@gajae-code/ai 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2644 -0
- package/README.md +1181 -0
- package/dist/types/api-registry.d.ts +30 -0
- package/dist/types/auth-broker/client.d.ts +66 -0
- package/dist/types/auth-broker/index.d.ts +5 -0
- package/dist/types/auth-broker/refresher.d.ts +25 -0
- package/dist/types/auth-broker/remote-store.d.ts +96 -0
- package/dist/types/auth-broker/server.d.ts +32 -0
- package/dist/types/auth-broker/types.d.ts +105 -0
- package/dist/types/auth-broker/wire-schemas.d.ts +412 -0
- package/dist/types/auth-gateway/http.d.ts +39 -0
- package/dist/types/auth-gateway/index.d.ts +3 -0
- package/dist/types/auth-gateway/server.d.ts +17 -0
- package/dist/types/auth-gateway/types.d.ts +115 -0
- package/dist/types/auth-storage.d.ts +641 -0
- package/dist/types/cli.d.ts +2 -0
- package/dist/types/index.d.ts +49 -0
- package/dist/types/model-cache.d.ts +17 -0
- package/dist/types/model-manager.d.ts +62 -0
- package/dist/types/model-thinking.d.ts +71 -0
- package/dist/types/models.d.ts +12 -0
- package/dist/types/provider-details.d.ts +24 -0
- package/dist/types/provider-models/bundled-references.d.ts +4 -0
- package/dist/types/provider-models/descriptors.d.ts +48 -0
- package/dist/types/provider-models/google.d.ts +20 -0
- package/dist/types/provider-models/index.d.ts +5 -0
- package/dist/types/provider-models/ollama.d.ts +7 -0
- package/dist/types/provider-models/openai-compat.d.ts +237 -0
- package/dist/types/provider-models/special.d.ts +16 -0
- package/dist/types/providers/amazon-bedrock.d.ts +36 -0
- package/dist/types/providers/anthropic-messages-server-schema.d.ts +450 -0
- package/dist/types/providers/anthropic-messages-server.d.ts +17 -0
- package/dist/types/providers/anthropic.d.ts +188 -0
- package/dist/types/providers/aws-credentials.d.ts +43 -0
- package/dist/types/providers/aws-eventstream.d.ts +38 -0
- package/dist/types/providers/aws-sigv4.d.ts +55 -0
- package/dist/types/providers/azure-openai-responses.d.ts +15 -0
- package/dist/types/providers/cursor/gen/agent_pb.d.ts +13022 -0
- package/dist/types/providers/cursor.d.ts +42 -0
- package/dist/types/providers/error-message.d.ts +27 -0
- package/dist/types/providers/github-copilot-headers.d.ts +40 -0
- package/dist/types/providers/gitlab-duo.d.ts +27 -0
- package/dist/types/providers/google-auth.d.ts +24 -0
- package/dist/types/providers/google-gemini-cli.d.ts +72 -0
- package/dist/types/providers/google-gemini-headers.d.ts +18 -0
- package/dist/types/providers/google-shared.d.ts +163 -0
- package/dist/types/providers/google-types.d.ts +138 -0
- package/dist/types/providers/google-vertex.d.ts +7 -0
- package/dist/types/providers/google.d.ts +4 -0
- package/dist/types/providers/grammar.d.ts +1 -0
- package/dist/types/providers/kimi.d.ts +27 -0
- package/dist/types/providers/mock.d.ts +175 -0
- package/dist/types/providers/ollama.d.ts +6 -0
- package/dist/types/providers/openai-anthropic-shim.d.ts +31 -0
- package/dist/types/providers/openai-chat-server-schema.d.ts +814 -0
- package/dist/types/providers/openai-chat-server.d.ts +16 -0
- package/dist/types/providers/openai-codex/constants.d.ts +26 -0
- package/dist/types/providers/openai-codex/request-transformer.d.ts +49 -0
- package/dist/types/providers/openai-codex/response-handler.d.ts +17 -0
- package/dist/types/providers/openai-codex-responses.d.ts +67 -0
- package/dist/types/providers/openai-completions-compat.d.ts +25 -0
- package/dist/types/providers/openai-completions.d.ts +33 -0
- package/dist/types/providers/openai-responses-server-schema.d.ts +392 -0
- package/dist/types/providers/openai-responses-server.d.ts +17 -0
- package/dist/types/providers/openai-responses-shared.d.ts +89 -0
- package/dist/types/providers/openai-responses.d.ts +32 -0
- package/dist/types/providers/pi-native-client.d.ts +13 -0
- package/dist/types/providers/pi-native-server.d.ts +68 -0
- package/dist/types/providers/register-builtins.d.ts +31 -0
- package/dist/types/providers/synthetic.d.ts +26 -0
- package/dist/types/providers/transform-messages.d.ts +12 -0
- package/dist/types/providers/vision-guard.d.ts +8 -0
- package/dist/types/rate-limit-utils.d.ts +19 -0
- package/dist/types/stream.d.ts +24 -0
- package/dist/types/types.d.ts +746 -0
- package/dist/types/usage/claude.d.ts +3 -0
- package/dist/types/usage/gemini.d.ts +2 -0
- package/dist/types/usage/github-copilot.d.ts +7 -0
- package/dist/types/usage/google-antigravity.d.ts +2 -0
- package/dist/types/usage/kimi.d.ts +2 -0
- package/dist/types/usage/minimax-code.d.ts +2 -0
- package/dist/types/usage/openai-codex.d.ts +3 -0
- package/dist/types/usage/shared.d.ts +1 -0
- package/dist/types/usage/zai.d.ts +2 -0
- package/dist/types/usage.d.ts +258 -0
- package/dist/types/utils/abort.d.ts +19 -0
- package/dist/types/utils/anthropic-auth.d.ts +31 -0
- package/dist/types/utils/discovery/antigravity.d.ts +61 -0
- package/dist/types/utils/discovery/codex.d.ts +38 -0
- package/dist/types/utils/discovery/cursor.d.ts +23 -0
- package/dist/types/utils/discovery/gemini.d.ts +25 -0
- package/dist/types/utils/discovery/index.d.ts +4 -0
- package/dist/types/utils/discovery/openai-compatible.d.ts +72 -0
- package/dist/types/utils/event-stream.d.ts +28 -0
- package/dist/types/utils/fireworks-model-id.d.ts +10 -0
- package/dist/types/utils/foundry.d.ts +1 -0
- package/dist/types/utils/h2-fetch.d.ts +22 -0
- package/dist/types/utils/http-inspector.d.ts +31 -0
- package/dist/types/utils/idle-iterator.d.ts +67 -0
- package/dist/types/utils/json-parse.d.ts +10 -0
- package/dist/types/utils/oauth/alibaba-coding-plan.d.ts +18 -0
- package/dist/types/utils/oauth/anthropic.d.ts +22 -0
- package/dist/types/utils/oauth/api-key-login.d.ts +35 -0
- package/dist/types/utils/oauth/api-key-validation.d.ts +27 -0
- package/dist/types/utils/oauth/callback-server.d.ts +57 -0
- package/dist/types/utils/oauth/cerebras.d.ts +1 -0
- package/dist/types/utils/oauth/cloudflare-ai-gateway.d.ts +18 -0
- package/dist/types/utils/oauth/cursor.d.ts +15 -0
- package/dist/types/utils/oauth/deepseek.d.ts +10 -0
- package/dist/types/utils/oauth/firepass.d.ts +1 -0
- package/dist/types/utils/oauth/fireworks.d.ts +1 -0
- package/dist/types/utils/oauth/github-copilot.d.ts +38 -0
- package/dist/types/utils/oauth/gitlab-duo.d.ts +3 -0
- package/dist/types/utils/oauth/google-antigravity.d.ts +11 -0
- package/dist/types/utils/oauth/google-gemini-cli.d.ts +10 -0
- package/dist/types/utils/oauth/google-oauth-shared.d.ts +28 -0
- package/dist/types/utils/oauth/huggingface.d.ts +19 -0
- package/dist/types/utils/oauth/index.d.ts +38 -0
- package/dist/types/utils/oauth/kagi.d.ts +17 -0
- package/dist/types/utils/oauth/kilo.d.ts +5 -0
- package/dist/types/utils/oauth/kimi.d.ts +21 -0
- package/dist/types/utils/oauth/litellm.d.ts +18 -0
- package/dist/types/utils/oauth/lm-studio.d.ts +17 -0
- package/dist/types/utils/oauth/minimax-code.d.ts +28 -0
- package/dist/types/utils/oauth/moonshot.d.ts +1 -0
- package/dist/types/utils/oauth/nanogpt.d.ts +1 -0
- package/dist/types/utils/oauth/nvidia.d.ts +18 -0
- package/dist/types/utils/oauth/ollama-cloud.d.ts +2 -0
- package/dist/types/utils/oauth/ollama.d.ts +18 -0
- package/dist/types/utils/oauth/openai-codex.d.ts +21 -0
- package/dist/types/utils/oauth/opencode.d.ts +18 -0
- package/dist/types/utils/oauth/parallel.d.ts +17 -0
- package/dist/types/utils/oauth/perplexity.d.ts +9 -0
- package/dist/types/utils/oauth/pkce.d.ts +8 -0
- package/dist/types/utils/oauth/qianfan.d.ts +17 -0
- package/dist/types/utils/oauth/qwen-portal.d.ts +19 -0
- package/dist/types/utils/oauth/synthetic.d.ts +1 -0
- package/dist/types/utils/oauth/tavily.d.ts +17 -0
- package/dist/types/utils/oauth/together.d.ts +1 -0
- package/dist/types/utils/oauth/types.d.ts +44 -0
- package/dist/types/utils/oauth/venice.d.ts +18 -0
- package/dist/types/utils/oauth/vercel-ai-gateway.d.ts +18 -0
- package/dist/types/utils/oauth/vllm.d.ts +16 -0
- package/dist/types/utils/oauth/xiaomi.d.ts +19 -0
- package/dist/types/utils/oauth/zai.d.ts +18 -0
- package/dist/types/utils/oauth/zenmux.d.ts +1 -0
- package/dist/types/utils/overflow.d.ts +54 -0
- package/dist/types/utils/parse-bind.d.ts +23 -0
- package/dist/types/utils/provider-response.d.ts +3 -0
- package/dist/types/utils/retry-after.d.ts +3 -0
- package/dist/types/utils/retry.d.ts +26 -0
- package/dist/types/utils/schema/adapt.d.ts +24 -0
- package/dist/types/utils/schema/compatibility.d.ts +30 -0
- package/dist/types/utils/schema/dereference.d.ts +11 -0
- package/dist/types/utils/schema/draft.d.ts +10 -0
- package/dist/types/utils/schema/equality.d.ts +4 -0
- package/dist/types/utils/schema/fields.d.ts +49 -0
- package/dist/types/utils/schema/index.d.ts +13 -0
- package/dist/types/utils/schema/json-schema-validator.d.ts +12 -0
- package/dist/types/utils/schema/meta-validator.d.ts +2 -0
- package/dist/types/utils/schema/normalize.d.ts +93 -0
- package/dist/types/utils/schema/spill.d.ts +8 -0
- package/dist/types/utils/schema/stamps.d.ts +25 -0
- package/dist/types/utils/schema/types.d.ts +4 -0
- package/dist/types/utils/schema/wire.d.ts +54 -0
- package/dist/types/utils/schema/zod-decontaminate.d.ts +31 -0
- package/dist/types/utils/sse-debug.d.ts +10 -0
- package/dist/types/utils/tool-call-healing.d.ts +71 -0
- package/dist/types/utils/tool-choice.d.ts +50 -0
- package/dist/types/utils/validation.d.ts +17 -0
- package/dist/types/utils.d.ts +28 -0
- package/package.json +146 -0
- package/src/api-registry.ts +96 -0
- package/src/auth-broker/client.ts +358 -0
- package/src/auth-broker/index.ts +5 -0
- package/src/auth-broker/refresher.ts +127 -0
- package/src/auth-broker/remote-store.ts +623 -0
- package/src/auth-broker/server.ts +644 -0
- package/src/auth-broker/types.ts +127 -0
- package/src/auth-broker/wire-schemas.ts +200 -0
- package/src/auth-gateway/http.ts +194 -0
- package/src/auth-gateway/index.ts +3 -0
- package/src/auth-gateway/server.ts +717 -0
- package/src/auth-gateway/types.ts +134 -0
- package/src/auth-storage.ts +4104 -0
- package/src/cli.ts +262 -0
- package/src/index.ts +54 -0
- package/src/model-cache.ts +129 -0
- package/src/model-manager.ts +450 -0
- package/src/model-thinking.ts +691 -0
- package/src/models.json +73853 -0
- package/src/models.json.d.ts +9 -0
- package/src/models.ts +56 -0
- package/src/prompts/turn-aborted-guidance.md +4 -0
- package/src/provider-details.ts +90 -0
- package/src/provider-models/bundled-references.ts +38 -0
- package/src/provider-models/descriptors.ts +308 -0
- package/src/provider-models/google.ts +91 -0
- package/src/provider-models/index.ts +5 -0
- package/src/provider-models/ollama.ts +153 -0
- package/src/provider-models/openai-compat.ts +2275 -0
- package/src/provider-models/special.ts +67 -0
- package/src/providers/amazon-bedrock.ts +849 -0
- package/src/providers/anthropic-messages-server-schema.ts +229 -0
- package/src/providers/anthropic-messages-server.ts +677 -0
- package/src/providers/anthropic.ts +2696 -0
- package/src/providers/aws-credentials.ts +501 -0
- package/src/providers/aws-eventstream.ts +185 -0
- package/src/providers/aws-sigv4.ts +218 -0
- package/src/providers/azure-openai-responses.ts +337 -0
- package/src/providers/cursor/gen/agent_pb.ts +15274 -0
- package/src/providers/cursor/proto/agent.proto +3526 -0
- package/src/providers/cursor/proto/buf.gen.yaml +6 -0
- package/src/providers/cursor/proto/buf.yaml +17 -0
- package/src/providers/cursor.ts +2561 -0
- package/src/providers/error-message.ts +21 -0
- package/src/providers/github-copilot-headers.ts +140 -0
- package/src/providers/gitlab-duo.ts +372 -0
- package/src/providers/google-auth.ts +252 -0
- package/src/providers/google-gemini-cli.ts +795 -0
- package/src/providers/google-gemini-headers.ts +41 -0
- package/src/providers/google-shared.ts +902 -0
- package/src/providers/google-types.ts +167 -0
- package/src/providers/google-vertex.ts +88 -0
- package/src/providers/google.ts +41 -0
- package/src/providers/grammar.ts +70 -0
- package/src/providers/kimi.ts +52 -0
- package/src/providers/mock.ts +500 -0
- package/src/providers/ollama.ts +544 -0
- package/src/providers/openai-anthropic-shim.ts +138 -0
- package/src/providers/openai-chat-server-schema.ts +243 -0
- package/src/providers/openai-chat-server.ts +628 -0
- package/src/providers/openai-codex/constants.ts +43 -0
- package/src/providers/openai-codex/request-transformer.ts +161 -0
- package/src/providers/openai-codex/response-handler.ts +81 -0
- package/src/providers/openai-codex-responses.ts +2598 -0
- package/src/providers/openai-completions-compat.ts +279 -0
- package/src/providers/openai-completions.ts +1853 -0
- package/src/providers/openai-responses-server-schema.ts +290 -0
- package/src/providers/openai-responses-server.ts +1183 -0
- package/src/providers/openai-responses-shared.ts +800 -0
- package/src/providers/openai-responses.ts +621 -0
- package/src/providers/pi-native-client.ts +228 -0
- package/src/providers/pi-native-server.ts +210 -0
- package/src/providers/register-builtins.ts +412 -0
- package/src/providers/synthetic.ts +50 -0
- package/src/providers/transform-messages.ts +309 -0
- package/src/providers/vision-guard.ts +31 -0
- package/src/rate-limit-utils.ts +84 -0
- package/src/stream.ts +895 -0
- package/src/types.ts +884 -0
- package/src/usage/claude.ts +431 -0
- package/src/usage/gemini.ts +250 -0
- package/src/usage/github-copilot.ts +421 -0
- package/src/usage/google-antigravity.ts +201 -0
- package/src/usage/kimi.ts +271 -0
- package/src/usage/minimax-code.ts +31 -0
- package/src/usage/openai-codex.ts +503 -0
- package/src/usage/shared.ts +10 -0
- package/src/usage/zai.ts +247 -0
- package/src/usage.ts +183 -0
- package/src/utils/abort.ts +51 -0
- package/src/utils/anthropic-auth.ts +87 -0
- package/src/utils/discovery/antigravity.ts +261 -0
- package/src/utils/discovery/codex.ts +371 -0
- package/src/utils/discovery/cursor.ts +306 -0
- package/src/utils/discovery/gemini.ts +248 -0
- package/src/utils/discovery/index.ts +4 -0
- package/src/utils/discovery/openai-compatible.ts +224 -0
- package/src/utils/event-stream.ts +142 -0
- package/src/utils/fireworks-model-id.ts +30 -0
- package/src/utils/foundry.ts +8 -0
- package/src/utils/h2-fetch.ts +60 -0
- package/src/utils/http-inspector.ts +176 -0
- package/src/utils/idle-iterator.ts +250 -0
- package/src/utils/json-parse.ts +148 -0
- package/src/utils/oauth/alibaba-coding-plan.ts +59 -0
- package/src/utils/oauth/anthropic.ts +200 -0
- package/src/utils/oauth/api-key-login.ts +87 -0
- package/src/utils/oauth/api-key-validation.ts +92 -0
- package/src/utils/oauth/callback-server.ts +276 -0
- package/src/utils/oauth/cerebras.ts +16 -0
- package/src/utils/oauth/cloudflare-ai-gateway.ts +48 -0
- package/src/utils/oauth/cursor.ts +157 -0
- package/src/utils/oauth/deepseek.ts +53 -0
- package/src/utils/oauth/firepass.ts +24 -0
- package/src/utils/oauth/fireworks.ts +15 -0
- package/src/utils/oauth/github-copilot.ts +362 -0
- package/src/utils/oauth/gitlab-duo.ts +123 -0
- package/src/utils/oauth/google-antigravity.ts +200 -0
- package/src/utils/oauth/google-gemini-cli.ts +256 -0
- package/src/utils/oauth/google-oauth-shared.ts +110 -0
- package/src/utils/oauth/huggingface.ts +62 -0
- package/src/utils/oauth/index.ts +444 -0
- package/src/utils/oauth/kagi.ts +47 -0
- package/src/utils/oauth/kilo.ts +87 -0
- package/src/utils/oauth/kimi.ts +254 -0
- package/src/utils/oauth/litellm.ts +47 -0
- package/src/utils/oauth/lm-studio.ts +38 -0
- package/src/utils/oauth/minimax-code.ts +78 -0
- package/src/utils/oauth/moonshot.ts +16 -0
- package/src/utils/oauth/nanogpt.ts +15 -0
- package/src/utils/oauth/nvidia.ts +70 -0
- package/src/utils/oauth/oauth.html +199 -0
- package/src/utils/oauth/ollama-cloud.ts +28 -0
- package/src/utils/oauth/ollama.ts +47 -0
- package/src/utils/oauth/openai-codex.ts +299 -0
- package/src/utils/oauth/opencode.ts +49 -0
- package/src/utils/oauth/parallel.ts +46 -0
- package/src/utils/oauth/perplexity.ts +206 -0
- package/src/utils/oauth/pkce.ts +18 -0
- package/src/utils/oauth/qianfan.ts +58 -0
- package/src/utils/oauth/qwen-portal.ts +60 -0
- package/src/utils/oauth/synthetic.ts +16 -0
- package/src/utils/oauth/tavily.ts +46 -0
- package/src/utils/oauth/together.ts +16 -0
- package/src/utils/oauth/types.ts +94 -0
- package/src/utils/oauth/venice.ts +59 -0
- package/src/utils/oauth/vercel-ai-gateway.ts +47 -0
- package/src/utils/oauth/vllm.ts +40 -0
- package/src/utils/oauth/xiaomi.ts +137 -0
- package/src/utils/oauth/zai.ts +60 -0
- package/src/utils/oauth/zenmux.ts +15 -0
- package/src/utils/overflow.ts +137 -0
- package/src/utils/parse-bind.ts +54 -0
- package/src/utils/provider-response.ts +30 -0
- package/src/utils/retry-after.ts +110 -0
- package/src/utils/retry.ts +54 -0
- package/src/utils/schema/CONSTRAINTS.md +164 -0
- package/src/utils/schema/adapt.ts +36 -0
- package/src/utils/schema/compatibility.ts +435 -0
- package/src/utils/schema/dereference.ts +98 -0
- package/src/utils/schema/draft.ts +341 -0
- package/src/utils/schema/equality.ts +97 -0
- package/src/utils/schema/fields.ts +190 -0
- package/src/utils/schema/index.ts +13 -0
- package/src/utils/schema/json-schema-validator.ts +577 -0
- package/src/utils/schema/meta-validator.ts +167 -0
- package/src/utils/schema/normalize.ts +1588 -0
- package/src/utils/schema/spill.ts +43 -0
- package/src/utils/schema/stamps.ts +97 -0
- package/src/utils/schema/types.ts +11 -0
- package/src/utils/schema/wire.ts +213 -0
- package/src/utils/schema/zod-decontaminate.ts +331 -0
- package/src/utils/sse-debug.ts +289 -0
- package/src/utils/tool-call-healing.ts +271 -0
- package/src/utils/tool-choice.ts +99 -0
- package/src/utils/validation.ts +1019 -0
- package/src/utils.ts +166 -0
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth broker HTTP server.
|
|
3
|
+
*
|
|
4
|
+
* Wraps an {@link AuthStorage} (backed by a SQLite store on the broker host)
|
|
5
|
+
* and exposes a minimal REST API for snapshot pulls and explicit refresh /
|
|
6
|
+
* disable operations. Background refresh of expiring credentials lives in
|
|
7
|
+
* {@link AuthBrokerRefresher}.
|
|
8
|
+
*
|
|
9
|
+
* Transport security is delegated to the operator (Tailscale / Wireguard);
|
|
10
|
+
* the server only checks a bearer token against an allow-list per request.
|
|
11
|
+
*/
|
|
12
|
+
import { logger } from "@gajae-code/utils";
|
|
13
|
+
import type { AuthStorage } from "../auth-storage";
|
|
14
|
+
import { parseBind } from "../utils/parse-bind";
|
|
15
|
+
import { AuthBrokerRefresher, type AuthBrokerRefresherSchedule } from "./refresher";
|
|
16
|
+
import type {
|
|
17
|
+
CredentialDisableResponse,
|
|
18
|
+
CredentialRefreshResponse,
|
|
19
|
+
CredentialUploadResponse,
|
|
20
|
+
HealthzResponse,
|
|
21
|
+
RefresherSchedule,
|
|
22
|
+
SnapshotEntry,
|
|
23
|
+
SnapshotResponse,
|
|
24
|
+
SnapshotStreamEntryEvent,
|
|
25
|
+
SnapshotStreamRemovedEvent,
|
|
26
|
+
SnapshotStreamSnapshotEvent,
|
|
27
|
+
} from "./types";
|
|
28
|
+
import {
|
|
29
|
+
DEFAULT_AUTH_BROKER_BIND,
|
|
30
|
+
DEFAULT_REFRESH_INTERVAL_MS,
|
|
31
|
+
DEFAULT_REFRESH_SKEW_MS,
|
|
32
|
+
DEFAULT_SERVER_IDLE_TIMEOUT_S,
|
|
33
|
+
DEFAULT_STREAM_KEEPALIVE_MS,
|
|
34
|
+
} from "./types";
|
|
35
|
+
import { credentialDisableRequestSchema, credentialUploadRequestSchema } from "./wire-schemas";
|
|
36
|
+
|
|
37
|
+
export interface AuthBrokerServerOptions {
|
|
38
|
+
/** Underlying credential storage (wraps the local SQLite store on the broker). */
|
|
39
|
+
storage: AuthStorage;
|
|
40
|
+
/** Listen address; accepts `host:port` or just `port`. */
|
|
41
|
+
bind?: string;
|
|
42
|
+
/** Accept any of these bearer tokens. Empty disables auth (loopback only). */
|
|
43
|
+
bearerTokens: string[];
|
|
44
|
+
/** Broker version string surfaced on `/v1/healthz`. */
|
|
45
|
+
version?: string;
|
|
46
|
+
/** Refresh credentials expiring within this window. Default 5 min. */
|
|
47
|
+
refreshSkewMs?: number;
|
|
48
|
+
/** Background refresh cadence. Default 60s. */
|
|
49
|
+
refreshIntervalMs?: number;
|
|
50
|
+
/** Disable the background refresher (e.g. for tests). */
|
|
51
|
+
disableRefresher?: boolean;
|
|
52
|
+
/**
|
|
53
|
+
* Override SSE keepalive cadence in milliseconds for `/v1/snapshot/stream`.
|
|
54
|
+
* Internal-only — tests use a short interval so they can assert heartbeats
|
|
55
|
+
* without long sleeps. Default {@link DEFAULT_STREAM_KEEPALIVE_MS}.
|
|
56
|
+
*/
|
|
57
|
+
streamKeepaliveMs?: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface AuthBrokerServerHandle {
|
|
61
|
+
/** Bound URL (`http://host:port`). */
|
|
62
|
+
url: string;
|
|
63
|
+
port: number;
|
|
64
|
+
hostname: string;
|
|
65
|
+
close(): Promise<void>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function json(status: number, body: unknown, headers?: Record<string, string>): Response {
|
|
69
|
+
return new Response(JSON.stringify(body), {
|
|
70
|
+
status,
|
|
71
|
+
headers: { "Content-Type": "application/json", ...(headers ?? {}) },
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function empty(status: number, headers?: Record<string, string>): Response {
|
|
76
|
+
return new Response(null, { status, headers });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isAuthorized(req: Request, tokens: ReadonlySet<string>): boolean {
|
|
80
|
+
if (tokens.size === 0) return true;
|
|
81
|
+
const header = req.headers.get("authorization");
|
|
82
|
+
if (!header) return false;
|
|
83
|
+
const match = header.match(/^Bearer\s+(.+)$/i);
|
|
84
|
+
if (!match) return false;
|
|
85
|
+
return tokens.has(match[1].trim());
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Parse + validate a JSON request body against a Zod schema. Returns a
|
|
90
|
+
* `Response` (400) on parse/validation failure so handlers can early-return.
|
|
91
|
+
* When `allowEmpty` is set, an empty request body is validated against `{}`.
|
|
92
|
+
*/
|
|
93
|
+
async function parseBody<T>(
|
|
94
|
+
req: Request,
|
|
95
|
+
schema: { safeParse(input: unknown): { success: true; data: T } | { success: false; error: { message: string } } },
|
|
96
|
+
options: { allowEmpty?: boolean } = {},
|
|
97
|
+
): Promise<{ ok: true; data: T } | { ok: false; response: Response }> {
|
|
98
|
+
let raw: string;
|
|
99
|
+
try {
|
|
100
|
+
raw = await req.text();
|
|
101
|
+
} catch (error) {
|
|
102
|
+
return { ok: false, response: json(400, { error: `Invalid request body: ${String(error)}` }) };
|
|
103
|
+
}
|
|
104
|
+
if (raw.length === 0 && !options.allowEmpty) {
|
|
105
|
+
return { ok: false, response: json(400, { error: "Request body required" }) };
|
|
106
|
+
}
|
|
107
|
+
let parsed: unknown;
|
|
108
|
+
try {
|
|
109
|
+
parsed = raw.length === 0 ? {} : JSON.parse(raw);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
return { ok: false, response: json(400, { error: `Invalid JSON body: ${String(error)}` }) };
|
|
112
|
+
}
|
|
113
|
+
const result = schema.safeParse(parsed);
|
|
114
|
+
if (!result.success) {
|
|
115
|
+
return { ok: false, response: json(400, { error: result.error.message }) };
|
|
116
|
+
}
|
|
117
|
+
return { ok: true, data: result.data };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const REFRESH_ROUTE = /^\/v1\/credential\/(\d+)\/refresh$/;
|
|
121
|
+
const DISABLE_ROUTE = /^\/v1\/credential\/(\d+)\/disable$/;
|
|
122
|
+
|
|
123
|
+
const MAX_SNAPSHOT_WAIT_MS = 30_000;
|
|
124
|
+
const DISABLED_NEXT_SWEEP_IN_MS = Number.MAX_SAFE_INTEGER;
|
|
125
|
+
|
|
126
|
+
function snapshotHeaders(generation: number): Record<string, string> {
|
|
127
|
+
return {
|
|
128
|
+
ETag: `"${generation}"`,
|
|
129
|
+
"Cache-Control": "no-store",
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function parseGenerationTag(header: string | null): number | undefined {
|
|
134
|
+
if (!header) return undefined;
|
|
135
|
+
let value = header.trim();
|
|
136
|
+
if (value.startsWith("W/")) value = value.slice(2).trim();
|
|
137
|
+
if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
|
|
138
|
+
value = value.slice(1, -1);
|
|
139
|
+
}
|
|
140
|
+
const generation = Number(value);
|
|
141
|
+
if (!Number.isInteger(generation) || generation < 0) return undefined;
|
|
142
|
+
return generation;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function parseWaitMs(url: URL): number {
|
|
146
|
+
const raw = url.searchParams.get("wait");
|
|
147
|
+
if (raw === null) return 0;
|
|
148
|
+
const parsed = Number(raw);
|
|
149
|
+
if (!Number.isFinite(parsed)) return 0;
|
|
150
|
+
return Math.max(0, Math.min(MAX_SNAPSHOT_WAIT_MS, Math.trunc(parsed)));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function delayResult(ms: number): { promise: Promise<"timeout">; cancel: () => void } {
|
|
154
|
+
const done = Promise.withResolvers<"timeout">();
|
|
155
|
+
const timer = setTimeout(() => done.resolve("timeout"), ms);
|
|
156
|
+
timer.unref?.();
|
|
157
|
+
return {
|
|
158
|
+
promise: done.promise,
|
|
159
|
+
cancel: () => clearTimeout(timer),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
class GenerationGate {
|
|
164
|
+
readonly #storage: AuthStorage;
|
|
165
|
+
readonly #unsubscribe: () => void;
|
|
166
|
+
#waiters: Map<number, Set<() => void>> = new Map();
|
|
167
|
+
|
|
168
|
+
constructor(storage: AuthStorage) {
|
|
169
|
+
this.#storage = storage;
|
|
170
|
+
this.#unsubscribe = storage.onGenerationChanged(generation => this.#wake(generation));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
waitForChange(afterGeneration: number, signal: AbortSignal): Promise<"changed" | "aborted"> {
|
|
174
|
+
if (this.#storage.getGeneration() !== afterGeneration) return Promise.resolve("changed");
|
|
175
|
+
if (signal.aborted) return Promise.resolve("aborted");
|
|
176
|
+
|
|
177
|
+
const done = Promise.withResolvers<"changed" | "aborted">();
|
|
178
|
+
let settled = false;
|
|
179
|
+
const waiters = this.#waiters.get(afterGeneration) ?? new Set<() => void>();
|
|
180
|
+
this.#waiters.set(afterGeneration, waiters);
|
|
181
|
+
|
|
182
|
+
const cleanup = (): void => {
|
|
183
|
+
signal.removeEventListener("abort", onAbort);
|
|
184
|
+
waiters.delete(resolveChanged);
|
|
185
|
+
if (waiters.size === 0) this.#waiters.delete(afterGeneration);
|
|
186
|
+
};
|
|
187
|
+
const settle = (result: "changed" | "aborted"): void => {
|
|
188
|
+
if (settled) return;
|
|
189
|
+
settled = true;
|
|
190
|
+
cleanup();
|
|
191
|
+
done.resolve(result);
|
|
192
|
+
};
|
|
193
|
+
const resolveChanged = (): void => settle("changed");
|
|
194
|
+
const onAbort = (): void => settle("aborted");
|
|
195
|
+
|
|
196
|
+
waiters.add(resolveChanged);
|
|
197
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
198
|
+
return done.promise;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
close(): void {
|
|
202
|
+
this.#unsubscribe();
|
|
203
|
+
for (const waiters of this.#waiters.values()) {
|
|
204
|
+
for (const resolve of waiters) resolve();
|
|
205
|
+
}
|
|
206
|
+
this.#waiters.clear();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
#wake(generation: number): void {
|
|
210
|
+
for (const [waitingFor, waiters] of [...this.#waiters]) {
|
|
211
|
+
if (generation <= waitingFor) continue;
|
|
212
|
+
for (const resolve of [...waiters]) resolve();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function resolveRefresherSchedule(
|
|
218
|
+
refresher: AuthBrokerRefresher | undefined,
|
|
219
|
+
serverNowMs: number,
|
|
220
|
+
): { wire: RefresherSchedule; nextSweepAt: number } {
|
|
221
|
+
if (!refresher) {
|
|
222
|
+
return {
|
|
223
|
+
wire: {
|
|
224
|
+
enabled: false,
|
|
225
|
+
intervalMs: 0,
|
|
226
|
+
skewMs: 0,
|
|
227
|
+
nextSweepInMs: DISABLED_NEXT_SWEEP_IN_MS,
|
|
228
|
+
},
|
|
229
|
+
nextSweepAt: DISABLED_NEXT_SWEEP_IN_MS,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
const schedule: AuthBrokerRefresherSchedule = refresher.getSchedule();
|
|
233
|
+
return {
|
|
234
|
+
wire: {
|
|
235
|
+
enabled: schedule.enabled,
|
|
236
|
+
intervalMs: schedule.intervalMs,
|
|
237
|
+
skewMs: schedule.skewMs,
|
|
238
|
+
nextSweepInMs: Math.max(0, schedule.nextSweepAt - serverNowMs),
|
|
239
|
+
},
|
|
240
|
+
nextSweepAt: schedule.nextSweepAt,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function computeRotatesInMs(
|
|
245
|
+
entry: { credential: { type: string; expires?: number } },
|
|
246
|
+
schedule: RefresherSchedule,
|
|
247
|
+
nextSweepAt: number,
|
|
248
|
+
serverNowMs: number,
|
|
249
|
+
): number | null {
|
|
250
|
+
if (!schedule.enabled || entry.credential.type !== "oauth") return null;
|
|
251
|
+
const expires = entry.credential.expires;
|
|
252
|
+
if (typeof expires !== "number" || !Number.isFinite(expires)) return null;
|
|
253
|
+
if (!Number.isFinite(nextSweepAt) || !Number.isFinite(schedule.intervalMs) || schedule.intervalMs <= 0) return null;
|
|
254
|
+
|
|
255
|
+
const dueAt = expires - schedule.skewMs;
|
|
256
|
+
const eligibleAt = Math.max(serverNowMs, dueAt);
|
|
257
|
+
if (dueAt <= serverNowMs && nextSweepAt <= serverNowMs) return 0;
|
|
258
|
+
if (nextSweepAt >= eligibleAt) return Math.max(0, nextSweepAt - serverNowMs);
|
|
259
|
+
const steps = Math.ceil((eligibleAt - nextSweepAt) / schedule.intervalMs);
|
|
260
|
+
const rotatesAt = nextSweepAt + steps * schedule.intervalMs;
|
|
261
|
+
return Math.max(0, rotatesAt - serverNowMs);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function buildSnapshot(storage: AuthStorage, refresher: AuthBrokerRefresher | undefined): SnapshotResponse {
|
|
265
|
+
const serverNowMs = Date.now();
|
|
266
|
+
const base = storage.exportSnapshot();
|
|
267
|
+
const { wire, nextSweepAt } = resolveRefresherSchedule(refresher, serverNowMs);
|
|
268
|
+
const credentials: SnapshotEntry[] = base.credentials.map(entry => ({
|
|
269
|
+
...entry,
|
|
270
|
+
rotatesInMs: computeRotatesInMs(entry, wire, nextSweepAt, serverNowMs),
|
|
271
|
+
}));
|
|
272
|
+
return {
|
|
273
|
+
generation: base.generation,
|
|
274
|
+
generatedAt: base.generatedAt,
|
|
275
|
+
serverNowMs,
|
|
276
|
+
refresher: wire,
|
|
277
|
+
credentials,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function serveSnapshot(
|
|
282
|
+
req: Request,
|
|
283
|
+
url: URL,
|
|
284
|
+
storage: AuthStorage,
|
|
285
|
+
gate: GenerationGate,
|
|
286
|
+
refresher: AuthBrokerRefresher | undefined,
|
|
287
|
+
peer: string,
|
|
288
|
+
): Promise<Response> {
|
|
289
|
+
await storage.reload();
|
|
290
|
+
let currentGeneration = storage.getGeneration();
|
|
291
|
+
const clientGeneration = parseGenerationTag(req.headers.get("if-none-match"));
|
|
292
|
+
const waitMs = parseWaitMs(url);
|
|
293
|
+
|
|
294
|
+
if (clientGeneration === undefined || currentGeneration !== clientGeneration || waitMs <= 0) {
|
|
295
|
+
const body = buildSnapshot(storage, refresher);
|
|
296
|
+
logger.info("auth-broker snapshot served", {
|
|
297
|
+
peer,
|
|
298
|
+
credentials: body.credentials.length,
|
|
299
|
+
generation: body.generation,
|
|
300
|
+
});
|
|
301
|
+
return json(200, body, snapshotHeaders(body.generation));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const delay = delayResult(waitMs);
|
|
305
|
+
const waitController = new AbortController();
|
|
306
|
+
const waitSignal = AbortSignal.any([req.signal, waitController.signal]);
|
|
307
|
+
const result = await Promise.race([gate.waitForChange(clientGeneration, waitSignal), delay.promise]);
|
|
308
|
+
delay.cancel();
|
|
309
|
+
waitController.abort();
|
|
310
|
+
if (result === "aborted" || req.signal.aborted) return empty(499, snapshotHeaders(currentGeneration));
|
|
311
|
+
|
|
312
|
+
await storage.reload();
|
|
313
|
+
currentGeneration = storage.getGeneration();
|
|
314
|
+
if (currentGeneration !== clientGeneration) {
|
|
315
|
+
const body = buildSnapshot(storage, refresher);
|
|
316
|
+
logger.info("auth-broker snapshot long-poll changed", {
|
|
317
|
+
peer,
|
|
318
|
+
credentials: body.credentials.length,
|
|
319
|
+
generation: body.generation,
|
|
320
|
+
});
|
|
321
|
+
return json(200, body, snapshotHeaders(body.generation));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
logger.info("auth-broker snapshot long-poll unchanged", { peer, generation: currentGeneration });
|
|
325
|
+
return empty(304, snapshotHeaders(currentGeneration));
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Stable per-credential fingerprint for SSE delta detection. Field order is
|
|
330
|
+
* fixed by this serializer (NOT by entry insertion order) so a credential
|
|
331
|
+
* built by two different paths still produces the same fingerprint.
|
|
332
|
+
*
|
|
333
|
+
* `rotatesInMs` is intentionally part of the fingerprint: when it shifts we
|
|
334
|
+
* want the client to recompute its `prepareForRequest` deadline rather than
|
|
335
|
+
* keep the stale projection.
|
|
336
|
+
*/
|
|
337
|
+
function fingerprintEntry(entry: SnapshotEntry): string {
|
|
338
|
+
return JSON.stringify([entry.id, entry.provider, entry.identityKey, entry.rotatesInMs, entry.credential]);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function sseEvent(event: string, body: unknown): string {
|
|
342
|
+
return `event: ${event}\ndata: ${JSON.stringify(body)}\n\n`;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function serveSnapshotStream(
|
|
346
|
+
req: Request,
|
|
347
|
+
storage: AuthStorage,
|
|
348
|
+
refresher: AuthBrokerRefresher | undefined,
|
|
349
|
+
peer: string,
|
|
350
|
+
keepaliveMs: number,
|
|
351
|
+
): Response {
|
|
352
|
+
const encoder = new TextEncoder();
|
|
353
|
+
const openedAt = Date.now();
|
|
354
|
+
const lastByCredId = new Map<number, string>();
|
|
355
|
+
let controller: ReadableStreamDefaultController<Uint8Array> | null = null;
|
|
356
|
+
let unsubscribe: (() => void) | null = null;
|
|
357
|
+
let keepaliveTimer: NodeJS.Timeout | undefined;
|
|
358
|
+
let abortHandler: (() => void) | null = null;
|
|
359
|
+
let processing = false;
|
|
360
|
+
let pendingBumps = 0;
|
|
361
|
+
let closed = false;
|
|
362
|
+
let lastGeneration = -1;
|
|
363
|
+
|
|
364
|
+
const cleanup = (): void => {
|
|
365
|
+
if (closed) return;
|
|
366
|
+
closed = true;
|
|
367
|
+
if (keepaliveTimer !== undefined) {
|
|
368
|
+
clearInterval(keepaliveTimer);
|
|
369
|
+
keepaliveTimer = undefined;
|
|
370
|
+
}
|
|
371
|
+
if (unsubscribe) {
|
|
372
|
+
unsubscribe();
|
|
373
|
+
unsubscribe = null;
|
|
374
|
+
}
|
|
375
|
+
if (abortHandler) {
|
|
376
|
+
req.signal.removeEventListener("abort", abortHandler);
|
|
377
|
+
abortHandler = null;
|
|
378
|
+
}
|
|
379
|
+
try {
|
|
380
|
+
controller?.close();
|
|
381
|
+
} catch {
|
|
382
|
+
// Already closed by Bun on client disconnect; harmless.
|
|
383
|
+
}
|
|
384
|
+
logger.info("auth-broker stream closed", { peer, durationMs: Date.now() - openedAt });
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
const write = (chunk: string): boolean => {
|
|
388
|
+
if (closed || !controller) return false;
|
|
389
|
+
try {
|
|
390
|
+
controller.enqueue(encoder.encode(chunk));
|
|
391
|
+
return true;
|
|
392
|
+
} catch (err) {
|
|
393
|
+
logger.debug("auth-broker stream enqueue failed", { peer, error: String(err) });
|
|
394
|
+
cleanup();
|
|
395
|
+
return false;
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
const processGenerationBump = async (): Promise<void> => {
|
|
400
|
+
if (closed) return;
|
|
401
|
+
if (processing) {
|
|
402
|
+
pendingBumps += 1;
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
processing = true;
|
|
406
|
+
try {
|
|
407
|
+
do {
|
|
408
|
+
pendingBumps = 0;
|
|
409
|
+
await storage.reload();
|
|
410
|
+
if (closed) return;
|
|
411
|
+
const snapshot = buildSnapshot(storage, refresher);
|
|
412
|
+
// Generation must move forward; a duplicate listener firing without a
|
|
413
|
+
// real bump is a no-op below (fingerprints unchanged).
|
|
414
|
+
if (snapshot.generation < lastGeneration) {
|
|
415
|
+
logger.warn("auth-broker stream generation went backwards", {
|
|
416
|
+
peer,
|
|
417
|
+
previous: lastGeneration,
|
|
418
|
+
current: snapshot.generation,
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
lastGeneration = snapshot.generation;
|
|
422
|
+
const seenIds = new Set<number>();
|
|
423
|
+
for (const entry of snapshot.credentials) {
|
|
424
|
+
seenIds.add(entry.id);
|
|
425
|
+
const fp = fingerprintEntry(entry);
|
|
426
|
+
if (lastByCredId.get(entry.id) === fp) continue;
|
|
427
|
+
lastByCredId.set(entry.id, fp);
|
|
428
|
+
const payload: SnapshotStreamEntryEvent = {
|
|
429
|
+
kind: "entry",
|
|
430
|
+
generation: snapshot.generation,
|
|
431
|
+
serverNowMs: snapshot.serverNowMs,
|
|
432
|
+
refresher: snapshot.refresher,
|
|
433
|
+
entry,
|
|
434
|
+
};
|
|
435
|
+
if (!write(sseEvent("entry", payload))) return;
|
|
436
|
+
logger.debug("auth-broker stream entry", {
|
|
437
|
+
peer,
|
|
438
|
+
id: entry.id,
|
|
439
|
+
provider: entry.provider,
|
|
440
|
+
generation: snapshot.generation,
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
for (const id of [...lastByCredId.keys()]) {
|
|
444
|
+
if (seenIds.has(id)) continue;
|
|
445
|
+
lastByCredId.delete(id);
|
|
446
|
+
const payload: SnapshotStreamRemovedEvent = {
|
|
447
|
+
kind: "removed",
|
|
448
|
+
generation: snapshot.generation,
|
|
449
|
+
serverNowMs: snapshot.serverNowMs,
|
|
450
|
+
refresher: snapshot.refresher,
|
|
451
|
+
id,
|
|
452
|
+
};
|
|
453
|
+
if (!write(sseEvent("removed", payload))) return;
|
|
454
|
+
logger.debug("auth-broker stream removed", { peer, id, generation: snapshot.generation });
|
|
455
|
+
}
|
|
456
|
+
} while (pendingBumps > 0 && !closed);
|
|
457
|
+
} finally {
|
|
458
|
+
processing = false;
|
|
459
|
+
}
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
463
|
+
async start(c) {
|
|
464
|
+
controller = c;
|
|
465
|
+
await storage.reload();
|
|
466
|
+
const initial = buildSnapshot(storage, refresher);
|
|
467
|
+
lastGeneration = initial.generation;
|
|
468
|
+
for (const entry of initial.credentials) lastByCredId.set(entry.id, fingerprintEntry(entry));
|
|
469
|
+
const initialEvent: SnapshotStreamSnapshotEvent = { kind: "snapshot", ...initial };
|
|
470
|
+
if (!write(sseEvent("snapshot", initialEvent))) return;
|
|
471
|
+
keepaliveTimer = setInterval(() => {
|
|
472
|
+
write(": keepalive\n\n");
|
|
473
|
+
}, keepaliveMs);
|
|
474
|
+
keepaliveTimer.unref?.();
|
|
475
|
+
unsubscribe = storage.onGenerationChanged(() => {
|
|
476
|
+
void processGenerationBump();
|
|
477
|
+
});
|
|
478
|
+
abortHandler = (): void => cleanup();
|
|
479
|
+
req.signal.addEventListener("abort", abortHandler);
|
|
480
|
+
logger.info("auth-broker stream opened", { peer, generation: initial.generation });
|
|
481
|
+
},
|
|
482
|
+
cancel() {
|
|
483
|
+
cleanup();
|
|
484
|
+
},
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
return new Response(stream, {
|
|
488
|
+
status: 200,
|
|
489
|
+
headers: {
|
|
490
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
491
|
+
"Cache-Control": "no-cache",
|
|
492
|
+
Connection: "keep-alive",
|
|
493
|
+
"X-Accel-Buffering": "no",
|
|
494
|
+
},
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** Boot the broker. Caller owns lifecycle; `handle.close()` to stop. */
|
|
499
|
+
export function startAuthBroker(opts: AuthBrokerServerOptions): AuthBrokerServerHandle {
|
|
500
|
+
const bind = parseBind(opts.bind ?? DEFAULT_AUTH_BROKER_BIND);
|
|
501
|
+
const tokens = new Set<string>(opts.bearerTokens);
|
|
502
|
+
const version = opts.version;
|
|
503
|
+
const streamKeepaliveMs = opts.streamKeepaliveMs ?? DEFAULT_STREAM_KEEPALIVE_MS;
|
|
504
|
+
|
|
505
|
+
const refresher = opts.disableRefresher
|
|
506
|
+
? undefined
|
|
507
|
+
: new AuthBrokerRefresher({
|
|
508
|
+
storage: opts.storage,
|
|
509
|
+
refreshSkewMs: opts.refreshSkewMs ?? DEFAULT_REFRESH_SKEW_MS,
|
|
510
|
+
refreshIntervalMs: opts.refreshIntervalMs ?? DEFAULT_REFRESH_INTERVAL_MS,
|
|
511
|
+
});
|
|
512
|
+
refresher?.start();
|
|
513
|
+
const generationGate = new GenerationGate(opts.storage);
|
|
514
|
+
|
|
515
|
+
const server = Bun.serve({
|
|
516
|
+
hostname: bind.hostname,
|
|
517
|
+
port: bind.port,
|
|
518
|
+
idleTimeout: DEFAULT_SERVER_IDLE_TIMEOUT_S,
|
|
519
|
+
fetch: async (req): Promise<Response> => {
|
|
520
|
+
const url = new URL(req.url);
|
|
521
|
+
const pathname = url.pathname;
|
|
522
|
+
const peer =
|
|
523
|
+
req.headers.get("x-forwarded-for")?.split(",")[0].trim() || req.headers.get("x-real-ip") || "unknown";
|
|
524
|
+
try {
|
|
525
|
+
if (req.method === "GET" && pathname === "/v1/healthz") {
|
|
526
|
+
const body: HealthzResponse = { ok: true, version };
|
|
527
|
+
return json(200, body);
|
|
528
|
+
}
|
|
529
|
+
if (!isAuthorized(req, tokens)) {
|
|
530
|
+
logger.info("auth-broker request unauthorized", { method: req.method, path: pathname, peer });
|
|
531
|
+
return json(401, { error: "unauthorized" });
|
|
532
|
+
}
|
|
533
|
+
if (req.method === "GET" && pathname === "/v1/snapshot/stream") {
|
|
534
|
+
return serveSnapshotStream(req, opts.storage, refresher, peer, streamKeepaliveMs);
|
|
535
|
+
}
|
|
536
|
+
if (req.method === "GET" && pathname === "/v1/snapshot") {
|
|
537
|
+
return serveSnapshot(req, url, opts.storage, generationGate, refresher, peer);
|
|
538
|
+
}
|
|
539
|
+
if (req.method === "GET" && pathname === "/v1/usage") {
|
|
540
|
+
try {
|
|
541
|
+
// AuthStorage caches usage reports internally with a 5-minute per-credential
|
|
542
|
+
// TTL (USAGE_REPORT_TTL_MS) so back-to-back widget polls re-use the
|
|
543
|
+
// last fetch instead of hitting provider endpoints repeatedly.
|
|
544
|
+
// `req.signal` propagates HTTP-client disconnects all the way to the
|
|
545
|
+
// per-caller cancel without touching the shared upstream fetch.
|
|
546
|
+
const reports = (await opts.storage.fetchUsageReports?.({ signal: req.signal })) ?? [];
|
|
547
|
+
// Drop the `raw` field — it's the provider-specific upstream body,
|
|
548
|
+
// large and unstable. Everything UI-relevant lives in `limits` and
|
|
549
|
+
// `metadata`.
|
|
550
|
+
const trimmed = reports.map(({ raw: _raw, ...rest }) => rest);
|
|
551
|
+
logger.info("auth-broker usage served", { peer, reports: trimmed.length });
|
|
552
|
+
return json(200, { generatedAt: Date.now(), reports: trimmed });
|
|
553
|
+
} catch (error) {
|
|
554
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
555
|
+
logger.warn("auth-broker usage fetch failed", { peer, error: message });
|
|
556
|
+
return json(502, { error: message });
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
const refreshMatch = req.method === "POST" ? pathname.match(REFRESH_ROUTE) : null;
|
|
560
|
+
if (refreshMatch) {
|
|
561
|
+
const id = Number.parseInt(refreshMatch[1], 10);
|
|
562
|
+
try {
|
|
563
|
+
const entry = await opts.storage.refreshCredentialById(id, req.signal);
|
|
564
|
+
const body: CredentialRefreshResponse = { entry };
|
|
565
|
+
logger.info("auth-broker credential refreshed", {
|
|
566
|
+
id,
|
|
567
|
+
provider: entry.provider,
|
|
568
|
+
peer,
|
|
569
|
+
expires: entry.credential.type === "oauth" ? entry.credential.expires : undefined,
|
|
570
|
+
});
|
|
571
|
+
return json(200, body);
|
|
572
|
+
} catch (error) {
|
|
573
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
574
|
+
logger.warn("auth-broker refresh failed", { id, peer, error: message });
|
|
575
|
+
const status = message.includes("No credential with id") ? 404 : 500;
|
|
576
|
+
return json(status, { error: message });
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
const disableMatch = req.method === "POST" ? pathname.match(DISABLE_ROUTE) : null;
|
|
580
|
+
if (disableMatch) {
|
|
581
|
+
const id = Number.parseInt(disableMatch[1], 10);
|
|
582
|
+
const parsed = await parseBody(req, credentialDisableRequestSchema, { allowEmpty: true });
|
|
583
|
+
if (!parsed.ok) return parsed.response;
|
|
584
|
+
const cause =
|
|
585
|
+
parsed.data.cause && parsed.data.cause.length > 0 ? parsed.data.cause : "disabled via auth-broker";
|
|
586
|
+
const ok = opts.storage.disableCredentialById(id, cause);
|
|
587
|
+
if (!ok) {
|
|
588
|
+
logger.info("auth-broker disable miss", { id, peer, cause });
|
|
589
|
+
return json(404, { error: `No credential with id=${id}` });
|
|
590
|
+
}
|
|
591
|
+
logger.info("auth-broker credential disabled", { id, peer, cause });
|
|
592
|
+
const response: CredentialDisableResponse = { ok: true };
|
|
593
|
+
return json(200, response);
|
|
594
|
+
}
|
|
595
|
+
if (req.method === "POST" && pathname === "/v1/credential") {
|
|
596
|
+
const parsed = await parseBody(req, credentialUploadRequestSchema);
|
|
597
|
+
if (!parsed.ok) return parsed.response;
|
|
598
|
+
const { provider, credential } = parsed.data;
|
|
599
|
+
try {
|
|
600
|
+
const entries = opts.storage.upsertCredential(provider, credential);
|
|
601
|
+
const identity =
|
|
602
|
+
credential.type === "oauth"
|
|
603
|
+
? (credential.email ?? credential.accountId ?? credential.projectId ?? "(no identity)")
|
|
604
|
+
: "(api key)";
|
|
605
|
+
logger.info("auth-broker credential upserted", {
|
|
606
|
+
provider,
|
|
607
|
+
type: credential.type,
|
|
608
|
+
identity,
|
|
609
|
+
peer,
|
|
610
|
+
providerTotal: entries.length,
|
|
611
|
+
});
|
|
612
|
+
const response: CredentialUploadResponse = { entries };
|
|
613
|
+
return json(200, response);
|
|
614
|
+
} catch (error) {
|
|
615
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
616
|
+
logger.warn("auth-broker upload failed", { provider, peer, error: message });
|
|
617
|
+
return json(500, { error: message });
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
return json(404, { error: `No route: ${req.method} ${pathname}` });
|
|
621
|
+
} catch (error) {
|
|
622
|
+
logger.error("auth-broker handler crashed", {
|
|
623
|
+
method: req.method,
|
|
624
|
+
path: pathname,
|
|
625
|
+
error: String(error),
|
|
626
|
+
});
|
|
627
|
+
return json(500, { error: "internal error" });
|
|
628
|
+
}
|
|
629
|
+
},
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
const boundHost = server.hostname ?? bind.hostname;
|
|
633
|
+
const boundPort = server.port ?? bind.port;
|
|
634
|
+
return {
|
|
635
|
+
url: `http://${boundHost}:${boundPort}`,
|
|
636
|
+
port: boundPort,
|
|
637
|
+
hostname: boundHost,
|
|
638
|
+
close: async () => {
|
|
639
|
+
refresher?.stop();
|
|
640
|
+
generationGate.close();
|
|
641
|
+
server.stop(true);
|
|
642
|
+
},
|
|
643
|
+
};
|
|
644
|
+
}
|