Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 33 additions & 33 deletions .github/THREAT_MODEL.md

Large diffs are not rendered by default.

47 changes: 37 additions & 10 deletions .github/workflows/deploy-bunny-js.yml
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
name: Deploy Bunny JS searchapi twin
name: Deploy Bunny JS twins

# 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.
# Deploys the bunny Edge Script twins of the x402 sellers: searchapi (web-agent), llm
# (chat REST), and mcp (the MCP wrapper around them — its bundle embeds both tools, so
# mcp.js serves web_search and chat_completion on its own). All three coexist 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.
# 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) 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.
#
# Prerequisite per script (one-time, manual): the remote script must already exist —
# `bunny scripts create <name> --type standalone --no-link` — and its id and per-script
# deploy key (dashboard: Script -> Deployments -> Settings) must be the matching repo
# secrets, or the deploy step below fails. The deploy key authorizes exactly that one
# script, not the account.

permissions:
contents: read
Expand Down Expand Up @@ -58,12 +67,30 @@ jobs:
working-directory: ./bunny_js
run: npm run build

- name: Deploy script to Bunny Edge Scripting
- name: Deploy searchapi twin 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"
file: "bunny_js/dist/searchapi.js"

- name: Deploy llm twin to Bunny Edge Scripting
uses: BunnyWay/actions/deploy-script@main
with:
# Same per-script secret pair pattern as searchapi, its own script id and its
# own deploy key — narrower blast radius than an account-wide API key.
script_id: ${{ secrets.BUNNYLLM_SCRIPT_ID }}
deploy_key: ${{ secrets.BUNNYLLM_DEPLOY_KEY }}
file: "bunny_js/dist/llm.js"

- name: Deploy mcp twin to Bunny Edge Scripting
uses: BunnyWay/actions/deploy-script@main
with:
# The mcp bundle embeds the llm and search tools (see mcp_tools.ts), so this one
# script serves both web_search and chat_completion.
script_id: ${{ secrets.BUNNYMCP_SCRIPT_ID }}
deploy_key: ${{ secrets.BUNNYMCP_DEPLOY_KEY }}
file: "bunny_js/dist/mcp.js"
66 changes: 43 additions & 23 deletions bunny_js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,16 @@ the bunny-provided URL until a formal switch.

## 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 run build` — tsdown bundle to a **single** `dist/searchapi.js` (bunny deploys one file).
- `npm run dev:searchapi` / `dev:llm` / `dev:mcp` — 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 (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,
- `npm run deploy` — build, then `bunny scripts deploy dist/searchapi.js` (needs the bunny CLI,
installed and logged in).

Ordering (repo rule): `npm run build` in `shared/chain-utils` and `shared/s3-utils` first —
Expand Down Expand Up @@ -61,7 +64,7 @@ it is inert without another credential, plain — and plain stays inspectable wh

## Known limits / decisions

- **Single-file bundle, 1 MB limit.** tsdown emits one `dist/index.js` with `codeSplitting:
- **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`
Expand Down Expand Up @@ -99,7 +102,7 @@ Budget: ≤ ~$0.10 of real purchases.

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
install + login (or dashboard paste of `dist/searchapi.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)
Expand All @@ -108,18 +111,24 @@ Bunny publishes whichever deployment happened **last**, so a manual CLI deploy a
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`),
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`.
`bunny login`. The llm and mcp twins deploy by naming their bundle and script id
explicitly: `bunny scripts deploy dist/llm.js <LLM_SCRIPT_ID>` and
`bunny scripts deploy dist/mcp.js <MCP_SCRIPT_ID>`.
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)
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

(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
Expand All @@ -132,17 +141,28 @@ and the `.bunny/script.json` link comes out of the create step. One-time sequenc

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, 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://<that-url>.bunny.run` and redeploy, so
the 402 advertises the trial origin instead of the parity default.
`--secret` (RECEIVER_AUTHORIZER_PRIVATE_KEY, BRAVE_API_KEY, MISTRAL_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`. Per script:
- **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 <id>` /
`bunny scripts deploy dist/mcp.js <id>` — publishes each; the output prints the live
`*.bunny.run` URL.
4. `bunny scripts env set SEARCH_SERVICE_URL https://<that-url>.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://<that-url>.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.
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.
53 changes: 43 additions & 10 deletions bunny_js/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,23 +10,41 @@ Report privately via [GitHub Security Advisories](https://github.com/fretchen/fr

## 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.
The bunny.net Edge Script twins of the x402 sellers — all of `bunny_js/` code and the three
deployed scripts it builds to:

| Twin | Script id | URL | Tools served |
| --------- | --------- | ------------------------------ | ----------------------------------- |
| searchapi | 97472 | `bunnyjs-vlyol.bunny.run` | `/search` + `/fetch` (Brave) |
| llm | 97948 | `fretchen-llm-2dirg.bunny.run` | chat completions (Mistral) |
| mcp | 97949 | `fretchen-mcp-53eoc.bunny.run` | both, over stateless JSON-RPC (MCP) |

Plus each script's env, the GitHub Action
(`.github/workflows/deploy-bunny-js.yml` — one deploy step per script, one per-script
deploy-key secret pair), 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).
boundary, 2026-10-18 audit, three-script update 2026-10-20).

---

## 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 |
| Area | Severity | Status |
| ------------------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| Deploy-key blast radius (×3, one per twin) | Medium | Bounded — trigger hygiene (push-to-main only) + rotate-on-suspicion; see THREAT_MODEL §2 |
| Secret-set copies across two platforms | Medium | Accepted for the trial — scw_js + up to three script envs carry the S3 pair and authorizer key; every copy must rotate together (see below) |
| 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 |

**Secret-copy rotation coupling (2026-10-20, three-script update).** The channel-store S3
credential pair and the receiver-authorizer private key exist in `scw_js`'s Scaleway secrets
and in each twin's script env — searchapi, llm, and mcp all charge the same S3 channel
store. A rotation that touches any one copy must touch all of them in the same change, or
the twins and Scaleway diverge on what "the same channel tuple" means. `BRAVE_API_KEY`
couples searchapi + mcp; `MISTRAL_API_KEY` couples llm + mcp. No buyer key or owner key
exists in any script env.

**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
Expand Down Expand Up @@ -64,6 +82,21 @@ matcher with Deno's error shapes on first observation, not speculatively.
- Repo hygiene: `.env` untracked and gitignored in both packages; tracked files contain only
published test keys, public addresses, and the public ownership proof.

## Fixed / Verified (2026-10-20 review, llm + mcp twins)

- `structuredClone` on the llm twin's `GET /openapi.json` path was replaced with a JSON
round-trip — an unverified Node-global on bunny's Deno 1.x, and lossless for a JSON
document by construction.
- The MCP wrapper's POST body is read capped at 1 MB (declared `Content-Length` checked up
front, the stream read refused mid-body past the cap) — the edge imposes no
platform-level request-size cap the way Scaleway did, so the twin encodes its own. A
streamed-oversize test pins the 413.
- Payment flows on the new twins verified against the review checklist: settle-after-success
and lock release on upstream failure hold in `llm_routes.ts` (the 40 s completion cap
releases the lock explicitly; the advertised lock TTL is scw's 120), the mcp relay
duplicates no pricing (it calls the real handlers in-process), and every response path
spreads `CORS_HEADERS` (which carries the `no-store` pin).

---

## Mitigations in Place
Expand Down
Loading
Loading