diff --git a/cloudflare-workers/oc-gateway/.gitignore b/cloudflare-workers/oc-gateway/.gitignore new file mode 100644 index 00000000..42eb0340 --- /dev/null +++ b/cloudflare-workers/oc-gateway/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.dev.vars +.wrangler/ +dist/ diff --git a/cloudflare-workers/oc-gateway/README.md b/cloudflare-workers/oc-gateway/README.md new file mode 100644 index 00000000..9f7c46a2 --- /dev/null +++ b/cloudflare-workers/oc-gateway/README.md @@ -0,0 +1,129 @@ +# oc-gateway — thin OC Worker over OpenRouter (W3, productionized) + +**Buildout W3** for the Flue-native agent type (`oc-bg-agents .agents/work/flue-native-buildout.md`, design `013 §4`, contract **#1**). Productionizes the `spike/oc-gateway` (#486) reference to the **resolved token seam** (2026-07-05, option b + the co-location refinement). + +It **extends** the shipped managed-model path (does not replace it): org-level spend keeps flowing through the org's single OpenRouter inference key → the existing `model_meter` cron → Autumn (`opencomputer/cloudflare-workers/api-edge/src/{model_billing,model_meter,openrouter}.ts`, `token-billing.md`). The gateway only adds the injection point a CF Worker needs (it can't use the box secrets-proxy) plus **org+agt budget enforcement + best-effort per-session sub-metering**. It pushes **nothing** to Autumn. + +--- + +## Contract #1 — the Gateway HTTP contract + +### 1. Path shape + +An unmodified Flue app registers a managed provider at the gateway **inside `defineAgent`**: + +```ts +registerProvider('anthropic', { + baseUrl: `${env.OC_GATEWAY}/anthropic`, + apiKey: env.OC_SESSION_TOKEN, // the per-DEPLOY token (bound by W7 as an env var) + headers: { 'X-OC-Session': id }, // the DO's own init id = ses_… — best-effort attribution +}); +``` + +The provider client appends its native tail; the gateway strips the provider prefix and forwards to the OpenRouter base the box path already uses (`credential.ts` `MANAGED_ANTHROPIC_BASE = https://openrouter.ai/api`): + +| Gateway request | Forwarded to OpenRouter | +|---|---| +| `POST {gw}/anthropic/v1/messages` | `POST https://openrouter.ai/api/v1/messages` (Claude-Code path) | +| `POST {gw}/openai/chat/completions` | `POST https://openrouter.ai/api/v1/chat/completions` | +| `GET {gw}/healthz` | — (liveness) | + +Rule: `/{provider}/` → ` + `, query string preserved. `cloudflare/` is **out of scope** — `env.AI.run()` bypasses `fetch`, so the gateway can't meter it (design §4). + +### 2. The deploy token — per-DEPLOY, EdDSA, lease-fenced + +**Resolved token seam.** The token is **per-DEPLOY**, not per-session. Flue's `registerProvider` `apiKey` is a static string only, and its provider registry is isolate-global while CF co-locates many session-DOs of one agent's script in one isolate — so per-session data injected via `registerProvider` (the token OR the header) **races** across co-located sessions. Therefore the token carries only `(org, agt)` and the **hard cost-safety boundary is at the org+agt grain**. + +**Claims:** `{ org, agt, iat, exp, ep? }` — **no** `sub:session`, **no** `bud`. +- `org` — selects the org's OpenRouter inference key (never leaves the gateway). +- `agt` — the deploy this token authorizes; the enforcement + lease-fence key with `org`. +- `ep` — optional monotonic deploy epoch; a token below the current lease floor is fenced. + +**Prod hardening over the spike:** +- **EdDSA (Ed25519):** the minter (W7 deploy pipeline) holds the private key; the gateway holds only `GATEWAY_TOKEN_PUBLIC_KEY` — a compromised gateway can't forge tokens. Alg pinned (rejects `none`/HS256 swaps). +- **Lease-epoch fence** (`DeployLease` DO, per `${org}:${agt}`): the floor rises to a token's `ep` on first use, so a **rotated** deploy's higher-epoch token instantly supersedes older tokens (401 `token_superseded`). A **revoke without redeploy** is `POST /admin/lease/bump {org, agt, min_epoch}`. + +**Transport:** `Authorization: Bearer ` **or** `x-api-key: `. Verify = alg-pin + signature + `exp`/`iat` + `org`/`agt` present. Failure → `401`. + +### 3. Enforcement grain (co-location refinement) + +- **HARD (the 402): org+agt.** `SpendCounter` DO keyed `agt:${org}:${agt}` — race-free (the value comes from the token, identical for every co-located session of the agent). `/check` gates **before** the call; over → `402 budget_exceeded`. Budget is looked up **server-side** (provisioned via `/admin/agent/budget`, else `AGENT_BUDGET_USD_DEFAULT`) — **never** carried in the token. +- **BEST-EFFORT per session: `X-OC-Session`.** `SpendCounter` DO keyed `sess:` — the gateway only **records** spend here for per-session visibility (dashboard W11). It is **never gated**, so a co-location race can't wrongly block a legitimate session. Exact per-session enforcement is deferred to an upstream Flue per-request resolver (tracked ask — see below). + +### 4. Request/response passthrough + +- **Request body:** buffered (model requests are small), `usage:{include:true}` injected so OpenRouter echoes cost, re-serialized. `cache_control` stripped for caching-unsafe models (§6). All other fields preserved. +- **Auth swap:** the tenant's `Authorization`/`x-api-key` **and** the `X-OC-Session` header are stripped; `Authorization: Bearer ` set. `http-referer`/`x-title` added for OR attribution. Everything else passes through. **No raw provider key ever reaches the tenant** — it holds only the deploy token; the OR key lives in the gateway. +- **Response:** OpenRouter's status, headers, body returned **untouched** — JSON or `text/event-stream`. + +### 5. Metering + reconciliation — one cost-source-of-truth + +- **On-path sub-meter:** `/check` gates (org+agt) before; `/add` commits cost after (off the response path via `waitUntil`), idempotent on the OpenRouter generation id. Recorded at the org+agt grain (authoritative for enforcement) **and** best-effort per session. +- **Cost source:** the `usage.cost` (USD) OpenRouter echoes per response (`cost.ts`; SSE terminal usage). Exact-cost fallback `GET /api/v1/generation?id=` (documented, unwired). +- **Reconciliation:** the gateway forwards through the **org's existing OR inference key**, so OR's per-key cumulative usage still captures Flue spend → `model_meter` cron → Autumn, **exactly as the brain-box path does today**. The gateway builds **no** billing path and pushes **nothing** to Autumn. Its counters are for **enforcement + display only**; they and OR's per-key usage are independent by design. +- **Budget refusal:** `402 {error:{type:"budget_exceeded", code:"insufficient_quota"}, oc:{org,agent,spent_usd,budget_usd}}` — a provider-style error so the Flue turn terminates and the tailer maps it to outcome `budget_exceeded` (§8). *The exact shape Flue surfaces cleanly is a live-verify item.* + +### 6. Org OpenRouter key resolution (from Infisical, via a sessions-api seam) + +The managed OR **inference** key's plaintext lives in **Infisical**, referenced by the org's managed credential and resolved only by `sessions-api` `resolveManagedSecret` (`credential.ts`). A CF Worker can't reach Infisical or the box secrets-proxy (design §4), so the gateway resolves it through a **dedicated internal sessions-api route** — mirroring the edge's dedicated-secret plaintext-key hand-off (`model_billing.ts §6.7.5`: a route carrying a live key gets its **own** secret, not the generic internal-auth one): + +``` +POST {GATEWAY_ORKEY_URL} Authorization: Bearer {GATEWAY_ORKEY_SECRET} body {"org": orgId} + → 200 {"key": "sk-or-..."} (resolveManagedSecret for the org's active managed credential) +``` + +The plaintext is cached per-org in-isolate with a 60 s TTL. `TEST_OR_KEY` short-circuits resolution for the acceptance run. **This route is the one control-plane seam W3 needs sessions-api to add** (see "seam questions"). + +### 7. Prompt-caching safety + +Some models route (via OpenRouter) to a backend that rejects Anthropic `cache_control` breakpoints (`claude-3-haiku` → OR→Bedrock 400s; `claude-haiku-4.5` works). The gateway **strips `cache_control`** from the body for an env-extensible denylist (`CACHE_CONTROL_UNSAFE_MODELS`) so the call still completes; caching-safe models are untouched. + +### 8. Control-plane admin routes (guarded by `GATEWAY_ADMIN_SECRET`) + +- `POST /admin/agent/budget {org, agt, budget_usd|null}` — provision the org+agt hard cap (W1/W7 seam). +- `POST /admin/lease/bump {org, agt, min_epoch}` — revoke deploy tokens below `min_epoch` (no redeploy). + +--- + +## What's here + +| File | Role | +|---|---| +| `src/index.ts` | verify deploy token → lease fence → org+agt hard gate → org-key inject → forward → tee-meter → passthrough | +| `src/token.ts` | EdDSA per-deploy token mint/verify (Web Crypto, no deps) | +| `src/budget.ts` | `SpendCounter` DO — keyed spend counter + hard gate (µ$ integers); org+agt (hard) + per-session (tracked) | +| `src/deploylease.ts` | `DeployLease` DO — per-(org,agt) lease-epoch floor (rotation/revocation fence) | +| `src/orgkey.ts` | org OR-key resolution via the dedicated sessions-api seam (`TEST_OR_KEY` override for tests) | +| `src/cost.ts` | per-response cost extraction (JSON + SSE) | +| `src/models.ts` | `cache_control` safety (strip for unsafe models) | +| `scripts/mint.ts` | mint a per-deploy token for live verification | +| `test/` | `logic` (20) + `integration` (11) — **31 green** | + +## Verification status + +- **In-process integration (green, CI-able):** `npx vitest run` drives the real worker handler + real `SpendCounter`/`DeployLease` DOs with `fetch` stubbed to a mock OpenRouter. Proves: 401 (no/expired/superseded token), forward with **org-key injection** (deploy token never reaches OR; session header never egresses) + `usage.include`, body passthrough, **org+agt hard enforcement** with bounded overshoot, **co-location** (two sessions share the org+agt cap), **per-session tracked-but-never-gated**, `cache_control` strip, admin provision. +- **Live turn:** one real `anthropic/*` turn through a local `wrangler dev` gateway → OpenRouter, against a **$1-capped throwaway** OR inference key minted from `OPENROUTER_PROVISIONING_KEY` and torn down after. + +### Live verification + +```bash +nvm use 22.19 +# 1. mint a $1-capped throwaway OR inference key + an Ed25519 keypair + a deploy token; write .dev.vars +# (helper reads OPENROUTER_PROVISIONING_KEY from a path arg — never sourced, never printed) +# 2. run the gateway locally (real DOs, real egress to openrouter.ai) +npx wrangler dev --port 8799 +# 3. one real anthropic turn THROUGH the gateway (unmodified-Flue shape) +curl -sN -X POST http://localhost:8799/anthropic/v1/messages \ + -H "authorization: Bearer $TOKEN" -H "x-oc-session: ses_live" -H 'content-type: application/json' \ + -d '{"model":"anthropic/claude-haiku-4.5","max_tokens":64,"messages":[{"role":"user","content":"say hi"}]}' +# → a real completion; OR bills the throwaway key; a low AGENT_BUDGET_USD_DEFAULT makes the 2nd call 402. +# 4. tear down: DELETE the OR key (by hash) and stop wrangler. +``` + +**Acceptance (buildout W3):** a real turn completes gateway → OpenRouter; the deploy token verifies (org+agt) and yields **no raw provider key** to the tenant; the org+agt budget refuses on-path (402); per-session spend is tracked by `X-OC-Session`. Org spend stays on the existing OpenRouter→Autumn cron. + +## Seam questions for the control plane (W1/W7) + +1. **Org OR-key route (required to leave `TEST_OR_KEY`):** sessions-api must expose `POST {GATEWAY_ORKEY_URL}` (dedicated bearer) returning `{key}` = `resolveManagedSecret` for the org's active managed credential. +2. **Per-agent budget provisioning (optional):** if a per-(org,agt) cap other than `AGENT_BUDGET_USD_DEFAULT` is wanted, W1/W7 calls `POST /admin/agent/budget`. +3. **Lease epoch (`ep`) minting:** W7 should mint a monotonic per-(org,agt) `ep` into the deploy token so rotation auto-fences; a leaked token is revoked via `POST /admin/lease/bump`. diff --git a/cloudflare-workers/oc-gateway/package-lock.json b/cloudflare-workers/oc-gateway/package-lock.json new file mode 100644 index 00000000..31387889 --- /dev/null +++ b/cloudflare-workers/oc-gateway/package-lock.json @@ -0,0 +1,2913 @@ +{ + "name": "oc-gateway", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "oc-gateway", + "version": "0.1.0", + "devDependencies": { + "@cloudflare/workers-types": "^4.20240924.0", + "typescript": "^5.5.0", + "vitest": "^2.0.0", + "wrangler": "^4.92.0" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", + "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": ">1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260701.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260701.1.tgz", + "integrity": "sha512-Zd9Y1bah6DwwBN2RW8vJohffQrIUazb8UXnqSNecOxM+jJLhUuvv5IOG8dbHcV83TyZAubea6gsQXo2yH1lDdw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260701.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260701.1.tgz", + "integrity": "sha512-yBLsjS1qCWqFyCY37qRUrYfzHHvMGvjh8zRKJ6MvUivYDhkZTzqduppK38FoqYvayLJ5KbcxH7zo5rkxGqbsaA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260701.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260701.1.tgz", + "integrity": "sha512-vMfqSIMfoo4xmZXEuUVqLpSFS921YKjiR9q7kDXPi6Vld1PK74UHg9LZuBavT2KSyemHUCTpj9y/4JSYOEyQbQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260701.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260701.1.tgz", + "integrity": "sha512-HRfwbKU2pK44V2NhoM0+iH0JJSj7nQ9Wv13ifIiGYCmTtDL8/zKtEhX7kQ3D4Vy/Cpjhttl0FkfqXj1aqLDPPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260701.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260701.1.tgz", + "integrity": "sha512-ngxCiIN9s/fM2o1IBMD0o1/mcXrv2NJVdyznh51UH8sQuvrTrXvV2nM0Uj/qU2wMwF6prgNBcdcd7AZeZGiBQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workers-types": { + "version": "4.20260702.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260702.1.tgz", + "integrity": "sha512-mOhf5TUEB1m2vPrxtqoIGfz0fUC9xyxRDx5gWHy5s+OCo6dcV+g7wI1R7gYCMFohhqF/2y2xeKVwMwCJjfn/WA==", + "dev": true, + "license": "MIT OR Apache-2.0" + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.17", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.17.tgz", + "integrity": "sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/miniflare": { + "version": "4.20260701.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260701.0.tgz", + "integrity": "sha512-L6eAAi6IKtyb/7J6L+YsH2vb1yBrJWKRXI293JYDiMl70+6nncdAgigex58w6WBd+CwvdMsqOyNyGs95Op5gWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "0.34.5", + "undici": "7.28.0", + "workerd": "1.20260701.1", + "ws": "8.21.0", + "youch": "4.1.0-beta.10" + }, + "bin": { + "miniflare": "bootstrap.js" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "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" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3" + } + }, + "node_modules/unenv/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/workerd": { + "version": "1.20260701.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260701.1.tgz", + "integrity": "sha512-uF813NG09JwNRRUfJ0zBomyTslSPM810dMj9LVvkQ7RAkLrQLzAlPU8Xh/3dIqZDo2bfd7tChbf2PtqLRARRJQ==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260701.1", + "@cloudflare/workerd-darwin-arm64": "1.20260701.1", + "@cloudflare/workerd-linux-64": "1.20260701.1", + "@cloudflare/workerd-linux-arm64": "1.20260701.1", + "@cloudflare/workerd-windows-64": "1.20260701.1" + } + }, + "node_modules/wrangler": { + "version": "4.107.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.107.0.tgz", + "integrity": "sha512-fw69ThymNitZ0oIEBU2yNeq3kK59UKz/jyA3udwRrQIAIsxX57q5qLOpPTN7qc5t8n9pnUeofe0uxtMuhQZW8w==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.1", + "blake3-wasm": "2.1.5", + "esbuild": "0.28.1", + "miniflare": "4.20260701.0", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260701.1" + }, + "bin": { + "cf-wrangler": "bin/cf-wrangler.js", + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^4.20260701.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/wrangler/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "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" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "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 + } + } + }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "license": "MIT", + "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" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + } + } +} diff --git a/cloudflare-workers/oc-gateway/package.json b/cloudflare-workers/oc-gateway/package.json new file mode 100644 index 00000000..52826745 --- /dev/null +++ b/cloudflare-workers/oc-gateway/package.json @@ -0,0 +1,19 @@ +{ + "name": "oc-gateway", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "mint": "node --experimental-strip-types scripts/mint.ts" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20240924.0", + "typescript": "^5.5.0", + "vitest": "^2.0.0", + "wrangler": "^4.92.0" + } +} diff --git a/cloudflare-workers/oc-gateway/scripts/mint.ts b/cloudflare-workers/oc-gateway/scripts/mint.ts new file mode 100644 index 00000000..953c4c93 --- /dev/null +++ b/cloudflare-workers/oc-gateway/scripts/mint.ts @@ -0,0 +1,44 @@ +// Mint a per-DEPLOY gateway token (EdDSA). The control plane / deploy pipeline (W7) mints these in +// prod, binding the token as the tenant script's OC_SESSION_TOKEN env var; this is the dev/e2e helper. +// On first use it also generates an Ed25519 keypair. +// +// Generate + mint (PUBLIC + PRIVATE key printed on stderr; the token on stdout): +// node --experimental-strip-types scripts/mint.ts --org org_1 --agent agt_1 --ep 1 +// → set the gateway's GATEWAY_TOKEN_PUBLIC_KEY secret from the printed value. +// Reuse a private key so the gateway's public key stays fixed across mints: +// GATEWAY_TOKEN_PRIVATE_KEY= node ... scripts/mint.ts --org org_1 --agent agt_1 --ep 2 + +import { mintDeployToken, type DeployClaims } from "../src/token.ts"; + +const b64url = (buf: ArrayBuffer) => Buffer.from(buf).toString("base64url"); +const fromB64url = (s: string) => Buffer.from(s, "base64url"); + +function arg(name: string, def?: string): string | undefined { + const i = process.argv.indexOf(`--${name}`); + return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : def; +} + +const ED = { name: "Ed25519" } as const; + +let privateKey: CryptoKey; +const existing = process.env.GATEWAY_TOKEN_PRIVATE_KEY; +if (existing) { + privateKey = await crypto.subtle.importKey("pkcs8", fromB64url(existing), ED, true, ["sign"]); +} else { + const kp = (await crypto.subtle.generateKey(ED, true, ["sign", "verify"])) as CryptoKeyPair; + privateKey = kp.privateKey; + console.error(`GATEWAY_TOKEN_PUBLIC_KEY=${b64url(await crypto.subtle.exportKey("raw", kp.publicKey))}`); + console.error(`GATEWAY_TOKEN_PRIVATE_KEY=${b64url(await crypto.subtle.exportKey("pkcs8", kp.privateKey))}`); +} + +const now = Math.floor(Date.now() / 1000); +const ttl = Number(arg("ttl", "3600")); +const ep = arg("ep"); +const claims: DeployClaims = { + org: arg("org", "org_1")!, + agt: arg("agent", "agt_1")!, + ep: ep != null ? Number(ep) : undefined, + iat: now, + exp: now + ttl, +}; +console.log(await mintDeployToken(privateKey, claims)); diff --git a/cloudflare-workers/oc-gateway/src/budget.ts b/cloudflare-workers/oc-gateway/src/budget.ts new file mode 100644 index 00000000..430ec25d --- /dev/null +++ b/cloudflare-workers/oc-gateway/src/budget.ts @@ -0,0 +1,103 @@ +// SpendCounter — a strongly-consistent keyed spend counter + optional hard gate (design 013 §4/§8). +// +// GRAINS (resolved token seam + co-location refinement 2026-07-05). Flue's provider registry is +// isolate-global and CF co-locates many session-DOs of one agent's script in one isolate, so ANY +// per-session data injected via registerProvider (the static token OR an X-OC-Session header) races +// across co-located sessions. Therefore the gateway uses this counter at TWO grains: +// • HARD (cost-safety boundary): keyed `${org}:${agt}` — race-free because the per-DEPLOY token +// carries org+agt and nothing per-session. The gateway /check-gates here and returns 402 when over. +// This matches today's org-level OpenRouter→Autumn model; cost-safety is unchanged. +// • TRACKING (best-effort): keyed by the X-OC-Session header — the gateway only /add-records here for +// per-session visibility (dashboard W11) + soft budgets. It NEVER /check-gates a session instance, +// so a co-location race can never wrongly 402 a legitimate session. Exact per-session enforcement +// is deferred to an upstream Flue per-request resolver (tracked upstream ask, off the critical path). +// +// The budget is looked up SERVER-SIDE here — NEVER carried in the token. It is set by the control plane +// (POST /provision) or falls back to the gateway's configured default on first /check. +// +// WHY A DO (not KV): enforcement must be read-then-write consistent — concurrent calls (subagents, +// parallel tools) racing on KV would double-spend past the cap. A DO serializes /check and /add. +// +// This counter is for ENFORCEMENT + display ONLY — NOT a billing source. Org-level spend stays on the +// org's single OpenRouter inference key → the existing model_meter cron → Autumn (one cost-source-of- +// truth). Money is integer MICRODOLLARS (µ$, 1e-6 USD) to avoid float drift, matching model_meter. + +interface State { + spent_micro: number; + budget_micro: number | null; // null = uncapped + provisioned: boolean; // true once the control plane set an explicit cap (default no longer applies) + calls: number; + updated: number; +} + +export class SpendCounter { + private state: DurableObjectState; + constructor(state: DurableObjectState) { + this.state = state; + } + + private async load(): Promise { + const s = await this.state.storage.get("s"); + return s ?? { spent_micro: 0, budget_micro: null, provisioned: false, calls: 0, updated: 0 }; + } + + async fetch(req: Request): Promise { + const url = new URL(req.url); + const body = req.method === "POST" ? ((await req.json().catch(() => ({}))) as Record) : {}; + + // POST /provision {budget_micro} — the control plane sets this grain's explicit cap (W1/W7 seam). + // `budget_micro: null` = uncapped. Marks it provisioned so the gateway default is ignored. + if (url.pathname === "/provision") { + const s = await this.load(); + if (typeof body.budget_micro === "number") s.budget_micro = Math.max(0, Math.round(body.budget_micro)); + else if (body.budget_micro === null) s.budget_micro = null; + s.provisioned = true; + s.updated = Date.now(); + await this.state.storage.put("s", s); + return Response.json({ ok: true, budget_micro: s.budget_micro }); + } + + // POST /check {default_budget_micro?} → GATE a NEW call (used ONLY at the hard org+agt grain). On + // first sight of an unprovisioned key, adopt the gateway's default cap (server-side; never from the + // token). Allowed while spent < budget; the last in-flight call can overshoot by at most one call's + // cost — bounded and acceptable for "refuse past the limit". Runs BEFORE the model call, DO-serialized. + if (url.pathname === "/check") { + const s = await this.load(); + if (!s.provisioned && typeof body.default_budget_micro === "number") { + s.budget_micro = Math.max(0, Math.round(body.default_budget_micro)); + } + const allowed = s.budget_micro == null || s.spent_micro < s.budget_micro; + s.updated = Date.now(); + await this.state.storage.put("s", s); + return Response.json({ allowed, spent_micro: s.spent_micro, budget_micro: s.budget_micro }); + } + + // POST /add {cost_micro, idem} → commit a completed call's cost (from waitUntil, after the response). + // Called at BOTH grains. Idempotent on the caller's key (an OpenRouter generation id) so a retried + // meter never double-counts. Each DO instance has its own idem namespace, so the same generation id + // recorded at the org+agt grain and the session grain does not collide. + if (url.pathname === "/add") { + const s = await this.load(); + const costMicro = typeof body.cost_micro === "number" ? Math.max(0, Math.round(body.cost_micro)) : 0; + const idem = typeof body.idem === "string" ? body.idem : null; + if (idem) { + const seen = await this.state.storage.get(`idem:${idem}`); + if (seen) return Response.json({ spent_micro: s.spent_micro, deduped: true }); + await this.state.storage.put(`idem:${idem}`, true); + } + s.spent_micro += costMicro; + s.calls += 1; + s.updated = Date.now(); + await this.state.storage.put("s", s); + return Response.json({ spent_micro: s.spent_micro, calls: s.calls }); + } + + // GET /state → spend at this grain (dashboard, §9). + if (url.pathname === "/state") { + const s = await this.load(); + return Response.json(s); + } + + return new Response("not found", { status: 404 }); + } +} diff --git a/cloudflare-workers/oc-gateway/src/cost.ts b/cloudflare-workers/oc-gateway/src/cost.ts new file mode 100644 index 00000000..6e03cb66 --- /dev/null +++ b/cloudflare-workers/oc-gateway/src/cost.ts @@ -0,0 +1,66 @@ +// Per-response cost extraction — the on-path meter's input (design 013 §4, token-billing §9.7). +// +// OpenRouter echoes cost on both the Anthropic Messages and OpenAI paths (verified live 2026-06-29, +// token-billing.md §9.7): a `usage` object carries a `cost` field in USD when usage accounting is +// on. We inject `usage:{include:true}` into the request (openrouter.ts precedent) so the field is +// present, then read it here. Fallbacks, in order: `usage.cost` → `usage.total_cost` → null (we log +// and count 0, flagged — never guess a price). The AUTHORITATIVE org-level cost stays OpenRouter's +// per-key cumulative usage (the cron); this is the fast on-path estimate for per-session enforcement. +// +// The OpenRouter generation id (`id` on the response) is returned too — it is the meter's idempotency +// key (so a retried /add never double-counts) AND the handle for the exact-cost fallback +// (GET /api/v1/generation?id=… — documented, not wired in the spike). + +export interface ExtractedCost { + costUsd: number | null; // null = cost not found in the echo (flagged; counted as 0) + generationId: string | null; + source: "usage.cost" | "usage.total_cost" | "none"; +} + +function readCost(usage: unknown): { usd: number; source: ExtractedCost["source"] } | null { + if (!usage || typeof usage !== "object") return null; + const u = usage as Record; + if (typeof u.cost === "number") return { usd: u.cost, source: "usage.cost" }; + if (typeof u.total_cost === "number") return { usd: u.total_cost, source: "usage.total_cost" }; + return null; +} + +/** Extract cost + generation id from a fully-buffered JSON response body. */ +export function costFromJson(bodyText: string): ExtractedCost { + let obj: Record; + try { obj = JSON.parse(bodyText); } catch { return { costUsd: null, generationId: null, source: "none" }; } + const id = typeof obj.id === "string" ? obj.id : null; + const hit = readCost(obj.usage); + return { costUsd: hit?.usd ?? null, generationId: id, source: hit?.source ?? "none" }; +} + +/** Extract cost + generation id from an SSE stream body (text/event-stream). Scans `data:` lines for + * the terminal usage — Anthropic emits `message_delta`/`message_stop` with usage; OR's cost rides the + * final usage. Reads the whole (already-teed) stream; the client copy is untouched. */ +export async function costFromStream(stream: ReadableStream): Promise { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let buf = ""; + let best: ExtractedCost = { costUsd: null, generationId: null, source: "none" }; + for (;;) { + const { value, done } = await reader.read(); + if (value) buf += decoder.decode(value, { stream: true }); + let nl: number; + while ((nl = buf.indexOf("\n")) >= 0) { + const line = buf.slice(0, nl).trim(); + buf = buf.slice(nl + 1); + if (!line.startsWith("data:")) continue; + const data = line.slice(5).trim(); + if (!data || data === "[DONE]") continue; + try { + const obj = JSON.parse(data) as Record; + if (typeof obj.id === "string" && !best.generationId) best.generationId = obj.id; + // usage can sit at the top level or under a delta (`message_delta.usage`). + const hit = readCost(obj.usage) ?? readCost((obj.message as Record | undefined)?.usage); + if (hit) best = { ...best, costUsd: hit.usd, source: hit.source }; + } catch { /* non-JSON keep-alive/comment line */ } + } + if (done) break; + } + return best; +} diff --git a/cloudflare-workers/oc-gateway/src/deploylease.ts b/cloudflare-workers/oc-gateway/src/deploylease.ts new file mode 100644 index 00000000..839f2792 --- /dev/null +++ b/cloudflare-workers/oc-gateway/src/deploylease.ts @@ -0,0 +1,70 @@ +// DeployLease — the per-(org, agt) lease-epoch floor that fences a rotated or revoked deploy token +// (design 013 §4 / buildout W3 "lease-epoch fence"). Keyed by `${org}:${agt}`. +// +// The deploy token (token.ts) carries a monotonic `ep` (deploy epoch, minted by W7). This DO holds +// the current floor for an (org, agt): +// - GATE: a token whose `ep` is BELOW the floor is fenced (superseded). A token at/above the floor +// is admitted and RAISES the floor to its `ep` — so the moment a redeploy's higher-epoch token is +// first used, every still-in-flight older-epoch token stops verifying. This is the rotation fence, +// automatic, no control-plane action required. +// - BUMP: the control plane raises the floor explicitly to REVOKE without a redeploy (e.g. a leaked +// token): POST /bump {min_epoch} sets floor = max(floor, min_epoch). To revoke epoch E, bump to E+1. +// +// A DO (not KV) so the floor is read-then-write consistent under concurrent calls. A token with no +// `ep` skips the fence entirely (lenient — the fence is opt-in on the mint side). + +interface Lease { + floor: number; // highest deploy epoch admitted / bumped; a token with ep < floor is fenced + updated: number; +} + +export class DeployLease { + private state: DurableObjectState; + constructor(state: DurableObjectState) { + this.state = state; + } + + private async load(): Promise { + const l = await this.state.storage.get("l"); + return l ?? { floor: 0, updated: 0 }; + } + + async fetch(req: Request): Promise { + const url = new URL(req.url); + const body = req.method === "POST" ? ((await req.json().catch(() => ({}))) as Record) : {}; + + // POST /gate {ep?} → { ok, fenced, floor }. Fence ep < floor; adopt (raise floor) on ep >= floor. + if (url.pathname === "/gate") { + const l = await this.load(); + const ep = typeof body.ep === "number" ? body.ep : null; + if (ep == null) return Response.json({ ok: true, fenced: false, floor: l.floor }); // no epoch → no fence + if (ep < l.floor) return Response.json({ ok: false, fenced: true, floor: l.floor }); + if (ep > l.floor) { + l.floor = ep; + l.updated = Date.now(); + await this.state.storage.put("l", l); + } + return Response.json({ ok: true, fenced: false, floor: l.floor }); + } + + // POST /bump {min_epoch} → raise the floor for an explicit revocation. floor = max(floor, min_epoch). + if (url.pathname === "/bump") { + const l = await this.load(); + const min = typeof body.min_epoch === "number" ? Math.round(body.min_epoch) : null; + if (min != null && min > l.floor) { + l.floor = min; + l.updated = Date.now(); + await this.state.storage.put("l", l); + } + return Response.json({ floor: l.floor }); + } + + // GET /state → the current floor (ops / dashboard). + if (url.pathname === "/state") { + const l = await this.load(); + return Response.json(l); + } + + return new Response("not found", { status: 404 }); + } +} diff --git a/cloudflare-workers/oc-gateway/src/index.ts b/cloudflare-workers/oc-gateway/src/index.ts new file mode 100644 index 00000000..2d420329 --- /dev/null +++ b/cloudflare-workers/oc-gateway/src/index.ts @@ -0,0 +1,284 @@ +// oc-gateway — the thin OC Worker over OpenRouter (design 013 §4, buildout contract #1 / W3). +// +// An unmodified Flue app registers the managed provider INSIDE defineAgent (resolved token seam): +// registerProvider('anthropic', { +// baseUrl: `${env.OC_GATEWAY}/anthropic`, +// apiKey: env.OC_SESSION_TOKEN, // the per-DEPLOY token — authorizes (org, agt) only +// headers: { 'X-OC-Session': id }, // the DO's own init id = ses_… — BEST-EFFORT attribution +// }); +// +// COST-SAFETY GRAIN (co-location refinement 2026-07-05). Flue's provider registry is isolate-global and +// CF co-locates many session-DOs of one agent's script in one isolate, so per-session data injected via +// registerProvider (the token OR the X-OC-Session header) RACES across co-located sessions. Therefore: +// - HARD enforcement (the 402) is at the **org+agt** grain — carried by the per-DEPLOY token, so it is +// race-free. This matches today's org-level OpenRouter→Autumn model; cost-safety is unchanged. +// - The X-OC-Session header is **best-effort per-session attribution** — recorded for visibility only, +// never gated (a co-location race must not wrongly block a legitimate session). Exact per-session +// enforcement is deferred to an upstream Flue per-request resolver (tracked ask, off the critical path). +// +// On every model call the Worker: +// (a) verifies the per-DEPLOY EdDSA token → (org, agt, ep). No session id, no budget in the token. +// (b) fences a superseded lease epoch (DeployLease DO, per org+agt) — a rotated/revoked token stops. +// (c) HARD-gates the org+agt budget ON-PATH (SpendCounter DO, keyed `${org}:${agt}`; budget looked up +// server-side, never in the token — §8). Over → 402 budget_exceeded. +// (d) injects the ORG's OpenRouter inference key (resolved from the credential store; never exposed). +// (e) makes the body prompt-caching-safe, injects usage accounting, forwards to OpenRouter. +// (f) sub-meters the response cost at the org+agt grain (authoritative) AND best-effort per session +// (X-OC-Session), leaving ORG-level spend on that same OR key → the existing model_meter cron → +// Autumn (one cost-source-of-truth). The gateway pushes NOTHING to Autumn. + +import { verifyDeployToken } from "./token.js"; +import { costFromJson, costFromStream } from "./cost.js"; +import { resolveOrgKey } from "./orgkey.js"; +import { unsafeModelMatchers, modelNeedsCacheStrip, stripCacheControl } from "./models.js"; +export { SpendCounter } from "./budget.js"; +export { DeployLease } from "./deploylease.js"; + +export interface Env { + // base64url raw 32-byte Ed25519 PUBLIC key. The minter (control plane / W7) holds the private key. + GATEWAY_TOKEN_PUBLIC_KEY: string; + // Spend counter + gate. Used at org+agt grain (hard) and per-session grain (tracked-only). + SPEND_COUNTER: DurableObjectNamespace; + // Per-(org, agt) lease-epoch floor that fences rotated/revoked deploy tokens. + DEPLOY_LEASE: DurableObjectNamespace; + // Default HARD budget (USD) per org+agt, applied to an unprovisioned grain on first sight. Unset = uncapped. + AGENT_BUDGET_USD_DEFAULT?: string; + // Org OR-key seam (orgkey.ts): dedicated internal sessions-api route + its bearer secret. + GATEWAY_ORKEY_URL?: string; + GATEWAY_ORKEY_SECRET?: string; + // Acceptance-test single-key override (bypasses the seam). Never set in multi-org prod. + TEST_OR_KEY?: string; + // Bearer that guards the control-plane admin routes (/admin/*). Unset → admin routes 404. + GATEWAY_ADMIN_SECRET?: string; + // Override OpenRouter base for tests; default = prod. + OPENROUTER_BASE?: string; + // Extra comma-separated model patterns whose OR route rejects cache_control (models.ts). + CACHE_CONTROL_UNSAFE_MODELS?: string; + // Optional per-session spend telemetry sink (OC_INGEST). Best-effort; absent = skip. + OC_INGEST_URL?: string; + OC_INGEST_AUTH?: string; +} + +const OR_BASE_DEFAULT = "https://openrouter.ai/api"; // == credential.ts MANAGED_ANTHROPIC_BASE + +// Map a gateway path prefix → the OpenRouter path prefix (credential.ts managed bases). +// /anthropic/v1/messages → https://openrouter.ai/api/v1/messages (Claude-Code path) +// /openai/chat/completions → https://openrouter.ai/api/v1/chat/completions +function forwardUrl(base: string, pathname: string): string | null { + if (pathname === "/anthropic" || pathname.startsWith("/anthropic/")) { + return base + pathname.slice("/anthropic".length); // base already ends /api + } + if (pathname === "/openai" || pathname.startsWith("/openai/")) { + return base + "/v1" + pathname.slice("/openai".length); + } + return null; +} + +function bearer(h: Headers): string | null { + const a = h.get("authorization"); + if (a && /^Bearer\s+/i.test(a)) return a.replace(/^Bearer\s+/i, "").trim(); + const x = h.get("x-api-key"); // Anthropic-style clients put the apiKey here + return x ? x.trim() : null; +} + +const json = (obj: unknown, status = 200) => + new Response(JSON.stringify(obj), { status, headers: { "content-type": "application/json" } }); + +export default { + async fetch(req: Request, env: Env, ctx: ExecutionContext): Promise { + const url = new URL(req.url); + + if (req.method === "GET" && url.pathname === "/healthz") { + return json({ status: "ok", service: "oc-gateway" }); + } + + // Control-plane admin routes (provision an org+agt budget, revoke a deploy lease). Guarded by a + // dedicated bearer; absent secret → not exposed. These are the W1/W7 control-plane seams. + if (url.pathname.startsWith("/admin/")) { + return admin(req, env, url); + } + + const target = forwardUrl(env.OPENROUTER_BASE || OR_BASE_DEFAULT, url.pathname); + if (!target) return json({ error: { type: "not_found", message: "unknown gateway path" } }, 404); + if (req.method !== "POST") return json({ error: { type: "method_not_allowed" } }, 405); + + // (a) verify the per-DEPLOY token (EdDSA — gateway holds only the public key). + const token = bearer(req.headers); + if (!token) return json({ error: { type: "unauthorized", message: "missing deploy token" } }, 401); + const nowSec = Math.floor(Date.now() / 1000); + const v = await verifyDeployToken(env.GATEWAY_TOKEN_PUBLIC_KEY, token, nowSec); + if (!v.ok) return json({ error: { type: "unauthorized", message: `invalid deploy token: ${v.reason}` } }, 401); + const { org: orgId, agt: agentId, ep } = v.claims; + + // Best-effort per-session attribution — the header may be stale under co-location; used for + // tracking only, never gated. Absent → we simply skip the per-session record (the call proceeds). + const sessionId = req.headers.get("x-oc-session")?.trim() || null; + + // (b) lease-epoch fence — a rotated/revoked deploy token stops verifying (per org+agt, DO-serialized). + const leaseStub = env.DEPLOY_LEASE.get(env.DEPLOY_LEASE.idFromName(`${orgId}:${agentId}`)); + const gate = await leaseStub + .fetch("https://do/gate", { method: "POST", body: JSON.stringify({ ep }) }) + .then((r) => r.json() as Promise<{ ok: boolean; fenced?: boolean; floor: number }>); + if (gate.fenced) { + return json({ error: { type: "unauthorized", message: "deploy token superseded (stale lease epoch)", code: "token_superseded" } }, 401); + } + + // (c) HARD budget gate at the org+agt grain — ON-PATH, before the model call (§8), DO-serialized, + // race-free (org+agt from the token). Budget looked up SERVER-SIDE (provisioned cap, else the + // gateway default); NEVER carried in the token. + const agentKey = `agt:${orgId}:${agentId}`; + const agentBudget = env.SPEND_COUNTER.get(env.SPEND_COUNTER.idFromName(agentKey)); + const defaultBudgetMicro = parseUsdMicro(env.AGENT_BUDGET_USD_DEFAULT); + const check = await agentBudget + .fetch("https://do/check", { method: "POST", body: JSON.stringify({ default_budget_micro: defaultBudgetMicro }) }) + .then((r) => r.json() as Promise<{ allowed: boolean; spent_micro: number; budget_micro: number | null }>); + if (!check.allowed) { + // Refuse past the org+agt budget. Shaped as a provider-style error so the Flue/pi-ai turn + // terminates and the tailer maps it to outcome `budget_exceeded` (§8). Exact shape = live-verify. + return json({ + error: { type: "budget_exceeded", message: "org/agent model budget exhausted", code: "insufficient_quota" }, + oc: { org: orgId, agent: agentId, spent_usd: check.spent_micro / 1e6, budget_usd: (check.budget_micro ?? 0) / 1e6 }, + }, 402); + } + + // (d) resolve the ORG's OpenRouter inference key (from the credential-store seam; never exposed). + const orKey = await resolveOrgKey(env, orgId, Date.now()); + if (!orKey) return json({ error: { type: "server_error", message: "no OpenRouter key resolved for org" } }, 500); + + // (e) rewrite the body: strip cache_control for caching-unsafe models, inject usage:{include:true} + // so OpenRouter echoes cost (openrouter.ts precedent). Model bodies are small; buffer is fine. + const rawBody = await req.text(); + let outBody = rawBody; + try { + const parsed = JSON.parse(rawBody) as Record; + if (modelNeedsCacheStrip(parsed.model, unsafeModelMatchers(env.CACHE_CONTROL_UNSAFE_MODELS))) { + stripCacheControl(parsed); + } + const usage = (parsed.usage && typeof parsed.usage === "object" ? parsed.usage : {}) as Record; + usage.include = true; + parsed.usage = usage; + outBody = JSON.stringify(parsed); + } catch { + /* not JSON — forward verbatim */ + } + + // forward to OpenRouter with the org key swapped in. Strip the tenant's auth + the session header; + // pass the rest. + const fwdHeaders = new Headers(req.headers); + fwdHeaders.delete("x-api-key"); + fwdHeaders.delete("authorization"); + fwdHeaders.delete("x-oc-session"); + fwdHeaders.set("authorization", `Bearer ${orKey}`); + fwdHeaders.set("content-type", req.headers.get("content-type") || "application/json"); + fwdHeaders.set("content-length", String(new TextEncoder().encode(outBody).length)); + // OpenRouter attribution/routing headers (non-secret): help the OR dashboard + rankings. + fwdHeaders.set("http-referer", "https://opencomputer.dev"); + fwdHeaders.set("x-title", "OpenComputer"); + + const forwardTarget = target + (url.search || ""); + const upstream = await fetch(forwardTarget, { method: "POST", headers: fwdHeaders, body: outBody }); + + // (f) sub-meter the response cost, off the response path (waitUntil): authoritative at the org+agt + // grain + best-effort per session (for the dashboard). Passing the same generation id to both + // grains is safe — each DO instance has its own idempotency namespace. + const isStream = (upstream.headers.get("content-type") || "").includes("text/event-stream"); + const meterCopy = upstream.clone(); + ctx.waitUntil( + meter(meterCopy, isStream, env, { + agentBudget, + sessionCounter: sessionId ? env.SPEND_COUNTER.get(env.SPEND_COUNTER.idFromName(`sess:${sessionId}`)) : null, + sessionId, orgId, agentId, + }), + ); + + // Passthrough: return OpenRouter's response (status + headers + body) untouched to Flue. + return new Response(upstream.body, { status: upstream.status, statusText: upstream.statusText, headers: upstream.headers }); + }, +}; + +/** Parse a USD string into integer µ$, or null (uncapped) if unset/invalid/≤0. */ +function parseUsdMicro(usd?: string): number | null { + if (!usd) return null; + const n = Number(usd); + if (!Number.isFinite(n) || n <= 0) return null; + return Math.round(n * 1e6); +} + +// ── Control-plane admin routes (guarded by GATEWAY_ADMIN_SECRET) ── +async function admin(req: Request, env: Env, url: URL): Promise { + if (!env.GATEWAY_ADMIN_SECRET) return json({ error: { type: "not_found" } }, 404); + const auth = bearer(req.headers); + if (auth !== env.GATEWAY_ADMIN_SECRET) return json({ error: { type: "unauthorized" } }, 401); + if (req.method !== "POST") return json({ error: { type: "method_not_allowed" } }, 405); + const body = (await req.json().catch(() => ({}))) as Record; + + // POST /admin/agent/budget {org, agt, budget_usd|null} — provision the org+agt HARD cap (W1/W7 seam). + if (url.pathname === "/admin/agent/budget") { + const org = typeof body.org === "string" ? body.org : null; + const agt = typeof body.agt === "string" ? body.agt : null; + if (!org || !agt) return json({ error: { type: "bad_request", message: "org, agt required" } }, 400); + const budgetMicro = body.budget_usd === null ? null : parseUsdMicro(String(body.budget_usd)); + const stub = env.SPEND_COUNTER.get(env.SPEND_COUNTER.idFromName(`agt:${org}:${agt}`)); + const r = await stub.fetch("https://do/provision", { method: "POST", body: JSON.stringify({ budget_micro: budgetMicro }) }); + return new Response(r.body, { status: r.status, headers: { "content-type": "application/json" } }); + } + + // POST /admin/lease/bump {org, agt, min_epoch} — revoke deploy tokens below min_epoch (no redeploy). + if (url.pathname === "/admin/lease/bump") { + const org = typeof body.org === "string" ? body.org : null; + const agt = typeof body.agt === "string" ? body.agt : null; + const minEpoch = typeof body.min_epoch === "number" ? body.min_epoch : null; + if (!org || !agt || minEpoch == null) return json({ error: { type: "bad_request", message: "org, agt, min_epoch required" } }, 400); + const stub = env.DEPLOY_LEASE.get(env.DEPLOY_LEASE.idFromName(`${org}:${agt}`)); + const r = await stub.fetch("https://do/bump", { method: "POST", body: JSON.stringify({ min_epoch: minEpoch }) }); + return new Response(r.body, { status: r.status, headers: { "content-type": "application/json" } }); + } + + return json({ error: { type: "not_found" } }, 404); +} + +async function meter( + resp: Response, + isStream: boolean, + env: Env, + ctx: { + agentBudget: DurableObjectStub; + sessionCounter: DurableObjectStub | null; + sessionId: string | null; + orgId: string; + agentId: string; + }, +): Promise { + try { + if (resp.status >= 400) return; // a failed provider call bills nothing + const extracted = isStream + ? await costFromStream(resp.body ?? new ReadableStream()) + : costFromJson(await resp.text()); + const costMicro = extracted.costUsd != null ? Math.round(extracted.costUsd * 1e6) : 0; + // Authoritative: the org+agt grain that gates. + await ctx.agentBudget.fetch("https://do/add", { + method: "POST", + body: JSON.stringify({ cost_micro: costMicro, idem: extracted.generationId }), + }); + // Best-effort per-session tracking (visibility only; never gated). + if (ctx.sessionCounter) { + await ctx.sessionCounter + .fetch("https://do/add", { method: "POST", body: JSON.stringify({ cost_micro: costMicro, idem: extracted.generationId }) }) + .catch(() => {}); + } + // Per-session spend telemetry (dashboard, §9) — best-effort; NOT a billing source. Absent → skip. + if (env.OC_INGEST_URL) { + await fetch(env.OC_INGEST_URL, { + method: "POST", + headers: { "content-type": "application/json", ...(env.OC_INGEST_AUTH ? { "x-internal-auth": env.OC_INGEST_AUTH } : {}) }, + body: JSON.stringify({ + name: "gateway.model_call", session: ctx.sessionId, org: ctx.orgId, agent: ctx.agentId, + cost_usd: extracted.costUsd, cost_source: extracted.source, generation_id: extracted.generationId, + }), + }).catch(() => {}); + } + } catch { + // Metering must never affect the served response; a lost sample is tolerable (org billing is + // OR-authoritative). Enforcement degrades gracefully — worst case one uncounted call. + } +} diff --git a/cloudflare-workers/oc-gateway/src/models.ts b/cloudflare-workers/oc-gateway/src/models.ts new file mode 100644 index 00000000..c06769af --- /dev/null +++ b/cloudflare-workers/oc-gateway/src/models.ts @@ -0,0 +1,51 @@ +// Prompt-caching safety (buildout W3: "Handle prompt-caching-safe models"). +// +// Some models route (via OpenRouter) to a backend that REJECTS Anthropic `cache_control` breakpoints +// — e.g. `claude-3-haiku` served through OR→Bedrock 400s the whole request. A Flue app written for +// Anthropic-native caching would then fail every turn on those models. Rather than restrict the model +// list (brittle as the catalog moves), the gateway STRIPS `cache_control` from the request body for a +// small, env-extensible denylist of known-unsafe models — the call still completes, just without +// caching. Models that support caching are untouched (no cost/perf regression). + +const DEFAULT_UNSAFE: RegExp[] = [ + /claude-3-haiku/i, // OR→Bedrock rejects cache_control (observed 1a) +]; + +/** Build the unsafe-model matchers, extended by a comma-separated env list (CACHE_CONTROL_UNSAFE_MODELS). */ +export function unsafeModelMatchers(extra?: string): RegExp[] { + const list = [...DEFAULT_UNSAFE]; + if (extra) { + for (const s of extra.split(",").map((x) => x.trim()).filter(Boolean)) { + try { + list.push(new RegExp(s, "i")); + } catch { + /* ignore an invalid pattern rather than break every request */ + } + } + } + return list; +} + +export function modelNeedsCacheStrip(model: unknown, matchers: RegExp[]): boolean { + return typeof model === "string" && matchers.some((re) => re.test(model)); +} + +/** Recursively delete every `cache_control` property in place. Returns how many were removed. */ +export function stripCacheControl(node: unknown): number { + if (Array.isArray(node)) { + let n = 0; + for (const v of node) n += stripCacheControl(v); + return n; + } + if (node && typeof node === "object") { + const o = node as Record; + let n = 0; + if ("cache_control" in o) { + delete o.cache_control; + n++; + } + for (const k of Object.keys(o)) n += stripCacheControl(o[k]); + return n; + } + return 0; +} diff --git a/cloudflare-workers/oc-gateway/src/orgkey.ts b/cloudflare-workers/oc-gateway/src/orgkey.ts new file mode 100644 index 00000000..bfc1e636 --- /dev/null +++ b/cloudflare-workers/oc-gateway/src/orgkey.ts @@ -0,0 +1,60 @@ +// Org OpenRouter inference-key resolution (design 013 §4, buildout W3 "org OR key from Infisical"). +// +// The plaintext managed OR key lives in Infisical, sealed by sessions-api (referenced by the org's +// managed credential → `resolveManagedSecret`; edge `managed_model_keys` owns the key lifecycle). A +// CF Worker cannot reach Infisical or the box secrets-proxy (design §4), so the gateway resolves the +// key through a DEDICATED internal sessions-api seam — mirroring the edge's dedicated-secret +// plaintext-key hand-off (model_billing.ts §6.7.5): a route that carries a live key gets its OWN +// secret, never the generic internal-auth one. +// +// SEAM (sessions-api / L3 must provide — flagged in the W3 PR): +// POST {GATEWAY_ORKEY_URL} Authorization: Bearer {GATEWAY_ORKEY_SECRET} body {"org": orgId} +// → 200 {"key": "sk-or-..."} (resolveManagedSecret for the org's active managed credential) +// → 404/other on no active managed key. +// +// The plaintext is cached PER ORG in-isolate with a short TTL — it bounds exposure (evaporates with +// the isolate) and avoids hammering the seam on every model call in a turn. TEST_OR_KEY short-circuits +// resolution for the acceptance run against a throwaway $1-capped key (no sealed dev credential needed). + +export interface OrgKeyEnv { + GATEWAY_ORKEY_URL?: string; + GATEWAY_ORKEY_SECRET?: string; + /** Acceptance-test / single-key override — bypasses the seam. Never set in multi-org prod. */ + TEST_OR_KEY?: string; +} + +interface CacheEntry { + key: string; + exp: number; // epoch ms +} +const CACHE_TTL_MS = 60_000; +const cache = new Map(); + +/** Resolve the org's OpenRouter inference key, or null if unavailable. Never throws. */ +export async function resolveOrgKey(env: OrgKeyEnv, orgId: string, nowMs: number): Promise { + if (env.TEST_OR_KEY) return env.TEST_OR_KEY; + + const hit = cache.get(orgId); + if (hit && hit.exp > nowMs) return hit.key; + + if (!env.GATEWAY_ORKEY_URL || !env.GATEWAY_ORKEY_SECRET) return null; + try { + const r = await fetch(env.GATEWAY_ORKEY_URL, { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${env.GATEWAY_ORKEY_SECRET}` }, + body: JSON.stringify({ org: orgId }), + }); + if (!r.ok) return null; + const body = (await r.json()) as { key?: unknown }; + const key = typeof body.key === "string" && body.key ? body.key : null; + if (key) cache.set(orgId, { key, exp: nowMs + CACHE_TTL_MS }); + return key; + } catch { + return null; + } +} + +/** Test-only: clear the per-isolate cache between cases. */ +export function _clearOrgKeyCache(): void { + cache.clear(); +} diff --git a/cloudflare-workers/oc-gateway/src/token.ts b/cloudflare-workers/oc-gateway/src/token.ts new file mode 100644 index 00000000..311bd99e --- /dev/null +++ b/cloudflare-workers/oc-gateway/src/token.ts @@ -0,0 +1,116 @@ +// The gateway deploy token (design 013 §4 / buildout contract #1, resolved token seam 2026-07-05). +// +// RESOLVED SEAM (option b, header-based). The gateway token is **per-DEPLOY**, not per-session: +// claims = { org, agt, iat, exp, ep? } — it authorizes an (org, agent) pair, nothing more. +// There is NO `sub:session` and NO `bud` claim. The session identity rides an `X-OC-Session` request +// header the tenant DO injects (Flue's `registerProvider` `apiKey` is a static string only, so it +// cannot carry per-session data — providers.ts:60); the per-session budget is looked up server-side +// in the SessionBudget DO keyed by that header. The token is bound as the tenant script's +// `OC_SESSION_TOKEN` env var by the deploy pipeline (W7) and rotates every redeploy. +// +// PROD hardening over the 1a spike (HS256 shared secret): +// 1. EdDSA (Ed25519). The MINTER (control plane / deploy pipeline) holds the private key; the +// gateway holds ONLY the public key — the same asymmetry as the turn token, so a compromised +// gateway cannot forge deploy tokens. Dependency-free — Ed25519 is in Workers' WebCrypto. +// 2. A **lease epoch** (`ep`, optional): a monotonic per-(org, agt) deploy counter. The gateway +// fences a token whose `ep` is below the current floor (DeployLease DO) — so a rotated or +// explicitly-revoked deploy token stops verifying even before `exp`. +// +// MINT↔VERIFY CONTRACT (W7 mints; the gateway verifies): +// alg "EdDSA"; claims { org, agt, iat, exp, ep? }; the gateway is configured with +// GATEWAY_TOKEN_PUBLIC_KEY = base64url(raw 32-byte Ed25519 public key). + +export interface DeployClaims { + /** org id — selects the org's OpenRouter inference key (never leaves the gateway). */ + org: string; + /** agent id — the deploy this token authorizes; attribution + the lease-fence key with `org`. */ + agt: string; + /** lease/deploy epoch (monotonic per (org, agt)). Below the DeployLease floor → fenced. Omit = no fence. */ + ep?: number; + /** issued-at / expiry (seconds). */ + iat: number; + exp: number; +} + +const enc = new TextEncoder(); +const dec = new TextDecoder(); +const ED = { name: "Ed25519" } as const; + +function b64urlEncode(bytes: Uint8Array): string { + let s = ""; + for (const b of bytes) s += String.fromCharCode(b); + return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} +function b64urlDecode(s: string): Uint8Array { + const pad = s.length % 4 === 0 ? "" : "=".repeat(4 - (s.length % 4)); + const b = atob(s.replace(/-/g, "+").replace(/_/g, "/") + pad); + const out = new Uint8Array(b.length); + for (let i = 0; i < b.length; i++) out[i] = b.charCodeAt(i); + return out; +} + +/** Import a base64url raw 32-byte Ed25519 public key for verification. */ +async function importPublicKey(publicKeyB64url: string): Promise { + return crypto.subtle.importKey("raw", b64urlDecode(publicKeyB64url), ED, false, ["verify"]); +} + +export type VerifyResult = { ok: true; claims: DeployClaims } | { ok: false; reason: string }; + +/** Verify a per-deploy EdDSA token: alg pin + signature + exp/iat + required claims (org, agt). */ +export async function verifyDeployToken(publicKeyB64url: string, token: string, nowSec: number): Promise { + const parts = token.split("."); + if (parts.length !== 3) return { ok: false, reason: "malformed" }; + const [header, payload, sig] = parts; + + let alg: string; + try { + alg = (JSON.parse(dec.decode(b64urlDecode(header))) as { alg?: string }).alg ?? ""; + } catch { + return { ok: false, reason: "bad_header" }; + } + if (alg !== "EdDSA") return { ok: false, reason: "unexpected_alg" }; // pin — never accept "none"/HS256 + + let key: CryptoKey; + try { + key = await importPublicKey(publicKeyB64url); + } catch { + return { ok: false, reason: "bad_public_key" }; + } + + let valid: boolean; + try { + valid = await crypto.subtle.verify(ED, key, b64urlDecode(sig), enc.encode(`${header}.${payload}`)); + } catch { + return { ok: false, reason: "bad_signature_encoding" }; + } + if (!valid) return { ok: false, reason: "bad_signature" }; + + let claims: DeployClaims; + try { + claims = JSON.parse(dec.decode(b64urlDecode(payload))); + } catch { + return { ok: false, reason: "bad_payload" }; + } + if (typeof claims.exp !== "number" || claims.exp <= nowSec) return { ok: false, reason: "expired" }; + if (typeof claims.iat === "number" && claims.iat > nowSec + 60) return { ok: false, reason: "future_iat" }; + if (!claims.org || !claims.agt) return { ok: false, reason: "missing_claims" }; + return { ok: true, claims }; +} + +// ── Mint helpers — for the reference minter (W7) + tests. The gateway NEVER mints in prod. ── + +/** Generate an Ed25519 keypair; returns the private key + base64url raw public key (gateway config). */ +export async function generateKeyPair(): Promise<{ privateKey: CryptoKey; publicKeyB64url: string }> { + const kp = (await crypto.subtle.generateKey(ED, true, ["sign", "verify"])) as CryptoKeyPair; + const raw = new Uint8Array((await crypto.subtle.exportKey("raw", kp.publicKey)) as ArrayBuffer); + return { privateKey: kp.privateKey, publicKeyB64url: b64urlEncode(raw) }; +} + +/** Sign a deploy token with the Ed25519 private key (mint side). */ +export async function mintDeployToken(privateKey: CryptoKey, claims: DeployClaims): Promise { + const header = b64urlEncode(enc.encode(JSON.stringify({ alg: "EdDSA", typ: "JWT" }))); + const payload = b64urlEncode(enc.encode(JSON.stringify(claims))); + const signingInput = `${header}.${payload}`; + const sig = new Uint8Array(await crypto.subtle.sign(ED, privateKey, enc.encode(signingInput))); + return `${signingInput}.${b64urlEncode(sig)}`; +} diff --git a/cloudflare-workers/oc-gateway/test/integration.test.ts b/cloudflare-workers/oc-gateway/test/integration.test.ts new file mode 100644 index 00000000..a698ab3b --- /dev/null +++ b/cloudflare-workers/oc-gateway/test/integration.test.ts @@ -0,0 +1,240 @@ +// In-process integration: the REAL worker handler + REAL SpendCounter/DeployLease DOs, with `fetch` +// stubbed to a mock OpenRouter. Proves the whole on-path flow deterministically without wrangler: +// deploy-token verify → lease fence → HARD org+agt budget gate → org-key injection → forward → cost +// sub-meter (org+agt authoritative + best-effort per session) → passthrough → 402 over budget. +// Reflects the resolved token seam + co-location refinement: HARD enforcement is org+agt (race-free); +// the X-OC-Session header is best-effort per-session tracking, never gated. +// Run: npx vitest run + +import { describe, it, expect, beforeEach, beforeAll, vi, afterEach } from "vitest"; +import worker, { Env } from "../src/index.js"; +import { SpendCounter } from "../src/budget.js"; +import { DeployLease } from "../src/deploylease.js"; +import { generateKeyPair, mintDeployToken } from "../src/token.js"; + +const OR_KEY = "sk-or-v1-FAKE-org-key"; +const OR_BASE = "https://mock-openrouter.test/api"; + +// EdDSA keypair for the suite: the minter (control plane) holds PRIV, the gateway holds PUB. +let PRIV: CryptoKey; +let PUB: string; +beforeAll(async () => { const kp = await generateKeyPair(); PRIV = kp.privateKey; PUB = kp.publicKeyB64url; }); + +// ── a fake DurableObjectState backed by a Map (the real DO runs against it) ── +function fakeState() { + const store = new Map(); + return { storage: { + get: async (k: string) => store.get(k), + put: async (k: string, v: unknown) => void store.set(k, v), + } } as unknown as DurableObjectState; +} + +// ── a fake DO namespace: one real instance of `Klass` per name; `instances` exposed for assertions ── +function fakeNamespace }>(Klass: new (s: DurableObjectState) => T) { + const instances = new Map(); + const ns = { + idFromName: (n: string) => ({ toString: () => n, name: n }) as unknown as DurableObjectId, + get: (id: DurableObjectId) => { + const name = (id as unknown as { name: string }).name; + if (!instances.has(name)) instances.set(name, new Klass(fakeState())); + const inst = instances.get(name)!; + return { fetch: (input: RequestInfo, init?: RequestInit) => inst.fetch(new Request(typeof input === "string" ? input : (input as Request).url, init)) } as unknown as DurableObjectStub; + }, + } as unknown as DurableObjectNamespace; + return { ns, instances }; +} + +let lastAuthToOR: string | null; +let lastBodyToOR: Record | null; +let lastHeadersToOR: Headers | null; + +function mockFetch(perCallCost: number) { + return vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.toString(); + lastHeadersToOR = new Headers(init?.headers); + lastAuthToOR = lastHeadersToOR.get("authorization"); + lastBodyToOR = init?.body ? JSON.parse(init.body as string) : null; + expect(url.startsWith(OR_BASE)).toBe(true); // forwarded to the OR base, tail preserved + return new Response(JSON.stringify({ + id: "gen-" + Math.random().toString(36).slice(2), + type: "message", role: "assistant", + content: [{ type: "text", text: "pong" }], + usage: { input_tokens: 10, output_tokens: 3, cost: perCallCost }, + }), { status: 200, headers: { "content-type": "application/json" } }); + }); +} + +// a fresh env each test; `spend`/`lease` handles let us assert on per-grain DO state. +function mkEnv(extra: Partial = {}) { + const spend = fakeNamespace(SpendCounter); + const lease = fakeNamespace(DeployLease); + const env = { GATEWAY_TOKEN_PUBLIC_KEY: PUB, TEST_OR_KEY: OR_KEY, OPENROUTER_BASE: OR_BASE, SPEND_COUNTER: spend.ns, DEPLOY_LEASE: lease.ns, ...extra } as Env; + return { env, spend, lease }; +} + +// waitUntil runs the metering; collect the promises so we can await them (the meter is off-path). +function ctx(): ExecutionContext { + const pending: Promise[] = []; + return { waitUntil: (p: Promise) => pending.push(p), passThroughOnException: () => {}, _pending: pending } as unknown as ExecutionContext; +} +async function drain(c: ExecutionContext) { await Promise.all((c as unknown as { _pending: Promise[] })._pending); } + +const MSG = JSON.stringify({ model: "anthropic/claude-sonnet-5", max_tokens: 16, messages: [{ role: "user", content: "ping" }] }); +const post = (token?: string, session?: string, body = MSG) => new Request("https://gw.test/anthropic/v1/messages", { + method: "POST", + headers: { + "content-type": "application/json", + ...(token ? { authorization: `Bearer ${token}` } : {}), + ...(session ? { "x-oc-session": session } : {}), + }, + body, +}); +const mint = async (o: Partial<{ org: string; agt: string; ep: number; iat: number; exp: number }> = {}) => { + const now = Math.floor(Date.now() / 1000); + return mintDeployToken(PRIV, { org: "org_1", agt: "agt_1", iat: now, exp: now + 3600, ...o }); +}; +const stateOf = async (inst: { fetch(r: Request): Promise }) => + (await inst.fetch(new Request("https://do/state"))).json() as Promise<{ spent_micro: number }>; + +describe("gateway on-path flow (resolved seam)", () => { + beforeEach(() => { lastAuthToOR = null; lastBodyToOR = null; lastHeadersToOR = null; }); + afterEach(() => vi.restoreAllMocks()); + + it("GET /healthz → ok", async () => { + const { env } = mkEnv(); + const res = await worker.fetch(new Request("https://gw.test/healthz"), env, ctx()); + expect(res.status).toBe(200); + expect((await res.json() as { status: string }).status).toBe("ok"); + }); + + it("rejects a POST with no deploy token (401)", async () => { + const { env } = mkEnv(); + const res = await worker.fetch(post(), env, ctx()); + expect(res.status).toBe(401); + }); + + it("rejects an expired token (401)", async () => { + const { env } = mkEnv(); + const now = Math.floor(Date.now() / 1000); + const expired = await mint({ iat: now - 10, exp: now - 5 }); + const res = await worker.fetch(post(expired), env, ctx()); + expect(res.status).toBe(401); + }); + + it("forwards a valid turn: injects the ORG key (not the deploy token), adds usage.include, passes the body through, does NOT leak the session header to OR", async () => { + vi.stubGlobal("fetch", mockFetch(0.02)); + const { env } = mkEnv(); + const token = await mint(); + const c = ctx(); + const res = await worker.fetch(post(token, "ses_ok"), env, c); + expect(res.status).toBe(200); + const body = await res.json() as { content: { text: string }[] }; + expect(body.content[0].text).toBe("pong"); + // the ORG key was injected; the deploy token never reached OpenRouter (no raw key to the tenant either) + expect(lastAuthToOR).toBe(`Bearer ${OR_KEY}`); + expect(lastAuthToOR).not.toContain(token); + // the session header is an OC-internal signal — it must not egress to OpenRouter + expect(lastHeadersToOR?.get("x-oc-session")).toBeNull(); + // usage.include injected so OR echoes cost + expect(lastBodyToOR?.usage).toMatchObject({ include: true }); + expect(lastBodyToOR?.model).toBe("anthropic/claude-sonnet-5"); + await drain(c); + }); + + it("proceeds WITHOUT an X-OC-Session header (best-effort attribution, not required)", async () => { + vi.stubGlobal("fetch", mockFetch(0.01)); + const { env } = mkEnv(); + const c = ctx(); + const res = await worker.fetch(post(await mint()), env, c); // no session header + expect(res.status).toBe(200); + await drain(c); + }); + + it("HARD-enforces the org+agt budget ON-PATH: refuses once org+agt spend reaches the cap (402)", async () => { + vi.stubGlobal("fetch", mockFetch(0.02)); // $0.02 per call + // default org+agt budget $0.03 → call1 (0<0.03) ok→0.02; call2 (0.02<0.03) ok→0.04; call3 refused. + const { env } = mkEnv({ AGENT_BUDGET_USD_DEFAULT: "0.03" }); + const token = await mint(); + const c1 = ctx(); const r1 = await worker.fetch(post(token, "ses_a"), env, c1); await drain(c1); + const c2 = ctx(); const r2 = await worker.fetch(post(token, "ses_a"), env, c2); await drain(c2); + const c3 = ctx(); const r3 = await worker.fetch(post(token, "ses_a"), env, c3); await drain(c3); + expect(r1.status).toBe(200); + expect(r2.status).toBe(200); + expect(r3.status).toBe(402); + const refusal = await r3.json() as { error: { type: string }; oc: { spent_usd: number; budget_usd: number } }; + expect(refusal.error.type).toBe("budget_exceeded"); + expect(refusal.oc.spent_usd).toBeCloseTo(0.04, 5); // bounded one-call overshoot past $0.03 + expect(refusal.oc.budget_usd).toBeCloseTo(0.03, 5); + }); + + it("co-location: two DIFFERENT sessions of the same org+agt SHARE the hard budget (grain is org+agt, not per-session)", async () => { + vi.stubGlobal("fetch", mockFetch(0.02)); + const { env, spend } = mkEnv({ AGENT_BUDGET_USD_DEFAULT: "0.03" }); + const token = await mint(); + // ses_x spends $0.02, ses_y spends $0.02 → org+agt total $0.04 ≥ $0.03 → the next call (either + // session) is refused. This is exactly the race-free property: budget is on the token's org+agt. + const cx = ctx(); const rx = await worker.fetch(post(token, "ses_x"), env, cx); await drain(cx); + const cy = ctx(); const ry = await worker.fetch(post(token, "ses_y"), env, cy); await drain(cy); + const cz = ctx(); const rz = await worker.fetch(post(token, "ses_z"), env, cz); await drain(cz); + expect(rx.status).toBe(200); + expect(ry.status).toBe(200); + expect(rz.status).toBe(402); // org+agt cap hit across sessions + // best-effort per-session tracking recorded each session's own spend separately + expect((await stateOf(spend.instances.get("sess:ses_x")!)).spent_micro).toBe(20_000); + expect((await stateOf(spend.instances.get("sess:ses_y")!)).spent_micro).toBe(20_000); + // and the authoritative org+agt grain summed them + expect((await stateOf(spend.instances.get("agt:org_1:agt_1")!)).spent_micro).toBe(40_000); + }); + + it("per-session counter is TRACKED but NEVER gated: a session over its own spend is not 402'd", async () => { + vi.stubGlobal("fetch", mockFetch(0.10)); // $0.10/call, well over any per-session intuition + const { env, spend } = mkEnv(); // no org+agt cap → uncapped hard grain + const token = await mint(); + for (let i = 0; i < 3; i++) { const c = ctx(); const r = await worker.fetch(post(token, "ses_hot"), env, c); await drain(c); expect(r.status).toBe(200); } + // the session accumulated $0.30 but was never blocked (no per-session hard gate) + expect((await stateOf(spend.instances.get("sess:ses_hot")!)).spent_micro).toBe(300_000); + }); + + it("fences a superseded deploy lease epoch (401 token_superseded)", async () => { + vi.stubGlobal("fetch", mockFetch(0.001)); + const { env } = mkEnv(); // same DEPLOY_LEASE namespace → same lease for org_1:agt_1 + const t2 = await mint({ ep: 2 }); + const c2 = ctx(); const r2 = await worker.fetch(post(t2, "ses_ep"), env, c2); await drain(c2); + expect(r2.status).toBe(200); // adopt epoch 2 + const t1 = await mint({ ep: 1 }); + const r1 = await worker.fetch(post(t1, "ses_ep"), env, ctx()); // the old deploy's token — superseded + expect(r1.status).toBe(401); + expect((await r1.json() as { error: { code?: string } }).error.code).toBe("token_superseded"); + }); + + it("strips cache_control for a caching-unsafe model, still injects usage.include", async () => { + vi.stubGlobal("fetch", mockFetch(0.001)); + const { env } = mkEnv(); + const token = await mint(); + const body = JSON.stringify({ + model: "anthropic/claude-3-haiku", max_tokens: 16, + system: [{ type: "text", text: "s", cache_control: { type: "ephemeral" } }], + messages: [{ role: "user", content: "ping" }], + }); + const c = ctx(); const r = await worker.fetch(post(token, "ses_cc", body), env, c); await drain(c); + expect(r.status).toBe(200); + expect(JSON.stringify(lastBodyToOR).includes("cache_control")).toBe(false); + expect(lastBodyToOR?.usage).toMatchObject({ include: true }); + }); + + it("admin: provision an org+agt cap, then it gates (402); guarded by GATEWAY_ADMIN_SECRET", async () => { + vi.stubGlobal("fetch", mockFetch(0.05)); + const { env } = mkEnv({ GATEWAY_ADMIN_SECRET: "adm" }); + // unauthorized admin call is rejected + const bad = await worker.fetch(new Request("https://gw.test/admin/agent/budget", { method: "POST", headers: { authorization: "Bearer nope", "content-type": "application/json" }, body: JSON.stringify({ org: "org_1", agt: "agt_1", budget_usd: 0.04 }) }), env, ctx()); + expect(bad.status).toBe(401); + // provision a $0.04 cap + const prov = await worker.fetch(new Request("https://gw.test/admin/agent/budget", { method: "POST", headers: { authorization: "Bearer adm", "content-type": "application/json" }, body: JSON.stringify({ org: "org_1", agt: "agt_1", budget_usd: 0.04 }) }), env, ctx()); + expect(prov.status).toBe(200); + const token = await mint(); + const c1 = ctx(); const r1 = await worker.fetch(post(token, "s1"), env, c1); await drain(c1); // 0→0.05 + const c2 = ctx(); const r2 = await worker.fetch(post(token, "s1"), env, c2); await drain(c2); // 0.05≥0.04 → 402 + expect(r1.status).toBe(200); + expect(r2.status).toBe(402); + }); +}); diff --git a/cloudflare-workers/oc-gateway/test/logic.test.ts b/cloudflare-workers/oc-gateway/test/logic.test.ts new file mode 100644 index 00000000..1067d6db --- /dev/null +++ b/cloudflare-workers/oc-gateway/test/logic.test.ts @@ -0,0 +1,181 @@ +// Pure-logic tests (no Workers runtime needed): EdDSA per-deploy token crypto, the SpendCounter DO's +// budget gate + idempotency + provision, the DeployLease DO's epoch fence + bump, cache_control safety, +// and cost extraction. The full on-path flow (forward + meter) is exercised by test/integration.test.ts. +// Run: npx vitest run + +import { describe, it, expect } from "vitest"; +import { generateKeyPair, mintDeployToken, verifyDeployToken, type DeployClaims } from "../src/token.js"; +import { costFromJson, costFromStream } from "../src/cost.js"; +import { unsafeModelMatchers, modelNeedsCacheStrip, stripCacheControl } from "../src/models.js"; +import { SpendCounter } from "../src/budget.js"; +import { DeployLease } from "../src/deploylease.js"; + +const now = 1_800_000_000; +const b64url = (o: unknown) => btoa(JSON.stringify(o)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +const claims = (o: Partial = {}): DeployClaims => ({ + org: "org_1", agt: "agt_1", ep: 2, iat: now, exp: now + 3600, ...o, +}); + +function fakeState(): DurableObjectState { + const m = new Map(); + return { storage: { get: async (k: string) => m.get(k), put: async (k: string, v: unknown) => void m.set(k, v) } } as unknown as DurableObjectState; +} +const call = async (o: { fetch(r: Request): Promise }, path: string, body?: unknown) => + (await o.fetch(new Request(`https://do${path}`, { method: "POST", body: JSON.stringify(body ?? {}) }))).json() as Promise>; + +describe("deploy token (EdDSA, per-deploy {org, agt})", () => { + it("mint → verify round-trips the claims", async () => { + const { privateKey, publicKeyB64url } = await generateKeyPair(); + const v = await verifyDeployToken(publicKeyB64url, await mintDeployToken(privateKey, claims()), now); + expect(v.ok).toBe(true); + if (v.ok) { + expect(v.claims.org).toBe("org_1"); + expect(v.claims.agt).toBe("agt_1"); + expect(v.claims.ep).toBe(2); + // resolved seam: no per-session data in the token + const raw = v.claims as unknown as Record; + expect(raw.sub).toBeUndefined(); + expect(raw.bud).toBeUndefined(); + } + }); + it("rejects a token signed by a different key (gateway holds only the public key)", async () => { + const a = await generateKeyPair(); + const b = await generateKeyPair(); + const v = await verifyDeployToken(b.publicKeyB64url, await mintDeployToken(a.privateKey, claims()), now); + expect(v.ok).toBe(false); + }); + it("rejects a tampered payload", async () => { + const { privateKey, publicKeyB64url } = await generateKeyPair(); + const [h, , s] = (await mintDeployToken(privateKey, claims())).split("."); + const v = await verifyDeployToken(publicKeyB64url, `${h}.${b64url(claims({ org: "org_evil" }))}.${s}`, now); + expect(v.ok).toBe(false); + }); + it("rejects an expired token", async () => { + const { privateKey, publicKeyB64url } = await generateKeyPair(); + const v = await verifyDeployToken(publicKeyB64url, await mintDeployToken(privateKey, claims({ exp: now - 1 })), now); + expect(v.ok).toBe(false); + if (!v.ok) expect(v.reason).toBe("expired"); + }); + it("rejects a token missing org/agt", async () => { + const { privateKey, publicKeyB64url } = await generateKeyPair(); + const t = await mintDeployToken(privateKey, { org: "", agt: "", iat: now, exp: now + 3600 }); + const v = await verifyDeployToken(publicKeyB64url, t, now); + expect(v.ok).toBe(false); + if (!v.ok) expect(v.reason).toBe("missing_claims"); + }); + it("pins alg=EdDSA — rejects an alg-swap (none/HS256) header", async () => { + const { privateKey, publicKeyB64url } = await generateKeyPair(); + const [, p, s] = (await mintDeployToken(privateKey, claims())).split("."); + const v = await verifyDeployToken(publicKeyB64url, `${b64url({ alg: "none", typ: "JWT" })}.${p}.${s}`, now); + expect(v.ok).toBe(false); + if (!v.ok) expect(v.reason).toBe("unexpected_alg"); + }); +}); + +describe("SpendCounter DO — budget gate + idempotency + provision", () => { + it("gates on the budget (spent < budget), applies the default on first check", async () => { + const c = new SpendCounter(fakeState()); + const capMicro = 1_000_000; // $1.00 in µ$ + expect((await call(c, "/check", { default_budget_micro: capMicro })).allowed).toBe(true); + await call(c, "/add", { cost_micro: 600_000, idem: "g1" }); + expect((await call(c, "/check", { default_budget_micro: capMicro })).allowed).toBe(true); // 0.6 < 1.0 + await call(c, "/add", { cost_micro: 600_000, idem: "g2" }); // now 1.2 > 1.0 + expect((await call(c, "/check", { default_budget_micro: capMicro })).allowed).toBe(false); + }); + + it("a provisioned cap wins over the gateway default", async () => { + const c = new SpendCounter(fakeState()); + await call(c, "/provision", { budget_micro: 100_000 }); // $0.10 explicit + await call(c, "/add", { cost_micro: 150_000, idem: "x" }); + // default is huge, but the provisioned $0.10 cap is what gates + expect((await call(c, "/check", { default_budget_micro: 999_000_000 })).allowed).toBe(false); + }); + + it("uncapped (no budget, no default) never gates", async () => { + const c = new SpendCounter(fakeState()); + await call(c, "/add", { cost_micro: 5_000_000, idem: "big" }); + expect((await call(c, "/check", {})).allowed).toBe(true); + }); + + it("dedupes /add by generation id (retried meter never double-counts)", async () => { + const c = new SpendCounter(fakeState()); + await call(c, "/add", { cost_micro: 100_000, idem: "gen-x" }); + const second = await call(c, "/add", { cost_micro: 100_000, idem: "gen-x" }); + expect(second.deduped).toBe(true); + const st = (await (await c.fetch(new Request("https://do/state"))).json()) as { spent_micro: number }; + expect(st.spent_micro).toBe(100_000); + }); +}); + +describe("DeployLease DO — lease-epoch fence + bump (revocation)", () => { + it("fences a stale deploy epoch (monotonic floor)", async () => { + const l = new DeployLease(fakeState()); + expect((await call(l, "/gate", { ep: 1 })).fenced).toBe(false); + expect((await call(l, "/gate", { ep: 2 })).fenced).toBe(false); // adopt the newer epoch → floor 2 + const stale = await call(l, "/gate", { ep: 1 }); // the old deploy's token is now superseded + expect(stale.fenced).toBe(true); + expect(stale.ok).toBe(false); + }); + it("a token with no epoch is never fenced (opt-in fence)", async () => { + const l = new DeployLease(fakeState()); + await call(l, "/gate", { ep: 5 }); // raise the floor + expect((await call(l, "/gate", {})).fenced).toBe(false); // no ep → passes + }); + it("bump revokes without a redeploy (raise the floor above the live token)", async () => { + const l = new DeployLease(fakeState()); + expect((await call(l, "/gate", { ep: 3 })).fenced).toBe(false); // floor 3, current token ep=3 valid + await call(l, "/bump", { min_epoch: 4 }); // revoke everything below 4 + expect((await call(l, "/gate", { ep: 3 })).fenced).toBe(true); // the still-live ep=3 token now fences + }); +}); + +describe("cache_control safety", () => { + const m = unsafeModelMatchers(); + it("flags claude-3-haiku, leaves sonnet alone", () => { + expect(modelNeedsCacheStrip("anthropic/claude-3-haiku", m)).toBe(true); + expect(modelNeedsCacheStrip("anthropic/claude-sonnet-4", m)).toBe(false); + }); + it("env extends the denylist", () => { + expect(modelNeedsCacheStrip("vendor/some-bedrock-model", unsafeModelMatchers("some-bedrock-model"))).toBe(true); + }); + it("strips every nested cache_control in place", () => { + const body = { + model: "anthropic/claude-3-haiku", + system: [{ type: "text", text: "sys", cache_control: { type: "ephemeral" } }], + messages: [{ role: "user", content: [{ type: "text", text: "hi", cache_control: { type: "ephemeral" } }] }], + }; + expect(stripCacheControl(body)).toBe(2); + expect(JSON.stringify(body).includes("cache_control")).toBe(false); + }); +}); + +describe("cost extraction", () => { + it("reads usage.cost from a JSON response", () => { + const c = costFromJson(JSON.stringify({ id: "gen-123", usage: { cost: 0.0042, prompt_tokens: 10 } })); + expect(c.costUsd).toBe(0.0042); + expect(c.generationId).toBe("gen-123"); + expect(c.source).toBe("usage.cost"); + }); + it("falls back to usage.total_cost", () => { + const c = costFromJson(JSON.stringify({ id: "g", usage: { total_cost: 0.01 } })); + expect(c.costUsd).toBe(0.01); + expect(c.source).toBe("usage.total_cost"); + }); + it("returns null cost when the echo lacks it (flagged, never guessed)", () => { + const c = costFromJson(JSON.stringify({ id: "g", usage: { prompt_tokens: 10 } })); + expect(c.costUsd).toBeNull(); + expect(c.source).toBe("none"); + }); + it("extracts cost + generation id from an SSE stream", async () => { + const sse = [ + 'data: {"id":"gen-9","type":"message_start"}', + 'data: {"type":"content_block_delta","delta":{"text":"hi"}}', + 'data: {"type":"message_delta","usage":{"cost":0.0009,"output_tokens":3}}', + "data: [DONE]", + "", + ].join("\n"); + const c = await costFromStream(new Response(sse).body!); + expect(c.costUsd).toBe(0.0009); + expect(c.generationId).toBe("gen-9"); + }); +}); diff --git a/cloudflare-workers/oc-gateway/test/mock-openrouter.mjs b/cloudflare-workers/oc-gateway/test/mock-openrouter.mjs new file mode 100644 index 00000000..54c3eb2c --- /dev/null +++ b/cloudflare-workers/oc-gateway/test/mock-openrouter.mjs @@ -0,0 +1,31 @@ +// Minimal mock OpenRouter for the local integration proof. Records the Authorization header it +// received (to prove the gateway injected the ORG key, not the session token) and echoes an +// Anthropic-Messages-shaped response carrying usage.cost (what the on-path meter reads). +import { createServer } from "node:http"; + +let lastAuth = null; +let lastBody = null; +const PORT = Number(process.env.MOCK_PORT || 8799); + +createServer((req, res) => { + if (req.url === "/__spy") { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ lastAuth, lastBody })); + return; + } + let body = ""; + req.on("data", (c) => (body += c)); + req.on("end", () => { + lastAuth = req.headers["authorization"] || null; + try { lastBody = JSON.parse(body); } catch { lastBody = body; } + // Echo an anthropic-style completion with an OpenRouter cost echo. + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ + id: "gen-" + Math.floor(Date.now() / 1000), + type: "message", + role: "assistant", + content: [{ type: "text", text: "pong from mock" }], + usage: { input_tokens: 12, output_tokens: 4, cost: 0.02 }, // $0.02 per call + })); + }); +}).listen(PORT, () => console.log(`mock-openrouter on :${PORT}`)); diff --git a/cloudflare-workers/oc-gateway/tsconfig.json b/cloudflare-workers/oc-gateway/tsconfig.json new file mode 100644 index 00000000..2f022d94 --- /dev/null +++ b/cloudflare-workers/oc-gateway/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "Bundler", + "lib": ["ES2022"], + "types": ["@cloudflare/workers-types"], + "strict": true, + "noImplicitAny": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "noEmit": true + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/cloudflare-workers/oc-gateway/wrangler.toml b/cloudflare-workers/oc-gateway/wrangler.toml new file mode 100644 index 00000000..8b103ca1 --- /dev/null +++ b/cloudflare-workers/oc-gateway/wrangler.toml @@ -0,0 +1,33 @@ +name = "oc-gateway" +main = "src/index.ts" +compatibility_date = "2025-06-01" +compatibility_flags = ["nodejs_compat"] + +# Spend counter + hard gate (design 013 §4/§8). Used at org+agt grain (hard 402) and per-session +# grain (tracked-only). Strongly consistent, so concurrent calls can't double-spend past the cap. +[[durable_objects.bindings]] +name = "SPEND_COUNTER" +class_name = "SpendCounter" + +# Per-(org, agt) lease-epoch floor — fences a rotated/revoked deploy token (design 013 §4). +[[durable_objects.bindings]] +name = "DEPLOY_LEASE" +class_name = "DeployLease" + +[[migrations]] +tag = "v1" +new_sqlite_classes = ["SpendCounter", "DeployLease"] + +# Secrets (set with `wrangler secret put …`, never in this file): +# GATEWAY_TOKEN_PUBLIC_KEY — base64url raw 32-byte Ed25519 PUBLIC key (minter holds the private key) +# GATEWAY_ORKEY_SECRET — bearer for the dedicated sessions-api org-OR-key seam (carries a live key) +# GATEWAY_ADMIN_SECRET — bearer that guards the control-plane admin routes (/admin/*) +# TEST_OR_KEY — acceptance-test single OR key override (bypasses the seam); never in prod +# OC_INGEST_AUTH — optional, per-session spend telemetry auth + +[vars] +# GATEWAY_ORKEY_URL — the internal sessions-api route that returns an org's OR key {org}→{key}. +# AGENT_BUDGET_USD_DEFAULT — default HARD budget (USD) per org+agt for an unprovisioned grain; unset = uncapped. +# OPENROUTER_BASE — leave unset for prod (https://openrouter.ai/api); override for a mock in tests. +# CACHE_CONTROL_UNSAFE_MODELS — extra comma-separated model patterns to strip cache_control for. +# OC_INGEST_URL — optional per-session spend sink.