@gajae-code/ai 0.1.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 +2644 -0
- package/README.md +1181 -0
- package/dist/types/api-registry.d.ts +30 -0
- package/dist/types/auth-broker/client.d.ts +66 -0
- package/dist/types/auth-broker/index.d.ts +5 -0
- package/dist/types/auth-broker/refresher.d.ts +25 -0
- package/dist/types/auth-broker/remote-store.d.ts +96 -0
- package/dist/types/auth-broker/server.d.ts +32 -0
- package/dist/types/auth-broker/types.d.ts +105 -0
- package/dist/types/auth-broker/wire-schemas.d.ts +412 -0
- package/dist/types/auth-gateway/http.d.ts +39 -0
- package/dist/types/auth-gateway/index.d.ts +3 -0
- package/dist/types/auth-gateway/server.d.ts +17 -0
- package/dist/types/auth-gateway/types.d.ts +115 -0
- package/dist/types/auth-storage.d.ts +641 -0
- package/dist/types/cli.d.ts +2 -0
- package/dist/types/index.d.ts +49 -0
- package/dist/types/model-cache.d.ts +17 -0
- package/dist/types/model-manager.d.ts +62 -0
- package/dist/types/model-thinking.d.ts +71 -0
- package/dist/types/models.d.ts +12 -0
- package/dist/types/provider-details.d.ts +24 -0
- package/dist/types/provider-models/bundled-references.d.ts +4 -0
- package/dist/types/provider-models/descriptors.d.ts +48 -0
- package/dist/types/provider-models/google.d.ts +20 -0
- package/dist/types/provider-models/index.d.ts +5 -0
- package/dist/types/provider-models/ollama.d.ts +7 -0
- package/dist/types/provider-models/openai-compat.d.ts +237 -0
- package/dist/types/provider-models/special.d.ts +16 -0
- package/dist/types/providers/amazon-bedrock.d.ts +36 -0
- package/dist/types/providers/anthropic-messages-server-schema.d.ts +450 -0
- package/dist/types/providers/anthropic-messages-server.d.ts +17 -0
- package/dist/types/providers/anthropic.d.ts +188 -0
- package/dist/types/providers/aws-credentials.d.ts +43 -0
- package/dist/types/providers/aws-eventstream.d.ts +38 -0
- package/dist/types/providers/aws-sigv4.d.ts +55 -0
- package/dist/types/providers/azure-openai-responses.d.ts +15 -0
- package/dist/types/providers/cursor/gen/agent_pb.d.ts +13022 -0
- package/dist/types/providers/cursor.d.ts +42 -0
- package/dist/types/providers/error-message.d.ts +27 -0
- package/dist/types/providers/github-copilot-headers.d.ts +40 -0
- package/dist/types/providers/gitlab-duo.d.ts +27 -0
- package/dist/types/providers/google-auth.d.ts +24 -0
- package/dist/types/providers/google-gemini-cli.d.ts +72 -0
- package/dist/types/providers/google-gemini-headers.d.ts +18 -0
- package/dist/types/providers/google-shared.d.ts +163 -0
- package/dist/types/providers/google-types.d.ts +138 -0
- package/dist/types/providers/google-vertex.d.ts +7 -0
- package/dist/types/providers/google.d.ts +4 -0
- package/dist/types/providers/grammar.d.ts +1 -0
- package/dist/types/providers/kimi.d.ts +27 -0
- package/dist/types/providers/mock.d.ts +175 -0
- package/dist/types/providers/ollama.d.ts +6 -0
- package/dist/types/providers/openai-anthropic-shim.d.ts +31 -0
- package/dist/types/providers/openai-chat-server-schema.d.ts +814 -0
- package/dist/types/providers/openai-chat-server.d.ts +16 -0
- package/dist/types/providers/openai-codex/constants.d.ts +26 -0
- package/dist/types/providers/openai-codex/request-transformer.d.ts +49 -0
- package/dist/types/providers/openai-codex/response-handler.d.ts +17 -0
- package/dist/types/providers/openai-codex-responses.d.ts +67 -0
- package/dist/types/providers/openai-completions-compat.d.ts +25 -0
- package/dist/types/providers/openai-completions.d.ts +33 -0
- package/dist/types/providers/openai-responses-server-schema.d.ts +392 -0
- package/dist/types/providers/openai-responses-server.d.ts +17 -0
- package/dist/types/providers/openai-responses-shared.d.ts +89 -0
- package/dist/types/providers/openai-responses.d.ts +32 -0
- package/dist/types/providers/pi-native-client.d.ts +13 -0
- package/dist/types/providers/pi-native-server.d.ts +68 -0
- package/dist/types/providers/register-builtins.d.ts +31 -0
- package/dist/types/providers/synthetic.d.ts +26 -0
- package/dist/types/providers/transform-messages.d.ts +12 -0
- package/dist/types/providers/vision-guard.d.ts +8 -0
- package/dist/types/rate-limit-utils.d.ts +19 -0
- package/dist/types/stream.d.ts +24 -0
- package/dist/types/types.d.ts +746 -0
- package/dist/types/usage/claude.d.ts +3 -0
- package/dist/types/usage/gemini.d.ts +2 -0
- package/dist/types/usage/github-copilot.d.ts +7 -0
- package/dist/types/usage/google-antigravity.d.ts +2 -0
- package/dist/types/usage/kimi.d.ts +2 -0
- package/dist/types/usage/minimax-code.d.ts +2 -0
- package/dist/types/usage/openai-codex.d.ts +3 -0
- package/dist/types/usage/shared.d.ts +1 -0
- package/dist/types/usage/zai.d.ts +2 -0
- package/dist/types/usage.d.ts +258 -0
- package/dist/types/utils/abort.d.ts +19 -0
- package/dist/types/utils/anthropic-auth.d.ts +31 -0
- package/dist/types/utils/discovery/antigravity.d.ts +61 -0
- package/dist/types/utils/discovery/codex.d.ts +38 -0
- package/dist/types/utils/discovery/cursor.d.ts +23 -0
- package/dist/types/utils/discovery/gemini.d.ts +25 -0
- package/dist/types/utils/discovery/index.d.ts +4 -0
- package/dist/types/utils/discovery/openai-compatible.d.ts +72 -0
- package/dist/types/utils/event-stream.d.ts +28 -0
- package/dist/types/utils/fireworks-model-id.d.ts +10 -0
- package/dist/types/utils/foundry.d.ts +1 -0
- package/dist/types/utils/h2-fetch.d.ts +22 -0
- package/dist/types/utils/http-inspector.d.ts +31 -0
- package/dist/types/utils/idle-iterator.d.ts +67 -0
- package/dist/types/utils/json-parse.d.ts +10 -0
- package/dist/types/utils/oauth/alibaba-coding-plan.d.ts +18 -0
- package/dist/types/utils/oauth/anthropic.d.ts +22 -0
- package/dist/types/utils/oauth/api-key-login.d.ts +35 -0
- package/dist/types/utils/oauth/api-key-validation.d.ts +27 -0
- package/dist/types/utils/oauth/callback-server.d.ts +57 -0
- package/dist/types/utils/oauth/cerebras.d.ts +1 -0
- package/dist/types/utils/oauth/cloudflare-ai-gateway.d.ts +18 -0
- package/dist/types/utils/oauth/cursor.d.ts +15 -0
- package/dist/types/utils/oauth/deepseek.d.ts +10 -0
- package/dist/types/utils/oauth/firepass.d.ts +1 -0
- package/dist/types/utils/oauth/fireworks.d.ts +1 -0
- package/dist/types/utils/oauth/github-copilot.d.ts +38 -0
- package/dist/types/utils/oauth/gitlab-duo.d.ts +3 -0
- package/dist/types/utils/oauth/google-antigravity.d.ts +11 -0
- package/dist/types/utils/oauth/google-gemini-cli.d.ts +10 -0
- package/dist/types/utils/oauth/google-oauth-shared.d.ts +28 -0
- package/dist/types/utils/oauth/huggingface.d.ts +19 -0
- package/dist/types/utils/oauth/index.d.ts +38 -0
- package/dist/types/utils/oauth/kagi.d.ts +17 -0
- package/dist/types/utils/oauth/kilo.d.ts +5 -0
- package/dist/types/utils/oauth/kimi.d.ts +21 -0
- package/dist/types/utils/oauth/litellm.d.ts +18 -0
- package/dist/types/utils/oauth/lm-studio.d.ts +17 -0
- package/dist/types/utils/oauth/minimax-code.d.ts +28 -0
- package/dist/types/utils/oauth/moonshot.d.ts +1 -0
- package/dist/types/utils/oauth/nanogpt.d.ts +1 -0
- package/dist/types/utils/oauth/nvidia.d.ts +18 -0
- package/dist/types/utils/oauth/ollama-cloud.d.ts +2 -0
- package/dist/types/utils/oauth/ollama.d.ts +18 -0
- package/dist/types/utils/oauth/openai-codex.d.ts +21 -0
- package/dist/types/utils/oauth/opencode.d.ts +18 -0
- package/dist/types/utils/oauth/parallel.d.ts +17 -0
- package/dist/types/utils/oauth/perplexity.d.ts +9 -0
- package/dist/types/utils/oauth/pkce.d.ts +8 -0
- package/dist/types/utils/oauth/qianfan.d.ts +17 -0
- package/dist/types/utils/oauth/qwen-portal.d.ts +19 -0
- package/dist/types/utils/oauth/synthetic.d.ts +1 -0
- package/dist/types/utils/oauth/tavily.d.ts +17 -0
- package/dist/types/utils/oauth/together.d.ts +1 -0
- package/dist/types/utils/oauth/types.d.ts +44 -0
- package/dist/types/utils/oauth/venice.d.ts +18 -0
- package/dist/types/utils/oauth/vercel-ai-gateway.d.ts +18 -0
- package/dist/types/utils/oauth/vllm.d.ts +16 -0
- package/dist/types/utils/oauth/xiaomi.d.ts +19 -0
- package/dist/types/utils/oauth/zai.d.ts +18 -0
- package/dist/types/utils/oauth/zenmux.d.ts +1 -0
- package/dist/types/utils/overflow.d.ts +54 -0
- package/dist/types/utils/parse-bind.d.ts +23 -0
- package/dist/types/utils/provider-response.d.ts +3 -0
- package/dist/types/utils/retry-after.d.ts +3 -0
- package/dist/types/utils/retry.d.ts +26 -0
- package/dist/types/utils/schema/adapt.d.ts +24 -0
- package/dist/types/utils/schema/compatibility.d.ts +30 -0
- package/dist/types/utils/schema/dereference.d.ts +11 -0
- package/dist/types/utils/schema/draft.d.ts +10 -0
- package/dist/types/utils/schema/equality.d.ts +4 -0
- package/dist/types/utils/schema/fields.d.ts +49 -0
- package/dist/types/utils/schema/index.d.ts +13 -0
- package/dist/types/utils/schema/json-schema-validator.d.ts +12 -0
- package/dist/types/utils/schema/meta-validator.d.ts +2 -0
- package/dist/types/utils/schema/normalize.d.ts +93 -0
- package/dist/types/utils/schema/spill.d.ts +8 -0
- package/dist/types/utils/schema/stamps.d.ts +25 -0
- package/dist/types/utils/schema/types.d.ts +4 -0
- package/dist/types/utils/schema/wire.d.ts +54 -0
- package/dist/types/utils/schema/zod-decontaminate.d.ts +31 -0
- package/dist/types/utils/sse-debug.d.ts +10 -0
- package/dist/types/utils/tool-call-healing.d.ts +71 -0
- package/dist/types/utils/tool-choice.d.ts +50 -0
- package/dist/types/utils/validation.d.ts +17 -0
- package/dist/types/utils.d.ts +28 -0
- package/package.json +146 -0
- package/src/api-registry.ts +96 -0
- package/src/auth-broker/client.ts +358 -0
- package/src/auth-broker/index.ts +5 -0
- package/src/auth-broker/refresher.ts +127 -0
- package/src/auth-broker/remote-store.ts +623 -0
- package/src/auth-broker/server.ts +644 -0
- package/src/auth-broker/types.ts +127 -0
- package/src/auth-broker/wire-schemas.ts +200 -0
- package/src/auth-gateway/http.ts +194 -0
- package/src/auth-gateway/index.ts +3 -0
- package/src/auth-gateway/server.ts +717 -0
- package/src/auth-gateway/types.ts +134 -0
- package/src/auth-storage.ts +4104 -0
- package/src/cli.ts +262 -0
- package/src/index.ts +54 -0
- package/src/model-cache.ts +129 -0
- package/src/model-manager.ts +450 -0
- package/src/model-thinking.ts +691 -0
- package/src/models.json +73853 -0
- package/src/models.json.d.ts +9 -0
- package/src/models.ts +56 -0
- package/src/prompts/turn-aborted-guidance.md +4 -0
- package/src/provider-details.ts +90 -0
- package/src/provider-models/bundled-references.ts +38 -0
- package/src/provider-models/descriptors.ts +308 -0
- package/src/provider-models/google.ts +91 -0
- package/src/provider-models/index.ts +5 -0
- package/src/provider-models/ollama.ts +153 -0
- package/src/provider-models/openai-compat.ts +2275 -0
- package/src/provider-models/special.ts +67 -0
- package/src/providers/amazon-bedrock.ts +849 -0
- package/src/providers/anthropic-messages-server-schema.ts +229 -0
- package/src/providers/anthropic-messages-server.ts +677 -0
- package/src/providers/anthropic.ts +2696 -0
- package/src/providers/aws-credentials.ts +501 -0
- package/src/providers/aws-eventstream.ts +185 -0
- package/src/providers/aws-sigv4.ts +218 -0
- package/src/providers/azure-openai-responses.ts +337 -0
- package/src/providers/cursor/gen/agent_pb.ts +15274 -0
- package/src/providers/cursor/proto/agent.proto +3526 -0
- package/src/providers/cursor/proto/buf.gen.yaml +6 -0
- package/src/providers/cursor/proto/buf.yaml +17 -0
- package/src/providers/cursor.ts +2561 -0
- package/src/providers/error-message.ts +21 -0
- package/src/providers/github-copilot-headers.ts +140 -0
- package/src/providers/gitlab-duo.ts +372 -0
- package/src/providers/google-auth.ts +252 -0
- package/src/providers/google-gemini-cli.ts +795 -0
- package/src/providers/google-gemini-headers.ts +41 -0
- package/src/providers/google-shared.ts +902 -0
- package/src/providers/google-types.ts +167 -0
- package/src/providers/google-vertex.ts +88 -0
- package/src/providers/google.ts +41 -0
- package/src/providers/grammar.ts +70 -0
- package/src/providers/kimi.ts +52 -0
- package/src/providers/mock.ts +500 -0
- package/src/providers/ollama.ts +544 -0
- package/src/providers/openai-anthropic-shim.ts +138 -0
- package/src/providers/openai-chat-server-schema.ts +243 -0
- package/src/providers/openai-chat-server.ts +628 -0
- package/src/providers/openai-codex/constants.ts +43 -0
- package/src/providers/openai-codex/request-transformer.ts +161 -0
- package/src/providers/openai-codex/response-handler.ts +81 -0
- package/src/providers/openai-codex-responses.ts +2598 -0
- package/src/providers/openai-completions-compat.ts +279 -0
- package/src/providers/openai-completions.ts +1853 -0
- package/src/providers/openai-responses-server-schema.ts +290 -0
- package/src/providers/openai-responses-server.ts +1183 -0
- package/src/providers/openai-responses-shared.ts +800 -0
- package/src/providers/openai-responses.ts +621 -0
- package/src/providers/pi-native-client.ts +228 -0
- package/src/providers/pi-native-server.ts +210 -0
- package/src/providers/register-builtins.ts +412 -0
- package/src/providers/synthetic.ts +50 -0
- package/src/providers/transform-messages.ts +309 -0
- package/src/providers/vision-guard.ts +31 -0
- package/src/rate-limit-utils.ts +84 -0
- package/src/stream.ts +895 -0
- package/src/types.ts +884 -0
- package/src/usage/claude.ts +431 -0
- package/src/usage/gemini.ts +250 -0
- package/src/usage/github-copilot.ts +421 -0
- package/src/usage/google-antigravity.ts +201 -0
- package/src/usage/kimi.ts +271 -0
- package/src/usage/minimax-code.ts +31 -0
- package/src/usage/openai-codex.ts +503 -0
- package/src/usage/shared.ts +10 -0
- package/src/usage/zai.ts +247 -0
- package/src/usage.ts +183 -0
- package/src/utils/abort.ts +51 -0
- package/src/utils/anthropic-auth.ts +87 -0
- package/src/utils/discovery/antigravity.ts +261 -0
- package/src/utils/discovery/codex.ts +371 -0
- package/src/utils/discovery/cursor.ts +306 -0
- package/src/utils/discovery/gemini.ts +248 -0
- package/src/utils/discovery/index.ts +4 -0
- package/src/utils/discovery/openai-compatible.ts +224 -0
- package/src/utils/event-stream.ts +142 -0
- package/src/utils/fireworks-model-id.ts +30 -0
- package/src/utils/foundry.ts +8 -0
- package/src/utils/h2-fetch.ts +60 -0
- package/src/utils/http-inspector.ts +176 -0
- package/src/utils/idle-iterator.ts +250 -0
- package/src/utils/json-parse.ts +148 -0
- package/src/utils/oauth/alibaba-coding-plan.ts +59 -0
- package/src/utils/oauth/anthropic.ts +200 -0
- package/src/utils/oauth/api-key-login.ts +87 -0
- package/src/utils/oauth/api-key-validation.ts +92 -0
- package/src/utils/oauth/callback-server.ts +276 -0
- package/src/utils/oauth/cerebras.ts +16 -0
- package/src/utils/oauth/cloudflare-ai-gateway.ts +48 -0
- package/src/utils/oauth/cursor.ts +157 -0
- package/src/utils/oauth/deepseek.ts +53 -0
- package/src/utils/oauth/firepass.ts +24 -0
- package/src/utils/oauth/fireworks.ts +15 -0
- package/src/utils/oauth/github-copilot.ts +362 -0
- package/src/utils/oauth/gitlab-duo.ts +123 -0
- package/src/utils/oauth/google-antigravity.ts +200 -0
- package/src/utils/oauth/google-gemini-cli.ts +256 -0
- package/src/utils/oauth/google-oauth-shared.ts +110 -0
- package/src/utils/oauth/huggingface.ts +62 -0
- package/src/utils/oauth/index.ts +444 -0
- package/src/utils/oauth/kagi.ts +47 -0
- package/src/utils/oauth/kilo.ts +87 -0
- package/src/utils/oauth/kimi.ts +254 -0
- package/src/utils/oauth/litellm.ts +47 -0
- package/src/utils/oauth/lm-studio.ts +38 -0
- package/src/utils/oauth/minimax-code.ts +78 -0
- package/src/utils/oauth/moonshot.ts +16 -0
- package/src/utils/oauth/nanogpt.ts +15 -0
- package/src/utils/oauth/nvidia.ts +70 -0
- package/src/utils/oauth/oauth.html +199 -0
- package/src/utils/oauth/ollama-cloud.ts +28 -0
- package/src/utils/oauth/ollama.ts +47 -0
- package/src/utils/oauth/openai-codex.ts +299 -0
- package/src/utils/oauth/opencode.ts +49 -0
- package/src/utils/oauth/parallel.ts +46 -0
- package/src/utils/oauth/perplexity.ts +206 -0
- package/src/utils/oauth/pkce.ts +18 -0
- package/src/utils/oauth/qianfan.ts +58 -0
- package/src/utils/oauth/qwen-portal.ts +60 -0
- package/src/utils/oauth/synthetic.ts +16 -0
- package/src/utils/oauth/tavily.ts +46 -0
- package/src/utils/oauth/together.ts +16 -0
- package/src/utils/oauth/types.ts +94 -0
- package/src/utils/oauth/venice.ts +59 -0
- package/src/utils/oauth/vercel-ai-gateway.ts +47 -0
- package/src/utils/oauth/vllm.ts +40 -0
- package/src/utils/oauth/xiaomi.ts +137 -0
- package/src/utils/oauth/zai.ts +60 -0
- package/src/utils/oauth/zenmux.ts +15 -0
- package/src/utils/overflow.ts +137 -0
- package/src/utils/parse-bind.ts +54 -0
- package/src/utils/provider-response.ts +30 -0
- package/src/utils/retry-after.ts +110 -0
- package/src/utils/retry.ts +54 -0
- package/src/utils/schema/CONSTRAINTS.md +164 -0
- package/src/utils/schema/adapt.ts +36 -0
- package/src/utils/schema/compatibility.ts +435 -0
- package/src/utils/schema/dereference.ts +98 -0
- package/src/utils/schema/draft.ts +341 -0
- package/src/utils/schema/equality.ts +97 -0
- package/src/utils/schema/fields.ts +190 -0
- package/src/utils/schema/index.ts +13 -0
- package/src/utils/schema/json-schema-validator.ts +577 -0
- package/src/utils/schema/meta-validator.ts +167 -0
- package/src/utils/schema/normalize.ts +1588 -0
- package/src/utils/schema/spill.ts +43 -0
- package/src/utils/schema/stamps.ts +97 -0
- package/src/utils/schema/types.ts +11 -0
- package/src/utils/schema/wire.ts +213 -0
- package/src/utils/schema/zod-decontaminate.ts +331 -0
- package/src/utils/sse-debug.ts +289 -0
- package/src/utils/tool-call-healing.ts +271 -0
- package/src/utils/tool-choice.ts +99 -0
- package/src/utils/validation.ts +1019 -0
- package/src/utils.ts +166 -0
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side {@link AuthCredentialStore} that mirrors a remote broker's
|
|
3
|
+
* snapshot. Refresh tokens never leave the broker; mutating methods (`replace*`,
|
|
4
|
+
* `upsert*`, `delete*ForProvider`) throw because login flows are server-side.
|
|
5
|
+
*
|
|
6
|
+
* Cache (`getCache`/`setCache`/`cleanExpiredCache`) is in-memory and ephemeral —
|
|
7
|
+
* usage reports cache TTL is 5 minutes per credential, so durability across
|
|
8
|
+
* runs isn't required.
|
|
9
|
+
*/
|
|
10
|
+
import { scheduler } from "node:timers/promises";
|
|
11
|
+
import { logger } from "@gajae-code/utils";
|
|
12
|
+
import {
|
|
13
|
+
type AuthCredential,
|
|
14
|
+
type AuthCredentialSnapshotEntry,
|
|
15
|
+
type AuthCredentialStore,
|
|
16
|
+
type OAuthCredential,
|
|
17
|
+
REMOTE_REFRESH_SENTINEL,
|
|
18
|
+
type StoredAuthCredential,
|
|
19
|
+
} from "../auth-storage";
|
|
20
|
+
import type { Provider } from "../types";
|
|
21
|
+
import type { UsageReport } from "../usage";
|
|
22
|
+
import type { OAuthCredentials } from "../utils/oauth/types";
|
|
23
|
+
import { type AuthBrokerClient, AuthBrokerStreamUnsupportedError } from "./client";
|
|
24
|
+
import type { RefresherSchedule, SnapshotEntry, SnapshotResponse, SnapshotStreamEvent } from "./types";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Client-side TTL for the aggregate `/v1/usage` response. Set below the
|
|
28
|
+
* broker server's own 30s usage cache so we typically pick up the broker's
|
|
29
|
+
* cached value instead of re-walking the network — but high enough to absorb
|
|
30
|
+
* the parallel fan-out from `#rankOAuthSelections` into a single round-trip.
|
|
31
|
+
*/
|
|
32
|
+
const USAGE_CACHE_TTL_MS = 15_000;
|
|
33
|
+
const WAIT_THRESHOLD_MS = 1_000;
|
|
34
|
+
const MAX_WAIT_MS = 5_000;
|
|
35
|
+
const BACKGROUND_WAIT_MS = 30_000;
|
|
36
|
+
const BACKGROUND_BACKOFF_INITIAL_MS = 500;
|
|
37
|
+
const BACKGROUND_BACKOFF_MAX_MS = 30_000;
|
|
38
|
+
|
|
39
|
+
function emptySnapshot(): SnapshotResponse {
|
|
40
|
+
return {
|
|
41
|
+
generation: 0,
|
|
42
|
+
generatedAt: 0,
|
|
43
|
+
serverNowMs: 0,
|
|
44
|
+
refresher: {
|
|
45
|
+
enabled: false,
|
|
46
|
+
intervalMs: 0,
|
|
47
|
+
skewMs: 0,
|
|
48
|
+
nextSweepInMs: Number.MAX_SAFE_INTEGER,
|
|
49
|
+
},
|
|
50
|
+
credentials: [],
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface CacheEntry {
|
|
55
|
+
value: string;
|
|
56
|
+
expiresAtSec: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface UsageCacheEntry {
|
|
60
|
+
reports: UsageReport[];
|
|
61
|
+
fetchedAt: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface RemoteAuthCredentialStoreOptions {
|
|
65
|
+
client: AuthBrokerClient;
|
|
66
|
+
/**
|
|
67
|
+
* Initial snapshot. When omitted, callers must call
|
|
68
|
+
* {@link RemoteAuthCredentialStore.refreshSnapshot} before the first read.
|
|
69
|
+
*/
|
|
70
|
+
initialSnapshot?: SnapshotResponse;
|
|
71
|
+
/**
|
|
72
|
+
* Subscribe to the broker's SSE snapshot stream when available. Falls back
|
|
73
|
+
* to long-poll permanently when the broker returns 404. Default `true`.
|
|
74
|
+
*/
|
|
75
|
+
streamSnapshots?: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
79
|
+
readonly #client: AuthBrokerClient;
|
|
80
|
+
readonly #streamSnapshots: boolean;
|
|
81
|
+
#snapshot: SnapshotResponse = emptySnapshot();
|
|
82
|
+
#snapshotReceivedAt = Date.now();
|
|
83
|
+
#generation = 0;
|
|
84
|
+
#backgroundAbort = new AbortController();
|
|
85
|
+
#cache: Map<string, CacheEntry> = new Map();
|
|
86
|
+
#usageCache?: UsageCacheEntry;
|
|
87
|
+
#usageInflight?: Promise<UsageReport[] | null>;
|
|
88
|
+
#closed = false;
|
|
89
|
+
/**
|
|
90
|
+
* `true` once the SSE consumer received its first frame and hasn't dropped
|
|
91
|
+
* since. Writes consult this to suppress the otherwise-mandatory
|
|
92
|
+
* `refreshSnapshot()` follow-up — the stream will deliver the new
|
|
93
|
+
* generation without an extra GET.
|
|
94
|
+
*/
|
|
95
|
+
#streamingActive = false;
|
|
96
|
+
/** Latched once the broker has answered 404 — never try the stream again. */
|
|
97
|
+
#streamingUnsupported = false;
|
|
98
|
+
|
|
99
|
+
constructor(opts: RemoteAuthCredentialStoreOptions) {
|
|
100
|
+
this.#client = opts.client;
|
|
101
|
+
this.#streamSnapshots = opts.streamSnapshots ?? true;
|
|
102
|
+
this.#applySnapshot(opts.initialSnapshot ?? emptySnapshot(), opts.initialSnapshot?.generation ?? 0);
|
|
103
|
+
void this.#runBackground();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
get client(): AuthBrokerClient {
|
|
107
|
+
return this.#client;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
get snapshot(): SnapshotResponse {
|
|
111
|
+
return this.#snapshot;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
#applySnapshot(snapshot: SnapshotResponse, generation: number): void {
|
|
115
|
+
this.#snapshot = snapshot;
|
|
116
|
+
this.#generation = generation;
|
|
117
|
+
this.#snapshotReceivedAt = Date.now();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async #runBackground(): Promise<void> {
|
|
121
|
+
let backoffMs = BACKGROUND_BACKOFF_INITIAL_MS;
|
|
122
|
+
while (!this.#closed && !this.#backgroundAbort.signal.aborted) {
|
|
123
|
+
if (this.#streamSnapshots && !this.#streamingUnsupported) {
|
|
124
|
+
try {
|
|
125
|
+
await this.#consumeSnapshotStream();
|
|
126
|
+
backoffMs = BACKGROUND_BACKOFF_INITIAL_MS;
|
|
127
|
+
continue;
|
|
128
|
+
} catch (error) {
|
|
129
|
+
if (this.#closed || this.#backgroundAbort.signal.aborted) break;
|
|
130
|
+
if (error instanceof AuthBrokerStreamUnsupportedError) {
|
|
131
|
+
this.#streamingUnsupported = true;
|
|
132
|
+
logger.debug("auth-broker snapshot stream unsupported; falling back to long-poll");
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
logger.debug("auth-broker snapshot stream failed; backing off", { error: String(error) });
|
|
136
|
+
await scheduler.wait(backoffMs, { signal: this.#backgroundAbort.signal }).catch(() => {});
|
|
137
|
+
backoffMs = Math.min(BACKGROUND_BACKOFF_MAX_MS, backoffMs * 2);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
const result = await this.#client.fetchSnapshot({
|
|
143
|
+
ifGenerationGt: this.#generation,
|
|
144
|
+
waitMs: BACKGROUND_WAIT_MS,
|
|
145
|
+
signal: this.#backgroundAbort.signal,
|
|
146
|
+
});
|
|
147
|
+
if (result.status === 200) this.#applySnapshot(result.snapshot, result.generation);
|
|
148
|
+
backoffMs = BACKGROUND_BACKOFF_INITIAL_MS;
|
|
149
|
+
} catch (error) {
|
|
150
|
+
if (this.#closed || this.#backgroundAbort.signal.aborted) break;
|
|
151
|
+
logger.debug("auth-broker background snapshot sync failed", { error: String(error) });
|
|
152
|
+
await scheduler.wait(backoffMs, { signal: this.#backgroundAbort.signal }).catch(() => {});
|
|
153
|
+
backoffMs = Math.min(BACKGROUND_BACKOFF_MAX_MS, backoffMs * 2);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async #consumeSnapshotStream(): Promise<void> {
|
|
159
|
+
const iterator = this.#client.openSnapshotStream({ signal: this.#backgroundAbort.signal });
|
|
160
|
+
try {
|
|
161
|
+
for await (const event of iterator) {
|
|
162
|
+
if (this.#closed || this.#backgroundAbort.signal.aborted) break;
|
|
163
|
+
this.#streamingActive = true;
|
|
164
|
+
this.#applyStreamEvent(event);
|
|
165
|
+
}
|
|
166
|
+
} finally {
|
|
167
|
+
this.#streamingActive = false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
#applyStreamEvent(event: SnapshotStreamEvent): void {
|
|
172
|
+
switch (event.kind) {
|
|
173
|
+
case "snapshot": {
|
|
174
|
+
// Strip the discriminator so we store the wire-shape SnapshotResponse.
|
|
175
|
+
const { kind: _kind, ...snapshot } = event;
|
|
176
|
+
if (snapshot.generation < this.#generation) {
|
|
177
|
+
logger.debug("auth-broker stream snapshot older than local; ignoring", {
|
|
178
|
+
local: this.#generation,
|
|
179
|
+
incoming: snapshot.generation,
|
|
180
|
+
});
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
this.#applySnapshot(snapshot, snapshot.generation);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
case "entry": {
|
|
187
|
+
if (event.generation < this.#generation) return;
|
|
188
|
+
this.#applyStreamEntry(event.entry, event.refresher, event.generation, event.serverNowMs);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
case "removed": {
|
|
192
|
+
if (event.generation < this.#generation) return;
|
|
193
|
+
this.#removeStreamCredential(event.id, event.refresher, event.generation, event.serverNowMs);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
#applyStreamEntry(
|
|
200
|
+
entry: SnapshotEntry,
|
|
201
|
+
refresher: RefresherSchedule,
|
|
202
|
+
generation: number,
|
|
203
|
+
serverNowMs: number,
|
|
204
|
+
): void {
|
|
205
|
+
const index = this.#snapshot.credentials.findIndex(candidate => candidate.id === entry.id);
|
|
206
|
+
const credentials =
|
|
207
|
+
index === -1
|
|
208
|
+
? [...this.#snapshot.credentials, entry]
|
|
209
|
+
: this.#snapshot.credentials.map((candidate, i) => (i === index ? entry : candidate));
|
|
210
|
+
this.#snapshot = { ...this.#snapshot, generation, serverNowMs, refresher, credentials };
|
|
211
|
+
this.#generation = generation;
|
|
212
|
+
this.#snapshotReceivedAt = Date.now();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
#removeStreamCredential(id: number, refresher: RefresherSchedule, generation: number, serverNowMs: number): void {
|
|
216
|
+
const credentials = this.#snapshot.credentials.filter(entry => entry.id !== id);
|
|
217
|
+
this.#snapshot = { ...this.#snapshot, generation, serverNowMs, refresher, credentials };
|
|
218
|
+
this.#generation = generation;
|
|
219
|
+
this.#snapshotReceivedAt = Date.now();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Re-hydrate the in-memory snapshot from the broker. */
|
|
223
|
+
async refreshSnapshot(): Promise<SnapshotResponse> {
|
|
224
|
+
const result = await this.#client.fetchSnapshot();
|
|
225
|
+
if (result.status === 200) this.#applySnapshot(result.snapshot, result.generation);
|
|
226
|
+
return this.#snapshot;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
listAuthCredentials(provider?: string): StoredAuthCredential[] {
|
|
230
|
+
const out: StoredAuthCredential[] = [];
|
|
231
|
+
for (const entry of this.#snapshot.credentials) {
|
|
232
|
+
if (provider !== undefined && entry.provider !== provider) continue;
|
|
233
|
+
out.push({
|
|
234
|
+
id: entry.id,
|
|
235
|
+
provider: entry.provider,
|
|
236
|
+
credential: entry.credential as AuthCredential,
|
|
237
|
+
disabledCause: null,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
return out;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* In-memory update from a successful refresh through the broker. AuthStorage
|
|
245
|
+
* calls this after `#replaceCredentialAt`; the broker already persisted the
|
|
246
|
+
* authoritative row, so we just mirror it.
|
|
247
|
+
*/
|
|
248
|
+
updateAuthCredential(id: number, credential: AuthCredential): void {
|
|
249
|
+
for (const entry of this.#snapshot.credentials) {
|
|
250
|
+
if (entry.id !== id) continue;
|
|
251
|
+
entry.credential = credential as typeof entry.credential;
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
deleteAuthCredential(id: number, disabledCause: string): void {
|
|
257
|
+
this.#removeCredentialById(id);
|
|
258
|
+
// Fire-and-forget: tell the broker to persist the disable.
|
|
259
|
+
this.#client.disableCredential(id, disabledCause).catch(error => {
|
|
260
|
+
logger.warn("auth-broker disable propagation failed", { id, error: String(error) });
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
tryDisableAuthCredentialIfMatches(id: number, _expectedData: string, disabledCause: string): boolean {
|
|
265
|
+
const found = this.#snapshot.credentials.find(entry => entry.id === id);
|
|
266
|
+
if (!found) return false;
|
|
267
|
+
this.deleteAuthCredential(id, disabledCause);
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async waitForFreshSnapshot(maxWaitMs: number, opts: { signal?: AbortSignal } = {}): Promise<boolean> {
|
|
272
|
+
const previousGeneration = this.#generation;
|
|
273
|
+
const result = await this.#client.fetchSnapshot({
|
|
274
|
+
ifGenerationGt: this.#generation,
|
|
275
|
+
waitMs: maxWaitMs,
|
|
276
|
+
signal: opts.signal,
|
|
277
|
+
});
|
|
278
|
+
if (result.status === 200) this.#applySnapshot(result.snapshot, result.generation);
|
|
279
|
+
return this.#generation !== previousGeneration;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async prepareForRequest(credentialId: number, opts: { signal?: AbortSignal } = {}): Promise<boolean> {
|
|
283
|
+
const entry = this.#snapshot.credentials.find(candidate => candidate.id === credentialId);
|
|
284
|
+
if (!entry || entry.credential.type !== "oauth" || entry.rotatesInMs === null) return false;
|
|
285
|
+
const remainingMs = this.#snapshotReceivedAt + entry.rotatesInMs - Date.now();
|
|
286
|
+
if (remainingMs > WAIT_THRESHOLD_MS) return false;
|
|
287
|
+
return this.waitForFreshSnapshot(MAX_WAIT_MS, opts);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async markCredentialSuspect(credentialId: number, opts: { signal?: AbortSignal } = {}): Promise<void> {
|
|
291
|
+
const { entry } = await this.#client.refreshCredential(credentialId, opts.signal);
|
|
292
|
+
if (entry.credential.type !== "oauth") {
|
|
293
|
+
throw new Error(`Broker returned non-OAuth credential for id=${credentialId}`);
|
|
294
|
+
}
|
|
295
|
+
this.#applyCredentialEntry(entry);
|
|
296
|
+
this.#maybeRefreshSnapshot("suspect credential refresh");
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
replaceAuthCredentialsForProvider(_provider: string, _credentials: AuthCredential[]): StoredAuthCredential[] {
|
|
300
|
+
throw new Error(
|
|
301
|
+
"RemoteAuthCredentialStore is read-only on the client. Use `gjc auth-broker login <provider>` to mutate credentials.",
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
upsertAuthCredentialForProvider(_provider: string, _credential: AuthCredential): StoredAuthCredential[] {
|
|
306
|
+
throw new Error(
|
|
307
|
+
"RemoteAuthCredentialStore is read-only on the client. Use `gjc auth-broker login <provider>` to mutate credentials.",
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
deleteAuthCredentialsForProvider(_provider: string, _disabledCause: string): void {
|
|
312
|
+
throw new Error(
|
|
313
|
+
"RemoteAuthCredentialStore is read-only on the client. Use `gjc auth-broker logout <provider>` to mutate credentials.",
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Upsert a single credential through the broker. The broker server is the
|
|
319
|
+
* canonical writer — see `POST /v1/credential`. The redacted snapshot
|
|
320
|
+
* entries returned by the server replace the provider's rows in our local
|
|
321
|
+
* snapshot, and the global snapshot is then refreshed in the background so
|
|
322
|
+
* any concurrent peer (refresh, generation bump) stays in sync.
|
|
323
|
+
*/
|
|
324
|
+
async upsertAuthCredentialRemote(provider: string, credential: AuthCredential): Promise<StoredAuthCredential[]> {
|
|
325
|
+
const { entries } = await this.#client.uploadCredential(provider, credential);
|
|
326
|
+
this.#applyProviderEntries(provider, entries);
|
|
327
|
+
this.#maybeRefreshSnapshot("upload");
|
|
328
|
+
return this.listAuthCredentials(provider);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Replace-all semantics: disable every active credential for the provider,
|
|
333
|
+
* then upload each of the new credentials. Used by API-key login so a new
|
|
334
|
+
* key clobbers any previously stored key for the same provider.
|
|
335
|
+
*/
|
|
336
|
+
async replaceAuthCredentialsRemote(
|
|
337
|
+
provider: string,
|
|
338
|
+
credentials: AuthCredential[],
|
|
339
|
+
): Promise<StoredAuthCredential[]> {
|
|
340
|
+
const existing = this.listAuthCredentials(provider);
|
|
341
|
+
for (const entry of existing) {
|
|
342
|
+
try {
|
|
343
|
+
await this.#client.disableCredential(entry.id, "replaced by newer credential");
|
|
344
|
+
} catch (error) {
|
|
345
|
+
logger.warn("auth-broker disable during replace failed", {
|
|
346
|
+
provider,
|
|
347
|
+
id: entry.id,
|
|
348
|
+
error: String(error),
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
// Snapshot reflects the disables before we add the new rows so a concurrent
|
|
353
|
+
// reader cannot momentarily see old + new together for the same provider.
|
|
354
|
+
this.#removeProviderEntries(provider);
|
|
355
|
+
for (const credential of credentials) {
|
|
356
|
+
const { entries } = await this.#client.uploadCredential(provider, credential);
|
|
357
|
+
this.#applyProviderEntries(provider, entries);
|
|
358
|
+
}
|
|
359
|
+
this.#maybeRefreshSnapshot("replace");
|
|
360
|
+
return this.listAuthCredentials(provider);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Logout: disable every active credential for the provider on the broker,
|
|
365
|
+
* then drop them from the local snapshot. Refresh fetches the authoritative
|
|
366
|
+
* post-state in the background.
|
|
367
|
+
*/
|
|
368
|
+
async deleteAuthCredentialsRemote(provider: string, disabledCause: string): Promise<void> {
|
|
369
|
+
const existing = this.listAuthCredentials(provider);
|
|
370
|
+
for (const entry of existing) {
|
|
371
|
+
try {
|
|
372
|
+
await this.#client.disableCredential(entry.id, disabledCause);
|
|
373
|
+
} catch (error) {
|
|
374
|
+
logger.warn("auth-broker disable during delete failed", {
|
|
375
|
+
provider,
|
|
376
|
+
id: entry.id,
|
|
377
|
+
error: String(error),
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
this.#removeProviderEntries(provider);
|
|
382
|
+
this.#maybeRefreshSnapshot("delete");
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
#applyProviderEntries(provider: string, entries: AuthCredentialSnapshotEntry[]): void {
|
|
386
|
+
// `entries` is the broker's authoritative post-upsert list of rows for
|
|
387
|
+
// `provider`. Drop our existing rows for the same provider and splice in
|
|
388
|
+
// the fresh set — preserving every other provider's rows in place.
|
|
389
|
+
const others = this.#snapshot.credentials.filter(entry => entry.provider !== provider);
|
|
390
|
+
const incoming = entries.map(entry => ({ ...entry, rotatesInMs: null }));
|
|
391
|
+
this.#snapshot = { ...this.#snapshot, credentials: [...others, ...incoming] };
|
|
392
|
+
}
|
|
393
|
+
#applyCredentialEntry(entry: AuthCredentialSnapshotEntry): void {
|
|
394
|
+
const incoming = { ...entry, rotatesInMs: null };
|
|
395
|
+
const index = this.#snapshot.credentials.findIndex(candidate => candidate.id === entry.id);
|
|
396
|
+
if (index === -1) {
|
|
397
|
+
this.#snapshot = { ...this.#snapshot, credentials: [...this.#snapshot.credentials, incoming] };
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
const credentials = [...this.#snapshot.credentials];
|
|
401
|
+
credentials[index] = incoming;
|
|
402
|
+
this.#snapshot = { ...this.#snapshot, credentials };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
#removeProviderEntries(provider: string): void {
|
|
406
|
+
const next = this.#snapshot.credentials.filter(entry => entry.provider !== provider);
|
|
407
|
+
this.#snapshot = { ...this.#snapshot, credentials: next };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
#removeCredentialById(id: number): void {
|
|
411
|
+
const next = this.#snapshot.credentials.filter(entry => entry.id !== id);
|
|
412
|
+
this.#snapshot = { ...this.#snapshot, credentials: next };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Fire-and-forget `refreshSnapshot()` after a write. When the SSE stream is
|
|
417
|
+
* active the broker will deliver the new generation push, so the extra GET
|
|
418
|
+
* is wasted bandwidth and we skip it.
|
|
419
|
+
*/
|
|
420
|
+
#maybeRefreshSnapshot(reason: string): void {
|
|
421
|
+
if (this.#streamingActive) return;
|
|
422
|
+
void this.refreshSnapshot().catch(error => {
|
|
423
|
+
logger.debug("auth-broker snapshot refresh after write failed", { reason, error: String(error) });
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
getCache(key: string): string | null {
|
|
428
|
+
const entry = this.#cache.get(key);
|
|
429
|
+
if (!entry) return null;
|
|
430
|
+
if (entry.expiresAtSec * 1000 <= Date.now()) {
|
|
431
|
+
this.#cache.delete(key);
|
|
432
|
+
return null;
|
|
433
|
+
}
|
|
434
|
+
return entry.value;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
setCache(key: string, value: string, expiresAtSec: number): void {
|
|
438
|
+
this.#cache.set(key, { value, expiresAtSec });
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
cleanExpiredCache(): void {
|
|
442
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
443
|
+
for (const [key, entry] of this.#cache) {
|
|
444
|
+
if (entry.expiresAtSec <= nowSec) this.#cache.delete(key);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Store-level hook consumed by `AuthStorage` — routes refresh through the
|
|
450
|
+
* broker so the actual refresh token never leaves the broker host. Returns
|
|
451
|
+
* the broker-redacted credential with {@link REMOTE_REFRESH_SENTINEL} in
|
|
452
|
+
* the `refresh` slot.
|
|
453
|
+
*/
|
|
454
|
+
async refreshOAuthCredential(
|
|
455
|
+
_provider: Provider,
|
|
456
|
+
credentialId: number,
|
|
457
|
+
_credential: OAuthCredential,
|
|
458
|
+
signal?: AbortSignal,
|
|
459
|
+
): Promise<OAuthCredentials> {
|
|
460
|
+
const { entry } = await this.#client.refreshCredential(credentialId, signal);
|
|
461
|
+
if (!this.#streamingActive) {
|
|
462
|
+
await this.refreshSnapshot().catch(error => {
|
|
463
|
+
logger.debug("auth-broker snapshot refresh after credential refresh failed", { error: String(error) });
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
if (entry.credential.type !== "oauth") {
|
|
467
|
+
throw new Error(`Broker returned non-OAuth credential for id=${credentialId}`);
|
|
468
|
+
}
|
|
469
|
+
const refreshed = entry.credential;
|
|
470
|
+
return {
|
|
471
|
+
access: refreshed.access,
|
|
472
|
+
refresh: REMOTE_REFRESH_SENTINEL,
|
|
473
|
+
expires: refreshed.expires,
|
|
474
|
+
accountId: refreshed.accountId,
|
|
475
|
+
email: refreshed.email,
|
|
476
|
+
projectId: refreshed.projectId,
|
|
477
|
+
enterpriseUrl: refreshed.enterpriseUrl,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Store-level hook consumed by `AuthStorage.fetchUsageReports()` — proxies
|
|
483
|
+
* to the broker's `/v1/usage` endpoint. The broker's egress IP isn't
|
|
484
|
+
* rate-limited by Anthropic's per-IP `/usage` cap the way a heavy
|
|
485
|
+
* residential laptop is, so all credentials surface every cycle.
|
|
486
|
+
*/
|
|
487
|
+
async fetchUsageReports(signal?: AbortSignal): Promise<UsageReport[] | null> {
|
|
488
|
+
return this.#raceWithSignal(this.#loadUsageReports(), signal);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Per-credential usage hook consumed by `AuthStorage.#getUsageReport`. Pulls
|
|
493
|
+
* the aggregate broker `/v1/usage` once and serves all callers from the
|
|
494
|
+
* same response (coalesced + cached), then matches the credential to a
|
|
495
|
+
* report by provider + identity (accountId / email / projectId).
|
|
496
|
+
*
|
|
497
|
+
* The broker already aggregates with its own 30s TTL on the server side; our
|
|
498
|
+
* 15s client TTL is below that so we usually re-use the broker's cache too.
|
|
499
|
+
*/
|
|
500
|
+
async getUsageReport(
|
|
501
|
+
provider: Provider,
|
|
502
|
+
credential: OAuthCredential,
|
|
503
|
+
signal?: AbortSignal,
|
|
504
|
+
): Promise<UsageReport | null> {
|
|
505
|
+
const reports = await this.#raceWithSignal(this.#loadUsageReports(), signal);
|
|
506
|
+
if (!reports) return null;
|
|
507
|
+
return matchUsageReport(reports, provider, credential);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Reject the awaited promise when the caller's signal aborts, without
|
|
512
|
+
* affecting the shared upstream fetch. Used to give each caller their
|
|
513
|
+
* own cancel without one caller's abort cascading into a peer's in-flight
|
|
514
|
+
* request through the single-flight `#usageInflight`.
|
|
515
|
+
*/
|
|
516
|
+
#raceWithSignal<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
517
|
+
if (!signal) return promise;
|
|
518
|
+
if (signal.aborted) return Promise.reject(new Error("auth-broker request aborted"));
|
|
519
|
+
return new Promise<T>((resolve, reject) => {
|
|
520
|
+
const onAbort = (): void => {
|
|
521
|
+
signal.removeEventListener("abort", onAbort);
|
|
522
|
+
reject(new Error("auth-broker request aborted"));
|
|
523
|
+
};
|
|
524
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
525
|
+
promise.then(
|
|
526
|
+
value => {
|
|
527
|
+
signal.removeEventListener("abort", onAbort);
|
|
528
|
+
resolve(value);
|
|
529
|
+
},
|
|
530
|
+
err => {
|
|
531
|
+
signal.removeEventListener("abort", onAbort);
|
|
532
|
+
reject(err);
|
|
533
|
+
},
|
|
534
|
+
);
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
#loadUsageReports(): Promise<UsageReport[] | null> {
|
|
539
|
+
const cached = this.#usageCache;
|
|
540
|
+
if (cached && Date.now() - cached.fetchedAt < USAGE_CACHE_TTL_MS) {
|
|
541
|
+
return Promise.resolve(cached.reports);
|
|
542
|
+
}
|
|
543
|
+
if (this.#usageInflight) return this.#usageInflight;
|
|
544
|
+
const inflight = this.#client
|
|
545
|
+
.fetchUsage()
|
|
546
|
+
.then(body => {
|
|
547
|
+
this.#usageCache = { reports: body.reports, fetchedAt: Date.now() };
|
|
548
|
+
return body.reports;
|
|
549
|
+
})
|
|
550
|
+
.catch(error => {
|
|
551
|
+
logger.warn("auth-broker usage fetch failed", { error: String(error) });
|
|
552
|
+
return null;
|
|
553
|
+
})
|
|
554
|
+
.finally(() => {
|
|
555
|
+
this.#usageInflight = undefined;
|
|
556
|
+
});
|
|
557
|
+
this.#usageInflight = inflight;
|
|
558
|
+
return inflight;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
close(): void {
|
|
562
|
+
if (this.#closed) return;
|
|
563
|
+
this.#closed = true;
|
|
564
|
+
this.#backgroundAbort.abort();
|
|
565
|
+
this.#cache.clear();
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Match a broker-supplied usage report to a specific OAuth credential. The
|
|
571
|
+
* broker returns aggregate reports across all credentials it manages, so we
|
|
572
|
+
* pick the one whose identity (accountId / email / projectId) lines up with
|
|
573
|
+
* the credential the caller is asking about.
|
|
574
|
+
*
|
|
575
|
+
* Falls back to the lone candidate when only one matches the provider; falls
|
|
576
|
+
* through to `null` when nothing matches, which `AuthStorage` treats as "no
|
|
577
|
+
* usage data" (ranking proceeds without a usage signal for this credential).
|
|
578
|
+
*/
|
|
579
|
+
function matchUsageReport(reports: UsageReport[], provider: Provider, credential: OAuthCredential): UsageReport | null {
|
|
580
|
+
const candidates = reports.filter(report => report.provider === provider);
|
|
581
|
+
if (candidates.length === 0) return null;
|
|
582
|
+
if (candidates.length === 1) return candidates[0];
|
|
583
|
+
const accountId = credential.accountId?.trim().toLowerCase();
|
|
584
|
+
const email = credential.email?.trim().toLowerCase();
|
|
585
|
+
const projectId = credential.projectId?.trim().toLowerCase();
|
|
586
|
+
for (const report of candidates) {
|
|
587
|
+
if (reportMatchesIdentity(report, accountId, email, projectId)) return report;
|
|
588
|
+
}
|
|
589
|
+
return null;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function reportMatchesIdentity(
|
|
593
|
+
report: UsageReport,
|
|
594
|
+
accountId: string | undefined,
|
|
595
|
+
email: string | undefined,
|
|
596
|
+
projectId: string | undefined,
|
|
597
|
+
): boolean {
|
|
598
|
+
const metadata = (report.metadata ?? {}) as Record<string, unknown>;
|
|
599
|
+
if (accountId) {
|
|
600
|
+
const metaAccount = readMetadataString(metadata, "accountId") ?? readMetadataString(metadata, "account_id");
|
|
601
|
+
if (metaAccount && metaAccount.toLowerCase() === accountId) return true;
|
|
602
|
+
for (const limit of report.limits) {
|
|
603
|
+
if (limit.scope.accountId?.toLowerCase() === accountId) return true;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
if (email) {
|
|
607
|
+
const metaEmail = readMetadataString(metadata, "email");
|
|
608
|
+
if (metaEmail && metaEmail.toLowerCase() === email) return true;
|
|
609
|
+
}
|
|
610
|
+
if (projectId) {
|
|
611
|
+
const metaProject = readMetadataString(metadata, "projectId") ?? readMetadataString(metadata, "project_id");
|
|
612
|
+
if (metaProject && metaProject.toLowerCase() === projectId) return true;
|
|
613
|
+
for (const limit of report.limits) {
|
|
614
|
+
if (limit.scope.projectId?.toLowerCase() === projectId) return true;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
return false;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function readMetadataString(metadata: Record<string, unknown>, key: string): string | undefined {
|
|
621
|
+
const value = metadata[key];
|
|
622
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
|
623
|
+
}
|