@vib-rato/ai 0.16.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 +3354 -0
- package/README.md +1194 -0
- package/dist/types/adapter-internals/provider-safety-stop.d.ts +45 -0
- package/dist/types/api-registry.d.ts +30 -0
- package/dist/types/auth-broker/client.d.ts +80 -0
- package/dist/types/auth-broker/index.d.ts +5 -0
- package/dist/types/auth-broker/redact.d.ts +7 -0
- package/dist/types/auth-broker/refresher.d.ts +25 -0
- package/dist/types/auth-broker/remote-store.d.ts +153 -0
- package/dist/types/auth-broker/server.d.ts +32 -0
- package/dist/types/auth-broker/types.d.ts +132 -0
- package/dist/types/auth-broker/wire-schemas.d.ts +555 -0
- package/dist/types/auth-gateway/http.d.ts +40 -0
- package/dist/types/auth-gateway/index.d.ts +3 -0
- package/dist/types/auth-gateway/server.d.ts +70 -0
- package/dist/types/auth-gateway/types.d.ts +129 -0
- package/dist/types/auth-storage.d.ts +1074 -0
- package/dist/types/cli.d.ts +2 -0
- package/dist/types/codex-tools.d.ts +4 -0
- package/dist/types/context-cap-policy.d.ts +68 -0
- package/dist/types/core.d.ts +35 -0
- package/dist/types/index.d.ts +55 -0
- package/dist/types/model-cache.d.ts +24 -0
- package/dist/types/model-manager.d.ts +77 -0
- package/dist/types/model-pricing.d.ts +3 -0
- package/dist/types/model-retirements.d.ts +6 -0
- package/dist/types/model-thinking.d.ts +100 -0
- package/dist/types/models.d.ts +21 -0
- package/dist/types/openai-completions-compat.d.ts +34 -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 +293 -0
- package/dist/types/provider-models/special.d.ts +29 -0
- package/dist/types/providers/amazon-bedrock.d.ts +60 -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 +280 -0
- package/dist/types/providers/aws-credential-config.d.ts +19 -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 +22 -0
- package/dist/types/providers/composer-discipline.d.ts +32 -0
- package/dist/types/providers/cursor/client-version.d.ts +10 -0
- package/dist/types/providers/cursor/exec-modern.d.ts +98 -0
- package/dist/types/providers/cursor/gen/agent_pb.d.ts +16769 -0
- package/dist/types/providers/cursor-pi-args.d.ts +119 -0
- package/dist/types/providers/cursor.d.ts +72 -0
- package/dist/types/providers/dashscope-token-plan-headers.d.ts +57 -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 +26 -0
- package/dist/types/providers/google-gemini-cli.d.ts +75 -0
- package/dist/types/providers/google-gemini-headers.d.ts +43 -0
- package/dist/types/providers/google-shared.d.ts +183 -0
- package/dist/types/providers/google-types.d.ts +138 -0
- package/dist/types/providers/google-vertex.d.ts +11 -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/kiro-api-key.d.ts +50 -0
- package/dist/types/providers/kiro-codewhisperer.d.ts +11 -0
- package/dist/types/providers/mock.d.ts +189 -0
- package/dist/types/providers/ollama.d.ts +41 -0
- package/dist/types/providers/openai-anthropic-shim.d.ts +31 -0
- package/dist/types/providers/openai-bounded-rate-limits.d.ts +3 -0
- package/dist/types/providers/openai-chat-server-schema.d.ts +1733 -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 +50 -0
- package/dist/types/providers/openai-codex/response-handler.d.ts +18 -0
- package/dist/types/providers/openai-codex-responses.d.ts +71 -0
- package/dist/types/providers/openai-completions-compat.d.ts +6 -0
- package/dist/types/providers/openai-completions.d.ts +35 -0
- package/dist/types/providers/openai-opencodex-responses.d.ts +10 -0
- package/dist/types/providers/openai-request-transform.d.ts +4 -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 +106 -0
- package/dist/types/providers/openai-responses.d.ts +37 -0
- package/dist/types/providers/pi-native-client.d.ts +29 -0
- package/dist/types/providers/pi-native-server.d.ts +60 -0
- package/dist/types/providers/register-builtins.d.ts +59 -0
- package/dist/types/providers/synthetic.d.ts +26 -0
- package/dist/types/providers/transform-messages.d.ts +33 -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 +45 -0
- package/dist/types/types.d.ts +1073 -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/grok-cli.d.ts +17 -0
- package/dist/types/usage/kimi.d.ts +4 -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 +264 -0
- package/dist/types/utils/abort.d.ts +19 -0
- package/dist/types/utils/anthropic-auth.d.ts +39 -0
- package/dist/types/utils/block-symbols.d.ts +6 -0
- package/dist/types/utils/discovery/antigravity.d.ts +67 -0
- package/dist/types/utils/discovery/codex.d.ts +38 -0
- package/dist/types/utils/discovery/cursor.d.ts +49 -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 +81 -0
- package/dist/types/utils/event-stream.d.ts +41 -0
- package/dist/types/utils/fallback-transport.d.ts +110 -0
- package/dist/types/utils/fireworks-model-id.d.ts +10 -0
- package/dist/types/utils/foundry.d.ts +11 -0
- package/dist/types/utils/h2-fetch.d.ts +22 -0
- package/dist/types/utils/http-inspector.d.ts +59 -0
- package/dist/types/utils/idle-iterator.d.ts +122 -0
- package/dist/types/utils/json-parse.d.ts +98 -0
- package/dist/types/utils/oauth/alibaba-token-plan.d.ts +19 -0
- package/dist/types/utils/oauth/anthropic.d.ts +41 -0
- package/dist/types/utils/oauth/api-key-login.d.ts +38 -0
- package/dist/types/utils/oauth/api-key-validation.d.ts +39 -0
- package/dist/types/utils/oauth/bizrouter.d.ts +1 -0
- package/dist/types/utils/oauth/callback-server.d.ts +80 -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/commandcode.d.ts +1 -0
- package/dist/types/utils/oauth/cursor.d.ts +15 -0
- package/dist/types/utils/oauth/deepinfra.d.ts +1 -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/fugu.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/glm-zcode.d.ts +71 -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 +39 -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 +17 -0
- package/dist/types/utils/oauth/kiro.d.ts +71 -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/mara.d.ts +1 -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/opengateway.d.ts +1 -0
- package/dist/types/utils/oauth/openrouter.d.ts +1 -0
- package/dist/types/utils/oauth/parallel.d.ts +17 -0
- package/dist/types/utils/oauth/perplexity.d.ts +4 -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/sglang.d.ts +16 -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 +56 -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/xai.d.ts +30 -0
- package/dist/types/utils/oauth/xiaomi.d.ts +25 -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 +14 -0
- package/dist/types/utils/parse-bind.d.ts +26 -0
- package/dist/types/utils/provider-response.d.ts +6 -0
- package/dist/types/utils/provider-safety-stop.d.ts +8 -0
- package/dist/types/utils/proxy.d.ts +7 -0
- package/dist/types/utils/retry-after.d.ts +3 -0
- package/dist/types/utils/retry-budget.d.ts +1 -0
- package/dist/types/utils/retry.d.ts +29 -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 +14 -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/root-combinator.d.ts +12 -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 +80 -0
- package/dist/types/utils/tool-choice-capability.d.ts +57 -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 +117 -0
- package/package.json +152 -0
- package/src/adapter-internals/provider-safety-stop.d.ts +45 -0
- package/src/adapter-internals/provider-safety-stop.ts +156 -0
- package/src/api-registry.d.ts +30 -0
- package/src/api-registry.ts +96 -0
- package/src/auth-broker/client.ts +444 -0
- package/src/auth-broker/index.ts +5 -0
- package/src/auth-broker/redact.ts +39 -0
- package/src/auth-broker/refresher.ts +130 -0
- package/src/auth-broker/remote-store.ts +1576 -0
- package/src/auth-broker/server.ts +764 -0
- package/src/auth-broker/types.ts +164 -0
- package/src/auth-broker/wire-schemas.ts +261 -0
- package/src/auth-gateway/http.ts +198 -0
- package/src/auth-gateway/index.ts +3 -0
- package/src/auth-gateway/server.ts +1315 -0
- package/src/auth-gateway/types.ts +160 -0
- package/src/auth-storage.ts +7312 -0
- package/src/cli.ts +269 -0
- package/src/codex-tools.d.ts +4 -0
- package/src/codex-tools.ts +24 -0
- package/src/context-cap-policy.d.ts +68 -0
- package/src/context-cap-policy.ts +123 -0
- package/src/core.ts +44 -0
- package/src/index.ts +61 -0
- package/src/model-cache.ts +236 -0
- package/src/model-manager.ts +744 -0
- package/src/model-pricing.d.ts +3 -0
- package/src/model-pricing.ts +68 -0
- package/src/model-retirements.d.ts +6 -0
- package/src/model-retirements.ts +19 -0
- package/src/model-thinking.d.ts +100 -0
- package/src/model-thinking.ts +1054 -0
- package/src/models.d.ts +21 -0
- package/src/models.json +94672 -0
- package/src/models.json.d.ts +9 -0
- package/src/models.ts +126 -0
- package/src/openai-completions-compat.d.ts +34 -0
- package/src/openai-completions-compat.ts +383 -0
- package/src/prompts/composer-bash-policy-recovery.md +1 -0
- package/src/prompts/cursor-composer-bash-policy-recovery.md +1 -0
- package/src/prompts/cursor-composer-edit-discipline.md +7 -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 +392 -0
- package/src/provider-models/google.ts +92 -0
- package/src/provider-models/index.ts +5 -0
- package/src/provider-models/ollama.ts +159 -0
- package/src/provider-models/openai-compat.ts +2920 -0
- package/src/provider-models/special.ts +185 -0
- package/src/providers/amazon-bedrock.d.ts +60 -0
- package/src/providers/amazon-bedrock.ts +939 -0
- package/src/providers/anthropic-messages-server-schema.ts +229 -0
- package/src/providers/anthropic-messages-server.ts +839 -0
- package/src/providers/anthropic.d.ts +280 -0
- package/src/providers/anthropic.ts +4421 -0
- package/src/providers/aws-credential-config.d.ts +19 -0
- package/src/providers/aws-credential-config.ts +179 -0
- package/src/providers/aws-credentials.d.ts +43 -0
- package/src/providers/aws-credentials.ts +457 -0
- package/src/providers/aws-eventstream.d.ts +38 -0
- package/src/providers/aws-eventstream.ts +185 -0
- package/src/providers/aws-sigv4.d.ts +55 -0
- package/src/providers/aws-sigv4.ts +218 -0
- package/src/providers/azure-openai-responses.d.ts +22 -0
- package/src/providers/azure-openai-responses.ts +447 -0
- package/src/providers/composer-discipline.d.ts +32 -0
- package/src/providers/composer-discipline.ts +95 -0
- package/src/providers/cursor/client-version.d.ts +10 -0
- package/src/providers/cursor/client-version.ts +10 -0
- package/src/providers/cursor/exec-modern.d.ts +98 -0
- package/src/providers/cursor/exec-modern.ts +497 -0
- package/src/providers/cursor/gen/agent_pb.d.ts +16769 -0
- package/src/providers/cursor/gen/agent_pb.ts +19780 -0
- package/src/providers/cursor/proto/agent.proto +4533 -0
- package/src/providers/cursor/proto/buf.gen.yaml +6 -0
- package/src/providers/cursor/proto/buf.yaml +17 -0
- package/src/providers/cursor-pi-args.d.ts +119 -0
- package/src/providers/cursor-pi-args.ts +187 -0
- package/src/providers/cursor.d.ts +72 -0
- package/src/providers/cursor.ts +3396 -0
- package/src/providers/dashscope-token-plan-headers.d.ts +57 -0
- package/src/providers/dashscope-token-plan-headers.ts +84 -0
- package/src/providers/error-message.d.ts +27 -0
- package/src/providers/error-message.ts +21 -0
- package/src/providers/github-copilot-headers.d.ts +40 -0
- package/src/providers/github-copilot-headers.ts +140 -0
- package/src/providers/gitlab-duo.d.ts +27 -0
- package/src/providers/gitlab-duo.ts +393 -0
- package/src/providers/google-auth.d.ts +26 -0
- package/src/providers/google-auth.ts +262 -0
- package/src/providers/google-gemini-cli.d.ts +75 -0
- package/src/providers/google-gemini-cli.ts +969 -0
- package/src/providers/google-gemini-headers.d.ts +43 -0
- package/src/providers/google-gemini-headers.ts +100 -0
- package/src/providers/google-shared.d.ts +183 -0
- package/src/providers/google-shared.ts +1105 -0
- package/src/providers/google-types.d.ts +138 -0
- package/src/providers/google-types.ts +167 -0
- package/src/providers/google-vertex.d.ts +11 -0
- package/src/providers/google-vertex.ts +124 -0
- package/src/providers/google.d.ts +4 -0
- package/src/providers/google.ts +41 -0
- package/src/providers/grammar.d.ts +1 -0
- package/src/providers/grammar.ts +70 -0
- package/src/providers/kimi.d.ts +27 -0
- package/src/providers/kimi.ts +52 -0
- package/src/providers/kiro-api-key.d.ts +50 -0
- package/src/providers/kiro-api-key.ts +786 -0
- package/src/providers/kiro-codewhisperer.d.ts +11 -0
- package/src/providers/kiro-codewhisperer.ts +600 -0
- package/src/providers/mock.ts +526 -0
- package/src/providers/ollama.d.ts +41 -0
- package/src/providers/ollama.ts +645 -0
- package/src/providers/openai-anthropic-shim.d.ts +31 -0
- package/src/providers/openai-anthropic-shim.ts +156 -0
- package/src/providers/openai-bounded-rate-limits.d.ts +3 -0
- package/src/providers/openai-bounded-rate-limits.ts +57 -0
- package/src/providers/openai-chat-server-schema.ts +254 -0
- package/src/providers/openai-chat-server.ts +724 -0
- package/src/providers/openai-codex/constants.d.ts +26 -0
- package/src/providers/openai-codex/constants.ts +43 -0
- package/src/providers/openai-codex/request-transformer.d.ts +50 -0
- package/src/providers/openai-codex/request-transformer.ts +219 -0
- package/src/providers/openai-codex/response-handler.d.ts +18 -0
- package/src/providers/openai-codex/response-handler.ts +111 -0
- package/src/providers/openai-codex-responses.d.ts +71 -0
- package/src/providers/openai-codex-responses.ts +3288 -0
- package/src/providers/openai-completions-compat.d.ts +6 -0
- package/src/providers/openai-completions-compat.ts +6 -0
- package/src/providers/openai-completions.d.ts +35 -0
- package/src/providers/openai-completions.ts +2294 -0
- package/src/providers/openai-opencodex-responses.ts +174 -0
- package/src/providers/openai-request-transform.d.ts +4 -0
- package/src/providers/openai-request-transform.ts +136 -0
- package/src/providers/openai-responses-server-schema.ts +290 -0
- package/src/providers/openai-responses-server.ts +1268 -0
- package/src/providers/openai-responses-shared.d.ts +106 -0
- package/src/providers/openai-responses-shared.ts +1253 -0
- package/src/providers/openai-responses.d.ts +37 -0
- package/src/providers/openai-responses.ts +989 -0
- package/src/providers/pi-native-client.d.ts +29 -0
- package/src/providers/pi-native-client.ts +243 -0
- package/src/providers/pi-native-server.ts +488 -0
- package/src/providers/register-builtins.d.ts +59 -0
- package/src/providers/register-builtins.ts +544 -0
- package/src/providers/synthetic.d.ts +26 -0
- package/src/providers/synthetic.ts +50 -0
- package/src/providers/transform-messages.d.ts +33 -0
- package/src/providers/transform-messages.ts +408 -0
- package/src/providers/vision-guard.d.ts +8 -0
- package/src/providers/vision-guard.ts +31 -0
- package/src/rate-limit-utils.d.ts +19 -0
- package/src/rate-limit-utils.ts +102 -0
- package/src/stream.d.ts +45 -0
- package/src/stream.ts +1306 -0
- package/src/types.d.ts +1073 -0
- package/src/types.ts +1305 -0
- package/src/usage/claude.ts +449 -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/grok-cli.ts +259 -0
- package/src/usage/kimi.ts +285 -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 +190 -0
- package/src/utils/abort.d.ts +19 -0
- package/src/utils/abort.ts +51 -0
- package/src/utils/anthropic-auth.ts +95 -0
- package/src/utils/block-symbols.d.ts +6 -0
- package/src/utils/block-symbols.ts +11 -0
- package/src/utils/discovery/antigravity.ts +275 -0
- package/src/utils/discovery/codex.ts +362 -0
- package/src/utils/discovery/cursor.ts +388 -0
- package/src/utils/discovery/gemini.ts +248 -0
- package/src/utils/discovery/index.ts +4 -0
- package/src/utils/discovery/openai-compatible.ts +379 -0
- package/src/utils/event-stream.d.ts +41 -0
- package/src/utils/event-stream.ts +269 -0
- package/src/utils/fallback-transport.d.ts +110 -0
- package/src/utils/fallback-transport.ts +411 -0
- package/src/utils/fireworks-model-id.d.ts +10 -0
- package/src/utils/fireworks-model-id.ts +30 -0
- package/src/utils/foundry.d.ts +11 -0
- package/src/utils/foundry.ts +18 -0
- package/src/utils/h2-fetch.ts +60 -0
- package/src/utils/http-inspector.d.ts +59 -0
- package/src/utils/http-inspector.ts +380 -0
- package/src/utils/idle-iterator.d.ts +122 -0
- package/src/utils/idle-iterator.ts +410 -0
- package/src/utils/json-parse.d.ts +98 -0
- package/src/utils/json-parse.ts +607 -0
- package/src/utils/oauth/alibaba-token-plan.ts +60 -0
- package/src/utils/oauth/anthropic.ts +233 -0
- package/src/utils/oauth/api-key-login.ts +98 -0
- package/src/utils/oauth/api-key-validation.ts +344 -0
- package/src/utils/oauth/bizrouter.ts +15 -0
- package/src/utils/oauth/callback-server.d.ts +80 -0
- package/src/utils/oauth/callback-server.ts +359 -0
- package/src/utils/oauth/cerebras.ts +16 -0
- package/src/utils/oauth/cloudflare-ai-gateway.ts +48 -0
- package/src/utils/oauth/commandcode.ts +17 -0
- package/src/utils/oauth/cursor.ts +157 -0
- package/src/utils/oauth/deepinfra.ts +15 -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/fugu.ts +15 -0
- package/src/utils/oauth/github-copilot.d.ts +38 -0
- package/src/utils/oauth/github-copilot.ts +362 -0
- package/src/utils/oauth/gitlab-duo.ts +123 -0
- package/src/utils/oauth/glm-zcode.d.ts +71 -0
- package/src/utils/oauth/glm-zcode.ts +433 -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 +558 -0
- package/src/utils/oauth/kagi.ts +47 -0
- package/src/utils/oauth/kilo.ts +87 -0
- package/src/utils/oauth/kimi.d.ts +17 -0
- package/src/utils/oauth/kimi.ts +275 -0
- package/src/utils/oauth/kiro.ts +448 -0
- package/src/utils/oauth/litellm.ts +47 -0
- package/src/utils/oauth/lm-studio.ts +38 -0
- package/src/utils/oauth/mara.ts +16 -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/opengateway.ts +15 -0
- package/src/utils/oauth/openrouter.ts +16 -0
- package/src/utils/oauth/parallel.ts +46 -0
- package/src/utils/oauth/perplexity.ts +225 -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/sglang.ts +42 -0
- package/src/utils/oauth/synthetic.ts +15 -0
- package/src/utils/oauth/tavily.ts +46 -0
- package/src/utils/oauth/together.ts +16 -0
- package/src/utils/oauth/types.d.ts +56 -0
- package/src/utils/oauth/types.ts +122 -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 +42 -0
- package/src/utils/oauth/xai.ts +246 -0
- package/src/utils/oauth/xiaomi.ts +199 -0
- package/src/utils/oauth/zai.ts +60 -0
- package/src/utils/oauth/zenmux.ts +15 -0
- package/src/utils/overflow.ts +275 -0
- package/src/utils/parse-bind.ts +81 -0
- package/src/utils/provider-response.d.ts +6 -0
- package/src/utils/provider-response.ts +30 -0
- package/src/utils/provider-safety-stop.ts +8 -0
- package/src/utils/proxy.d.ts +7 -0
- package/src/utils/proxy.ts +652 -0
- package/src/utils/retry-after.d.ts +3 -0
- package/src/utils/retry-after.ts +110 -0
- package/src/utils/retry-budget.d.ts +1 -0
- package/src/utils/retry-budget.ts +4 -0
- package/src/utils/retry.d.ts +29 -0
- package/src/utils/retry.ts +67 -0
- package/src/utils/schema/CONSTRAINTS.md +164 -0
- package/src/utils/schema/adapt.d.ts +24 -0
- package/src/utils/schema/adapt.ts +36 -0
- package/src/utils/schema/compatibility.d.ts +30 -0
- package/src/utils/schema/compatibility.ts +435 -0
- package/src/utils/schema/dereference.d.ts +11 -0
- package/src/utils/schema/dereference.ts +98 -0
- package/src/utils/schema/draft.d.ts +10 -0
- package/src/utils/schema/draft.ts +341 -0
- package/src/utils/schema/equality.d.ts +4 -0
- package/src/utils/schema/equality.ts +97 -0
- package/src/utils/schema/fields.d.ts +49 -0
- package/src/utils/schema/fields.ts +190 -0
- package/src/utils/schema/index.d.ts +14 -0
- package/src/utils/schema/index.ts +14 -0
- package/src/utils/schema/json-schema-validator.d.ts +12 -0
- package/src/utils/schema/json-schema-validator.ts +577 -0
- package/src/utils/schema/meta-validator.d.ts +2 -0
- package/src/utils/schema/meta-validator.ts +167 -0
- package/src/utils/schema/normalize.d.ts +93 -0
- package/src/utils/schema/normalize.ts +1588 -0
- package/src/utils/schema/root-combinator.d.ts +12 -0
- package/src/utils/schema/root-combinator.ts +143 -0
- package/src/utils/schema/spill.d.ts +8 -0
- package/src/utils/schema/spill.ts +43 -0
- package/src/utils/schema/stamps.d.ts +25 -0
- package/src/utils/schema/stamps.ts +97 -0
- package/src/utils/schema/types.d.ts +4 -0
- package/src/utils/schema/types.ts +11 -0
- package/src/utils/schema/wire.d.ts +54 -0
- package/src/utils/schema/wire.ts +213 -0
- package/src/utils/schema/zod-decontaminate.d.ts +31 -0
- package/src/utils/schema/zod-decontaminate.ts +331 -0
- package/src/utils/sse-debug.d.ts +10 -0
- package/src/utils/sse-debug.ts +289 -0
- package/src/utils/tool-call-healing.d.ts +80 -0
- package/src/utils/tool-call-healing.ts +298 -0
- package/src/utils/tool-choice-capability.d.ts +57 -0
- package/src/utils/tool-choice-capability.ts +633 -0
- package/src/utils/tool-choice.d.ts +50 -0
- package/src/utils/tool-choice.ts +99 -0
- package/src/utils/validation.ts +1080 -0
- package/src/utils.d.ts +117 -0
- package/src/utils.ts +523 -0
|
@@ -0,0 +1,1576 @@
|
|
|
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
|
+
|
|
11
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
12
|
+
import * as fs from "node:fs/promises";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
import { scheduler } from "node:timers/promises";
|
|
15
|
+
|
|
16
|
+
import { getConfigRootDir, isEnoent, logger } from "@vib-rato/utils";
|
|
17
|
+
import {
|
|
18
|
+
type AuthCredential,
|
|
19
|
+
type AuthCredentialIfAbsentResult,
|
|
20
|
+
type AuthCredentialStore,
|
|
21
|
+
assertCanonicalMCPOAuthBinding,
|
|
22
|
+
type CachedCredentialHealth,
|
|
23
|
+
type CachedUsagePresentation,
|
|
24
|
+
type CredentialDispatchTicket,
|
|
25
|
+
type CredentialInventoryRecord,
|
|
26
|
+
type MCPOAuthRefreshClient,
|
|
27
|
+
type OAuthCredential,
|
|
28
|
+
REMOTE_REFRESH_SENTINEL,
|
|
29
|
+
type SafeUsageReport,
|
|
30
|
+
type StoredAuthCredential,
|
|
31
|
+
} from "../auth-storage";
|
|
32
|
+
import type { Provider } from "../types";
|
|
33
|
+
import type { UsageReport } from "../usage";
|
|
34
|
+
import type { OAuthCredentials } from "../utils/oauth/types";
|
|
35
|
+
import {
|
|
36
|
+
type AuthBrokerClient,
|
|
37
|
+
AuthBrokerCredentialMetadataUnsupportedError,
|
|
38
|
+
AuthBrokerStreamUnsupportedError,
|
|
39
|
+
} from "./client";
|
|
40
|
+
import { cleanReason } from "./redact";
|
|
41
|
+
import type {
|
|
42
|
+
CredentialMetadataRecord,
|
|
43
|
+
RefresherSchedule,
|
|
44
|
+
SnapshotEntry,
|
|
45
|
+
SnapshotResponse,
|
|
46
|
+
SnapshotStreamEvent,
|
|
47
|
+
} from "./types";
|
|
48
|
+
|
|
49
|
+
export type CredentialInventoryMetadataCapability = "pending" | "supported" | "unsupported" | "mismatch" | "failed";
|
|
50
|
+
|
|
51
|
+
export interface CachedInventoryNotice {
|
|
52
|
+
status: Exclude<CredentialInventoryMetadataCapability, "supported">;
|
|
53
|
+
reason: string;
|
|
54
|
+
generation?: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface CredentialInventoryMetadataState {
|
|
58
|
+
capability: CredentialInventoryMetadataCapability;
|
|
59
|
+
generation: number;
|
|
60
|
+
records: readonly CredentialInventoryRecord[];
|
|
61
|
+
notice?: CachedInventoryNotice;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Client-side TTL for the aggregate `/v1/usage` response. Set below the
|
|
66
|
+
* broker server's own 30s usage cache so we typically pick up the broker's
|
|
67
|
+
* cached value instead of re-walking the network — but high enough to absorb
|
|
68
|
+
* the parallel fan-out from `#rankOAuthSelections` into a single round-trip.
|
|
69
|
+
*/
|
|
70
|
+
const USAGE_CACHE_TTL_MS = 15_000;
|
|
71
|
+
const WAIT_THRESHOLD_MS = 1_000;
|
|
72
|
+
const MAX_WAIT_MS = 5_000;
|
|
73
|
+
const BACKGROUND_WAIT_MS = 30_000;
|
|
74
|
+
const BACKGROUND_BACKOFF_INITIAL_MS = 500;
|
|
75
|
+
const BACKGROUND_BACKOFF_MAX_MS = 30_000;
|
|
76
|
+
|
|
77
|
+
function epochRank(epoch: string): number | undefined {
|
|
78
|
+
const match = /^(\d+)-/.exec(epoch);
|
|
79
|
+
return match ? Number(match[1]) : undefined;
|
|
80
|
+
}
|
|
81
|
+
const PRESENTATION_FRESH_MS = 5 * 60_000;
|
|
82
|
+
const PRESENTATION_RETENTION_MS = 24 * 60 * 60_000;
|
|
83
|
+
const PRESENTATION_SIDECAR_VERSION = 1;
|
|
84
|
+
/**
|
|
85
|
+
* Default location of the redacted presentation sidecar.
|
|
86
|
+
*
|
|
87
|
+
* Derived when a store is constructed, never at import time: the trusted
|
|
88
|
+
* config root is call-time state (#4761, #4772), so an import-time constant
|
|
89
|
+
* keeps pointing at the home that was in effect when this module first loaded
|
|
90
|
+
* and a process can read and write one logical profile through two different
|
|
91
|
+
* roots (#4786).
|
|
92
|
+
*/
|
|
93
|
+
function defaultPresentationSidecarPath(): string {
|
|
94
|
+
return path.join(getConfigRootDir(), "auth-broker-presentations.json");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function emptySnapshot(): SnapshotResponse {
|
|
98
|
+
return {
|
|
99
|
+
generation: 0,
|
|
100
|
+
generatedAt: 0,
|
|
101
|
+
serverNowMs: 0,
|
|
102
|
+
refresher: {
|
|
103
|
+
enabled: false,
|
|
104
|
+
intervalMs: 0,
|
|
105
|
+
skewMs: 0,
|
|
106
|
+
nextSweepInMs: Number.MAX_SAFE_INTEGER,
|
|
107
|
+
},
|
|
108
|
+
credentials: [],
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
interface CacheEntry {
|
|
113
|
+
value: string;
|
|
114
|
+
expiresAtSec: number;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
interface UsageCacheEntry {
|
|
118
|
+
reports: UsageReport[];
|
|
119
|
+
fetchedAt: number;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
interface PersistedPresentation {
|
|
123
|
+
credentialId: number;
|
|
124
|
+
provider: string;
|
|
125
|
+
identityDigest: string;
|
|
126
|
+
health?: {
|
|
127
|
+
v: 1;
|
|
128
|
+
status: "ok" | "failed" | "unverifiable";
|
|
129
|
+
reason: string | null;
|
|
130
|
+
checkedAt?: number;
|
|
131
|
+
retainUntil: number;
|
|
132
|
+
};
|
|
133
|
+
usage?: CachedUsagePresentation;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
interface PresentationSidecarFile {
|
|
137
|
+
version: 1;
|
|
138
|
+
authorities: Record<string, Record<string, PersistedPresentation>>;
|
|
139
|
+
sourceHealth?: Record<string, Record<string, CacheEntry>>;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function presentationRecordKey(provider: string, identityDigest: string): string {
|
|
143
|
+
return `${provider}\u0000${identityDigest}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function safePresentationReason(value: unknown): string | null {
|
|
147
|
+
return cleanReason(value) ?? null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface RemoteAuthCredentialStoreOptions {
|
|
151
|
+
client: AuthBrokerClient;
|
|
152
|
+
/**
|
|
153
|
+
* Initial snapshot. When omitted, callers must call
|
|
154
|
+
* {@link RemoteAuthCredentialStore.refreshSnapshot} before the first read.
|
|
155
|
+
*/
|
|
156
|
+
initialSnapshot?: SnapshotResponse;
|
|
157
|
+
/**
|
|
158
|
+
* Subscribe to the broker's SSE snapshot stream when available. Falls back
|
|
159
|
+
* to long-poll permanently when the broker returns 404. Default `true`.
|
|
160
|
+
*/
|
|
161
|
+
streamSnapshots?: boolean;
|
|
162
|
+
/** Override the local redacted presentation sidecar path (primarily for tests). */
|
|
163
|
+
presentationPath?: string;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
167
|
+
readonly #client: AuthBrokerClient;
|
|
168
|
+
readonly #streamSnapshots: boolean;
|
|
169
|
+
#snapshot: SnapshotResponse = emptySnapshot();
|
|
170
|
+
#snapshotReceivedAt = Date.now();
|
|
171
|
+
#generation = 0;
|
|
172
|
+
#epoch?: string;
|
|
173
|
+
#retiredEpochs = new Set<string>();
|
|
174
|
+
#snapshotAuthorityTail: Promise<void> = Promise.resolve();
|
|
175
|
+
#snapshotListeners = new Set<() => void>();
|
|
176
|
+
#backgroundAbort = new AbortController();
|
|
177
|
+
#cache: Map<string, CacheEntry> = new Map();
|
|
178
|
+
#usageCache?: UsageCacheEntry;
|
|
179
|
+
#usageInflight?: Promise<UsageReport[] | null>;
|
|
180
|
+
#scopedUsageCache = new Map<Provider, UsageCacheEntry>();
|
|
181
|
+
#scopedUsageInflight = new Map<Provider, Promise<UsageReport[] | null>>();
|
|
182
|
+
#scopedUsageFailures = new Map<Provider, number>();
|
|
183
|
+
#usageCacheEpoch = 0;
|
|
184
|
+
#inventoryMetadata = new Map<number, CredentialMetadataRecord>();
|
|
185
|
+
#inventoryMetadataGeneration = -1;
|
|
186
|
+
#inventoryState: CredentialInventoryMetadataState = {
|
|
187
|
+
capability: "pending",
|
|
188
|
+
generation: 0,
|
|
189
|
+
records: [],
|
|
190
|
+
notice: { status: "pending", reason: "credential metadata sync pending", generation: 0 },
|
|
191
|
+
};
|
|
192
|
+
#inventorySyncInflight?: Promise<Readonly<CredentialInventoryMetadataState>>;
|
|
193
|
+
#inventoryMetadataUnsupported = false;
|
|
194
|
+
#usagePresentations = new Map<number, CachedUsagePresentation>();
|
|
195
|
+
readonly #presentationPath: string;
|
|
196
|
+
readonly #presentationAuthority: string;
|
|
197
|
+
#persistedPresentations = new Map<string, PersistedPresentation>();
|
|
198
|
+
#sidecarAuthorities: Record<string, Record<string, PersistedPresentation>> = {};
|
|
199
|
+
#persistedSourceHealth: Record<string, Record<string, CacheEntry>> = {};
|
|
200
|
+
#presentationReady: Promise<void>;
|
|
201
|
+
#presentationWriteChain: Promise<void> = Promise.resolve();
|
|
202
|
+
#closed = false;
|
|
203
|
+
/**
|
|
204
|
+
* `true` once the SSE consumer received its first frame and hasn't dropped
|
|
205
|
+
* since. Writes consult this to suppress the otherwise-mandatory
|
|
206
|
+
* `refreshSnapshot()` follow-up — the stream will deliver the new
|
|
207
|
+
* generation without an extra GET.
|
|
208
|
+
*/
|
|
209
|
+
#streamingActive = false;
|
|
210
|
+
/** Latched once the broker has answered 404 — never try the stream again. */
|
|
211
|
+
#streamingUnsupported = false;
|
|
212
|
+
#loadingInitialSnapshot = true;
|
|
213
|
+
#snapshotAuthoritative = false;
|
|
214
|
+
|
|
215
|
+
constructor(opts: RemoteAuthCredentialStoreOptions) {
|
|
216
|
+
this.#client = opts.client;
|
|
217
|
+
this.#streamSnapshots = opts.streamSnapshots ?? true;
|
|
218
|
+
this.#presentationPath = opts.presentationPath ?? defaultPresentationSidecarPath();
|
|
219
|
+
this.#presentationAuthority = createHash("sha256").update(this.#client.baseUrl).digest("hex");
|
|
220
|
+
this.#applySnapshot(opts.initialSnapshot ?? emptySnapshot(), opts.initialSnapshot?.generation ?? 0, false);
|
|
221
|
+
this.#loadingInitialSnapshot = false;
|
|
222
|
+
this.#snapshotAuthoritative = opts.initialSnapshot !== undefined;
|
|
223
|
+
this.#setInventoryState("pending", this.#generation, {
|
|
224
|
+
status: "pending",
|
|
225
|
+
reason: "credential metadata sync pending",
|
|
226
|
+
generation: this.#generation,
|
|
227
|
+
});
|
|
228
|
+
this.#presentationReady = this.#loadPresentationSidecar();
|
|
229
|
+
this.#scheduleInventoryMetadataSync();
|
|
230
|
+
void this.#runBackground();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
get client(): AuthBrokerClient {
|
|
234
|
+
return this.#client;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Wait for redacted presentation hydration and initial inventory metadata. */
|
|
238
|
+
async waitForReady(): Promise<void> {
|
|
239
|
+
await this.#presentationReady;
|
|
240
|
+
await this.syncInventoryMetadata();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Await pending atomic sidecar writes (useful to bounded shutdown callers). */
|
|
244
|
+
async flushPresentationPersistence(): Promise<void> {
|
|
245
|
+
await this.#presentationReady;
|
|
246
|
+
await this.#presentationWriteChain;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async #loadPresentationSidecar(): Promise<void> {
|
|
250
|
+
let raw: string;
|
|
251
|
+
try {
|
|
252
|
+
const stat = await fs.lstat(this.#presentationPath);
|
|
253
|
+
if (stat.isSymbolicLink() || !stat.isFile()) return;
|
|
254
|
+
raw = await fs.readFile(this.#presentationPath, "utf8");
|
|
255
|
+
} catch (error) {
|
|
256
|
+
if (isEnoent(error)) return;
|
|
257
|
+
logger.debug("auth-broker presentation sidecar unavailable", { error: String(error) });
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
try {
|
|
261
|
+
const parsed = JSON.parse(raw) as Partial<PresentationSidecarFile>;
|
|
262
|
+
if (parsed.version !== PRESENTATION_SIDECAR_VERSION || !parsed.authorities) return;
|
|
263
|
+
this.#sidecarAuthorities = parsed.authorities;
|
|
264
|
+
this.#persistedSourceHealth = parsed.sourceHealth ?? {};
|
|
265
|
+
for (const [key, entry] of Object.entries(this.#persistedSourceHealth[this.#presentationAuthority] ?? {})) {
|
|
266
|
+
if (entry.expiresAtSec * 1000 > Date.now()) this.#cache.set(key, entry);
|
|
267
|
+
}
|
|
268
|
+
const records = this.#sidecarAuthorities[this.#presentationAuthority];
|
|
269
|
+
if (!records || typeof records !== "object") return;
|
|
270
|
+
const now = Date.now();
|
|
271
|
+
for (const [key, candidate] of Object.entries(records)) {
|
|
272
|
+
if (!candidate || typeof candidate !== "object") continue;
|
|
273
|
+
const record = candidate as PersistedPresentation;
|
|
274
|
+
if (!Number.isInteger(record.credentialId) || typeof record.provider !== "string") continue;
|
|
275
|
+
if (record.health && (!Number.isFinite(record.health.retainUntil) || record.health.retainUntil <= now)) {
|
|
276
|
+
delete record.health;
|
|
277
|
+
}
|
|
278
|
+
if (record.usage && (!Number.isFinite(record.usage.retainUntil) || record.usage.retainUntil <= now)) {
|
|
279
|
+
delete record.usage;
|
|
280
|
+
}
|
|
281
|
+
if (!record.health && !record.usage) continue;
|
|
282
|
+
this.#persistedPresentations.set(key, record);
|
|
283
|
+
}
|
|
284
|
+
if (this.#snapshotAuthoritative) {
|
|
285
|
+
this.#reconcilePersistedPresentations();
|
|
286
|
+
this.#hydratePresentations();
|
|
287
|
+
this.#queuePresentationWrite();
|
|
288
|
+
}
|
|
289
|
+
} catch (error) {
|
|
290
|
+
logger.debug("auth-broker presentation sidecar invalid", { error: String(error) });
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
#hydratePresentations(): void {
|
|
295
|
+
for (const entry of this.#snapshot.credentials) {
|
|
296
|
+
const key = presentationRecordKey(entry.provider, this.#identityDigestForEntry(entry));
|
|
297
|
+
const persisted = this.#persistedPresentations.get(key);
|
|
298
|
+
if (!persisted) continue;
|
|
299
|
+
if (persisted.usage) {
|
|
300
|
+
this.#usagePresentations.set(entry.id, {
|
|
301
|
+
...persisted.usage,
|
|
302
|
+
credentialId: entry.id,
|
|
303
|
+
provider: entry.provider,
|
|
304
|
+
inventoryGeneration: this.#generation,
|
|
305
|
+
identityDigest: this.#identityDigestForEntry(entry),
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
if (persisted.health && persisted.health.retainUntil <= Date.now()) delete persisted.health;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
#reconcilePersistedPresentations(): void {
|
|
313
|
+
if (this.#persistedPresentations.size === 0) return;
|
|
314
|
+
let changed = false;
|
|
315
|
+
const current = new Map(
|
|
316
|
+
this.#snapshot.credentials.map(entry => [
|
|
317
|
+
entry.id,
|
|
318
|
+
{ provider: entry.provider, identityDigest: this.#identityDigestForEntry(entry) },
|
|
319
|
+
]),
|
|
320
|
+
);
|
|
321
|
+
for (const [key, record] of this.#persistedPresentations) {
|
|
322
|
+
const identity = current.get(record.credentialId);
|
|
323
|
+
if (
|
|
324
|
+
!identity ||
|
|
325
|
+
identity.provider !== record.provider ||
|
|
326
|
+
key !== presentationRecordKey(identity.provider, identity.identityDigest)
|
|
327
|
+
) {
|
|
328
|
+
this.#persistedPresentations.delete(key);
|
|
329
|
+
changed = true;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
if (changed) this.#queuePresentationWrite();
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
#queuePresentationWrite(): void {
|
|
336
|
+
const next = this.#presentationWriteChain
|
|
337
|
+
.catch(() => {})
|
|
338
|
+
.then(async () => {
|
|
339
|
+
const parsed: PresentationSidecarFile = {
|
|
340
|
+
version: 1,
|
|
341
|
+
authorities: {
|
|
342
|
+
...this.#sidecarAuthorities,
|
|
343
|
+
[this.#presentationAuthority]: Object.fromEntries(this.#persistedPresentations),
|
|
344
|
+
},
|
|
345
|
+
sourceHealth: this.#persistedSourceHealth,
|
|
346
|
+
};
|
|
347
|
+
this.#sidecarAuthorities = parsed.authorities;
|
|
348
|
+
const directory = path.dirname(this.#presentationPath);
|
|
349
|
+
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
350
|
+
const existing = await fs.lstat(this.#presentationPath).catch(error => {
|
|
351
|
+
if (isEnoent(error)) return undefined;
|
|
352
|
+
throw error;
|
|
353
|
+
});
|
|
354
|
+
if (existing?.isSymbolicLink() || (existing && !existing.isFile()))
|
|
355
|
+
throw new Error("Auth-broker presentation sidecar path is unsafe");
|
|
356
|
+
const temporary = `${this.#presentationPath}.${process.pid}.${randomUUID()}.tmp`;
|
|
357
|
+
try {
|
|
358
|
+
try {
|
|
359
|
+
await fs.writeFile(temporary, JSON.stringify(parsed), { flag: "wx", mode: 0o600 });
|
|
360
|
+
} catch (error) {
|
|
361
|
+
if (isEnoent(error)) return;
|
|
362
|
+
throw error;
|
|
363
|
+
}
|
|
364
|
+
await fs.chmod(temporary, 0o600).catch(() => {});
|
|
365
|
+
await fs.rename(temporary, this.#presentationPath).catch(error => {
|
|
366
|
+
if (!isEnoent(error)) throw error;
|
|
367
|
+
});
|
|
368
|
+
} finally {
|
|
369
|
+
await fs.unlink(temporary).catch(() => {});
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
this.#presentationWriteChain = next;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
get snapshot(): SnapshotResponse {
|
|
376
|
+
return this.#snapshot;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
getInventoryMetadataState(): Readonly<CredentialInventoryMetadataState> {
|
|
380
|
+
const records = this.#buildInventoryRecords().map(record => ({ ...record }));
|
|
381
|
+
const notice = this.#inventoryState.notice ? { ...this.#inventoryState.notice } : undefined;
|
|
382
|
+
return {
|
|
383
|
+
capability: this.#inventoryState.capability,
|
|
384
|
+
generation: this.#inventoryState.generation,
|
|
385
|
+
records,
|
|
386
|
+
...(notice ? { notice } : {}),
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
#applySnapshot(snapshot: SnapshotResponse, generation: number, scheduleMetadata = true): boolean {
|
|
391
|
+
// Broker generations are process-local and reset when the broker restarts.
|
|
392
|
+
// A lower generation from a newer server timestamp is therefore a new
|
|
393
|
+
// broker epoch, while an older timestamp is an out-of-order response from
|
|
394
|
+
// the current epoch and must be discarded.
|
|
395
|
+
if (this.#epoch) {
|
|
396
|
+
if (!snapshot.epoch) return false;
|
|
397
|
+
if (snapshot.epoch === this.#epoch) {
|
|
398
|
+
if (generation < this.#generation) return false;
|
|
399
|
+
if (generation === this.#generation && snapshot.serverNowMs < this.#snapshot.serverNowMs) return false;
|
|
400
|
+
} else {
|
|
401
|
+
if (this.#retiredEpochs.has(snapshot.epoch)) return false;
|
|
402
|
+
const currentRank = epochRank(this.#epoch);
|
|
403
|
+
const incomingRank = epochRank(snapshot.epoch);
|
|
404
|
+
// An unseen epoch is only safe to accept when both broker epochs carry
|
|
405
|
+
// the authoritative monotonic sequence prefix. Opaque epoch strings
|
|
406
|
+
// cannot prove that a delayed response is newer, so fail closed rather
|
|
407
|
+
// than letting it replace the current credential snapshot.
|
|
408
|
+
if (currentRank === undefined || incomingRank === undefined || incomingRank <= currentRank) return false;
|
|
409
|
+
this.#retiredEpochs.add(this.#epoch);
|
|
410
|
+
}
|
|
411
|
+
} else if (snapshot.epoch) {
|
|
412
|
+
// First epoch-bearing response establishes the authority namespace.
|
|
413
|
+
} else if (
|
|
414
|
+
(generation < this.#generation && snapshot.serverNowMs <= this.#snapshot.serverNowMs) ||
|
|
415
|
+
(generation === this.#generation && snapshot.serverNowMs < this.#snapshot.serverNowMs)
|
|
416
|
+
) {
|
|
417
|
+
// Epoch-less legacy brokers use serverNowMs as the restart discriminator:
|
|
418
|
+
// accept a reset generation only when the broker proves it is newer than
|
|
419
|
+
// the last accepted legacy snapshot, while delayed old responses fail closed.
|
|
420
|
+
if (
|
|
421
|
+
(generation < this.#generation && snapshot.serverNowMs <= this.#snapshot.serverNowMs) ||
|
|
422
|
+
(generation === this.#generation && snapshot.serverNowMs < this.#snapshot.serverNowMs)
|
|
423
|
+
)
|
|
424
|
+
return false;
|
|
425
|
+
}
|
|
426
|
+
const generationChanged =
|
|
427
|
+
generation !== this.#generation ||
|
|
428
|
+
snapshot.epoch !== this.#epoch ||
|
|
429
|
+
this.#inventoryMetadataGeneration !== generation;
|
|
430
|
+
this.#snapshot = snapshot;
|
|
431
|
+
this.#generation = generation;
|
|
432
|
+
if (!this.#loadingInitialSnapshot) this.#snapshotAuthoritative = true;
|
|
433
|
+
this.#epoch = snapshot.epoch ?? this.#epoch;
|
|
434
|
+
this.#snapshotReceivedAt = Date.now();
|
|
435
|
+
if (generationChanged) {
|
|
436
|
+
this.#inventoryMetadata.clear();
|
|
437
|
+
this.#inventoryMetadataGeneration = -1;
|
|
438
|
+
this.#invalidateUsageCache();
|
|
439
|
+
this.#reconcileUsagePresentations();
|
|
440
|
+
if (this.#inventoryMetadataUnsupported) {
|
|
441
|
+
this.#setInventoryState("unsupported", generation, {
|
|
442
|
+
status: "unsupported",
|
|
443
|
+
reason: "credential metadata endpoint unsupported; disabled rows unavailable",
|
|
444
|
+
generation,
|
|
445
|
+
});
|
|
446
|
+
} else {
|
|
447
|
+
this.#setInventoryState("pending", generation, {
|
|
448
|
+
status: "pending",
|
|
449
|
+
reason: "credential metadata sync pending",
|
|
450
|
+
generation,
|
|
451
|
+
});
|
|
452
|
+
if (scheduleMetadata) this.#scheduleInventoryMetadataSync();
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
if (this.#snapshotAuthoritative) {
|
|
456
|
+
this.#reconcilePersistedPresentations();
|
|
457
|
+
this.#hydratePresentations();
|
|
458
|
+
}
|
|
459
|
+
for (const listener of this.#snapshotListeners) listener();
|
|
460
|
+
return true;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* A full GET can race the SSE stream. Once a newer snapshot from the same
|
|
465
|
+
* broker epoch has been accepted, an older GET is merely superseded data —
|
|
466
|
+
* not an authority failure that should turn a healthy request into a 503.
|
|
467
|
+
* Epoch regressions remain rejected so a delayed response from a retired
|
|
468
|
+
* broker cannot cross an authority boundary.
|
|
469
|
+
*/
|
|
470
|
+
#isSupersededSnapshot(snapshot: SnapshotResponse, generation: number): boolean {
|
|
471
|
+
if (this.#epoch !== undefined || snapshot.epoch !== undefined) {
|
|
472
|
+
return (
|
|
473
|
+
snapshot.epoch === this.#epoch &&
|
|
474
|
+
(generation < this.#generation ||
|
|
475
|
+
(generation === this.#generation && snapshot.serverNowMs < this.#snapshot.serverNowMs))
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
return (
|
|
479
|
+
(generation < this.#generation && snapshot.serverNowMs <= this.#snapshot.serverNowMs) ||
|
|
480
|
+
(generation === this.#generation && snapshot.serverNowMs < this.#snapshot.serverNowMs)
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
onSnapshotChanged(listener: () => void): () => void {
|
|
485
|
+
this.#snapshotListeners.add(listener);
|
|
486
|
+
return () => this.#snapshotListeners.delete(listener);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
#withSnapshotAuthority<T>(operation: () => Promise<T>): Promise<T> {
|
|
490
|
+
const run = this.#snapshotAuthorityTail.then(operation);
|
|
491
|
+
this.#snapshotAuthorityTail = run.then(
|
|
492
|
+
() => undefined,
|
|
493
|
+
() => undefined,
|
|
494
|
+
);
|
|
495
|
+
return run;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Queue a provider-admission ticket behind all currently pending snapshot
|
|
500
|
+
* authority work. Snapshot applications that arrive after this ticket wait
|
|
501
|
+
* for its release, so a revocation is ordered either before admission or
|
|
502
|
+
* after it — never in the middle of the dispatch boundary.
|
|
503
|
+
*/
|
|
504
|
+
async acquireCredentialDispatchTicket(_provider: Provider, signal?: AbortSignal): Promise<CredentialDispatchTicket> {
|
|
505
|
+
const previous = this.#snapshotAuthorityTail;
|
|
506
|
+
const deferred = Promise.withResolvers<void>();
|
|
507
|
+
const ticketTail = previous.then(
|
|
508
|
+
() => deferred.promise,
|
|
509
|
+
() => deferred.promise,
|
|
510
|
+
);
|
|
511
|
+
this.#snapshotAuthorityTail = ticketTail;
|
|
512
|
+
let released = false;
|
|
513
|
+
const release = (): void => {
|
|
514
|
+
if (released) return;
|
|
515
|
+
released = true;
|
|
516
|
+
deferred.resolve();
|
|
517
|
+
};
|
|
518
|
+
try {
|
|
519
|
+
await this.#raceWithSignal(previous, signal);
|
|
520
|
+
} catch (error) {
|
|
521
|
+
release();
|
|
522
|
+
throw error;
|
|
523
|
+
}
|
|
524
|
+
return { release };
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
#setInventoryState(
|
|
528
|
+
capability: CredentialInventoryMetadataCapability,
|
|
529
|
+
generation: number,
|
|
530
|
+
notice?: CachedInventoryNotice,
|
|
531
|
+
): void {
|
|
532
|
+
const records = this.#buildInventoryRecords();
|
|
533
|
+
this.#inventoryState = {
|
|
534
|
+
capability,
|
|
535
|
+
generation,
|
|
536
|
+
records,
|
|
537
|
+
...(notice ? { notice } : {}),
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
#buildInventoryRecords(): CredentialInventoryRecord[] {
|
|
542
|
+
const metadataReady = this.#inventoryMetadataGeneration === this.#generation;
|
|
543
|
+
const records: CredentialInventoryRecord[] = [];
|
|
544
|
+
const seen = new Set<number>();
|
|
545
|
+
for (const entry of this.#snapshot.credentials) {
|
|
546
|
+
const metadata = metadataReady ? this.#inventoryMetadata.get(entry.id) : undefined;
|
|
547
|
+
seen.add(entry.id);
|
|
548
|
+
records.push({
|
|
549
|
+
id: entry.id,
|
|
550
|
+
provider: entry.provider,
|
|
551
|
+
credentialKind: metadata?.type ?? entry.credential.type,
|
|
552
|
+
identityLabel: metadata?.identity ?? snapshotIdentityLabel(entry),
|
|
553
|
+
disabled: false,
|
|
554
|
+
disabledCause: null,
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
if (metadataReady) {
|
|
558
|
+
for (const metadata of this.#inventoryMetadata.values()) {
|
|
559
|
+
if (seen.has(metadata.id) || metadata.disabledCause === null) continue;
|
|
560
|
+
records.push({
|
|
561
|
+
id: metadata.id,
|
|
562
|
+
provider: metadata.provider,
|
|
563
|
+
credentialKind: metadata.type,
|
|
564
|
+
identityLabel: metadata.identity,
|
|
565
|
+
disabled: true,
|
|
566
|
+
disabledCause: metadata.disabledCause,
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
return records;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
#scheduleInventoryMetadataSync(): void {
|
|
574
|
+
if (this.#closed || this.#inventoryMetadataUnsupported) return;
|
|
575
|
+
if (this.#inventorySyncInflight) return;
|
|
576
|
+
void this.syncInventoryMetadata().catch(() => {});
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
async syncInventoryMetadata(): Promise<Readonly<CredentialInventoryMetadataState>> {
|
|
580
|
+
if (this.#inventoryMetadataUnsupported) return this.getInventoryMetadataState();
|
|
581
|
+
if (this.#inventoryState.capability === "supported" && this.#inventoryMetadataGeneration === this.#generation) {
|
|
582
|
+
return this.getInventoryMetadataState();
|
|
583
|
+
}
|
|
584
|
+
if (this.#inventorySyncInflight) return this.#inventorySyncInflight;
|
|
585
|
+
const inflight = this.#syncInventoryMetadata().finally(() => {
|
|
586
|
+
this.#inventorySyncInflight = undefined;
|
|
587
|
+
});
|
|
588
|
+
this.#inventorySyncInflight = inflight;
|
|
589
|
+
return inflight;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
async #syncInventoryMetadata(): Promise<Readonly<CredentialInventoryMetadataState>> {
|
|
593
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
594
|
+
try {
|
|
595
|
+
const metadata = await this.#client.fetchCredentialMetadata();
|
|
596
|
+
if (metadata.epoch !== this.#epoch) {
|
|
597
|
+
if (attempt === 0) {
|
|
598
|
+
await this.refreshSnapshot().catch(() => {});
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
this.#inventoryMetadata.clear();
|
|
602
|
+
this.#inventoryMetadataGeneration = -1;
|
|
603
|
+
this.#setInventoryState("mismatch", this.#generation, {
|
|
604
|
+
status: "mismatch",
|
|
605
|
+
reason: "credential metadata epoch mismatch",
|
|
606
|
+
generation: this.#generation,
|
|
607
|
+
});
|
|
608
|
+
return this.getInventoryMetadataState();
|
|
609
|
+
}
|
|
610
|
+
if (metadata.generation !== this.#generation) {
|
|
611
|
+
if (attempt === 0) {
|
|
612
|
+
await this.refreshSnapshot().catch(() => {});
|
|
613
|
+
continue;
|
|
614
|
+
}
|
|
615
|
+
this.#inventoryMetadata.clear();
|
|
616
|
+
this.#inventoryMetadataGeneration = -1;
|
|
617
|
+
this.#setInventoryState("mismatch", this.#generation, {
|
|
618
|
+
status: "mismatch",
|
|
619
|
+
reason: "credential metadata generation mismatch",
|
|
620
|
+
generation: this.#generation,
|
|
621
|
+
});
|
|
622
|
+
return this.getInventoryMetadataState();
|
|
623
|
+
}
|
|
624
|
+
this.#inventoryMetadata = new Map(metadata.credentials.map(record => [record.id, { ...record }]));
|
|
625
|
+
this.#inventoryMetadataGeneration = metadata.generation;
|
|
626
|
+
this.#setInventoryState("supported", metadata.generation);
|
|
627
|
+
this.#reconcileUsagePresentations();
|
|
628
|
+
return this.getInventoryMetadataState();
|
|
629
|
+
} catch (error) {
|
|
630
|
+
if (error instanceof AuthBrokerCredentialMetadataUnsupportedError || isErrorStatus(error, 404)) {
|
|
631
|
+
this.#inventoryMetadataUnsupported = true;
|
|
632
|
+
this.#inventoryMetadata.clear();
|
|
633
|
+
this.#inventoryMetadataGeneration = -1;
|
|
634
|
+
this.#setInventoryState("unsupported", this.#generation, {
|
|
635
|
+
status: "unsupported",
|
|
636
|
+
reason: "credential metadata endpoint unsupported; disabled rows unavailable",
|
|
637
|
+
generation: this.#generation,
|
|
638
|
+
});
|
|
639
|
+
return this.getInventoryMetadataState();
|
|
640
|
+
}
|
|
641
|
+
this.#setInventoryState("failed", this.#generation, {
|
|
642
|
+
status: "failed",
|
|
643
|
+
reason: "credential metadata sync failed; retry explicitly",
|
|
644
|
+
generation: this.#generation,
|
|
645
|
+
});
|
|
646
|
+
return this.getInventoryMetadataState();
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
this.#setInventoryState("mismatch", this.#generation, {
|
|
650
|
+
status: "mismatch",
|
|
651
|
+
reason: "credential metadata generation mismatch",
|
|
652
|
+
generation: this.#generation,
|
|
653
|
+
});
|
|
654
|
+
return this.getInventoryMetadataState();
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
async #runBackground(): Promise<void> {
|
|
658
|
+
let backoffMs = BACKGROUND_BACKOFF_INITIAL_MS;
|
|
659
|
+
while (!this.#closed && !this.#backgroundAbort.signal.aborted) {
|
|
660
|
+
if (this.#streamSnapshots && !this.#streamingUnsupported) {
|
|
661
|
+
try {
|
|
662
|
+
await this.#consumeSnapshotStream();
|
|
663
|
+
backoffMs = BACKGROUND_BACKOFF_INITIAL_MS;
|
|
664
|
+
continue;
|
|
665
|
+
} catch (error) {
|
|
666
|
+
if (this.#closed || this.#backgroundAbort.signal.aborted) break;
|
|
667
|
+
if (error instanceof AuthBrokerStreamUnsupportedError) {
|
|
668
|
+
this.#streamingUnsupported = true;
|
|
669
|
+
logger.debug("auth-broker snapshot stream unsupported; falling back to long-poll");
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
logger.debug("auth-broker snapshot stream failed; backing off", { error: String(error) });
|
|
673
|
+
await scheduler.wait(backoffMs, { signal: this.#backgroundAbort.signal }).catch(() => {});
|
|
674
|
+
backoffMs = Math.min(BACKGROUND_BACKOFF_MAX_MS, backoffMs * 2);
|
|
675
|
+
continue;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
try {
|
|
679
|
+
const result = await this.#client.fetchSnapshot({
|
|
680
|
+
ifGenerationGt: this.#generation,
|
|
681
|
+
ifEpoch: this.#epoch,
|
|
682
|
+
waitMs: BACKGROUND_WAIT_MS,
|
|
683
|
+
signal: this.#backgroundAbort.signal,
|
|
684
|
+
});
|
|
685
|
+
if (result.status === 200) {
|
|
686
|
+
await this.#withSnapshotAuthority(async () => {
|
|
687
|
+
if (!this.#applySnapshot(result.snapshot, result.generation)) {
|
|
688
|
+
if (!this.#isSupersededSnapshot(result.snapshot, result.generation)) {
|
|
689
|
+
throw new Error("Auth broker background snapshot authority was rejected");
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
backoffMs = BACKGROUND_BACKOFF_INITIAL_MS;
|
|
695
|
+
} catch (error) {
|
|
696
|
+
if (this.#closed || this.#backgroundAbort.signal.aborted) break;
|
|
697
|
+
logger.debug("auth-broker background snapshot sync failed", { error: String(error) });
|
|
698
|
+
await scheduler.wait(backoffMs, { signal: this.#backgroundAbort.signal }).catch(() => {});
|
|
699
|
+
backoffMs = Math.min(BACKGROUND_BACKOFF_MAX_MS, backoffMs * 2);
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
async #consumeSnapshotStream(): Promise<void> {
|
|
705
|
+
const iterator = this.#client.openSnapshotStream({ signal: this.#backgroundAbort.signal });
|
|
706
|
+
try {
|
|
707
|
+
for await (const event of iterator) {
|
|
708
|
+
if (this.#closed || this.#backgroundAbort.signal.aborted) break;
|
|
709
|
+
this.#streamingActive = true;
|
|
710
|
+
await this.#applyStreamEvent(event);
|
|
711
|
+
}
|
|
712
|
+
} finally {
|
|
713
|
+
this.#streamingActive = false;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
async #applyStreamEvent(event: SnapshotStreamEvent): Promise<void> {
|
|
718
|
+
await this.#withSnapshotAuthority(async () => {
|
|
719
|
+
switch (event.kind) {
|
|
720
|
+
case "snapshot": {
|
|
721
|
+
// Strip the discriminator so we store the wire-shape SnapshotResponse.
|
|
722
|
+
const { kind: _kind, ...snapshot } = event;
|
|
723
|
+
if (!this.#applySnapshot(snapshot, snapshot.generation)) {
|
|
724
|
+
if (!this.#isSupersededSnapshot(snapshot, snapshot.generation)) {
|
|
725
|
+
throw new Error("Auth broker stream snapshot authority was rejected");
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
case "entry": {
|
|
731
|
+
const applied = this.#applyStreamEntry(
|
|
732
|
+
event.entry,
|
|
733
|
+
event.refresher,
|
|
734
|
+
event.generation,
|
|
735
|
+
event.serverNowMs,
|
|
736
|
+
event.epoch,
|
|
737
|
+
);
|
|
738
|
+
if (!applied && !this.#isSupersededStreamEvent(event)) {
|
|
739
|
+
throw new Error("Auth broker stream entry authority was rejected");
|
|
740
|
+
}
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
case "removed": {
|
|
744
|
+
const applied = this.#removeStreamCredential(
|
|
745
|
+
event.id,
|
|
746
|
+
event.refresher,
|
|
747
|
+
event.generation,
|
|
748
|
+
event.serverNowMs,
|
|
749
|
+
event.epoch,
|
|
750
|
+
);
|
|
751
|
+
if (!applied && !this.#isSupersededStreamEvent(event)) {
|
|
752
|
+
throw new Error("Auth broker stream removal authority was rejected");
|
|
753
|
+
}
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
#isSupersededStreamEvent(event: SnapshotStreamEvent): boolean {
|
|
761
|
+
return this.#isSupersededSnapshot(
|
|
762
|
+
{ ...this.#snapshot, epoch: event.epoch, generation: event.generation, serverNowMs: event.serverNowMs },
|
|
763
|
+
event.generation,
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
#applyStreamEntry(
|
|
768
|
+
entry: SnapshotEntry,
|
|
769
|
+
refresher: RefresherSchedule,
|
|
770
|
+
generation: number,
|
|
771
|
+
serverNowMs: number,
|
|
772
|
+
epoch?: string,
|
|
773
|
+
): boolean {
|
|
774
|
+
if (!epoch && this.#epoch && epochRank(this.#epoch) !== undefined) return false;
|
|
775
|
+
const index = this.#snapshot.credentials.findIndex(candidate => candidate.id === entry.id);
|
|
776
|
+
const credentials =
|
|
777
|
+
index === -1
|
|
778
|
+
? [...this.#snapshot.credentials, entry]
|
|
779
|
+
: this.#snapshot.credentials.map((candidate, i) => (i === index ? entry : candidate));
|
|
780
|
+
return this.#applySnapshot(
|
|
781
|
+
{ ...this.#snapshot, epoch, generation, serverNowMs, refresher, credentials },
|
|
782
|
+
generation,
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
#removeStreamCredential(
|
|
787
|
+
id: number,
|
|
788
|
+
refresher: RefresherSchedule,
|
|
789
|
+
generation: number,
|
|
790
|
+
serverNowMs: number,
|
|
791
|
+
epoch?: string,
|
|
792
|
+
): boolean {
|
|
793
|
+
if (!epoch && this.#epoch && epochRank(this.#epoch) !== undefined) return false;
|
|
794
|
+
const credentials = this.#snapshot.credentials.filter(entry => entry.id !== id);
|
|
795
|
+
return this.#applySnapshot(
|
|
796
|
+
{ ...this.#snapshot, epoch, generation, serverNowMs, refresher, credentials },
|
|
797
|
+
generation,
|
|
798
|
+
);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* Payload-free inventory view. This method never performs network I/O; metadata
|
|
803
|
+
* rows appear only after an explicit or background metadata synchronization for
|
|
804
|
+
* the current snapshot generation.
|
|
805
|
+
*/
|
|
806
|
+
listCredentialInventory(provider?: string): CredentialInventoryRecord[] {
|
|
807
|
+
return this.#buildInventoryRecords()
|
|
808
|
+
.filter(record => provider === undefined || record.provider === provider)
|
|
809
|
+
.map(record => ({ ...record }));
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/** Re-hydrate the in-memory snapshot from the broker. */
|
|
813
|
+
async refreshSnapshot(signal?: AbortSignal): Promise<SnapshotResponse> {
|
|
814
|
+
const result = await this.#client.fetchSnapshot({ signal });
|
|
815
|
+
if (result.status === 200) {
|
|
816
|
+
await this.#raceWithSignal(
|
|
817
|
+
this.#withSnapshotAuthority(async () => {
|
|
818
|
+
if (!this.#applySnapshot(result.snapshot, result.generation)) {
|
|
819
|
+
if (!this.#isSupersededSnapshot(result.snapshot, result.generation)) {
|
|
820
|
+
throw new Error("Auth broker snapshot authority was rejected");
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
}),
|
|
824
|
+
signal,
|
|
825
|
+
);
|
|
826
|
+
}
|
|
827
|
+
return this.#snapshot;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
listAuthCredentials(provider?: string): StoredAuthCredential[] {
|
|
831
|
+
const out: StoredAuthCredential[] = [];
|
|
832
|
+
for (const entry of this.#snapshot.credentials) {
|
|
833
|
+
if (provider !== undefined && entry.provider !== provider) continue;
|
|
834
|
+
out.push({
|
|
835
|
+
id: entry.id,
|
|
836
|
+
provider: entry.provider,
|
|
837
|
+
credential: entry.credential as AuthCredential,
|
|
838
|
+
disabledCause: null,
|
|
839
|
+
...(entry.revision === undefined ? {} : { revision: entry.revision }),
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
return out;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* In-memory update from a successful refresh through the broker. AuthStorage
|
|
847
|
+
* calls this after `#replaceCredentialAt`; the broker already persisted the
|
|
848
|
+
* authoritative row, so we just mirror it.
|
|
849
|
+
*/
|
|
850
|
+
updateAuthCredential(id: number, credential: AuthCredential): void {
|
|
851
|
+
void id;
|
|
852
|
+
void credential;
|
|
853
|
+
throw new Error("Remote auth-broker credentials must be updated through broker authority");
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
deleteAuthCredential(_id: number, _disabledCause: string): void {
|
|
857
|
+
throw new Error("Remote auth-broker credentials can only be disabled on the broker host");
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
allocateMonotonicSequence(_key: string, _expiresAtSec: number): number {
|
|
861
|
+
throw new Error("Remote auth-broker credentials cannot allocate broker incarnation sequences");
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
tryDisableAuthCredentialIfMatches(_id: number, _expectedData: string, _disabledCause: string): boolean {
|
|
865
|
+
return false;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/** Disable one credential through the authenticated broker mutation endpoint. */
|
|
869
|
+
async disableAuthCredentialRemote(
|
|
870
|
+
credentialId: number,
|
|
871
|
+
disabledCause: string,
|
|
872
|
+
signal?: AbortSignal,
|
|
873
|
+
expectedRevision?: number,
|
|
874
|
+
): Promise<boolean> {
|
|
875
|
+
try {
|
|
876
|
+
await this.#client.disableCredential(credentialId, disabledCause, signal, expectedRevision);
|
|
877
|
+
} catch (error) {
|
|
878
|
+
if (!isErrorStatus(error, 404)) throw error;
|
|
879
|
+
// A peer may have disabled the row first. Reconcile the local mirror so
|
|
880
|
+
// AuthStorage can select a fallback without ever mutating remote state
|
|
881
|
+
// through the read-only synchronous methods.
|
|
882
|
+
await this.refreshSnapshot().catch(refreshError => {
|
|
883
|
+
logger.debug("auth-broker snapshot refresh after remote disable miss failed", {
|
|
884
|
+
error: String(refreshError),
|
|
885
|
+
});
|
|
886
|
+
});
|
|
887
|
+
return false;
|
|
888
|
+
}
|
|
889
|
+
this.#removeCredentialEntry(credentialId);
|
|
890
|
+
this.#maybeRefreshSnapshot("disable");
|
|
891
|
+
return true;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
async waitForFreshSnapshot(maxWaitMs: number, opts: { signal?: AbortSignal } = {}): Promise<boolean> {
|
|
895
|
+
const previousGeneration = this.#generation;
|
|
896
|
+
const previousEpoch = this.#epoch;
|
|
897
|
+
const result = await this.#client.fetchSnapshot({
|
|
898
|
+
ifGenerationGt: this.#generation,
|
|
899
|
+
ifEpoch: this.#epoch,
|
|
900
|
+
waitMs: maxWaitMs,
|
|
901
|
+
signal: opts.signal,
|
|
902
|
+
});
|
|
903
|
+
if (result.status === 200) {
|
|
904
|
+
await this.#withSnapshotAuthority(async () => {
|
|
905
|
+
if (!this.#applySnapshot(result.snapshot, result.generation)) {
|
|
906
|
+
if (!this.#isSupersededSnapshot(result.snapshot, result.generation)) {
|
|
907
|
+
throw new Error("Auth broker freshness snapshot authority was rejected");
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
return this.#generation !== previousGeneration || this.#epoch !== previousEpoch;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
async prepareForRequest(credentialId: number, opts: { signal?: AbortSignal } = {}): Promise<boolean> {
|
|
916
|
+
const entry = this.#snapshot.credentials.find(candidate => candidate.id === credentialId);
|
|
917
|
+
if (entry?.credential.type !== "oauth" || entry.rotatesInMs === null) return false;
|
|
918
|
+
const remainingMs = this.#snapshotReceivedAt + entry.rotatesInMs - Date.now();
|
|
919
|
+
if (remainingMs > WAIT_THRESHOLD_MS) return false;
|
|
920
|
+
return this.waitForFreshSnapshot(MAX_WAIT_MS, opts);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
async markCredentialSuspect(credentialId: number, opts: { signal?: AbortSignal } = {}): Promise<void> {
|
|
924
|
+
const current = this.#snapshot.credentials.find(entry => entry.id === credentialId);
|
|
925
|
+
if (current?.credential.type !== "oauth") {
|
|
926
|
+
// API-key rows have no refresh token, and a stale id may already have
|
|
927
|
+
// disappeared. A 401 means the broker may have rotated or removed the
|
|
928
|
+
// row; refresh the authoritative snapshot so the caller can reselect a
|
|
929
|
+
// live key instead of hitting the OAuth endpoint.
|
|
930
|
+
await this.refreshSnapshot(opts.signal);
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
await this.#client.refreshCredential(credentialId, opts.signal);
|
|
934
|
+
await this.refreshSnapshot(opts.signal);
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
replaceAuthCredentialsForProvider(_provider: string, _credentials: AuthCredential[]): StoredAuthCredential[] {
|
|
938
|
+
throw new Error(
|
|
939
|
+
"RemoteAuthCredentialStore is read-only on the client. Use `vib auth-broker login <provider>` to mutate credentials.",
|
|
940
|
+
);
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
upsertAuthCredentialForProvider(_provider: string, _credential: AuthCredential): StoredAuthCredential[] {
|
|
944
|
+
throw new Error(
|
|
945
|
+
"RemoteAuthCredentialStore is read-only on the client. Use `vib auth-broker login <provider>` to mutate credentials.",
|
|
946
|
+
);
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
upsertAuthCredentialForProviderIfAbsent(
|
|
950
|
+
_provider: string,
|
|
951
|
+
_credential: AuthCredential,
|
|
952
|
+
): AuthCredentialIfAbsentResult {
|
|
953
|
+
throw new Error(
|
|
954
|
+
"RemoteAuthCredentialStore is read-only on the client. Use `vib auth-broker login <provider>` to mutate credentials.",
|
|
955
|
+
);
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
deleteAuthCredentialsForProvider(_provider: string, _disabledCause: string): void {
|
|
959
|
+
throw new Error(
|
|
960
|
+
"RemoteAuthCredentialStore is read-only on the client. Use `vib auth-broker logout <provider>` to mutate credentials.",
|
|
961
|
+
);
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/** Logout authority remains on the broker; the client only mirrors its result. */
|
|
965
|
+
async deleteAuthCredentialsRemote(provider: string, disabledCause: string): Promise<void> {
|
|
966
|
+
const existing = this.listAuthCredentials(provider);
|
|
967
|
+
for (const entry of existing) await this.#client.disableCredential(entry.id, disabledCause);
|
|
968
|
+
await this.refreshSnapshot();
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/**
|
|
972
|
+
* Upsert a single credential through the broker. The broker server is the
|
|
973
|
+
* canonical writer — see `POST /v1/credential`. The redacted snapshot
|
|
974
|
+
* entries returned by the server replace the provider's rows in our local
|
|
975
|
+
* snapshot, and the global snapshot is then refreshed in the background so
|
|
976
|
+
* any concurrent peer (refresh, generation bump) stays in sync.
|
|
977
|
+
*/
|
|
978
|
+
async upsertAuthCredentialRemote(provider: string, credential: AuthCredential): Promise<StoredAuthCredential[]> {
|
|
979
|
+
await this.#client.uploadCredential(provider, credential);
|
|
980
|
+
await this.refreshSnapshot();
|
|
981
|
+
return this.listAuthCredentials(provider);
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
async upsertAuthCredentialRemoteIfAbsent(
|
|
985
|
+
provider: string,
|
|
986
|
+
credential: AuthCredential,
|
|
987
|
+
): Promise<AuthCredentialIfAbsentResult> {
|
|
988
|
+
const { inserted, reason } = await this.#client.uploadCredentialIfAbsent(provider, credential);
|
|
989
|
+
await this.refreshSnapshot();
|
|
990
|
+
return { inserted, reason, provider, entries: this.listAuthCredentials(provider) };
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
/**
|
|
994
|
+
* Replace-all semantics: disable every active credential for the provider,
|
|
995
|
+
* then upload each of the new credentials. Used by API-key login so a new
|
|
996
|
+
* key clobbers any previously stored key for the same provider.
|
|
997
|
+
*/
|
|
998
|
+
async replaceAuthCredentialsRemote(
|
|
999
|
+
provider: string,
|
|
1000
|
+
credentials: AuthCredential[],
|
|
1001
|
+
): Promise<StoredAuthCredential[]> {
|
|
1002
|
+
const existing = this.listAuthCredentials(provider);
|
|
1003
|
+
for (const entry of existing) {
|
|
1004
|
+
await this.#client.disableCredential(entry.id, "replaced by newer credential");
|
|
1005
|
+
}
|
|
1006
|
+
await this.refreshSnapshot();
|
|
1007
|
+
for (const credential of credentials) {
|
|
1008
|
+
await this.#client.uploadCredential(provider, credential);
|
|
1009
|
+
await this.refreshSnapshot();
|
|
1010
|
+
}
|
|
1011
|
+
this.#maybeRefreshSnapshot("replace");
|
|
1012
|
+
return this.listAuthCredentials(provider);
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
#removeCredentialEntry(credentialId: number): void {
|
|
1016
|
+
const credentials = this.#snapshot.credentials.filter(entry => entry.id !== credentialId);
|
|
1017
|
+
if (credentials.length === this.#snapshot.credentials.length) return;
|
|
1018
|
+
this.#applySnapshot({ ...this.#snapshot, credentials }, this.#generation, false);
|
|
1019
|
+
}
|
|
1020
|
+
/**
|
|
1021
|
+
* Fire-and-forget `refreshSnapshot()` after a write. When the SSE stream is
|
|
1022
|
+
* active the broker will deliver the new generation push, so the extra GET
|
|
1023
|
+
* is wasted bandwidth and we skip it.
|
|
1024
|
+
*/
|
|
1025
|
+
#maybeRefreshSnapshot(reason: string): void {
|
|
1026
|
+
if (this.#streamingActive) return;
|
|
1027
|
+
void this.refreshSnapshot().catch(error => {
|
|
1028
|
+
logger.debug("auth-broker snapshot refresh after write failed", { reason, error: String(error) });
|
|
1029
|
+
});
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
getCache(key: string): string | null {
|
|
1033
|
+
const entry = this.#cache.get(key);
|
|
1034
|
+
if (!entry) return null;
|
|
1035
|
+
if (entry.expiresAtSec * 1000 <= Date.now()) {
|
|
1036
|
+
this.#cache.delete(key);
|
|
1037
|
+
return null;
|
|
1038
|
+
}
|
|
1039
|
+
return entry.value;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
setCache(key: string, value: string, expiresAtSec: number): void {
|
|
1043
|
+
let persistedValue = value;
|
|
1044
|
+
if (key.startsWith("account_health:v1:source:")) {
|
|
1045
|
+
try {
|
|
1046
|
+
const parsed = JSON.parse(value) as { reason?: unknown } & Record<string, unknown>;
|
|
1047
|
+
persistedValue = JSON.stringify({ ...parsed, reason: safePresentationReason(parsed.reason) });
|
|
1048
|
+
} catch {
|
|
1049
|
+
persistedValue = JSON.stringify({ status: "unknown", reason: null });
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
const entry = { value: persistedValue, expiresAtSec };
|
|
1053
|
+
this.#cache.set(key, entry);
|
|
1054
|
+
if (key.startsWith("account_health:v1:source:")) {
|
|
1055
|
+
this.#persistedSourceHealth[this.#presentationAuthority] = {
|
|
1056
|
+
...(this.#persistedSourceHealth[this.#presentationAuthority] ?? {}),
|
|
1057
|
+
[key]: entry,
|
|
1058
|
+
};
|
|
1059
|
+
this.#queuePresentationWrite();
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
cleanExpiredCache(): void {
|
|
1064
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
1065
|
+
for (const [key, entry] of this.#cache) {
|
|
1066
|
+
if (entry.expiresAtSec <= nowSec) this.#cache.delete(key);
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
deleteCachePrefix(prefix: string): void {
|
|
1071
|
+
for (const key of this.#cache.keys()) {
|
|
1072
|
+
if (key.startsWith(prefix)) this.#cache.delete(key);
|
|
1073
|
+
}
|
|
1074
|
+
if (prefix.startsWith("usage_cache:")) this.#invalidateUsageCache();
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
#invalidateUsageCache(): void {
|
|
1078
|
+
this.#usageCache = undefined;
|
|
1079
|
+
this.#usageInflight = undefined;
|
|
1080
|
+
this.#scopedUsageCache.clear();
|
|
1081
|
+
this.#scopedUsageInflight.clear();
|
|
1082
|
+
this.#scopedUsageFailures.clear();
|
|
1083
|
+
this.#usageCacheEpoch += 1;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/**
|
|
1087
|
+
* Store-level hook consumed by `AuthStorage` — routes refresh through the
|
|
1088
|
+
* broker so the actual refresh token never leaves the broker host. Returns
|
|
1089
|
+
* the broker-redacted credential with {@link REMOTE_REFRESH_SENTINEL} in
|
|
1090
|
+
* the `refresh` slot.
|
|
1091
|
+
*/
|
|
1092
|
+
async refreshOAuthCredential(
|
|
1093
|
+
_provider: Provider,
|
|
1094
|
+
credentialId: number,
|
|
1095
|
+
_credential: OAuthCredential,
|
|
1096
|
+
signal?: AbortSignal,
|
|
1097
|
+
): Promise<OAuthCredentials> {
|
|
1098
|
+
try {
|
|
1099
|
+
await this.#client.refreshCredential(credentialId, signal);
|
|
1100
|
+
} catch (error) {
|
|
1101
|
+
if (isErrorStatus(error, 404) && !this.#streamingActive) {
|
|
1102
|
+
await this.refreshSnapshot().catch(refreshError => {
|
|
1103
|
+
logger.debug("auth-broker snapshot refresh after missing credential refresh failed", {
|
|
1104
|
+
error: String(refreshError),
|
|
1105
|
+
});
|
|
1106
|
+
});
|
|
1107
|
+
}
|
|
1108
|
+
throw error;
|
|
1109
|
+
}
|
|
1110
|
+
await this.refreshSnapshot(signal);
|
|
1111
|
+
const accepted = this.#snapshot.credentials.find(candidate => candidate.id === credentialId);
|
|
1112
|
+
if (accepted?.credential.type !== "oauth") {
|
|
1113
|
+
throw new Error(`Broker snapshot no longer contains OAuth credential id=${credentialId}`);
|
|
1114
|
+
}
|
|
1115
|
+
const refreshed = accepted.credential;
|
|
1116
|
+
return {
|
|
1117
|
+
access: refreshed.access,
|
|
1118
|
+
refresh: REMOTE_REFRESH_SENTINEL,
|
|
1119
|
+
expires: refreshed.expires,
|
|
1120
|
+
accountId: refreshed.accountId,
|
|
1121
|
+
email: refreshed.email,
|
|
1122
|
+
projectId: refreshed.projectId,
|
|
1123
|
+
enterpriseUrl: refreshed.enterpriseUrl,
|
|
1124
|
+
};
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
async refreshMCPOAuthCredential(
|
|
1128
|
+
credentialId: number,
|
|
1129
|
+
credential: OAuthCredential,
|
|
1130
|
+
client: MCPOAuthRefreshClient,
|
|
1131
|
+
signal?: AbortSignal,
|
|
1132
|
+
): Promise<OAuthCredential> {
|
|
1133
|
+
await this.#client.refreshMCPCredential(credentialId, client, signal);
|
|
1134
|
+
await this.refreshSnapshot(signal);
|
|
1135
|
+
const accepted = this.#snapshot.credentials.find(candidate => candidate.id === credentialId);
|
|
1136
|
+
if (accepted?.credential.type !== "oauth") {
|
|
1137
|
+
throw new Error(`Broker snapshot no longer contains OAuth credential id=${credentialId}`);
|
|
1138
|
+
}
|
|
1139
|
+
assertCanonicalMCPOAuthBinding(credential.mcpBinding);
|
|
1140
|
+
assertCanonicalMCPOAuthBinding(accepted.credential.mcpBinding);
|
|
1141
|
+
if (
|
|
1142
|
+
accepted.credential.mcpBinding.resourceOrigin !== credential.mcpBinding.resourceOrigin ||
|
|
1143
|
+
accepted.credential.mcpBinding.tokenEndpoint !== credential.mcpBinding.tokenEndpoint
|
|
1144
|
+
) {
|
|
1145
|
+
throw new Error("Broker returned mismatched MCP OAuth credential binding");
|
|
1146
|
+
}
|
|
1147
|
+
return accepted.credential;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
/**
|
|
1151
|
+
* Store-level hook consumed by `AuthStorage.fetchUsageReports()` — proxies
|
|
1152
|
+
* to the broker's `/v1/usage` endpoint. The broker's egress IP isn't
|
|
1153
|
+
* rate-limited by Anthropic's per-IP `/usage` cap the way a heavy
|
|
1154
|
+
* residential laptop is, so all credentials surface every cycle.
|
|
1155
|
+
*/
|
|
1156
|
+
async fetchUsageReports(signal?: AbortSignal): Promise<UsageReport[] | null> {
|
|
1157
|
+
return this.#raceWithSignal(this.#loadUsageReports(), signal);
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
async fetchUsageReportsForProvider(provider: Provider, signal?: AbortSignal): Promise<UsageReport[] | null> {
|
|
1161
|
+
return this.#raceWithSignal(this.#loadUsageReports(provider), signal);
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
/** Synchronous, zero-network usage presentation read. */
|
|
1165
|
+
peekCachedUsagePresentation(provider: Provider, credentialId: number): CachedUsagePresentation | undefined {
|
|
1166
|
+
const entry = this.#snapshot.credentials.find(candidate => candidate.id === credentialId);
|
|
1167
|
+
if (!entry || entry.provider !== provider) return undefined;
|
|
1168
|
+
const cached = this.#usagePresentations.get(credentialId);
|
|
1169
|
+
if (!cached) return undefined;
|
|
1170
|
+
const now = Date.now();
|
|
1171
|
+
if (cached.retainUntil <= now) {
|
|
1172
|
+
this.#usagePresentations.delete(credentialId);
|
|
1173
|
+
return undefined;
|
|
1174
|
+
}
|
|
1175
|
+
const identityDigest = this.#identityDigestForEntry(entry);
|
|
1176
|
+
if (
|
|
1177
|
+
cached.provider !== provider ||
|
|
1178
|
+
cached.inventoryGeneration !== this.#generation ||
|
|
1179
|
+
cached.identityDigest !== identityDigest
|
|
1180
|
+
) {
|
|
1181
|
+
this.#usagePresentations.delete(credentialId);
|
|
1182
|
+
return undefined;
|
|
1183
|
+
}
|
|
1184
|
+
return cloneUsagePresentation(cached);
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
/** Synchronous, zero-network health presentation read backed by the redacted sidecar. */
|
|
1188
|
+
peekCachedCredentialHealth(provider: Provider, credentialId: number): CachedCredentialHealth | undefined {
|
|
1189
|
+
const entry = this.#snapshot.credentials.find(candidate => candidate.id === credentialId);
|
|
1190
|
+
if (!entry || entry.provider !== provider) return undefined;
|
|
1191
|
+
const key = presentationRecordKey(provider, this.#identityDigestForEntry(entry));
|
|
1192
|
+
const health = this.#persistedPresentations.get(key)?.health;
|
|
1193
|
+
if (!health || health.retainUntil <= Date.now()) {
|
|
1194
|
+
if (health) {
|
|
1195
|
+
const record = this.#persistedPresentations.get(key);
|
|
1196
|
+
if (record) {
|
|
1197
|
+
delete record.health;
|
|
1198
|
+
if (!record.usage) this.#persistedPresentations.delete(key);
|
|
1199
|
+
this.#queuePresentationWrite();
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
return undefined;
|
|
1203
|
+
}
|
|
1204
|
+
return {
|
|
1205
|
+
status: health.status,
|
|
1206
|
+
reason: health.reason,
|
|
1207
|
+
...(health.checkedAt === undefined ? {} : { checkedAt: health.checkedAt }),
|
|
1208
|
+
retainUntil: health.retainUntil,
|
|
1209
|
+
};
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
/** Persist a safe health result without credential or bearer-token material. */
|
|
1213
|
+
recordCredentialHealth(provider: Provider, credentialId: number, health: CachedCredentialHealth): void {
|
|
1214
|
+
if (health.status === "unknown" || !health.retainUntil || health.retainUntil <= Date.now()) return;
|
|
1215
|
+
const entry = this.#snapshot.credentials.find(candidate => candidate.id === credentialId);
|
|
1216
|
+
if (!entry || entry.provider !== provider) return;
|
|
1217
|
+
const identityDigest = this.#identityDigestForEntry(entry);
|
|
1218
|
+
const key = presentationRecordKey(provider, identityDigest);
|
|
1219
|
+
const record = this.#persistedPresentations.get(key) ?? { credentialId, provider, identityDigest };
|
|
1220
|
+
record.health = {
|
|
1221
|
+
v: 1,
|
|
1222
|
+
status: health.status,
|
|
1223
|
+
reason: safePresentationReason(health.reason),
|
|
1224
|
+
...(health.checkedAt === undefined ? {} : { checkedAt: health.checkedAt }),
|
|
1225
|
+
retainUntil: health.retainUntil,
|
|
1226
|
+
};
|
|
1227
|
+
this.#persistedPresentations.set(key, record);
|
|
1228
|
+
this.#queuePresentationWrite();
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
/** Record a safe usage observation after an explicit broker usage/check call. */
|
|
1232
|
+
recordUsagePresentation(observation: CachedUsagePresentation): void {
|
|
1233
|
+
if (!Number.isInteger(observation.credentialId)) return;
|
|
1234
|
+
if (!Number.isFinite(observation.inventoryGeneration) || observation.inventoryGeneration !== this.#generation)
|
|
1235
|
+
return;
|
|
1236
|
+
if (!Number.isFinite(observation.fetchedAt) || !Number.isFinite(observation.freshUntil)) return;
|
|
1237
|
+
if (!Number.isFinite(observation.retainUntil) || observation.retainUntil <= observation.fetchedAt) return;
|
|
1238
|
+
const entry = this.#snapshot.credentials.find(candidate => candidate.id === observation.credentialId);
|
|
1239
|
+
if (!entry || entry.provider !== observation.provider) return;
|
|
1240
|
+
if (this.#identityDigestForEntry(entry) !== observation.identityDigest) return;
|
|
1241
|
+
const usage = safePresentationUsageReport(observation.usage);
|
|
1242
|
+
const stored = cloneUsagePresentation({
|
|
1243
|
+
credentialId: observation.credentialId,
|
|
1244
|
+
provider: observation.provider,
|
|
1245
|
+
inventoryGeneration: observation.inventoryGeneration,
|
|
1246
|
+
identityDigest: observation.identityDigest,
|
|
1247
|
+
usage,
|
|
1248
|
+
fetchedAt: observation.fetchedAt,
|
|
1249
|
+
freshUntil: Math.min(observation.freshUntil, observation.fetchedAt + PRESENTATION_FRESH_MS),
|
|
1250
|
+
retainUntil: Math.min(observation.retainUntil, observation.fetchedAt + PRESENTATION_RETENTION_MS),
|
|
1251
|
+
});
|
|
1252
|
+
this.#usagePresentations.set(observation.credentialId, stored);
|
|
1253
|
+
const key = presentationRecordKey(observation.provider, observation.identityDigest);
|
|
1254
|
+
const persisted = this.#persistedPresentations.get(key) ?? {
|
|
1255
|
+
credentialId: observation.credentialId,
|
|
1256
|
+
provider: observation.provider,
|
|
1257
|
+
identityDigest: observation.identityDigest,
|
|
1258
|
+
};
|
|
1259
|
+
persisted.usage = stored;
|
|
1260
|
+
this.#persistedPresentations.set(key, persisted);
|
|
1261
|
+
this.#queuePresentationWrite();
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
/** Persist an explicit usage/check report for the current credential identity. */
|
|
1265
|
+
recordCredentialUsage(provider: Provider, credentialId: number, report: SafeUsageReport): void {
|
|
1266
|
+
const entry = this.#snapshot.credentials.find(candidate => candidate.id === credentialId);
|
|
1267
|
+
if (!entry || entry.provider !== provider) return;
|
|
1268
|
+
const fetchedAt = Number.isFinite(report.fetchedAt) ? report.fetchedAt : Date.now();
|
|
1269
|
+
this.recordUsagePresentation({
|
|
1270
|
+
credentialId,
|
|
1271
|
+
provider,
|
|
1272
|
+
inventoryGeneration: this.#generation,
|
|
1273
|
+
identityDigest: this.#identityDigestForEntry(entry),
|
|
1274
|
+
usage: safePresentationUsageReport(report),
|
|
1275
|
+
fetchedAt,
|
|
1276
|
+
freshUntil: fetchedAt + PRESENTATION_FRESH_MS,
|
|
1277
|
+
retainUntil: fetchedAt + PRESENTATION_RETENTION_MS,
|
|
1278
|
+
});
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
/**
|
|
1282
|
+
* Per-credential usage hook consumed by `AuthStorage.#getUsageReport`. Pulls
|
|
1283
|
+
* the aggregate broker `/v1/usage` once and serves all callers from the
|
|
1284
|
+
* same response (coalesced + cached), then matches the credential to a
|
|
1285
|
+
* report by provider + identity (accountId / email / projectId).
|
|
1286
|
+
*
|
|
1287
|
+
* The broker already aggregates with its own 30s TTL on the server side; our
|
|
1288
|
+
* 15s client TTL is below that so we usually re-use the broker's cache too.
|
|
1289
|
+
*/
|
|
1290
|
+
async getUsageReport(
|
|
1291
|
+
provider: Provider,
|
|
1292
|
+
credential: OAuthCredential,
|
|
1293
|
+
signal?: AbortSignal,
|
|
1294
|
+
): Promise<UsageReport | null> {
|
|
1295
|
+
let reports: UsageReport[] | null;
|
|
1296
|
+
try {
|
|
1297
|
+
reports = await this.#raceWithSignal(this.#loadUsageReports(provider), signal);
|
|
1298
|
+
} catch (error) {
|
|
1299
|
+
// A caller cancellation is control flow, not a missing usage report.
|
|
1300
|
+
// Preserve it so selection/dispatch can stop promptly; only ordinary
|
|
1301
|
+
// broker/provider failures degrade to "no usage".
|
|
1302
|
+
if (signal?.aborted) throw error;
|
|
1303
|
+
return null;
|
|
1304
|
+
}
|
|
1305
|
+
if (!reports) return null;
|
|
1306
|
+
return matchUsageReport(reports, provider, credential);
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
/**
|
|
1310
|
+
* Reject the awaited promise when the caller's signal aborts, without
|
|
1311
|
+
* affecting the shared upstream fetch. Used to give each caller their
|
|
1312
|
+
* own cancel without one caller's abort cascading into a peer's in-flight
|
|
1313
|
+
* request through the single-flight `#usageInflight`.
|
|
1314
|
+
*/
|
|
1315
|
+
#raceWithSignal<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
1316
|
+
if (!signal) return promise;
|
|
1317
|
+
if (signal.aborted) return Promise.reject(new Error("auth-broker request aborted"));
|
|
1318
|
+
return new Promise<T>((resolve, reject) => {
|
|
1319
|
+
const onAbort = (): void => {
|
|
1320
|
+
signal.removeEventListener("abort", onAbort);
|
|
1321
|
+
reject(new Error("auth-broker request aborted"));
|
|
1322
|
+
};
|
|
1323
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1324
|
+
promise.then(
|
|
1325
|
+
value => {
|
|
1326
|
+
signal.removeEventListener("abort", onAbort);
|
|
1327
|
+
resolve(value);
|
|
1328
|
+
},
|
|
1329
|
+
err => {
|
|
1330
|
+
signal.removeEventListener("abort", onAbort);
|
|
1331
|
+
reject(err);
|
|
1332
|
+
},
|
|
1333
|
+
);
|
|
1334
|
+
});
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
#identityDigestForEntry(entry: SnapshotEntry): string {
|
|
1338
|
+
const credential = entry.credential;
|
|
1339
|
+
const identity =
|
|
1340
|
+
credential.type === "oauth"
|
|
1341
|
+
? {
|
|
1342
|
+
accountId: credential.accountId ?? null,
|
|
1343
|
+
email: credential.email?.trim().toLowerCase() ?? null,
|
|
1344
|
+
projectId: credential.projectId ?? null,
|
|
1345
|
+
enterpriseUrl: credential.enterpriseUrl ?? null,
|
|
1346
|
+
mcpBinding: credential.mcpBinding ?? null,
|
|
1347
|
+
}
|
|
1348
|
+
: { type: credential.type };
|
|
1349
|
+
return createHash("sha256")
|
|
1350
|
+
.update(JSON.stringify({ id: entry.id, provider: entry.provider, type: credential.type, identity }))
|
|
1351
|
+
.digest("hex");
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
#reconcileUsagePresentations(): void {
|
|
1355
|
+
for (const [credentialId, cached] of this.#usagePresentations) {
|
|
1356
|
+
const entry = this.#snapshot.credentials.find(candidate => candidate.id === credentialId);
|
|
1357
|
+
if (
|
|
1358
|
+
!entry ||
|
|
1359
|
+
entry.provider !== cached.provider ||
|
|
1360
|
+
this.#identityDigestForEntry(entry) !== cached.identityDigest
|
|
1361
|
+
) {
|
|
1362
|
+
this.#usagePresentations.delete(credentialId);
|
|
1363
|
+
continue;
|
|
1364
|
+
}
|
|
1365
|
+
this.#usagePresentations.set(credentialId, { ...cached, inventoryGeneration: this.#generation });
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
#recordUsageReports(reports: UsageReport[], epoch: number): void {
|
|
1370
|
+
if (this.#usageCacheEpoch !== epoch) return;
|
|
1371
|
+
for (const entry of this.#snapshot.credentials) {
|
|
1372
|
+
if (entry.credential.type !== "oauth") continue;
|
|
1373
|
+
const report = matchUsageReport(reports, entry.provider, entry.credential);
|
|
1374
|
+
if (!report) continue;
|
|
1375
|
+
const fetchedAt = Number.isFinite(report.fetchedAt) ? report.fetchedAt : Date.now();
|
|
1376
|
+
this.recordUsagePresentation({
|
|
1377
|
+
credentialId: entry.id,
|
|
1378
|
+
provider: entry.provider,
|
|
1379
|
+
inventoryGeneration: this.#generation,
|
|
1380
|
+
identityDigest: this.#identityDigestForEntry(entry),
|
|
1381
|
+
usage: safePresentationUsageReport(report),
|
|
1382
|
+
fetchedAt,
|
|
1383
|
+
freshUntil: fetchedAt + PRESENTATION_FRESH_MS,
|
|
1384
|
+
retainUntil: fetchedAt + PRESENTATION_RETENTION_MS,
|
|
1385
|
+
});
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
#loadUsageReports(provider?: Provider): Promise<UsageReport[] | null> {
|
|
1390
|
+
if (provider) {
|
|
1391
|
+
const cached = this.#scopedUsageCache.get(provider);
|
|
1392
|
+
if (cached && Date.now() - cached.fetchedAt < USAGE_CACHE_TTL_MS) return Promise.resolve(cached.reports);
|
|
1393
|
+
const failedAt = this.#scopedUsageFailures.get(provider);
|
|
1394
|
+
if (failedAt !== undefined && Date.now() - failedAt < USAGE_CACHE_TTL_MS) return Promise.resolve(null);
|
|
1395
|
+
const existing = this.#scopedUsageInflight.get(provider);
|
|
1396
|
+
if (existing) return existing;
|
|
1397
|
+
const epoch = this.#usageCacheEpoch;
|
|
1398
|
+
const inflight = this.#client
|
|
1399
|
+
.fetchUsage(undefined, provider)
|
|
1400
|
+
.then(body => {
|
|
1401
|
+
if (this.#usageCacheEpoch === epoch) {
|
|
1402
|
+
this.#scopedUsageCache.set(provider, { reports: body.reports, fetchedAt: Date.now() });
|
|
1403
|
+
this.#scopedUsageFailures.delete(provider);
|
|
1404
|
+
this.#recordUsageReports(body.reports, epoch);
|
|
1405
|
+
}
|
|
1406
|
+
return body.reports;
|
|
1407
|
+
})
|
|
1408
|
+
.catch(error => {
|
|
1409
|
+
logger.warn("auth-broker scoped usage fetch failed", {
|
|
1410
|
+
provider,
|
|
1411
|
+
error: cleanReason(error) ?? "Usage unavailable.",
|
|
1412
|
+
});
|
|
1413
|
+
if (this.#usageCacheEpoch === epoch) this.#scopedUsageFailures.set(provider, Date.now());
|
|
1414
|
+
return null;
|
|
1415
|
+
})
|
|
1416
|
+
.finally(() => {
|
|
1417
|
+
if (this.#scopedUsageInflight.get(provider) === inflight) {
|
|
1418
|
+
this.#scopedUsageInflight.delete(provider);
|
|
1419
|
+
}
|
|
1420
|
+
});
|
|
1421
|
+
this.#scopedUsageInflight.set(provider, inflight);
|
|
1422
|
+
return inflight;
|
|
1423
|
+
}
|
|
1424
|
+
const cached = this.#usageCache;
|
|
1425
|
+
if (cached && Date.now() - cached.fetchedAt < USAGE_CACHE_TTL_MS) {
|
|
1426
|
+
return Promise.resolve(cached.reports);
|
|
1427
|
+
}
|
|
1428
|
+
if (this.#usageInflight) return this.#usageInflight;
|
|
1429
|
+
const epoch = this.#usageCacheEpoch;
|
|
1430
|
+
const inflight = this.#client
|
|
1431
|
+
.fetchUsage()
|
|
1432
|
+
.then(body => {
|
|
1433
|
+
if (this.#usageCacheEpoch === epoch) {
|
|
1434
|
+
this.#usageCache = { reports: body.reports, fetchedAt: Date.now() };
|
|
1435
|
+
this.#recordUsageReports(body.reports, epoch);
|
|
1436
|
+
}
|
|
1437
|
+
return body.reports;
|
|
1438
|
+
})
|
|
1439
|
+
.catch(error => {
|
|
1440
|
+
logger.warn("auth-broker usage fetch failed", { error: String(error) });
|
|
1441
|
+
return null;
|
|
1442
|
+
})
|
|
1443
|
+
.finally(() => {
|
|
1444
|
+
if (this.#usageCacheEpoch === epoch) this.#usageInflight = undefined;
|
|
1445
|
+
});
|
|
1446
|
+
this.#usageInflight = inflight;
|
|
1447
|
+
return inflight;
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
close(): void {
|
|
1451
|
+
if (this.#closed) return;
|
|
1452
|
+
this.#backgroundAbort.abort();
|
|
1453
|
+
void this.#presentationWriteChain.finally(() => {
|
|
1454
|
+
this.#persistedPresentations.clear();
|
|
1455
|
+
});
|
|
1456
|
+
this.#closed = true;
|
|
1457
|
+
this.#snapshotListeners.clear();
|
|
1458
|
+
this.#cache.clear();
|
|
1459
|
+
this.#usagePresentations.clear();
|
|
1460
|
+
this.#inventoryMetadata.clear();
|
|
1461
|
+
this.#inventorySyncInflight = undefined;
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
function isErrorStatus(error: unknown, status: number): boolean {
|
|
1466
|
+
return (
|
|
1467
|
+
typeof error === "object" &&
|
|
1468
|
+
error !== null &&
|
|
1469
|
+
"status" in error &&
|
|
1470
|
+
(error as { status?: unknown }).status === status
|
|
1471
|
+
);
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
function snapshotIdentityLabel(entry: SnapshotEntry): string | null {
|
|
1475
|
+
if (entry.credential.type !== "oauth") return null;
|
|
1476
|
+
return entry.credential.email ?? entry.credential.accountId ?? entry.credential.projectId ?? null;
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
function safePresentationUsageReport(report: UsageReport): SafeUsageReport {
|
|
1480
|
+
const sanitize = (value: string): string =>
|
|
1481
|
+
(cleanReason(value) ?? "Usage unavailable.")
|
|
1482
|
+
.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
|
|
1483
|
+
.replace(/\s+/g, " ")
|
|
1484
|
+
.trim()
|
|
1485
|
+
.slice(0, 160);
|
|
1486
|
+
const { raw: _raw, metadata: _metadata, ...safe } = report;
|
|
1487
|
+
return {
|
|
1488
|
+
...safe,
|
|
1489
|
+
limits: safe.limits.map(limit => {
|
|
1490
|
+
const { accountId: _accountId, projectId: _projectId, orgId: _orgId, ...scope } = limit.scope;
|
|
1491
|
+
return {
|
|
1492
|
+
...limit,
|
|
1493
|
+
id: sanitize(limit.id),
|
|
1494
|
+
label: sanitize(limit.label),
|
|
1495
|
+
...(limit.window
|
|
1496
|
+
? { window: { ...limit.window, id: sanitize(limit.window.id), label: sanitize(limit.window.label) } }
|
|
1497
|
+
: {}),
|
|
1498
|
+
...(limit.notes ? { notes: limit.notes.map(sanitize) } : {}),
|
|
1499
|
+
scope,
|
|
1500
|
+
};
|
|
1501
|
+
}),
|
|
1502
|
+
};
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
function cloneUsagePresentation(observation: CachedUsagePresentation): CachedUsagePresentation {
|
|
1506
|
+
return {
|
|
1507
|
+
...observation,
|
|
1508
|
+
usage: {
|
|
1509
|
+
...observation.usage,
|
|
1510
|
+
limits: observation.usage.limits.map(limit => ({
|
|
1511
|
+
...limit,
|
|
1512
|
+
scope: { ...limit.scope },
|
|
1513
|
+
window: limit.window ? { ...limit.window } : undefined,
|
|
1514
|
+
amount: { ...limit.amount },
|
|
1515
|
+
notes: limit.notes ? [...limit.notes] : undefined,
|
|
1516
|
+
})),
|
|
1517
|
+
metadata: observation.usage.metadata ? { ...observation.usage.metadata } : undefined,
|
|
1518
|
+
},
|
|
1519
|
+
};
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
/**
|
|
1523
|
+
* Match a broker-supplied usage report to a specific OAuth credential. The
|
|
1524
|
+
* broker returns aggregate reports across all credentials it manages, so we
|
|
1525
|
+
* pick the one whose identity (accountId / email / projectId) lines up with
|
|
1526
|
+
* the credential the caller is asking about.
|
|
1527
|
+
*
|
|
1528
|
+
* Falls back to the lone candidate when only one matches the provider; falls
|
|
1529
|
+
* through to `null` when nothing matches, which `AuthStorage` treats as "no
|
|
1530
|
+
* usage data" (ranking proceeds without a usage signal for this credential).
|
|
1531
|
+
*/
|
|
1532
|
+
function matchUsageReport(reports: UsageReport[], provider: Provider, credential: OAuthCredential): UsageReport | null {
|
|
1533
|
+
const candidates = reports.filter(report => report.provider === provider);
|
|
1534
|
+
if (candidates.length === 0) return null;
|
|
1535
|
+
if (candidates.length === 1) return candidates[0];
|
|
1536
|
+
const accountId = credential.accountId?.trim().toLowerCase();
|
|
1537
|
+
const email = credential.email?.trim().toLowerCase();
|
|
1538
|
+
const projectId = credential.projectId?.trim().toLowerCase();
|
|
1539
|
+
for (const report of candidates) {
|
|
1540
|
+
if (reportMatchesIdentity(report, accountId, email, projectId)) return report;
|
|
1541
|
+
}
|
|
1542
|
+
return null;
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
function reportMatchesIdentity(
|
|
1546
|
+
report: UsageReport,
|
|
1547
|
+
accountId: string | undefined,
|
|
1548
|
+
email: string | undefined,
|
|
1549
|
+
projectId: string | undefined,
|
|
1550
|
+
): boolean {
|
|
1551
|
+
const metadata = (report.metadata ?? {}) as Record<string, unknown>;
|
|
1552
|
+
if (accountId) {
|
|
1553
|
+
const metaAccount = readMetadataString(metadata, "accountId") ?? readMetadataString(metadata, "account_id");
|
|
1554
|
+
if (metaAccount && metaAccount.toLowerCase() === accountId) return true;
|
|
1555
|
+
for (const limit of report.limits) {
|
|
1556
|
+
if (limit.scope.accountId?.toLowerCase() === accountId) return true;
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
if (email) {
|
|
1560
|
+
const metaEmail = readMetadataString(metadata, "email");
|
|
1561
|
+
if (metaEmail && metaEmail.toLowerCase() === email) return true;
|
|
1562
|
+
}
|
|
1563
|
+
if (projectId) {
|
|
1564
|
+
const metaProject = readMetadataString(metadata, "projectId") ?? readMetadataString(metadata, "project_id");
|
|
1565
|
+
if (metaProject && metaProject.toLowerCase() === projectId) return true;
|
|
1566
|
+
for (const limit of report.limits) {
|
|
1567
|
+
if (limit.scope.projectId?.toLowerCase() === projectId) return true;
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
return false;
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
function readMetadataString(metadata: Record<string, unknown>, key: string): string | undefined {
|
|
1574
|
+
const value = metadata[key];
|
|
1575
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
|
1576
|
+
}
|