diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..702af69 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +* text=auto eol=lf +*.sh text eol=lf +*.png binary +*.jpg binary +*.ico binary diff --git a/.gitignore b/.gitignore index 9adce91..44732d4 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,12 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json .cache *.tsbuildinfo +# wrangler local state / build cache +.wrangler + +# playwright artifacts +.playwright-mcp + # IntelliJ based IDEs .idea @@ -34,3 +40,6 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json .DS_Store CLAUDE.md + +# claude local settings +.claude/settings.local.json diff --git a/README.md b/README.md index 3baa050..7bd58a9 100644 --- a/README.md +++ b/README.md @@ -1,86 +1,95 @@ # api-proxy -Minimal Cloudflare Workers that act as transparent reverse proxies for AI APIs. Each worker rewrites incoming requests to the upstream API and injects the API key server-side, so clients never need (or see) the key. +A single Cloudflare Worker that reverse-proxies the OpenAI, Anthropic, and Google Gemini APIs behind **revocable "doppelganger" tokens**. You issue tokens from an admin dashboard and hand them out; each token is validated server-side and swapped for the real provider key before the request is forwarded. Consumers never see your real keys, and you can scope or revoke any token at any time. -## Proxies +The consumer changes only **two things** in their normal SDK: the base URL (point at your worker) and the API key (use a doppelganger token). -| Proxy | Upstream | Config | Secret | +## How it works + +The doppelganger token rides in the SDK's normal auth slot. The worker reads it, validates it against KV, checks the token is scoped to the requested provider, strips every inbound auth header, sets the one real key, and forwards the request (path + query verbatim, streaming included). + +| Token arrives in | Provider | Upstream | Real key set as | |---|---|---|---| -| Claude | `api.anthropic.com` | `wrangler.claude.toml` | `ANTHROPIC_API_KEY` | -| Gemini | `generativelanguage.googleapis.com` | `wrangler.gemini.toml` | `GEMINI_API_KEY` | -| OpenAI | `api.openai.com` | `wrangler.openai.toml` | `OPENAI_API_KEY` | +| `Authorization: Bearer` | OpenAI | `api.openai.com` | `Authorization: Bearer` | +| `x-api-key` | Anthropic | `api.anthropic.com` | `x-api-key` | +| `x-goog-api-key` / `?key=` | Gemini | `generativelanguage.googleapis.com` | `x-goog-api-key` | +| `Authorization: Bearer` + path `/v1beta/openai/*` | Gemini (OpenAI-compat) | `generativelanguage.googleapis.com` | `Authorization: Bearer` | -## Setup +## Client setup + +Point the SDK's base URL at the worker and use a doppelganger token as the key: + +| SDK | base URL | key | +|---|---|---| +| OpenAI (Python / Node) | `https:///v1` | token | +| Anthropic (Python / Node) | `https://` (no `/v1`) | token | +| Google `@google/genai` (Node) | `httpOptions.baseUrl = https://` | token | +| Gemini from Python | point the **OpenAI** SDK at `https:///v1beta/openai` | token | ```bash -bun install -bunx wrangler login +# OpenAI-style +curl https:///v1/chat/completions \ + -H "authorization: Bearer " -H "content-type: application/json" \ + -d '{"model":"gpt-5.4","messages":[{"role":"user","content":"Hello"}]}' ``` -### Deploy +## Setup ```bash -# Claude -bunx wrangler secret put ANTHROPIC_API_KEY --config wrangler.claude.toml -bunx wrangler deploy --config wrangler.claude.toml +nub install +nubx wrangler login +nubx wrangler kv namespace create TOKENS # paste the id into wrangler.toml +``` -# Gemini -bunx wrangler secret put GEMINI_API_KEY --config wrangler.gemini.toml -bunx wrangler deploy --config wrangler.gemini.toml +Set the secrets (only these four; never committed): -# OpenAI -bunx wrangler secret put OPENAI_API_KEY --config wrangler.openai.toml -bunx wrangler deploy --config wrangler.openai.toml +```bash +nubx wrangler secret put OPENAI_API_KEY +nubx wrangler secret put ANTHROPIC_API_KEY +nubx wrangler secret put GEMINI_API_KEY +nubx wrangler secret put ADMIN_SECRET # password for the admin dashboard +nubx wrangler deploy ``` -### Usage +Optional plain vars (NOT secrets) override the upstreams; they default to the real hosts and only need setting for testing: `OPENAI_UPSTREAM`, `ANTHROPIC_UPSTREAM`, `GEMINI_UPSTREAM`. -Use the worker URL in place of the upstream API. No API key needed in the request: +## Admin dashboard + +Visit `https:///admin`, sign in with `ADMIN_SECRET`, and create tokens: give each a label, the providers it may use (OpenAI / Anthropic / Gemini), and either type a token or generate one. The token is shown **once** at creation — copy it then; only its SHA-256 hash is stored. Disable or delete any token instantly. + +## Security + +- Real provider keys are Cloudflare secrets, injected only into outbound requests — never in KV, never returned to callers. +- Tokens are stored as SHA-256 hashes; a KV/dashboard dump yields unusable hashes, not live tokens. +- The worker strips all inbound auth headers before setting the real key, so a doppelganger token is never forwarded upstream. +- Do not host the worker on a `*.openai.azure.com` / `*.cognitiveservices.azure.com` domain (the OpenAI SDK switches to Azure auth on those hostnames). + +## Testing + +Two tiers (Vitest): ```bash -# Claude -curl https://.workers.dev/v1/messages \ - -H "content-type: application/json" \ - -H "anthropic-version: 2023-06-01" \ - -d '{"model": "claude-sonnet-4-5-20250929", "max_tokens": 256, - "messages": [{"role": "user", "content": "Hello"}]}' - -# Gemini -curl https://.workers.dev/v1beta/models/gemini-3.1-flash-image-preview:generateContent \ - -H "content-type: application/json" \ - -d '{"contents": [{"parts": [{"text": "Hello"}]}]}' - -# OpenAI -curl https://.workers.dev/v1/chat/completions \ - -H "content-type: application/json" \ - -d '{"model": "gpt-5.4", "messages": [{"role": "user", "content": "Hello"}]}' +nub run test:unit # tier 1: proxy logic in workerd (vitest-pool-workers), fast CI gate +nub run test:compat # tier 2: real openai / @anthropic-ai/sdk / @google/genai SDKs vs a local worker + mock upstream +nub run test # both ``` +Tier 2 starts the real worker (`unstable_dev`) with `*_UPSTREAM` pointed at a `node:http` mock, seeds a token via the admin API, then drives each real SDK and asserts the forwarded request carries the real key (and never the token). + ## Disable / Enable -Toggle or schedule a worker URL without deleting it: +`schedule.sh` toggles the worker's `workers_dev` URL without deleting it: ```bash -./schedule.sh disable # disable Claude immediately -./schedule.sh --gemini enable # enable Gemini immediately -./schedule.sh disable 22:00 # disable Claude at 10pm today -./schedule.sh disable +30m # disable Claude in 30 minutes -./schedule.sh --gemini disable "2026-03-03 01:00" # disable Gemini on a specific date -./schedule.sh --openai disable 23:00 # disable OpenAI at 11pm +./schedule.sh disable # now +./schedule.sh disable +30m # in 30 minutes +./schedule.sh enable 22:00 # at 10pm ``` -Time is optional — omit it to run immediately. For `HH:MM`, if the time has already passed today it schedules for tomorrow. - ## Cost -Cloudflare Workers free tier covers this (100k requests/day, no credit card required). You only pay for API usage with the upstream providers. - -## Security - -- API keys are stored as Cloudflare secrets (encrypted at rest, never in code) -- Keys are only injected into outbound requests, never returned to callers -- **Note:** Worker URLs are unauthenticated — anyone with the URL can use your API key (without seeing it). Keep URLs private or add a bearer token check +Cloudflare Workers free tier covers this (100k requests/day). You only pay upstream providers for API usage. ## Contributing -Issues are welcome. PRs are not accepted and will be auto-closed. \ No newline at end of file +Issues are welcome. PRs are not accepted and will be auto-closed. diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..fd151d6 --- /dev/null +++ b/biome.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.5.0/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "ignoreUnknown": false, + "includes": [ + "**", + "!node_modules", + "!dist", + "!coverage", + "!.wrangler", + "!**/*.d.ts" + ] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2 + }, + "linter": { + "enabled": true, + "rules": { + "preset": "recommended", + "style": { + "noNonNullAssertion": "off" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "preset": "recommended" + } + } + } +} diff --git a/bun.lock b/bun.lock deleted file mode 100644 index 8181ed8..0000000 --- a/bun.lock +++ /dev/null @@ -1,210 +0,0 @@ -{ - "lockfileVersion": 1, - "configVersion": 1, - "workspaces": { - "": { - "name": "claude-proxy", - "dependencies": { - "wrangler": "latest", - }, - "devDependencies": { - "@cloudflare/workers-types": "latest", - "@types/bun": "latest", - }, - "peerDependencies": { - "typescript": "latest", - }, - }, - }, - "packages": { - "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.2", "", {}, "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ=="], - - "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.15.0", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": "1.20260301.1 || ~1.20260302.1 || ~1.20260303.1 || ~1.20260304.1 || >1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-EGYmJaGZKWl+X8tXxcnx4v2bOZSjQeNI5dWFeXivgX9+YCT69AkzHHwlNbVpqtEUTbew8eQurpyOpeN8fg00nw=="], - - "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260312.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-HUAtDWaqUduS6yasV6+NgsK7qBpP1qGU49ow/Wb117IHjYp+PZPUGReDYocpB4GOMRoQlvdd4L487iFxzdARpw=="], - - "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260312.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DOn7TPTHSxJYfi4m4NYga/j32wOTqvJf/pY4Txz5SDKWIZHSTXFyGz2K4B+thoPWLop/KZxGoyTv7db0mk/qyw=="], - - "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260312.1", "", { "os": "linux", "cpu": "x64" }, "sha512-TdkIh3WzPXYHuvz7phAtFEEvAxvFd30tHrm4gsgpw0R0F5b8PtoM3hfL2uY7EcBBWVYUBtkY2ahDYFfufnXw/g=="], - - "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260312.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-kNauZhL569Iy94t844OMwa1zP6zKFiL3xiJ4tGLS+TFTEfZ3pZsRH6lWWOtkXkjTyCmBEOog0HSEKjIV4oAffw=="], - - "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260312.1", "", { "os": "win32", "cpu": "x64" }, "sha512-5dBrlSK+nMsZy5bYQpj8t9iiQNvCRlkm9GGvswJa9vVU/1BNO4BhJMlqOLWT24EmFyApZ+kaBiPJMV8847NDTg=="], - - "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260317.1", "", {}, "sha512-+G4eVwyCpm8Au1ex8vQBCuA9wnwqetz4tPNRoB/53qvktERWBRMQnrtvC1k584yRE3emMThtuY0gWshvSJ++PQ=="], - - "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], - - "@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], - - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], - - "@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], - - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], - - "@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], - - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], - - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], - - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], - - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], - - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], - - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], - - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], - - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], - - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], - - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], - - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], - - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], - - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], - - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], - - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], - - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], - - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], - - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], - - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], - - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], - - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], - - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], - - "@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="], - - "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], - - "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], - - "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], - - "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], - - "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], - - "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], - - "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], - - "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], - - "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], - - "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], - - "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], - - "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], - - "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], - - "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], - - "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], - - "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], - - "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], - - "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], - - "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], - - "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], - - "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], - - "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], - - "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], - - "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], - - "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], - - "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], - - "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], - - "@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="], - - "@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="], - - "@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="], - - "@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="], - - "@speed-highlight/core": ["@speed-highlight/core@1.2.14", "", {}, "sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA=="], - - "@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="], - - "@types/node": ["@types/node@25.2.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="], - - "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], - - "bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="], - - "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], - - "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - - "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], - - "esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], - - "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - - "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], - - "miniflare": ["miniflare@4.20260312.1", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.18.2", "workerd": "1.20260312.1", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-YSWxec9ssisqkQgaCgcIQxZlB41E9hMiq1nxUgxXHRrE9NsfyC6ptSt8yfgBobsKIseAVKLTB/iEDpMumBv8oA=="], - - "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], - - "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], - - "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - - "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], - - "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], - - "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - - "undici": ["undici@7.18.2", "", {}, "sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw=="], - - "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - - "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="], - - "workerd": ["workerd@1.20260312.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260312.1", "@cloudflare/workerd-darwin-arm64": "1.20260312.1", "@cloudflare/workerd-linux-64": "1.20260312.1", "@cloudflare/workerd-linux-arm64": "1.20260312.1", "@cloudflare/workerd-windows-64": "1.20260312.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-nNpPkw9jaqo79B+iBCOiksx+N62xC+ETIfyzofUEdY3cSOHJg6oNnVSHm7vHevzVblfV76c8Gr0cXHEapYMBEg=="], - - "wrangler": ["wrangler@4.74.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.2", "@cloudflare/unenv-preset": "2.15.0", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", "miniflare": "4.20260312.1", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260312.1" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260312.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-3qprbhgdUyqYGHZ+Y1k0gsyHLMOlLrKL/HU0LDqLlCkbsKPprUA0/ThE4IZsxD84xAAXY6pv5JUuxS2+OnMa3A=="], - - "ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], - - "youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="], - - "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], - } -} diff --git a/docs/learnings/README.md b/docs/learnings/README.md new file mode 100644 index 0000000..760468f --- /dev/null +++ b/docs/learnings/README.md @@ -0,0 +1,13 @@ +# Learnings + +A running log of project-specific knowledge worth not rediscovering. One topic per file, kept short. + +Write a file for anything **non-general**: a gotcha, a constraint, a decision and its why, a platform or +API quirk we hit. The bar is "non-obvious and specific to this project," not "it changed the product +direction." Skip general/common knowledge anyone would already have. Don't rewrite history; append. + +Each file: the problem, what we found, and the decision we keep. + +- [openai-egress-geo-block.md](openai-egress-geo-block.md) - why OpenAI 403'd ~40% of the time, and the North-America-pinned Durable Object that fixes it +- [provider-routing-by-auth-header.md](provider-routing-by-auth-header.md) - one base URL, no path prefix; route by which auth slot the SDK used +- [doppelganger-token-security.md](doppelganger-token-security.md) - how a shareable token rides the SDK's auth slot without ever leaking the real key diff --git a/docs/learnings/doppelganger-token-security.md b/docs/learnings/doppelganger-token-security.md new file mode 100644 index 0000000..9222f81 --- /dev/null +++ b/docs/learnings/doppelganger-token-security.md @@ -0,0 +1,47 @@ +# Doppelganger token security + +## Idea + +A "doppelganger" token is a shareable, revocable stand-in for a real provider key. The holder puts it +in the normal SDK auth slot; the proxy validates it, then swaps in the real key. You can hand someone +access without exposing your OpenAI/Anthropic/Gemini key, and revoke it any time. + +## Request flow + +``` +client SDK Worker upstream +────────── ────── ──────── +auth slot = ──▶ extract token from auth slot + look up SHA-256(token) in KV + ├─ not found / disabled ──▶ 401 + ├─ provider not in scope ──▶ 403 + └─ ok: + strip ALL auth headers + set ONE real key ──────────────────▶ api..com + (token never sent on) +``` + +## The decisions that keep it safe + +- **Token rides the SDK's auth slot.** No custom header, no path change. The client only swaps base URL + and key. The proxy reads the token from whichever slot routing matched + (see [provider-routing-by-auth-header.md](provider-routing-by-auth-header.md)). + +- **Strip-all-then-set-one.** Before forwarding, delete *every* inbound auth header + (`authorization`, `x-api-key`, `x-goog-api-key`) and set exactly one with the real key + (`src/proxy.ts` `swapAuth`). This guarantees the doppelganger token is never forwarded upstream, + even if a client sends it in an unexpected slot. A test asserts the token never appears in any + outbound auth header. + +- **Hashed at rest.** Tokens are stored as `SHA-256(token)` and shown to the admin exactly once at + creation. The KV value never contains the plaintext. + +- **Revoke-safe `lastUsed`.** Usage timestamps live in a separate `:lu` key, not in the token + record. Stamping "last used" on a hot path can therefore never recreate or re-enable a record that + was deleted or disabled - a revoked token stays revoked. + +## Scope (v1) + +Per-token: label, provider scope, enable/disable, revoke, last-used. Rate limits, spend caps, expiry, +and per-token analytics are deliberately deferred - the data model leaves room without carrying the +weight now. diff --git a/docs/learnings/openai-egress-geo-block.md b/docs/learnings/openai-egress-geo-block.md new file mode 100644 index 0000000..405757d --- /dev/null +++ b/docs/learnings/openai-egress-geo-block.md @@ -0,0 +1,81 @@ +# OpenAI egress geo-block + +## Problem + +Through the Worker, OpenAI returned `403 unsupported_country_region_territory` intermittently +(~40% of requests). Anthropic was always fine. A valid key, correct path, correct auth. + +## The mechanism in one picture + +``` + ┌─────────── Cloudflare ───────────┐ +client ──▶│ Worker runs in the colo nearest │ + │ the client; fetch() egresses from│ + │ THAT colo (fixed per invocation) │ + └───────┬───────────────┬──────────┘ + │ │ + egress via SIN egress via HKG + │ │ + ▼ ▼ + OpenAI 200 OpenAI 403 ← "unsupported_country_region_territory" + (HKG is a region OpenAI does not serve) +``` + +Roughly 40% of invocations happened to egress via HKG, hence the ~40% failure. + +## What we found + +- Single probes of `GET /v1/models`, `POST /v1/chat/completions`, and streaming all succeeded, so + egress was not blanket-blocked. +- Hammering the same endpoint exposed the ~40% failure, and the failures correlated **100%** with + the Cloudflare egress colo: every request that egressed via **Hong Kong (HKG) returned 403**, every + one via **Singapore (SIN) returned 200**. It is OpenAI's country geo-restriction (HKG/CN unsupported), + not an IP-reputation or bot block. Anthropic works because it does not geo-block those regions. +- A Worker's `fetch()` egresses from the colo the invocation runs in, and that colo varies per request. +- The egress colo is **pinned per invocation**: six sequential subrequests inside one invocation always + hit the same colo. So an in-invocation **retry cannot escape a bad colo** - it just re-hits HKG. The + colo only re-rolls across separate invocations. + +## What does NOT fix it + +- **Smart Placement** and **`placement.region`** optimize *execution* location for *latency*, not + *egress country*. They have no notion of "OpenAI-supported region," can leave a Worker in HKG, and + for a single-subrequest proxy may not relocate at all. Community reports confirm they don't fix this. +- **Dedicated Egress IPs / Regional Services** would pin egress region, but they are paid/Enterprise. +- A **third-party relay** in a supported region (Vercel `iad1`, free VPS) works, but routes the real + OpenAI key through another host - rejected on the "key never leaves Cloudflare" rule. + +## The fix + +Route **only the OpenAI hop** through a Durable Object pinned to North America with +`locationHint:"wnam"` (`src/egress.ts`). The DO runs in a US colo, so its `fetch()` egresses from an +OpenAI-supported region. It is wired as a **fallback**, not the default path: try the fast edge fetch +first and re-issue through the DO **only on the geo-403** (`src/proxy.ts`). The request body is buffered +for OpenAI so it can be replayed to the DO. + +``` +OpenAI request + │ + ▼ + direct edge fetch() ──── 200 ──▶ return (fast path, ~60%) + │ + geo-403? + │ yes + ▼ + re-issue through US-pinned DO (locationHint:"wnam") + │ + ▼ + DO runs in a US colo ─▶ fetch() egresses US ─▶ OpenAI 200 ─▶ return +``` + +Why this shape: +- The real key never leaves Cloudflare. +- Free on the Workers Free plan (SQLite-backed Durable Object). +- The ~60% of OpenAI calls that already egress from a good colo stay fast (no extra hop); Anthropic and + Gemini are untouched. + +## Result + +Post-fix stress test: 25/25 `200`, 0 `403`. The DO's egress colos verified as US (DFW/LAX/DEN/SJC/SEA). +Streaming survives the fallback. If another provider ever shows the same geo-403, apply the same DO +pattern by extending the `coarse(provider) === "openai"` branch. diff --git a/docs/learnings/provider-routing-by-auth-header.md b/docs/learnings/provider-routing-by-auth-header.md new file mode 100644 index 0000000..d56c11f --- /dev/null +++ b/docs/learnings/provider-routing-by-auth-header.md @@ -0,0 +1,47 @@ +# Provider routing by auth header + +## Problem + +One consolidated base URL has to transparently serve OpenAI, Anthropic, and Gemini. The goal: a client +changes only its base URL and API key, nothing else. So the proxy must decide which upstream a request +is for without the client adding a path prefix or custom header. + +## What we found + +Each SDK already announces its provider by *which auth slot it populates*: + +| Inbound signal | Provider | +|---|---| +| `Authorization: Bearer ...` | OpenAI | +| `Authorization: Bearer ...` + path starts `/v1beta/openai/` | Gemini (OpenAI-compat) | +| `x-api-key` | Anthropic | +| `x-goog-api-key` or `?key=` | Gemini | + +So routing reads the auth slot, not a path prefix (`src/proxy.ts` `routeProvider`): + +``` +inbound request + │ + ├─ has x-api-key? ──▶ Anthropic + ├─ has x-goog-api-key? ──▶ Gemini + ├─ has Authorization: Bearer? + │ ├─ path /v1beta/openai/* ──▶ Gemini (OpenAI-compat) + │ └─ else ──▶ OpenAI + ├─ has ?key= ? ──▶ Gemini + └─ none ──▶ 401 +``` + +## Why not a path prefix (e.g. `/openai/...`) + +- It would break Gemini, whose file-upload flow returns absolute `x-goog-upload-url` paths the client + then calls directly; a prefix scheme can't survive that round trip. +- It would force every client to rewrite the SDK's own base path, defeating the "change only base URL + + key" promise. + +Auth-slot routing keeps each SDK's native path intact, so it stays a true drop-in. + +## Decision we keep + +Route by auth header. The token is extracted from the same slot, validated, and then **all** inbound +auth headers are stripped and exactly one real key is set for the chosen provider (see +[doppelganger-token-security.md](doppelganger-token-security.md)). diff --git a/docs/superpowers/specs/2026-06-22-api-proxy-doppelganger-tokens-design.md b/docs/superpowers/specs/2026-06-22-api-proxy-doppelganger-tokens-design.md new file mode 100644 index 0000000..69a4b46 --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-api-proxy-doppelganger-tokens-design.md @@ -0,0 +1,221 @@ +# api-proxy — Doppelganger Tokens Design + +- **Date:** 2026-06-22 +- **Status:** Approved; implementation in progress +- **Scope:** Replace the three transparent reverse-proxy workers with one token-gated worker plus an embedded admin dashboard. Issue shareable, revocable "doppelganger" API-key tokens that map to real provider keys server-side. + +> Naming: there is no "v1/v2" product split. This is the `api-proxy` project evolving. The new token-gated worker deploys under its own worker name so it can run alongside the existing transparent proxies during validation; the old `src/{claude,openai,gemini}.ts` files and their tomls are deleted once the new worker is reliable. + +--- + +## 1. Problem + +The current proxy is three minimal workers (`src/openai.ts`, `src/claude.ts`, `src/gemini.ts`). Each host-rewrites the request to one upstream and injects a single shared real key. The worker URLs are **unauthenticated** — anyone with a URL uses the owner's real key, with no per-user access control and no revocation. + +We want: hand someone a token they plug into their normal SDK (changing only the base URL + the key), have it work, and be able to revoke or scope that token at any time from a dashboard — all without exposing the real provider keys. + +## 2. Goals + +- A consumer uses their existing SDK by changing **two things**: the base URL (point at the worker) and the API key (use a doppelganger token). +- The owner mints, scopes (per provider), disables, and deletes tokens from an admin dashboard. +- Real provider keys never leave the worker and never live in KV. +- Support OpenAI, Anthropic, and Google Gemini, including streaming, for server-side SDK usage. + +## 3. Non-goals (deferred — see §11) + +Rate limits, spend/token caps, expiry dates, per-token usage analytics, browser/CORS support, Gemini file uploads, multiple real keys per provider, instant (sub-minute) revocation. + +## 4. Locked decisions + +| Decision | Choice | Why | +|---|---|---| +| Mechanism | Doppelganger token = the API key the SDK already sends. Worker reads it from the auth header, validates against KV, swaps in the real key. | Unifies "shareable key" and "common-ground SDK config"; client changes only base URL + key. | +| Topology | One worker, one base URL, **no provider path prefix**. Provider routing by which auth header the token arrives in (+ path for Gemini OpenAI-compat). | A `/openai` `/anthropic` `/gemini` prefix breaks Gemini native file uploads. The auth header already identifies the provider. | +| Architecture | **Single worker, embedded.** Top-level dispatch: `/admin/*` → Hono admin sub-app (wrapped in try/catch); everything else → framework-free proxy hot-path. | Proxy requests pay zero routing/SSR cost. Avoids two deploys / two secret sets / broken `schedule.sh`. Escape hatch: move `src/admin/*` to a second worker on the same KV namespace if it ever outgrows CRUD. | +| Token storage | Store **SHA-256(token)** in KV; show plaintext once at creation; dashboard shows label + last-4. | Foundational, hard to retrofit. A KV/dashboard dump yields unusable hashes. Standard practice. | +| Real keys | One real key per provider, env **secret**, shared by all tokens for that provider. | Token is an access/revocation handle, not a routing key to different accounts. | +| Admin stack | **Hono + JSX fragments + HTMX 2.x** (HTMX loaded from CDN, browser-side only — zero bytes in the Worker bundle). Pin HTMX 2.x (4.0 is alpha; do not adopt). | Concise, embeds as a one-line sub-app, ~14KB. Heavy frameworks (SvelteKit/Astro/React Router) own the entrypoint, force a 2nd worker, and add 50-500KB+. | +| Test runner | **Vitest `^4.1.0`** + `@cloudflare/vitest-pool-workers` (0.16.x). nub has no built-in runner; `nub run test` invokes vitest. | Cloudflare-supported path; runs inside workerd. | +| Package manager | **nub** (`nubjs.com`); lockfile `lock.yaml` (pnpm v9 format). | Project standard. | + +## 5. Architecture & module layout + +One worker, dispatched by path: + +``` +fetch(req, env, ctx): + if pathname startsWith "/admin": try { adminApp.fetch(req, env, ctx) } catch { 500 } + else: proxyHandler(req, env, ctx) +``` + +- **State:** one KV namespace, `TOKENS`. Key = `SHA-256(token)` (hex). Value = `TokenMetadata` (§7). +- **Secrets** (only these four): `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`, `ADMIN_SECRET`. Never in KV, never returned to callers. +- **Plain vars (NOT secrets):** `OPENAI_UPSTREAM`, `ANTHROPIC_UPSTREAM`, `GEMINI_UPSTREAM` — default to the real hosts; overridable for tests (§6, §Testing). + +``` +src/ + index.ts # fetch entry + dispatch + proxy.ts # ZERO framework deps: extractToken, routeProvider, swapAuth, stream passthrough. MUST NOT import Hono. + tokens.ts # KV helpers: sha256hex, generateToken (dgk_ + 32 base64url), create, list, getValidated, update, delete, touchLastUsed + upstreams.ts # UPSTREAM resolver: reads *_UPSTREAM env with real-host defaults; parses protocol+hostname+port (the test seam) + types.ts # shared TokenMetadata, Provider types (imported by proxy + admin) + admin/ + index.ts # new Hono(); mounts routes; HMAC cookie auth middleware + routes.tsx # GET/POST/PUT/DELETE /admin/api/tokens (HTML fragments); GET /admin; /admin/login, /admin/logout + layout.tsx # shell; loads HTMX 2.x from CDN; minimal inline CSS (no Tailwind JIT) + components.tsx # , , , +test/ + proxy.test.ts # TIER 1 (vitest-pool-workers) + sdk-compat/ + setup.ts # TIER 2 harness: mock upstream (node:http) + unstable_startWorker + capture helpers + openai.test.ts + anthropic.test.ts + gemini.test.ts +vitest.config.ts # cloudflare pool (tier 1) +vitest.compat.config.ts # node pool, --pool=forks (tier 2) +wrangler.toml # ONE config: name='api-proxy', [[kv_namespaces]] binding='TOKENS' +schedule.sh # kept +``` + +Deleted once the new worker is verified reliable: `src/openai.ts`, `src/claude.ts`, `src/gemini.ts`, `wrangler.openai.toml`, `wrangler.claude.toml`, `wrangler.gemini.toml`. + +## 6. Request flow (proxy path) + +``` +1. extractToken(req, url) + x-api-key || x-goog-api-key || Authorization: "Bearer X" -> X || ?key= + none -> 401 "missing token" +2. provider = routeProvider(req, url) // §6.1 +3. rec = getValidated(KV, sha256hex(token)) + miss || status != "active" -> 401 "invalid or revoked token" +4. coarse(provider) not in rec.providers -> 403 "token not allowed for provider" +5. swapAuth: delete x-api-key, x-goog-api-key, authorization; set the ONE real key +6. resolve upstream: u = parse(UPSTREAM[provider]); url.protocol=u.protocol; url.hostname=u.hostname; url.port=u.port + if provider startsWith "gemini": url.searchParams.delete("key") +7. fetch(new Request(url, { method, headers, body })) // path + query verbatim +8. return new Response(upstream.body, upstream) // stream straight through, no buffering +9. ctx.waitUntil(touchLastUsed(KV, hash)) // fire-and-forget +``` + +### 6.1 Provider routing table + +| Token arrives in | + path signal | Provider | Upstream (default, overridable) | Real key set as | +|---|---|---|---|---| +| `x-api-key` | — | `anthropic` | `ANTHROPIC_UPSTREAM` = `api.anthropic.com` | `x-api-key` | +| `x-goog-api-key` or `?key=` | — | `gemini` | `GEMINI_UPSTREAM` = `generativelanguage.googleapis.com` | `x-goog-api-key` | +| `Authorization: Bearer` | path starts `/v1beta/openai/` | `gemini-openai` | `GEMINI_UPSTREAM` | `Authorization: Bearer` | +| `Authorization: Bearer` | else | `openai` | `OPENAI_UPSTREAM` = `api.openai.com` | `Authorization: Bearer` | + +`routeProvider` returns one of `openai | anthropic | gemini | gemini-openai`. The provider-scope check (step 4) and the token's `providers` array use the coarse set `openai | anthropic | gemini`, so `gemini-openai` maps to the `gemini` scope. The `gemini` vs `gemini-openai` distinction only selects the swap branch in §6.2. + +### 6.2 Auth swap (security linchpin) + +Always **strip all three** inbound auth headers, then set exactly one: + +```ts +headers.delete("x-api-key"); +headers.delete("x-goog-api-key"); +headers.delete("authorization"); +switch (provider) { + case "openai": headers.set("authorization", `Bearer ${realKey}`); break; + case "anthropic": headers.set("x-api-key", realKey); break; + case "gemini": headers.set("x-goog-api-key", realKey); break; + case "gemini-openai": headers.set("authorization", `Bearer ${realKey}`); break; +} +``` + +Stripping-all-then-setting-one prevents the doppelganger token leaking upstream and closes the Anthropic dual-header leak and the duplicate-`x-goog-api-key` 401. + +## 7. Token model & lifecycle + +KV: `SHA-256(token)` (hex) → + +```ts +type TokenMetadata = { + label: string; + last4: string; + providers: ("openai" | "anthropic" | "gemini")[]; + status: "active" | "disabled"; + createdAt: string; // ISO + lastUsed?: string; // ISO + // reserved for Later (absent in v1): expiresAt, limits, spend +}; +``` + +- **Create:** admin supplies label + provider scopes, types a token or clicks generate (`dgk_` + 32 random base64url from `crypto.getRandomValues`). Worker stores `sha256hex(token) -> metadata`, returns the **plaintext once**. Never retrievable again. +- **List/Update/Delete:** by hash. Update edits label, providers, status (`active` ⇄ `disabled`). +- **last-used:** fire-and-forget KV write on each successful proxied request. + +> Revocation latency: KV is eventually consistent (~up to 60s). Acceptable for v1; instant revocation via Durable Object is a Later item. + +## 8. Admin dashboard + +Embedded **Hono** sub-app, server-rendered **JSX fragments + HTMX 2.x** (HTMX from CDN; no client JS we write; no UI/charting libs in the bundle). + +- **Auth:** single `ADMIN_SECRET` password → `POST /admin/login` sets an HMAC-SHA256-signed cookie `cm_admin=.`, `HttpOnly; SameSite=Strict; Max-Age=86400`. Middleware guards all `/admin/*` except login. +- **Routes:** HTMX-driven CRUD — `hx-get/post/put/delete` on `/admin/api/tokens`, `hx-swap="outerHTML"` on rows for create/edit/delete/enable-disable. `GET /admin` dashboard, `GET /admin/logout`. +- **UI:** add-token card (token field + generate, label, provider checkboxes), token table (label, last-4, provider pills, created, last-used, edit/delete). Plaintext token shown once after creation. +- **Blast-radius:** the dispatcher wraps `adminApp.fetch` in try/catch returning a plain 500, so an admin bug can never crash the proxy branch. + +## 9. Real key handling + +`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY` are Cloudflare secrets resolved at request time and injected only into the outbound request. Never logged, never in KV, never in a response body. + +## 10. Gotchas handled in v1 + +1. **SSE streaming** — pass `upstream.body` straight through; never `await response.text()`. Preserve upstream `cache-control: no-transform`; no response-buffering features; ensure the zone isn't re-compressing `text/event-stream`. +2. **Verbatim path + query forwarding** — only protocol/host/port are rewritten; path and query (incl. Gemini `?alt=sse`) stay intact. +3. **Strip-all-then-set-one auth** (§6.2). +4. **Gemini `?key=` hygiene** — keep `url.searchParams.delete("key")` for raw-REST/curl callers. +5. **Hostname** — do not issue worker hostnames ending in `.openai.azure.com`, `.services.ai.azure.com`, `.cognitiveservices.azure.com`. + +## 11. Deferred (Later) + +KV value shape + module boundaries leave room; none built now: rate limits, spend/token caps (parse `usage`; OpenAI needs `stream_options.include_usage`), expiry, per-token analytics, browser/CORS preflight, Gemini file uploads (forward `x-goog-upload-url`), multiple real keys per provider, instant revocation + atomic counters via Durable Object. + +## 12. Testing (two-tier harness) + +Do not test proxy logic and real-SDK HTTP behavior with one tool — conflating them is the main flakiness source. + +**Tier 1 — proxy logic (always-on CI gate, ~1s):** `@cloudflare/vitest-pool-workers` inside workerd (`vitest.config.ts`). +- Seed KV directly: `env.TOKENS.put(sha256hex(token), JSON.stringify(meta))`. +- Capture the outbound call with `vi.spyOn(globalThis, "fetch")`; assert: (a) right upstream host, (b) real key swapped in AND doppelganger token absent (`.not.toContain(token)` on all three header slots), (c) path+query verbatim, (d) 401 on missing/invalid/revoked, 403 on provider-scope mismatch. +- SSE: mocked fetch returns a `ReadableStream` `text/event-stream`; drive via `createExecutionContext()`/`waitOnExecutionContext()`; read `response.body.getReader()` chunk-by-chunk; assert content-type preserved and chunks un-buffered. Tier 1 does not use the upstream env seam (it mocks fetch entirely). + +**Tier 2 — real-SDK compatibility (feature-branch + pre-deploy, ~10-20s):** `vitest.compat.config.ts`, Node pool, `--pool=forks`, serial. +- `wrangler unstable_startWorker` starts a real HTTP listener (not the deprecated `unstable_dev`). +- A `node:http` mock upstream captures the raw inbound request; point the worker's `*_UPSTREAM` env at it (the seam earns its keep). +- Seed the token via the worker's own `POST /admin/api/tokens` (also exercises create). +- Run the real `openai`, `@anthropic-ai/sdk`, `@google/genai` packages with `baseURL` = local worker and `apiKey` = doppelganger token. Assert on the captured request: real key present, doppelganger absent, exact path the SDK constructed (catches `:generateContent`, `/v1beta/openai`). +- SSE: mock writes `text/event-stream` chunks; consume via the SDK's own stream iterator; assert on connection-start headers, not the buffered body (avoids mid-stream-disconnect races). + +**Flakiness guards:** Tier 2 serial (shared mutable capture state + port); never `await body.text()/json()` in the proxy path; use the SDK's stream iterator for completion, not `setTimeout`. + +## 13. Dev commands (nub) & rollout + +``` +nub install # install all deps (CI: nub ci) +nub add hono # runtime dep +nub add -E -D vitest@^4.1.0 @cloudflare/vitest-pool-workers @cloudflare/workers-types +nub add -E -D openai @anthropic-ai/sdk @google/genai # tier-2 SDKs +nub run dev # "dev": wrangler dev (or: nubx wrangler dev) +nub run test:unit # vitest run --config vitest.config.ts +nub run test:compat # vitest run --config vitest.compat.config.ts --pool=forks +nub run test # test:unit && test:compat +``` + +Rollout: (1) build under `name = "api-proxy"` (distinct from the old `*-proxy` workers); (2) create KV namespace, set the four secrets, mint a test token, verify each SDK end-to-end incl. streaming; (3) once reliable, delete the three old `src/*.ts` + tomls. **README is stale** (shows `bun`/`bunx`) — update to nub + `lock.yaml`. + +## 14. Bloat-watch (single-worker discipline) + +The single-worker choice is only safe with discipline: +- `proxy.ts` MUST NOT import Hono or admin code — the hot-path stays framework-free pure functions. +- No npm UI/component libraries, charting libs, or Tailwind JIT output in the admin — server-rendered HTML + HTMX attributes + minimal inline CSS only (these bundle into the same worker and inflate cold-start). +- Keep the upstream seam to exactly three vars with real-host defaults; do not generalize into a routing/rewrite config. +- Pin HTMX 2.x; pin vitest `^4.1.0` with current `@cloudflare/vitest-pool-workers` 0.16.x. + +## 15. Open items to verify during implementation + +- Exact base-URL strings per SDK (esp. Vercel AI SDK Anthropic, whose provider default includes `/v1`) — confirmed at test time; the worker forwards verbatim so it's robust either way. +- Gemini OpenAI-compat coverage (`/v1beta/openai/chat/completions`) before documenting it as the recommended Python-Gemini path. +- KV propagation delay — measure to decide whether a short in-worker cache + bust-on-revoke is worth adding before the Durable-Object Later item. diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 0000000..27a28f6 --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,8 @@ +# Biome pre-commit, per https://biomejs.dev/recipes/git-hooks/#lefthook +# (nubx instead of npx; safe fixes only at commit time - no --unsafe) +pre-commit: + commands: + check: + glob: "*.{js,ts,cjs,mjs,d.cts,d.mts,jsx,tsx,json,jsonc,css}" + run: nubx biome check --write --no-errors-on-unmatched --files-ignore-unknown=true {staged_files} + stage_fixed: true diff --git a/lock.yaml b/lock.yaml new file mode 100644 index 0000000..44d06a9 --- /dev/null +++ b/lock.yaml @@ -0,0 +1,2625 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + hono: + specifier: ^4.12.25 + version: 4.12.25 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + wrangler: + specifier: ^4.100.0 + version: 4.100.0(@cloudflare/workers-types@4.20260615.1) + devDependencies: + '@anthropic-ai/sdk': + specifier: ^0.104.1 + version: 0.104.1(zod@3.25.76) + '@biomejs/biome': + specifier: ^2.5.0 + version: 2.5.0 + '@cloudflare/vitest-pool-workers': + specifier: ^0.16.18 + version: 0.16.18(@vitest/runner@4.1.9)(@vitest/snapshot@4.1.9)(vitest@4.1.9(@types/node@25.9.3)(vite@8.0.16(@types/node@25.9.3))) + '@cloudflare/workers-types': + specifier: ^4.20260615.1 + version: 4.20260615.1 + '@google/genai': + specifier: ^2.8.0 + version: 2.8.0 + lefthook: + specifier: ^2.1.9 + version: 2.1.9 + openai: + specifier: ^6.42.0 + version: 6.42.0(ws@8.21.0)(zod@3.25.76) + vitest: + specifier: ^4.1.9 + version: 4.1.9(@types/node@25.9.3)(esbuild@0.28.1)(vite@8.0.16(@types/node@25.9.3)) + +packages: + + '@anthropic-ai/sdk@0.104.1': + resolution: {integrity: sha512-gGACa/+IaiXzRRmF96aOhamoBgapKRBiFWbmmTFP8aMkpaEcuStF+Q61bjo4vPxBM7gqWJNZqsngslRdnLHv0Q==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@biomejs/biome@2.5.0': + resolution: {integrity: sha512-4kURkd9hAPrdDM3C9n82ycYgx8hvQcW6MjKTEejruj8rK0N8P3OPpdy8BvI8kt3KWY4ycF5XtDOrktetEfhfuw==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.5.0': + resolution: {integrity: sha512-Mn3Fwi3SA5fgmfCPqmzpWF2DLZnms3BVAhM088nTnGrTZmHS3wwIjcoZPqpXeNgd3DrrLH6xp8vTLIBuJoZiXw==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.5.0': + resolution: {integrity: sha512-rg3VPL5P8mYro6pqlXYXuJWph21slVp3SZtAqWSrkZs40d2gTzYmHF8E/X1iTID25btmNKltNDJ926sqVBp7DQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.5.0': + resolution: {integrity: sha512-vQdM4oSGaf7ZNeGO9w5+Y8SBtyser9M6znxYbm7Ec8wInxJu1WiKxFYZW5Auj2d80bcVvefuGGRxoFOE0eee8g==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-arm64@2.5.0': + resolution: {integrity: sha512-tl+LW8fdD96/xdeWtWwc82LIOc5CoY7N2AsogLTp5R4ECErYt+8Jl/N68ezN9vzSiqPTxw6vjcihoLPYKZHrlw==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-linux-x64-musl@2.5.0': + resolution: {integrity: sha512-+9hIcMngJ+yGUahXqZuZ8CoWKJE9SAZsFsM3QDvXpNsLbXZ9lqVzgBhOk/jTSYkOA0GLP9eu3teukqpLUojHMg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-x64@2.5.0': + resolution: {integrity: sha512-zpEGf4RQbFEh8Vt7OmavLyyOzRbtcE9osCqrS1kfvt8jDvxwhKXLSf7n0ebr/ov0RJ9ssP+lhs6C8a9WwFvrQA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-win32-arm64@2.5.0': + resolution: {integrity: sha512-jB0wAvTLI4itx5VidqVUejPQFhRUxiZ9l9FvZ26D5fl6t3qme+ZB4PD3bTSeL1vZ8NI2Rx/zj6H9zcESuGHKGw==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.5.0': + resolution: {integrity: sha512-VT/lF+GId+67j8aDfLkxdxNoVApsPSTbyAtB3jJq0IWTrY77WXfbPfpngxq0bA6JCEv/7k8C9qWjDRKRznDlyw==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + + '@cloudflare/kv-asset-handler@0.5.0': + resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} + engines: {node: '>=22.0.0'} + + '@cloudflare/unenv-preset@2.16.1': + resolution: {integrity: sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==} + peerDependencies: + unenv: 2.0.0-rc.24 + workerd: '>1.20260305.0 <2.0.0-0' + peerDependenciesMeta: + workerd: + optional: true + + '@cloudflare/vitest-pool-workers@0.16.18': + resolution: {integrity: sha512-TEktXyevK9lkTWouElbIcDPK3YEfV+Szqgnlq5sNk+KYZR3LiDdYDaGNmUYgiT2LiiFeGU2yzCrcgmN8mJhqWQ==} + peerDependencies: + '@vitest/runner': ^4.1.0 + '@vitest/snapshot': ^4.1.0 + vitest: ^4.1.0 + + '@cloudflare/workerd-darwin-64@1.20260611.1': + resolution: {integrity: sha512-iJICldmi4sBGgi7IrQles8cStOGXM/Tmv95C4OODVs6VIbMsJPqThUM5h3uYVQNULuJ8I/aVvnJ3Eh/wZCKwuA==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@cloudflare/workerd-darwin-64@1.20260617.1': + resolution: {integrity: sha512-jWwmgEVVWbsHNrLSNXzwjJaH90VzRxq1cWkQFUidxyeUPnMxemeNE8I9qFAfrpzGgE11e9sKDcE3ettJW08swQ==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@cloudflare/workerd-darwin-arm64@1.20260611.1': + resolution: {integrity: sha512-yBbVXvbZyltR3I7NJdC4C4ItkItjZSiabcA/3HzEWOUQjLVKFqRh4so6ToHr70VCYh8VGeR8EDZL23igLhXqFQ==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@cloudflare/workerd-darwin-arm64@1.20260617.1': + resolution: {integrity: sha512-LHH7b565g9znfCUOkwbec6FG2rmRbsgCy6aJiU9KN662mNheWl5sw/iKleiFSiljPKQQP3HkjnC/NSkdgi/aSA==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@cloudflare/workerd-linux-64@1.20260611.1': + resolution: {integrity: sha512-PfNjpxOlaIgZFYuhD7+neEEewCN2Ud993wEEN0fmbtSOax1AK53LGqmXUDvFhnbkHxJLFAxYCSNISW8QbzaAIg==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@cloudflare/workerd-linux-64@1.20260617.1': + resolution: {integrity: sha512-FMnaAKXe4Cfd8TQurCVd9fs2XQVBFRCsP+Id/SRdUv89MlwYu9zXfoyx6BxM+brPTIUK38SHbo8iaxiwzLi9JQ==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@cloudflare/workerd-linux-arm64@1.20260611.1': + resolution: {integrity: sha512-GEp4XbuIKjlF8pakqXcUDJfKiJosD/Q7S83J0d+r+z9XIlYGfF3ntm08e2aiF5TFTwp3fnG4yMoPUAKNhNJpvQ==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@cloudflare/workerd-linux-arm64@1.20260617.1': + resolution: {integrity: sha512-MRoifFYcqbxxIIQy7PqO5tFY/qPFSnjXzakWl0sO93l+HLyG35jRAgOi6jfqa4kBxc7gKKtH861DcewjxUfkjA==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@cloudflare/workerd-windows-64@1.20260611.1': + resolution: {integrity: sha512-S6JkS0kEbcCKs19RGqEPhjCRbP8GBkQwqYLp2fhBJtD/KTlwqLzOJ9E6PQ7gQKgWHtxy1NBG3oXarlNFRNU/dw==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + + '@cloudflare/workerd-windows-64@1.20260617.1': + resolution: {integrity: sha512-rgBV9wQrv0OSKgCTTbhFUFY3sLGNANZ88aqaLvtmEn2gmbFVb1J4PDGochVUdB7NSEp4D/ghHva6/8SZmbONpw==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + + '@cloudflare/workers-types@4.20260615.1': + resolution: {integrity: sha512-fGOiTwoLj/8bU8mj3VAfa1EULx4ceZhDwnjvY+afDBlSXI9pvY7PE9t62rGEhJjbAOGd7i5WUDun0eZCWBDrzg==} + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@esbuild/aix-ppc64@0.27.3': + resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.3': + resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.3': + resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.3': + resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.3': + resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.3': + resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.3': + resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.3': + resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.3': + resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.3': + resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.3': + resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.3': + resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.3': + resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.3': + resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.3': + resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.3': + resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.3': + resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.3': + resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.3': + resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.3': + resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.3': + resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.3': + resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.3': + resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.3': + resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.3': + resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.3': + resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@google/genai@2.8.0': + resolution: {integrity: sha512-pc2ayxqO5+O7AvnHBqpNHIk7PAZkHZgL31tbyx0gJZBSS9qPYiQoqwK7oYOw/ePmG6QY4EMSu+304vD5QlhXAw==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@modelcontextprotocol/sdk': ^1.25.2 + peerDependenciesMeta: + '@modelcontextprotocol/sdk': + optional: true + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + + '@napi-rs/wasm-runtime@1.1.5': + resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oxc-project/types@0.133.0': + resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} + + '@poppinss/colors@4.1.6': + resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} + + '@poppinss/dumper@0.6.5': + resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==} + + '@poppinss/exception@1.2.3': + resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.5': + resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} + + '@protobufjs/eventemitter@1.1.1': + resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==} + + '@protobufjs/fetch@1.1.1': + resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.1': + resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==} + + '@rolldown/binding-android-arm64@1.0.3': + resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.3': + resolution: {integrity: sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.3': + resolution: {integrity: sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.3': + resolution: {integrity: sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': + resolution: {integrity: sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.3': + resolution: {integrity: sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.0.3': + resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.0.3': + resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.0.3': + resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.0.3': + resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.0.3': + resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.0.3': + resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.3': + resolution: {integrity: sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.3': + resolution: {integrity: sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.3': + resolution: {integrity: sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@sindresorhus/is@7.2.0': + resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} + engines: {node: '>=18'} + + '@speed-highlight/core@1.2.17': + resolution: {integrity: sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==} + + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@25.9.3': + resolution: {integrity: sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==} + + '@types/retry@0.12.0': + resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} + + '@vitest/expect@4.1.9': + resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==} + + '@vitest/mocker@4.1.9': + resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.9': + resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==} + + '@vitest/runner@4.1.9': + resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==} + + '@vitest/snapshot@4.1.9': + resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==} + + '@vitest/spy@4.1.9': + resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==} + + '@vitest/utils@4.1.9': + resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + + blake3-wasm@2.1.5: + resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + cjs-module-lexer@1.2.3: + resolution: {integrity: sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + + error-stack-parser-es@1.0.5: + resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + + esbuild@0.27.3: + resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} + engines: {node: '>=18'} + hasBin: true + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + gaxios@7.1.5: + resolution: {integrity: sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg==} + engines: {node: '>=18'} + + gcp-metadata@8.1.2: + resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} + engines: {node: '>=18'} + + google-auth-library@10.7.0: + resolution: {integrity: sha512-QpTAbNJ36TliZLx3TTtahR8HG0hN9RllL1e3FymOvQSIKK8JmgV58H924ub2wa2DsS3ANjjP1Aw1N+Ramc8hqQ==} + engines: {node: '>=18'} + + google-logging-utils@1.1.3: + resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} + engines: {node: '>=14'} + + hono@4.12.25: + resolution: {integrity: sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==} + engines: {node: '>=16.9.0'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + json-bigint@1.0.0: + resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + + json-schema-to-ts@3.1.1: + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} + engines: {node: '>=16'} + + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + lefthook-darwin-arm64@2.1.9: + resolution: {integrity: sha512-119HryNcvr4nqn0wUIrNPgpMEPn9yMQzEcW/lezRsnb56PCJriJB92+MCySPVcWDxJnZef7o0T3jdnPNiSH7Qg==} + cpu: [arm64] + os: [darwin] + + lefthook-darwin-x64@2.1.9: + resolution: {integrity: sha512-dwo5Tke2XcQCM56DGHgFKBfRbJIL6xs2wZ0zG1TUVZgl4t4mQUt6LiZ4V/ZQfYHTZF9qywvXoIlR5N35qOaiVQ==} + cpu: [x64] + os: [darwin] + + lefthook-freebsd-arm64@2.1.9: + resolution: {integrity: sha512-+09PVap6nl6xsaHch5JLtq7WvIR++U1Q2MzA2ai0M4uB/VP3AqrvKqHw6+9hjyKnIH+HHL83uqi77EAY+LaxLA==} + cpu: [arm64] + os: [freebsd] + + lefthook-freebsd-x64@2.1.9: + resolution: {integrity: sha512-8XresjKIYpkE9ARgCtBEZgJZxAU3T4MIqzj4zNy15XRT59I1Us+QdqXTNm+pkZ41Yd2X/nxs2Pkvbq3NWWlIGw==} + cpu: [x64] + os: [freebsd] + + lefthook-linux-arm64@2.1.9: + resolution: {integrity: sha512-1oNIQfwrPe6rgU2KcDM3aF6+hpZDCKx1TmawQKpXUY5gVsbZ7MqX0Sk/1lnnWxqPm+kQQ5f6J2dpFWd+4xH8jg==} + cpu: [arm64] + os: [linux] + + lefthook-linux-x64@2.1.9: + resolution: {integrity: sha512-fT+7Q+BJyGp+CslFQkNXmdFRgyVXsPHPi9NAsDX0a6QOyNnoORByAsvx6zeAKuF5rL3BBgNfho1/v2RuGxGy9w==} + cpu: [x64] + os: [linux] + + lefthook-openbsd-arm64@2.1.9: + resolution: {integrity: sha512-4bVuafBk3dddVNo0+3hMbjcJs4mqYAstxpPMmX2ufkudSTYFNIhWoqwuGVQV/SS/xdcOKJAldW4qayAzed2ysw==} + cpu: [arm64] + os: [openbsd] + + lefthook-openbsd-x64@2.1.9: + resolution: {integrity: sha512-PmPoMmLP/wQQWcQ9u2YH86bTZ3UCfBsxuEmVTEyPU2U8R1qSTp5r/Gs3G8cN5Mxo91XB9oBERtF1n+xD3W6aVA==} + cpu: [x64] + os: [openbsd] + + lefthook-windows-arm64@2.1.9: + resolution: {integrity: sha512-KphfkBKmwBnmolyrdhIl3lrBaOyTcCgXBT2AB/9OHnEXhOLvv5uTCUkrD4YRAxXPtFKq6UvnapIeoL3GZq0bdA==} + cpu: [arm64] + os: [win32] + + lefthook-windows-x64@2.1.9: + resolution: {integrity: sha512-2qlUtkJHZ3MyUxgV5XTEmcrIoNZA07iwaquoswAcqv/1MeBFXlD+O+koFRfrzWng2O5WYEbpJnd8tvaYnV8fTA==} + cpu: [x64] + os: [win32] + + lefthook@2.1.9: + resolution: {integrity: sha512-bwDaIOViTktE8kJLf9jP0p+H2/RDTlFFlc43Am2YgUsX22hI6Sq4RbzsrecwzY5y+MHTipOH7WsmWSEniePHWQ==} + hasBin: true + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + miniflare@4.20260611.0: + resolution: {integrity: sha512-i+JwEo8vN96naz1WL3ntFgFyRluBDYL408zwhHKvR2jefJ464KsZ/gCmJAQ5k+oaWeb5Ug+s7yne5AyiAEswjg==} + engines: {node: '>=22.0.0'} + hasBin: true + + miniflare@4.20260617.1: + resolution: {integrity: sha512-Go3/gzStm99QHptsSgU+q1S+xDfLoRgwjJNY80kaTVi0ENhTyqKq+sc4xZiWBSbM7uUcJwmzm8+QFKtcYLJ9nw==} + engines: {node: '>=22.0.0'} + hasBin: true + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.14: + resolution: {integrity: sha512-U9kYi5bpVMEI31yC8iw4bJJp0avcHXA0W8/wNfLfnvJYzihQo2ZRPYPvpAAd570HAcCBjCTN7vnr+v4StKl1IQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + obug@2.1.3: + resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} + engines: {node: '>=12.20.0'} + + openai@6.42.0: + resolution: {integrity: sha512-1WFEt/uXMXOLhYRNkgJWo08Y2YNvNwpVU72K7ibrWgWpNOXd4VojXLbe6SQ4bLiUQ3Y8jz4IiyVkylJCL1DtZg==} + peerDependencies: + ws: ^8.18.0 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + ws: + optional: true + zod: + optional: true + + p-retry@4.6.2: + resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==} + engines: {node: '>=8'} + + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + protobufjs@7.6.4: + resolution: {integrity: sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==} + engines: {node: '>=12.0.0'} + + retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} + engines: {node: '>= 4'} + + rolldown@1.0.3: + resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + semver@7.8.4: + resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==} + engines: {node: '>=10'} + hasBin: true + + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + standardwebhooks@1.0.0: + resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + + undici@7.24.8: + resolution: {integrity: sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==} + engines: {node: '>=20.18.1'} + + undici@7.28.0: + resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} + engines: {node: '>=20.18.1'} + + unenv@2.0.0-rc.24: + resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + + vite@8.0.16: + resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.18 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.9: + resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.9 + '@vitest/browser-preview': 4.1.9 + '@vitest/browser-webdriverio': 4.1.9 + '@vitest/coverage-istanbul': 4.1.9 + '@vitest/coverage-v8': 4.1.9 + '@vitest/ui': 4.1.9 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vite: {} + + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + workerd@1.20260611.1: + resolution: {integrity: sha512-CS/640T7pIJ2HYX6x2DwKFGbcSckAWN3tgcdq+ptB6SaqjWUhlzIgA/YhPuwIU+/NnMnGpqOFX/hC18Oyge63w==} + engines: {node: '>=16'} + hasBin: true + + workerd@1.20260617.1: + resolution: {integrity: sha512-Re5pl6pdowt3ZmWUzGlOuB7jbRIIPetgKalmo4cYmucQnVhpo7/3e4MfpekbhLi2EhZZz5EY9NWRu8zFzuEZew==} + engines: {node: '>=16'} + hasBin: true + + wrangler@4.100.0: + resolution: {integrity: sha512-dSQO7DO+mD6XDzkVWIWBoGLO3yw+lacWSc/KhFvd7pgfpth+kX98qb5SGRHZN8ACCDhhfwzDLXwB6qHsIHhfBg==} + engines: {node: '>=22.0.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': ^4.20260611.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + + wrangler@4.103.0: + resolution: {integrity: sha512-3Lv1P5t2xcSEkSTKtG+Lz+3JFryuU7YPLkaCUj7gNe+CJsjZJLtUwqsh1x595QBxkIbCE0GAvDx2DCJUU4+oqw==} + engines: {node: '>=22.0.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': ^4.20260617.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + + ws@8.20.1: + resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + youch-core@0.3.3: + resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} + + youch@4.1.0-beta.10: + resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + +snapshots: + + '@anthropic-ai/sdk@0.104.1(zod@3.25.76)': + dependencies: + json-schema-to-ts: 3.1.1 + standardwebhooks: 1.0.0 + zod: 3.25.76 + + '@babel/runtime@7.29.7': {} + + '@biomejs/biome@2.5.0': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.5.0 + '@biomejs/cli-darwin-x64': 2.5.0 + '@biomejs/cli-linux-arm64': 2.5.0 + '@biomejs/cli-linux-arm64-musl': 2.5.0 + '@biomejs/cli-linux-x64': 2.5.0 + '@biomejs/cli-linux-x64-musl': 2.5.0 + '@biomejs/cli-win32-arm64': 2.5.0 + '@biomejs/cli-win32-x64': 2.5.0 + + '@biomejs/cli-darwin-arm64@2.5.0': + optional: true + + '@biomejs/cli-darwin-x64@2.5.0': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.5.0': + optional: true + + '@biomejs/cli-linux-arm64@2.5.0': + optional: true + + '@biomejs/cli-linux-x64-musl@2.5.0': + optional: true + + '@biomejs/cli-linux-x64@2.5.0': + optional: true + + '@biomejs/cli-win32-arm64@2.5.0': + optional: true + + '@biomejs/cli-win32-x64@2.5.0': + optional: true + + '@cloudflare/kv-asset-handler@0.5.0': {} + + '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260611.1)': + dependencies: + unenv: 2.0.0-rc.24 + workerd: 1.20260611.1 + + '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260617.1)': + dependencies: + unenv: 2.0.0-rc.24 + workerd: 1.20260617.1 + + '@cloudflare/vitest-pool-workers@0.16.18(@vitest/runner@4.1.9)(@vitest/snapshot@4.1.9)(vitest@4.1.9(@types/node@25.9.3)(vite@8.0.16(@types/node@25.9.3)))': + dependencies: + '@vitest/runner': 4.1.9 + '@vitest/snapshot': 4.1.9 + cjs-module-lexer: 1.2.3 + esbuild: 0.28.1 + miniflare: 4.20260617.1 + vitest: 4.1.9(@types/node@25.9.3)(esbuild@0.28.1)(vite@8.0.16(@types/node@25.9.3)) + wrangler: 4.103.0 + zod: 3.25.76 + transitivePeerDependencies: + - '@cloudflare/workers-types' + - bufferutil + - utf-8-validate + + '@cloudflare/workerd-darwin-64@1.20260611.1': + optional: true + + '@cloudflare/workerd-darwin-64@1.20260617.1': + optional: true + + '@cloudflare/workerd-darwin-arm64@1.20260611.1': + optional: true + + '@cloudflare/workerd-darwin-arm64@1.20260617.1': + optional: true + + '@cloudflare/workerd-linux-64@1.20260611.1': + optional: true + + '@cloudflare/workerd-linux-64@1.20260617.1': + optional: true + + '@cloudflare/workerd-linux-arm64@1.20260611.1': + optional: true + + '@cloudflare/workerd-linux-arm64@1.20260617.1': + optional: true + + '@cloudflare/workerd-windows-64@1.20260611.1': + optional: true + + '@cloudflare/workerd-windows-64@1.20260617.1': + optional: true + + '@cloudflare/workers-types@4.20260615.1': {} + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.27.3': + optional: true + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.27.3': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.27.3': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.27.3': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.27.3': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.27.3': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.27.3': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.27.3': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.27.3': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.27.3': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.27.3': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.27.3': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.27.3': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.27.3': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.27.3': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.27.3': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.27.3': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.27.3': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.27.3': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.27.3': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.27.3': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.27.3': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.27.3': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.27.3': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.27.3': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.27.3': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@google/genai@2.8.0': + dependencies: + google-auth-library: 10.7.0 + p-retry: 4.6.2 + protobufjs: 7.6.4 + ws: 8.20.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.11.1 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 + optional: true + + '@oxc-project/types@0.133.0': {} + + '@poppinss/colors@4.1.6': + dependencies: + kleur: 4.1.5 + + '@poppinss/dumper@0.6.5': + dependencies: + '@poppinss/colors': 4.1.6 + '@sindresorhus/is': 7.2.0 + supports-color: 10.2.2 + + '@poppinss/exception@1.2.3': {} + + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.5': {} + + '@protobufjs/eventemitter@1.1.1': {} + + '@protobufjs/fetch@1.1.1': + dependencies: + '@protobufjs/aspromise': 1.1.2 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.1': {} + + '@rolldown/binding-android-arm64@1.0.3': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.3': + optional: true + + '@rolldown/binding-darwin-x64@1.0.3': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.3': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.3': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.3': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.3': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.3': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.3': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.3': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@sindresorhus/is@7.2.0': {} + + '@speed-highlight/core@1.2.17': {} + + '@stablelib/base64@1.0.1': {} + + '@standard-schema/spec@1.1.0': {} + + '@tybys/wasm-util@0.10.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/node@25.9.3': + dependencies: + undici-types: 7.24.6 + + '@types/retry@0.12.0': {} + + '@vitest/expect@4.1.9': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.9(esbuild@0.28.1)(vite@8.0.16(@types/node@25.9.3))': + dependencies: + '@vitest/spy': 4.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + vite: 8.0.16(@types/node@25.9.3)(esbuild@0.28.1) + + '@vitest/pretty-format@4.1.9': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.9': + dependencies: + '@vitest/utils': 4.1.9 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.9': + dependencies: + '@vitest/pretty-format': 4.1.9 + '@vitest/utils': 4.1.9 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.9': {} + + '@vitest/utils@4.1.9': + dependencies: + '@vitest/pretty-format': 4.1.9 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + agent-base@7.1.4: {} + + assertion-error@2.0.1: {} + + base64-js@1.5.1: {} + + bignumber.js@9.3.1: {} + + blake3-wasm@2.1.5: {} + + buffer-equal-constant-time@1.0.1: {} + + chai@6.2.2: {} + + cjs-module-lexer@1.2.3: {} + + convert-source-map@2.0.0: {} + + cookie@1.1.1: {} + + data-uri-to-buffer@4.0.1: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + detect-libc@2.1.2: {} + + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + + error-stack-parser-es@1.0.5: {} + + es-module-lexer@2.1.0: {} + + esbuild@0.27.3: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.3 + '@esbuild/android-arm': 0.27.3 + '@esbuild/android-arm64': 0.27.3 + '@esbuild/android-x64': 0.27.3 + '@esbuild/darwin-arm64': 0.27.3 + '@esbuild/darwin-x64': 0.27.3 + '@esbuild/freebsd-arm64': 0.27.3 + '@esbuild/freebsd-x64': 0.27.3 + '@esbuild/linux-arm': 0.27.3 + '@esbuild/linux-arm64': 0.27.3 + '@esbuild/linux-ia32': 0.27.3 + '@esbuild/linux-loong64': 0.27.3 + '@esbuild/linux-mips64el': 0.27.3 + '@esbuild/linux-ppc64': 0.27.3 + '@esbuild/linux-riscv64': 0.27.3 + '@esbuild/linux-s390x': 0.27.3 + '@esbuild/linux-x64': 0.27.3 + '@esbuild/netbsd-arm64': 0.27.3 + '@esbuild/netbsd-x64': 0.27.3 + '@esbuild/openbsd-arm64': 0.27.3 + '@esbuild/openbsd-x64': 0.27.3 + '@esbuild/openharmony-arm64': 0.27.3 + '@esbuild/sunos-x64': 0.27.3 + '@esbuild/win32-arm64': 0.27.3 + '@esbuild/win32-ia32': 0.27.3 + '@esbuild/win32-x64': 0.27.3 + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.3.0: {} + + extend@3.0.2: {} + + fast-sha256@1.3.0: {} + + fdir@6.5.0(picomatch@4.0.4): + dependencies: + picomatch: 4.0.4 + + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + + fsevents@2.3.3: + optional: true + + gaxios@7.1.5: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + transitivePeerDependencies: + - supports-color + + gcp-metadata@8.1.2: + dependencies: + gaxios: 7.1.5 + google-logging-utils: 1.1.3 + json-bigint: 1.0.0 + transitivePeerDependencies: + - supports-color + + google-auth-library@10.7.0: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 7.1.5 + gcp-metadata: 8.1.2 + google-logging-utils: 1.1.3 + jws: 4.0.1 + transitivePeerDependencies: + - supports-color + + google-logging-utils@1.1.3: {} + + hono@4.12.25: {} + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + json-bigint@1.0.0: + dependencies: + bignumber.js: 9.3.1 + + json-schema-to-ts@3.1.1: + dependencies: + '@babel/runtime': 7.29.7 + ts-algebra: 2.0.0 + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + + kleur@4.1.5: {} + + lefthook-darwin-arm64@2.1.9: + optional: true + + lefthook-darwin-x64@2.1.9: + optional: true + + lefthook-freebsd-arm64@2.1.9: + optional: true + + lefthook-freebsd-x64@2.1.9: + optional: true + + lefthook-linux-arm64@2.1.9: + optional: true + + lefthook-linux-x64@2.1.9: + optional: true + + lefthook-openbsd-arm64@2.1.9: + optional: true + + lefthook-openbsd-x64@2.1.9: + optional: true + + lefthook-windows-arm64@2.1.9: + optional: true + + lefthook-windows-x64@2.1.9: + optional: true + + lefthook@2.1.9: + optionalDependencies: + lefthook-darwin-arm64: 2.1.9 + lefthook-darwin-x64: 2.1.9 + lefthook-freebsd-arm64: 2.1.9 + lefthook-freebsd-x64: 2.1.9 + lefthook-linux-arm64: 2.1.9 + lefthook-linux-x64: 2.1.9 + lefthook-openbsd-arm64: 2.1.9 + lefthook-openbsd-x64: 2.1.9 + lefthook-windows-arm64: 2.1.9 + lefthook-windows-x64: 2.1.9 + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + long@5.3.2: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + miniflare@4.20260611.0: + dependencies: + '@cspotcode/source-map-support': 0.8.1 + sharp: 0.34.5 + undici: 7.24.8 + workerd: 1.20260611.1 + ws: 8.20.1 + youch: 4.1.0-beta.10 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + miniflare@4.20260617.1: + dependencies: + '@cspotcode/source-map-support': 0.8.1 + sharp: 0.34.5 + undici: 7.28.0 + workerd: 1.20260617.1 + ws: 8.21.0 + youch: 4.1.0-beta.10 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + ms@2.1.3: {} + + nanoid@3.3.14: {} + + node-domexception@1.0.0: {} + + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + + obug@2.1.3: {} + + openai@6.42.0(ws@8.21.0)(zod@3.25.76): + dependencies: + ws: 8.21.0 + zod: 3.25.76 + + p-retry@4.6.2: + dependencies: + '@types/retry': 0.12.0 + retry: 0.13.1 + + path-to-regexp@6.3.0: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss@8.5.15: + dependencies: + nanoid: 3.3.14 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + protobufjs@7.6.4: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.5 + '@protobufjs/eventemitter': 1.1.1 + '@protobufjs/fetch': 1.1.1 + '@protobufjs/float': 1.0.2 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.1 + '@types/node': 25.9.3 + long: 5.3.2 + + retry@0.13.1: {} + + rolldown@1.0.3: + dependencies: + '@oxc-project/types': 0.133.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.3 + '@rolldown/binding-darwin-arm64': 1.0.3 + '@rolldown/binding-darwin-x64': 1.0.3 + '@rolldown/binding-freebsd-x64': 1.0.3 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.3 + '@rolldown/binding-linux-arm64-gnu': 1.0.3 + '@rolldown/binding-linux-arm64-musl': 1.0.3 + '@rolldown/binding-linux-ppc64-gnu': 1.0.3 + '@rolldown/binding-linux-s390x-gnu': 1.0.3 + '@rolldown/binding-linux-x64-gnu': 1.0.3 + '@rolldown/binding-linux-x64-musl': 1.0.3 + '@rolldown/binding-openharmony-arm64': 1.0.3 + '@rolldown/binding-wasm32-wasi': 1.0.3 + '@rolldown/binding-win32-arm64-msvc': 1.0.3 + '@rolldown/binding-win32-x64-msvc': 1.0.3 + + safe-buffer@5.2.1: {} + + semver@7.8.4: {} + + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.4 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + standardwebhooks@1.0.0: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + + std-env@4.1.0: {} + + supports-color@10.2.2: {} + + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinyrainbow@3.1.0: {} + + ts-algebra@2.0.0: {} + + tslib@2.8.1: + optional: true + + typescript@6.0.3: {} + + undici-types@7.24.6: {} + + undici@7.24.8: {} + + undici@7.28.0: {} + + unenv@2.0.0-rc.24: + dependencies: + pathe: 2.0.3 + + vite@8.0.16(@types/node@25.9.3): + dependencies: + '@types/node': 25.9.3 + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.15 + rolldown: 1.0.3 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 + optional: true + + vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1): + dependencies: + '@types/node': 25.9.3 + esbuild: 0.28.1 + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.15 + rolldown: 1.0.3 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 + + vitest@4.1.9(@types/node@25.9.3)(esbuild@0.28.1)(vite@8.0.16(@types/node@25.9.3)): + dependencies: + '@types/node': 25.9.3 + '@vitest/expect': 4.1.9 + '@vitest/mocker': 4.1.9(esbuild@0.28.1)(vite@8.0.16(@types/node@25.9.3)) + '@vitest/pretty-format': 4.1.9 + '@vitest/runner': 4.1.9 + '@vitest/snapshot': 4.1.9 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.0.16(@types/node@25.9.3)(esbuild@0.28.1) + why-is-node-running: 2.3.0 + transitivePeerDependencies: + - msw + + web-streams-polyfill@3.3.3: {} + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + workerd@1.20260611.1: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20260611.1 + '@cloudflare/workerd-darwin-arm64': 1.20260611.1 + '@cloudflare/workerd-linux-64': 1.20260611.1 + '@cloudflare/workerd-linux-arm64': 1.20260611.1 + '@cloudflare/workerd-windows-64': 1.20260611.1 + + workerd@1.20260617.1: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20260617.1 + '@cloudflare/workerd-darwin-arm64': 1.20260617.1 + '@cloudflare/workerd-linux-64': 1.20260617.1 + '@cloudflare/workerd-linux-arm64': 1.20260617.1 + '@cloudflare/workerd-windows-64': 1.20260617.1 + + wrangler@4.100.0(@cloudflare/workers-types@4.20260615.1): + dependencies: + '@cloudflare/kv-asset-handler': 0.5.0 + '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260611.1) + '@cloudflare/workers-types': 4.20260615.1 + blake3-wasm: 2.1.5 + esbuild: 0.27.3 + miniflare: 4.20260611.0 + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.24 + workerd: 1.20260611.1 + optionalDependencies: + fsevents: 2.3.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + wrangler@4.103.0: + dependencies: + '@cloudflare/kv-asset-handler': 0.5.0 + '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260617.1) + blake3-wasm: 2.1.5 + esbuild: 0.28.1 + miniflare: 4.20260617.1 + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.24 + workerd: 1.20260617.1 + optionalDependencies: + fsevents: 2.3.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + ws@8.20.1: {} + + ws@8.21.0: {} + + youch-core@0.3.3: + dependencies: + '@poppinss/exception': 1.2.3 + error-stack-parser-es: 1.0.5 + + youch@4.1.0-beta.10: + dependencies: + '@poppinss/colors': 4.1.6 + '@poppinss/dumper': 0.6.5 + '@speed-highlight/core': 1.2.17 + cookie: 1.1.1 + youch-core: 0.3.3 + + zod@3.25.76: {} diff --git a/package.json b/package.json index d65ded1..ff831f6 100644 --- a/package.json +++ b/package.json @@ -1,16 +1,31 @@ { "name": "api-proxy", - "module": "src/claude.ts", + "module": "src/index.ts", "type": "module", "private": true, + "scripts": { + "dev": "wrangler dev", + "test:unit": "vitest run --config vitest.config.ts", + "test:compat": "vitest run --config vitest.compat.config.ts", + "test": "nub run test:unit && nub run test:compat", + "lint": "tsc --noEmit && biome check --write --unsafe .", + "prepare": "lefthook install" + }, "devDependencies": { - "@cloudflare/workers-types": "^4.20260317.1", - "@types/bun": "^1.3.10" + "@anthropic-ai/sdk": "^0.104.1", + "@biomejs/biome": "^2.5.0", + "@cloudflare/vitest-pool-workers": "^0.16.18", + "@cloudflare/workers-types": "^4.20260615.1", + "@google/genai": "^2.8.0", + "lefthook": "^2.1.9", + "openai": "^6.42.0", + "vitest": "^4.1.9" }, "peerDependencies": { - "typescript": "^5.9.3" + "typescript": "^6.0.3" }, "dependencies": { - "wrangler": "^4.74.0" + "hono": "^4.12.25", + "wrangler": "^4.100.0" } -} \ No newline at end of file +} diff --git a/schedule.sh b/schedule.sh index 8391021..2a3cdd4 100755 --- a/schedule.sh +++ b/schedule.sh @@ -2,7 +2,7 @@ # Usage: ./schedule.sh [--claude|--gemini|--openai] [HH:MM | YYYY-MM-DD HH:MM | +Nm] set -euo pipefail -label="claude"; action=""; time_args=() +label=""; action=""; time_args=() for arg in "$@"; do case "$arg" in --claude|--gemini|--openai) label="${arg#--}" ;; @@ -10,7 +10,10 @@ for arg in "$@"; do *) time_args+=("$arg") ;; esac done -config="wrangler.${label}.toml" +# Default target is the single token-gated worker (wrangler.toml). The provider flags +# still target the legacy per-provider workers during the transition. +config="${label:+wrangler.${label}.toml}"; config="${config:-wrangler.toml}" +name="${label:-api-proxy}" time_arg="${time_args[*]:-}" [[ "$action" =~ ^(enable|disable)$ ]] || { @@ -23,7 +26,7 @@ dir=$(cd "$(dirname "$0")" && pwd) if [[ -z "$time_arg" ]]; then sed -i '' "s/workers_dev = $from/workers_dev = $to/" "$dir/$config" grep -q "workers_dev = $to" "$dir/$config" || { echo "ERROR: sed failed"; exit 1; } - cd "$dir" && bunx wrangler deploy --config "$config" + cd "$dir" && nubx wrangler deploy --config "$config" exit 0 fi @@ -39,15 +42,15 @@ else delay=$(( (target - now + 86400) % 86400 )) fi -logfile="/tmp/${label}-proxy-schedule.log" +logfile="/tmp/${name}-schedule.log" -cat > "/tmp/${label}-proxy-scheduled.sh" < + + +
+

api-proxy admin

+
+

Sign in

+
+ + +
+
+
+
+ + `; + +export const dashboardPage = () => html` + + + + + api-proxy admin + + + + +
+

api-proxy admin

+
+

Add token

+
+
+
+ + +
+
+ + +
+
+
+ + + +
+ + sign out +
+
+
+
+

Tokens

+
+ Loading… +
+
+
+ + `; diff --git a/src/claude.ts b/src/claude.ts index 8fac15f..3e2861b 100644 --- a/src/claude.ts +++ b/src/claude.ts @@ -1,20 +1,23 @@ export default { - async fetch(request: Request, env: { ANTHROPIC_API_KEY: string }): Promise { - const url = new URL(request.url); - url.hostname = "api.anthropic.com"; - url.protocol = "https:"; + async fetch( + request: Request, + env: { ANTHROPIC_API_KEY: string }, + ): Promise { + const url = new URL(request.url); + url.hostname = "api.anthropic.com"; + url.protocol = "https:"; - const headers = new Headers(request.headers); - headers.set("x-api-key", env.ANTHROPIC_API_KEY); + const headers = new Headers(request.headers); + headers.set("x-api-key", env.ANTHROPIC_API_KEY); - const response = await fetch( - new Request(url.toString(), { - method: request.method, - headers, - body: request.body, - }), - ); + const response = await fetch( + new Request(url.toString(), { + method: request.method, + headers, + body: request.body, + }), + ); - return new Response(response.body, response); - }, + return new Response(response.body, response); + }, }; diff --git a/src/egress.ts b/src/egress.ts new file mode 100644 index 0000000..156a5a4 --- /dev/null +++ b/src/egress.ts @@ -0,0 +1,14 @@ +import { DurableObject } from "cloudflare:workers"; +import type { Env } from "./types"; + +// Region-pinned egress relay. OpenAI geo-blocks requests that egress from some Cloudflare +// colos (e.g. Hong Kong) with 403 unsupported_country_region_territory. A Worker's fetch() +// egresses from whatever colo the invocation runs in, and that is fixed per invocation, so an +// in-invocation retry cannot escape a bad colo. Routing the request to this Durable Object via +// locationHint:"wnam" makes the object run in North America; its outbound fetch() then egresses +// from an OpenAI-supported region. The real key never leaves Cloudflare. +export class UsEgress extends DurableObject { + override fetch(request: Request): Promise { + return fetch(request); + } +} diff --git a/src/gemini.ts b/src/gemini.ts index 663e8cc..d5371cb 100644 --- a/src/gemini.ts +++ b/src/gemini.ts @@ -1,21 +1,24 @@ export default { - async fetch(request: Request, env: { GEMINI_API_KEY: string }): Promise { - const url = new URL(request.url); - url.hostname = "generativelanguage.googleapis.com"; - url.protocol = "https:"; - url.searchParams.delete("key"); + async fetch( + request: Request, + env: { GEMINI_API_KEY: string }, + ): Promise { + const url = new URL(request.url); + url.hostname = "generativelanguage.googleapis.com"; + url.protocol = "https:"; + url.searchParams.delete("key"); - const headers = new Headers(request.headers); - headers.set("x-goog-api-key", env.GEMINI_API_KEY); + const headers = new Headers(request.headers); + headers.set("x-goog-api-key", env.GEMINI_API_KEY); - const response = await fetch( - new Request(url.toString(), { - method: request.method, - headers, - body: request.body, - }), - ); + const response = await fetch( + new Request(url.toString(), { + method: request.method, + headers, + body: request.body, + }), + ); - return new Response(response.body, response); - }, + return new Response(response.body, response); + }, }; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..0e9092a --- /dev/null +++ b/src/index.ts @@ -0,0 +1,25 @@ +import adminApp from "./admin"; +import { handleProxy } from "./proxy"; +import type { Env } from "./types"; + +export { UsEgress } from "./egress"; + +// Top-level dispatch: /admin/* -> admin sub-app (isolated in try/catch so an admin +// bug can never crash the proxy branch), everything else -> the proxy hot-path. +export default { + async fetch( + req: Request, + env: Env, + ctx: ExecutionContext, + ): Promise { + const url = new URL(req.url); + if (url.pathname === "/admin" || url.pathname.startsWith("/admin/")) { + try { + return await adminApp.fetch(req, env, ctx); + } catch { + return new Response("admin error", { status: 500 }); + } + } + return handleProxy(req, env, ctx); + }, +}; diff --git a/src/openai.ts b/src/openai.ts index 2541640..e05cd17 100644 --- a/src/openai.ts +++ b/src/openai.ts @@ -1,20 +1,23 @@ export default { - async fetch(request: Request, env: { OPENAI_API_KEY: string }): Promise { - const url = new URL(request.url); - url.hostname = "api.openai.com"; - url.protocol = "https:"; + async fetch( + request: Request, + env: { OPENAI_API_KEY: string }, + ): Promise { + const url = new URL(request.url); + url.hostname = "api.openai.com"; + url.protocol = "https:"; - const headers = new Headers(request.headers); - headers.set("Authorization", `Bearer ${env.OPENAI_API_KEY}`); + const headers = new Headers(request.headers); + headers.set("Authorization", `Bearer ${env.OPENAI_API_KEY}`); - const response = await fetch( - new Request(url.toString(), { - method: request.method, - headers, - body: request.body, - }), - ); + const response = await fetch( + new Request(url.toString(), { + method: request.method, + headers, + body: request.body, + }), + ); - return new Response(response.body, response); - }, + return new Response(response.body, response); + }, }; diff --git a/src/proxy.ts b/src/proxy.ts new file mode 100644 index 0000000..b143a11 --- /dev/null +++ b/src/proxy.ts @@ -0,0 +1,160 @@ +// The proxy hot-path. ZERO framework deps - pure functions + a fetch handler. +// MUST NOT import Hono or any admin code. + +import { getValidatedByHash, sha256hex, touchLastUsed } from "./tokens"; +import type { CoarseProvider, Env, Provider } from "./types"; +import { rewriteToUpstream } from "./upstreams"; + +/** Pull the candidate token from whichever auth slot the SDK used. */ +export function extractToken(req: Request, url: URL): string | null { + const h = req.headers; + const xApiKey = h.get("x-api-key"); + if (xApiKey) return xApiKey; + const xGoog = h.get("x-goog-api-key"); + if (xGoog) return xGoog; + const auth = h.get("authorization"); + if (auth) { + const m = /^Bearer\s+(.+)$/i.exec(auth); + if (m) return m[1].trim(); + } + return url.searchParams.get("key"); +} + +/** Identify the provider from the auth header it arrived in (+ path for the Gemini OpenAI-compat route). */ +export function routeProvider(req: Request, url: URL): Provider | null { + const h = req.headers; + if (h.get("x-api-key")) return "anthropic"; + if (h.get("x-goog-api-key")) return "gemini"; + const auth = h.get("authorization"); + if (auth && /^Bearer\s+/i.test(auth)) { + return url.pathname.startsWith("/v1beta/openai/") + ? "gemini-openai" + : "openai"; + } + if (url.searchParams.get("key")) return "gemini"; + return null; +} + +/** Collapse gemini-openai onto the gemini scope used by token.providers. */ +export function coarse(provider: Provider): CoarseProvider { + return provider === "gemini-openai" ? "gemini" : provider; +} + +/** Strip every inbound auth header, then set exactly one with the real key. Security linchpin. */ +export function swapAuth( + headers: Headers, + provider: Provider, + realKey: string, +): void { + headers.delete("x-api-key"); + headers.delete("x-goog-api-key"); + headers.delete("authorization"); + switch (provider) { + case "openai": + case "gemini-openai": + headers.set("authorization", `Bearer ${realKey}`); + break; + case "anthropic": + headers.set("x-api-key", realKey); + break; + case "gemini": + headers.set("x-goog-api-key", realKey); + break; + } +} + +/** The real upstream key for a provider. */ +export function realKeyFor(provider: Provider, env: Env): string { + switch (coarse(provider)) { + case "openai": + return env.OPENAI_API_KEY; + case "anthropic": + return env.ANTHROPIC_API_KEY; + case "gemini": + return env.GEMINI_API_KEY; + } +} + +function errorResponse(status: number, error: string): Response { + return new Response(JSON.stringify({ error }), { + status, + headers: { "content-type": "application/json" }, + }); +} + +const EGRESS_POOL = 8; + +/** OpenAI 403s requests that egress from an unsupported region (e.g. the Hong Kong colo). */ +async function isGeoBlock(res: Response): Promise { + if (res.status !== 403) return false; + try { + return (await res.clone().text()).includes( + "unsupported_country_region_territory", + ); + } catch { + return false; + } +} + +/** A North-America-pinned egress stub, so its fetch() leaves from an OpenAI-supported region. */ +function egressStub(env: Env): DurableObjectStub { + const id = env.US_EGRESS.idFromName( + `oa-egress-${Math.floor(Math.random() * EGRESS_POOL)}`, + ); + return env.US_EGRESS.get(id, { locationHint: "wnam" }); +} + +/** Validate the doppelganger token, swap in the real key, forward to the upstream, stream back. */ +export async function handleProxy( + req: Request, + env: Env, + ctx: ExecutionContext, +): Promise { + const url = new URL(req.url); + const token = extractToken(req, url); + const provider = routeProvider(req, url); + if (!token || !provider) return errorResponse(401, "missing token"); + + const hash = await sha256hex(token); + const meta = await getValidatedByHash(env.TOKENS, hash); + if (!meta) return errorResponse(401, "invalid or revoked token"); + if (!meta.providers.includes(coarse(provider))) + return errorResponse(403, "token not allowed for provider"); + + const realKey = realKeyFor(provider, env); + rewriteToUpstream(url, provider, env); + if (provider === "gemini" || provider === "gemini-openai") + url.searchParams.delete("key"); + + const headers = new Headers(req.headers); + swapAuth(headers, provider, realKey); + const target = url.toString(); + const hasBody = req.method !== "GET" && req.method !== "HEAD"; + + let upstream: Response; + try { + if (coarse(provider) === "openai") { + // Buffer the body so the request can be re-issued through the egress DO. The edge + // colo this invocation egresses from is fixed, so an in-invocation retry is useless; + // only re-issuing from a region-pinned DO escapes a geo-blocked colo. + const body = hasBody ? await req.arrayBuffer() : undefined; + upstream = await fetch( + new Request(target, { method: req.method, headers, body }), + ); + if (await isGeoBlock(upstream)) { + upstream = await egressStub(env).fetch( + new Request(target, { method: req.method, headers, body }), + ); + } + } else { + upstream = await fetch( + new Request(target, { method: req.method, headers, body: req.body }), + ); + } + } catch { + return errorResponse(502, "upstream request failed"); + } + + ctx.waitUntil(touchLastUsed(env.TOKENS, hash)); + return new Response(upstream.body, upstream); +} diff --git a/src/tokens.ts b/src/tokens.ts new file mode 100644 index 0000000..29c830f --- /dev/null +++ b/src/tokens.ts @@ -0,0 +1,129 @@ +// KV-backed token store. Tokens are stored by SHA-256(token); the plaintext is shown +// once at creation and never persisted. +import type { CoarseProvider, TokenMetadata } from "./types"; + +export async function sha256hex(input: string): Promise { + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(input), + ); + return [...new Uint8Array(digest)] + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +function base64url(bytes: Uint8Array): string { + let bin = ""; + for (const b of bytes) bin += String.fromCharCode(b); + return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +/** A fresh opaque token: dgk_ + 32 url-safe chars (24 random bytes). */ +export function generateToken(): string { + return `dgk_${base64url(crypto.getRandomValues(new Uint8Array(24)))}`; +} + +export interface CreateInput { + label: string; + providers: CoarseProvider[]; + token?: string; // admin-typed; otherwise generated +} + +export async function createToken( + kv: KVNamespace, + input: CreateInput, +): Promise<{ token: string; hash: string; meta: TokenMetadata }> { + const token = input.token?.trim() || generateToken(); + const hash = await sha256hex(token); + const meta: TokenMetadata = { + label: input.label, + last4: token.slice(-4), + providers: input.providers, + status: "active", + createdAt: new Date().toISOString(), + }; + await kv.put(hash, JSON.stringify(meta)); + return { token, hash, meta }; +} + +// lastUsed lives in its own key so stamping it never rewrites (and never resurrects) +// the token record that the admin may be concurrently disabling. +const luKey = (hash: string) => `${hash}:lu`; + +export type TokenRow = TokenMetadata & { hash: string; lastUsed?: string }; + +function parseMeta(raw: string | null): TokenMetadata | null { + if (!raw) return null; + try { + return JSON.parse(raw) as TokenMetadata; + } catch { + return null; + } +} + +/** Resolve a token hash to its metadata, only if it exists and is active. */ +export async function getValidatedByHash( + kv: KVNamespace, + hash: string, +): Promise { + const meta = parseMeta(await kv.get(hash)); + return meta?.status === "active" ? meta : null; +} + +/** Resolve a plaintext token to its metadata, only if it exists and is active. */ +export async function getValidated( + kv: KVNamespace, + token: string, +): Promise { + return getValidatedByHash(kv, await sha256hex(token)); +} + +export async function listTokens(kv: KVNamespace): Promise { + // Paginate so we never silently truncate at KV's 1000-key page limit. + const hashes: string[] = []; + let cursor: string | undefined; + do { + const res = await kv.list({ cursor }); + for (const k of res.keys) if (!k.name.endsWith(":lu")) hashes.push(k.name); + cursor = res.list_complete ? undefined : res.cursor; + } while (cursor); + + const rows = await Promise.all( + hashes.map(async (hash): Promise => { + const [raw, lastUsed] = await Promise.all([ + kv.get(hash), + kv.get(luKey(hash)), + ]); + const meta = parseMeta(raw); + return meta ? { hash, ...meta, lastUsed: lastUsed ?? undefined } : null; + }), + ); + return rows.filter((r): r is TokenRow => r !== null); +} + +export async function updateToken( + kv: KVNamespace, + hash: string, + patch: Partial>, +): Promise { + const meta = parseMeta(await kv.get(hash)); + if (!meta) return null; + const updated = { ...meta, ...patch }; + await kv.put(hash, JSON.stringify(updated)); + return updated; +} + +export async function deleteToken( + kv: KVNamespace, + hash: string, +): Promise { + await Promise.all([kv.delete(hash), kv.delete(luKey(hash))]); +} + +export async function touchLastUsed( + kv: KVNamespace, + hash: string, +): Promise { + // Write only the side key; never touch the token record (avoids resurrecting a revoke). + await kv.put(luKey(hash), new Date().toISOString()); +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..e16c3b8 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,25 @@ +export type CoarseProvider = "openai" | "anthropic" | "gemini"; +export type Provider = CoarseProvider | "gemini-openai"; + +export interface TokenMetadata { + label: string; + last4: string; + providers: CoarseProvider[]; + status: "active" | "disabled"; + createdAt: string; // ISO + // lastUsed is stored in a separate `:lu` key (see tokens.ts), not here. + // reserved for Later (absent in v1): expiresAt, limits, spend +} + +export interface Env { + TOKENS: KVNamespace; + US_EGRESS: DurableObjectNamespace; // North-America-pinned egress relay (see egress.ts) + OPENAI_API_KEY: string; + ANTHROPIC_API_KEY: string; + GEMINI_API_KEY: string; + ADMIN_SECRET: string; + // Optional upstream overrides (plain vars, NOT secrets); default to the real hosts. + OPENAI_UPSTREAM?: string; + ANTHROPIC_UPSTREAM?: string; + GEMINI_UPSTREAM?: string; +} diff --git a/src/upstreams.ts b/src/upstreams.ts new file mode 100644 index 0000000..13d2c98 --- /dev/null +++ b/src/upstreams.ts @@ -0,0 +1,32 @@ +// Upstream resolver + test seam. *_UPSTREAM env vars default to the real hosts, so +// production is unchanged when unset; tests point them at a local mock. +import type { Env, Provider } from "./types"; + +const DEFAULTS = { + openai: "https://api.openai.com", + anthropic: "https://api.anthropic.com", + gemini: "https://generativelanguage.googleapis.com", +} as const; + +export function upstreamBase(provider: Provider, env: Env): string { + switch (provider === "gemini-openai" ? "gemini" : provider) { + case "openai": + return env.OPENAI_UPSTREAM || DEFAULTS.openai; + case "anthropic": + return env.ANTHROPIC_UPSTREAM || DEFAULTS.anthropic; + case "gemini": + return env.GEMINI_UPSTREAM || DEFAULTS.gemini; + } +} + +/** Rewrite protocol/hostname/port to the upstream, leaving path and query intact. */ +export function rewriteToUpstream( + url: URL, + provider: Provider, + env: Env, +): void { + const base = new URL(upstreamBase(provider, env)); + url.protocol = base.protocol; + url.hostname = base.hostname; + url.port = base.port; +} diff --git a/test/admin.test.ts b/test/admin.test.ts new file mode 100644 index 0000000..ba53648 --- /dev/null +++ b/test/admin.test.ts @@ -0,0 +1,138 @@ +import { + createExecutionContext, + env, + waitOnExecutionContext, +} from "cloudflare:test"; +import { describe, expect, it } from "vitest"; +import worker from "../src/index"; +import { getValidated, sha256hex } from "../src/tokens"; + +async function call(path: string, init?: RequestInit): Promise { + const ctx = createExecutionContext(); + const res = await worker.fetch( + new Request(`https://proxy.example${path}`, init), + env, + ctx, + ); + await waitOnExecutionContext(ctx); + return res; +} + +const form = (data: Record) => ({ + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams(data).toString(), +}); + +async function login(): Promise { + const res = await call( + "/admin/login", + form({ password: "test-admin-secret" }), + ); + expect(res.status).toBe(200); + const setCookie = res.headers.get("set-cookie"); + expect(setCookie).toBeTruthy(); + return setCookie!.split(";")[0]; +} + +describe("admin auth", () => { + it("serves a login page (not the dashboard) when unauthenticated", async () => { + const res = await call("/admin"); + expect(res.status).toBe(200); + expect(await res.text()).toContain("password"); + }); + it("rejects a wrong password", async () => { + const res = await call("/admin/login", form({ password: "nope" })); + expect(res.status).toBe(401); + }); + it("rejects API calls without a valid cookie", async () => { + const res = await call("/admin/api/tokens"); + expect(res.status).toBe(401); + }); + it("accepts a valid cookie", async () => { + const cookie = await login(); + const res = await call("/admin/api/tokens", { headers: { cookie } }); + expect(res.status).toBe(200); + }); +}); + +describe("admin token CRUD", () => { + it("creates a custom token that the proxy then accepts", async () => { + const cookie = await login(); + const res = await call("/admin/api/tokens", { + ...form({ label: "alice", providers: "openai", token: "compat-xyz" }), + headers: { "content-type": "application/x-www-form-urlencoded", cookie }, + }); + expect(res.status).toBe(200); + const meta = await getValidated(env.TOKENS, "compat-xyz"); + expect(meta?.label).toBe("alice"); + expect(meta?.providers).toEqual(["openai"]); + }); + + it("lists tokens by label", async () => { + const cookie = await login(); + await call("/admin/api/tokens", { + ...form({ label: "bob", providers: "anthropic", token: "list-bob" }), + headers: { "content-type": "application/x-www-form-urlencoded", cookie }, + }); + const res = await call("/admin/api/tokens", { headers: { cookie } }); + expect(await res.text()).toContain("bob"); + }); + + it("disables a token so the proxy rejects it", async () => { + const cookie = await login(); + await call("/admin/api/tokens", { + ...form({ label: "c", providers: "gemini", token: "to-disable" }), + headers: { "content-type": "application/x-www-form-urlencoded", cookie }, + }); + const hash = await sha256hex("to-disable"); + const upd = await call(`/admin/api/tokens/${hash}`, { + method: "PUT", + headers: { "content-type": "application/x-www-form-urlencoded", cookie }, + body: new URLSearchParams({ status: "disabled" }).toString(), + }); + expect(upd.status).toBe(200); + expect(await getValidated(env.TOKENS, "to-disable")).toBeNull(); + }); + + it("deletes a token", async () => { + const cookie = await login(); + await call("/admin/api/tokens", { + ...form({ label: "d", providers: "openai", token: "to-delete" }), + headers: { "content-type": "application/x-www-form-urlencoded", cookie }, + }); + const hash = await sha256hex("to-delete"); + const del = await call(`/admin/api/tokens/${hash}`, { + method: "DELETE", + headers: { cookie }, + }); + expect(del.status).toBe(200); + expect(await getValidated(env.TOKENS, "to-delete")).toBeNull(); + }); + + it("rejects a malformed token id on PUT and DELETE", async () => { + const cookie = await login(); + const put = await call("/admin/api/tokens/not-a-hash", { + method: "PUT", + headers: { "content-type": "application/x-www-form-urlencoded", cookie }, + body: "status=disabled", + }); + expect(put.status).toBe(400); + const del = await call("/admin/api/tokens/xyz", { + method: "DELETE", + headers: { cookie }, + }); + expect(del.status).toBe(400); + }); + + it("supports multiple providers from repeated form fields", async () => { + const cookie = await login(); + await call("/admin/api/tokens", { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded", cookie }, + body: "label=multi&token=multi-tok&providers=openai&providers=anthropic", + }); + const meta = await getValidated(env.TOKENS, "multi-tok"); + expect(meta?.providers).toEqual(["openai", "anthropic"]); + }); +}); diff --git a/test/env.d.ts b/test/env.d.ts new file mode 100644 index 0000000..217b8ab --- /dev/null +++ b/test/env.d.ts @@ -0,0 +1,9 @@ +/// +import type { Env as WorkerEnv } from "../src/types"; + +// `env` from cloudflare:test is typed as Cloudflare.Env; make it carry our bindings. +declare global { + namespace Cloudflare { + interface Env extends WorkerEnv {} + } +} diff --git a/test/proxy-handler.test.ts b/test/proxy-handler.test.ts new file mode 100644 index 0000000..afaf740 --- /dev/null +++ b/test/proxy-handler.test.ts @@ -0,0 +1,334 @@ +import { + createExecutionContext, + env, + waitOnExecutionContext, +} from "cloudflare:test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import worker from "../src/index"; +import { createToken, updateToken } from "../src/tokens"; + +let captured: Request | null; +let fetchSpy: ReturnType; + +beforeEach(() => { + captured = null; + fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockImplementation( + async (input: RequestInfo | URL, init?: RequestInit) => { + captured = input instanceof Request ? input : new Request(input, init); + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }, + ); +}); +afterEach(() => vi.restoreAllMocks()); + +async function call(req: Request): Promise { + const ctx = createExecutionContext(); + const res = await worker.fetch(req, env, ctx); + await waitOnExecutionContext(ctx); + return res; +} + +const seed = ( + token: string, + providers: ("openai" | "anthropic" | "gemini")[], +) => createToken(env.TOKENS, { label: token, providers, token }); + +describe("proxy routing + key swap", () => { + it("forwards a valid OpenAI request with the real key swapped in", async () => { + await seed("tk-oai", ["openai"]); + const res = await call( + new Request("https://proxy.example/v1/chat/completions", { + method: "POST", + headers: { + authorization: "Bearer tk-oai", + "content-type": "application/json", + }, + body: JSON.stringify({ model: "gpt-x", messages: [] }), + }), + ); + expect(res.status).toBe(200); + const u = new URL(captured!.url); + expect(u.hostname).toBe("api.openai.com"); + expect(u.pathname).toBe("/v1/chat/completions"); + expect(captured!.headers.get("authorization")).toBe( + "Bearer real-openai-key-FAKE", + ); + }); + + it("forwards a valid Anthropic request swapping x-api-key and passing other headers through", async () => { + await seed("tk-anth", ["anthropic"]); + const res = await call( + new Request("https://proxy.example/v1/messages", { + method: "POST", + headers: { "x-api-key": "tk-anth", "anthropic-version": "2023-06-01" }, + body: "{}", + }), + ); + expect(res.status).toBe(200); + expect(new URL(captured!.url).hostname).toBe("api.anthropic.com"); + expect(captured!.headers.get("x-api-key")).toBe("real-anthropic-key-FAKE"); + expect(captured!.headers.get("anthropic-version")).toBe("2023-06-01"); + }); + + it("forwards a Gemini request swapping x-goog-api-key, dropping ?key=, preserving other query", async () => { + await seed("tk-gem", ["gemini"]); + const res = await call( + new Request( + "https://proxy.example/v1beta/models/g:generateContent?key=tk-gem&alt=sse", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }, + ), + ); + expect(res.status).toBe(200); + const u = new URL(captured!.url); + expect(u.hostname).toBe("generativelanguage.googleapis.com"); + expect(u.searchParams.get("key")).toBeNull(); + expect(u.searchParams.get("alt")).toBe("sse"); + expect(captured!.headers.get("x-goog-api-key")).toBe( + "real-gemini-key-FAKE", + ); + }); + + it("routes the Gemini OpenAI-compat path via bearer to the gemini upstream", async () => { + await seed("tk-gem2", ["gemini"]); + const res = await call( + new Request("https://proxy.example/v1beta/openai/chat/completions", { + method: "POST", + headers: { authorization: "Bearer tk-gem2" }, + body: "{}", + }), + ); + expect(res.status).toBe(200); + expect(new URL(captured!.url).hostname).toBe( + "generativelanguage.googleapis.com", + ); + expect(captured!.headers.get("authorization")).toBe( + "Bearer real-gemini-key-FAKE", + ); + }); +}); + +describe("auth failures (upstream never called)", () => { + it("401 when no token is present", async () => { + const res = await call( + new Request("https://proxy.example/v1/chat/completions", { + method: "POST", + body: "{}", + }), + ); + expect(res.status).toBe(401); + expect(captured).toBeNull(); + }); + it("401 for an unknown token", async () => { + const res = await call( + new Request("https://proxy.example/v1/messages", { + method: "POST", + headers: { "x-api-key": "ghost" }, + body: "{}", + }), + ); + expect(res.status).toBe(401); + expect(captured).toBeNull(); + }); + it("401 for a disabled token", async () => { + const { hash } = await createToken(env.TOKENS, { + label: "d", + providers: ["openai"], + token: "tk-disabled", + }); + await updateToken(env.TOKENS, hash, { status: "disabled" }); + const res = await call( + new Request("https://proxy.example/v1/chat/completions", { + method: "POST", + headers: { authorization: "Bearer tk-disabled" }, + body: "{}", + }), + ); + expect(res.status).toBe(401); + expect(captured).toBeNull(); + }); + it("403 when the token is not scoped to the requested provider", async () => { + await createToken(env.TOKENS, { + label: "s", + providers: ["openai"], + token: "tk-oai-only", + }); + const res = await call( + new Request("https://proxy.example/v1/messages", { + method: "POST", + headers: { "x-api-key": "tk-oai-only" }, + body: "{}", + }), + ); + expect(res.status).toBe(403); + expect(captured).toBeNull(); + }); +}); + +describe("security invariant", () => { + it("never forwards the doppelganger token upstream", async () => { + await createToken(env.TOKENS, { + label: "sec", + providers: ["openai"], + token: "SECRET-DOPPEL", + }); + await call( + new Request("https://proxy.example/v1/chat/completions", { + method: "POST", + headers: { authorization: "Bearer SECRET-DOPPEL" }, + body: "{}", + }), + ); + const slots = [ + captured!.headers.get("authorization"), + captured!.headers.get("x-api-key"), + captured!.headers.get("x-goog-api-key"), + ].join("|"); + expect(slots).not.toContain("SECRET-DOPPEL"); + }); +}); + +describe("OpenAI geo-403 fallback via the US egress DO", () => { + const realEgress = env.US_EGRESS; + let egressCalls: Request[]; + afterEach(() => { + (env as { US_EGRESS: typeof realEgress }).US_EGRESS = realEgress; + }); + + // Replace the DO namespace with a fake whose stub.fetch records the request and returns 200. + function fakeEgress() { + egressCalls = []; + const stub = { + fetch: async (r: Request) => { + egressCalls.push(r); + return new Response(JSON.stringify({ ok: "via-egress" }), { + status: 200, + }); + }, + }; + (env as { US_EGRESS: unknown }).US_EGRESS = { + idFromName: () => ({}), + get: () => stub, + }; + } + + const geo403 = () => + new Response( + JSON.stringify({ + error: { code: "unsupported_country_region_territory" }, + }), + { status: 403 }, + ); + + it("retries through the egress DO when OpenAI returns a geo-403, with the real key", async () => { + await seed("tk-geo", ["openai"]); + fakeEgress(); + fetchSpy.mockImplementation( + async (input: RequestInfo | URL, init?: RequestInit) => { + captured = input instanceof Request ? input : new Request(input, init); + return geo403(); + }, + ); + const res = await call( + new Request("https://proxy.example/v1/chat/completions", { + method: "POST", + headers: { + authorization: "Bearer tk-geo", + "content-type": "application/json", + }, + body: JSON.stringify({ + model: "gpt-x", + messages: [{ role: "user", content: "hi" }], + }), + }), + ); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ok: "via-egress" }); + expect(egressCalls.length).toBe(1); + const sent = egressCalls[0]; + expect(new URL(sent.url).hostname).toBe("api.openai.com"); + expect(sent.headers.get("authorization")).toBe( + "Bearer real-openai-key-FAKE", + ); + expect(await sent.text()).toContain("hi"); // buffered body survived to the retry + }); + + it("does NOT retry on a non-geo 403 (passes it through)", async () => { + await seed("tk-403", ["openai"]); + fakeEgress(); + fetchSpy.mockImplementation( + async () => + new Response(JSON.stringify({ error: { code: "invalid_api_key" } }), { + status: 403, + }), + ); + const res = await call( + new Request("https://proxy.example/v1/chat/completions", { + method: "POST", + headers: { authorization: "Bearer tk-403" }, + body: "{}", + }), + ); + expect(res.status).toBe(403); + expect(egressCalls.length).toBe(0); + }); + + it("never routes non-OpenAI providers through the egress DO", async () => { + await seed("tk-anth-geo", ["anthropic"]); + fakeEgress(); + fetchSpy.mockImplementation(async () => geo403()); + const res = await call( + new Request("https://proxy.example/v1/messages", { + method: "POST", + headers: { "x-api-key": "tk-anth-geo" }, + body: "{}", + }), + ); + expect(res.status).toBe(403); // anthropic 403 passes straight through + expect(egressCalls.length).toBe(0); + }); +}); + +describe("SSE passthrough", () => { + it("streams text/event-stream chunks through without buffering", async () => { + await createToken(env.TOKENS, { + label: "sse", + providers: ["openai"], + token: "tk-sse", + }); + fetchSpy.mockImplementation(async () => { + const enc = new TextEncoder(); + const stream = new ReadableStream({ + start(c) { + c.enqueue(enc.encode("data: a\n\n")); + c.enqueue(enc.encode("data: b\n\n")); + c.enqueue(enc.encode("data: [DONE]\n\n")); + c.close(); + }, + }); + return new Response(stream, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + }); + const res = await call( + new Request("https://proxy.example/v1/chat/completions", { + method: "POST", + headers: { authorization: "Bearer tk-sse" }, + body: "{}", + }), + ); + expect(res.headers.get("content-type")).toBe("text/event-stream"); + const text = await res.text(); + expect(text).toContain("data: a"); + expect(text).toContain("[DONE]"); + }); +}); diff --git a/test/proxy.test.ts b/test/proxy.test.ts new file mode 100644 index 0000000..87d0f38 --- /dev/null +++ b/test/proxy.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from "vitest"; +import { + coarse, + extractToken, + realKeyFor, + routeProvider, + swapAuth, +} from "../src/proxy"; +import type { Env } from "../src/types"; + +function ctx( + headers: Record, + urlStr = "https://proxy.example/v1/chat/completions", +) { + return { req: new Request(urlStr, { headers }), url: new URL(urlStr) }; +} + +describe("extractToken", () => { + it("reads the anthropic x-api-key slot", () => { + const { req, url } = ctx( + { "x-api-key": "tok_anth" }, + "https://proxy.example/v1/messages", + ); + expect(extractToken(req, url)).toBe("tok_anth"); + }); + it("reads the gemini x-goog-api-key slot", () => { + const { req, url } = ctx({ "x-goog-api-key": "tok_gem" }); + expect(extractToken(req, url)).toBe("tok_gem"); + }); + it("reads the bearer token from authorization", () => { + const { req, url } = ctx({ authorization: "Bearer tok_oai" }); + expect(extractToken(req, url)).toBe("tok_oai"); + }); + it("reads ?key= for raw gemini REST callers", () => { + const { req, url } = ctx( + {}, + "https://proxy.example/v1beta/models/x:generateContent?key=tok_q", + ); + expect(extractToken(req, url)).toBe("tok_q"); + }); + it("prefers x-api-key when both x-api-key and authorization are present (anthropic authToken case)", () => { + const { req, url } = ctx({ + "x-api-key": "tok_anth", + authorization: "Bearer tok_other", + }); + expect(extractToken(req, url)).toBe("tok_anth"); + }); + it("returns null when no auth is present", () => { + const { req, url } = ctx({}); + expect(extractToken(req, url)).toBeNull(); + }); +}); + +describe("routeProvider", () => { + it("routes x-api-key to anthropic", () => { + const { req, url } = ctx( + { "x-api-key": "t" }, + "https://proxy.example/v1/messages", + ); + expect(routeProvider(req, url)).toBe("anthropic"); + }); + it("routes x-goog-api-key to gemini", () => { + const { req, url } = ctx({ "x-goog-api-key": "t" }); + expect(routeProvider(req, url)).toBe("gemini"); + }); + it("routes bearer + normal path to openai", () => { + const { req, url } = ctx( + { authorization: "Bearer t" }, + "https://proxy.example/v1/chat/completions", + ); + expect(routeProvider(req, url)).toBe("openai"); + }); + it("routes bearer + /v1beta/openai/ path to gemini-openai", () => { + const { req, url } = ctx( + { authorization: "Bearer t" }, + "https://proxy.example/v1beta/openai/chat/completions", + ); + expect(routeProvider(req, url)).toBe("gemini-openai"); + }); + it("routes ?key= to gemini", () => { + const { req, url } = ctx( + {}, + "https://proxy.example/v1beta/models/x:generateContent?key=t", + ); + expect(routeProvider(req, url)).toBe("gemini"); + }); + it("returns null when no provider can be determined", () => { + const { req, url } = ctx({}); + expect(routeProvider(req, url)).toBeNull(); + }); +}); + +describe("coarse", () => { + it("maps gemini-openai to the gemini scope", () => + expect(coarse("gemini-openai")).toBe("gemini")); + it("leaves openai/anthropic/gemini unchanged", () => { + expect(coarse("openai")).toBe("openai"); + expect(coarse("anthropic")).toBe("anthropic"); + expect(coarse("gemini")).toBe("gemini"); + }); +}); + +describe("swapAuth", () => { + it("sets bearer for openai and strips the other slots", () => { + const h = new Headers({ + authorization: "Bearer DOPPEL", + "x-api-key": "DOPPEL", + "x-goog-api-key": "DOPPEL", + }); + swapAuth(h, "openai", "REALKEY"); + expect(h.get("authorization")).toBe("Bearer REALKEY"); + expect(h.get("x-api-key")).toBeNull(); + expect(h.get("x-goog-api-key")).toBeNull(); + }); + it("sets x-api-key for anthropic and strips the other slots", () => { + const h = new Headers({ + authorization: "Bearer DOPPEL", + "x-api-key": "DOPPEL", + }); + swapAuth(h, "anthropic", "REALKEY"); + expect(h.get("x-api-key")).toBe("REALKEY"); + expect(h.get("authorization")).toBeNull(); + expect(h.get("x-goog-api-key")).toBeNull(); + }); + it("sets x-goog-api-key for gemini", () => { + const h = new Headers({ "x-goog-api-key": "DOPPEL" }); + swapAuth(h, "gemini", "REALKEY"); + expect(h.get("x-goog-api-key")).toBe("REALKEY"); + }); + it("sets bearer for gemini-openai", () => { + const h = new Headers({ authorization: "Bearer DOPPEL" }); + swapAuth(h, "gemini-openai", "REALKEY"); + expect(h.get("authorization")).toBe("Bearer REALKEY"); + }); + it("never leaves the doppelganger token in any auth header", () => { + const h = new Headers({ + "x-api-key": "DOPPEL", + authorization: "Bearer DOPPEL", + "x-goog-api-key": "DOPPEL", + }); + swapAuth(h, "anthropic", "REALKEY"); + const all = [ + h.get("authorization"), + h.get("x-api-key"), + h.get("x-goog-api-key"), + ].join("|"); + expect(all).not.toContain("DOPPEL"); + }); +}); + +describe("realKeyFor", () => { + const env = { + OPENAI_API_KEY: "oai", + ANTHROPIC_API_KEY: "anth", + GEMINI_API_KEY: "gem", + } as Env; + it("returns the right key per provider", () => { + expect(realKeyFor("openai", env)).toBe("oai"); + expect(realKeyFor("anthropic", env)).toBe("anth"); + expect(realKeyFor("gemini", env)).toBe("gem"); + expect(realKeyFor("gemini-openai", env)).toBe("gem"); + }); +}); diff --git a/test/sdk-compat/anthropic.test.ts b/test/sdk-compat/anthropic.test.ts new file mode 100644 index 0000000..eb609f7 --- /dev/null +++ b/test/sdk-compat/anthropic.test.ts @@ -0,0 +1,68 @@ +import Anthropic from "@anthropic-ai/sdk"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import type { Unstable_DevWorker } from "wrangler"; +import { + FAKE, + type MockUpstream, + seedToken, + startMockUpstream, + startWorker, +} from "./setup"; + +let mock: MockUpstream; +let worker: Unstable_DevWorker; +let baseURL: string; +const TOKEN = "compat-anthropic-token"; + +beforeAll(async () => { + mock = await startMockUpstream(); + const w = await startWorker(mock.url); + worker = w.worker; + baseURL = w.url; + await seedToken(baseURL, { + token: TOKEN, + providers: ["anthropic"], + label: "anthropic", + }); +}); + +afterAll(async () => { + await worker?.stop(); + await mock?.close(); +}); + +beforeEach(() => mock.reset()); + +// Anthropic SDK appends /v1/messages itself, so baseURL must NOT include /v1. +const client = () => new Anthropic({ baseURL, apiKey: TOKEN }); + +describe("anthropic SDK compatibility", () => { + it("forwards a message with x-api-key swapped to the real key and the token absent", async () => { + const r = await client().messages.create({ + model: "claude-x", + max_tokens: 16, + messages: [{ role: "user", content: "hi" }], + }); + expect(r).toBeTruthy(); + const cap = mock.last(); + expect(cap?.path).toBe("/v1/messages"); + expect(cap?.headers["x-api-key"]).toBe(FAKE.anthropic); + expect(cap?.headers["anthropic-version"]).toBeTruthy(); // SDK header passed through + expect(JSON.stringify(cap?.headers)).not.toContain(TOKEN); + }); + + it("streams a message through the proxy", async () => { + const stream = client().messages.stream({ + model: "claude-x", + max_tokens: 16, + messages: [{ role: "user", content: "hi" }], + }); + let text = ""; + for await (const ev of stream) { + if (ev.type === "content_block_delta" && ev.delta.type === "text_delta") + text += ev.delta.text; + } + expect(text).toContain("hi"); + expect(mock.last()?.headers["x-api-key"]).toBe(FAKE.anthropic); + }); +}); diff --git a/test/sdk-compat/gemini.test.ts b/test/sdk-compat/gemini.test.ts new file mode 100644 index 0000000..f886525 --- /dev/null +++ b/test/sdk-compat/gemini.test.ts @@ -0,0 +1,87 @@ +import { GoogleGenAI } from "@google/genai"; +import OpenAI from "openai"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import type { Unstable_DevWorker } from "wrangler"; +import { + FAKE, + type MockUpstream, + seedToken, + startMockUpstream, + startWorker, +} from "./setup"; + +let mock: MockUpstream; +let worker: Unstable_DevWorker; +let baseURL: string; +const TOKEN = "compat-gemini-token"; + +beforeAll(async () => { + mock = await startMockUpstream(); + const w = await startWorker(mock.url); + worker = w.worker; + baseURL = w.url; + await seedToken(baseURL, { + token: TOKEN, + providers: ["gemini"], + label: "gemini", + }); +}); + +afterAll(async () => { + await worker?.stop(); + await mock?.close(); +}); + +beforeEach(() => mock.reset()); + +describe("google genai SDK compatibility", () => { + const ai = () => + new GoogleGenAI({ apiKey: TOKEN, httpOptions: { baseUrl: baseURL } }); + + it("forwards generateContent with x-goog-api-key swapped and the token absent", async () => { + const r = await ai().models.generateContent({ + model: "gemini-2.5-flash", + contents: "hi", + }); + expect(r).toBeTruthy(); + const cap = mock.last(); + expect(cap?.path).toContain( + "/v1beta/models/gemini-2.5-flash:generateContent", + ); + expect(cap?.headers["x-goog-api-key"]).toBe(FAKE.gemini); + expect(cap?.path).not.toContain(TOKEN); // ?key= (if used) is stripped + expect(JSON.stringify(cap?.headers)).not.toContain(TOKEN); + }); + + it("streams generateContent through the proxy (alt=sse preserved)", async () => { + const stream = await ai().models.generateContentStream({ + model: "gemini-2.5-flash", + contents: "hi", + }); + let text = ""; + for await (const chunk of stream) text += chunk.text ?? ""; + expect(text).toContain("hi"); + const cap = mock.last(); + expect(cap?.path).toContain("streamGenerateContent"); + expect(cap?.path).toContain("alt=sse"); + expect(cap?.headers["x-goog-api-key"]).toBe(FAKE.gemini); + }); +}); + +describe("gemini via the OpenAI-compat route", () => { + // OpenAI SDK pointed at /v1beta/openai — token in Authorization: Bearer, scoped to gemini. + const oai = () => + new OpenAI({ baseURL: `${baseURL}/v1beta/openai`, apiKey: TOKEN }); + + it("routes to the gemini upstream with the real gemini key as a bearer token", async () => { + const r = await oai().chat.completions.create({ + model: "gemini-2.5-flash", + messages: [{ role: "user", content: "hi" }], + }); + expect(r).toBeTruthy(); + const cap = mock.last(); + expect(cap?.path).toBe("/v1beta/openai/chat/completions"); + expect(cap?.headers.authorization).toBe(`Bearer ${FAKE.gemini}`); + expect(JSON.stringify(cap?.headers)).not.toContain(TOKEN); + }); +}); diff --git a/test/sdk-compat/openai.test.ts b/test/sdk-compat/openai.test.ts new file mode 100644 index 0000000..a371a75 --- /dev/null +++ b/test/sdk-compat/openai.test.ts @@ -0,0 +1,63 @@ +import OpenAI from "openai"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import type { Unstable_DevWorker } from "wrangler"; +import { + FAKE, + type MockUpstream, + seedToken, + startMockUpstream, + startWorker, +} from "./setup"; + +let mock: MockUpstream; +let worker: Unstable_DevWorker; +let baseURL: string; +const TOKEN = "compat-openai-token"; + +beforeAll(async () => { + mock = await startMockUpstream(); + const w = await startWorker(mock.url); + worker = w.worker; + baseURL = w.url; + await seedToken(baseURL, { + token: TOKEN, + providers: ["openai"], + label: "openai", + }); +}); + +afterAll(async () => { + await worker?.stop(); + await mock?.close(); +}); + +beforeEach(() => mock.reset()); + +const client = () => new OpenAI({ baseURL: `${baseURL}/v1`, apiKey: TOKEN }); + +describe("openai SDK compatibility", () => { + it("forwards a chat completion with the real key swapped in and the token absent", async () => { + const r = await client().chat.completions.create({ + model: "gpt-x", + messages: [{ role: "user", content: "hi" }], + }); + expect(r).toBeTruthy(); + const cap = mock.last(); + expect(cap?.path).toBe("/v1/chat/completions"); + expect(cap?.headers.authorization).toBe(`Bearer ${FAKE.openai}`); + expect(JSON.stringify(cap?.headers)).not.toContain(TOKEN); + }); + + it("streams a chat completion through the proxy", async () => { + const stream = await client().chat.completions.create({ + model: "gpt-x", + stream: true, + messages: [{ role: "user", content: "hi" }], + }); + let text = ""; + for await (const chunk of stream) + text += chunk.choices?.[0]?.delta?.content ?? ""; + expect(text).toContain("hi"); + expect(mock.last()?.headers.authorization).toBe(`Bearer ${FAKE.openai}`); + }); +}); diff --git a/test/sdk-compat/setup.ts b/test/sdk-compat/setup.ts new file mode 100644 index 0000000..f078e11 --- /dev/null +++ b/test/sdk-compat/setup.ts @@ -0,0 +1,199 @@ +import http from "node:http"; +import type { AddressInfo } from "node:net"; +import { type Unstable_DevWorker, unstable_dev } from "wrangler"; + +// Fake real-keys injected as the worker's bindings. Tests assert these reach the mock +// upstream (proving the swap) and that the doppelganger token never does. +export const FAKE = { + openai: "FAKE-OPENAI-KEY", + anthropic: "FAKE-ANTHROPIC-KEY", + gemini: "FAKE-GEMINI-KEY", +}; +export const ADMIN_SECRET = "compat-admin-secret"; + +export interface Captured { + method: string; + path: string; + headers: Record; + body: string; +} + +export interface MockUpstream { + url: string; + last(): Captured | null; + reset(): void; + close(): Promise; +} + +function providerFromPath(path: string): "openai" | "anthropic" | "gemini" { + if (path.includes("/v1beta/openai/")) return "openai"; // gemini OpenAI-compat uses OpenAI shape + if ( + path.includes(":generateContent") || + path.includes(":streamGenerateContent") || + path.startsWith("/v1beta/") + ) + return "gemini"; + if (path.includes("/v1/messages")) return "anthropic"; + return "openai"; +} + +function writeJson(res: http.ServerResponse, provider: string) { + res.writeHead(200, { "content-type": "application/json" }); + if (provider === "anthropic") + res.end( + JSON.stringify({ + id: "msg_1", + type: "message", + role: "assistant", + model: "x", + content: [{ type: "text", text: "hi" }], + stop_reason: "end_turn", + usage: { input_tokens: 1, output_tokens: 1 }, + }), + ); + else if (provider === "gemini") + res.end( + JSON.stringify({ + candidates: [ + { + content: { parts: [{ text: "hi" }], role: "model" }, + finishReason: "STOP", + }, + ], + usageMetadata: { + promptTokenCount: 1, + candidatesTokenCount: 1, + totalTokenCount: 2, + }, + }), + ); + else + res.end( + JSON.stringify({ + id: "chatcmpl_1", + object: "chat.completion", + created: 0, + model: "x", + choices: [ + { + index: 0, + message: { role: "assistant", content: "hi" }, + finish_reason: "stop", + }, + ], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }), + ); +} + +function writeSse(res: http.ServerResponse, provider: string) { + res.writeHead(200, { + "content-type": "text/event-stream", + "cache-control": "no-cache", + }); + if (provider === "anthropic") { + res.write( + 'event: message_start\ndata: {"type":"message_start","message":{"id":"m","type":"message","role":"assistant","model":"x","content":[],"stop_reason":null,"usage":{"input_tokens":1,"output_tokens":0}}}\n\n', + ); + res.write( + 'event: content_block_start\ndata: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}\n\n', + ); + res.write( + 'event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}}\n\n', + ); + res.write( + 'event: content_block_stop\ndata: {"type":"content_block_stop","index":0}\n\n', + ); + res.write( + 'event: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}}\n\n', + ); + res.write('event: message_stop\ndata: {"type":"message_stop"}\n\n'); + } else if (provider === "gemini") { + res.write( + 'data: {"candidates":[{"content":{"parts":[{"text":"hi"}],"role":"model"},"finishReason":"STOP"}]}\n\n', + ); + } else { + res.write( + 'data: {"id":"x","object":"chat.completion.chunk","created":0,"model":"x","choices":[{"index":0,"delta":{"role":"assistant","content":"hi"},"finish_reason":null}]}\n\n', + ); + res.write( + 'data: {"id":"x","object":"chat.completion.chunk","created":0,"model":"x","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n', + ); + res.write("data: [DONE]\n\n"); + } + res.end(); +} + +export async function startMockUpstream(): Promise { + let captured: Captured | null = null; + const server = http.createServer((req, res) => { + const chunks: Buffer[] = []; + req.on("data", (c) => chunks.push(c)); + req.on("end", () => { + const path = req.url ?? ""; + const body = Buffer.concat(chunks).toString(); + captured = { method: req.method ?? "", path, headers: req.headers, body }; + const provider = providerFromPath(path); + const stream = + path.includes("streamGenerateContent") || + /[?&]alt=sse/.test(path) || + /"stream"\s*:\s*true/.test(body); + if (stream) writeSse(res, provider); + else writeJson(res, provider); + }); + }); + await new Promise((r) => server.listen(0, "127.0.0.1", () => r())); + const port = (server.address() as AddressInfo).port; + return { + url: `http://127.0.0.1:${port}`, + last: () => captured, + reset: () => { + captured = null; + }, + close: () => new Promise((res) => server.close(() => res())), + }; +} + +export async function startWorker( + mockUrl: string, +): Promise<{ worker: Unstable_DevWorker; url: string }> { + const worker = await unstable_dev("src/index.ts", { + config: "wrangler.toml", + local: true, + vars: { + OPENAI_API_KEY: FAKE.openai, + ANTHROPIC_API_KEY: FAKE.anthropic, + GEMINI_API_KEY: FAKE.gemini, + ADMIN_SECRET, + OPENAI_UPSTREAM: mockUrl, + ANTHROPIC_UPSTREAM: mockUrl, + GEMINI_UPSTREAM: mockUrl, + }, + experimental: { disableExperimentalWarning: true }, + }); + return { worker, url: `http://${worker.address}:${worker.port}` }; +} + +export async function seedToken( + url: string, + opts: { token: string; providers: string[]; label?: string }, +): Promise { + const login = await fetch(`${url}/admin/login`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ password: ADMIN_SECRET }).toString(), + }); + if (login.status !== 200) + throw new Error(`admin login failed: ${login.status}`); + const cookie = (login.headers.get("set-cookie") ?? "").split(";")[0]; + const body = new URLSearchParams(); + body.set("label", opts.label ?? opts.token); + body.set("token", opts.token); + for (const p of opts.providers) body.append("providers", p); + const res = await fetch(`${url}/admin/api/tokens`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded", cookie }, + body: body.toString(), + }); + if (res.status !== 200) throw new Error(`seed token failed: ${res.status}`); +} diff --git a/test/tokens.test.ts b/test/tokens.test.ts new file mode 100644 index 0000000..ebd7356 --- /dev/null +++ b/test/tokens.test.ts @@ -0,0 +1,132 @@ +import { env } from "cloudflare:test"; +import { describe, expect, it } from "vitest"; +import { + createToken, + deleteToken, + generateToken, + getValidated, + listTokens, + sha256hex, + touchLastUsed, + updateToken, +} from "../src/tokens"; + +describe("sha256hex", () => { + it("produces a 64-char hex digest", async () => { + expect(await sha256hex("hello")).toMatch(/^[0-9a-f]{64}$/); + }); + it("is deterministic", async () => { + expect(await sha256hex("x")).toBe(await sha256hex("x")); + }); +}); + +describe("generateToken", () => { + it("has the dgk_ prefix and a url-safe body", () => { + expect(generateToken()).toMatch(/^dgk_[A-Za-z0-9_-]{32,}$/); + }); + it("is unique across calls", () => { + expect(generateToken()).not.toBe(generateToken()); + }); +}); + +describe("createToken + getValidated", () => { + it("stores by hash and validates the plaintext token", async () => { + const { token, meta } = await createToken(env.TOKENS, { + label: "alice", + providers: ["openai"], + }); + expect(token).toMatch(/^dgk_/); + expect(meta.last4).toBe(token.slice(-4)); + const got = await getValidated(env.TOKENS, token); + expect(got?.label).toBe("alice"); + expect(got?.providers).toEqual(["openai"]); + expect(got?.status).toBe("active"); + }); + it("accepts a custom admin-typed token", async () => { + const { token } = await createToken(env.TOKENS, { + label: "bob", + providers: ["anthropic"], + token: "my-code", + }); + expect(token).toBe("my-code"); + expect((await getValidated(env.TOKENS, "my-code"))?.label).toBe("bob"); + }); + it("returns null for an unknown token", async () => { + expect(await getValidated(env.TOKENS, "nope-unknown")).toBeNull(); + }); + it("returns null for a disabled token", async () => { + const { token, hash } = await createToken(env.TOKENS, { + label: "c", + providers: ["gemini"], + }); + await updateToken(env.TOKENS, hash, { status: "disabled" }); + expect(await getValidated(env.TOKENS, token)).toBeNull(); + }); + it("never stores the plaintext token in the KV value", async () => { + const { token, hash } = await createToken(env.TOKENS, { + label: "d", + providers: ["openai"], + }); + const raw = await env.TOKENS.get(hash); + expect(raw).not.toContain(token); + }); +}); + +describe("listTokens / updateToken / deleteToken", () => { + it("lists created tokens by hash with metadata", async () => { + await createToken(env.TOKENS, { + label: "L1", + providers: ["openai"], + token: "list-t1", + }); + const row = (await listTokens(env.TOKENS)).find((r) => r.label === "L1"); + expect(row?.hash).toBe(await sha256hex("list-t1")); + }); + it("updates label and providers", async () => { + const { hash } = await createToken(env.TOKENS, { + label: "old", + providers: ["openai"], + token: "upd-t", + }); + const updated = await updateToken(env.TOKENS, hash, { + label: "new", + providers: ["openai", "anthropic"], + }); + expect(updated?.label).toBe("new"); + expect(updated?.providers).toEqual(["openai", "anthropic"]); + }); + it("deletes a token", async () => { + const { token, hash } = await createToken(env.TOKENS, { + label: "del", + providers: ["openai"], + token: "del-t", + }); + await deleteToken(env.TOKENS, hash); + expect(await getValidated(env.TOKENS, token)).toBeNull(); + }); +}); + +describe("touchLastUsed", () => { + it("sets lastUsed without clobbering other fields", async () => { + const { hash } = await createToken(env.TOKENS, { + label: "tu", + providers: ["openai"], + token: "tu-t", + }); + await touchLastUsed(env.TOKENS, hash); + const row = (await listTokens(env.TOKENS)).find((r) => r.hash === hash); + expect(row?.lastUsed).toBeTruthy(); + expect(row?.label).toBe("tu"); + }); + + it("does not resurrect a disabled token when lastUsed is stamped", async () => { + const { token, hash } = await createToken(env.TOKENS, { + label: "rev", + providers: ["openai"], + token: "to-revoke", + }); + await updateToken(env.TOKENS, hash, { status: "disabled" }); + await touchLastUsed(env.TOKENS, hash); // must not re-enable the revoked token + expect(await getValidated(env.TOKENS, token)).toBeNull(); + }); +}); diff --git a/test/upstreams.test.ts b/test/upstreams.test.ts new file mode 100644 index 0000000..0a0cf5b --- /dev/null +++ b/test/upstreams.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import type { Env } from "../src/types"; +import { rewriteToUpstream, upstreamBase } from "../src/upstreams"; + +const bare = {} as Env; + +describe("upstreamBase", () => { + it("defaults to the real hosts when no override is set", () => { + expect(upstreamBase("openai", bare)).toBe("https://api.openai.com"); + expect(upstreamBase("anthropic", bare)).toBe("https://api.anthropic.com"); + expect(upstreamBase("gemini", bare)).toBe( + "https://generativelanguage.googleapis.com", + ); + }); + it("maps gemini-openai to the gemini upstream", () => { + expect(upstreamBase("gemini-openai", bare)).toBe( + "https://generativelanguage.googleapis.com", + ); + }); + it("honors env overrides", () => { + const env = { OPENAI_UPSTREAM: "http://127.0.0.1:9999" } as Env; + expect(upstreamBase("openai", env)).toBe("http://127.0.0.1:9999"); + }); +}); + +describe("rewriteToUpstream", () => { + it("rewrites protocol/host but preserves path and query", () => { + const url = new URL("https://proxy.example/v1/chat/completions?foo=bar"); + rewriteToUpstream(url, "openai", bare); + expect(url.hostname).toBe("api.openai.com"); + expect(url.protocol).toBe("https:"); + expect(url.pathname).toBe("/v1/chat/completions"); + expect(url.search).toBe("?foo=bar"); + }); + it("targets a localhost override with port (the test seam)", () => { + const env = { GEMINI_UPSTREAM: "http://127.0.0.1:9100" } as Env; + const url = new URL( + "https://proxy.example/v1beta/models/x:streamGenerateContent?alt=sse", + ); + rewriteToUpstream(url, "gemini", env); + expect(url.protocol).toBe("http:"); + expect(url.hostname).toBe("127.0.0.1"); + expect(url.port).toBe("9100"); + expect(url.pathname).toBe("/v1beta/models/x:streamGenerateContent"); + expect(url.search).toBe("?alt=sse"); + }); +}); diff --git a/vitest.compat.config.ts b/vitest.compat.config.ts new file mode 100644 index 0000000..3b08105 --- /dev/null +++ b/vitest.compat.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vitest/config"; + +// Tier 2: real-SDK compatibility. Runs in Node (the SDKs run here), hits a locally +// started worker whose *_UPSTREAM env points at a node:http mock. Serial to avoid +// shared-capture-state and port races. +export default defineConfig({ + test: { + include: ["test/sdk-compat/**/*.test.ts"], + pool: "forks", + fileParallelism: false, // serial: each file owns a mock upstream + worker on its own port + testTimeout: 30_000, + hookTimeout: 30_000, + }, +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..659fddc --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,30 @@ +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { defineConfig } from "vitest/config"; + +// Tier 1: proxy logic, run inside workerd via the cloudflareTest plugin (pool-workers 0.16.x). +// Mocks outbound fetch; seeds KV directly. The fake bindings stand in for real keys/secrets. +export default defineConfig({ + test: { + include: ["test/**/*.test.ts"], + exclude: ["test/sdk-compat/**", "node_modules/**"], + }, + plugins: [ + cloudflareTest({ + main: "./src/index.ts", + miniflare: { + compatibilityDate: "2025-01-01", + kvNamespaces: ["TOKENS"], + durableObjects: { + US_EGRESS: { className: "UsEgress", useSQLite: true }, + }, + bindings: { + // FAKE real-keys for tests. Real keys live in .env / CF secrets, never here. + OPENAI_API_KEY: "real-openai-key-FAKE", + ANTHROPIC_API_KEY: "real-anthropic-key-FAKE", + GEMINI_API_KEY: "real-gemini-key-FAKE", + ADMIN_SECRET: "test-admin-secret", + }, + }, + }), + ], +}); diff --git a/wrangler.toml b/wrangler.toml new file mode 100644 index 0000000..b953999 --- /dev/null +++ b/wrangler.toml @@ -0,0 +1,27 @@ +name = "api-proxy" +main = "src/index.ts" +compatibility_date = "2025-01-01" +workers_dev = true +preview_urls = false + +# Token store. Create with: nubx wrangler kv namespace create api-proxy-tokens +# then paste the returned id below before deploy. Local dev/tests use an in-memory namespace. +# Dedicated to api-proxy — must NOT be shared with any other worker. +[[kv_namespaces]] +binding = "TOKENS" +id = "1b212581e15144fb9f931b221a8cc5cd" + +# Region-pinned egress relay (SQLite Durable Object, free plan). OpenAI calls that hit a +# geo-blocked egress colo are re-issued through this DO via locationHint:"wnam" (North America). +[[durable_objects.bindings]] +name = "US_EGRESS" +class_name = "UsEgress" + +[[migrations]] +tag = "v1" +new_sqlite_classes = ["UsEgress"] + +# Secrets (set with `nubx wrangler secret put `), never committed: +# OPENAI_API_KEY ANTHROPIC_API_KEY GEMINI_API_KEY ADMIN_SECRET +# Optional upstream overrides (plain vars, NOT secrets) default to the real hosts: +# OPENAI_UPSTREAM ANTHROPIC_UPSTREAM GEMINI_UPSTREAM