diff --git a/.github/workflows/deploy-bunny-js.yml b/.github/workflows/deploy-bunny-js.yml index 26882f1c3..207364f7c 100644 --- a/.github/workflows/deploy-bunny-js.yml +++ b/.github/workflows/deploy-bunny-js.yml @@ -8,7 +8,7 @@ name: Deploy Bunny JS twins # # This workflow ships CODE only. Each script's environment (BRAVE_API_KEY, # MISTRAL_API_KEY, the receiver authorizer key, the S3 channel-store credentials, RPC -# URLs, SEARCH_SERVICE_URL, the Cockpit observability pair SCW_COCKPIT_PUSH_TOKEN + +# URLs, the Cockpit observability pair SCW_COCKPIT_PUSH_TOKEN + # COCKPIT_LOKI_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. diff --git a/bunny_js/README.md b/bunny_js/README.md index 12eca3978..a7463a9b8 100644 --- a/bunny_js/README.md +++ b/bunny_js/README.md @@ -1,272 +1,127 @@ # bunny_js -Bunny Edge Scripts serving the x402 sellers that outgrew `scw_js`: **searchapi** -(the `web-agent.fretchen.eu` seller — Brave `/search` + `/fetch`), **llm** (the -`llm-agent.fretchen.eu` chat seller) and **mcp** (the `mcp-agent.fretchen.eu` stateless -MCP endpoint), all paid per call/message via x402 batch-settlement. The switch is done: -these scripts are the authoritative servers behind the three `-agent.fretchen.eu` -domains, and the Scaleway twins they replaced are removed from `scw_js`'s -`serverless.yml` — the plugin deletes the functions themselves on that package's next -`npm run deploy` (run it before merging to make the docs' "retired" claims literal). -Only `scw_js`'s `llmx402cron` remains, claiming the channels these twins charge. +Four Bunny Edge Scripts, each deployed as **one bundled file**: -The fourth script is the **facilitator** (`facilitator.ts`, `service="facilitator"`) — the x402 -payment facilitator (`/verify`, `/settle`, `/supported`, `/openapi.json`) ported verbatim from -`x402_facilitator/` (payment core + its test suite) with Scaleway's envelope exchanged for -native `Request`/`Response` routing in `src/facilitator_routes.ts`. The cutover happened — -`facilitator.fretchen.eu` serves this script, and the retired Scaleway function is deleted -(the move's decisions + cutover checklist are preserved in git history as -`x402_facilitator/bunny-port-implementation-plan.md`); that package keeps only `walletreportcron` -now, the facilitator's weekly wallet-report cron — the edge has no cron). +| Script | Serves | What | +| ------------- | ------------------------- | -------------------------------------------------------------------------- | +| `searchapi` | `web-agent.fretchen.eu` | Web-search (`/search`) and single-page-fetch (`/fetch`) — paid per call | +| `llm` | `llm-agent.fretchen.eu` | Mistral chat completions (OpenAI shape) — paid per request, metered | +| `mcp` | `mcp-agent.fretchen.eu` | Stateless MCP gateway (JSON-RPC 2.0 over POST) relaying both tools' calls | +| `facilitator` | `facilitator.fretchen.eu` | The x402 facilitator (`/verify`, `/settle`, `/supported`, `/openapi.json`) | -### Facilitator: recipient gating (the "whitelist architecture") +All four are live. Three of them are **sellers** — unpaid requests get the honest `402` + +`Payment-Required` challenge; retry with the payment attached settles and returns the +result. The channel state lives in the shared S3 bucket, claimed by `scw_js`'s +`llmx402cron` (the edge has no cron), which is also why `x402_facilitator/` keeps only +`walletreportcron`. -There is no recipient whitelist. Both schemes gate recipients the same way: an allowance the -recipient has `approve()`d for the facilitator's wallet, **in the token the payment settles -in** — USDC on any network, EURC and EURe on Base. The fee is charged in that same token -(0.01 USDC, 0.01 EURC or 0.01 EURe), so a seller paid in several approves each. EURe has 18 -decimals, so its 0.01 is `10000000000000000` atomic units, not the `10000` of USDC/EURC — and -an EURe approval of `1000000` covers no fee at all. Any other token is refused with -`unsupported_fee_asset`. For `exact` the token is `requirements.asset`; for batch-settlement -it is the channel's token (`payload.token` for `settle`, the claims' channel token for -`claim`, `channelConfig.token` for a claim-carrying refund), and a batch mixing tokens is -refused. `checkMerchantAllowance()` (`src/facilitator/x402_fee.ts`) reads that allowance; an -under-approved recipient is rejected with `insufficient_fee_allowance` rather than settled -for free. `BATCH_SETTLEMENT_TEST_WALLETS` (`src/facilitator/x402_whitelist.ts`) is the one -remaining carve-out: a listed address skips the allowance check, but only on testnets (the -old manual allowlist and NFT-holder branch are retired — `claim`/`settle` moved onto the -allowance check in FEE_MODEL_PLAN.md Phase 3). +The sellers are ERC-8004-registered agents on Base. Every origin serves: -### Facilitator: the fee pull right after the settlement +- `GET /` — the **agent root page**: capability, prices, contracts and a no-wallet curl, + generated by [`src/discovery.ts`](./src/discovery.ts) from the committed spec + (nothing hand-restated). The genimg origin serves its twin from `scw_js`. +- `GET /openapi.json` — the live OpenAPI 3.1 discovery contract. +- `GET /.well-known/agent-registration.json` — the ERC-8004 registration file + (also the on-chain `agentURI`); `GET /.well-known/oasf.json` — the OASF record. -The fee is a second transaction from the facilitator wallet, sent within a second of the -settlement. On a load-balanced RPC that second send can reach a node that has not caught up: -fee pulls have reused the settlement's nonce (`replacement transaction underpriced`) and -simulated against a balance that did not yet include the payment. Two measures, both in -`src/facilitator/x402_fee.ts`: +Security posture: [`SECURITY.md`](./SECURITY.md). Agent rules: [`AGENTS.md`](./AGENTS.md). -- Every transaction is signed by an account from `loadFacilitatorAccount()`, which carries - viem's shared `nonceManager`, so the fee pull never takes a nonce below the settlement's. - Build no signer for this key any other way. -- A fee send that the node refused before broadcast (underpriced, nonce too low, or a - reverted simulation) is sent again, up to `FEE_SEND_ATTEMPTS` times, 2 s apart - (`FEE_RETRY_DELAY_MS`). Nothing that may have been broadcast is ever resent; a receipt - timeout stays `fee_collection_pending`. - -If a settlement ever hangs as `settlement_pending` on a healthy RPC after a deploy, suspect a -dropped transaction the nonce manager has already counted: a restart of the isolate clears it. - -## Architecture +## Commands -- `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; the lines go to - the bunny dashboard AND, since the Cockpit integration, to Scaleway's custom Loki data - source: `withLogFlush()` wraps each entry handler, so buffered lines are pushed per - request to Cockpit, where one datasource-managed alert rule on `level="error"` pages by - email (the `logger.error` convention applies again — see - [`observability/bunny-edge-script-observability-kiss-implementation-plan.md`](../observability/bunny-edge-script-observability-kiss-implementation-plan.md)). - - `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. -- `src/facilitator/` — the facilitator's own corner of the tree (`src/` is otherwise the - twins'): the payment core ported **verbatim** from `x402_facilitator/` (that package keeps - its own copy alive for its Scaleway function until the teardown PR; only `walletreportcron` - stays on Scaleway long-term, because the edge has no cron) plus one new file — the routing. - `src/facilitator/facilitator_routes.ts` mirrors the Scaleway handler's routing/CORS/envelope - semantics line by line against web-standard `Request`/`Response`; everything else is - untouched payment behavior, including its test suite (`test/facilitator/` — the ported - core tests, `facilitator_routes.test.ts` for the native routing, and - `facilitator_alert_coverage.test.ts`, which pins the ported log phrases to - `observability/alerts/bunny.yaml`'s facilitator branch; the twins need no such test). - The entry is `src/facilitator.ts`, flat like the other three. +```bash +npm run build # tsdown bundle: dist/searchapi.js, dist/llm.js, dist/mcp.js, dist/facilitator.js +npm run dev: # local serve of one twin via Deno on 127.0.0.1 (source, no build; --env-file=.env baked in) +npm test # vitest +npm run check # typecheck + lint + format + tests +npm run deploy # build, then `bunny scripts deploy dist/searchapi.js` +``` -## Commands +- Dev for the paid path needs `NFT_WALLET_PUBLIC_KEY` + `RECEIVER_AUTHORIZER_PRIVATE_KEY` + in `.env`; extra args go after `--`. +- Ordering (repo rule): `npm run build` in `shared/chain-utils` and `shared/s3-utils` + first — they are `file:` dependencies. -- `npm run build` — tsdown bundle to a **single** `dist/searchapi.js` (bunny deploys one file). -- `npm run dev:searchapi` / `dev:llm` / `dev:mcp` / `dev:facilitator` — local serve of one - twin via Deno on - 127.0.0.1 (from source, no build needed; `--sloppy-imports` covers the shared packages' - extensionless tsc output, and `--env-file=.env` is baked in — a missing `.env` only warns). - The paid path needs `NFT_WALLET_PUBLIC_KEY` + `RECEIVER_AUTHORIZER_PRIVATE_KEY` in `.env`; - extra args go after `--` (`npm run dev:mcp -- --port 8081`-style), because npm eats - unknown flags like `--env-file` itself. -- `npm test` — vitest (521 tests: the twins' ported suite + routes, the facilitator's ported - payment suite (`test/x402_*.test.ts`) + native routes + its alert-phrase guard; - `npm run test:integration` runs the live-RPC cases under `test/integration/`, inherited - from the facilitator package). -- `npm run check` — typecheck + lint + format + tests. -- `npm run deploy` — build, then `bunny scripts deploy dist/searchapi.js` (needs the bunny CLI, - installed and logged in). +## Architecture -Ordering (repo rule): `npm run build` in `shared/chain-utils` and `shared/s3-utils` first — -they are `file:` dependencies. +- `src/routes.ts` (searchapi), `src/llm_routes.ts`, `src/mcp_routes.ts` and + `src/facilitator/facilitator_routes.ts` are the **only native-routing code**: web-standard + `Request`/`Response` handlers that mirror the Scaleway twins' route tables and ordering + rules. +- Everything else under `src/` is **ported verbatim** from `scw_js` (the shared seller + stack, services with the SSRF defence, schemas, discovery, favicon) or from + `x402_facilitator/` (the payment core, `src/facilitator/`), with two deliberate + deviations: `logger.ts` is a pino-shaped console shim that also flushes to Scaleway + Cockpit/Loki, and `utils.ts` ports only the platform-neutral half (no Scaleway envelope). +- `src/runtime-shims.ts` — pins the Node `Buffer`/`process` globals the ported code assumes; + imported FIRST by each entry. + +### Facilitator specifics + +- **Recipient gating is an allowance gate, not a whitelist**: the recipient must have + `approve()`d the facilitator wallet for the fee **in the settled token**; under-approval + is rejected with `insufficient_fee_allowance`. `BATCH_SETTLEMENT_TEST_WALLETS` + (`src/facilitator/x402_whitelist.ts`) is the only bypass, honored on testnets only. +- **The fee pull** (`src/facilitator/x402_fee.ts`) is a second transaction right after the + settlement: it signs via viem's shared `nonceManager`, retries pre-broadcast refusals + (`FEE_SEND_ATTEMPTS` × 2 s), and never resends anything that may have been broadcast. + A settlement stuck as `settlement_pending` on a healthy RPC after a deploy means a dropped + transaction the nonce manager already counted — restarting the isolate clears it. ## 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: +Set per script via `bunny scripts env set`; `--secret` encrypts, plain stays inspectable. +Criterion: **if a value authenticates, signs, or bills, it is a secret.** -`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. +Sellers (`searchapi`, `llm`, `mcp` — the same channel tuple as the retired Scaleway +functions, or the channels are unusable): -| 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 | -| `SCW_COCKPIT_PUSH_TOKEN` | secret (`--secret`) | Cockpit push-only token — authenticates the Loki log flush to the observability data source (see the observability README/implementation plan) | -| `COCKPIT_LOKI_URL` | plain | push URL of the custom Cockpit logs data source — public and inert without the token | +| Variable | kind | Use | +| --------------------------------------------- | -------------- | ------------------------------------------------------------------------------- | +| `NFT_WALLET_PUBLIC_KEY` | plain | receiver / payTo (public address) | +| `RECEIVER_AUTHORIZER_PRIVATE_KEY` | `--secret` | signs batch-settlement locks / refund authorizations | +| `BRAVE_API_KEY` (searchapi, mcp) | `--secret` | Brave search API (metered) | +| `MISTRAL_API_KEY` (llm, mcp) | `--secret` | upstream chat model | +| `SCW_S3_BUCKET` / `SCW_S3_REGION` | plain | channel store (`my-imagestore` / `nl-ams`; no `.env` fallback — set explicitly) | +| `SCW_ACCESS_KEY` + `SCW_SECRET_KEY` | `--secret` | channel storage on S3 | +| `RPC_URL_EIP155_10`, `RPC_URL_EIP155_8453` | `--secret` | viem chain reads; unset = viem public defaults | +| `SCW_COCKPIT_PUSH_TOKEN` / `COCKPIT_LOKI_URL` | secret / plain | Cockpit log-flush auth pair | -**Facilitator script — its own env set** (`bunny scripts env set` against its script id; the -values mirror `x402_facilitator/serverless.yml`): - -| Variable | bunny kind | Use | -| --------------------------------------------- | ------------------- | --------------------------------------------------------------------------------------------------------- | -| `FACILITATOR_FEE_AMOUNT` | plain | fee in the token's smallest unit (`10000` = 0.01 USDC/EURC) | -| `BATCH_SETTLEMENT_TEST_WALLETS` | plain | testnet-only allowance-check bypass list (public, testnet-fenced in code) | -| `FACILITATOR_WALLET_PRIVATE_KEY` | secret (`--secret`) | the facilitator hot wallet — signs settles and pulls fees; write-once on bunny's side | -| `RPC_URL_EIP155_*` | secret (`--secret`) | the four network RPC URLs — real values embed API keys; unset falls back to rate-limited public endpoints | -| `SCW_COCKPIT_PUSH_TOKEN` / `COCKPIT_LOKI_URL` | as above | the logger pair, same as the twins | +Facilitator — its own script env (mirrors `x402_facilitator/serverless.yml`): +`FACILITATOR_FEE_AMOUNT` (plain, atomic units), `BATCH_SETTLEMENT_TEST_WALLETS` (plain), +`FACILITATOR_WALLET_PRIVATE_KEY` (`--secret`, signs settles and fee pulls), +`RPC_URL_EIP155_*` (`--secret`), plus the Cockpit pair. ## Known limits / decisions -- **Single-file bundle, 1 MB limit.** tsdown emits one `dist/searchapi.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. For the same reason the - facilitator's `walletreportcron` (weekly wallet-report email) stays on Scaleway too — the - edge has no scheduler, and the report additionally rides Scaleway Transactional Email. -- **Facilitator bundle ≈ 573 KB minified** (`dist/facilitator.js`, single file, deps inlined — - viem + the @x402 SDK are the bulk), well under the 1 MB script limit. Its imports are - `node:buffer`/`node:process` only, same as the twins. - -## Trial matrix (Task 6 — historical; superseded by the production switch) - -The formal switch happened: the three `-agent.fretchen.eu` domains serve these scripts in -production, and the Scaleway twins' `serverless.yml` entries are removed from `scw_js` (the -functions are deleted by its next `npm run deploy`). The unpaid checks were run -during the trial; the paid/probe rows were overtaken by real production traffic rather than -closed as one-off exercises, so their results live in the observability logs and on-chain -settlements, not here. - -| # | 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 | superseded by production use | -| 4 | Paid `/fetch` ($0.001) — buyer: `bunny_js/notebooks/search_x402_buyer.ipynb`, `SERVER` = "bunny-trial" with `TRIAL_URL` set → 200 + `Payment-Response` | superseded by production use | -| 5 | Paid `/search` ($0.01) via the same notebook → 200 + results | superseded by production use | -| 6 | Channel record in S3 updated; next `llmx402cron` run claims it cleanly (observability logs) | superseded by production use | -| 7 | Cold-start spot checks at the bunny URL | superseded by production use | - -## Status - -Ported and production. The three twins serve the llm, web and MCP sellers at their -`*.fretchen.eu` origins; `scw_js` deploys only `genimgx402token`, `llmx402cron` (which claims -these twins' channels) and `growthapi` — the old twins' entries are gone from its -`serverless.yml`, and its next `npm run deploy` deletes the retired functions from Scaleway. -The original port plan (scaffold, bundle gate, SigV4 spike, verbatim stack + tests, native -routing + tests) is complete. - -The facilitator twin is ported and green but **not yet live**: `facilitator.fretchen.eu` still -serves the Scaleway function until the port plan's operational cutover (trial-hostname probes -→ DNS flip), after which `x402_facilitator/` trims down to `walletreportcron` in the teardown -PR. - -## 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/searchapi.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`. The llm and mcp twins deploy by naming their bundle and script id - explicitly: `bunny scripts deploy dist/llm.js ` and - `bunny scripts deploy dist/mcp.js `. -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 all three bundles via the official `BunnyWay/actions/deploy-script` action: - `dist/searchapi.js`, `dist/llm.js`, `dist/mcp.js`. Requires two repo secrets per script, - both from the bunny dashboard (**Script -> Deployments -> Settings**): - - `BUNNYJS_SCRIPT_ID` / `BUNNYJS_DEPLOY_KEY` — the searchapi script - - `BUNNYLLM_SCRIPT_ID` / `BUNNYLLM_DEPLOY_KEY` — the llm script - - `BUNNYMCP_SCRIPT_ID` / `BUNNYMCP_DEPLOY_KEY` — the mcp script - - `BUNNYFACILITATOR_SCRIPT_ID` / `BUNNYFACILITATOR_DEPLOY_KEY` — the facilitator script - (same pattern; serves `facilitator.fretchen.eu` after its DNS flip — see the port plan) - - (Per-script deploy keys are deliberately chosen over the account-wide API key a - CLI-in-CI variant would need — narrower blast radius. A step whose secrets are missing - fails; the searchapi step runs first, so its deploy is never blocked by the others.) - -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). - For the SECOND and THIRD script, add `--no-link` — the directory's link belongs to - searchapi, and `create` without it would silently repoint `.bunny/script.json`; the - other twins take their id from the create output (or `bunny scripts list`) instead. -2. Upload the env from `.env` with `bunny scripts env set`, marking the sensitive keys with - `--secret` (RECEIVER_AUTHORIZER_PRIVATE_KEY, BRAVE_API_KEY, MISTRAL_API_KEY, - SCW_ACCESS_KEY, SCW_SECRET_KEY, RPC_URL_*, SCW_COCKPIT_PUSH_TOKEN). Skip - `TEST_WALLET_PRIVATE_KEY` — buyer-side only, the server must never see it. Verify with - `bunny scripts env list`. Per script — all three also get the Cockpit observability pair - (`SCW_COCKPIT_PUSH_TOKEN` as `--secret`, `COCKPIT_LOKI_URL` plain): - - **searchapi** — the channel stack + `BRAVE_API_KEY` (web_search/fetch) + `SCW_S3_*`. - - **llm** — the channel stack + `MISTRAL_API_KEY` (chat_completion) + `SCW_S3_*`. - - **mcp** — the union: both `BRAVE_API_KEY` and `MISTRAL_API_KEY` (its bundle embeds - both tools) + `SCW_S3_*`. -3. `npm run deploy` (searchapi) / `bunny scripts deploy dist/llm.js ` / - `bunny scripts deploy dist/mcp.js ` — publishes each; the output prints the live - `*.bunny.run` URL. -4. `bunny scripts env set SEARCH_SERVICE_URL https://.bunny.run` on the searchapi - script and redeploy, so the 402 advertises the trial origin instead of the parity default. - (llm/mcp have no equivalent — their 402s quote the spec's committed server URL.) -5. Unpaid smoke: `curl -D - 'https://.bunny.run/search?q=x402'` → 402 + - `Payment-Required` header. -6. For the GitHub Action: `bunny scripts show` (or `list`) gives each script id; the deploy - keys are in the dashboard (Script -> Deployments -> Settings). Add each pair as the - `BUNNYJS_*` / `BUNNYLLM_*` / `BUNNYMCP_*` repo secrets. - -After the first deploy, the Action and manual `npm run deploy` are interchangeable — last -deployment wins. +- **Bunny's runtime is Deno 1.x, not Node.** Assumes only what `src/runtime-shims.ts` pins; + Node builtins must be imported through the `node:` prefix. +- **1 MB single-file bundle limit.** tsdown emits one file per script (`codeSplitting: +false`); measured well under the cap (run `npm run build` to check). +- **The CDN in front rewrites `no-store` to `no-cache`.** Still safe: the responses carry no + validators, so every hit goes to the origin and paid responses cannot be replayed. +- **Non-latin1 header bytes** are escaped as JSON `\uXXXX` in payment headers — lossless + for any JSON-parsing client. +- **50 subrequests per request** — comfortably within budget for one paid request; fetch + storms are not. +- **No cron at the edge** — `llmx402cron` (`scw_js`) claims these channels; + `walletreportcron` (`x402_facilitator/`) sends the weekly wallet report. + +## Deploying + +Bunny publishes whichever deployment happened **last** — CLI and Actions interoperate, and +the next push to `main` restores the committed state. + +1. **GitHub Actions** (preferred): `.github/workflows/deploy-bunny-js.yml` builds on push to + `main` touching `bunny_js/**` or the shared libs and publishes all four bundles. Needs + the per-script secrets `BUNNYJS_*`, `BUNNYLLM_*`, `BUNNYMCP_*`, `BUNNYFACILITATOR_*` + (`_SCRIPT_ID` + `_DEPLOY_KEY`, from the dashboard: Script → Deployments → + Settings). The workflow ships code only — env vars and secrets live on the script itself. +2. **Manual**: `npm run deploy` for searchapi; the others by id: + `bunny scripts deploy dist/llm.js ` etc. + +First-time setup (once, not in CI): `bunny scripts create --type standalone` creates each +script (only searchapi takes the `.bunny/script.json` link; the others use `--no-link`), +then `bunny scripts env set` per the tables above, then the smoke test: +`curl -D - 'https:///search?q=x402'` → 402 + `Payment-Required` header. + +The trial matrix and facilitator-cutover checklist of the port are preserved in git history. diff --git a/bunny_js/erc-8004-ax-audit-fretchen-agents-#97599-&-#97600-2026-11-01.md b/bunny_js/erc-8004-ax-audit-fretchen-agents-#97599-&-#97600-2026-11-01.md deleted file mode 100644 index 9b8a9c313..000000000 --- a/bunny_js/erc-8004-ax-audit-fretchen-agents-#97599-&-#97600-2026-11-01.md +++ /dev/null @@ -1,47 +0,0 @@ -# ERC-8004 AX Audit — Fretchen Agents (Base) - -**Date:** 2026-11-01 · **Agents:** #97599 (LLM Assistant) · #97600 (Web Access) · **Registry:** `eip155:8453:0x8004a169fb4a3325136eb29fa0ceb6d2e539a432` - -## Current Scores - -| Dimension (weight) | #97599 | #97600 | -| ------------------ | --------- | ------ | -| Engagement (0.30) | 14 | 5 | -| Service (0.25) | 54 | 54 | -| Publisher (0.20) | 25 | 25 | -| Compliance (0.15) | 94 | 94 | -| Momentum (0.10) | 15 | 15 | -| **Overall** | **43.64** | **30** | - -## Key Finding - -Quality score is already **83.49** — but the leaderboard policy caps "early"-evidence agents at a **score-band ceiling of 55**. The score is limited by _evidence of usage_, not service quality. x402 support is registered (`x402_supported: true`, wallet tagged X402) but **is not a scored dimension**; per ERC-8004, payments are orthogonal — x402 only enriches feedback signals once distinct clients actually transact. - -## AX Gaps (Both Agents) - -1. **No A2A service** — `has_a2a: false`; Service score 54 is the MCP-only ceiling (`max(mcp_quality, a2a_quality)`, a2a = null). -2. **Endpoint not verified** — `is_endpoint_verified: false`. Publish `/.well-known/agent-registration.json` matching agentId + registry → verification bonus. -3. **OASF endpoint is a placeholder** — points to `github.com/agntcy/oasf/` (human page), not a machine-readable manifest. Dead end for agent clients. -4. **`supportedTrust` empty** — per spec, empty = discovery-only, no trust. Declare `["reputation", "crypto-economic"]`. -5. **Thin MCP surface** — 2 tools, 0 prompts, 0 resources; health status "unknown". -6. **Discovery metadata empty** — `tags`, `categories`, `agent_type` unset; indexer filtering cannot match capability queries. - -## Actions — #97599 (LLM Service) - -- Add A2A entry pointing to a real `/.well-known/agent-card.json` -- Fix OASF endpoint → actual manifest (hosted JSON or IPFS) -- Publish `.well-known/agent-registration.json` on fretchen.eu / MCP domain -- Set `supportedTrust`; fill tags/categories/agent_type -- Get feedback from **distinct wallets** (current: 2 ratings, both from `0x073f...7d20`, 10–11 days old → `recent_feedback_count: 0`) - -## Actions — #97600 (Web Access) - -- Same 4 registration fixes (A2A, domain verification, OASF, trust models) -- **Zero feedback** → top priority: have 3–5 distinct agent clients call it via MCP + x402 and post feedback citing payment txs -- Make per-call pricing machine-readable in metadata, not prose only - -## Sources - -- 8004scan: `8004scan.io/agents/base/97599` · `8004scan.io/agents/base/97600` · API `api.8004scan.io/api/v1/agents/8453/97599` (last scored 2026-10-31) -- ERC-8004 spec + data profiles: `eips.ethereum.org/EIPS/eip-8004` · `best-practices.8004scan.io` -- Note: API blocked automated access for #97600; its registration shape is inferred from the identical Service/Compliance/Publisher pattern — verify in dashboard. diff --git a/bunny_js/src/discovery.ts b/bunny_js/src/discovery.ts index 2e5653e14..4b488eb95 100644 --- a/bunny_js/src/discovery.ts +++ b/bunny_js/src/discovery.ts @@ -7,33 +7,64 @@ * 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). + * + * The root page doubles as the agent-facing "README": an agent that lands on the origin + * (curl, MCP client, ERC-8004 shepherd) should learn what the service is, where the + * machine-readable contracts live, what a call costs, how to pay, and what a 402 means — + * in one fetch. Every fact on the page is GENERATED by the caller from the same committed + * constants/files that serve it (spec title/description, `x-payment-info`, endpoint + * constants), never hand-restated: a restated fact is the next silent drift bug. Surface + * area is deliberately minimal — human-oriented prose lives on the website, this page is + * the last mile from discovery to a first (unpaid) honest 402. */ -/** Minimal HTML served at the origin root so scrapers can discover the favicon. */ -export const FAVICON_DISCOVERY_HTML = ` +/** The fields `buildRootPage` renders; `endpoints` carries absolute hrefs. */ +export interface RootPageInput { + title: string; + summary: string; + endpoints: { href: string; label: string }[]; + /** One line per route/price pair, generated from the spec's `x-payment-info`. */ + price: string[]; + payment: string; + /** The no-wallet first look — usually the curl that returns the honest 402. */ + tryIt: string; + errors: string; +} + +const escapeHtml = (text: string): string => + text.replace(/&/g, "&").replace(//g, ">"); + +/** The agent-facing root page. The favicon link stays first — x402scan parses it. */ +export function buildRootPage(input: RootPageInput): string { + return ` -Fretchen x402 service +${escapeHtml(input.title)} - + +

${escapeHtml(input.title)}

+

${escapeHtml(input.summary)}

+

Endpoints

+
    +${input.endpoints + .map( + (e) => + `
  • ${escapeHtml(e.href)} — ${escapeHtml(e.label)}
  • `, + ) + .join("\n")} +
+

Price

+
    +${input.price.map((p) => `
  • ${escapeHtml(p)}
  • `).join("\n")} +
+

Payment

+

${escapeHtml(input.payment)}

+

Try it

+
${escapeHtml(input.tryIt)}
+

Errors

+

${escapeHtml(input.errors)}

+ `; - -/** - * 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/llm_routes.ts b/bunny_js/src/llm_routes.ts index c48cac93d..cc56c0624 100644 --- a/bunny_js/src/llm_routes.ts +++ b/bunny_js/src/llm_routes.ts @@ -53,9 +53,10 @@ import { type SdkPaymentPayload, type SdkPaymentRequirements, } from "./x402_server.js"; -import { FAVICON_DISCOVERY_HTML, wantsHtml } from "./discovery.js"; +import { buildRootPage } from "./discovery.js"; import { AGENT_REGISTRATION_PATH, + MCP_SERVICE, OASF_MANIFEST_PATH, buildAgentRegistration, buildOasfRecord, @@ -73,6 +74,44 @@ export const LLM_COMPLETION_BOUND_SECONDS = 40; /** 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)); +/** + * The agent-facing root page, generated from the same constants/files that serve the twin: + * title/summary from the committed spec, the price ceiling from the live MAX_PRICE_ATOMIC + * constant (the static spec's x-payment-info max is a documentation baseline that is + * overridden at serve time — the page must show the real one), links to the well-known + * contracts. Nothing is hand-restated — see discovery.ts. + */ +const ROOT_PAGE = buildRootPage({ + title: openapiSpec.info.title, + summary: openapiSpec.info.description, + endpoints: [ + { + href: `${openapiSpec.servers[0].url}/openapi.json`, + label: "OpenAPI 3.1 spec — request/response schemas and payment terms", + }, + { + href: `${openapiSpec.servers[0].url}/${AGENT_REGISTRATION_PATH}`, + label: "ERC-8004 registration (identity, services, wallet)", + }, + { + href: `${openapiSpec.servers[0].url}/${OASF_MANIFEST_PATH}`, + label: "OASF capability record (skills, domains)", + }, + { + href: MCP_SERVICE.endpoint, + label: "stateless MCP endpoint — same tools as JSON-RPC 2.0 over POST (chat_completion)", + }, + ], + price: [ + `POST / — metered by real token usage, at most $${formatUsdcAtomicAsDecimalUsd(MAX_PRICE_ATOMIC.USDC)} per request`, + ], + payment: + "x402 (HTTP 402): no account, no API key. An unpaid request answers 402 with the machine-readable Payment-Required challenge — exact terms (scheme batch-settlement, network, token, ceiling) are in the challenge, then retry the same request with the payment attached.", + tryIt: `curl -X POST '${openapiSpec.servers[0].url}/' -H 'Content-Type: application/json' -d '{"model":"${advertisedModelIds()[0]}","messages":[{"role":"user","content":"ping"}]}'\n# → HTTP 402 + Payment-Required header: the terms. No wallet needed for this first look.\n# Testnet note: on Base Sepolia the payment settles and the completion is a mocked answer — a free end-to-end test.`, + errors: + "402 = unpaid or invalid payment (challenge in the Payment-Required header); 400 = invalid request (OpenAI-shaped error body); 404 = unknown model. Response schemas: openapi.json.", +}); + function jsonError(statusCode: number, error: string): Response { return new Response(JSON.stringify({ error }), { status: statusCode, headers: CORS_HEADERS }); } @@ -158,8 +197,9 @@ export async function handleLLMRequest(request: Request): Promise { }); } - if (isGetOrHead && path === "" && wantsHtml(headers)) { - return new Response(isHead ? null : FAVICON_DISCOVERY_HTML, { + if (isGetOrHead && path === "") { + // Unconditional on purpose: the reader is an agent, and agents send Accept: */* too. + return new Response(isHead ? null : ROOT_PAGE, { status: 200, headers: { ...CORS_HEADERS, "Content-Type": "text/html; charset=utf-8" }, }); @@ -205,7 +245,7 @@ export async function handleLLMRequest(request: Request): Promise { networks: getBatchSettlementNetworks(), maxTimeoutSeconds: LLM_MAX_TIMEOUT_SECONDS, extensions: buildLlmBazaarExtension(), - serviceName: "Fretchen AI Assistant", + serviceName: "Fretchen LLM Inference", tags: ["llm", "chat", "x402"], iconUrl: `${openapiSpec.servers[0].url}/favicon.png`, }); diff --git a/bunny_js/src/mcp_routes.ts b/bunny_js/src/mcp_routes.ts index 1a9dab915..29d187938 100644 --- a/bunny_js/src/mcp_routes.ts +++ b/bunny_js/src/mcp_routes.ts @@ -28,10 +28,49 @@ * does not do, per the MCP spec's session-less allowance. */ import { CORS_HEADERS } from "./utils.js"; -import { FAVICON_DISCOVERY_HTML, wantsHtml } from "./discovery.js"; +import { buildRootPage } from "./discovery.js"; import { faviconBase64, faviconContentType } from "./favicon.js"; import { MCP_TOOLS } from "./mcp_tools.js"; -import { AGENT_REGISTRATION_PATH, buildMcpOriginRegistration } from "./agent_registration.js"; +import { + AGENT_REGISTRATION_PATH, + MCP_SERVICE, + buildMcpOriginRegistration, +} from "./agent_registration.js"; + +/** + * The agent-facing gateway page. This origin relays to the twin sellers and holds no spec + * of its own, so the page is hand-composed (no spec to generate from): it points at the + * two service origins — the single sources of truth for their schemas, prices and 402 + * terms — plus this origin's own registration document and the MCP protocol curls. See + * discovery.ts for the generated-not-restated rule. + */ +const ROOT_PAGE = buildRootPage({ + title: "Fretchen MCP Gateway — pay-per-call tools for agents", + summary: `A stateless MCP endpoint (Streamable HTTP, JSON-RPC 2.0 over POST) relaying ${MCP_TOOLS.length} paid tools. No sessions, no server-initiated streams: initialize and tools/list are free; tools/call relays to the seller, whose x402 terms apply — an unpaid call comes back as a tool result with isError, the payment demands in structuredContent.`, + endpoints: [ + { + href: "https://llm-agent.fretchen.eu/", + label: "chat_completion — the LLM seller's page, spec and terms", + }, + { + href: "https://web-agent.fretchen.eu/", + label: "web_search — the web seller's page, spec and terms", + }, + { + href: `https://mcp-agent.fretchen.eu/${AGENT_REGISTRATION_PATH}`, + label: "this origin's registration document (domain control proof for both agents)", + }, + ], + price: MCP_TOOLS.map( + (tool) => + `${tool.name} — priced at the seller it relays to; the unpaid call's tool result carries the exact 402 terms`, + ), + payment: + "per tool, via x402 at the relaying seller: no account, no API key. initialize and tools/list are free; an unpaid tools/call returns the 402 demands as structuredContent in an isError tool result — retry with the payment attached.", + tryIt: `curl -X POST '${MCP_SERVICE.endpoint}' -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'\n# → the tools and their input schemas, free (as is initialize). tools/call carries the x402 payment.`, + errors: + "JSON-RPC errors: -32601 unknown method (a stateless server: initialize, tools/list, tools/call only), -32602 unknown tool. GET /mcp is 405 Allow: POST (the optional GET stream is not offered). Unpaid tools/call is NOT an error — it is a tools result with isError and the 402 demands in structuredContent.", +}); /** The latest protocol revision this server implements. */ const DEFAULT_PROTOCOL_VERSION = "2025-06-18"; @@ -201,8 +240,9 @@ export async function handleMCPRequest(request: Request): Promise { // of the scw original's blanket 405. if (method === "GET" || method === "HEAD") { const isHead = method === "HEAD"; - if (path === "" && wantsHtml(Object.fromEntries(request.headers))) { - return new Response(isHead ? null : FAVICON_DISCOVERY_HTML, { + if (path === "") { + // Unconditional on purpose: the reader is an agent, and agents send Accept: */* too. + return new Response(isHead ? null : ROOT_PAGE, { status: 200, headers: { ...CORS_HEADERS, "Content-Type": "text/html; charset=utf-8" }, }); diff --git a/bunny_js/src/openapi.llm.json b/bunny_js/src/openapi.llm.json index 0ef4f1480..6dc1b498b 100644 --- a/bunny_js/src/openapi.llm.json +++ b/bunny_js/src/openapi.llm.json @@ -1,8 +1,8 @@ { "openapi": "3.1.0", "info": { - "title": "Fretchen AI Assistant (LLM) Service", - "description": "AI chat assistant, paid via x402 batch-settlement payment channels in USDC, EURC or EURe on Base (USDC everywhere; the euro tokens on Base).", + "title": "Fretchen LLM Inference — pay-per-call chat completions for agents", + "description": "OpenAI-shaped chat completions from Mistral models, sold per request to autonomous agents via x402 batch-settlement payment channels — no account, no API key. Metered by real token usage under a fixed per-request ceiling; settled in USDC (Base or Optimism), EURC or EURe (Base), while Base Sepolia settles for free and answers with a mocked completion.", "version": "1.0.0", "x-guidance": "OpenAI chat-completions body. POST / with { model, messages: [{ role, content }, ...] } and no payment header to receive a 402 with x402 batch-settlement payment requirements (accepts[]). Open/top up a payment channel per the requirements, retry with the payment header, and the service returns a standard OpenAI chat.completion object. Streaming (stream: true) is not supported. Each message is metered and settled up to a per-message price ceiling; the real cost is usage-derived and typically lower. Other standard OpenAI chat params (temperature, top_p, stop, seed, the penalties, response_format) are forwarded to the upstream model and work as normal; they are not enumerated here because the upstream owns that contract. The exceptions are stream, n and max_tokens, which are rejected with a 400 rather than ignored, because each would move cost past the fixed per-message ceiling this endpoint meters against. Tool calling is supported (see x-capabilities): send OpenAI-shaped tools, and a tool call comes back as choices[].message.tool_calls with finish_reason: \"tool_calls\" and content: null. Execute it yourself and send the result back as a role:\"tool\" message with the matching tool_call_id — this endpoint never calls a tool on your behalf, and accepts no callback or MCP server URL. Note: payment uses x402 batch-settlement (a stateful channel), so a stock OpenAI SDK cannot pay this endpoint without batch-settlement client wiring — the OpenAI shape is for body legibility, not drop-in SDK use.", "contact": { diff --git a/bunny_js/src/openapi.search.json b/bunny_js/src/openapi.search.json index 5c32745ad..fe2f87558 100644 --- a/bunny_js/src/openapi.search.json +++ b/bunny_js/src/openapi.search.json @@ -1,7 +1,7 @@ { "openapi": "3.1.0", "info": { - "title": "Fretchen Web Access Service", + "title": "Fretchen Web Retrieval — pay-per-call web access for agents", "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.", diff --git a/bunny_js/src/searchapi_routes.ts b/bunny_js/src/searchapi_routes.ts index 65fb88f44..d311f1827 100644 --- a/bunny_js/src/searchapi_routes.ts +++ b/bunny_js/src/searchapi_routes.ts @@ -24,9 +24,10 @@ import { type Route, } from "./search_schemas.js"; import { CORS_HEADERS } from "./utils.js"; -import { FAVICON_DISCOVERY_HTML, wantsHtml } from "./discovery.js"; +import { buildRootPage } from "./discovery.js"; import { AGENT_REGISTRATION_PATH, + MCP_SERVICE, OASF_MANIFEST_PATH, buildAgentRegistration, buildOasfRecord, @@ -65,12 +66,57 @@ const MAINNET_NETWORKS = ["eip155:10", "eip155:8453"]; 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. + * BASE URL for the advertised `resource.url`, derived from the committed spec — the same + * pattern as the llm twin, and the published terms themselves. There is deliberately no env + * override anymore: `SEARCH_SERVICE_URL` was a trial-period mechanism (advertise where the + * trial was actually served) and its leftover value kept advertising the `*.bunny.run` origin + * in production, which indexers then could not attribute to web-agent.fretchen.eu. The + * resource url is the payment scope, not the serving origin — it keeps naming the production + * identity wherever the script is served from. */ -const SERVICE_URL = process.env.SEARCH_SERVICE_URL ?? "https://web-agent.fretchen.eu"; +const SERVICE_URL = openapiSpec.servers[0].url; + +/** + * The agent-facing root page, generated from the same constants/files that serve the twin: + * title/summary from the committed spec, prices from its `x-payment-info` per route, links to + * the well-known contracts. Nothing is hand-restated — see discovery.ts. + */ +const ROOT_PAGE = buildRootPage({ + title: openapiSpec.info.title, + summary: openapiSpec.info.description, + endpoints: [ + { + href: `${SERVICE_URL}/openapi.json`, + label: "OpenAPI 3.1 spec — request/response schemas and payment terms", + }, + { + href: `${SERVICE_URL}/${AGENT_REGISTRATION_PATH}`, + label: "ERC-8004 registration (identity, services, wallet)", + }, + { + href: `${SERVICE_URL}/${OASF_MANIFEST_PATH}`, + label: "OASF capability record (skills, domains)", + }, + { + href: MCP_SERVICE.endpoint, + label: "stateless MCP endpoint — same tools as JSON-RPC 2.0 over POST (web_search)", + }, + ], + price: ROUTES.map((route) => { + const info = ( + openapiSpec.paths as Record< + string, + Record + > + )[`/${route}`].get["x-payment-info"]; + return `GET /${route} — $${info?.price.amount ?? "?"} per call`; + }), + payment: + "x402 (HTTP 402): no account, no API key. An unpaid request answers 402 with the machine-readable Payment-Required challenge — exact terms (scheme batch-settlement, network, token, amount) are in the challenge, then retry the same request with the payment attached.", + tryIt: `curl '${SERVICE_URL}/search?q=erc-8004'\n# → HTTP 402 + Payment-Required header: the terms. No wallet needed for this first look.`, + errors: + "402 = unpaid (challenge in the Payment-Required header); 404 = no such route; everything else is standard HTTP. Response schemas: openapi.json.", +}); /** 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)); @@ -312,7 +358,7 @@ async function challenge(route: Route, receiverAddress: `0x${string}`): Promise< // 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", + serviceName: "Fretchen Web Retrieval", tags: ["search", "web", "x402"], iconUrl: `${openapiSpec.servers[0].url}/favicon.png`, }); @@ -331,7 +377,7 @@ export async function handleRequest(request: Request): Promise { } // Web Request headers are lowercase; every consumer in the ported stack - // (extractPaymentPayload, wantsHtml) matches case-insensitively. + // (extractPaymentPayload) matches case-insensitively. const headers = Object.fromEntries(request.headers) as Record; const method = request.method; const url = new URL(request.url); @@ -382,8 +428,9 @@ export async function handleRequest(request: Request): Promise { }); } - if (isGetOrHead && path === "" && wantsHtml(headers)) { - return new Response(isHead ? null : FAVICON_DISCOVERY_HTML, { + if (isGetOrHead && path === "") { + // Unconditional on purpose: the reader is an agent, and agents send Accept: */* too. + return new Response(isHead ? null : ROOT_PAGE, { status: 200, headers: { ...CORS_HEADERS, "Content-Type": "text/html; charset=utf-8" }, }); diff --git a/bunny_js/test/llm.test.ts b/bunny_js/test/llm.test.ts index afdabe6c6..046f63051 100644 --- a/bunny_js/test/llm.test.ts +++ b/bunny_js/test/llm.test.ts @@ -262,8 +262,8 @@ describe("sc_llm_x402", () => { expect(res.headers["access-control-expose-headers"]).toContain("Payment-Response"); }); - it("returns 400 for a non-POST method", async () => { - const res = await handle(makeEvent({ httpMethod: "GET" }) as never, {}); + it("returns 400 for a non-POST, non-GET method", async () => { + const res = await handle(makeEvent({ httpMethod: "PUT" }) as never, {}); expect(res.statusCode).toBe(400); }); @@ -392,31 +392,35 @@ describe("sc_llm_x402", () => { expect(res.body).toBe(""); }); - // x402scan fetches the origin root and parses before probing any - // favicon path. An HTML-accepting GET on "/" must return that discovery HTML. - it("serves favicon-discovery HTML on GET / when the client accepts HTML", async () => { - const res = await handle( - makeEvent({ - httpMethod: "GET", - path: "/", - headers: { accept: "text/html,application/xhtml+xml" }, - }) as never, - {}, - ); - expect(res.statusCode).toBe(200); - expect(res.headers["content-type"]).toContain("text/html"); - expect(res.body).toContain('rel="icon"'); - expect(res.body).toContain("/favicon.png"); - }); - - // An x402 payment client GETs / without an HTML Accept header — it must fall through - // to the normal "only POST" rejection, not receive HTML. - it("does NOT serve HTML on GET / without an HTML Accept header", async () => { - const res = await handle( - makeEvent({ httpMethod: "GET", path: "/", headers: {} }) as never, - {}, - ); - expect(res.statusCode).toBe(400); + // The root page is the agent-facing README, served unconditionally — agents send + // Accept: */* too, so gating on text/html hid the whole page from the reader it is + // for. x402scan still gets its first; the page asserts favicon + // link, contracts, price, curl and errors below. + it("serves the agent-facing root page on GET / regardless of the Accept header", async () => { + for (const accept of [undefined, "*/*", "text/html"]) { + const res = await handle( + makeEvent({ + httpMethod: "GET", + path: "/", + ...(accept ? { headers: { accept } } : {}), + }) as never, + {}, + ); + expect(res.statusCode).toBe(200); + expect(res.headers["content-type"]).toContain("text/html"); + // x402scan parses from this document — it must stay first. + expect(res.body.indexOf('rel="icon"')).toBeLessThan(res.body.indexOf("")); + expect(res.body).toContain("/favicon.png"); + // The page title is the spec's — pinned here so a title change updates the test. + expect(res.body).toContain("Fretchen LLM Inference"); + expect(res.body).toContain("/openapi.json"); + expect(res.body).toContain("agent-registration.json"); + expect(res.body).toContain("/.well-known/oasf.json"); + expect(res.body).toContain("https://mcp-agent.fretchen.eu/mcp"); + expect(res.body).toContain("POST /"); + expect(res.body).toContain("curl -X POST"); + expect(res.body).toContain("402"); + } }); // Regression guard: x-payment-info.price.max must track the real, live price @@ -581,7 +585,7 @@ describe("sc_llm_x402", () => { }), }), }), - serviceName: "Fretchen AI Assistant", + serviceName: "Fretchen LLM Inference", tags: ["llm", "chat", "x402"], iconUrl: "https://llm-agent.fretchen.eu/favicon.png", }), diff --git a/bunny_js/test/mcp.test.ts b/bunny_js/test/mcp.test.ts index 91fdce288..2fc276c9d 100644 --- a/bunny_js/test/mcp.test.ts +++ b/bunny_js/test/mcp.test.ts @@ -148,7 +148,7 @@ describe("mcp_routes (stateless paid MCP endpoint)", () => { /** * The DECIDED deviation from the scw original: GET answers like a normal origin * instead of a blanket 405 — 404 with the honest stateless notice for paths with no - * content, 200 discovery HTML at the root when the caller accepts HTML, and DELETE + * content, 200 agent-facing root page at the root (unconditional), and DELETE * keeps 405 (session termination is genuinely not a thing here, per the MCP spec). */ /** @@ -214,16 +214,28 @@ describe("mcp_routes (stateless paid MCP endpoint)", () => { expect(await head.text()).toBe(""); }); - it("serves discovery HTML at the origin root when the caller accepts HTML", async () => { - const res = await handle( - new Request("https://mcp-agent.fretchen.eu/", { - method: "GET", - headers: { Accept: "text/html" }, - }), - ); - expect(res.status).toBe(200); - expect(res.headers.get("content-type")).toContain("text/html"); - expect(await res.text()).toContain('rel="icon"'); + /** The root page is the agent-facing gateway README, served unconditionally — agents + * send "Accept: star-slash" too. x402scan still gets its <link rel="icon"> first. */ + it("serves the agent-facing gateway page at the origin root regardless of the Accept header", async () => { + for (const headers of [undefined, { Accept: "*/*" }, { Accept: "text/html" }]) { + const res = await handle( + new Request("https://mcp-agent.fretchen.eu/", { method: "GET", headers }), + ); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("text/html"); + const body = await res.text(); + // x402scan parses <link rel="icon"> from this document — it must stay first. + expect(body.indexOf('rel="icon"')).toBeLessThan(body.indexOf("<title>")); + expect(body).toContain("Fretchen MCP Gateway"); + // The gateway points at the two sellers, its domain-proof registration doc, and + // the free first look: initialize and tools/list respond unpaid. + expect(body).toContain("https://llm-agent.fretchen.eu/"); + expect(body).toContain("https://web-agent.fretchen.eu/"); + expect(body).toContain(".well-known/agent-registration.json"); + expect(body).toContain("https://mcp-agent.fretchen.eu/mcp"); + expect(body).toContain("tools/list"); + expect(body).toContain("402"); + } }); it("answers DELETE with 405 Allow: POST — no sessions to terminate", async () => { diff --git a/bunny_js/test/routes.test.ts b/bunny_js/test/routes.test.ts index 160d186ed..bb8972b9c 100644 --- a/bunny_js/test/routes.test.ts +++ b/bunny_js/test/routes.test.ts @@ -522,20 +522,32 @@ describe("discovery", () => { 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" })); + /** The root page is the agent-facing README, served unconditionally — agents send + * "Accept: star-slash" too, so gating on text/html hid the whole page from the reader + * it is for. x402scan still gets its <link rel="icon"> first. */ + test("serves the agent-facing root page at the origin root, regardless of the Accept header", async () => { + const headerCases: Record<string, string>[] = [{}, { Accept: "*/*" }, { Accept: "text/html" }]; + for (const headers of headerCases) { + const res = await handle(discoveryRequest("GET", "", headers)); - 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); + expect(res.statusCode).toBe(200); + expect(res.headers["content-type"]).toContain("text/html"); + // x402scan parses <link rel="icon"> from this document — it must stay first. + expect(res.body.indexOf('rel="icon"')).toBeLessThan(res.body.indexOf("<title>")); + expect(res.body).toContain("/favicon.png"); + // The page title is the spec's — pinned so a title change updates this test. + expect(res.body).toContain("Fretchen Web Retrieval"); + expect(res.body).toContain("/openapi.json"); + expect(res.body).toContain(".well-known/agent-registration.json"); + expect(res.body).toContain(".well-known/oasf.json"); + expect(res.body).toContain("https://mcp-agent.fretchen.eu/mcp"); + // Prices restated on the page must be the spec's x-payment-info values. + expect(res.body).toContain("GET /search — $0.01 per call"); + expect(res.body).toContain("GET /fetch — $0.001 per call"); + expect(res.body).toContain("curl 'https://web-agent.fretchen.eu/search?q=erc-8004'"); + expect(res.body).toContain("402"); + expect(mockCreateBatchSettlementPaymentRequirements).not.toHaveBeenCalled(); + } }); /** Discovery must not open a hole in the route check: an unknown path is still 404, and still @@ -588,7 +600,9 @@ describe("the 402 challenge", () => { 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$/); + // The full production origin from the committed spec — never a serving origin (a leftover + // trial env once advertised the *.bunny.run URL here, which indexers could not attribute). + expect(resource.url).toBe("https://web-agent.fretchen.eu/search"); }); /** One bazaar block per route (docs.x402.org/extensions/bazaar): the block names the method @@ -615,7 +629,7 @@ describe("the 402 challenge", () => { 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.serviceName).toBe("Fretchen Web Retrieval"); expect(opts.tags).toEqual(["search", "web", "x402"]); expect(opts.iconUrl).toBe("https://web-agent.fretchen.eu/favicon.png"); } diff --git a/scw_js/README.md b/scw_js/README.md index 35c8a7784..bd9d3dc56 100644 --- a/scw_js/README.md +++ b/scw_js/README.md @@ -11,6 +11,7 @@ the **channel cron**, and the **Growth API**. - **OpenAPI Spec**: [`openapi.genimg.json`](./openapi.genimg.json) - the x402 discovery contract, served live at `GET /openapi.json` on the origin - **EIP-8004 Registration**: built per service by [`agent_registration.ts`](./agent_registration.ts), served live at `GET /.well-known/agent-registration.json` on each origin (see [ERC-8004 agent registration](#erc-8004-agent-registration)) +- **Agent root page**: served unconditionally at `GET /` on each origin — generated from the committed spec by [`discovery.ts`](./discovery.ts)'s `buildRootPage` (title, prices from `x-payment-info`, contracts, the no-wallet curl returning the honest 402; the `<link rel="icon">` x402scan parses stays first). Nothing on the page is hand-restated. ### Quick Links diff --git a/scw_js/discovery.ts b/scw_js/discovery.ts index 2e5653e14..af0e5fc48 100644 --- a/scw_js/discovery.ts +++ b/scw_js/discovery.ts @@ -7,33 +7,67 @@ * <link rel="icon"> 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). + * + * The root page doubles as the agent-facing "README": an agent that lands on the origin + * (curl, MCP client, ERC-8004 shepherd) should learn what the service is, where the + * machine-readable contracts live, what a call costs, how to pay, and what a 402 means — + * in one fetch. Every fact on the page is GENERATED by the caller from the same committed + * constants/files that serve it (spec title/description, `x-payment-info`, endpoint + * constants), never hand-restated: a restated fact is the next silent drift bug. Surface + * area is deliberately minimal — human-oriented prose lives on the website, this page is + * the last mile from discovery to a first (unpaid) honest 402. + * + * The bunny twin of this file is `bunny_js/src/discovery.ts` (kept in sync; that one is + * restricted to APIs the bunny Deno 1.x isolate reliably exposes). */ -/** Minimal HTML served at the origin root so scrapers can discover the favicon. */ -export const FAVICON_DISCOVERY_HTML = `<!doctype html> +/** The fields `buildRootPage` renders; `endpoints` carries absolute hrefs. */ +export interface RootPageInput { + title: string; + summary: string; + endpoints: { href: string; label: string }[]; + /** One line per route/price pair, generated from the spec's `x-payment-info`. */ + price: string[]; + payment: string; + /** The no-wallet first look — usually the curl that returns the honest 402. */ + tryIt: string; + errors: string; +} + +const escapeHtml = (text: string): string => + text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); + +/** The agent-facing root page. The favicon link stays first — x402scan parses it. */ +export function buildRootPage(input: RootPageInput): string { + return `<!doctype html> <html lang="en"> <head> <meta charset="utf-8"> <link rel="icon" href="/favicon.png" type="image/jpeg"> -<title>Fretchen x402 service +${escapeHtml(input.title)} - + +

${escapeHtml(input.title)}

+

${escapeHtml(input.summary)}

+

Endpoints

+
    +${input.endpoints + .map( + (e) => + `
  • ${escapeHtml(e.href)} — ${escapeHtml(e.label)}
  • `, + ) + .join("\n")} +
+

Price

+
    +${input.price.map((p) => `
  • ${escapeHtml(p)}
  • `).join("\n")} +
+

Payment

+

${escapeHtml(input.payment)}

+

Try it

+
${escapeHtml(input.tryIt)}
+

Errors

+

${escapeHtml(input.errors)}

+ `; - -/** - * 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/scw_js/genimg_x402_token.ts b/scw_js/genimg_x402_token.ts index 557d48ca4..fcdca287f 100644 --- a/scw_js/genimg_x402_token.ts +++ b/scw_js/genimg_x402_token.ts @@ -42,7 +42,7 @@ import { validatePaymentNetwork, getExpectedNetworks } from "./getChain.js"; import type { ScwEvent } from "./types.js"; import openapiSpec from "./openapi.genimg.json" with { type: "json" }; import { faviconBase64, faviconContentType } from "./favicon.js"; -import { FAVICON_DISCOVERY_HTML, wantsHtml } from "./discovery.js"; +import { buildRootPage } from "./discovery.js"; import { buildGenimgBazaarExtension } from "./bazaar_discovery.js"; import { AGENT_REGISTRATION_PATH, @@ -65,6 +65,47 @@ const PRICE_ATOMIC: PriceList = { }; const GAS_BUFFER = parseEther("0.00001"); +/** + * The agent-facing root page, generated from the same constants/files that serve the + * handler: title/summary from the committed spec, the price from its `x-payment-info`, + * links to the well-known contracts. Nothing is hand-restated — see discovery.ts. + */ +const ROOT_PAGE = buildRootPage({ + title: openapiSpec.info.title, + summary: openapiSpec.info.description, + endpoints: [ + { + href: `${openapiSpec.servers[0].url}/openapi.json`, + label: "OpenAPI 3.1 spec — request/response schemas and payment terms", + }, + { + href: `${openapiSpec.servers[0].url}/${AGENT_REGISTRATION_PATH}`, + label: "ERC-8004 registration (identity, services, wallet)", + }, + { + href: `${openapiSpec.servers[0].url}/${OASF_MANIFEST_PATH}`, + label: "OASF capability record (skills, domains)", + }, + ], + price: [ + ((): string => { + // Generated from the committed spec, not restated — the page and the 402 share it. + const info = ( + openapiSpec.paths as Record< + string, + Record + > + )["/"].post["x-payment-info"]; + return `POST /imagegen — $${info?.price.amount ?? "?"} per image (USDC, EURC or EURe; each token's price is set in its own units)`; + })(), + ], + payment: + "x402 (HTTP 402), exact scheme — one signature moves the money per call: no account, no API key, no approve() needed. An unpaid request answers 402 with the machine-readable Payment-Required challenge — exact terms (network, token, amount) are in the challenge, then retry the same request with the payment attached.", + tryIt: `curl -X POST '${openapiSpec.servers[0].url}/imagegen' -H 'Content-Type: application/json' -d '{"prompt":"a lighthouse at dusk"}'\n# → HTTP 402 + Payment-Required header: the terms. No wallet needed for this first look.`, + errors: + "402 = unpaid (challenge in the Payment-Required header); 400 = invalid request (OpenAI-shaped error body); 500 = server configuration error. Response schemas: openapi.json.", +}); + // keccak256("Transfer(address,address,uint256)") — used to extract tokenId from mint tx logs const TRANSFER_EVENT_HASH = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; @@ -397,9 +438,8 @@ async function handle( // Instead we cover the two paths that DO reach the function and that x402scan // (@agentcash/discovery) actually uses to resolve an icon: // 1. The origin root: scrapeFavicon fetches it and parses first, - // before probing any COMMON_FAVICON_PATHS. So a GET on "/" that asks for HTML - // (a scraper, never an x402 payment client — those POST JSON) gets a minimal - // HTML page pointing at /favicon.png. + // before probing any COMMON_FAVICON_PATHS. The root now serves the agent-facing + // README page unconditionally — the favicon link stays its first head element. // 2. /favicon.png: one of scrapeFavicon's COMMON_FAVICON_PATHS fallbacks, and unlike // /favicon.ico it is not swallowed by the gateway. const normalizedPath = (event.path ?? "").replace(/^\/+/, ""); @@ -415,12 +455,13 @@ async function handle( }; } - if (isGetOrHead && normalizedPath === "" && wantsHtml(event.headers)) { + if (isGetOrHead && normalizedPath === "") { + // Unconditional on purpose: the reader is an agent, and agents send Accept: */* too. const isHead = event.httpMethod === "HEAD"; return { statusCode: 200, headers: { ...CORS_HEADERS, "Content-Type": "text/html; charset=utf-8" }, - body: isHead ? "" : FAVICON_DISCOVERY_HTML, + body: isHead ? "" : ROOT_PAGE, }; } diff --git a/scw_js/test/genimg_x402_token.test.ts b/scw_js/test/genimg_x402_token.test.ts index ee48d6217..0b7905651 100644 --- a/scw_js/test/genimg_x402_token.test.ts +++ b/scw_js/test/genimg_x402_token.test.ts @@ -637,38 +637,41 @@ describe("genimg_x402_token.js - x402 v2 Token Payment Tests", () => { expect(response.body).toBe(""); }); - // x402scan fetches the origin root and parses before probing any - // favicon path. An HTML-accepting GET on "/" must return that discovery HTML. - test("should serve favicon-discovery HTML on GET / when the client accepts HTML", async () => { - const event = { - httpMethod: "GET", - headers: { accept: "text/html,application/xhtml+xml" }, - body: "", - path: "/", - }; - - const response = await handle(event, {}); - - expect(response.statusCode).toBe(200); - expect(response.headers["Content-Type"]).toContain("text/html"); - expect(response.body).toContain('rel="icon"'); - expect(response.body).toContain("/favicon.png"); - }); - - // An x402 payment client GETs / without an HTML Accept header — it must NOT get HTML, - // it must fall through to the normal "only POST" rejection. - test("should NOT serve HTML on GET / without an HTML Accept header", async () => { - const event = { - httpMethod: "GET", - headers: {}, - body: "", - path: "/", - }; + // The root page is the agent-facing README, served unconditionally — agents send + // Accept: */* too, so gating on text/html hid the page from the reader it is for. + // x402scan still gets its first. + test("should serve the agent-facing root page on GET / regardless of the Accept header", async () => { + const headerCases: Record[] = [ + {}, + { accept: "*/*" }, + { accept: "text/html,application/xhtml+xml" }, + ]; + for (const headers of headerCases) { + const event = { + httpMethod: "GET", + headers, + body: "", + path: "/", + }; - const response = await handle(event, {}); + const response = await handle(event, {}); - expect(response.statusCode).toBe(400); - expect(JSON.parse(response.body).error).toContain("Only POST"); + expect(response.statusCode).toBe(200); + expect(response.headers["Content-Type"]).toContain("text/html"); + // x402scan parses from this document — it must stay first. + expect(response.body.indexOf('rel="icon"')).toBeLessThan(response.body.indexOf("")); + expect(response.body).toContain("/favicon.png"); + expect(response.body).toContain("Fretchen AI Image Generation Service"); + expect(response.body).toContain("/openapi.json"); + expect(response.body).toContain(".well-known/agent-registration.json"); + expect(response.body).toContain(".well-known/oasf.json"); + // The page price is the spec's x-payment-info value, not a hand-restated one. + expect(response.body).toContain("POST /imagegen — $0.07 per image"); + expect(response.body).toContain( + "curl -X POST 'https://imagegen-agent.fretchen.eu/imagegen'", + ); + expect(response.body).toContain("402"); + } }); test("should reject invalid JSON body", async () => {