diff --git a/.agents/skills/bunny-cli/SKILL.md b/.agents/skills/bunny-cli/SKILL.md new file mode 100644 index 000000000..ea41e01f1 --- /dev/null +++ b/.agents/skills/bunny-cli/SKILL.md @@ -0,0 +1,124 @@ +--- +name: bunny-cli +description: Manage bunny.net resources from the command line (databases, DNS, Edge Storage, Edge Scripts, static sites, sandboxes, Stream video libraries and imports, authentication, and raw API requests) and query a Bunny Database from application code with `@bunny.net/database-client`. Use when working with bunny.net (pullzones, DNS zones/records, databases, Edge Storage zones and files, Edge Scripts, Magic Containers, static-site hosting/deploys, cloud sandboxes, Stream video libraries, uploads, collections, captions, and transcription, moving a video library into Bunny Stream from Vimeo, S3, Wistia, Mux, Cloudflare Stream, JW Player, or Brightcove), invoking the `bunny` CLI, or making authenticated API calls to api.bunny.net. +--- + +# bunny.net CLI Skill + +The bunny.net CLI (`bunny`) manages bunny.net resources from the command line. Use `bunny --help` for full flag details on any command. + +## Critical: Authentication + +Commands require an API key. Authenticate first with `bunny login`, which opens a browser-based auth flow and stores the key in a local profile. Alternatively, set `BUNNYNET_API_KEY` as an environment variable or pass `--api-key` directly. + +Config is stored in (first match wins): + +- `$XDG_CONFIG_HOME/bunnynet.json` +- `~/.config/bunnynet.json` +- `~/.bunnynet.json` +- `/etc/bunnynet.json` + +**When something goes wrong, check auth first**: run a quick `bunny api GET /user` to verify your key works. If using profiles, confirm the right one is active with `--profile`. + +## Quick Start + +```bash +# authenticate +bunny login + +# install this skill for AI coding tools (AGENTS.md + .claude/skills; --global for ~/.claude/skills) +bunny skills install + +# make a raw API request +bunny api GET /pullzone +bunny api GET /user + +# manage databases +bunny db create +bunny db list +bunny db shell +bunny db migrations apply # run pending migrations/*.sql files +bunny db migrations apply --pattern "*/migration.sql" # opt into a nested ORM layout + +# manage Edge Scripts +bunny scripts init +bunny scripts deploy dist/index.js +bunny scripts list + +# manage cloud sandboxes (isolated containers with Claude Code inside) +bunny sandbox create my-sandbox -e ANTHROPIC_API_KEY=sk-ant-... +bunny sandbox exec my-sandbox -- bun install +bunny sandbox url add my-sandbox 3000 + +# manage Edge Storage +bunny storage zones create my-zone --region DE --pull-zone +bunny storage files upload ./photo.png --to images/ +bunny storage zones credentials my-zone --connection s3 + +# manage DNS +bunny dns zones create example.com +bunny dns zones nameservers example.com # is the registrar delegated to bunny yet? +bunny dns records add example.com api A 198.51.100.1 +bunny dns records preset google-workspace example.com # apply a preset record set +bunny dns records list example.com + +# host a static site +bunny sites create my-site # provision (served at sites-my-site-.b-cdn.net) +bunny sites deploy ./dist # deploy a directory and publish it as the live site +bunny sites domains add example.com --wait # custom production domain +bunny sites deployments publish --previous --force # instant rollback + +# manage Bunny Stream video +bunny stream libraries create my-library --link --save-env --no-add-videos # link this directory, write .env +bunny stream videos upload ./video.mp4 --title "Launch demo" +bunny stream videos list + +# import a video library into Bunny Stream +bunny stream import list --source vimeo # browse folders first +bunny stream import --library 12345 --source vimeo --dry-run # plan first +bunny stream import --library 12345 --source vimeo --force # hand every video to Bunny +bunny stream import status --library 12345 # encoding progress +``` + +## Decision Tree + +Use this to route to the correct reference file: + +- **Authenticate or switch profiles** -> `references/auth.md` +- **Database management (create, list, show, link, delete, shell, studio, migrations, regions, tokens)** -> `references/database.md` +- **Querying a database from application code (`@bunny.net/database-client`: connect, prepare, bind, batch, types, errors)** -> `references/database-client.md` +- **DNS (zones, delegation checks, records, presets, BIND import/export, DNSSEC, logging, Scriptable DNS scripts)** -> `references/dns.md` +- **Edge Scripts (init, create, deploy, link, stats, deployments/rollback, env vars, custom domains)** -> `references/scripts.md` +- **Static sites (create, deploy, rollback, custom domains, GitHub Actions)** -> `references/sites.md` +- **Edge Storage (zones, files, connection credentials, S3/FTP/HTTP, custom domains)** -> `references/storage.md` +- **Sandboxes (create, exec, ssh, files list/cp, public URLs, persistent env vars, Claude Code auth)** -> `references/sandbox.md` +- **Bunny Stream (libraries, player/security settings, video upload/fetch/embed/cleanup, captions, collections, encoding, transcription, smart titles/chapters)** -> `references/stream.md` +- **Import a video library into Bunny Stream (Vimeo, S3, Wistia, Mux, Cloudflare Stream, JW Player, Brightcove; browse, dry run, resume, status)** -> `references/stream-import.md` +- **Make raw API requests** -> `references/api.md` +- **CLI doesn't have a command for it** -> use `bunny api` as a fallback (see `references/api.md`) + +## Global Flags + +Available on every command: + +| Flag | Short | Default | Description | +| ----------- | ----- | --------- | --------------------------------------------------------- | +| `--profile` | `-p` | `default` | Configuration profile to use | +| `--verbose` | `-v` | `false` | Enable verbose/debug output | +| `--output` | `-o` | `text` | Output format: `text`, `json`, `table`, `csv`, `markdown` | +| `--api-key` | | | API key (takes priority over profile and env) | + +## Environment Variables + +| Variable | Description | +| ------------------------ | ----------------------------------------------------------------------------- | +| `BUNNYNET_API_KEY` | API key (overrides profile) | +| `BUNNYNET_API_URL` | API base URL (default: `https://api.bunny.net`) | +| `BUNNYNET_DASHBOARD_URL` | Dashboard URL for browser-based auth flow (default: `https://dash.bunny.net`) | +| `NO_COLOR` | Disable colored output | + +## Anti-Patterns + +- **Forgetting to authenticate**: Run `bunny login` first. Without it, commands fail with a missing API key error. Use `bunny api GET /user` to verify. +- **Hardcoding API keys in scripts**: Use `BUNNYNET_API_KEY` env var or `--api-key` flag instead of embedding keys. Better yet, use `bunny login` profiles. +- **Relying on prompts in automation**: Prompts require an interactive terminal; piped answers are not supported. In scripts, CI, and agent sessions, pass every value as a flag and add `--force` to skip confirmations. A command that would need a prompt without a terminal does not wait: it fails fast with exit code 1 and names the flag to pass, so treat that as "retry with flags", never as success. diff --git a/.agents/skills/bunny-cli/references/api.md b/.agents/skills/bunny-cli/references/api.md new file mode 100644 index 000000000..b7635667f --- /dev/null +++ b/.agents/skills/bunny-cli/references/api.md @@ -0,0 +1,79 @@ +# API Command + +## `bunny api` — Make raw authenticated API requests + +**Use `bunny api` when a CLI command doesn't exist for what you need.** Provides direct access to the bunny.net REST API with automatic authentication. + +```bash +bunny api GET /user # get current user +bunny api GET /pullzone # list pull zones +bunny api GET /database/v2/databases # list databases +bunny api POST /database/v2/databases --body '{"name":"test","storage_region":"DE","primary_regions":["DE"]}' +bunny api DELETE /dnszone/12345 # delete a resource +``` + +## Syntax + +``` +bunny api METHOD PATH [--body BODY] +``` + +### Positional Arguments + +| Argument | Required | Description | +| -------- | -------- | --------------------------------------------------------------- | +| `METHOD` | Yes | HTTP method: `GET`, `POST`, `PUT`, `PATCH`, `DELETE` | +| `PATH` | Yes | API endpoint path (e.g., `/pullzone`, `/database/v2/databases`) | + +### Flags + +| Flag | Short | Description | +| -------- | ----- | ----------------- | +| `--body` | `-b` | JSON request body | + +## Request Body + +The body can come from three sources (in priority order): + +1. `--body` flag: `bunny api POST /path --body '{"key":"value"}'` +2. STDIN (if not a TTY and method is not GET): `echo '{"key":"value"}' | bunny api POST /path` +3. No body (default for GET requests) + +The body must be valid JSON — the command validates it before sending. + +## Headers + +All requests automatically include: + +- `AccessKey: {apiKey}` — from profile or `--api-key` flag +- `User-Agent: bunny-cli/{VERSION}` +- `Accept: application/json` +- `Content-Type: application/json` (when body is provided) + +## Output + +- Always outputs JSON (pretty-printed with 2-space indent) +- On error: extracts message from response fields (`detail`, `Message`, `title`) or falls back to HTTP status text +- In verbose mode (`-v`): logs the request method, URL, body, and response status + +## Examples + +```bash +# List all pull zones +bunny api GET /pullzone + +# Get a specific pull zone +bunny api GET /pullzone/12345 + +# Create a DNS zone +bunny api POST /dnszone --body '{"Domain":"example.com"}' + +# Pipe body from a file +cat payload.json | bunny api POST /database/v2/databases + +# Use with a specific profile +bunny api GET /user -p staging + +# Verbose mode to debug requests +bunny api GET /pullzone -v +``` diff --git a/.agents/skills/bunny-cli/references/auth.md b/.agents/skills/bunny-cli/references/auth.md new file mode 100644 index 000000000..6caa804af --- /dev/null +++ b/.agents/skills/bunny-cli/references/auth.md @@ -0,0 +1,127 @@ +# Authentication + +## `bunny login` — Browser-based login + +Opens a browser to authenticate with bunny.net and stores the API key in a local profile. + +```bash +bunny login # interactive login, saves to "default" profile +bunny login -p staging # save to "staging" profile +bunny login --force # overwrite existing profile without confirmation +bunny login --api-key KEY # no browser: verify and save the key directly +``` + +### How it works + +1. Starts a local HTTP server on a random port +2. Opens `https://dash.bunny.net/auth/login` in the default browser +3. Waits up to 5 minutes for the auth callback +4. Extracts the API key and saves it to the profile +5. Fetches user details and prints a welcome message + +### Flags + +| Flag | Default | Description | +| ----------- | ------- | ----------------------------------------------------- | +| `--force` | `false` | Overwrite existing profile without confirmation | +| `--api-key` | - | Skip the browser and save this key after verifying it | + +### Notes + +- If the browser doesn't open automatically, the URL is printed to the terminal +- Exits with an error if the profile already exists (use `--force` to overwrite) +- Uses `BUNNYNET_DASHBOARD_URL` env var if set (default: `https://dash.bunny.net`) + +### Headless and remote machines + +The browser flow needs a browser the user can see. `bunny login` detects when +that is impossible (SSH session, CI, container, or a Unix host with no display +server) and does not open one. + +- **With a terminal**: it warns why, then offers to take a pasted API key at a masked prompt, or to print the login URL plus the `ssh -L` port forward the callback needs. +- **Without a terminal** (agents, CI, piped stdin): it exits immediately with a hint rather than hanging for 5 minutes on a callback nobody can reach. + +When running unattended, do not call bare `bunny login`. Either pass the key: + +```bash +bunny login --api-key "$BUNNYNET_API_KEY" +``` + +or skip login entirely and export `BUNNYNET_API_KEY`, which takes priority over +any stored profile and needs no config file. Keys given to `bunny login` are +checked against `/user` first, so a bad key fails there instead of on the next +command. + +With `--output json`, login prints `{"authenticated": true, "profile": "...", +"name": "..."}` on stdout and nothing else, so the result is parseable. + +--- + +## `bunny auth logout` — Remove a profile + +Deletes the stored API key for a profile. + +```bash +bunny auth logout # remove "default" profile (with confirmation) +bunny auth logout -p staging # remove "staging" profile +bunny auth logout --force # skip confirmation prompt +``` + +### Flags + +| Flag | Default | Description | +| --------- | ------- | ------------------------ | +| `--force` | `false` | Skip confirmation prompt | + +### Notes + +- Errors if the profile doesn't exist +- Prompts "Are you sure?" before deleting (bypassed with `--force`) + +--- + +## Custom API Endpoint + +The CLI defaults to `https://api.bunny.net` but supports custom endpoints for staging or internal environments. + +**Via environment variable** (takes priority over profile config): + +```bash +BUNNYNET_API_URL=https://api.staging.bunny.net bunny api GET /user +``` + +**Via profile config** (set `api_url` in `~/.config/bunnynet.json`): + +```json +{ + "profiles": { + "staging": { + "api_key": "...", + "api_url": "https://api.staging.bunny.net" + } + } +} +``` + +```bash +bunny --profile staging api GET /user +``` + +**Precedence**: `BUNNYNET_API_URL` env var always wins over the profile's `api_url` field. + +--- + +## Config Resolution Precedence + +Authentication is resolved in this order (first wins): + +1. `--api-key` flag +2. `BUNNYNET_API_KEY` environment variable +3. Profile from config file (via `--profile`, default: `default`) +4. Built-in defaults (empty API key — commands will fail) + +The API base URL is resolved separately: + +1. `BUNNYNET_API_URL` environment variable +2. `api_url` from the active profile +3. Default: `https://api.bunny.net` diff --git a/.agents/skills/bunny-cli/references/database-client.md b/.agents/skills/bunny-cli/references/database-client.md new file mode 100644 index 000000000..403145c17 --- /dev/null +++ b/.agents/skills/bunny-cli/references/database-client.md @@ -0,0 +1,237 @@ +# Database Client (`@bunny.net/database-client`) + +The SQL client for querying a Bunny Database from application code. `bunny db` provisions and inspects the database; this package is what your app uses at runtime. + +It has no dependencies and uses `fetch` and nothing else, so the same code runs on Bunny Edge Scripting (Deno), Bun, and Node (>= 18.17). + +## Critical: server-side only + +**Never ship this client, or a database auth token, to a browser or any other untrusted client.** + +An auth token authorizes the connection, not the query: anything holding it can run whatever SQL that token allows against every table, whatever your UI offers. In client-side code the token shows up in the network tab, the bundle, `localStorage`, and to any injected script. Read-only tokens narrow the damage without fixing it, because they still expose every row of every table and SQLite has no row-level security. + +Keep the token on a server and expose only the queries you want to allow. An Edge Script in front of the database is the usual shape (see [Edge Scripting](#edge-scripting) below, and `references/scripts.md` for deploying one). + +## Install + +```bash +bun add @bunny.net/database-client +``` + +## Connecting + +```ts +import { connect } from "@bunny.net/database-client"; + +const db = connect(); +``` + +`connect()` reads `BUNNY_DATABASE_URL` and `BUNNY_DATABASE_AUTH_TOKEN` from the environment, and Edge Scripting injects both. + +To populate them locally, `bunny db quickstart --lang typescript` is the shortest path: it resolves the URL, generates a token when there isn't one, and prints the two `.env` lines next to this exact snippet. For a database you are creating anyway, `bunny db create --token --save-env` writes them straight to `.env`, and both flags are needed since `--save-env` has nothing to write without a token. `bunny db link` does not help here: it writes only `.bunny/database.json`, which is how the CLI resolves its own commands, not how your app finds credentials. + +Pass them explicitly when you need to: + +```ts +const db = connect({ + url: "libsql://your-db.lite.bunnydb.net", + authToken: "your-token", +}); +``` + +| Option | Type | Default | Description | +| ----------- | ------------------------ | --------------------------- | ---------------------------------------------------- | +| `url` | `string` | `BUNNY_DATABASE_URL` | `libsql://`, `https://`, or `http://` connection URL | +| `authToken` | `string` | `BUNNY_DATABASE_AUTH_TOKEN` | Sent as `Authorization: Bearer ` | +| `fetch` | `typeof fetch` | global `fetch` | Override for testing, tracing, or a custom agent | +| `headers` | `Record` | none | Extra headers on every request | +| `signal` | `AbortSignal` | none | Applied to every request | +| `timeout` | `number` | none | Milliseconds before a single request is aborted | + +A `libsql://` URL is rewritten to `https://`. Credentials in the URL are rejected, both `user:pass@` and an `authToken` query parameter, because URLs end up in logs and referrers; pass `authToken` instead. + +Without `timeout` a request waits as long as the runtime allows, so on an edge function a hung fetch can burn the whole invocation. Set one. `timeout` and `signal` compose: whichever fires first aborts. Combining them uses `AbortSignal.any`, which is why the Node floor is 18.17 rather than 18.0. + +`connect()` opens no socket and does no I/O, so building the client at module scope is fine, with nothing to warm up or tear down per request. + +## Queries + +Two ways to build a statement. Both parameterize. + +```ts +// Template literal: every ${...} becomes a ? placeholder with its value bound +const note = await db.sql`SELECT * FROM notes WHERE id = ${id}`.first(); + +// prepare(): reusable and immutable, so keep one around and bind it repeatedly +const byId = db.prepare("SELECT * FROM users WHERE id = ?"); +const alice = await byId.bind(1).first(); +const bob = await byId.bind(2).first(); +``` + +Only values can be parameterized (a SQLite limit, not a client one). When a table or column name has to vary, build the SQL string with `prepare()`. + +### `bind(...values)` + +Returns a new statement. Accepts `null`, `boolean`, `number`, `bigint`, `string`, and `Uint8Array`. Positional for `?`, or a single object for SQLite's named forms (`:name`, `@name`, `$name`, with or without the sigil): + +```ts +await db.prepare("SELECT * FROM users WHERE id = ? AND active = ?").bind(1, true).all(); +await db.prepare("SELECT * FROM users WHERE id = :id").bind({ id: 1 }).all(); +``` + +One statement uses one style: mixing positional values and an object in the same `bind()` throws. So does an interpolated object inside `db.sql` (inside a template it's far more likely a mistake than named parameters). + +These throw rather than converting quietly: + +- `undefined` — so a mistyped property (`bind(user.nmae)`) surfaces instead of writing NULL. Pass `null` for NULL. +- `Date` — the error suggests `.toISOString()` or `.getTime()`, since guessing would change what lands in the column. + +Integer `number`s are sent as INTEGER up to 2^53 and as REAL past it, where every double is already a whole number. Pass a `bigint` for an exact integer that large (within SQLite's signed 64-bit range). + +### Executing + +Statements do nothing until one of these is called, so `prepare()` and `bind()` are safe to pass around. + +```ts +await stmt.all(); // rows as objects +await stmt.first(); // first row, or null +await stmt.first("name"); // one column of the first row, or null +await stmt.raw(); // rows as positional arrays +await stmt.run(); // { rows, columns, rowsAffected, lastInsertRowid } +await stmt.runRaw(); // same metadata, positional rows +``` + +```ts +const result = await db + .prepare("INSERT INTO users (name) VALUES (?) RETURNING id") + .bind("Carol") + .run(); +// { rows: [{ id: 3 }], columns: ["id"], rowsAffected: 1, lastInsertRowid: 3 } +``` + +Reach for the `raw` variants when a result can contain two columns of the same name, since object rows keep only the last one. + +### `db.batch(statements, options?)` + +One transaction, one round trip: all statements commit or none do. You get one result per statement, in order, with each row type inferred from its statement. + +```ts +const [inserted, count] = await db.batch([ + db.prepare("INSERT INTO users (name) VALUES (?)").bind("Dan"), + db.prepare("SELECT COUNT(*) AS c FROM users"), +]); +``` + +If any statement fails, the transaction rolls back and `batch()` throws that statement's error with `error.batchIndex` set. `batchRaw()` is the same with positional rows. Statements starting with `BEGIN`, `COMMIT`, `END`, or `ROLLBACK` are rejected: the batch is the transaction. Pass `{ mode: "immediate" }` for write batches so the lock is taken up front instead of at the first write. + +`{ foreignKeys: false }` brackets the transaction with `PRAGMA foreign_keys=off` and `=on`. Schema changes need it: SQLite's table rebuild procedure and several `ALTER TABLE` forms require enforcement genuinely off, not just deferred to commit. `bunny db migrations apply` runs this way. + +### `db.exec(sql)` + +Runs a multi-statement script with no parameters and no rows back, so it's mostly for standing up a schema: + +```ts +await db.exec(` + CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT NOT NULL); + CREATE INDEX IF NOT EXISTS users_name ON users (name); +`); +``` + +For anything that runs more than once, use migrations (`bunny db migrations`, see `references/database.md`) instead. + +## Errors + +Everything throws `DatabaseError`, including failures before any SQL runs, so one `instanceof` check covers the whole surface. + +```ts +import { DatabaseError } from "@bunny.net/database-client"; + +try { + await db.prepare("INSERT INTO users (email) VALUES (?)").bind("dupe@example.com").run(); +} catch (error) { + if (error instanceof DatabaseError && error.code === "SQLITE_CONSTRAINT") { + // handle the duplicate + } +} +``` + +| Property | Description | +| --------- | ----------------------------------------------------------------------------------- | +| `message` | The server's message, or a description of the local validation failure | +| `code` | SQLite code (`SQLITE_CONSTRAINT`), or a client code (`UNAUTHORIZED`, `URL_MISSING`) | +| `status` | HTTP status, when the failure came from the transport rather than from SQL | + +Client codes for pre-SQL failures: `NETWORK` (DNS, TLS, connection refused, or a non-JSON response), `TIMEOUT` (the `timeout` deadline elapsed), `ABORTED` (the `signal` was aborted), `PROTOCOL` (a 200 whose body is not a pipeline response). `error.cause` holds the runtime's original error. + +A `DatabaseError` carries the server's message and SQLite code, so returning one straight to a caller can leak schema details. Log it and return something generic. + +## Types + +Rows are `Record` by default. Type the executor, or type the statement once and let every execution inherit it: + +```ts +interface User { + id: number; + name: string; +} + +const users = await db.prepare("SELECT id, name FROM users").all(); + +const byId = db.prepare("SELECT id, name FROM users WHERE id = ?"); +const alice = await byId.bind(1).first(); // User | null +const both = await byId.bind(1).all(); // User[] +``` + +A type argument on the executor wins over the statement's. Nothing validates rows at runtime, so the type is only as accurate as the SQL. + +| SQLite | JavaScript | +| ------- | ------------------------------- | +| NULL | `null` | +| INTEGER | `number`, or `bigint` past 2^53 | +| REAL | `number` | +| TEXT | `string` | +| BLOB | `Uint8Array` | + +SQLite has no boolean type: `bind(true)` stores `1` and reads back as `1`. Integers come back as `number` while they fit exactly and as `bigint` beyond `Number.MAX_SAFE_INTEGER`; the client never rounds a value to make it fit. + +## Edge Scripting + +Edge Scripting runs Deno, so import straight from npm: + +```ts +import * as BunnySDK from "npm:@bunny.net/edgescript-sdk@0.12.1"; +import { connect } from "npm:@bunny.net/database-client"; + +const db = connect(); + +BunnySDK.net.http.serve(async (request: Request): Promise => { + const url = new URL(request.url); + const author = url.searchParams.get("author"); + if (!author) return new Response("author required", { status: 400 }); + + // Parameterized, and scoped to the columns and rows the caller may see. + const posts = await db + .prepare("SELECT id, title FROM posts WHERE author = ? AND published = 1 LIMIT 50") + .bind(author) + .all(); + + return Response.json(posts); +}); +``` + +This is also the right place to hold a database token: the code and its environment stay on bunny.net's edge, and the browser only sees the response you chose to return. + +## Handling tokens + +- Keep tokens in environment variables, not source. `connect()` reads them from the environment, so a token never has to appear in code. +- Prefer short-lived, narrow tokens: `bunny db tokens create --read-only --expiry 12h`. Bare `bunny db tokens create` defaults to full access with no expiry, which makes the token you're most likely to have lying around the worst one to lose. +- `bunny db tokens invalidate` revokes every token for the database if one leaks. + +## Anti-Patterns + +- **Any browser or client-side use.** See the warning at the top: the token is a whole-database credential, and read-only does not fix it. +- **Interpolating values into SQL strings.** Use `db.sql` or `bind()`; string concatenation is how injection gets in. +- **No `timeout` on an edge function.** A hung fetch burns the whole invocation. +- **`db.exec()` for repeatable schema changes.** Use `bunny db migrations` so the change is tracked and applied once. +- **Reusing `db.exec()` or a plain `batch()` for table rebuilds.** `ALTER TABLE` rebuild forms need `{ foreignKeys: false }`. +- **Returning a caught `DatabaseError` to a caller.** It carries schema details; log it and return something generic. diff --git a/.agents/skills/bunny-cli/references/database.md b/.agents/skills/bunny-cli/references/database.md new file mode 100644 index 000000000..cd8d61c08 --- /dev/null +++ b/.agents/skills/bunny-cli/references/database.md @@ -0,0 +1,389 @@ +# Database Commands + +All database commands live under `bunny db`. Most accept an optional `DB_ID` positional argument. When omitted, the ID is resolved in this order: + +1. Explicit `DB_ID` argument +2. `.bunny/database.json` manifest (written by `bunny db link` or accepted during `bunny db create`) +3. `BUNNY_DATABASE_URL` in `.env` (walked up from the current directory and matched against your database list) +4. Interactive prompt + +Commands that show a "from ..." hint (e.g. `db delete`, `db tokens create`, `db tokens invalidate`, `db usage`) report which source resolved the ID so you can spot the wrong target before acting. + +## `bunny db list` — List all databases + +```bash +bunny db list # table format +bunny db ls # alias +bunny db list --output json # JSON format +``` + +Fetches all databases with pagination, live metrics, and region configuration. Displays: ID, Name, Status (Active/Idle), Primary Region, Size. + +--- + +## `bunny db create` — Create a new database + +```bash +bunny db create # interactive mode +bunny db create --name my-app --primary FR,DE # non-interactive +bunny db create --name my-app --primary FR --replicas UK # with replicas +bunny db create --name my-app --primary FR --output json # JSON output (no follow-ups) +bunny db create --name my-app --primary FR --link --token --save-env --output json # fully non-interactive (CI) +``` + +### Flags + +| Flag | Description | +| ------------------ | ---------------------------------------------------------------------------------------- | +| `--name` | Database name (prompted if omitted) | +| `--primary` | Comma-separated primary region IDs (e.g., `FR,DE`) | +| `--replicas` | Comma-separated replica region IDs (e.g., `UK,NY`) | +| `--storage-region` | Override auto-detected storage region | +| `--link` | Link the current directory to the new database (skips prompt). Use `--no-link` to skip. | +| `--token` | Generate a full-access auth token (skips prompt). Use `--no-token` to skip. | +| `--save-env` | Save `BUNNY_DATABASE_URL` and `BUNNY_DATABASE_AUTH_TOKEN` to `.env`. Requires `--token`. | + +### Interactive mode + +When `--name` and `--primary` are not both provided, the command enters interactive mode: + +1. Prompts for database name +2. Fetches available regions and offers three selection modes: + - **Automatic** — detects optimal regions via CDN probe + - **Single region** — probes for one optimal region + - **Manual** — multi-select chooser grouped by continent +3. Creates the database +4. Offers to link the current directory (writes `.bunny/database.json`). If a link already exists for another database, the prompt notes what will be replaced. +5. Offers to generate an auth token +6. Offers to save `BUNNY_DATABASE_URL` and `BUNNY_DATABASE_AUTH_TOKEN` to `.env` + +For each of the three follow-up prompts (link, token, save-env), the matching flag overrides the prompt — `--link`/`--no-link`, `--token`/`--no-token`, `--save-env`/`--no-save-env`. + +### Non-interactive mode (`--output json`) + +In `--output json` mode, prompts are suppressed entirely — flags are the only way to opt in to linking, token creation, and `.env` writes. The JSON output gains `linked`, `token`, and `saved_to_env` fields reflecting what happened: + +```json +{ + "db_id": "db_01KCHBG8C5KSFGG0VRNFQ7EK7X", + "name": "my-app", + "url": "libsql://01KCHBG8C5KSFGG0VRNFQ7EK7X-my-app.lite.bunnydb.net/", + "linked": true, + "token": "ey...", + "saved_to_env": true +} +``` + +`token` is `null` when `--token` was not passed (or `--no-token` was). `saved_to_env` is always `false` without a token. + +--- + +## `bunny db show` — Display database details + +```bash +bunny db show # auto-detect from .env +bunny db show db_01KCHBG8C5KSFGG0VRNFQ7EK7X # explicit ID +bunny db show --output json +``` + +Displays: ID, Name, URL, Status (Active/Idle), Size (with progress bar), Storage Region, Primary Regions, Replica Regions. + +--- + +## `bunny db link` — Link the current directory to a database + +```bash +bunny db link # interactive selection +bunny db link db_01KCHBG8C5KSFGG0VRNFQ7EK7X # explicit ID +bunny db link --output json +``` + +Writes `{ id, name }` to `.bunny/database.json` so subsequent `db` commands resolve the target without `BUNNY_DATABASE_URL` in `.env`. The manifest is gitignored — it's per-developer state, not shared. + +`bunny db create` offers to write this for the new database. `bunny db delete` removes it automatically when it points at the deleted database. + +--- + +## `bunny db delete` — Delete a database + +```bash +bunny db delete db_01KCHBG8C5KSFGG0VRNFQ7EK7X # with confirmation +bunny db delete --force # skip all prompts +bunny db delete --force --output json +``` + +### Flags + +| Flag | Short | Default | Description | +| --------- | ----- | ------- | ------------------------- | +| `--force` | `-f` | `false` | Skip confirmation prompts | + +### Confirmation flow + +1. First prompt: "Delete database [name] ([id])? This cannot be undone." +2. Second prompt: Type the database name to verify (skipped with `--force`) +3. After deletion: removes `.bunny/database.json` automatically when it points at the deleted database (silent — a manifest pointing at a deleted DB is unambiguously stale), then offers to clean up `.env` references + +--- + +## `bunny db usage` — Display usage statistics + +```bash +bunny db usage # current month +bunny db usage --period 7d # last 7 days +bunny db usage --period 24h # last 24 hours +bunny db usage --from 2026-01-01 --to 2026-01-31 # custom range +bunny db usage --output json +``` + +### Flags + +| Flag | Default | Description | +| ---------- | ------------ | -------------------------------------------- | +| `--from` | | Start date (ISO date or date-time) | +| `--to` | | End date (ISO date or date-time) | +| `--period` | `this-month` | Time range: `24h`, `7d`, `30d`, `this-month` | + +Displays: Rows read, Rows written, Queries, Avg latency (ms), Storage (with progress bar). + +--- + +## `bunny db shell` — Interactive SQL REPL + +```bash +bunny db shell # interactive REPL +bunny db shell db_01KCHBG8C5KSFGG0VRNFQ7EK7X # specific database +bunny db shell -e "SELECT * FROM users LIMIT 10" # execute and exit +bunny db shell -e query.sql # execute .sql file +bunny db shell --mode json # JSON output +bunny db shell --unmask # show sensitive values +bunny db shell --url libsql://-.lite.bunnydb.net --token ey... # explicit credentials +``` + +### Flags + +| Flag | Short | Default | Description | +| ------------- | ----- | -------------------------------- | ------------------------------------------------------------ | +| `--execute` | `-e` | | SQL statement (or `.sql` file) to execute and exit | +| `--mode` | `-m` | `default` | Output format: `default`, `table`, `json`, `csv`, `markdown` | +| `--unmask` | | `false` | Show sensitive column values unmasked | +| `--url` | | | Explicit database URL (skips API lookup) | +| `--token` | | | Explicit auth token (skips token generation) | +| `--views-dir` | | `~/.config/bunny/views//` | Directory for saved SQL views | + +### Credential resolution order + +1. `--url` / `--token` flags +2. `BUNNY_DATABASE_URL` / `BUNNY_DATABASE_AUTH_TOKEN` from `.env` +3. API lookup (fetches URL and generates a temporary token) + +Shared by `db shell`, `db studio`, and `db migrations apply`. These rules apply: + +- **Passing a database ID skips `.env`.** `bunny db shell db_01ABC` targets that database even when `.env` describes another one, so an explicit target is never silently redirected. +- **`--url` without `--token` must match the resolved database.** A generated token is only sent to that database's own endpoint (hostname and normalized TLS port); a mismatch directs the user back to the Bunny-provided URL. The token from `.env` is likewise only reused for a `--url` matching the endpoint in `BUNNY_DATABASE_URL`. +- **Every database URL must be encrypted.** `libsql:`, `https:`, and `wss:` are accepted, while plaintext schemes and `libsql:` URLs with `tls=0` are rejected regardless of where the token came from. + +The CLI targets hosted Bunny Database connections and does not provide a plaintext local-database exception. + +### REPL dot-commands + +In interactive mode, the shell supports dot-commands like `.tables`, `.schema`, `.fk`, etc. + +--- + +## `bunny db studio` — Read-only table viewer + +```bash +bunny db studio # auto-detect database +bunny db studio db_01KCHBG8C5KSFGG0VRNFQ7EK7X # specific database +bunny db studio --port 3000 # custom port +bunny db studio --no-open # don't auto-open browser +bunny db studio --url libsql://-.lite.bunnydb.net --token ey... # explicit credentials +``` + +| Flag | Default | Description | +| ----------- | ------- | ------------------------------------ | +| `--port` | `4488` | Port for the local studio server | +| `--url` | | Database URL (skips API lookup) | +| `--token` | | Auth token (skips token generation) | +| `--no-open` | `false` | Don't automatically open the browser | + +Spins up a local server, generates a short-lived auth token if needed, and opens a browser-based read-only table viewer. Long-running until interrupted (Ctrl+C). Credential resolution mirrors `db shell`: `--url`/`--token` flags > `.env` vars > API lookup. + +--- + +## `bunny db migrations` — Create and apply SQL migrations + +Migrations are plain `.sql` files in `migrations/`, named `NNNN_.sql`. The filename (or relative path for a nested layout) is the migration's identity and its numeric prefix is the apply order. Applied migrations are recorded in a `__bunny_migrations` table in the database. There is no rollback: fix a bad migration with another migration. + +```bash +bunny db migrations create add_users_table # writes migrations/0001_add_users_table.sql +bunny db migrations list # applied / pending / modified / missing +bunny db migrations apply --dry-run # show what would run +bunny db migrations apply # apply pending migrations in order +bunny db migrations apply --dir drizzle # apply drizzle-kit generate output +bunny db migrations apply --pattern "*/migration.sql" # apply a nested ORM layout +``` + +### `bunny db migrations create ` (alias: `new`) + +| Flag | Default | Description | +| ------- | ------------ | -------------------- | +| `--dir` | `migrations` | Migrations directory | + +Numbers the file one above the highest existing prefix and slugifies the name. Creates the directory if needed. + +### `bunny db migrations list` (aliases: `ls`, `status`) + +| Flag | Default | Description | +| ----------- | ------------ | ------------------------------------------------ | +| `--dir` | `migrations` | Migrations directory | +| `--pattern` | `*.sql` | Migration glob relative to the migrations folder | +| `--url` | | Database URL (skips API lookup) | +| `--token` | | Auth token (skips token generation) | + +Never creates the tracking table, so it is safe against a database that has never had a migration applied. States are `Applied`, `Pending`, `Modified` (the file changed after being applied), `Missing` (the file was deleted), and `Out of order` (a new file sorts before an applied one). The database ID and host are shown without credentials. + +### `bunny db migrations apply` + +| Flag | Short | Default | Description | +| --------------- | ----- | ------------ | --------------------------------------------------- | +| `--dir` | | `migrations` | Migrations directory | +| `--pattern` | | `*.sql` | Migration glob relative to the migrations folder | +| `--dry-run` | | `false` | List what would run without applying | +| `--force` | `-f` | `false` | Skip the confirmation prompt | +| `--allow-drift` | | `false` | Apply despite modified, missing, or late migrations | +| `--url` | | | Database URL (skips API lookup) | +| `--token` | | | Auth token (skips token generation) | + +Each file runs as one atomic batch together with its tracking row, so a migration either lands and is recorded or neither happens. Foreign keys are deferred for the duration, so table rebuilds and `ALTER TABLE` work. Every pending file is parsed before the first write. The run stops at the first database failure and leaves the rest pending. + +Modified, missing, and out-of-order histories are shown by `list` but block `apply` when more migrations are pending. Restore or rename the affected files; use `--allow-drift` only when the divergence is intentional. + +Confirms before writing when a TTY is attached; the prompt is skipped under `--force`, `--output json`, or any non-interactive run, so CI and agent flows aren't blocked. Credential resolution mirrors `db shell`. + +### ORM-generated migrations + +Flat `drizzle-kit generate` output uses `0000_.sql`, which matches this convention. When `migrations/` doesn't exist, `drizzle/` is used automatically by `list` and `apply`. For nested layouts, set a glob relative to `--dir`: + +```bash +drizzle-kit generate +bunny db migrations apply +bunny db migrations apply --dir migrations --pattern "*/migration.sql" +``` + +`db migrations create` always defaults to `migrations/` and only writes top-level files; it never silently writes into an auto-detected ORM directory. Use the ORM's own generator when it owns the schema. + +Choose one migration runner for each history. Bunny records paths in `__bunny_migrations` and does not read or update Drizzle, Prisma, dbmate, or other tools' journals. Existing tools remain supported by running their own migration command instead of alternating runners over the same files. + +--- + +## `bunny db quickstart` — Language-specific getting-started guide + +```bash +bunny db quickstart # interactive language selection +bunny db quickstart --lang typescript +bunny db quickstart --lang go +bunny db quickstart --lang rust +bunny db quickstart --lang dotnet +``` + +### Flags + +| Flag | Short | Description | +| --------- | ----- | ---------------------------------------------- | +| `--lang` | `-l` | Language: `typescript`, `go`, `rust`, `dotnet` | +| `--url` | | Database URL (skips API lookup) | +| `--token` | | Auth token (skips token generation) | + +Displays step-by-step instructions: environment variables, install command, and a ready-to-use code snippet. + +--- + +## Regions + +### `bunny db regions list` — List configured regions + +```bash +bunny db regions list # auto-detect from .env +bunny db regions ls # alias +bunny db regions list --output json +``` + +Displays primary and replica regions with Type, Name, and ID. + +### `bunny db regions add` — Add regions + +```bash +bunny db regions add # interactive multi-select +bunny db regions add --primary FR,DE +bunny db regions add --replicas UK,NY +bunny db regions add --primary FR --replicas UK +``` + +| Flag | Description | +| ------------ | ----------------------------------------- | +| `--primary` | Comma-separated primary region IDs to add | +| `--replicas` | Comma-separated replica region IDs to add | + +### `bunny db regions remove` — Remove regions + +```bash +bunny db regions remove # interactive multi-select +bunny db regions rm # alias +bunny db regions remove --primary FR,DE +bunny db regions remove --replicas UK --force +``` + +| Flag | Short | Default | Description | +| ------------ | ----- | ------- | -------------------------------------------- | +| `--primary` | | | Comma-separated primary region IDs to remove | +| `--replicas` | | | Comma-separated replica region IDs to remove | +| `--force` | | `false` | Skip confirmation prompt | + +**Important**: At least one primary region must remain. The command errors if you try to remove all primary regions. + +--- + +## Tokens + +### `bunny db tokens create` — Generate an auth token + +```bash +bunny db tokens create # full-access, no expiry +bunny db tokens create --read-only --expiry 30d # read-only, expires in 30 days +bunny db tokens create --no-save # don't prompt to save to .env +bunny db tokens create --force --output json +``` + +| Flag | Short | Default | Description | +| ------------- | ----- | --------- | ---------------------------------------------------------- | +| `--read-only` | | `false` | Generate read-only token (default: full-access) | +| `--expiry` | `-e` | no expiry | Duration (`30d`, `12h`, `1w`, `1m`, `1y`) or RFC 3339 date | +| `--save` | | `true` | Prompt to save to `.env` (use `--no-save` to skip) | +| `--force` | `-f` | `false` | Skip confirmation prompts | + +After generation, offers to save `BUNNY_DATABASE_AUTH_TOKEN` (and `BUNNY_DATABASE_URL` if missing) to `.env`. + +### `bunny db tokens invalidate` — Revoke all tokens + +```bash +bunny db tokens invalidate db_01KCHBG8C5KSFGG0VRNFQ7EK7X +bunny db tokens invalidate --force +bunny db tokens invalidate --force --regenerate --save-env +bunny db tokens invalidate --force --output json +``` + +| Flag | Short | Default | Description | +| -------------- | ----- | ------- | ---------------------------------------------------------- | +| `--force` | `-f` | `false` | Skip confirmation prompts | +| `--regenerate` | | `false` | Generate a replacement token after invalidation | +| `--save-env` | | | Save replacement token to `.env` (requires `--regenerate`) | + +**This is destructive** — all existing tokens for the database are revoked. After invalidation, the command offers to: + +1. Remove stale `BUNNY_DATABASE_AUTH_TOKEN` from `.env` +2. Generate a replacement token +3. Save the new token to `.env` + +Use `--force --regenerate --save-env` to do all three without prompts. diff --git a/.agents/skills/bunny-cli/references/dns.md b/.agents/skills/bunny-cli/references/dns.md new file mode 100644 index 000000000..fbf6dcc28 --- /dev/null +++ b/.agents/skills/bunny-cli/references/dns.md @@ -0,0 +1,292 @@ +# DNS Commands + +`bunny dns` manages Bunny DNS: zones, the records inside them, and Scriptable DNS scripts. Use `bunny dns --help` for full flag details. + +Three command groups: + +- `bunny dns zones` (alias: `zone`; hidden: `domain`, `domains`): zone lifecycle, DNSSEC, logging, stats, nameservers +- `bunny dns records` (aliases: `record`, `rec`): the records within a zone +- `bunny dns scripts` (alias: `script`): Scriptable DNS scripts + +## Zone resolution + +Most zone and record commands take an optional `[domain]`, which is a domain name (`example.com`) or a numeric zone ID. When omitted, the zone is resolved in this order: + +1. Explicit `[domain]` positional +2. `.bunny/dns.json` manifest (written by `bunny dns zones link`) +3. Interactive picker (suppressed under `--output json`; pass a domain or link the directory in CI) + +When a zone is chosen via the picker, the command offers to link the directory to it (`.bunny/dns.json`). The offer is skipped under `--output json`. + +## Typical workflows + +```bash +# Create a zone; it prints the nameservers to set and names your registrar. +# Once you have pointed them, check delegation has taken effect: +bunny dns zones create example.com +bunny dns zones nameservers example.com + +# Add records, or apply a preset record set (email, verification, security) +bunny dns records add example.com api A 198.51.100.1 +bunny dns records add example.com '@' MX mail.example.com 10 +bunny dns records preset google-workspace example.com +bunny dns records list example.com + +# Back up and restore a zone as a BIND file +bunny dns records export example.com --save +bunny dns records import example.com ./example.com.zone + +# Scriptable DNS: scaffold, deploy, attach to a hostname +bunny dns scripts init geo-router --example geo --deploy +cd geo-router +bunny dns scripts deploy handleQuery.js +bunny dns scripts attach example.com api --script +``` + +--- + +# Zones + +## `bunny dns zones create`: Create a zone + +```bash +bunny dns zones create example.com +``` + +After creating the zone it checks the live registrar delegation: + +- If the domain already points at bunny (common when you set the nameservers first), it just confirms, with no further steps. +- Otherwise it prints the nameservers to set, naming your registrar when it can detect it (via RDAP), plus how to verify once you have pointed them. + +## `bunny dns zones list` / `show` / `nameservers` + +```bash +bunny dns zones list # all zones (alias: ls) +bunny dns zones show example.com # zone details +bunny dns zones nameservers example.com # check delegation (alias: ns) +``` + +`nameservers` does a live nameserver lookup rather than trusting the API's detection flag (which reads as detected on a brand-new zone). When the registrar already delegates to bunny it confirms; otherwise it shows the nameservers to set at your (named) registrar. `list` shows a `Nameservers` column (`Detected` / `Pending` / `Unknown`) from the same live lookup, and `--output json` overwrites each zone's `NameserversDetected` with the live value. + +## `bunny dns zones delete`: Delete a zone + +Deletes the zone and all of its records. Confirms unless `--force`. + +```bash +bunny dns zones delete example.com +bunny dns zones delete example.com --force +``` + +## `bunny dns zones stats`: Query statistics + +Defaults to the last 30 days. + +```bash +bunny dns zones stats example.com +bunny dns zones stats example.com --from 2026-05-01 --to 2026-05-31 +bunny dns zones stats example.com --output json +``` + +| Flag | Description | +| -------- | ------------------------------------------------ | +| `--from` | Start date (YYYY-MM-DD); defaults to 30 days ago | +| `--to` | End date (YYYY-MM-DD); defaults to today | + +## `bunny dns zones link` / `unlink` + +Link the current directory to a zone (`.bunny/dns.json`) so other commands resolve it without a `[domain]`. + +```bash +bunny dns zones link example.com # by domain or zone ID +bunny dns zones link # pick interactively +bunny dns zones unlink # confirms unless --force +``` + +## `bunny dns zones dnssec`: DNSSEC + +```bash +bunny dns zones dnssec enable example.com # enable and print the DS record +bunny dns zones dnssec disable example.com # confirms unless --force +``` + +## `bunny dns zones logging`: Query logging + +```bash +bunny dns zones logging enable example.com +bunny dns zones logging enable example.com --anonymize-ip --anonymization drop +bunny dns zones logging disable example.com # confirms unless --force +``` + +| Flag | Description | +| ----------------- | ---------------------------------------- | +| `--anonymize-ip` | Anonymize client IPs in the logs | +| `--anonymization` | Strategy: `onedigit` (default) or `drop` | + +--- + +# Records + +All record commands operate within a zone (see [Zone resolution](#zone-resolution)). Record types: `A`, `AAAA`, `CNAME`, `TXT`, `MX`, `REDIRECT`, `FLATTEN`, `PULLZONE`, `SRV`, `CAA`, `PTR`, `SCRIPT`, `NS`, `SVCB`, `HTTPS`, `TLSA`. Use `'@'` for the zone apex. + +## `bunny dns records add`: Add a record + +Runs an interactive wizard when the type is omitted. Value ordering is per-type (see examples). The wizard first asks whether to add a single record or apply a preset (see [`bunny dns records preset`](#bunny-dns-records-preset-apply-a-preset-record-set)). + +```bash +bunny dns records add example.com api A 198.51.100.1 +bunny dns records add example.com '@' MX mail.example.com 10 +bunny dns records add example.com '@' SRV 10 0 389 sip.example.com +bunny dns records add example.com '@' CAA '0 issue "letsencrypt.org"' +bunny dns records add # interactive wizard +``` + +## `bunny dns records preset`: Apply a preset record set + +Applies a curated set of records for a common provider or task in one step. It prompts for the account-specific values (DKIM keys, verification codes, selectors), shows a summary of every record, and confirms before writing. Account-specific secrets cannot be hardcoded, but the record names, types, priorities, and SPF includes are filled in for you. + +```bash +bunny dns records preset # pick a preset interactively +bunny dns records preset list # list available presets +bunny dns records preset google-workspace example.com # apply a named preset +bunny dns records preset list --output json +``` + +Available presets: + +| Preset | Category | Records | +| ------------------ | -------------- | -------------------------------------------------------------- | +| `google-workspace` | Email | MX, SPF; optional DKIM and DMARC (aliases: `gmail`) | +| `microsoft365` | Email | MX (derived from domain), SPF, autodiscover (alias: `outlook`) | +| `zoho` | Email | MX, SPF; optional verification and DKIM (region-aware) | +| `mailgun` | Email | SPF, MX, tracking CNAME, optional DKIM (sending subdomain) | +| `resend` | Email | MX, SPF, optional DKIM (SES-backed) | +| `proton` | Email | verification, MX, SPF, optional DKIM CNAMEs | +| `bluesky` | Verification | `_atproto` TXT at the apex or a handle subdomain | +| `dmarc` | Email security | a `_dmarc` policy record | +| `caa` | Email security | restrict which CAs may issue certificates | +| `no-email` | Email security | null MX, `-all` SPF, reject DMARC (domains that never mail) | + +| Flag | Description | +| ------------- | ------------------------------------- | +| `--ttl` | Time to live in seconds | +| `--comment` | Optional comment for the record | +| `--pull-zone` | Pull zone ID (for `PullZone` records) | +| `--script` | Edge Script ID (for `Script` records) | + +## `bunny dns records list`: List records + +```bash +bunny dns records list example.com # alias: ls +bunny dns records list example.com --output json +``` + +## `bunny dns records update`: Update a record + +Pass the record ID (from `list`), or omit it to pick interactively. Only the flags you set are changed. + +```bash +bunny dns records update example.com 123 --value 198.51.100.2 +bunny dns records update example.com 123 --ttl 3600 +bunny dns records update example.com 123 --disabled +bunny dns records update example.com --value 198.51.100.2 # pick the record interactively +``` + +| Flag | Description | +| ---------------------------------- | ---------------------------- | +| `--name` | Record name (`'@'` for apex) | +| `--value` | Record value | +| `--type` | Record type | +| `--ttl` | Time to live in seconds | +| `--priority`, `--weight`, `--port` | MX / SRV fields | +| `--flags`, `--tag` | CAA fields | +| `--comment` | Comment for the record | +| `--disabled` | Disable the record | +| `--pull-zone`, `--script` | PullZone ID / Edge Script ID | + +## `bunny dns records remove`: Remove a record + +Pass the record ID, or omit to pick interactively. Confirms unless `--force`. + +```bash +bunny dns records remove example.com 123 # alias: rm +bunny dns records remove example.com 123 --force +``` + +## `bunny dns records export` / `import`: BIND zone files + +```bash +bunny dns records export example.com # print to stdout +bunny dns records export example.com --file ./example.zone +bunny dns records export example.com --save # write ./example.com.zone +bunny dns records import example.com ./zonefile.txt +``` + +--- + +# Scriptable DNS scripts + +`bunny dns scripts` manages Scriptable DNS scripts: code that computes a DNS answer at query time, such as geo routing, weighted or failover answers, and closest-region selection. They are separate from Edge Scripts (`bunny scripts`, a different runtime) and reuse the compute API. + +Scripts resolve without an explicit ID in this order: explicit `[id]`, then `.bunny/dns-script.json` (written by `link`/`create`/`init`), then the interactive picker (errors under `--output json`). + +A script only affects DNS once it backs a `SCRIPT` record on a zone. See `attach` below. + +## `bunny dns scripts init`: Scaffold a project + +Writes a `handleQuery` entry file, tsconfig, and `package.json` (devDep on `@bunny.net/scriptable-dns-types` for editor autocomplete) plus the manifest. + +```bash +bunny dns scripts init # interactive wizard +bunny dns scripts init geo-router --example geo --deploy +``` + +| Flag | Description | +| ---------------- | ---------------------------------------------------------------------- | +| `--example` | Starter: `empty`, `geo`, `closest`, `weighted`, `failover`, `pullzone` | +| `--deploy` | Create the script on bunny.net after scaffolding | +| `--skip-install` | Skip installing editor type dependencies | + +## `bunny dns scripts create`: Create a remote script + +Creates the script on bunny.net without scaffolding. Unlike Edge Scripts, DNS scripts have no linked pull zone. + +```bash +bunny dns scripts create # current directory name + link +bunny dns scripts create geo-router +bunny dns scripts create geo-router --no-link +``` + +## `bunny dns scripts deploy`: Upload and publish + +```bash +bunny dns scripts deploy # publish the linked script's entry file +bunny dns scripts deploy handleQuery.js +bunny dns scripts deploy handleQuery.js 12345 # target a specific script +bunny dns scripts deploy --skip-publish # stage without publishing +``` + +## `bunny dns scripts link` / `list` + +```bash +bunny dns scripts link # interactive selection, writes .bunny/dns-script.json +bunny dns scripts link 12345 # direct link by ID +bunny dns scripts list # all DNS scripts (alias: ls) +``` + +## `bunny dns scripts attach`: Point a hostname at a script + +Bridges a script to a zone by adding a `SCRIPT` record so `name.domain` is answered by the script. This is the same result as `bunny dns records add SCRIPT --script `, surfaced from the script side. + +```bash +bunny dns scripts attach # pick a zone and the linked script +bunny dns scripts attach example.com api --script 12345 +bunny dns scripts attach example.com '@' # apex, using the linked script +``` + +| Flag | Description | +| ---------- | ------------------------------------------------- | +| `--script` | DNS script ID (uses the linked script if omitted) | +| `--ttl` | Time to live in seconds | +| `--force` | Skip the confirmation prompt | + +**Safety:** `attach` always confirms before writing the record. Attaching at the apex (`'@'`) prints a stronger warning naming the root domain, and any records already at that name are listed before you confirm. In non-interactive contexts (`--output json`, no TTY), `attach` refuses to write unless `--force` is passed. diff --git a/.agents/skills/bunny-cli/references/sandbox.md b/.agents/skills/bunny-cli/references/sandbox.md new file mode 100644 index 000000000..c4f2ad7f8 --- /dev/null +++ b/.agents/skills/bunny-cli/references/sandbox.md @@ -0,0 +1,161 @@ +# Sandbox Commands + +All sandbox commands live under `bunny sandbox`. Each sandbox is a fully isolated Ubuntu container (Bunny Magic Containers) with Node.js, Bun, Python (plus `uv`), the bunny CLI, and Claude Code pre-installed, alongside the tooling agents reach for: `git`, `gh`, `ripgrep`, `fd`, `jq`, `tmux`, `sqlite3`, `tree`, and `fzf`. A 10 GB persistent volume is mounted at `/workplace`, the default working directory; relative remote paths resolve against it. + +Sandbox credentials (app ID, SSH endpoint, agent token) are stored in the CLI's local config (same candidate paths as in SKILL.md), so commands reference sandboxes by name. + +Claude Code is pre-installed but needs the user's own Anthropic credentials: bake an API key in at create time (prefer `--env-file .env` so the key stays out of shell history), set it later with `bunny sandbox env set`, or run `claude` inside the sandbox and complete the login prompt. Both paths survive restarts and redeploys: baked env vars live on the container, and interactive logins write to the persistent volume. The image pins config directories there via `/etc/environment` (Docker `ENV` alone does not reach SSH sessions): `CLAUDE_CONFIG_DIR=/workplace/.claude`, plus `XDG_CONFIG_HOME=/workplace/.config`, which the bunny CLI and `gh` both honour. The entrypoint creates these at startup because the volume mounts over anything the image created at build time. + +## Typical workflows + +```bash +# Create, run a command, expose a dev server, tear down +bunny sandbox create my-sandbox --env-file .env # .env holds ANTHROPIC_API_KEY for Claude Code +bunny sandbox exec my-sandbox -- bun install +bunny sandbox url add my-sandbox 3000 # public HTTPS URL for port 3000 +bunny sandbox delete my-sandbox --force + +# Move files in and out +bunny sandbox files cp ./app.js my-sandbox:app.js # relative paths resolve against /workplace +bunny sandbox files cp my-sandbox:out.log ./out.log + +# Interactive work +bunny sandbox ssh my-sandbox +``` + +--- + +## `bunny sandbox create` + +Create and start a new sandbox. Waits for the container's SSH port to become reachable. Prompts for a name when omitted (in `--output json` mode the positional is required). + +```bash +bunny sandbox create # interactive: prompts for a name +bunny sandbox create my-sandbox +bunny sandbox create my-sandbox --region NY +bunny sandbox create my-sandbox -e NODE_ENV=production --env-file .env +bunny sandbox create my-sandbox --env-file .env # bake in ANTHROPIC_API_KEY for Claude Code +``` + +| Flag | Alias | Description | Default | +| ------------ | ----- | ------------------------------------------------ | ------- | +| `--region` | | Region ID to deploy in (e.g. `AMS`, `NY`, `LA`) | `AMS` | +| `--env` | `-e` | Environment variable as `KEY=VALUE` (repeatable) | | +| `--env-file` | | Load environment variables from a dotenv file | | + +Variables set at creation are baked into the container and persist across restarts; `--env` overrides `--env-file`. Output shows the app ID and SSH address; public URLs come later via `bunny sandbox url add`. + +--- + +## `bunny sandbox list` / `delete` + +```bash +bunny sandbox list # sandboxes in local config (alias: ls) +bunny sandbox delete my-sandbox # destroys the underlying Magic Containers app +bunny sandbox rm my-sandbox -f # alias; --force skips confirmation +``` + +--- + +## `bunny sandbox exec` + +Run a shell command inside a sandbox over SSH. Defaults to `/workplace`. Exit code is propagated; use `--` before the command so its flags are not consumed by the CLI. + +```bash +bunny sandbox exec my-sandbox ls -la +bunny sandbox exec my-sandbox --cwd /tmp env +bunny sandbox exec my-sandbox --env DEBUG=1 -- node app.js +bunny sandbox exec my-sandbox --timeout 30 -- bun run build # exit 124 on timeout +``` + +| Flag | Description | Default | +| ------------ | --------------------------------------------------------------- | ------------ | +| `--cwd` | Working directory inside the sandbox | `/workplace` | +| `--env` | Temporary environment variable as `KEY=VALUE` (repeatable) | | +| `--env-file` | Load temporary environment variables from a dotenv file | | +| `--timeout` | Close the SSH connection and exit `124` after this many seconds | | + +Variables passed here apply to that single command only. For persistent variables, use `bunny sandbox env`. + +--- + +## `bunny sandbox ssh` + +Open a full interactive SSH session (bash at `/workplace`). `exit` or Ctrl-D closes it. + +```bash +bunny sandbox ssh my-sandbox +bunny sandbox ssh my-sandbox -e DEBUG=1 --env-file .env # session-only variables +``` + +--- + +## `bunny sandbox files` + +Manage files over SFTP. A bare sandbox name targets `/workplace`; use `:` for a specific directory. + +```bash +bunny sandbox files list my-sandbox # alias: ls +bunny sandbox files list my-sandbox:src +bunny sandbox files list my-sandbox --output json # name, type, size, mode +``` + +`cp` copies a single file between the local machine and a sandbox. Exactly one path must be `:`; relative remote paths resolve against `/workplace`. Uploads preserve the local Unix mode. Directory and sandbox-to-sandbox copies are not supported. + +```bash +bunny sandbox files cp ./app.js my-sandbox:/workplace/app.js +bunny sandbox files cp ./app.js my-sandbox:app.js # relative to /workplace +bunny sandbox files cp ./app.js my-sandbox:/workplace/src # existing dir (or trailing slash) keeps the filename +bunny sandbox files cp my-sandbox:/workplace/out.log ./logs/ # ./logs must already exist +``` + +The old `bunny sandbox cp` path errors and prints the `bunny sandbox files cp` command to run instead. + +--- + +## Public URLs + +Expose ports running inside a sandbox as public HTTPS endpoints (for dev servers and APIs). + +### `bunny sandbox url add` + +Waits until the URL is provisioned and prints it. + +```bash +bunny sandbox url add my-sandbox 3000 # endpoint named "port-3000" +bunny sandbox url add my-sandbox 8080 --label my-api +``` + +### `bunny sandbox url list` / `delete` + +```bash +bunny sandbox url list my-sandbox # user-created endpoints (alias: ls) +bunny sandbox url remove my-sandbox port-3000 +bunny sandbox url rm my-sandbox my-api -f # alias; --force skips confirmation +``` + +--- + +## Persistent environment variables + +`bunny sandbox env` manages the variables baked into the container (unlike the temporary `--env` on `exec`/`ssh`). Changing them redeploys the sandbox and restarts running processes. + +### `bunny sandbox env set` + +Merges with the existing set. + +```bash +bunny sandbox env set my-sandbox NODE_ENV=production +bunny sandbox env set my-sandbox A=1 B=2 +bunny sandbox env set my-sandbox --env-file .env +``` + +### `bunny sandbox env list` / `delete` + +```bash +bunny sandbox env list my-sandbox # alias: ls (internal AGENT_TOKEN hidden) +bunny sandbox env remove my-sandbox NODE_ENV +bunny sandbox env rm my-sandbox A B # aliases: rm, unset +``` + +Unset names are reported and skipped; if none match, the command errors and nothing is redeployed. diff --git a/.agents/skills/bunny-cli/references/scripts.md b/.agents/skills/bunny-cli/references/scripts.md new file mode 100644 index 000000000..c5c8a79a6 --- /dev/null +++ b/.agents/skills/bunny-cli/references/scripts.md @@ -0,0 +1,264 @@ +# Edge Scripts Commands + +All Edge Script commands live under `bunny scripts`. Most accept an optional `SCRIPT_ID`. When omitted, the ID is resolved in this order: + +1. Explicit `SCRIPT_ID` — a trailing `[id]` positional on most commands, or the `--id` flag on `env set` / `env remove` (which already use positionals for the variable name/value) +2. `.bunny/script.json` manifest (written by `bunny scripts link`, `bunny scripts create`, or `bunny scripts init --deploy`) +3. Interactive prompt (suppressed in `--output json` mode — pass an ID or link the directory in CI) + +When the script is chosen via the interactive prompt, these commands (`show`, `stats`, `env`, `deployments`) offer to link the directory to it. Pass `--link` to link without the prompt, or `--no-link` to skip it. + +## Typical workflows + +```bash +# New project from scratch: scaffold, create remote script, deploy +bunny scripts init --name my-script --type standalone --template Empty --no-github-actions --deploy +cd my-script +bunny scripts deploy dist/index.js + +# Existing project: create the remote script, then deploy +bunny scripts create # links .bunny/script.json + creates a pull zone +bunny scripts deploy dist/index.js + +# Existing remote script: link the directory first +bunny scripts link +bunny scripts deploy dist/index.js +``` + +--- + +## `bunny scripts init` — Scaffold a project from a template + +```bash +bunny scripts init # interactive wizard +bunny scripts init --name my-script --type standalone --template Empty --no-github-actions --deploy +bunny scripts init --name my-script --type standalone --template Empty --github-actions --deploy +bunny scripts init --repo owner/my-template # custom template (shorthand) +bunny scripts init --template-repo https://github.com/owner/my-template # custom template (full URL) +``` + +| Flag | Description | +| --------------------------- | ------------------------------------------------------------------------------------ | +| `--name` | Project directory name | +| `--type` | Script type: `standalone` or `middleware` | +| `--template` | Template name | +| `--template-repo`, `--repo` | Git repository URL or GitHub `owner/repo` shorthand to use as template | +| `--github-actions` | Keep the template's GitHub Actions workflow (use `--no-github-actions` to remove it) | +| `--deploy` | Create script on bunny.net after scaffolding | +| `--skip-git` | Skip git initialization | +| `--skip-install` | Skip dependency installation | + +- `--repo`/`--template-repo` without `--type` defaults to `standalone`. +- With `--github-actions`, git is initialized automatically and the `SCRIPT_ID` to add as a GitHub repo secret is printed after script creation. +- The interactive wizard offers an optional custom domain after creating the script (same DNS + HTTPS flow as `domains add`). If the domain is in one of the account's Bunny DNS zones, it offers — after confirmation — to add or repoint the DNS record, then issues SSL straight away since DNS is already live on bunny's resolvers. A domain delegated to bunny's nameservers with no zone in the account gets an offer to create the zone first. + +--- + +## `bunny scripts create` — Create a remote Edge Script + +Creates the script on bunny.net without scaffolding a project. Use when a local project already exists and needs a remote script before `deploy`. + +```bash +bunny scripts create # current directory name + link +bunny scripts create my-script --type middleware +bunny scripts create my-script --no-pull-zone --no-link +bunny scripts create my-script --domain shop.example.com +``` + +| Flag | Description | +| ------------------ | ---------------------------------------------------------------------------------------- | +| `--type` | Script type: `standalone` or `middleware` (defaults to manifest, prompts if interactive) | +| `--pull-zone` | Create a linked pull zone (default: true). Use `--no-pull-zone` to skip. | +| `--pull-zone-name` | Name for the linked pull zone | +| `--link` | Link this directory to the new script (default: true). Use `--no-link` to skip. | +| `--domain` | Add a custom domain to the new script's pull zone (prompted when interactive) | + +--- + +## `bunny scripts deploy` — Upload and publish code + +```bash +bunny scripts deploy dist/index.js # deploy and publish +bunny scripts deploy dist/index.js --skip-publish # upload without publishing +bunny scripts deploy dist/index.js 12345 # target a specific script +``` + +| Flag | Description | +| ---------------- | ------------------------------ | +| `--skip-publish` | Upload code without publishing | + +After publishing, the live URL and any custom domains are printed. The last deployment always wins, whether triggered by GitHub Actions or a manual CLI deploy. + +--- + +## `bunny scripts link` — Link the current directory + +Writes `.bunny/script.json` so subsequent commands resolve the script without an ID. + +```bash +bunny scripts link # interactive selection +bunny scripts link --id # non-interactive +``` + +--- + +## `bunny scripts list` / `show` + +```bash +bunny scripts list # all scripts (alias: ls) +bunny scripts list --output json +bunny scripts show # details incl. hostnames + SSL status +bunny scripts show # linked script +``` + +--- + +## `bunny scripts stats` — Usage statistics + +Request, CPU, and cost totals plus a per-bucket requests-served bar chart (text mode). Defaults to the last 30 days. With no ID and no link, prompts to pick a script and offers to link the directory; in `--output json` mode it errors instead. + +```bash +bunny scripts stats +bunny scripts stats 12345 --from 2026-05-01 --to 2026-05-31 +bunny scripts stats 12345 --hourly +bunny scripts stats 12345 --output json +bunny scripts stats --no-link # interactive pick without the link prompt +``` + +| Flag | Description | +| ---------- | ---------------------------------------------------------------------------------- | +| `--from` | Start date (YYYY-MM-DD); defaults to 30 days ago | +| `--to` | End date (YYYY-MM-DD); defaults to today | +| `--hourly` | Group statistics by hour instead of by day | +| `--link` | After an interactive pick, link the directory (use `--no-link` to skip the prompt) | + +--- + +## `bunny scripts delete` — Delete a script + +Requires double confirmation (or `--force`). + +```bash +bunny scripts delete +bunny scripts delete # linked script +bunny scripts delete --force +``` + +--- + +## Deployments + +### `bunny scripts deployments list` + +```bash +bunny scripts deployments list # linked script (alias: ls) +bunny scripts deployments list +bunny scripts deployments list --output json +``` + +### `bunny scripts deployments publish` — Roll back to a past release + +Re-publishes an earlier release by its release ID (from `deployments list`) without touching the current code. + +```bash +bunny scripts deployments publish +bunny scripts deployments publish +bunny scripts deployments publish --force +``` + +| Flag | Description | +| --------- | ----------------------------------------------------------- | +| `--force` | Skip the confirmation prompt | +| `--link` | After an interactive pick, link the directory to the script | + +--- + +## Environment variables + +All `env` subcommands default to the linked script. `env list` and `env pull` take a trailing `[id]`; `env set` and `env remove` use `--id ` (their positionals are the variable name and value). All accept `--link` (see the resolution note above). + +### `bunny scripts env list` + +```bash +bunny scripts env list # alias: ls +bunny scripts env list --output json +``` + +### `bunny scripts env set` + +Variable names are uppercased. Runs interactively when arguments are omitted. + +```bash +bunny scripts env set MY_VAR value +bunny scripts env set API_KEY secret-value --secret # encrypted secret +``` + +### `bunny scripts env remove` + +Interactive picker when no name is given; confirms unless `--force`. + +```bash +bunny scripts env remove MY_VAR +bunny scripts env rm MY_VAR -f +``` + +### `bunny scripts env pull` + +Pull environment variables to a local `.env` file. + +```bash +bunny scripts env pull +bunny scripts env pull --force # overwrite existing .env without prompting +``` + +--- + +## Custom domains + +A script's domains live on its linked pull zone — these commands operate on that pull zone. Pass a trailing `[id]` (or `--id`) to target a non-linked script, and `--pull-zone ` when a script has multiple linked zones. (`bunny scripts hostnames` is a hidden alias.) + +### `bunny scripts domains add` + +SSL is **not** requested by default — a free certificate can only be issued once DNS points at bunny.net, so the command prints the `CNAME` record to create. Interactively it offers to wait for DNS propagation (up to 10 minutes) and issues the certificate automatically. + +```bash +bunny scripts domains add shop.example.com # print CNAME, optionally wait +bunny scripts domains add shop.example.com --wait # wait for DNS, then enable HTTPS — no prompts +bunny scripts domains add shop.example.com --ssl # request SSL now (DNS must already point at bunny.net) +bunny scripts domains add shop.example.com --ssl --no-force-ssl +bunny scripts domains add shop.example.com 12345 # non-linked script +``` + +| Flag | Description | +| ---------------- | ----------------------------------------------------------------------- | +| `--ssl` | Issue a free SSL certificate now and force HTTPS (requires DNS pointed) | +| `--wait` | Wait for DNS to point at bunny.net (up to 10 minutes), then issue SSL | +| `--no-force-ssl` | When issuing SSL, keep serving HTTP instead of redirecting to HTTPS | +| `--pull-zone` | Pull zone ID (required if the script has multiple linked zones) | + +### `bunny scripts domains ssl` + +Request a free SSL certificate after DNS points at bunny.net. HTTP redirects to HTTPS by default. + +```bash +bunny scripts domains ssl shop.example.com +bunny scripts domains ssl shop.example.com --no-force-ssl +``` + +### `bunny scripts domains list` / `remove` + +```bash +bunny scripts domains list # domains with SSL + Force SSL status (alias: ls) +bunny scripts domains remove shop.example.com # system hostnames cannot be removed +bunny scripts domains remove shop.example.com --force +``` + +--- + +## `bunny scripts docs` + +Open the Edge Scripts documentation in your browser. + +```bash +bunny scripts docs +``` diff --git a/.agents/skills/bunny-cli/references/sites.md b/.agents/skills/bunny-cli/references/sites.md new file mode 100644 index 000000000..fbe53ee72 --- /dev/null +++ b/.agents/skills/bunny-cli/references/sites.md @@ -0,0 +1,184 @@ +# Static Sites Commands + +All site commands live under `bunny sites`. A site is one storage zone (files) + one pull zone (CDN) with edge rules routing requests to the published deploy, provisioned together by `sites create`. Deploys are immutable directories; promoting or rolling back retargets an edge rule and purges the cache; no files move, so it's instant. + +Most commands accept an optional site (a trailing `[site]` positional, or the `--site` flag on commands whose positionals are taken, like `deploy`). When omitted, the site resolves in this order: + +1. Explicit name or storage zone ID +2. `.bunny/site.json` manifest (written by `bunny sites link` or `bunny sites create`) +3. `sites.name` in `bunny.jsonc` +4. Interactive prompt (suppressed in `--output json` mode, and on destructive commands run with `--force`; pass a site or link the directory in CI) + +Commands that can link the directory (`deploy`, `show`, `deployments list/publish`, `ci init`) take `--link`/`--no-link`: the picker prompts unless the flag decided it, and an explicit `--link` also links a site resolved from a ref or from `bunny.jsonc`, including under `--output json`. The other site commands never write the manifest and don't take the flag. + +## Typical workflows + +```bash +# New site: provision, deploy, iterate +bunny sites create my-site # served at https://sites-my-site-.b-cdn.net +bunny sites deploy ./dist # deploy and publish as the live site + +# Build-and-deploy in one step (build command from bunny.jsonc or the flag) +bunny sites deploy --build # runs `sites.build`, deploys `sites.dir` +bunny sites deploy ./out --build "npm run build" + +# Rollback +bunny sites deployments list # find the deploy ID (● Live marks production) +bunny sites deployments publish --previous --force # instant rollback +bunny sites deployments publish a1b2c3d4 --force # promote a specific deploy + +# Custom production domain (optional) +bunny sites domains add example.com --wait # production vanity hostname + SSL +``` + +## Deploying is publishing + +This is the rule that shapes every other command here: + +- Every `deploy` becomes the live site. There is no separate preview URL and no unpublished deploy. +- Deploys stay immutable under their own ID, so `deployments publish ` rolls back to any earlier one by retargeting the edge rule; no files move and nothing is re-uploaded. +- Custom domains are vanity hostnames on the site's pull zone; without one the site serves at `https://sites--.b-cdn.net`. + +Content is root-served, so root-absolute assets work as-is. Single-page apps get `index.html` for extensionless misses when the detected framework is client-routed (Vite, CRA, React Router, Angular, Vue CLI, Ember, Preact, Blazor WebAssembly) and the output has no root `404.html`; `sites.spa` in `bunny.jsonc` or `--spa`/`--no-spa` on the deploy decides explicitly, and otherwise a root `404.html` is the not-found page. The mode is recorded per deploy and follows rollbacks. Deploys are not individually addressable: `/deploys//` URLs are internal to the storage layout and are not publicly served. To review a change before it goes live, build and serve it locally, or deploy it to a separate site. + +## Deploy IDs + +- The deploy ID is the **git short-sha** when the working tree is clean, otherwise a 12-char **content hash**. Re-deploying identical content is a no-op (`--force` overrides). +- `--deploy-id ` sets the ID yourself, so a deploy can carry the same identifier as whatever produced it (a release tag, a catalog build, a timestamped artifact) and `deployments list` needs no cross-referencing. The ID is used **exactly as given**, case included: it exists to match your identifier, and it never appears in a client-facing URL (the edge rule builds the origin path from it server-side). IDs become storage paths, so they take letters, digits and `-`, `_` or `.`, 4 to 64 characters, starting and ending alphanumeric: `20260827-1433-r42`, `Catalog_V3`, `v1.2.3`. + - Deploy IDs are therefore **case-sensitive**. `publish`/`delete` match exactly and suggest a case variant when one exists, and deploying an ID that differs from an existing one only in case is refused (not even with `--force`), since two storage paths differing only by case are indistinguishable to anything that folds case. + - An explicit ID is an assertion about identity, so it is never aliased onto an earlier deploy that happens to share content: each release keeps its own ID and rollback target even when the bytes are unchanged. + - Reusing an ID for **different** content asks before replacing, because rolling back to that ID would then serve the new files instead of the originals (`--force` skips the prompt for CI). A replacement clears the old files first, so nothing stale survives. The **live deploy and the rollback target are never replaceable in place** (not even with `--force`): that would empty and rewrite the files being served. Deploy under a new ID, or publish another deploy first. + - The git sha is still recorded alongside a custom ID when the deploy came from a repo, so provenance is not lost; `deployments list` shows it as `custom (git abc12345)`. +- Dotfiles and `node_modules` are never uploaded. + +--- + +## `bunny sites create`: Provision a site + +```bash +bunny sites create # uses `sites.name` from bunny.jsonc, else prompts (directory-name suggestion), then a custom domain +bunny sites create my-site +bunny sites create my-site --region NY +bunny sites create my-site --tier ssd # Edge (SSD) storage, always DE +bunny sites create my-site --domain example.com +bunny sites create my-site --no-link # don't write .bunny/site.json +``` + +| Flag | Description | +| --------------- | --------------------------------------------------------------------------------------------------- | +| `--region` | Main storage region code (default `DE`) | +| `--tier` | Storage tier: `hdd` (Standard) or `ssd` (Edge, always `DE`); create-time only | +| `--domain` | Attach a custom production domain after provisioning; interactive runs prompt for one when omitted | +| `--link` | Link this directory (default true; `--no-link` to skip) | +| `--from-zone` | Import an existing storage zone (name or ID) and its pull zone as the site instead of creating them | +| `--force`, `-f` | Skip the import confirmation (only with `--from-zone`) | + +Site names are 3-47 lowercase letters, digits, and dashes. The storage zone, pull zone, and b-cdn.net subdomain become `sites--xxxxxx` (a `sites-` prefix marking them in the dashboard, plus a shared random suffix since zone names are global across bunny.net); commands still take the clean site name. Creation is idempotent; a failed create re-runs cleanly, reusing whatever was already provisioned. A name already used by any site, including an imported one, is refused. + +--- + +## `bunny sites deploy`: Deploy a directory + +```bash +bunny sites deploy ./dist # deploy and publish as the live site +bunny sites deploy --build # run `sites.build` from bunny.jsonc first +bunny sites deploy ./out --build "npm run build" --env VITE_FLAG=1 +``` + +| Flag | Description | +| ------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| `[dir]` | Directory to deploy (default: `sites.dir` in bunny.jsonc, then the detected framework's output dir when building, then cwd) | +| `--build` | Run a build first (bare flag: `sites.build`, else a detected build) | +| `--env` | Build-time env override `KEY=VALUE` (repeatable; requires `--build`) | +| `--env-file` | Dotenv file of build-time overrides (requires `--build`) | +| `--force` | Deploy even when content is unchanged | +| `--site` | Target site (name or storage zone ID) | +| `--link` | Link this directory to the deployed site (`--no-link` never links) | +| `--deploy-id` | Your own deploy ID (release tag, catalog build); see Deploy IDs | +| `--spa`, `--no-spa` | Serve `index.html` for client-side routes, or the 404 page; beats `sites.spa` and detection | + +With `--build`, the build runs in your shell environment plus the `--env`/`--env-file` overrides; there is no remote env store; put build-time values in your local `.env` or CI secrets. Redeploying content that is already uploaded skips the upload and just republishes it; when it is already live, the deploy is a no-op unless you pass `--force`. + +Interactive `deploy` adds two conveniences (both skipped under `--output json`): + +- **No linked site** → it offers to create a new site or pick an existing one, then links it (goes straight to create when the account has no sites). +- **No `--build`** → it offers to run a build first: the configured `sites.build`, else a detected framework build (same detection as `ci init`), else a `package.json` `build` script. Confirming builds first, and when no `[dir]` was given it deploys the framework's output directory. +- **A domainless site's first deploy** → it offers to attach a custom production domain (blank skips) and runs the same DNS/SSL flow as `sites domains add`; it asks only once per site, and every later domainless deploy just prints a one-line `sites domains add` hint. + +--- + +## `bunny sites deployments`: List, publish, prune, delete + +```bash +bunny sites deployments list +bunny sites deployments publish a1b2c3d4 # confirm prompt; --force to skip +bunny sites deployments publish --previous # instant rollback +bunny sites deployments prune --keep 10 # never prunes current/previous +bunny sites deployments prune my-site # target a site other than the linked one +bunny sites deployments delete a1b2c3d4 --force # delete one deploy +``` + +`publish` (alias `promote`) flips production to a past deploy; the files are already on the CDN, so this is instant plus a cache purge. + +`delete` removes a single deploy: its files and its record. The live deploy and the rollback target are refused (`--force` only skips the confirmation), and deleting an ID that's already gone is a no-op success, so re-runs converge. `--output json` prints `{ site, id, deleted }`. For retention cleanup, use `prune`. + +--- + +## `bunny sites domains`: Custom domains + +```bash +bunny sites domains add example.com --wait # wait for DNS, then issue SSL +bunny sites domains ssl example.com # issue a certificate later +bunny sites domains list +bunny sites domains remove example.com +``` + +A custom domain is the site's production URL and nothing more. The first added domain is recorded as the production URL. If the domain is on a Bunny DNS zone in the account, the CLI offers to create the record; if its nameservers already point at bunny.net without a zone in the account, it offers to create the zone first (shared flow in `core/hostnames/flow.ts`, so scripts and storage domains behave the same); otherwise it prints the CNAME target. The CLI verifies a certificate actually landed on the exact hostname before forcing HTTPS or reporting success, and probes the domain over TLS afterwards, so a mismatched certificate (e.g. shadowed by another zone's wildcard) warns instead of printing a broken URL. Re-running `bunny sites domains add ` after a partial setup reconciles the remaining steps instead of failing on the already-attached hostname. + +--- + +## `bunny sites ci init`: GitHub Actions deployments + +```bash +bunny sites ci init # detect the framework, write .github/workflows/bunny-sites.yml +bunny sites ci init --framework astro # skip detection (astro, vite, react-router, next, sveltekit, vitepress, docusaurus, eleventy, jekyll, hugo, static) +bunny sites ci init --site my-site --force # overwrite an existing workflow +``` + +Writes a workflow using the `BunnyWay/actions/deploy-site` action with the site name baked in: pushes to `main` go live, plus `workflow_dispatch` for on-demand redeploys. Deploys serialize and are never cancelled in flight, since cancelling mid-upload would leave a half-written deploy directory behind. `sites.dir` and `sites.build` from `bunny.jsonc` override the preset's deploy directory and build command, so CI builds and deploys exactly what a local `sites deploy` does. The workflow is written at the git root; when `bunny.jsonc` lives below it (a monorepo package), the job gets `defaults.run.working-directory` and the deploy directory is prefixed, so those paths still mean what they do locally. Framework detection reads `package.json` dependencies, `Gemfile`, or Hugo config; the lockfile picks the package manager for the install steps. The job requests `contents: read` and `deployments: write`, so the run is recorded in the repository's Environments. After writing, the CLI offers to run `gh secret set BUNNYNET_API_KEY` (or prints the manual steps). `sites create` offers the same scaffold on GitHub repos; declining prints the workflow instead. + +--- + +## `bunny sites` lifecycle and maintenance + +```bash +bunny sites list +bunny sites show # resources, domains, current deploy +bunny sites open # open the live URL in the browser (--print emits it) +bunny sites ssl --no-force-ssl # toggle Force HTTPS on the site's b-cdn.net system host +bunny sites link my-site # .bunny/site.json +bunny sites unlink +bunny sites delete my-site # typed-name confirmation; --keep-storage keeps files +``` + +## `bunny.jsonc` integration + +An optional `sites` block configures the deploy defaults (validated on its own, so a sites-only file works without an `app` block): + +```jsonc +{ + "sites": { + "name": "my-site", // resolves the site when nothing is linked + "dir": "./dist", // default deploy directory + "build": "npm run build", // command for `deploy --build` + "spa": true, // serve index.html for client-side routes (omit to detect) + }, +} +``` + +## CI / agents + +- Pass `--force` on anything with a confirmation (publish, prune, remove, delete, `create --from-zone`); without a TTY they error with a hint rather than waiting on a prompt. +- Pass the site explicitly (or commit `bunny.jsonc` with `sites.name`); the interactive picker is disabled under `--output json` and by `--force`, so `sites delete --force` with nothing linked errors instead of prompting. +- `--output json` on every command emits machine-readable results. `deploy` prints `{ id, production, unchanged, live }`, where `production` is `null` on a site whose hostname couldn't be read. +- The first-deploy custom-domain prompt never runs under `--output json` or without a TTY, so CI deploys are unaffected. diff --git a/.agents/skills/bunny-cli/references/storage.md b/.agents/skills/bunny-cli/references/storage.md new file mode 100644 index 000000000..86a8ee346 --- /dev/null +++ b/.agents/skills/bunny-cli/references/storage.md @@ -0,0 +1,236 @@ +# Edge Storage Commands + +All storage commands live under `bunny storage`, split into two resource groups: + +- **`bunny storage zones`** (alias `zone`, hidden `bucket`/`buckets`) manages the zone itself with the account API key. +- **`bunny storage files`** (alias `file`) manages the files inside a zone, using the zone's own password against a region-specific host. Both are resolved from the zone automatically, so there is nothing to configure. + +A storage zone only holds files. A **pull zone** is what serves them on the web, and custom domains live on the pull zone (`bunny storage zones domains`), not on the storage zone. + +## Resolving the zone + +`zones` commands take the zone as an optional `[zone]` positional. `files` commands take it as `--zone`/`-z`, because their positional is the file path. Either accepts the zone name or its numeric ID. When omitted, the zone resolves in this order: + +1. Explicit name or numeric ID +2. `.bunny/storage.json` (written by `bunny storage link`) +3. Interactive picker, which offers to link the directory to the zone it picked (never on destructive commands) + +Non-interactive runs (`--output json`, no TTY, or `--force`) error instead of prompting. Pass a zone or link the directory first. + +## Typical workflows + +```bash +# New zone, served on the web, credentials in .env +bunny storage zones create my-zone --region DE --s3 --domain cdn.example.com \ + --connection s3 --save-env --link + +# Push a build up and check it landed +bunny storage files upload ./dist/app.js --to assets/ --content-type application/javascript +bunny storage files list assets/ + +# Hand credentials to another tool +set -a; eval "$(bunny storage zones credentials my-zone --format env)"; set +a +bunny storage zones credentials my-zone --format rclone >> ~/.config/rclone/rclone.conf +``` + +--- + +## `bunny storage zones list` — List storage zones + +```bash +bunny storage zones list +bunny storage zones list --output json +``` + +Reports name, ID, main region, tier, and S3 support per zone. + +--- + +## `bunny storage zones create` — Create a storage zone + +```bash +bunny storage zones create # interactive +bunny storage zones create my-zone --region DE +bunny storage zones create my-zone --region NY --replication LA,SG +bunny storage zones create my-zone --region DE --pull-zone # also serve it on the web +bunny storage zones create my-zone --region DE --domain cdn.example.com # pull zone + custom domain +bunny storage zones create my-zone --tier ssd --s3 # Edge tier with S3 access +bunny storage zones create my-zone --region DE --s3 --connection s3 --save-env +``` + +### Flags + +| Flag | Description | +| ------------------ | ------------------------------------------------------------------------------------------- | +| `--region` | Main storage region code (e.g. `DE`, `NY`, `LA`, `SG`). Run `bunny storage regions` to list | +| `--replication` | Replication region codes, comma-separated or repeated (any region except the primary) | +| `--tier` | `hdd` (Standard) or `ssd` (Edge) | +| `--s3` | Enable S3-compatible API access | +| `--pull-zone` | Also create a pull zone with this storage zone as its origin | +| `--pull-zone-name` | Name for that pull zone (defaults to the storage zone name) | +| `--domain` | Custom domain to add to the pull zone (implies `--pull-zone`) | +| `--link` | Link this directory to the new zone. `--no-link` skips without prompting | +| `--connection` | Print connection details for `http`, `ftp`, or `s3` | +| `--format` | Emit client config instead of a table: `sdk`, `rclone`, `aws`, `s3cmd`, `env` | +| `--save-env` | Write the connection details to `.env` (needs `--connection`) | +| `--force` | Skip prompts and confirmations, using flag values only | + +**Create-time only**: the tier, the main region, and S3 compatibility are all fixed once the zone exists, so interactive `create` prompts for each when its flag is omitted, and none of them can be changed later. Edge (SSD) zones are always primaried in `DE`, so `--tier ssd` rejects any other `--region` rather than letting the API rewrite it silently. Replication regions are unaffected. + +After creating the zone, interactive `create` offers to link the directory, print connection details, and save them to `.env`. Credentials print in full here because they were explicitly asked for; `zones credentials` masks them by default. + +--- + +## `bunny storage zones show` — Inspect a zone + +```bash +bunny storage zones show my-zone +bunny storage zones show # linked zone +bunny storage zones show my-zone --output json +``` + +Reports the zone's settings, its tier, whether S3 is enabled, and the S3 endpoint when it is. + +--- + +## `bunny storage zones update` — Edit zone settings + +```bash +bunny storage zones update my-zone # interactive, pre-filled +bunny storage zones update my-zone --custom-404-path /404.html +bunny storage zones update my-zone --replication SG +``` + +Only the settings that aren't create-time can change here (see `--help` for the full list). **Replication is additive**: bunny.net has no API to remove a replication region once added, so `update` only offers regions the zone doesn't have, unions the flags with the current set, warns when an existing region is omitted rather than dropping it, and confirms before adding. + +--- + +## `bunny storage zones delete` — Delete a zone + +```bash +bunny storage zones delete my-zone # confirms twice +bunny storage zones delete my-zone --force # skip both confirmations +``` + +Deletes the zone and everything in it. Interactively it confirms twice: yes/no, then typing the zone name. + +--- + +## `bunny storage zones credentials` — Connection details + +```bash +bunny storage zones credentials my-zone # pick a protocol, secret masked +bunny storage zones credentials my-zone --connection ftp --show-secret +bunny storage zones credentials my-zone --connection s3 --read-only +bunny storage zones credentials my-zone --format sdk # storage SDK snippet +bunny storage zones credentials my-zone --format rclone >> ~/.config/rclone/rclone.conf +set -a; eval "$(bunny storage zones credentials my-zone --format env)"; set +a +bunny storage zones credentials my-zone --connection http --save-env +``` + +A zone has one password, shaped per protocol: + +| `--connection` | What it prints | +| -------------- | ------------------------------------------------------------------------ | +| `http` | Base URL and the `AccessKey` header | +| `ftp` | Host, username, password | +| `s3` | Endpoint, region, access key (the zone name), secret (the zone password) | + +### Flags + +| Flag | Description | +| --------------- | ----------------------------------------------------------------------------------------- | +| `--connection` | `http`, `ftp`, or `s3` (prompts when omitted) | +| `--format` | Client config: `sdk` (HTTP API), `rclone`, `aws`, `s3cmd`, `env` (S3). Implies a protocol | +| `--read-only` | Use the zone's read-only password as the secret | +| `--show-secret` | Reveal the masked secret in table and JSON output | +| `--save-env` | Write the protocol's variables into whichever `.env` already holds one of them | + +`--format` implies its protocol, so a conflicting `--connection` is an error. The four S3 tool formats (`rclone`, `aws`, `s3cmd`, `env`) emit the secret in full, since the output is meant to be piped into a tool; under `--output json` the config rides along in a `config` field. `--format sdk` is the exception: it emits a snippet that reads `process.env.BUNNY_STORAGE_ZONE` and `process.env.BUNNY_STORAGE_PASSWORD`, so it carries no credentials and needs those variables populated separately (`--connection http --save-env`). `--save-env` writes `BUNNY_STORAGE_ZONE`, `BUNNY_STORAGE_PASSWORD`, and `BUNNY_STORAGE_REGION`, or the `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_ENDPOINT_URL` / `AWS_REGION` quad for S3. + +The S3 access key and secret are just the zone's existing name and password, so there is nothing extra to rotate beyond the zone's own credentials. + +--- + +## `bunny storage files` — Files within a zone + +```bash +bunny storage files list --zone my-zone +bunny storage files list images/ # linked zone +bunny storage files upload ./photo.png --to images/ +bunny storage files upload ./photo.png --checksum --content-type image/png +bunny storage files download images/photo.png --out ./local.png +bunny storage files remove images/photo.png +bunny storage files remove images/ --force # trailing slash removes a directory +``` + +Paths are relative to the zone root. **A trailing slash means directory**: `files list images/` lists that directory, and `files remove images/` deletes it and its contents recursively. + +| Flag | Commands | Description | +| ---------------- | ---------------- | --------------------------------------------------------- | +| `--zone`, `-z` | all `files` | Zone name or ID (defaults to the linked zone) | +| `--to` | `files upload` | Remote path; a trailing slash uploads into that directory | +| `--checksum` | `files upload` | Send a SHA256 checksum for server-side verification | +| `--content-type` | `files upload` | Content type to store the file as | +| `--out` | `files download` | Local destination (defaults to the file name) | +| `--force` | `files remove` | Skip the confirmation prompt | + +There is no per-file cache header: cache policy lives on the pull zone (edge rules), not on the stored object. + +--- + +## `bunny storage zones domains` — Custom domains + +```bash +bunny storage zones domains list my-zone +bunny storage zones domains add cdn.example.com my-zone +bunny storage zones domains ssl cdn.example.com my-zone +bunny storage zones domains remove cdn.example.com my-zone +``` + +These act on the pull zone linked to the storage zone, since that's where hostnames live. The CLI only creates a pull zone during `zones create --pull-zone`; for a zone that has none, create one with the storage zone as its origin in the dashboard. When a zone has several pull zones, pass `--pull-zone ` to choose. + +`add` prints the DNS record to create and can wait for propagation; `ssl` requests the certificate once DNS resolves to bunny.net. `hostnames` works as a hidden alias for `domains`. + +--- + +## `bunny storage link` / `unlink` — Directory linking + +```bash +bunny storage link my-zone # writes .bunny/storage.json +bunny storage link # interactive picker +bunny storage unlink +bunny storage unlink --force # skip the confirmation +``` + +`.bunny/storage.json` is per-developer state and gitignored, not shared config. + +--- + +## `bunny storage regions` — List storage regions + +```bash +bunny storage regions +bunny storage regions --output json +``` + +Use this to find valid `--region` and `--replication` codes. + +--- + +## `bunny storage docs` — Open the documentation + +```bash +bunny storage docs +``` + +Opens the Edge Storage docs in a browser. Like other browser helpers, it has no `--output json`. + +## Anti-Patterns + +- **Expecting to change the tier, main region, or S3 support later**: all three are create-time only. Getting them wrong means creating a new zone and copying the files. +- **Adding a replication region "to try it"**: replicas cannot be removed. There is no API for it, so the only undo is deleting the zone. +- **Putting a custom domain on the storage zone**: domains live on the pull zone. Use `bunny storage zones domains`, and create a pull zone first (`zones create --pull-zone`) if the zone has none. +- **Setting per-file cache headers**: `files upload` takes `--content-type` and `--checksum` only. Cache behavior is a pull zone concern. +- **Expecting `--connection s3` to enable S3**: it only picks which credentials to print. Without `--s3`, `zones create` creates a zone with S3 off and warns that the credentials will not work, and S3 cannot be turned on afterwards. +- **Relying on the interactive picker in CI**: `--output json`, a closed stdin, and `--force` all suppress it. Pass `--zone`/`[zone]`, or link the directory. diff --git a/.agents/skills/bunny-cli/references/stream-import.md b/.agents/skills/bunny-cli/references/stream-import.md new file mode 100644 index 000000000..79a1a19d6 --- /dev/null +++ b/.agents/skills/bunny-cli/references/stream-import.md @@ -0,0 +1,137 @@ +# Stream Import Commands + +`bunny stream import` moves an existing video library into a Bunny Stream library from **Vimeo**, **AWS S3** (or an S3-compatible provider), **Wistia**, **Mux**, **Cloudflare Stream**, **JW Player**, or **Brightcove**. Bunny fetches every file straight from the source by URL, so nothing is downloaded to the local machine. The command hands each video to Bunny, tags it, and returns once they are all queued; encoding then happens on Bunny's side. + +## Resolving the library + +`--library` (alias `--lib`) takes a library name or numeric ID. When omitted, it resolves in this order: + +1. `--library` +2. `.bunny/stream.json` (the directory's linked library, written by `bunny stream link`; see `stream.md`) +3. Interactive picker, which offers to link the directory (never on `--dry-run`) + +The library's own Stream API key is read from the account automatically and never printed. + +## Source credentials + +Credentials come from environment variables, never from flags: + +| Source | Variables | +| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `vimeo` | `VIMEO_ACCESS_TOKEN` (scopes `public`, `private`, `video_files`; downloads need a Standard plan or above) | +| `s3` | `S3_BUCKET`, `AWS_REGION` (or `AWS_DEFAULT_REGION`; default `us-east-1`); optional `S3_PREFIX`, `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`, `S3_PRESIGNED_URL_TTL`, `S3_ENDPOINT` (or `AWS_ENDPOINT_URL_S3`) | +| `wistia` | `WISTIA_ACCESS_TOKEN` | +| `mux` | `MUX_TOKEN_ID`, `MUX_TOKEN_SECRET` | +| `cloudflare` | `CLOUDFLARE_API_TOKEN` (Stream:Edit), `CLOUDFLARE_ACCOUNT_ID` | +| `jwplayer` | `JWPLAYER_API_KEY` (v2), `JWPLAYER_SITE_ID` | +| `brightcove` | `BRIGHTCOVE_CLIENT_ID`, `BRIGHTCOVE_CLIENT_SECRET` (CMS video read), `BRIGHTCOVE_ACCOUNT_ID` | + +`bunny stream import --help` lists the same variables. Interactively, the command prompts only for missing required values, then offers to save them for the active profile in `$XDG_CONFIG_HOME/bunnynet/stream-import-credentials.json` (default `~/.config/...`). Environment variables always win over saved values, and saved values are removed only when the source rejects them. Unattended runs (`--output json`, no TTY) fail naming the missing variables. + +With `--source` omitted, the command picks the one source whose credentials are all set, otherwise it prompts (or fails when unattended). + +S3 notes: leaving both AWS keys unset uses the default AWS credential chain; setting only one is an error. The IAM policy needs `s3:ListBucket` and `s3:GetObject`. Only video extensions are imported; `.ts` files only with `--include-ts` (or `S3_INCLUDE_TS=true`), since HLS buckets hold thousands of segments. + +## Typical workflows + +```bash +# 1. Browse the source: folders and their IDs, or every video +bunny stream import list --source vimeo +bunny stream import list --source vimeo --videos + +# 2. Look before importing: folder and video counts, what is already in Bunny +bunny stream import --library 12345 --source vimeo --dry-run + +# 3. Import (confirms first; --force skips the prompt and is required without a TTY) +bunny stream import --library 12345 --source vimeo --force + +# 4. Check how far Bunny has got with encoding +bunny stream import status --library 12345 --source vimeo + +# One folder only (Vimeo/Wistia projects, Brightcove folders, S3 prefixes) +bunny stream import --library 12345 --source vimeo --folder 987654 --force + +# S3, unattended, overriding the bucket and prefix for this run +bunny stream import --library 12345 --source s3 --bucket my-videos --prefix 2024/ --force + +# Continue an interrupted or partly failed run +bunny stream import --library 12345 --source vimeo --resume --force + +# Re-fetch videos stuck at zero bytes, or retry ones Bunny rejected +bunny stream import --library 12345 --source vimeo --retry-stalled --force +bunny stream import --library 12345 --source vimeo --clean-failed --force +``` + +--- + +## `bunny stream import`: Import a video library + +| Flag | Description | +| ----------------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `--library`, `--lib` | Destination library name or ID (defaults to the linked library) | +| `--source`, `-s` | `vimeo`, `s3`, `wistia`, `mux`, `cloudflare`, `jwplayer`, `brightcove` | +| `--folder` | One source folder only (Vimeo, S3, Wistia, Brightcove; rejected for Mux, Cloudflare Stream, JW Player) | +| `--dry-run` | Print the plan; writes nothing (no state file, no link, nothing in the library) | +| `--resume` | Continue the saved import for this source and library | +| `--wait` | Stay until Bunny has encoded every video (default: return once they are queued) | +| `--force`, `-f` | Skip the confirmation (which defaults to No) and the post-run retry offer | +| `--concurrency`, `-c` | Videos handed to Bunny in parallel, 1 to 20 (default 3) | +| `--bucket`, `--prefix`, `--url-ttl` | S3 overrides for one run (`--url-ttl` 3600 to 604800 seconds) | +| `--include-ts` | S3: import `.ts` files too | +| `--clean-failed` | Delete a video's failed Bunny copies before importing it again; retries rejected videos and ones that failed 3 times | +| `--retry-stalled` | Delete and import again videos stuck at zero bytes past the 30 minute stall threshold | +| `--request-timeout` | Seconds per HTTP request, 1 to 3600 (default 30) | +| `--video-timeout` | Seconds per video hand-off, or per encode wait with `--wait`, 60 to 86400 (default 5400) | +| `--processing-timeout` | With `--wait`, seconds per encode, 60 to 86400 (default 3600); raises `--video-timeout` when larger | + +**Re-running is safe.** Every imported video carries a per-source metaTag (`vimeoId`, `s3Source`, `wistiaId`, `muxAssetId`, `cfStreamId`, `jwPlayerId`, `brightcoveId`). A re-run skips tagged videos Bunny has finished, leaves ones still encoding alone, and imports again only the ones Bunny failed on. Source folders become Stream collections of the same name. Videos the source reports as beyond Bunny's limits (over 72 hours, or above 2160p) are flagged before you confirm. + +**Progress** is saved to `$XDG_STATE_HOME/bunnynet/stream-import//-.json` (default `~/.local/state/...`). `--resume` retries failed videos, works outstanding ones, and keeps the `--folder` scope the run started with. It does **not** retry videos Bunny rejected for a reason the same file cannot get past (over limits, invalid framerate, corrupt original), ones no longer at the source, or ones that failed 3 times; use `--clean-failed` for those. Two imports cannot share one state file. + +**Ctrl-C** stops queuing new videos, saves progress, prints the `--resume` command, and exits 130. A second Ctrl-C exits at once. + +**Exit codes:** 0 when everything was queued (or finished with `--wait`), 1 when any video failed (they are listed with Bunny's reason; in a terminal it offers to retry them), 130 when paused. + +### JSON output + +Every `--output json` document carries an `outcome`: + +- `no_videos`, `nothing_new`, `dry_run`: stopped before importing. Shape `{ outcome, library, source, folder, dryRun, truncated, summary }`; `summary` holds the counts (including `processingOnBunny`) and name lists (capped at 10000; `truncated` says when). +- `imported`, `paused`: shape `{ outcome, library, source, folder, dryRun, status, waited, queued, completed, processing, failed, collections, warnings, statePath, elapsedMs }`, each `failed` entry `{ name, sourceId, error }`. On `paused`, `library` and `statePath` may be null if it stopped before reaching them. + +Declining the confirmation prints nothing. + +--- + +## `bunny stream import list`: Browse a source + +```bash +bunny stream import list --source vimeo # folders: ID, name, video count +bunny stream import list --source vimeo --videos # every video, with folder, duration, size +bunny stream import list --source vimeo --folder 987654 # videos in one folder +bunny stream import list --source s3 --bucket my-videos --prefix 2024/ +``` + +Resolves the source and credentials the same way an import does and needs no library. The folder IDs it prints are what `--folder` takes. JSON: `{ source, supportsFolders, folders, uncategorized, videos, truncated }`. + +--- + +## `bunny stream import status`: Check a queued import + +```bash +bunny stream import status --library 12345 +bunny stream import status --library 12345 --source s3 --output json +``` + +Reads the saved run (pass `--source` when more than one platform was imported into the library), asks Bunny for the current state of every video it queued, and prints Bunny's status, encode percentage, size, and when each was queued. A video still processing with no data after 30 minutes is marked `(stalled?)`: re-run the import with `--retry-stalled` to delete and fetch it again. Exits 1 when any video failed, so it works as a poll in scripts. JSON: `{ library, source, status, completed, processing, failed, stalled, stalledAfterMinutes, saved, videos }`. + +--- + +## Anti-patterns + +- **Passing credentials as flags or pasting them into commands.** They are read from environment variables only; export them (or use a `.env` the shell loads) instead. +- **Importing without a dry run.** Run `import list` and `--dry-run` first to see the folders, video counts, and what is already in Bunny. +- **Holding the command open with `--wait` in automation.** Queue with `--force`, then poll `bunny stream import status` until it exits 0; encodes can take hours. +- **Deleting the state file to "start fresh".** It is how a resume recovers videos Bunny created but never tagged; deleting it can create duplicates. +- **Treating `(stalled?)` as success.** Bunny gives no signal for a fetch that died silently; re-run with `--retry-stalled`. +- **Looping `--resume` on rejected videos.** It skips them on purpose; use `--clean-failed` once, or fix the source file. diff --git a/.agents/skills/bunny-cli/references/stream.md b/.agents/skills/bunny-cli/references/stream.md new file mode 100644 index 000000000..5565ae162 --- /dev/null +++ b/.agents/skills/bunny-cli/references/stream.md @@ -0,0 +1,176 @@ +# Stream Commands + +All Stream commands live under `bunny stream`, split into resource groups: + +- **`bunny stream libraries`** (aliases `library`, `lib`) manages the video library itself, plus its `player` and `security` settings, with the account API key. +- **`bunny stream videos`** (alias `video`) manages the videos inside one library, their `captions`, and the paid `encode`, `transcribe`, and `smart`. +- **`bunny stream collections`** (alias `collection`) manages groups of videos within a library. +- **`bunny stream link`** and **`bunny stream unlink`** mirror `libraries link` and `libraries unlink`. +- **`bunny stream regions`** lists the region codes a library can replicate to. +- **`bunny stream import`** moves a library in from another platform; see `stream-import.md`. + +Each library carries its own Stream API key for the video-level API. Video commands resolve it from the library automatically, so there is nothing extra to configure; the key is never printed unless you ask for it with `libraries credentials`. + +## Resolving the library + +`libraries` commands take the library as an optional positional (name or numeric ID). Every other stream command takes it as `--lib` (alias `--library`), because its positional is a video GUID, a collection ID, or a local file path. When omitted, the library resolves in this order: + +1. Explicit name or ID +2. `.bunny/stream.json` (written by `bunny stream link`) +3. Interactive picker, which offers to link the directory (never on destructive commands) + +Non-interactive runs (`--output json`, no TTY, or `--force`) error instead of prompting: pass a library or link the directory first. A `videos`, `collections`, or `videos captions` command with no ID behaves the same way. On destructive commands and `videos smart`, `--force` also disables the pickers, so it never acts on something you did not name; on `videos transcribe` that is `--overwrite`, and `videos encode` has neither. + +## Typical workflows + +```bash +# New library: link this directory and write BUNNY_STREAM_LIBRARY_ID and BUNNY_STREAM_API_KEY to .env +bunny stream libraries create my-library --link --save-env --no-add-videos + +# Upload a local file, or have bunny.net fetch a URL server-side +bunny stream videos upload ./video.mp4 --title "Launch demo" +bunny stream videos fetch https://example.com/video.mp4 --header "Authorization: Bearer abc" + +# Find it, check it, embed it +bunny stream videos list --search launch +bunny stream videos show 1a2b3c4d-... +bunny stream videos embed 1a2b3c4d-... --expires 1h + +# Organise with collections +bunny stream collections create Tutorials +bunny stream videos update 1a2b3c4d-... --collection 8a7b6c5d-... +``` + +--- + +## `bunny stream libraries`: Libraries + +```bash +bunny stream libraries list # ID, name, videos, storage, traffic, replication regions +bunny stream libraries create my-library # stored in DE, replicated to SG, LA, NY by default +bunny stream libraries create my-library --replication-regions NY,SG # DE alone means no replication +bunny stream libraries create my-library --encoding-tier premium --codecs x264,vp9 +bunny stream libraries show my-library # API keys are never printed here +bunny stream libraries update my-library --resolutions 720p,1080p +bunny stream libraries update my-library --transcribing --transcribing-languages en,de +bunny stream libraries update my-library --encoding-tier premium --codecs x264,vp9 # paid; warns about billing +bunny stream libraries update my-library --add-replication-regions SYD # add-only +bunny stream libraries credentials my-library # library ID + API key, masked +bunny stream libraries credentials my-library --show-secret --read-only +bunny stream libraries credentials my-library --save-env # write BUNNY_STREAM_LIBRARY_ID and BUNNY_STREAM_API_KEY to .env +bunny stream libraries credentials my-library --rotate # reset the key (confirms first) +bunny stream libraries delete my-library # deletes every video in it; confirms with the count +bunny stream regions # DE is the fixed main region; SG, LA, NY the default replicas +bunny stream link my-library # same as `libraries link` +bunny stream unlink +``` + +`create` takes the name as its positional. After creating, it offers to link the directory, save `BUNNY_STREAM_LIBRARY_ID` and `BUNNY_STREAM_API_KEY` to `.env` (the names the Stream examples read), and add videos by importing from another platform or uploading a file. `--link`/`--no-link` and `--save-env`/`--no-save-env` answer the first two, `--no-add-videos` skips the third; non-interactive runs skip anything a flag does not set. + +`update` sends only the flags you pass. With no flags it opens an interactive editor (name, resolutions, transcribing, keep original, MP4 fallback, content tagging); unattended runs need at least one flag. Rename with `update --name`. + +Run `bunny stream libraries --help` for every flag. The ones with consequences: + +- `--encoding-tier premium` bills per output codec per minute. `--codecs` other than `x264`, and `--jit`, need it. +- `--early-play` needs `--keep-original` and exposes the originals. +- `--transcribing` bills per use on every new upload and re-encode. +- `--add-replication-regions` is add-only: regions can never be removed. +- `--content-tagging` is accepted on `update` only. +- `--force` is required for `unlink`, `delete`, `credentials --rotate`, `player reset`, and `security show --rotate` without a TTY. + +### Player and security + +```bash +bunny stream libraries player show my-library +bunny stream libraries player update my-library --color "#FF7755" --speeds 0.5,1.0,1.15,2.0 --heatmap +bunny stream libraries player update my-library --no-controls +bunny stream libraries player reset my-library # dashboard defaults; confirms first +bunny stream libraries security show my-library # token key masked +bunny stream libraries security update my-library --embed-token --block-direct-access --allow-referrer example.com +bunny stream libraries security update my-library --drm-basic +bunny stream libraries security show my-library --rotate # reset the token key (confirms first); signed URLs stop working +``` + +Run `player update --help` and `security update --help` for the full flag lists; every boolean takes a `--no-` form. `security update` applies domain changes one at a time and skips ones already in place, so re-running a partly failed update is safe. + +--- + +## `bunny stream videos`: Videos + +```bash +bunny stream videos upload ./video.mp4 --lib 12345 --title "Launch demo" +bunny stream videos upload ./video.mp4 --collection 8a7b6c5d-... +bunny stream videos fetch https://example.com/video.mp4 --lib 12345 +bunny stream videos list --collection 8a7b6c5d-... +bunny stream videos show 1a2b3c4d-... # includes caption languages and the Direct Play URL +bunny stream videos update 1a2b3c4d-... --title "Launch demo" +bunny stream videos update 1a2b3c4d-... --chapters '[{"title":"Intro","start":0,"end":30}]' +bunny stream videos update 1a2b3c4d-... --collection "" # remove from its collection +bunny stream videos thumbnail 1a2b3c4d-... --file ./thumb.jpg # or --url +bunny stream videos resolutions 1a2b3c4d-... # configured, encoded, and stored +bunny stream videos cleanup 1a2b3c4d-... --non-configured --dry-run +bunny stream videos stats 1a2b3c4d-... --from 2026-01-01 --to 2026-01-31 +bunny stream videos embed 1a2b3c4d-... --expires 30m # signed when embed tokens are on +bunny stream videos delete 1a2b3c4d-... +``` + +There is no `videos create`: add a video with `upload` (local file) or `fetch` (bunny.net downloads a URL). + +- **`upload`** creates the entry, then sends the bytes. Files over 2 GB switch to resumable TUS uploads automatically. If the byte upload fails, an entry that never received bytes is deleted; one that may hold the upload is kept with a warning naming its GUID. Encoding continues after the command returns, so the status is usually `Uploaded` or `Processing`. +- **`fetch`** returns once the fetch is queued and reports no video ID; the video shows up in `videos list` once fetched. +- **`cleanup`** deletes renditions, MP4 fallbacks, or the original, and requires at least one selector (`--resolutions`, `--non-configured`, `--all`, `--original`, `--mp4`); `--outputs hls|mp4|all` only narrows which outputs those selectors touch. `--dry-run` lists what would go and skips the confirmation. + +Run `bunny stream videos --help` for every flag. `--force` is required for `delete` and `cleanup` without a TTY. + +### Captions + +```bash +bunny stream videos captions add ./captions.vtt 1a2b3c4d-... en --label "English" +bunny stream videos captions remove 1a2b3c4d-... en +``` + +`captions add [video] [lang]` takes a local `.vtt` or `.srt` and reports the API validator's errors and warnings; `--label` defaults to the language code. `captions remove [video] [lang]` offers the video's caption languages to pick from interactively and needs `--force` without a TTY. Unattended runs need the video and the language. These are captions you wrote yourself, unrelated to the paid transcription. + +--- + +## `bunny stream collections`: Collections + +```bash +bunny stream collections list --search tut +bunny stream collections create Tutorials +bunny stream collections show 8a7b6c5d-... +bunny stream collections update 8a7b6c5d-... --name Guides +bunny stream collections delete 8a7b6c5d-... # deletes the videos inside it too +``` + +Deleting a collection deletes every video in it. Move videos out first with `videos update --collection ""`. + +--- + +## Paid commands: premium encoding, `videos encode`, `videos transcribe`, `videos smart` + +```bash +bunny stream libraries update my-library --encoding-tier premium --codecs x264,vp9 # switch the library to premium encoding +bunny stream videos encode 1a2b3c4d-... # re-encode one video with current settings (alias reencode) +bunny stream videos transcribe 1a2b3c4d-... --target-languages en,de --source-language en +bunny stream videos smart 1a2b3c4d-... --title --chapters # generate fields from the transcript +``` + +All of them state the cost before running. + +- **Premium encoding** bills per output codec per minute; `--jit`, `--codecs`, `--resolutions` apply in the same request. `videos encode` regenerates every output at the library's encoding rate. +- **`videos transcribe`** costs $0.10 per language-minute (source transcript plus each target). The estimate is exact with `--source-language`, a range without. `--generate-title`, `--generate-description`, `--generate-chapters`, `--generate-moments` also generate fields. `--overwrite` replaces the existing transcription and overrides the library defaults (it is sent as the API's force flag), and disables the pickers. +- **`videos smart`** needs at least one of `--title`, `--description`, `--chapters`, `--moments`. `--source-language` picks the caption track (auto-generated tracks are `-auto`; `en` falls back to `en-auto`). On a video with no captions it shows the transcription cost and asks first; unattended runs stop unless `--transcribe` approves it. `--force` only disables pickers. + +--- + +## Anti-patterns + +- **Deleting a collection to "ungroup" videos.** It deletes the videos too; clear `--collection` on each video first. +- **Running `videos transcribe` on a library that transcribes automatically.** A fresh upload or `videos encode` already triggers transcription; transcribing again bills twice. +- **Using `--overwrite` on `videos transcribe` to skip a prompt.** It re-runs the transcription and overrides library defaults. +- **Expecting `videos fetch` to return a video ID.** Poll `videos list` instead. +- **Running `videos cleanup` without `--dry-run` first.** A deleted original is gone, and re-creating renditions means a paid re-encode. +- **Expecting to remove replication regions.** `--add-replication-regions` is add-only, permanently. +- **Rotating keys casually.** `credentials --rotate` and `security show --rotate` break existing integrations and signed URLs. +- **Using the old spellings.** `encode enable`, `encode reencode`, top-level `transcribe`, `smart`, and `captions`, `collections rename`, and `security reset-token` still work but are hidden; use the commands above. diff --git a/.claude/skills/hardhat b/.claude/skills/hardhat deleted file mode 120000 index 9bc20062c..000000000 --- a/.claude/skills/hardhat +++ /dev/null @@ -1 +0,0 @@ -../../.agents/skills/hardhat \ No newline at end of file diff --git a/.claude/skills/hardhat-toolbox-mocha-ethers b/.claude/skills/hardhat-toolbox-mocha-ethers deleted file mode 120000 index cc3a1b495..000000000 --- a/.claude/skills/hardhat-toolbox-mocha-ethers +++ /dev/null @@ -1 +0,0 @@ -../../.agents/skills/hardhat-toolbox-mocha-ethers \ No newline at end of file diff --git a/.claude/skills/hardhat-toolbox-viem b/.claude/skills/hardhat-toolbox-viem deleted file mode 120000 index bd31cf0ef..000000000 --- a/.claude/skills/hardhat-toolbox-viem +++ /dev/null @@ -1 +0,0 @@ -../../.agents/skills/hardhat-toolbox-viem \ No newline at end of file diff --git a/.github/THREAT_MODEL.md b/.github/THREAT_MODEL.md index 69af48bcc..68698f1ba 100644 --- a/.github/THREAT_MODEL.md +++ b/.github/THREAT_MODEL.md @@ -1,15 +1,15 @@ # Threat Model -Last updated: 2026-10-12 +Last updated: 2026-10-18 This is a lightweight, living threat model. It follows the OWASP four-question framework and the values of the Threat Modeling Manifesto — a maintained document over a one-time audit, design issues over checkbox compliance, action over ceremony. -| Question | Answered in | -|---|---| -| What are we working on? | §1 Assets · §4 Trust Boundaries | -| What can go wrong? | §2 Blast Radius · §3 Threat Actors · §5 Attack Techniques | -| What are we going to do about it? | §7 Component Findings · §8 Mitigations | -| Did we do a good enough job? | §8 Review Cadence | +| Question | Answered in | +| --------------------------------- | --------------------------------------------------------- | +| What are we working on? | §1 Assets · §4 Trust Boundaries | +| What can go wrong? | §2 Blast Radius · §3 Threat Actors · §5 Attack Techniques | +| What are we going to do about it? | §7 Component Findings · §8 Mitigations | +| Did we do a good enough job? | §8 Review Cadence | --- @@ -17,22 +17,25 @@ This is a lightweight, living threat model. It follows the OWASP four-question f What has monetary value, irreversibility, or trust significance in this system: -| Asset | Location | Value | -|---|---|---| -| ETH in GenImNFTv4 | On-chain | Accumulates mint fees until `withdraw()` is called | -| ETH in LLMv1 (deprecated) | On-chain | User prepaid balances; withdrawable by users or claimable by providers. **No serverless code path calls this contract anymore** — `sc_llm.ts`/`leaf_history.ts` were retired in favor of `/assistent`'s x402 batch-settlement USDC channels. Final decommission (contract-level retirement) is pending confirmation that all balances have been withdrawn. | -| Stablecoin settlements | x402_facilitator | Per-request payments routed through the facilitator: USDC (any supported network), EURC and EURe on Base. EURe (Monerium, no EIP-3009, 18 decimals) settles via Permit2 through the x402 exact Permit2 proxy | -| x402 exact Permit2 proxy + Uniswap Permit2 (external) | On-chain, Base and Base Sepolia (SDK-pinned addresses, not upgradeable by this project) | Third-party contracts the facilitator settles EURe through. A defect affects EURe settlement availability, not fund safety: `/verify` simulates `settleWithPermit` against the live proxy before anything is broadcast, and Permit2 enforces the buyer's signature over the witness on-chain | -| Batch-settlement channel escrow | On-chain, canonical x402 batch-settlement contract (Optimism + Base mainnet) | Buyer deposits held in the channel's own token — USDC, EURC or EURe on Base — until claimed against signed vouchers. Escrow is the buyer's: refundable via the buyer-owned `withdraw` path after the lock, and a stalled claim delays it rather than losing it. Every token's channels are separate (the token is part of `channelId`), so each needs its own claim sweep and its own fee allowance | -| EIP3009 Splitter contract (testnet) | On-chain, Optimism Sepolia only (`eip155:11155420`) | Routes USDC settlements via EIP-3009; owner-upgradeable. Not yet on mainnet — promotes to a live asset on mainnet deployment | -| NFT metadata integrity | On-chain (token URIs) | Authoritative record of what image each token represents | -| Owner EOA private key | Dedicated keystore account (`0x1af51D…fBB20`), separated from daily wallet since 2026-06 | Controls every upgradeable contract and owns the three ERC-8004 agent identities — the highest-value key in the system | -| ERC-8004 agent identities (3) | On-chain, Base Identity Registry `0x8004A169…a432` (agentIds 97598, 97599, 97600) | Owned by the owner EOA. Owner can repoint `agentURI`, transfer, or change the bound `agentWallet`; holds no funds, but the identity is what discovery and reputation attach to | -| Agent wallet private key | scw_js secrets | Can trigger `requestImageUpdate()` and receive mintPrice per call. Same address as the x402 `payTo` (`NFT_WALLET`), bound as the `agentWallet` of the three ERC-8004 agents | -| Receiver-authorizer private key | scw_js secrets | Signs batch-settlement channel-config and refund authorizations (EIP-712) for the `/assistent` sellers (LLM, search, fetch) via `receiverAuthorizerSigner`. Never sends transactions, never needs funding | -| Facilitator wallet private key | x402_facilitator secrets | Receives settlement fees (USDC; EURC and EURe on Base) | -| Scaleway secrets (SCW_SECRET_KEY) | Serverless secrets | Access to S3 image bucket, transactional email, and analytics counters | -| BFL / IONOS API keys | Serverless secrets | Image generation quota; no on-chain access | +| Asset | Location | Value | +| ----------------------------------------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| ETH in GenImNFTv4 | On-chain | Accumulates mint fees until `withdraw()` is called | +| ETH in LLMv1 (deprecated) | On-chain | User prepaid balances; withdrawable by users or claimable by providers. **No serverless code path calls this contract anymore** — `sc_llm.ts`/`leaf_history.ts` were retired in favor of `/assistent`'s x402 batch-settlement USDC channels. Final decommission (contract-level retirement) is pending confirmation that all balances have been withdrawn. | +| Stablecoin settlements | x402_facilitator | Per-request payments routed through the facilitator: USDC (any supported network), EURC and EURe on Base. EURe (Monerium, no EIP-3009, 18 decimals) settles via Permit2 through the x402 exact Permit2 proxy | +| x402 exact Permit2 proxy + Uniswap Permit2 (external) | On-chain, Base and Base Sepolia (SDK-pinned addresses, not upgradeable by this project) | Third-party contracts the facilitator settles EURe through. A defect affects EURe settlement availability, not fund safety: `/verify` simulates `settleWithPermit` against the live proxy before anything is broadcast, and Permit2 enforces the buyer's signature over the witness on-chain | +| Batch-settlement channel escrow | On-chain, canonical x402 batch-settlement contract (Optimism + Base mainnet) | Buyer deposits held in the channel's own token — USDC, EURC or EURe on Base — until claimed against signed vouchers. Escrow is the buyer's: refundable via the buyer-owned `withdraw` path after the lock, and a stalled claim delays it rather than losing it. Every token's channels are separate (the token is part of `channelId`), so each needs its own claim sweep and its own fee allowance | +| EIP3009 Splitter contract (testnet) | On-chain, Optimism Sepolia only (`eip155:11155420`) | Routes USDC settlements via EIP-3009; owner-upgradeable. Not yet on mainnet — promotes to a live asset on mainnet deployment | +| NFT metadata integrity | On-chain (token URIs) | Authoritative record of what image each token represents | +| Owner EOA private key | Dedicated keystore account (`0x1af51D…fBB20`), separated from daily wallet since 2026-06 | Controls every upgradeable contract and owns the three ERC-8004 agent identities — the highest-value key in the system | +| ERC-8004 agent identities (3) | On-chain, Base Identity Registry `0x8004A169…a432` (agentIds 97598, 97599, 97600) | Owned by the owner EOA. Owner can repoint `agentURI`, transfer, or change the bound `agentWallet`; holds no funds, but the identity is what discovery and reputation attach to | +| Agent wallet private key | scw_js secrets | Can trigger `requestImageUpdate()` and receive mintPrice per call. Same address as the x402 `payTo` (`NFT_WALLET`), bound as the `agentWallet` of the three ERC-8004 agents | +| Receiver-authorizer private key | scw_js secrets | Signs batch-settlement channel-config and refund authorizations (EIP-712) for the `/assistent` sellers (LLM, search, fetch) via `receiverAuthorizerSigner`. Never sends transactions, never needs funding | +| Facilitator wallet private key | x402_facilitator secrets | Receives settlement fees (USDC; EURC and EURe on Base) | +| Scaleway secrets (SCW_SECRET_KEY) | Serverless secrets | Access to S3 image bucket, transactional email, and analytics counters | +| BFL / IONOS API keys | Serverless secrets | Image generation quota; no on-chain access | +| bunny.net account (incl. account-wide API key) | bunny.net dashboard; CLI profile at `~/.config/bunnynet.json` (mode 600) | Controls the Edge Script twin — can read the script's plain env values, overwrite secrets, replace code, delete scripts. Broader than the per-script deploy key below | +| Edge Script deploy key (`BUNNYJS_DEPLOY_KEY`) | GitHub repo secret in a public repo (Actions; `BUNNYJS_SCRIPT_ID` names the target) | Publishes arbitrary code to the twin — code execution in a sandbox whose env holds a second copy of the seller secret set | +| Bunny Edge Script env secrets (script 97472) | bunny.net script configuration | Second copy of: S3 credential pair (production channel store), receiver-authorizer private key, Brave API key. The two copies must be rotated together with `scw_js`'s | Notably absent: user wallet keys (never touch the server), user PII (not collected). @@ -42,17 +45,19 @@ Notably absent: user wallet keys (never touch the server), user PII (not collect If a component is fully compromised, what else falls with it: -| Component | Direct impact | Cascades to | -|---|---|---| -| **Owner EOA** | Malicious upgrade to every upgradeable contract | Complete ETH drain from GenImNFTv4 + LLMv1; all NFT URIs replaceable; USDC fee wallet redirectable — total system compromise. The EIP3009 Splitter joins this blast radius once deployed to mainnet. Key is now a dedicated EOA (not daily wallet); full mitigation requires Gnosis Safe. The same key owns the three ERC-8004 agent identities (Base), so a compromise also lets an attacker repoint their `agentURI` to impersonate the services; a Safe migration must transfer them too, which clears each `agentWallet` and needs re-binding. | -| **Agent wallet** (scw_js) | `requestImageUpdate()` callable arbitrarily; drains GenImNFTv4 at `mintPrice` per call | NFT metadata corruption for all tokens; contract ETH drained | -| **Facilitator wallet** | Settlement fees redirected (USDC, EURC, EURe) | Financial only; no access to user funds or upgrade paths | -| **Claim path** (the cron's per-token sweep + the seller's per-token fee allowance) | Claims for one token stall — a token missing from the sweep's enumeration leaves that token's escrow unclaimed indefinitely, and an exhausted fee allowance stalls claims with `insufficient_fee_allowance`. The sweep's refund half also garbage-collects fully-claimed records: a wrongly-guarded delete would destroy the stored voucher of a payment that raced the sweep, making that charge permanently unclaimable | Buyer impact is delay, not loss — the `withdraw` path after the lock is the buyer's own. Revenue delayed; no fund redirection. The GC delete is CAS-guarded and only commits when the record still shows `balance <= totalClaimed` and still idle, so a raced payment's voucher survives; a blocked delete self-heals next run | -| **Receiver-authorizer key** (scw_js) | Refund authorizations signable: buyer escrow returned early, claims stalled or unwound — revenue denial and channel DoS | No fund redirection (claims pay the channel's fixed receiver); no access to other contracts or upgrade paths | -| **SCW_SECRET_KEY** | S3 bucket writable; email notifications spoofable | Generated images replaceable; no on-chain impact | -| **BFL / IONOS key** | Image generation quota consumed | No on-chain or financial impact to users | -| **x402_facilitator service** | Payment settlements halt | Image generation feature unavailable; already-signed USDC authorizations expire unused | -| **LLMv1 authorized provider** (deprecated path — see §1) | Can submit fraudulent Merkle batches; drains user balances up to available ETH | Only affects LLMv1 user balances; no access to other contracts | +| Component | Direct impact | Cascades to | +| ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Owner EOA** | Malicious upgrade to every upgradeable contract | Complete ETH drain from GenImNFTv4 + LLMv1; all NFT URIs replaceable; USDC fee wallet redirectable — total system compromise. The EIP3009 Splitter joins this blast radius once deployed to mainnet. Key is now a dedicated EOA (not daily wallet); full mitigation requires Gnosis Safe. The same key owns the three ERC-8004 agent identities (Base), so a compromise also lets an attacker repoint their `agentURI` to impersonate the services; a Safe migration must transfer them too, which clears each `agentWallet` and needs re-binding. | +| **Agent wallet** (scw_js) | `requestImageUpdate()` callable arbitrarily; drains GenImNFTv4 at `mintPrice` per call | NFT metadata corruption for all tokens; contract ETH drained | +| **Facilitator wallet** | Settlement fees redirected (USDC, EURC, EURe) | Financial only; no access to user funds or upgrade paths | +| **Claim path** (the cron's per-token sweep + the seller's per-token fee allowance) | Claims for one token stall — a token missing from the sweep's enumeration leaves that token's escrow unclaimed indefinitely, and an exhausted fee allowance stalls claims with `insufficient_fee_allowance`. The sweep's refund half also garbage-collects fully-claimed records: a wrongly-guarded delete would destroy the stored voucher of a payment that raced the sweep, making that charge permanently unclaimable | Buyer impact is delay, not loss — the `withdraw` path after the lock is the buyer's own. Revenue delayed; no fund redirection. The GC delete is CAS-guarded and only commits when the record still shows `balance <= totalClaimed` and still idle, so a raced payment's voucher survives; a blocked delete self-heals next run | +| **Receiver-authorizer key** (scw_js) | Refund authorizations signable: buyer escrow returned early, claims stalled or unwound — revenue denial and channel DoS | No fund redirection (claims pay the channel's fixed receiver); no access to other contracts or upgrade paths | +| **SCW_SECRET_KEY** | S3 bucket writable; email notifications spoofable | Generated images replaceable; no on-chain impact | +| **bunny.net account** | Twin's code replaced and env secrets read/overwritten; scripts deletable | Channel-store records (production payment records) writable; revenue denial via the authorizer key. No on-chain fund access — the same shape as the SCW_SECRET_KEY row, one platform over | +| **Edge Script deploy key** | Arbitrary code published to the twin | Runs with the twin's env — the bunny-account blast radius for that one script. Bounded today by the workflow's push-to-main-only trigger (no `pull_request`, no `workflow_dispatch`); any future trigger widening is a security review, not a CI tweak | +| **BFL / IONOS key** | Image generation quota consumed | No on-chain or financial impact to users | +| **x402_facilitator service** | Payment settlements halt | Image generation feature unavailable; already-signed USDC authorizations expire unused | +| **LLMv1 authorized provider** (deprecated path — see §1) | Can submit fraudulent Merkle batches; drains user balances up to available ETH | Only affects LLMv1 user balances; no access to other contracts | Key observation: the **owner EOA** is the single point of catastrophic failure. All other compromises are bounded in scope. This makes key management the highest-priority operational security concern, ahead of any code-level finding. @@ -64,12 +69,12 @@ The signature-gated read/admin APIs (analytics `GET /stats`, the growth approval Realistic adversaries for a project of this scale, ordered by capability. Each entry describes WHO the actor is — their role, access level, and motivation. Attack methods and vectors are documented in §4 Trust Boundaries, §5 Attack Techniques, and `eth/SECURITY.md`. -| Actor | Capability | Motivation | Primary access | -|---|---|---|---| -| **Opportunistic bot** | Low — runs known exploit scripts | Financial: drain any available ETH | Public contract ABI; scans for common Solidity patterns (no-auth functions, reentrancy) | -| **Financially motivated attacker** | Medium — reads deployed ABIs, may operate a malicious website | Financial: extract ETH, redirect USDC, mint below-market | Direct contract calls; crafted ERC721 receiver contracts; cross-origin requests to x402_facilitator | -| **Insider / compromised authorized account** | High — already holds partial on-chain trust or serverless secrets | Financial gain or sabotage | `authorizedProviders` role in LLMv1; SCW_SECRET_KEY or agent wallet obtained via credential leak | -| **Attacker with owner credentials** | Critical — holds the owner EOA or agent wallet key | Total control | Key obtained via phishing, leaked `.env`, or compromised dev machine | +| Actor | Capability | Motivation | Primary access | +| -------------------------------------------- | ----------------------------------------------------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| **Opportunistic bot** | Low — runs known exploit scripts | Financial: drain any available ETH | Public contract ABI; scans for common Solidity patterns (no-auth functions, reentrancy) | +| **Financially motivated attacker** | Medium — reads deployed ABIs, may operate a malicious website | Financial: extract ETH, redirect USDC, mint below-market | Direct contract calls; crafted ERC721 receiver contracts; cross-origin requests to x402_facilitator | +| **Insider / compromised authorized account** | High — already holds partial on-chain trust or serverless secrets | Financial gain or sabotage | `authorizedProviders` role in LLMv1; SCW_SECRET_KEY or agent wallet obtained via credential leak | +| **Attacker with owner credentials** | Critical — holds the owner EOA or agent wallet key | Total control | Key obtained via phishing, leaked `.env`, or compromised dev machine | Out of scope: nation-state actors, L1/L2 consensus attacks, Scaleway infrastructure compromise, wallet software vulnerabilities. @@ -116,13 +121,15 @@ The `mcp` function (`mcp_server.ts`, served at `mcp-agent.fretchen.eu/mcp`) is a ★ The x402 facilitator is an intentionally open public service (any merchant on Base or Optimism). `Access-Control-Allow-Origin: *` is required by the open-protocol design. The blast radius of a cross-origin attack is bounded by EIP-712/EIP-3009 cryptography: a malicious page can trigger settlement requests but cannot redirect funds (the signature binds recipient, amount, and nonce) or forge signatures. The residual risk (gas drain via `/settle` spam) is accepted: garbage requests fail before any on-chain transaction is attempted, L2 gas per settlement is negligible (~$0.001), and no realistic threat actor has financial incentive to drain a facilitator wallet whose ETH goes to miners, not the attacker. +The bunny.net Edge Script twin (`bunny_js/`, script 97472 at `bunnyjs-vlyol.bunny.run`) is a fifth public inbound surface and a new deployment actor. Inbound: browser → bunny pull zone (a real CDN, which the Scaleway functions never had) → twin; the CDN cannot replay paid responses because the twin sends `Cache-Control: no-store`, the platform normalizes it to `no-cache`, and the responses carry no validators — every hit revalidates at origin (verified live 2026-10-18). Deployment: GitHub Actions on the public repo (`BUNNYJS_DEPLOY_KEY` + `BUNNYJS_SCRIPT_ID` secrets, push-to-main only) → deploy key → script env (S3 channel-store credentials, receiver-authorizer key, Brave key) → the same S3 bucket and facilitator as the Scaleway sellers — one shared channel store, one shared channel tuple, claimed by the same `llmx402cron` on Scaleway. Trial-phase posture: the Scaleway `searchapi` function stays live and authoritative at `web-agent.fretchen.eu`; the twin carries no buyer-key or owner key, and the platform's isolate has no metadata service, which weakens the classic SSRF payoff the `/fetch` defence was written against (the private-range block remains enforced regardless). Bunny.net platform compromise is out of scope on the same terms as Scaleway infrastructure compromise (§3). + The analytics service accepts anonymous, unauthenticated `POST /hit` writes behind the same origin whitelist as `comment_service`. Note that the whitelist is defence-in-depth, **not** a write control here: CORS is browser-enforced only (any script or `curl` ignores it), and the pageview beacon is a `sendBeacon` simple request (`text/plain`), so no preflight occurs and a cross-origin write would land regardless of the header. Write abuse is bounded instead by path validation and the 200-entry `pages` cap (§5, API4). The counters themselves are written **private** (no public-read ACL) and the read path is authorized — see §6. --- ## 5. Attack Techniques by Surface -This is the **HOW** that complements §3 (WHO) and §4 (WHERE): the concrete classes of weakness in scope when reviewing or scanning this system. Only *live* categories are listed — a category's absence means it was considered and judged not applicable, not overlooked. Detailed, per-instance findings live in the documents named in the **Tracked in** column. The three layers of the system map to three standard catalogs: +This is the **HOW** that complements §3 (WHO) and §4 (WHERE): the concrete classes of weakness in scope when reviewing or scanning this system. Only _live_ categories are listed — a category's absence means it was considered and judged not applicable, not overlooked. Detailed, per-instance findings live in the documents named in the **Tracked in** column. The three layers of the system map to three standard catalogs: - [OWASP Smart Contract Top 10 (2025)](https://owasp.org/www-project-smart-contract-top-10/) — for `eth/` contracts. - [OWASP API Security Top 10 (2023)](https://owasp.org/API-Security/editions/2023/en/0x11-t10/) — for the serverless functions and website. @@ -130,38 +137,38 @@ This is the **HOW** that complements §3 (WHO) and §4 (WHERE): the concrete cla ### Contract layer — OWASP Smart Contract Top 10 -| Technique (OWASP) | Where it applies | Status | Tracked in | -|---|---|---|---| -| Access control (SC01) | `requestImageUpdate` agent whitelist; single-EOA upgrade governance | Mitigated / partially (governance open) | eth/SECURITY.md; §2 | -| Reentrancy (SC05) | CollectorNFT `_safeMint` price manipulation; SupportV2 donate paths | Open (price-only, no fund theft) / mitigated via `ReentrancyGuardTransient` | eth/SECURITY.md | -| Logic errors (SC03) | LLMv1 `processBatch` CEI ordering | Low — Open; reentrancy hardening on next upgrade | eth/SECURITY.md | -| Unchecked external calls (SC06) | SupportV2 arbitrary `_token` in `donateToken` | Accepted (contract holds no funds) | eth/SECURITY.md | -| Signature replay | LLMv1 Merkle batch submission | Mitigated via `processedBatches` mapping | eth/SECURITY.md | +| Technique (OWASP) | Where it applies | Status | Tracked in | +| ---------------------------------------- | ----------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | +| Access control (SC01) | `requestImageUpdate` agent whitelist; single-EOA upgrade governance | Mitigated / partially (governance open) | eth/SECURITY.md; §2 | +| Reentrancy (SC05) | CollectorNFT `_safeMint` price manipulation; SupportV2 donate paths | Open (price-only, no fund theft) / mitigated via `ReentrancyGuardTransient` | eth/SECURITY.md | +| Logic errors (SC03) | LLMv1 `processBatch` CEI ordering | Low — Open; reentrancy hardening on next upgrade | eth/SECURITY.md | +| Unchecked external calls (SC06) | SupportV2 arbitrary `_token` in `donateToken` | Accepted (contract holds no funds) | eth/SECURITY.md | +| Signature replay | LLMv1 Merkle batch submission | Mitigated via `processedBatches` mapping | eth/SECURITY.md | | Access control / fund redirection (SC01) | EIP3009 Splitter `executeSplit`; `onlyOwner` `setFacilitatorWallet`/upgrade — **testnet-stage** | Mitigated (testnet): relayer cannot redirect funds — `nonce = keccak256(seller, salt)` binds seller to the buyer signature; owner powers are the residual risk | eth/contracts/EIP3009SplitterV1.sol; mainnet promotion → §8 | -| Integer over/underflow (SC08) | All contracts | Mitigated (Solidity ≥0.8.27 built-in checks; Splitter is 0.8.33) | eth/SECURITY.md | +| Integer over/underflow (SC08) | All contracts | Mitigated (Solidity ≥0.8.27 built-in checks; Splitter is 0.8.33) | eth/SECURITY.md | ### API / serverless layer — OWASP API Security Top 10 -| Technique (OWASP) | Where it applies | Status | Tracked in | -|---|---|---|---| -| Broken authentication (API2) | EIP-712/EIP-3009 sig verify (facilitator), plus Permit2-witness and EIP-2612-permit verify for EURe (facilitator); agent-wallet whitelist (scw_js); `useWalletAuth` owner-sig bearer (growth **and analytics**); origin whitelist (comment_service) | Mitigated | §4; §7 | -| Broken function-level authz (API5) | x402 `Access-Control-Allow-Origin: *`; bounded by EIP-3009 crypto | Accepted (intentional open protocol) | §4 ★; §7 | -| Unrestricted resource consumption (API4) | `/settle` spam gas drain; unpaid MCP `tools/call` 402 generation (accepted — pure CPU: no metered service, no S3, no signing, same posture as the REST sellers' unpaid requests, see §4); serverless cold starts (accepted); LLM pre-charge balance gate (batch-settlement stall; Open, medium); analytics `/hit` request volume (not rate-limited, and **CORS is not the control** — browser-only, and `sendBeacon` sends a simple request with no preflight; accepted, since the consequence is a skewed counter, not cost or exposure); analytics `pages` map cardinality abuse (mitigated via path validation + a 200-entry cap) | Mixed — gas drain, MCP unpaid calls and `/hit` volume accepted; balance-gate open; `pages` cardinality mitigated | §4; scw_js/SECURITY.md | -| Unrestricted access to sensitive business flows (API6) | Deliver-before-payment in the LLM and genimg flows | Open (medium) | scw_js/SECURITY.md | -| Security misconfiguration / secret exposure (API8) | comment_service sends `SCW_SECRET_KEY` as the `X-Auth-Token` header to the Scaleway transactional-email API | Open (medium) | §7 | -| Unsafe client trust (API10) | Client-side-only image validation | Open (low) | §7 | +| Technique (OWASP) | Where it applies | Status | Tracked in | +| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | ---------------------- | +| Broken authentication (API2) | EIP-712/EIP-3009 sig verify (facilitator), plus Permit2-witness and EIP-2612-permit verify for EURe (facilitator); agent-wallet whitelist (scw_js); `useWalletAuth` owner-sig bearer (growth **and analytics**); origin whitelist (comment_service) | Mitigated | §4; §7 | +| Broken function-level authz (API5) | x402 `Access-Control-Allow-Origin: *`; bounded by EIP-3009 crypto | Accepted (intentional open protocol) | §4 ★; §7 | +| Unrestricted resource consumption (API4) | `/settle` spam gas drain; unpaid MCP `tools/call` 402 generation (accepted — pure CPU: no metered service, no S3, no signing, same posture as the REST sellers' unpaid requests, see §4); serverless cold starts (accepted); LLM pre-charge balance gate (batch-settlement stall; Open, medium); analytics `/hit` request volume (not rate-limited, and **CORS is not the control** — browser-only, and `sendBeacon` sends a simple request with no preflight; accepted, since the consequence is a skewed counter, not cost or exposure); analytics `pages` map cardinality abuse (mitigated via path validation + a 200-entry cap) | Mixed — gas drain, MCP unpaid calls and `/hit` volume accepted; balance-gate open; `pages` cardinality mitigated | §4; scw_js/SECURITY.md | +| Unrestricted access to sensitive business flows (API6) | Deliver-before-payment in the LLM and genimg flows | Open (medium) | scw_js/SECURITY.md | +| Security misconfiguration / secret exposure (API8) | comment_service sends `SCW_SECRET_KEY` as the `X-Auth-Token` header to the Scaleway transactional-email API | Open (medium) | §7 | +| Unsafe client trust (API10) | Client-side-only image validation | Open (low) | §7 | ### Agent layer — OWASP Top 10 for LLM Applications The assistant is itself an LLM agent with a paid tool loop, so its review surface is mapped like the other layers. Per the section's own convention, unlisted categories were considered and judged not applicable: LLM02 (no PII is collected, §1; the conversation is browser-local state), LLM03 (supply chain is the cve-triage skill's mandate), LLM04/LLM08 (no RAG, training, or embedding store), LLM09 (no integrity claim is made about LLM output). -| Technique (OWASP) | Where it applies | Status | Tracked in | -|---|---|---|---| -| Prompt injection (LLM01) | Indirect injection via tool outputs: `search_web` results and `fetch_url` page contents enter the conversation as untrusted text (direct injection via user messages is inherent to any chat) | Mitigated by bounding, not sanitization: tool results are projected hard before reaching the model, a failing tool is withdrawn for the rest of the turn, and the loop is capped (MAX_HOPS 10, MAX_PAID_CALLS 15). Residual risk: a malicious page steering the model's next tool call within those caps | website/utils/toolLoop.ts; website/tools/; this table (audit procedure: `.agents/skills/security-review/plays/agent-security-audit.md`) | -| Improper output handling (LLM05) | Rendering of model output: ReactMarkdown without rehype-raw does not render raw HTML in model output; CodeBlock injects highlight.js output via `dangerouslySetInnerHTML`, but highlight.js escapes while highlighting | Mitigated | website/components/AssistantChat.tsx; website/components/CodeBlock.tsx | -| Excessive agency (LLM06) | Spend-capable tools are confirm-gated in the browser (the generate_image confirm card); the paid LLM endpoint never executes tools on the caller's behalf (a published contract in `scw_js/openapi.llm.json`); the paid web tools spend only from the escrowed batch-settlement channel | Mitigated by design | .agents/skills/chat-tools/SKILL.md; scw_js/openapi.llm.json | -| System prompt leakage (LLM07) | The system prompt (including the research variant) is sent to the model every turn; owner-scope tool descriptions are withheld from visitors entirely | Accepted (the prompt is not secret material; owner tools are never offered to visitors) | .agents/skills/chat-tools/SKILL.md | -| Unbounded consumption (LLM10) | Per-message price ceiling (operator absorbs overage), MAX_MESSAGES_BYTES 192 KB, MAX_TOOLS_BYTES 8192, MAX_HOPS/MAX_PAID_CALLS loop caps | Mitigated | scw_js/llm_schemas.ts; website/utils/toolLoop.ts | +| Technique (OWASP) | Where it applies | Status | Tracked in | +| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| Prompt injection (LLM01) | Indirect injection via tool outputs: `search_web` results and `fetch_url` page contents enter the conversation as untrusted text (direct injection via user messages is inherent to any chat) | Mitigated by bounding, not sanitization: tool results are projected hard before reaching the model, a failing tool is withdrawn for the rest of the turn, and the loop is capped (MAX_HOPS 10, MAX_PAID_CALLS 15). Residual risk: a malicious page steering the model's next tool call within those caps | website/utils/toolLoop.ts; website/tools/; this table (audit procedure: `.agents/skills/security-review/plays/agent-security-audit.md`) | +| Improper output handling (LLM05) | Rendering of model output: ReactMarkdown without rehype-raw does not render raw HTML in model output; CodeBlock injects highlight.js output via `dangerouslySetInnerHTML`, but highlight.js escapes while highlighting | Mitigated | website/components/AssistantChat.tsx; website/components/CodeBlock.tsx | +| Excessive agency (LLM06) | Spend-capable tools are confirm-gated in the browser (the generate_image confirm card); the paid LLM endpoint never executes tools on the caller's behalf (a published contract in `scw_js/openapi.llm.json`); the paid web tools spend only from the escrowed batch-settlement channel | Mitigated by design | .agents/skills/chat-tools/SKILL.md; scw_js/openapi.llm.json | +| System prompt leakage (LLM07) | The system prompt (including the research variant) is sent to the model every turn; owner-scope tool descriptions are withheld from visitors entirely | Accepted (the prompt is not secret material; owner tools are never offered to visitors) | .agents/skills/chat-tools/SKILL.md | +| Unbounded consumption (LLM10) | Per-message price ceiling (operator absorbs overage), MAX_MESSAGES_BYTES 192 KB, MAX_TOOLS_BYTES 8192, MAX_HOPS/MAX_PAID_CALLS loop caps | Mitigated | scw_js/llm_schemas.ts; website/utils/toolLoop.ts | --- @@ -169,13 +176,13 @@ The assistant is itself an LLM agent with a paid tool loop, so its review surfac For blockchain systems, **Integrity** dominates. Confidentiality is generally low concern (code is public, transactions are public). Availability is partially guaranteed by the L2 itself; the serverless layer is the availability risk. **Repudiation** is a structural strength: all on-chain transactions are cryptographically signed and permanently recorded — no actor can deny having submitted a transaction. -| Component | Confidentiality | Integrity | Availability | Notes | -|---|---|---|---|---| -| Smart contracts | Low — all code and state is public | **Critical** — funds and metadata are irreversible | High — L2 guarantees liveness | Upgrade path is the integrity weak point | -| x402_facilitator | Low | **High** — invalid settlement = wrong payment routing | Medium — serverless cold starts | Open CORS degrades integrity boundary | -| scw_js | Low | **High** — agent wallet controls on-chain writes | Medium | API key compromise has no on-chain reach | -| website frontend | Low | Medium — client-side only; no server state | Low — static hosting | Wallet auth is the integrity boundary | -| comment_service | Medium — stores anonymous user names/text in S3 | Low — comment content and like counts | Low | Low-value, but holds `SCW_SECRET_KEY` (email) — not zero-secret | +| Component | Confidentiality | Integrity | Availability | Notes | +| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| Smart contracts | Low — all code and state is public | **Critical** — funds and metadata are irreversible | High — L2 guarantees liveness | Upgrade path is the integrity weak point | +| x402_facilitator | Low | **High** — invalid settlement = wrong payment routing | Medium — serverless cold starts | Open CORS degrades integrity boundary | +| scw_js | Low | **High** — agent wallet controls on-chain writes | Medium | API key compromise has no on-chain reach | +| website frontend | Low | Medium — client-side only; no server state | Low — static hosting | Wallet auth is the integrity boundary | +| comment_service | Medium — stores anonymous user names/text in S3 | Low — comment content and like counts | Low | Low-value, but holds `SCW_SECRET_KEY` (email) — not zero-secret | | analytics service | Low — aggregate counts only, no PII; objects are written **private** (no public-read ACL) and the read path is planned as owner-signature-gated, like the Growth API | Low — writes are unauthenticated, so counts are **advisory**: fine for traffic insight, never to be trusted for a security or financial decision | Low — same serverless cold-start profile as other functions | Private-by-omission: the bucket also serves public-read images, so "private" rests on the handler omitting an ACL — locked by a test | --- @@ -184,10 +191,11 @@ For blockchain systems, **Integrity** dominates. Confidentiality is generally lo Detailed code-level findings live alongside the code they describe: -| Component | Findings document | Open issues | -|---|---|---| -| Smart contracts | [eth/SECURITY.md](../eth/SECURITY.md) | CollectorNFT price manipulation (medium), single-owner upgrade path (medium governance) | -| Serverless & frontend | [scw_js/SECURITY.md](../scw_js/SECURITY.md) | LLM pre-charge balance gate (medium) and genimg deliver-before-settle (medium) — see scw_js/SECURITY.md; x402_facilitator open CORS (intentional; gas-drain risk accepted — negligible on L2, no attacker incentive), comment_service sends `SCW_SECRET_KEY` as the email API `X-Auth-Token` header (medium), client-side-only image validation (low) | +| Component | Findings document | Open issues | +| ---------------------- | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Smart contracts | [eth/SECURITY.md](../eth/SECURITY.md) | CollectorNFT price manipulation (medium), single-owner upgrade path (medium governance) | +| Serverless & frontend | [scw_js/SECURITY.md](../scw_js/SECURITY.md) | LLM pre-charge balance gate (medium) and genimg deliver-before-settle (medium) — see scw_js/SECURITY.md; x402_facilitator open CORS (intentional; gas-drain risk accepted — negligible on L2, no attacker incentive), comment_service sends `SCW_SECRET_KEY` as the email API `X-Auth-Token` header (medium), client-side-only image validation (low) | +| Bunny Edge Script twin | [bunny_js/SECURITY.md](../bunny_js/SECURITY.md) | Deploy-key blast radius (bounded by trigger hygiene; rotate-on-suspicion), deployed-runtime SigV4/DNS verification pending, undici-specific error matching on the edge (all fail-closed) | --- @@ -195,13 +203,15 @@ Detailed code-level findings live alongside the code they describe: Active mitigations and where they live: -| Area | Document | What it covers | -|---|---|---| -| Contract-level findings | [eth/SECURITY.md](../eth/SECURITY.md) | Open issues, known vulnerabilities, fixed CVEs | -| Serverless findings | [scw_js/SECURITY.md](../scw_js/SECURITY.md) | Open serverless issues (rough; full detail tracked privately via GHSA) | -| Dependency CVEs | [CVE_TRIAGE.md](CVE_TRIAGE.md) | Threat-model-driven triage framework for Dependabot alerts; `/cve-triage` skill operationalizes it | +| Area | Document | What it covers | +| -------------------------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| Contract-level findings | [eth/SECURITY.md](../eth/SECURITY.md) | Open issues, known vulnerabilities, fixed CVEs | +| Serverless findings | [scw_js/SECURITY.md](../scw_js/SECURITY.md) | Open serverless issues (rough; full detail tracked privately via GHSA) | +| Bunny Edge Script findings | [bunny_js/SECURITY.md](../bunny_js/SECURITY.md) | Twin-specific open issues (deploy-key blast radius, runtime verification, error-matching drift) | +| Dependency CVEs | [CVE_TRIAGE.md](CVE_TRIAGE.md) | Threat-model-driven triage framework for Dependabot alerts; `/cve-triage` skill operationalizes it | **Review triggers** — revisit this document when any of the following occur: + - A contract is upgraded or a new contract is deployed - The EIP3009 Splitter (or any testnet-stage contract) is deployed to mainnet — promote it to a full asset/blast-radius entry and re-scope the owner blast radius - A key is rotated or a new privileged account is added diff --git a/.github/workflows/deploy-bunny-js.yml b/.github/workflows/deploy-bunny-js.yml new file mode 100644 index 000000000..c4f096776 --- /dev/null +++ b/.github/workflows/deploy-bunny-js.yml @@ -0,0 +1,69 @@ +name: Deploy Bunny JS searchapi twin + +# Deploys the bunny Edge Script twin of the searchapi seller. Coexists with manual CLI +# deploys by design: bunny publishes whichever deployment happened last, so a manual +# `bunny scripts deploy` from a laptop stays live only until the next push to main. +# +# This workflow ships CODE only. The script's environment (BRAVE_API_KEY, the receiver +# authorizer key, the S3 channel-store credentials, RPC URLs, SEARCH_SERVICE_URL) is +# configured on the script itself — bunny dashboard or `bunny scripts env set` — and is +# deliberately not part of CI: a repo leak must not be a secrets leak. + +permissions: + contents: read + +on: + push: + branches: + - main + paths: + - "bunny_js/**" + - "shared/chain-utils/**" + - "shared/s3-utils/**" + - ".github/workflows/deploy-bunny-js.yml" + +jobs: + deploy-bunny-js: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "24" + cache: "npm" + cache-dependency-path: | + bunny_js/package-lock.json + shared/chain-utils/package-lock.json + shared/s3-utils/package-lock.json + + # Repo ordering rule (AGENTS.md -> Commands): the shared file: dependencies must be + # built before bunny_js can build against them. + - name: Install and build chain-utils + working-directory: ./shared/chain-utils + run: npm ci && npm run build + + - name: Install and build s3-utils + working-directory: ./shared/s3-utils + run: npm ci && npm run build + + - name: Install dependencies + working-directory: ./bunny_js + run: npm ci + + - name: Build the single-file bundle + working-directory: ./bunny_js + run: npm run build + + - name: Deploy script to Bunny Edge Scripting + uses: BunnyWay/actions/deploy-script@main + with: + # BUNNYJS_SCRIPT_ID and BUNNYJS_DEPLOY_KEY are repo secrets; both come from the + # bunny dashboard: Script -> Deployments -> Settings. The deploy key authorizes + # exactly this one script, not the account. + script_id: ${{ secrets.BUNNYJS_SCRIPT_ID }} + deploy_key: ${{ secrets.BUNNYJS_DEPLOY_KEY }} + file: "bunny_js/dist/index.js" diff --git a/.github/workflows/test-bunny-js.yml b/.github/workflows/test-bunny-js.yml new file mode 100644 index 000000000..532b24657 --- /dev/null +++ b/.github/workflows/test-bunny-js.yml @@ -0,0 +1,59 @@ +name: Test Bunny JS Edge Script + +permissions: + contents: read + +on: + pull_request: + paths: + - "bunny_js/**" + - "shared/chain-utils/**" + - "shared/s3-utils/**" + - ".github/workflows/test-bunny-js.yml" + push: + branches: + - main + paths: + - "bunny_js/**" + - "shared/chain-utils/**" + - "shared/s3-utils/**" + - ".github/workflows/test-bunny-js.yml" + +jobs: + test-bunny-js: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "24" + cache: "npm" + cache-dependency-path: | + bunny_js/package-lock.json + shared/chain-utils/package-lock.json + shared/s3-utils/package-lock.json + + # Repo ordering rule (AGENTS.md -> Commands): the shared file: dependencies must be + # built before bunny_js can build and typecheck against them. + - name: Install and build chain-utils + working-directory: ./shared/chain-utils + run: npm ci && npm run build + + - name: Install and build s3-utils + working-directory: ./shared/s3-utils + run: npm ci && npm run build + + - name: Install dependencies + working-directory: ./bunny_js + run: npm ci + + - name: Run quality checks (typecheck + lint + format + tests) + working-directory: ./bunny_js + run: npm run check + env: + NODE_ENV: test + CI: true diff --git a/AGENTS.md b/AGENTS.md index 2775d7ebd..05ff26877 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,7 @@ Monorepo for a blockchain-based AI service platform. Each subdirectory is an ind | `website/` | Frontend (Vike SSR + React 19 + Panda CSS) | [`README`](website/README.md) · [`AGENTS.md`](website/AGENTS.md) | | `eth/` | Solidity smart contracts (Hardhat, Optimism L2) | [`README`](eth/README.md) · [`AGENTS.md`](eth/AGENTS.md) | | `scw_js/` | Serverless backend (Scaleway Functions, AI image gen + LLM) | [`README`](scw_js/README.md) | +| `bunny_js/` | Bunny Edge Script twins of the `scw_js` x402 sellers (first: searchapi) | [`README`](bunny_js/README.md) | | `x402_facilitator/` | EIP-3009 USDC payment facilitator (Scaleway Functions) | [`README`](x402_facilitator/README.md) | | `comment_service/` | Blog comment backend (Scaleway Functions) | [`README`](comment_service/README.md) | | `analytics/` | Serverless pageview counter (Scaleway Functions, S3-backed) | [`README`](analytics/README.md) | diff --git a/bunny_js/.gitignore b/bunny_js/.gitignore new file mode 100644 index 000000000..d92bc25b7 --- /dev/null +++ b/bunny_js/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +coverage/ +.env +.bunny/ diff --git a/bunny_js/.prettierignore b/bunny_js/.prettierignore new file mode 100644 index 000000000..95b915f13 --- /dev/null +++ b/bunny_js/.prettierignore @@ -0,0 +1,10 @@ +node_modules/ +coverage/ +dist/ +*.min.js +*.bundle.js +package-lock.json +.git/ +.vscode/ +.env +*.log diff --git a/bunny_js/.prettierrc b/bunny_js/.prettierrc new file mode 100644 index 000000000..7c1b7897b --- /dev/null +++ b/bunny_js/.prettierrc @@ -0,0 +1,14 @@ +{ + "semi": true, + "trailingComma": "all", + "singleQuote": false, + "printWidth": 100, + "tabWidth": 2, + "useTabs": false, + "bracketSpacing": true, + "arrowParens": "always", + "endOfLine": "lf", + "quoteProps": "as-needed", + "jsxSingleQuote": false, + "bracketSameLine": false +} diff --git a/bunny_js/README.md b/bunny_js/README.md new file mode 100644 index 000000000..eaff313ba --- /dev/null +++ b/bunny_js/README.md @@ -0,0 +1,148 @@ +# bunny_js + +Bunny Edge Script twins of the `scw_js` x402 sellers. First port: **searchapi** (the +`web-agent.fretchen.eu` seller — Brave `/search` + `/fetch`, paid per call via x402 +batch-settlement). The Scaleway function stays live and untouched; this twin is trialed at +the bunny-provided URL until a formal switch. + +## Architecture + +- `src/routes.ts` — the **only new code**: the routing layer, natively against web-standard + `Request`/`Response`, a line-by-line mirror of `scw_js/search_api.ts` (route table, ordering + rules, and the payment/settlement comments carried over). +- Everything else in `src/` is **ported verbatim** from `scw_js` (import paths only): + the shared seller stack (`x402_server.ts`, channel storage/sync), the services + (`search_service.ts`, `web_fetch_service.ts` with the SSRF defence), schemas, discovery, + favicon, `openapi.search.json`. Two deliberate deviations: + - `logger.ts` is a pino-shaped console shim — pino is a Node-runtime library; bunny logs go + to the dashboard, and the observability `logger.error` alert convention does not apply. + - `utils.ts` ports only the platform-neutral half (CORS/parseJsonBody); the ScwResponse + envelope helpers are not ported — there is no Scaleway envelope here. +- `src/runtime-shims.ts` — pins the Node `Buffer`/`process` globals the ported code assumes, + imported FIRST by the entry so they exist before any ported module's top-level code runs. + +## Commands + +- `npm run build` — tsdown bundle to a **single** `dist/index.js` (bunny deploys one file). +- `npm run dev` — local serve via Deno on 127.0.0.1:8080. Pass real env with + `--env-file=.env` for the paid-path machinery (the unpaid 402 path needs + `NFT_WALLET_PUBLIC_KEY` + `RECEIVER_AUTHORIZER_PRIVATE_KEY`). +- `npm test` — vitest (165 tests: 108 ported + the translated routes suite (incl. cache-control)). +- `npm run check` — typecheck + lint + format + tests. +- `npm run deploy` — build, then `bunny scripts deploy dist/index.js` (needs the bunny CLI, + installed and logged in). + +Ordering (repo rule): `npm run build` in `shared/chain-utils` and `shared/s3-utils` first — +they are `file:` dependencies. + +## Environment / secrets (bunny script env vars) + +Same values the Scaleway function uses — the twin must quote the **same channel tuple** +(receiver, receiver authorizer, withdraw delay) or its channels are unusable: + +`bunny scripts env set` kinds — the criterion is not relevance but what a readable +value is worth: plain variables are stored unencrypted and readable back via +`env list`/`env pull`; `--secret` ones are encrypted and redacted. If a value +authenticates, signs, or bills it is a secret; if a public 402 response already reveals it or +it is inert without another credential, plain — and plain stays inspectable when debugging. + +| Variable | bunny kind | Use | +| ------------------------------------------ | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `NFT_WALLET_PUBLIC_KEY` | plain | receiver/payTo (agent wallet) — a public address | +| `RECEIVER_AUTHORIZER_PRIVATE_KEY` | secret (`--secret`) | signs batch-settlement locks/refund authorizations | +| `BRAVE_API_KEY` | secret (`--secret`) | Brave LLM Context API (metered) | +| `SCW_S3_BUCKET` | plain | channel-store bucket name — NOT in `.env`; set explicitly (`my-imagestore`): the s3-utils code default points there today, but the twin must not depend on a fallback that could change | +| `SCW_S3_REGION` | plain | `nl-ams` — not in `.env` either; set explicitly alongside the bucket | +| `SCW_ACCESS_KEY` + `SCW_SECRET_KEY` | secret (`--secret`) | channel storage (the same bucket `llmx402cron` on Scaleway claims) — most sensitive pair | +| `RPC_URL_EIP155_10`, `RPC_URL_EIP155_8453` | secret (`--secret`) | viem chain reads — NOT in `.env`: production runs unset on viem's public defaults (the accepted tradeoff in scw_js's serverless.yml). Set as `--secret` only if rate limits bite — real values embed an API key | +| `FACILITATOR_URL` | plain (code default ok) | `https://facilitator.fretchen.eu` | +| `LLM_WITHDRAW_DELAY_SECONDS` | plain (code default ok) | 86400 — must match production | +| `SEARCH_SERVICE_URL` | plain | advertised `resource.url` base; the bunny trial URL while trialing, production default otherwise | + +## Known limits / decisions + +- **Single-file bundle, 1 MB limit.** tsdown emits one `dist/index.js` with `codeSplitting: +false`; Node builtins stay external and MUST be imported through the `node:` prefix + (bunny's Deno resolves them; the bundler plugin rewrites bare ones). Measured: **~397 KB**. +- **The platform rewrites Cache-Control: no-store to no-cache.** The twin sends `no-store` + on every response (a bunny_js addition: the pull zone in front is a real CDN and a cached + paid 200 would replay a purchase); bunny's edge normalizes it to `no-cache`, observed live + on 2026-10-18. Still safe — no-cache forces revalidation, and the responses carry no + validators, so every hit is an origin fetch and paid responses cannot be replayed. +- **Non-latin1 header bytes.** Web `Headers` reject the raw-JSON `X-Payment` header when the + bazaar metadata contains em-dashes/ellipses; `routes.ts` escapes non-latin1 codepoints as + JSON `\uXXXX` — lossless for any JSON-parsing client. +- **`node:dns/promises`** (SSRF defence) and `node:crypto` (SigV4) are untested on bunny's + Deno 1.x specifically — `scripts/sigv4_probe.ts` is the deployed probe for the S3 half + (local Deno: PASS). Deployed runtime verification is part of the trial matrix below. +- **50 subrequests/request** — a paid request uses: S3 ops (lock/charge) + RPC reads (channel + sync) + 1 upstream + facilitator calls. Well within the cap, but `getFacilitatorFeeConfig` + style fetch storms would not be. +- **No cron at the edge** — `llmx402cron` stays on Scaleway and claims the channels this twin + charges, unchanged, because the channel storage is the same bucket. + +## Trial matrix (Task 6 — fill in as trials run) + +| # | Check | Result | +| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ | +| 1 | Unpaid `GET /search` at the bunny URL → 402 + `Payment-Required` header | **PASS** 2026-10-18: 402 + Payment-Required, mainnet-only accepts, receiver 0xAAEBC1…239C, `cdn-cache: MISS` | +| 2 | `GET /openapi.json`, `/.well-known/agent-registration.json`, `/favicon.png` at the bunny URL | **PASS** 2026-10-18: 200s, correct content types and sizes (6.3 KB / 1.2 KB / 31 KB) | +| 3 | Deployed SigV4 probe (`scripts/sigv4_probe.ts` as its own script) against the real bucket | pending | +| 4 | Paid `/fetch` ($0.001) — buyer: `bunny_js/notebooks/search_x402_buyer.ipynb`, `SERVER` = "bunny-trial" with `TRIAL_URL` set → 200 + `Payment-Response` | pending | +| 5 | Paid `/search` ($0.01) via the same notebook → 200 + results | pending | +| 6 | Channel record in S3 updated; next `llmx402cron` run claims it cleanly (observability logs) | pending | +| 7 | Cold-start spot checks at the bunny URL | pending | + +Budget: ≤ ~$0.10 of real purchases. + +## Status + +Tasks 1-5 of the port plan are done (scaffold, bundle gate, SigV4 spike, verbatim stack + +tests, native routing + tests). Task 6 (deploy + trials) is pending user-side setup: bunny CLI +install + login (or dashboard paste of `dist/index.js`), and the secrets above configured on +the script. The formal switch (moving `web-agent.fretchen.eu` off Scaleway) is out of scope. + +## Deploy paths (both can coexist) + +Bunny publishes whichever deployment happened **last**, so a manual CLI deploy and the +GitHub Action interoperate by design — deploy from the laptop for an experiment, and the +next push to `main` overwrites it back to the committed state. + +1. **Manual (CLI):** `npm run deploy` (= build + `bunny scripts deploy dist/index.js`), + needs the bunny CLI — `curl -fsSL https://cli.bunny.net/install.sh | sh` (single binary, + independent of the nvm-managed Node that a global npm install would bind it to) — then + `bunny login`. +2. **GitHub Actions:** `.github/workflows/deploy-bunny-js.yml` — on push to `main` touching + `bunny_js/**` or the shared libs, builds (with the repo's shared-deps-first ordering) and + publishes `dist/index.js` via the official `BunnyWay/actions/deploy-script` action. + Requires two repo secrets, both from the bunny dashboard + (**Script -> Deployments -> Settings**): + - `BUNNYJS_SCRIPT_ID` — the script's id + - `BUNNYJS_DEPLOY_KEY` — the per-script deploy key (deliberately chosen over the + account-wide API key the CLI-in-CI variant would need — narrower blast radius) + +The workflow ships code only; the script's environment variables and secrets are configured +on the script itself (dashboard or `bunny scripts env set`), so a repo-side leak cannot leak +the Brave key, the authorizer key, or the S3 channel-store credentials. + +## First deploy (one-time) + +`npm run deploy` alone fails the first time — it pushes to a remote script that must exist, +and the `.bunny/script.json` link comes out of the create step. One-time sequence: + +1. `bunny scripts create --type standalone` — creates the remote script and links this + directory (writes `.bunny/script.json`, gitignored — machine-local, like the docs treat it). +2. Upload the env from `.env` with `bunny scripts env set`, marking the sensitive keys with + `--secret` (RECEIVER_AUTHORIZER_PRIVATE_KEY, BRAVE_API_KEY, SCW_ACCESS_KEY, SCW_SECRET_KEY, + RPC_URL_*). Skip `TEST_WALLET_PRIVATE_KEY` — buyer-side only, the server must never see it. + Verify with `bunny scripts env list`. +3. `npm run deploy` — builds, uploads, publishes; prints the live `*.bunny.run` URL. +4. `bunny scripts env set SEARCH_SERVICE_URL https://.bunny.run` and redeploy, so + the 402 advertises the trial origin instead of the parity default. +5. Unpaid smoke: `curl -D - 'https://.bunny.run/search?q=x402'` → 402 + + `Payment-Required` header. +6. For the GitHub Action: `bunny scripts show` gives the script id; the deploy key is in the + dashboard (Script -> Deployments -> Settings). Add both as the `BUNNYJS_*` repo secrets. + +After the first deploy, the Action and manual `npm run deploy` are interchangeable — last +deployment wins. diff --git a/bunny_js/SECURITY.md b/bunny_js/SECURITY.md new file mode 100644 index 000000000..ceac42c74 --- /dev/null +++ b/bunny_js/SECURITY.md @@ -0,0 +1,80 @@ +# Security Policy + +## Reporting a Vulnerability + +Please **do not open a public GitHub issue** for security vulnerabilities. + +Report privately via [GitHub Security Advisories](https://github.com/fretchen/fretchen.github.io/security/advisories/new). You will receive a response within 7 days. + +--- + +## Scope + +The bunny.net Edge Script twin of the `searchapi` seller: `bunny_js/` code, the deployed +script (id 97472, `bunnyjs-vlyol.bunny.run`), its script env, the GitHub Action +(`.github/workflows/deploy-bunny-js.yml`), and the bunny CLI credential on the dev machine. +See [../.github/THREAT_MODEL.md](../.github/THREAT_MODEL.md) for the asset inventory and +blast radius — the bunny surface has its own rows there (assets, blast radius, trust +boundary, 2026-10-18 audit). + +--- + +## Known Open Issues + +| Area | Severity | Status | +| ------------------------------------------ | -------- | ---------------------------------------------------------------------------------------- | +| Deploy-key blast radius | Medium | Bounded — trigger hygiene (push-to-main only) + rotate-on-suspicion; see THREAT_MODEL §2 | +| Deployed-runtime SigV4 + DNS verification | Low | Pending — local Deno PASS; both failure branches verified fail-closed; see below | +| Undici-specific error matching on the edge | Low | Watch item — unreachable hosts may 500 instead of 400 on bunny's runtime; see below | +| `.env` copy drift vs `scw_js/.env` | Low | Accepted for the trial — the two secret sets must be rotated/copied together | + +**Deployed-runtime verification (2026-10-18 audit).** `node:crypto` (SigV4) and +`node:dns/promises` (the `/fetch` SSRF defence) are unverified on bunny's Deno 1.x — the +runtime version is not selectable and differs from local Deno 2. Code paths were verified +fail-closed in both directions: a module-level import failure takes the whole script down +(no SSRF window), and a per-call `lookup` throw refuses the fetch with a 400. A deployed +probe was prepared to prove both, then removed undeployed after the audit flagged its +unauthenticated design (CWE-306); **the first paid `/fetch` trial doubles as the runtime +verification** — a 200 proves DNS resolved on the isolate, and any charge landing in the +channel store proves SigV4 signed correctly. + +**Undici-specific error matching.** `web_fetch_service.ts`'s `asUnreachable` matches Node's +undici error shapes (`TypeError` + `"fetch failed"`). On bunny's runtime, connection +failures will likely not match and surface as a generic 500 instead of the intended 400. +Safety is unaffected (the channel lock is released and nothing is settled); broaden the +matcher with Deno's error shapes on first observation, not speculatively. + +--- + +## Fixed / Verified (2026-10-18 audit) + +- The unauthenticated S3-probe design was removed rather than deployed. If a deployed probe + is ever needed again, it must require a `PROBE_TOKEN` secret checked against the + `x-probe-token` header. +- CDN replay of paid responses: the twin sends `Cache-Control: no-store` on every response; + the platform normalizes it to `no-cache`, and the responses carry no validators, so every + hit revalidates at origin — a paid 200 with its `Payment-Response` receipt cannot be + replayed to another caller. Verified live (`cdn-cache: MISS` on repeats). +- Non-latin1 header bytes: the raw-JSON `X-Payment`/`X-Payment-Response` headers are escaped + as JSON `\uXXXX` at the conversion layer — lossless for any JSON-parsing client. +- Deployed env classification verified: 5 plain (public or inert values), 4 secret; no + buyer key or RPC URLs on the server; `TEST_WALLET_PRIVATE_KEY` never uploaded. +- The DNS-rebinding acceptance from `scw_js` holds more comfortably on the edge: a CDN PoP + has no cloud metadata service, and the private-range block is enforced identically. +- Repo hygiene: `.env` untracked and gitignored in both packages; tracked files contain only + published test keys, public addresses, and the public ownership proof. + +--- + +## Mitigations in Place + +- Same payment cryptography as the Scaleway sellers (port verbatim): EIP-712-bound channel + charges, settle-after-success, lock release on upstream failure, mainnet-only search, + per-route fixed prices +- The full SSRF defence of `scw_js/web_fetch_service.ts` ported unchanged (scheme rule, + resolved-address allowlist, per-hop redirect re-validation, body cap, content-type filter, + timeout) +- No owner or buyer credentials in the script env; the receiver is a public address +- Workflow: `permissions: contents: read`, push-to-`main`-only triggers, path-filtered — + fork PRs cannot reach the deploy key +- Bunny CLI credential stored mode 600 (`~/.config/bunnynet.json`) diff --git a/bunny_js/deno.lock b/bunny_js/deno.lock new file mode 100644 index 000000000..218dc4652 --- /dev/null +++ b/bunny_js/deno.lock @@ -0,0 +1,30 @@ +{ + "version": "5", + "remote": { + "https://deno.land/std@0.224.0/dotenv/mod.ts": "0180eaeedaaf88647318811cdaa418cc64dc51fb08354f91f5f480d0a1309f7d", + "https://deno.land/std@0.224.0/dotenv/parse.ts": "09977ff88dfd1f24f9973a338f0f91bbdb9307eb5ff6085446e7c423e4c7ba0c", + "https://deno.land/std@0.224.0/dotenv/stringify.ts": "275da322c409170160440836342eaa7cf012a1d11a7e700d8ca4e7f2f8aa4615" + }, + "workspace": { + "packageJson": { + "dependencies": [ + "npm:@bunny.net/edgescript-sdk@~0.11.2", + "npm:@eslint/js@10", + "npm:@types/node@^26.1.0", + "npm:@vitest/coverage-v8@^4.1.11", + "npm:@vitest/eslint-plugin@^1.6.27", + "npm:@x402/core@^2.23.0", + "npm:@x402/evm@^2.23.0", + "npm:@x402/extensions@2.28", + "npm:eslint@10", + "npm:prettier@^3.6.2", + "npm:tsdown@~0.23.1", + "npm:typescript-eslint@^8.58.2", + "npm:typescript@^5.9.3", + "npm:viem@2.57.4", + "npm:vitest@^4.1.11", + "npm:zod@^4.5.4" + ] + } + } +} diff --git a/bunny_js/eslint.config.js b/bunny_js/eslint.config.js new file mode 100644 index 000000000..469de9f8d --- /dev/null +++ b/bunny_js/eslint.config.js @@ -0,0 +1,83 @@ +import js from "@eslint/js"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { + ignores: ["node_modules/", "coverage/", "dist/"], + }, + js.configs.recommended, + ...tseslint.configs.recommended, + // TypeScript source files (test files get their own vitest-globals block below) + { + files: ["src/**/*.ts", "scripts/**/*.ts"], + languageOptions: { + ecmaVersion: 2022, + sourceType: "module", + parserOptions: { + project: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: { + "@typescript-eslint/no-unused-vars": [ + "error", + { + argsIgnorePattern: "^(_|context|cb|_context|_cb)$", + varsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + }, + ], + "@typescript-eslint/no-explicit-any": "warn", + "no-unused-vars": "off", + "no-console": "off", + "prefer-const": "error", + "no-var": "error", + "no-duplicate-imports": "error", + "no-unreachable": "error", + "no-undef": "off", + "comma-dangle": ["error", "always-multiline"], + eqeqeq: ["error", "always"], + curly: ["error", "all"], + "no-eval": "error", + "no-implied-eval": "error", + "no-new-func": "error", + "no-throw-literal": "error", + "arrow-spacing": "error", + "template-curly-spacing": "error", + "object-shorthand": "error", + }, + }, + // Test files: type-aware like the rest of the package, plus vitest globals. + { + files: ["test/**/*.ts", "**/*.test.ts"], + languageOptions: { + parserOptions: { + project: true, + tsconfigRootDir: import.meta.dirname, + }, + globals: { + describe: "readonly", + test: "readonly", + expect: "readonly", + beforeAll: "readonly", + beforeEach: "readonly", + afterEach: "readonly", + afterAll: "readonly", + vi: "readonly", + process: "readonly", + Buffer: "readonly", + console: "readonly", + }, + }, + rules: { + "no-unused-expressions": "off", + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unused-vars": [ + "error", + { argsIgnorePattern: "^_", varsIgnorePattern: "^_", caughtErrorsIgnorePattern: "^_" }, + ], + "no-undef": "off", + "@typescript-eslint/no-require-imports": "off", + }, + }, +); diff --git a/bunny_js/notebooks/deno.json b/bunny_js/notebooks/deno.json new file mode 100644 index 000000000..b89c4f098 --- /dev/null +++ b/bunny_js/notebooks/deno.json @@ -0,0 +1,5 @@ +{ + "$comment": "Scoped to this dir only — does not affect the parent bunny_js npm/vitest project. nodeModulesDir: auto gives THIS folder its own local node_modules (bunny_js/notebooks/node_modules, with its .deno store), driven purely by the npm: specifiers in the notebooks — no package.json needed; the dir already exists, which is what makes \"auto\" use it (Deno 2's \"auto\" only creates the dir when a package.json exists in scope, so recreate it by temporarily setting \"true\" and running any import once). Same fix as scw_js/notebooks/deno.json and x402_facilitator/notebooks/deno.json — without this file, Deno auto-detects ../package.json as an ancestor and switches npm: resolution to that package's node_modules (byonm-style), which only has bunny_js's own deps (no @x402/fetch, different viem) and breaks the buyer notebook's imports. lock: false disables deno.lock creation/checking for this dir.", + "nodeModulesDir": "auto", + "lock": false +} diff --git a/bunny_js/notebooks/search_x402_buyer.ipynb b/bunny_js/notebooks/search_x402_buyer.ipynb new file mode 100644 index 000000000..17b00ed94 --- /dev/null +++ b/bunny_js/notebooks/search_x402_buyer.ipynb @@ -0,0 +1,655 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# `bunny_js` search twin — Buyer Notebook (Deno/TypeScript)\n", + "\n", + "Adapted from `scw_js/notebooks/search_x402_buyer.ipynb` — same buyer machinery, pointed at the\n", + "bunny Edge Script twin of `search_api.ts` instead of the Scaleway function. Drives the twin's two\n", + "paid routes as a real client would: `GET /fetch` reads one web page, `GET /search` proxies Brave's\n", + "LLM Context API. It only ever talks to the resource server over plain HTTP via\n", + "`wrapFetchWithPayment` — the server advertises everything else.\n", + "\n", + "Three things here are not covered by `bunny_js/test/routes.test.ts`, which mocks the SDK:\n", + "\n", + "1. **A GET can be paid for at all.** Both of the other paid endpoints are POSTs, and the payment\n", + " arrives in a header either way — there is no body to fall back to.\n", + "2. **The 402 this handler emits is readable by `@x402/fetch`**, prices and all.\n", + "3. **The second paid call opens no second channel.** That is the whole design: a tool call bills\n", + " onto the channel the chat already funded.\n", + "\n", + "## ⚠️ This notebook always spends real money\n", + "\n", + "There is no testnet option. `/search` and `/fetch` spend on the caller's behalf — Brave bills per\n", + "query, `/fetch` is egress — so testnet USDC, which is free, would buy a metered API for nothing.\n", + "`MAINNET_NETWORKS` in the twin (`bunny_js/src/routes.ts`, copied from `search_api.ts`) deliberately\n", + "lists only Optimism and Base mainnet.\n", + "\n", + "A full run costs **$0.011** (one fetch at $0.001, one search at $0.01) plus deposit gas, out of a\n", + "$0.10 channel deposit. The rest of the deposit stays escrowed until `llmx402cron` claims it\n", + "(within 12h) or a refund after the 24h `withdrawDelay` — it is not spent, but it is not immediately\n", + "back in your wallet either.\n", + "\n", + "⚠️ **A local twin is not a local experiment** (same as the scw_js dev server): the twin reads and\n", + "writes the **production S3 channel store**, and its charges are claimed by the same\n", + "`llmx402cron` on Scaleway. The channel itself is on-chain state keyed by (buyer, receiver,\n", + "network, asset), so local, trial, and production all bill the same channel.\n", + "\n", + "## Prerequisites\n", + "\n", + "1. **Deno Jupyter kernel** — `deno jupyter --install` (the same global kernel the sibling notebooks\n", + " use).\n", + "2. **`bunny_js/.env`** (one level up from this notebook — a local copy of `scw_js/.env`) needs:\n", + " - `TEST_WALLET_PRIVATE_KEY` — the buyer wallet, funded with **mainnet** USDC on Base or\n", + " Optimism, plus a little ETH on that chain for the deposit tx. Notebook-testing convenience\n", + " only; no production code reads this key.\n", + " - `NFT_WALLET_PUBLIC_KEY` — the receiver, shared with the chat endpoint. This is what makes the\n", + " channel the same one.\n", + " - `RECEIVER_AUTHORIZER_PRIVATE_KEY` — needed for `createLLMResourceServer()` to construct at\n", + " all. A pure off-chain signer, no funding.\n", + " - `BRAVE_API_KEY` — read by the **server**, not by this notebook.\n", + "3. **The server**, one of three (the `SERVER` flag below switches between them):\n", + " - `bunny-local` (default): `cd bunny_js && npm run build && deno run -A --env-file=.env dist/index.js`\n", + " — serves on 127.0.0.1:8080.\n", + " - `bunny-trial`: the deployed twin's `*.bunny.run` URL (`TRIAL_URL` below).\n", + " - `scw-deployed`: the Scaleway original, `https://web-agent.fretchen.eu`.\n", + "\n", + " The channel is the same in all three: it is on-chain state keyed by (buyer, receiver, network,\n", + " asset), not anything a server holds." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "🔎 search_api buyer notebook\n", + " Buyer (payer): 0x553179556FC2A39e535D65b921e01fA995E79101\n", + " Receiver : 0xAAEBC1441323B8ad6Bdf6793A8428166b510239C\n" + ] + } + ], + "source": [ + "// Setup: imports + env\n", + "import { load } from \"https://deno.land/std@0.224.0/dotenv/mod.ts\";\n", + "import { privateKeyToAccount } from \"npm:viem@2/accounts\";\n", + "\n", + "// bunny_js's own .env (copied from scw_js's) — one level up from this notebook.\n", + "// the server's own secrets all live here; the server reads the same file).\n", + "const env = await load({ envPath: \"../.env\", examplePath: null, export: true });\n", + "\n", + "const PRIVATE_KEY = env.TEST_WALLET_PRIVATE_KEY;\n", + "if (!PRIVATE_KEY) {\n", + " throw new Error(\n", + " \"TEST_WALLET_PRIVATE_KEY missing from bunny_js/.env — add a key funded with MAINNET USDC \" +\n", + " \"(Base or Optimism). There is no testnet path for these routes; see the cell above.\",\n", + " );\n", + "}\n", + "const account = privateKeyToAccount(`0x${PRIVATE_KEY.replace(/^0x/, \"\")}`);\n", + "\n", + "console.log(\"🔎 search_api buyer notebook\");\n", + "console.log(` Buyer (payer): ${account.address}`);\n", + "console.log(` Receiver : ${env.NFT_WALLET_PUBLIC_KEY ?? \"(not set — server will 500)\"}`);" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Network selection\n", + "\n", + "Base or Optimism, both mainnet — see the warning above for why there is no third option. The two\n", + "differ in one way that has bitten this repo before: **USDC's EIP-712 domain name is `USD Coin` on\n", + "mainnet and `USDC` on testnet**, so a config copied from a testnet notebook signs vouchers no\n", + "server will accept. The table in `scw_js/README.md` → _Known USDC Domain Names_ is the verified\n", + "source.\n", + "\n", + "**Base also offers EURC**, listed before USDC in the 402 (`scw_js/stablecoin_pricing.ts`) — so the\n", + "default `USE_BASE = true` pays EURC once the buyer-setup cell allows it. Optimism has no EURC\n", + "deployment. Search and fetch have their own EURC prices (`PRICE_ATOMIC` in `search_schemas.ts`);\n", + "nothing is converted from USDC.\n", + "\n", + "`SERVER` (local twin / bunny trial / deployed Scaleway) is orthogonal to the network: same code path and the same channel in all three modes." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "🚨 REAL MONEY on Base Mainnet\n", + " eip155:8453 • USDC USD Coin @ 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913\n", + " eip155:8453 • EURC @ 0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42\n", + " eip155:8453 • EURe @ 0xbf6e2966A9C3D99C9E4D069E04f7Bdb9C8aa762C (server prefers this)\n", + " Service: http://localhost:8080\n" + ] + } + ], + "source": [ + "import { base, optimism } from \"npm:viem@2/chains\";\n", + "\n", + "const USE_BASE = true; // false = Optimism mainnet\n", + "\n", + "const NETWORK_CONFIG = {\n", + " \"base-mainnet\": {\n", + " caip2Network: \"eip155:8453\" as const, networkName: \"Base Mainnet\",\n", + " chain: base, usdcName: \"USD Coin\",\n", + " usdcAddress: \"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913\" as `0x${string}`,\n", + " // EURC on Base — see shared/chain-utils/src/addresses.ts and scw_js/README.md's EURC\n", + " // domain-name table.\n", + " eurcAddress: \"0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42\" as `0x${string}` | undefined,\n", + " // EURe on Base (Monerium, 18 decimals, Permit2 + EIP-2612 permit — no approve()).\n", + " eureAddress: \"0xbf6e2966A9C3D99C9E4D069E04f7Bdb9C8aa762C\" as `0x${string}` | undefined,\n", + " rpcUrl: \"https://base-rpc.publicnode.com\",\n", + " explorer: \"https://basescan.org\",\n", + " },\n", + " \"optimism-mainnet\": {\n", + " caip2Network: \"eip155:10\" as const, networkName: \"Optimism Mainnet\",\n", + " chain: optimism, usdcName: \"USD Coin\",\n", + " usdcAddress: \"0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85\" as `0x${string}`,\n", + " // No EURC on Optimism (Circle has no deployment there).\n", + " eurcAddress: undefined as `0x${string}` | undefined,\n", + " rpcUrl: \"https://optimism-rpc.publicnode.com\",\n", + " explorer: \"https://optimistic.etherscan.io\",\n", + " },\n", + "};\n", + "// RPCs pinned to publicnode rather than viem's bare defaults (mainnet.base.org /\n", + "// mainnet.optimism.io): opening a channel does a Multicall3 read batch, which those defaults have\n", + "// already rate-limited in this repo — surfacing as a generic ..._deposit_transaction_failed. Same\n", + "// endpoints website/wagmi.config.ts settled on.\n", + "const config = NETWORK_CONFIG[USE_BASE ? \"base-mainnet\" : \"optimism-mainnet\"];\n", + "const NETWORK = config.caip2Network;\n", + "\n", + "// bunny-local: `cd bunny_js && npm run build && deno run -A --env-file=.env dist/index.js`\n", + "// (127.0.0.1:8080 — the SDK's serve default; no port config in the twin).\n", + "// bunny-trial: the deployed twin's *.bunny.run URL. scw-deployed: the Scaleway original. Same\n", + "// channel in all three — see the notebook header.\n", + "const SERVER = \"bunny-local\" as \"bunny-local\" | \"bunny-trial\" | \"scw-deployed\";\n", + "const TRIAL_URL = \"https://CHANGE-ME.bunny.run\"; // ← set when SERVER = \"bunny-trial\"\n", + "const SERVICE_URL =\n", + " SERVER === \"bunny-local\" ? \"http://localhost:8080\"\n", + " : SERVER === \"bunny-trial\" ? TRIAL_URL\n", + " : \"https://web-agent.fretchen.eu\";\n", + "\n", + "console.log(`🚨 REAL MONEY on ${config.networkName}`);\n", + "console.log(` ${NETWORK} • USDC ${config.usdcName} @ ${config.usdcAddress}`);\n", + "if (config.eurcAddress) {\n", + " console.log(` ${NETWORK} • EURC @ ${config.eurcAddress}`);\n", + "}\n", + "if (config.eureAddress) {\n", + " console.log(` ${NETWORK} • EURe @ ${config.eureAddress} (server prefers this)`);\n", + "}\n", + "console.log(` Service: ${SERVICE_URL}`);" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Buyer setup\n", + "\n", + "Identical in shape to `sc_llm_x402_buyer.ipynb`'s buyer cell, and for the same reason: the scheme\n", + "is `batch-settlement`, so the client needs a channel store, a deposit strategy and a signer that\n", + "can read contracts. The one difference is the deposit floor, which is smaller here — these calls\n", + "cost a tenth of a cent, not three tenths." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "_x402Client {\n", + " registeredClientSchemes: Map(1) {\n", + " \u001b[33m2\u001b[39m => Map(1) {\n", + " \u001b[32m\"eip155:8453\"\u001b[39m => Map(1) {\n", + " \u001b[32m\"batch-settlement\"\u001b[39m => BatchSettlementEvmScheme {\n", + " signer: \u001b[36m[Object]\u001b[39m,\n", + " scheme: \u001b[32m\"batch-settlement\"\u001b[39m,\n", + " findDefaultAsset: \u001b[36m[Function: findDefaultAsset]\u001b[39m,\n", + " storage: \u001b[36m[WebStorageClientChannelStorage]\u001b[39m,\n", + " depositPolicy: \u001b[90mundefined\u001b[39m,\n", + " depositStrategy: \u001b[36m[Function: depositStrategy]\u001b[39m,\n", + " salt: \u001b[32m\"0x0000000000000000000000000000000000000000000000000000000000000000\"\u001b[39m,\n", + " payerAuthorizer: \u001b[90mundefined\u001b[39m,\n", + " voucherSigner: \u001b[90mundefined\u001b[39m,\n", + " extensionRpcOptions: \u001b[90mundefined\u001b[39m,\n", + " schemeHooks: \u001b[36m[Object]\u001b[39m\n", + " }\n", + " }\n", + " }\n", + " },\n", + " schemeClientHookAdapters: Map(1) {\n", + " \u001b[33m2\u001b[39m => Map(1) {\n", + " \u001b[32m\"eip155:8453\"\u001b[39m => Map(1) {\n", + " \u001b[32m\"batch-settlement\"\u001b[39m => { onPaymentResponse: \u001b[36m[AsyncFunction: onPaymentResponse]\u001b[39m }\n", + " }\n", + " }\n", + " },\n", + " policies: [],\n", + " registeredExtensions: Map(0) {},\n", + " spendControls: {\n", + " allowedAssets: [\n", + " {\n", + " network: \u001b[32m\"eip155:8453\"\u001b[39m,\n", + " asset: \u001b[32m\"0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42\"\u001b[39m\n", + " },\n", + " {\n", + " network: \u001b[32m\"eip155:8453\"\u001b[39m,\n", + " asset: \u001b[32m\"0xbf6e2966A9C3D99C9E4D069E04f7Bdb9C8aa762C\"\u001b[39m\n", + " }\n", + " ]\n", + " },\n", + " beforePaymentCreationHooks: [],\n", + " afterPaymentCreationHooks: [],\n", + " onPaymentCreationFailureHooks: [],\n", + " paymentResponseHooks: [],\n", + " paymentRequirementsSelector: \u001b[36m[Function (anonymous)]\u001b[39m\n", + "}" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import { x402Client, wrapFetchWithPayment } from \"npm:@x402/fetch@2.28.0\";\n", + "import { toClientEvmSigner } from \"npm:@x402/evm@2.28.0\";\n", + "import {\n", + " BatchSettlementEvmScheme,\n", + " type ClientChannelStorage,\n", + " type BatchSettlementClientContext,\n", + " type BatchSettlementDepositStrategyContext,\n", + "} from \"npm:@x402/evm@2.28.0/batch-settlement/client\";\n", + "import { createPublicClient, http } from \"npm:viem@2\";\n", + "\n", + "// readContract is documented as \"optional\" on ClientEvmSigner, but batch-settlement's\n", + "// corrective-402 recovery (processCorrectivePaymentRequired) unconditionally needs it — both\n", + "// recoverFromSignature and recoverFromOnChainState bail out with `if (!deps.signer.readContract)\n", + "// return false`. Without it, a client/server cumulative desync (an old channel reused across\n", + "// kernel restarts) surfaces as a hard 402 instead of self-healing.\n", + "const publicClient = createPublicClient({ chain: config.chain, transport: http(config.rpcUrl) });\n", + "const buyerSigner = toClientEvmSigner(\n", + " { address: account.address, signTypedData: (a: any) => account.signTypedData(a) },\n", + " publicClient,\n", + ");\n", + "\n", + "// Channel storage. Deno exposes a global `localStorage` that persists across kernel restarts,\n", + "// which is what lets a second run reuse the first run's channel instead of depositing again.\n", + "class WebStorageClientChannelStorage implements ClientChannelStorage {\n", + " constructor(private backend: Storage, private prefix = \"x402-channel:\") {}\n", + " private keyFor(key: string) { return `${this.prefix}${key.toLowerCase()}`; }\n", + " async get(key: string) {\n", + " const raw = this.backend.getItem(this.keyFor(key));\n", + " return raw ? (JSON.parse(raw) as BatchSettlementClientContext) : undefined;\n", + " }\n", + " async set(key: string, context: BatchSettlementClientContext) {\n", + " this.backend.setItem(this.keyFor(key), JSON.stringify(context));\n", + " }\n", + " async delete(key: string) { this.backend.removeItem(this.keyFor(key)); }\n", + "}\n", + "const buyerStorage = new WebStorageClientChannelStorage(localStorage);\n", + "\n", + "// $0.10 of escrow: ~9 searches or ~100 fetches. Deliberately smaller than useX402Chat.ts's $0.50\n", + "// floor, because this is mainnet and a notebook — but note it is ESCROW, not spend. Whatever is\n", + "// not consumed stays locked until llmx402cron claims the channel (≤12h) or a refund after the 24h\n", + "// withdrawDelay. Going much lower just buys more top-up transactions.\n", + "const MINIMUM_DEPOSIT_ATOMIC = 100_000n;\n", + "\n", + "function depositStrategy(context: BatchSettlementDepositStrategyContext): string {\n", + " const required = BigInt(context.minimumDepositAmount);\n", + " return (required > MINIMUM_DEPOSIT_ATOMIC ? required : MINIMUM_DEPOSIT_ATOMIC).toString();\n", + "}\n", + "\n", + "const buyerScheme = new BatchSettlementEvmScheme(buyerSigner, {\n", + " storage: buyerStorage,\n", + " depositStrategy,\n", + "});\n", + "\n", + "const client = new x402Client();\n", + "client.register(NETWORK, buyerScheme);\n", + "\n", + "// USDC already passes the SDK's default spend-control asset registry on both networks this\n", + "// notebook uses, so it needs no explicit entry. EURC and EURe do not exist in that registry\n", + "// at all, so without these they would be silently dropped from Base's offer rather than paid.\n", + "client.setSpendControls({\n", + " allowedAssets: [\n", + " ...(config.eurcAddress ? [{ network: NETWORK, asset: config.eurcAddress }] : []),\n", + " ...(config.eureAddress ? [{ network: NETWORK, asset: config.eureAddress }] : []),\n", + " ],\n", + "});\n", + "\n", + "const fetchWithPayment = wrapFetchWithPayment(fetch, client);\n", + "\n", + "/** The settlement receipt the server attaches to a paid 200. `transaction: \"\"` means a voucher\n", + " * was committed off-chain — no tx, which is the point of batch settlement. */\n", + "function settlement(res: Response) {\n", + " const header = res.headers.get(\"Payment-Response\");\n", + " return header\n", + " ? (JSON.parse(atob(header)) as {\n", + " transaction?: string;\n", + " network?: string;\n", + " extensions?: { facilitatorFees?: { info?: { asset?: string } } };\n", + " })\n", + " : null;\n", + "}\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## The 402 is the price list\n", + "\n", + "Reading it costs nothing — no wallet, no payment, no channel. A bare `fetch` gets the terms in the\n", + "base64 `Payment-Required` header, which is how any client discovers what it is being asked for.\n", + "\n", + "Note the two prices, and that the quote is the same whatever the query string says: an unpaid\n", + "request is answered with the price before its query is even validated, so a client probing for\n", + "terms is never told its query is malformed instead." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "GET /fetch → 402 \"The readable text of one public web page\"\n", + " resource: https://web-agent.fretchen.eu/fetch\n", + " eip155:10 batch-settlement 1000 USD Coin atomic (0.0010) → 0xAAEBC1441323B8ad6Bdf6793A8428166b510239C lock 30s\n", + " eip155:8453 batch-settlement 1000000000000000 Monerium EURe atomic (0.0010) → 0xAAEBC1441323B8ad6Bdf6793A8428166b510239C lock 30s\n", + " eip155:8453 batch-settlement 1000 EURC atomic (0.0010) → 0xAAEBC1441323B8ad6Bdf6793A8428166b510239C lock 30s\n", + " eip155:8453 batch-settlement 1000 USD Coin atomic (0.0010) → 0xAAEBC1441323B8ad6Bdf6793A8428166b510239C lock 30s\n", + "\n", + "GET /search → 402 \"Web search with extracted page content (Brave LLM Context API)\"\n", + " resource: https://web-agent.fretchen.eu/search\n", + " eip155:10 batch-settlement 10000 USD Coin atomic (0.0100) → 0xAAEBC1441323B8ad6Bdf6793A8428166b510239C lock 30s\n", + " eip155:8453 batch-settlement 10000000000000000 Monerium EURe atomic (0.0100) → 0xAAEBC1441323B8ad6Bdf6793A8428166b510239C lock 30s\n", + " eip155:8453 batch-settlement 10000 EURC atomic (0.0100) → 0xAAEBC1441323B8ad6Bdf6793A8428166b510239C lock 30s\n", + " eip155:8453 batch-settlement 10000 USD Coin atomic (0.0100) → 0xAAEBC1441323B8ad6Bdf6793A8428166b510239C lock 30s\n" + ] + } + ], + "source": [ + "async function quote(route: \"search\" | \"fetch\") {\n", + " const res = await fetch(`${SERVICE_URL}/${route}`);\n", + " const header = res.headers.get(\"Payment-Required\");\n", + " // Nothing reads the body here — the terms are in the header — so release it rather than\n", + " // leaving an open response behind on every kernel run.\n", + " await res.body?.cancel();\n", + " if (!header) throw new Error(`No Payment-Required header on ${res.status} — is the server running?`);\n", + " return { status: res.status, terms: JSON.parse(atob(header)) };\n", + "}\n", + "\n", + "for (const route of [\"fetch\", \"search\"] as const) {\n", + " const { status, terms } = await quote(route);\n", + " console.log(`\\nGET /${route} → ${status} \"${terms.resource.description}\"`);\n", + " console.log(` resource: ${terms.resource.url}`);\n", + " for (const accept of terms.accepts) {\n", + " // Per-token decimals: USDC/EURC are 6, EURe 18 — one 1e6 divisor would print\n", + " // EURe's price twelve orders of magnitude too large.\n", + " const decimals = accept.asset === config.eureAddress ? 18 : 6;\n", + " const price = (Number(accept.amount) / 10 ** decimals).toFixed(4);\n", + " const asset = accept.extra?.name ?? accept.asset;\n", + " console.log(\n", + " ` ${accept.network.padEnd(13)} ${accept.scheme} ${accept.amount} ${asset} atomic (${price})` +\n", + " ` → ${accept.payTo} lock ${accept.maxTimeoutSeconds}s`,\n", + " );\n", + " }\n", + "}" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## There is no other way in\n", + "\n", + "This endpoint used to fork: a valid signature over `search-api:` was served free, and\n", + "everything else paid. That path is gone. It was justified as what kept server-side callers\n", + "working without a funded wallet, and no such caller existed — `growth-agent` never called this\n", + "endpoint, and the browser tools moved to paid fetch. `genimg` and `llm` never had one either.\n", + "\n", + "The cell below sends a correctly signed owner bearer anyway, to show it now buys nothing: the\n", + "header is ignored and the answer is the same 402 an anonymous caller gets.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "GET /fetch with a bearer → 402\n", + " quoted a price — the bearer was ignored, which is the point\n" + ] + } + ], + "source": [ + "const ts = Math.floor(Date.now() / 1000);\n", + "const message = `search-api:${ts}`;\n", + "const signature = await account.signMessage({ message });\n", + "// The shape the browser used to send, before the owner path was removed.\n", + "const token = btoa(JSON.stringify({ address: account.address, signature, message }));\n", + "\n", + "const ownerRes = await fetch(`${SERVICE_URL}/fetch?url=${encodeURIComponent(\"https://www.fretchen.eu/\")}`, {\n", + " headers: { Authorization: `Bearer ${token}` },\n", + "});\n", + "\n", + "console.log(`GET /fetch with a bearer → ${ownerRes.status}`);\n", + "console.log(\n", + " ownerRes.status === 402\n", + " ? \" quoted a price — the bearer was ignored, which is the point\"\n", + " : ` unexpected: ${ownerRes.status}. A bearer should buy nothing here.`,\n", + ");\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## First paid call: `/fetch`\n", + "\n", + "The cheap route first, at $0.001. This is the call that **opens the channel**: the SDK sees the\n", + "402, finds no channel in storage, signs a deposit, and the facilitator puts it on-chain. Expect a\n", + "real transaction hash in the settlement receipt, and a wait of a few seconds.\n", + "\n", + "Everything after this one is a signature." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "GET /fetch → 200\n", + " finalUrl : https://www.fretchen.eu/blog/36/\n", + " contentType: text/html\n", + " html : 56003 chars\n", + " starts : \"\\n \\n \\n localStorage.key(i)!)\n", + " .filter((key) => key.startsWith(\"x402-channel:\"))\n", + " .map((key) => ({ channelId: key.slice(\"x402-channel:\".length), ...JSON.parse(localStorage.getItem(key)!) }));\n", + "\n", + "console.log(`\\n channel records in localStorage: ${channels.length} (keyed to this notebook's location)`);\n", + "for (const channel of channels) {\n", + " console.log(` ${channel.channelId}`);\n", + " console.log(` balance ${channel.balance ?? \"?\"} • charged ${channel.chargedCumulativeAmount ?? \"?\"} • claimed ${channel.totalClaimed ?? \"?\"}`);\n", + "}\n", + "console.log(\"\\n The one this run used is whichever advanced by 11000 — 1000 for /fetch, 10000 for /search.\");" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Findings\n", + "\n", + "_Fill in after a real run, as `genimg_x402_buyer.ipynb` and `sc_llm_x402_buyer.ipynb` do — each\n", + "carries a dated \"observed …\" section, which is how a later reader knows whether a claim was\n", + "measured or assumed._\n", + "\n", + "- Observed on: _(date, network, local or deployed)_\n", + "- Deposit tx: _(hash)_\n", + "- Did `/search` settle without a transaction?\n", + "- Channels in storage after both calls:\n", + "- Anything the 402 advertised that the client did not use:" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Deno", + "language": "typescript", + "name": "deno" + }, + "language_info": { + "codemirror_mode": "typescript", + "file_extension": ".ts", + "mimetype": "text/x.typescript", + "name": "typescript", + "nbconvert_exporter": "script", + "pygments_lexer": "typescript", + "version": "6.0.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/bunny_js/package-lock.json b/bunny_js/package-lock.json new file mode 100644 index 000000000..772e7c33b --- /dev/null +++ b/bunny_js/package-lock.json @@ -0,0 +1,3868 @@ +{ + "name": "@fretchen/bunny-js", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@fretchen/bunny-js", + "version": "0.1.0", + "license": "UNLICENSED", + "dependencies": { + "@bunny.net/edgescript-sdk": "^0.11.2", + "@fretchen/chain-utils": "file:../shared/chain-utils", + "@fretchen/s3-utils": "file:../shared/s3-utils", + "@x402/core": "^2.23.0", + "@x402/evm": "^2.23.0", + "@x402/extensions": "~2.28.0", + "viem": "2.57.4", + "zod": "^4.5.4" + }, + "devDependencies": { + "@eslint/js": "^10.0.0", + "@types/node": "^26.1.0", + "@vitest/coverage-v8": "^4.1.11", + "@vitest/eslint-plugin": "^1.6.27", + "eslint": "^10.0.0", + "prettier": "^3.6.2", + "tsdown": "^0.23.1", + "typescript": "^5.9.3", + "typescript-eslint": "^8.58.2", + "vitest": "^4.1.11" + } + }, + "../shared/chain-utils": { + "name": "@fretchen/chain-utils", + "version": "1.0.0", + "devDependencies": { + "@eslint/js": "^10.0.0", + "@vitest/coverage-v8": "^5.0.0", + "eslint": "^10.0.0", + "prettier": "^3.8.1", + "typescript": "^5.0.0", + "typescript-eslint": "^8.60.0", + "viem": "^2.57.4", + "vitest": "^5.0.3" + }, + "optionalDependencies": { + "@rollup/rollup-linux-x64-gnu": "*", + "@rollup/rollup-linux-x64-musl": "*" + }, + "peerDependencies": { + "viem": ">=2.57.4 <3" + } + }, + "../shared/s3-utils": { + "name": "@fretchen/s3-utils", + "version": "1.0.0", + "devDependencies": { + "@eslint/js": "^10.0.0", + "@types/node": "^26.1.0", + "@vitest/coverage-v8": "^5.0.0", + "eslint": "^10.0.0", + "prettier": "^3.8.1", + "typescript": "^5.0.0", + "typescript-eslint": "^8.60.0", + "vitest": "^5.0.3" + } + }, + "node_modules/@adraffy/ens-normalize": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz", + "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==", + "license": "MIT" + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@bunny.net/edgescript-sdk": { + "version": "0.11.5", + "resolved": "https://registry.npmjs.org/@bunny.net/edgescript-sdk/-/edgescript-sdk-0.11.5.tgz", + "integrity": "sha512-FUR9mx+XYzfI4JHSvH5a/qXFOzDFfcmkFfAHbWsycbTrx1vUAvGd28E63zNBX48sqU87kzqS3Pe4nsram4eSiA==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.12.0", + "hono": "^4.5.5" + } + }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@fretchen/chain-utils": { + "resolved": "../shared/chain-utils", + "link": true + }, + "node_modules/@fretchen/s3-utils": { + "resolved": "../shared/s3-utils", + "link": true + }, + "node_modules/@hono/node-server": { + "version": "1.19.17", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.17.tgz", + "integrity": "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "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.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.154.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.154.0.tgz", + "integrity": "sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@quansync/fs": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@quansync/fs/-/fs-1.1.0.tgz", + "integrity": "sha512-qAPG/t3HqML1TlN7sY/pTbEjzFVAKsMjNNMGheyDosro+kT4iw2KCUoHcVdmliwWjorm4elZbgNQyU2eD97eDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "quansync": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.14.tgz", + "integrity": "sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.14.tgz", + "integrity": "sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.14.tgz", + "integrity": "sha512-lTQOzHuBIooh3jIr5XINQkjPGEuGy+Dsy0meXDr6Sjty0b99kYgGh4uBC/8mJX1T0kC8d6S0aPP1Y+bHkh6aBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.14.tgz", + "integrity": "sha512-hyMKEGatjmBYCs/Q4sbIcL/GqpFEqsX3wc+HYVQ/85A4rxc1o7rPm4DUI4GV4k/h0ITddwFSGpqQunI14GXjTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.14.tgz", + "integrity": "sha512-u371GbXoVXrEgP20PwzoGNOdAOqAMhzo19JzzY+V4YmmnC+DTOoqdbMu+O+54emM59Mik/UdrtWgcuEDKWRPKA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.14.tgz", + "integrity": "sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.14.tgz", + "integrity": "sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.14.tgz", + "integrity": "sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.14.tgz", + "integrity": "sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.14.tgz", + "integrity": "sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.14.tgz", + "integrity": "sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.14.tgz", + "integrity": "sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.14.tgz", + "integrity": "sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.14.tgz", + "integrity": "sha512-oA4o5o0S2yB199YyYXDzKUv1Z56Rc+mgOjwm7Z/vJ9Z8igiDPYvoOuDyo6g7AK/ZaF09nPhDKVnGBcYHO11GXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.14.tgz", + "integrity": "sha512-9mE94bf2GZUaR/fUSXCMpBoWkctu421Mi0ORpm4zqfbkjKep7RxzPFa5etcGs5h+2KApoWBOA/hAJa/7saGgEQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@scure/base": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", + "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz", + "integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.9.0", + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz", + "integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@signinwithethereum/siwe": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@signinwithethereum/siwe/-/siwe-4.2.1.tgz", + "integrity": "sha512-CQEQhCIgN9blil1YpaGR+wvGPdsBVK6zed5DBu03s1IdbSO3kA/n7Zp+SAkZGiHy3HY6OJm1Su0/Bd33vKK6nQ==", + "license": "Apache-2.0", + "dependencies": { + "@signinwithethereum/siwe-parser": "^4.2.1" + }, + "peerDependencies": { + "ethers": "^5.7.0 || ^6.13.0", + "viem": "^2.7.0" + }, + "peerDependenciesMeta": { + "ethers": { + "optional": true + }, + "viem": { + "optional": true + } + } + }, + "node_modules/@signinwithethereum/siwe-parser": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@signinwithethereum/siwe-parser/-/siwe-parser-4.2.1.tgz", + "integrity": "sha512-GAdwNiBaRpUMTiFfPrbQ3e/RBhPbEhRRN1sZmDMLgLEZuKqKwO7V0ksVzCerE9l8HFKTNw5R/q3kPDCWJRz4zA==", + "license": "Apache-2.0", + "dependencies": { + "@noble/hashes": "^1.7.0", + "apg-js": "^4.4.0" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "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/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.6.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz", + "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.2.tgz", + "integrity": "sha512-K72/pvT7qpm8q3k8G5u93NCSN1RcLxX5h7Gw0bQpbMXviBHeY6Nx1WEwmk9wP+eKTSQ8ecxvcgt/ZX/rB938xQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/type-utils": "8.71.2", + "@typescript-eslint/utils": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.71.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", + "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.2.tgz", + "integrity": "sha512-aTtmZjp/x3gsGHkDs04/1I8kNogBJkpQaeGaN0PfiG43NzDEmW2n+OP5im9wfsvyX0yP1UZWBrAGtDBfH9jEVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.2.tgz", + "integrity": "sha512-Pd68GsLVh5ZXlcEccFPdhSOvtHZ/2WU0XsA8zmXOS3F158lVlcTLhfQ6qvtL1RzbMie0Kp8xtbVwAg4nejKPHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.71.2", + "@typescript-eslint/types": "^8.71.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.2.tgz", + "integrity": "sha512-YF4wgg5iUX5Obywoac27B56FhozMNFhdJSbh11H5GBAwmBtfyRhmgTQqW9tRNNmHDvsZ2CX6UGzoz+YfYn2Leg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.2.tgz", + "integrity": "sha512-jz49TAWokm/7yWD9/o837nYax9DyALxIPOImdImvVzo6canBCZYzin8zJyqNR5k1j9K1DE0bVvr0XYB+99uAdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.2.tgz", + "integrity": "sha512-cC8UaalCViJkb3rH/xLIvidEwoFRtMZMUqDjjJMGEKaTFwYRHYBcoczlEGHuvkHEO8svRDrgZ0yQgOArFmwang==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/utils": "8.71.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.2.tgz", + "integrity": "sha512-y5Ukl3oVp7e/VGxqIoEXoeb/Roj71PAJ4WG/N/RGj+eeDvKRLM15kGr9MijLbN0i03S52zxu31Z98E5gWChJKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.2.tgz", + "integrity": "sha512-DgNOEsl4pdYzwSL+N/Cl33tX1Hlw3FvId73LnKXfq7jnrF2viOEn7X+P7RBkkSWCVm92gnWd28GIC5vJuLREeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.71.2", + "@typescript-eslint/tsconfig-utils": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.2.tgz", + "integrity": "sha512-RyKa1G1M9rKHDhrk+HJtiF1MvLD5Ux2JW3TSkzw+02pRALdpJb+QzG1Y5em+LvCgzGwgg8H7mBRFKljNz7ImPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.2.tgz", + "integrity": "sha512-zozlN0UA32bSG2zkKAgQyRYSbEISVoyU6vvkKYo2aSny74D8Jh4e+POkJ6Lk0dlezZi5x7TtD+88Xb06IB8nhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.12.tgz", + "integrity": "sha512-6S7/iVknKglLAxMFxQGNppBI0iqJS2NgM6FBxavitj7KaDq/vywOWGf6dGu7F2L1Qy0G6S3uTp7aGshHrzK8JA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.12", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.12", + "vitest": "4.1.12" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/eslint-plugin": { + "version": "1.6.27", + "resolved": "https://registry.npmjs.org/@vitest/eslint-plugin/-/eslint-plugin-1.6.27.tgz", + "integrity": "sha512-X1RCAfwbatG4GFbJ/1PIHP9MSZMt0JJThqbYID+vS4L783k6QGlLPe421wQE8dHXuGCcenVLsydiM4qzsYWxhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "^8.58.0", + "@typescript-eslint/utils": "^8.58.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@typescript-eslint/eslint-plugin": "*", + "eslint": ">=8.57.0", + "typescript": ">=5.0.0", + "vitest": "*" + }, + "peerDependenciesMeta": { + "@typescript-eslint/eslint-plugin": { + "optional": true + }, + "typescript": { + "optional": true + }, + "vitest": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.12.tgz", + "integrity": "sha512-vFlTt+YEu9WBpNjdGayNmlMVzrWPi1+sv853/7t2FU7hpO5gAFBKHzQmkCq5Xjpr+evegF2994LQT3hF3mRlqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.12", + "@vitest/utils": "4.1.12", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.12.tgz", + "integrity": "sha512-fKShNpKZzupWZuY0z7egydvL0sqW4INEZBEExMBMAa6izlENEVHwoXgdaTCaBZ5fwlr4xf2o1wXC83n9RpO+NA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.12", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.12.tgz", + "integrity": "sha512-XW9O8+Mxx4VpCtkYdMJLZQQwsVryP9wk7cPKDxtXFfwSak6yRh5+2fep/Y0XF/ElBvJHq8bcYdi6aeuai0opZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.12.tgz", + "integrity": "sha512-NKlVKCr5QnCe8jijdMnmkpHHU+WTqiLFR6G0pHji9uRjXK9xJl1k1nTw6F3pOOj2PDcsDt2GKQMI7wIsaT491w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.12", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.12.tgz", + "integrity": "sha512-ko1y1Qgb/slzQ9mr+b89Cuqud8WG1TKtsPD8r7ZUBdvVOGt85Af2HwCqHujrhwJq95hs/9RM7fcAPg57o1vlzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.12", + "@vitest/utils": "4.1.12", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.12.tgz", + "integrity": "sha512-RGq9x4JNGNTZHEJxvN0oSiJjeSCExm0n2gZKKJZHY9cX5AVA6M7VJILBT3ZJ/tJ/0J9uWT7fETV9/btoi//vSg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.12.tgz", + "integrity": "sha512-Q3awXrwOfEV+qc3WmO5ONyE0yO7Q1gQ9/oyHxGLZ/pl7N3NO/ZztqQwKLuwMkV+CA1JYSIeO3gkHnRDIo4AVaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.12", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@x402/core": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/@x402/core/-/core-2.28.0.tgz", + "integrity": "sha512-Da/7zKxCKqpBoB4Msc9tFgkAv7tJTD8nG+DSSm+QdXAyJS4PQsmgVSKe17rDJTtlu0pwNbLcOje6MyQ6kaDkig==", + "license": "Apache-2.0", + "dependencies": { + "zod": "^3.24.2" + } + }, + "node_modules/@x402/core/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@x402/evm": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/@x402/evm/-/evm-2.28.0.tgz", + "integrity": "sha512-BZuoaCKMoPQQuMTa4oibvoyI06WdCpgrzgdicUu8d+5NBMAfWb6LAmSQf1KkeiS8C+25/+2JGB4cHSL0VLGnSA==", + "license": "Apache-2.0", + "dependencies": { + "@x402/core": "~2.28.0", + "viem": "^2.48.11", + "zod": "^3.24.2" + } + }, + "node_modules/@x402/evm/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@x402/extensions": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/@x402/extensions/-/extensions-2.28.0.tgz", + "integrity": "sha512-M3SVxO8DJnx7gpok/ODQp803jr21s1XrCMholNw0hSgOVvSPRksfTZ+CUNfJwteNaHmaAxGjPelXWBhTCMD9oQ==", + "license": "Apache-2.0", + "dependencies": { + "@noble/curves": "^1.9.0", + "@scure/base": "^1.2.6", + "@signinwithethereum/siwe": "^4.1.0", + "@x402/core": "~2.28.0", + "ajv": "^8.17.1", + "jose": "^5.9.6", + "tweetnacl": "^1.0.3", + "viem": "^2.48.11", + "zod": "^3.24.2" + } + }, + "node_modules/@x402/extensions/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@yuku-core/binding-android-arm64": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@yuku-core/binding-android-arm64/-/binding-android-arm64-0.19.0.tgz", + "integrity": "sha512-wdL2XmTiZixAOaHk9Z0JJZ8u9Qm8d333Zjpc6gaxXFX+UR5BlsfxZ7a2+2Rm71pamppsPYGUGFq2oVLbh6DISw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@yuku-core/binding-darwin-arm64": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@yuku-core/binding-darwin-arm64/-/binding-darwin-arm64-0.19.0.tgz", + "integrity": "sha512-7sNJa6XJhyetnTy64jw3NEi4YgUcezHk1G5GH9FSvXAuC8wIlbaOwT9lgq5qo5sspjrZJQlBR1TWflP49uRpMw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@yuku-core/binding-darwin-x64": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@yuku-core/binding-darwin-x64/-/binding-darwin-x64-0.19.0.tgz", + "integrity": "sha512-pTC7dYEc59isdyizc3nsuyFNBu2LU5wMYZ1be6UMr59PY/AJNtO1aBLWV0MgL/z7C1nv/2sX4TNjGgKLoeevJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@yuku-core/binding-freebsd-x64": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@yuku-core/binding-freebsd-x64/-/binding-freebsd-x64-0.19.0.tgz", + "integrity": "sha512-xrZjF6XOEwyLOiDIfrwDdRCS6ghxcJeirNC1KivQhVcUwlRonaU79S3/Uiyx3lBylPnT8qKJ3/aMT+H52nGTJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@yuku-core/binding-linux-arm-gnu": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@yuku-core/binding-linux-arm-gnu/-/binding-linux-arm-gnu-0.19.0.tgz", + "integrity": "sha512-0OyIH53C0I1CbADHn9FSdCfX5qbpeLwJhmOVhonFJNYheybOnkSJ50qGMfTJIb8y5zrwqvLs7FVBvz6Fk8maZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@yuku-core/binding-linux-arm-musl": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@yuku-core/binding-linux-arm-musl/-/binding-linux-arm-musl-0.19.0.tgz", + "integrity": "sha512-z/iPfkCOCSXE34VXNt0uP1ORSW+4mEa9b5CiJcWZeqZH08g49bFU/jRM2T04f+Wpw5mJ6HIzNBiojxeAbrHjeg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@yuku-core/binding-linux-arm64-gnu": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@yuku-core/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.19.0.tgz", + "integrity": "sha512-jLNTi+gzaRwBJ+gwhCJ+rDb5dmx/8hEBx93UWd7hjMSqPTL1D1wvVSvXVAldV5kALvju7b0/m51EHEhs4+I1fA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@yuku-core/binding-linux-arm64-musl": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@yuku-core/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.19.0.tgz", + "integrity": "sha512-YwyPIdW/rN2Fmv0MTFWi47FaB3F9g+2b+sJISyAw83Yd60RWH4T3Nx6XkAHHVN+uKJNBAd6So7/BVihSiFYvuQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@yuku-core/binding-linux-x64-gnu": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@yuku-core/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.19.0.tgz", + "integrity": "sha512-/vskaAXVX3ESR43FiTdfiYS7z1b9uoQJwVQ/0RgUHgN2sWoAyCZs+aX4CgU7eKGIHTVfp9VFwv+c+2XchYiEtQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@yuku-core/binding-linux-x64-musl": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@yuku-core/binding-linux-x64-musl/-/binding-linux-x64-musl-0.19.0.tgz", + "integrity": "sha512-FuXL5igeSKib9Rye02TdH+D6SQezTyGxIATMu1Qsn/QywrzwpGl35OLpS2C1x5vpGLeVE2XgiLd5mVPFHmIglQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@yuku-core/binding-win32-arm64": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@yuku-core/binding-win32-arm64/-/binding-win32-arm64-0.19.0.tgz", + "integrity": "sha512-YuJAe7zEKT1Xa7LuHlhGJpPnYQeVtvo0rZvNjJRqkH0hQGOw4C8QeP30Tg5VKGzZfo2x8CjhjEhKt4TmLwfmdg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@yuku-core/binding-win32-x64": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@yuku-core/binding-win32-x64/-/binding-win32-x64-0.19.0.tgz", + "integrity": "sha512-o8iY/mXwlRkedp0LNTr4+TEfXTPQwoNZmv6k+1WIAd1ZXEn8xJJTBCtB0osaVvdZo/x/cNmV16UrWrrGpM5Mvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@yuku-toolchain/types": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@yuku-toolchain/types/-/types-0.19.0.tgz", + "integrity": "sha512-tFkNbsP4WXX9UMUC5syJGdK9dPMLK0PPaIuXCcCYH75sZ8MrebfBZ2XvCILRTw8AgLivSSCfDa4R67iYBDa4Kg==", + "dev": true, + "license": "MIT" + }, + "node_modules/abitype": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.3.tgz", + "integrity": "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/wevm" + }, + "peerDependencies": { + "typescript": ">=5.0.4", + "zod": "^3.22.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/acorn": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/apg-js": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/apg-js/-/apg-js-4.4.0.tgz", + "integrity": "sha512-fefmXFknJmtgtNEXfPwZKYkMFX4Fyeyz+fNF6JWp87biGOPslJbCBVU158zvKRZfHBKnJDy8CMM40oLFGkXT8Q==", + "license": "BSD-2-Clause" + }, + "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/ast-v8-to-istanbul": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.7.tgz", + "integrity": "sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/cac": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cac/-/cac-7.1.0.tgz", + "integrity": "sha512-LMDU/U2yA4IPIY8KT0NsNPWEYZvAMkbgyspxJ6btJaYV+WMDOHAZCmB0lsTObiHaRS1V/ztKAl7+TSkTqXyeoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "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-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "dev": true, + "license": "MIT" + }, + "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/dts-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/dts-resolver/-/dts-resolver-3.0.0.tgz", + "integrity": "sha512-1T1f+z+4tl9XD+m+0HBgWoL/nm0bOIffyWaUuUSBlFg/86IWvfx+wjNaO/ybU0AJzG9/Mi5hBUgGV6zCmWEN7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + }, + "peerDependencies": { + "oxc-resolver": ">=11.0.0" + }, + "peerDependenciesMeta": { + "oxc-resolver": { + "optional": true + } + } + }, + "node_modules/empathic": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.1.0.tgz", + "integrity": "sha512-AnfC1ATldl49/cvZdLPDjBfrRNwbDO05aibiOtzQu3qtlbJtomNLhF30HEtn/7iBz50dlMECqATo3fG0LrdEgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.13.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.13.0.tgz", + "integrity": "sha512-UohDjD4zSzEwhf58M3O7vc/ycc8O1I7dNlhtD0WxE8Px8EHNJKtUwzBqrzeGhisRuv2ag4sAYswszCJRgKXYUg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.3", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "11.1.5 || >11.1.6 <12", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "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/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, + "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/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.23" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "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/get-tsconfig": { + "version": "5.0.0-beta.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-5.0.0-beta.6.tgz", + "integrity": "sha512-X6fBC0pmImC70gvX2zm56go9hx0MyoGVdG0tUCkg/D+Xnh5TJsOZ7iDbOdI3PvmtrDxnu1YdDufpK2QJX1Meqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "engines": { + "node": ">=20.20.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/hono": { + "version": "4.13.13", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.13.tgz", + "integrity": "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/hookable": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-6.1.2.tgz", + "integrity": "sha512-+abwxtiEA52GCVIsQqut3S/uKTbUwYIp4Pe/vv+6py5XiXBCqMZHg6pA6Y5qhgLSEys0/cYuPbO0z1QFj5ZCmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-without-cache": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/import-without-cache/-/import-without-cache-0.4.1.tgz", + "integrity": "sha512-vXoV9PjKHEednCUu01e98TkImxy67e3BJXbTIOmIq4Hyzw3IAwYRcuPkfeTzJzwyyts4kjuA73x+pWJLc4f86A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/isows": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz", + "integrity": "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/magicast": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.5.tgz", + "integrity": "sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "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/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/ox": { + "version": "0.14.54", + "resolved": "https://registry.npmjs.org/ox/-/ox-0.14.54.tgz", + "integrity": "sha512-52PGH6ldJmWY9+iy/TBSJUsCSy5NmLvkVBYTINmO8srsjRrCmCdO575kGizmZ1jpNQ1gnlpVwiciIpU37fcULA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "^1.11.0", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "1.9.1", + "@noble/hashes": "^1.8.0", + "@scure/bip32": "^1.7.0", + "@scure/bip39": "^1.6.0", + "abitype": "^1.2.3", + "eventemitter3": "5.0.1" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/ox/node_modules/@noble/curves": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", + "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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/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/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.30", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz", + "integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==", + "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.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.10", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.10.tgz", + "integrity": "sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, + "node_modules/quansync": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/quansync/-/quansync-1.0.0.tgz", + "integrity": "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ], + "license": "MIT" + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/rolldown": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.14.tgz", + "integrity": "sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.154.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.14", + "@rolldown/binding-android-arm64": "1.2.14", + "@rolldown/binding-darwin-arm64": "1.2.14", + "@rolldown/binding-darwin-x64": "1.2.14", + "@rolldown/binding-freebsd-x64": "1.2.14", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.14", + "@rolldown/binding-linux-arm64-gnu": "1.2.14", + "@rolldown/binding-linux-arm64-musl": "1.2.14", + "@rolldown/binding-linux-ppc64-gnu": "1.2.14", + "@rolldown/binding-linux-s390x-gnu": "1.2.14", + "@rolldown/binding-linux-x64-gnu": "1.2.14", + "@rolldown/binding-linux-x64-musl": "1.2.14", + "@rolldown/binding-openharmony-arm64": "1.2.14", + "@rolldown/binding-win32-arm64-msvc": "1.2.14", + "@rolldown/binding-win32-x64-msvc": "1.2.14" + } + }, + "node_modules/rolldown-plugin-dts": { + "version": "0.28.8", + "resolved": "https://registry.npmjs.org/rolldown-plugin-dts/-/rolldown-plugin-dts-0.28.8.tgz", + "integrity": "sha512-FVhfqxorCWcc20BVT5eVRfRjzLLl6mgzjcMR4gSBf0K4oQMXT0qoHhhzfXy0L7y83fQy7Ug5ZDNk90xM3/LiCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dts-resolver": "^3.0.0", + "get-tsconfig": "5.0.0-beta.6", + "obug": "^3.0.0", + "yuku-ast": "^0.19.0", + "yuku-codegen": "^0.19.0", + "yuku-parser": "^0.19.0" + }, + "engines": { + "node": "^22.18.0 || ^24.11.0 || >=26.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + }, + "peerDependencies": { + "@typescript/native-preview": "*", + "@volar/typescript": "~2.4.0", + "@vue/language-core": "~3.2.0 || ~3.3.0", + "rolldown": "^1.2.0", + "typescript": "^5.0.0 || ^6.0.0 || ~7.0.0", + "vue-tsc": "~3.2.0 || ~3.3.0" + }, + "peerDependenciesMeta": { + "@typescript/native-preview": { + "optional": true + }, + "@volar/typescript": { + "optional": true + }, + "@vue/language-core": { + "optional": true + }, + "typescript": { + "optional": true + }, + "vue-tsc": { + "optional": true + } + } + }, + "node_modules/rolldown-plugin-dts/node_modules/obug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "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/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "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": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "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": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz", + "integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tsdown": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/tsdown/-/tsdown-0.23.1.tgz", + "integrity": "sha512-MSC2WSOts3in95J334/VIS7KPfor3eCSvndJ6KdoVj9Aak9Kh1aNfz9ws5owhdukuTNSXRdfkPEzA/ihpDD6SA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^7.0.1", + "defu": "^6.1.7", + "empathic": "^2.1.0", + "hookable": "^6.1.2", + "import-without-cache": "^0.4.1", + "obug": "^3.0.0", + "picomatch": "^4.0.7", + "rolldown": "~1.2.13", + "rolldown-plugin-dts": "^0.28.7", + "tinyexec": "^1.3.1", + "tinyglobby": "^0.2.17", + "tree-kill": "^1.2.2", + "unconfig-core": "^7.5.0", + "verkit": "^0.5.0" + }, + "bin": { + "tsdown": "dist/run.mjs" + }, + "engines": { + "node": "^22.18.0 || ^24.11.0 || >=26.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + }, + "peerDependencies": { + "@arethetypeswrong/core": "^0.18.1", + "@tsdown/css": "0.23.1", + "@tsdown/exe": "0.23.1", + "@vitejs/devtools": "*", + "publint": "^0.3.8", + "tsx": "*", + "typescript": "^5.0.0 || ^6.0.0 || ^7.0.0", + "unplugin-unused": ">=0.5.0", + "unrun": "*" + }, + "peerDependenciesMeta": { + "@arethetypeswrong/core": { + "optional": true + }, + "@tsdown/css": { + "optional": true + }, + "@tsdown/exe": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "publint": { + "optional": true + }, + "tsx": { + "optional": true + }, + "typescript": { + "optional": true + }, + "unplugin-unused": { + "optional": true + }, + "unrun": { + "optional": true + } + } + }, + "node_modules/tsdown/node_modules/obug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", + "license": "Unlicense" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.2.tgz", + "integrity": "sha512-Rz4effbMy2g9wnvOJZG6SbVavX313Lap1vNEbk1RUochzickvzP8P4HB+bkNg5e50eVz1hQGjh9K1omI1xB4Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.71.2", + "@typescript-eslint/parser": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/utils": "8.71.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/unconfig-core": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/unconfig-core/-/unconfig-core-7.5.0.tgz", + "integrity": "sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@quansync/fs": "^1.0.0", + "quansync": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/verkit": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/verkit/-/verkit-0.5.0.tgz", + "integrity": "sha512-u4J1hr6Y+C/hit34GwL3eIcUf3wngT8TlYUjK7tk0jxydKLAq3sYvifjdTDA6U3KCKRcQCQDGf/srjxfZF3l7g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/viem": { + "version": "2.57.4", + "resolved": "https://registry.npmjs.org/viem/-/viem-2.57.4.tgz", + "integrity": "sha512-ro+8AKrcGU6tPfou0gBRYv6bxnU3tPxxJGY4qpJwjiR40eHCWzeGhrAgYMlIncFtlnry2/yGjz57WOC67oshCQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@noble/curves": "1.9.1", + "@noble/hashes": "1.8.0", + "@scure/bip32": "1.7.0", + "@scure/bip39": "1.6.0", + "abitype": "1.2.3", + "isows": "1.0.7", + "ox": "0.14.54", + "ws": "8.21.0" + }, + "peerDependencies": { + "typescript": ">=5.0.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/viem/node_modules/@noble/curves": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", + "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/vite": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", + "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.29", + "rolldown": "~1.2.12", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.12.tgz", + "integrity": "sha512-5eWdz2yb3ajxWF3C8C4TjkXl7FGm3n2E/VlazALrQHzK7nYMSGFnCZto0bbPNl77AwkOZVLk0YnBAejUu0NiWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.12", + "@vitest/mocker": "4.1.12", + "@vitest/pretty-format": "4.1.12", + "@vitest/runner": "4.1.12", + "@vitest/snapshot": "4.1.12", + "@vitest/spy": "4.1.12", + "@vitest/utils": "4.1.12", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.12", + "@vitest/browser-preview": "4.1.12", + "@vitest/browser-webdriverio": "^4.1.11", + "@vitest/coverage-istanbul": "4.1.12", + "@vitest/coverage-v8": "4.1.12", + "@vitest/ui": "4.1.12", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "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/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "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/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yuku-ast": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/yuku-ast/-/yuku-ast-0.19.0.tgz", + "integrity": "sha512-PyCsiScaelhxBGzL8dyeJkLqvRZycoh78R31Vrt/WqYrm0hMFoKwIw/X7Dx5mBKLWAMlV9I9HEyBOHnj2gZVyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@yuku-toolchain/types": "^0.19.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/yuku-codegen": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/yuku-codegen/-/yuku-codegen-0.19.0.tgz", + "integrity": "sha512-uymAKHjDuaP4BEpkXC/BSLz4DUEF5oArZ5AuD3888nb2miChjWSgpPdojEJxm3pjpXkgUaCRJQyNAbnMN33wHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@yuku-toolchain/types": "^0.19.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/yuku-core": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/yuku-core/-/yuku-core-0.19.0.tgz", + "integrity": "sha512-kYK9e1XdfirNhkipzW0wmMcSWcVF/CWnuugodfVYRCUwvoBefweL9HzB96OiKEhsx78V5WbUxbJqlizMX869Qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@yuku-toolchain/types": "^0.19.0" + }, + "engines": { + "node": ">=14.18.0" + }, + "optionalDependencies": { + "@yuku-core/binding-android-arm64": "0.19.0", + "@yuku-core/binding-darwin-arm64": "0.19.0", + "@yuku-core/binding-darwin-x64": "0.19.0", + "@yuku-core/binding-freebsd-x64": "0.19.0", + "@yuku-core/binding-linux-arm-gnu": "0.19.0", + "@yuku-core/binding-linux-arm-musl": "0.19.0", + "@yuku-core/binding-linux-arm64-gnu": "0.19.0", + "@yuku-core/binding-linux-arm64-musl": "0.19.0", + "@yuku-core/binding-linux-x64-gnu": "0.19.0", + "@yuku-core/binding-linux-x64-musl": "0.19.0", + "@yuku-core/binding-win32-arm64": "0.19.0", + "@yuku-core/binding-win32-x64": "0.19.0" + } + }, + "node_modules/yuku-parser": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/yuku-parser/-/yuku-parser-0.19.0.tgz", + "integrity": "sha512-XrBh9ZiQK5mJVRxZBAvD6LwRyAYrK3MFtH3+onyuxFL5hFD44EC3QOmIC1FjTj6H2MMwq46/NAI+NZxLZPZgxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@yuku-toolchain/types": "^0.19.0", + "yuku-core": "0.19.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/zod": { + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/bunny_js/package.json b/bunny_js/package.json new file mode 100644 index 000000000..dd9190707 --- /dev/null +++ b/bunny_js/package.json @@ -0,0 +1,48 @@ +{ + "name": "@fretchen/bunny-js", + "version": "0.1.0", + "description": "Bunny Edge Script twins of the scw_js x402 sellers. First port: searchapi (web-agent).", + "type": "module", + "scripts": { + "build": "tsdown", + "test": "NODE_ENV=test vitest run", + "test:watch": "NODE_ENV=test vitest", + "test:coverage": "NODE_ENV=test vitest run --coverage", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "format": "prettier --write .", + "format:check": "prettier --check .", + "check": "tsc --noEmit && npm run lint && npm run format:check && npm run test", + "dev": "deno run -A src/index.ts", + "deploy": "npm run build && bunny scripts deploy dist/index.js" + }, + "keywords": [ + "bunny.net", + "edge-scripting", + "x402" + ], + "license": "UNLICENSED", + "private": true, + "devDependencies": { + "@eslint/js": "^10.0.0", + "@types/node": "^26.1.0", + "@vitest/coverage-v8": "^4.1.11", + "@vitest/eslint-plugin": "^1.6.27", + "eslint": "^10.0.0", + "prettier": "^3.6.2", + "tsdown": "^0.23.1", + "typescript": "^5.9.3", + "typescript-eslint": "^8.58.2", + "vitest": "^4.1.11" + }, + "dependencies": { + "@bunny.net/edgescript-sdk": "^0.11.2", + "@fretchen/chain-utils": "file:../shared/chain-utils", + "@fretchen/s3-utils": "file:../shared/s3-utils", + "@x402/core": "^2.23.0", + "@x402/evm": "^2.23.0", + "@x402/extensions": "~2.28.0", + "viem": "2.57.4", + "zod": "^4.5.4" + } +} diff --git a/bunny_js/scripts/sigv4_probe.ts b/bunny_js/scripts/sigv4_probe.ts new file mode 100644 index 000000000..1a9ef0178 --- /dev/null +++ b/bunny_js/scripts/sigv4_probe.ts @@ -0,0 +1,28 @@ +/** + * TEMPORARY SigV4 spike (plan Task 3) — deleted again once the channel-storage port is + * proven on bunny. Proves @fretchen/s3-utils works under Deno's `node:crypto` compat + * (what bunny's isolate also runs): one put, one read-back, one delete against the real + * Scaleway Object Storage bucket, under a throwaway prefix. + * + * Run: deno run -A --env-file=.env scripts/sigv4_probe.ts + */ +import { deleteS3Object, getS3Object, putS3Object } from "@fretchen/s3-utils"; + +const key = "bunny-spike/sigv4-probe.json"; +const payload = JSON.stringify({ probe: "bunny_js", ts: Date.now() }); + +await putS3Object(key, payload, { contentType: "application/json" }); +const readBack = await getS3Object(key); +if (readBack !== payload) { + console.error("read-back mismatch:", readBack); + process.exit(1); +} + +await deleteS3Object(key); +const afterDelete = await getS3Object(key); +if (afterDelete !== null) { + console.error("delete failed, object still readable"); + process.exit(1); +} + +console.log("SigV4 probe OK: put + read-back + delete against the real bucket"); diff --git a/bunny_js/src/agent_registration.ts b/bunny_js/src/agent_registration.ts new file mode 100644 index 000000000..ade0d0b21 --- /dev/null +++ b/bunny_js/src/agent_registration.ts @@ -0,0 +1,144 @@ +/** + * ERC-8004 agent registration file, built per x402 service from its committed OpenAPI spec. + * + * Served at `/.well-known/agent-registration.json` on each origin. That path is also the + * on-chain `agentURI`, so the file doubles as the EIP's optional endpoint-domain proof. + * + * Shape: EIP-8004, "Agent URI and Agent Registration File", plus three services from the 8004scan + * Agent Metadata Standard (best-practices.8004scan.io, 01-agent-metadata-standard): `OASF` + * (capability taxonomy), `agentWallet` (offchain copy of the payment wallet, which that + * standard says an `x402Support: true` agent SHOULD declare) and `MCP` (the stateless paid + * MCP endpoint, with the per-agent `mcpTools` list). `services` replaced the draft-era + * `endpoints`; `registrations` is `[]` until the agent has an id. + */ + +export type AgentService = "genimg" | "llm" | "search"; + +/** Identity Registry on Base mainnet (erc-8004-contracts README). */ +export const REGISTRY = { + chainId: 8453, + address: "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432", +} as const; + +/** + * Agent ids in the Base mainnet Identity Registry (`REGISTRY`), from + * `eth/scripts/deployments/erc8004-agents-base.json`. Owner: `0x1af51D…fBB20`. `null` = not registered. + */ +export const AGENT_IDS: Record = { + genimg: 97598, + llm: 97599, + search: 97600, +}; + +/** + * OASF v0.8.0 capabilities per service. Every slug is a path that exists under `schema/skills/` + * or `schema/domains/` in agntcy/oasf at tag v0.8.0; 8004scan warns on unknown slugs, so check + * the tree there before adding one. + */ +export const OASF: Record = { + genimg: { + skills: [ + "multi_modal/image_processing/text_to_image", + "images_computer_vision/image_generation", + "images_computer_vision/image_to_image", + ], + domains: ["media_and_entertainment/content_creation", "media_and_entertainment/digital_media"], + }, + llm: { + skills: [ + "natural_language_processing/natural_language_generation/dialogue_generation", + "natural_language_processing/natural_language_generation/text_completion", + "natural_language_processing/information_retrieval_synthesis/question_answering", + ], + domains: [], + }, + search: { + skills: [ + "natural_language_processing/information_retrieval_synthesis/search", + "natural_language_processing/information_retrieval_synthesis/document_passage_retrieval", + ], + domains: [], + }, +}; + +/** The mainnets every 402 here quotes; the payTo is the same address on both. */ +const PAYMENT_CHAINS = [8453, 10] as const; + +/** + * The stateless paid MCP endpoint (`mcp_server.ts`, served by the `mcp` function at + * mcp-agent.fretchen.eu). One endpoint for all tools; each agent lists only the tools that + * are its own service, so the registry entry stays honest about who serves what. Version is + * the MCP protocol revision the dispatcher speaks (see `mcp_server.ts`). + */ +export const MCP_SERVICE = { + endpoint: "https://mcp-agent.fretchen.eu/mcp", + version: "2025-06-18", +} as const; +const MCP_TOOLS: Partial> = { + llm: ["chat_completion"], + search: ["web_search"], +}; + +export const AGENT_REGISTRATION_PATH = ".well-known/agent-registration.json"; + +/** The parts of a committed `openapi.*.json` this file is derived from. */ +export interface OpenApiSource { + info: { title: string; description: string }; + servers: { url: string }[]; + openapi: string; +} + +/** + * `payTo` is the address the service's 402 quotes (`NFT_WALLET_PUBLIC_KEY`). It is listed as an + * `agentWallet` service per payment chain; when it is missing or malformed, the entries are left + * out rather than publishing a wrong wallet. The registry's on-chain `agentWallet` stays the + * authoritative value. + */ +export function buildAgentRegistration(spec: OpenApiSource, service: AgentService, payTo?: string) { + const origin = spec.servers[0].url; + const agentId = AGENT_IDS[service]; + const oasf = OASF[service]; + const wallets = + payTo && /^0x[0-9a-fA-F]{40}$/.test(payTo) + ? PAYMENT_CHAINS.map((chainId) => ({ + name: "agentWallet", + endpoint: `eip155:${chainId}:${payTo}`, + })) + : []; + return { + type: "https://eips.ethereum.org/EIPS/eip-8004#registration-v1", + name: spec.info.title, + description: spec.info.description, + image: `${origin}/favicon.png`, + services: [ + { name: "OpenAPI", endpoint: `${origin}/openapi.json`, version: spec.openapi }, + { name: "web", endpoint: "https://www.fretchen.eu" }, + { + name: "OASF", + endpoint: "https://github.com/agntcy/oasf/", + version: "0.8.0", + skills: oasf.skills, + ...(oasf.domains.length > 0 ? { domains: oasf.domains } : {}), + }, + ...(MCP_TOOLS[service] + ? [ + { + name: "MCP", + endpoint: MCP_SERVICE.endpoint, + version: MCP_SERVICE.version, + capabilities: [] as unknown[], + mcpTools: MCP_TOOLS[service], + }, + ] + : []), + ...wallets, + ], + x402Support: true, + active: true, + registrations: + agentId === null + ? [] + : [{ agentId, agentRegistry: `eip155:${REGISTRY.chainId}:${REGISTRY.address}` }], + supportedTrust: ["reputation"], + }; +} diff --git a/bunny_js/src/bazaar_discovery.ts b/bunny_js/src/bazaar_discovery.ts new file mode 100644 index 000000000..cbe20b80f --- /dev/null +++ b/bunny_js/src/bazaar_discovery.ts @@ -0,0 +1,167 @@ +/** + * Bazaar discovery metadata for the paid routes (docs.x402.org/extensions/bazaar). + * + * Each 402 challenge the sellers emit carries an `extensions.bazaar` block: a machine-readable + * declaration of the route's method, an example request, and the request's JSON Schema, so a + * Bazaar-style discovery layer (the CDP Facilitator's catalog, or any facilitator implementing + * the open `/discovery/resources` endpoint) can list and pre-validate the route without a paid + * probe. This deployment is metadata-only: blocks are advertised, but settlement still runs + * through facilitator.fretchen.eu, so the CDP Bazaar does not index these routes — full indexing + * additionally needs a settled payment through the CDP Facilitator and a re-settlement at least + * every 30 days. See README → "Bazaar discovery metadata". + * + * `declareDiscoveryExtension` (from @x402/extensions, the same org as @x402/core) is the + * canonical serializer — the package pins the wire shape, and the tests in + * `test/bazaar_discovery.test.ts` pin our use of it. Schemas come from the same Zod request + * schemas the OpenAPI specs are generated from (scripts/openapi-codegen.ts), keeping one source + * of truth — including the z.object/z.looseObject additionalProperties distinction the OpenAPI + * generation tests already pin (root AGENTS.md → Serverless). + */ +import { + declareDiscoveryExtension, + type DeclareBodyDiscoveryExtensionConfig, + type DeclareQueryDiscoveryExtensionConfig, + type DiscoveryExtension, +} from "@x402/extensions/bazaar"; +import { z } from "zod"; +import { ImageGenerationRequestSchema, ImageGenerationResponseSchema } from "./genimg_schemas.js"; +import { LLMChatRequestSchema, LLMChatResponseSchema } from "./llm_schemas.js"; +import { SearchQuerySchema, FetchQuerySchema, type Route } from "./search_schemas.js"; + +export interface BazaarDiscoveryInput { + method: "GET" | "POST"; + /** + * Example request. POST bodies land in `info.input.body`, GET arguments in + * `info.input.queryParams` — the package picks the slot from the method. + */ + inputExample: Record; + /** + * JSON Schema of the request, embedded as `schema.properties.input.body` (POST) or + * `.queryParams` (GET). Derive with `z.toJSONSchema` from the same Zod schema as OpenAPI. + */ + inputSchema: Record; + /** Example response, surfaced to agents as `info.output.example`. */ + outputExample: Record; + /** Optional response schema, folded into the schema envelope next to the example. */ + outputSchema?: Record; +} + +/** The 402 `extensions` entry a route declares: exactly one key, `bazaar`. */ +export type BazaarExtension = { bazaar: DiscoveryExtension }; + +export function buildBazaarExtension(input: BazaarDiscoveryInput): BazaarExtension { + const output = { + example: input.outputExample, + ...(input.outputSchema ? { schema: input.outputSchema } : {}), + }; + // The declare-input type omits `method` (the SDK's route stack enriches it from the route + // key); this standalone call has no route stack, so method rides on the config itself — + // typed as the full config interfaces, which the runtime reads and the Omit-union accepts. + const config: DeclareBodyDiscoveryExtensionConfig | DeclareQueryDiscoveryExtensionConfig = + input.method === "POST" + ? { + method: "POST", + bodyType: "json", + input: input.inputExample, + inputSchema: input.inputSchema, + output, + } + : { method: "GET", input: input.inputExample, inputSchema: input.inputSchema, output }; + const declared = declareDiscoveryExtension(config); + // The single key of the returned record is the bazaar extension name; the wrapper's shape — + // one `bazaar` key, the canonical block under it — is pinned by test/bazaar_discovery.test.ts. + return declared as unknown as BazaarExtension; +} + +/** POST /imagegen — the only route paid via the exact scheme the CDP Facilitator could verify. */ +export function buildGenimgBazaarExtension(): BazaarExtension { + return buildBazaarExtension({ + method: "POST", + // network pinned to Base so a probe replaying this example gets a Base-only 402 offer — + // the CDP Facilitator supports a narrower network/asset list than this endpoint accepts. + inputExample: { + prompt: "A watercolor lighthouse at dawn, calm sea, soft light", + network: "eip155:8453", + size: "1024x1024", + }, + inputSchema: z.toJSONSchema(ImageGenerationRequestSchema) as Record, + outputExample: { + created: 1_760_000_000, + data: [ + { + url: "https://imagegen-agent.fretchen.eu/images/.png", + revised_prompt: null, + }, + ], + model: "flux", + x_nft: { status: "minted", token_id: 97598, network: "eip155:8453" }, + }, + outputSchema: z.toJSONSchema(ImageGenerationResponseSchema) as Record, + }); +} + +/** POST /llm — chat completion, paid via the batch-settlement channels. */ +export function buildLlmBazaarExtension(): BazaarExtension { + return buildBazaarExtension({ + method: "POST", + // `model` is required by LLMChatRequestSchema; `network` is deliberately absent — unlisted + // keys are forwarded upstream (FORWARDED_OWN_KEYS in llm_schemas.ts), so an example that + // taught agents to send one would train them to ship junk to the model API. + inputExample: { + model: "mistral-large-latest", + messages: [ + { role: "user", content: "Explain batch-settlement payment channels in one paragraph." }, + ], + }, + inputSchema: z.toJSONSchema(LLMChatRequestSchema) as Record, + outputExample: { + id: "chatcmpl-bazaar-example", + object: "chat.completion", + created: 1_760_000_000, + model: "mistral-large-latest", + choices: [ + { + index: 0, + message: { role: "assistant", content: "Payment channels escrow a deposit…" }, + finish_reason: "stop", + }, + ], + usage: { prompt_tokens: 24, completion_tokens: 60, total_tokens: 84 }, + }, + outputSchema: z.toJSONSchema(LLMChatResponseSchema) as Record, + }); +} + +/** + * GET /search and GET /fetch — one declaration per route, since the block names the method and + * the query parameters, and the two routes sell different things at different prices. + * + * Responses are the provider's shape and deliberately not re-modelled (see + * `SearchQueryResponse` in openapi.search.json), so the examples stand alone without a schema — + * the block advertises the example, the OpenAPI spec documents the response. + */ +export function buildSearchBazaarExtension(route: Route): BazaarExtension { + const inputExample = + route === "search" + ? { q: "ERC-8004 agent registry adoption" } + : { url: "https://www.fretchen.eu/" }; + return buildBazaarExtension({ + method: "GET", + inputExample, + inputSchema: z.toJSONSchema( + route === "search" ? SearchQuerySchema : FetchQuerySchema, + ) as Record, + outputExample: + route === "search" + ? { + results: [ + { + title: "ERC-8004: Trustless Agents", + url: "https://eips.ethereum.org/EIPS/eip-8004", + content: "…", + }, + ], + } + : { title: "Fretchen", text: "…" }, + }); +} diff --git a/bunny_js/src/discovery.ts b/bunny_js/src/discovery.ts new file mode 100644 index 000000000..2e5653e14 --- /dev/null +++ b/bunny_js/src/discovery.ts @@ -0,0 +1,39 @@ +/** + * Discovery helpers shared by the x402 function handlers. + * + * The Scaleway/Envoy gateway intercepts the exact path /favicon.ico with its own 404 + * before our function runs, so we cannot serve favicon.ico directly. x402scan + * (@agentcash/discovery) resolves an icon by first fetching the origin root and parsing + * from the HTML, and only then probing COMMON_FAVICON_PATHS + * (/favicon.ico, /favicon.png, /favicon.svg). We satisfy the root-HTML path here and + * point it at /favicon.png (which, unlike /favicon.ico, does reach the function). + */ + +/** Minimal HTML served at the origin root so scrapers can discover the favicon. */ +export const FAVICON_DISCOVERY_HTML = ` + + + + +Fretchen x402 service + + +`; + +/** + * True when the client is asking for HTML (a browser or discovery scraper), rather than + * an x402 payment client — those POST JSON and never send `Accept: text/html`. Used to + * decide whether a GET on the origin root should return the discovery HTML instead of the + * normal "only POST" rejection. Header lookup is case-insensitive. + */ +export function wantsHtml(headers: Record | undefined): boolean { + if (!headers) { + return false; + } + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === "accept") { + return typeof value === "string" && value.toLowerCase().includes("text/html"); + } + } + return false; +} diff --git a/bunny_js/src/favicon.ts b/bunny_js/src/favicon.ts new file mode 100644 index 000000000..6693e3ff5 --- /dev/null +++ b/bunny_js/src/favicon.ts @@ -0,0 +1,3 @@ +export const faviconBase64 = + "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAQABAADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwDLopTSV9EeSJS0UUxBSUtFABRRRQAUUUUAJRS4ooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACkpaKAEopaKACilpKACiiigAoopcUAJRRRQAUUUUAFJS0UAFFFFACUUtFABRRRQAlFLRQAUUUUAFJS0UAFFFFABRRRQAlLRRQAUUUUAFFFFABRRRQAlFLRQAUUUUAFFFFABRRRQAlFLRQAUUUUAJS0lLQAUUUUAFFFFABRRRQAUUUUAFJS0UAJRS0UAFFFFACUtFFACUUtFACUtFFABRRRQAUUUUAJRS0UAJS0UUAFFFFABRRRQAUUUUAFJS0UAJRS0UAJRS0UAFFFKKQAaSlNFACUUUUwCiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKWgBKKWkoAKKKKACiiigAooooAWkoooAKKKWgBKKKKAFpKWg0AJRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRS0AJRRRQAUUUtACUUGigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKWiigAooopABpKKKYBRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFGaKKAFpKKKACiiigAooooAKKKWgApKWigBKKKKACilooAKQ0tJQAUUUUAFFFFABRRRQAUUUUAFFFLQAlFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFLQAlLRRQAlFFFABRS0lABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUtJQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUooAKSlNFIBKKKKYBRRRQAUUUUAFFFFABRRRQAUUUtABSUtJQAUUUUAFFFFABRRRQAUUUUAFFFFABRRS0AJRRRQAUUtJQAUUUtABRRRQAUUUUAFJS0lABRRS0AJS0UUAJRRRigAopaMUAJRS0UAJRRRQAUUUUAFFFFAAKWiigBKKKWgBKKKKACiiigAoopcUAFJS0lABRRS0AJRS0lABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFAAKWikoAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigApaSloAKKKKQCUUUUwCiiigAooooAKKKKAClpKWgBKKKKACiiigAooooAKKKKACiiloASilpKACilooASilpDQAUUUUAFBopaAEopaKAEopaKAEopaSgBaSlooAKSlpKACiiigApaSloAKKSloAM0UlLQAUlLSUAFLSUtABSUtJQAUUUUAFFFFABRRRQMKKKKAFooooEFJS0lAC0UUUAFJS0lABRRRQAUtJRQAUUUUALSUtIaACiiigApaKKACkpaKAEooooAWikooAKKKKACiiigAooooAKKKKAClpKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKWkooAKKKKACiiigAooooAKKKWgBKWilFACUUUUgEooopgFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUtJRQAtJS0lABRRRQAZooooAKKKKACiiigApaSigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACilooASloooAKSlpKACiiigAooooAKKWigBKKWkoAWikpaACkpaSgAooooAKKKKACiiigAooooAKWkpaACkpaKAEooooGFLSUUCCiiigAooooAKKWigBKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooASloooAKKKKACiiigBaKSloAKKKKQCUUUUwCiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKBhRRRQIKKWkoAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAopaKAEooooAWkoooAWkpaMUAJRS0UAJRRRQAtFJS0AFFFFACUUUUAFFLRQAlFFFABRRRQAUUUUAFFFFABS0lFAC0lLRQAlFFFABRRRQAUUUYoAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKWkNABRRRQAUUUUAFFFFABRRRQAUtJSigBKWiikAlFFFMAooooAKKKKACiiigAooooAKWkpaACiikoAWkpaSgAopcUlABS0lLQAlFFFABRRRQAUUUUAFFGKKACiiigAooooAKKKKACilooASloooASilooASiiigAoopaACiiigAopKKACilpKACiiigYtFJS0hBSGlooASiilpgJRRRQAUUUUAFFFFAC0UUlAC0lLSUAFFFFABRRRQAUUUUAFFFFAC0lFFABRRRQAUUUtACUtJRQAtJS0lABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUALSUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABS0lKKACiiikAlFLSUwCiiigAooooAKKKKACiiigAooooAKKKKACiiigBaKKKAEopaSgAooooAKKKKACiiigBaSiigAooooAKKKKACiiloAKKKKACiiigAooooASiiigAopaKAEoopaAEopaSgBaSiigAooooGFLSUtAgpKKKACjNFFABRRRQAUUUUAFFFFAC0GiigBKKKKACiiigAooooAKKWjFACUUUUAFFFFABRRRQMKKKKBBRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRS0AFFFFIAopKKYBRRRQAUUUUAFFFFABRRRQAUUUUAFFFFAC0lFLQAlFLSUALmkoooAKKKKACiiigAooooAKWkooAWkpaKAEopaSgAooooAWiiigAooooAKKKKAEooooAWiiigBKWkooAWkpaSgAooooAKKWigBKKWkNABRRRQAUUUYoAKKWigBKWkpaACkoooAWkoooAKKKKACiiigAooooAWikooAWkpaSgAopaSgAooooAKKWkoAKKKKACiiigAoopaACkpaSgAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKAClpKUUAFFFFIBKKKKYBRRRQAopKWkoAKKKKACiiigAooooAKKKKAClpKKACiiigAooooAKKKKACiiigAooooAKKKKAFopKXNABSUtJQAUUUUAFFFFABRRRQAUUUUAFFLSUAFFFFABRRRQAUUUUAFLRRQAUUUlAC0lLRQAlLRRQAUUUGgBKKKWgBKKKKACiiigYUUuKSgQUUUUAFFFFABRRRQAUUUUAFFBooAWkpaQ0AFFFFABRRRQAUUUUAFFFFABRRRQAUUtJQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFAwpwpKKBBRRRSASiiimAUUUUAFFFFABRRRQAUUUUAFLSUtABSUUUAFFLRQAlFFFABRRRQAUUUUAFFFFABRRS0AFFFFACUUtJQAtJS0lABRRRQAUUUUALRSUtACUUUUAFFFFABRS0lABRRS0AJRRRQAtFFFAAaSlpKAFooooASilpKACiiloASilpKACilpKBhRRRQIWkoooAKWkooAKKKKAFopKWgAoopDQAUUUUALRSUtACUUtJQAUUUUDCiiigQUtFFABSUtJQAtJRRQAUUUUAFFLSUAFFFFABRRRQAUUUUAFFFFABRRS0AJRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUtJS0AFFFFIBKKWkpgFFFFABRRRQAUUtJQAUUUUAFFFFABRRS0AFFFJQAUUUUAFLSUUAFFFFABRS0UAJRS0hoAWikpaAEooooAUUUUUAFJS0UAJRSmkoAKKKKACiiigYUUUtAgooooAKKKKACiiigAooooAKSlooAKSlooASilpKACiiloASiiigBaSiigAopaKAEooooAKKKKAFpKWkoAKKKKAFpKKKAFoNFFACUtJRQAtJS0UAJRS0mKACilooASiiigBaSiigAooooAKKKKAFpDRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUtJS0AFFFFIBKKKWmAlFFFABRRRQAUUUtACUUUUAFFLSUAFLSUUALSUtJQAUUUUAFFFFABRRRQAUUUUAGaKKKAClpKKACiiigAopaKACiikoAKKKWgAooooAKKKKACiikNAC0UlLQAUUlFAC0lLRQAUUUUAFFFJQAtFFFABSUUUALRSZpc0AJRRRQAUtAooAKKKKACkpaQ0ALSUUUAFFFFABRRRQAUUUtABRRSUAFFFFABRRRQAtFJS0AIaKKKACiiloASiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigApaKKACiiikAUlFFMAooooAKKKKACilooASiiigAooooAWiiigAoNFFACUUUUAFFFFABQaKWgBKKKKACiiigAooooAKWkooAKKKKAFpKKKACiiigBaSlooAKQ0tJQAUUUUALSUUtACUUUUALRRRQAUlLRQAUlFFAC0lLRQAlFFFABRRRQAUUUUAFFFFAC0UUUAJRRRQAUUUUAFFFFABRRRQAUtJS0AFJS0UAJRS0lABRRRQAUtFIaACiiigAooooAKKKKACiiigApaSloASiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKAFpKKKACiiigAooooAKKKKACiiigAooooAKWkooGLRRRQIKKKKQCUUUUwCiiigAooooAKKKKACiiigAoopaACikpaACg0UlABRRSigBKKWigBKKWkoAKKKKACiiigAooooAKKKKACiiigAooooAKKKWgAooooAKKKKAEopaSgAooooAKKKKACiiigBaKKKAEopaSgBRRSUUAFFLSUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFLRQAlFFFABRRRQMWkpaKBBSUtJQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUALSUUUAFFFFABRS0UAJRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUtJS0AKaSlNJSAKDRRTASiiigAooooAKKBRQAUUUUAFLRRQAUlLSUAFFFFABS0lFAC0UlFAC0lLQaAEooooAKKKKACiiigAooooAKKKKACiiloASlopKAClpKKAFopKKAFpKKWgBBS0UUAJRRRQAUUUUALRRRQAUlLSUAFFFFABRRRQAUUlFADiCMZBGelJV/UohFbaaQPv2oY/99tVCkndXGFFFFMQUUUUAFFFFABRRRQAtJRRQAUUUUALSUUtACUuaSigBaSiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKWigBKWkpaAA0UUUgCiiimAlFFLQAlFFFAC0lFFABRRRQAUUUUAFFFFABRRS0AFJS0lABRRRQAtFJS0AJRRS0AJRRRQAtJRRQAUUUUAFFFFABS0lLQAUlLRQAlFFFABRRS0AJS0UUAFFFJQAUUUUAFFFFAC0lFFABRRRQAUUUUAFFFSRxGVtoZAf9tgo/M0AR0YrSh0SefBF1YID/fu0H9a2NO8ET3s6q2q6YoPaOcSMfoBWUqsY7suMGyj4ht2htdEJGA2noR/30x/qKw8V6/4q8Irqml2UdnLFC9kmxTKcKUwOCe3QfrXAx+Eb57gQi5sXfOCsdwHIHcnA4rGjXi47lzptM58AnoKsJYXko3R2s7r6rGSK9K0nwtYaWoYqJ5v+ekg6fQdq2woHSpli0nohqi+p4rJDLC22WN0b0YYNMr2m4tLe7jMdxDHKh7Oua4jxF4PFtE95pwJjXl4SclR6j1FXTxMZOz0JlSaOMooxzRiuoyCiiigAooooAKKKKACiiigAopaSgAooooAKKKXFACUUtJQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFLSUUAFFFFABRRRQAUUUUAFFFFABSikpRQAUUUUgEooopgFFFFABRRRQAUUUUAFFFFABRS0lABRRRQMKKKKBBRRRQAUUUUAFFFFABS0UlAC0lFFABRRRQAUUUUAFFFFABRRRQAuaKSigBaKSloAKKSigBTSUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFAC5p8cjRurqcMDkGo6UUmrgdp4+vLnULXQljJImt/NMY/iY7ccV0fhzRl0bTEjbBuHG6Vh6+g9hVPTrBLttGu5RuFtYKEB6biTz+Qrou1eVOVlyI7Ixu+Zi1n3Wu6XZSmK4voUkHVc5I/KszxXrh0yyEED4upxgEdUXuf6CvNiSSSTkk5ya1o4fnV5Ezq8uiPZ7W7t72ES20ySof4kOamIyOa8g0fVptHv0uIiSucSR54dfSvWre5iu7aO4hYNHIoZT6is61J02OE+Y868XaH/Z16LqBMWs3p/C3p/X865uvZL+yh1CyltZxmOQY9wexFeS6lp82mX0ltMpBU8H+8OxFdeHq8ys9zGpCzuinRRRXUZBRRRQAUUUUAFFFFABQaKWgBKKKKAClpKKACiiigAooooAKKKKACiiigAooooAKKKKAClpKWgApKM0UAFFFLigBKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooGFFFFAgooooAKKKKACiiigApaSlFABRRRSASilpKYBRRRQAUUUUAFFFFABQKKKAFpKWkoAKKKKACiiigAoopaAEooooAKKKXFACUUUUAFFFFAC0lFFABRRRQAUUUUAFFFFABRS0UAJRRRQAUUtJQAUUUtACUUtFACUUtJQAUUUUALSUUUAFFFFABRRRQAUUUUAFbHh/RJNZvgnK28eDK49PQe5qhY2U2oXkdtAu6Rzge3ufavWNL02HS7GO2hHC8s2OWbua5sRV5FZbmtOHMy5HGkUSRooVEUKqjsB2qtqN/Dp1lLczNhEH4k9gPerEkixxs7kKqjJJOABXmHibXW1i92RMRaRHEY/vH+8a46NN1JG9SfKjO1LUJtTvpLqY/M54Xso7CqmaSivUSsrI5G7hXXeDteFpL/AGfctiGRv3bE8Kx7fQ1yNOzipqQU42Y4ycXc9s61h+JtDGr2O6IAXMWWjP8Ae9R+NVfCOvf2ja/ZLh83MI4J6uvr9RXTV5b5qUzr0nE8TZGRyrqQwOCD2NJXceMtABVtTtU5H+uVR/49/j+frXEV6dOopxujklFxdhtFBorQkKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiloAKSlpKACiiigAoopaAEpaKKAEooooAWikooAKKKKACiiloASiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKUUlLQAUUUUgEooopgFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRS0AJRRS0AJRS0lABRRRQMKKKKBBRRRQAtJS0lAC0UlLQAlFFFAC0lFFAC0UUUAFJS0UAFJS0lAC0UlFAC0lFFABRRRQAtJRRQAtJRRQAUUUUAFPRS7BVGSeABTa7TwZoG8rqlyvyj/UqR1P8Ae/wrOpNQV2VGLk7G14X0EaTaebMo+1Sj5v8AYH93/Gt8inDiue8Va+NJsjDAw+2TDC/7A/vf4f8A1q8z3qszr0hExPGWv72bS7V/lB/fuD1P93/GuMpCSxJJyaWvTpwUI2RySlzO4lFFFaEhRRRQBNaXc1ldR3MDFZIzkGvWNG1aHWNPW5i4b7siZ5RvSvIK1ND1iXRr8Tplo24ljz95f8R2rnr0udXW5rTnys9aYBgQQCCMEGvNPFGgnSbrzYFP2SU/L/sH+7/h/wDWr0i2uIru3jnhcPHIMqRTL+yh1CyltZ1yjjH0PYiuKlUdORvOPMjxg0Vd1TTZtKv3tZhyOVbsw7EVTr1E01dHI1YSiiimIKKKKACiiigAooooAKKKKAFpKKKACiiigAooooAWkoooAKKKKACiiigBaSiloASiiigAooooAKKKKACiiigAooooAKKKKACiiigAopaSgAooooAKKKKACiiigAooooAKKKKACiiigAooooAKWkpaACkpaKQCUUUUwCiiigAooooAKKKKACilpKACiiigAooooAKKWigBKWkpaACg0UlABRRS0AJRS0lABRRRQAUUtFABSUtFACUUUtABSUUUDCiiigQUUUUALQaSigApaSjNABRRRQAUUUUAFFFFABRRRQAUUUUAFKBSYq3p9jNqV7HawDLuevYDuTSbsrsaVzT8NaEdYvN0gItYiDIf7x7KK9PjRY41RFCqowAOgFVtO0+HTLKO1gGEQcnux7k1YmlSCF5ZGCogLMx6AV5dWo6kjrhHlRT1bVIdJsXuZiMjhEzyzdhXlF9ezahdyXM7bpHOT7ew9qu+INak1rUDJysCfLEnoPU+5rJrtoUeRXe5z1J8zCiiiugzCiiigAooooAKKKKAOq8Ia/8AYbgWNy3+jyn5WJ+43+B/z3r0QHNeIivQ/CGv/bIBY3T5njHyMTy6/wCI/wA964cTRt7yOilPozT8Q6JHrNkVAC3CZMT+/ofY15ZLG8MjRyIUdSQynqD6V7XXI+MdBFxC2pWy/vYx+9UfxL6/UfyqcPWs+Vjqwvqjz+kzSmkr0DmFooooAKKKKACiiigAooooAKKKKACiiigAoopaAEopaSgAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigApaKKAEooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKAFoopRQAlJS0lIYUUUUxBRRRQAUUtFACUUUUAFFFFABRRiloAKSiigBaKSloAKSlooASiiigAooooAWiiigBKKKKAFoopKAFoNFFACUtFFACUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUoFADkVnYKoJYnAA716d4a0JdIs98oBupRmQ/3f9kVj+DvD+Nup3Sf9cFI/8e/wrtOlefia13yo6KUOrFzXn/jHxAbqZtOtX/cRn96wP329PoP51t+Lde/s6zNrbvi6mGMjqi9z9fSvN8VWGo/bYVZ/ZQlFLRXcc4lFLilEbnorH8KLgNop/kyf883/AO+TS+RL/wA83/75NK4WI6KkMMg/5Zv/AN8mkMbjqjflRcLDKKUjFJTAKkhmkt5klicpIhDKwPINRUtFrgereHtaTWbDecLcR8SoPX1Hsa1yAwINeQaTqc+k3yXMB5HDKejDuDXrFjeRX9nFdQHMcgyPUex968yvS5HdbHXTlzKx574r0A6ddfarePFpKeg/gb0+lc3ivaLu1hvLWS3nQPFIMMK8p1rSZdH1B7d8sh5jfH3lrpw9bmXK9zGpDld0ZtFFLiuoyEooooAKKKKACiiigAooooAKKKKACiiigAopaSgAopaDQAlFFFAC0lFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFLSGgAooooAKKKKACiiigAooooAKKKKACiiigApaSloAKKKKQCUUGimAUUUUALRSUtACUUUUAFFFFAC0UlLQAlFFFAC0lFFABRRRQAUUUtACUUUUAFFFFABRRRQAUUtJQAUUUUAFFFFABRRS0AJRS0UAJRRilxQAlFFGKAClpMUuKACkpwVmOAM1Zi0y+n/ANVZzv8A7sZNJtIdmVKXFbEPhXWpuli6D1chf51oQ+BtUfmR7eP6uT/IVDqwXUahLscvijFdtD4AY/66/UeyR5/mavxeBNOTHmTXEn4hf6Vm8TTXUr2UjzrFLivUovCejRf8uYc+rux/rV2HRtNt/wDVWNup9fLBP61DxceiKVFnkUcMkpxGjOfRRmui8O+Gbi9vlku4JIrWP5m3qRv9hXo6oqDCqqj0UYpwrKeLbVki40UtwVQqhVAAAwAOgqnqF4tnbM/BkIIRT3P+FWpZVijZ3OFUZJrk728e7nMjZC9FX0FYQg5O5pJ2Vjn59Jub26e4uroNJIcsQuafHoFuv35JG/ECtTNIXVRlmAHvXYpPZGPKimukWSf8sc/ViamSytk+7BGP+AimyahaRnDTpn0Bz/Kq0mt2qfd3v9Fx/On77D3UaAjReiKPoKXGKx28QJ/DbsfqwFRN4gk/ht1H1YmjkkHNE3aK57+37jP+qi/Wj+37n/nnF+R/xp+zkLnR0X40Vzv9vXP/ADzi/WnL4gmH3oIz9CaPZyDnRvFVPUA/hTGtoH+9DGfqorIXxD/etvyb/wCtUya/bn70Ui/TBpckw5olxtNs3626fgMfyqu+i2bdEZfo3+NPTWLJ/wDlqV/3lNWUu7eT7k8bfRhR7yH7rMt/D8Z/1c7L/vDNbXhr7To1w8UsyyWkpyRyCh9f8aaGB6U8Gpm3JWY0kndHajBFZPiDRU1nT2iGFnT5onPY+n0NM0e/zi2kP+4T/KtquLWEro2+JHj02kajbE+bZXCgcZMZx+dVSpU4IIPvXteBTHghl/1kUb/7yg11LGd0YuieKYpcV65NoGkz/f0+3+qpt/lVGXwdo0mcQPH/ALkh/rmtFi4dSXRZ5hRXoE3gKybPk3Uyf7wDf4VQl8AXI/1N9E3s6Ef41osRTfUn2UjjqK6GbwTrMediQyj/AGZMfzxVKXw3rMWd2nzH/dAb+VWqsH1JcGjKpasS2F5B/rrSeP8A3oyKgIq00xWG5opcUYpiEooooAKKKKAFpKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAWkoooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigBaKKBQAUUUUgCkpaSmAUUUUAFFFFABRRRQAUUUUAFFFLQAlFFFABRRRQAUUUUALRRQaAEoopaAEooooAKKKMUAFFLSUAFFFFABRRRQAUtJS0AFFFFACir2n6Te6pLstIGfH3m6Kv1NWvDuiSaze4OVt4sGRx/Ie5r1C2tobSBYYI1jjUYCqK5q1dQ0W5rCnzanEwfD+4YA3F7Gh9EQt+pxV6LwDZqR5t5M/sqhf8a66jNcbxNR9TdUonPReC9GjOWilk/wB+Q/0xV6Lw7o8P3NPhz/tDd/OtPNNZ0QZdlUepOKh1Kj6lcsURx2lvCMRQRIP9lAKlwKpTa1pcH+tv7ZcdvMBqhN4v0WIcXRkPpGhP9KFCpLoHNFG7ikNcnN4+slz5VrO/uxC/41Qm+IE5/wBTYxr7u5P8sVaw9R9CfaxR3VLmvNZfG2ryE7XhjHosef55qlN4k1efIa/lAPZML/LFaLCT6k+2ieqlgOScD3qvJqFnDnzLuBMf3pAK8ilu7iY/vZ5H/wB5yan06yN5Pkj5F5Y/0qvqiW7J9s3sj1D+2rAruWcOp6FQTmon162X7qu36VzYAVQAMAVm6te/ZofLQ/vXHHsPWkqEWynUaRb13xc88v2e3iURofmJOcmsGTWr1+kir/uqKoIjyuFRSzE4AAySa63SPh9ql8Flu8WcJ5w4y5H+72/Gul+zpLUyXPN6HMteXUn3p5D/AMCx/KkjguLpsRxyTMeyqWNevad4H0aw2s0BuJB/FOd36dK6CG3igUJFEiKOyqAK5p42K+FGscNJ/EzxKLwvrswyml3OP9pNv86n/wCEO8QYz/Zc35r/AI17ZtHpRgelZfXpdjT6tHueFS+GtagyZNLugB3EZb+VZ8tvLC22WN0b0ZSDX0IVHpUctvDOm2aGORfR1BH61Ucc+qJeFXRnzyRzRivZdQ8D6HfZItfs7n+KA7f06fpXHaz8Pb6xjeaxkF3EvO0DDgfTv+H5V008XTlpsYyoSicUaKDkHB60V1GIlLRRQAUZoooAek8sf3JGX6MRVhNUvE6Tsfrg1TopWQ7s1YtfvI2BOxseox/Kuz0nxYt5bK0sGJF4cKe/rXm9WbK7ezuBIvI6MvqKyqUYyWxcajR6qmt2jfeLp9V/wp41vTd2w3sKt6O20/rXKwypPEskZyrDINVdTsheQZUfvV5U+vtXJ7GN7G/O7HfRXEE4zFNHIP8AZYGpa8TbdGxxkMPwqxFqmoQf6q+uU+krY/nVPB9mR7buj2SlrymHxZrUP/L4XH+2in+laEPj3Uo8eZDbyj/dIP6GoeEmtilWiejUmBXEx/EEcebp/wD3xJ/iKvw+O9Lkx5kdxGfdQf5Gs3QqLoUqkWdPioJbG1nH722hk/3kBrNh8V6LMcC9VD6OpX+laEOp2Fx/qbyB/wDdkFRyziVeLKc3hrR5s77GMf7mV/kaozeCNIk+4s0Z/wBmTP8APNdGCGGRyPalqlVmuouSLOOk+H9sc+Xeyr/vID/hWRqHgrULSMyW7LcqOSFGG/LvXpFFXHEzT3JdKLPEGUqxVgQQcEHtSYr0vxN4Zi1OF7q2QLeKM8ceZ7H39682IKsVYEEHBB7V30qqqK5zzg4sbRRRWpAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFKKKAEooooAKKKKACiiigAooooAKKKKACiiigBaBSUtACUUUtACUUUUAFFFFABRRRQAUUUtABRRRQAUUUUAFJS0UAFJS0UAJRS0lAC0lApaAEopaSgAoopaACiiigAooooAKSlooASilpKACiiloAKWkpc0mB6v4asFsNCtkxh5FEr/AFbn+WB+Fa/So7fabeLb93YMfTFVtYMq6JfGHPmiByuOucHpXjyvKep3LSJy2t+N2huXttNRGCHa0z8gn2H9awZfF2tyD/j82/7kaj+lYQpa9ONGCWxyOpJl+XXdVn/1moXLD08wgfpVKSaSU5kdnP8AtMTTKK0UUtkTdiiiiiqEFFFFACilptKKAHxRPNIscYyzHAFdZZ2q2lusa8nqT6mqWkWHkR+fIP3jjgegrSkkWONnY4VRkmuepK+iNYRtqyG9uktLdpXPToPU+lY+l6TqHiTUCkCEknLyH7qD3NSadYXfizW1ihUrCvVj0RfU+9ex6XpdtpNilpaptRRyT1Y+p9656tZUlZbmkKbqO/QztA8J6foaK6J511jmZxz+A7Vv4opa86c3J3Z2KKirIMUUUVJQUUUUAFJVDUda07Sk3Xt3FD6KTlj9AOa43VPibCm5NMtDIe0kxwP++Rz+orWFGc/hREqkY7s789ay9W13TdJt3e7uo1YDiMEF2PsK8j1HxVrOpkia9dUP8ER2L+nX8axySSSeprshgXvJnPLEroiS7n+1Xs9xtC+bIz7R2yc1DRRXopWVjjeoUUUUwCiiigAooooASiiloA1NHvzbzCGQ/unPBP8ACa6XqK4aui0fUDNH5Mh+dBwfUVhUh1RpCXQr63YlT9pjHB++B/OsWu1cK6FWAKkYIPeuV1CzNpcFRny25U+1VTlfRhONinRRS1qZiUUYpaAEozS0lAE0d1cQnMc8iH/ZcirsXiHV4cbNQnx/tPu/nWZRUuEXuhptHQQ+NNZiILTJKB2eMc/liu60HXoNctS6L5c8eBJETnHuPUV5NXSeBfN/4SD5M7PJbf8ATjH64rnr0YcraNadR3sel15r4109bPWfOjGEuF38f3uh/wAfxr0uuI+IZG3T/wC9+8/9lrlwzaqI1qq8ThqKKK9Q5AooooAWkpaDQAlFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAtFFFABSUtJQAUUUUAFFFFABRRRQAUUUUAFFFFABS0lLQAlFLRSASiiimAUUUUAFFFFABRRRQAtFJRmgBaKKDQAlFFFABRRRQAtFJS0AJRmiigAooooAKKKKAFzSUUtACUZpaKACiiigBKKKKAClpKWgAooooA9T8Kaj/aGhxbj+8hHlP+HQ/littgCpB5BFeZeENT/s/V1jc4huMRt7Hsfz4/GvTu1eVXhyTOunLmieRa/ph0vWJoAuIyd8f+6en+H4Vm16P4z0v7Xpv2uNcy2/J907/l1/OvOa76M+eJzzjysbRQaK2ICiiigAoopaADFaekWH2iYTSD90h/M1Rt4HuZ0iTqx/KuughS3gSJB8qjFZ1JW0LhG7HnpWNqE0t7eRadagu7sF2r1LHoKtapfC0tyFP71+FHp71u/DnQcs+tXC5PKQZ/8AHm/p+dc05KEXJmqXNLlR2Xh/Q4dC0uO2jCmQjMrgfeb/AA9K1qSlrypScndnckkrIKKKKQxKM1DdXcFnbvPcypFEgyzOcAV5v4i+IU0++20jMUfQ3DD5m+g7fz+la0qMqjtFETqRgtTtdZ8TaZoi/wClTgy4yIU5c/h2/GvP9Z+IWpX+6OyH2OE8ZU5c/j2/D86495HkdndyzsclmOST7mm5r06WEhDV6s4p15S2HSyyTSGSR2d25LMck0yiiuu1jAKKKKACiiigAooooAKKKKACiiigAooooAKfFI0MiyIcMpyDTKKQHW2V0t3Arjg9GHoaL60W7tyh+8OVPoa52wvDaThuSh4YV1SMHUMpBBGQa55LlZsnzI4x0aNyrDDA4INNrf1qyDRm5QcqPnHqPWsA1vGV0ZtWYUUlLVEhRRRQAUUUooAMV6N4J0v7HpZvHX97c8jPZB0/Pr+VcRo+nnU9TgtRnazfMR2Ucn9K9cjRY41RFCqoAUDsBXFip2XKb0Y3dx5NeY+MdSXUNaaOM5ith5QPq38R/Pj8K7nxDqf9laPNOpHmt8kX+8e/4da8mJJOTyfepwkNeZjrS6CUUUV3nOFFFFABRRRQAUUUUAFFFFABRS0lABRRRQAUUUUAFFFFABRRRQAUUUUALSUUUAFFLSUAFFFLQAlFFFABRRRQAUUUUAFFFFABS0lLQAUUUUgEooNFMAooooAWkoooAKKKKACiiigAooooAKKKKACiiloASilooASiiigYUUtFAhKKWigBKKWkoAWikooAKWiigBKKWkoAKKKKACiiigBwYjkcH2r1nw9qf9q6PDcMcygbJP8AeH+PX8a8lrp/BOqfZNTazkbEVz93PZx0/Pp+Vc2JhzRuuhrSlZnosiLIjI4BVgQQe4ryHV9PbTdUntTnajfKfVTyD+Vewdq4/wAc6b5trHfovzR/I/8Aunofz/nXLhp8srPqa1Y3VzgKKKK9M5QooooAKWirVhbfartUx8o5b6Um7AjZ0WzEUHnsPnkHHsK0JpFijZ3OFUZNPACqABgCsPW7ss4tkPA5b6+lc6vORvpFFXZLrGpRQoP3kzhEHpk17lY2cdhZQ2sQwkSBF+gFeW/Dyx+0+IjOwyttGXH+8eB/M161XFjJ3ly9jfDR05mOoooriOkKydc1+y0Kzaa5kBkI/dxKfmc+3t71B4k8SWvh+zLyEPcOP3UIPLe59BXjWo6ldapeyXd3IXlc/gB2A9AK68PhnU1lsc9Wty6Lcua54hvdeuvNuX2xj7kKk7U/+v71kk0lLXrRioqyOFtt3YlFFFUIKKKKACiiigAooooAKKKKAClNJRQAUUUUAFFFFABRRRQAVt6Le8/ZnP8Auf4ViU9HaNw6nBByCKmSurDTsztGAZSpAIIwRXJ6hafZLpkA+Q8r9K6Syuhd2ySDg9GHoah1W1FxaMQPnT5h/hWEHyysayV1c5Y0gpaK6TEKKKKAClpKsWdrJe3kVtEMvIwUUm7K4HbeBtN8u3k1CRfml+SP/dHU/n/Kuw7VDaWyWdpFbxDCRqFH4VBq+oLpmmT3bY+RflHqx4A/OvJm3UmdsVyxOF8a6mLvVRaRtmO2GD/vnr/QVzFK7tJI0jnc7EsxPcmkr1IRUYpHJJ3dwoooqyQooooAKKKKACiiigAooooAKKKKACigUtACUUUUAFLSUtABSUtJQAUUUUAFFFFAC0UlFABRRRQAUUUUAFFFFABRRRQAUUUUAFLRRQAUUUUgEooopgFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFLSUUALRRRQAUlLSYoAWkpaSgBaKKKAEooooAKKKKAFooooATNFFFABRRRQAUUUUALTo3aKRZEOGUggjsRTKWgD2HSb9NT0uC7XGXX5h6MOCPzqa8tkvLSW3k5SRSp/GuJ8C6n5dzLp8jfLKN8ef7w6j8R/Ku8ryaseSZ2RfNE8Yu7aSzu5beUYeNipqGuy8daXsmi1KNflfEcmPXsfy4/CuNr0qc+eKZyyVnYSiiitCRa6PRLXyrXzWHzSHP4dqwbaE3FxHEP4jiuwQKiBVGABgCsqktLGkF1I7mdbe3eVuij865J3aR2djlmOSa1teucslup4HzN/SsXNFONlcU3d2PUvhnahNKu7oj5pJdgPso/+vXc1y3w9QL4ThPdpHP64/pXU149d3qM9CkrQQZrI8Q+ILbQdPaeU7pWBEUQPLn/AA9TVzUtRt9LsZbu6cJHGMn1J7AepNeJa5rdxrupSXc/yqfljjzwi9hV4ah7SV3sTWqcqstytqOoXGqX0t3dOXlkOT6D0A9hVSiivZSSVkee3cKKKKYgooooAWikpc0AFJS5pKACiiigAooooAKKKKACiiigAooooAKKKKACiiigDT0a68m58pj8knH0PaujPIrilJByDyK62yuBc2iSdyOfr3rCpHW5rB9DndStvs14wAwjfMtU66PWrYS2nmAfNGc/h3rnK0hK6IkrMKWkpRVkigV2fgXTd002oyLwn7uP69z/AE/GuPijaWRI0Us7EKoHcmvXtMsU07T4bVMfu1wT6nufzrlxM7Rsa0o3dy52rgfHep+bcxadG3yxfPJ/vHoPy/nXb3t3HY2c1zKcJEpY/wCFeO3dw93dS3Ehy8jFj+NYYWF5cz6GtaVlYhooor0TlCiiigAooooAKKKKACiiigAooooAKKKKACiiigAoopaAEooooAKKKKACiilNACUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFLSUooAKKKKQCUUUUwCiiigAooooAKKKKAFopKWgApKWkoAKKWigBKKKKAFpKWigAooooAKSlFFABRSUtABSUtJQAUUUUALRSUtACUUUUAFFFFABRRRQAUUUooAmtbmS0uoriI4eNgy/UV7BZXUd9ZQ3UX3JUDD29q8aruvAup74JdOkPzR/vI8+h6j8/51yYqF48yNqMrOx1GpWKajp09pJjEi4B9D2P515BNC8ErxSKVdGKsD2Ir2mvPPG+m/ZtSW8RcR3A+b2cdfzH9aywtSz5WXWj1OUpRRSgV6BzGxoNvukknI+78q/XvW27BEZieAMmq+nW/wBnsIkIwxG4/U1DrM3lWDqD80hCj+tcz96RstInO3MxuLh5W6sajpKBXRsYnsXw+cP4ThA/hkcH/vquoJABJOAOprzb4e+IbSxhn069nWEM/mRO5wvTBGe3SrXjfxjAbN9M0yZZGlGJpozkKvdQfU/y/TyKlGUqzSR3wqJU0znPGfiQ65qXkwOfsUBIjx0c92/w9vrXMUlFepTgoR5UcUpOTuwooorQkKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAWkNFFABRRRQAtbOgz/NJAe43D+v9Kxans5zbXUcvYHn6VM1dDi7M62RQ6FSMgjBrj54jBM8Z/hYiuyHIB7Guf12DZcJMBw4wfqP8isabs7Gs1pcyaUUlOUFiABkmtzE6nwTpf2nUWvZBmO3+77uen5dfyr0TGKzNA03+zNGggYYkI3yf7x6/wCFaE8yW8Ek0hwkalmPsK8qtNzmdkFyxON8eaptSLTYzycSS49Ow/r+VcLVrUr2TUdRnu5PvSNnHoOw/KqtejShyRSOacrsKKKK1ICiiigAooooAKKKWgBKKKKACiiigAooooAKKKKACiiigAooooAKKWkoAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAoopRQMKKKKQhKKKKYBRRRQAUUUUAFFFGKACilooAKKSloAKKKSgAooooAWiiigBKKKKAClpKWgApKKKACiiigAooooAKKKKACiiloGJRS0UCEooooAKWkooAXNXNMvn07UYLpM5jbJHqO4/KqVKKTV1Yadnc9rhlSeFJY23I6hlPqDWdr+mDVNImgAzIBvjP+0On+H41k+CNT+06c1lIf3lufl91P+B/pXV15Mk6czsTUoniDAqxBGCOxq1p0H2m9jQj5c5b6Ctbxhpn2DWGlRcRXGXGOx7j+v403QLfbHJcEfe+Vf616XOnG6OTl96xs9q53XJvMuViHRB+proZGCIWJwAMmuOnlM07yH+Jiaikru5c3pYhopTSV0GQZooooAKKKKACiiigAooooAKKKWgBKKKKACiiigAooooAKKKKACiiigAooooAKKKKACkoooAWiiigAooooA6vS5/PsIyeoG0/hSarb+fYvgfMnzD8P8ms7QZ8SyQHow3D6/5/lW+QCMGuWXuyN1qjh66Hwhpv2/WFkdcw248xvc9h+f8AKsW9tzbXkkXQA8fSvS/Cel/2do6F1xNP+8f29B+X86qvU5YaEU43kb3auU8b6n9n05bJD+8uDlvZR/if611LsFUknAA5NeSa9qR1XVprgHMYOyMf7I6fn1/GuTDQ5p37G9WVlYzTSUUV6ZyBRRRQAUUUUAFFFFABS0lFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFAwooooEFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUCgBaKKKQBSUtJTAKKKKACiiigApaKSgBaKSloASilpKACiiigAooooAWiiigBKKKKACiiigAopaTFABRRRQAUtJRQAUUUtACUtFFABRRRQAlFFFABRRRQAUUUtAGnoWonS9VhuMny87ZB6qev8Aj+FesKwZQwIIIyCK8VFek+D9T+3aSIHbMtt8h917H+n4VxYuGnMjooy6FvxJpY1TS2QD97Gd6f1H5VgRQLbxLEowFGBXbEZFctqUH2e6ZcfKeVrGlN25S5LW5h6zceTYsoPzSfKP61zFXtWuvtN6wU/u4/lX+pqhXoU1ZHNJ3YUUUVZIUUUUAFFFFABRRRQAUtJRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFACUUUUALRRRQAUUUUATWk5trmOUfwnJ+neuxQh1BByCMg1xFdLoV151v5DH54+nutY1Vpc0g+hqQaNFqWp27yLlYzucf3gOg/PFdqOBxWbpFt5Vv5pHzP0+laROBXnVZuTsdMYpanPeMdT+xaM0KNiW4+QY7L3P8AT8a8zrZ8Tan/AGnrErI2YYv3cf0HU/iaxq9ChDkgc1SV2JRRRW5mFFFFABRRRQAUUUUAFFFLQAlFFFABRRRQAUUUUAFFFFABRRRQMKKWkoEFFFFABRRRQAUUUUAFFFFABS0lLQAlFFFABRRRQAUUUUAFFFFABRRS0AFFFFIBKKKKYBRRRQAUUUUAFFFFABS0lFAC0lFFABRRS0AJRS0UAFJS0UAJRRRQAUUUUAFLmkooAKKKKACiiigApaSloAKSlooAKKKKAEooooAKKKKAFpKWkoAUVv8Ag+aePxBCkILLICsg/wBnufwrAr0HwPpnkWL37r+8n+VPZB/if5VhXklB3LppuR1lc/4sXbpDSx/65eFx6d66A1zesT+fcMmcog2j+tefSXvXOqex5tSVb1C2+y3jxgfL1X6VVr1k7o4mJRRRTAKKKKACiiigBaSlpKACiiigAooooAKKKKACiilxQAlFFFABRRRQAUUUUAJRS0UAFdL4X8IT+I1lmacW9tG23ft3Fm9AMj865qux8HeMINAt5bS7hkeB38xWjwSpxg8HtxWNdzUPc3NKai5e8ZnijwxN4cuYgZRNBMD5cm3ByOoIrBrpPGHigeI7qFYImitYM7A/3mJ6k+n0rm6dHn5Fz7inZS90KKKK1ICiiigAq9o5kGqwbMkbvm/3e/6VRrovD9qI42uWHzPwv0/z/KoqP3WVFanpSABAFGFAwPpWd4hlng0G7ktx+8CfkOhP4DJqbS7kT2oB+8nyn+lXZEWSNkdQysMEHuK8n4ZanZujxKitDW9PbTNVntiuFByh9VPT/PtWca9eLTV0cT0YUUUVQgooooAKKKKACiiigAooooAKKKKACiiigAopaSgAooooAKKKKACiiigAooooAKKKKBhRRRQIKWkooAKKKKBhRRRQIKKKKACiiigAoopaACiiigANFFFIBKKU0lMAooooAKKKKACiiigAooooAKKKKAClpKWgAoopKAClpKWgAooooASilpKACiiloASilpKACilpKACilpMUALRRRQAUUUUAJRRRQMKKKKACgUtKBQIt6bYvqOoQ2qdZGwT6Dufyr16CFLeCOGNdqIoVR6AVx/gbTNscuoSLyTsj/qf6fnXZ15uJnzS5TqoxsrlDW9RTS9KmuWxuA2oPVj0/x/CuYhmW4hWUdHGap+OtT+0X0djGf3cAy/ux/wABVXQLjdbNAx5jOR9D/k1pTpWhzEud5WJNctRJbeco+aPr9K5qu2lUSRMjDhgQa4qRGjlZG6qSDXRSeljOohtFFFbGYUUUUAFFFFABRS0lABRRRQAUUUtABSUtFABRRQaAEooooAKKKKACiiigBRQaVFLsFVSzMcAAZJNd1pPw1urq2WbULr7KWGREi7mA9znA/WsqlWNP4mXGDlscHRXba38Or3T4GuLGb7XGoyybcOB7DvXFcjg06dSNRXixShKO4lFFFaEhRS0lABRRQKAJIITPPHEvVmxXZRRiKJUUYVRgCsDQYN9y0x6IOPqf8muhPArnqvWxrBdSWx1dLDVreCRsJP8AKx9PQ/nXZZrxu+ufPvZJAeAcL9BXpvh3Uv7T0eGZjmVfkk+o/wARg1zV6VkpGtOd7oy/G2mfadOW9jX95bn5vdD/AIHH6155ivapolmheJ1DI6lWB7g15Fqlg+m6jNauD8jfKfVex/KtcLO65WRVjrco0UppK7DAKKKKAFFFJRQAUUUUALRRRQAlFFFABS0lLQAUlLSUAFFFFABRRRQAUUUUAFFFFABRRS0AJRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAtFFAoAKKKKQBSUUUwCiiigAooooAKKKKACiiigAooooAKWiigBKKDRQAUUUUALRRRQAUlFFABS0lLQAlFLSUAFLSUtABRRSUALRSUUALQaKSgAopaKAEooooAUVPa28l1cxW8S5kkYKo9zUFdj4F0zzbiXUJF+WP5I/wDePU/gP51nUnyRuVGPM7HaWFolhYw2sf3YlC59fU/nTdRvEsNPnupPuxoTj1PYfnVquI8dar80emxtwMSS/wBB/X8q82nF1JnXN8sTjriZ7meSaQ5d2LMfc1Y0ufyL5CT8rfKfx/8Ar1RzSg4Oa9S2ljjT1udx2rmNbg8q+3gcSDP41v2M/wBps4pO5HP171U12DzLLzAOYzn8DWEHaRrLWJzNFFFdJiFFFFABRRRQAUUtFACUUUtACUUUUALRSUtACUUtJQAUUUUAFFFLQAlFFFAHW/Dyyju/EoklAIt4mkUH+9kAfzr1+vC/DWsnQ9agvDkxA7ZQO6Hr/j+Fe3W11b3lulxbSrLC4yrqcg15ONjLnv0O7DNctiY14h4xtYrPxXfRRKFQsHwOxYAn9TXsGq6vZ6NZPc3cqooHC/xMfQDua8N1TUJdV1S4vpRh5n3Y9B0A/AYFVgYy5m+hOJaskVKWkFWrGyl1C8itYBmSQ4HoPUn2Fem3bU5Ejf8AB2hJqVy15cx7raE4Cno7en0FdR4n0S0utFnlS3jSeBC6OihTgckcdsZrX06wi02whtIRhI1xnuT3J+tPvl3afdL6xOP/AB015k6zlUujrUEo2PFcUUCpYIjNOkY6sQK9O5xnRaPD5NgjY+Z/mP8ASptRn+z2UsgPOMD6mrCIEQKOABgVia/PzFAD/tH+n9a5170jd6RMTNdR4J1MWupm0dsR3IwPZh0/Pp+VcvTopHilSRG2uhDAjsRW1SHNFoyi7O57bXH+OdN320V+i/NGdj/Q9P1/nXSaTfrqel292uP3i/MPRu4/Opb21jvbOa2lHySKVPt715dOTpzOuS5onjBpKnu7aSzu5beUYeNip/Coa9ZO5xCUUUUwCiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigApaSloAKSlpKACiiigAooooAKKKKACiiigAooooAKKKKAClFJSigAooopAJRRRTAKKKKACiiloASiiigAooooAKKWigApKKKACiiigAopaKAEopaKAEopaKAEopcUlAC0lLRQAlFLRQAlFFFABRRRQAUUUUAFFFFABQKKUUAPjjaWRY0BLMQAB3Jr13SbBdM0yC1Xqi/MfVj1P51w/gzTftWpm6cZjt+R7sen9TXoo6V5+Knd8p00Y6XIrq4jtLWW4lOI41LMfYV49f3j399PdScNK5bHoOw/Ku28dal5VpFp8bfNKd8n+6On5n+VcDWuFhaPM+pFaV3YKKKK6zE3dAuOJICenzL/WtmZBLC8bdGGDXI2U5trqOTsDz9O9deDkZrnqKzuawd1Y4yaNopmjbqpwaZWrrlvsuFmA4cYP1/wA/yrJraLujNqzCiiiqEFFFFABS0lLQAUlFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABU9tfXdmSba6mgz18qQrn8jUFJSaT3C5LNcTXL755ZJX/vOxY/majxRSgUWSHuFek+D9COnWn2u4TFzMOAeqL6fU96xfB/h37XKuo3afuIzmJCPvt6/Qfzr0ICuHE1vso3pQ6sWorr/jzn/wCubfyqWqWrzCDRr2UnG2F/5Vxx1aOiWx41WxoMG+4eYjiMYH1NY4rq9Lg+zWSKRhm+Zvxr1pu0TigrsuHgVyF9P9pvJJB0JwPoOldFq1z9nsXIOGf5V/GuUpUl1HUfQKKKUVsZnZ+BNT8uaXTpG4k/eR/UdR+X8q7omvGbO6ks7qK5iOJI2DD8K9esruO+sobmL7kqBh7e1ediqdpc3c6aMrqxx3jrTNssWoxrw37uXHr2P5cfgK4uvYdTsk1HTZ7V8fvFwD6HsfzryCWN4ZXikUq6EqwPYit8LO8bPoZ1Y2dxlFFFdRkFFFFABRRRQAUUUUDCiiigQUUUUAFFFFAC0lFFABRRRQAUUUUAFLSUtACUUUUAFFFFAwpaKM0CENFLSUAFFFFABRRRQAUUUUAFFFFABS0UUAFFFFIBKKKKYBRS0UAJRRRQAUUUUAFFFFAC0UlLQAlFLSGgAooooAKWkpaACiiigAopKKAFopKWgBKKDRQAtFJS0AJRRRQAUUUUAFFFFABRRRQAuKUCkrf8J6X/AGjq6u65hg/eN7nsPz/lUTlyq7Gld2O58PaaNM0iGJlxKw3yf7x/w6fhWo7BEZmICqMknsKWud8Y6n9j0g26NiW5+Tjsvf8Aw/GvLSdSfqdj92JwesX7anqs90fus2EHoo4FUKDRXqxVlY427sSiiiqEFdPpFz9oswpPzx/Kf6VzFXNMuvst2pJ+Rvlb/GonG6Ki7M6K+thdWrx456r9a5JgVYgggjsa7XORXP61Z+XJ9oQfKxw3sazpys7FzXUyaKSlrcyCiiigAooooAKKKKAFpKKKACiiigAooooAKKKKACiiigAooooAKKKXFAAK6Dw14efWLnzZQVs4z87f3z/dFM8PeG7jWJhI4Mdop+aTu3svv/KvTra2itLdIII1jiQYVR2rjxFfl92O5vTp31ZJHGkUapGoVVGAAOAKfSUtee3c6RK53xrdeR4feMH5p3CY9up/lXRGvO/HV+bjVIrJORAnIH95v/rY/OtqEeaaIqu0Tn9MtftN4MjKJ8zf0FdQOBVXTrP7JahW/wBY3L/X0p97cC1tXlPUD5fc12yfMzCK5UYetXXnXflKfli4/HvWZQxLMWJySck0V0JWVjJu4UUUUxC5ruvAmpb4ZdOkblP3kf0PUf1/GuEq5pd++m6lBdpk+WwLD1XuPyrKtDnjYuErSPYq8+8baWLfUEvYxhJxhv8AeH+I/rXfxSJNEkkbbkdQykdwelUdc05dU0ia3wPMxujPow6f4fjXnUp8kzpqR5onkVJTmBVipBBBwQabXrHGFFFFABRRRQAUUCg0AFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAtJRS0AJRRRQAUtJS0AFJS0lABRRS0AJRRRQAUUUtACUtFFAAaSlopAJRRRTAKKKKACiiloASiiigAoopaAEooNFABRRRQAUUUtAxKKWkoEFFFLQAlFLikoAKKKKACilooASlpKWgBKKWigApKWigBKKKKACiiloAK9Q8J6Z/Z2jIzriaf94/sOw/L+dcJ4d03+1NYiiZcxL88n+6O349K9YUYFcWKn9lG9GPUDXlfibVP7T1mV0bMMf7uP6Dv+Jya7vxRqf9maLKytiaX93H65PU/gK8qpYWn9pjrS6C0lFFdxzhRRRQAUUUUAdHo9558HlOf3kY/MVfnhWeFo3GVYYrkred7eZZU6qc/Wutt5kuIUlQ/KwzXPONndGsXdWOSuYGtp2iccg8e4qKum1ax+0weYgzKg49x6VzOMVrCV0RJWYUUUVZIUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFGKuWGmXepTiK0haRu5A4X6ntUuSWrGlcqgZrrfD3hGW7ZLq/VorfqsZ4Z/r6Ct7QvCFtpu2e62z3I5HHyofYdz710oFcVbE9InRCl1kMiiSKNY40CIowqqMACpKKK4m7m4UUlBNIZWv7yKwsZrqY4SJSx9/b8a8z02KXUdQm1S65LOWUerf4CtvxbqL6nfJotq37uM77hx0z2H4fzx6U2KNIYljQYVRgCu6jHkjfqznk+ZimuZ1a9+03GxD+7j4HufWtTWb37PB5SH95IMfQdzXNV00o9TKcugGkpTSVsZi0UUUAFFFFAHongnVPtOnNZyNl7c/Ln+6en5H+ldSeleTaBqX9l6tDcE4jztk/3T1/x/CvWVIIBByD0NeZiIcs79zrpSvE818Y6b9i1czouIrgb+P73f8Ax/Gucr1fxLpg1PRpUC5mj/eR465Hb8eleUdK68PU5oehhUjZhRRRXQZhRRRQAUUUUAFFLRQAlFFFABRS0lABRRRQAUUUUAFFFFABRS0UAJRRRQAUUUUAFFFFABRRRQAtBoooASilpKACiiigAooooAWikpaAENLRRSASiiimAUUUUAFFFFABRRRQAuaSiigAooooAKKKKACiiigBaSlooASlopKAFpKWkoAKKKKAFooooAKKKKACiikoAWikpaAEoooxQAoopKei7nC+pApMD0nwdpQstJW5df3tyA/0XsP6/jXR0kaLFEkaDCooUD2HFUdauZLPRby4i/1iREqfQ9M/h1ryJNzmdqXLE4Lxhqgv9YaGNsw237tfQt/Ef6fhXOmg5JzSV6sIqMUkcjd3cKKKKskKKKKACiiigBa0tIvvs83kuf3bnr6GsylpNXVhp2Z2+Miue1iw8mT7RGPkY/MB2NaGkX32mDynP71B37j1q/LEk0bRuMqwwa5leEjX4kcTRVm9tHs7kxtyvVT6iq1dKdzEKKQ0tMAooooAKKKKACiiigAooooAKWlSN5HCIpZj0CjJNb1h4R1a8wzQfZ0P8U3B/LrUSnGO7KUW9jBxVqz066v5fLtYHlb/AGRwPqe1d7p/giwtsPdO9y47fdX8uv610sMEVvGI4Y0jQdFRQB+lc08Wl8JrGi+pxmk+BFVll1OTcevkxnj8T/h+ddlb2sFpCIbeJIox0VBgVLS1xTqynuzeMFHYKWkorMsKKTNBNACE1h+I9dGk2ojiw15NxEvp/tGpNe8QWuiWxLsHuGH7uLPJ9z6CuLsEuL+5bVL5i8shygPYf56V0UqV/eexjOfRFiwszbRs0h3TyHdIx5OalurhLaBpXPC/rU7HAzXLarf/AGufYh/cp09z611xXMzNvlRUuZ3uZ2lc8t+ntUVFFdSRgwooooAKKKKACiiigABr0zwbqv2/SBBI2Zrb5DnqV/hP5cfhXmdb3hC4kg8RW6J92bKOPbGf0xWGIhzQNKcrSPUTXlninTf7O1qUIuIpv3qenPUfnmvUs1x/j+MGys5MfMJGX8CM/wBK48NJqdjeqrxOBooor0zkCiiigAooooAKKKKACiiigAooooAKKKKACiiigApaSigBaSiigAooooAKKWkoAKKKKACiiigAooooAKKKKACiiigAooooAKKKKAFooopAJRRRTAKKKWgAooooASiiloASiiigAooooAWkpaDQAlFFLQAlLSUUAFFFFABS0lLQAlFBooAKKKKAFpKKKAFpKWkoAKKKKACiiigApykggjqKaKdSYHsthdLeWFvcqeJYw35in3ECXNvJBIMpIpVh7GuJ8Ha/HABpt0+1ScwuTwCf4f8ACu7ryakHCZ2xkpRPJ9Z0G70i5ZXjZoCfklA4I/ofasrFe2FQRggEHsagksbSUYktYGHvGDXRHF2WqMnR7HjOKMV6zN4b0efJbT4QfVAV/lVCbwTpEmdgmj/3ZM/zBrVYuD3JdGR5rijFd3L4AgOfJvpF9nQH+RFUZvAd8v8AqbqB/ZgV/wAatYim+pDpyORorfl8H61HnFssn+5IDVCbQtVg5k0+4A9QhP8AKtFUi9mLlZn0U94ZYzh43U/7QIpuKq6JsSQTPbzLKhwynNddaXSXcCyp0PUeh9K43FXtLvTZ3GGP7p+G9veoqRuioSsb+oWS3kBU8MOVPoa5R0aNijgqwOCD2rtQdwyOhrJ1jTzLEbiMfOg+YeorOnKzsy5RvqjnKWjHNFdBkFFFFABRRRQAUUUUAFWdPhiuNRtoZn2RSSKrt6Amq1KOKT1Q0e0Wel2mmxiO2gSMY6gcn6nvVnGK8s07xfqunqsZlFxCvASUZwPY9a6iz8fWEwAuoZbdu5Hzr+nP6V5lShUTu9TqjUgdXS1mW/iDSbnHlahBk9mbaf1q+lxDIMpNG3+64NYOElujVNElFNMiDq6/nUcl3bRKWkuIkA6lnAo5WF0S0hNZFx4n0e2zuvo2PpHl/wCVYOoePo1UrYWxZuzzHA/If41caM5bIlziup2Ms0cMZeV1RByWY4Arj9b8bRxhoNLG9uhnYcD6Dv8AX+dclf6xfanJuup2cdlHCj6Cq8ED3EqxoMsa7KeGUdZGE6reiLVjayapeNPcu0gB3OzHOfaunACqABgCq9pbLawLGvbqfU0l/drZ2zSHBY8KPU1UnzOyElyq7M7W7/YptYz8zD5yOw9K5+nyO0sjO5yzHJNMrojGysZN3FooAqWO2nlOI4ZHP+ypNO6FYjorSh8P6vN9zT5+e7LtH61fi8GaxJ96KKP/AH5B/TNQ6kVuylBvoc9ilxXXxeAbw/628gQf7ILf4VoQ+ArVcedeTOf9lQv+NQ8RTXUpUpHAYoK16bF4O0eLrDJJ/vyH+mKvQ6FpcHMdjAD6su7+dZvFx6FKizyRVJOAMn2ru/CHh+W0b+0LuMpIVxEjdQD1JrrEtoI/uQxr/uoBUo4FY1cS5qyNIUrO7A1w3jy7DS2tqDyoMjD68D+RrsNQvrfTrOS5uHCog/EnsB715LqV/JqV/LdS8M5yB/dHYUYWDcuYVaVlYq0lFFeicwUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUtJS0AFJQaKACiiigAooooAKKKKACiiigAooooAKKKWgBKWiigAooopAJRRRTAKWkooAWkoooAKKKKACiiigAooooAKKKKACiiigAooooAWkopaAEooooAWkpaSgAooooAKWiigApKWigBKKWigBKKKWgYCikooELmuq0bxrcWaLBeobiFeA4Pzgf1rlKKicIzVmVGTWx6pB4r0e4UH7YsZ/uyAqf8KvRatp83+rvrdvpIK8ezRmuZ4SPRmqrM9sSRXGUYMPUHNLXiqTSRHMcjof8AZYir8OvarBjy9QuMDsz7v55qHhH0ZSrI9corzKHxprEWN00Ug/24x/TFX4vH94uPNs4H/wB0lf8AGs3hahSrRO+xSbRXIReP7dv9dYyL/uOD/PFX4vGujyY3PNGf9qPP8s1Do1F0H7SPc6Bo1cYZQR6EZqrNpGnXH+tsbdv+2YqtF4l0eYgLqEQJ/vZX+dXor21mAMVxDJn+64NTacSvdZmTeE9Fl/5cwh/2HYf1qhL4E018+VLPH+Ib+ldSOaKaqzXUXJFnOW/hh7SERLd+Yq/d3pggenWlfRLkdNjf8Cros0Ue1kHKjyzW/D93YXHmLbt5Lnjbzg+nFYzROnDKy/UV7Hd2yXUDRP36H0PrXK3Fu0EjRyLyPWuuliLrUxlS10OCors3tbeT78EZ/wCAiq0mj2T/APLHb/usRWyqoj2bOVoroX8Pwsf3crr9eagfw7L/AAXKH/eUiqVSJPIzForUbQbxejRN9GqFtIvl/wCWGfowqudC5WUqKt/2ZeD/AJd3/Sk/s29/59pPyo5kFmVaSrY0y9P/AC7P+VOGk3x/5YEfUijmQWZSpa0V0W9bqir9WFSr4fuD96WNfzNJyiHKzL3Me5/OkrcTQMffuM/7q1Yj0O1X729/q3+FJziVySOa5oxmutTTbROlun48/wA6sJDEn3Y0X6KKl1V0Q/Zs5CO2mk4SF2+ik112j+HLyG3EskO2RxnDMOBWxpVj5z+fKv7tfug9zXQAVzVcQ9kawpdWc+miXJ+8yL+OaguPBy3s/mXF420DCoidPxNdRSjFYe2l0NORdTmI/Aulry73D+xcD+Qq7D4U0WE5Fkrn/poxb+tbfFHak6s31BQiinFpVhB/qrK3U+ojFW1RVGFUAeg4qCa/s7cZmuoI/wDekAqhL4o0aHO6/iY/7GW/kKVpyHeKNbAoxXNS+ONJT7gnkPsmP5mqE3j9P+WNgT7vJj+QqlQqPoT7SKO1pK88m8eai/EdvbR/gT/WqM3i7WZR/wAfewf7CKP6Vaws+ovbRPUKbJJHEMyOqD1Y4ryGbV9RuP8AW31w31lP+NU2YucsxY+5zWiwj6sh1/I9ek1nTY87r+2GP+mgrL1DxlptohEDG6k7BOF/M/0rzTNGa0jhYrcl1m9jR1fWrvWZxJcMAi/cjX7q/wD1/es2iiupJRVkZN33CiiimIKKKKACiiigAooooAKKKKAClpKKACiiigAooooAKKKKACiiigAoopaAEopaSgAooooAKKKKACiiigAooooAKKKKACiiigBaKKKAEpaKKQCUUUUwCiiigAooooAKKKKAFoopKAFpKKKACiiigAoopaAEopaSgApaSigAooooAKKKKACiiigBaKKKACiiigAooooAKSlpKACiiigAoopaAEpaKSgBaKKSgBaKKSgBaSl7UlABmjJBzRRQBZh1G8t/9Tdzpj+7IR/Wr0XinWYel9I3++A386yKKlwi90PmZ00PjnVU++sEn1TH8jWzpXjf7VeR293bJCrnb5iscA9sg9q4ClBrOVCDWxaqSXU9trO1axNzbl4lzMgyo6bvasrwlr/9oWwsrlv9JiHyknmRf8R/nvXT4rzZJ05WOlNSR5t/bUCuUkjljYHBBA4NTJqlm3/LcD6jFX/GWgbw2p2yfMB++Ud/9r/H8/WuFrvpqM1dGEm4vU7NLmCT7kyN9GqXNcOTT1lkT7sjr9GIqnSF7Q7XNFcet/dp0uZf++s1KNWvR/y3J+oH+FL2TH7RHVcUuBXLDWb3/nov/fAp39t3v99P++BS9lIOdHT8UcVy51q+/wCei/8AfApp1i9P/Lb8lH+FHspBzo6qiuRbU71utw4+mBUb3dw/3p5D9WNP2TF7RHYMyr1YD6moHvbaP708Y/4EK5AsWPJz9aBT9iL2h1LavaL/AMtN3+6DWhohGr3JEcbiGPl3bA/AVyFjZy393HbQLudzge3vXq2l6dFpljHbRDhRy3949zWNa0FZbmlO8mW0jVFCqAAOgFVNT1KDSrGS6nPyr0UdWPYCrUsqRRtJIwVFGWYngCvLvEeuPrN6duRaxkiJT3/2j7muejTdSXkaTnyo2JfiDcEkQ2ES+7uT/LFUZvHGsSZ2GCIf7Eef55rm6K71QproczqSfU1ZfE2szfe1CUf7mF/lVGa+u7j/AF1zNJ/vuTUFFaKEVsieZhRmkpaoQZpc0lFABRmiigAooooAKKKKACiiigAooooAKKKWgBKKWkoAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACilpKACiiigAooooAKKKKACiiigBaBSUooAKKQ0tIBKKKKYBRRRQAUUUUAFFLSUDFpKKKBBRRRQMKKKKBBRRRQAtFFFACUUtJQAUUUUDFooooEJRRRQMWiiigQUlLRQAUUlLQAUlLSUAFLSUUALRRRQAlFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFLQBLbXEtpcRzwuVkjYMpHrXq+i6vFrFgtwmFcfLImfut/hXkdaOi6vNo9+txHkoeJEzwy/4+lc9elzrTc0pz5WeuMAykEAg9Qa8y8U6EdKvPOhQ/ZJTlcfwH+7/hXpNtcxXltHcQOHjkXcppl9ZQ6hZy2s65jkGPcHsR71w0qjpy1OicVJHjFLVzVdMn0m/e2mHQ5VuzL2IqnivVTTV0cjVgpKWkpiCiiloAKTNFFABRRRQAtORS7BVBJPAAptdr4N0Ddt1S6T5R/qFPf/a/wrOpNQV2VGLk7Gz4Z0IaVaebMo+1Sj5v9kf3f8a36MYrnfFeu/2XYmCB8XcwwuOqL3b/AA/+tXme9VmdekImN4x18TyNplq/7tD++YfxN/d+g/nXGmgkk5PNJXp04KEbI5JS5ncKKKK0JCiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKWiigApKKKACiiigAooooAKKKKACiiigApaSigBaSiigAooooAKKKKACiiigAooooAKKKKACiiigAoopaAEooooAKKKKAClFJSigYUUtJSEJRRRTAKKKKACiiigAooooAKWkpaACiiigApKWigBKKKXFABRRRQAUlLRQAlFFFAC0UUUAJRRRQAtFFFABSUtJQAUUUUALRRRQAlLRRQAUUUUAFFFFACGilooASilxSUAFLSUtABSUtJQAUUUUAFFFFABRRRQB1HhDxB9guPsVy+LaU/Kx6I3+B/z3r0YHNeI16F4Q8Qfa4Rp9y37+Mfu2J++o7fUfy/GuHE0ftI6KU/ss19f0WPWbAxkATplon9D6fQ15ZPDJbyvFKhR0OGU9Qa9orlfF+gfbLc39sn+kRj94oH319fqP89qjD1uV8r2HVhfVHnlJQaK9E5gooooAKKKKAClpKu6Zp02qX0drCOWPLdlHcmk3ZXY0rmn4Y0A6veebMp+yRHLn++f7v+NenIqogVQFUDAAHSq9jZQ6fZx20C7Y0GB6k9yfc1NNNHBC8srBY0UszHsBXl1ajqSOuEeVFPWNUg0mwe5mPThF7s3YV5PfX02oXkl1O2ZJDk+g9APar3iHW5Nb1Aycrbx/LEh7D1Puaya7aFLkV3uc9SfMwoooroMwooooAKKKKACiiigAooooAKKWigBKKWigBKKWigBKKKKACiiigApaSloASiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigApRSUooAXNJRRSASig0UwCiiigAooooAKKKKACilooASiiigAooooAKWkpaACiiigApKWigAooooAKSiigApaTFLQAUUUlAC0lLSUAFLSUtABRRRQAlLRRQAUUUUAFFFFABRRRQAUlLRQAUUUUAFFFFACUUtJQAUUUUAFFFFABUkE0lvMk0TlJEIZWHY1HS0PXQD1fQNYj1iwWUYEyfLKnof8DWseRXkWkanNpN8lzFyBw6Z4Yeleq2V7Df2kdzA26Nxke3sfevLr0uSV1sddOfMjgvF3h/7DP9utk/0aQ/Oo/gb/A/57Vy1e1XFvFdW7wTIHjcbWU9xXlOu6PLo9+0LAmJuYn/ALy/4104atzLle5lVhZ3Rl0UUV1mIUopKUUASRxPLIqIpZmOAB1Jr0/w5oaaPZDcAbmQZlb09h7CsjwhoIijXUrlf3jD9ypHQf3v8K7GvPxNa/uo6aULasWuB8Y+IPtMjabav+5Q/vWH8bDt9B/OtrxXr39mWn2aB/8ASphwR1RfX/D/AOtXmxOTTw1G/vsVWp0Q2iiiu85wooooAKKKKACiiigApaKKAEpaKSgBaKSigAzS0lFAC0UlFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABS0lFABRRRQAUUUUAFFFFAwooooAKKKKBBRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQMKWkpRQIKKKKQBSUUUwCiiigApaSloASilpKAFopKWgApKKKACiiigApaSigBaSlpKAFooooAKSlpKACiiigApaSigAooooAWkpaKAEpaSloAKKDSUALRRRQAUhpaSgAoopaACiiigAooooAKTNLRQAUUlFAC0lFFAC0UUlAC0UlLQAUUUUAKDXQ+F9eOl3fkTt/osp+Yn+A+v8AjXO0ucGonBTVmVF2d0e2AggEdDWbrekRaxYPbvhZB80b/wB1v8KwPBuv+co0y5f51H7lj3H938K7GvLlF05nWmpo8Wubaa0uHgnQpIhwwNRV6J4x0L7bam/t0/0iEfOAPvr/AIj/AD2rzzFelSqKcbnLOPKxK6XwroH9p3X2m4X/AESI8g/8tG9Pp61iafYT6nfR2sC5ZzyeyjuTXrllaRWNnFbQriONdo/xrLEVeVWW5VKF3dk4AAwBgVS1fU4tJ097mXkjhVHVm7CrrMFUsxAAGST2rzzxHq39raa86f6hL3y4uOqhOv4nJrjpQ55a7G85cqOdvbyW/vJLmdsySHJ9vYe1QUmaK9VKysjjCiiimAUUUUAFFFFABRRSmgBKKWkoAWkoooAWkopaAEopaSgAooooAKKKKACilooASilooASiiigAooooAKKKKACiig0ALSUUUAFFFGKACiiigAooooGFFFFAgooooAKKKKBhRS0UCEopaKAEooooAKKKKACiiigAoFFAoAWiiikAlFFFMAooooAKKKWgApKWkoAKKBS0AJRRRQAUUUUAFFFFABRS0UAFFFFABRRRQAUUUUAJRRRQAtFFFABRRRQAUlFFABRRRQAtFIKWgAooooAKKKKACiiigAooooAKKKKACkpaSgAopaKAEoNFFAC0UUUAFJS0lABRRRQA+KV4ZUkjYq6EMpHUEV6xoOrprOmJOMCVfllUdm/wPWvJK0dF1ifRb0TxfMh4kjJwHH+PvWFelzx03NKc+V6nrxGaxLvwjpF5cGZ4XRm6iJ9oP4Vb0zWbLVoRJbSgtj5o24ZfqK0M15t5QfY6rKRTsNJstMjK2kCx56t1Y/UmrmKXsSeAOp9Kx9U1u3it3js9R09bnpmWcYX34zk0kpSYNqKMHxr4h8hG0u1f94w/fMOw/u/4/wD66wfs2fAouCcAX4UD1+Sp4/Cct9MXOs6W0kjZZjd7iSe/Su+/4Q23PhBdGaf5gfN8/HHmdc49O30rq9pCkkkY8kp3Z49ilrprzwc9oxB1nSj7NcbT+VZE+kvDnN/pz4/uXIP9K7I1Iy2Zi4tbmfRQRtYjIOO4OaK0JCiiigAooooAKKKKBhRRRQIKKWkoAKKKKADNLSUtABSUUUAFFFFAC0UZooAKSlooAKSlpKAFpKKKACiiigAooooAKKKKAFopKM0AFFFFABRRRQAUUUUAFFLSUAFFFFABS0UhoAWikpaAEopaSgAooooAKKKKACloooAKKU9aSkAlFFFMAooooAKKKKAFpKKKAClpKKACiiigAoopaAEopaKACiiigApKKKAFopKWgApKWkoAKKWigBKKXFFACUUUUAFFFFAC0UUUAFFFFABSUtFABRRRQAUUUUAFFFFABRRRQAmaKWkoAWikpaACiiigAooooAKSlooASilooASiiigByO8bh0Yqw6EHBFaKeIdXjXauo3GPd8/zrMoqXFPdDu0W7nVL68GLm8nlH915CR+XSqmaKKaSWwXErul164/4VkyNKTL532QN32dcf988VwtannH/AIRXyc8fbt2P+2dZVYKVr9yoSauZmaM0UVqQFFFFMAooooAKKKWgBKKWigAooooAKSlpKACiiloASiiigAooooAKKKKACiijFAC0UlLQAUlLSUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABS0lLQAUUUUAJRRRQAUtJS0AJRS0lABRRRQAUUUUAFFFFAC0UUUAFFFFIBKKKKYBRRRQAUUUUAFFLRQAlFFFABRRRQAtFFJQAtFFFABSUUUAFFFLQAlFLRQAUlLSUAFLRRQAUlFFABRRRQAUUUUAFLSUUAFLRRQAlFLRQAUUUUAFFFJQAtFFFABRRRQAUlFFABRS0UAFFFIaADNFFFABRRRQAZopaKAEoopaAEopaSgAooooAKm84fYvIwc+Zv8A0x/jUNFABRRRQAUUUUAFFFFABRRmigAozRS0AFFFFABSUtJQAtJRRQAtJS0UAJRRRQAUUUUAFFLSUAFLSUUAFFFFABRRRQAUUUUAFFFFABRRRQMKKKKBBRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABS0lFABRRRQAUUUUAFFFLQAUUUUAFFFFIBKKKKYBRRRQAUUUUALSUUtACUUUUAFFLSUAFFFFAC0UlLQAlFFFABRRS0AFFFFACUUtFABSUtJQAUUtJQAUUUUAFFFFABRRRQAtFFFABRRSUALRSUtABSUtJQAtFIKWgAoopKACiiigAooooAWkoooAKKKKACiiigBaQ0UGgApaSigBaSiigAooooAKKKKACiiigAooooAKKKKACiiloAKSiigAooooAKKKKACiiloAKKKKAEpaKKACkoooAKKWigBKKKKACiiigAopaSgAooooAKKKKACiiigAooooAKWkpaACkpaKAEopaKAEooooAWkopaAEopaSgAooooAKKKKACiiigAooooAKUUlKKACiiikAlFFFMAooooAKKKKACiiigAoopaACkpaSgAopaSgAooooAWkoooAKWkpaACiiigAooooAKSlpKACiiigAopaKACkpaKAEooooAKWiigBKKWkoAKWiigApKWigBKWiigApKWkoAKKKKACiiigApaKKACiiigAooooAKSlooASilpKAClpKWgApKWigBKKWkNABRRRQAUUUtACUUUUDCiilxQISlpKKACiiigAopaSgAooooAKWkFLQAUUUUAJS0UUAFFFFACUUUUAFFLSUAFFFFABRRRigAooooAKKKKACiiigAooooAKWikNABRRRQAUUUUAFFLRQAUUUUAJRRRQAUUUUAFFFFABRRRQAUtJSigAooopAFJRRTAKKKKACiiigAooooAKKKKACiiigBaSlooASiiigAooooAKKKKACiiigAooooAKKKKACiiigAopaKACiiigBKWiigBKWkpaACiiigAooooAKKKKACiiigAooooASilpKAClpKKAFooooAKKKKACiiigAozRRQAlFLSUAFFLRQAUUUUAFJS0lABRRS0AFFFFABSGlooASlpKWgBKKWigBKKKKACilooASiiloABRSUtABSUUtACUuaSigBc0lFFABS0UlAC0UZooASiiigAzS0lFABRRRQAUUUUAFFFFABRRRQMWkoooEFFFFABRRRQAtFFJQAUUUUAFFFFABRRRQAUUUUAFFFFABS0UUAFFFFIBKKWkpgFFFFABRRRQAYopaSgAopcUlABQKKKAFpKWigBKKKWgBKKKKACiiloASiiigAopaSgAooooAKKKKACiiigBaKKKACiiigApKWigApKWigBKKKKACloooAKKKKAEpaKKACkpaKAEopaKAAUUUUAFFFFABRRRQAUUUUAJRS0UAFFFFABRRRQAUUUUAFFFFABRRSUALSUUUAFFFFAC0lLSUALSUtJQAtFFFACGloooAKSlooASilooASilooASlpKKAFpKKKACiiigAooooAKKKWgBKKKKACiiigAooooAKKKKACiiigAopaKAEooooAKKKKACiiigAooooAKKKKACiiigAooooAWikpaACiiikMSilpKYgooooAKKWigBKKKKACiiloAKSlpKAFopKKAFooooASiiigBaKKKAA0lLSUAFFFFABRRRQAUUUYoAKWiigBKWiigAooooAKKKKACigUUAFJilpKAFooooAKKKKACkpaSgApaSloAKKSigBaKKKACiiigAooooAKKKKAEooooAWkpaKACiiigAooooAKKSloAKSlpKAFpKKKACiiloAKKKKACkpaKACkpaSgBaSiigBaSlpKAFopKKAFoopDQAUUUUAFFFFABRRS0AJRRRQAUUUUALSUtFACUUUUAFFFFABRRRQAUtJRQAtJRRQAUUUUAFFFFABRRRQAtJS0lABRRRQAUUUUAFFFFABS0UUAFFLSUgCkoopgFFFFAC0lLSUAFFFFABRRS0AJRS0lABRRRQAtFJS0AFJS0UAFJS0UAFJS0UAJRS0UAFJS0lAC0UlLQAUUUUAJmlpKKAFooooAKKKKACiiigApKKKAFpKWigBKKWigBKKKWgAooooAQ0UUUALSUUYoAKWiigAooooAKKKQ0AFFFLQAUUUlAC5pKKKACiiigAooooAWiiigApKWkoAKKKWgAooooAKSlooAKSlpKACiiloASloooAKSlooAKKKKAEooooAKKKKACiiigBaSlpKACiiigAooooAKKKKACiiigAooooAKKKKBhRRRQIKKKKACilooASiiigAooooAKKKKACiiigApRSUUALRRRQAUUUUAIaKDRQAUUUUAFAoooAWkpaSgAoFFFAC0UUUAFFJRQAUUUUAFLSUtABRRRQAUlLRQAlLmkooAKKKWgBKWiigAooooASloooAKKKKACiiigAooooAKKKKACiikoAWiiigAooooAKKKKAEopaSgBaKSloAKKKKACiiigAooooAKSlooAKSlooASloooAKKKKAEoFFLQAUUUUAFJQaKAFpKWkoAKWig0AFFJS0AFJQaKACiiigApc0lFAC0UlLQAUUUUAJRS0UAJS0UUAFFFFACUUtJQAtJRRQAUUUUAFFLSUAFFFLQAUUUhoAKKKKACiiigAooooAWikooAKKKKACiiigAooooAKKKKAClpKKAFooooAKKKKQAaSg0UwCiiigYUUUUCCiiloASilooAKKKSgAooooAWkoooAKWkpaAEpaKKACiiigBKKWkoAKWkpaACkNLRQAlFFLQAlFLRQAlFLRQAUUlLQAUUUUAFFFFABRRRQAUUUUAFFFFACUtFFABSUtFACUUuKSgBaKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigApKWigBKKKKACilooAKKKKAEopaKAEpaKKAEopaSgApaSloAKKKKACkpaKACiiigAooooASiiigBaSiloASiiigAooooAKKKKAFooooAKSlooASiiigAopcUUAJRS0lABRRRQAUUtFACUUUUAFFFFABRRRQAUUUUAFLSUtABRRRQAhopaSgAooooAKKWigBKKWkoAKWkooAKKKKACilooASiiigAoopaACiiigAooooAKSlooASiiigAooooAKKKKAFpKKKACijFFABRRRQAtFFFABSUUUALRRSUALRSUtABRRRQAUUUUAFJS0UAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAJS0lLQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFJS0lAC0lFFAC0UUUAFFFFABRRRQAUUUUAFFJS0AJRS0lABRRRQAUUCloASilooASilooASilxRQAUUUUAFJS0UAFFFFABSGlpKACiiigAooooAKKKKAFpKKKACiiloASijFLQAUUUUAFFKaSgApKKKACiiigBaKKKACkpaKAEopaKAEooooAKKKKACiiigApaSloAKKKKACkpaSgApaSloASiiigAopaKAEopaKAEpaSigBaSlooASilooASilooAKMUUUAFJS0UAJRS0YoASlpKKAFooooAKSlooAKKKSgBaSiigApaSloAKSlooAKKKKACiiigApKWigAooooAKKKKACiiigAooooAKKKKADNFJS0AFFFFABRRRQAUmKWigApKWigBKWkooAWkzRRQAtFFFACUUtFACUUtJQAUUUUAFFFFABRRRQAtFJS0AFJRRQAUUUUAFFFFABS0lFABRRRQAUUUtACUUUUAFFFFABRRRQAUUUUALRRRQAUCilFACUUUUgEopaSmAUUUUAFGaKKAClpKWgAooooAKSlooASilooASlxRRQAlLSUtABRRRQAUlLRQAlFLSUAFFLiigBKWiigAoopKACiiigApaSigBaKKKACiiigAooooAKKKKACkpaKACiiigAooooAKSlooAKKKKACiiigBKKWigAooooAKKKKACiiigAooooAKKKKACiiigBKWiigAooooASiiigAooooAWkpaTFABRRRQAUtJS0AFFFFACUUtFABRRRQAUUUUAFJRRQAUtJS0AFFFFACUUUUAFFLSUAFLSUtACUUUtACUUUUAFFFFAwooooEFFFFABRRRQAUUUUAFFKKKAEooooAKKKKAClpKWgAopKUUAFJRRSGFFFFMQUUUUAFFFFAC0lFFABS0lFABRRRQAtFJRQAtFJS0AFFFFABSUtFABSUtJQAUUUUALmkoooAWiikoAKKKKACiiigBaSiigAFLSUtABRRRQAUZopKAFooooAKKKKACiiigAooooAKKKKACiiigAooooAKSlooASloooAKSlpKADNFFFABRRS0AFJS0UAFFFFABRRRQAUUUUABpKKWgBKKWigAooooAKSiigBaSlpDQAUtJS0AFFFJQAtFFFABRRRQAlFFFABRRS0AFJRRQAUUUUAFFFLQAlFLSUAFFFFABRRRQAUUUtACUUtJQAUUUUDCiiigQUUUUALSUtJQAUUUUAFFFFABRRRQAUtFFAAetFFFIBKKKKYBRRRQAUUUUAFFFFABRRRQAUUtFACUUtJQAUUUUAFFFFABRRRQAUUtFACUUUtABRRRQAlFFFABRRRQAUUUUDFpKKKBBS0UUAFFFFABSUtIaAClpKWgAooozQAUUUlAC0UUUAJS0lFAC0lFLQAUUUlAC0UUUAIaWkooAWkpaKAEooooAKWkpaACiiigAoopKAFopKKAFpKWigAFFFFACUUtFABRRSUALRSCloAKKKKACikzS0AFJS0lABRRRQAtFJRQAUUUtABSUtIaACiiigAooooAKKKKACiiigAooooAKKKKAClpKWgBKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigApaKKAA0UlLQAlFFFABRRRQAUUUUAFFFFABRRRQAtJRRQAUUUUAFFFFABRRRQMKKKKBC0UlFABRRRQAUUUUAFFFFABRRRQAtJS0lABRRRQAUtJRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFAxaSiigBaQ0UUCCiiigYUtJRQAtFJS0CEooooAXNJRRQMKKKKACg0UUCCiiigYtFFFAhKKKKACiiigYUUUUCCiiigAooooAKKKKACiiigBaSiigApaSigBaSiigAooooAKKKKACiiigAooooAKKKKACiiigApaSigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKAClpKKAFopKWkAlFFGKYBRRRigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiijFABRRS0AJRRRQAUUUUAFFFFABRRRQAUUtFACUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUuKAEooooAKKKKACiiigAoopaAEpaKKAEpaKKAEopaKAEopaTFABRS4ooASiiigAooooAM0UUUAFFFFAwooooEFLRRQAlFLRQAlFFFABRRRQAUUUtACUUtJQAUUUUAFLSUtACUUtJQAUUUUAFFFFABRRS0AJRRRQAUUUtACUUUtACUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFLQAlFLRQAlKKSnYpDP/9k="; +export const faviconContentType = "image/jpeg"; diff --git a/bunny_js/src/fee_config.ts b/bunny_js/src/fee_config.ts new file mode 100644 index 000000000..47ad78794 --- /dev/null +++ b/bunny_js/src/fee_config.ts @@ -0,0 +1,46 @@ +/** + * The facilitator's fee model, as /supported publishes it — and how to read the right figure + * for a token. + * + * Separate module, not part of `x402_server.ts`, deliberately: `x402_server` pulls in S3 + * storage and the SDK resource-server machinery, and several tests mock it wholesale — a + * consumer that imports the fee math from there would get the mock, not the math. This file + * has no imports at all, so every consumer (sellers' fee-allowance script, the claim cron, + * and their tests) runs the real thing. + */ + +/** What the facilitator charges, and the address that collects it. */ +export interface FacilitatorFeeConfig { + /** Spender to approve — the facilitator wallet that runs `transferFrom`. */ + recipient: `0x${string}`; + /** Flat fee per settlement, in 6-decimal NOMINAL units — the top-level figure /supported + * publishes. Valid as an atomic amount only for 6-decimal tokens (USDC, EURC); EURe's + * real fee is 10¹² times this, and lives in `feeByAsset`. Never divide an allowance by + * this number for a token with other decimals — see `facilitatorFeeFor`. */ + flatFee: bigint; + /** Per-token fees in each token's OWN atomic units, from /supported's `facilitatorFees.assets` + * (key: `":"`). Empty on a facilitator build predating the + * per-token assets list — then only 6-decimal tokens have a knowable fee. */ + feeByAsset: Map; +} + +/** + * The facilitator's fee in `token`'s own atomic units on `network`, or null when it cannot be + * known: no per-token `assets` entry, and `token` is not a 6-decimal token for which the + * nominal `flatFee` happens to be the atomic amount. Callers must treat null as "unknown", + * never fall back to the nominal figure — dividing an 18-decimal EURe allowance by the + * 6-decimal 10000 reports a 1-EURe approval as a trillion settlements, and approving + * 100 × 10000 atomic would write an allowance of 10⁻¹² EURe over a healthy one. + */ +export function facilitatorFeeFor( + fee: FacilitatorFeeConfig, + network: string, + token: string, + decimals: number, +): bigint | null { + const perToken = fee.feeByAsset.get(`${network}:${token.toLowerCase()}`); + if (perToken !== undefined) { + return perToken; + } + return decimals === 6 ? fee.flatFee : null; +} diff --git a/bunny_js/src/genimg_schemas.ts b/bunny_js/src/genimg_schemas.ts new file mode 100644 index 000000000..1836cb8dd --- /dev/null +++ b/bunny_js/src/genimg_schemas.ts @@ -0,0 +1,209 @@ +/** + * Zod schemas for the genimg endpoint's wire contract. + * + * Single source for both the runtime request validation in `genimg_x402_token.ts` and the + * generated `openapi.genimg.json` (see `scripts/generate-openapi-genimg.ts`) — a shape change + * here is a type error where it's inconsistent, and a `npm run generate:openapi:genimg` away + * from being documented. Before this file existed, the OpenAPI spec was a hand-written second + * description of the same shapes with nothing keeping the two in sync; that's how it ended up + * advertising `tokenId` as a *required request field* the handler has never read, while omitting + * `network` and `isListed` (both real, both sent by the website) and documenting a + * `transaction_hash` that is never returned. + * + * Three deliberate differences from `x402_facilitator/x402_schemas.ts`, which this mirrors: + * + * 1. Scope is request *and* response. The facilitator kept its request schema shallow because + * `@x402/evm` validates those payloads internally and re-modeling them would duplicate the + * SDK. genimg is the opposite case: its request body is the actual user-facing contract, and + * was hand-validated with a separate `if` block per field. + * 2. `ImageGenerationRequestSchema` is **strict**. Unknown fields are rejected, not ignored — + * this endpoint charges $0.07 per call, so silently dropping a `quality: "hd"` a caller + * believed in means taking money for a request we did not fulfil as asked. It also matches + * what the real OpenAI API does ("Unrecognized request argument supplied: ..."), and the + * principle `sc_llm_x402.ts` already applies to `stream: true` — reject what you would + * otherwise silently mishandle. `z.strictObject` renders as `additionalProperties: false`, + * so the strictness documents itself in the generated spec. + * 3. The x402 payment payload stays **out** of the schema, for the facilitator's original + * reason — `@x402/evm` validates it, and `payment` is passed through as received. + * + * These schemas are PUBLISHED UI, not just validation. `website/pages/x402/buyers` renders + * `components.schemas.ImageGenerationResponse` from the live deployed spec via `SpecParamTable`, + * so every `.describe()` below is user-facing copy, and the exported schema *names* are + * load-bearing — renaming `ImageGenerationResponse` breaks that page. + */ + +import { z } from "zod"; +import { getExpectedNetworks } from "./getChain.js"; + +// ── Request ── + +/** + * The CAIP-2 networks this agent can actually be paid on — every chain where GenImNFT is + * deployed. Derived from the same source the 402 challenge offers from, so the enum cannot drift + * from what the server will accept. + * + * Enumerated rather than left as a free string for two reasons: the paid path gets the check for + * free, and the generated OpenAPI lists them, so an images/v1 implementer can read the supported + * chains instead of guessing. Note these are genimg's own — `sc_llm_x402` serves a *different* + * set (its testnet is Base Sepolia, which has no GenImNFT), and confusing the two is exactly the + * mistake this enum makes loud. + */ +export const SUPPORTED_NETWORKS = [...getExpectedNetworks(false), ...getExpectedNetworks(true)] as [ + string, + ...string[], +]; + +/** Sizes this agent serves. Not a fixed set across `images/v1` implementers — see §1 of the migration doc. */ +export const IMAGE_SIZES = ["1024x1024", "1792x1024"] as const; + +/** + * Model ids this endpoint advertises, mapped to the provider in `image_service.ts` that serves + * them. One entry today; adding a model means adding it here and to PROVIDER_CONFIGS there. + * + * Any provider added must honour `mode: "edit"` and `referenceImage`, or the handler has to + * reject edit requests for it — silently returning a freshly generated image to someone who + * asked for an edit, and charging them, is the failure mode to avoid. + */ +export const MODEL_TO_PROVIDER = { "flux-kontext-pro": "bfl" } as const; + +export const ADVERTISED_MODELS = Object.keys(MODEL_TO_PROVIDER) as [keyof typeof MODEL_TO_PROVIDER]; + +export const ImageGenerationRequestSchema = z + .strictObject({ + // Custom messages throughout: these reach callers as `error.message`, so they should read + // like an API contract, not like a Zod internal ("Invalid input: expected string, received + // undefined"). + prompt: z + .string({ error: "No prompt provided" }) + .min(1, { error: "No prompt provided" }) + .describe("The text prompt to generate from."), + model: z + .enum(ADVERTISED_MODELS, { + error: `The requested model does not exist or is not served by this endpoint. Available: ${Object.keys(MODEL_TO_PROVIDER).join(", ")}`, + }) + .optional() + .describe( + `Model to generate with. Only the advertised id(s) are served: ${ADVERTISED_MODELS.join(", ")}. Defaults to ${ADVERTISED_MODELS[0]}.`, + ), + size: z + .enum(IMAGE_SIZES, { + error: `Invalid size parameter. Must be one of: ${IMAGE_SIZES.join(", ")}`, + }) + .optional() + .describe("Output image dimensions. Defaults to 1024x1024."), + n: z + .literal(1, { + error: "Only n=1 is supported by this endpoint — pricing and the NFT mint are per-image.", + }) + .optional() + .describe("Number of images. Only 1 is supported — pricing and the NFT mint are per-image."), + response_format: z + .literal("url", { + error: + "Only response_format='url' is supported by this endpoint. Images are served from stable URLs.", + }) + .optional() + .describe( + "Only 'url' is supported. Images are served from stable URLs; b64_json would push large bodies through a paid function for no benefit.", + ), + + // ── Vendor extensions (not part of the OpenAI images body) ── + mode: z + .enum(["generate", "edit"], { + error: "Invalid mode parameter. Must be one of: generate, edit", + }) + .optional() + .describe( + "Vendor extension: 'edit' transforms `referenceImage` instead of generating fresh.", + ), + referenceImage: z + .string() + .optional() + .describe("Vendor extension: base64-encoded source image. Required when mode is 'edit'."), + network: z + .enum(SUPPORTED_NETWORKS, { + error: `Unsupported network. This endpoint can only be paid on: ${SUPPORTED_NETWORKS.join(", ")}`, + }) + .optional() + .describe( + `Vendor extension: CAIP-2 network to restrict the 402 payment offer to. One of ${SUPPORTED_NETWORKS.join(", ")} — the chains where this agent's NFT contract is deployed. Omit it and the 402 offers every mainnet the agent accepts. Drives payment negotiation, not generation.`, + ), + isListed: z + .boolean() + .optional() + .describe( + "Vendor extension: list the minted NFT in the public gallery. Alias of x_nft.listed.", + ), + x_nft: z + .strictObject({ + listed: z.boolean().optional().describe("List the minted NFT in the public gallery."), + }) + .optional() + .describe("Vendor extension: NFT-specific options. Not part of the images/v1 floor."), + payment: z + .unknown() + .optional() + .describe( + "Vendor extension: x402 payment payload, as a body fallback for clients that cannot set the PAYMENT-SIGNATURE / X-PAYMENT header. Passed through as received; validated by @x402/evm, not here.", + ), + }) + .describe( + "OpenAI images-generation body plus this agent's vendor extensions. Unknown fields are rejected rather than ignored — see additionalProperties.", + ); + +export type ImageGenerationRequest = z.infer; + +// ── Response ── + +/** + * The NFT receipt. One object with a `status` enum rather than a discriminated union: a union + * renders as `anyOf` through `z.toJSONSchema`, which reads badly in the published parameter + * table. Everything below `status` is therefore optional — on `mint_failed` only `reason` is set. + */ +export const NftReceiptSchema = z + .object({ + status: z + .enum(["minted", "mint_failed"]) + .describe( + "'minted': the NFT exists and belongs to the payer. 'mint_failed': the image was generated and is yours, but the on-chain mint did not complete. The payment settled in that case — settlement precedes the mint, so a failed mint means you were charged for the generation you received, not for the token.", + ), + token_id: z.number().int().optional().describe("Token id of the minted NFT."), + contract: z.string().optional().describe("Address of the NFT contract holding the token."), + network: z.string().optional().describe("CAIP-2 id of the chain the NFT was minted on."), + metadata_url: z.string().optional().describe("URL of the NFT metadata JSON."), + mint_tx: z.string().optional().describe("Hash of the mint transaction."), + transfer_tx: z.string().optional().describe("Hash of the transfer to the payer."), + listed: z.boolean().optional().describe("Whether the NFT was listed in the public gallery."), + mint_price: z.string().optional().describe("Mint price paid on-chain, in wei."), + owner: z + .string() + .optional() + .describe("Address the NFT was transferred to, derived from the x402 payment."), + reason: z.string().optional().describe("Why the mint failed. Only set on 'mint_failed'."), + }) + .describe( + "Vendor extension carrying the NFT receipt. Not part of the images/v1 contract — a client that only wants an image can ignore it entirely.", + ); + +export const ImageGenerationResponseSchema = z + .object({ + created: z.number().int().describe("Unix timestamp (seconds) when the image was produced."), + data: z + .array( + z.object({ + url: z.string().describe("URL of the generated image."), + revised_prompt: z + .string() + .nullable() + .describe("Always null — this endpoint does not rewrite prompts."), + }), + ) + .describe("The generated images. Always exactly one entry, since only n=1 is supported."), + model: z.string().describe("The model that served the request."), + x_nft: NftReceiptSchema, + }) + .describe( + "OpenAI images-generation response envelope, plus the x_nft vendor extension. A 200 does not by itself mean the NFT was minted — check x_nft.status.", + ); + +export type ImageGenerationResponse = z.infer; diff --git a/bunny_js/src/getChain.ts b/bunny_js/src/getChain.ts new file mode 100644 index 000000000..c9ed734b9 --- /dev/null +++ b/bunny_js/src/getChain.ts @@ -0,0 +1,38 @@ +import { getGenAiNFTMainnetNetworks, getGenAiNFTTestnetNetworks } from "@fretchen/chain-utils"; + +export function getExpectedNetworks(sepoliaTest: boolean): readonly string[] { + return sepoliaTest ? getGenAiNFTTestnetNetworks() : getGenAiNFTMainnetNetworks(); +} + +export type NetworkValidationResult = + | { valid: true } + | { valid: false; reason: string; expected?: readonly string[]; received?: string }; + +/** + * Is this a network the endpoint can be paid on — that is, one where GenImNFT is deployed? + * + * Deliberately NOT a production/test boundary, despite once looking like one. It used to take a + * `sepoliaTest` flag and reject cross-mode payments, but its only caller derived that flag from + * the client's own network (`isTestnet(clientNetwork)`), so the expected set always contained the + * received value and both cross-mode branches were unreachable. It read as a guard without being + * one. What actually stops a testnet payment from buying real work is the mock-image path in + * `genimg_x402_token.ts`, which keys off `isTestnet()` directly. + */ +export function validatePaymentNetwork(clientNetwork: string | undefined): NetworkValidationResult { + if (!clientNetwork) { + return { valid: false, reason: "missing_network" }; + } + + const expected = [...getExpectedNetworks(false), ...getExpectedNetworks(true)]; + + if (!expected.includes(clientNetwork)) { + return { + valid: false, + reason: "unsupported_network", + expected, + received: clientNetwork, + }; + } + + return { valid: true }; +} diff --git a/bunny_js/src/index.ts b/bunny_js/src/index.ts new file mode 100644 index 000000000..ca7545b7a --- /dev/null +++ b/bunny_js/src/index.ts @@ -0,0 +1,13 @@ +/** + * bunny_js entry point: the searchapi x402 seller (Brave /search + /fetch, paid via x402 + * batch-settlement) as a Bunny Edge Script — a twin of scw_js/search_api.ts, trialed at the + * bunny-provided URL before any formal switch away from the Scaleway function. + * + * The runtime shims must import first: the ported scw_js code assumes the Node `Buffer` and + * `process` globals, which bunny's Deno isolate does not guarantee. + */ +import "./runtime-shims.js"; +import * as BunnySDK from "@bunny.net/edgescript-sdk"; +import { handleRequest } from "./routes.js"; + +BunnySDK.net.http.serve(handleRequest); diff --git a/bunny_js/src/llm_schemas.ts b/bunny_js/src/llm_schemas.ts new file mode 100644 index 000000000..912901534 --- /dev/null +++ b/bunny_js/src/llm_schemas.ts @@ -0,0 +1,341 @@ +/** + * Zod schemas for the LLM endpoint's wire contract. + * + * Single source for the generated `openapi.llm.json` (see `scripts/generate-openapi-llm.ts`). + * + * **The rule: forward the body, reject only what moves cost past the metered ceiling.** The body + * goes to the upstream model, which owns the chat-param contract — an unlisted OpenAI param + * (`temperature`, `top_p`, `seed`, …) is passed through and simply works. But this endpoint meters + * each message against a fixed ceiling (2000 output tokens ≈ $0.003, see `sc_llm_x402.ts`), so + * `stream`, `n` and `max_tokens` are rejected; see `REJECTED_PARAMS` below for why each. `tools` is + * the middle case: capped rather than refused, since a caller can inflate it. + * + * Hence `z.looseObject` on the request side — `z.object` publishes `additionalProperties: false`, + * a strictness the handler does not enforce (see the OpenAPI rule in the repo `CLAUDE.md`). The + * response side stays `z.object`: `callLLMAPI` reconstructs the envelope field by field. + * + * These schemas are PUBLISHED UI: `website/pages/agent-onboarding` and `website/pages/x402/buyers` + * render them from the live deployed spec via `SpecParamTable`. Every `.describe()` below is + * user-facing copy, and the exported schema *names* are load-bearing — renaming either breaks + * both pages. + */ + +import { z } from "zod"; +import { advertisedModelIds, tokensToCost } from "./llm_service.js"; +import { TOKEN_DECIMALS, type PriceList } from "./stablecoin_pricing.js"; +import type { SettlementTokenSymbol } from "@fretchen/chain-utils"; + +/** + * The model ids this endpoint advertises. Derived from `advertisedModelIds()` in `llm_service.ts` + * (the runtime source of truth, keyed off `LLM_PROVIDERS`) rather than hand-copied, so the + * published `model` enum cannot list a model the endpoint doesn't serve, or omit one it does. + * `llm_service.ts` does not import this file, so the dependency direction is safe. + */ +export const ADVERTISED_LLM_MODELS = advertisedModelIds() as [string, ...string[]]; + +// ── Pricing ── +// Lives here, beside the schemas, for the same reason `PRICE_ATOMIC` lives in +// `search_schemas.ts`: consumers that must not import a handler (the OpenAPI generator, +// the MCP tool table) still need the exact advertised ceiling. + +/** + * The token estimate a per-message price ceiling is derived from. Env-overridable for test + * environments; see `sc_llm_x402.ts` for the full rationale of the value. + */ +const MAX_TOKENS_PER_MESSAGE = process.env.LLM_ESTIMATED_TOKENS_PER_MESSAGE ?? "20000"; + +/** The provider the ceiling is priced on — see `llm_service.ts`'s LLM_PROVIDERS. */ +const LLM_PROVIDER = "mistral"; + +// No real prompt/completion split exists yet for the ceiling, so price the entire +// estimate as completion (output) tokens — the pricier of the two rates for a +// provider with an asymmetric split like Mistral's. This guarantees the ceiling is +// never an underestimate relative to whatever the real split turns out to be; +// `getSettleAmount`'s cap in `sc_llm_x402.ts` still protects the ceiling from ever +// being exceeded. +// +// One ceiling per token, each priced on the provider's own rate card in that currency — three +// parallel price systems, no conversion (see llm_service.ts). EURe's ceiling is the same EUR +// arithmetic rescaled to 18 decimals (tokensToCost's `decimals`). +const maxPriceFor = (symbol: SettlementTokenSymbol): string => + tokensToCost( + { prompt_tokens: 0, completion_tokens: MAX_TOKENS_PER_MESSAGE }, + LLM_PROVIDER, + symbol, + TOKEN_DECIMALS[symbol], + ).toString(); + +/** The per-message price ceiling in each stablecoin's own atomic units — the 402's price. */ +export const MAX_PRICE_ATOMIC: PriceList = { + USDC: maxPriceFor("USDC"), + EURC: maxPriceFor("EURC"), + EURe: maxPriceFor("EURe"), +}; + +/** + * Real, usage-derived charge for this message in `symbol`'s atomic units, from that currency's + * rate card, capped at the same currency's pre-authorized ceiling (`MAX_PRICE_ATOMIC`) — a + * response that somehow runs over the estimate still settles for the max rather than aborting + * with cap_exceeded; the difference is absorbed as under-billing, not a fund-safety issue (the + * client is always protected by the voucher's signed ceiling). + * + * Shared by the REST chat (`sc_llm_x402.ts`) and the MCP `chat_completion` tool — both settle the + * real usage, never the flat ceiling they advertise. + */ +export function getSettleAmount( + usage: { prompt_tokens: number; completion_tokens: number }, + symbol: SettlementTokenSymbol, +): string { + const actualCost = tokensToCost(usage, LLM_PROVIDER, symbol, TOKEN_DECIMALS[symbol]); + const maxCost = BigInt(MAX_PRICE_ATOMIC[symbol]); + return (actualCost > maxCost ? maxCost : actualCost).toString(); +} + +// ── Tools ── + +/** Bytes, not a tool count: what is billed is the serialized definitions, on every hop. A count + * cap on top of this bounded nothing the byte cap does not already bound. */ +export const MAX_TOOLS_BYTES = 8192; + +/** One tool call the model wants made. `arguments` is a JSON *string*, per OpenAI. */ +export const LLMToolCallSchema = z.looseObject({ + id: z.string().describe("Echo this back as tool_call_id on the matching tool result message."), + type: z.literal("function"), + function: z.looseObject({ + name: z.string(), + arguments: z.string().describe("JSON-encoded arguments, as a string."), + }), +}); + +/** + * Tool definitions a caller may offer the model. `function.parameters` stays loose: it is a JSON + * Schema the upstream model owns. + * + * The byte cap is a `.refine()`, which `z.toJSONSchema` does not render, so it is restated in the + * `.describe()` on the request field. We enforce slightly more than the JSON Schema alone + * publishes — the safe direction, and the prose closes the gap for a reader. + */ +export const LLMToolsSchema = z + .array( + z.looseObject({ + type: z.literal("function"), + function: z.looseObject({ + name: z.string().min(1), + description: z.string().optional(), + parameters: z.looseObject({}).optional(), + }), + }), + ) + .refine((tools) => Buffer.byteLength(JSON.stringify(tools), "utf8") <= MAX_TOOLS_BYTES, { + error: `tools must serialize to at most ${MAX_TOOLS_BYTES} bytes`, + }); + +// ── Request ── + +/** + * Byte cap on the serialized `messages` array, for the same reason `tools` has one: conversation + * turns are input tokens charged on every hop, and the per-message charge is capped at a fixed + * ceiling (see `USDC_MAX_PRICE_PER_MESSAGE` in `sc_llm_x402.ts`), so an uncapped conversation is + * billed at a flat rate no matter what it costs us. `tools` was capped and this was not, which + * left the far larger of the two open: a request filling the model's context window cost several + * times the ceiling it was charged, repeatably. + * + * Sized from the ceiling: 20000 output tokens ≈ 30000 atomic USDC, which at the $0.50/M *input* + * rate buys ~60,000 input tokens ≈ ~240 KB of English. 192 KB stays under that with room for + * `tools` (8 KB) and the system turn. Raised from 64 KB together with the ceiling, for multi-hop + * web research; the two move together or the cap stops matching what is charged. + * + * Enforced as a byte count rather than a character count because that is what the upstream bills + * against; a `.refine()` here would not survive `z.toJSONSchema`, so the handler checks it and + * the `messages` field restates it in prose below. + */ +export const MAX_MESSAGES_BYTES = 196_608; + +export const LLMChatMessageSchema = z + .looseObject({ + role: z + .string() + .describe( + 'The speaker\'s role. Forwarded to the upstream model as sent — deliberately not restricted to the OpenAI "system"/"user"/"assistant" set, so a role the upstream adds later (e.g. "tool") works without a schema change, matching how the handler validates it.', + ), + content: z + .string() + .nullable() + .optional() + .describe( + "The message text. Required except on an assistant turn that carries tool_calls, where it is null.", + ), + tool_calls: z + .array(LLMToolCallSchema) + .min(1) // an assistant turn either carries a tool call or omits the key — never [] + .nullable() // a caller replaying an upstream turn verbatim sends the null; see upstream_schemas.ts + .optional() + .describe("Present when replaying an assistant turn that requested tool calls."), + tool_call_id: z + .string() + .optional() + .describe("On a role:'tool' message, the id of the call this message answers."), + }) + .describe("One turn of the conversation.") + // Array.isArray, not `!== undefined`: null !== undefined is true, so that check would accept + // a genuinely empty {content: null, tool_calls: null} turn now that tool_calls is nullable. + .refine((m) => (m.content !== null && m.content !== undefined) || Array.isArray(m.tool_calls), { + error: "message must have content unless it carries tool_calls", + }); + +/** + * The params rejected with a 400 because they move cost past the metered ceiling. + * + * One list, three consumers: the schema fields below (`doc`), the handler's rejection loop in + * `sc_llm_x402.ts`, and the generated 400 description and x-guidance (`specPhrase`/`name`). `n` + * and `max_tokens` once became 400 causes the published spec never mentioned; this is what stops + * that recurring. + */ +export interface RejectedParam { + readonly name: string; + readonly predicate: (value: unknown) => boolean; + readonly code: string; + /** OpenAI error `param`. Omitted for stream, whose wire shape predates the param field. */ + readonly errorParam?: string; + readonly message: string; + /** The schema field's `.describe()` — published UI, hence separate copy from `message`. */ + readonly doc: string; + readonly specPhrase: string; +} + +// Annotated rather than `as const satisfies`, which narrows each entry to its own literal type and +// leaves `errorParam` missing from the `stream` member of the union. +export const REJECTED_PARAMS: readonly RejectedParam[] = [ + { + name: "stream", + predicate: (v: unknown) => v === true, + code: "stream_unsupported", + message: "Streaming (stream: true) is not supported by this endpoint.", + doc: "Streaming is not supported. Each message settles on its final token usage, which requires the whole completion, so stream:true is rejected rather than silently buffered.", + specPhrase: "stream:true", + }, + { + name: "n", + predicate: (v: unknown) => v !== undefined && v !== 1, + code: "unsupported_value", + errorParam: "n", + message: + "Only n=1 is supported. Each message is metered against a fixed per-message price ceiling, and additional completions multiply output tokens past it.", + doc: "Only n=1 is supported. Each message is metered against a fixed per-message price ceiling, and additional completions multiply output tokens past it.", + specPhrase: "n other than 1", + }, + { + name: "max_tokens", + predicate: (v: unknown) => v !== undefined, + code: "unsupported_value", + errorParam: "max_tokens", + message: + "'max_tokens' is not supported. Output length is bounded by the per-message price ceiling this endpoint meters against, not by a caller-supplied limit.", + doc: "Not supported. Output length is bounded by the per-message price ceiling this endpoint meters against, not by a caller-supplied limit.", + specPhrase: "any max_tokens", + }, +]; + +function rejected(name: string): RejectedParam { + const param = REJECTED_PARAMS.find((p) => p.name === name); + if (!param) { + throw new Error(`No REJECTED_PARAMS entry named '${name}'`); + } + return param; +} + +export const LLMChatRequestSchema = z + .looseObject({ + model: z + .enum(ADVERTISED_LLM_MODELS) + .describe( + "The model to use. Only the advertised model id(s) are served; others return model_not_found.", + ), + messages: z + .array(LLMChatMessageSchema) + .min(1) + .describe( + `The conversation so far. At most ${MAX_MESSAGES_BYTES} bytes serialized — conversation turns are input tokens charged on every hop, and this endpoint meters each message against a fixed price ceiling, so an uncapped conversation would be billed far below what it costs to serve.`, + ), + // Only the prose comes from REJECTED_PARAMS; the constraints must stay literal, because + // OWN_REQUEST_KEYS reads Object.keys(shape) and z.infer would lose the field types. + stream: z.literal(false).optional().describe(rejected("stream").doc), + n: z.literal(1).optional().describe(rejected("n").doc), + max_tokens: z.never().optional().describe(rejected("max_tokens").doc), + tools: LLMToolsSchema.optional().describe( + `Tool definitions offered to the model, OpenAI shape. At most ${MAX_TOOLS_BYTES} bytes serialized — tool definitions are input tokens charged on every hop, so an uncapped array inflates the metered cost. A tool call comes back as choices[].message.tool_calls with finish_reason: "tool_calls"; execute it and send the result back as a role:"tool" message. This endpoint never calls a tool itself.`, + ), + tool_choice: z + .enum(["auto", "none"]) + .optional() + .describe( + 'Whether the model may call a tool. Only "auto" and "none" are served; a forced or named choice is not.', + ), + useDummyData: z + .boolean() + .optional() + .describe( + "Vendor extension (not OpenAI): forces a mock completion. Testnet networks always mock regardless. The mock is tool-aware — offered a tool with no result in the conversation yet it returns a tool_calls turn, and once a role:'tool' message is present it answers with text — so the whole tool loop can be exercised without a billed completion.", + ), + payment: z + .unknown() + .optional() + .describe( + "Vendor extension: x402 payment payload, as a body fallback for clients that cannot set the PAYMENT-SIGNATURE / X-PAYMENT header. Passed through as received; validated by @x402/evm, not here.", + ), + }) + .describe( + "OpenAI chat-completions request body, plus this agent's vendor extensions. Unlisted fields are forwarded to the upstream model rather than dropped or rejected — except stream, n and max_tokens, which are rejected with a 400 because they move cost past the metered per-message ceiling.", + ); + +export type LLMChatRequest = z.infer; + +/** + * Keys declared above that must still be sent upstream. + * + * `sc_llm_x402.ts` strips every declared key from the bag it forwards, on the principle that a + * declared key is one this endpoint handles itself. `tools`/`tool_choice` are the exception — the + * model is the thing that has to see them. Without this set they would be silently dropped and the + * caller charged for a completion that ignored their tools. + */ +export const FORWARDED_OWN_KEYS = new Set(["tools", "tool_choice"]); + +// ── Response ── + +export const LLMChatResponseSchema = z + .object({ + id: z.string(), + object: z.literal("chat.completion"), + created: z.number().int().describe("Unix timestamp (seconds)."), + model: z.string(), + choices: z.array( + z.object({ + index: z.number().int(), + message: z.object({ + role: z.string(), + content: z + .string() + .nullable() + .describe("The generated reply, or null when the model is requesting a tool call."), + tool_calls: z + .array(LLMToolCallSchema) + .min(1) + .optional() + .describe( + "Present only when finish_reason is tool_calls. Execute them and send the results back as role:'tool' messages carrying the matching tool_call_id.", + ), + }), + finish_reason: z.string().nullable(), + }), + ), + usage: z + .object({ + prompt_tokens: z.number().int(), + completion_tokens: z.number().int(), + total_tokens: z.number().int(), + }) + .describe("Token usage; the settled charge is derived from it."), + }) + .describe("OpenAI chat.completion object."); + +export type LLMChatResponse = z.infer; diff --git a/bunny_js/src/llm_service.ts b/bunny_js/src/llm_service.ts new file mode 100644 index 000000000..f21d79c33 --- /dev/null +++ b/bunny_js/src/llm_service.ts @@ -0,0 +1,343 @@ +import type { SettlementTokenSymbol } from "@fretchen/chain-utils"; +import { logger } from "./logger.js"; +import { UpstreamChatCompletionSchema, flattenUpstreamContent } from "./upstream_schemas.js"; + +interface LLMProviderConfig { + displayName: string; // for error messages/logs — e.g. "Could not reach Mistral: ..." + baseUrl: string; // no trailing "/chat/completions" — appended at call time + defaultModel: string; + apiKeyEnvVar: string; + // Price per 1,000,000 tokens, num/den to stay exact bigint math — one rate card per + // settlement token, each as the provider publishes it in that currency. Parallel price + // systems: a EURC or EURe cost is computed from the EUR card, never converted from the USD one. + pricePerMillion: Record; +} + +interface RatePerMillion { + num: bigint; + den: bigint; +} + +const LLM_PROVIDERS: Record = { + mistral: { + displayName: "Mistral", + baseUrl: "https://api.mistral.ai/v1", + defaultModel: "mistral-large-latest", + apiKeyEnvVar: "MISTRAL_API_KEY", + // Mistral Large 3, mistral.ai/pricing/api — re-verify before any mainnet cutover; Mistral + // has repriced materially before. The EUR card is the page's own EUR view (behind its + // currency toggle), read 2026-09-26; it is not a conversion of the USD card. + pricePerMillion: { + USDC: { input: { num: 50n, den: 100n }, output: { num: 150n, den: 100n } }, // $0.50 / $1.50 (2026-07-21) + EURC: { input: { num: 44n, den: 100n }, output: { num: 150n, den: 100n } }, // €0.44 / €1.50 (2026-09-26) + // Same euro card as EURC: EURe is a euro like EURC, so the provider's EUR prices apply + // unchanged — only the atomic scale differs (18 decimals, passed by the caller). + EURe: { input: { num: 44n, den: 100n }, output: { num: 150n, den: 100n } }, // €0.44 / €1.50 + }, + }, +}; + +function getLLMProviderConfig(provider: string): LLMProviderConfig { + const config = LLM_PROVIDERS[provider]; + if (!config) { + throw new Error( + `Unknown LLM provider: ${provider}. Valid providers: ${Object.keys(LLM_PROVIDERS).join(", ")}`, + ); + } + return config; +} + +/** One tool call the model wants made. `arguments` is a JSON *string*, per OpenAI. */ +export interface LLMToolCall { + id: string; + type: "function"; + function: { name: string; arguments: string }; +} + +/** + * `content` is nullable and `tool_calls` optional so an assistant turn that requests a tool can be + * replayed back to the model on the next hop. A tool *result* turn is `{ role: "tool", + * tool_call_id, content }` — `role` is unconstrained, so it needs no separate variant. + */ +export interface LLMMessage { + role: string; + content?: string | null; + tool_calls?: LLMToolCall[]; + tool_call_id?: string; +} + +export interface LLMUsage { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; +} + +/** + * An OpenAI chat-completion response object. Mistral (and the other OpenAI-compatible + * upstreams in LLM_PROVIDERS) already return this shape, so we forward it largely as-is + * rather than unwrapping it — the endpoint's public contract is the OpenAI chat-completions + * shape. `usage` is load-bearing: the batch-settlement handler prices the settled amount + * from it (see getSettleAmount in sc_llm_x402.ts). + */ +export interface LLMResponse { + id: string; + object: "chat.completion"; + created: number; + model: string; + choices: Array<{ + index: number; + message: { role: string; content: string | null; tool_calls?: LLMToolCall[] }; + finish_reason: string | null; + }>; + usage: LLMUsage; +} + +/** + * Resolve an OpenAI-style `model` id (as sent by a caller in the request body) to the + * internal provider whose config serves it. Returns `null` for an unknown/unsupported model + * so the handler can answer with an OpenAI-shaped `model_not_found` error. One provider is + * registered today; adding another means an entry in LLM_PROVIDERS and its id in + * advertisedModelIds(). + */ +export function resolveModel(modelId: string): { provider: string } | null { + for (const [provider, config] of Object.entries(LLM_PROVIDERS)) { + if (config.defaultModel === modelId) { + return { provider }; + } + } + return null; +} + +/** The model ids this endpoint currently advertises + serves (for OpenAPI enum + validation). */ +export function advertisedModelIds(): string[] { + return [LLM_PROVIDERS.mistral.defaultModel]; +} + +/** + * The stand-in for the provider, used on testnet and whenever a caller sends `useDummyData: true`. + * + * Input-aware rather than a fixed string: the endpoint behaviour worth exercising around a tool + * call — handing `tool_calls` back to the client, then accepting a `role: "tool"` follow-up — is + * only reachable if the stand-in actually asks for a tool. With a fixed text answer, a tool loop + * could only be driven against real, billed Mistral, which rules it out for the local dev server + * the frontend loop is built against and for `notebooks/sc_llm_x402_buyer.ipynb` on testnet. + * + * Returns a *raw* upstream shape rather than a finished LLMResponse, so it flows through the same + * schema + rebuild as a real completion below. That is what lets the mock cover the two things + * that otherwise need a mainnet run: the schema accepting `content: null`, and the rebuild + * preserving `tool_calls`. `created` and the per-choice `index`/`role` are omitted on purpose, to + * exercise the synthesis a real provider's omissions would. + */ +function buildMockUpstreamResponse( + prompt: LLMMessage[], + forwardedParams: Record, +): unknown { + const tools = forwardedParams.tools; + const firstTool = Array.isArray(tools) + ? (tools[0] as { function?: { name?: unknown } } | undefined) + : undefined; + const toolName = typeof firstTool?.function?.name === "string" ? firstTool.function.name : null; + const toolAlreadyRan = prompt.some((m) => m.role === "tool"); + + // Identical in both branches, so getSettleAmount — and the settlement assertions built on it — + // do not depend on which branch ran. + const usage = { prompt_tokens: 5, completion_tokens: 15, total_tokens: 15 }; + + // A tool was offered and nothing has come back yet: ask for it. `arguments` is deliberately an + // empty JSON object — the endpoint never checks tool-call arguments against the tool's own + // `parameters` (that is the caller's job), so inventing plausible ones would be theatre. + if (toolName && !toolAlreadyRan) { + return { + id: "chatcmpl-mock", + model: "placeholder-model", + choices: [ + { + message: { + content: null, + tool_calls: [ + { + id: "call_mock_1", + type: "function", + function: { name: toolName, arguments: "{}" }, + }, + ], + }, + finish_reason: "tool_calls", + }, + ], + usage, + }; + } + + const content = toolAlreadyRan + ? "**Placeholder response**\n\n" + + "The tool result came back, so this is the *mock* answer that would normally summarise it." + : "**Placeholder response**\n\n" + + "This is a *mock* answer used for local/testnet testing. It includes:\n\n" + + "- a list item\n" + + "- some `inline code`\n" + + "- a [link](https://example.com)\n\n" + + "```js\nconsole.log('mock code block');\n```"; + + return { + id: "chatcmpl-mock", + model: "placeholder-model", + choices: [{ message: { content }, finish_reason: "stop" }], + usage, + }; +} + +export async function callLLMAPI( + prompt: LLMMessage[], + dummy = false, + provider = "mistral", + forwardedParams: Record = {}, +): Promise { + const config = getLLMProviderConfig(provider); + + if (!prompt || !prompt.length) { + throw new Error("No prompt provided."); + } + + // The mock and the real provider converge on one validated tail below, rather than the mock + // short-circuiting with a finished envelope. Sharing the tail is the point: a regression in the + // schema or the rebuild now fails on the mock path too, where it is free to catch. + let raw: unknown; + if (dummy) { + logger.info({ provider }, "Using mock upstream completion"); + raw = buildMockUpstreamResponse(prompt, forwardedParams); + } else { + const apiToken = process.env[config.apiKeyEnvVar]; + logger.info({ provider }, "Work with real API"); + if (!apiToken) { + throw new Error( + `API token not found. Please configure the ${config.apiKeyEnvVar} environment variable.`, + ); + } + + logger.debug({ prompt }, "Generating answer for prompt"); + + // Forward the caller's remaining chat params to the upstream model rather than dropping them — + // `temperature`, `top_p`, `stop`, `seed` and the like cost us nothing and are the upstream's + // contract to honour. Our own `model` and `messages` are written last so they always win: the + // model id is resolved against what we actually serve, and `messages` has already been + // validated. sc_llm_x402.ts strips the params that would move cost past the metered ceiling + // before we get here (see its deny-list), so anything still present is safe to pass on. + const body = { ...forwardedParams, model: config.defaultModel, messages: prompt }; + + logger.debug("Sending answer generation request..."); + const response = await fetch(`${config.baseUrl}/chat/completions`, { + method: "POST", + headers: { Authorization: `Bearer ${apiToken}`, "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + logger.error( + { provider, status: response.status, statusText: response.statusText }, + "LLM API error", + ); + throw new Error( + `Could not reach ${config.displayName}: ${response.status} ${response.statusText}`, + ); + } + + raw = await response.json(); + } + + // Upstream is OpenAI-compatible; forward its chat-completion envelope, synthesizing only + // the fields a given provider might omit (id/created/object) so our response is a + // well-formed OpenAI chat.completion regardless of upstream quirks. + // + // Validated rather than cast: `usage` feeds getSettleAmount() in sc_llm_x402.ts, which runs + // after the try/catch around this function has closed, so a non-numeric prompt_tokens threw out + // of handle() as an unhandled rejection. The schema subsumes the old + // `!firstChoice || !data.usage` guard. + const parsed = UpstreamChatCompletionSchema.safeParse(raw); + if (!parsed.success) { + const upstreamModel = (raw as { model?: unknown } | null)?.model; + logger.error( + { provider, issues: parsed.error.issues }, + "Upstream returned a completion we cannot bill from", + ); + throw new Error( + `LLM API returned an incomplete completion (model: ${ + typeof upstreamModel === "string" ? upstreamModel : config.defaultModel + })`, + ); + } + const data = parsed.data; + return { + id: data.id ?? `chatcmpl-${Date.now()}`, + object: "chat.completion", + created: data.created ?? Math.floor(Date.now() / 1000), + model: data.model ?? config.defaultModel, + choices: data.choices.map((c, i) => ({ + index: c.index ?? i, + message: { + role: c.message.role ?? "assistant", + // Upstream may send a string, an array of content parts, or null — our published + // envelope says `string | null`, so normalize rather than republish the variation. + content: flattenUpstreamContent(c.message.content), + // Conditional, so an ordinary completion keeps its exact previous shape instead of + // serializing a `tool_calls: undefined` key. `message` is rebuilt field by field, so + // anything not named here is dropped — which is how tool_calls used to vanish while + // finish_reason: "tool_calls" survived, producing self-contradictory JSON. + ...(c.message.tool_calls ? { tool_calls: c.message.tool_calls } : {}), + }, + finish_reason: c.finish_reason ?? "stop", + })), + usage: data.usage, + }; +} + +function parseTokenCount(tokenCount: bigint | number | string): bigint { + if (typeof tokenCount === "bigint") { + return tokenCount; + } + if (typeof tokenCount === "number") { + if (!Number.isFinite(tokenCount) || tokenCount < 0) { + throw new TypeError("tokenCount must be a non-negative finite number when given as number"); + } + return BigInt(Math.floor(tokenCount)); + } + if (typeof tokenCount === "string" && /^\d+$/.test(tokenCount)) { + return BigInt(tokenCount); + } + throw new TypeError("tokenCount must be a bigint, number, or numeric string"); +} + +/** + * Cost of `usage` in `symbol`'s atomic units, from the provider's rate card in that currency, + * pricing prompt and completion tokens separately — providers typically charge more + * for completion (output) tokens than prompt (input) tokens, so a single blended rate would + * systematically mis-price a provider with an asymmetric split (e.g. Mistral: $0.50/M input vs + * $1.50/M output — a 3x gap. See LLM_PROVIDERS above). + * + * Prices are quoted per 1,000,000 tokens and USDC/EURC have 6 decimals, so for those two the + * 1e6 factors cancel exactly and the raw cross-product is already atomic. `decimals` rescales + * that for anything else: EURe's 18 decimals multiply the result by 10¹² — forgetting this + * would undercharge EURe messages by twelve orders of magnitude, silently. A card is read as + * 1 USD = 1 USDC and 1 EUR = 1 EURC = 1 EURe. + */ +export function tokensToCost( + usage: { + prompt_tokens: bigint | number | string; + completion_tokens: bigint | number | string; + }, + provider: string, + symbol: SettlementTokenSymbol, + decimals: number, +): bigint { + const config = getLLMProviderConfig(provider); + const p = parseTokenCount(usage.prompt_tokens); + const c = parseTokenCount(usage.completion_tokens); + const { input, output } = config.pricePerMillion[symbol]; + const { num: inNum, den: inDen } = input; + const { num: outNum, den: outDen } = output; + // Cross-multiply to keep one shared denominator instead of assuming inDen === outDen. + // No explicit 1e6 factor here — the "per 1,000,000 tokens" divisor and the tokens' 6 + // decimals cancel exactly; `decimals` then rescales from that 6-decimal base. + const base = (p * inNum * outDen + c * outNum * inDen) / (inDen * outDen); + return decimals === 6 ? base : base * 10n ** BigInt(decimals - 6); +} diff --git a/bunny_js/src/logger.ts b/bunny_js/src/logger.ts new file mode 100644 index 000000000..8472bec11 --- /dev/null +++ b/bunny_js/src/logger.ts @@ -0,0 +1,30 @@ +/** + * bunny_js's stand-in for scw_js/logger.ts. scw_js builds one pino instance; pino is a + * Node-runtime library (worker transports, stdout handles), so it does not belong in a + * Deno isolate bundle. The call surface is kept pino-shaped — `logger.info(obj, msg)`, + * `logger.error({ err }, msg)` — because the ported files use that convention verbatim. + * + * The `logger.error` convention (observability alert rules key on it) does not carry over + * to bunny: the edge script's logs live in the bunny dashboard, not Loki, and shipping + * structured logs out is a documented follow-up if this twin graduates. + */ + +function emit(level: string, arg: unknown, maybeMsg?: string): void { + const [obj, msg] = typeof arg === "string" ? [{}, arg] : [(arg as object) ?? {}, maybeMsg ?? ""]; + // pino prints `level then message then context`; JSON-lines keeps the same fields. + const line = JSON.stringify({ level, time: Date.now(), msg, ...obj }); + if (level === "error") { + console.error(line); + } else if (level === "warn") { + console.warn(line); + } else { + console.log(line); + } +} + +export const logger = { + info: (arg: unknown, msg?: string) => emit("info", arg, msg), + warn: (arg: unknown, msg?: string) => emit("warn", arg, msg), + error: (arg: unknown, msg?: string) => emit("error", arg, msg), + debug: (arg: unknown, msg?: string) => emit("debug", arg, msg), +}; diff --git a/bunny_js/src/openapi.search.json b/bunny_js/src/openapi.search.json new file mode 100644 index 000000000..5c32745ad --- /dev/null +++ b/bunny_js/src/openapi.search.json @@ -0,0 +1,203 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Fretchen Web Access Service", + "description": "Web search and single-page fetch for agents, paid per call via x402 batch-settlement payment channels in USDC (or EURC on Base).", + "version": "1.0.0", + "x-guidance": "GET /search?q=... or GET /fetch?url=... with no payment header to receive a 402 carrying x402 batch-settlement payment requirements (accepts[]). Open or top up a payment channel per those requirements, retry with the payment header, and the service returns JSON. Payment travels in the header only — these are GET routes, so there is no body fallback. Both routes bill onto the SAME channel as llm-agent.fretchen.eu when the payer, voucher signer and receiver match, so an agent already chatting there pays for web access without a second deposit. Prices are fixed per route and settled only when the call succeeds: an upstream failure returns 500 and takes no money. Mainnet only — testnet USDC is free and these routes spend real money on the caller's behalf.", + "contact": { + "name": "fretchen", + "url": "https://www.fretchen.eu", + "email": "fretchen.dev@proton.me" + } + }, + "x-discovery": { + "ownershipProofs": [ + "0xb73eb61a6660f671483c3ce7f1faebd7490af0db5dbcbdb3a38d8f9e6c0a14aa781437e9bd3ef56edcc6bc1b063cdff844c18fe05307afb2198c5b3774208e4b1b" + ] + }, + "x-service-type": "web/v1", + "x-interop-floor": "A compatible web/v1 agent MUST offer GET /search taking a q query parameter, MUST offer GET /fetch taking an https url query parameter, MUST return JSON, and MUST advertise at least one accepts[] entry with asset USDC on network Optimism (eip155:10) or Base (eip155:8453), scheme batch-settlement. Request shapes are defined by this document's SearchQuery/FetchQuery. Sharing a payment channel with a chat agent is NOT part of the floor — it follows from the channel id being a hash of payer, authorizers, receiver, token and withdraw delay, so any agent configured alike shares one. See README.md.", + "servers": [ + { + "url": "https://web-agent.fretchen.eu" + } + ], + "tags": [ + { + "name": "Web", + "description": "Search and single-page retrieval for agents" + }, + { + "name": "x402", + "description": "Paid via x402 batch-settlement payment channels in USDC, EURC or EURe on Base" + } + ], + "paths": { + "/search": { + "get": { + "operationId": "searchWeb", + "summary": "Web search with extracted page content (Brave LLM Context API) (x402: 0.01 USDC, or 0.01 EURC, or 0.01 EURe on Base)", + "description": "Proxies Brave's LLM Context API and returns search results with extracted page content. The cost knobs (result count, tokens per page, number of pages fetched) are pinned server-side and ignored if passed — the budget is the proxy's, not the caller's.", + "tags": ["Web", "x402"], + "security": [], + "x-payment-info": { + "protocols": ["x402"], + "price": { + "mode": "fixed", + "currency": "USD", + "amount": "0.01" + } + }, + "parameters": [ + { + "name": "q", + "in": "query", + "required": true, + "description": "The search query. Result count and token budgets are fixed server-side.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 600 + } + } + ], + "responses": { + "200": { + "description": "Web search with extracted page content (Brave LLM Context API)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchQueryResponse" + } + } + } + }, + "400": { + "description": "The query parameters did not validate. Body is { error } with a message the caller can correct from." + }, + "402": { + "description": "Payment required. Body and the Payment-Required header carry the x402 batch-settlement requirements to satisfy and retry with." + }, + "404": { + "description": "No such route. Returned before any price is advertised — a resource that does not exist has no price." + }, + "500": { + "description": "The upstream failed. Nothing is settled in this case: the payment is verified before the work and settled only after it succeeds." + } + } + } + }, + "/fetch": { + "get": { + "operationId": "fetchUrl", + "summary": "The readable text of one public web page (x402: 0.001 USDC, or 0.001 EURC, or 0.001 EURe on Base)", + "description": "Retrieves one public web page and returns its readable text. Only https is accepted, and every resolved address is checked: a payment authorises a fetch, not a fetch of an internal address. Redirects are followed with per-hop re-validation, and size and content-type are capped.", + "tags": ["Web", "x402"], + "security": [], + "x-payment-info": { + "protocols": ["x402"], + "price": { + "mode": "fixed", + "currency": "USD", + "amount": "0.001" + } + }, + "parameters": [ + { + "name": "url", + "in": "query", + "required": true, + "description": "Absolute https URL. Other schemes and non-public hosts are rejected with 400.", + "schema": { + "type": "string", + "minLength": 1 + } + } + ], + "responses": { + "200": { + "description": "The readable text of one public web page", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FetchQueryResponse" + } + } + } + }, + "400": { + "description": "The query parameters did not validate. Body is { error } with a message the caller can correct from." + }, + "402": { + "description": "Payment required. Body and the Payment-Required header carry the x402 batch-settlement requirements to satisfy and retry with." + }, + "404": { + "description": "No such route. Returned before any price is advertised — a resource that does not exist has no price." + }, + "500": { + "description": "The upstream failed. Nothing is settled in this case: the payment is verified before the work and settled only after it succeeds." + } + } + } + }, + "/openapi.json": { + "get": { + "operationId": "openapiSpec", + "summary": "This document", + "tags": ["x402"], + "responses": { + "200": { + "description": "This OpenAPI document.", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "SearchQuery": { + "type": "object", + "properties": { + "q": { + "type": "string", + "minLength": 1, + "maxLength": 600, + "description": "The search query. Result count and token budgets are fixed server-side." + } + }, + "required": ["q"], + "additionalProperties": {} + }, + "FetchQuery": { + "type": "object", + "properties": { + "url": { + "type": "string", + "minLength": 1, + "description": "Absolute https URL. Other schemes and non-public hosts are rejected with 400." + } + }, + "required": ["url"], + "additionalProperties": {} + }, + "SearchQueryResponse": { + "type": "object", + "description": "Brave LLM Context results. Shape is the provider's and is validated but not re-modelled here; see search_service.ts.", + "additionalProperties": true + }, + "FetchQueryResponse": { + "type": "object", + "description": "The page's readable text and the metadata extracted alongside it.", + "additionalProperties": true + } + } + } +} diff --git a/bunny_js/src/routes.ts b/bunny_js/src/routes.ts new file mode 100644 index 000000000..5ece6f0db --- /dev/null +++ b/bunny_js/src/routes.ts @@ -0,0 +1,442 @@ +/** + * The searchapi x402 seller's routing, natively against web-standard Request/Response — + * the bunny twin of scw_js/search_api.ts. The payment and service layers underneath are + * ported verbatim; this file is the only genuinely new code, a line-by-line mirror of the + * Scaleway handler's route table, ordering rules and payment comments. + * + * Routing order is load-bearing and copied exactly: + * OPTIONS preflight → discovery (agent-registration, openapi.json, favicon.png, root + * HTML) → 404 for unknown paths (never a 402: a nonexistent resource has no price) → + * unpaid GET quotes the challenge whatever its query says → paid GET validates the + * query BEFORE verify/settle (verify takes the shared channel's lock; a request that + * fails after it never settles, orphaning the lock for MAX_TIMEOUT_SECONDS). + */ +import { logger } from "./logger.js"; +import { offeredStablecoins, resolvePaidStablecoin } from "./stablecoin_pricing.js"; +import { searchWeb, QueryError } from "./search_service.js"; +import { fetchExternalHtml, FetchUrlError, parseHttpsUrl } from "./web_fetch_service.js"; +import { + DESCRIPTION, + FetchQuerySchema, + PRICE_ATOMIC, + ROUTES, + SearchQuerySchema, + type Route, +} from "./search_schemas.js"; +import { CORS_HEADERS } from "./utils.js"; +import { FAVICON_DISCOVERY_HTML, wantsHtml } from "./discovery.js"; +import { AGENT_REGISTRATION_PATH, buildAgentRegistration } from "./agent_registration.js"; +import { faviconBase64, faviconContentType } from "./favicon.js"; +import openapiSpec from "./openapi.search.json" with { type: "json" }; +import { buildSearchBazaarExtension } from "./bazaar_discovery.js"; +import { + create402Response, + createBatchSettlementPaymentRequirements, + createLLMResourceServer, + createSettlementHeaders, + extractPaymentPayload, + releaseLock, + type HttpResponse, + type SdkPaymentPayload, + type SdkPaymentRequirements, +} from "./x402_server.js"; + +/** + * Mainnet only, unlike the chat, which also accepts Base Sepolia. Testnet USDC is free, and + * both routes spend real money on the caller's behalf — Brave bills per query, and /fetch is + * egress. Advertising a testnet would hand anyone a metered API for nothing. The cost is + * that the paid path cannot be exercised on a testnet at all: at $0.001 a call, a real + * run is cheaper than the setup it replaces. (Copied from scw_js/search_api.ts.) + */ +const MAINNET_NETWORKS = ["eip155:10", "eip155:8453"]; + +/** + * Advertised and verified lock TTL, a quarter of the chat's LLM_MAX_TIMEOUT_SECONDS. It + * drives the per-channel pendingRequest lock the SDK holds from verify until settle; both + * upstreams time out at 8 seconds, so 30 leaves ample margin — and because these routes + * share the chat's channel, a lock orphaned by an upstream failure blocks the user's next + * chat message. Must be identical at advertise and verify time. (Copied.) + */ +const MAX_TIMEOUT_SECONDS = 30; + +/** + * Base for the advertised `resource.url` — the production custom domain by default, so the + * twin's published terms match the Scaleway original; set SEARCH_SERVICE_URL to the + * bunny-provided URL when deploying the trial so the advertised origin names where the + * trial is actually served. + */ +const SERVICE_URL = process.env.SEARCH_SERVICE_URL ?? "https://web-agent.fretchen.eu"; + +/** The favicon as raw bytes — web Response bodies are bytes, not Scaleway base64 strings. */ +const FAVICON_BYTES = Uint8Array.from(atob(faviconBase64), (c) => c.charCodeAt(0)); + +function jsonResponse(statusCode: number, body: unknown): Response { + return new Response(JSON.stringify(body), { status: statusCode, headers: CORS_HEADERS }); +} + +/** + * A web-standard Response cannot carry non-latin1 header bytes (the Headers API rejects them + * as "not a valid ByteString"), and the 402's X-Payment / the 200's X-Payment-Response are + * raw JSON whose bazaar metadata contains em-dashes, arrows and ellipses. JSON \uXXXX escapes + * decode to the exact same string, so escaping non-latin1 codepoints keeps the header's + * meaning identical for any JSON-parsing client — the base64 Payment-Required / + * Payment-Response headers are ASCII and pass through untouched. + */ +function byteStringHeaderValue(value: string): string { + return value.replace(/[\u0100-\uFFFF]/g, (ch) => { + return `\\u${ch.charCodeAt(0).toString(16).padStart(4, "0")}`; + }); +} + +/** The ported x402 helpers still speak the {statusCode, headers, body} envelope. */ +function toResponse(http: HttpResponse): Response { + const headers: Record = {}; + for (const [key, value] of Object.entries(http.headers)) { + headers[key] = byteStringHeaderValue(value); + } + // The pull zone in front of this script is a real CDN; a cached paid 200 would replay a + // purchased result — with its Payment-Response receipt — to the next caller. The ported + // create402Response emits no Cache-Control (the Scaleway function had no CDN), so the + // conversion layer pins it. CORS_HEADERS covers the responses routes.ts builds directly. + headers["Cache-Control"] ??= "no-store"; + return new Response(http.body, { status: http.statusCode, headers }); +} + +function parsePath(rawPath: string): string { + return rawPath.replace(/^\/+/, ""); +} + +function isRoute(path: string): path is Route { + return (ROUTES as readonly string[]).includes(path); +} + +// --- Serving ------------------------------------------------------------------------------------ + +type Args = { route: "search"; q: string } | { route: "fetch"; url: string }; + +/** + * Validate the query string, or throw QueryError / FetchUrlError. + * + * Separate from `run` below so a paid request can be rejected **before** anything is verified + * or settled — verifyPayment takes the channel's pendingRequest lock, and a request that + * fails after it never settles, so the lock is orphaned for MAX_TIMEOUT_SECONDS on a channel + * the chat shares. Every check that can happen without I/O therefore happens here, including + * the url's scheme. One parameter per route means the first issue is the error, used + * verbatim. (Copied from scw_js/search_api.ts.) + */ +function parseArgs(route: Route, queryParams: Record): Args { + if (route === "search") { + const parsed = SearchQuerySchema.safeParse(queryParams); + if (!parsed.success) { + throw new QueryError(parsed.error.issues[0].message); + } + return { route, q: parsed.data.q }; + } + + const parsed = FetchQuerySchema.safeParse(queryParams); + if (!parsed.success) { + throw new QueryError(parsed.error.issues[0].message); + } + // The schema says "a non-empty string"; parseHttpsUrl says "a url we could actually fetch", + // and it is the same function assertPublicUrl runs later — the scheme rule has one home. + return { route, url: parseHttpsUrl(parsed.data.url).toString() }; +} + +function run(args: Args): Promise { + return args.route === "search" ? searchWeb(args.q) : fetchExternalHtml(args.url); +} + +/** + * A bad query or an unfetchable url is the caller's fault and worth saying precisely — the + * model corrects itself from these. Everything else is ours or the upstream's, and stays + * generic so an internal hostname or path cannot leak through an error string. (Copied.) + */ +function errorResponseFor(err: unknown): Response { + if (err instanceof QueryError || err instanceof FetchUrlError) { + return jsonResponse(400, { error: err.message }); + } + logger.error({ err }, "Request handler error"); + return jsonResponse(500, { error: "Internal server error" }); +} + +// --- The paid path ------------------------------------------------------------------------------ + +/** The network the payer built their payload against, or null if they named none. */ +function acceptedNetwork(payload: Record): string | null { + const accepted = payload["accepted"] as Record | undefined; + const network = accepted?.["network"]; + return typeof network === "string" ? network : null; +} + +/** + * Serve one route against a payment: verify, run, and settle **only if the run succeeded**. + * + * Settle-after-success is what makes an upstream failure free for the caller. It leaves a + * verify without a matching settle, which holds the channel lock for MAX_TIMEOUT_SECONDS — + * the reason that constant is 30 and not 120. (Copied from scw_js/search_api.ts.) + */ +async function servePaid( + route: Route, + args: Args, + payload: Record, + receiverAddress: `0x${string}`, +): Promise { + const network = acceptedNetwork(payload); + if (!network || !MAINNET_NETWORKS.includes(network)) { + return jsonResponse(402, { + error: `Unsupported or missing network. This endpoint accepts ${MAINNET_NETWORKS.join(", ")}`, + }); + } + + // Price the stablecoin the buyer's channel is in (see sc_llm_x402.ts) — checked before any + // server setup, since an unoffered asset is the caller's error, not ours. + const paidAsset = (payload["accepted"] as Record | undefined)?.["asset"]; + const coin = resolvePaidStablecoin(network, paidAsset); + if (!coin) { + return jsonResponse(402, { + error: `Unsupported payment asset on ${network}`, + accepted: offeredStablecoins(network).map((offered) => offered.address), + }); + } + + let resourceServer: ReturnType["resourceServer"]; + let scheme: ReturnType["scheme"]; + try { + ({ resourceServer, scheme } = createLLMResourceServer(receiverAddress)); + } catch (err) { + logger.error({ err }, "Failed to configure batch-settlement resource server"); + return jsonResponse(500, { error: "Internal server error" }); + } + + const baseRequirements: SdkPaymentRequirements = { + scheme: "batch-settlement", + network: network as `${string}:${string}`, + amount: PRICE_ATOMIC[route][coin.symbol], + asset: coin.address, + payTo: receiverAddress, + maxTimeoutSeconds: MAX_TIMEOUT_SECONDS, + extra: { name: coin.name, version: coin.version }, + }; + // The ENHANCED requirements, not the base ones: the facilitator's validateChannelConfig treats + // a missing extra.receiverAuthorizer as a mismatch rather than as "not required", so a raw + // object here makes every deposit fail with receiver_authorizer_mismatch. (Copied.) + const paymentRequirements = await scheme.enhancePaymentRequirements( + baseRequirements, + { + x402Version: 2, + scheme: "batch-settlement", + network: network as `${string}:${string}`, + extra: baseRequirements.extra, + }, + [], + ); + + let verification: { + isValid: boolean; + invalidReason?: string; + invalidMessage?: string; + payer?: string; + }; + try { + verification = await resourceServer.verifyPayment( + payload as SdkPaymentPayload, + paymentRequirements, + ); + } catch (err) { + logger.error({ err }, "Payment verification error"); + return jsonResponse(402, { error: `Payment verification failed: ${(err as Error).message}` }); + } + + if (!verification.isValid) { + logger.warn( + { reason: verification.invalidReason, message: verification.invalidMessage }, + "Payment verification failed", + ); + // Through the SDK rather than a hand-rolled body, so its response-time enrichment runs: for a + // cumulative-amount mismatch this attaches channelState/voucherState, which is what the + // client needs to resync and retry by itself instead of showing the user a dead channel. + const paymentRequired = await resourceServer.createPaymentRequiredResponse( + [paymentRequirements], + { + url: `${SERVICE_URL}/${route}`, + description: DESCRIPTION[route], + mimeType: "application/json", + }, + verification.invalidReason, + verification.payer ? { payer: verification.payer } : undefined, + undefined, + payload as SdkPaymentPayload, + ); + return toResponse(create402Response(paymentRequired)); + } + + let data: unknown; + try { + data = await run(args); + } catch (err) { + // Nothing is settled: an upstream that failed is not something to charge for. But verify took + // the channel lock, which the chat shares — release it, or the user's next message is + // rejected with channel_busy. (Copied.) + logger.warn({ err, route }, "Run failed after verify; lock released"); + await releaseLock(resourceServer, payload as SdkPaymentPayload, paymentRequirements); + return errorResponseFor(err); + } + + // Fixed price, so settle takes the same amount verify did. + let settlement: Record & { success: boolean; errorReason?: string }; + try { + settlement = await resourceServer.settlePayment( + payload as SdkPaymentPayload, + paymentRequirements, + ); + } catch (err) { + logger.error({ err }, "Settlement error"); + return jsonResponse(402, { error: `Settlement failed: ${(err as Error).message}` }); + } + + if (!settlement.success) { + logger.error({ settlement }, "Settlement failed"); + return jsonResponse(402, { + error: `Settlement failed: ${settlement.errorReason ?? "unknown"}`, + }); + } + + logger.info( + { route, network, asset: coin.symbol, amount: baseRequirements.amount }, + "Served and settled", + ); + return new Response(JSON.stringify(data), { + status: 200, + headers: { ...CORS_HEADERS, ...createSettlementHeaders(settlement) }, + }); +} + +/** The 402 challenge: what this route costs and where it may be paid. */ +async function challenge(route: Route, receiverAddress: `0x${string}`): Promise { + let scheme: ReturnType["scheme"]; + try { + ({ scheme } = createLLMResourceServer(receiverAddress)); + } catch (err) { + logger.error({ err }, "Failed to configure batch-settlement resource server"); + return jsonResponse(500, { error: "Internal server error" }); + } + + const paymentRequirements = await createBatchSettlementPaymentRequirements({ + // The route, not the full request: a client caches payment terms per resource url, and the + // terms are the same whatever the query says. (Copied.) + resourceUrl: `${SERVICE_URL}/${route}`, + description: DESCRIPTION[route], + mimeType: "application/json", + price: PRICE_ATOMIC[route], + payTo: receiverAddress, + scheme, + networks: MAINNET_NETWORKS, + maxTimeoutSeconds: MAX_TIMEOUT_SECONDS, + // One bazaar block per route: the block names the method and the query parameters, and the + // two routes sell different things at different prices. (Copied.) + extensions: buildSearchBazaarExtension(route), + serviceName: "Fretchen Web Access", + tags: ["search", "web", "x402"], + iconUrl: `${openapiSpec.servers[0].url}/favicon.png`, + }); + return toResponse(create402Response(paymentRequirements)); +} + +function isHexAddress(addr: unknown): addr is `0x${string}` { + return typeof addr === "string" && /^0x[a-fA-F0-9]{40}$/.test(addr); +} + +export async function handleRequest(request: Request): Promise { + // CORS preflight, answered before anything else — a browser sends it with neither an + // Authorization header nor a payment. (Copied ordering.) + if (request.method === "OPTIONS") { + return new Response(null, { status: 200, headers: CORS_HEADERS }); + } + + // Web Request headers are lowercase; every consumer in the ported stack + // (extractPaymentPayload, wantsHtml) matches case-insensitively. + const headers = Object.fromEntries(request.headers) as Record; + const method = request.method; + const url = new URL(request.url); + const path = parsePath(url.pathname); + const queryParams = Object.fromEntries(url.searchParams) as Record; + + // ── Discovery, answered before payment and before the route check ───────────────────────── + // Free, unauthenticated and unpaid. A price list nobody can read is not a price list: + // x402scan resolves an icon by fetching the origin ROOT and parsing + // before it ever probes /favicon.png, so the root HTML is what makes the crawler index + // this origin at all (see discovery.ts). (Copied.) + const isGetOrHead = method === "GET" || method === "HEAD"; + const isHead = method === "HEAD"; + + if (isGetOrHead && path === AGENT_REGISTRATION_PATH) { + return new Response( + isHead + ? null + : JSON.stringify( + buildAgentRegistration(openapiSpec, "search", process.env.NFT_WALLET_PUBLIC_KEY), + ), + { status: 200, headers: { ...CORS_HEADERS, "Content-Type": "application/json" } }, + ); + } + + if (isGetOrHead && path === "openapi.json") { + // Served verbatim, unlike the chat's, which rewrites its price ceiling at serve time. + // Both prices here are constants generated from PRICE_ATOMIC, so there is nothing that + // could drift between the document and the 402. (Copied.) + return new Response(isHead ? null : JSON.stringify(openapiSpec), { + status: 200, + headers: { ...CORS_HEADERS, "Content-Type": "application/json" }, + }); + } + + if (isGetOrHead && path === "favicon.png") { + return new Response(isHead ? null : FAVICON_BYTES, { + status: 200, + headers: { ...CORS_HEADERS, "Content-Type": faviconContentType }, + }); + } + + if (isGetOrHead && path === "" && wantsHtml(headers)) { + return new Response(isHead ? null : FAVICON_DISCOVERY_HTML, { + status: 200, + headers: { ...CORS_HEADERS, "Content-Type": "text/html; charset=utf-8" }, + }); + } + + // Paid routes are GET only. The 404 stays ahead of the 402: a resource that does not exist has + // no price, so an unknown path must never be answered with payment requirements. (Copied.) + if (method !== "GET" || !isRoute(path)) { + return jsonResponse(404, { error: "Not found" }); + } + + const receiverAddress = process.env.NFT_WALLET_PUBLIC_KEY; + if (!isHexAddress(receiverAddress)) { + logger.error("NFT_WALLET_PUBLIC_KEY not configured or invalid"); + return jsonResponse(500, { error: "Internal server error" }); + } + + // GET routes, so the payment can only arrive in a header. (Copied.) + const payload = extractPaymentPayload(headers); + if (!payload) { + // Before validation, deliberately: an unpaid request is answered with the price whatever + // its query string says, so a client probing for terms is not first told its query is + // malformed. (Copied.) + return challenge(path, receiverAddress); + } + + // A *paid* request is validated before anything is verified or settled. (Copied.) + let args: Args; + try { + args = parseArgs(path, queryParams); + } catch (err) { + return errorResponseFor(err); + } + + try { + return await servePaid(path, args, payload, receiverAddress); + } catch (err) { + logger.error({ err }, "Paid request handler error"); + return jsonResponse(500, { error: "Internal server error" }); + } +} diff --git a/bunny_js/src/runtime-shims.ts b/bunny_js/src/runtime-shims.ts new file mode 100644 index 000000000..e20d9efc2 --- /dev/null +++ b/bunny_js/src/runtime-shims.ts @@ -0,0 +1,20 @@ +/** + * The ported scw_js files assume two Node globals exist: `Buffer` (x402_server's + * create402Response/createSettlementHeaders/extractPaymentPayload) and `process` (env + * reads at module top level, e.g. x402_server's FACILITATOR_URL). bunny's Deno 1.x + * isolate provides Node compat through `node:` imports but does not guarantee the + * bare globals, so this module pins both before anything else executes. + * + * index.ts imports this FIRST: rolldown preserves the order of side-effect imports, + * so these assignments run before any ported module's top-level code. + */ +import { Buffer as NodeBuffer } from "node:buffer"; +import process from "node:process"; + +const g = globalThis as Record; +if (g.Buffer === undefined) { + g.Buffer = NodeBuffer; +} +if (g.process === undefined) { + g.process = process; +} diff --git a/bunny_js/src/search_schemas.ts b/bunny_js/src/search_schemas.ts new file mode 100644 index 000000000..49b108caf --- /dev/null +++ b/bunny_js/src/search_schemas.ts @@ -0,0 +1,92 @@ +import { z } from "zod"; +import { MAX_QUERY_CHARS } from "./search_service.js"; +import type { PriceList } from "./stablecoin_pricing.js"; + +/** + * The query strings `search_api.ts` accepts, as schemas rather than hand-written checks. + * + * Shaped after `llm_schemas.ts`: the description of a request and the thing the handler actually + * enforces are one object, so the two cannot drift. `openapi.search.json` is generated from these + * by `scripts/generate-openapi-search.ts` rather than written beside them. + * + * **`looseObject`, not `object`.** An unknown query parameter is ignored here, and has been since + * the routes existed: `CONTEXT_LIMITS` in `search_service.ts` pins the cost knobs whatever the + * caller passes, rather than refusing the request. `z.object` would publish that as + * `additionalProperties: false` while the handler kept ignoring extras — a strictness claimed and + * not enforced, which is the trap in the repo's CLAUDE.md and one the facilitator shipped once. + * `looseObject` publishes what actually happens. + * + * Each service re-checks its own parameter (`fetchBraveContext`, `assertPublicUrl`) and owns the + * wording; the duplicated bounds here are defence in depth for callers that reach those functions + * directly. + * + * **Which message a caller sees.** Through the API these schemas always fire first — `parseArgs` + * runs before anything else — so the wording below is what a 400 carries. The service's variants + * are for direct callers and are deliberately allowed to differ where they can say more: Zod + * reports `Query too long: maximum is 600 characters` from the raw value, while + * `fetchBraveContext` trims first and can therefore report the actual length. (An earlier version + * of this comment claimed the two were identical. They are not, and it does not matter — but a + * comment asserting a sameness nobody maintains is worse than one describing the real split.) + * + * **What is NOT here, on purpose.** `url` is validated as a string only; that it must be absolute + * and `https:` is `parseHttpsUrl`'s rule and lives there alone, because it trims before checking + * and a duplicate here would reject a leading space the handler accepts today. The constraint is + * published through `.describe()` instead, so the generated spec states it without a second + * implementation drifting from the first. + */ + +/** The routes served. A path outside this set is a 404 *before* any payment is advertised — a + * resource that does not exist has no price. */ +export const ROUTES = ["search", "fetch"] as const; +export type Route = (typeof ROUTES)[number]; + +/** + * Price per call, quoted separately in each token's atomic units (6 decimals) — two parallel + * price lists, as in `genimg_x402_token.ts`, never a float and never a converted amount. + * + * Search is ten times fetch on purpose. Brave costs ~$0.005 a query, so $0.01 covers it twice over + * with the invoke and a share of settlement; a fetch is egress only and priced to mean "not open" + * rather than to recover a cost. The ratio also steers the model into the pattern that reads best: + * search once, then read several of the results. + * + * It lives here, beside the schemas, rather than in `search_api.ts`, because the OpenAPI generator + * needs it and must not import the handler: `search_api.ts` starts a local server when it is the + * entrypoint under NODE_ENV=test, so importing it from a build script would bind a port as a side + * effect of generating a document. This module has no side effects. + */ +export const PRICE_ATOMIC: Record = { + // USDC/EURC in 6 decimals, EURe in 18 — same nominal prices, each in its own atomic units. + search: { USDC: "10000", EURC: "10000", EURe: "10000000000000000" }, + fetch: { USDC: "1000", EURC: "1000", EURe: "1000000000000000" }, +}; + +/** What each route sells, used both in the 402 challenge and in the published spec. */ +export const DESCRIPTION: Record = { + search: "Web search with extracted page content (Brave LLM Context API)", + fetch: "The readable text of one public web page", +}; + +/** The query schema per route, so the generator can walk routes rather than special-case names. */ +export const QUERY_SCHEMA_FOR: Record = { + get search() { + return SearchQuerySchema; + }, + get fetch() { + return FetchQuerySchema; + }, +}; + +export const SearchQuerySchema = z.looseObject({ + q: z + .string({ error: "Missing required query parameter 'q'" }) + .min(1, "Missing required query parameter 'q'") + .max(MAX_QUERY_CHARS, `Query too long: maximum is ${MAX_QUERY_CHARS} characters`) + .describe("The search query. Result count and token budgets are fixed server-side."), +}); + +export const FetchQuerySchema = z.looseObject({ + url: z + .string({ error: "Missing required query parameter 'url'" }) + .min(1, "Missing required query parameter 'url'") + .describe("Absolute https URL. Other schemes and non-public hosts are rejected with 400."), +}); diff --git a/bunny_js/src/search_service.ts b/bunny_js/src/search_service.ts new file mode 100644 index 000000000..37f3b42f1 --- /dev/null +++ b/bunny_js/src/search_service.ts @@ -0,0 +1,181 @@ +import { logger } from "./logger.js"; +import { z } from "zod"; + +/** + * Brave's LLM Context API as a search backend for /assistent. + * + * `https://api.search.brave.com/res/v1/llm/context` returns pre-extracted page chunks plus source + * metadata in one call, so unlike an ordinary search API there is no second scraping step: what + * comes back is already the shape a tool result wants. + * + * Why this is proxied at all: the key travels in an `X-Subscription-Token` header, which can never + * reach a browser. The handler in `search_api.ts` gates it on an owner wallet signature, because + * Brave bills per query and an open proxy to a metered API is an open tab. + * + * Split the same way as `image_service.ts` / `llm_service.ts`: this module owns the upstream call + * and the projection, the handler owns auth, routing and HTTP shape. + */ + +const BRAVE_ENDPOINT = "https://api.search.brave.com/res/v1/llm/context"; + +/** + * Neither `image_service.ts` nor `llm_service.ts` sets a timeout, which is defensible there: both + * are the point of the request that called them. This one is a lookup inside somebody's chat turn, + * where a hung upstream burns the turn rather than one call, so it follows the + * `FEE_CONFIG_FETCH_TIMEOUT_MS` precedent in `x402_server.ts` instead. + */ +const REQUEST_TIMEOUT_MS = 8_000; + +/** Brave's own limit. Rejected here so an over-long query is our 400 rather than their 422. */ +export const MAX_QUERY_CHARS = 600; + +/** + * The cost knobs, pinned here and deliberately NOT accepted from the caller. + * + * Brave defaults `maximum_number_of_tokens` to 8192 and `maximum_number_of_urls` to 20. A tool + * result is charged as input tokens on *every later hop* of a chat turn, and the per-message + * charge is capped by `USDC_MAX_PRICE_PER_MESSAGE` with the operator absorbing anything above it — + * so accepting Brave's defaults would mean paying for ~8x more context than an answer needs, again + * and again within one turn. Half the reason this proxy is ours is that these cannot be overridden + * from the browser. + * + * `safesearch` is the one entry here that costs nothing; it sits with the others because it is + * pinned for the same reason — the browser does not get to choose it. Brave applies no filtering + * at all when it is unset, which is not a default anyone picked: results are stated back to the + * user as fact by a model that cannot decline to read them. `moderate` is the setting, for every + * caller; `off` and `strict` are the other two values Brave accepts. + */ +const CONTEXT_LIMITS: Record = { + count: "10", + maximum_number_of_urls: "5", + maximum_number_of_tokens: "2048", + maximum_number_of_tokens_per_url: "512", + maximum_number_of_snippets_per_url: "3", + context_threshold_mode: "balanced", + safesearch: "moderate", +}; + +/** Second line of defence behind CONTEXT_LIMITS: Brave's caps are a request, these are a promise. */ +const MAX_RESULTS = 5; +const MAX_CHARS_PER_RESULT = 800; + +/** A caller error — a missing or oversized query — as distinct from an upstream failure. */ +export class QueryError extends Error { + constructor(message: string) { + super(message); + this.name = "QueryError"; + } +} + +// --- Upstream contract ------------------------------------------------------------------------ +// +// `looseObject` throughout, like `upstream_schemas.ts`: the provider owns its contract and adds +// fields whenever it likes. `.nullish()` rather than `.optional()` for the same reason documented +// there — providers send present-but-null far more often than they omit a key, and `.optional()` +// accepts only the latter. + +const BraveGenericSchema = z.looseObject({ + url: z.string(), + title: z.string().nullish(), + snippets: z.array(z.string()).nullish(), +}); + +/** `age` is positional: [full date, YYYY-MM-DD, relative age, ISO 8601]. */ +const BraveSourceSchema = z.looseObject({ + age: z.array(z.string().nullish()).nullish(), +}); + +const BraveContextSchema = z.looseObject({ + grounding: z + .looseObject({ + generic: z.array(BraveGenericSchema).nullish(), + }) + .nullish(), + sources: z.record(z.string(), BraveSourceSchema).nullish(), +}); + +export interface SearchResult { + url: string; + title: string; + /** YYYY-MM-DD where Brave knows it. Omitted rather than guessed. */ + date?: string; + text: string; +} + +// --- Fetcher ------------------------------------------------------------------------------------ + +export async function fetchBraveContext(query: string): Promise { + const trimmed = query.trim(); + if (!trimmed) { + throw new QueryError("Missing required query parameter 'q'"); + } + if (trimmed.length > MAX_QUERY_CHARS) { + throw new QueryError( + `Query too long: ${trimmed.length} characters, maximum is ${MAX_QUERY_CHARS}`, + ); + } + + const apiToken = process.env.BRAVE_API_KEY; + if (!apiToken) { + throw new Error( + "API token not found. Please configure the BRAVE_API_KEY environment variable.", + ); + } + + const url = new URL(BRAVE_ENDPOINT); + url.searchParams.set("q", trimmed); + for (const [key, value] of Object.entries(CONTEXT_LIMITS)) { + url.searchParams.set(key, value); + } + + const response = await fetch(url, { + headers: { accept: "application/json", "X-Subscription-Token": apiToken }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + + if (!response.ok) { + throw new Error(`Could not reach Brave: ${response.status} ${response.statusText}`); + } + + return response.json(); +} + +// --- Selector: pure, synchronous, operates on already-parsed JSON -------------------------------- + +/** + * Projects Brave's payload down to what an answer actually needs. + * + * Brave's own caps are a request to a third party; these are what this endpoint promises its + * caller, which is why both exist. `selectClaims` in the website's Bundestakt tool cuts a payload + * for the same reason. + */ +export function selectSearchResults(raw: unknown): { results: SearchResult[] } { + const parsed = BraveContextSchema.safeParse(raw); + if (!parsed.success) { + throw new Error(`Brave returned an unusable response: ${z.prettifyError(parsed.error)}`); + } + + const generic = parsed.data.grounding?.generic ?? []; + const sources = parsed.data.sources ?? {}; + + const results = generic.slice(0, MAX_RESULTS).map((entry) => { + const text = (entry.snippets ?? []).join(" ").replace(/\s+/g, " ").trim(); + const date = sources[entry.url]?.age?.[1]; + + return { + url: entry.url, + title: entry.title?.trim() || entry.url, + ...(typeof date === "string" && date ? { date } : {}), + text: text.length > MAX_CHARS_PER_RESULT ? `${text.slice(0, MAX_CHARS_PER_RESULT)}…` : text, + }; + }); + + return { results }; +} + +export async function searchWeb(query: string): Promise<{ results: SearchResult[] }> { + const raw = await fetchBraveContext(query); + const projected = selectSearchResults(raw); + logger.info({ resultCount: projected.results.length }, "Brave search completed"); + return projected; +} diff --git a/bunny_js/src/stablecoin_pricing.ts b/bunny_js/src/stablecoin_pricing.ts new file mode 100644 index 000000000..4e7b27f5f --- /dev/null +++ b/bunny_js/src/stablecoin_pricing.ts @@ -0,0 +1,52 @@ +/** + * Which settlement tokens the sellers offer, in which order, and how their prices are expressed. + * + * USDC, EURC and EURe are three parallel price systems. Every price is quoted explicitly in each + * token (`PriceList`), and anything derived — the LLM's per-message cost — is computed inside that + * token's own system from its own rate card. There is no exchange rate anywhere: a EURC or EURe + * price is a euro price someone chose, not a converted dollar price. EURe has 18 decimals + * (USDC and EURC have 6), so a PriceList's atomic values differ per token — never assume 6. + */ + +import { + getSettlementTokens, + type SettlementTokenInfo, + type SettlementTokenSymbol, +} from "@fretchen/chain-utils"; + +/** A price in each token's atomic units — 6 decimals for USDC/EURC, 18 for EURe. */ +export type PriceList = Record; + +/** + * The seller's preference, most preferred first. Its only effect is the order of the 402 + * `accepts` array: a stock x402 client pays with the first entry its spend controls allow. + * The SDK's built-in asset registry knows only USDC, so a default-configured client drops the + * EURe and EURC entries and keeps paying USDC; a client that allowlists EURe (or EURC) pays it. + */ +export const STABLECOIN_PREFERENCE: readonly SettlementTokenSymbol[] = ["EURe", "EURC", "USDC"]; + +/** The settlement tokens to offer on `network`, most preferred first. */ +export function offeredStablecoins(network: string): SettlementTokenInfo[] { + return getSettlementTokens(network).sort( + (a, b) => STABLECOIN_PREFERENCE.indexOf(a.symbol) - STABLECOIN_PREFERENCE.indexOf(b.symbol), + ); +} + +/** + * Decimals per settlement token, derived from the shared registry (USDC/EURC 6, EURe 18) — a + * symbol's decimals never vary by network, so this is a lookup table built from the registry, + * not a second source of truth. Base carries all three tokens, so it is the one network the + * table is read from. Pricing code that only holds a symbol uses it to rescale. + */ +export const TOKEN_DECIMALS = Object.fromEntries( + offeredStablecoins("eip155:8453").map((token) => [token.symbol, token.decimals]), +) as Record; + +/** The token a buyer paid with, if this seller offers it on `network`; null otherwise. */ +export function resolvePaidStablecoin(network: string, asset: unknown): SettlementTokenInfo | null { + if (typeof asset !== "string") { + return null; + } + const wanted = asset.toLowerCase(); + return offeredStablecoins(network).find((coin) => coin.address.toLowerCase() === wanted) ?? null; +} diff --git a/bunny_js/src/upstream_schemas.ts b/bunny_js/src/upstream_schemas.ts new file mode 100644 index 000000000..adc935d75 --- /dev/null +++ b/bunny_js/src/upstream_schemas.ts @@ -0,0 +1,121 @@ +/** + * Zod schemas for the responses of the third-party APIs this package calls. + * + * PRIVATE — never imported by `scripts/`, never published, unlike `genimg_schemas.ts` and + * `llm_schemas.ts`. The separate file keeps that rule enforced by the import graph. All + * `looseObject`: the provider owns its contract and adds fields whenever it likes. + * + * **`.optional()` is not enough.** It accepts a missing key but never a present-but-null one, and + * real providers overwhelmingly send the latter. Every loosening below came from a production + * failure rather than speculation, and three were this same mistake: + * + * - BFL sends `result: null` on every poll before the job finishes — killed generation on poll + * 1 of 60, on a job that was merely Pending. + * - Mistral sends `tool_calls: null` on an ordinary text turn — 500'd the third hop of a live + * conversation, after two tool calls had already run and been paid for. + * - Mistral sends `content` as an array of content parts rather than a string, on the turn + * right after a tool result. See `flattenUpstreamContent`. + */ + +import { z } from "zod"; + +// ── Black Forest Labs (image generation) ── + +/** + * Both required: a missing `polling_url` becomes `fetch(undefined)` in the poll loop, is swallowed + * as a transient blip, and fails five minutes later as a timeout naming the wrong cause. + */ +export const BflSubmitSchema = z.looseObject({ + id: z.string(), + polling_url: z.url(), +}); + +/** + * `status` is a free string because BFL owns that vocabulary — an unrecognized status must reach + * the status checks rather than be rejected here. + */ +export const BflPollSchema = z.looseObject({ + status: z.string(), + result: z.looseObject({ sample: z.string() }).nullable().optional(), +}); + +// ── OpenAI-compatible chat completions (Mistral) ── + +/** + * `getSettleAmount(usage)` in `sc_llm_x402.ts` runs after the try/catch around `callLLMAPI` has + * closed, so a non-numeric `prompt_tokens` used to throw out of `handle()` as an unhandled + * rejection. `total_tokens` is required because `LLMResponse["usage"]` requires it. + */ +export const UpstreamUsageSchema = z.looseObject({ + prompt_tokens: z.number().int().nonnegative(), + completion_tokens: z.number().int().nonnegative(), + total_tokens: z.number().int().nonnegative(), +}); + +/** One tool call the model wants made. `arguments` is a JSON *string*, per OpenAI. */ +const UpstreamToolCallSchema = z.looseObject({ + id: z.string(), + type: z.literal("function"), + function: z.looseObject({ name: z.string(), arguments: z.string() }), +}); + +/** An OpenAI "content part". `text` is optional: a part may be an image, carrying no text. */ +const UpstreamContentPartSchema = z.looseObject({ + type: z.string(), + text: z.string().optional(), +}); + +const UpstreamContentSchema = z.union([z.string(), z.array(UpstreamContentPartSchema)]); + +/** + * Collapse either content form to the plain string our own envelope publishes. Flattened here + * rather than republished as a union, which would be a breaking change to `LLMChatResponseSchema` + * for an upstream quirk. Null when there is no text to show — the tool-call case. + */ +export function flattenUpstreamContent(content: unknown): string | null { + if (typeof content === "string") { + return content; + } + if (Array.isArray(content)) { + const text = content + .map((part) => (part as { text?: unknown }).text) + .filter((t): t is string => typeof t === "string") + .join(""); + return text.length > 0 ? text : null; + } + return null; +} + +/** + * Not `LLMChatResponseSchema` — that describes what we return, with every field required. This + * describes what we receive, so everything `callLLMAPI` synthesizes (`id`, `created`, `object`, + * `index`, `role`, `finish_reason`) is optional. + * + * The refine tests `Array.isArray(m.tool_calls)` rather than `!== undefined`, because + * `null !== undefined` is true and would let a genuinely empty `{content: null, tool_calls: null}` + * completion through. + */ +export const UpstreamChatCompletionSchema = z.looseObject({ + id: z.string().optional(), + created: z.number().optional(), + model: z.string().optional(), + choices: z + .array( + z.looseObject({ + index: z.number().int().optional(), + message: z + .looseObject({ + role: z.string().optional(), + content: UpstreamContentSchema.nullable().optional(), + tool_calls: z.array(UpstreamToolCallSchema).min(1).nullable().optional(), + }) + .refine( + (m) => flattenUpstreamContent(m.content) !== null || Array.isArray(m.tool_calls), + { error: "message must carry either content or tool_calls" }, + ), + finish_reason: z.string().nullable().optional(), + }), + ) + .min(1), + usage: UpstreamUsageSchema, +}); diff --git a/bunny_js/src/utils.ts b/bunny_js/src/utils.ts new file mode 100644 index 000000000..659b77a20 --- /dev/null +++ b/bunny_js/src/utils.ts @@ -0,0 +1,66 @@ +/** + * The platform-neutral half of scw_js/utils.ts. Ported verbatim because every x402 seller + * needs the exact same CORS contract — the comments there document why each header is + * load-bearing (browser preflight for @x402/fetch's paid retry, readable Payment-Response). + * + * NOT ported: errorResponse/openAiError and the ScwResponse envelope they return — that is + * the Scaleway handler shape, and bunny_js's routing layer (routes.ts) builds web-standard + * Response objects directly instead. + */ + +export function parseJsonBody(raw: unknown): Record | null { + if (raw === null || raw === undefined || raw === "") { + return null; + } + try { + const parsed = typeof raw === "string" ? JSON.parse(raw) : raw; + if (typeof parsed !== "object" || Array.isArray(parsed) || parsed === null) { + return null; + } + return parsed as Record; + } catch { + return null; + } +} + +/** + * x402 response headers a browser client must be able to READ. + * + * Only CORS-safelisted response headers are readable cross-origin; everything else needs + * naming here or `response.headers.get(...)` just returns null, with no error anywhere. + * `Payment-Response` was the one that mattered and the one that was missing: the client + * SDK updates its cached channel balance from it, so without it every browser buyer's + * record went stale, the client believed its channel was unfunded, and it signed a fresh + * $0.50 deposit per message. Node buyers (the notebooks) were unaffected — CORS does not + * apply there — which is why this validated cleanly off-browser for months. + * + * Header-name matching here is case-insensitive, so one spelling per name is enough; these + * match what the settlement/402 headers actually emit. Not `*`: that stops working the + * moment a request goes credentialed. + */ +export const EXPOSED_X402_HEADERS = + "Payment-Response, X-Payment-Response, Payment-Required, X-Payment"; + +/** + * CORS headers shared by the seller's routes (see scw_js/utils.ts for the full rationale). + * + * `Allow-Headers` must cover every header @x402/fetch sets on the paid retry REQUEST, or + * the browser preflight fails. `Expose-Headers` is the other direction — which RESPONSE + * headers the client may read. + * + * `Cache-Control: no-store` is a bunny_js addition with no scw_js counterpart: unlike the + * Scaleway function, this script is served through a bunny pull zone (a real CDN), and a + * cached paid 200 would replay a purchased result — with its Payment-Response receipt + * header — to the next caller. Nothing this seller returns may be cached. + * + * Keep this in sync with @x402/fetch; the OPTIONS test enforces it. + */ +export const CORS_HEADERS = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": + "Content-Type, PAYMENT-SIGNATURE, X-PAYMENT, Access-Control-Expose-Headers", + "Access-Control-Allow-Methods": "GET, HEAD, POST, OPTIONS", + "Access-Control-Expose-Headers": EXPOSED_X402_HEADERS, + "Cache-Control": "no-store", + "Content-Type": "application/json", +}; diff --git a/bunny_js/src/web_fetch_service.ts b/bunny_js/src/web_fetch_service.ts new file mode 100644 index 000000000..21240f354 --- /dev/null +++ b/bunny_js/src/web_fetch_service.ts @@ -0,0 +1,298 @@ +import { lookup } from "node:dns/promises"; +import { logger } from "./logger.js"; + +/** + * Fetches an arbitrary web page on behalf of /assistent's `fetch_url` tool. + * + * Why this is server-side at all: a browser cannot read a response from a third-party origin — + * CORS forbids it, and `mode: "no-cors"` yields an opaque body. Only the fetch lives here. The + * HTML goes back to the browser untouched, which then runs the same `extractPageText` it already + * uses for this site's own pages; Node has no `DOMParser`, so extracting here would mean a new + * dependency and a second copy of that logic. + * + * **Almost everything below is SSRF defence, and it is the point of the file.** Owner-gating does + * not remove the need: the url is chosen by the *model*, which can be talked into a request by any + * page it has just read. A server that fetches attacker-chosen urls from inside a cloud network is + * the classic route to a metadata endpoint. + * + * **A payment does not widen any of this.** `/fetch` is sold per call, and the obvious reading of + * "paid resource" — the customer gets what they asked for — is wrong here: what is bought is *a + * fetch*, never *a fetch of `169.254.169.254`*. Every check below runs identically for a paid + * request and an owner's, and a refusal after payment is simply not settled (see `search_api.ts`). + * + * Known residual risk, accepted: **DNS rebinding.** The address is validated at resolve time, then + * `fetch` resolves the name again for the connection, so a name that answers differently between + * the two wins. Closing it means pinning the resolved IP into the connection through a custom + * agent/lookup, which is disproportionate here. Everything else — scheme, address space, each + * redirect hop, body size, content type, time — is enforced. + */ + +/** Redirects followed before giving up. Each hop is re-validated; see `fetchExternalHtml`. */ +const MAX_REDIRECTS = 3; + +/** + * Body bytes read before the download is abandoned. + * + * Generous because it is HTML, not text: the extraction that follows typically yields a fifth to + * a tenth of this, and the model sees at most 10 000 characters of that. The cap exists so a + * hostile or merely enormous response cannot exhaust the function's memory. + */ +const MAX_BYTES = 1_000_000; + +const REQUEST_TIMEOUT_MS = 8_000; + +/** Types `extractPageText` can do something with. Anything else is refused before the body. */ +const ALLOWED_CONTENT_TYPES = ["text/html", "application/xhtml+xml", "text/plain"]; + +/** + * A refusal the caller can act on — bad url, wrong type, too big, an upstream status like 404 + * or 403, or a site that did not answer in time — as distinct from this function actually + * malfunctioning. `search_api.ts` answers 400 with the message, and charges nothing; anything else + * is a logged 500 and a generic body. + */ +export class FetchUrlError extends Error { + constructor(message: string) { + super(message); + this.name = "FetchUrlError"; + } +} + +/** + * A site that never answered, or refused the connection, is as much the caller's choice of url as + * its 404 is. Before this, the 8 s timeout surfaced as an unhandled 500 and paged us + * (`ServicesNeedAttention`, 2026-10-04) while the caller got "Internal server error" — no hint to + * pick another source. Anything not recognised here is ours and stays an unhandled error. + */ +function asUnreachable(err: unknown, host: string): unknown { + if (err instanceof FetchUrlError) { + return err; + } + const { name, cause } = (err ?? {}) as { name?: string; cause?: { code?: string } }; + if (name === "TimeoutError") { + return new FetchUrlError( + `Could not reach ${host}: no response within ${REQUEST_TIMEOUT_MS / 1000} s`, + ); + } + // undici's network failures: connection refused or reset, TLS certificate rejected. + if (err instanceof TypeError && err.message === "fetch failed") { + return new FetchUrlError(`Could not reach ${host}: ${cause?.code ?? "connection failed"}`); + } + return err; +} + +export interface FetchedPage { + /** Where the content actually came from, after redirects. This is what the model should cite. */ + finalUrl: string; + html: string; + contentType: string; +} + +// --- Address space ------------------------------------------------------------------------------ + +/** + * Is this address somewhere a public web page cannot live? + * + * Checked against the *resolved* address rather than the hostname, because a hostname is not + * evidence of anything: `metadata.example.com` may resolve to 169.254.169.254, and an attacker + * controls their own DNS. The link-local range matters most — that is where cloud metadata + * services sit, and reading one is how a fetcher becomes a credential leak. + */ +export function isPrivateAddress(ip: string): boolean { + const normalized = ip.trim().toLowerCase(); + + // IPv6, including the IPv4-mapped form ::ffff:10.0.0.1, which must be unwrapped and re-checked. + if (normalized.includes(":")) { + const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(normalized); + if (mapped) { + return isPrivateAddress(mapped[1]); + } + return ( + normalized === "::" || + normalized === "::1" || + normalized.startsWith("fc") || // fc00::/7, unique local + normalized.startsWith("fd") || + normalized.startsWith("fe8") || // fe80::/10, link-local + normalized.startsWith("fe9") || + normalized.startsWith("fea") || + normalized.startsWith("feb") + ); + } + + const octets = normalized.split(".").map(Number); + if (octets.length !== 4 || octets.some((o) => !Number.isInteger(o) || o < 0 || o > 255)) { + // Unparseable: refuse rather than assume it is fine. + return true; + } + const [a, b] = octets; + + return ( + a === 0 || // 0.0.0.0/8, "this network" + a === 10 || // private + a === 127 || // loopback + (a === 169 && b === 254) || // link-local — cloud metadata + (a === 172 && b >= 16 && b <= 31) || // private; note 172.32.x is PUBLIC + (a === 192 && b === 168) || // private + (a === 100 && b >= 64 && b <= 127) || // carrier-grade NAT + a >= 224 // multicast and reserved + ); +} + +/** + * The local half of `assertPublicUrl`: trim, parse, scheme. No I/O. + * + * `https:` only. That is not just tidiness: it removes `file:`, `gopher:`, `javascript:` and + * credentials-in-url in one rule, at the cost of the handful of sites still http-only. + * + * Separate from the DNS half so `search_api.ts` can refuse a malformed url **before** verifying a + * payment. Verification takes the channel's `pendingRequest` lock, and a request rejected after + * that point never settles, so the lock is orphaned — on a channel the chat shares, which means a + * junk url would stall the user's next chat message. The DNS half stays inside the paid path: + * resolving a stranger's hostname is work, and an unpaid caller does not get to ask for it. + */ +export function parseHttpsUrl(raw: string): URL { + const trimmed = raw.trim(); + if (!trimmed) { + throw new FetchUrlError("Missing required query parameter 'url'"); + } + + let url: URL; + try { + url = new URL(trimmed); + } catch { + throw new FetchUrlError(`Not a valid absolute url: ${trimmed.slice(0, 100)}`); + } + + if (url.protocol !== "https:") { + throw new FetchUrlError(`Only https urls can be fetched, got ${url.protocol}`); + } + + return url; +} + +/** + * Parse a caller-supplied url and prove it points somewhere public, or throw. + * + * Unchanged in behaviour — `parseHttpsUrl` above is the first half of what this always did, lifted + * out so it can also run on its own. + */ +export async function assertPublicUrl(raw: string): Promise { + const url = parseHttpsUrl(raw); + + let addresses: { address: string }[]; + try { + addresses = await lookup(url.hostname, { all: true }); + } catch { + throw new FetchUrlError(`Could not resolve ${url.hostname}`); + } + + // EVERY address, not just the first: a name that answers with one public and one private + // address would otherwise pass here and connect to whichever the OS prefers. + const blocked = addresses.find((entry) => isPrivateAddress(entry.address)); + if (blocked) { + logger.warn({ host: url.hostname, address: blocked.address }, "Refused non-public address"); + throw new FetchUrlError(`${url.hostname} resolves to a non-public address`); + } + + return url; +} + +// --- Fetch -------------------------------------------------------------------------------------- + +/** Read at most `MAX_BYTES`, then stop pulling. `response.text()` would buffer the whole body. */ +async function readCapped(response: Response): Promise { + if (!response.body) { + return ""; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + const chunks: string[] = []; + let total = 0; + + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) { + break; + } + total += value.length; + if (total > MAX_BYTES) { + chunks.push(decoder.decode(value.slice(0, value.length - (total - MAX_BYTES)))); + break; + } + chunks.push(decoder.decode(value, { stream: true })); + } + } finally { + await reader.cancel().catch(() => undefined); + } + + return chunks.join(""); +} + +/** + * Fetch one page, following redirects by hand so each hop can be re-checked. + * + * Automatic redirect following is what makes a url allowlist useless: the url handed in is public, + * the 302 it answers with is not. Node's undici returns the real 3xx with a readable `Location` + * under `redirect: "manual"` — unlike a browser, where the response would be opaque. + */ +export async function fetchExternalHtml(raw: string): Promise { + let url = await assertPublicUrl(raw); + + for (let hop = 0; hop <= MAX_REDIRECTS; hop++) { + const host = url.hostname; + let response: Response; + try { + response = await fetch(url, { + redirect: "manual", + headers: { + // Named honestly. A fetcher that hides what it is invites being treated as one. + "User-Agent": "fretchen.eu-assistant/1.0 (+https://www.fretchen.eu/assistent)", + Accept: "text/html,application/xhtml+xml;q=0.9,text/plain;q=0.8", + }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + } catch (err) { + throw asUnreachable(err, host); + } + + if (response.status >= 300 && response.status < 400) { + const location = response.headers.get("location"); + if (!location) { + throw new FetchUrlError(`Redirect with no location from ${url.hostname}`); + } + // Re-validated from scratch, which is the entire reason this loop is hand-written. + url = await assertPublicUrl(new URL(location, url).toString()); + continue; + } + + if (!response.ok) { + // The remote's 404 or 403 is not our failure, and a model told "HTTP 500" retries the same + // url forever. Told the real status it picks another source — 403 in particular is the + // expected answer for a bot with an honest User-Agent. The hostname here is the caller's + // own, so saying it leaks nothing internal. + throw new FetchUrlError( + `Could not reach ${url.hostname}: ${response.status} ${response.statusText}`, + ); + } + + const contentType = response.headers.get("content-type") ?? ""; + const mediaType = contentType.split(";")[0].trim().toLowerCase(); + if (!ALLOWED_CONTENT_TYPES.includes(mediaType)) { + // Cancelled rather than read: no reason to pull a video down to discard it. + await response.body?.cancel().catch(() => undefined); + throw new FetchUrlError( + `Cannot read ${mediaType || "an unknown content type"}; only web pages and plain text`, + ); + } + + // The timeout signal covers the body too: a site can answer the headers and then stall. + const html = await readCapped(response).catch((err: unknown) => { + throw asUnreachable(err, host); + }); + logger.info({ host: url.hostname, bytes: html.length, hops: hop }, "Fetched external page"); + return { finalUrl: url.toString(), html, contentType: mediaType }; + } + + throw new FetchUrlError(`Too many redirects (more than ${MAX_REDIRECTS})`); +} diff --git a/bunny_js/src/x402_channel_storage.ts b/bunny_js/src/x402_channel_storage.ts new file mode 100644 index 000000000..bbff87f2c --- /dev/null +++ b/bunny_js/src/x402_channel_storage.ts @@ -0,0 +1,154 @@ +import type { + Channel, + ChannelStorage, + ChannelUpdateResult, +} from "@x402/evm/batch-settlement/server"; +// `computeChannelId` is only exported from the *client* subpath (the server subpath exports +// just BatchSettlementChannelManager / BatchSettlementEvmScheme / InMemoryChannelStorage). +// It is a pure hashTypedData helper with no browser dependency, so importing it server-side +// is deliberate, not a mistake. +import { computeChannelId } from "@x402/evm/batch-settlement/client"; +import { + getS3ObjectWithMeta, + putS3ObjectConditional, + deleteS3Object, + listObjects, +} from "@fretchen/s3-utils"; +import { logger } from "./logger.js"; + +const PREFIX = "channels/"; +const MAX_CAS_ATTEMPTS = 3; + +/** + * S3 prefix holding one network's channels, e.g. `channels/eip155:10/`. + * + * Channels are partitioned by network because `list()` has no other way to tell them apart: + * the SDK's `Channel` record carries no network field, and `BatchSettlementChannelManager` + * claims whatever `list()` returns against the one chain it was constructed for. A flat + * keyspace therefore fed Base channels into Optimism claim batches, every batch reverted with + * `claim_simulation_failed`, and no claim or refund landed for as long as the cron ran. + */ +export function channelPrefix(network: string): string { + return `${PREFIX}${network}/`; +} + +/** + * Whether a stored record actually belongs to `network`. + * + * `channelId` is a hash of the EIP-712 domain, which includes the chain id, so recomputing it + * from `channelConfig` is a complete check — a record that lands under the wrong prefix cannot + * fake a match. Used both as `list()`'s read-time guard and as the migration's attribution rule. + */ +export function belongsToNetwork(channel: Channel, network: string): boolean { + try { + return ( + computeChannelId(channel.channelConfig, network as `${string}:${string}`).toLowerCase() === + channel.channelId.toLowerCase() + ); + } catch { + // Malformed channelConfig: not attributable to any network. + return false; + } +} + +/** + * S3-backed `ChannelStorage` for the batch-settlement resource server, scoped to one network. + * Uses conditional PUT/DELETE (If-Match / If-None-Match) for the CAS + * guarantee the SDK requires of `updateChannel` across concurrent callers. + * + * One instance per network — see `createLLMResourceServer`, which registers a separate scheme + * per network so both the serving path and the claim cron reach the right prefix. + * + * USDC and EURC channels share a network's prefix (the token is part of `channelId`, so they + * never collide). The optional `token` makes `list()` a single-token view for the claim cron: + * the SDK's channel manager claims EVERY channel `list()` returns and settles them all in its + * one token, and the facilitator refuses a claim batch that mixes tokens — so an unfiltered + * list would fail every claim on a network with both. Reads and writes by id are unaffected. + */ +export class S3ChannelStorage implements ChannelStorage { + private readonly prefix: string; + + constructor( + private readonly network: string, + private readonly token?: string, + ) { + this.prefix = channelPrefix(network); + } + + private keyFor(channelId: string): string { + return `${this.prefix}${channelId.toLowerCase()}.json`; + } + + async get(channelId: string): Promise { + const result = await getS3ObjectWithMeta(this.keyFor(channelId)); + if (!result) { + return undefined; + } + return JSON.parse(result.body) as Channel; + } + + async list(): Promise { + const keys = await listObjects(this.prefix); + const channels: Channel[] = []; + for (const key of keys) { + const result = await getS3ObjectWithMeta(key); + if (!result) { + continue; + } + const channel = JSON.parse(result.body) as Channel; + // A misfiled object would be claimed against the wrong chain and revert the whole + // batch, taking every well-filed channel down with it. Skip it loudly instead. + if (!belongsToNetwork(channel, this.network)) { + logger.warn( + { key, network: this.network, channelId: channel.channelId }, + "Skipping channel filed under the wrong network prefix", + ); + continue; + } + if (this.token && channel.channelConfig.token.toLowerCase() !== this.token.toLowerCase()) { + continue; + } + channels.push(channel); + } + return channels.sort((a, b) => a.channelId.localeCompare(b.channelId)); + } + + async updateChannel( + channelId: string, + update: (current: Channel | undefined) => Channel | undefined, + ): Promise { + const key = this.keyFor(channelId); + + for (let attempt = 1; attempt <= MAX_CAS_ATTEMPTS; attempt++) { + const existing = await getS3ObjectWithMeta(key); + const current = existing ? (JSON.parse(existing.body) as Channel) : undefined; + + const next = update(current); + if (next === current) { + return { channel: current, status: "unchanged" }; + } + + if (!next) { + const result = await deleteS3Object(key, existing ? { ifMatch: existing.etag } : {}); + if (result.ok) { + return { channel: undefined, status: current ? "deleted" : "unchanged" }; + } + continue; + } + + const body = JSON.stringify(next); + const putResult = await putS3ObjectConditional(key, body, { + contentType: "application/json", + ...(existing ? { ifMatch: existing.etag } : { ifNoneMatch: "*" }), + }); + if (putResult.ok) { + return { channel: next, status: "updated" }; + } + // 412 precondition failure: another writer won the race — retry from a fresh read. + } + + throw new Error( + `S3ChannelStorage.updateChannel: exceeded ${MAX_CAS_ATTEMPTS} CAS attempts for ${channelId}`, + ); + } +} diff --git a/bunny_js/src/x402_channel_sync.ts b/bunny_js/src/x402_channel_sync.ts new file mode 100644 index 000000000..f69f0d8ec --- /dev/null +++ b/bunny_js/src/x402_channel_sync.ts @@ -0,0 +1,161 @@ +import { createPublicClient, http } from "viem"; +import { getViemChain, getRpcUrl } from "@fretchen/chain-utils"; +import type { Channel, ChannelStorage } from "@x402/evm/batch-settlement/server"; +import { logger } from "./logger.js"; + +/** Canonical CREATE2 address of the x402BatchSettlement escrow, same on every chain. */ +const BATCH_SETTLEMENT_ADDRESS = "0x4020074e9dF2ce1deE5A9C1b5c3f541D02a10003" as const; + +const CHANNELS_ABI = [ + { + type: "function", + name: "channels", + inputs: [{ name: "channelId", type: "bytes32" }], + outputs: [ + { name: "balance", type: "uint128" }, + { name: "totalClaimed", type: "uint128" }, + ], + stateMutability: "view", + }, +] as const; + +/** The contract's per-channel refund nonce, which `channels()` does not return — it lives in its + * own getter. Selector `0xf0dc792e`. */ +const REFUND_NONCE_ABI = [ + { + type: "function", + name: "refundNonce", + inputs: [{ name: "channelId", type: "bytes32" }], + outputs: [{ name: "", type: "uint256" }], + stateMutability: "view", + }, +] as const; + +export interface ChannelSyncResult { + channelId: string; + storedBalance: string; + chainBalance: string; + /** Carried for the same reason as the balance pair: `corrected` is true when ANY of the three + * chain-owned fields drifted, so a caller that reports the drift needs all three or it prints + * a channel id with no reason after it. */ + storedTotalClaimed: string; + chainTotalClaimed: string; + storedRefundNonce: number; + chainRefundNonce: number; + corrected: boolean; +} + +/** + * Refresh everything the CHAIN owns on each stored channel — `balance`, `totalClaimed` and + * `refundNonce` — before any of it is used to build a refund. + * + * Why this is needed before any refund: the SDK builds a refund entirely from the STORED record + * (server/index.mjs refundChannel) — the amount from `balance - chargedCumulativeAmount`, the + * candidate filter from `balance !== 0`, and the signature from `refundNonce`. Every one of those + * is a cache, and each has gone stale in production: + * + * - **`balance`.** `handleAfterVerify` writes the facilitator's PRE-deposit reading and relies on + * `handleAfterSettle` to correct it, so a request that dies in between leaves a stale-low figure + * behind for good. Two live Optimism channels holding 1.0 and 6.5 USDC both cached `balance: 0`; + * refunding from that cache would have skipped them (the zero filter) or computed a NEGATIVE + * amount. Either way the 7.5 USDC stays locked. + * - **`refundNonce`.** A successful refund DELETES the channel record, and a later deposit with the + * same voucher signer recreates it — same `channelId`, `refundNonce` back to 0, while the chain + * has moved to 1. Every subsequent refund is then signed against a consumed nonce and the + * contract reverts with `0x164f1afe`. Two Optimism channels sat like that: refunds had never + * worked on either, and because the SDK's `refundChannels` throws on the first failure with no + * per-channel catch, one of them blocked the whole sweep — including a third channel whose nonce + * was fine. 2.08 USDC accumulated behind it. + * + * That last point is the reason this function exists in its current shape: it is the one place that + * re-reads the chain, so it is the one place that can stop a cached mirror from drifting. + * + * Read-only against the chain; the only writes are corrections to our own S3 records. + */ +export async function resyncChannelState( + storage: ChannelStorage, + network: string, + { dryRun = false }: { dryRun?: boolean } = {}, +): Promise { + const client = createPublicClient({ + chain: getViemChain(network), + transport: http(getRpcUrl(network)), + }); + + const channels: Channel[] = await storage.list(); + const results: ChannelSyncResult[] = []; + + for (const channel of channels) { + let chainBalance: bigint; + let chainTotalClaimed: bigint; + let chainRefundNonce: bigint; + try { + // Both reads together: a record corrected from one and not the other would be a third + // flavour of the same drift this function exists to prevent. + const [channelState, refundNonce] = await Promise.all([ + client.readContract({ + address: BATCH_SETTLEMENT_ADDRESS, + abi: CHANNELS_ABI, + functionName: "channels", + args: [channel.channelId as `0x${string}`], + }) as Promise<[bigint, bigint]>, + client.readContract({ + address: BATCH_SETTLEMENT_ADDRESS, + abi: REFUND_NONCE_ABI, + functionName: "refundNonce", + args: [channel.channelId as `0x${string}`], + }) as Promise, + ]); + [chainBalance, chainTotalClaimed] = channelState; + chainRefundNonce = refundNonce; + } catch (err) { + // A bad read must never overwrite a good record — leave it exactly as it is. + logger.warn({ err, network, channelId: channel.channelId }, "Channel state read failed"); + continue; + } + + const corrected = + chainBalance.toString() !== channel.balance || + chainTotalClaimed.toString() !== channel.totalClaimed || + Number(chainRefundNonce) !== channel.refundNonce; + + results.push({ + channelId: channel.channelId, + storedBalance: channel.balance, + chainBalance: chainBalance.toString(), + storedTotalClaimed: channel.totalClaimed, + chainTotalClaimed: chainTotalClaimed.toString(), + storedRefundNonce: channel.refundNonce, + chainRefundNonce: Number(chainRefundNonce), + corrected, + }); + + if (!corrected || dryRun) { + continue; + } + + await storage.updateChannel(channel.channelId, (current) => + current + ? { + ...current, + balance: chainBalance.toString(), + totalClaimed: chainTotalClaimed.toString(), + refundNonce: Number(chainRefundNonce), + } + : current, + ); + logger.info( + { + network, + channelId: channel.channelId, + balanceFrom: channel.balance, + balanceTo: chainBalance.toString(), + refundNonceFrom: channel.refundNonce, + refundNonceTo: Number(chainRefundNonce), + }, + "Corrected stale cached channel state", + ); + } + + return results; +} diff --git a/bunny_js/src/x402_server.ts b/bunny_js/src/x402_server.ts new file mode 100644 index 000000000..9155796fa --- /dev/null +++ b/bunny_js/src/x402_server.ts @@ -0,0 +1,698 @@ +import { x402ResourceServer, HTTPFacilitatorClient } from "@x402/core/server"; +import { ExactEvmScheme } from "@x402/evm/exact/server"; +import { + BatchSettlementEvmScheme, + type AuthorizerSigner, + type BatchSettlementChannelManager, +} from "@x402/evm/batch-settlement/server"; +import { + sanitizeResourceServiceMetadata, + type SanitizedResourceServiceMetadata, +} from "@x402/extensions/bazaar"; +import { loadPrivateKey } from "@fretchen/chain-utils"; +import { privateKeyToAccount } from "viem/accounts"; +import { S3ChannelStorage } from "./x402_channel_storage.js"; +import { EXPOSED_X402_HEADERS } from "./utils.js"; +import { logger } from "./logger.js"; +import { offeredStablecoins, type PriceList } from "./stablecoin_pricing.js"; + +const FACILITATOR_URL = process.env.FACILITATOR_URL ?? "https://facilitator.fretchen.eu"; + +const SUPPORTED_NETWORKS = [ + "eip155:11155420", // Optimism Sepolia + "eip155:10", // Optimism Mainnet + "eip155:8453", // Base Mainnet + "eip155:84532", // Base Sepolia +]; + +// Networks where the x402BatchSettlement contract (CREATE2, canonical address) is deployed. +// Keep in sync with x402_facilitator/chain_utils.ts::getBatchSettlementNetworks() — notably +// Optimism *Sepolia* has no deployment, so it is absent here despite being in +// SUPPORTED_NETWORKS above (the exact scheme works there; batch-settlement does not). +// +// Optimism mainnet was omitted here until @x402/evm 2.20: enhancePaymentRequirements() used +// to resolve the asset from the SDK's own `DEFAULT_STABLECOINS` registry, which has no +// "eip155:10" entry, so it threw and 500'd the *entire* 402 response (Promise.all in +// createBatchSettlementPaymentRequirements below), not just the Optimism entry. 2.20 honours +// the caller's `asset`/`extra` instead (x402-foundation/x402#2910). The registry itself STILL +// has no eip155:10 entry, so the remaining paths that read it must be passed an explicit +// token — see createChannelManager in llm_x402_cron.ts. +const BATCH_SETTLEMENT_NETWORKS = ["eip155:10", "eip155:8453", "eip155:84532"]; + +// Onchain channel state (balance/totalClaimed) is cached for this long before a fresh +// RPC read; the SDK's default resolves to a fixed 5 minutes (floored by the protocol's +// MIN_WITHDRAW_DELAY), which would make a user's first chat message right after +// depositing spuriously fail with cumulative_exceeds_balance. Confirmed via the B0 spike. +const ONCHAIN_STATE_TTL_MS = 5_000; + +// How long a client must wait after requesting a unilateral exit before they can pull +// their escrow back out. Must stay well above the claim/settle cron's interval +// (llmx402cron, serverless.yml) or the server risks losing already-earned revenue on a +// channel that goes quiet — a channel could become withdrawable before the cron ever +// gets a chance to claim it. Previously unset here, which silently fell back to the +// SDK's own default (`MIN_WITHDRAW_DELAY`, 900s / 15 minutes) — far below the cron's +// hourly interval at the time. 24h leaves the (now 12h) cron a 2x safety margin. +const WITHDRAW_DELAY_SECONDS = Number(process.env.LLM_WITHDRAW_DELAY_SECONDS ?? "86400"); + +// TTL for the batch-settlement per-channel "pendingRequest" lock. @x402/evm sets this lock +// in onBeforeVerify and clears it in onAfterSettle to serialize concurrent requests on one +// channel; a second request while the lock is live is rejected with `channel_busy`. +// maxTimeoutSeconds is the ONLY thing that drives this TTL in the batch-settlement scheme — +// it does not gate voucher freshness or payment expiry (verified against the SDK). The SDK +// clamps the derived TTL to [5s, 10min] (pendingExpiresAt), so a large value like the +// previous 3600 pinned it to the 10-minute ceiling: if a request is abandoned between verify +// and settle (tab close, network drop, notebook interrupt) the lock is orphaned and the +// channel stays busy for the full 10 minutes — and the client SDK does NOT auto-recover from +// `channel_busy`. 120s keeps ample headroom over the real verify + LLM + settle wall-clock +// (seconds, even for a slow mainnet completion + on-chain claim) while cutting the worst-case +// orphan lockout to 2 minutes. Must be used identically at 402-advertise time (below) AND at +// verify time (sc_llm_x402.ts) — the SDK treats maxTimeoutSeconds as immutable across the two. +export const LLM_MAX_TIMEOUT_SECONDS = 120; + +export function getSupportedNetworks(): string[] { + return SUPPORTED_NETWORKS; +} + +export function getBatchSettlementNetworks(): string[] { + return BATCH_SETTLEMENT_NETWORKS; +} + +export function createFacilitatorClient(): HTTPFacilitatorClient { + return new HTTPFacilitatorClient({ url: FACILITATOR_URL }); +} + +/** + * The facilitator's fee model lives in `fee_config.ts` (pure, import-light, unmocked by tests + * that mock this module wholesale) — re-exported here so existing consumers keep their imports. + */ +export type { FacilitatorFeeConfig } from "./fee_config.js"; +export { facilitatorFeeFor } from "./fee_config.js"; +import type { FacilitatorFeeConfig } from "./fee_config.js"; + +/** + * Read the fee model the facilitator currently advertises, or null when it charges none. + * + * `/supported` is the documented source of truth for both the amount and the collecting + * address, so reading it here means no extra env var to keep in sync and no breakage if + * the facilitator rotates its key. + * + * Returns null — never throws — when the facilitator is unreachable, advertises no fee, or + * returns something unparseable. Callers use this for advisory checks only, so a failure + * here must degrade to "no warning", never to a blocked payment. + * + * Bounded to FEE_CONFIG_FETCH_TIMEOUT_MS: this is awaited once, before the per-network + * claim loop even starts, so an unbounded fetch would stall every network's claim behind + * it — the opposite of "advisory only" if the facilitator is merely slow, not down. + * + * 30s, not 5s: the facilitator is a serverless function that is cold at every scheduled cron + * run, and its first response takes ~15s (measured 2026-10-12: 3/3 cold runs aborted at + * the old 5s bound, silently disabling the fee-allowance early warning — the one signal + * before claims fail with insufficient_fee_allowance). 30s is a 2x margin over the worst + * observed cold start; the cost is only paid when the facilitator is actually slow, and + * once per run. No retry yet — the escalation trigger for adding one is a >30s timeout + * appearing in these logs. The SDK's own facilitator client allows 90s for the same reason. + */ +const FEE_CONFIG_FETCH_TIMEOUT_MS = 30_000; + +export async function getFacilitatorFeeConfig(): Promise { + try { + const res = await fetch(`${FACILITATOR_URL}/supported`, { + signal: AbortSignal.timeout(FEE_CONFIG_FETCH_TIMEOUT_MS), + }); + if (!res.ok) { + // Still advisory-only (returns null, same as every other branch here) — this just stops + // the facilitator being unreachable from being completely silent. + logger.warn({ status: res.status }, "Could not read facilitator fee config"); + return null; + } + const body: unknown = await res.json(); + const fees = ( + body as { + facilitatorFees?: { + recipient?: unknown; + flatFee?: unknown; + assets?: Array<{ network?: unknown; asset?: unknown; flatFee?: unknown }>; + }; + } + )?.facilitatorFees; + if (typeof fees?.recipient !== "string" || typeof fees?.flatFee !== "string") { + // No fee configured on this facilitator — nothing to check an allowance against. + return null; + } + const flatFee = BigInt(fees.flatFee); + if (flatFee <= 0n) { + return null; + } + // Per-token fees, each in that token's own atomic units (EURe's 0.01 is 10¹⁶, not 10000). + // Per-entry parse: one malformed entry must not nuke the whole map — a null config reads + // as "no fee configured" downstream and silently disables the allowance checks. Skipped + // rather than guessed for a token with no entry — see facilitatorFeeFor. + const feeByAsset = new Map(); + for (const asset of fees.assets ?? []) { + if ( + typeof asset?.network === "string" && + typeof asset.asset === "string" && + typeof asset.flatFee === "string" + ) { + try { + const perToken = BigInt(asset.flatFee); + if (perToken > 0n) { + feeByAsset.set(`${asset.network}:${asset.asset.toLowerCase()}`, perToken); + } + } catch { + // Unparseable figure — leave the token unknown rather than poison the config. + } + } + } + return { recipient: fees.recipient as `0x${string}`, flatFee, feeByAsset }; + } catch (err) { + logger.warn({ err }, "Could not read facilitator fee config"); + return null; + } +} + +/** + * The SDK's own per-request payload/requirements types, read off its public method signatures + * rather than hand-copied — `@x402/core` uses both throughout its API (verify/settle, + * enhancePaymentRequirements, ...) but does not export either from any of its public entry + * points (`.`, `/client`, `/server`, `/facilitator` all checked). This is the stable way to name + * them without depending on the internal, hash-named chunk file they actually live in, and it + * stays correct across an SDK version bump since it reads whatever the installed `.d.ts` says. + * + * Named distinctly from this file's own `PaymentRequirements` below, which is the *whole* 402 + * challenge document (`{ x402Version, resource, accepts: [...] }`) — confusingly similar name, + * different shape: the SDK's type here is what one entry of that `accepts` array looks like. + */ +export type SdkPaymentPayload = Parameters[0]; +export type SdkPaymentRequirements = Parameters[1]; + +/** + * Release the channel lock a verified-but-unsettled request holds, charging nothing. + * + * `verifyPayment` takes the channel's `pendingRequest` lock and only `settlePayment` clears it, so + * a request that fails in between left the lock to its TTL (`LLM_MAX_TIMEOUT_SECONDS`, or the + * search routes' 30 s). The chat and the paid web tools share one channel, so a single failed + * `fetch_url` then rejected the user's next chat message with `channel_busy` — which really ended + * research turns. Cancelling fires the scheme's `onVerifiedPaymentCanceled`, which clears exactly + * this request's reservation. Must get the same `resourceServer` and payload object the verify + * used: the scheme looks up the request context verify stored. + * + * Best effort: a failure here only leaves the lock to its TTL, today's behaviour, so it is logged + * and never thrown. + */ +export async function releaseLock( + resourceServer: x402ResourceServer, + payload: SdkPaymentPayload, + requirements: SdkPaymentRequirements, +): Promise { + try { + await resourceServer + .createPaymentCancellationDispatcher(payload, requirements) + .cancel({ reason: "handler_failed" }); + } catch (err) { + logger.warn({ err }, "Could not release channel lock; it expires with its TTL"); + } +} + +export function createResourceServer(): x402ResourceServer { + const server = new x402ResourceServer(createFacilitatorClient()); + for (const network of getSupportedNetworks()) { + server.register(network as `${string}:${string}`, new ExactEvmScheme()); + } + return server; +} + +export interface LLMResourceServer { + resourceServer: x402ResourceServer; + /** + * The scheme serving `network`, whose storage is scoped to that network's S3 prefix. + * Use this wherever channel storage is touched — settlement and the claim/settle cron. + */ + schemeFor: (network: string) => BatchSettlementEvmScheme; + /** + * A scheme whose storage lists only `token`'s channels on `network` — for the claim/settle + * cron, which must claim one token at a time (see S3ChannelStorage). Not registered with the + * resource server: serving keeps one scheme per network, writing both tokens to one prefix. + */ + claimSchemeFor: (network: string, token: string) => BatchSettlementEvmScheme; + /** + * Convenience handle for storage-independent work only — building the 402 `accepts` + * array, where `enhancePaymentRequirements` just stamps on `receiverAuthorizer`, + * `withdrawDelay` and the EIP-712 domain. Anything that reads or writes channels must + * go through `schemeFor` instead, or it will reach the wrong network's prefix. + */ + scheme: BatchSettlementEvmScheme; +} + +/** + * Resource server for the LLM assistant's x402 batch-settlement channels. + * + * One `BatchSettlementEvmScheme` **per network**, each owning an `S3ChannelStorage` scoped to + * that network's prefix. The scheme itself is receiver-bound rather than network-bound (see B0 + * spike), so a single shared instance used to serve every network — but its storage is not: + * `ChannelStorage.list()` takes no network argument, so one shared store handed Base channels + * to Optimism claim batches and every batch reverted. `x402ResourceServer` keys its registry by + * network (`Map>`), so registering a distinct instance per network + * routes settlement to the right store with no change at the call sites. + */ +export function createLLMResourceServer(receiverAddress: `0x${string}`): LLMResourceServer { + const resourceServer = new x402ResourceServer(createFacilitatorClient()); + + const authorizerAccount = privateKeyToAccount(loadPrivateKey("RECEIVER_AUTHORIZER_PRIVATE_KEY")); + const receiverAuthorizerSigner: AuthorizerSigner = { + address: authorizerAccount.address, + signTypedData: (params) => + // AuthorizerSigner's own params shape (domain/types/message as Record) + // is looser than viem's TypedDataDefinition — the two libraries just don't share one + // canonical typed-data type. Safe: viem validates the real EIP-712 structure at runtime + // regardless of how loosely this bridge sees it. + authorizerAccount.signTypedData( + params as Parameters[0], + ), + }; + + const makeScheme = (storage: S3ChannelStorage) => + new BatchSettlementEvmScheme(receiverAddress, { + storage, + receiverAuthorizerSigner, + onchainStateTtlMs: ONCHAIN_STATE_TTL_MS, + withdrawDelay: WITHDRAW_DELAY_SECONDS, + }); + + const schemes = new Map(); + for (const network of BATCH_SETTLEMENT_NETWORKS) { + const scheme = makeScheme(new S3ChannelStorage(network)); + schemes.set(network, scheme); + resourceServer.register(network as `${string}:${string}`, scheme); + } + + const schemeFor = (network: string): BatchSettlementEvmScheme => { + const scheme = schemes.get(network); + if (!scheme) { + throw new Error( + `No batch-settlement scheme for network ${network} ` + + `(supported: ${BATCH_SETTLEMENT_NETWORKS.join(", ")})`, + ); + } + return scheme; + }; + + const claimSchemeFor = (network: string, token: string): BatchSettlementEvmScheme => { + schemeFor(network); // same unsupported-network error as the serving path + return makeScheme(new S3ChannelStorage(network, token)); + }; + + return { + resourceServer, + schemeFor, + claimSchemeFor, + scheme: schemeFor(BATCH_SETTLEMENT_NETWORKS[0]), + }; +} + +/** + * Make a `BatchSettlementChannelManager` send ENHANCED payment requirements. + * + * Works around an SDK gap present in @x402/evm 2.25 and 2.26 alike: the manager's own + * `buildPaymentRequirements()` returns `extra: {}`, and the facilitator's + * `validateChannelConfig` rejects any refund whose requirements carry no + * `extra.receiverAuthorizer` — it treats "absent" as "mismatch" and fails closed: + * + * const requiredReceiverAuthorizer = extra?.receiverAuthorizer; + * if (!requiredReceiverAuthorizer || …) return ErrReceiverAuthorizerMismatch; + * + * So every cooperative refund fails with `receiver_authorizer_mismatch` regardless of which + * key signed it. This is the same trap already documented for the deposit path in + * `sc_llm_x402.ts` — the requirements the facilitator checks against must carry + * `receiverAuthorizer`/`withdrawDelay`, and only `enhancePaymentRequirements` adds them. + * + * Claims are unaffected: they are verified through `claimAuthorizerSignature`, not through + * `validateChannelConfig` against `accepted.extra`. + * + * Apply this AFTER claiming and before refunding, so the claim/settle calls keep using the + * requirements they already work with. Remove once the SDK enhances its own refund + * requirements. + */ +export async function useEnhancedRefundRequirements( + scheme: BatchSettlementEvmScheme, + manager: BatchSettlementChannelManager, + opts: { network: string; asset: string; payTo: string }, +): Promise { + const base: SdkPaymentRequirements = { + scheme: "batch-settlement", + network: opts.network as `${string}:${string}`, + asset: opts.asset, + amount: "0", + payTo: opts.payTo, + maxTimeoutSeconds: 0, + extra: {}, + }; + const enhanced = await scheme.enhancePaymentRequirements( + base, + { + x402Version: 2, + scheme: "batch-settlement", + network: opts.network as `${string}:${string}`, + extra: {}, + }, + [], + ); + + // Drop `withdrawDelay`, which the enhancer stamps from the CURRENT server config. It is part + // of `computeChannelId` (payer, payerAuthorizer, receiver, receiverAuthorizer, token, + // withdrawDelay, salt), so it is fixed per channel at creation and can never be updated — a + // different delay is a different channel. The facilitator's validateChannelConfig compares + // the payload's stored config against this field: + // + // if (extra?.withdrawDelay !== undefined && config.withdrawDelay !== Number(extra.withdrawDelay)) + // return ErrWithdrawDelayMismatch; + // + // so once LLM_WITHDRAW_DELAY_SECONDS changed (900 -> 86400, commit 313e76df), every channel + // opened before it became permanently unrefundable: `withdraw_delay_mismatch` on every sweep, + // escrow stranded with no way back. Absent, the equality branch is skipped; the range check + // that follows still applies (MIN_WITHDRAW_DELAY is 900). + // + // Safe to omit because the check immediately above it in the same function already binds the + // config cryptographically — `computeChannelId(config) === channelId` fails first if anything + // in the config was forged. The equality test is policy ("this channel matches today's + // setting"), not security, and enforcing today's policy on an old channel only strands funds. + // `receiverAuthorizer` is kept: that one the facilitator fails closed on, and it is why this + // function exists. + const enhancedExtra = (enhanced as { extra?: Record }).extra ?? {}; + const { withdrawDelay: _configuredDelay, ...extraWithoutDelay } = enhancedExtra; + const refundRequirements = { ...enhanced, extra: extraWithoutDelay }; + + (manager as unknown as { buildPaymentRequirements: () => unknown }).buildPaymentRequirements = + () => refundRequirements; +} + +export interface BatchSettlementPaymentRequirementsOptions { + resourceUrl: string; + description: string; + mimeType: string; + /** The price in each stablecoin's own atomic units — two parallel lists, no conversion. */ + price: PriceList; + payTo: string; + scheme: BatchSettlementEvmScheme; + networks?: readonly string[]; + /** + * Lock TTL to advertise, defaulting to the LLM's. `search_api.ts` passes a shorter one: it is + * not part of `channelConfig` (see `computeChannelId` — payer, payerAuthorizer, receiver, + * receiverAuthorizer, token, withdrawDelay, salt), so routes sharing one channel may differ + * here. Whatever a route advertises it must also pass at verify time. + */ + maxTimeoutSeconds?: number; + /** + * Extra 402 `extensions` entries beyond the permit2 one, keyed by extension name — today the + * Bazaar discovery block from `bazaar_discovery.ts`. Merged after `eip2612GasSponsoring`, + * which must survive; an unknown key in a challenge is inert for clients that do not + * recognize it (both extensions are optional by spec). + */ + extensions?: Record; + /** + * Bazaar service metadata (docs.x402.org/extensions/bazaar → "Service Metadata"), sanitized + * by the package's soft-drop rules and stamped onto the `resource` object: `serviceName` + * (ASCII ≤ 32), up to 5 `tags`, and an absolute `iconUrl`. Facilitators enrich discovery + * search results with them; all three are optional. + */ + serviceName?: string; + tags?: string[]; + iconUrl?: string; +} + +/** + * Builds the 402 `accepts` array for batch-settlement: one entry per (network, offered + * stablecoin), most preferred coin first (see STABLECOIN_PREFERENCE). Each entry must be run + * through the scheme's own `enhancePaymentRequirements` so the client receives the + * `receiverAuthorizer`/`withdrawDelay`/EIP-712 domain fields it needs to build a deposit + * payload (confirmed necessary in the B0 spike). + */ +export async function createBatchSettlementPaymentRequirements({ + resourceUrl, + description, + mimeType, + price, + payTo, + scheme, + networks = BATCH_SETTLEMENT_NETWORKS, + maxTimeoutSeconds = LLM_MAX_TIMEOUT_SECONDS, + extensions, + serviceName, + tags, + iconUrl, +}: BatchSettlementPaymentRequirementsOptions): Promise<{ + x402Version: number; + resource: { + url: string; + description: string; + mimeType: string; + } & SanitizedResourceServiceMetadata; + accepts: unknown[]; + /** Present when a Permit2 token is offered or extra extensions were passed (see `permit2Extensions`). */ + extensions?: Record; +}> { + const offered = networks.flatMap((network) => + offeredStablecoins(network).map((coin) => ({ network, coin })), + ); + const accepts = await Promise.all( + offered.map(({ network, coin }) => { + const base: SdkPaymentRequirements = { + scheme: "batch-settlement", + network: network as `${string}:${string}`, + amount: price[coin.symbol], + asset: coin.address, + payTo, + maxTimeoutSeconds, + extra: tokenExtra(coin), + }; + return scheme.enhancePaymentRequirements( + base, + { + x402Version: 2, + scheme: "batch-settlement", + network: network as `${string}:${string}`, + extra: base.extra, + }, + [], + ); + }), + ); + + return { + x402Version: 2, + resource: { + url: resourceUrl, + description, + mimeType, + ...sanitizeResourceServiceMetadata({ url: resourceUrl, serviceName, tags, iconUrl }), + }, + accepts, + extensions: mergeExtensions(permit2Extensions(offered.map(({ coin }) => coin)), extensions), + }; +} + +export interface PaymentRequirementsOptions { + resourceUrl: string; + description: string; + mimeType: string; + /** The price in each stablecoin's own atomic units — two parallel lists, no conversion. */ + price: PriceList; + payTo: string; + networks?: readonly string[]; + /** Extra 402 `extensions` entries beyond the permit2 one — see the batch-settlement twin above. */ + extensions?: Record; + /** Bazaar service metadata, sanitized onto the `resource` object — see the batch-settlement twin above. */ + serviceName?: string; + tags?: string[]; + iconUrl?: string; +} + +export interface PaymentRequirements { + x402Version: number; + resource: { + url: string; + description: string; + mimeType: string; + } & SanitizedResourceServiceMetadata; + accepts: Array<{ + scheme: string; + network: string; + amount: string; + asset: string; + payTo: string; + maxTimeoutSeconds: number; + /** EURe (Permit2) entries also carry `assetTransferMethod: "permit2"`; EIP-3009 ones omit it. */ + extra: { name: string; version: string; assetTransferMethod?: "permit2" }; + }>; + /** Present when a Permit2 token is offered or extra extensions were passed: the permit2 entry + * lets buyers sign an EIP-2612 permit for Permit2 inside the payment (gas sponsoring), so a + * fresh wallet needs no approve() transaction; the rest are caller-supplied (see options). */ + extensions?: Record; +} + +/** + * Merges the route's own extensions with caller-supplied ones. `undefined` when both sides are + * empty, preserving the pre-bazaar challenge shape for callers that pass nothing — an + * `extensions: {}` would be harmless but noisy. + */ +function mergeExtensions( + permit2: Record | undefined, + extra: Record | undefined, +): Record | undefined { + const merged = { ...permit2, ...extra }; + return Object.keys(merged).length > 0 ? merged : undefined; +} + +/** + * The 402 `extensions` entry that makes an EURe buyer's payment permit-carrying. The client only + * signs an EIP-2612 permit when the challenge advertises the extension; without it, a wallet + * without a standing Permit2 approval cannot pay EURe at all. + */ +function permit2Extensions( + coins: readonly { transferMethod: string }[], +): Record | undefined { + return coins.some((coin) => coin.transferMethod === "permit2") + ? { eip2612GasSponsoring: {} } + : undefined; +} + +/** + * The per-token `extra` every client needs to build its payment: the token's EIP-712 domain, plus + * the transfer method when it is not the EIP-3009 default — EURe has no EIP-3009, and a client + * that tries it (the SDK's `extra?.assetTransferMethod ?? "eip3009"` default) fails at signing. + */ +function tokenExtra(coin: { name: string; version: string; transferMethod: string }): { + name: string; + version: string; + assetTransferMethod?: "permit2"; +} { + return { + name: coin.name, + version: coin.version, + ...(coin.transferMethod === "permit2" && { assetTransferMethod: "permit2" as const }), + }; +} + +/** The exact-scheme 402: one entry per (network, offered stablecoin), most preferred first. */ +export function createPaymentRequirements({ + resourceUrl, + description, + mimeType, + price, + payTo, + networks = getSupportedNetworks(), + extensions, + serviceName, + tags, + iconUrl, +}: PaymentRequirementsOptions): PaymentRequirements { + const offered = networks.flatMap((network) => + offeredStablecoins(network).map((coin) => ({ network, coin })), + ); + const accepts = offered.map(({ network, coin }) => ({ + scheme: "exact", + network, + amount: price[coin.symbol], + asset: coin.address, + payTo, + maxTimeoutSeconds: 60, + extra: tokenExtra(coin), + })); + + return { + x402Version: 2, + resource: { + url: resourceUrl, + description, + mimeType, + ...sanitizeResourceServiceMetadata({ url: resourceUrl, serviceName, tags, iconUrl }), + }, + accepts, + extensions: mergeExtensions(permit2Extensions(offered.map(({ coin }) => coin)), extensions), + }; +} + +export interface HttpResponse { + statusCode: number; + headers: Record; + body: string; +} + +export function create402Response(paymentRequirements: { + x402Version: number; + error?: string; + resource: { url: string; description?: string; mimeType?: string }; + accepts: unknown[]; + extensions?: Record; +}): HttpResponse { + const paymentRequiredHeader = Buffer.from(JSON.stringify(paymentRequirements)).toString("base64"); + + return { + statusCode: 402, + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "*", + "Access-Control-Allow-Methods": "*", + // Shared with CORS_HEADERS so the 402 and the settled 200 cannot drift apart — + // they did, and the 200 was the one missing Payment-Response. + "Access-Control-Expose-Headers": EXPOSED_X402_HEADERS, + "Content-Type": "application/json", + "Payment-Required": paymentRequiredHeader, + "X-Payment": JSON.stringify(paymentRequirements), + }, + body: JSON.stringify(paymentRequirements), + }; +} + +export function extractPaymentPayload( + headers: Record, +): Record | null { + const v2Header = headers["payment-signature"] ?? headers["Payment-Signature"]; + if (v2Header) { + try { + const decoded = Buffer.from(v2Header, "base64").toString("utf-8"); + return JSON.parse(decoded) as Record; + } catch { + // The caller's fault (malformed header), not ours — warn, not error. + logger.warn("Failed to parse PAYMENT-SIGNATURE header"); + return null; + } + } + + const v1Header = headers["x-payment"] ?? headers["X-Payment"]; + if (v1Header) { + try { + return JSON.parse(v1Header) as Record; + } catch { + logger.warn("Failed to parse X-PAYMENT header"); + return null; + } + } + + return null; +} + +/** + * Converts a USDC atomic-unit amount (6 decimals, as used by accepts[].amount) into a + * decimal USD string suitable for OpenAPI's x-payment-info.price (which the x402scan + * discovery spec requires in decimal USD, not atomic units). Bigint-based to avoid float + * precision loss; trims trailing zeros so "3000" -> "0.003", not "0.003000". + */ +export function formatUsdcAtomicAsDecimalUsd(atomicAmount: string, decimals: bigint = 6n): string { + const atomic = BigInt(atomicAmount); + const divisor = 10n ** decimals; + const whole = atomic / divisor; + const fraction = (atomic % divisor).toString().padStart(Number(decimals), "0"); + const trimmedFraction = fraction.replace(/0+$/, ""); + return trimmedFraction ? `${whole}.${trimmedFraction}` : whole.toString(); +} + +export function createSettlementHeaders( + settlementResult: Record, +): Record { + const paymentResponseHeader = Buffer.from(JSON.stringify(settlementResult)).toString("base64"); + return { + "Payment-Response": paymentResponseHeader, + "X-Payment-Response": JSON.stringify(settlementResult), + }; +} diff --git a/bunny_js/test/routes.test.ts b/bunny_js/test/routes.test.ts new file mode 100644 index 000000000..e933d56b2 --- /dev/null +++ b/bunny_js/test/routes.test.ts @@ -0,0 +1,829 @@ +import { describe, test, expect, vi, beforeEach, afterEach } from "vitest"; + +// Kept only to build a genuinely valid owner bearer, which the "no free path" cases below send to +// prove it now buys nothing. Signing for real rather than mocking: `verifySignedMessage` resolves +// its own copy of viem through the symlinked workspace package, so a `vi.mock("viem")` here could +// never have reached it anyway — and a hand-faked token would not prove the point, since the thing +// being asserted is that even a CORRECT signature is ignored. +import { privateKeyToAccount } from "viem/accounts"; +import { USDC_ADDRESSES, EURC_ADDRESSES } from "@fretchen/chain-utils"; + +// DNS is stubbed so these stay hermetic: /fetch resolves every hostname before connecting, and a +// test that needs a working resolver fails on an offline machine for reasons that have nothing to +// do with the code. Literal IPs still take the real path through isPrivateAddress. +const mockLookup = vi.fn(); +vi.mock("node:dns/promises", () => ({ lookup: mockLookup })); + +// The x402 seller is mocked the way sc_llm_x402.test.ts mocks it: the SDK wants S3, a receiver +// authorizer key and a chain, none of which belong in a unit test. `@fretchen/chain-utils` stays +// REAL here — the signature checks above depend on it, and the stablecoin registry is a pure +// lookup whose answer (which asset is priced how) is part of what these tests check. +const { + mockCreateLLMResourceServer, + mockCreateBatchSettlementPaymentRequirements, + mockCreate402Response, + mockExtractPaymentPayload, + mockCreateSettlementHeaders, + mockVerifyPayment, + mockSettlePayment, + mockCreatePaymentRequiredResponse, + mockEnhancePaymentRequirements, + mockReleaseLock, +} = vi.hoisted(() => ({ + mockCreateLLMResourceServer: vi.fn(), + mockCreateBatchSettlementPaymentRequirements: vi.fn(), + mockCreate402Response: vi.fn(), + mockExtractPaymentPayload: vi.fn(), + mockCreateSettlementHeaders: vi.fn(), + mockVerifyPayment: vi.fn(), + mockSettlePayment: vi.fn(), + mockCreatePaymentRequiredResponse: vi.fn(), + mockEnhancePaymentRequirements: vi.fn(), + mockReleaseLock: vi.fn(), +})); + +vi.mock("../src/x402_server.js", () => ({ + createLLMResourceServer: mockCreateLLMResourceServer, + createBatchSettlementPaymentRequirements: mockCreateBatchSettlementPaymentRequirements, + create402Response: mockCreate402Response, + extractPaymentPayload: mockExtractPaymentPayload, + createSettlementHeaders: mockCreateSettlementHeaders, + releaseLock: mockReleaseLock, +})); + +/** The seller's own address — `NFT_WALLET_PUBLIC_KEY`, shared with the chat so both land on one + * payment channel. Anvil account #2, standing in for it. */ +const RECEIVER = "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"; + +/** A payment payload, as the SDK would hand it over after decoding the header. */ +function payment(network = "eip155:10", asset: string = USDC_ADDRESSES[network]) { + return { accepted: { network, asset }, payload: { voucher: "0xsigned" } }; +} + +// Anvil account #0 — a well-known test key, never used for anything real. +const owner = privateKeyToAccount( + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", +); +const OWNER_ADDRESS = owner.address; +const AUTH_PREFIX = "search-api"; + +function braveResponse() { + return { + grounding: { + generic: [{ url: "https://example.com/0", title: "Result 0", snippets: ["A snippet."] }], + poi: null, + map: [], + }, + sources: { "https://example.com/0": { title: "Result 0", hostname: "example.com", age: null } }, + }; +} + +async function makeAuthHeader( + account: typeof owner = owner, + { timestamp, prefix = AUTH_PREFIX }: { timestamp?: number; prefix?: string } = {}, +): Promise { + const ts = timestamp ?? Math.floor(Date.now() / 1000); + const message = `${prefix}:${ts}`; + const signature = await account.signMessage({ message }); + const payload = JSON.stringify({ address: account.address, signature, message }); + return `Bearer ${Buffer.from(payload).toString("base64")}`; +} + +const ORIGIN = "https://web-agent.fretchen.eu"; + +/** The native twin of the original makeEvent: a web-standard Request. */ +function makeRequest( + method: string, + path: string, + options: { auth?: string | null; query?: Record } = {}, +) { + const auth = options.auth === null ? undefined : options.auth; + const qs = options.query ? `?${new URLSearchParams(options.query).toString()}` : ""; + return new Request(`${ORIGIN}/${path}${qs}`, { + method, + headers: auth ? { Authorization: auth } : {}, + }); +} + +/** Discovery requests carry arbitrary headers, not an auth bearer. */ +function discoveryRequest(method: string, path: string, headers: Record = {}) { + return new Request(`${ORIGIN}/${path}`, { method, headers }); +} + +/** + * Legacy view over the native Response, so the assertions ported from scw_js's suite stay + * shape-identical: statusCode, a flat lowercase headers map, and the body as text. + */ +async function handle(request: Request) { + const res = await handleRequest(request); + const headers: Record = {}; + res.headers.forEach((value, key) => { + headers[key] = value; + }); + return { statusCode: res.status, headers, body: await res.text() }; +} + +let handleRequest: typeof import("../src/routes.js").handleRequest; +let validAuth: string; + +beforeEach(async () => { + vi.resetModules(); + mockLookup.mockReset(); + // Hostnames resolve public by default; the private-address cases below use literal IPs, which + // node's lookup returns verbatim. + mockLookup.mockImplementation((host: string) => + Promise.resolve([{ address: /^[\d.]+$/.test(host) ? host : "93.184.216.34", family: 4 }]), + ); + validAuth = await makeAuthHeader(); + process.env.OWNER_ETH_ADDRESS = OWNER_ADDRESS; + process.env.BRAVE_API_KEY = "test-token"; + process.env.NFT_WALLET_PUBLIC_KEY = RECEIVER; + + vi.clearAllMocks(); + mockCreateLLMResourceServer.mockReturnValue({ + resourceServer: { + verifyPayment: mockVerifyPayment, + settlePayment: mockSettlePayment, + createPaymentRequiredResponse: mockCreatePaymentRequiredResponse, + }, + scheme: { enhancePaymentRequirements: mockEnhancePaymentRequirements }, + }); + // Echoes the options back, so a test can read what the handler advertised. + mockCreateBatchSettlementPaymentRequirements.mockImplementation( + (opts: { + resourceUrl: string; + price: { USDC: string; EURC: string }; + payTo: string; + networks: string[]; + maxTimeoutSeconds: number; + }) => ({ + x402Version: 2, + resource: { url: opts.resourceUrl }, + accepts: opts.networks.map((network) => ({ + network, + amount: opts.price.USDC, + payTo: opts.payTo, + maxTimeoutSeconds: opts.maxTimeoutSeconds, + })), + }), + ); + mockCreate402Response.mockImplementation((requirements: unknown) => ({ + statusCode: 402, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(requirements), + })); + // Stands in for the real enhancement, which stamps on receiverAuthorizer/withdrawDelay. + mockEnhancePaymentRequirements.mockImplementation((base: Record) => ({ + ...base, + extra: { ...(base.extra as object), receiverAuthorizer: "0xauthorizer" }, + })); + mockExtractPaymentPayload.mockReturnValue(null); + mockVerifyPayment.mockResolvedValue({ isValid: true, payer: "0xpayer" }); + mockSettlePayment.mockResolvedValue({ success: true, transaction: "", network: "eip155:10" }); + mockCreatePaymentRequiredResponse.mockResolvedValue({ + x402Version: 2, + error: "channel_busy", + accepts: [], + }); + mockCreateSettlementHeaders.mockReturnValue({ "Payment-Response": "encoded" }); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ ok: true, json: async () => braveResponse() }), + ); + handleRequest = (await import("../src/routes.js")).handleRequest; +}); + +afterEach(() => { + vi.unstubAllGlobals(); + delete process.env.OWNER_ETH_ADDRESS; + delete process.env.BRAVE_API_KEY; + delete process.env.NFT_WALLET_PUBLIC_KEY; +}); + +/** + * There is one way in: payment. The owner-signature path was removed — `genimg` and `llmx402` + * never had one, and the consumer it was justified by (server-side callers without a funded + * wallet) turned out not to exist. What these pin is that its removal did not leave a crack: + * a bearer is now just an ignored header, not a cheaper route. + */ +describe("no free path", () => { + test("quotes a price for a request with no Authorization header", async () => { + const res = await handle(makeRequest("GET", "search", { auth: null, query: { q: "x402" } })); + + expect(res.statusCode).toBe(402); + }); + + /** + * The regression guard for this change. A token that used to be served for free — correctly + * signed, current, right prefix, right owner — must now be quoted a price like anything else, + * and must not reach the metered upstream on the way. + */ + test("quotes a price even for what used to be a valid owner token", async () => { + const res = await handle( + makeRequest("GET", "search", { auth: validAuth, query: { q: "x402" } }), + ); + + expect(res.statusCode).toBe(402); + expect(fetch).not.toHaveBeenCalled(); + }); + + /** OWNER_ETH_ADDRESS is no longer read by this endpoint at all; setting it buys nobody a + * free call. It stays in the environment for growthapi and analytics. */ + test("serves nobody free even when OWNER_ETH_ADDRESS names the signer", async () => { + process.env.OWNER_ETH_ADDRESS = OWNER_ADDRESS; + + const res = await handle( + makeRequest("GET", "search", { auth: validAuth, query: { q: "x402" } }), + ); + + expect(res.statusCode).toBe(402); + expect(fetch).not.toHaveBeenCalled(); + }); + + test("never calls Brave when the caller has not paid", async () => { + await handle(makeRequest("GET", "search", { auth: null, query: { q: "x402" } })); + + expect(fetch).not.toHaveBeenCalled(); + }); +}); + +describe("OPTIONS preflight", () => { + test("returns 200 without auth or payment", async () => { + const res = await handle(makeRequest("OPTIONS", "search", { auth: null })); + + expect(res.statusCode).toBe(200); + expect(res.headers["access-control-allow-origin"]).toBe("*"); + expect(res.headers["access-control-allow-methods"]).toContain("GET"); + }); + + /** The bunny twin sits behind a pull zone (a real CDN); anything cached here would replay + * a purchased result — with its Payment-Response receipt — to the next caller. */ + test("sends Cache-Control: no-store on every response", async () => { + const res = await handle(makeRequest("OPTIONS", "search", { auth: null })); + expect(res.headers["cache-control"]).toBe("no-store"); + + const unpaid = await handle(makeRequest("GET", "search", { auth: null })); + expect(unpaid.statusCode).toBe(402); + expect(unpaid.headers["cache-control"]).toBe("no-store"); + + mockExtractPaymentPayload.mockReturnValue(payment()); + const paid = await handle(makeRequest("GET", "search", { auth: null, query: { q: "x402" } })); + expect(paid.statusCode).toBe(200); + expect(paid.headers["cache-control"]).toBe("no-store"); + }); + + /** Both directions, and they are unrelated despite the similar names: the payment header must be + * allowed on the request, and the settlement header must be readable on the response. Without + * the second, a browser buyer's channel record goes stale and it re-deposits every call. */ + test("allows the payment header and exposes the settlement header", async () => { + const res = await handle(makeRequest("OPTIONS", "search", { auth: null })); + + expect(res.headers["access-control-allow-headers"]).toContain("PAYMENT-SIGNATURE"); + expect(res.headers["access-control-expose-headers"]).toContain("Payment-Response"); + }); + + /** + * `Authorization` is deliberately NOT named any more. It had to be, while the owner bearer was a + * way in — it is the one header a `*` wildcard does not cover. With that path gone the endpoint + * is payment-only like `genimg` and `llmx402`, and the shared `CORS_HEADERS` is the honest + * description of what it accepts. Asserted rather than left implicit, because silently + * re-widening the preflight is how a removed auth path grows back. + */ + test("does not advertise Authorization, which nothing sends any more", async () => { + const res = await handle(makeRequest("OPTIONS", "search", { auth: null })); + + expect(res.headers["access-control-allow-headers"]).not.toContain("Authorization"); + }); +}); + +/** + * These exercise the route's behaviour, which now means exercising it PAID — there is no other way + * in since the owner signature was removed. Note what that changes about validation: an unpaid + * request is answered with a 402 before its query is ever looked at (a client probing for terms is + * not first told its query is malformed), so a 400 is only reachable once a payment payload is + * present. It still costs nothing — `parseArgs` runs before verify and settle. + */ +describe("GET /search", () => { + beforeEach(() => { + mockExtractPaymentPayload.mockReturnValue(payment()); + }); + + test("returns the projected results", async () => { + const res = await handle(makeRequest("GET", "search", { auth: null, query: { q: "x402" } })); + + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body)).toEqual({ + results: [{ url: "https://example.com/0", title: "Result 0", text: "A snippet." }], + }); + }); + + /** The caller must not be able to buy itself more context than the proxy budgets for. */ + test("pins the cost knobs whatever the caller passes", async () => { + await handle( + makeRequest("GET", "search", { + auth: null, + query: { + q: "x402", + maximum_number_of_tokens: "32768", + count: "50", + maximum_number_of_urls: "50", + }, + }), + ); + + const url = new URL(String(vi.mocked(fetch).mock.calls[0][0])); + expect(url.searchParams.get("maximum_number_of_tokens")).toBe("2048"); + expect(url.searchParams.get("count")).toBe("10"); + expect(url.searchParams.get("maximum_number_of_urls")).toBe("5"); + }); + + test.each([ + ["a missing q", {}], + ["an empty q", { q: "" }], + ["an over-long q", { q: "x".repeat(601) }], + ])("answers 400 for %s", async (_label, query) => { + const res = await handle(makeRequest("GET", "search", { auth: null, query })); + + expect(res.statusCode).toBe(400); + }); + + test("answers 500 without detail when Brave fails", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ ok: false, status: 429, statusText: "Too Many Requests" }), + ); + + const res = await handle(makeRequest("GET", "search", { auth: null, query: { q: "x402" } })); + + expect(res.statusCode).toBe(500); + expect(JSON.parse(res.body).error).toBe("Internal server error"); + }); +}); + +describe("GET /fetch", () => { + beforeEach(() => { + mockExtractPaymentPayload.mockReturnValue(payment()); + }); + + /** The whole point of the shared function: one way in, priced per route, in front of both. */ + test("quotes a price when unpaid, exactly as /search does", async () => { + mockExtractPaymentPayload.mockReturnValue(null); + + const res = await handle( + makeRequest("GET", "fetch", { auth: null, query: { url: "https://example.com/" } }), + ); + + expect(res.statusCode).toBe(402); + }); + + test("returns the fetched envelope", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + status: 200, + ok: true, + statusText: "OK", + headers: new Headers({ "content-type": "text/html" }), + body: new Blob(["

Hello

"]).stream(), + }), + ); + + const res = await handle( + makeRequest("GET", "fetch", { auth: null, query: { url: "https://example.com/" } }), + ); + + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body)).toMatchObject({ + finalUrl: "https://example.com/", + contentType: "text/html", + }); + }); + + /** An SSRF refusal must read as a caller error the model can correct, not as a 500. */ + test.each([ + ["a missing url", {}], + ["an http url", { url: "http://example.com/" }], + ["a file url", { url: "file:///etc/passwd" }], + ["a loopback url", { url: "https://127.0.0.1/" }], + ["a metadata url", { url: "https://169.254.169.254/" }], + ])("answers 400 for %s", async (_label, query) => { + const res = await handle(makeRequest("GET", "fetch", { auth: null, query })); + + expect(res.statusCode).toBe(400); + }); +}); + +describe("routing", () => { + test.each([ + ["an unknown path", "GET", "elsewhere"], + ["the wrong method", "POST", "search"], + ])("answers 404 for %s", async (_label, method, path) => { + const res = await handle(makeRequest(method, path, { auth: validAuth, query: { q: "x402" } })); + + expect(res.statusCode).toBe(404); + expect(JSON.parse(res.body).error).toBe("Not found"); + }); + + /** A resource that does not exist has no price — quoting one would advertise a route that + * answers 404 the moment it is paid for. */ + test("answers 404 for an unknown path without quoting a price", async () => { + const res = await handle(makeRequest("GET", "elsewhere", { auth: null })); + + expect(res.statusCode).toBe(404); + expect(mockCreateBatchSettlementPaymentRequirements).not.toHaveBeenCalled(); + }); +}); + +/** + * Discovery: free, unauthenticated, unpaid. + * + * These exist so something that has never heard of this endpoint can find it. x402scan resolves an + * icon by fetching the origin ROOT and parsing `` before it probes any favicon + * path (see discovery.ts), so the root HTML is what makes the crawler index the origin at all — a + * published spec nobody crawls is not discovery. Every one of these paths returned 404 before + * PR 3, verified against the live endpoint. + */ +describe("discovery", () => { + test("serves the ERC-8004 registration file, unpaid, and an empty body on HEAD", async () => { + const res = await handle(discoveryRequest("GET", ".well-known/agent-registration.json")); + expect(res.statusCode).toBe(200); + expect(res.headers["content-type"]).toBe("application/json"); + expect(res.headers["access-control-allow-origin"]).toBe("*"); + const file = JSON.parse(res.body) as { services: { name: string; endpoint: string }[] }; + expect(file.services[0].endpoint).toBe("https://web-agent.fretchen.eu/openapi.json"); + // The offchain agentWallet is the same payTo the 402 quotes. + expect(file.services).toContainEqual({ + name: "agentWallet", + endpoint: `eip155:8453:${RECEIVER}`, + }); + expect(mockCreateBatchSettlementPaymentRequirements).not.toHaveBeenCalled(); + + const head = await handle(discoveryRequest("HEAD", ".well-known/agent-registration.json")); + expect(head.statusCode).toBe(200); + expect(head.body).toBe(""); + }); + + test("serves the generated spec, unpaid and unauthenticated", async () => { + const res = await handle(discoveryRequest("GET", "openapi.json")); + + expect(res.statusCode).toBe(200); + const spec = JSON.parse(res.body) as Record; + expect(spec["openapi"]).toBe("3.1.0"); + expect(spec["x-service-type"]).toBe("web/v1"); + expect(mockCreateBatchSettlementPaymentRequirements).not.toHaveBeenCalled(); + }); + + /** The price in the served document must be the price the 402 quotes. They are generated from + * one constant; this asserts the served copy did not get stale between build and deploy. */ + test("quotes the same prices in the spec as the routes charge", async () => { + const res = await handle(discoveryRequest("GET", "openapi.json")); + + const spec = JSON.parse(res.body) as Record< + string, + Record>>>> + >; + expect(spec["paths"]["/search"]["get"]["x-payment-info"]["price"]["amount"]).toBe("0.01"); + expect(spec["paths"]["/fetch"]["get"]["x-payment-info"]["price"]["amount"]).toBe("0.001"); + }); + + /** The Scaleway twin served base64 + isBase64Encoded; the native edge serves raw bytes. */ + test("serves the favicon as bytes on GET", async () => { + const res = await handleRequest(discoveryRequest("GET", "favicon.png")); + + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("image/jpeg"); + expect((await res.arrayBuffer()).byteLength).toBeGreaterThan(0); + }); + + /** HEAD was a 404 before PR 3 — the dispatch accepted GET only. A crawler that probes with HEAD + * would have concluded the icon was absent. */ + test("answers HEAD for the favicon with headers but no body", async () => { + const res = await handleRequest(discoveryRequest("HEAD", "favicon.png")); + + expect(res.status).toBe(200); + expect(await res.text()).toBe(""); + }); + + test("serves the discovery HTML at the root when the caller accepts HTML", async () => { + const res = await handle(discoveryRequest("GET", "", { Accept: "text/html" })); + + expect(res.statusCode).toBe(200); + expect(res.headers["content-type"]).toContain("text/html"); + expect(res.body).toContain('rel="icon"'); + }); + + /** Only when HTML is asked for. A bare root request is not a browser or a crawler looking for an + * icon, and answering HTML to it would hide the fact that the root serves no API. */ + test("does not serve HTML at the root without an HTML Accept header", async () => { + const res = await handle(discoveryRequest("GET", "")); + + expect(res.statusCode).toBe(404); + }); + + /** Discovery must not open a hole in the route check: an unknown path is still 404, and still + * without a price. */ + test("leaves the unknown-path 404 intact", async () => { + const res = await handle(discoveryRequest("GET", "openapi.yaml")); + + expect(res.statusCode).toBe(404); + expect(mockCreateBatchSettlementPaymentRequirements).not.toHaveBeenCalled(); + }); +}); + +describe("the 402 challenge", () => { + test.each([ + ["search", "10000"], + ["fetch", "1000"], + ])("quotes %s at %s atomic units", async (route, amount) => { + const res = await handle(makeRequest("GET", route, { auth: null })); + + const { accepts } = JSON.parse(res.body) as { accepts: { amount: string }[] }; + expect(res.statusCode).toBe(402); + expect(accepts.every((entry) => entry.amount === amount)).toBe(true); + }); + + /** + * The seller identity that makes a tool call land on the channel the CHAT already funded. + * `computeChannelId` hashes payer, payerAuthorizer, receiver, receiverAuthorizer, token, + * withdrawDelay and salt — so sharing `createLLMResourceServer` and `NFT_WALLET_PUBLIC_KEY` with + * `sc_llm_x402.ts` is the whole mechanism. Pay to a different address here and every visitor is + * asked for a second on-chain deposit. + */ + test("sells as the same receiver as the chat", async () => { + const res = await handle(makeRequest("GET", "search", { auth: null })); + const { accepts } = JSON.parse(res.body) as { accepts: { payTo: string }[] }; + + expect(mockCreateLLMResourceServer).toHaveBeenCalledWith(RECEIVER); + expect(accepts.every((entry) => entry.payTo === RECEIVER)).toBe(true); + }); + + /** Testnet USDC is free and both routes spend real money, so a testnet payment would buy + * metered Brave queries for nothing. */ + test("offers mainnet only", async () => { + const res = await handle(makeRequest("GET", "search", { auth: null })); + + const { accepts } = JSON.parse(res.body) as { accepts: { network: string }[] }; + expect(accepts.map((entry) => entry.network).sort()).toEqual(["eip155:10", "eip155:8453"]); + }); + + test("quotes the route, not the query string, as the resource", async () => { + const res = await handle(makeRequest("GET", "search", { auth: null, query: { q: "x402" } })); + + const { resource } = JSON.parse(res.body) as { resource: { url: string } }; + expect(resource.url).toMatch(/\/search$/); + }); + + /** One bazaar block per route (docs.x402.org/extensions/bazaar): the block names the method + * and the query parameters, and the two routes sell different things at different prices. */ + test("declares a per-route bazaar block with the service metadata", async () => { + await handle(makeRequest("GET", "search", { auth: null, query: { q: "x402" } })); + await handle( + makeRequest("GET", "fetch", { auth: null, query: { url: "https://example.com/" } }), + ); + + const searchOpts = mockCreateBatchSettlementPaymentRequirements.mock.calls[0][0] as { + extensions: { + bazaar: { info: { input: { method: string; queryParams: Record } } }; + }; + serviceName: string; + tags: string[]; + iconUrl: string; + }; + const fetchOpts = mockCreateBatchSettlementPaymentRequirements.mock + .calls[1][0] as typeof searchOpts; + + expect(searchOpts.extensions.bazaar.info.input.method).toBe("GET"); + expect(Object.keys(searchOpts.extensions.bazaar.info.input.queryParams)).toEqual(["q"]); + expect(fetchOpts.extensions.bazaar.info.input.method).toBe("GET"); + expect(Object.keys(fetchOpts.extensions.bazaar.info.input.queryParams)).toEqual(["url"]); + for (const opts of [searchOpts, fetchOpts]) { + expect(opts.serviceName).toBe("Fretchen Web Access"); + expect(opts.tags).toEqual(["search", "web", "x402"]); + expect(opts.iconUrl).toBe("https://web-agent.fretchen.eu/favicon.png"); + } + }); + + /** An unpaid request is quoted whatever its query says: a client probing for terms must not be + * told its query is malformed instead of being told the price. */ + test("quotes a price even when the query is invalid", async () => { + const res = await handle(makeRequest("GET", "search", { auth: null, query: {} })); + + expect(res.statusCode).toBe(402); + }); +}); + +describe("the paid path", () => { + beforeEach(() => { + mockExtractPaymentPayload.mockReturnValue(payment()); + }); + + test("serves and settles a valid payment", async () => { + const res = await handle(makeRequest("GET", "search", { auth: null, query: { q: "x402" } })); + + expect(res.statusCode).toBe(200); + expect(mockSettlePayment).toHaveBeenCalled(); + expect(res.headers["payment-response"]).toBe("encoded"); + expect(JSON.parse(res.body).results).toHaveLength(1); + }); + + /** + * The lock TTL must be the same number in the quote and in the verification — the SDK treats it + * as immutable across the pair, and a mismatch rejects the payment. It is NOT part of + * `channelConfig`, which is why it may be shorter here than the chat's 120s. + */ + test("verifies against the same maxTimeoutSeconds it advertised", async () => { + mockExtractPaymentPayload.mockReturnValueOnce(null); + const quote = await handle(makeRequest("GET", "search", { auth: null })); + const { accepts } = JSON.parse(quote.body) as { accepts: { maxTimeoutSeconds: number }[] }; + + await handle(makeRequest("GET", "search", { auth: null, query: { q: "x402" } })); + + const verified = mockVerifyPayment.mock.calls[0][1] as { maxTimeoutSeconds: number }; + expect(verified.maxTimeoutSeconds).toBe(accepts[0].maxTimeoutSeconds); + }); + + /** The enhanced requirements carry receiverAuthorizer; the raw ones do not, and the facilitator + * reads a missing one as a mismatch rather than as "not required". */ + test("verifies against enhanced requirements", async () => { + await handle(makeRequest("GET", "search", { auth: null, query: { q: "x402" } })); + + const verified = mockVerifyPayment.mock.calls[0][1] as { extra: Record }; + expect(verified.extra.receiverAuthorizer).toBe("0xauthorizer"); + }); + + test("settles the same amount it verified", async () => { + await handle(makeRequest("GET", "search", { auth: null, query: { q: "x402" } })); + + expect(mockSettlePayment.mock.calls[0][1]).toEqual(mockVerifyPayment.mock.calls[0][1]); + }); + + describe("paid in EURC", () => { + const BASE_EURC = EURC_ADDRESSES["eip155:8453"]; + + // Search costs 0.01 in both lists today, so a test with the real values could not tell which + // list was read. Give EURC a distinct price for the duration of the test. Imported here, after + // the outer beforeEach's resetModules + handler import, so it is the instance the handler reads. + beforeEach(async () => { + const { PRICE_ATOMIC } = await import("../src/search_schemas.js"); + PRICE_ATOMIC.search.EURC = "9000"; + }); + + test("prices the search from the EURC price list", async () => { + mockExtractPaymentPayload.mockReturnValue(payment("eip155:8453", BASE_EURC)); + + const res = await handle(makeRequest("GET", "search", { auth: null, query: { q: "x402" } })); + + expect(res.statusCode).toBe(200); + expect(mockVerifyPayment.mock.calls[0][1]).toMatchObject({ + asset: BASE_EURC, + amount: "9000", + extra: { name: "EURC", version: "2" }, + }); + }); + + test("refuses EURC on a network where it does not exist", async () => { + mockExtractPaymentPayload.mockReturnValue(payment("eip155:10", BASE_EURC)); + + const res = await handle(makeRequest("GET", "search", { auth: null, query: { q: "x402" } })); + + expect(res.statusCode).toBe(402); + expect(mockVerifyPayment).not.toHaveBeenCalled(); + }); + }); + + /** An upstream failure is not something to charge for. */ + test("does not settle when Brave fails", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ ok: false, status: 502, statusText: "Bad Gateway" }), + ); + + const res = await handle(makeRequest("GET", "search", { auth: null, query: { q: "x402" } })); + + expect(res.statusCode).toBe(500); + expect(mockSettlePayment).not.toHaveBeenCalled(); + // Verified but unsettled: the channel lock is released, not left to block the next message. + expect(mockReleaseLock).toHaveBeenCalledOnce(); + }); + + /** Nor is a request we refused to attempt — and it is refused before the channel is touched. */ + test("does not verify a paid request whose query is invalid", async () => { + const res = await handle(makeRequest("GET", "search", { auth: null, query: {} })); + + expect(res.statusCode).toBe(400); + expect(mockVerifyPayment).not.toHaveBeenCalled(); + }); + + /** + * The url's scheme is checked without I/O, so it is checked before paying. Not for the money — + * a rejected request never settles anyway — but for the lock: `verifyPayment` takes the + * channel's pendingRequest lock, and a request that dies after it orphans that lock on the + * channel the chat shares, stalling the user's next chat message for MAX_TIMEOUT_SECONDS. + */ + test.each([ + ["an http url", "http://example.com/"], + ["a file url", "file:///etc/passwd"], + ["not a url at all", "certainly not a url"], + ])("rejects %s before touching the channel", async (_label, url) => { + const res = await handle(makeRequest("GET", "fetch", { auth: null, query: { url } })); + + expect(res.statusCode).toBe(400); + expect(mockVerifyPayment).not.toHaveBeenCalled(); + expect(mockSettlePayment).not.toHaveBeenCalled(); + }); + + /** + * The failure that ended real research turns: a fetch rejected after verify (here the private + * address, found only by resolving the name) returns a quiet 400. The lock that verify took must + * be released, or the user's next chat message on the shared channel gets `channel_busy`. + */ + test("does not settle a paid request for a private address, and releases the lock", async () => { + const res = await handle( + makeRequest("GET", "fetch", { auth: null, query: { url: "https://169.254.169.254/" } }), + ); + + expect(res.statusCode).toBe(400); + expect(mockSettlePayment).not.toHaveBeenCalled(); + expect(mockReleaseLock).toHaveBeenCalledOnce(); + }); + + /** + * A page that never answers is the caller's choice of url, not our failure: the caller gets a + * 400 that names the host, is not charged, and nothing pages us. On 2026-10-04 this was a 500 + * "Internal server error" and a ServicesNeedAttention alert. + */ + test("explains a page that timed out, charges nothing, and does not alert", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockRejectedValue(new DOMException("The operation timed out.", "TimeoutError")), + ); + // The instance routes.ts logs through: beforeEach reset the registry before importing it. + const { logger } = await import("../src/logger.js"); + const errorLog = vi.spyOn(logger, "error"); + + const res = await handle( + makeRequest("GET", "fetch", { auth: null, query: { url: "https://slow.example/" } }), + ); + + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).error).toBe("Could not reach slow.example: no response within 8 s"); + expect(mockSettlePayment).not.toHaveBeenCalled(); + expect(mockReleaseLock).toHaveBeenCalledOnce(); + expect(errorLog).not.toHaveBeenCalled(); + }); + + test("does not release a lock on success", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ ok: true, json: async () => braveResponse() }), + ); + + await handle(makeRequest("GET", "search", { auth: null, query: { q: "x402" } })); + + expect(mockReleaseLock).not.toHaveBeenCalled(); + }); + + test("refuses a payment on a network it does not sell on", async () => { + mockExtractPaymentPayload.mockReturnValue(payment("eip155:84532")); + + const res = await handle(makeRequest("GET", "search", { auth: null, query: { q: "x402" } })); + + expect(res.statusCode).toBe(402); + expect(mockVerifyPayment).not.toHaveBeenCalled(); + }); + + /** Through the SDK, so its corrective enrichment runs and the client can resync by itself. */ + test("answers a failed verification with the SDK's corrective 402", async () => { + mockVerifyPayment.mockResolvedValue({ isValid: false, invalidReason: "channel_busy" }); + + const res = await handle(makeRequest("GET", "search", { auth: null, query: { q: "x402" } })); + + expect(res.statusCode).toBe(402); + expect(mockCreatePaymentRequiredResponse).toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); + }); + + test("answers 402 when settlement fails", async () => { + mockSettlePayment.mockResolvedValue({ + success: false, + errorReason: "cumulative_exceeds_balance", + }); + + const res = await handle(makeRequest("GET", "search", { auth: null, query: { q: "x402" } })); + + expect(res.statusCode).toBe(402); + expect(JSON.parse(res.body).error).toMatch(/cumulative_exceeds_balance/); + }); + + /** A bearer no longer buys an exemption: a paid request carrying one is charged like any other, + * and the header is simply ignored. */ + test("charges a paying caller even when it also sends a bearer", async () => { + const res = await handle( + makeRequest("GET", "search", { auth: validAuth, query: { q: "x402" } }), + ); + + expect(res.statusCode).toBe(200); + expect(mockVerifyPayment).toHaveBeenCalled(); + expect(mockSettlePayment).toHaveBeenCalled(); + }); +}); diff --git a/bunny_js/test/search_service.test.ts b/bunny_js/test/search_service.test.ts new file mode 100644 index 000000000..a81d0c620 --- /dev/null +++ b/bunny_js/test/search_service.test.ts @@ -0,0 +1,197 @@ +import { describe, test, expect, vi, beforeEach, afterEach } from "vitest"; +import { + fetchBraveContext, + selectSearchResults, + searchWeb, + QueryError, + MAX_QUERY_CHARS, +} from "../src/search_service.js"; + +/** + * The projection and the pinned cost knobs. + * + * Both exist for the same reason and neither is cosmetic: a tool result is charged as input tokens + * on every later hop of a chat turn, so Brave's defaults (8192 tokens across 20 urls) would be paid + * for repeatedly inside one turn. The "pins the cost knobs" test is the one that matters most — + * nothing else stops those from drifting back to the defaults. + */ + +/** Brave's documented response shape: grounding.generic plus a sources map keyed by url. */ +function braveResponse( + urlCount: number, + snippetsPerUrl = 2, + snippetText = "A snippet of context.", +) { + const generic = Array.from({ length: urlCount }, (_, i) => ({ + url: `https://example.com/${i}`, + title: `Result ${i}`, + snippets: Array.from({ length: snippetsPerUrl }, () => snippetText), + })); + const sources = Object.fromEntries( + generic.map((entry) => [ + entry.url, + { + title: entry.title, + hostname: "example.com", + age: ["13 April 2026", "2026-04-13", "5 days ago", ""], + }, + ]), + ); + return { grounding: { generic, poi: null, map: [] }, sources }; +} + +afterEach(() => { + vi.unstubAllGlobals(); + delete process.env.BRAVE_API_KEY; +}); + +describe("selectSearchResults", () => { + test("projects Brave's payload to url, title, date and text", () => { + const { results } = selectSearchResults(braveResponse(1)); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + url: "https://example.com/0", + title: "Result 0", + date: "2026-04-13", + }); + expect(results[0].text).toBe("A snippet of context. A snippet of context."); + }); + + test("caps the number of results regardless of how many Brave returns", () => { + expect(selectSearchResults(braveResponse(20)).results).toHaveLength(5); + }); + + test("caps the text of a single result", () => { + const { results } = selectSearchResults(braveResponse(1, 50, "x".repeat(100))); + + expect(results[0].text.length).toBeLessThanOrEqual(801); // 800 + the ellipsis + expect(results[0].text.endsWith("…")).toBe(true); + }); + + test("keeps a full projected payload small enough to re-bill on every hop", () => { + const serialized = JSON.stringify(selectSearchResults(braveResponse(20, 50, "x".repeat(100)))); + + expect(serialized.length).toBeLessThan(4500); + }); + + test("omits the date rather than guessing when Brave has no age for a url", () => { + const payload = braveResponse(1); + payload.sources = {}; + + expect(selectSearchResults(payload).results[0]).not.toHaveProperty("date"); + }); + + test("falls back to the url when a result has no title", () => { + const payload = braveResponse(1); + payload.grounding.generic[0].title = null as unknown as string; + + expect(selectSearchResults(payload).results[0].title).toBe("https://example.com/0"); + }); + + test.each([ + ["no grounding at all", { sources: {} }], + ["a null generic array", { grounding: { generic: null }, sources: {} }], + ["an empty result set", { grounding: { generic: [] }, sources: {} }], + ])("returns no results for %s", (_label, payload) => { + expect(selectSearchResults(payload).results).toEqual([]); + }); + + test("tolerates unknown fields, because the provider owns its contract", () => { + const payload = braveResponse(1) as Record; + payload.some_new_field = { added: "without notice" }; + + expect(selectSearchResults(payload).results).toHaveLength(1); + }); + + test("throws on a response that is not Brave's shape", () => { + expect(() => selectSearchResults({ grounding: { generic: [{ no_url: true }] } })).toThrow( + /unusable/i, + ); + }); +}); + +describe("fetchBraveContext", () => { + beforeEach(() => { + process.env.BRAVE_API_KEY = "test-token"; + }); + + test("pins the cost knobs and ignores nothing to the caller's discretion", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => braveResponse(1) }); + vi.stubGlobal("fetch", fetchMock); + + await fetchBraveContext("what is x402"); + + const url = new URL(fetchMock.mock.calls[0][0] as URL); + expect(url.origin + url.pathname).toBe("https://api.search.brave.com/res/v1/llm/context"); + expect(url.searchParams.get("q")).toBe("what is x402"); + // Brave's defaults are 8192 and 20; these are the whole point of proxying. + expect(url.searchParams.get("maximum_number_of_tokens")).toBe("2048"); + expect(url.searchParams.get("maximum_number_of_urls")).toBe("5"); + expect(url.searchParams.get("maximum_number_of_tokens_per_url")).toBe("512"); + expect(url.searchParams.get("count")).toBe("10"); + // Unset means no filtering at all, which is why this is asserted rather than left to the + // constant: the value is a decision, and a silent drop back to Brave's nothing is invisible. + expect(url.searchParams.get("safesearch")).toBe("moderate"); + }); + + test("sends the key in the header Brave expects, and nowhere else", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => braveResponse(1) }); + vi.stubGlobal("fetch", fetchMock); + + await fetchBraveContext("query"); + + const init = fetchMock.mock.calls[0][1] as { + headers: Record; + signal: AbortSignal; + }; + expect(init.headers["X-Subscription-Token"]).toBe("test-token"); + expect(String(fetchMock.mock.calls[0][0])).not.toContain("test-token"); + expect(init.signal).toBeInstanceOf(AbortSignal); + }); + + test.each([ + ["an empty query", ""], + ["a whitespace-only query", " "], + ])("rejects %s as a caller error", async (_label, query) => { + vi.stubGlobal("fetch", vi.fn()); + + await expect(fetchBraveContext(query)).rejects.toThrow(QueryError); + }); + + test("rejects a query past Brave's own limit, so it is our 400 and not their 422", async () => { + vi.stubGlobal("fetch", vi.fn()); + + await expect(fetchBraveContext("x".repeat(MAX_QUERY_CHARS + 1))).rejects.toThrow(QueryError); + }); + + test("throws a plain error when the key is not configured", async () => { + delete process.env.BRAVE_API_KEY; + vi.stubGlobal("fetch", vi.fn()); + + await expect(fetchBraveContext("query")).rejects.toThrow(/BRAVE_API_KEY/); + }); + + test("throws on a non-ok upstream response", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ ok: false, status: 429, statusText: "Too Many Requests" }), + ); + + await expect(fetchBraveContext("query")).rejects.toThrow( + "Could not reach Brave: 429 Too Many Requests", + ); + }); +}); + +describe("searchWeb", () => { + test("fetches and projects in one call", async () => { + process.env.BRAVE_API_KEY = "test-token"; + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ ok: true, json: async () => braveResponse(20) }), + ); + + expect((await searchWeb("query")).results).toHaveLength(5); + }); +}); diff --git a/bunny_js/test/stablecoin_pricing.test.ts b/bunny_js/test/stablecoin_pricing.test.ts new file mode 100644 index 000000000..9eb2fbff2 --- /dev/null +++ b/bunny_js/test/stablecoin_pricing.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from "vitest"; +import { USDC_ADDRESSES, EURC_ADDRESSES } from "@fretchen/chain-utils"; +import { offeredStablecoins, resolvePaidStablecoin } from "../src/stablecoin_pricing.js"; + +const BASE = "eip155:8453"; +const BASE_SEPOLIA = "eip155:84532"; +const OP = "eip155:10"; + +describe("stablecoin_pricing", () => { + describe("offeredStablecoins", () => { + it("offers EURe, then EURC, then USDC on both Base networks — the order is the preference", () => { + expect(offeredStablecoins(BASE).map((c) => c.symbol)).toEqual(["EURe", "EURC", "USDC"]); + expect(offeredStablecoins(BASE_SEPOLIA).map((c) => c.symbol)).toEqual([ + "EURe", + "EURC", + "USDC", + ]); + }); + + it("offers USDC only on Optimism, where EURC and EURe do not exist", () => { + expect(offeredStablecoins(OP).map((c) => c.symbol)).toEqual(["USDC"]); + }); + + it("needs no configuration to offer EURC or EURe", () => { + // EURC and EURe are first-class price lists, not conversions that depend on a rate being set. + expect(offeredStablecoins(BASE).some((c) => c.symbol === "EURC")).toBe(true); + expect(offeredStablecoins(BASE).some((c) => c.symbol === "EURe")).toBe(true); + }); + + it("carries each token's decimals — EURe is 18, not 6", () => { + const bySymbol = Object.fromEntries(offeredStablecoins(BASE).map((c) => [c.symbol, c])); + expect(bySymbol.EURe.decimals).toBe(18); + expect(bySymbol.EURC.decimals).toBe(6); + expect(bySymbol.USDC.decimals).toBe(6); + }); + }); + + describe("resolvePaidStablecoin", () => { + it("resolves an offered token, case-insensitively", () => { + expect(resolvePaidStablecoin(BASE, EURC_ADDRESSES[BASE].toLowerCase())?.symbol).toBe("EURC"); + expect(resolvePaidStablecoin(BASE, USDC_ADDRESSES[BASE])?.symbol).toBe("USDC"); + }); + + it("rejects another network's token, an unknown token, and a missing asset", () => { + expect(resolvePaidStablecoin(OP, EURC_ADDRESSES[BASE])).toBeNull(); + expect(resolvePaidStablecoin(BASE, "0x0000000000000000000000000000000000000001")).toBeNull(); + expect(resolvePaidStablecoin(BASE, undefined)).toBeNull(); + }); + }); +}); diff --git a/bunny_js/test/web_fetch_service.test.ts b/bunny_js/test/web_fetch_service.test.ts new file mode 100644 index 000000000..d8e2e331a --- /dev/null +++ b/bunny_js/test/web_fetch_service.test.ts @@ -0,0 +1,338 @@ +import { describe, test, expect, vi, beforeEach, afterEach } from "vitest"; + +const mockLookup = vi.fn(); +vi.mock("node:dns/promises", () => ({ lookup: mockLookup })); + +const { isPrivateAddress, assertPublicUrl, fetchExternalHtml, FetchUrlError } = + await import("../src/web_fetch_service.js"); + +/** + * This file is almost entirely SSRF tests, which is the right proportion: the rest of the module + * is a fetch and a string. The url is chosen by a model that can be steered by any page it has + * read, so every one of these is a path somebody could actually be talked into. + */ + +/** A body the streaming reader can consume, since `readCapped` uses getReader() not text(). */ +function htmlResponse(html: string, contentType = "text/html; charset=utf-8", status = 200) { + return { + status, + ok: status >= 200 && status < 300, + statusText: "OK", + headers: new Headers({ "content-type": contentType }), + body: new Blob([html]).stream(), + }; +} + +function redirectTo(location: string, status = 302) { + return { + status, + ok: false, + statusText: "Found", + headers: new Headers({ location }), + body: null, + }; +} + +beforeEach(() => { + mockLookup.mockReset(); + // Public by default; individual tests override. + mockLookup.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("isPrivateAddress", () => { + test.each([ + ["loopback", "127.0.0.1"], + ["private 10/8", "10.0.0.1"], + ["private 172.16/12 low", "172.16.0.1"], + ["private 172.16/12 high", "172.31.255.255"], + ["private 192.168/16", "192.168.1.1"], + ["link-local — cloud metadata", "169.254.169.254"], + ["this network", "0.0.0.0"], + ["carrier-grade NAT", "100.64.0.1"], + ["multicast", "224.0.0.1"], + ["IPv6 loopback", "::1"], + ["IPv6 unspecified", "::"], + ["IPv6 unique local", "fc00::1"], + ["IPv6 unique local fd", "fd12:3456::1"], + ["IPv6 link-local", "fe80::1"], + ["IPv4-mapped private", "::ffff:10.0.0.1"], + ["garbage", "not-an-ip"], + ])("refuses %s", (_label, ip) => { + expect(isPrivateAddress(ip)).toBe(true); + }); + + test.each([ + ["a public v4", "93.184.216.34"], + ["Cloudflare DNS", "1.1.1.1"], + // The off-by-one the /12 mask invites: 172.32 is public, 172.31 is not. + ["172.32, just outside the private block", "172.32.0.1"], + ["172.15, just below it", "172.15.0.1"], + ["a public v6", "2606:4700:4700::1111"], + ["IPv4-mapped public", "::ffff:93.184.216.34"], + ])("allows %s", (_label, ip) => { + expect(isPrivateAddress(ip)).toBe(false); + }); +}); + +describe("assertPublicUrl", () => { + test.each([ + ["http", "http://example.com/"], + ["file", "file:///etc/passwd"], + ["javascript", "javascript:alert(1)"], + ["gopher", "gopher://example.com/"], + ])("refuses the %s scheme", async (_label, url) => { + await expect(assertPublicUrl(url)).rejects.toThrow(FetchUrlError); + }); + + test.each([ + ["an empty string", ""], + ["whitespace", " "], + ["a relative path", "/etc/passwd"], + ["nonsense", "not a url"], + ])("refuses %s", async (_label, url) => { + await expect(assertPublicUrl(url)).rejects.toThrow(FetchUrlError); + }); + + test("refuses a hostname that resolves into private space", async () => { + // The attack: the name looks public, the DNS answer is not. + mockLookup.mockResolvedValue([{ address: "169.254.169.254", family: 4 }]); + + await expect(assertPublicUrl("https://metadata.example.com/")).rejects.toThrow(/non-public/i); + }); + + test("refuses when ANY resolved address is private, not just the first", async () => { + mockLookup.mockResolvedValue([ + { address: "93.184.216.34", family: 4 }, + { address: "127.0.0.1", family: 4 }, + ]); + + await expect(assertPublicUrl("https://split-horizon.example.com/")).rejects.toThrow( + /non-public/i, + ); + }); + + test("refuses a name that cannot be resolved", async () => { + mockLookup.mockRejectedValue(new Error("ENOTFOUND")); + + await expect(assertPublicUrl("https://nope.example/")).rejects.toThrow(/resolve/i); + }); + + test("accepts a public https url", async () => { + const url = await assertPublicUrl(" https://example.com/page?a=1 "); + + expect(url.toString()).toBe("https://example.com/page?a=1"); + }); +}); + +describe("fetchExternalHtml", () => { + test("returns the html, the resolved type and the final url", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(htmlResponse("

Hi

"))); + + const page = await fetchExternalHtml("https://example.com/"); + + expect(page).toMatchObject({ + finalUrl: "https://example.com/", + html: "

Hi

", + contentType: "text/html", + }); + }); + + test("identifies itself rather than fetching anonymously", async () => { + const fetchMock = vi.fn().mockResolvedValue(htmlResponse("")); + vi.stubGlobal("fetch", fetchMock); + + await fetchExternalHtml("https://example.com/"); + + const init = fetchMock.mock.calls[0][1] as { + headers: Record; + redirect: string; + }; + expect(init.headers["User-Agent"]).toContain("fretchen.eu"); + expect(init.redirect).toBe("manual"); + }); + + test("follows a redirect and reports where it landed", async () => { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce(redirectTo("https://example.com/final")) + .mockResolvedValueOnce(htmlResponse("final")), + ); + + const page = await fetchExternalHtml("https://example.com/start"); + + expect(page.finalUrl).toBe("https://example.com/final"); + expect(page.html).toBe("final"); + }); + + test("resolves a relative Location against the current url", async () => { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce(redirectTo("/elsewhere")) + .mockResolvedValueOnce(htmlResponse("x")), + ); + + expect((await fetchExternalHtml("https://example.com/start")).finalUrl).toBe( + "https://example.com/elsewhere", + ); + }); + + /** + * The test the hand-written redirect loop exists for. The url handed in is public; the 302 is + * not. Automatic redirect following would have made every other check here pointless. + */ + test("refuses a redirect into private space", async () => { + mockLookup.mockImplementation((host: string) => + host === "example.com" + ? Promise.resolve([{ address: "93.184.216.34", family: 4 }]) + : Promise.resolve([{ address: "169.254.169.254", family: 4 }]), + ); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(redirectTo("https://metadata.internal/latest/meta-data/")), + ); + + await expect(fetchExternalHtml("https://example.com/")).rejects.toThrow(/non-public/i); + }); + + test("refuses a redirect to a non-https scheme", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(redirectTo("http://example.com/insecure"))); + + await expect(fetchExternalHtml("https://example.com/")).rejects.toThrow(/https/i); + }); + + test("gives up on a redirect loop instead of following forever", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(redirectTo("https://example.com/again"))); + + await expect(fetchExternalHtml("https://example.com/")).rejects.toThrow(/too many redirects/i); + }); + + test("refuses a redirect with no Location", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ ...redirectTo(""), headers: new Headers() }), + ); + + await expect(fetchExternalHtml("https://example.com/")).rejects.toThrow(/no location/i); + }); + + test.each([ + ["a pdf", "application/pdf"], + ["an image", "image/png"], + ["json", "application/json"], + ["nothing at all", ""], + ])("refuses %s without reading the body", async (_label, contentType) => { + const cancel = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + status: 200, + ok: true, + statusText: "OK", + headers: new Headers(contentType ? { "content-type": contentType } : {}), + body: { cancel }, + }), + ); + + await expect(fetchExternalHtml("https://example.com/")).rejects.toThrow(FetchUrlError); + expect(cancel).toHaveBeenCalled(); + }); + + test("accepts plain text and xhtml", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(htmlResponse("plain", "text/plain"))); + + expect((await fetchExternalHtml("https://example.com/")).contentType).toBe("text/plain"); + }); + + test("stops reading a body past the cap instead of buffering it whole", async () => { + const huge = "

" + "x".repeat(1_500_000) + "

"; + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(htmlResponse(huge))); + + const page = await fetchExternalHtml("https://example.com/"); + + expect(page.html.length).toBeLessThanOrEqual(1_000_000); + expect(page.html.length).toBeGreaterThan(0); + }); + + test("reports an upstream error status without exposing its body", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ ...htmlResponse("nope"), status: 403, ok: false }), + ); + + // A FetchUrlError, so search_api answers 400 with the status rather than logging a 500 and + // telling the model "Internal server error" about someone else's 403. + await expect(fetchExternalHtml("https://example.com/")).rejects.toThrow(FetchUrlError); + await expect(fetchExternalHtml("https://example.com/")).rejects.toThrow(/403/); + }); + + /** + * A slow or dead site is the caller's choice of url, like its 403. Before these were mapped, + * the 8 s timeout escaped as an unhandled error: a 500 "Internal server error" for the caller + * and a page for us (ServicesNeedAttention, 2026-10-04). + */ + describe("a site that does not answer", () => { + test("reports a timeout as unreachable, naming the host and the limit", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockRejectedValue(new DOMException("The operation timed out.", "TimeoutError")), + ); + + const err = await fetchExternalHtml("https://example.com/").catch((e: unknown) => e); + + expect(err).toBeInstanceOf(FetchUrlError); + expect((err as Error).message).toBe("Could not reach example.com: no response within 8 s"); + }); + + test("reports a body that stalls past the timeout the same way", async () => { + // Headers arrive, then the same timeout signal aborts the body mid-read. + const stalled = new ReadableStream({ + start(controller) { + controller.error(new DOMException("The operation timed out.", "TimeoutError")); + }, + }); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ...htmlResponse(""), body: stalled })); + + await expect(fetchExternalHtml("https://example.com/")).rejects.toThrow( + new FetchUrlError("Could not reach example.com: no response within 8 s"), + ); + }); + + test("reports a refused connection with its code", async () => { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockRejectedValue(new TypeError("fetch failed", { cause: { code: "ECONNREFUSED" } })), + ); + + await expect(fetchExternalHtml("https://example.com/")).rejects.toThrow( + new FetchUrlError("Could not reach example.com: ECONNREFUSED"), + ); + }); + + test("leaves an error of our own unhandled, so it still alerts", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("boom"))); + + const err = await fetchExternalHtml("https://example.com/").catch((e: unknown) => e); + + expect(err).not.toBeInstanceOf(FetchUrlError); + expect((err as Error).message).toBe("boom"); + }); + }); + + test("never calls fetch when the url fails validation", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + await expect(fetchExternalHtml("http://example.com/")).rejects.toThrow(FetchUrlError); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/bunny_js/test/x402_channel_storage.test.ts b/bunny_js/test/x402_channel_storage.test.ts new file mode 100644 index 000000000..2c5b16e07 --- /dev/null +++ b/bunny_js/test/x402_channel_storage.test.ts @@ -0,0 +1,356 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { Channel } from "@x402/evm/batch-settlement/server"; + +// ===== Mocks (vi.hoisted ensures these are available when vi.mock factories run) ===== + +const { mockGetS3ObjectWithMeta, mockPutS3ObjectConditional, mockDeleteS3Object, mockListObjects } = + vi.hoisted(() => ({ + mockGetS3ObjectWithMeta: vi.fn(), + mockPutS3ObjectConditional: vi.fn(), + mockDeleteS3Object: vi.fn(), + mockListObjects: vi.fn(), + })); + +vi.mock("@fretchen/s3-utils", () => ({ + getS3ObjectWithMeta: mockGetS3ObjectWithMeta, + putS3ObjectConditional: mockPutS3ObjectConditional, + deleteS3Object: mockDeleteS3Object, + listObjects: mockListObjects, +})); + +// ===== Import after mocks ===== + +import { S3ChannelStorage, belongsToNetwork, channelPrefix } from "../src/x402_channel_storage.js"; +import { computeChannelId } from "@x402/evm/batch-settlement/client"; + +// ===== Helpers ===== + +const OP = "eip155:10"; +const BASE = "eip155:8453"; + +/** + * A channelConfig differing only by `salt`, so two fixtures can share every field and still + * be distinct channels. `withdrawDelay` matches the deployed 86400. + */ +const BASE_USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; +const BASE_EURC = "0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42"; + +function makeConfig( + salt = 1, + token = "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", +): Channel["channelConfig"] { + return { + payer: "0x073f26F0C3FC100e7b075C3DC3cDE0A777497D20", + payerAuthorizer: "0x45E41fC1d1c7e47E209a2867F3948065B6b627A8", + receiver: "0xAAEBC1441323B8ad6Bdf6793A8428166b510239C", + receiverAuthorizer: "0xF9B70303375f9762516669591D75049692Ab2c93", + token, + withdrawDelay: 86400, + salt: `0x${salt.toString(16).padStart(64, "0")}`, + } as Channel["channelConfig"]; +} + +/** + * Builds a channel whose `channelId` is the REAL hash for `network`, so the storage's + * network guard is exercised rather than stubbed. Pass a mismatched `network`/`idNetwork` + * pair to simulate a misfiled object. + */ +function makeChannel( + overrides: Partial = {}, + { network = OP, salt = 1, token }: { network?: string; salt?: number; token?: string } = {}, +): Channel { + const channelConfig = makeConfig(salt, token); + return { + channelId: computeChannelId(channelConfig, network as `${string}:${string}`), + channelConfig, + chargedCumulativeAmount: "0", + signedMaxClaimable: "10000", + signature: "0xsig", + balance: "10000", + totalClaimed: "0", + withdrawRequestedAt: 0, + refundNonce: 0, + lastRequestTimestamp: Date.now(), + ...overrides, + }; +} + +// ===== Tests ===== + +describe("S3ChannelStorage", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("get", () => { + it("returns undefined when the object is missing", async () => { + mockGetS3ObjectWithMeta.mockResolvedValue(null); + const storage = new S3ChannelStorage(OP); + const result = await storage.get("0xChannel1"); + expect(result).toBeUndefined(); + }); + + it("returns the parsed channel and lowercases the key", async () => { + const channel = makeChannel(); + mockGetS3ObjectWithMeta.mockResolvedValue({ + body: JSON.stringify(channel), + etag: '"etag-1"', + }); + const storage = new S3ChannelStorage(OP); + const result = await storage.get("0xChannel1"); + expect(result).toEqual(channel); + expect(mockGetS3ObjectWithMeta).toHaveBeenCalledWith("channels/eip155:10/0xchannel1.json"); + }); + }); + + describe("list", () => { + it("returns an empty array when there are no keys", async () => { + mockListObjects.mockResolvedValue([]); + const storage = new S3ChannelStorage(OP); + const result = await storage.list(); + expect(result).toEqual([]); + }); + + it("fetches every key and returns channels sorted by channelId", async () => { + const one = makeChannel({}, { network: OP, salt: 1 }); + const two = makeChannel({}, { network: OP, salt: 2 }); + const keyOf = (c: Channel) => `${channelPrefix(OP)}${c.channelId.toLowerCase()}.json`; + mockListObjects.mockResolvedValue([keyOf(two), keyOf(one)]); + mockGetS3ObjectWithMeta.mockImplementation(async (key: string) => { + if (key === keyOf(one)) return { body: JSON.stringify(one), etag: '"e1"' }; + if (key === keyOf(two)) return { body: JSON.stringify(two), etag: '"e2"' }; + return null; + }); + const storage = new S3ChannelStorage(OP); + const result = await storage.list(); + expect(result.map((c) => c.channelId)).toEqual( + [one.channelId, two.channelId].sort((a, b) => a.localeCompare(b)), + ); + }); + + it("lists only the network it was constructed for", async () => { + // The regression that drained the escrow: one flat store handed Base channels to + // Optimism claim batches, every batch reverted with claim_simulation_failed, and no + // claim or refund ever landed. list() must never see another chain's channels. + const opChannel = makeChannel({}, { network: OP, salt: 1 }); + mockListObjects.mockResolvedValue([ + `${channelPrefix(OP)}${opChannel.channelId.toLowerCase()}.json`, + ]); + mockGetS3ObjectWithMeta.mockResolvedValue({ + body: JSON.stringify(opChannel), + etag: '"e1"', + }); + + const storage = new S3ChannelStorage(OP); + await storage.list(); + + expect(mockListObjects).toHaveBeenCalledWith("channels/eip155:10/"); + expect(mockListObjects).not.toHaveBeenCalledWith("channels/"); + }); + + it("skips a record filed under the wrong network prefix", async () => { + // Defence in depth for a bad migration: a Base channel sitting under the Optimism + // prefix would revert the entire claim batch, taking every valid channel with it. + const baseChannel = makeChannel({}, { network: BASE, salt: 1 }); + const opChannel = makeChannel({}, { network: OP, salt: 2 }); + const misfiled = `${channelPrefix(OP)}${baseChannel.channelId.toLowerCase()}.json`; + const correct = `${channelPrefix(OP)}${opChannel.channelId.toLowerCase()}.json`; + mockListObjects.mockResolvedValue([misfiled, correct]); + mockGetS3ObjectWithMeta.mockImplementation(async (key: string) => { + if (key === misfiled) return { body: JSON.stringify(baseChannel), etag: '"e1"' }; + if (key === correct) return { body: JSON.stringify(opChannel), etag: '"e2"' }; + return null; + }); + + const storage = new S3ChannelStorage(OP); + const result = await storage.list(); + + expect(result.map((c) => c.channelId)).toEqual([opChannel.channelId]); + }); + + describe("scoped to one token", () => { + // USDC and EURC channels share Base's prefix. The claim cron lists one token at a time, + // because the SDK claims every listed channel in its single token and the facilitator + // refuses a batch that mixes tokens — one unfiltered list would fail every Base claim. + const usdc = makeChannel({}, { network: BASE, salt: 1, token: BASE_USDC }); + const eurc = makeChannel({}, { network: BASE, salt: 2, token: BASE_EURC }); + const keyOf = (c: Channel) => `${channelPrefix(BASE)}${c.channelId.toLowerCase()}.json`; + + beforeEach(() => { + mockListObjects.mockResolvedValue([keyOf(usdc), keyOf(eurc)]); + mockGetS3ObjectWithMeta.mockImplementation(async (key: string) => { + if (key === keyOf(usdc)) return { body: JSON.stringify(usdc), etag: '"e1"' }; + if (key === keyOf(eurc)) return { body: JSON.stringify(eurc), etag: '"e2"' }; + return null; + }); + }); + + it("lists only that token's channels", async () => { + const eurcOnly = await new S3ChannelStorage(BASE, BASE_EURC).list(); + expect(eurcOnly.map((c) => c.channelId)).toEqual([eurc.channelId]); + + const usdcOnly = await new S3ChannelStorage(BASE, BASE_USDC).list(); + expect(usdcOnly.map((c) => c.channelId)).toEqual([usdc.channelId]); + }); + + it("matches the token case-insensitively", async () => { + const result = await new S3ChannelStorage(BASE, BASE_EURC.toLowerCase()).list(); + expect(result.map((c) => c.channelId)).toEqual([eurc.channelId]); + }); + + it("lists both tokens when no token is given — the serving path's view", async () => { + const result = await new S3ChannelStorage(BASE).list(); + expect(result).toHaveLength(2); + }); + }); + }); + + describe("belongsToNetwork", () => { + it("matches a channel against the network its id was hashed for", () => { + const channel = makeChannel({}, { network: OP }); + expect(belongsToNetwork(channel, OP)).toBe(true); + expect(belongsToNetwork(channel, BASE)).toBe(false); + }); + + it("returns false for a malformed channelConfig rather than throwing", () => { + const broken = { ...makeChannel(), channelConfig: {} as Channel["channelConfig"] }; + expect(belongsToNetwork(broken, OP)).toBe(false); + }); + }); + + describe("channelPrefix", () => { + it("partitions channels by network", () => { + expect(channelPrefix(OP)).toBe("channels/eip155:10/"); + expect(channelPrefix(BASE)).toBe("channels/eip155:8453/"); + }); + }); + + describe("updateChannel", () => { + it("creates a new channel with If-None-Match when none exists", async () => { + mockGetS3ObjectWithMeta.mockResolvedValue(null); + mockPutS3ObjectConditional.mockResolvedValue({ ok: true, etag: '"new-etag"' }); + + const storage = new S3ChannelStorage(OP); + const newChannel = makeChannel(); + const result = await storage.updateChannel("0xChannel1", () => newChannel); + + expect(result).toEqual({ channel: newChannel, status: "updated" }); + const [key, , opts] = mockPutS3ObjectConditional.mock.calls[0]; + expect(key).toBe("channels/eip155:10/0xchannel1.json"); + expect(opts.ifNoneMatch).toBe("*"); + expect(opts.ifMatch).toBeUndefined(); + }); + + it("updates an existing channel with If-Match on its current etag", async () => { + const current = makeChannel(); + mockGetS3ObjectWithMeta.mockResolvedValue({ + body: JSON.stringify(current), + etag: '"etag-1"', + }); + mockPutS3ObjectConditional.mockResolvedValue({ ok: true, etag: '"etag-2"' }); + + const storage = new S3ChannelStorage(OP); + const updated = { ...current, chargedCumulativeAmount: "500" }; + const result = await storage.updateChannel("0xChannel1", () => updated); + + expect(result).toEqual({ channel: updated, status: "updated" }); + const [, , opts] = mockPutS3ObjectConditional.mock.calls[0]; + expect(opts.ifMatch).toBe('"etag-1"'); + }); + + it("returns unchanged without writing when the callback returns the same reference", async () => { + const current = makeChannel(); + mockGetS3ObjectWithMeta.mockResolvedValue({ + body: JSON.stringify(current), + etag: '"etag-1"', + }); + + const storage = new S3ChannelStorage(OP); + // Since get() re-parses JSON, "current" as seen by the callback is a fresh object; + // returning it back by reference is what the SDK contract means by "unchanged". + const result = await storage.updateChannel("0xChannel1", (c) => c); + + expect(result.status).toBe("unchanged"); + expect(mockPutS3ObjectConditional).not.toHaveBeenCalled(); + }); + + it("retries from a fresh read on a 412 CAS conflict, then succeeds", async () => { + const staleChannel = makeChannel({ chargedCumulativeAmount: "100" }); + const freshChannel = makeChannel({ chargedCumulativeAmount: "200" }); + mockGetS3ObjectWithMeta + .mockResolvedValueOnce({ body: JSON.stringify(staleChannel), etag: '"stale"' }) + .mockResolvedValueOnce({ body: JSON.stringify(freshChannel), etag: '"fresh"' }); + mockPutS3ObjectConditional + .mockResolvedValueOnce({ ok: false, status: 412 }) + .mockResolvedValueOnce({ ok: true, etag: '"final"' }); + + const storage = new S3ChannelStorage(OP); + const result = await storage.updateChannel("0xChannel1", (c) => ({ + ...c!, + chargedCumulativeAmount: (BigInt(c!.chargedCumulativeAmount) + 1n).toString(), + })); + + expect(result.status).toBe("updated"); + expect(result.channel?.chargedCumulativeAmount).toBe("201"); + expect(mockGetS3ObjectWithMeta).toHaveBeenCalledTimes(2); + expect(mockPutS3ObjectConditional).toHaveBeenCalledTimes(2); + }); + + it("throws after exceeding the max CAS attempts", async () => { + mockGetS3ObjectWithMeta.mockResolvedValue({ + body: JSON.stringify(makeChannel()), + etag: '"e"', + }); + mockPutS3ObjectConditional.mockResolvedValue({ ok: false, status: 412 }); + + const storage = new S3ChannelStorage(OP); + await expect( + storage.updateChannel("0xChannel1", (c) => ({ ...c!, chargedCumulativeAmount: "999" })), + ).rejects.toThrow(/CAS attempts/); + }); + + it("deletes the object with If-Match when the callback returns undefined", async () => { + const current = makeChannel(); + mockGetS3ObjectWithMeta.mockResolvedValue({ + body: JSON.stringify(current), + etag: '"etag-1"', + }); + mockDeleteS3Object.mockResolvedValue({ ok: true }); + + const storage = new S3ChannelStorage(OP); + const result = await storage.updateChannel("0xChannel1", () => undefined); + + expect(result).toEqual({ channel: undefined, status: "deleted" }); + const [key, opts] = mockDeleteS3Object.mock.calls[0]; + expect(key).toBe("channels/eip155:10/0xchannel1.json"); + expect(opts.ifMatch).toBe('"etag-1"'); + }); + + it("returns unchanged when deleting a channel that never existed", async () => { + mockGetS3ObjectWithMeta.mockResolvedValue(null); + mockDeleteS3Object.mockResolvedValue({ ok: true }); + + const storage = new S3ChannelStorage(OP); + const result = await storage.updateChannel("0xChannel1", () => undefined); + + expect(result).toEqual({ channel: undefined, status: "unchanged" }); + }); + + it("retries the delete on a 412 conflict", async () => { + const current = makeChannel(); + mockGetS3ObjectWithMeta.mockResolvedValue({ + body: JSON.stringify(current), + etag: '"etag-1"', + }); + mockDeleteS3Object + .mockResolvedValueOnce({ ok: false, status: 412 }) + .mockResolvedValueOnce({ ok: true }); + + const storage = new S3ChannelStorage(OP); + const result = await storage.updateChannel("0xChannel1", () => undefined); + + expect(result).toEqual({ channel: undefined, status: "deleted" }); + expect(mockDeleteS3Object).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/bunny_js/test/x402_channel_sync.test.ts b/bunny_js/test/x402_channel_sync.test.ts new file mode 100644 index 000000000..5906345d1 --- /dev/null +++ b/bunny_js/test/x402_channel_sync.test.ts @@ -0,0 +1,210 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { Channel, ChannelStorage } from "@x402/evm/batch-settlement/server"; + +const { mockReadContract } = vi.hoisted(() => ({ mockReadContract: vi.fn() })); + +vi.mock("viem", async () => { + const actual = await vi.importActual("viem"); + return { ...actual, createPublicClient: vi.fn(() => ({ readContract: mockReadContract })) }; +}); + +vi.mock("@fretchen/chain-utils", () => ({ + getViemChain: vi.fn(() => ({ id: 10 })), + getRpcUrl: vi.fn(() => "https://rpc.example"), +})); + +import { resyncChannelState } from "../src/x402_channel_sync.js"; + +const OP = "eip155:10"; + +function makeChannel(overrides: Partial = {}): Channel { + return { + channelId: "0x397fe7b50624f0e6cd64fbea71327a2f9de70834fe02f9bc38bc0e45be629e34", + channelConfig: {} as Channel["channelConfig"], + chargedCumulativeAmount: "35147", + signedMaxClaimable: "40000", + signature: "0xsig", + balance: "0", + totalClaimed: "0", + withdrawRequestedAt: 0, + refundNonce: 0, + lastRequestTimestamp: Date.now(), + ...overrides, + }; +} + +function makeStorage(channels: Channel[]) { + const updateChannel = vi.fn( + async (channelId: string, update: (c: Channel | undefined) => Channel | undefined) => { + const idx = channels.findIndex((c) => c.channelId === channelId); + const next = update(channels[idx]); + if (next) channels[idx] = next; + return { channel: next, status: "updated" as const }; + }, + ); + return { + storage: { + get: vi.fn(), + list: vi.fn(async () => channels), + updateChannel, + } as unknown as ChannelStorage, + updateChannel, + channels, + }; +} + +/** + * Two reads per channel now — `channels()` and `refundNonce()` — so the mock routes on + * functionName. `setChain` is the only way tests should drive it: a positional mock would silently + * feed the nonce to the balance read the moment the call order changed. + */ +function setChain({ + balance = 0n, + totalClaimed = 0n, + refundNonce = 0n, +}: { balance?: bigint; totalClaimed?: bigint; refundNonce?: bigint } = {}) { + mockReadContract.mockImplementation(({ functionName }: { functionName: string }) => + Promise.resolve(functionName === "refundNonce" ? refundNonce : [balance, totalClaimed]), + ); +} + +describe("resyncChannelState", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + /** + * The production case this exists for: two Optimism channels holding 1.0 and 6.5 USDC both + * cached `balance: "0"`. The SDK refunds `balance - chargedCumulativeAmount` from that cache + * and skips channels whose cached balance is zero — so without this correction the refund + * either passes them over or computes a negative amount, and the escrow stays locked. + */ + it("corrects a stale-zero cached balance from the chain", async () => { + const { storage, updateChannel, channels } = makeStorage([makeChannel()]); + setChain({ balance: 1_000_000n }); + + const results = await resyncChannelState(storage, OP); + + expect(results[0].corrected).toBe(true); + expect(results[0].storedBalance).toBe("0"); + expect(results[0].chainBalance).toBe("1000000"); + expect(updateChannel).toHaveBeenCalledTimes(1); + expect(channels[0].balance).toBe("1000000"); + }); + + it("leaves an already-correct record untouched", async () => { + const { storage, updateChannel } = makeStorage([makeChannel({ balance: "400000" })]); + setChain({ balance: 400_000n }); + + const results = await resyncChannelState(storage, OP); + + expect(results[0].corrected).toBe(false); + expect(updateChannel).not.toHaveBeenCalled(); + }); + + it("writes nothing in dry-run mode but still reports the drift", async () => { + const { storage, updateChannel } = makeStorage([makeChannel()]); + setChain({ balance: 6_500_000n }); + + const results = await resyncChannelState(storage, OP, { dryRun: true }); + + expect(results[0].corrected).toBe(true); + expect(results[0].chainBalance).toBe("6500000"); + expect(updateChannel).not.toHaveBeenCalled(); + }); + + it("leaves a record alone when the chain read fails", async () => { + // A bad read must never overwrite a good record — the same rule as the client-side fix. + const { storage, updateChannel, channels } = makeStorage([makeChannel({ balance: "400000" })]); + mockReadContract.mockRejectedValue(new Error("RPC down")); + + const results = await resyncChannelState(storage, OP); + + expect(results).toHaveLength(0); + expect(updateChannel).not.toHaveBeenCalled(); + expect(channels[0].balance).toBe("400000"); + }); + + it("also corrects totalClaimed, so outstanding vouchers are not re-claimed", async () => { + const { storage, channels } = makeStorage([makeChannel({ balance: "0", totalClaimed: "0" })]); + setChain({ balance: 1_000_000n, totalClaimed: 35_147n }); + + const results = await resyncChannelState(storage, OP); + + expect(channels[0].totalClaimed).toBe("35147"); + expect(results[0].storedTotalClaimed).toBe("0"); + expect(results[0].chainTotalClaimed).toBe("35147"); + }); + + /** + * `corrected` is true when any of the three chain-owned fields drifted, so the result has to + * carry all three: with only the balance and nonce pairs reported, a channel whose totalClaimed + * alone had moved came out of `scripts/recover_channels.ts` as a bare id with no reason after it. + */ + it("reports a totalClaimed-only drift, which nothing else in the result would show", async () => { + const { storage } = makeStorage([ + makeChannel({ balance: "544239", totalClaimed: "0", refundNonce: 1 }), + ]); + setChain({ balance: 544_239n, totalClaimed: 52_897n, refundNonce: 1n }); + + const results = await resyncChannelState(storage, OP); + + expect(results[0].corrected).toBe(true); + expect(results[0].storedBalance).toBe(results[0].chainBalance); + expect(results[0].storedRefundNonce).toBe(results[0].chainRefundNonce); + expect(results[0].storedTotalClaimed).toBe("0"); + expect(results[0].chainTotalClaimed).toBe("52897"); + }); + + /** + * The bug this function grew a second read for. A successful refund deletes the channel record; + * a later deposit with the same voucher signer recreates it with `refundNonce: 0` while the chain + * has moved to 1. The SDK then signs every refund against a consumed nonce, the contract reverts + * with 0x164f1afe, and since its refund loop has no per-channel catch, that one channel blocks + * the whole sweep. Two Optimism channels sat like that with 2.08 USDC behind them. + */ + it("corrects a stale refundNonce, which is what makes a refund signable at all", async () => { + const { storage, updateChannel, channels } = makeStorage([ + makeChannel({ balance: "544239", totalClaimed: "52897", refundNonce: 0 }), + ]); + setChain({ balance: 544_239n, totalClaimed: 52_897n, refundNonce: 1n }); + + const results = await resyncChannelState(storage, OP); + + // The balance agrees with the chain; the nonce alone is the drift, and it must still count. + expect(results[0].corrected).toBe(true); + expect(results[0].storedRefundNonce).toBe(0); + expect(results[0].chainRefundNonce).toBe(1); + expect(updateChannel).toHaveBeenCalledTimes(1); + expect(channels[0].refundNonce).toBe(1); + }); + + it("leaves a record alone when only the nonce read fails", async () => { + const { storage, updateChannel, channels } = makeStorage([makeChannel({ balance: "400000" })]); + mockReadContract.mockImplementation(({ functionName }: { functionName: string }) => + functionName === "refundNonce" + ? Promise.reject(new Error("RPC down")) + : Promise.resolve([1_000_000n, 0n]), + ); + + const results = await resyncChannelState(storage, OP); + + // Half-corrected is its own flavour of drift, so a partial read writes nothing at all. + expect(results).toHaveLength(0); + expect(updateChannel).not.toHaveBeenCalled(); + expect(channels[0].balance).toBe("400000"); + }); + + it("reports an in-sync nonce without correcting anything", async () => { + const { storage, updateChannel } = makeStorage([ + makeChannel({ balance: "400000", refundNonce: 2 }), + ]); + setChain({ balance: 400_000n, refundNonce: 2n }); + + const results = await resyncChannelState(storage, OP); + + expect(results[0].corrected).toBe(false); + expect(results[0].chainRefundNonce).toBe(2); + expect(updateChannel).not.toHaveBeenCalled(); + }); +}); diff --git a/bunny_js/tsconfig.json b/bunny_js/tsconfig.json new file mode 100644 index 000000000..76829792f --- /dev/null +++ b/bunny_js/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "verbatimModuleSyntax": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "outDir": "dist", + "declaration": false, + "noEmit": true + }, + "include": ["src/**/*.ts", "scripts/**/*.ts", "test/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/bunny_js/tsdown.config.ts b/bunny_js/tsdown.config.ts new file mode 100644 index 000000000..b2fc7c8b6 --- /dev/null +++ b/bunny_js/tsdown.config.ts @@ -0,0 +1,67 @@ +import { defineConfig } from "tsdown"; +import { builtinModules } from "node:module"; + +/** + * Bare Node builtin specifiers ("url", "crypto", "events", ...) cannot be resolved by the + * bundler on the "neutral" platform, and — left as bare externals — cannot be imported by + * bunny's Deno runtime either, which requires the `node:` prefix (bunny Edge Scripting + * runs Deno 1.x, where builtin resolution only works through `node:` specifiers). + * + * This plugin rewrites every bare builtin (and `node:*` itself, which stays external) to its + * `node:`-prefixed form and marks it external, so the bundle keeps exactly the builtin + * imports Deno can resolve at runtime and nothing else. + */ +const nodeBuiltins = new Set([ + ...builtinModules, + ...builtinModules.map((m) => `node:${m}`), + "node:test", +]); + +/** + * bunny deploys ONE script file; chunked output is not deployable. tsdown's passthrough + * options do not reliably reach rolldown's output options, so this plugin sets them on the + * rolldown-level output options directly: no code splitting, dynamic imports inlined. + */ +const singleFilePlugin = { + name: "single-file-edge-bundle", + outputOptions(options: Record) { + return { + ...options, + codeSplitting: false, + inlineDynamicImports: true, + }; + }, +}; + +const nodeBuiltinPlugin = { + name: "node-builtins-to-node-prefix", + resolveId(id: string) { + if (nodeBuiltins.has(id)) { + return { id: id.startsWith("node:") ? id : `node:${id}`, external: true }; + } + return null; + }, +}; + +export default defineConfig({ + entry: ["src/index.ts"], + format: "esm", + // The bundle runs in bunny's Deno isolate, not Node: "neutral" keeps Node builtins + // external (resolved by Deno at runtime via the plugin above) instead of letting the + // bundler stub or polyfill them for a platform the edge is not. + platform: "neutral", + target: "es2022", + outDir: "dist", + fixedExtension: false, + dts: false, + // Not deployed (the script upload is dist/index.js alone) but kept next to it so a + // minified production stack trace can be correlated locally, as in scw_js. + sourcemap: true, + // Minified for isolate cold-start parse time; the 1 MB bunny limit is enforced on + // the uploaded script, so every byte counts. + minify: true, + // Bundle all npm dependencies: the deployed artifact is one self-contained file that + // must not resolve anything from a node_modules the edge does not have. + deps: { alwaysBundle: [/.*/] }, + plugins: [nodeBuiltinPlugin as never, singleFilePlugin as never], +}); diff --git a/bunny_js/vitest.config.js b/bunny_js/vitest.config.js new file mode 100644 index 000000000..1c9dbee67 --- /dev/null +++ b/bunny_js/vitest.config.js @@ -0,0 +1,16 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + globals: true, + pool: "threads", + singleThread: true, + coverage: { + provider: "v8", + reporter: ["text", "json", "html"], + exclude: ["node_modules/**", "test/**", "coverage/**", "*.config.*"], + }, + include: ["test/**/*.test.js", "test/**/*.test.ts"], + }, +}); diff --git a/skills-lock.json b/skills-lock.json index 717369a77..b0ef02076 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -1,6 +1,12 @@ { "version": 1, "skills": { + "bunny-cli": { + "source": "BunnyWay/cli", + "sourceType": "github", + "skillPath": "skills/bunny-cli/SKILL.md", + "computedHash": "fee30431f94a9dcfbd0e207d7191b0fef052ce6090b472ee31ed3ab4ab089b62" + }, "hardhat": { "source": "nomicfoundation/hardhat-skills", "sourceType": "github",