From a5d96bfb8eeb7f132579aad484dfefc9ab909978 Mon Sep 17 00:00:00 2001 From: Sudharsan Date: Mon, 22 Jun 2026 15:16:05 +0530 Subject: [PATCH 01/12] docs: doppelganger-token proxy design --- ...22-api-proxy-doppelganger-tokens-design.md | 188 ++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-22-api-proxy-doppelganger-tokens-design.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..26effed --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-api-proxy-doppelganger-tokens-design.md @@ -0,0 +1,188 @@ +# api-proxy — Doppelganger Tokens Design + +- **Date:** 2026-06-22 +- **Status:** Approved (design); implementation pending +- **Scope:** Replace the three transparent reverse-proxy workers with one token-gated worker plus an admin dashboard. Issue shareable, revocable "doppelganger" API-key tokens that map to real provider keys server-side. + +> Naming note: 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. Sharing access safely is impossible. + +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" into one mechanism; client changes only base URL + key. | +| Topology | One worker, one base URL, **no provider path prefix**. | A `/openai` `/anthropic` `/gemini` prefix breaks Gemini native file uploads (the SDK drops path prefixes when building resumable upload URLs — js-genai #709; Python rewrites host only). A prefix is unnecessary because the auth header already identifies the provider. | +| Provider routing | By which auth header the token arrives in (+ path for the Gemini OpenAI-compat case). | `Authorization: Bearer`→OpenAI, `x-api-key`→Anthropic, `x-goog-api-key`→Gemini. Resolves the `/v1/models` collision for free. | +| 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, not live tokens. Standard practice (Stripe/OpenAI/OpenRouter). | +| 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. Key pools deferred. | +| Stack | Single worker, **Hono + JSX** dashboard, **KV** for tokens, **nub** as package manager. | Mirrors the proven `cheating-mommy/cloud` pattern; nub already installed. | + +## 5. Architecture + +One worker, dispatched by path: + +``` +fetch(req): + /admin/* -> admin handler (cookie-gated dashboard + token CRUD) + else -> token-gated reverse proxy +``` + +- **State:** one KV namespace, `TOKENS`. Key = `SHA-256(token)` (hex). Value = token metadata JSON (§7). +- **Secrets:** `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`, `ADMIN_SECRET` — Cloudflare secrets, never in KV, never returned to callers. + +### Module layout +- `src/index.ts` — fetch entry + path dispatch. +- `src/proxy.ts` — token extraction, provider routing, KV validation, auth swap, forwarding. +- `src/tokens.ts` — KV helpers: hash, create, list, get-validated, update, delete; token generation. +- `src/admin/` — Hono sub-app: cookie auth (HMAC), pages (JSX), token CRUD API. +- `wrangler.toml` — one config; `name = "api-proxy"`, `[[kv_namespaces]]` binding, secrets via `wrangler secret put`. +- `schedule.sh` — kept (enable/disable a worker by toggling `workers_dev`). +- Deleted once 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, sha256(token)) + miss || status != "active" -> 401 "invalid or revoked token" +4. if 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. url.hostname = UPSTREAM[provider]; url.protocol = "https:" + 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 +9. ctx.waitUntil(touchLastUsed(KV, hash)) // fire-and-forget +``` + +### 6.1 Provider routing table + +| Token arrives in | + path signal | Provider key | Upstream host | Real key set as | +|---|---|---|---|---| +| `x-api-key` | — | `anthropic` | `api.anthropic.com` | `x-api-key` | +| `x-goog-api-key` or `?key=` | — | `gemini` | `generativelanguage.googleapis.com` | `x-goog-api-key` | +| `Authorization: Bearer` | path starts `/v1beta/openai/` | `gemini-openai` | `generativelanguage.googleapis.com` | `Authorization: Bearer` | +| `Authorization: Bearer` | else | `openai` | `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 (different auth header). + +### 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 (`apiKey` + `authToken`) leak and the duplicate-`x-goog-api-key` 401 seen with some integration layers. + +## 7. Token model & lifecycle + +KV: `SHA-256(token)` (hex) → + +```json +{ + "label": "alice-laptop", + "last4": "9f3c", + "providers": ["openai", "anthropic"], + "status": "active", + "createdAt": "2026-06-22T10:00:00.000Z", + "lastUsed": "2026-06-22T12:30:00.000Z" +} +``` + +Reserved for Later (written as `undefined`/absent in v1, no logic depends on them): `expiresAt`, `limits`, `spend`. + +- **Create:** admin supplies a label, provider scopes, and either types a token or clicks generate (`dgk_` + 32 random base64url chars from `crypto.getRandomValues`). Worker stores `sha256(token) -> metadata`, returns the **plaintext once**. Never retrievable again. +- **List:** `KV.list()` → each value rendered (no plaintext token, only `last4`). +- **Update:** edit label, providers, status (`active` ⇄ `disabled`) by hash. +- **Delete:** remove the hash key. +- **last-used:** fire-and-forget KV write on each successful proxied request. + +> Revocation latency: KV is eventually consistent (propagation up to ~60s). A disabled/deleted token may still work briefly. Acceptable for v1; instant revocation via Durable Object is a Later item. + +## 8. Admin dashboard + +Direct port of the `cheating-mommy/cloud` pattern (Hono + JSX, no React): + +- **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; API routes return JSON 401, page routes render the login form. +- **Routes:** `GET/POST/PUT/DELETE /admin/api/tokens` (CRUD), `GET /admin` (dashboard), `GET /admin/logout`. +- **UI:** add-token card (token field + generate button, label, provider checkboxes for OpenAI/Anthropic/Gemini, status); token table (label, last-4, provider pills, created, last-used, edit/delete). Plaintext token surfaced once in a copy field right after creation. + +## 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 (`new Response(upstream.body, upstream)`); never `await response.text()`. Preserve upstream `cache-control: no-transform`; do not enable any response-buffering worker feature; ensure the zone is not re-compressing `text/event-stream`. +2. **Verbatim path + query forwarding** — mutate the `URL` object's host/protocol only; keep path and query intact so Gemini's `?alt=sse` and all params survive. +3. **Strip-all-then-set-one auth** (§6.2). +4. **Gemini `?key=` hygiene** — keep `url.searchParams.delete("key")` for raw-REST/curl callers (SDKs use the header). +5. **Hostname** — do not issue worker hostnames ending in `.openai.azure.com`, `.services.ai.azure.com`, or `.cognitiveservices.azure.com` (openai-node auto-switches to Azure auth mode by hostname suffix). + +## 11. Deferred (Later) + +The KV value shape and module boundaries leave room for these; none are built now: + +- Rate limits, spend/token caps (parse provider `usage`; OpenAI needs `stream_options.include_usage`), expiry dates, per-token usage analytics. +- Token hashing is already in v1; **show-once** is in v1. +- Browser/CORS: answer `OPTIONS` preflight and synthesize `Access-Control-Allow-Origin/Headers` (incl. `authorization, x-api-key, x-goog-api-key, anthropic-version, anthropic-dangerous-direct-browser-access, x-stainless-*`). +- Gemini file uploads: forward the `x-goog-upload-url` response header so resumable URLs point back at the worker (host-root requirement already satisfied by the no-prefix design). +- Multiple real keys per provider (key pools / per-token real-key mapping). +- Instant revocation + atomic counters via Durable Object or Cloudflare Rate Limiting binding. + +## 12. Client setup (the payoff) + +The worker forwards paths verbatim, so it is agnostic to which SDK is used; the only per-SDK difference is the base-URL string the consumer sets. Both `base URL` and `key` can also be set via env vars where the SDK supports it. + +| SDK | base URL | key slot | auth header sent | +|---|---|---|---| +| OpenAI (Python / Node) | `https://worker/v1` | token | `Authorization: Bearer` | +| Anthropic (Python / Node) | `https://worker` (no `/v1`) | token | `x-api-key` | +| Gemini (Node `@google/genai`) | `httpOptions.baseUrl = https://worker` (or `GOOGLE_GEMINI_BASE_URL`) | token | `x-goog-api-key` | +| Gemini from Python | point the **OpenAI** SDK at `https://worker/v1beta/openai` | token | `Authorization: Bearer` | +| Vercel AI SDK | `createOpenAI({baseURL:'…/v1'})`, `createAnthropic({baseURL:'…/v1'})`, `createGoogleGenerativeAI({baseURL:'…/v1beta'})` | token | per provider | + +> Why Gemini-from-Python uses the OpenAI-compat path: the native `google-genai` Python SDK has **no base-URL env var** and requires an `http_options=HttpOptions(base_url=…)` constructor object (a third code change). Routing Gemini through the OpenAI SDK against `/v1beta/openai` keeps it to the same two-line change. + +## 13. Rollout & deprecation + +1. Build the new worker; deploy under `name = "api-proxy"` (distinct from `openai-proxy`/`claude-proxy`/`gemini-proxy`) so existing proxies keep running. +2. Create the KV namespace; set the four secrets; mint a test token per provider; verify each SDK end-to-end (incl. streaming). +3. Once reliable, delete the three old `src/*.ts` files and three tomls; the new worker is the proxy. + +## 14. Open items to verify during implementation + +- **Exact base-URL strings per SDK** in §12 — confirm against each SDK at test time (especially Vercel AI SDK Anthropic, whose provider default base URL includes `/v1`, unlike the native Anthropic SDK). The worker is robust either way since it forwards verbatim; this only affects the setup docs. +- **Gemini OpenAI-compat coverage** — confirm `/v1beta/openai/chat/completions` (and embeddings) behave through the swap before documenting it as the recommended Python-Gemini path. +- **KV propagation delay** — measure actual disable/delete latency to decide whether a short in-worker `caches` TTL + bust-on-revoke is worth adding before the Durable-Object Later item. From 8b0f732201d4faa26f03660b53a2740e147d21c3 Mon Sep 17 00:00:00 2001 From: Sudharsan Date: Mon, 22 Jun 2026 15:48:02 +0530 Subject: [PATCH 02/12] docs: fold implementation-stack research into design --- ...22-api-proxy-doppelganger-tokens-design.md | 203 ++++++++++-------- 1 file changed, 118 insertions(+), 85 deletions(-) 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 index 26effed..69a4b46 100644 --- 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 @@ -1,16 +1,16 @@ # api-proxy — Doppelganger Tokens Design - **Date:** 2026-06-22 -- **Status:** Approved (design); implementation pending -- **Scope:** Replace the three transparent reverse-proxy workers with one token-gated worker plus an admin dashboard. Issue shareable, revocable "doppelganger" API-key tokens that map to real provider keys server-side. +- **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 note: 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. +> 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. Sharing access safely is impossible. +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. @@ -29,34 +29,55 @@ Rate limits, spend/token caps, expiry dates, per-token usage analytics, browser/ | 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" into one mechanism; client changes only base URL + key. | -| Topology | One worker, one base URL, **no provider path prefix**. | A `/openai` `/anthropic` `/gemini` prefix breaks Gemini native file uploads (the SDK drops path prefixes when building resumable upload URLs — js-genai #709; Python rewrites host only). A prefix is unnecessary because the auth header already identifies the provider. | -| Provider routing | By which auth header the token arrives in (+ path for the Gemini OpenAI-compat case). | `Authorization: Bearer`→OpenAI, `x-api-key`→Anthropic, `x-goog-api-key`→Gemini. Resolves the `/v1/models` collision for free. | -| 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, not live tokens. Standard practice (Stripe/OpenAI/OpenRouter). | -| 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. Key pools deferred. | -| Stack | Single worker, **Hono + JSX** dashboard, **KV** for tokens, **nub** as package manager. | Mirrors the proven `cheating-mommy/cloud` pattern; nub already installed. | +| 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 +## 5. Architecture & module layout One worker, dispatched by path: ``` -fetch(req): - /admin/* -> admin handler (cookie-gated dashboard + token CRUD) - else -> token-gated reverse proxy +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 = token metadata JSON (§7). -- **Secrets:** `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`, `ADMIN_SECRET` — Cloudflare secrets, never in KV, never returned to callers. +- **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). -### Module layout -- `src/index.ts` — fetch entry + path dispatch. -- `src/proxy.ts` — token extraction, provider routing, KV validation, auth swap, forwarding. -- `src/tokens.ts` — KV helpers: hash, create, list, get-validated, update, delete; token generation. -- `src/admin/` — Hono sub-app: cookie auth (HMAC), pages (JSX), token CRUD API. -- `wrangler.toml` — one config; `name = "api-proxy"`, `[[kv_namespaces]]` binding, secrets via `wrangler secret put`. -- `schedule.sh` — kept (enable/disable a worker by toggling `workers_dev`). -- Deleted once reliable: `src/openai.ts`, `src/claude.ts`, `src/gemini.ts`, `wrangler.openai.toml`, `wrangler.claude.toml`, `wrangler.gemini.toml`. +``` +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) @@ -65,27 +86,27 @@ fetch(req): 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, sha256(token)) +3. rec = getValidated(KV, sha256hex(token)) miss || status != "active" -> 401 "invalid or revoked token" -4. if provider not in rec.providers -> 403 "token not allowed for provider" +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. url.hostname = UPSTREAM[provider]; url.protocol = "https:" +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 +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 key | Upstream host | Real key set as | +| Token arrives in | + path signal | Provider | Upstream (default, overridable) | Real key set as | |---|---|---|---|---| -| `x-api-key` | — | `anthropic` | `api.anthropic.com` | `x-api-key` | -| `x-goog-api-key` or `?key=` | — | `gemini` | `generativelanguage.googleapis.com` | `x-goog-api-key` | -| `Authorization: Bearer` | path starts `/v1beta/openai/` | `gemini-openai` | `generativelanguage.googleapis.com` | `Authorization: Bearer` | -| `Authorization: Bearer` | else | `openai` | `api.openai.com` | `Authorization: Bearer` | +| `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 (different auth header). +`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) @@ -103,40 +124,38 @@ switch (provider) { } ``` -Stripping-all-then-setting-one prevents the doppelganger token leaking upstream and closes the Anthropic dual-header (`apiKey` + `authToken`) leak and the duplicate-`x-goog-api-key` 401 seen with some integration layers. +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) → +KV: `SHA-256(token)` (hex) → -```json -{ - "label": "alice-laptop", - "last4": "9f3c", - "providers": ["openai", "anthropic"], - "status": "active", - "createdAt": "2026-06-22T10:00:00.000Z", - "lastUsed": "2026-06-22T12:30:00.000Z" -} +```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 +}; ``` -Reserved for Later (written as `undefined`/absent in v1, no logic depends on them): `expiresAt`, `limits`, `spend`. - -- **Create:** admin supplies a label, provider scopes, and either types a token or clicks generate (`dgk_` + 32 random base64url chars from `crypto.getRandomValues`). Worker stores `sha256(token) -> metadata`, returns the **plaintext once**. Never retrievable again. -- **List:** `KV.list()` → each value rendered (no plaintext token, only `last4`). -- **Update:** edit label, providers, status (`active` ⇄ `disabled`) by hash. -- **Delete:** remove the hash key. +- **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 (propagation up to ~60s). A disabled/deleted token may still work briefly. Acceptable for v1; instant revocation via Durable Object is a Later item. +> Revocation latency: KV is eventually consistent (~up to 60s). Acceptable for v1; instant revocation via Durable Object is a Later item. ## 8. Admin dashboard -Direct port of the `cheating-mommy/cloud` pattern (Hono + JSX, no React): +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; API routes return JSON 401, page routes render the login form. -- **Routes:** `GET/POST/PUT/DELETE /admin/api/tokens` (CRUD), `GET /admin` (dashboard), `GET /admin/logout`. -- **UI:** add-token card (token field + generate button, label, provider checkboxes for OpenAI/Anthropic/Gemini, status); token table (label, last-4, provider pills, created, last-used, edit/delete). Plaintext token surfaced once in a copy field right after creation. +- **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 @@ -144,45 +163,59 @@ Direct port of the `cheating-mommy/cloud` pattern (Hono + JSX, no React): ## 10. Gotchas handled in v1 -1. **SSE streaming** — pass `upstream.body` straight through (`new Response(upstream.body, upstream)`); never `await response.text()`. Preserve upstream `cache-control: no-transform`; do not enable any response-buffering worker feature; ensure the zone is not re-compressing `text/event-stream`. -2. **Verbatim path + query forwarding** — mutate the `URL` object's host/protocol only; keep path and query intact so Gemini's `?alt=sse` and all params survive. +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 (SDKs use the header). -5. **Hostname** — do not issue worker hostnames ending in `.openai.azure.com`, `.services.ai.azure.com`, or `.cognitiveservices.azure.com` (openai-node auto-switches to Azure auth mode by hostname suffix). +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) -The KV value shape and module boundaries leave room for these; none are built now: +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. -- Rate limits, spend/token caps (parse provider `usage`; OpenAI needs `stream_options.include_usage`), expiry dates, per-token usage analytics. -- Token hashing is already in v1; **show-once** is in v1. -- Browser/CORS: answer `OPTIONS` preflight and synthesize `Access-Control-Allow-Origin/Headers` (incl. `authorization, x-api-key, x-goog-api-key, anthropic-version, anthropic-dangerous-direct-browser-access, x-stainless-*`). -- Gemini file uploads: forward the `x-goog-upload-url` response header so resumable URLs point back at the worker (host-root requirement already satisfied by the no-prefix design). -- Multiple real keys per provider (key pools / per-token real-key mapping). -- Instant revocation + atomic counters via Durable Object or Cloudflare Rate Limiting binding. +## 12. Testing (two-tier harness) -## 12. Client setup (the payoff) +Do not test proxy logic and real-SDK HTTP behavior with one tool — conflating them is the main flakiness source. -The worker forwards paths verbatim, so it is agnostic to which SDK is used; the only per-SDK difference is the base-URL string the consumer sets. Both `base URL` and `key` can also be set via env vars where the SDK supports it. +**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). -| SDK | base URL | key slot | auth header sent | -|---|---|---|---| -| OpenAI (Python / Node) | `https://worker/v1` | token | `Authorization: Bearer` | -| Anthropic (Python / Node) | `https://worker` (no `/v1`) | token | `x-api-key` | -| Gemini (Node `@google/genai`) | `httpOptions.baseUrl = https://worker` (or `GOOGLE_GEMINI_BASE_URL`) | token | `x-goog-api-key` | -| Gemini from Python | point the **OpenAI** SDK at `https://worker/v1beta/openai` | token | `Authorization: Bearer` | -| Vercel AI SDK | `createOpenAI({baseURL:'…/v1'})`, `createAnthropic({baseURL:'…/v1'})`, `createGoogleGenerativeAI({baseURL:'…/v1beta'})` | token | per provider | +**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 +``` -> Why Gemini-from-Python uses the OpenAI-compat path: the native `google-genai` Python SDK has **no base-URL env var** and requires an `http_options=HttpOptions(base_url=…)` constructor object (a third code change). Routing Gemini through the OpenAI SDK against `/v1beta/openai` keeps it to the same two-line change. +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`. -## 13. Rollout & deprecation +## 14. Bloat-watch (single-worker discipline) -1. Build the new worker; deploy under `name = "api-proxy"` (distinct from `openai-proxy`/`claude-proxy`/`gemini-proxy`) so existing proxies keep running. -2. Create the KV namespace; set the four secrets; mint a test token per provider; verify each SDK end-to-end (incl. streaming). -3. Once reliable, delete the three old `src/*.ts` files and three tomls; the new worker is the proxy. +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. -## 14. Open items to verify during implementation +## 15. Open items to verify during implementation -- **Exact base-URL strings per SDK** in §12 — confirm against each SDK at test time (especially Vercel AI SDK Anthropic, whose provider default base URL includes `/v1`, unlike the native Anthropic SDK). The worker is robust either way since it forwards verbatim; this only affects the setup docs. -- **Gemini OpenAI-compat coverage** — confirm `/v1beta/openai/chat/completions` (and embeddings) behave through the swap before documenting it as the recommended Python-Gemini path. -- **KV propagation delay** — measure actual disable/delete latency to decide whether a short in-worker `caches` TTL + bust-on-revoke is worth adding before the Durable-Object Later item. +- 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. From b2c67b5564f924613474ffbc377a883a8ac09362 Mon Sep 17 00:00:00 2001 From: Sudharsan Date: Mon, 22 Jun 2026 16:06:45 +0530 Subject: [PATCH 03/12] feat: token-gated proxy core + tier-1 tests (workerd) --- bun.lock | 210 ---- lock.yaml | 2430 ++++++++++++++++++++++++++++++++++++ package.json | 23 +- src/index.ts | 13 + src/proxy.ts | 101 ++ src/tokens.ts | 89 ++ src/types.ts | 24 + src/upstreams.ts | 28 + test/env.d.ts | 9 + test/proxy-handler.test.ts | 178 +++ test/proxy.test.ts | 117 ++ test/tokens.test.ts | 89 ++ test/upstreams.test.ts | 41 + vitest.compat.config.ts | 14 + vitest.config.ts | 27 + wrangler.toml | 16 + 16 files changed, 3193 insertions(+), 216 deletions(-) delete mode 100644 bun.lock create mode 100644 lock.yaml create mode 100644 src/index.ts create mode 100644 src/proxy.ts create mode 100644 src/tokens.ts create mode 100644 src/types.ts create mode 100644 src/upstreams.ts create mode 100644 test/env.d.ts create mode 100644 test/proxy-handler.test.ts create mode 100644 test/proxy.test.ts create mode 100644 test/tokens.test.ts create mode 100644 test/upstreams.test.ts create mode 100644 vitest.compat.config.ts create mode 100644 vitest.config.ts create mode 100644 wrangler.toml 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/lock.yaml b/lock.yaml new file mode 100644 index 0000000..0188acb --- /dev/null +++ b/lock.yaml @@ -0,0 +1,2430 @@ +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) + '@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 + 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'} + + '@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'} + + 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': {} + + '@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: {} + + 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..11f184f 100644 --- a/package.json +++ b/package.json @@ -1,16 +1,27 @@ { "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" + }, "devDependencies": { - "@cloudflare/workers-types": "^4.20260317.1", - "@types/bun": "^1.3.10" + "@anthropic-ai/sdk": "^0.104.1", + "@cloudflare/vitest-pool-workers": "^0.16.18", + "@cloudflare/workers-types": "^4.20260615.1", + "@google/genai": "^2.8.0", + "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/src/index.ts b/src/index.ts new file mode 100644 index 0000000..0965fd2 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,13 @@ +import type { Env } from "./types"; +import { handleProxy } from "./proxy"; + +// Top-level dispatch: /admin/* -> admin sub-app (wired in a later step), everything else -> proxy. +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/")) { + return new Response("admin not implemented", { status: 501 }); + } + return handleProxy(req, env, ctx); + }, +}; diff --git a/src/proxy.ts b/src/proxy.ts new file mode 100644 index 0000000..9a27764 --- /dev/null +++ b/src/proxy.ts @@ -0,0 +1,101 @@ +// The proxy hot-path. ZERO framework deps — pure functions + a fetch handler. +// MUST NOT import Hono or any admin code. +import type { Provider, CoarseProvider, Env } from "./types"; +import { getValidatedByHash, touchLastUsed, sha256hex } from "./tokens"; +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" }, + }); +} + +/** 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 upstream = await fetch(new Request(url.toString(), { method: req.method, headers, body: req.body })); + + 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..00add5e --- /dev/null +++ b/src/tokens.ts @@ -0,0 +1,89 @@ +// KV-backed token store. Tokens are stored by SHA-256(token); the plaintext is shown +// once at creation and never persisted. +import type { TokenMetadata, CoarseProvider } 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 }; +} + +/** Resolve a token hash to its metadata, only if it exists and is active. */ +export async function getValidatedByHash(kv: KVNamespace, hash: string): Promise { + const raw = await kv.get(hash); + if (!raw) return null; + const meta = JSON.parse(raw) as TokenMetadata; + 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<(TokenMetadata & { hash: string })[]> { + const { keys } = await kv.list(); + const rows: (TokenMetadata & { hash: string })[] = []; + for (const k of keys) { + const raw = await kv.get(k.name); + if (raw) rows.push({ hash: k.name, ...(JSON.parse(raw) as TokenMetadata) }); + } + return rows; +} + +export async function updateToken( + kv: KVNamespace, + hash: string, + patch: Partial>, +): Promise { + const raw = await kv.get(hash); + if (!raw) return null; + const meta = { ...(JSON.parse(raw) as TokenMetadata), ...patch }; + await kv.put(hash, JSON.stringify(meta)); + return meta; +} + +export async function deleteToken(kv: KVNamespace, hash: string): Promise { + await kv.delete(hash); +} + +export async function touchLastUsed(kv: KVNamespace, hash: string): Promise { + const raw = await kv.get(hash); + if (!raw) return; + const meta = JSON.parse(raw) as TokenMetadata; + meta.lastUsed = new Date().toISOString(); + await kv.put(hash, JSON.stringify(meta)); +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..250bd14 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,24 @@ +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?: string; // ISO + // reserved for Later (absent in v1): expiresAt, limits, spend +} + +export interface Env { + TOKENS: KVNamespace; + 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..0e79cae --- /dev/null +++ b/src/upstreams.ts @@ -0,0 +1,28 @@ +// 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 { Provider, Env } 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/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..47d4481 --- /dev/null +++ b/test/proxy-handler.test.ts @@ -0,0 +1,178 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { env, createExecutionContext, waitOnExecutionContext } from "cloudflare:test"; +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("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..c39404c --- /dev/null +++ b/test/proxy.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect } from "vitest"; +import { extractToken, routeProvider, coarse, swapAuth, realKeyFor } 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/tokens.test.ts b/test/tokens.test.ts new file mode 100644 index 0000000..a08a65f --- /dev/null +++ b/test/tokens.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect } from "vitest"; +import { env } from "cloudflare:test"; +import { + sha256hex, + generateToken, + createToken, + getValidated, + listTokens, + updateToken, + deleteToken, + touchLastUsed, +} 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"); + }); +}); diff --git a/test/upstreams.test.ts b/test/upstreams.test.ts new file mode 100644 index 0000000..950aec9 --- /dev/null +++ b/test/upstreams.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from "vitest"; +import { upstreamBase, rewriteToUpstream } from "../src/upstreams"; +import type { Env } from "../src/types"; + +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..0115b2b --- /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..4cc56c5 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,27 @@ +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"], + 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..fae1607 --- /dev/null +++ b/wrangler.toml @@ -0,0 +1,16 @@ +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 TOKENS +# then paste the returned id below before deploy. Local dev/tests use an in-memory namespace. +[[kv_namespaces]] +binding = "TOKENS" +id = "REPLACE_WITH_KV_NAMESPACE_ID" + +# 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 From 3ae8a3532c73f6b8d4ab405e435af4aa6755e615 Mon Sep 17 00:00:00 2001 From: Sudharsan Date: Mon, 22 Jun 2026 16:14:26 +0530 Subject: [PATCH 04/12] feat: admin dashboard (Hono + HTMX) with HMAC auth + token CRUD --- src/admin/index.ts | 93 ++++++++++++++++++++++++ src/admin/views.ts | 176 +++++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 10 ++- test/admin.test.ts | 109 ++++++++++++++++++++++++++++ 4 files changed, 386 insertions(+), 2 deletions(-) create mode 100644 src/admin/index.ts create mode 100644 src/admin/views.ts create mode 100644 test/admin.test.ts diff --git a/src/admin/index.ts b/src/admin/index.ts new file mode 100644 index 0000000..fda861c --- /dev/null +++ b/src/admin/index.ts @@ -0,0 +1,93 @@ +import { Hono } from "hono"; +import type { Env, CoarseProvider } from "../types"; +import { createToken, listTokens, updateToken, deleteToken } from "../tokens"; +import { loginPage, dashboardPage, tokenTable, tokenRow, createdNotice } from "./views"; + +const COOKIE = "cm_admin"; +const MAX_AGE = 86400; // 24h + +async function hmac(secret: string, data: string): Promise { + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data)); + return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join(""); +} + +async function isAuthed(req: Request, secret: string): Promise { + const m = (req.headers.get("cookie") || "").match(new RegExp(`${COOKIE}=([^;]+)`)); + if (!m) return false; + const [ts, sig] = m[1].split("."); + if (!ts || !sig || Date.now() / 1000 - Number(ts) > MAX_AGE) return false; + return sig === (await hmac(secret, ts)); +} + +async function makeCookie(secret: string): Promise { + const ts = String(Math.floor(Date.now() / 1000)); + return `${COOKIE}=${ts}.${await hmac(secret, ts)}; Path=/admin; HttpOnly; SameSite=Strict; Max-Age=${MAX_AGE}`; +} + +const VALID_PROVIDERS: CoarseProvider[] = ["openai", "anthropic", "gemini"]; +const parseProviders = (fd: FormData): CoarseProvider[] => + fd.getAll("providers").map(String).filter((p): p is CoarseProvider => VALID_PROVIDERS.includes(p as CoarseProvider)); + +const app = new Hono<{ Bindings: Env }>().basePath("/admin"); + +// Login is the only unguarded route (registered before the auth guard). +app.post("/login", async (c) => { + const body = await c.req.parseBody(); + if (!c.env.ADMIN_SECRET || body.password !== c.env.ADMIN_SECRET) return c.text("invalid password", 401); + return c.body("ok", 200, { + "Set-Cookie": await makeCookie(c.env.ADMIN_SECRET), + "HX-Redirect": "/admin", + }); +}); + +// Auth guard for everything below. +app.use("/*", async (c, next) => { + if (await isAuthed(c.req.raw, c.env.ADMIN_SECRET)) return next(); + if (c.req.path.startsWith("/admin/api/")) return c.text("unauthorized", 401); + return c.html(loginPage()); +}); + +app.get("/", (c) => c.html(dashboardPage())); + +app.get("/logout", (c) => + c.body(null, 302, { Location: "/admin", "Set-Cookie": `${COOKIE}=; Path=/admin; Max-Age=0` }), +); + +app.get("/api/tokens", async (c) => c.html(tokenTable(await listTokens(c.env.TOKENS)))); + +app.post("/api/tokens", async (c) => { + const fd = await c.req.formData(); + const providers = parseProviders(fd); + const custom = fd.get("token"); + const { token, hash, meta } = await createToken(c.env.TOKENS, { + label: String(fd.get("label") || ""), + providers: providers.length ? providers : ["openai"], + token: custom ? String(custom) : undefined, + }); + return c.html(createdNotice(token, { hash, ...meta }), 200, { "HX-Trigger": "tokens-changed" }); +}); + +app.put("/api/tokens/:hash", async (c) => { + const fd = await c.req.formData(); + const patch: Partial<{ label: string; status: "active" | "disabled"; providers: CoarseProvider[] }> = {}; + if (fd.has("label")) patch.label = String(fd.get("label")); + if (fd.has("status")) patch.status = String(fd.get("status")) === "disabled" ? "disabled" : "active"; + if (fd.has("providers")) patch.providers = parseProviders(fd); + const meta = await updateToken(c.env.TOKENS, c.req.param("hash"), patch); + if (!meta) return c.text("not found", 404); + return c.html(tokenRow({ hash: c.req.param("hash"), ...meta }), 200, { "HX-Trigger": "tokens-changed" }); +}); + +app.delete("/api/tokens/:hash", async (c) => { + await deleteToken(c.env.TOKENS, c.req.param("hash")); + return c.body("", 200, { "HX-Trigger": "tokens-changed" }); +}); + +export default app; diff --git a/src/admin/views.ts b/src/admin/views.ts new file mode 100644 index 0000000..b5ea59d --- /dev/null +++ b/src/admin/views.ts @@ -0,0 +1,176 @@ +import { html, raw } from "hono/html"; +import type { TokenMetadata, CoarseProvider } from "../types"; + +type Row = TokenMetadata & { hash: string }; + +const HTMX = "https://unpkg.com/htmx.org@2.0.9"; + +const STYLE = ` +:root{color-scheme:dark} +*{box-sizing:border-box} +body{margin:0;background:#0b0b10;color:#e7e7ea;font:14px/1.5 ui-sans-serif,system-ui,sans-serif} +.wrap{max-width:920px;margin:0 auto;padding:28px 20px} +h1{font-size:20px;font-weight:600;margin:0 0 20px} +.card{background:#15151c;border:1px solid #24242e;border-radius:12px;padding:18px;margin:0 0 18px} +.card h2{font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:#9a9aa6;margin:0 0 14px} +label{display:block;font-size:12px;color:#9a9aa6;margin:0 0 4px} +input[type=text]{width:100%;background:#0e0e14;border:1px solid #2a2a36;border-radius:8px;color:#e7e7ea;padding:9px 11px;font:inherit} +.row{display:flex;gap:12px;flex-wrap:wrap;align-items:flex-end} +.row>div{flex:1;min-width:160px} +.checks{display:flex;gap:14px;margin:12px 0} +.checks label{display:flex;align-items:center;gap:6px;color:#cfcfd6;margin:0} +button{background:#5b5bd6;color:#fff;border:0;border-radius:8px;padding:9px 16px;font:inherit;font-weight:600;cursor:pointer} +button.ghost{background:transparent;border:1px solid #2a2a36;color:#cfcfd6;padding:5px 10px;font-weight:500} +table{width:100%;border-collapse:collapse} +th{text-align:left;font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:#73737f;padding:0 8px 10px;font-weight:600} +td{padding:10px 8px;border-top:1px solid #20202a;vertical-align:middle} +.mono{font-family:ui-monospace,monospace} +.pill{display:inline-block;font-size:11px;padding:2px 8px;border-radius:999px;margin-right:4px} +.openai{background:#0c3b2e;color:#74e0bb}.anthropic{background:#3a2740;color:#d6a6ec}.gemini{background:#10325c;color:#86b7f5} +.muted{color:#73737f} +.disabled{opacity:.5} +.notice{background:#0c2e1f;border:1px solid #1c5c3e;border-radius:8px;padding:12px;margin:0 0 14px} +.notice code{display:block;background:#04140c;padding:8px 10px;border-radius:6px;margin-top:6px;word-break:break-all} +.danger{color:#f08a8a;border-color:#5c2a2a} +`; + +const providerPills = (providers: CoarseProvider[]) => + raw(providers.map((p) => `${p}`).join("")); + +const timeAgo = (iso?: string) => { + if (!iso) return "never"; + return iso.slice(0, 10); +}; + +export const tokenRow = (r: Row) => html` + + ${r.label || "(no label)"} + …${r.last4} + ${providerPills(r.providers)} + ${r.status} + ${timeAgo(r.lastUsed)} + + + + + +`; + +export const tokenTable = (rows: Row[]) => html` + + + + + + + + + + + + + ${rows.length ? rows.map(tokenRow) : html``} + +
LabelTokenProvidersStatusLast used
No tokens yet.
+`; + +export const createdNotice = (token: string, row: Row) => html` +
+ Token created. Copy it now — it is shown only once: + ${token} +
+`; + +export const loginPage = () => html` + + + + + api-proxy admin + + + + +
+

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/index.ts b/src/index.ts index 0965fd2..73c697d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,12 +1,18 @@ import type { Env } from "./types"; import { handleProxy } from "./proxy"; +import adminApp from "./admin"; -// Top-level dispatch: /admin/* -> admin sub-app (wired in a later step), everything else -> proxy. +// 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/")) { - return new Response("admin not implemented", { status: 501 }); + try { + return await adminApp.fetch(req, env, ctx); + } catch { + return new Response("admin error", { status: 500 }); + } } return handleProxy(req, env, ctx); }, diff --git a/test/admin.test.ts b/test/admin.test.ts new file mode 100644 index 0000000..853904a --- /dev/null +++ b/test/admin.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect } from "vitest"; +import { env, createExecutionContext, waitOnExecutionContext } from "cloudflare:test"; +import worker from "../src/index"; +import { sha256hex, getValidated } 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("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"]); + }); +}); From 3f0394182265d295551cd54dead0818d625bab6e Mon Sep 17 00:00:00 2001 From: Sudharsan Date: Mon, 22 Jun 2026 16:19:11 +0530 Subject: [PATCH 05/12] test: tier-2 real-SDK compatibility (openai, anthropic, @google/genai + gemini-openai) --- test/sdk-compat/anthropic.test.ts | 57 +++++++++++ test/sdk-compat/gemini.test.ts | 67 +++++++++++++ test/sdk-compat/openai.test.ts | 52 ++++++++++ test/sdk-compat/setup.ts | 158 ++++++++++++++++++++++++++++++ 4 files changed, 334 insertions(+) create mode 100644 test/sdk-compat/anthropic.test.ts create mode 100644 test/sdk-compat/gemini.test.ts create mode 100644 test/sdk-compat/openai.test.ts create mode 100644 test/sdk-compat/setup.ts diff --git a/test/sdk-compat/anthropic.test.ts b/test/sdk-compat/anthropic.test.ts new file mode 100644 index 0000000..e360cbc --- /dev/null +++ b/test/sdk-compat/anthropic.test.ts @@ -0,0 +1,57 @@ +import { beforeAll, afterAll, beforeEach, describe, it, expect } from "vitest"; +import Anthropic from "@anthropic-ai/sdk"; +import { startMockUpstream, startWorker, seedToken, FAKE, type MockUpstream } from "./setup"; +import type { Unstable_DevWorker } from "wrangler"; + +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..4375c74 --- /dev/null +++ b/test/sdk-compat/gemini.test.ts @@ -0,0 +1,67 @@ +import { beforeAll, afterAll, beforeEach, describe, it, expect } from "vitest"; +import { GoogleGenAI } from "@google/genai"; +import OpenAI from "openai"; +import { startMockUpstream, startWorker, seedToken, FAKE, type MockUpstream } from "./setup"; +import type { Unstable_DevWorker } from "wrangler"; + +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..5d8e774 --- /dev/null +++ b/test/sdk-compat/openai.test.ts @@ -0,0 +1,52 @@ +import { beforeAll, afterAll, beforeEach, describe, it, expect } from "vitest"; +import OpenAI from "openai"; +import { startMockUpstream, startWorker, seedToken, FAKE, type MockUpstream } from "./setup"; +import type { Unstable_DevWorker } from "wrangler"; + +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..ff67c1f --- /dev/null +++ b/test/sdk-compat/setup.ts @@ -0,0 +1,158 @@ +import http from "node:http"; +import type { AddressInfo } from "node:net"; +import { unstable_dev, type Unstable_DevWorker } 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}`); +} From e25237231bd97abf43529cf5744f561accbba99e Mon Sep 17 00:00:00 2001 From: Sudharsan Date: Mon, 22 Jun 2026 16:27:00 +0530 Subject: [PATCH 06/12] docs: rewrite README for token-gated proxy; retarget schedule.sh to single worker; ignore local state --- .gitignore | 6 +++ README.md | 117 ++++++++++++++++++++++++++++------------------------ schedule.sh | 19 +++++---- 3 files changed, 80 insertions(+), 62 deletions(-) diff --git a/.gitignore b/.gitignore index 9adce91..ddde8de 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 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/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" <