From b2f2f6fb1fd02afdd5ffe5b4f9c21f713b38f7ea Mon Sep 17 00:00:00 2001 From: Igor Zalutski Date: Sun, 5 Jul 2026 20:54:27 +0100 Subject: [PATCH 01/25] flue-native: open evergreen integration branch Long-lived integration branch for the Flue-native agent type in opencomputer (L2 gateway, L4 CLI, L5 @opencomputer/flue package, docs). Spec: oc-bg-agents .agents/design/013 + 014; plan: work/flue-native-buildout.md. Lane PRs target this branch (base=flue-native); Igor merges the evergreen -> main as a unit. Co-Authored-By: Claude Opus 4.8 From 821c68cc2326bc6ca6a0dbbe66ee2dc00d24c28f Mon Sep 17 00:00:00 2001 From: Igor Zalutski Date: Sun, 5 Jul 2026 23:40:53 +0100 Subject: [PATCH 02/25] @opencomputer/flue: CF-native scaffolding (W4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the serveOC/brain-box package with the Flue CF-native shape (design 013 §4/§5). A stock `flue build --target cloudflare` app opts into OC via: - useOcGateway(ctx) + DEFAULT_MODEL (claude-haiku-4.5, prompt-caching-safe): point managed anthropic at env.OC_GATEWAY. Called INSIDE the defineAgent initializer (top-level is tree-shaken — 1a). - route: the HTTP-transport opt-in every OC agent must export. - ocSandbox(env): a durable OC-fleet SandboxApi (files/exec over the OC sandbox HTTP API; stat/mkdir/rm via shell, mirroring cloudflareSandbox). - ocRepoTools(env): publish_pull_request (repo plane, used by W10). - ./app: default hosting app (flue() + /health + observe→OC_INGEST). ./wire: telemetry-only side-effect for apps with their own app.ts. Builds + typechecks clean against @flue/runtime@1.0.0-beta.9. Verified end-to-end: the re-scaffolded oc-flue-starter `flue build --target cloudflare`s with zero hand-editing and the wiring (useOcGateway/registerProvider, ocSandbox, /health, haiku model) is present in the bundle (not tree-shaken). TOKEN SEAM — OPEN, needs orchestrator decision (see gateway.ts): Flue's registerProvider takes only a static apiKey and its getApiKey(providerId) callback gets no request context, and the provider registry is isolate-global (shared across co-located DO instances) — so a per-SESSION token/header/baseUrl via registerProvider RACES. Wired the buildable env shape; robust per-session attribution needs the upstream `headers(ctx)` ask. Co-Authored-By: Claude Opus 4.8 (1M context) --- sdks/flue/.gitignore | 3 + sdks/flue/README.md | 47 +++++++++++++++ sdks/flue/package.json | 48 +++++++++++++++ sdks/flue/src/app.ts | 18 ++++++ sdks/flue/src/gateway.ts | 51 ++++++++++++++++ sdks/flue/src/index.ts | 15 +++++ sdks/flue/src/observe.ts | 27 +++++++++ sdks/flue/src/sandbox.ts | 124 +++++++++++++++++++++++++++++++++++++++ sdks/flue/src/tools.ts | 39 ++++++++++++ sdks/flue/src/wire.ts | 8 +++ sdks/flue/tsconfig.json | 18 ++++++ 11 files changed, 398 insertions(+) create mode 100644 sdks/flue/.gitignore create mode 100644 sdks/flue/README.md create mode 100644 sdks/flue/package.json create mode 100644 sdks/flue/src/app.ts create mode 100644 sdks/flue/src/gateway.ts create mode 100644 sdks/flue/src/index.ts create mode 100644 sdks/flue/src/observe.ts create mode 100644 sdks/flue/src/sandbox.ts create mode 100644 sdks/flue/src/tools.ts create mode 100644 sdks/flue/src/wire.ts create mode 100644 sdks/flue/tsconfig.json diff --git a/sdks/flue/.gitignore b/sdks/flue/.gitignore new file mode 100644 index 00000000..f4e2c6d6 --- /dev/null +++ b/sdks/flue/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.tsbuildinfo diff --git a/sdks/flue/README.md b/sdks/flue/README.md new file mode 100644 index 00000000..b46e580c --- /dev/null +++ b/sdks/flue/README.md @@ -0,0 +1,47 @@ +# @opencomputer/flue + +Make a stock [Flue](https://flue.dev) agent OpenComputer-native. A Flue app built with +`flue build --target cloudflare` runs unchanged as an OpenComputer durable session (a Workers-for-Platforms +tenant script); this package supplies the OC-specific wiring the app opts into. + +## What it gives you + +- **`useOcGateway(ctx)` + `DEFAULT_MODEL`** — point the managed `anthropic` provider at the OC model + gateway (org key injected + per-session metering). Call it **inside** your `defineAgent` initializer. +- **`route`** — the HTTP-transport opt-in every OC-hosted agent must export (`export { route }`). +- **`ocSandbox(env)`** — a durable OpenComputer-fleet sandbox as the agent's `SandboxApi` (workspace + survives across turns; also serves the repo plane). +- **`ocRepoTools(env)`** — `publish_pull_request` and friends (open PRs as the OpenComputer GitHub App). +- **`@opencomputer/flue/app`** — a default hosting app (`flue()` routes + `/health` + telemetry). Or + `import '@opencomputer/flue/wire'` from your own `app.ts` for telemetry only. + +## Minimal agent + +```ts +import { defineAgent, defineAgentProfile } from '@flue/runtime'; +import { useOcGateway, route, ocSandbox, DEFAULT_MODEL, type OcSandboxEnv } from '@opencomputer/flue'; + +export { route }; + +export default defineAgent((ctx) => { + useOcGateway(ctx); + return { + profile: defineAgentProfile({ instructions: 'You help customers.' }), + model: DEFAULT_MODEL, // prompt-caching-safe + sandbox: ocSandbox(ctx.env), + }; +}); +``` + +`src/app.ts`: + +```ts +export { default } from '@opencomputer/flue/app'; +``` + +Then `flue build --target cloudflare` and `oc agent deploy`. See `oc-flue-starter` for a full example. + +## Environment (set on the tenant script by the OC deploy) + +`OC_GATEWAY`, `OC_SESSION_TOKEN`, `OC_INGEST`, `OC_SANDBOX_API` (+ `OC_SANDBOX_ID` or a resolve seam), +`OC_REPO_API`. Reserved `OC_`/`FLUE_` prefixes are OC-managed. diff --git a/sdks/flue/package.json b/sdks/flue/package.json new file mode 100644 index 00000000..f80c30d7 --- /dev/null +++ b/sdks/flue/package.json @@ -0,0 +1,48 @@ +{ + "name": "@opencomputer/flue", + "version": "0.2.0", + "description": "Make a stock Flue agent OpenComputer-native — OC model gateway, OC-fleet sandbox, publish/repo tools, and the default hosting app.", + "license": "MIT", + "type": "module", + "engines": { + "node": ">=22.19" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./app": { + "types": "./dist/app.d.ts", + "default": "./dist/app.js" + }, + "./wire": { + "types": "./dist/wire.d.ts", + "default": "./dist/wire.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run" + }, + "sideEffects": [ + "./dist/wire.js" + ], + "peerDependencies": { + "@flue/runtime": "1.0.0-beta.9" + }, + "dependencies": { + "hono": "^4.6.0", + "valibot": "^1.1.0" + }, + "devDependencies": { + "@flue/runtime": "1.0.0-beta.9", + "@types/node": "^22.10.0", + "typescript": "^5.9.3", + "vitest": "^3.0.0" + } +} diff --git a/sdks/flue/src/app.ts b/sdks/flue/src/app.ts new file mode 100644 index 00000000..342402b8 --- /dev/null +++ b/sdks/flue/src/app.ts @@ -0,0 +1,18 @@ +// The default OC hosting app. A scaffolded starter uses this as its `src/app.ts` (or the CF build +// generates an equivalent when no app.ts exists). It mounts Flue's routes, adds the `/health` probe the +// OC deploy/activate step needs (stock Flue exposes NO health route — Spike B finding), and installs the +// telemetry forwarder. Apps that own their `app.ts` instead `import '@opencomputer/flue/wire'` (§wire). +// +// Default export = a `Fetchable` (Hono qualifies), per Flue's routing contract. + +import { Hono } from "hono"; +import { flue } from "@flue/runtime/routing"; +import { installOcObserver } from "./observe.js"; + +installOcObserver(); + +const app = new Hono(); +app.get("/health", (c) => c.json({ status: "ok" })); +app.route("/", flue()); + +export default app; diff --git a/sdks/flue/src/gateway.ts b/sdks/flue/src/gateway.ts new file mode 100644 index 00000000..486ae93a --- /dev/null +++ b/sdks/flue/src/gateway.ts @@ -0,0 +1,51 @@ +// OC model-gateway wiring (design 013 §4). A stock Flue app points its managed `anthropic` provider at +// the OC gateway (a thin Worker over OpenRouter that injects the org key + meters per session). +// +// TOKEN SEAM — OPEN, confirm with the orchestrator. The buildout's seam (a) ("per-turn token → Flue's +// per-call getApiKey(providerId) callback") is NOT achievable: `registerProvider` accepts only a STATIC +// `apiKey`, and Flue's internal `getApiKey(providerId)` gets no request/turn context (providers.ts:199). +// Worse, the provider registry is MODULE(isolate)-scoped and shared across co-located DO instances, so a +// per-SESSION `apiKey`/`headers`/`baseUrl` set via registerProvider RACES across sessions in one isolate. +// → Robust per-session attribution needs the upstream ask (per-request `headers(ctx)` on registerProvider, +// buildout "Upstream asks"). Interim, buildable shape below: a static env token (works one-session-per- +// isolate) with metering by `token.sub` at the gateway. See the W4 hand-off note. + +import { registerProvider } from "@flue/runtime"; +import type { AgentInitializerContext, AgentRouteHandler } from "@flue/runtime"; + +/** Default managed model — MUST be prompt-caching-safe (Constraint): `claude-3-haiku` fails via + * OpenRouter→Bedrock; `claude-haiku-4.5` works. Cheap + caching-safe for the scaffolded starter. */ +export const DEFAULT_MODEL = "anthropic/claude-haiku-4.5"; + +export interface OcEnv { + /** Deployed gateway Worker base URL (set per tenant script by the OC deploy). */ + OC_GATEWAY?: string; + /** Signed session/deploy JWT the gateway verifies (`{sub, org, agt, bud}`); never a raw provider key. */ + OC_SESSION_TOKEN?: string; + /** Telemetry sink for `observe()` (operator panel + spend attribution). */ + OC_INGEST?: string; + [key: string]: unknown; +} + +/** + * Point the managed `anthropic` provider at the OC gateway. **Call this INSIDE the `defineAgent` + * initializer** — top-level module code is stripped by the CF build (proven in 1a), and the initializer + * body runs per harness init with `env` available. No-op when `OC_GATEWAY` is unset (local `flue dev` + * falls through to pi-ai's env-var key lookup). `anthropic` is a catalog id, so `baseUrl` alone rehydrates + * the wire protocol. + */ +export function useOcGateway(ctx: AgentInitializerContext): void { + const gw = ctx.env.OC_GATEWAY; + if (!gw) return; + registerProvider("anthropic", { + baseUrl: `${gw.replace(/\/+$/, "")}/anthropic`, + ...(ctx.env.OC_SESSION_TOKEN ? { apiKey: ctx.env.OC_SESSION_TOKEN } : {}), + }); +} + +/** + * The HTTP-transport opt-in every OC-hosted agent MUST export as `route` (an agent is reachable at + * `/agents/:name/:id` only when its module exports `route` — flue-app.ts). Pass-through: the OC dispatch + * Worker is the auth boundary (013 §3 B5), so the app adds none. + */ +export const route: AgentRouteHandler = async (_c, next) => next(); diff --git a/sdks/flue/src/index.ts b/sdks/flue/src/index.ts new file mode 100644 index 00000000..12168f24 --- /dev/null +++ b/sdks/flue/src/index.ts @@ -0,0 +1,15 @@ +// @opencomputer/flue — make a stock Flue agent OpenComputer-native (design 013 §4/§5). +// - useOcGateway + route + DEFAULT_MODEL: point managed anthropic at the OC gateway; HTTP-transport opt-in. +// - ocSandbox: a durable OC-fleet sandbox as the agent's SandboxApi. +// - ocRepoTools: publish/repo tools (repo plane). +// - installOcObserver: forward lifecycle/usage to OC_INGEST. +// Default hosting app is at `@opencomputer/flue/app`; `@opencomputer/flue/wire` is the telemetry-only +// side-effect for apps with their own app.ts. + +export { useOcGateway, route, DEFAULT_MODEL } from "./gateway.js"; +export type { OcEnv } from "./gateway.js"; +export { ocSandbox, WORKSPACE_CWD } from "./sandbox.js"; +export type { OcSandboxEnv } from "./sandbox.js"; +export { installOcObserver } from "./observe.js"; +export { ocRepoTools } from "./tools.js"; +export type { OcRepoEnv } from "./tools.js"; diff --git a/sdks/flue/src/observe.ts b/sdks/flue/src/observe.ts new file mode 100644 index 00000000..ecc31fa3 --- /dev/null +++ b/sdks/flue/src/observe.ts @@ -0,0 +1,27 @@ +// Telemetry: forward Flue lifecycle/usage observations to OC_INGEST for the operator panel + spend +// attribution (design 013 §4; buildout Integration seams). The DO transcript stays authoritative — the +// TAILER is the event-truth path; `observe()` is a best-effort side channel, so this is fire-and-forget +// and never blocks or breaks a run. `observe` subscribers receive `ctx.env`, so this reads OC_INGEST per +// event and can be installed once at module load (isolate-scoped, matching observe's own scope). + +import { observe } from "@flue/runtime"; + +interface CtxEnv { OC_INGEST?: string; OC_SESSION_TOKEN?: string } + +/** Install the OC observation forwarder. Returns the unsubscribe fn. No-op per event when OC_INGEST unset. */ +export function installOcObserver(): () => void { + return observe((obs, ctx) => { + try { + const env = (ctx as { env?: CtxEnv }).env; + if (!env?.OC_INGEST) return; + const session = (obs as { session?: string }).session ?? (ctx as { id?: string }).id; + void fetch(env.OC_INGEST, { + method: "POST", + headers: { "content-type": "application/json", ...(env.OC_SESSION_TOKEN ? { authorization: `Bearer ${env.OC_SESSION_TOKEN}` } : {}) }, + body: JSON.stringify({ session, agent: (ctx as { agentName?: string }).agentName, event: obs }), + }).catch(() => {}); + } catch { + /* telemetry must never break the run */ + } + }); +} diff --git a/sdks/flue/src/sandbox.ts b/sdks/flue/src/sandbox.ts new file mode 100644 index 00000000..99e4b0c2 --- /dev/null +++ b/sdks/flue/src/sandbox.ts @@ -0,0 +1,124 @@ +// ocSandbox — a Flue `SandboxApi`/`SandboxFactory` (design 013 §5) driving an OpenComputer fleet sandbox +// over its public HTTP API, so the workspace is DURABLE across turns (git checkout + build cache survive) +// and also serves the repo plane (§5.2). Public-seam impl, no trick. +// +// The session's sandbox is provisioned by the control plane at Flue-session create (§5.2); this client +// resolves it per instance and proxies exec/fs. Endpoints (from @opencomputer/sdk, all fetch-based so +// they run in a CF DO): +// exec POST {base}/sandboxes/{id}/exec/run {args:["-c",cmd],cwd,envs,timeout} -> {exitCode,stdout,stderr} +// read GET {base}/sandboxes/{id}/files?path= +// write PUT {base}/sandboxes/{id}/files?path= (body = content) +// list GET {base}/sandboxes/{id}/files/list?path= -> [{name,...}] +// stat/exists/mkdir/rm compose over exec (shell), mirroring cloudflareSandbox. + +import { createSandboxSessionEnv } from "@flue/runtime"; +import type { SandboxApi, SandboxFactory, FileStat, ShellResult, SessionEnv } from "@flue/runtime"; +import type { OcEnv } from "./gateway.js"; + +/** Constant workspace cwd (matches the OC session contract — flue resolves skills at `${cwd}/.agents/skills`). */ +export const WORKSPACE_CWD = "/workspace"; + +export interface OcSandboxEnv extends OcEnv { + /** OC sandbox API base, e.g. `https://app.opencomputer.dev/api`. */ + OC_SANDBOX_API?: string; + /** Pre-resolved sandbox id, when the control plane injects it; else resolved lazily (see below). */ + OC_SANDBOX_ID?: string; +} + +class OcSandboxApi implements SandboxApi { + constructor(private readonly base: string, private readonly token: string, private sandboxId: string) {} + + private headers(extra?: Record): Record { + return { authorization: `Bearer ${this.token}`, ...extra }; + } + private url(suffix: string): string { + return `${this.base.replace(/\/+$/, "")}/sandboxes/${this.sandboxId}${suffix}`; + } + + async exec(command: string, options?: { cwd?: string; env?: Record; timeoutMs?: number; signal?: AbortSignal }): Promise { + const body: Record = { args: ["-c", command], timeout: Math.ceil((options?.timeoutMs ?? 60_000) / 1000) }; + if (options?.cwd) body.cwd = options.cwd; + if (options?.env) body.envs = options.env; + const resp = await fetch(this.url("/exec/run"), { method: "POST", headers: this.headers({ "content-type": "application/json" }), body: JSON.stringify(body), signal: options?.signal }); + if (!resp.ok) throw new Error(`oc sandbox exec failed: ${resp.status} ${(await resp.text()).slice(0, 200)}`); + const r = (await resp.json()) as { exitCode?: number; stdout?: string; stderr?: string }; + return { stdout: r.stdout ?? "", stderr: r.stderr ?? "", exitCode: r.exitCode ?? 0 }; + } + + async readFile(path: string): Promise { + const resp = await fetch(this.url(`/files?path=${encodeURIComponent(path)}`), { headers: this.headers() }); + if (!resp.ok) throw new Error(`oc sandbox read ${path}: ${resp.status}`); + return resp.text(); + } + async readFileBuffer(path: string): Promise { + const resp = await fetch(this.url(`/files?path=${encodeURIComponent(path)}`), { headers: this.headers() }); + if (!resp.ok) throw new Error(`oc sandbox read ${path}: ${resp.status}`); + return new Uint8Array(await resp.arrayBuffer()); + } + async writeFile(path: string, content: string | Uint8Array): Promise { + const resp = await fetch(this.url(`/files?path=${encodeURIComponent(path)}`), { method: "PUT", headers: this.headers({ "content-type": "application/octet-stream" }), body: content }); + if (!resp.ok) throw new Error(`oc sandbox write ${path}: ${resp.status}`); + } + async readdir(path: string): Promise { + const resp = await fetch(this.url(`/files/list?path=${encodeURIComponent(path)}`), { headers: this.headers() }); + if (!resp.ok) throw new Error(`oc sandbox list ${path}: ${resp.status}`); + const entries = (await resp.json()) as Array<{ name?: string; path?: string }>; + return entries.map((e) => e.name ?? (e.path ?? "").split("/").pop() ?? "").filter(Boolean); + } + + // stat/exists/mkdir/rm over the shell (mirrors cloudflareSandbox — the files API has no stat/mkdir/rm). + async stat(path: string): Promise { + const r = await this.exec(`stat -L -c '%s/%F' ${shq(path)}`); + if (r.exitCode !== 0) throw new Error(`oc sandbox stat ${path}: ${r.stderr.slice(0, 120)}`); + const [sizeStr, kind = ""] = r.stdout.trim().split("/"); + return { isFile: /regular file/.test(kind), isDirectory: /directory/.test(kind), size: Number(sizeStr) || undefined }; + } + async exists(path: string): Promise { + return (await this.exec(`test -e ${shq(path)}`)).exitCode === 0; + } + async mkdir(path: string, options?: { recursive?: boolean }): Promise { + const r = await this.exec(`mkdir ${options?.recursive ? "-p " : ""}${shq(path)}`); + if (r.exitCode !== 0) throw new Error(`oc sandbox mkdir ${path}: ${r.stderr.slice(0, 120)}`); + } + async rm(path: string, options?: { recursive?: boolean; force?: boolean }): Promise { + const flags = `${options?.recursive ? "r" : ""}${options?.force ? "f" : ""}`; + const r = await this.exec(`rm ${flags ? `-${flags} ` : ""}${shq(path)}`); + if (r.exitCode !== 0 && !options?.force) throw new Error(`oc sandbox rm ${path}: ${r.stderr.slice(0, 120)}`); + } +} + +function shq(s: string): string { + return `'${s.replace(/'/g, "'\\''")}'`; +} + +/** Resolve the session's OC sandbox id (control-plane seam). Uses the injected id when present, else a + * documented resolve endpoint keyed by the session id. Kept a single point so W1/W5 can pin the contract. */ +async function resolveSandboxId(env: OcSandboxEnv, sessionId: string): Promise { + if (env.OC_SANDBOX_ID) return env.OC_SANDBOX_ID; + const base = (env.OC_SANDBOX_API ?? "").replace(/\/+$/, ""); + const resp = await fetch(`${base}/flue/session-sandbox?session=${encodeURIComponent(sessionId)}`, { + method: "POST", + headers: { authorization: `Bearer ${env.OC_SESSION_TOKEN ?? ""}` }, + }); + if (!resp.ok) throw new Error(`oc sandbox resolve failed for ${sessionId}: ${resp.status}`); + return ((await resp.json()) as { sandbox_id: string }).sandbox_id; +} + +/** + * The OC-fleet sandbox factory. Set `sandbox: ocSandbox(env)` in your `defineAgent` initializer; the OC + * template scaffolds exactly this. Lazily resolves the session's sandbox (keyed by the DO instance id = + * `ses_`) on first tool use, so no sandbox is provisioned for tool-free turns (§5). + */ +export function ocSandbox(env: OcSandboxEnv, opts?: { cwd?: string }): SandboxFactory { + const cwd = opts?.cwd ?? WORKSPACE_CWD; + return { + async createSessionEnv({ id }: { id: string }): Promise { + if (!env.OC_SANDBOX_API && !env.OC_SANDBOX_ID) { + throw new Error("[oc-flue] ocSandbox: set OC_SANDBOX_API (+ OC_SESSION_TOKEN) or OC_SANDBOX_ID — the OC sandbox binding is not configured."); + } + const sandboxId = await resolveSandboxId(env, id); + const api = new OcSandboxApi((env.OC_SANDBOX_API ?? "").replace(/\/+$/, ""), env.OC_SESSION_TOKEN ?? "", sandboxId); + return createSandboxSessionEnv(api, cwd); + }, + }; +} diff --git a/sdks/flue/src/tools.ts b/sdks/flue/src/tools.ts new file mode 100644 index 00000000..695abf97 --- /dev/null +++ b/sdks/flue/src/tools.ts @@ -0,0 +1,39 @@ +// OC repo-plane tools (design 013 §5.2) — `defineTool`s an agent adds to reach the platform's +// checkout/publish capabilities from inside a Flue turn. They POST to a Flue-session-authed OC endpoint +// that runs the EXISTING `runPublishAction` → isolated repo-op → GitHub-App-mint path (identity stays the +// App; the DO never sees a git token). Consumed by W10; the endpoint contract is pinned there. + +import { defineTool } from "@flue/runtime"; +import * as v from "valibot"; +import type { OcEnv } from "./gateway.js"; + +export interface OcRepoEnv extends OcEnv { + /** Base URL of the Flue-session-authed OC repo/publish endpoints (control plane). */ + OC_REPO_API?: string; +} + +/** Build the OC repo tools bound to `env`. Add to a coding agent's `tools` in its initializer. */ +export function ocRepoTools(env: OcRepoEnv) { + const base = (env.OC_REPO_API ?? "").replace(/\/+$/, ""); + const headers = () => ({ authorization: `Bearer ${env.OC_SESSION_TOKEN ?? ""}`, "content-type": "application/json" }); + + return [ + defineTool({ + name: "publish_pull_request", + description: + "Open or update a GitHub pull request from the changes in the agent's workspace. Identity stays the OpenComputer GitHub App; requires an attached source repo on the session.", + input: v.object({ + title: v.string(), + body: v.string(), + branch: v.optional(v.string()), + }), + async run(ctx) { + if (!base) throw new Error("[oc-flue] publish_pull_request: OC_REPO_API is not configured."); + const resp = await fetch(`${base}/publish`, { method: "POST", headers: headers(), body: JSON.stringify(ctx.input) }); + const text = await resp.text(); + if (!resp.ok) throw new Error(`publish_pull_request failed: ${resp.status} ${text.slice(0, 200)}`); + return text; + }, + }), + ]; +} diff --git a/sdks/flue/src/wire.ts b/sdks/flue/src/wire.ts new file mode 100644 index 00000000..c4909c60 --- /dev/null +++ b/sdks/flue/src/wire.ts @@ -0,0 +1,8 @@ +// Side-effect module for apps that own their `app.ts`: `import '@opencomputer/flue/wire'` to forward +// Flue observations to OC_INGEST without adopting the default app. (Add `/health` to your own Hono app +// too — the OC deploy/activate probe expects it.) Marked in package.json `sideEffects` so it survives +// tree-shaking. + +import { installOcObserver } from "./observe.js"; + +installOcObserver(); diff --git a/sdks/flue/tsconfig.json b/sdks/flue/tsconfig.json new file mode 100644 index 00000000..dd873a81 --- /dev/null +++ b/sdks/flue/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2023"], + "types": ["node"], + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "strict": true, + "noUncheckedIndexedAccess": true, + "esModuleInterop": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true + }, + "include": ["src"] +} From 0b5f580f68cbaa2f90e6207be05f0f2fb3f14b94 Mon Sep 17 00:00:00 2001 From: Igor Zalutski Date: Sun, 5 Jul 2026 23:45:23 +0100 Subject: [PATCH 03/25] feat(oc-gateway): productionize the Flue per-session gateway (W3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Productionizes the 1a spike (spike/oc-gateway, #486) into the flue-native gateway per buildout contract #1 / design 013 §4. Thin OC Worker over OpenRouter: verify a per-session token → epoch-fence + on-path budget gate → inject the org's OR key → cache-safe + usage-accounted forward → sub-meter. Org spend stays on the org's single OR key → the existing OpenRouter→Autumn cron (nothing pushed to Autumn); the gateway only adds per-session sub-metering + enforcement. What changed vs the spike: - Token: HS256 → EdDSA (Ed25519). The minter holds the private key; the gateway holds only GATEWAY_TOKEN_PUBLIC_KEY (base64url raw). Alg pinned to EdDSA (rejects none/HS256 swaps). - Lease-epoch fence (close the freeze-flags): the SessionBudget DO tracks a monotonic max_epoch; a token with a stale `ep` → 401 token_superseded; a newer epoch supersedes older in-flight tokens. DO-serialized. - Org OR key from the credential store, not the KV/SPIKE stand-in: a dedicated internal sessions-api seam (mirrors the edge's dedicated-secret key hand-off), cached per-org in-isolate (60s). TEST_OR_KEY override for the acceptance run. The L3 route (resolveManagedSecret) is flagged for sessions-api. - Prompt-caching safety: strip cache_control for caching-unsafe models (claude-3-haiku → Bedrock 400s) via an env-extensible denylist. - Kept: usage:{include:true}, 402 budget_exceeded, metering by token.sub, the µ$ counter + /add idempotency on the OR generation id. Tests: 23 green (vitest) — EdDSA/alg-pin/tamper/expired, SessionBudget fence + budget + idempotency, cache_control strip, cost extraction, and the full on-path flow through the real handler. Live acceptance (2026-07-05, real OpenRouter via wrangler dev --local, throwaway $1-capped OR key minted from the provisioning key and deleted after): a real claude-haiku-4.5 turn completed gateway → OpenRouter (200, no key in the response, token != key); per-session budget refused on-path (402); cache_control stripped so claude-3-haiku succeeds via the gateway where a direct OR call 400s; epoch fence returns 401 token_superseded. Seam to confirm with the orchestrator (W1): EdDSA claim set + the mint side (default: per-turn token via getApiKey). L3 seam to build: the internal org-OR-key route. Not merged — Igor merges opencomputer. Co-Authored-By: Claude Opus 4.8 (1M context) --- cloudflare-workers/oc-gateway/.gitignore | 4 + cloudflare-workers/oc-gateway/README.md | 103 + .../oc-gateway/package-lock.json | 2913 +++++++++++++++++ cloudflare-workers/oc-gateway/package.json | 19 + cloudflare-workers/oc-gateway/scripts/mint.ts | 46 + cloudflare-workers/oc-gateway/src/budget.ts | 87 + cloudflare-workers/oc-gateway/src/cost.ts | 66 + cloudflare-workers/oc-gateway/src/index.ts | 183 ++ cloudflare-workers/oc-gateway/src/models.ts | 51 + cloudflare-workers/oc-gateway/src/orgkey.ts | 60 + cloudflare-workers/oc-gateway/src/token.ts | 116 + .../oc-gateway/test/integration.test.ts | 179 + .../oc-gateway/test/logic.test.ts | 143 + .../oc-gateway/test/mock-openrouter.mjs | 31 + cloudflare-workers/oc-gateway/tsconfig.json | 17 + cloudflare-workers/oc-gateway/wrangler.toml | 25 + 16 files changed, 4043 insertions(+) create mode 100644 cloudflare-workers/oc-gateway/.gitignore create mode 100644 cloudflare-workers/oc-gateway/README.md create mode 100644 cloudflare-workers/oc-gateway/package-lock.json create mode 100644 cloudflare-workers/oc-gateway/package.json create mode 100644 cloudflare-workers/oc-gateway/scripts/mint.ts create mode 100644 cloudflare-workers/oc-gateway/src/budget.ts create mode 100644 cloudflare-workers/oc-gateway/src/cost.ts create mode 100644 cloudflare-workers/oc-gateway/src/index.ts create mode 100644 cloudflare-workers/oc-gateway/src/models.ts create mode 100644 cloudflare-workers/oc-gateway/src/orgkey.ts create mode 100644 cloudflare-workers/oc-gateway/src/token.ts create mode 100644 cloudflare-workers/oc-gateway/test/integration.test.ts create mode 100644 cloudflare-workers/oc-gateway/test/logic.test.ts create mode 100644 cloudflare-workers/oc-gateway/test/mock-openrouter.mjs create mode 100644 cloudflare-workers/oc-gateway/tsconfig.json create mode 100644 cloudflare-workers/oc-gateway/wrangler.toml 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..5c0f30b7 --- /dev/null +++ b/cloudflare-workers/oc-gateway/README.md @@ -0,0 +1,103 @@ +# oc-gateway — thin per-session Worker over OpenRouter + +**W3 (productionized) / Lane L2** for the Flue-native agent type (`oc-bg-agents .agents/work/flue-native-buildout.md`, design `013 §4`). Implements **inter-lane Contract #1** (Gateway HTTP contract), hardened from the 1a spike (`spike/oc-gateway`, #486) and **live-verified** against real OpenRouter. + +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 **per-session sub-metering + on-path budget enforcement**. + +--- + +## Contract #1 — the Gateway HTTP contract + +### 1. Path shape + +```ts +registerProvider('anthropic', { baseUrl: `${env.OC_GATEWAY}/anthropic`, apiKey: }); +``` + +| 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. Per-session token — EdDSA, with a lease-epoch fence + +A compact **EdDSA (Ed25519) JWT** the tenant Worker holds as the provider `apiKey`. The **minter** (control plane / session DO) holds the private key; the **gateway holds only the public key** (`GATEWAY_TOKEN_PUBLIC_KEY` = base64url raw 32-byte Ed25519 public key) — the same asymmetry as the turn token, so a compromised gateway can't forge tokens. + +**Claims:** `{ sub: ses_, org, agt, bud?, ep?, iat, exp }` — `sub` = sub-meter/budget key; `org` selects the org's OR key; `bud` = per-session USD cap (omit/0 = uncapped); `ep` = lease/turn epoch. + +**Transport:** `Authorization: Bearer ` or `x-api-key: `. **Verify:** alg pinned to `EdDSA` (rejects `none`/HS256 swaps) + signature + `exp`/`iat` + required claims → `401` on failure. + +**Lease-epoch fence:** the `SessionBudget` DO tracks a monotonic `max_epoch`; a token whose `ep` is below it is **superseded** → `401 {code:"token_superseded"}`. A newer epoch bumps the watermark, invalidating older-epoch tokens still in flight (DO-serialized). Omitted `ep` skips the fence. + +> **Mint↔verify seam (confirm with the orchestrator):** W1 signs the EdDSA token; default delivery = per-turn token (option a) read by Flue's `getApiKey`, so the meter attributes by `sub` unspoofably. The gateway is configured with the public key only. + +### 3. Request/response passthrough + +- **Body:** buffered (small), **`cache_control` stripped for caching-unsafe models** (§6), `usage:{include:true}` injected so OR echoes cost, re-serialized; all else preserved. +- **Auth swap:** the tenant token is stripped; `Authorization: Bearer ` set; `http-referer`/`x-title` added. Everything else (`anthropic-version`, …) passes through. +- **Response:** OR's status/headers/body returned **untouched** — JSON or `text/event-stream` (SSE straight through). Transparent proxy on the response path. + +### 4. Metering + reconciliation + +- **On-path sub-meter** in a `SessionBudget` **DO** (strongly consistent — serializes concurrent calls so subagents can't double-spend). `POST /check` gates **before** (`spent < budget`) + runs the epoch fence; `POST /add` commits cost **after** (via `waitUntil`), idempotent on the OR generation id. +- **Cost source:** the `usage.cost` (USD) OR echoes per response (`cost.ts`; SSE terminal usage). Fallback `GET /api/v1/generation?id=` (unwired). +- **One cost-source-of-truth:** the gateway forwards through the **org's existing OR key**, so OR's per-key usage still captures Flue spend → `model_meter` cron → Autumn, **exactly as the brain-box path does**. The gateway builds no billing path and pushes nothing to Autumn; its counter is enforcement + per-session display only (optionally emitted to `OC_INGEST`). +- **Budget refusal:** `402 {error:{type:"budget_exceeded", code:"insufficient_quota"}, oc:{spent_usd,budget_usd}}` — a provider-style error so the turn terminates and the tailer maps it to outcome `budget_exceeded`. Bounded **one-call overshoot** (a call that passes pre-check but tips the total over) is accepted. + +### 5. Org OpenRouter key resolution + +The gateway maps `org_id → OR inference key`. The plaintext lives in **Infisical**, sealed by sessions-api (edge `managed_model_keys` owns the key lifecycle; `credential.ts resolveManagedSecret`). A CF Worker can't reach Infisical, so the gateway resolves through a **dedicated internal sessions-api seam** (mirrors the edge's dedicated-secret plaintext-key hand-off — a route carrying a live key gets its own secret): + +``` +POST {GATEWAY_ORKEY_URL} Authorization: Bearer {GATEWAY_ORKEY_SECRET} {"org": orgId} → {"key": "sk-or-..."} +``` + +Cached per org in-isolate (60s TTL — bounds exposure + avoids per-call hits). `TEST_OR_KEY` short-circuits resolution for the acceptance run. **L3 seam to build:** the route reusing `resolveManagedSecret` (flagged in the W3 PR). + +### 6. Prompt-caching safety + +Some models route (via OR) to a backend that rejects Anthropic `cache_control` — `anthropic/claude-3-haiku` (→ Bedrock) **400s** the whole request. The gateway **strips `cache_control`** from the body for an env-extensible denylist (`models.ts`, `CACHE_CONTROL_UNSAFE_MODELS`); caching-capable models are untouched. + +--- + +## What's here + +| File | Role | +|---|---| +| `src/index.ts` | the Worker: verify → epoch-fence + budget gate → org-key inject → cache-safe + usage → forward → tee-meter → passthrough | +| `src/token.ts` | EdDSA session token verify + mint/keygen helpers (Web Crypto, no deps) | +| `src/budget.ts` | `SessionBudget` DO — per-session spend counter + hard gate + epoch fence (µ$ integers) | +| `src/orgkey.ts` | org OR-key resolver — internal seam + per-isolate cache + test override | +| `src/models.ts` | `cache_control` safety (unsafe-model denylist + strip) | +| `src/cost.ts` | per-response cost extraction (JSON + SSE) | +| `scripts/mint.ts` | EdDSA mint helper (generates a keypair; mints a session token) | +| `test/` | `logic` (15) + `integration` (8, real handler + real DO vs mock OR) — **23 green** | + +## Verification + +**Unit + integration (`npx vitest run`, 23 green):** EdDSA mint/verify + alg-pin + wrong-key/expired/tamper; `SessionBudget` epoch fence + budget gate + `/add` idempotency; `cache_control` strip; cost extraction; and the full on-path flow through the real handler (401 no/bad token, org-key injection with the session token never reaching OR, `usage.include`, passthrough, on-path 402, epoch fence 401, cache_control strip). + +**Live acceptance (run 2026-07-05 against real OpenRouter via `wrangler dev --local`, throwaway $1-capped OR key, torn down after):** +- Happy path — a real `anthropic/claude-haiku-4.5` turn completed gateway → OpenRouter → `200`, answer `pong`, `usage.cost` echoed; **no OR key in the response**; token ≠ key. +- Budget — `bud=$0.000001`: call 1 `200` (spent $3.8e-05) → call 2 **`402 budget_exceeded`** on-path. +- cache_control — `claude-3-haiku` with a `cache_control` block: **via gateway `200`** (stripped) vs **direct-to-OR `400`** (proves the strip is necessary and works). +- Epoch fence — epoch 2 adopted → epoch 1 **`401 token_superseded`**. Auth — no/garbage token **`401`**. + +### Reproduce the live run + +```bash +# secrets in .dev.vars (gitignored): the gateway's public key + a real OR key +GATEWAY_TOKEN_PUBLIC_KEY= +TEST_OR_KEY= + +npx wrangler dev --port 8791 --local +# mint a token with the matching private key, then POST a real turn: +GATEWAY_TOKEN_PRIVATE_KEY= node --experimental-strip-types scripts/mint.ts \ + --session ses_live --org org_1 --budget 0.05 +curl -sN -X POST http://localhost:8791/anthropic/v1/messages \ + -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \ + -d '{"model":"anthropic/claude-haiku-4.5","max_tokens":16,"messages":[{"role":"user","content":"say pong"}]}' +``` 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..35641d19 --- /dev/null +++ b/cloudflare-workers/oc-gateway/scripts/mint.ts @@ -0,0 +1,46 @@ +// Mint a per-session gateway token (EdDSA). The control plane mints these in prod; this is the +// dev/e2e helper. On first use it also generates an Ed25519 keypair. +// +// Generate + mint (PUBLIC key + PRIVATE key printed on stderr; the token on stdout): +// node --experimental-strip-types scripts/mint.ts --session ses_test --org org_1 --budget 0.50 +// → set the gateway's secret from the printed GATEWAY_TOKEN_PUBLIC_KEY. +// Reuse a private key so the gateway's public key stays fixed across mints: +// GATEWAY_TOKEN_PRIVATE_KEY= node ... scripts/mint.ts --session ses_x --ep 2 + +import { mintSessionToken, type SessionClaims } 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 budget = Number(arg("budget", "0")); // USD; 0 = uncapped +const ep = arg("ep"); +const claims: SessionClaims = { + sub: arg("session", "ses_test")!, + org: arg("org", "org_1")!, + agt: arg("agent", "agt_1")!, + bud: budget > 0 ? budget : undefined, + ep: ep != null ? Number(ep) : undefined, + iat: now, + exp: now + ttl, +}; +console.log(await mintSessionToken(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..f2c3bdb9 --- /dev/null +++ b/cloudflare-workers/oc-gateway/src/budget.ts @@ -0,0 +1,87 @@ +// SessionBudget — the per-session on-path spend counter + hard-limit gate (design 013 §4/§8). +// +// WHY A DO (and not KV): enforcement must be read-then-write consistent. Concurrent model calls in +// one session (subagents, parallel tools) racing on KV would double-spend past the cap. A DO +// serializes /check and /add per session, so the running total is authoritative. This is the +// "authoritative, not best-effort observe()" the design calls for. +// +// This counter is for ENFORCEMENT + per-session display ONLY — it is 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, unchanged). The gateway sub-meter and OR's per-key usage are independent +// by design; they need not reconcile to the penny. +// +// Money is tracked in integer MICRODOLLARS (µ$, 1e-6 USD) to avoid float drift, matching model_meter. + +interface State { + spent_micro: number; + budget_micro: number | null; // null = uncapped + calls: number; + updated: number; + max_epoch: number; // highest lease/turn epoch seen; a token with a lower `ep` is fenced (superseded) +} + +export class SessionBudget { + 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, calls: 0, updated: 0, max_epoch: 0 }; + } + + async fetch(req: Request): Promise { + const url = new URL(req.url); + const body = req.method === "POST" ? ((await req.json().catch(() => ({}))) as Record) : {}; + + // POST /check {budget_micro, ep?} → (1) lease-epoch fence, then (2) budget gate. + // + // FENCE (finding: "close the freeze-flags"): `max_epoch` is monotonic. A token whose `ep` is + // BELOW the highest epoch seen is superseded — reject it (`fenced`). A new epoch (>= max) is + // adopted and bumps the watermark, invalidating any still-in-flight older-epoch tokens. Omitted + // `ep` skips the fence (uncapped/legacy mint). This runs BEFORE the model call, DO-serialized. + // + // GATE: return whether a NEW call is allowed (spent < budget). The last in-flight call can + // overshoot by at most one call's cost — bounded and acceptable for "refuse past the limit". + if (url.pathname === "/check") { + const s = await this.load(); + const ep = typeof body.ep === "number" ? body.ep : null; + if (ep != null && ep < s.max_epoch) { + return Response.json({ allowed: false, fenced: true, spent_micro: s.spent_micro, budget_micro: s.budget_micro }); + } + if (ep != null && ep > s.max_epoch) s.max_epoch = ep; + if (typeof body.budget_micro === "number") s.budget_micro = body.budget_micro; + else if (body.budget_micro === null) s.budget_micro = null; + const allowed = s.budget_micro == null || s.spent_micro < s.budget_micro; + await this.state.storage.put("s", s); + return Response.json({ allowed, fenced: false, spent_micro: s.spent_micro, budget_micro: s.budget_micro }); + } + + // POST /add {cost_micro} → commit a completed call's cost (called from waitUntil after the + // response). Idempotency is keyed by the caller (an OpenRouter generation id) so a retried + // meter never double-counts. + 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; + await this.state.storage.put("s", s); + return Response.json({ spent_micro: s.spent_micro, calls: s.calls }); + } + + // GET /state → per-session spend (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/index.ts b/cloudflare-workers/oc-gateway/src/index.ts new file mode 100644 index 00000000..f92c1e65 --- /dev/null +++ b/cloudflare-workers/oc-gateway/src/index.ts @@ -0,0 +1,183 @@ +// oc-gateway — the thin per-session Worker over OpenRouter (design 013 §4, buildout contract #1 / W3). +// +// An unmodified Flue app does: +// registerProvider('anthropic', { baseUrl: `${env.OC_GATEWAY}/anthropic`, apiKey: }) +// and this Worker, on every model call: +// (a) verifies the per-session EdDSA token → (org, agent, session, budget, epoch); +// (b) fences a superseded lease epoch, then gates on the per-session budget ON-PATH (§8); +// (c) injects the ORG's OpenRouter inference key (resolved from the credential store; never exposed); +// (d) makes the body prompt-caching-safe, injects usage accounting, forwards to OpenRouter; +// (e) sub-meters the response cost per session (SessionBudget DO), leaving ORG-level spend on that +// same OR key → the existing model_meter cron → Autumn (one cost-source-of-truth). +// +// It builds NOTHING new for billing: org spend flows through the org's single OR key exactly as the +// brain-box path does today; the gateway only adds per-session sub-metering + enforcement. + +import { verifySessionToken } from "./token.js"; +import { costFromJson, costFromStream } from "./cost.js"; +import { resolveOrgKey } from "./orgkey.js"; +import { unsafeModelMatchers, modelNeedsCacheStrip, stripCacheControl } from "./models.js"; +export { SessionBudget } from "./budget.js"; + +export interface Env { + // base64url raw 32-byte Ed25519 PUBLIC key. The minter (control plane) holds the private key. + GATEWAY_TOKEN_PUBLIC_KEY: string; + // Per-session budget counter + gate + epoch fence. + SESSION_BUDGET: DurableObjectNamespace; + // 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; + // 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" }); + } + + 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-session token (EdDSA — gateway holds only the public key). + const token = bearer(req.headers); + if (!token) return json({ error: { type: "unauthorized", message: "missing session token" } }, 401); + const nowSec = Math.floor(Date.now() / 1000); + const v = await verifySessionToken(env.GATEWAY_TOKEN_PUBLIC_KEY, token, nowSec); + if (!v.ok) return json({ error: { type: "unauthorized", message: `invalid session token: ${v.reason}` } }, 401); + const { sub: sessionId, org: orgId, bud, ep } = v.claims; + + // (b) lease-epoch fence + budget gate — ON-PATH, before the model call (§8), DO-serialized. + const doStub = env.SESSION_BUDGET.get(env.SESSION_BUDGET.idFromName(sessionId)); + const budgetMicro = typeof bud === "number" && bud > 0 ? Math.round(bud * 1e6) : null; + const check = await doStub + .fetch("https://do/check", { method: "POST", body: JSON.stringify({ budget_micro: budgetMicro, ep }) }) + .then((r) => r.json() as Promise<{ allowed: boolean; fenced?: boolean; spent_micro: number; budget_micro: number | null }>); + if (check.fenced) { + // A superseded lease epoch — the token was minted for an older turn. 401 so the caller re-mints. + return json({ error: { type: "unauthorized", message: "session token superseded (stale lease epoch)", code: "token_superseded" } }, 401); + } + if (!check.allowed) { + // Refuse past the per-session 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: "per-session model budget exhausted", code: "insufficient_quota" }, + oc: { session: sessionId, spent_usd: check.spent_micro / 1e6, budget_usd: (check.budget_micro ?? 0) / 1e6 }, + }, 402); + } + + // (c) 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); + + // (d) 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; pass the rest. + const fwdHeaders = new Headers(req.headers); + fwdHeaders.delete("x-api-key"); + fwdHeaders.delete("authorization"); + 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 }); + + // (e) sub-meter the response cost per session, off the response path (waitUntil). + const isStream = (upstream.headers.get("content-type") || "").includes("text/event-stream"); + const meterCopy = upstream.clone(); + ctx.waitUntil(meter(meterCopy, isStream, doStub, env, { sessionId, orgId })); + + // 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 }); + }, +}; + +async function meter( + resp: Response, + isStream: boolean, + doStub: DurableObjectStub, + env: Env, + ctx: { sessionId: string; orgId: 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; + await doStub.fetch("https://do/add", { + method: "POST", + body: JSON.stringify({ cost_micro: costMicro, idem: extracted.generationId }), + }); + // Per-session spend telemetry (dashboard, §9) — best-effort; NOT a billing source (org billing + // is OR→cron→Autumn). Absent OC_INGEST_URL → 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, + 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..ef3d0c15 --- /dev/null +++ b/cloudflare-workers/oc-gateway/test/integration.test.ts @@ -0,0 +1,179 @@ +// In-process integration: the REAL worker handler + the REAL SessionBudget DO, with `fetch` +// stubbed to a mock OpenRouter. Proves the whole on-path flow deterministically without wrangler: +// token verify → per-session budget gate → org-key injection → forward → cost sub-meter → +// passthrough → refusal past budget. (The wrangler-dev + curl variant is documented in README.md; +// this is the CI-able equivalent.) +// +// Run: npx vitest run + +import { describe, it, expect, beforeEach, beforeAll, vi, afterEach } from "vitest"; +import worker, { Env } from "../src/index.js"; +import { SessionBudget } from "../src/budget.js"; +import { generateKeyPair, mintSessionToken } 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 SESSION_BUDGET namespace: one real SessionBudget instance per name ── +function fakeBudgetNamespace() { + const instances = new Map(); + return { + 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 SessionBudget(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; +} + +let lastAuthToOR: string | null; +let lastBodyToOR: Record | null; + +function mockFetch(perCallCost: number) { + return vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.toString(); + lastAuthToOR = new Headers(init?.headers).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" } }); + }); +} + +function env(): Env { + return { GATEWAY_TOKEN_PUBLIC_KEY: PUB, TEST_OR_KEY: OR_KEY, OPENROUTER_BASE: OR_BASE, SESSION_BUDGET: fakeBudgetNamespace() }; +} + +// 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) => new Request("https://gw.test/anthropic/v1/messages", { + method: "POST", + headers: { "content-type": "application/json", ...(token ? { authorization: `Bearer ${token}` } : {}) }, + body: MSG, +}); + +describe("gateway on-path flow", () => { + beforeEach(() => { lastAuthToOR = null; lastBodyToOR = null; }); + afterEach(() => vi.restoreAllMocks()); + + it("GET /healthz → ok", async () => { + 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 session token (401)", async () => { + const res = await worker.fetch(post(), env(), ctx()); + expect(res.status).toBe(401); + }); + + it("rejects an invalid/expired token (401)", async () => { + const now = Math.floor(Date.now() / 1000); + const expired = await mintSessionToken(PRIV, { sub: "ses_x", org: "org_1", agt: "a", 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 session token), adds usage.include, passes the body through", async () => { + vi.stubGlobal("fetch", mockFetch(0.02)); + const now = Math.floor(Date.now() / 1000); + const token = await mintSessionToken(PRIV, { sub: "ses_ok", org: "org_1", agt: "a", bud: 1, iat: now, exp: now + 3600 }); + const e = env(); + const c = ctx(); + const res = await worker.fetch(post(token), e, 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 session token never reached OpenRouter + expect(lastAuthToOR).toBe(`Bearer ${OR_KEY}`); + expect(lastAuthToOR).not.toContain(token); + // usage.include was injected so OR echoes cost + expect(lastBodyToOR?.usage).toMatchObject({ include: true }); + // original request fields preserved + expect(lastBodyToOR?.model).toBe("anthropic/claude-sonnet-5"); + await drain(c); + }); + + it("enforces the per-session budget ON-PATH: refuses once spend reaches the cap (402)", async () => { + vi.stubGlobal("fetch", mockFetch(0.02)); // $0.02 per call + const now = Math.floor(Date.now() / 1000); + // budget $0.03 → call1 (spent 0<0.03) ok→0.02; call2 (0.02<0.03) ok→0.04; call3 (0.04≥0.03) refused. + const token = await mintSessionToken(PRIV, { sub: "ses_budget", org: "org_1", agt: "a", bud: 0.03, iat: now, exp: now + 3600 }); + const e = env(); + + const c1 = ctx(); const r1 = await worker.fetch(post(token), e, c1); await drain(c1); + const c2 = ctx(); const r2 = await worker.fetch(post(token), e, c2); await drain(c2); + const c3 = ctx(); const r3 = await worker.fetch(post(token), e, 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("uncapped session (no bud claim) never refuses", async () => { + vi.stubGlobal("fetch", mockFetch(1.0)); + const now = Math.floor(Date.now() / 1000); + const token = await mintSessionToken(PRIV, { sub: "ses_uncapped", org: "org_1", agt: "a", iat: now, exp: now + 3600 }); + const e = env(); + for (let i = 0; i < 3; i++) { const c = ctx(); const r = await worker.fetch(post(token), e, c); await drain(c); expect(r.status).toBe(200); } + }); + + it("fences a superseded lease epoch (401 token_superseded)", async () => { + vi.stubGlobal("fetch", mockFetch(0.001)); + const now = Math.floor(Date.now() / 1000); + const e = env(); // same namespace → same DO for ses_ep across calls + const t2 = await mintSessionToken(PRIV, { sub: "ses_ep", org: "org_1", agt: "a", ep: 2, iat: now, exp: now + 3600 }); + const c2 = ctx(); const r2 = await worker.fetch(post(t2), e, c2); await drain(c2); + expect(r2.status).toBe(200); // adopt epoch 2 + const t1 = await mintSessionToken(PRIV, { sub: "ses_ep", org: "org_1", agt: "a", ep: 1, iat: now, exp: now + 3600 }); + const r1 = await worker.fetch(post(t1), e, ctx()); // the old turn'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 now = Math.floor(Date.now() / 1000); + const token = await mintSessionToken(PRIV, { sub: "ses_cc", org: "org_1", agt: "a", iat: now, exp: now + 3600 }); + 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 req = new Request("https://gw.test/anthropic/v1/messages", { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, body }); + const c = ctx(); const r = await worker.fetch(req, 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 }); + }); +}); 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..d8ce67af --- /dev/null +++ b/cloudflare-workers/oc-gateway/test/logic.test.ts @@ -0,0 +1,143 @@ +// Pure-logic tests (no Workers runtime needed): EdDSA token crypto, the SessionBudget DO's epoch +// fence + budget gate + idempotency (over a fake DurableObjectState), cache_control safety, and cost +// extraction. The full on-path flow (forward + meter) is exercised by the live integration in +// README.md / scripts/e2e. Run: npx vitest run + +import { describe, it, expect } from "vitest"; +import { generateKeyPair, mintSessionToken, verifySessionToken, type SessionClaims } from "../src/token.js"; +import { costFromJson, costFromStream } from "../src/cost.js"; +import { unsafeModelMatchers, modelNeedsCacheStrip, stripCacheControl } from "../src/models.js"; +import { SessionBudget } from "../src/budget.js"; + +const now = 1_800_000_000; +const b64url = (o: unknown) => btoa(JSON.stringify(o)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +const claims = (o: Partial = {}): SessionClaims => ({ + sub: "ses_abc", org: "org_1", agt: "agt_1", bud: 0.5, ep: 2, iat: now, exp: now + 3600, ...o, +}); + +describe("session token (EdDSA)", () => { + it("mint → verify round-trips the claims", async () => { + const { privateKey, publicKeyB64url } = await generateKeyPair(); + const v = await verifySessionToken(publicKeyB64url, await mintSessionToken(privateKey, claims()), now); + expect(v.ok).toBe(true); + if (v.ok) { + expect(v.claims.sub).toBe("ses_abc"); + expect(v.claims.org).toBe("org_1"); + expect(v.claims.bud).toBe(0.5); + expect(v.claims.ep).toBe(2); + } + }); + 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 verifySessionToken(b.publicKeyB64url, await mintSessionToken(a.privateKey, claims()), now); + expect(v.ok).toBe(false); + }); + it("rejects a tampered payload", async () => { + const { privateKey, publicKeyB64url } = await generateKeyPair(); + const [h, , s] = (await mintSessionToken(privateKey, claims())).split("."); + const v = await verifySessionToken(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 verifySessionToken(publicKeyB64url, await mintSessionToken(privateKey, claims({ exp: now - 1 })), now); + expect(v.ok).toBe(false); + if (!v.ok) expect(v.reason).toBe("expired"); + }); + it("pins alg=EdDSA — rejects an alg-swap (none/HS256) header", async () => { + const { privateKey, publicKeyB64url } = await generateKeyPair(); + const [, p, s] = (await mintSessionToken(privateKey, claims())).split("."); + const v = await verifySessionToken(publicKeyB64url, `${b64url({ alg: "none", typ: "JWT" })}.${p}.${s}`, now); + expect(v.ok).toBe(false); + if (!v.ok) expect(v.reason).toBe("unexpected_alg"); + }); +}); + +describe("SessionBudget DO — epoch fence + budget gate + idempotency", () => { + 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 (bd: SessionBudget, path: string, body: unknown) => + (await bd.fetch(new Request(`https://do${path}`, { method: "POST", body: JSON.stringify(body) }))).json() as Promise>; + + it("fences a stale lease epoch (monotonic)", async () => { + const bd = new SessionBudget(fakeState()); + expect((await call(bd, "/check", { ep: 1 })).allowed).toBe(true); + expect((await call(bd, "/check", { ep: 2 })).allowed).toBe(true); // adopt the newer epoch + const stale = await call(bd, "/check", { ep: 1 }); // the old turn's token is now superseded + expect(stale.fenced).toBe(true); + expect(stale.allowed).toBe(false); + }); + + it("gates on the per-session budget (spent < budget)", async () => { + const bd = new SessionBudget(fakeState()); + const cap = 1_000_000; // $1.00 in µ$ + expect((await call(bd, "/check", { budget_micro: cap, ep: 1 })).allowed).toBe(true); + await call(bd, "/add", { cost_micro: 600_000, idem: "g1" }); + expect((await call(bd, "/check", { budget_micro: cap, ep: 1 })).allowed).toBe(true); // 0.6 < 1.0 + await call(bd, "/add", { cost_micro: 600_000, idem: "g2" }); // now 1.2 > 1.0 + expect((await call(bd, "/check", { budget_micro: cap, ep: 1 })).allowed).toBe(false); + }); + + it("dedupes /add by generation id (retried meter never double-counts)", async () => { + const bd = new SessionBudget(fakeState()); + await call(bd, "/add", { cost_micro: 100_000, idem: "gen-x" }); + const second = await call(bd, "/add", { cost_micro: 100_000, idem: "gen-x" }); + expect(second.deduped).toBe(true); + const st = (await (await bd.fetch(new Request("https://do/state"))).json()) as { spent_micro: number }; + expect(st.spent_micro).toBe(100_000); + }); +}); + +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..827313e2 --- /dev/null +++ b/cloudflare-workers/oc-gateway/wrangler.toml @@ -0,0 +1,25 @@ +name = "oc-gateway" +main = "src/index.ts" +compatibility_date = "2025-06-01" +compatibility_flags = ["nodejs_compat"] + +# The per-session spend counter + hard-limit gate (design 013 §4/§8). +[[durable_objects.bindings]] +name = "SESSION_BUDGET" +class_name = "SessionBudget" + +[[migrations]] +tag = "v1" +new_sqlite_classes = ["SessionBudget"] + +# 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) +# 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}. +# 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. From 78b0f7bb7d8e5bb4b2100b47565e12ddb906eff6 Mon Sep 17 00:00:00 2001 From: Igor Zalutski Date: Mon, 6 Jul 2026 00:04:54 +0100 Subject: [PATCH 04/25] feat(oc-gateway): W3 gateway to the resolved token seam (per-deploy token, org+agt enforcement) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reshapes the productionized gateway to the RESOLVED token seam (2026-07-05, option b) and the co-location refinement, and lands a consistent, compiling tree. The prior commit (0b5f580) captured a mid-reshape snapshot (new token.ts + old index.ts) that does not compile; this commit completes the reshape. buildout contract #1 / design 013 §4. Token seam (was per-session {sub,bud}; now per-DEPLOY): - token.ts: claims are {org, agt, iat, exp, ep?} — no sub:session, no bud. EdDSA (Ed25519), alg-pinned; the minter (W7) holds the private key, the gateway only the public key. verifyDeployToken/mintDeployToken/DeployClaims. - Session identity rides the X-OC-Session request header (Flue's static apiKey can't carry per-session data). It is BEST-EFFORT: Flue's provider registry is isolate-global and CF co-locates many session-DOs of one script per isolate, so per-session data injected via registerProvider races. Therefore hard enforcement is at the race-free org+agt grain. Enforcement (co-location refinement): - SpendCounter DO (renamed from SessionBudget; generic keyed µ$ counter + gate). HARD 402 at the org+agt grain (keyed agt:${org}:${agt}, from the token). Best-effort per-session tracking keyed sess:${X-OC-Session} — recorded for visibility, NEVER gated (a race must not wrongly 402 a legit session). Budget looked up server-side (provisioned or the gateway default), never carried in the token. Exact per-session enforcement is deferred to an upstream Flue per-request resolver (tracked ask, off the critical path). - DeployLease DO (new): per-(org,agt) lease-epoch floor. A token below the floor is fenced (401 token_superseded); the floor auto-rises on a higher-epoch token (rotation) and via POST /admin/lease/bump (revoke without redeploy). - Admin routes (guarded by GATEWAY_ADMIN_SECRET): /admin/agent/budget (provision org+agt cap), /admin/lease/bump (revoke). Unchanged from the spike: org OR key from the dedicated sessions-api seam (orgkey.ts; TEST_OR_KEY override), usage:{include:true}, cache_control strip for caching-unsafe models, cost extraction (JSON+SSE), org spend stays on the existing OpenRouter→Autumn cron (gateway pushes NOTHING to Autumn). Tests: 31 green (vitest) — EdDSA/alg-pin/tamper/expired/missing-claims, SpendCounter gate+provision+idempotency, DeployLease fence+bump, org+agt hard enforcement, co-location (two sessions share the org+agt cap), per-session tracked-but-never-gated, cache_control strip, admin provision. Live acceptance (2026-07-06, real OpenRouter via wrangler dev, throwaway $1-capped OR key minted from the provisioning key and DELETEd after): a real claude-haiku-4.5 turn completed gateway → OpenRouter (200, model claude-4.5-haiku, cost echoed; no OR key / deploy token in the response); org+agt budget refused on-path (402 budget_exceeded, spent 0.000088 > budget 0.00001); a stale lease epoch was fenced (401 token_superseded); no-token and bad-admin-secret → 401. opencomputer = PR-only; do NOT merge (Igor merges). Base = flue-native, into #489. Co-Authored-By: Claude Opus 4.8 --- cloudflare-workers/oc-gateway/README.md | 130 +++++++----- cloudflare-workers/oc-gateway/scripts/mint.ts | 22 +- cloudflare-workers/oc-gateway/src/budget.ts | 82 +++++--- .../oc-gateway/src/deploylease.ts | 70 +++++++ cloudflare-workers/oc-gateway/src/index.ts | 193 +++++++++++++----- .../oc-gateway/test/integration.test.ts | 185 +++++++++++------ .../oc-gateway/test/logic.test.ts | 126 ++++++++---- cloudflare-workers/oc-gateway/wrangler.toml | 16 +- 8 files changed, 571 insertions(+), 253 deletions(-) create mode 100644 cloudflare-workers/oc-gateway/src/deploylease.ts diff --git a/cloudflare-workers/oc-gateway/README.md b/cloudflare-workers/oc-gateway/README.md index 5c0f30b7..9f7c46a2 100644 --- a/cloudflare-workers/oc-gateway/README.md +++ b/cloudflare-workers/oc-gateway/README.md @@ -1,8 +1,8 @@ -# oc-gateway — thin per-session Worker over OpenRouter +# oc-gateway — thin OC Worker over OpenRouter (W3, productionized) -**W3 (productionized) / Lane L2** for the Flue-native agent type (`oc-bg-agents .agents/work/flue-native-buildout.md`, design `013 §4`). Implements **inter-lane Contract #1** (Gateway HTTP contract), hardened from the 1a spike (`spike/oc-gateway`, #486) and **live-verified** against real OpenRouter. +**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 **per-session sub-metering + on-path budget enforcement**. +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. --- @@ -10,10 +10,18 @@ It **extends** the shipped managed-model path (does not replace it): org-level s ### 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: }); +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) | @@ -22,44 +30,58 @@ registerProvider('anthropic', { baseUrl: `${env.OC_GATEWAY}/anthropic`, apiKey: 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. Per-session token — EdDSA, with a lease-epoch fence +### 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**. -A compact **EdDSA (Ed25519) JWT** the tenant Worker holds as the provider `apiKey`. The **minter** (control plane / session DO) holds the private key; the **gateway holds only the public key** (`GATEWAY_TOKEN_PUBLIC_KEY` = base64url raw 32-byte Ed25519 public key) — the same asymmetry as the turn token, so a compromised gateway can't forge tokens. +**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. -**Claims:** `{ sub: ses_, org, agt, bud?, ep?, iat, exp }` — `sub` = sub-meter/budget key; `org` selects the org's OR key; `bud` = per-session USD cap (omit/0 = uncapped); `ep` = lease/turn epoch. +**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 pinned to `EdDSA` (rejects `none`/HS256 swaps) + signature + `exp`/`iat` + required claims → `401` on failure. +**Transport:** `Authorization: Bearer ` **or** `x-api-key: `. Verify = alg-pin + signature + `exp`/`iat` + `org`/`agt` present. Failure → `401`. -**Lease-epoch fence:** the `SessionBudget` DO tracks a monotonic `max_epoch`; a token whose `ep` is below it is **superseded** → `401 {code:"token_superseded"}`. A newer epoch bumps the watermark, invalidating older-epoch tokens still in flight (DO-serialized). Omitted `ep` skips the fence. +### 3. Enforcement grain (co-location refinement) -> **Mint↔verify seam (confirm with the orchestrator):** W1 signs the EdDSA token; default delivery = per-turn token (option a) read by Flue's `getApiKey`, so the meter attributes by `sub` unspoofably. The gateway is configured with the public key only. +- **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). -### 3. Request/response passthrough +### 4. Request/response passthrough -- **Body:** buffered (small), **`cache_control` stripped for caching-unsafe models** (§6), `usage:{include:true}` injected so OR echoes cost, re-serialized; all else preserved. -- **Auth swap:** the tenant token is stripped; `Authorization: Bearer ` set; `http-referer`/`x-title` added. Everything else (`anthropic-version`, …) passes through. -- **Response:** OR's status/headers/body returned **untouched** — JSON or `text/event-stream` (SSE straight through). Transparent proxy on the response path. +- **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`. -### 4. Metering + reconciliation +### 5. Metering + reconciliation — one cost-source-of-truth -- **On-path sub-meter** in a `SessionBudget` **DO** (strongly consistent — serializes concurrent calls so subagents can't double-spend). `POST /check` gates **before** (`spent < budget`) + runs the epoch fence; `POST /add` commits cost **after** (via `waitUntil`), idempotent on the OR generation id. -- **Cost source:** the `usage.cost` (USD) OR echoes per response (`cost.ts`; SSE terminal usage). Fallback `GET /api/v1/generation?id=` (unwired). -- **One cost-source-of-truth:** the gateway forwards through the **org's existing OR key**, so OR's per-key usage still captures Flue spend → `model_meter` cron → Autumn, **exactly as the brain-box path does**. The gateway builds no billing path and pushes nothing to Autumn; its counter is enforcement + per-session display only (optionally emitted to `OC_INGEST`). -- **Budget refusal:** `402 {error:{type:"budget_exceeded", code:"insufficient_quota"}, oc:{spent_usd,budget_usd}}` — a provider-style error so the turn terminates and the tailer maps it to outcome `budget_exceeded`. Bounded **one-call overshoot** (a call that passes pre-check but tips the total over) is accepted. +- **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.* -### 5. Org OpenRouter key resolution +### 6. Org OpenRouter key resolution (from Infisical, via a sessions-api seam) -The gateway maps `org_id → OR inference key`. The plaintext lives in **Infisical**, sealed by sessions-api (edge `managed_model_keys` owns the key lifecycle; `credential.ts resolveManagedSecret`). A CF Worker can't reach Infisical, so the gateway resolves through a **dedicated internal sessions-api seam** (mirrors the edge's dedicated-secret plaintext-key hand-off — a route carrying a live key gets its own secret): +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} {"org": orgId} → {"key": "sk-or-..."} +POST {GATEWAY_ORKEY_URL} Authorization: Bearer {GATEWAY_ORKEY_SECRET} body {"org": orgId} + → 200 {"key": "sk-or-..."} (resolveManagedSecret for the org's active managed credential) ``` -Cached per org in-isolate (60s TTL — bounds exposure + avoids per-call hits). `TEST_OR_KEY` short-circuits resolution for the acceptance run. **L3 seam to build:** the route reusing `resolveManagedSecret` (flagged in the W3 PR). +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. -### 6. Prompt-caching safety +### 8. Control-plane admin routes (guarded by `GATEWAY_ADMIN_SECRET`) -Some models route (via OR) to a backend that rejects Anthropic `cache_control` — `anthropic/claude-3-haiku` (→ Bedrock) **400s** the whole request. The gateway **strips `cache_control`** from the body for an env-extensible denylist (`models.ts`, `CACHE_CONTROL_UNSAFE_MODELS`); caching-capable models are untouched. +- `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). --- @@ -67,37 +89,41 @@ Some models route (via OR) to a backend that rejects Anthropic `cache_control` | File | Role | |---|---| -| `src/index.ts` | the Worker: verify → epoch-fence + budget gate → org-key inject → cache-safe + usage → forward → tee-meter → passthrough | -| `src/token.ts` | EdDSA session token verify + mint/keygen helpers (Web Crypto, no deps) | -| `src/budget.ts` | `SessionBudget` DO — per-session spend counter + hard gate + epoch fence (µ$ integers) | -| `src/orgkey.ts` | org OR-key resolver — internal seam + per-isolate cache + test override | -| `src/models.ts` | `cache_control` safety (unsafe-model denylist + strip) | +| `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) | -| `scripts/mint.ts` | EdDSA mint helper (generates a keypair; mints a session token) | -| `test/` | `logic` (15) + `integration` (8, real handler + real DO vs mock OR) — **23 green** | +| `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 +## Verification status -**Unit + integration (`npx vitest run`, 23 green):** EdDSA mint/verify + alg-pin + wrong-key/expired/tamper; `SessionBudget` epoch fence + budget gate + `/add` idempotency; `cache_control` strip; cost extraction; and the full on-path flow through the real handler (401 no/bad token, org-key injection with the session token never reaching OR, `usage.include`, passthrough, on-path 402, epoch fence 401, cache_control strip). +- **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 acceptance (run 2026-07-05 against real OpenRouter via `wrangler dev --local`, throwaway $1-capped OR key, torn down after):** -- Happy path — a real `anthropic/claude-haiku-4.5` turn completed gateway → OpenRouter → `200`, answer `pong`, `usage.cost` echoed; **no OR key in the response**; token ≠ key. -- Budget — `bud=$0.000001`: call 1 `200` (spent $3.8e-05) → call 2 **`402 budget_exceeded`** on-path. -- cache_control — `claude-3-haiku` with a `cache_control` block: **via gateway `200`** (stripped) vs **direct-to-OR `400`** (proves the strip is necessary and works). -- Epoch fence — epoch 2 adopted → epoch 1 **`401 token_superseded`**. Auth — no/garbage token **`401`**. - -### Reproduce the live run +### Live verification ```bash -# secrets in .dev.vars (gitignored): the gateway's public key + a real OR key -GATEWAY_TOKEN_PUBLIC_KEY= -TEST_OR_KEY= - -npx wrangler dev --port 8791 --local -# mint a token with the matching private key, then POST a real turn: -GATEWAY_TOKEN_PRIVATE_KEY= node --experimental-strip-types scripts/mint.ts \ - --session ses_live --org org_1 --budget 0.05 -curl -sN -X POST http://localhost:8791/anthropic/v1/messages \ - -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \ - -d '{"model":"anthropic/claude-haiku-4.5","max_tokens":16,"messages":[{"role":"user","content":"say pong"}]}' +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/scripts/mint.ts b/cloudflare-workers/oc-gateway/scripts/mint.ts index 35641d19..953c4c93 100644 --- a/cloudflare-workers/oc-gateway/scripts/mint.ts +++ b/cloudflare-workers/oc-gateway/scripts/mint.ts @@ -1,13 +1,14 @@ -// Mint a per-session gateway token (EdDSA). The control plane mints these in prod; this is the -// dev/e2e helper. On first use it also generates an Ed25519 keypair. +// 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 key + PRIVATE key printed on stderr; the token on stdout): -// node --experimental-strip-types scripts/mint.ts --session ses_test --org org_1 --budget 0.50 -// → set the gateway's secret from the printed GATEWAY_TOKEN_PUBLIC_KEY. +// 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 --session ses_x --ep 2 +// GATEWAY_TOKEN_PRIVATE_KEY= node ... scripts/mint.ts --org org_1 --agent agt_1 --ep 2 -import { mintSessionToken, type SessionClaims } from "../src/token.ts"; +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"); @@ -32,15 +33,12 @@ if (existing) { const now = Math.floor(Date.now() / 1000); const ttl = Number(arg("ttl", "3600")); -const budget = Number(arg("budget", "0")); // USD; 0 = uncapped const ep = arg("ep"); -const claims: SessionClaims = { - sub: arg("session", "ses_test")!, +const claims: DeployClaims = { org: arg("org", "org_1")!, agt: arg("agent", "agt_1")!, - bud: budget > 0 ? budget : undefined, ep: ep != null ? Number(ep) : undefined, iat: now, exp: now + ttl, }; -console.log(await mintSessionToken(privateKey, claims)); +console.log(await mintDeployToken(privateKey, claims)); diff --git a/cloudflare-workers/oc-gateway/src/budget.ts b/cloudflare-workers/oc-gateway/src/budget.ts index f2c3bdb9..430ec25d 100644 --- a/cloudflare-workers/oc-gateway/src/budget.ts +++ b/cloudflare-workers/oc-gateway/src/budget.ts @@ -1,26 +1,36 @@ -// SessionBudget — the per-session on-path spend counter + hard-limit gate (design 013 §4/§8). +// SpendCounter — a strongly-consistent keyed spend counter + optional hard gate (design 013 §4/§8). // -// WHY A DO (and not KV): enforcement must be read-then-write consistent. Concurrent model calls in -// one session (subagents, parallel tools) racing on KV would double-spend past the cap. A DO -// serializes /check and /add per session, so the running total is authoritative. This is the -// "authoritative, not best-effort observe()" the design calls for. +// 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). // -// This counter is for ENFORCEMENT + per-session display ONLY — it is 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, unchanged). The gateway sub-meter and OR's per-key usage are independent -// by design; they need not reconcile to the penny. +// 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. // -// Money is tracked in integer MICRODOLLARS (µ$, 1e-6 USD) to avoid float drift, matching model_meter. +// 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; - max_epoch: number; // highest lease/turn epoch seen; a token with a lower `ep` is fenced (superseded) } -export class SessionBudget { +export class SpendCounter { private state: DurableObjectState; constructor(state: DurableObjectState) { this.state = state; @@ -28,39 +38,44 @@ export class SessionBudget { private async load(): Promise { const s = await this.state.storage.get("s"); - return s ?? { spent_micro: 0, budget_micro: null, calls: 0, updated: 0, max_epoch: 0 }; + 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 /check {budget_micro, ep?} → (1) lease-epoch fence, then (2) budget gate. - // - // FENCE (finding: "close the freeze-flags"): `max_epoch` is monotonic. A token whose `ep` is - // BELOW the highest epoch seen is superseded — reject it (`fenced`). A new epoch (>= max) is - // adopted and bumps the watermark, invalidating any still-in-flight older-epoch tokens. Omitted - // `ep` skips the fence (uncapped/legacy mint). This runs BEFORE the model call, DO-serialized. - // - // GATE: return whether a NEW call is allowed (spent < budget). The last in-flight call can - // overshoot by at most one call's cost — bounded and acceptable for "refuse past the limit". + // 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(); - const ep = typeof body.ep === "number" ? body.ep : null; - if (ep != null && ep < s.max_epoch) { - return Response.json({ allowed: false, fenced: true, spent_micro: s.spent_micro, budget_micro: s.budget_micro }); + if (!s.provisioned && typeof body.default_budget_micro === "number") { + s.budget_micro = Math.max(0, Math.round(body.default_budget_micro)); } - if (ep != null && ep > s.max_epoch) s.max_epoch = ep; - if (typeof body.budget_micro === "number") s.budget_micro = body.budget_micro; - else if (body.budget_micro === null) s.budget_micro = null; 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, fenced: false, spent_micro: s.spent_micro, budget_micro: s.budget_micro }); + return Response.json({ allowed, spent_micro: s.spent_micro, budget_micro: s.budget_micro }); } - // POST /add {cost_micro} → commit a completed call's cost (called from waitUntil after the - // response). Idempotency is keyed by the caller (an OpenRouter generation id) so a retried - // meter never double-counts. + // 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; @@ -72,11 +87,12 @@ export class SessionBudget { } 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 → per-session spend (dashboard, §9). + // GET /state → spend at this grain (dashboard, §9). if (url.pathname === "/state") { const s = await this.load(); return Response.json(s); 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 index f92c1e65..2d420329 100644 --- a/cloudflare-workers/oc-gateway/src/index.ts +++ b/cloudflare-workers/oc-gateway/src/index.ts @@ -1,34 +1,55 @@ -// oc-gateway — the thin per-session Worker over OpenRouter (design 013 §4, buildout contract #1 / W3). +// oc-gateway — the thin OC Worker over OpenRouter (design 013 §4, buildout contract #1 / W3). // -// An unmodified Flue app does: -// registerProvider('anthropic', { baseUrl: `${env.OC_GATEWAY}/anthropic`, apiKey: }) -// and this Worker, on every model call: -// (a) verifies the per-session EdDSA token → (org, agent, session, budget, epoch); -// (b) fences a superseded lease epoch, then gates on the per-session budget ON-PATH (§8); -// (c) injects the ORG's OpenRouter inference key (resolved from the credential store; never exposed); -// (d) makes the body prompt-caching-safe, injects usage accounting, forwards to OpenRouter; -// (e) sub-meters the response cost per session (SessionBudget DO), leaving ORG-level spend on that -// same OR key → the existing model_meter cron → Autumn (one cost-source-of-truth). +// 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 +// }); // -// It builds NOTHING new for billing: org spend flows through the org's single OR key exactly as the -// brain-box path does today; the gateway only adds per-session sub-metering + enforcement. +// 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 { verifySessionToken } from "./token.js"; +import { verifyDeployToken } from "./token.js"; import { costFromJson, costFromStream } from "./cost.js"; import { resolveOrgKey } from "./orgkey.js"; import { unsafeModelMatchers, modelNeedsCacheStrip, stripCacheControl } from "./models.js"; -export { SessionBudget } from "./budget.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) holds the private key. + // base64url raw 32-byte Ed25519 PUBLIC key. The minter (control plane / W7) holds the private key. GATEWAY_TOKEN_PUBLIC_KEY: string; - // Per-session budget counter + gate + epoch fence. - SESSION_BUDGET: DurableObjectNamespace; + // 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). @@ -71,42 +92,60 @@ export default { 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-session token (EdDSA — gateway holds only the public key). + // (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 session token" } }, 401); + if (!token) return json({ error: { type: "unauthorized", message: "missing deploy token" } }, 401); const nowSec = Math.floor(Date.now() / 1000); - const v = await verifySessionToken(env.GATEWAY_TOKEN_PUBLIC_KEY, token, nowSec); - if (!v.ok) return json({ error: { type: "unauthorized", message: `invalid session token: ${v.reason}` } }, 401); - const { sub: sessionId, org: orgId, bud, ep } = v.claims; - - // (b) lease-epoch fence + budget gate — ON-PATH, before the model call (§8), DO-serialized. - const doStub = env.SESSION_BUDGET.get(env.SESSION_BUDGET.idFromName(sessionId)); - const budgetMicro = typeof bud === "number" && bud > 0 ? Math.round(bud * 1e6) : null; - const check = await doStub - .fetch("https://do/check", { method: "POST", body: JSON.stringify({ budget_micro: budgetMicro, ep }) }) - .then((r) => r.json() as Promise<{ allowed: boolean; fenced?: boolean; spent_micro: number; budget_micro: number | null }>); - if (check.fenced) { - // A superseded lease epoch — the token was minted for an older turn. 401 so the caller re-mints. - return json({ error: { type: "unauthorized", message: "session token superseded (stale lease epoch)", code: "token_superseded" } }, 401); + 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 per-session budget. Shaped as a provider-style error so the Flue/pi-ai turn + // 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: "per-session model budget exhausted", code: "insufficient_quota" }, - oc: { session: sessionId, spent_usd: check.spent_micro / 1e6, budget_usd: (check.budget_micro ?? 0) / 1e6 }, + 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); } - // (c) resolve the ORG's OpenRouter inference key (from the credential-store seam; never exposed). + // (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); - // (d) rewrite the body: strip cache_control for caching-unsafe models, inject usage:{include:true} + // (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; @@ -123,10 +162,12 @@ export default { /* not JSON — forward verbatim */ } - // forward to OpenRouter with the org key swapped in. Strip the tenant's auth; pass the rest. + // 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)); @@ -137,22 +178,76 @@ export default { const forwardTarget = target + (url.search || ""); const upstream = await fetch(forwardTarget, { method: "POST", headers: fwdHeaders, body: outBody }); - // (e) sub-meter the response cost per session, off the response path (waitUntil). + // (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, doStub, env, { sessionId, orgId })); + 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, - doStub: DurableObjectStub, env: Env, - ctx: { sessionId: string; orgId: string }, + 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 @@ -160,18 +255,24 @@ async function meter( ? await costFromStream(resp.body ?? new ReadableStream()) : costFromJson(await resp.text()); const costMicro = extracted.costUsd != null ? Math.round(extracted.costUsd * 1e6) : 0; - await doStub.fetch("https://do/add", { + // 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 }), }); - // Per-session spend telemetry (dashboard, §9) — best-effort; NOT a billing source (org billing - // is OR→cron→Autumn). Absent OC_INGEST_URL → skip. + // 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, + 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(() => {}); diff --git a/cloudflare-workers/oc-gateway/test/integration.test.ts b/cloudflare-workers/oc-gateway/test/integration.test.ts index ef3d0c15..a698ab3b 100644 --- a/cloudflare-workers/oc-gateway/test/integration.test.ts +++ b/cloudflare-workers/oc-gateway/test/integration.test.ts @@ -1,15 +1,16 @@ -// In-process integration: the REAL worker handler + the REAL SessionBudget DO, with `fetch` +// 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: -// token verify → per-session budget gate → org-key injection → forward → cost sub-meter → -// passthrough → refusal past budget. (The wrangler-dev + curl variant is documented in README.md; -// this is the CI-able equivalent.) -// +// 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 { SessionBudget } from "../src/budget.js"; -import { generateKeyPair, mintSessionToken } from "../src/token.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"; @@ -28,27 +29,30 @@ function fakeState() { } } as unknown as DurableObjectState; } -// ── a fake SESSION_BUDGET namespace: one real SessionBudget instance per name ── -function fakeBudgetNamespace() { - const instances = new Map(); - return { +// ── 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 SessionBudget(fakeState())); + 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(); - lastAuthToOR = new Headers(init?.headers).get("authorization"); + 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({ @@ -60,8 +64,12 @@ function mockFetch(perCallCost: number) { }); } -function env(): Env { - return { GATEWAY_TOKEN_PUBLIC_KEY: PUB, TEST_OR_KEY: OR_KEY, OPENROUTER_BASE: OR_BASE, SESSION_BUDGET: fakeBudgetNamespace() }; +// 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). @@ -72,65 +80,84 @@ function ctx(): 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) => new Request("https://gw.test/anthropic/v1/messages", { +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}` } : {}) }, - body: MSG, + headers: { + "content-type": "application/json", + ...(token ? { authorization: `Bearer ${token}` } : {}), + ...(session ? { "x-oc-session": session } : {}), + }, + body, }); - -describe("gateway on-path flow", () => { - beforeEach(() => { lastAuthToOR = null; lastBodyToOR = null; }); +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 res = await worker.fetch(new Request("https://gw.test/healthz"), env(), ctx()); + 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 session token (401)", async () => { - const res = await worker.fetch(post(), env(), ctx()); + 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 invalid/expired token (401)", async () => { + it("rejects an expired token (401)", async () => { + const { env } = mkEnv(); const now = Math.floor(Date.now() / 1000); - const expired = await mintSessionToken(PRIV, { sub: "ses_x", org: "org_1", agt: "a", iat: now - 10, exp: now - 5 }); - const res = await worker.fetch(post(expired), env(), ctx()); + 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 session token), adds usage.include, passes the body through", async () => { + 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 now = Math.floor(Date.now() / 1000); - const token = await mintSessionToken(PRIV, { sub: "ses_ok", org: "org_1", agt: "a", bud: 1, iat: now, exp: now + 3600 }); - const e = env(); + const { env } = mkEnv(); + const token = await mint(); const c = ctx(); - const res = await worker.fetch(post(token), e, c); + 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 session token never reached OpenRouter + // 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); - // usage.include was injected so OR echoes cost + // 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 }); - // original request fields preserved expect(lastBodyToOR?.model).toBe("anthropic/claude-sonnet-5"); await drain(c); }); - it("enforces the per-session budget ON-PATH: refuses once spend reaches the cap (402)", async () => { - vi.stubGlobal("fetch", mockFetch(0.02)); // $0.02 per call - const now = Math.floor(Date.now() / 1000); - // budget $0.03 → call1 (spent 0<0.03) ok→0.02; call2 (0.02<0.03) ok→0.04; call3 (0.04≥0.03) refused. - const token = await mintSessionToken(PRIV, { sub: "ses_budget", org: "org_1", agt: "a", bud: 0.03, iat: now, exp: now + 3600 }); - const e = env(); - - const c1 = ctx(); const r1 = await worker.fetch(post(token), e, c1); await drain(c1); - const c2 = ctx(); const r2 = await worker.fetch(post(token), e, c2); await drain(c2); - const c3 = ctx(); const r3 = await worker.fetch(post(token), e, c3); await drain(c3); + 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); @@ -140,40 +167,74 @@ describe("gateway on-path flow", () => { expect(refusal.oc.budget_usd).toBeCloseTo(0.03, 5); }); - it("uncapped session (no bud claim) never refuses", async () => { - vi.stubGlobal("fetch", mockFetch(1.0)); - const now = Math.floor(Date.now() / 1000); - const token = await mintSessionToken(PRIV, { sub: "ses_uncapped", org: "org_1", agt: "a", iat: now, exp: now + 3600 }); - const e = env(); - for (let i = 0; i < 3; i++) { const c = ctx(); const r = await worker.fetch(post(token), e, c); await drain(c); expect(r.status).toBe(200); } + 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("fences a superseded lease epoch (401 token_superseded)", async () => { + 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 now = Math.floor(Date.now() / 1000); - const e = env(); // same namespace → same DO for ses_ep across calls - const t2 = await mintSessionToken(PRIV, { sub: "ses_ep", org: "org_1", agt: "a", ep: 2, iat: now, exp: now + 3600 }); - const c2 = ctx(); const r2 = await worker.fetch(post(t2), e, c2); await drain(c2); + 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 mintSessionToken(PRIV, { sub: "ses_ep", org: "org_1", agt: "a", ep: 1, iat: now, exp: now + 3600 }); - const r1 = await worker.fetch(post(t1), e, ctx()); // the old turn's token — superseded + 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 now = Math.floor(Date.now() / 1000); - const token = await mintSessionToken(PRIV, { sub: "ses_cc", org: "org_1", agt: "a", iat: now, exp: now + 3600 }); + 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 req = new Request("https://gw.test/anthropic/v1/messages", { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, body }); - const c = ctx(); const r = await worker.fetch(req, env(), c); await drain(c); + 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 index d8ce67af..1067d6db 100644 --- a/cloudflare-workers/oc-gateway/test/logic.test.ts +++ b/cloudflare-workers/oc-gateway/test/logic.test.ts @@ -1,96 +1,134 @@ -// Pure-logic tests (no Workers runtime needed): EdDSA token crypto, the SessionBudget DO's epoch -// fence + budget gate + idempotency (over a fake DurableObjectState), cache_control safety, and cost -// extraction. The full on-path flow (forward + meter) is exercised by the live integration in -// README.md / scripts/e2e. Run: npx vitest run +// 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, mintSessionToken, verifySessionToken, type SessionClaims } from "../src/token.js"; +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 { SessionBudget } from "../src/budget.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 = {}): SessionClaims => ({ - sub: "ses_abc", org: "org_1", agt: "agt_1", bud: 0.5, ep: 2, iat: now, exp: now + 3600, ...o, +const claims = (o: Partial = {}): DeployClaims => ({ + org: "org_1", agt: "agt_1", ep: 2, iat: now, exp: now + 3600, ...o, }); -describe("session token (EdDSA)", () => { +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 verifySessionToken(publicKeyB64url, await mintSessionToken(privateKey, claims()), now); + const v = await verifyDeployToken(publicKeyB64url, await mintDeployToken(privateKey, claims()), now); expect(v.ok).toBe(true); if (v.ok) { - expect(v.claims.sub).toBe("ses_abc"); expect(v.claims.org).toBe("org_1"); - expect(v.claims.bud).toBe(0.5); + 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 verifySessionToken(b.publicKeyB64url, await mintSessionToken(a.privateKey, claims()), now); + 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 mintSessionToken(privateKey, claims())).split("."); - const v = await verifySessionToken(publicKeyB64url, `${h}.${b64url(claims({ org: "org_evil" }))}.${s}`, now); + 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 verifySessionToken(publicKeyB64url, await mintSessionToken(privateKey, claims({ exp: now - 1 })), now); + 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 mintSessionToken(privateKey, claims())).split("."); - const v = await verifySessionToken(publicKeyB64url, `${b64url({ alg: "none", typ: "JWT" })}.${p}.${s}`, now); + 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("SessionBudget DO — epoch fence + budget gate + idempotency", () => { - 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 (bd: SessionBudget, path: string, body: unknown) => - (await bd.fetch(new Request(`https://do${path}`, { method: "POST", body: JSON.stringify(body) }))).json() as Promise>; +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("fences a stale lease epoch (monotonic)", async () => { - const bd = new SessionBudget(fakeState()); - expect((await call(bd, "/check", { ep: 1 })).allowed).toBe(true); - expect((await call(bd, "/check", { ep: 2 })).allowed).toBe(true); // adopt the newer epoch - const stale = await call(bd, "/check", { ep: 1 }); // the old turn's token is now superseded - expect(stale.fenced).toBe(true); - expect(stale.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("gates on the per-session budget (spent < budget)", async () => { - const bd = new SessionBudget(fakeState()); - const cap = 1_000_000; // $1.00 in µ$ - expect((await call(bd, "/check", { budget_micro: cap, ep: 1 })).allowed).toBe(true); - await call(bd, "/add", { cost_micro: 600_000, idem: "g1" }); - expect((await call(bd, "/check", { budget_micro: cap, ep: 1 })).allowed).toBe(true); // 0.6 < 1.0 - await call(bd, "/add", { cost_micro: 600_000, idem: "g2" }); // now 1.2 > 1.0 - expect((await call(bd, "/check", { budget_micro: cap, ep: 1 })).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 bd = new SessionBudget(fakeState()); - await call(bd, "/add", { cost_micro: 100_000, idem: "gen-x" }); - const second = await call(bd, "/add", { cost_micro: 100_000, idem: "gen-x" }); + 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 bd.fetch(new Request("https://do/state"))).json()) as { spent_micro: number }; + 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", () => { diff --git a/cloudflare-workers/oc-gateway/wrangler.toml b/cloudflare-workers/oc-gateway/wrangler.toml index 827313e2..8b103ca1 100644 --- a/cloudflare-workers/oc-gateway/wrangler.toml +++ b/cloudflare-workers/oc-gateway/wrangler.toml @@ -3,23 +3,31 @@ main = "src/index.ts" compatibility_date = "2025-06-01" compatibility_flags = ["nodejs_compat"] -# The per-session spend counter + hard-limit gate (design 013 §4/§8). +# 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 = "SESSION_BUDGET" -class_name = "SessionBudget" +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 = ["SessionBudget"] +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. From 53a473caa7c8b1ce5f9b2cb09bfe2ae89c7a3bfb Mon Sep 17 00:00:00 2001 From: Igor Zalutski Date: Tue, 7 Jul 2026 23:20:30 +0100 Subject: [PATCH 05/25] fix(@opencomputer/flue): 3 W6-surfaced package defects W6 live bring-up hit three defects W4's bundle-grep acceptance missed. Fixed at the package layer so the scaffolded starter needs no workarounds. 1. DEFAULT_MODEL dot->dash. `anthropic/claude-haiku-4.5` is absent from pi-ai's model catalog, so pi-ai can't derive max output tokens and defaults max_tokens=1 -> empty completions. Use the catalog id `anthropic/claude-haiku-4-5` (dashes; OpenRouter routes it too). 2. ctx.env empty on the CF build. On `flue build --target cloudflare` the real Worker bindings live on the ambient `cloudflare:workers` env, not the per-agent `ctx.env` Flue threads in (that is empty for OC bindings), so OC_GATEWAY was unset -> the anthropic provider never registered ("Unknown model specifier"). New cf-env.ts reads the ambient env via a guarded lazy import (falls back to the passed env for local dev / node); useOcGateway + ocSandbox read through it. 3. Default `@opencomputer/flue/app` 500s. app.ts mounted flue() from `@flue/runtime/routing`; the generated CF entry seeds the runtime via configureFlueRuntime from `@flue/runtime/internal` and its no-app.ts path builds the app with createDefaultFlueApp() from that same entry. Compose via createDefaultFlueApp() so flue()'s module-scoped runtimeConfig is the instance the build configures (+ keep /health and installOcObserver) -> no "flue() route invoked before runtime was configured". Verified: scaffolded starter `flue build --target cloudflare` + wrangler dev -> GET /health 200; POST /agents turn 202 -> model call reaches the gateway with model=claude-haiku-4-5, max_tokens=64000 (not 1); full turn settles completed with a non-empty agent message. Co-Authored-By: Claude Opus 4.8 --- sdks/flue/src/app.ts | 28 ++++++++++++++++------- sdks/flue/src/cf-env.ts | 33 +++++++++++++++++++++++++++ sdks/flue/src/cloudflare-workers.d.ts | 6 +++++ sdks/flue/src/gateway.ts | 22 ++++++++++++------ sdks/flue/src/sandbox.ts | 13 ++++++++--- 5 files changed, 84 insertions(+), 18 deletions(-) create mode 100644 sdks/flue/src/cf-env.ts create mode 100644 sdks/flue/src/cloudflare-workers.d.ts diff --git a/sdks/flue/src/app.ts b/sdks/flue/src/app.ts index 342402b8..5159b911 100644 --- a/sdks/flue/src/app.ts +++ b/sdks/flue/src/app.ts @@ -1,18 +1,30 @@ -// The default OC hosting app. A scaffolded starter uses this as its `src/app.ts` (or the CF build -// generates an equivalent when no app.ts exists). It mounts Flue's routes, adds the `/health` probe the -// OC deploy/activate step needs (stock Flue exposes NO health route — Spike B finding), and installs the -// telemetry forwarder. Apps that own their `app.ts` instead `import '@opencomputer/flue/wire'` (§wire). +// The default OC hosting app. A scaffolded starter uses this as its `src/app.ts`. It composes the +// SAME app Flue's Cloudflare build generates for the no-`app.ts` case, then adds the `/health` probe +// the OC deploy/activate step needs (stock Flue exposes NO health route — Spike B finding) and installs +// the telemetry forwarder. Apps that own their `app.ts` instead `import '@opencomputer/flue/wire'` (§wire). +// +// WHY `createDefaultFlueApp()` from `@flue/runtime/internal` (and NOT `flue()` from +// `@flue/runtime/routing`): `flue()`'s route handlers read the module-scoped `runtimeConfig` at REQUEST +// time, which the generated Cloudflare entry sets via `configureFlueRuntime(...)` (imported from +// `@flue/runtime/internal`) at module load. The generated entry's no-`app.ts` path builds its app with +// `createDefaultFlueApp()` — the exact same `@flue/runtime/internal` entry — so the mounted `flue()` and +// the `configureFlueRuntime()` that seeds it share one module instance and requests never hit +// "flue() route invoked before runtime was configured". A `src/app.ts` that instead mounted `flue()` +// from `@flue/runtime/routing` (a different published entry) risked resolving a second `@flue/runtime` +// module instance whose `runtimeConfig` is never configured → every request 500s. Composing via the +// build's own entry keeps this app on the configured instance. // // Default export = a `Fetchable` (Hono qualifies), per Flue's routing contract. -import { Hono } from "hono"; -import { flue } from "@flue/runtime/routing"; +import { createDefaultFlueApp } from "@flue/runtime/internal"; import { installOcObserver } from "./observe.js"; installOcObserver(); -const app = new Hono(); +// createDefaultFlueApp() mounts flue() at '/' and installs Flue's canonical notFound/onError envelopes. +// Adding a path-specific '/health' route afterwards is safe — flue() only registers its own concrete +// paths (/agents, /workflows, /runs, /channels), so GET /health matches this handler directly. +const app = createDefaultFlueApp(); app.get("/health", (c) => c.json({ status: "ok" })); -app.route("/", flue()); export default app; diff --git a/sdks/flue/src/cf-env.ts b/sdks/flue/src/cf-env.ts new file mode 100644 index 00000000..c29a6ad2 --- /dev/null +++ b/sdks/flue/src/cf-env.ts @@ -0,0 +1,33 @@ +// Ambient Cloudflare-Workers env access (design 013 §4). On the `flue build --target cloudflare` +// build the real Worker bindings (`OC_GATEWAY`, `OC_SESSION_TOKEN`, `OC_SANDBOX_*`, `OC_INGEST`, …) +// live on the AMBIENT env exported by `cloudflare:workers` — the same one Flue's generated entry reads +// (`import { env } from 'cloudflare:workers'`). The per-agent `ctx.env` Flue threads into the +// initializer is EMPTY for these bindings, so OC helpers must read the ambient env instead. +// +// `cloudflare:workers` only resolves inside workerd, so importing it statically would break local +// `flue dev` on the node target and the package's own vitest. Load it lazily + guarded: on CF the +// dynamic import resolves and `ambientEnv` is populated during module graph evaluation (before any +// request); everywhere else the import rejects, is caught, and callers fall back to the passed env. + +let ambientEnv: Record | undefined; +try { + // `@vite-ignore` so consumer/test bundlers don't try to statically resolve the workerd built-in; + // on CF this is a runtime import of the ambient module, off CF it throws and we fall back. + const mod = (await import(/* @vite-ignore */ "cloudflare:workers")) as { + env?: Record; + }; + ambientEnv = mod.env; +} catch { + ambientEnv = undefined; +} + +/** + * Resolve the effective OC env: the Cloudflare ambient bindings layered over `fallback` (ambient wins). + * On CF this returns the real Worker bindings even though `ctx.env` is empty; off CF (local dev / node / + * tests) it returns `fallback` unchanged so an explicitly-passed env still works. + */ +export function ocResolveEnv>(fallback: T | undefined): T { + const base = (fallback ?? {}) as T; + if (!ambientEnv) return base; + return { ...base, ...ambientEnv } as T; +} diff --git a/sdks/flue/src/cloudflare-workers.d.ts b/sdks/flue/src/cloudflare-workers.d.ts new file mode 100644 index 00000000..3102313c --- /dev/null +++ b/sdks/flue/src/cloudflare-workers.d.ts @@ -0,0 +1,6 @@ +// Minimal ambient type for the workerd-only `cloudflare:workers` virtual module, so the guarded +// dynamic import in `cf-env.ts` typechecks without pulling in `@cloudflare/workers-types`. The real +// module (present only on the `--target cloudflare` build) exports the ambient Worker `env` bindings. +declare module "cloudflare:workers" { + export const env: Record; +} diff --git a/sdks/flue/src/gateway.ts b/sdks/flue/src/gateway.ts index 486ae93a..d9c3bcae 100644 --- a/sdks/flue/src/gateway.ts +++ b/sdks/flue/src/gateway.ts @@ -12,10 +12,14 @@ import { registerProvider } from "@flue/runtime"; import type { AgentInitializerContext, AgentRouteHandler } from "@flue/runtime"; +import { ocResolveEnv } from "./cf-env.js"; /** Default managed model — MUST be prompt-caching-safe (Constraint): `claude-3-haiku` fails via - * OpenRouter→Bedrock; `claude-haiku-4.5` works. Cheap + caching-safe for the scaffolded starter. */ -export const DEFAULT_MODEL = "anthropic/claude-haiku-4.5"; + * OpenRouter→Bedrock; `claude-haiku-4-5` works. Use the pi-ai CATALOG id with DASHES + * (`claude-haiku-4-5`), never a dot (`claude-haiku-4.5`): the dotted id is absent from pi-ai's model + * catalog, so pi-ai can't derive the model's max output tokens and defaults `max_tokens` to 1 → empty + * completions. The dashed id resolves in the catalog and OpenRouter routes it too. Cheap + caching-safe. */ +export const DEFAULT_MODEL = "anthropic/claude-haiku-4-5"; export interface OcEnv { /** Deployed gateway Worker base URL (set per tenant script by the OC deploy). */ @@ -30,16 +34,20 @@ export interface OcEnv { /** * Point the managed `anthropic` provider at the OC gateway. **Call this INSIDE the `defineAgent` * initializer** — top-level module code is stripped by the CF build (proven in 1a), and the initializer - * body runs per harness init with `env` available. No-op when `OC_GATEWAY` is unset (local `flue dev` - * falls through to pi-ai's env-var key lookup). `anthropic` is a catalog id, so `baseUrl` alone rehydrates - * the wire protocol. + * body runs per harness init. Reads the CF ambient env (`cloudflare:workers`), not `ctx.env`: on the + * `--target cloudflare` build the real Worker bindings live on the ambient env and `ctx.env` is empty + * for them (Flue's generated entry threads `instance.env`, which lacks the OC bindings), so reading + * `ctx.env` alone would leave `OC_GATEWAY` unset and the provider unregistered ("Unknown model + * specifier"). No-op when `OC_GATEWAY` is unset (local `flue dev` falls through to pi-ai's env-var key + * lookup). `anthropic` is a catalog id, so `baseUrl` alone rehydrates the wire protocol. */ export function useOcGateway(ctx: AgentInitializerContext): void { - const gw = ctx.env.OC_GATEWAY; + const env = ocResolveEnv(ctx.env); + const gw = env.OC_GATEWAY; if (!gw) return; registerProvider("anthropic", { baseUrl: `${gw.replace(/\/+$/, "")}/anthropic`, - ...(ctx.env.OC_SESSION_TOKEN ? { apiKey: ctx.env.OC_SESSION_TOKEN } : {}), + ...(env.OC_SESSION_TOKEN ? { apiKey: env.OC_SESSION_TOKEN } : {}), }); } diff --git a/sdks/flue/src/sandbox.ts b/sdks/flue/src/sandbox.ts index 99e4b0c2..c66f835d 100644 --- a/sdks/flue/src/sandbox.ts +++ b/sdks/flue/src/sandbox.ts @@ -14,6 +14,7 @@ import { createSandboxSessionEnv } from "@flue/runtime"; import type { SandboxApi, SandboxFactory, FileStat, ShellResult, SessionEnv } from "@flue/runtime"; import type { OcEnv } from "./gateway.js"; +import { ocResolveEnv } from "./cf-env.js"; /** Constant workspace cwd (matches the OC session contract — flue resolves skills at `${cwd}/.agents/skills`). */ export const WORKSPACE_CWD = "/workspace"; @@ -108,16 +109,22 @@ async function resolveSandboxId(env: OcSandboxEnv, sessionId: string): Promise { - if (!env.OC_SANDBOX_API && !env.OC_SANDBOX_ID) { + const resolved = ocResolveEnv(env); + if (!resolved.OC_SANDBOX_API && !resolved.OC_SANDBOX_ID) { throw new Error("[oc-flue] ocSandbox: set OC_SANDBOX_API (+ OC_SESSION_TOKEN) or OC_SANDBOX_ID — the OC sandbox binding is not configured."); } - const sandboxId = await resolveSandboxId(env, id); - const api = new OcSandboxApi((env.OC_SANDBOX_API ?? "").replace(/\/+$/, ""), env.OC_SESSION_TOKEN ?? "", sandboxId); + const sandboxId = await resolveSandboxId(resolved, id); + const api = new OcSandboxApi((resolved.OC_SANDBOX_API ?? "").replace(/\/+$/, ""), resolved.OC_SESSION_TOKEN ?? "", sandboxId); return createSandboxSessionEnv(api, cwd); }, }; From eb87aafbccd80314e8c680047db98b044a35b2af Mon Sep 17 00:00:00 2001 From: Igor Zalutski Date: Tue, 7 Jul 2026 23:55:46 +0100 Subject: [PATCH 06/25] =?UTF-8?q?web:=20W11=20sessions=20dashboard=20?= =?UTF-8?q?=E2=80=94=20runtime=20+=20spend=20on=20list,=20richer=20viewer,?= =?UTF-8?q?=20turns=20health=20panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the existing Sessions UI (runtime-agnostic; renders flue and brain-box sessions alike): - Sessions list: Runtime badge (flue accented) + Spend column, from the session's agent_snapshot.runtime + usage. Adds agent_snapshot to the web SessionSchema and a display-only flue label to lib/runtimes (kept OUT of the create picker). - Conversation viewer: distinct rendering for agent.thinking, tool results (exec.completed AND flue's tool.result), turn.failed, and a body-spill affordance (content_ref/body_truncated). Keeps the Conversation/All level filter + live SSE tail. - Submission health: new turns panel (GET /v3/sessions/:id/turns) — per-turn state, yield_reason, timing, usage, error. Recovery stays cancel + steer + webhook redeliver (no fake retry — v3 has no turn re-drive endpoint). - Per-session spend: Spend/Tokens/Events metric cards on detail, derived defensively from the opaque usage object (flue meters at the gateway → renders '—'). - Data layer: getSessionTurns/getSessionResult wrappers + schemas; lib/usage helpers; preview mocks extended so the new surfaces render with no backend. Tier-2 note: /v3 has no streaming-parts/delta mode — tiering is the level field (internal/progress/user); 'Tier-2 fidelity' = faithful internal-level trace. Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/api/client.ts | 15 ++++ web/src/api/mock.ts | 81 ++++++++++++++++-- web/src/api/schemas.ts | 36 +++++++- web/src/components/runtime-badge.tsx | 36 ++++++++ web/src/components/session-turns.tsx | 114 +++++++++++++++++++++++++ web/src/lib/runtimes.ts | 19 +++++ web/src/lib/usage.ts | 54 ++++++++++++ web/src/pages/SessionDetail.tsx | 123 +++++++++++++++++++++++++-- web/src/pages/Sessions.tsx | 17 ++++ 9 files changed, 477 insertions(+), 18 deletions(-) create mode 100644 web/src/components/runtime-badge.tsx create mode 100644 web/src/components/session-turns.tsx create mode 100644 web/src/lib/usage.ts diff --git a/web/src/api/client.ts b/web/src/api/client.ts index c298e172..0923fbdc 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -33,8 +33,10 @@ export type { SkillItem, AgentDeploy, Session, + AgentSnapshot, SessionEvent, Turn, + SessionResult, Destination, Delivery, SandboxWebhook, @@ -736,6 +738,19 @@ export const getSessionEvents = (id: string, level?: string) => S.SessionEventListSchema, ).then((r) => r.data) +// Turns — the per-submission execution records behind a session (state, timing, +// usage, error). Read-only; powers the submission-health panel. Newest first. +export const getSessionTurns = (id: string) => + apiFetch( + `/v3/sessions/${id}/turns`, + {}, + S.SessionTurnListSchema, + ).then((r) => r.data) + +// The latest turn + its result event (if the turn produced one). +export const getSessionResult = (id: string) => + apiFetch(`/v3/sessions/${id}/result`, {}, S.SessionResultSchema) + // Steer — post a user message into a session. export const sendMessage = ( id: string, diff --git a/web/src/api/mock.ts b/web/src/api/mock.ts index 991e68fb..31173625 100644 --- a/web/src/api/mock.ts +++ b/web/src/api/mock.ts @@ -657,10 +657,14 @@ const credentials = [ const sessions = [ { + // A flue (CF-native) session — meters at the gateway, so its `usage` is empty here + // (spend renders "—"); the runtime badge is accented to set it apart from brain-box. id: 'ses_a1b2c3', status: 'running', agent_id: 'agt_3kf9xz', + agent_snapshot: { runtime: 'flue', model: 'anthropic/claude-haiku-4-5' }, head: 24, + usage: {}, created_at: at(0, 1), last_turn: { state: 'running' }, sandboxes: { brain: 'sbx_a1b2c3d4e5', hands: 'sbx_f6g7h8i9j0' }, @@ -669,7 +673,9 @@ const sessions = [ id: 'ses_d4e5f6', status: 'awaiting_input', agent_id: 'agt_3kf9xz', + agent_snapshot: { runtime: 'claude', model: 'anthropic/claude-sonnet-5' }, head: 12, + usage: { cost_usd: 0.0231, input_tokens: 8200, output_tokens: 640 }, created_at: at(0, 3), last_turn: { state: 'ok', yield_reason: 'needs_input' }, }, @@ -677,7 +683,10 @@ const sessions = [ id: 'ses_g7h8i9', status: 'idle', agent_id: 'agt_7mq2aa', + agent_snapshot: { runtime: 'pi', model: 'anthropic/claude-sonnet-5' }, head: 41, + // No cost reported → the spend column falls back to a token total. + usage: { input_tokens: 15000, output_tokens: 2200 }, created_at: at(1, 2), last_turn: { state: 'ok', yield_reason: 'completed' }, }, @@ -685,7 +694,9 @@ const sessions = [ id: 'ses_j1k2l3', status: 'failed', agent_id: 'agt_3kf9xz', + agent_snapshot: { runtime: 'codex', model: 'openai/gpt-5.3-codex' }, head: 8, + usage: { cost_usd: 0.11 }, created_at: at(2, 5), last_turn: { state: 'error', yield_reason: 'error' }, }, @@ -693,7 +704,9 @@ const sessions = [ id: 'ses_m4n5o6', status: 'archived', agent_id: 'agt_7mq2aa', + agent_snapshot: { runtime: 'claude', model: 'anthropic/claude-opus-4-8' }, head: 60, + usage: { cost_usd: 1.42, input_tokens: 320000, output_tokens: 18400 }, created_at: at(4, 1), last_turn: { state: 'ok', yield_reason: 'completed' }, }, @@ -722,6 +735,17 @@ const sessionEvents = [ { id: 'evt_3', seq: 3, + type: 'agent.thinking', + level: 'progress', + actor: { type: 'agent', display: 'PR Reviewer' }, + body: { + text: 'Fetch the PR head, then read the auth middleware diff before commenting.', + }, + ts: at(0, 1), + }, + { + id: 'evt_4', + seq: 4, type: 'tool.call', level: 'progress', actor: { type: 'runtime' }, @@ -729,8 +753,21 @@ const sessionEvents = [ ts: at(0, 1), }, { - id: 'evt_4', - seq: 4, + id: 'evt_5', + seq: 5, + type: 'exec.completed', + level: 'progress', + actor: { type: 'runtime' }, + body: { + tool: 'bash', + summary: 'fetched pull/412/head → FETCH_HEAD', + duration_ms: 412, + }, + ts: at(0, 1), + }, + { + id: 'evt_6', + seq: 6, type: 'agent.message', level: 'user', actor: { type: 'agent', display: 'PR Reviewer' }, @@ -740,8 +777,8 @@ const sessionEvents = [ ts: at(0, 1), }, { - id: 'evt_5', - seq: 5, + id: 'evt_7', + seq: 7, type: 'turn.completed', level: 'user', actor: { type: 'runtime' }, @@ -750,6 +787,29 @@ const sessionEvents = [ }, ] +// Turns power the submission-health panel (GET /v3/sessions/:id/turns), newest first. +const sessionTurns = [ + { + id: 'trn_2', + state: 'ok', + yield_reason: 'needs_input', + started_at: at(0, 1), + completed_at: at(0, 1), + active_seconds: 6.4, + usage: { cost_usd: 0.0121, input_tokens: 4200, output_tokens: 310 }, + }, + { + id: 'trn_1', + state: 'error', + yield_reason: 'error', + started_at: at(0, 2), + completed_at: at(0, 2), + active_seconds: 2.1, + usage: {}, + error: { code: 'provision_infra', message: 'brain sandbox failed to start' }, + }, +] + const destinations = [ { id: 'dst_1', @@ -767,8 +827,8 @@ const deliveries = [ { id: 'dlv_1', destination: 'dst_1', - event_id: 'evt_5', - event_seq: 5, + event_id: 'evt_7', + event_seq: 7, status: 'delivered', attempts: 1, last_attempt_at: at(0, 1), @@ -778,8 +838,8 @@ const deliveries = [ { id: 'dlv_2', destination: 'dst_1', - event_id: 'evt_4', - event_seq: 4, + event_id: 'evt_6', + event_seq: 6, status: 'failed', attempts: 3, last_attempt_at: at(0, 1), @@ -876,6 +936,11 @@ const ROUTES: Array<[RegExp, Handler]> = [ [/^\/v3\/agents$/, () => ({ data: agents })], [/^\/v3\/credentials$/, () => ({ data: credentials })], [/^\/v3\/sessions\/[^/]+\/events/, () => ({ data: sessionEvents })], + [/^\/v3\/sessions\/[^/]+\/turns$/, () => ({ data: sessionTurns })], + [ + /^\/v3\/sessions\/[^/]+\/result$/, + () => ({ last_turn: sessionTurns[0], result: sessionEvents[6] }), + ], [/^\/v3\/sessions\/[^/]+\/destinations$/, () => ({ data: destinations })], [/^\/v3\/sessions\/[^/]+\/deliveries$/, () => ({ data: deliveries })], [/^\/v3\/sessions\/[^/]+$/, () => sessions[0]], diff --git a/web/src/api/schemas.ts b/web/src/api/schemas.ts index 05d8e479..661890bc 100644 --- a/web/src/api/schemas.ts +++ b/web/src/api/schemas.ts @@ -487,10 +487,23 @@ export const SlackManifestResponseSchema = z.object({ status: z.string(), }) +// The pinned effective agent tuple (design 009 §3.5) the session ran with. `runtime` +// is what distinguishes flue from the brain-box runtimes (claude/codex/pi) in read views. +export const AgentSnapshotSchema = z.object({ + runtime: z.string().nullish(), + model: z.string().nullish(), + prompt_hash: z.string().nullish(), + revision: z.union([z.string(), z.number()]).nullish(), + agent_revision_number: z.number().nullish(), + digest: z.string().nullish(), + skill_bundle_digest: z.string().nullish(), +}) + export const SessionSchema = z.object({ id: z.string(), status: z.string(), agent_id: z.string().nullable().optional(), + agent_snapshot: AgentSnapshotSchema.nullish(), credential_id: z.string().nullable().optional(), head: z.coerce.number().optional(), // current event seq; API returns it as a string ("0") last_turn: record.nullish(), @@ -525,7 +538,11 @@ export const SessionEventSchema = z.object({ level: z.string(), actor: ActorSchema.optional(), body: z.unknown().optional(), - content_ref: z.string().nullish(), // set when body spilled to blob storage + // Set together when the body spilled to blob storage (body > 32KB): the inline + // `body` is absent/partial, `content_ref` points at the blob, `body_bytes` is the size. + content_ref: z.string().nullish(), + body_truncated: z.boolean().nullish(), + body_bytes: z.number().nullish(), refs: record.nullish(), source: z.string().optional(), turn_id: z.string().nullable().optional(), @@ -544,8 +561,19 @@ export const TurnSchema = z.object({ attempt: z.number().optional(), started_at: z.string().nullable().optional(), completed_at: z.string().nullable().optional(), - usage: record.optional(), - error: z.string().nullable().optional(), + active_seconds: z.number().nullish(), + result_event_id: z.string().nullish(), + usage: record.nullish(), + error: z.unknown().nullish(), // server serializes the error as an opaque object, not a string +}) +export const SessionTurnListSchema = z.object({ + data: z.array(TurnSchema), + next_cursor: z.string().nullish(), +}) +// GET /v3/sessions/:id/result → the latest turn + its result event (if any). +export const SessionResultSchema = z.object({ + last_turn: TurnSchema.nullable(), + result: SessionEventSchema.nullable(), }) export const DestinationSchema = z.object({ @@ -583,8 +611,10 @@ export type Credential = z.infer export type SlackConnection = z.infer export type SlackManifestResponse = z.infer export type Session = z.infer +export type AgentSnapshot = z.infer export type SessionEvent = z.infer export type Turn = z.infer +export type SessionResult = z.infer export type Destination = z.infer export type Delivery = z.infer diff --git a/web/src/components/runtime-badge.tsx b/web/src/components/runtime-badge.tsx new file mode 100644 index 00000000..82dc764b --- /dev/null +++ b/web/src/components/runtime-badge.tsx @@ -0,0 +1,36 @@ +import { Bot, Cloud, type LucideIcon } from 'lucide-react' +import { runtimeLabel } from '@/lib/runtimes' +import { cn } from '@/lib/utils' + +// Runtime is a category, not a health state — so it gets a quiet, neutral pill (not a +// status tone). `flue` (the CF-native durable path) carries a subtle accent + a distinct +// icon so it reads apart from the brain-box runtimes (claude/codex/pi) at a glance. +const ICON: Record = { + flue: Cloud, +} + +export function RuntimeBadge({ + runtime, + className, +}: { + runtime: string | null | undefined + className?: string +}) { + if (!runtime) return + const Icon = ICON[runtime] ?? Bot + const isFlue = runtime === 'flue' + return ( + + + {runtimeLabel(runtime)} + + ) +} diff --git a/web/src/components/session-turns.tsx b/web/src/components/session-turns.tsx new file mode 100644 index 00000000..7ba91ffd --- /dev/null +++ b/web/src/components/session-turns.tsx @@ -0,0 +1,114 @@ +import { useQuery } from '@tanstack/react-query' +import { getSessionTurns, type Turn } from '@/api/client' +import { Panel } from '@/components/panel' +import { StatusBadge } from '@/components/status-badge' +import { ApiHint } from '@/components/api-hint' +import { formatSpend } from '@/lib/usage' + +// A turn's `error` is an opaque object (or a string); pull a one-line message defensively. +function errorMessage(error: unknown): string | null { + if (error == null) return null + if (typeof error === 'string') return error + if (typeof error === 'number' || typeof error === 'boolean') { + return String(error) + } + if (typeof error === 'object') { + const e = error as Record + const msg = e.message ?? e.error ?? e.detail + const code = typeof e.code === 'string' ? e.code : null + if (typeof msg === 'string') return code ? `${code}: ${msg}` : msg + if (code) return code + try { + return JSON.stringify(error) + } catch { + return 'error' + } + } + return 'error' +} + +// Wall-clock duration of a turn: prefer active_seconds (billed compute), else derive +// from started/completed timestamps. Returns a compact label like "4.2s" / "1m 03s". +function duration(turn: Turn): string | null { + let secs: number | null = + typeof turn.active_seconds === 'number' ? turn.active_seconds : null + if (secs == null && turn.started_at && turn.completed_at) { + const ms = Date.parse(turn.completed_at) - Date.parse(turn.started_at) + if (Number.isFinite(ms) && ms >= 0) secs = ms / 1000 + } + if (secs == null) return null + if (secs < 60) return `${secs.toFixed(1)}s` + const m = Math.floor(secs / 60) + const s = Math.round(secs % 60) + return `${m}m ${String(s).padStart(2, '0')}s` +} + +export function SessionTurns({ + sessionId, + active, +}: { + sessionId: string + active: boolean +}) { + const { data: turns, isLoading } = useQuery({ + queryKey: ['session-turns', sessionId], + queryFn: () => getSessionTurns(sessionId), + // Keep the health view current while the session is doing work; idle when settled. + refetchInterval: active ? 5000 : false, + }) + + if (!isLoading && (turns?.length ?? 0) === 0) return null // nothing to show for a session with no turns yet + + return ( + +
+

Submission health

+ +
+ + {isLoading ? ( +
Loading…
+ ) : ( +
    + {(turns ?? []).map((t) => { + const err = t.state === 'error' ? errorMessage(t.error) : null + const dur = duration(t) + const spend = formatSpend(t.usage) + return ( +
  • +
    + + + {t.id} + + {t.yield_reason ? ( + + {t.yield_reason.replace(/_/g, ' ')} + + ) : null} + + {dur ? {dur} : null} + {spend !== '—' ? {spend} : null} + +
    + {err ? ( +

    + {err} +

    + ) : null} +
  • + ) + })} +
+ )} +
+ ) +} diff --git a/web/src/lib/runtimes.ts b/web/src/lib/runtimes.ts index 8772aca5..369b8737 100644 --- a/web/src/lib/runtimes.ts +++ b/web/src/lib/runtimes.ts @@ -86,6 +86,25 @@ export const PROVIDER_KEY_FIELDS: Record = { + claude: 'Claude', + codex: 'Codex', + pi: 'Pi', + flue: 'Flue', + hands: 'Hands', +} + +// A stable label for any runtime string (display-only — does NOT gate the create picker). +export function runtimeLabel(runtime: string | null | undefined): string { + if (!runtime) return '—' + return RUNTIME_LABELS[runtime] ?? runtime +} + export const runtimeOptions = RUNTIMES.map((r) => ({ value: r.value, label: r.label, diff --git a/web/src/lib/usage.ts b/web/src/lib/usage.ts new file mode 100644 index 00000000..b1ab36bd --- /dev/null +++ b/web/src/lib/usage.ts @@ -0,0 +1,54 @@ +// A session's / turn's `usage` is an opaque, runtime-authored object — its shape +// varies by runtime (brain-box runtimes report token counts + sometimes a cost; +// flue currently emits `{}`, with spend metered authoritatively at the gateway). +// Extract the human-meaningful bits defensively — never assume a field is present. + +export type UsageLike = Record | null | undefined + +function num(v: unknown): number | null { + return typeof v === 'number' && Number.isFinite(v) ? v : null +} + +export function hasUsage(usage: UsageLike): boolean { + return !!usage && Object.keys(usage).length > 0 +} + +// Cost in USD if the runtime reported one — several field spellings appear across runtimes. +export function usageCostUsd(usage: UsageLike): number | null { + if (!usage) return null + const u = usage + for (const k of ['cost_usd', 'total_cost_usd', 'cost', 'total_cost', 'usd']) { + const n = num(u[k]) + if (n !== null) return n + } + return null +} + +// Total tokens, summing input/output when a direct total isn't given. +export function usageTokens(usage: UsageLike): number | null { + if (!usage) return null + const u = usage + const total = num(u.total_tokens) ?? num(u.tokens) + if (total !== null) return total + const inp = num(u.input_tokens) ?? num(u.prompt_tokens) + const out = num(u.output_tokens) ?? num(u.completion_tokens) + if (inp !== null || out !== null) return (inp ?? 0) + (out ?? 0) + return null +} + +// Sub-cent per-session spend is common; show enough precision to stay non-zero. +export function formatUsd(n: number): string { + if (n === 0) return '$0' + if (n < 0.01) return `$${n.toFixed(4)}` + if (n < 1) return `$${n.toFixed(3)}` + return `$${n.toFixed(2)}` +} + +// A compact one-line spend label for a table cell / metric: "$0.0230", "1,240 tok", or "—". +export function formatSpend(usage: UsageLike): string { + const cost = usageCostUsd(usage) + if (cost !== null) return formatUsd(cost) + const tok = usageTokens(usage) + if (tok !== null) return `${tok.toLocaleString()} tok` + return '—' +} diff --git a/web/src/pages/SessionDetail.tsx b/web/src/pages/SessionDetail.tsx index b6c0d3d7..67b8da4d 100644 --- a/web/src/pages/SessionDetail.tsx +++ b/web/src/pages/SessionDetail.tsx @@ -1,7 +1,16 @@ import { useEffect, useMemo, useState } from 'react' import { Link, useParams } from 'react-router-dom' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { ArrowLeft, Send, Wrench, CircleAlert } from 'lucide-react' +import { + ArrowLeft, + Send, + Wrench, + CircleAlert, + Brain, + CheckCircle2, + XCircle, + FileWarning, +} from 'lucide-react' import { notifyError } from '@/lib/errors' import { useHalted } from '@/hooks/useHalted' import { @@ -19,12 +28,16 @@ import { Button } from '@/components/ui/button' import { ChatTextarea } from '@/components/chat-textarea' import { Skeleton } from '@/components/ui/skeleton' import { StatusBadge } from '@/components/status-badge' +import { RuntimeBadge } from '@/components/runtime-badge' +import { MetricCard } from '@/components/metric-card' +import { SessionTurns } from '@/components/session-turns' import { EmptyState } from '@/components/empty-state' import { ConfirmDialog } from '@/components/confirm-dialog' import { SessionWebhooks } from '@/components/session-webhooks' import { ApiHint } from '@/components/api-hint' import { MessagesSquare } from 'lucide-react' import { cn } from '@/lib/utils' +import { formatSpend, usageTokens } from '@/lib/usage' // body is unknown (inline JSON ≤32KB); pull the common shapes defensively. function bodyText(ev: SessionEvent): string | null { @@ -44,6 +57,30 @@ function toolSummary(ev: SessionEvent): string { const input = typeof b.input === 'string' ? b.input : '' return input ? `${tool} · ${input}` : tool } +// A tool RESULT — brain-box emits `exec.completed`, the flue tailer emits `tool.result`; +// both land here so flue + brain-box render identically. +function isToolResult(ev: SessionEvent): boolean { + return ev.type === 'exec.completed' || ev.type === 'tool.result' +} +function toolResult(ev: SessionEvent): { text: string; isError: boolean } { + const b = (ev.body ?? {}) as Record + const tool = typeof b.tool === 'string' ? b.tool : 'tool' + const isError = b.is_error === true || b.error != null + const summary = + (typeof b.summary === 'string' && b.summary) || + (typeof b.output === 'string' && b.output) || + (typeof b.text === 'string' && b.text) || + '' + const dur = typeof b.duration_ms === 'number' ? ` · ${b.duration_ms}ms` : '' + return { text: summary ? `${tool} → ${summary}${dur}` : `${tool} →${dur}`, isError } +} +// The body spilled to blob storage (event > 32KB) — surface an affordance instead of +// rendering an empty bubble. +function truncationNote(ev: SessionEvent): string | null { + if (!ev.body_truncated && !ev.content_ref) return null + const kb = ev.body_bytes ? ` (${Math.round(ev.body_bytes / 1024)} KB)` : '' + return `Output too large to inline${kb} — stored in blob` +} function humanizeType(t: string): string { return t.replace(/[._]/g, ' ').replace(/^\w/, (c) => c.toUpperCase()) } @@ -202,11 +239,14 @@ export default function SessionDetail() {
-
+
{sessionId} + {session?.agent_snapshot?.runtime ? ( + + ) : null}

{session?.head ?? 0} events · created{' '} @@ -270,6 +310,21 @@ export default function SessionDetail() {

+ {/* Spend / usage — derived from the session's opaque usage object (no dedicated + spend endpoint). Tokens shown only when the runtime reports them (flue meters + at the gateway and reports none here). */} +
+ + + +
+ {/* Event stream */}
@@ -367,6 +422,15 @@ export default function SessionDetail() {
+ + + + {text ?? trunc} + + ) + } // Conversation messages — the signal. if (ev.type === 'user.message' || ev.type === 'agent.message') { @@ -408,7 +484,16 @@ function EventRow({ ev }: { ev: SessionEvent }) { #{ev.seq}
-

{text}

+ {text ? ( +

+ {text} +

+ ) : trunc ? ( +

+ + {trunc} +

+ ) : null} {outOfCredits && ( +
+ + {rows.length ? ( +
+ {rows.map((row, index) => ( +
+ + setRows((old) => + old.map((item) => + item.id === row.id + ? { + ...item, + name: event.target.value.toUpperCase(), + } + : item, + ), + ) + } + placeholder="VARIABLE_NAME" + className="font-mono sm:w-2/5" + autoCapitalize="characters" + autoCorrect="off" + spellCheck={false} + /> + + setRows((old) => + old.map((item) => + item.id === row.id + ? { ...item, value: event.target.value } + : item, + ), + ) + } + placeholder="Value" + className="font-mono sm:flex-1" + spellCheck={false} + /> + +
+ ))} +
+ ) : ( +

+ No variables. This Worker receives only OpenComputer-managed + bindings and secrets. +

+ )} + + + +