diff --git a/.gitignore b/.gitignore index a58b587..8e95054 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ dist/ docs/superpowers/ .superpowers/ .playwright-mcp/ +satgate.root-key +pnpm-lock.yaml +.idea/ diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index ab1f416..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,10 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Ignored default folder with query files -/queries/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml -# Editor-based HTTP Client requests -/httpRequests/ diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index 90a5d17..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/token-toll.iml b/.idea/token-toll.iml deleted file mode 100644 index c956989..0000000 --- a/.idea/token-toll.iml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 35eb1dd..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index ac20fee..a5e400b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,27 +1,4 @@ -## [1.15.5](https://github.com/TheCryptoDonkey/satgate/compare/v1.15.4...v1.15.5) (2026-08-14) - -### Documentation and metadata - -* correct canonical ForgeSworn links for toll-booth and 402-mcp -* add npm funding metadata for the project support channels - -## [1.15.2](https://github.com/TheCryptoDonkey/satgate/compare/v1.15.1...v1.15.2) (2026-03-27) - - -### Bug Fixes - -* correct type mismatches in 402-announce service call ([4d76482](https://github.com/TheCryptoDonkey/satgate/commit/4d764827509927d534852da39c6fda3c232f470c)) -* update 402-announce call to v2 API (url → urls array) ([529683c](https://github.com/TheCryptoDonkey/satgate/commit/529683cb888eef167d19fb9c4699d171e78739db)) -* update paymentMethods to 402-announce v2 array format ([7df22a6](https://github.com/TheCryptoDonkey/satgate/commit/7df22a63c36204f27ed3f620596fa16689d730a4)) - -## [1.15.1](https://github.com/TheCryptoDonkey/satgate/compare/v1.15.0...v1.15.1) (2026-03-24) - - -### Bug Fixes - -* announce IETF Payment pmi tag when realm is configured ([f1abb5e](https://github.com/TheCryptoDonkey/satgate/commit/f1abb5e0f3a82337a0624faf44e6742522b2f44d)) - -# [1.15.0](https://github.com/TheCryptoDonkey/satgate/compare/v1.14.1...v1.15.0) (2026-03-24) +# Changelog ## 1.17.2 (2026-08-22) @@ -29,31 +6,30 @@ - point the deploy guard at the credential that exists (#11) - - ## 1.17.1 (2026-08-22) ### Bug Fixes - keep announcing after adding the lnurlcash rail (#9) - - ## 1.17.0 (2026-08-22) ### Features - accept LUD-25 bearer notes and sweep them to Lightning (#7) - - ## 1.16.0 (2026-08-17) ### Features - add an nwc lightning backend +## [1.15.5](https://github.com/TheCryptoDonkey/satgate/compare/v1.15.4...v1.15.5) (2026-08-14) +### Documentation and metadata + +* correct canonical ForgeSworn links for toll-booth and 402-mcp +* add npm funding metadata for the project support channels ## 1.15.4 (2026-08-13) @@ -61,15 +37,29 @@ - publish the built package exports - - ## 1.15.3 (2026-08-13) ### Bug Fixes - migrate to toll-booth 6 and restore releases +## [1.15.2](https://github.com/TheCryptoDonkey/satgate/compare/v1.15.1...v1.15.2) (2026-03-27) + + +### Bug Fixes + +* correct type mismatches in 402-announce service call ([4d76482](https://github.com/TheCryptoDonkey/satgate/commit/4d764827509927d534852da39c6fda3c232f470c)) +* update 402-announce call to v2 API (url → urls array) ([529683c](https://github.com/TheCryptoDonkey/satgate/commit/529683cb888eef167d19fb9c4699d171e78739db)) +* update paymentMethods to 402-announce v2 array format ([7df22a6](https://github.com/TheCryptoDonkey/satgate/commit/7df22a63c36204f27ed3f620596fa16689d730a4)) + +## [1.15.1](https://github.com/TheCryptoDonkey/satgate/compare/v1.15.0...v1.15.1) (2026-03-24) + + +### Bug Fixes + +* announce IETF Payment pmi tag when realm is configured ([f1abb5e](https://github.com/TheCryptoDonkey/satgate/commit/f1abb5e0f3a82337a0624faf44e6742522b2f44d)) +## [1.15.0](https://github.com/TheCryptoDonkey/satgate/compare/v1.14.1...v1.15.0) (2026-03-24) ### Features @@ -83,7 +73,7 @@ * allow Google Fonts in CSP for landing page typography ([3dc4a50](https://github.com/TheCryptoDonkey/satgate/commit/3dc4a50286c03219bf49d39f35fc508a098cd16f)) -# [1.14.0](https://github.com/TheCryptoDonkey/satgate/compare/v1.13.1...v1.14.0) (2026-03-24) +## [1.14.0](https://github.com/TheCryptoDonkey/satgate/compare/v1.13.1...v1.14.0) (2026-03-24) ### Features @@ -97,7 +87,7 @@ * branding — OG meta tags, ForgeSworn links, IETF Payment mention ([2fa82fb](https://github.com/TheCryptoDonkey/satgate/commit/2fa82fb07a2a19e61a2bf86b4fb261f1da9d0dcf)) -# [1.13.0](https://github.com/TheCryptoDonkey/satgate/compare/v1.12.1...v1.13.0) (2026-03-24) +## [1.13.0](https://github.com/TheCryptoDonkey/satgate/compare/v1.12.1...v1.13.0) (2026-03-24) ### Features @@ -111,21 +101,21 @@ * add explicit L402 rail for dual-scheme challenges ([4756ae4](https://github.com/TheCryptoDonkey/satgate/commit/4756ae43e572bd1e14790d4e6e8591c522274915)) -# [1.12.0](https://github.com/TheCryptoDonkey/satgate/compare/v1.11.0...v1.12.0) (2026-03-24) +## [1.12.0](https://github.com/TheCryptoDonkey/satgate/compare/v1.11.0...v1.12.0) (2026-03-24) ### Features * upgrade to @forgesworn/toll-booth + IETF Payment auth ([150a470](https://github.com/TheCryptoDonkey/satgate/commit/150a4706f483fcaa026028ad8c85a02ecb899466)) -# [1.11.0](https://github.com/TheCryptoDonkey/satgate/compare/v1.10.0...v1.11.0) (2026-03-17) +## [1.11.0](https://github.com/TheCryptoDonkey/satgate/compare/v1.10.0...v1.11.0) (2026-03-17) ### Features * auto-melt Cashu ecash to operator Lightning node ([9baa006](https://github.com/TheCryptoDonkey/satgate/commit/9baa0065cd54687db4f0455aed294ed273740f2e)) -# [1.10.0](https://github.com/TheCryptoDonkey/satgate/compare/v1.9.0...v1.10.0) (2026-03-17) +## [1.10.0](https://github.com/TheCryptoDonkey/satgate/compare/v1.9.0...v1.10.0) (2026-03-17) ### Bug Fixes @@ -141,14 +131,14 @@ * add Cashu metadata to well-known and llms.txt discovery ([91a9341](https://github.com/TheCryptoDonkey/satgate/commit/91a9341a4c45b92bfbfedc295b02aef33b229ae0)) * wire xcashu rail, fix paymentMethods, broaden auth for Cashu ([3ca5436](https://github.com/TheCryptoDonkey/satgate/commit/3ca54366dc7b7cacdbeafc4dbbf59f08981aebc0)) -# [1.9.0](https://github.com/TheCryptoDonkey/satgate/compare/v1.8.0...v1.9.0) (2026-03-15) +## [1.9.0](https://github.com/TheCryptoDonkey/satgate/compare/v1.8.0...v1.9.0) (2026-03-15) ### Features * add --public-url flag for explicit announce URL ([bb9b186](https://github.com/TheCryptoDonkey/satgate/commit/bb9b186b295d9c6dba955209875f31bc2ac4eb2e)) -# [1.8.0](https://github.com/TheCryptoDonkey/satgate/compare/v1.7.8...v1.8.0) (2026-03-15) +## [1.8.0](https://github.com/TheCryptoDonkey/satgate/compare/v1.7.8...v1.8.0) (2026-03-15) ### Features @@ -237,7 +227,7 @@ * pass maxBodySize to streaming proxy instead of using hardcoded 100 MiB ([18ae958](https://github.com/TheCryptoDonkey/satgate/commit/18ae95827341c71f4864c4577678f9e9a7e0c03d)) * require rootKey to be exactly 64 hex chars, matching toll-booth ([79ceb2a](https://github.com/TheCryptoDonkey/satgate/commit/79ceb2a7974bcc2970f57de566b9219d98581802)) -# [1.7.0](https://github.com/TheCryptoDonkey/satgate/compare/v1.6.0...v1.7.0) (2026-03-14) +## [1.7.0](https://github.com/TheCryptoDonkey/satgate/compare/v1.6.0...v1.7.0) (2026-03-14) ### Bug Fixes @@ -255,7 +245,7 @@ * add --announce for Nostr discovery via l402-announce ([0d23a48](https://github.com/TheCryptoDonkey/satgate/commit/0d23a48abe603cdf6ca917dc79d021a1a6ae0f00)) * add status tag and JSON schemas to Nostr service announcement ([1495451](https://github.com/TheCryptoDonkey/satgate/commit/14954513838173b233f3585d1e6e24580b14aca3)) -# [1.6.0](https://github.com/TheCryptoDonkey/satgate/compare/v1.5.1...v1.6.0) (2026-03-14) +## [1.6.0](https://github.com/TheCryptoDonkey/satgate/compare/v1.5.1...v1.6.0) (2026-03-14) ### Features @@ -270,14 +260,14 @@ * add spacing between payment tier buttons and actions ([9100278](https://github.com/TheCryptoDonkey/satgate/commit/91002785092d29c71818d170c2c1f0eb0c9e4cd4)) -# [1.5.0](https://github.com/TheCryptoDonkey/satgate/compare/v1.4.0...v1.5.0) (2026-03-14) +## [1.5.0](https://github.com/TheCryptoDonkey/satgate/compare/v1.4.0...v1.5.0) (2026-03-14) ### Features * add custom sats amount option to payment flow ([f3b9e68](https://github.com/TheCryptoDonkey/satgate/commit/f3b9e68439c859ab83340782fcea31f29d3ce0e5)) -# [1.4.0](https://github.com/TheCryptoDonkey/satgate/compare/v1.3.1...v1.4.0) (2026-03-14) +## [1.4.0](https://github.com/TheCryptoDonkey/satgate/compare/v1.3.1...v1.4.0) (2026-03-14) ### Features @@ -291,7 +281,7 @@ * show tokens/sat instead of sats/1k tokens on model cards ([2ba5714](https://github.com/TheCryptoDonkey/satgate/commit/2ba5714dafe1e7ca4f4bdbd7c8a84c79ae68437f)) -# [1.3.0](https://github.com/TheCryptoDonkey/satgate/compare/v1.2.1...v1.3.0) (2026-03-14) +## [1.3.0](https://github.com/TheCryptoDonkey/satgate/compare/v1.2.1...v1.3.0) (2026-03-14) ### Features @@ -305,7 +295,7 @@ * model selector UX improvements ([9a22de3](https://github.com/TheCryptoDonkey/satgate/commit/9a22de36521b17561a98c61879ac77388ab36aa6)) -# [1.2.0](https://github.com/TheCryptoDonkey/satgate/compare/v1.1.0...v1.2.0) (2026-03-14) +## [1.2.0](https://github.com/TheCryptoDonkey/satgate/compare/v1.1.0...v1.2.0) (2026-03-14) ### Bug Fixes @@ -318,14 +308,14 @@ * add gemma3:4b as second model with per-model pricing ([0c5eab6](https://github.com/TheCryptoDonkey/satgate/commit/0c5eab630fa031b93f7fdc78c428574d79b30938)) * add model selector pills to UI ([7bb3c49](https://github.com/TheCryptoDonkey/satgate/commit/7bb3c49e200566f735c75806b7aceb9fbc29e84c)) -# [1.1.0](https://github.com/TheCryptoDonkey/satgate/compare/v1.0.0...v1.1.0) (2026-03-14) +## [1.1.0](https://github.com/TheCryptoDonkey/satgate/compare/v1.0.0...v1.1.0) (2026-03-14) ### Features * switch free tier from per-request to per-usage (creditsPerDay) ([3aec2b3](https://github.com/TheCryptoDonkey/satgate/commit/3aec2b340d08b94b383dfe6e3998c1b7ce27ef17)) -# 1.0.0 (2026-03-14) +## 1.0.0 (2026-03-14) ### Bug Fixes diff --git a/Dockerfile b/Dockerfile index 7424b8c..6eedee8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,6 +8,7 @@ RUN npm ci COPY tsconfig.json ./ COPY src/ ./src/ COPY bin/ ./bin/ +COPY scripts/copy-page.mjs ./scripts/ RUN npm run build RUN npm prune --omit=dev @@ -17,11 +18,10 @@ WORKDIR /app COPY package.json package-lock.json ./ COPY --from=build /build/node_modules/ ./node_modules/ COPY --from=build /build/dist/ ./dist/ -COPY --from=build /build/src/page/ ./dist/page/ # Run as non-root user for defence-in-depth RUN groupadd -r satgate && useradd -r -g satgate satgate && chown -R satgate:satgate /app USER satgate -EXPOSE 3002 +EXPOSE 3000 CMD ["node", "dist/bin/satgate.js"] diff --git a/README.md b/README.md index 569e9dd..2ecccb3 100644 --- a/README.md +++ b/README.md @@ -2,22 +2,24 @@ [![MIT licence](https://img.shields.io/badge/licence-MIT-blue.svg)](./LICENSE) [![Nostr](https://img.shields.io/badge/Nostr-Zap%20me-purple)](https://primal.net/p/npub1mgvlrnf5hm9yf0n5mf9nqmvarhvxkc6remu5ec3vf8r0txqkuk7su0e7q2) -[![TypeScript](https://img.shields.io/badge/TypeScript-5.8-blue)](https://www.typescriptlang.org/) +[![TypeScript](https://img.shields.io/badge/TypeScript-6-blue)](https://www.typescriptlang.org/) [![Node](https://img.shields.io/badge/Node-%3E%3D22-green)](https://nodejs.org/) **Your GPU is burning money. Make it earn money.** -satgate sits in front of Ollama, vLLM, llama.cpp — any OpenAI-compatible backend — and turns it into a pay-per-token API. No accounts. No API keys. No Stripe. Clients pay per token, you earn sats before the response finishes streaming. +satgate sits in front of Ollama, vLLM, llama.cpp or any other OpenAI-compatible backend (with `UPSTREAM_API_KEY` for one that needs a key) and turns it into a pay-per-token API. No accounts. No API keys. No Stripe. Clients pay per token, you earn sats before the response finishes streaming. ![satgate demo](demo/token-toll-demo.gif) ## Quick start ```bash -npx satgate --upstream http://localhost:11434 +LIGHTNING_KEY= npx satgate --upstream http://localhost:11434 --lightning phoenixd ``` -That's it. satgate auto-detects your models, starts accepting payments, and proxies inference requests. Clients pay per token, you earn sats. +satgate auto-detects your models, charges per token over Lightning (here through a local phoenixd; `lnbits`, `lnd`, `cln` and `nwc` work too), and proxies paid inference requests. + +Without `--lightning` (or Cashu mints, or an allowlist) satgate runs in open mode: no payment and no authentication. Open mode stays on localhost; it only gets a public tunnel if you pass `--tunnel`. --- @@ -44,10 +46,10 @@ curl -s https://satgate.trotters.dev/llms.txt | | The old way | With satgate | |---|---|---| -| **Sell GPU time** | Sign up for a marketplace (OpenRouter, Together). They set the price, take a cut, own the customer. | `npx satgate --upstream http://localhost:11434`. You set the price. You keep 100%. | +| **Sell GPU time** | Sign up for a marketplace (OpenRouter, Together). They set the price, take a cut, own the customer. | `npx satgate --upstream http://localhost:11434 --lightning phoenixd`. You set the price. You keep 100%. | | **Handle billing** | Stripe account, KYC, usage tracking, invoices, chargebacks | Payments settle before the response finishes streaming. No accounts, no disputes. | | **Serve AI agents** | OAuth flows, API key management, billing portals — none of which machines can use | Agents discover your endpoint, pay per token from their own wallet, no human in the loop. | -| **Price fairly** | Flat rate per request, regardless of whether it's 10 tokens or 10,000 | Actual tokens counted from the response. Overpayments credited back. | +| **Price fairly** | Flat rate per request, regardless of whether it's 10 tokens or 10,000 | Billed from the token counts the upstream reports. Unused holds credited back. | --- @@ -91,7 +93,7 @@ sequenceDiagram Everything you just saw — the payment gating, the multi-rail support, the credit system, the free tier, the macaroon credentials — that's not satgate. That's [toll-booth](https://github.com/forgesworn/toll-booth). -satgate is ~400 lines of glue on top of toll-booth. It adds the AI-specific bits: token counting, model pricing, streaming reconciliation, capacity management. Everything else comes from the middleware. +satgate is a thin layer on top of toll-booth. It adds the AI-specific bits: token counting, model pricing, streaming reconciliation, capacity management. Everything else comes from the middleware. **You could build your own satgate for your domain in an afternoon.** @@ -101,7 +103,7 @@ Monetise a routing API. Gate a translation service. Sell weather data per reques ```mermaid graph TB - subgraph "satgate (~400 lines)" + subgraph "satgate" TC[Token counting] MP[Model pricing] SR[Streaming reconciliation] @@ -126,9 +128,9 @@ graph TB ## What satgate adds -- **Pay-per-token** — actual token count from the response, not estimated. Streaming and buffered. +- **Pay-per-token**: billed from the prompt and completion token counts the upstream reports, streaming and buffered, reasoning tokens included. If an upstream reports no usage, satgate falls back to a chunk count with a byte-based floor. - **Model-specific pricing** — 1 sat/1k for Llama, 5 sats/1k for DeepSeek. You set the rates. -- **Streaming reconciliation** — estimated charge upfront, reconciled to actual usage after. Overpayments credited back. +- **Streaming reconciliation**: the worst case for the request (its size plus `max_tokens`) is held up front and settled to actual usage after. The unused hold goes back to the credit balance. - **Capacity management** — limit concurrent inference requests to protect your GPU. - **Auto-detect models** — queries your upstream on startup. No manual model list. - **Four server-side payment rails** — Lightning, Cashu ecash, LNURLcash @@ -136,8 +138,8 @@ graph TB LNbits, LND, CLN or any NWC wallet (`--lightning nwc`, URI read from a file). Callers can pay from their own NWC wallet through 402-mcp without disclosing it to satgate. -- **Privacy by design** — no personal data collected or stored. No accounts, no cookies, no IP logging. GDPR-safe out of the box. -- **Instant public URL** — auto-spawns a Cloudflare tunnel. Your GPU is reachable from the internet in seconds. +- **Privacy by design**: no accounts and no cookies, and client IPs are not logged. IPs are used for free-tier and invoice limits and stored only as hashes keyed to the date. +- **Instant public URL** — when payment or an allowlist is required, satgate spawns a Cloudflare quick tunnel (if `cloudflared` is installed), so your GPU is reachable from the internet in seconds. Open mode never tunnels unless you pass `--tunnel`. --- @@ -160,7 +162,9 @@ sequenceDiagram T->>T: Reconcile: credit back overpayment ``` -Charges are estimated upfront based on model pricing, then reconciled to actual token usage after the response completes. Operators are never short-changed — costs round up. Overpayments are credited to the client's balance for the next request. +Before the upstream is called, satgate holds the most the request could cost: every byte of the body counted as a prompt token, plus `max_tokens` (clamped to `--max-tokens`, default 2048) for each choice. A balance that cannot cover that gets a 402. After the response, the charge is settled to the tokens the upstream reported, rounded up to the next sat, and the rest of the hold goes back to the client's balance. + +Per-request IETF Payment charges and the free tier cannot be topped up or refunded, so for those `max_tokens` is shrunk to fit what was paid, and the per-request price (`estimatedCostSats`) is sized by default to cover a 4 KiB request plus a full `max_tokens` completion. --- @@ -214,12 +218,19 @@ pricing: freeTier: creditsPerDay: 250 capacity: - maxConcurrent: 4 + maxConcurrent: 4 # default 8; 0 = unlimited +maxTokens: 2048 # cap on completion tokens per request (default 2048) +trustProxy: true # behind a reverse proxy: read client IPs from X-Forwarded-For +trustedProxies: + - 127.0.0.1 +maxPendingInvoicesPerIp: 20 # unpaid invoices per client before a 429 (default 20) lnurlcash: mints: - mint.example.com ``` +With a payment rail configured, storage defaults to SQLite (`./satgate.db`) and a generated macaroon root key is kept beside it in `satgate.root-key`, so balances and credentials survive a restart. Set `ROOT_KEY` to manage the key yourself. + CLI flags > environment variables > config file > defaults. --- @@ -269,10 +280,10 @@ including the safety envelope and the limits of what this single run proves. ```bash # Monetise your local Ollama -npx satgate --upstream http://localhost:11434 +LIGHTNING_KEY= npx satgate --upstream http://localhost:11434 --lightning phoenixd -# Or point at any OpenAI-compatible backend -npx satgate --upstream http://your-vllm-server:8000 +# Or point at another OpenAI-compatible backend +LIGHTNING_KEY= npx satgate --upstream http://your-vllm-server:8000 --lightning phoenixd ``` → [**toll-booth**](https://github.com/forgesworn/toll-booth) — the middleware that powers all of this. Build your own. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..5b78215 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,24 @@ +# Security Policy + +## Reporting a vulnerability + +Please report vulnerabilities privately through GitHub's private vulnerability reporting: + +https://github.com/TheCryptoDonkey/satgate/security/advisories/new + +Do not open a public issue for a vulnerability. Include the satgate version, how it was configured (payment rails, auth mode, pricing mode) and steps to reproduce. + +Payment handling itself lives in [toll-booth](https://github.com/forgesworn/toll-booth). If the flaw is in toll-booth rather than in satgate, report it there instead; if unsure, report it here. + +## Supported versions + +Only the latest release receives security fixes. + +## What satgate holds + +- **Macaroon root key** (`ROOT_KEY`, or `satgate.root-key` beside the database when generated). Anyone holding it can mint credentials. +- **Lightning backend credentials** (`LIGHTNING_KEY`, or the NWC URI file). These can move funds. +- **Upstream API key** (`UPSTREAM_API_KEY` or `--upstream-key-file`), when the upstream needs one. +- **SQLite database** (`satgate.db` by default): credit balances, invoices and settlement records. + +Keep these out of version control and readable only by the user satgate runs as. diff --git a/demo/demo.tape b/demo/demo.tape index f720f6e..fb371de 100644 --- a/demo/demo.tape +++ b/demo/demo.tape @@ -18,11 +18,10 @@ Set TypingSpeed 40ms Set Padding 24 Set PlaybackSpeed 1 -# Hidden: set up PATH and cd +# Hidden: move to the repository root (run vhs from inside the checkout, +# with Node >= 22 on PATH) Hide -Type `export PATH="/Users/darren/.nvm/versions/node/v22.17.0/bin:$PATH"` -Enter -Type "cd /Users/darren/WebstormProjects/satgate && clear" +Type `cd "$(git rev-parse --show-toplevel)" && clear` Enter Sleep 1s Show diff --git a/deploy/production.sh b/deploy/production.sh index 5c07111..464a16d 100755 --- a/deploy/production.sh +++ b/deploy/production.sh @@ -187,6 +187,10 @@ trap 'rm -f "$ENV_FILE"' EXIT printf 'SATGATE_DB_PATH=./data/satgate.db\n' printf 'SATGATE_REALM=%s\n' "$REALM" printf 'TUNNEL=false\n' + # Caddy on loopback is the only proxy satgate talks to. It sets the real + # client address from Cf-Connecting-Ip, so trust forwarded headers from it + # alone; without this every visitor shares one free-tier bucket. + printf 'TRUSTED_PROXIES=127.0.0.1,::1\n' printf 'ANNOUNCE=true\n' printf 'ANNOUNCE_KEY=%s\n' "$ANNOUNCE_KEY" printf 'ANNOUNCE_RELAYS=%s\n' "$ANNOUNCE_RELAYS" diff --git a/examples/basic.sh b/examples/basic.sh index e510de8..0c60d98 100755 --- a/examples/basic.sh +++ b/examples/basic.sh @@ -3,6 +3,8 @@ # # Prerequisites: # - Ollama running on localhost:11434 +# - phoenixd running on localhost:9740, with its HTTP password exported as +# LIGHTNING_KEY (or swap --lightning for lnbits, lnd, cln or nwc) # - Node.js >= 22 # # Usage: @@ -14,6 +16,8 @@ set -euo pipefail echo "Starting satgate in front of Ollama..." echo "" -# Start satgate with sensible defaults. -# This auto-detects models and begins accepting payments. -npx satgate --upstream http://localhost:11434 +# Auto-detects models and charges per token over Lightning. +# Without --lightning, satgate runs in open mode: no payment, no auth, +# local only. +: "${LIGHTNING_KEY:?Set LIGHTNING_KEY to your phoenixd HTTP password}" +npx satgate --upstream http://localhost:11434 --lightning phoenixd diff --git a/llms.txt b/llms.txt index 2648a2f..d7e892f 100644 --- a/llms.txt +++ b/llms.txt @@ -1,8 +1,8 @@ # satgate -> satgate is a pay-per-token inference proxy for any OpenAI-compatible endpoint — Ollama, vLLM, llama.cpp, or any API that speaks the OpenAI format. Clients pay per token using Lightning, Cashu, or NWC (Nostr Wallet Connect); no accounts, no API keys, instant settlement. Stablecoin payments via x402 are coming. Powered by toll-booth under the hood. +> satgate is a pay-per-token (or pay-per-request) inference proxy for OpenAI-compatible endpoints: Ollama, vLLM, llama.cpp, or a hosted API (set UPSTREAM_API_KEY for one that needs a key). Clients pay over Lightning, Cashu ecash, LNURLcash (LUD-25) bearer notes or x402 stablecoins; no accounts, no API keys. Payment handling comes from toll-booth. -One command turns a local inference server into a billable API endpoint. satgate auto-detects your models, issues L402 payment challenges, proxies requests once paid, counts actual tokens from the response, and reconciles any overpayment back to the client's credit balance. +One command turns a local inference server into a billable API endpoint. satgate auto-detects your models, issues HTTP 402 payment challenges, proxies requests once paid, and bills from the token counts the upstream reports, returning any unused hold to the client's credit balance. ## Live Demo @@ -15,29 +15,38 @@ A public instance is running at https://satgate.trotters.dev — open it in a br ## Getting Started ```bash -npx satgate --upstream http://localhost:11434 +LIGHTNING_KEY= npx satgate --upstream http://localhost:11434 --lightning phoenixd ``` -That's all that's needed. satgate queries `/v1/models` on startup, begins accepting payments, and proxies inference traffic to your backend. For production, create `satgate.yaml`: +satgate queries `/v1/models` on startup, charges over Lightning, and proxies paid inference traffic to your backend. Without `--lightning` (or Cashu mints, or an allowlist) it runs in open mode, with no payment or authentication, and stays local unless `--tunnel` is passed. + +With no pricing configured, each request costs a flat 1 sat. Set `--token-price`, `--model-price` or a `pricing:` block for per-token billing, or `--price` for a different flat price. For production, create `satgate.yaml`: ```yaml upstream: http://localhost:11434 port: 3000 -storage: sqlite +lightning: phoenixd # or lnbits, lnd, cln, nwc; key in LIGHTNING_KEY +storage: sqlite # the default when a payment rail is configured dbPath: ./satgate.db pricing: - default: 1 # sats per 1k tokens for unlisted models + default: 1 # sats per 1k tokens for unlisted models models: llama3: 1 deepseek-r1: 5 mixtral-8x22b: 3 +maxTokens: 2048 # cap on completion tokens per request + freeTier: - creditsPerDay: 250 + creditsPerDay: 250 # free sats of credit per client IP per day capacity: - maxConcurrent: 4 + maxConcurrent: 4 # default 8; 0 = unlimited + +trustProxy: true # only behind a reverse proxy +trustedProxies: + - 127.0.0.1 tiers: - amountSats: 1000 @@ -46,36 +55,46 @@ tiers: - amountSats: 5000 creditSats: 5500 label: "5,000 sats (10% bonus)" - - amountSats: 10000 - creditSats: 11100 - label: "10,000 sats (11% bonus)" ``` ## Key Concepts -- **Model pricing** — per-model rates in sats per 1,000 tokens. Unlisted models fall back to the `default` rate. Resolution order: exact match → case-insensitive match → Ollama tag stripping (`llama3:latest` → `llama3`) → default. Token costs always round up so the operator is never short-changed. -- **Token counting** — three priority sources. Priority 1: usage field from a buffered (non-streaming) JSON response. Priority 2: usage field from the final SSE chunk when `stream_options: { include_usage: true }` is set. Priority 3: content chunk count (1 chunk ≈ 1 token) as a fallback for streams that omit usage data. -- **Capacity management** — `maxConcurrent` limits how many inference requests run simultaneously. Requests that arrive when all slots are occupied receive a 503. Set to `0` to disable the limit. -- **Free tier** — optional per-IP daily allowance. Requests within the daily limit pass through without any payment challenge. Resets at midnight UTC. -- **Payment tiers with volume discounts** — clients choose a tier amount when funding their balance. Each tier can grant bonus credit (e.g. pay 5,000 sats, receive 5,500 in credit), incentivising larger upfront payments. -- **Cost reconciliation** — when a request arrives, an estimated sat amount is deducted from the client's balance upfront (based on model price and a configurable estimate). After the response completes and tokens are counted, the actual cost is reconciled. Any overpayment is credited back immediately. +- **Model pricing**: per-model rates in sats per 1,000 tokens. Unlisted models fall back to the `default` rate. Resolution order: exact match, case-insensitive match (a missing tag counts as `:latest`), Ollama tag stripping (`llama3:8b` to `llama3`), then default. Requests naming a model that is neither auto-detected nor priced are refused with a 400, so an alias cannot fall through to the default price. +- **Flat pricing**: `--price ` (or `price:` in the config file) charges a fixed amount per request instead. A price of 0 serves inference free. +- **Token counting**: billing uses the prompt and completion token counts the upstream reports, from the JSON body or, for streams, the final usage event (satgate always sets `stream_options.include_usage`). Completion tokens include any reasoning tokens. Only if the upstream reports no usage does satgate estimate from generated chunks, with a floor of one token per four bytes. +- **Holds and reconciliation**: before calling the upstream, satgate holds the most the request could cost: every byte of the body as a prompt token plus `max_tokens` for each choice (`n`/`best_of`, at most 8). `max_tokens` is clamped to `maxTokens` (default 2048) and set when absent. A credit balance that cannot cover the hold gets a 402. Afterwards the charge is settled to the reported usage, rounded up to the next sat, and the rest returns to the balance. Each request settles its own hold, so concurrent requests on one credential are billed correctly. +- **Per-request payments**: an IETF Payment charge pays exactly `estimatedCostSats` for one request and cannot be topped up or refunded, so `max_tokens` is shrunk to fit it and a larger prompt is refused. The same applies to the free tier and IETF Payment sessions. +- **Capacity management**: `maxConcurrent` limits simultaneous inference requests (default 8). Requests beyond it get a 503. Set to `0` to disable the limit. +- **Free tier**: an optional daily allowance of credit, in sats, per client IP. Each free request uses `estimatedCostSats` of it (or the flat price). Resets at midnight UTC. +- **Client IPs**: without `trustProxy`, the TCP peer address identifies a client. Behind a reverse proxy set `trustProxy` (and `trustedProxies`) so `X-Forwarded-For` is read instead; otherwise every client looks like the proxy. +- **Invoice limits**: each client IP may hold 20 unpaid invoices (`maxPendingInvoicesPerIp`) before further challenges get a 429. +- **Payment tiers with volume discounts**: clients choose a tier amount when buying credit. Each tier can grant bonus credit (e.g. pay 5,000 sats, receive 5,500 in credit). + +## Payment Rails + +- **Lightning**: `--lightning phoenixd | lnbits | lnd | cln | nwc`. For nwc, `LIGHTNING_KEY` names a file holding the `nostr+walletconnect://` URI. Lightning enables L402 (credit: pay once, spend the balance over many requests) and IETF Payment (per-request charge, draft-ryan-httpauth-payment-01) credentials. +- **Cashu ecash**: `--cashu-mints `; clients send an `X-Cashu` token. With Lightning configured, received ecash is melted to your node. +- **LNURLcash (LUD-25) bearer notes**: `--lnurlcash-mints `; clients send the note URL in `X-LNURLcash`. With Lightning configured, notes are melted to your node. +- **x402 stablecoins**: set `X402_RECEIVER`, `X402_NETWORK` and `X402_FACILITATOR_URL` (and `DEFAULT_PRICE_USD`); clients send a `PAYMENT-SIGNATURE` header. ## Supported Inference Endpoints -These endpoints require a valid L402 credential (pay once, use until credit runs out): +These endpoints require payment (or an allowlisted identity in allowlist mode): + +- `POST /v1/chat/completions`: chat completions, streaming and buffered +- `POST /v1/completions`: legacy text completions +- `POST /v1/embeddings`: embedding generation -- `POST /v1/chat/completions` — chat completions, streaming and buffered -- `POST /v1/completions` — legacy text completions -- `POST /v1/embeddings` — embedding generation +`HEAD` on any of them is a free price probe (`X-L402-Price-Sats`). ## Discovery Endpoints These endpoints require no authentication and are designed for machine consumption: -- `GET /.well-known/l402` — machine-readable service descriptor: pricing per model, available tiers, payment methods, and model list -- `GET /llms.txt` — this file, served dynamically with live pricing and model data -- `GET /openapi.json` — OpenAPI 3.1 specification for client code generation -- `GET /v1/models` — standard OpenAI model listing, proxied transparently from upstream +- `GET /.well-known/l402`: pricing per model, tiers, payment methods and model list +- `GET /llms.txt`: a description of this instance, generated from its live configuration +- `GET /openapi.json`: OpenAPI 3.1 specification for client code generation +- `GET /v1/models`: standard OpenAI model listing, proxied from upstream ## Configuration @@ -85,46 +104,66 @@ Configuration precedence: **CLI flags > environment variables > config file > de | Flag | Description | |------|-------------| -| `--upstream ` | Upstream inference URL (required) | +| `--upstream ` | Upstream inference URL (default: auto-detect Ollama on :11434) | +| `--upstream-key-file ` | File holding a bearer key for the upstream | +| `--lightning ` | `phoenixd`, `lnbits`, `lnd`, `cln` or `nwc` | +| `--cashu-mints ` | Accepted Cashu mints | +| `--lnurlcash-mints ` | Accepted LNURLcash mint hosts | +| `--auth ` | `open`, `lightning`, `cashu` or `allowlist` (inferred when omitted) | | `--port ` | Port to listen on (default: 3000) | -| `--config ` | Path to YAML config file | -| `--price ` | Default sats per 1k tokens (default: 1) | -| `--max-concurrent ` | Max concurrent requests (default: 0 = unlimited) | -| `--storage ` | `memory` or `sqlite` (default: memory) | -| `--free-tier ` | Free requests per IP per day (default: 0) | -| `--root-key ` | Root key for macaroon signing (auto-generated if omitted) | +| `--config ` | Path to a JSON or YAML config file | +| `--price ` | Flat sats per request (flat pricing) | +| `--token-price ` | Sats per 1k tokens (per-token pricing) | +| `--model-price ` | Per-model token price (repeatable) | +| `--max-tokens ` | Cap on completion tokens per request (default: 2048) | +| `--max-concurrent ` | Max concurrent requests (default: 8; 0 = unlimited) | +| `--max-pending-invoices ` | Unpaid invoices per client IP (default: 20; 0 = unlimited) | +| `--storage ` | `memory` or `sqlite` (default: sqlite with a payment rail, else memory) | +| `--free-tier ` | Free credit, in sats, per client IP per day (default: 0) | +| `--trust-proxy` | Read client IPs from `X-Forwarded-For` / `X-Real-IP` | +| `--trusted-proxies ` | Proxy IPs/IPv4 CIDRs to skip in `X-Forwarded-For` | +| `--tunnel` / `--no-tunnel` | Force the Cloudflare quick tunnel on or off | +| `--root-key ` | Root key for macaroon signing (prefer `ROOT_KEY`) | ### Environment variables | Variable | Description | |----------|-------------| | `UPSTREAM_URL` | Upstream inference URL | -| `ROOT_KEY` | Root key for macaroon signing | +| `UPSTREAM_API_KEY` | Bearer key for the upstream | +| `LIGHTNING_BACKEND`, `LIGHTNING_URL`, `LIGHTNING_KEY` | Lightning backend settings | +| `ROOT_KEY` | Root key for macaroon signing; otherwise generated and kept in `satgate.root-key` beside the database | | `PORT` | Port to listen on | | `STORAGE` | `memory` or `sqlite` | | `DEFAULT_PRICE` | Default sats per 1k tokens | -| `FREE_TIER_CREDITS` | Free credits (sats) per IP per day | +| `SATGATE_TOKEN_PRICE` | Sats per 1k tokens (per-token pricing) | +| `SATGATE_MAX_TOKENS` | Cap on completion tokens per request | +| `SATGATE_ESTIMATED_COST` | Per-request hold, in sats | +| `FREE_TIER_CREDITS` | Free credit, in sats, per client IP per day | | `MAX_CONCURRENT` | Max concurrent inference requests | +| `TRUST_PROXY`, `TRUSTED_PROXIES` | Reverse proxy settings | +| `SATGATE_MAX_PENDING_INVOICES` | Unpaid invoices per client IP | +| `CASHU_MINTS`, `LNURLCASH_MINTS` | Accepted mints | -### YAML config file +### Config file -The config file (default: `satgate.yaml` in the working directory) accepts all of the above plus: +The config file (default: `satgate.yaml` or `satgate.json` in the working directory) accepts the settings above plus: -- `dbPath` — path to the SQLite database file -- `tiers` — array of `{ amountSats, creditSats, label }` objects for volume discount tiers -- `trustProxy` — set `true` when running behind a reverse proxy (uses `X-Forwarded-For` for free tier tracking) -- `estimatedCostSats` — sat amount to hold per request before token count is known (default: `max(defaultPrice * 10, 10)`) -- `maxBodySize` — maximum request body size in bytes (default: 10 MiB) +- `dbPath`: path to the SQLite database file (default: `./satgate.db`) +- `tiers`: array of `{ amountSats, creditSats, label }` objects for volume discount tiers +- `estimatedCostSats`: sats held per request before the token count is known. Default: `ceil((4096 + maxTokens) * dearestPrice / 1000)`, enough for a 4 KiB request plus a full `max_tokens` completion at the dearest configured per-1k price +- `maxBodySize`: maximum request body size in bytes (default: 10 MiB) +- `upstreamKeyFile`: file holding a bearer key for the upstream ## Ecosystem -satgate is built on top of **toll-booth** — L402 payment middleware that handles the full invoice → credential → credit flow. toll-booth is payment-rail agnostic and supports Lightning (Phoenixd, LND, Core Lightning, LNbits, Alby), Cashu ecash, and NWC. satgate adds the inference-specific layer: model detection, token counting, cost reconciliation, and capacity management. +satgate is built on **toll-booth**, HTTP 402 payment middleware that handles the invoice, credential and credit flow. It supports Lightning (phoenixd, LND, Core Lightning, LNbits and NWC wallets), Cashu ecash, LNURLcash notes and x402. satgate adds the inference-specific layer: model detection, token counting, holds and reconciliation, and capacity management. -The natural client counterpart is **402-mcp** — an MCP server that lets AI agents discover L402 APIs (via `/.well-known/l402`), pay invoices from a connected wallet, and consume the API — all autonomously, with no human in the loop. +The natural client counterpart is **402-mcp**, an MCP server that lets AI agents discover 402 APIs (via `/.well-known/l402`), pay from a connected wallet and consume the API with no human in the loop. ``` AI Agent -> 402-mcp -> satgate -> Your inference backend ``` -- toll-booth: https://github.com/TheCryptoDonkey/toll-booth -- 402-mcp: https://github.com/TheCryptoDonkey/402-mcp +- toll-booth: https://github.com/forgesworn/toll-booth +- 402-mcp: https://github.com/forgesworn/402-mcp diff --git a/package.json b/package.json index e32ff5d..c8714f9 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ } }, "scripts": { - "build": "tsc", + "build": "tsc && node scripts/copy-page.mjs", "test": "vitest run", "test:release": "npm run check:publish-dependencies && npm test", "check:publish-dependencies": "node scripts/check-publish-dependencies.mjs", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml deleted file mode 100644 index ec89070..0000000 --- a/pnpm-lock.yaml +++ /dev/null @@ -1,2806 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - 402-announce: - specifier: ^2.1.1 - version: 2.1.1(typescript@6.0.3) - '@forgesworn/toll-booth': - specifier: file:../toll-booth - version: file:../toll-booth(express@5.2.1)(hono@4.13.2)(typescript@6.0.3) - '@hono/node-server': - specifier: ^2.0.12 - version: 2.1.0(hono@4.13.2) - '@noble/curves': - specifier: ^2.0.1 - version: 2.0.1 - '@scure/base': - specifier: ^2.0.0 - version: 2.0.0 - hono: - specifier: ^4.13.1 - version: 4.13.2 - js-yaml: - specifier: ^4.3.1 - version: 4.3.1 - devDependencies: - 402-mcp: - specifier: file:../402-mcp - version: file:../402-mcp(typescript@6.0.3) - '@types/js-yaml': - specifier: ^4.0.9 - version: 4.0.9 - '@types/node': - specifier: ^25.5.0 - version: 25.5.0 - tsx: - specifier: ^4.23.10 - version: 4.23.12 - typescript: - specifier: ^6.0.2 - version: 6.0.3 - vitest: - specifier: ^4.1.0 - version: 4.1.10(@types/node@25.5.0)(vite@7.3.1(@types/node@25.5.0)(tsx@4.23.12)) - -packages: - - 402-announce@2.1.1: - resolution: {integrity: sha512-V5GbVBLP58/qDm8TgjQ8f6jFaf+4WA10nHKN39r4rvmmTALnFEu1iNNRGWNPSCD50dtkmP4GqMe7w4QrPpe7LA==} - engines: {node: '>=18'} - - 402-mcp@file:../402-mcp: - resolution: {directory: ../402-mcp, type: directory} - engines: {node: '>=22'} - hasBin: true - - '@cashu/cashu-ts@3.7.1': - resolution: {integrity: sha512-EdUdjR6D0eY1YT6irNh/d5SBRc/ug4WNpnlLBXbRI9iOb8UNnQeGfk9CJQWL9ElQMjQ/4g582SehKXzEAZ2vnw==} - engines: {node: '>=22.4.0'} - - '@cashu/cashu-ts@4.8.0': - resolution: {integrity: sha512-X/w8pOJLEW0rpueUA88ZdB5H/UV1sgqF5O0BSt8eXjIkyU+Xx0CXN81tVo9gfqmNlzh+VI97wWzNuzpiCNTtPw==} - engines: {node: '>=22.4.0'} - - '@esbuild/aix-ppc64@0.27.4': - resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/aix-ppc64@0.28.2': - resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/android-arm64@0.27.4': - resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm64@0.28.2': - resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm@0.27.4': - resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-arm@0.28.2': - resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-x64@0.27.4': - resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/android-x64@0.28.2': - resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.27.4': - resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-arm64@0.28.2': - resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-x64@0.27.4': - resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/darwin-x64@0.28.2': - resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/freebsd-arm64@0.27.4': - resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-arm64@0.28.2': - resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.27.4': - resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.28.2': - resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/linux-arm64@0.27.4': - resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm64@0.28.2': - resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.27.4': - resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-arm@0.28.2': - resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-ia32@0.27.4': - resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-ia32@0.28.2': - resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-loong64@0.27.4': - resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-loong64@0.28.2': - resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-mips64el@0.27.4': - resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-mips64el@0.28.2': - resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-ppc64@0.27.4': - resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-ppc64@0.28.2': - resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.27.4': - resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-riscv64@0.28.2': - resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.27.4': - resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-s390x@0.28.2': - resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-x64@0.27.4': - resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/linux-x64@0.28.2': - resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-arm64@0.27.4': - resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-arm64@0.28.2': - resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.27.4': - resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.28.2': - resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-arm64@0.27.4': - resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-arm64@0.28.2': - resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.27.4': - resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.28.2': - resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openharmony-arm64@0.27.4': - resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/openharmony-arm64@0.28.2': - resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/sunos-x64@0.27.4': - resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/sunos-x64@0.28.2': - resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/win32-arm64@0.27.4': - resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-arm64@0.28.2': - resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-ia32@0.27.4': - resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-ia32@0.28.2': - resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-x64@0.27.4': - resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - '@esbuild/win32-x64@0.28.2': - resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - '@forgesworn/nwc-kit@file:../nwc-kit': - resolution: {directory: ../nwc-kit, type: directory} - engines: {node: '>=22'} - - '@forgesworn/toll-booth@file:../toll-booth': - resolution: {directory: ../toll-booth, type: directory} - engines: {node: '>=22'} - hasBin: true - peerDependencies: - express: ^4.0.0 || ^5.0.0 - hono: ^4.0.0 - peerDependenciesMeta: - express: - optional: true - hono: - optional: true - - '@hono/node-server@1.19.11': - resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==} - engines: {node: '>=18.14.1'} - peerDependencies: - hono: ^4 - - '@hono/node-server@2.1.0': - resolution: {integrity: sha512-XovyyCCnBzW+zKu+z/zq8hwNs4KOR5rEMAOxo2f40Q5xoOI37IMm6MIg2COOUtUApo0i6850MTBKH2u4QLGIqg==} - engines: {node: '>=20'} - peerDependencies: - hono: ^4 - - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - - '@modelcontextprotocol/ext-apps@1.7.5': - resolution: {integrity: sha512-TjPH2S2y5UEGKhmI6+XGFuqfqOV4ppe1x6DA3txnUaEWkgtA4G5vo14jGKFZmegdkZ1H4QMLyujLvoU1BEdnAg==} - engines: {node: '>=20'} - peerDependencies: - '@modelcontextprotocol/sdk': ^1.29.0 - react: ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 - zod: ^3.25.0 || ^4.0.0 - peerDependenciesMeta: - react: - optional: true - react-dom: - optional: true - - '@modelcontextprotocol/sdk@1.27.1': - resolution: {integrity: sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==} - engines: {node: '>=18'} - peerDependencies: - '@cfworker/json-schema': ^4.1.1 - zod: ^3.25 || ^4.0 - peerDependenciesMeta: - '@cfworker/json-schema': - optional: true - - '@noble/ciphers@2.1.1': - resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==} - engines: {node: '>= 20.19.0'} - - '@noble/curves@2.0.1': - resolution: {integrity: sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==} - engines: {node: '>= 20.19.0'} - - '@noble/curves@2.3.0': - resolution: {integrity: sha512-v7cY+4oWYPQszRj6ZFGzTVL7uP2TaLo1xMhWHzYC5wj0ZhOXQ5x+sBre8rF3hi8cAoi0bh1qXoovoOkdFtvqEg==} - engines: {node: '>= 20.19.0'} - - '@noble/hashes@1.8.0': - resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} - engines: {node: ^14.21.3 || >=16} - - '@noble/hashes@2.0.1': - resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} - engines: {node: '>= 20.19.0'} - - '@noble/hashes@2.3.0': - resolution: {integrity: sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==} - engines: {node: '>= 20.19.0'} - - '@rollup/rollup-android-arm-eabi@4.59.0': - resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} - cpu: [arm] - os: [android] - - '@rollup/rollup-android-arm64@4.59.0': - resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==} - cpu: [arm64] - os: [android] - - '@rollup/rollup-darwin-arm64@4.59.0': - resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==} - cpu: [arm64] - os: [darwin] - - '@rollup/rollup-darwin-x64@4.59.0': - resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==} - cpu: [x64] - os: [darwin] - - '@rollup/rollup-freebsd-arm64@4.59.0': - resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==} - cpu: [arm64] - os: [freebsd] - - '@rollup/rollup-freebsd-x64@4.59.0': - resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==} - cpu: [x64] - os: [freebsd] - - '@rollup/rollup-linux-arm-gnueabihf@4.59.0': - resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} - cpu: [arm] - os: [linux] - - '@rollup/rollup-linux-arm-musleabihf@4.59.0': - resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} - cpu: [arm] - os: [linux] - - '@rollup/rollup-linux-arm64-gnu@4.59.0': - resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} - cpu: [arm64] - os: [linux] - - '@rollup/rollup-linux-arm64-musl@4.59.0': - resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} - cpu: [arm64] - os: [linux] - - '@rollup/rollup-linux-loong64-gnu@4.59.0': - resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} - cpu: [loong64] - os: [linux] - - '@rollup/rollup-linux-loong64-musl@4.59.0': - resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} - cpu: [loong64] - os: [linux] - - '@rollup/rollup-linux-ppc64-gnu@4.59.0': - resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} - cpu: [ppc64] - os: [linux] - - '@rollup/rollup-linux-ppc64-musl@4.59.0': - resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} - cpu: [ppc64] - os: [linux] - - '@rollup/rollup-linux-riscv64-gnu@4.59.0': - resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} - cpu: [riscv64] - os: [linux] - - '@rollup/rollup-linux-riscv64-musl@4.59.0': - resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} - cpu: [riscv64] - os: [linux] - - '@rollup/rollup-linux-s390x-gnu@4.59.0': - resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} - cpu: [s390x] - os: [linux] - - '@rollup/rollup-linux-x64-gnu@4.59.0': - resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} - cpu: [x64] - os: [linux] - - '@rollup/rollup-linux-x64-musl@4.59.0': - resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} - cpu: [x64] - os: [linux] - - '@rollup/rollup-openbsd-x64@4.59.0': - resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} - cpu: [x64] - os: [openbsd] - - '@rollup/rollup-openharmony-arm64@4.59.0': - resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==} - cpu: [arm64] - os: [openharmony] - - '@rollup/rollup-win32-arm64-msvc@4.59.0': - resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==} - cpu: [arm64] - os: [win32] - - '@rollup/rollup-win32-ia32-msvc@4.59.0': - resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==} - cpu: [ia32] - os: [win32] - - '@rollup/rollup-win32-x64-gnu@4.59.0': - resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==} - cpu: [x64] - os: [win32] - - '@rollup/rollup-win32-x64-msvc@4.59.0': - resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==} - cpu: [x64] - os: [win32] - - '@scure/base@1.1.1': - resolution: {integrity: sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==} - - '@scure/base@2.0.0': - resolution: {integrity: sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==} - - '@scure/base@2.3.0': - resolution: {integrity: sha512-NsG6Y03tY6R5BUis4FdVtHVkur0U6FOzskgs9ZXNl78CUc9fkZ78HmENUle1nSOkCasDmbubmWD9qwB7mm4PZA==} - - '@scure/bip32@2.0.1': - resolution: {integrity: sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==} - - '@scure/bip32@2.3.0': - resolution: {integrity: sha512-mMPjNcXxJsvNveIgRIXrnwd4omu0wdXo4JowAUZO7/82+/bpAwVltclx6MG6nv2M3dA6IbfVv4xwWxaREm3OcA==} - - '@scure/bip39@2.0.1': - resolution: {integrity: sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==} - - '@standard-schema/spec@1.1.0': - resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - - '@types/chai@5.2.3': - resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} - - '@types/deep-eql@4.0.2': - resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} - - '@types/estree@1.0.8': - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - - '@types/js-yaml@4.0.9': - resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} - - '@types/node@25.5.0': - resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} - - '@vitest/expect@4.1.10': - resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} - - '@vitest/mocker@4.1.10': - resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} - peerDependencies: - msw: ^2.4.9 - vite: ^6.0.0 || ^7.0.0 || ^8.0.0 - peerDependenciesMeta: - msw: - optional: true - vite: - optional: true - - '@vitest/pretty-format@4.1.10': - resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} - - '@vitest/runner@4.1.10': - resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} - - '@vitest/snapshot@4.1.10': - resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} - - '@vitest/spy@4.1.10': - resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} - - '@vitest/utils@4.1.10': - resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} - - accepts@2.0.0: - resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} - engines: {node: '>= 0.6'} - - ajv-formats@3.0.1: - resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} - peerDependencies: - ajv: ^8.0.0 - peerDependenciesMeta: - ajv: - optional: true - - ajv@8.18.0: - resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} - - ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} - - ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} - - argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - - assertion-error@2.0.1: - resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} - engines: {node: '>=12'} - - base64-js@1.5.1: - resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - - better-sqlite3@13.0.3: - resolution: {integrity: sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==} - engines: {node: '>=22'} - - bl@4.1.0: - resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} - - body-parser@2.2.2: - resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} - engines: {node: '>=18'} - - buffer@5.7.1: - resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} - - bytes@3.1.2: - resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} - engines: {node: '>= 0.8'} - - call-bind-apply-helpers@1.0.2: - resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} - engines: {node: '>= 0.4'} - - call-bound@1.0.4: - resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} - engines: {node: '>= 0.4'} - - camelcase@5.3.1: - resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} - engines: {node: '>=6'} - - chai@6.2.2: - resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} - engines: {node: '>=18'} - - chownr@1.1.4: - resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} - - cliui@6.0.0: - resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} - - color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - - color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - - content-disposition@1.0.1: - resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} - engines: {node: '>=18'} - - content-type@1.0.5: - resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} - engines: {node: '>= 0.6'} - - convert-source-map@2.0.0: - resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - - cookie-signature@1.2.2: - resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} - engines: {node: '>=6.6.0'} - - cookie@0.7.2: - resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} - engines: {node: '>= 0.6'} - - cors@2.8.6: - resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} - engines: {node: '>= 0.10'} - - cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} - - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - decamelize@1.2.0: - resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} - engines: {node: '>=0.10.0'} - - decompress-response@6.0.0: - resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} - engines: {node: '>=10'} - - deep-extend@0.6.0: - resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} - engines: {node: '>=4.0.0'} - - depd@2.0.0: - resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} - engines: {node: '>= 0.8'} - - detect-libc@2.1.2: - resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} - engines: {node: '>=8'} - - dijkstrajs@1.0.3: - resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} - - dunder-proto@1.0.1: - resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} - engines: {node: '>= 0.4'} - - ee-first@1.1.1: - resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - - emoji-regex@8.0.0: - resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - - encodeurl@2.0.0: - resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} - engines: {node: '>= 0.8'} - - end-of-stream@1.4.5: - resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} - - es-define-property@1.0.1: - resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} - engines: {node: '>= 0.4'} - - es-errors@1.3.0: - resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} - engines: {node: '>= 0.4'} - - es-module-lexer@2.3.1: - resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} - - es-object-atoms@1.1.1: - resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} - engines: {node: '>= 0.4'} - - esbuild@0.27.4: - resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} - engines: {node: '>=18'} - hasBin: true - - esbuild@0.28.2: - resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} - engines: {node: '>=18'} - hasBin: true - - escape-html@1.0.3: - resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} - - estree-walker@3.0.3: - resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} - - etag@1.8.1: - resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} - engines: {node: '>= 0.6'} - - eventsource-parser@3.0.6: - resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} - engines: {node: '>=18.0.0'} - - eventsource@3.0.7: - resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} - engines: {node: '>=18.0.0'} - - expand-template@2.0.3: - resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} - engines: {node: '>=6'} - - expect-type@1.3.0: - resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} - engines: {node: '>=12.0.0'} - - express-rate-limit@8.3.1: - resolution: {integrity: sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==} - engines: {node: '>= 16'} - peerDependencies: - express: '>= 4.11' - - express@5.2.1: - resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} - engines: {node: '>= 18'} - - farrier-kit@1.1.1: - resolution: {integrity: sha512-j02M+spI2AGLI5m9Fq+Q/GimRcjzDue7ATC+IoontDigKkbXIkjEfLbD5N/HFVFuQG43U18iJwowBbA1EY1WMA==} - engines: {node: '>=18.0.0'} - - fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - - fast-uri@3.1.0: - resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} - - fdir@6.5.0: - resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} - engines: {node: '>=12.0.0'} - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - - finalhandler@2.1.1: - resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} - engines: {node: '>= 18.0.0'} - - find-up@4.1.0: - resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} - engines: {node: '>=8'} - - forwarded@0.2.0: - resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} - engines: {node: '>= 0.6'} - - fresh@2.0.0: - resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} - engines: {node: '>= 0.8'} - - fs-constants@1.0.0: - resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} - - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - function-bind@1.1.2: - resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - - get-caller-file@2.0.5: - resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} - engines: {node: 6.* || 8.* || >= 10.*} - - get-intrinsic@1.3.0: - resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} - engines: {node: '>= 0.4'} - - get-proto@1.0.1: - resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} - engines: {node: '>= 0.4'} - - github-from-package@0.0.0: - resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} - - gopd@1.2.0: - resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} - engines: {node: '>= 0.4'} - - has-symbols@1.1.0: - resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} - engines: {node: '>= 0.4'} - - hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} - engines: {node: '>= 0.4'} - - hono@4.13.2: - resolution: {integrity: sha512-JydRilDRkYBQMt9qR9U92mXxmbGqsqSn/IKOrh4e7/gEbn+0zSr8igTu0obwJoNGN4sez28DIql7FBHWydoJpA==} - engines: {node: '>=16.9.0'} - - http-errors@2.0.1: - resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} - engines: {node: '>= 0.8'} - - iconv-lite@0.7.2: - resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} - engines: {node: '>=0.10.0'} - - ieee754@1.2.1: - resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - - inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - - ini@1.3.8: - resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} - - ip-address@10.1.0: - resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} - engines: {node: '>= 12'} - - ipaddr.js@1.9.1: - resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} - engines: {node: '>= 0.10'} - - is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} - engines: {node: '>=8'} - - is-promise@4.0.0: - resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} - - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - - jose@6.2.1: - resolution: {integrity: sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==} - - js-yaml@4.3.1: - resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} - hasBin: true - - json-schema-traverse@1.0.0: - resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} - - json-schema-typed@8.0.2: - resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} - - keytar@7.9.0: - resolution: {integrity: sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ==} - - light-bolt11-decoder@3.2.0: - resolution: {integrity: sha512-3QEofgiBOP4Ehs9BI+RkZdXZNtSys0nsJ6fyGeSiAGCBsMwHGUDS/JQlY/sTnWs91A2Nh0S9XXfA8Sy9g6QpuQ==} - - locate-path@5.0.0: - resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} - engines: {node: '>=8'} - - macaroon@3.0.4: - resolution: {integrity: sha512-Tja2jvupseKxltPZbu5RPSz2Pgh6peYA3O46YCTcYL8PI1VqtGwDqRhGfP8pows26xx9wTiygk+en62Bq+Y8JA==} - - magic-string@0.30.21: - resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - - math-intrinsics@1.1.0: - resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} - engines: {node: '>= 0.4'} - - media-typer@1.1.0: - resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} - engines: {node: '>= 0.8'} - - merge-descriptors@2.0.0: - resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} - engines: {node: '>=18'} - - mime-db@1.54.0: - resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} - engines: {node: '>= 0.6'} - - mime-types@3.0.2: - resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} - engines: {node: '>=18'} - - mimic-response@3.1.0: - resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} - engines: {node: '>=10'} - - minimist@1.2.8: - resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - - mkdirp-classic@0.5.3: - resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - nanoid@3.3.11: - resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - - napi-build-utils@2.0.0: - resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} - - negotiator@1.0.0: - resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} - engines: {node: '>= 0.6'} - - node-abi@3.89.0: - resolution: {integrity: sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==} - engines: {node: '>=10'} - - node-addon-api@4.3.0: - resolution: {integrity: sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==} - - node-addon-api@8.9.2: - resolution: {integrity: sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==} - engines: {node: ^18 || ^20 || >= 21} - - nostr-tools@2.23.3: - resolution: {integrity: sha512-AALyt9k8xPdF4UV2mlLJ2mgCn4kpTB0DZ8t2r6wjdUh6anfx2cTVBsHUlo9U0EY/cKC5wcNyiMAmRJV5OVEalA==} - peerDependencies: - typescript: '>=5.0.0' - peerDependenciesMeta: - typescript: - optional: true - - nostr-tools@2.24.1: - resolution: {integrity: sha512-KdrKjC74n/rr6J3eCSfZj8dcbZFvolHYe4S22SefNZ5YWbhHiB0KL/mmJjEZ0u6B9mZK0YcQtl+WQ46KzwapeQ==} - peerDependencies: - typescript: '>=5.0.0' - peerDependenciesMeta: - typescript: - optional: true - - nostr-wasm@0.1.0: - resolution: {integrity: sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==} - - object-assign@4.1.1: - resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} - engines: {node: '>=0.10.0'} - - object-inspect@1.13.4: - resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} - engines: {node: '>= 0.4'} - - obug@2.1.4: - resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} - engines: {node: '>=12.20.0'} - - on-finished@2.4.1: - resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} - engines: {node: '>= 0.8'} - - once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - - p-limit@2.3.0: - resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} - engines: {node: '>=6'} - - p-locate@4.1.0: - resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} - engines: {node: '>=8'} - - p-try@2.2.0: - resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} - engines: {node: '>=6'} - - parseurl@1.3.3: - resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} - engines: {node: '>= 0.8'} - - path-exists@4.0.0: - resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} - engines: {node: '>=8'} - - path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - - path-to-regexp@8.3.0: - resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} - - pathe@2.0.3: - resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - - picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} - engines: {node: '>=12'} - - pkce-challenge@5.0.1: - resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} - engines: {node: '>=16.20.0'} - - pngjs@5.0.0: - resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} - engines: {node: '>=10.13.0'} - - postcss@8.5.8: - resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} - engines: {node: ^10 || ^12 || >=14} - - prebuild-install@7.1.3: - resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} - engines: {node: '>=10'} - deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. - hasBin: true - - proxy-addr@2.0.7: - resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} - engines: {node: '>= 0.10'} - - pump@3.0.4: - resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} - - qrcode@1.5.4: - resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} - engines: {node: '>=10.13.0'} - hasBin: true - - qs@6.15.0: - resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} - engines: {node: '>=0.6'} - - range-parser@1.2.1: - resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} - engines: {node: '>= 0.6'} - - raw-body@3.0.2: - resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} - engines: {node: '>= 0.10'} - - rc@1.2.8: - resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} - hasBin: true - - readable-stream@3.6.2: - resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} - engines: {node: '>= 6'} - - require-directory@2.1.1: - resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} - engines: {node: '>=0.10.0'} - - require-from-string@2.0.2: - resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} - engines: {node: '>=0.10.0'} - - require-main-filename@2.0.0: - resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} - - rollup@4.59.0: - resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} - hasBin: true - - router@2.2.0: - resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} - engines: {node: '>= 18'} - - safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - - safer-buffer@2.1.2: - resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - - semver@7.7.4: - resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} - engines: {node: '>=10'} - hasBin: true - - send@1.2.1: - resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} - engines: {node: '>= 18'} - - serve-static@2.2.1: - resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} - engines: {node: '>= 18'} - - set-blocking@2.0.0: - resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} - - setprototypeof@1.2.0: - resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - - shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} - - shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - - side-channel-list@1.0.0: - resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} - engines: {node: '>= 0.4'} - - side-channel-map@1.0.1: - resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} - engines: {node: '>= 0.4'} - - side-channel-weakmap@1.0.2: - resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} - engines: {node: '>= 0.4'} - - side-channel@1.1.0: - resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} - engines: {node: '>= 0.4'} - - siginfo@2.0.0: - resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - - simple-concat@1.0.1: - resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} - - simple-get@4.0.1: - resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} - - sjcl@1.0.8: - resolution: {integrity: sha512-LzIjEQ0S0DpIgnxMEayM1rq9aGwGRG4OnZhCdjx7glTaJtf4zRfpg87ImfjSJjoW9vKpagd82McDOwbRT5kQKQ==} - - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} - engines: {node: '>=0.10.0'} - - stackback@0.0.2: - resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - - statuses@2.0.2: - resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} - engines: {node: '>= 0.8'} - - std-env@4.2.0: - resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} - - string-width@4.2.3: - resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} - engines: {node: '>=8'} - - string_decoder@1.3.0: - resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} - - strip-ansi@6.0.1: - resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} - engines: {node: '>=8'} - - strip-json-comments@2.0.1: - resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} - engines: {node: '>=0.10.0'} - - tar-fs@2.1.4: - resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} - - tar-stream@2.2.0: - resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} - engines: {node: '>=6'} - - tinybench@2.9.0: - resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - - tinyexec@1.3.0: - resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} - engines: {node: '>=18'} - - tinyglobby@0.2.15: - resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} - engines: {node: '>=12.0.0'} - - tinyrainbow@3.1.1: - resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} - engines: {node: '>=14.0.0'} - - toidentifier@1.0.1: - resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} - engines: {node: '>=0.6'} - - tsx@4.23.12: - resolution: {integrity: sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==} - engines: {node: '>=18.0.0'} - hasBin: true - - tunnel-agent@0.6.0: - resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} - - tweetnacl-util@0.15.1: - resolution: {integrity: sha512-RKJBIj8lySrShN4w6i/BonWp2Z/uxwC3h4y7xsRrpP59ZboCd0GpEVsOnMDYLMmKBpYhb5TgHzZXy7wTfYFBRw==} - - tweetnacl@1.0.3: - resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==} - - type-is@2.0.1: - resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} - engines: {node: '>= 0.6'} - - typescript@6.0.3: - resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} - engines: {node: '>=14.17'} - hasBin: true - - undici-types@7.18.2: - resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} - - unpipe@1.0.0: - resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} - engines: {node: '>= 0.8'} - - util-deprecate@1.0.2: - resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - - vary@1.1.2: - resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} - engines: {node: '>= 0.8'} - - vite@7.3.1: - resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - '@types/node': ^20.19.0 || >=22.12.0 - jiti: '>=1.21.0' - less: ^4.0.0 - lightningcss: ^1.21.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 - jiti: - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - - vitest@4.1.10: - resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} - engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} - hasBin: true - 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.10 - '@vitest/browser-preview': 4.1.10 - '@vitest/browser-webdriverio': 4.1.10 - '@vitest/coverage-istanbul': 4.1.10 - '@vitest/coverage-v8': 4.1.10 - '@vitest/ui': 4.1.10 - 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 - - which-module@2.0.1: - resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} - - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true - - why-is-node-running@2.3.0: - resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} - engines: {node: '>=8'} - hasBin: true - - wrap-ansi@6.2.0: - resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} - engines: {node: '>=8'} - - wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - - y18n@4.0.3: - resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} - - yargs-parser@18.1.3: - resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} - engines: {node: '>=6'} - - yargs@15.4.1: - resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} - engines: {node: '>=8'} - - zod-to-json-schema@3.25.1: - resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} - peerDependencies: - zod: ^3.25 || ^4 - - zod@4.3.6: - resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} - -snapshots: - - 402-announce@2.1.1(typescript@6.0.3): - dependencies: - nostr-tools: 2.24.1(typescript@6.0.3) - transitivePeerDependencies: - - typescript - - 402-mcp@file:../402-mcp(typescript@6.0.3): - dependencies: - '@cashu/cashu-ts': 3.7.1 - '@forgesworn/nwc-kit': file:../nwc-kit(typescript@6.0.3) - '@modelcontextprotocol/ext-apps': 1.7.5(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(zod@4.3.6) - '@modelcontextprotocol/sdk': 1.27.1(zod@4.3.6) - cors: 2.8.6 - express: 5.2.1 - farrier-kit: 1.1.1 - light-bolt11-decoder: 3.2.0 - nostr-tools: 2.23.3(typescript@6.0.3) - qrcode: 1.5.4 - zod: 4.3.6 - optionalDependencies: - keytar: 7.9.0 - transitivePeerDependencies: - - '@cfworker/json-schema' - - react - - react-dom - - supports-color - - typescript - - '@cashu/cashu-ts@3.7.1': - dependencies: - '@noble/curves': 2.3.0 - '@noble/hashes': 2.3.0 - '@scure/base': 2.3.0 - '@scure/bip32': 2.3.0 - - '@cashu/cashu-ts@4.8.0': - dependencies: - '@noble/curves': 2.3.0 - '@noble/hashes': 2.3.0 - '@scure/base': 2.3.0 - '@scure/bip32': 2.3.0 - - '@esbuild/aix-ppc64@0.27.4': - optional: true - - '@esbuild/aix-ppc64@0.28.2': - optional: true - - '@esbuild/android-arm64@0.27.4': - optional: true - - '@esbuild/android-arm64@0.28.2': - optional: true - - '@esbuild/android-arm@0.27.4': - optional: true - - '@esbuild/android-arm@0.28.2': - optional: true - - '@esbuild/android-x64@0.27.4': - optional: true - - '@esbuild/android-x64@0.28.2': - optional: true - - '@esbuild/darwin-arm64@0.27.4': - optional: true - - '@esbuild/darwin-arm64@0.28.2': - optional: true - - '@esbuild/darwin-x64@0.27.4': - optional: true - - '@esbuild/darwin-x64@0.28.2': - optional: true - - '@esbuild/freebsd-arm64@0.27.4': - optional: true - - '@esbuild/freebsd-arm64@0.28.2': - optional: true - - '@esbuild/freebsd-x64@0.27.4': - optional: true - - '@esbuild/freebsd-x64@0.28.2': - optional: true - - '@esbuild/linux-arm64@0.27.4': - optional: true - - '@esbuild/linux-arm64@0.28.2': - optional: true - - '@esbuild/linux-arm@0.27.4': - optional: true - - '@esbuild/linux-arm@0.28.2': - optional: true - - '@esbuild/linux-ia32@0.27.4': - optional: true - - '@esbuild/linux-ia32@0.28.2': - optional: true - - '@esbuild/linux-loong64@0.27.4': - optional: true - - '@esbuild/linux-loong64@0.28.2': - optional: true - - '@esbuild/linux-mips64el@0.27.4': - optional: true - - '@esbuild/linux-mips64el@0.28.2': - optional: true - - '@esbuild/linux-ppc64@0.27.4': - optional: true - - '@esbuild/linux-ppc64@0.28.2': - optional: true - - '@esbuild/linux-riscv64@0.27.4': - optional: true - - '@esbuild/linux-riscv64@0.28.2': - optional: true - - '@esbuild/linux-s390x@0.27.4': - optional: true - - '@esbuild/linux-s390x@0.28.2': - optional: true - - '@esbuild/linux-x64@0.27.4': - optional: true - - '@esbuild/linux-x64@0.28.2': - optional: true - - '@esbuild/netbsd-arm64@0.27.4': - optional: true - - '@esbuild/netbsd-arm64@0.28.2': - optional: true - - '@esbuild/netbsd-x64@0.27.4': - optional: true - - '@esbuild/netbsd-x64@0.28.2': - optional: true - - '@esbuild/openbsd-arm64@0.27.4': - optional: true - - '@esbuild/openbsd-arm64@0.28.2': - optional: true - - '@esbuild/openbsd-x64@0.27.4': - optional: true - - '@esbuild/openbsd-x64@0.28.2': - optional: true - - '@esbuild/openharmony-arm64@0.27.4': - optional: true - - '@esbuild/openharmony-arm64@0.28.2': - optional: true - - '@esbuild/sunos-x64@0.27.4': - optional: true - - '@esbuild/sunos-x64@0.28.2': - optional: true - - '@esbuild/win32-arm64@0.27.4': - optional: true - - '@esbuild/win32-arm64@0.28.2': - optional: true - - '@esbuild/win32-ia32@0.27.4': - optional: true - - '@esbuild/win32-ia32@0.28.2': - optional: true - - '@esbuild/win32-x64@0.27.4': - optional: true - - '@esbuild/win32-x64@0.28.2': - optional: true - - '@forgesworn/nwc-kit@file:../nwc-kit(typescript@6.0.3)': - dependencies: - nostr-tools: 2.24.1(typescript@6.0.3) - transitivePeerDependencies: - - typescript - - '@forgesworn/toll-booth@file:../toll-booth(express@5.2.1)(hono@4.13.2)(typescript@6.0.3)': - dependencies: - '@cashu/cashu-ts': 4.8.0 - '@forgesworn/nwc-kit': file:../nwc-kit(typescript@6.0.3) - better-sqlite3: 13.0.3 - farrier-kit: 1.1.1 - macaroon: 3.0.4 - qrcode: 1.5.4 - optionalDependencies: - express: 5.2.1 - hono: 4.13.2 - transitivePeerDependencies: - - typescript - - '@hono/node-server@1.19.11(hono@4.13.2)': - dependencies: - hono: 4.13.2 - - '@hono/node-server@2.1.0(hono@4.13.2)': - dependencies: - hono: 4.13.2 - - '@jridgewell/sourcemap-codec@1.5.5': {} - - '@modelcontextprotocol/ext-apps@1.7.5(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(zod@4.3.6)': - dependencies: - '@modelcontextprotocol/sdk': 1.27.1(zod@4.3.6) - '@standard-schema/spec': 1.1.0 - zod: 4.3.6 - - '@modelcontextprotocol/sdk@1.27.1(zod@4.3.6)': - dependencies: - '@hono/node-server': 1.19.11(hono@4.13.2) - ajv: 8.18.0 - ajv-formats: 3.0.1(ajv@8.18.0) - content-type: 1.0.5 - cors: 2.8.6 - cross-spawn: 7.0.6 - eventsource: 3.0.7 - eventsource-parser: 3.0.6 - express: 5.2.1 - express-rate-limit: 8.3.1(express@5.2.1) - hono: 4.13.2 - jose: 6.2.1 - json-schema-typed: 8.0.2 - pkce-challenge: 5.0.1 - raw-body: 3.0.2 - zod: 4.3.6 - zod-to-json-schema: 3.25.1(zod@4.3.6) - transitivePeerDependencies: - - supports-color - - '@noble/ciphers@2.1.1': {} - - '@noble/curves@2.0.1': - dependencies: - '@noble/hashes': 2.0.1 - - '@noble/curves@2.3.0': - dependencies: - '@noble/hashes': 2.3.0 - - '@noble/hashes@1.8.0': {} - - '@noble/hashes@2.0.1': {} - - '@noble/hashes@2.3.0': {} - - '@rollup/rollup-android-arm-eabi@4.59.0': - optional: true - - '@rollup/rollup-android-arm64@4.59.0': - optional: true - - '@rollup/rollup-darwin-arm64@4.59.0': - optional: true - - '@rollup/rollup-darwin-x64@4.59.0': - optional: true - - '@rollup/rollup-freebsd-arm64@4.59.0': - optional: true - - '@rollup/rollup-freebsd-x64@4.59.0': - optional: true - - '@rollup/rollup-linux-arm-gnueabihf@4.59.0': - optional: true - - '@rollup/rollup-linux-arm-musleabihf@4.59.0': - optional: true - - '@rollup/rollup-linux-arm64-gnu@4.59.0': - optional: true - - '@rollup/rollup-linux-arm64-musl@4.59.0': - optional: true - - '@rollup/rollup-linux-loong64-gnu@4.59.0': - optional: true - - '@rollup/rollup-linux-loong64-musl@4.59.0': - optional: true - - '@rollup/rollup-linux-ppc64-gnu@4.59.0': - optional: true - - '@rollup/rollup-linux-ppc64-musl@4.59.0': - optional: true - - '@rollup/rollup-linux-riscv64-gnu@4.59.0': - optional: true - - '@rollup/rollup-linux-riscv64-musl@4.59.0': - optional: true - - '@rollup/rollup-linux-s390x-gnu@4.59.0': - optional: true - - '@rollup/rollup-linux-x64-gnu@4.59.0': - optional: true - - '@rollup/rollup-linux-x64-musl@4.59.0': - optional: true - - '@rollup/rollup-openbsd-x64@4.59.0': - optional: true - - '@rollup/rollup-openharmony-arm64@4.59.0': - optional: true - - '@rollup/rollup-win32-arm64-msvc@4.59.0': - optional: true - - '@rollup/rollup-win32-ia32-msvc@4.59.0': - optional: true - - '@rollup/rollup-win32-x64-gnu@4.59.0': - optional: true - - '@rollup/rollup-win32-x64-msvc@4.59.0': - optional: true - - '@scure/base@1.1.1': {} - - '@scure/base@2.0.0': {} - - '@scure/base@2.3.0': {} - - '@scure/bip32@2.0.1': - dependencies: - '@noble/curves': 2.0.1 - '@noble/hashes': 2.0.1 - '@scure/base': 2.0.0 - - '@scure/bip32@2.3.0': - dependencies: - '@noble/curves': 2.3.0 - '@noble/hashes': 2.3.0 - '@scure/base': 2.3.0 - - '@scure/bip39@2.0.1': - dependencies: - '@noble/hashes': 2.0.1 - '@scure/base': 2.0.0 - - '@standard-schema/spec@1.1.0': {} - - '@types/chai@5.2.3': - dependencies: - '@types/deep-eql': 4.0.2 - assertion-error: 2.0.1 - - '@types/deep-eql@4.0.2': {} - - '@types/estree@1.0.8': {} - - '@types/js-yaml@4.0.9': {} - - '@types/node@25.5.0': - dependencies: - undici-types: 7.18.2 - - '@vitest/expect@4.1.10': - dependencies: - '@standard-schema/spec': 1.1.0 - '@types/chai': 5.2.3 - '@vitest/spy': 4.1.10 - '@vitest/utils': 4.1.10 - chai: 6.2.2 - tinyrainbow: 3.1.1 - - '@vitest/mocker@4.1.10(vite@7.3.1(@types/node@25.5.0)(tsx@4.23.12))': - dependencies: - '@vitest/spy': 4.1.10 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 7.3.1(@types/node@25.5.0)(tsx@4.23.12) - - '@vitest/pretty-format@4.1.10': - dependencies: - tinyrainbow: 3.1.1 - - '@vitest/runner@4.1.10': - dependencies: - '@vitest/utils': 4.1.10 - pathe: 2.0.3 - - '@vitest/snapshot@4.1.10': - dependencies: - '@vitest/pretty-format': 4.1.10 - '@vitest/utils': 4.1.10 - magic-string: 0.30.21 - pathe: 2.0.3 - - '@vitest/spy@4.1.10': {} - - '@vitest/utils@4.1.10': - dependencies: - '@vitest/pretty-format': 4.1.10 - convert-source-map: 2.0.0 - tinyrainbow: 3.1.1 - - accepts@2.0.0: - dependencies: - mime-types: 3.0.2 - negotiator: 1.0.0 - - ajv-formats@3.0.1(ajv@8.18.0): - optionalDependencies: - ajv: 8.18.0 - - ajv@8.18.0: - dependencies: - fast-deep-equal: 3.1.3 - fast-uri: 3.1.0 - json-schema-traverse: 1.0.0 - require-from-string: 2.0.2 - - ansi-regex@5.0.1: {} - - ansi-styles@4.3.0: - dependencies: - color-convert: 2.0.1 - - argparse@2.0.1: {} - - assertion-error@2.0.1: {} - - base64-js@1.5.1: - optional: true - - better-sqlite3@13.0.3: - dependencies: - node-addon-api: 8.9.2 - - bl@4.1.0: - dependencies: - buffer: 5.7.1 - inherits: 2.0.4 - readable-stream: 3.6.2 - optional: true - - body-parser@2.2.2: - dependencies: - bytes: 3.1.2 - content-type: 1.0.5 - debug: 4.4.3 - http-errors: 2.0.1 - iconv-lite: 0.7.2 - on-finished: 2.4.1 - qs: 6.15.0 - raw-body: 3.0.2 - type-is: 2.0.1 - transitivePeerDependencies: - - supports-color - - buffer@5.7.1: - dependencies: - base64-js: 1.5.1 - ieee754: 1.2.1 - optional: true - - bytes@3.1.2: {} - - call-bind-apply-helpers@1.0.2: - dependencies: - es-errors: 1.3.0 - function-bind: 1.1.2 - - call-bound@1.0.4: - dependencies: - call-bind-apply-helpers: 1.0.2 - get-intrinsic: 1.3.0 - - camelcase@5.3.1: {} - - chai@6.2.2: {} - - chownr@1.1.4: - optional: true - - cliui@6.0.0: - dependencies: - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi: 6.2.0 - - color-convert@2.0.1: - dependencies: - color-name: 1.1.4 - - color-name@1.1.4: {} - - content-disposition@1.0.1: {} - - content-type@1.0.5: {} - - convert-source-map@2.0.0: {} - - cookie-signature@1.2.2: {} - - cookie@0.7.2: {} - - cors@2.8.6: - dependencies: - object-assign: 4.1.1 - vary: 1.1.2 - - cross-spawn@7.0.6: - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - - debug@4.4.3: - dependencies: - ms: 2.1.3 - - decamelize@1.2.0: {} - - decompress-response@6.0.0: - dependencies: - mimic-response: 3.1.0 - optional: true - - deep-extend@0.6.0: - optional: true - - depd@2.0.0: {} - - detect-libc@2.1.2: - optional: true - - dijkstrajs@1.0.3: {} - - dunder-proto@1.0.1: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-errors: 1.3.0 - gopd: 1.2.0 - - ee-first@1.1.1: {} - - emoji-regex@8.0.0: {} - - encodeurl@2.0.0: {} - - end-of-stream@1.4.5: - dependencies: - once: 1.4.0 - optional: true - - es-define-property@1.0.1: {} - - es-errors@1.3.0: {} - - es-module-lexer@2.3.1: {} - - es-object-atoms@1.1.1: - dependencies: - es-errors: 1.3.0 - - esbuild@0.27.4: - optionalDependencies: - '@esbuild/aix-ppc64': 0.27.4 - '@esbuild/android-arm': 0.27.4 - '@esbuild/android-arm64': 0.27.4 - '@esbuild/android-x64': 0.27.4 - '@esbuild/darwin-arm64': 0.27.4 - '@esbuild/darwin-x64': 0.27.4 - '@esbuild/freebsd-arm64': 0.27.4 - '@esbuild/freebsd-x64': 0.27.4 - '@esbuild/linux-arm': 0.27.4 - '@esbuild/linux-arm64': 0.27.4 - '@esbuild/linux-ia32': 0.27.4 - '@esbuild/linux-loong64': 0.27.4 - '@esbuild/linux-mips64el': 0.27.4 - '@esbuild/linux-ppc64': 0.27.4 - '@esbuild/linux-riscv64': 0.27.4 - '@esbuild/linux-s390x': 0.27.4 - '@esbuild/linux-x64': 0.27.4 - '@esbuild/netbsd-arm64': 0.27.4 - '@esbuild/netbsd-x64': 0.27.4 - '@esbuild/openbsd-arm64': 0.27.4 - '@esbuild/openbsd-x64': 0.27.4 - '@esbuild/openharmony-arm64': 0.27.4 - '@esbuild/sunos-x64': 0.27.4 - '@esbuild/win32-arm64': 0.27.4 - '@esbuild/win32-ia32': 0.27.4 - '@esbuild/win32-x64': 0.27.4 - - esbuild@0.28.2: - optionalDependencies: - '@esbuild/aix-ppc64': 0.28.2 - '@esbuild/android-arm': 0.28.2 - '@esbuild/android-arm64': 0.28.2 - '@esbuild/android-x64': 0.28.2 - '@esbuild/darwin-arm64': 0.28.2 - '@esbuild/darwin-x64': 0.28.2 - '@esbuild/freebsd-arm64': 0.28.2 - '@esbuild/freebsd-x64': 0.28.2 - '@esbuild/linux-arm': 0.28.2 - '@esbuild/linux-arm64': 0.28.2 - '@esbuild/linux-ia32': 0.28.2 - '@esbuild/linux-loong64': 0.28.2 - '@esbuild/linux-mips64el': 0.28.2 - '@esbuild/linux-ppc64': 0.28.2 - '@esbuild/linux-riscv64': 0.28.2 - '@esbuild/linux-s390x': 0.28.2 - '@esbuild/linux-x64': 0.28.2 - '@esbuild/netbsd-arm64': 0.28.2 - '@esbuild/netbsd-x64': 0.28.2 - '@esbuild/openbsd-arm64': 0.28.2 - '@esbuild/openbsd-x64': 0.28.2 - '@esbuild/openharmony-arm64': 0.28.2 - '@esbuild/sunos-x64': 0.28.2 - '@esbuild/win32-arm64': 0.28.2 - '@esbuild/win32-ia32': 0.28.2 - '@esbuild/win32-x64': 0.28.2 - - escape-html@1.0.3: {} - - estree-walker@3.0.3: - dependencies: - '@types/estree': 1.0.8 - - etag@1.8.1: {} - - eventsource-parser@3.0.6: {} - - eventsource@3.0.7: - dependencies: - eventsource-parser: 3.0.6 - - expand-template@2.0.3: - optional: true - - expect-type@1.3.0: {} - - express-rate-limit@8.3.1(express@5.2.1): - dependencies: - express: 5.2.1 - ip-address: 10.1.0 - - express@5.2.1: - dependencies: - accepts: 2.0.0 - body-parser: 2.2.2 - content-disposition: 1.0.1 - content-type: 1.0.5 - cookie: 0.7.2 - cookie-signature: 1.2.2 - debug: 4.4.3 - depd: 2.0.0 - encodeurl: 2.0.0 - escape-html: 1.0.3 - etag: 1.8.1 - finalhandler: 2.1.1 - fresh: 2.0.0 - http-errors: 2.0.1 - merge-descriptors: 2.0.0 - mime-types: 3.0.2 - on-finished: 2.4.1 - once: 1.4.0 - parseurl: 1.3.3 - proxy-addr: 2.0.7 - qs: 6.15.0 - range-parser: 1.2.1 - router: 2.2.0 - send: 1.2.1 - serve-static: 2.2.1 - statuses: 2.0.2 - type-is: 2.0.1 - vary: 1.1.2 - transitivePeerDependencies: - - supports-color - - farrier-kit@1.1.1: - dependencies: - '@noble/hashes': 1.8.0 - - fast-deep-equal@3.1.3: {} - - fast-uri@3.1.0: {} - - fdir@6.5.0(picomatch@4.0.3): - optionalDependencies: - picomatch: 4.0.3 - - finalhandler@2.1.1: - dependencies: - debug: 4.4.3 - encodeurl: 2.0.0 - escape-html: 1.0.3 - on-finished: 2.4.1 - parseurl: 1.3.3 - statuses: 2.0.2 - transitivePeerDependencies: - - supports-color - - find-up@4.1.0: - dependencies: - locate-path: 5.0.0 - path-exists: 4.0.0 - - forwarded@0.2.0: {} - - fresh@2.0.0: {} - - fs-constants@1.0.0: - optional: true - - fsevents@2.3.3: - optional: true - - function-bind@1.1.2: {} - - get-caller-file@2.0.5: {} - - get-intrinsic@1.3.0: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - function-bind: 1.1.2 - get-proto: 1.0.1 - gopd: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.2 - math-intrinsics: 1.1.0 - - get-proto@1.0.1: - dependencies: - dunder-proto: 1.0.1 - es-object-atoms: 1.1.1 - - github-from-package@0.0.0: - optional: true - - gopd@1.2.0: {} - - has-symbols@1.1.0: {} - - hasown@2.0.2: - dependencies: - function-bind: 1.1.2 - - hono@4.13.2: {} - - http-errors@2.0.1: - dependencies: - depd: 2.0.0 - inherits: 2.0.4 - setprototypeof: 1.2.0 - statuses: 2.0.2 - toidentifier: 1.0.1 - - iconv-lite@0.7.2: - dependencies: - safer-buffer: 2.1.2 - - ieee754@1.2.1: - optional: true - - inherits@2.0.4: {} - - ini@1.3.8: - optional: true - - ip-address@10.1.0: {} - - ipaddr.js@1.9.1: {} - - is-fullwidth-code-point@3.0.0: {} - - is-promise@4.0.0: {} - - isexe@2.0.0: {} - - jose@6.2.1: {} - - js-yaml@4.3.1: - dependencies: - argparse: 2.0.1 - - json-schema-traverse@1.0.0: {} - - json-schema-typed@8.0.2: {} - - keytar@7.9.0: - dependencies: - node-addon-api: 4.3.0 - prebuild-install: 7.1.3 - optional: true - - light-bolt11-decoder@3.2.0: - dependencies: - '@scure/base': 1.1.1 - - locate-path@5.0.0: - dependencies: - p-locate: 4.1.0 - - macaroon@3.0.4: - dependencies: - sjcl: 1.0.8 - tweetnacl: 1.0.3 - tweetnacl-util: 0.15.1 - - magic-string@0.30.21: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - - math-intrinsics@1.1.0: {} - - media-typer@1.1.0: {} - - merge-descriptors@2.0.0: {} - - mime-db@1.54.0: {} - - mime-types@3.0.2: - dependencies: - mime-db: 1.54.0 - - mimic-response@3.1.0: - optional: true - - minimist@1.2.8: - optional: true - - mkdirp-classic@0.5.3: - optional: true - - ms@2.1.3: {} - - nanoid@3.3.11: {} - - napi-build-utils@2.0.0: - optional: true - - negotiator@1.0.0: {} - - node-abi@3.89.0: - dependencies: - semver: 7.7.4 - optional: true - - node-addon-api@4.3.0: - optional: true - - node-addon-api@8.9.2: {} - - nostr-tools@2.23.3(typescript@6.0.3): - dependencies: - '@noble/ciphers': 2.1.1 - '@noble/curves': 2.0.1 - '@noble/hashes': 2.0.1 - '@scure/base': 2.0.0 - '@scure/bip32': 2.0.1 - '@scure/bip39': 2.0.1 - nostr-wasm: 0.1.0 - optionalDependencies: - typescript: 6.0.3 - - nostr-tools@2.24.1(typescript@6.0.3): - dependencies: - '@noble/ciphers': 2.1.1 - '@noble/curves': 2.0.1 - '@noble/hashes': 2.0.1 - '@scure/base': 2.0.0 - '@scure/bip32': 2.0.1 - '@scure/bip39': 2.0.1 - nostr-wasm: 0.1.0 - optionalDependencies: - typescript: 6.0.3 - - nostr-wasm@0.1.0: {} - - object-assign@4.1.1: {} - - object-inspect@1.13.4: {} - - obug@2.1.4: {} - - on-finished@2.4.1: - dependencies: - ee-first: 1.1.1 - - once@1.4.0: - dependencies: - wrappy: 1.0.2 - - p-limit@2.3.0: - dependencies: - p-try: 2.2.0 - - p-locate@4.1.0: - dependencies: - p-limit: 2.3.0 - - p-try@2.2.0: {} - - parseurl@1.3.3: {} - - path-exists@4.0.0: {} - - path-key@3.1.1: {} - - path-to-regexp@8.3.0: {} - - pathe@2.0.3: {} - - picocolors@1.1.1: {} - - picomatch@4.0.3: {} - - pkce-challenge@5.0.1: {} - - pngjs@5.0.0: {} - - postcss@8.5.8: - dependencies: - nanoid: 3.3.11 - picocolors: 1.1.1 - source-map-js: 1.2.1 - - prebuild-install@7.1.3: - dependencies: - detect-libc: 2.1.2 - expand-template: 2.0.3 - github-from-package: 0.0.0 - minimist: 1.2.8 - mkdirp-classic: 0.5.3 - napi-build-utils: 2.0.0 - node-abi: 3.89.0 - pump: 3.0.4 - rc: 1.2.8 - simple-get: 4.0.1 - tar-fs: 2.1.4 - tunnel-agent: 0.6.0 - optional: true - - proxy-addr@2.0.7: - dependencies: - forwarded: 0.2.0 - ipaddr.js: 1.9.1 - - pump@3.0.4: - dependencies: - end-of-stream: 1.4.5 - once: 1.4.0 - optional: true - - qrcode@1.5.4: - dependencies: - dijkstrajs: 1.0.3 - pngjs: 5.0.0 - yargs: 15.4.1 - - qs@6.15.0: - dependencies: - side-channel: 1.1.0 - - range-parser@1.2.1: {} - - raw-body@3.0.2: - dependencies: - bytes: 3.1.2 - http-errors: 2.0.1 - iconv-lite: 0.7.2 - unpipe: 1.0.0 - - rc@1.2.8: - dependencies: - deep-extend: 0.6.0 - ini: 1.3.8 - minimist: 1.2.8 - strip-json-comments: 2.0.1 - optional: true - - readable-stream@3.6.2: - dependencies: - inherits: 2.0.4 - string_decoder: 1.3.0 - util-deprecate: 1.0.2 - optional: true - - require-directory@2.1.1: {} - - require-from-string@2.0.2: {} - - require-main-filename@2.0.0: {} - - rollup@4.59.0: - dependencies: - '@types/estree': 1.0.8 - optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.59.0 - '@rollup/rollup-android-arm64': 4.59.0 - '@rollup/rollup-darwin-arm64': 4.59.0 - '@rollup/rollup-darwin-x64': 4.59.0 - '@rollup/rollup-freebsd-arm64': 4.59.0 - '@rollup/rollup-freebsd-x64': 4.59.0 - '@rollup/rollup-linux-arm-gnueabihf': 4.59.0 - '@rollup/rollup-linux-arm-musleabihf': 4.59.0 - '@rollup/rollup-linux-arm64-gnu': 4.59.0 - '@rollup/rollup-linux-arm64-musl': 4.59.0 - '@rollup/rollup-linux-loong64-gnu': 4.59.0 - '@rollup/rollup-linux-loong64-musl': 4.59.0 - '@rollup/rollup-linux-ppc64-gnu': 4.59.0 - '@rollup/rollup-linux-ppc64-musl': 4.59.0 - '@rollup/rollup-linux-riscv64-gnu': 4.59.0 - '@rollup/rollup-linux-riscv64-musl': 4.59.0 - '@rollup/rollup-linux-s390x-gnu': 4.59.0 - '@rollup/rollup-linux-x64-gnu': 4.59.0 - '@rollup/rollup-linux-x64-musl': 4.59.0 - '@rollup/rollup-openbsd-x64': 4.59.0 - '@rollup/rollup-openharmony-arm64': 4.59.0 - '@rollup/rollup-win32-arm64-msvc': 4.59.0 - '@rollup/rollup-win32-ia32-msvc': 4.59.0 - '@rollup/rollup-win32-x64-gnu': 4.59.0 - '@rollup/rollup-win32-x64-msvc': 4.59.0 - fsevents: 2.3.3 - - router@2.2.0: - dependencies: - debug: 4.4.3 - depd: 2.0.0 - is-promise: 4.0.0 - parseurl: 1.3.3 - path-to-regexp: 8.3.0 - transitivePeerDependencies: - - supports-color - - safe-buffer@5.2.1: - optional: true - - safer-buffer@2.1.2: {} - - semver@7.7.4: - optional: true - - send@1.2.1: - dependencies: - debug: 4.4.3 - encodeurl: 2.0.0 - escape-html: 1.0.3 - etag: 1.8.1 - fresh: 2.0.0 - http-errors: 2.0.1 - mime-types: 3.0.2 - ms: 2.1.3 - on-finished: 2.4.1 - range-parser: 1.2.1 - statuses: 2.0.2 - transitivePeerDependencies: - - supports-color - - serve-static@2.2.1: - dependencies: - encodeurl: 2.0.0 - escape-html: 1.0.3 - parseurl: 1.3.3 - send: 1.2.1 - transitivePeerDependencies: - - supports-color - - set-blocking@2.0.0: {} - - setprototypeof@1.2.0: {} - - shebang-command@2.0.0: - dependencies: - shebang-regex: 3.0.0 - - shebang-regex@3.0.0: {} - - side-channel-list@1.0.0: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - - side-channel-map@1.0.1: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - - side-channel-weakmap@1.0.2: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - side-channel-map: 1.0.1 - - side-channel@1.1.0: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - side-channel-list: 1.0.0 - side-channel-map: 1.0.1 - side-channel-weakmap: 1.0.2 - - siginfo@2.0.0: {} - - simple-concat@1.0.1: - optional: true - - simple-get@4.0.1: - dependencies: - decompress-response: 6.0.0 - once: 1.4.0 - simple-concat: 1.0.1 - optional: true - - sjcl@1.0.8: {} - - source-map-js@1.2.1: {} - - stackback@0.0.2: {} - - statuses@2.0.2: {} - - std-env@4.2.0: {} - - string-width@4.2.3: - dependencies: - emoji-regex: 8.0.0 - is-fullwidth-code-point: 3.0.0 - strip-ansi: 6.0.1 - - string_decoder@1.3.0: - dependencies: - safe-buffer: 5.2.1 - optional: true - - strip-ansi@6.0.1: - dependencies: - ansi-regex: 5.0.1 - - strip-json-comments@2.0.1: - optional: true - - tar-fs@2.1.4: - dependencies: - chownr: 1.1.4 - mkdirp-classic: 0.5.3 - pump: 3.0.4 - tar-stream: 2.2.0 - optional: true - - tar-stream@2.2.0: - dependencies: - bl: 4.1.0 - end-of-stream: 1.4.5 - fs-constants: 1.0.0 - inherits: 2.0.4 - readable-stream: 3.6.2 - optional: true - - tinybench@2.9.0: {} - - tinyexec@1.3.0: {} - - tinyglobby@0.2.15: - dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - - tinyrainbow@3.1.1: {} - - toidentifier@1.0.1: {} - - tsx@4.23.12: - dependencies: - esbuild: 0.28.2 - optionalDependencies: - fsevents: 2.3.3 - - tunnel-agent@0.6.0: - dependencies: - safe-buffer: 5.2.1 - optional: true - - tweetnacl-util@0.15.1: {} - - tweetnacl@1.0.3: {} - - type-is@2.0.1: - dependencies: - content-type: 1.0.5 - media-typer: 1.1.0 - mime-types: 3.0.2 - - typescript@6.0.3: {} - - undici-types@7.18.2: {} - - unpipe@1.0.0: {} - - util-deprecate@1.0.2: - optional: true - - vary@1.1.2: {} - - vite@7.3.1(@types/node@25.5.0)(tsx@4.23.12): - dependencies: - esbuild: 0.27.4 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - postcss: 8.5.8 - rollup: 4.59.0 - tinyglobby: 0.2.15 - optionalDependencies: - '@types/node': 25.5.0 - fsevents: 2.3.3 - tsx: 4.23.12 - - vitest@4.1.10(@types/node@25.5.0)(vite@7.3.1(@types/node@25.5.0)(tsx@4.23.12)): - dependencies: - '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@7.3.1(@types/node@25.5.0)(tsx@4.23.12)) - '@vitest/pretty-format': 4.1.10 - '@vitest/runner': 4.1.10 - '@vitest/snapshot': 4.1.10 - '@vitest/spy': 4.1.10 - '@vitest/utils': 4.1.10 - es-module-lexer: 2.3.1 - expect-type: 1.3.0 - magic-string: 0.30.21 - obug: 2.1.4 - pathe: 2.0.3 - picomatch: 4.0.3 - std-env: 4.2.0 - tinybench: 2.9.0 - tinyexec: 1.3.0 - tinyglobby: 0.2.15 - tinyrainbow: 3.1.1 - vite: 7.3.1(@types/node@25.5.0)(tsx@4.23.12) - why-is-node-running: 2.3.0 - optionalDependencies: - '@types/node': 25.5.0 - transitivePeerDependencies: - - msw - - which-module@2.0.1: {} - - which@2.0.2: - dependencies: - isexe: 2.0.0 - - why-is-node-running@2.3.0: - dependencies: - siginfo: 2.0.0 - stackback: 0.0.2 - - wrap-ansi@6.2.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - - wrappy@1.0.2: {} - - y18n@4.0.3: {} - - yargs-parser@18.1.3: - dependencies: - camelcase: 5.3.1 - decamelize: 1.2.0 - - yargs@15.4.1: - dependencies: - cliui: 6.0.0 - decamelize: 1.2.0 - find-up: 4.1.0 - get-caller-file: 2.0.5 - require-directory: 2.1.1 - require-main-filename: 2.0.0 - set-blocking: 2.0.0 - string-width: 4.2.3 - which-module: 2.0.1 - y18n: 4.0.3 - yargs-parser: 18.1.3 - - zod-to-json-schema@3.25.1(zod@4.3.6): - dependencies: - zod: 4.3.6 - - zod@4.3.6: {} diff --git a/scripts/copy-page.mjs b/scripts/copy-page.mjs new file mode 100644 index 0000000..e5987c6 --- /dev/null +++ b/scripts/copy-page.mjs @@ -0,0 +1,14 @@ +// Copies the landing page into the build output, where the compiled server +// looks for it (dist/src/server.js -> dist/page/index.html). tsc only emits +// TypeScript, so without this step the npm package ships no page. +import { cpSync, mkdirSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const outDir = resolve(process.argv[2] ?? join(root, 'dist')) +const target = join(outDir, 'page', 'index.html') + +mkdirSync(dirname(target), { recursive: true }) +cpSync(join(root, 'src', 'page', 'index.html'), target) +console.log(`Copied landing page to ${target}`) diff --git a/src/auth/allowlist.ts b/src/auth/allowlist.ts index d430216..b38a2d0 100644 --- a/src/auth/allowlist.ts +++ b/src/auth/allowlist.ts @@ -11,6 +11,11 @@ export interface AllowlistResult { export interface RequestContext { url: string method: string + /** + * Other URLs this same request is known by, such as its public URL behind + * a TLS-terminating proxy. A NIP-98 `u` tag may match any of them. + */ + alternateUrls?: string[] } const HEX_PUBKEY_RE = /^[0-9a-fA-F]{64}$/ @@ -173,7 +178,9 @@ function verifyNip98( // Validate URL and method tags match the actual request const urlTag = event.tags.find(t => t[0] === 'u')?.[1] const methodTag = event.tags.find(t => t[0] === 'method')?.[1] - if (urlTag !== request.url) return { allowed: false } + if (urlTag === undefined || (urlTag !== request.url && !request.alternateUrls?.includes(urlTag))) { + return { allowed: false } + } if (methodTag?.toUpperCase() !== request.method.toUpperCase()) return { allowed: false } // Validate hex field formats before expensive crypto operations diff --git a/src/auth/middleware.test.ts b/src/auth/middleware.test.ts index 7fd435a..f9878e3 100644 --- a/src/auth/middleware.test.ts +++ b/src/auth/middleware.test.ts @@ -1,6 +1,9 @@ import { describe, it, expect } from 'vitest' import { Hono } from 'hono' import { createAuthMiddleware } from './middleware.js' +import { schnorr } from '@noble/curves/secp256k1.js' +import { bytesToHex, hexToBytes } from '@noble/curves/utils.js' +import { createHash } from 'node:crypto' describe('createAuthMiddleware', () => { it('passes all requests in open mode', async () => { @@ -68,3 +71,70 @@ describe('createAuthMiddleware', () => { expect(res.status).toBe(200) }) }) + +describe('NIP-98 behind a TLS proxy', () => { + function nip98(privateKey: Uint8Array, url: string, method: string): string { + const pubkey = bytesToHex(schnorr.getPublicKey(privateKey)) + const event = { pubkey, created_at: Math.floor(Date.now() / 1000), kind: 27235, tags: [['u', url], ['method', method]], content: Math.random().toString(36) } + const id = createHash('sha256').update(JSON.stringify([0, event.pubkey, event.created_at, event.kind, event.tags, event.content])).digest('hex') + const sig = bytesToHex(schnorr.sign(hexToBytes(id), privateKey)) + return `Nostr ${btoa(JSON.stringify({ ...event, id, sig }))}` + } + + const key = schnorr.utils.randomSecretKey() + const pubkey = bytesToHex(schnorr.getPublicKey(key)) + + function appWith(config: { publicUrl?: string; trustProxy?: boolean }) { + const app = new Hono() + app.use('/v1/*', createAuthMiddleware({ authMode: 'allowlist', allowlist: [pubkey], ...config })) + app.post('/v1/chat/completions', (c) => c.json({ ok: true })) + return app + } + + // The proxy talks to satgate over plain HTTP on loopback + const internal = 'http://127.0.0.1:3000/v1/chat/completions' + + it('accepts a u tag for the configured public URL', async () => { + const app = appWith({ publicUrl: 'https://ai.example.com' }) + const res = await app.request(internal, { + method: 'POST', + headers: { Authorization: nip98(key, 'https://ai.example.com/v1/chat/completions', 'POST') }, + }) + expect(res.status).toBe(200) + }) + + it('accepts a u tag rebuilt from trusted forwarded headers', async () => { + const app = appWith({ trustProxy: true }) + const res = await app.request(internal, { + method: 'POST', + headers: { + Authorization: nip98(key, 'https://ai.example.com/v1/chat/completions', 'POST'), + 'X-Forwarded-Proto': 'https', + 'X-Forwarded-Host': 'ai.example.com', + }, + }) + expect(res.status).toBe(200) + }) + + it('ignores forwarded headers unless the proxy is trusted', async () => { + const app = appWith({}) + const res = await app.request(internal, { + method: 'POST', + headers: { + Authorization: nip98(key, 'https://ai.example.com/v1/chat/completions', 'POST'), + 'X-Forwarded-Proto': 'https', + 'X-Forwarded-Host': 'ai.example.com', + }, + }) + expect(res.status).toBe(403) + }) + + it('still refuses a u tag for some other service', async () => { + const app = appWith({ publicUrl: 'https://ai.example.com' }) + const res = await app.request(internal, { + method: 'POST', + headers: { Authorization: nip98(key, 'https://other.example.com/v1/chat/completions', 'POST') }, + }) + expect(res.status).toBe(403) + }) +}) diff --git a/src/auth/middleware.ts b/src/auth/middleware.ts index ca95bcc..799acc7 100644 --- a/src/auth/middleware.ts +++ b/src/auth/middleware.ts @@ -1,9 +1,41 @@ -import type { MiddlewareHandler } from 'hono' +import type { Context, MiddlewareHandler } from 'hono' import { checkAllowlist } from './allowlist.js' export interface AuthMiddlewareConfig { authMode: 'open' | 'lightning' | 'cashu' | 'allowlist' allowlist: string[] + /** The service's public base URL, e.g. https://ai.example.com. */ + publicUrl?: string + /** Trust X-Forwarded-Proto / X-Forwarded-Host from a reverse proxy. */ + trustProxy?: boolean +} + +const HOST_RE = /^[A-Za-z0-9.-]+(:\d{1,5})?$|^\[[0-9A-Fa-f:.]+\](:\d{1,5})?$/ + +/** + * The URLs a client may have signed for this request. Behind a TLS proxy + * the server sees http://127.0.0.1:3000/..., while the client signed + * https://public.host/..., so the public form is rebuilt from the + * configured public URL, or from forwarded headers when they are trusted. + */ +export function publicRequestUrls(c: Context, config: Pick): string[] { + const urls: string[] = [] + const raw = new URL(c.req.url) + const pathAndQuery = raw.pathname + raw.search + if (config.publicUrl) { + try { + const base = new URL(config.publicUrl) + urls.push(`${base.origin}${base.pathname.replace(/\/+$/, '')}${pathAndQuery}`) + } catch { /* invalid public URL: ignore */ } + } + if (config.trustProxy) { + const proto = c.req.header('x-forwarded-proto')?.split(',')[0]?.trim().toLowerCase() + const host = (c.req.header('x-forwarded-host') ?? c.req.header('host'))?.split(',')[0]?.trim() + if ((proto === 'http' || proto === 'https') && host && HOST_RE.test(host)) { + urls.push(`${proto}://${host}${pathAndQuery}`) + } + } + return urls } /** @@ -23,6 +55,7 @@ export function createAuthMiddleware(config: AuthMiddlewareConfig): MiddlewareHa const result = checkAllowlist(authHeader, config.allowlist, { url: requestUrl, method: requestMethod, + alternateUrls: publicRequestUrls(c, config), }) if (!result.allowed) { return c.json({ error: 'Forbidden' }, 403) diff --git a/src/cli.ts b/src/cli.ts index b3d73a2..9ed2cd6 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -7,12 +7,15 @@ import { createLightningBackend } from './lightning.js' import { startTunnel, stopTunnel, type TunnelResult } from './tunnel.js' import { createLogger } from './logger.js' import { resolveModelPrice } from './proxy/pricing.js' +import { readPackageVersion } from './version.js' +import { loadOrCreateRootKey } from './root-key.js' function parseArgs(argv: string[]): CliArgs { const args: CliArgs = {} for (let i = 2; i < argv.length; i++) { switch (argv[i]) { case '--upstream': args.upstream = argv[++i]; break + case '--upstream-key-file': args.upstreamKeyFile = argv[++i]; break case '--port': args.port = parseInt(argv[++i], 10); break case '--config': args.config = argv[++i]; break case '--price': args.price = parseInt(argv[++i], 10); break @@ -21,6 +24,7 @@ function parseArgs(argv: string[]): CliArgs { case '--db-path': args.dbPath = argv[++i]; break case '--free-tier': args.freeTier = parseInt(argv[++i], 10); break case '--trust-proxy': args.trustProxy = true; break + case '--trusted-proxies': args.trustedProxies = argv[++i]; break case '--lightning': args.lightning = argv[++i]; break case '--lightning-url': args.lightningUrl = argv[++i]; break case '--lightning-key': args.lightningKey = argv[++i]; break @@ -28,10 +32,13 @@ function parseArgs(argv: string[]): CliArgs { case '--allowlist': args.allowlist = argv[++i].split(','); break case '--allowlist-file': args.allowlistFile = argv[++i]; break case '--no-tunnel': args.noTunnel = true; break + case '--tunnel': args.tunnel = true; break case '--root-key': args.rootKey = argv[++i]; break case '--verbose': args.verbose = true; break case '--log-format': args.logFormat = argv[++i]; break case '--token-price': args.tokenPrice = parseInt(argv[++i], 10); break + case '--max-pending-invoices': args.maxPendingInvoices = parseInt(argv[++i], 10); break + case '--max-tokens': args.maxTokens = parseInt(argv[++i], 10); break case '--model-price': args.modelPrice = [...(args.modelPrice ?? []), argv[++i]] break @@ -77,6 +84,8 @@ function printHelp(): void { Upstream: --upstream Upstream API URL (default: auto-detect Ollama on :11434) + --upstream-key-file File holding a bearer key for the upstream API + (or set UPSTREAM_API_KEY) Lightning: --lightning phoenixd | lnbits | lnd | cln | nwc @@ -100,10 +109,15 @@ function printHelp(): void { --price Sats per request (flat pricing) --token-price Sats per 1k tokens (per-token pricing) --model-price Per-model token price (repeatable) + --max-tokens Cap on completion tokens per request (default: 2048); + per-token billing reserves the worst case up front Server: --port Listen port (default: 3000) - --no-tunnel Skip Cloudflare Tunnel + --tunnel Publish via a Cloudflare quick tunnel (default: on + when payment or an allowlist is required, off in + open mode) + --no-tunnel Skip the Cloudflare tunnel Announce: --announce Publish service on Nostr relays for discovery @@ -112,14 +126,21 @@ function printHelp(): void { --public-url Public URL for announcements (overrides tunnel URL) Storage: - --storage memory | sqlite (default: memory) + --storage memory | sqlite (default: sqlite when payments are + accepted, memory otherwise) --db-path SQLite path (default: ./satgate.db) Other: --config Config file (JSON or YAML) - --max-concurrent Max concurrent inference requests + --max-concurrent Max concurrent inference requests (default: 8; + 0 = unlimited) + --max-pending-invoices Unpaid invoices per client IP before 429 (default: 20; + 0 disables) --free-tier Free credits (sats) per IP per day (default: 0) - --trust-proxy Trust X-Forwarded-For headers + --trust-proxy Trust X-Forwarded-For / X-Real-IP for client IPs + (free tier and invoice limits); only behind a proxy + --trusted-proxies Comma-separated proxy IPs/IPv4 CIDRs to skip when + reading X-Forwarded-For (implies --trust-proxy) --root-key Root key for macaroon minting --verbose Show extra fields in log output --log-format pretty | json (default: pretty) @@ -129,12 +150,14 @@ function printHelp(): void { } function printVersion(): void { - try { - const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8')) - console.log(`satgate v${pkg.version}`) - } catch { - console.log('satgate (unknown version)') - } + const version = readPackageVersion() + console.log(version ? `satgate v${version}` : 'satgate (unknown version)') +} + +/** The Lightning line of the startup banner. nwc has no URL: its relay is in the URI. */ +export function lightningLabel(config: Pick): string { + if (!config.lightning) return 'none (free mode)' + return config.lightningUrl ? `${config.lightning} (${config.lightningUrl})` : config.lightning } /** @@ -211,6 +234,18 @@ export async function main(argv: string[] = process.argv): Promise { args.allowlist = [...(args.allowlist ?? []), ...entries] } + // Upstream API key from a file, so it never appears in argv + const upstreamKeyFile = args.upstreamKeyFile + ?? (typeof fileConfig.upstreamKeyFile === 'string' ? fileConfig.upstreamKeyFile : undefined) + if (upstreamKeyFile) { + try { + args.upstreamKey = readFileSync(upstreamKeyFile, 'utf-8').trim() + } catch { + console.error(`[satgate] Could not read upstream key file: ${upstreamKeyFile}`) + process.exit(1) + } + } + // Warn when secrets are passed on the command line (visible in `ps aux`) const cliSecrets: string[] = [] if (args.lightningKey) cliSecrets.push('--lightning-key') @@ -224,6 +259,24 @@ export async function main(argv: string[] = process.argv): Promise { const config = loadConfig(args, process.env as Record, fileConfig) const logger = createLogger({ format: config.logFormat, verbose: config.verbose }) + // Paid credentials are signed with the root key. With a database to keep + // balances in, keep a generated key beside it so credentials survive a + // restart; with memory storage both are lost together, loudly. + const acceptsPayment = Boolean(config.lightning || config.cashu || config.lnurlcash) + if (acceptsPayment && config.rootKeyGenerated) { + if (config.storage === 'sqlite') { + const { dirname } = await import('node:path') + const stored = loadOrCreateRootKey(dirname(config.dbPath)) + config.rootKey = stored.key + config.rootKeyGenerated = false + logger.info(`Root key ${stored.created ? 'saved to' : 'loaded from'} ${stored.path} (keep it private)`) + } + } + if (acceptsPayment && config.storage === 'memory') { + logger.warn('!!! Payments are enabled with MEMORY storage: every paid balance and credential') + logger.warn('!!! is lost when satgate restarts. Use --storage sqlite (the default with payments).') + } + if (config.flatPricing && config.price === 0) { logger.warn('Flat price is 0 sats — all inference is free') } @@ -234,6 +287,7 @@ export async function main(argv: string[] = process.argv): Promise { try { const res = await fetch(`${config.upstream}/v1/models`, { signal: AbortSignal.timeout(5000), + ...(config.upstreamKey && { headers: { Authorization: `Bearer ${config.upstreamKey}` } }), }) const body = await res.json() as { data?: Array<{ id: string }> } models = body.data?.map(m => m.id) ?? [] @@ -253,23 +307,18 @@ export async function main(argv: string[] = process.argv): Promise { const backend = createLightningBackend(config) const { app } = createTokenTollServer({ ...config, models, backend, logger }) - let version = '0.1.0' - try { - const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8')) - version = pkg.version - } catch { /* ignore */ } + const version = readPackageVersion() ?? 'unknown' let tunnelResult: TunnelResult | undefined let announcement: { close(): void; pubkey: string } | undefined const server = serve({ fetch: app.fetch, port: config.port }, async () => { - const lightningLabel = config.lightning - ? `${config.lightning} (${config.lightningUrl})` - : 'none (free mode)' const authLabel = config.authMode === 'lightning' ? 'lightning (pay-per-request)' : config.authMode === 'cashu' - ? `cashu (${config.cashu!.mints.length} mint${config.cashu!.mints.length > 1 ? 's' : ''})` + ? (config.cashu + ? `cashu (${config.cashu.mints.length} mint${config.cashu.mints.length > 1 ? 's' : ''})` + : 'lnurlcash notes') : config.authMode === 'allowlist' ? `allowlist (${config.allowlist.length} identities)` : 'open' @@ -280,7 +329,7 @@ export async function main(argv: string[] = process.argv): Promise { logger.info(`satgate v${version}`) logger.info(`Upstream: ${config.upstream}${ollamaAutoDetected ? ' (auto-detected)' : ''}`) logger.info(`Models: ${models.length > 0 ? models.join(', ') : '(none detected)'}`) - logger.info(`Lightning: ${lightningLabel}`) + logger.info(`Lightning: ${lightningLabel(config)}`) logger.info(`Auth: ${authLabel}`) logger.info(`Price: ${priceLabel}`) if (config.lnurlcash) { diff --git a/src/config.test.ts b/src/config.test.ts index ed527c7..1bc4514 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -8,7 +8,7 @@ describe('loadConfig', () => { expect(config.port).toBe(3000) expect(config.pricing.default).toBe(1) expect(config.storage).toBe('memory') - expect(config.capacity.maxConcurrent).toBe(0) + expect(config.capacity.maxConcurrent).toBe(8) expect(config.freeTier.creditsPerDay).toBe(0) }) @@ -329,6 +329,17 @@ describe('loadConfig', () => { }) expect(withCli.tunnel).toBe(false) }) + + it('leaves the tunnel off in open mode unless asked for', () => { + expect(loadConfig({ upstream: 'http://localhost:11434' }).tunnel).toBe(false) + expect(loadConfig({ upstream: 'http://localhost:11434', tunnel: true }).tunnel).toBe(true) + expect(loadConfig({ upstream: 'http://localhost:11434' }, { TUNNEL: 'true' }).tunnel).toBe(true) + }) + + it('turns the tunnel on by default when payment is required', () => { + expect(loadConfig({ upstream: 'http://localhost:11434', lightning: 'phoenixd' }).tunnel).toBe(true) + expect(loadConfig({ upstream: 'http://localhost:11434', lightning: 'phoenixd', noTunnel: true }).tunnel).toBe(false) + }) }) describe('x402 config', () => { @@ -540,7 +551,8 @@ describe('per-token CLI pricing', () => { it('estimatedCostSats reflects CLI tokenPrice when set', () => { const config = loadConfig({ upstream: 'http://localhost:11434', tokenPrice: 5 }) - expect(config.estimatedCostSats).toBe(10) + // (4096-byte prompt allowance + 2048 max tokens) at 5 sats/1k, rounded up + expect(config.estimatedCostSats).toBe(31) }) it('model ID with colon (e.g. qwen3:0.6b) parses correctly', () => { @@ -652,3 +664,73 @@ describe('lnurlcash mints', () => { ).toThrow(/at least one mint host/) }) }) + +describe('max tokens', () => { + it('defaults to 2048 and sizes the default hold from it and the dearest model', () => { + const config = loadConfig({ upstream: 'http://localhost:11434' }, {}, { pricing: { default: 1, models: { big: 10 } } }) + expect(config.maxTokens).toBe(2048) + // (4096 + 2048) tokens at the dearest price, 10 sats/1k + expect(config.estimatedCostSats).toBe(62) + }) + + it('reads --max-tokens, SATGATE_MAX_TOKENS and maxTokens in precedence order', () => { + expect(loadConfig({ upstream: 'http://x', maxTokens: 10 }, { SATGATE_MAX_TOKENS: '20' }, { maxTokens: 30 }).maxTokens).toBe(10) + expect(loadConfig({ upstream: 'http://x' }, { SATGATE_MAX_TOKENS: '20' }, { maxTokens: 30 }).maxTokens).toBe(20) + expect(loadConfig({ upstream: 'http://x' }, {}, { maxTokens: 30 }).maxTokens).toBe(30) + }) + + it('rejects a non-positive max tokens', () => { + expect(() => loadConfig({ upstream: 'http://x', maxTokens: 0 })).toThrow(/max tokens/) + }) +}) + +describe('trusted proxies', () => { + it('parses --trusted-proxies and TRUSTED_PROXIES, and implies trustProxy', () => { + const fromCli = loadConfig({ upstream: 'http://x', trustedProxies: '10.0.0.0/8, 127.0.0.1' }) + expect(fromCli.trustedProxies).toEqual(['10.0.0.0/8', '127.0.0.1']) + expect(fromCli.trustProxy).toBe(true) + const fromEnv = loadConfig({ upstream: 'http://x' }, { TRUSTED_PROXIES: '::1' }) + expect(fromEnv.trustedProxies).toEqual(['::1']) + const fromFile = loadConfig({ upstream: 'http://x' }, {}, { trustedProxies: ['172.16.0.0/12'] }) + expect(fromFile.trustedProxies).toEqual(['172.16.0.0/12']) + expect(loadConfig({ upstream: 'http://x' }).trustProxy).toBe(false) + }) + + it('rejects entries that are not IPs or CIDRs', () => { + expect(() => loadConfig({ upstream: 'http://x', trustedProxies: 'proxy.example.com' })).toThrow(/trusted proxy/) + }) +}) + +describe('pending invoice limit', () => { + it('defaults to 20 and can be set or disabled', () => { + expect(loadConfig({ upstream: 'http://x' }).maxPendingInvoicesPerIp).toBe(20) + expect(loadConfig({ upstream: 'http://x', maxPendingInvoices: 0 }).maxPendingInvoicesPerIp).toBe(0) + expect(loadConfig({ upstream: 'http://x' }, { SATGATE_MAX_PENDING_INVOICES: '5' }).maxPendingInvoicesPerIp).toBe(5) + expect(loadConfig({ upstream: 'http://x' }, {}, { maxPendingInvoicesPerIp: 7 }).maxPendingInvoicesPerIp).toBe(7) + expect(() => loadConfig({ upstream: 'http://x', maxPendingInvoices: -1 })).toThrow(/pending invoices/) + }) +}) + +describe('storage defaults', () => { + it('uses SQLite when payments are accepted, memory otherwise', () => { + expect(loadConfig({ upstream: 'http://x' }).storage).toBe('memory') + expect(loadConfig({ upstream: 'http://x', lightning: 'phoenixd' }).storage).toBe('sqlite') + expect(loadConfig({ upstream: 'http://x', cashuMints: 'https://mint.example.com' }).storage).toBe('sqlite') + expect(loadConfig({ upstream: 'http://x', lightning: 'phoenixd', storage: 'memory' }).storage).toBe('memory') + }) +}) + +describe('concurrency limit', () => { + it('can still be switched off explicitly', () => { + expect(loadConfig({ upstream: 'http://x', maxConcurrent: 0 }).capacity.maxConcurrent).toBe(0) + expect(loadConfig({ upstream: 'http://x' }, { MAX_CONCURRENT: '0' }).capacity.maxConcurrent).toBe(0) + }) +}) + +describe('LNURLcash without Lightning', () => { + it('requires payment rather than falling back to open mode', () => { + const config = loadConfig({ upstream: 'http://x', lnurlcashMints: 'mint.example.com' }) + expect(config.authMode).toBe('cashu') + expect(config.tunnel).toBe(true) + }) +}) diff --git a/src/config.ts b/src/config.ts index d6e2af2..6043b8e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -13,6 +13,8 @@ export interface ModelPricing { export interface TokenTollConfig { upstream: string + /** Bearer token sent to the upstream, for hosted OpenAI-compatible APIs. */ + upstreamKey?: string port: number rootKey: string rootKeyGenerated: boolean @@ -23,10 +25,27 @@ export interface TokenTollConfig { capacity: { maxConcurrent: number } tiers: Array<{ amountSats: number; creditSats: number; label: string }> trustProxy: boolean + /** + * IPs or IPv4 CIDRs of the reverse proxies in front of satgate. With these + * set, X-Forwarded-For is read right to left skipping them, so a client + * cannot pick its own IP by prepending entries. + */ + trustedProxies: string[] /** Estimated cost in sats to hold per request (deducted upfront, reconciled after). */ estimatedCostSats: number + /** + * Most completion tokens one request may ask for. Requests asking for more + * (or naming no limit) are clamped to this, and per-token billing reserves + * the worst case up front. + */ + maxTokens: number /** Maximum request body size in bytes. */ maxBodySize: number + /** + * Unpaid invoices one client IP may hold before further challenges and + * /create-invoice calls get a 429. 0 disables the limit. + */ + maxPendingInvoicesPerIp: number /** Auto-detected model IDs from upstream. */ models?: string[] // New fields: @@ -87,6 +106,9 @@ export interface TokenTollConfig { export interface CliArgs { upstream?: string + /** Upstream API key, read by the CLI from --upstream-key-file (never a flag value). */ + upstreamKey?: string + upstreamKeyFile?: string port?: number config?: string price?: number @@ -95,7 +117,10 @@ export interface CliArgs { dbPath?: string freeTier?: number trustProxy?: boolean + trustedProxies?: string rootKey?: string + maxTokens?: number + maxPendingInvoices?: number // New fields: lightning?: string lightningUrl?: string @@ -104,6 +129,7 @@ export interface CliArgs { allowlist?: string[] allowlistFile?: string noTunnel?: boolean + tunnel?: boolean verbose?: boolean logFormat?: string tokenPrice?: number @@ -119,6 +145,8 @@ export interface CliArgs { export interface FileConfig { upstream?: string + /** Path to a file holding the upstream API key. */ + upstreamKeyFile?: string port?: number rootKey?: string storage?: string @@ -128,8 +156,11 @@ export interface FileConfig { capacity?: { maxConcurrent?: number } tiers?: Array<{ amountSats: number; creditSats: number; label: string }> trustProxy?: boolean + trustedProxies?: string[] estimatedCostSats?: number + maxTokens?: number maxBodySize?: number + maxPendingInvoicesPerIp?: number // New fields: lightning?: string lightningUrl?: string @@ -175,6 +206,21 @@ export function normaliseMintHost(entry: string): string | undefined { } } +/** + * Default cap on concurrent inference requests. Beyond it clients get a + * quick 503 rather than queueing on the GPU; 0 means no limit. + */ +export const DEFAULT_MAX_CONCURRENT = 8 + +/** Default cap on completion tokens per request. */ +export const DEFAULT_MAX_TOKENS = 2048 + +/** Default cap on unpaid invoices per client IP. */ +export const DEFAULT_MAX_PENDING_INVOICES_PER_IP = 20 + +/** Request body size, in bytes, that the default per-request hold is sized to cover. */ +export const PROMPT_ALLOWANCE_BYTES = 4096 + const LIGHTNING_URL_DEFAULTS: Record = { phoenixd: 'http://localhost:9740', lnbits: 'https://legend.lnbits.com', @@ -205,6 +251,8 @@ export function loadConfig( throw new Error(`upstream URL is not a valid URL: ${upstream}`) } + const upstreamKey = (args.upstreamKey ?? env.UPSTREAM_API_KEY)?.trim() || undefined + const portRaw = args.port ?? (env.PORT ? parseInt(env.PORT, 10) : undefined) ?? file.port ?? 3000 if (!Number.isFinite(portRaw) || portRaw < 0 || portRaw > 65535) { throw new Error(`Invalid port: ${portRaw} (must be 0–65535)`) @@ -218,12 +266,6 @@ export function loadConfig( } const rootKey = rootKeyRaw ?? randomBytes(32).toString('hex') - const storageRaw = args.storage ?? env.STORAGE ?? file.storage ?? 'memory' - if (storageRaw !== 'memory' && storageRaw !== 'sqlite') { - throw new Error(`Invalid storage type: ${storageRaw} (must be 'memory' or 'sqlite')`) - } - const storage = storageRaw as 'memory' | 'sqlite' - const dbPathRaw = args.dbPath ?? env.SATGATE_DB_PATH ?? file.dbPath ?? './satgate.db' // Canonicalise cwd to handle symlinked working directories let canonCwd: string @@ -329,16 +371,28 @@ export function loadConfig( const maxConcurrent = args.maxConcurrent ?? (env.MAX_CONCURRENT ? parseInt(env.MAX_CONCURRENT, 10) : undefined) ?? file.capacity?.maxConcurrent - ?? 0 + ?? DEFAULT_MAX_CONCURRENT if (!Number.isFinite(maxConcurrent) || maxConcurrent < 0) { throw new Error(`Invalid max concurrent value: ${maxConcurrent} (must be a non-negative integer)`) } + const trustedProxiesRaw = args.trustedProxies ?? env.TRUSTED_PROXIES + const trustedProxies = (trustedProxiesRaw !== undefined + ? trustedProxiesRaw.split(',') + : file.trustedProxies ?? [] + ).map(p => String(p).trim()).filter(Boolean) + for (const entry of trustedProxies) { + if (!/^[0-9a-fA-F.:]+(\/\d{1,2})?$/.test(entry)) { + throw new Error(`Invalid trusted proxy: ${entry} (expected an IP or IPv4 CIDR)`) + } + } + + // Naming trusted proxies implies trusting forwarded headers from them const trustProxy = args.trustProxy !== undefined ? args.trustProxy : env.TRUST_PROXY !== undefined ? env.TRUST_PROXY === 'true' - : file.trustProxy ?? false + : file.trustProxy ?? trustedProxies.length > 0 const tiers = file.tiers ?? [] @@ -348,8 +402,22 @@ export function loadConfig( if (estimatedCostRaw !== undefined && !Number.isFinite(estimatedCostRaw)) { throw new Error(`Invalid SATGATE_ESTIMATED_COST: ${env.SATGATE_ESTIMATED_COST}`) } + const maxTokens = args.maxTokens + ?? (env.SATGATE_MAX_TOKENS ? parseInt(env.SATGATE_MAX_TOKENS, 10) : undefined) + ?? file.maxTokens + ?? DEFAULT_MAX_TOKENS + if (!Number.isSafeInteger(maxTokens) || maxTokens < 1) { + throw new Error(`Invalid max tokens: ${maxTokens} (must be a positive integer)`) + } + + // Default hold: enough for a request body of PROMPT_ALLOWANCE_BYTES plus a + // full max_tokens completion at the dearest configured price. Per-request + // payments and the free tier are capped at this amount, so it sets how + // large a request they can make before max_tokens is clamped further. + const dearestPrice = Math.max(pricing.default, ...Object.values(pricing.models)) const estimatedCostSats = estimatedCostRaw - ?? file.estimatedCostSats ?? Math.max(pricing.default * 2, 5) + ?? file.estimatedCostSats + ?? Math.max(1, Math.ceil((PROMPT_ALLOWANCE_BYTES + maxTokens) * dearestPrice / 1000)) const maxBodySizeRaw = file.maxBodySize ?? 10 * 1024 * 1024 // 10 MiB const MAX_BODY_SIZE_LIMIT = 100 * 1024 * 1024 // 100 MiB hard cap if (typeof maxBodySizeRaw !== 'number' || !Number.isFinite(maxBodySizeRaw) || maxBodySizeRaw <= 0 || maxBodySizeRaw > MAX_BODY_SIZE_LIMIT) { @@ -357,6 +425,14 @@ export function loadConfig( } const maxBodySize = maxBodySizeRaw + const maxPendingInvoicesPerIp = args.maxPendingInvoices + ?? (env.SATGATE_MAX_PENDING_INVOICES ? parseInt(env.SATGATE_MAX_PENDING_INVOICES, 10) : undefined) + ?? file.maxPendingInvoicesPerIp + ?? DEFAULT_MAX_PENDING_INVOICES_PER_IP + if (!Number.isSafeInteger(maxPendingInvoicesPerIp) || maxPendingInvoicesPerIp < 0) { + throw new Error(`Invalid max pending invoices: ${maxPendingInvoicesPerIp} (must be a non-negative integer)`) + } + // Lightning backend config // nwc carries its relay inside the connection URI, so it needs no lightning URL. const VALID_BACKENDS = ['phoenixd', 'lnbits', 'lnd', 'cln', 'nwc'] as const @@ -425,6 +501,15 @@ export function loadConfig( }) const lnurlcash = lnurlcashMints ? { mints: lnurlcashMints } : undefined + // Storage. Paid credits must survive a restart, so any payment rail + // defaults to SQLite; memory is for open or allowlist use. + const acceptsPayment = Boolean(lightning || cashu || lnurlcash) + const storageRaw = args.storage ?? env.STORAGE ?? file.storage ?? (acceptsPayment ? 'sqlite' : 'memory') + if (storageRaw !== 'memory' && storageRaw !== 'sqlite') { + throw new Error(`Invalid storage type: ${storageRaw} (must be 'memory' or 'sqlite')`) + } + const storage = storageRaw as 'memory' | 'sqlite' + // Auth mode inference const VALID_AUTH_MODES = ['open', 'lightning', 'cashu', 'allowlist'] as const const explicitAuth = args.authMode ?? env.AUTH_MODE ?? file.auth @@ -437,11 +522,13 @@ export function loadConfig( if (authMode === 'lightning' && !lightning) { throw new Error("auth mode 'lightning' requires --lightning ") } - if (authMode === 'cashu' && !cashu) { - throw new Error("auth mode 'cashu' requires --cashu-mints ") + if (authMode === 'cashu' && !cashu && !lnurlcash) { + throw new Error("auth mode 'cashu' requires --cashu-mints or --lnurlcash-mints ") } } else { - authMode = lightning ? 'lightning' : cashu ? 'cashu' : 'open' + // 'cashu' is the bearer-ecash mode: payment without a Lightning backend, + // by Cashu tokens or LNURLcash notes + authMode = lightning ? 'lightning' : (cashu || lnurlcash) ? 'cashu' : 'open' } // Allowlist @@ -450,9 +537,12 @@ export function loadConfig( throw new Error("auth mode 'allowlist' requires --allowlist or --allowlist-file ") } - // Tunnel + // Tunnel. On by default only when requests need payment or an allowlist: + // an open-auth proxy is never published to the internet unless asked for. const tunnelEnv = env.TUNNEL !== undefined ? env.TUNNEL !== 'false' : undefined - const tunnel = args.noTunnel === true ? false : (tunnelEnv ?? file.tunnel ?? true) + const tunnel = args.noTunnel === true + ? false + : (args.tunnel ?? tunnelEnv ?? file.tunnel ?? authMode !== 'open') // x402 stablecoin config const x402Receiver = env.X402_RECEIVER ?? file.x402?.receiverAddress @@ -513,6 +603,7 @@ export function loadConfig( return { upstream: upstream.replace(/\/+$/, ''), + upstreamKey, port, rootKey, rootKeyGenerated, @@ -523,8 +614,11 @@ export function loadConfig( capacity: { maxConcurrent }, tiers, trustProxy, + trustedProxies, estimatedCostSats, + maxTokens, maxBodySize, + maxPendingInvoicesPerIp, lightning, lightningUrl, lightningKey, diff --git a/src/discovery/llms-txt.test.ts b/src/discovery/llms-txt.test.ts index ae4f00d..3e7f580 100644 --- a/src/discovery/llms-txt.test.ts +++ b/src/discovery/llms-txt.test.ts @@ -17,6 +17,7 @@ describe('generateLlmsTxt', () => { const text = generateLlmsTxt({ pricing: { default: 1, models: {} }, models: ['llama3'], + lightning: 'phoenixd', }) expect(text).toContain('/v1/chat/completions') expect(text).toContain('402') @@ -32,4 +33,29 @@ describe('generateLlmsTxt', () => { expect(text).toContain('x402') expect(text).toContain('base') }) + + it('names only the payment methods that are configured', () => { + const lightningOnly = generateLlmsTxt({ pricing: { default: 1, models: {} }, models: [], lightning: 'phoenixd', ietfPayment: true }) + expect(lightningOnly).toContain('Pay with Lightning.') + expect(lightningOnly).not.toMatch(/NWC|Cashu|LNURLcash|x402/) + + const cashuOnly = generateLlmsTxt({ pricing: { default: 1, models: {} }, models: [], cashu: true }) + expect(cashuOnly).toContain('Pay with Cashu ecash.') + expect(cashuOnly).not.toContain('L402') + expect(cashuOnly).not.toContain('/create-invoice') + + const everything = generateLlmsTxt({ pricing: { default: 1, models: {} }, models: [], lightning: 'nwc', cashu: true, lnurlcash: true, x402: { network: 'base' } }) + expect(everything).toContain('Lightning, Cashu ecash, LNURLcash (LUD-25) bearer notes, x402 stablecoins (base)') + }) + + it('shows the flat price instead of per-token rates in flat mode', () => { + const text = generateLlmsTxt({ pricing: { default: 1, models: { llama3: 2 } }, models: ['llama3'], lightning: 'phoenixd', flatPriceSats: 3 }) + expect(text).toContain('flat 3 sats') + expect(text).not.toContain('/ 1k tokens') + }) + + it('prices tagged models by their base entry', () => { + const text = generateLlmsTxt({ pricing: { default: 1, models: { llama3: 4 } }, models: ['llama3:8b'] }) + expect(text).toContain('llama3:8b (4 sats / 1k tokens)') + }) }) diff --git a/src/discovery/llms-txt.ts b/src/discovery/llms-txt.ts index fbc7d9c..5379276 100644 --- a/src/discovery/llms-txt.ts +++ b/src/discovery/llms-txt.ts @@ -1,48 +1,69 @@ import type { ModelPricing } from '../config.js' +import { resolveModelPrice } from '../proxy/pricing.js' export interface LlmsTxtInput { pricing: ModelPricing models: string[] + /** Lightning backend in use (phoenixd, lnbits, lnd, cln, nwc), if any. */ + lightning?: string + /** Flat per-request price in sats; when set, per-token prices are not shown. */ + flatPriceSats?: number x402?: { network: string } cashu?: boolean + lnurlcash?: boolean ietfPayment?: boolean } +function sats(n: number): string { + return `${n} ${n === 1 ? 'sat' : 'sats'}` +} + export function generateLlmsTxt(input: LlmsTxtInput): string { - const modelLines = input.models.map((model) => { - const price = input.pricing.models[model] ?? input.pricing.default - const unit = price === 1 ? 'sat' : 'sats' - return `- ${model} (${price} ${unit} / 1k tokens)` - }) + const modelLines = input.models.map((model) => input.flatPriceSats !== undefined + ? `- ${model}` + : `- ${model} (${sats(resolveModelPrice(input.pricing, model))} / 1k tokens)`) - const methods = ['Lightning', input.cashu ? 'Cashu ecash' : ''].filter(Boolean).join(' or ') + const methods = [ + input.lightning ? 'Lightning' : '', + input.cashu ? 'Cashu ecash' : '', + input.lnurlcash ? 'LNURLcash (LUD-25) bearer notes' : '', + input.x402 ? `x402 stablecoins (${input.x402.network})` : '', + ].filter(Boolean) - const schemes = ['L402 (credit-based)', input.ietfPayment ? 'IETF Payment (per-request, draft-ryan-httpauth-payment-01)' : ''].filter(Boolean) + const schemes = [ + input.lightning ? 'L402 (credit-based: pay once, spend the balance over many requests)' : '', + input.lightning && input.ietfPayment ? 'IETF Payment (per-request, draft-ryan-httpauth-payment-01)' : '', + ].filter(Boolean) + + const pricingLine = input.flatPriceSats !== undefined + ? `Each request costs a flat ${sats(input.flatPriceSats)}.` + : 'Requests are billed per token (prompt + completion) at the rates above. The worst case for max_tokens is held up front and the unused part refunded once the upstream reports usage.' return `# satgate - Lightning-paid AI inference -> This endpoint provides OpenAI-compatible inference behind Lightning payments. -> Pay with ${methods}. No account required. +> This endpoint provides OpenAI-compatible inference behind HTTP 402 payments. +> ${methods.length > 0 ? `Pay with ${methods.join(', ')}.` : 'No payment rails are configured.'} No account required. ## Available Models ${modelLines.join('\n')} +## Pricing +${pricingLine} + ## Payment Schemes -This endpoint supports ${schemes.length > 1 ? 'dual-scheme challenges' : schemes[0]}: -${schemes.map(s => `- ${s}`).join('\n')} +${schemes.length > 0 ? schemes.map(s => `- ${s}`).join('\n') : '- None (no Lightning backend configured)'} -When credits are exhausted, the server returns HTTP 402 with both schemes in the WWW-Authenticate header. Clients can choose either scheme. +When payment is needed, the server returns HTTP 402 with a challenge for each scheme it accepts. Clients can choose any of them. ## Usage -Send standard OpenAI-compatible requests to /v1/chat/completions. -First request returns 402 with a Lightning invoice. -Pay the invoice, then retry with your chosen credential: -- L402: Authorization: L402 : -${input.ietfPayment ? '- IETF Payment: Authorization: Payment \n' : ''} -## Payment -POST /create-invoice to get a Lightning invoice. -Supports Lightning${input.cashu ? ', Cashu ecash,' : ''} and NWC. -${input.x402 ? `Also accepts x402 stablecoin payments (${input.x402.network} network).\n` : ''}${input.ietfPayment ? `## IETF Payment Authentication +Send standard OpenAI-compatible requests to /v1/chat/completions, /v1/completions or /v1/embeddings. +The first request returns 402 with payment details. Pay, then retry with your credential: +${input.lightning ? '- L402: Authorization: L402 :\n' : ''}${input.lightning && input.ietfPayment ? '- IETF Payment: Authorization: Payment \n' : ''}${input.cashu ? '- Cashu: X-Cashu: \n' : ''}${input.lnurlcash ? '- LNURLcash: X-LNURLcash: \n' : ''}${input.x402 ? '- x402: PAYMENT-SIGNATURE header\n' : ''} +${input.lightning ? `## Buying credit +POST /create-invoice to get a Lightning invoice for credit up front. +` : ''}${input.lightning && input.ietfPayment ? ` +## IETF Payment Authentication Implements draft-ryan-httpauth-payment-01 with the lightning payment method. -See: https://github.com/forgesworn/payment-methods\n` : ''}` +See: https://github.com/forgesworn/payment-methods +` : ''}` } diff --git a/src/discovery/openapi.test.ts b/src/discovery/openapi.test.ts index 42b2eaa..f165723 100644 --- a/src/discovery/openapi.test.ts +++ b/src/discovery/openapi.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest' import { generateOpenApiSpec, type OpenApiInput } from './openapi.js' +import { readPackageVersion } from '../version.js' describe('generateOpenApiSpec', () => { it('generates valid OpenAPI 3.1 structure', () => { @@ -37,4 +38,22 @@ describe('generateOpenApiSpec', () => { }) expect(spec.components.securitySchemes).not.toHaveProperty('x402') }) + + it('reports the package version rather than a fixed 1.0.0', () => { + const spec = generateOpenApiSpec({ models: [], pricing: { default: 1, models: {} } }) + expect(spec.info.version).toBe(readPackageVersion()) + expect(spec.info.version).not.toBe('1.0.0') + }) + + it('declares the IETF Payment scheme and lets any scheme authorise inference', () => { + const spec = generateOpenApiSpec({ models: [], pricing: { default: 1, models: {} }, lightning: true, cashu: true }) + expect(spec.components.securitySchemes.payment).toMatchObject({ type: 'http', scheme: 'Payment' }) + expect(spec.components.securitySchemes.cashu).toMatchObject({ type: 'apiKey', name: 'X-Cashu' }) + expect(spec.paths['/v1/chat/completions'].post.security).toEqual([{ l402: [] }, { payment: [] }, { cashu: [] }]) + }) + + it('declares no Lightning schemes without Lightning', () => { + const spec = generateOpenApiSpec({ models: [], pricing: { default: 1, models: {} }, lightning: false, cashu: true }) + expect(Object.keys(spec.components.securitySchemes)).toEqual(['cashu']) + }) }) diff --git a/src/discovery/openapi.ts b/src/discovery/openapi.ts index 4e9b873..c767a16 100644 --- a/src/discovery/openapi.ts +++ b/src/discovery/openapi.ts @@ -1,9 +1,16 @@ import type { ModelPricing } from '../config.js' +import { readPackageVersion } from '../version.js' export interface OpenApiInput { models: string[] pricing: ModelPricing + /** Lightning is configured: L402 and IETF Payment credentials are accepted. */ + lightning?: boolean x402?: boolean + cashu?: boolean + lnurlcash?: boolean + /** Defaults to satgate's package version. */ + version?: string } export function generateOpenApiSpec(input: OpenApiInput): Record { @@ -12,7 +19,7 @@ export function generateOpenApiSpec(input: OpenApiInput): Record { info: { title: 'satgate', description: 'Lightning-paid AI inference (OpenAI-compatible)', - version: '1.0.0', + version: input.version ?? readPackageVersion() ?? '0.0.0', }, paths: { '/v1/chat/completions': { @@ -113,13 +120,35 @@ export function generateOpenApiSpec(input: OpenApiInput): Record { }, } - if (input.x402) { - spec.components.securitySchemes.x402 = { + const schemes = spec.components.securitySchemes + if (input.lightning === false) delete schemes.l402 + if (input.lightning !== false) { + schemes.payment = { type: 'http', - scheme: 'x-payment', - description: 'x402 stablecoin payment via x-payment header', + scheme: 'Payment', + description: 'IETF Payment authentication (draft-ryan-httpauth-payment-01), lightning method: Authorization: Payment ', + } + } + if (input.x402) { + schemes.x402 = { + type: 'apiKey', + in: 'header', + name: 'PAYMENT-SIGNATURE', + description: 'x402 stablecoin payment', } } + if (input.cashu) { + schemes.cashu = { type: 'apiKey', in: 'header', name: 'X-Cashu', description: 'Cashu ecash token (cashuB)' } + } + if (input.lnurlcash) { + schemes.lnurlcash = { type: 'apiKey', in: 'header', name: 'X-LNURLcash', description: 'LUD-25 bearer note URL' } + } + + // Any one accepted scheme authorises a paid request + const security = Object.keys(schemes).map(name => ({ [name]: [] })) + for (const path of ['/v1/chat/completions', '/v1/completions', '/v1/embeddings']) { + spec.paths[path].post.security = security + } return spec } diff --git a/src/page/index.html b/src/page/index.html index 0389740..01041e0 100644 --- a/src/page/index.html +++ b/src/page/index.html @@ -4,12 +4,12 @@ satgate — Lightning-paid AI inference - + - + @@ -598,7 +598,8 @@

Satgate

var serverTiersRaw = []; // raw tier data for recalc on model switch // Free token budget — tracked client-side, resets daily - var FREE_TOKEN_BUDGET = 250; + // Set from the server's free-tier allowance in init(); 0 when there is none + var FREE_TOKEN_BUDGET = 0; var freeTokensKey = 'satgate_free_tokens'; var freeTokensDayKey = 'satgate_free_day'; function todayStr() { return new Date().toISOString().slice(0, 10); } @@ -716,9 +717,15 @@

Satgate

// Build model selector cards buildModelCards(models, modelDescriptions); - // Free tier chip + // Free tier chip, from the server's actual allowance (sats of credit per day) + var freeCredits = Number(freeTier.credits_per_day) || 0; + FREE_TOKEN_BUDGET = freeCredits > 0 && pricePerThousand + ? Math.floor(freeCredits * 1000 / pricePerThousand) + : 0; while (freeChipEl.firstChild) { freeChipEl.removeChild(freeChipEl.firstChild); } - freeChipEl.appendChild(el('span', { className: 'chip', textContent: formatTokenCount(FREE_TOKEN_BUDGET) + ' free tokens to start' })); + if (freeCredits > 0) { + freeChipEl.appendChild(el('span', { className: 'chip', textContent: freeCredits + ' sats of free credit a day' })); + } // Store raw tiers for recalc on model switch serverTiersRaw = (data.payment && data.payment.tiers) || []; diff --git a/src/proxy/handler-billing.test.ts b/src/proxy/handler-billing.test.ts new file mode 100644 index 0000000..03576bb --- /dev/null +++ b/src/proxy/handler-billing.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { Hono } from 'hono' +import { serve } from '@hono/node-server' +import { createProxyHandler, type ProxyDeps } from './handler.js' +import { CapacityTracker } from './capacity.js' + +/** Upstream that records every request body and replies with a fixed SSE script. */ +async function startUpstream(sse: string[] = ['data: [DONE]\n\n']) { + const bodies: Record[] = [] + const app = new Hono() + app.post('/v1/chat/completions', async (c) => { + const body = await c.req.json() + bodies.push(body) + if (body.stream) { + const encoder = new TextEncoder() + const stream = new ReadableStream({ + start(controller) { + for (const chunk of sse) controller.enqueue(encoder.encode(chunk)) + controller.close() + }, + }) + return new Response(stream, { headers: { 'Content-Type': 'text/event-stream' } }) + } + return c.json({ choices: [], usage: { prompt_tokens: 1, completion_tokens: 1 } }) + }) + let server: ReturnType | undefined + const url = await new Promise((resolve) => { + server = serve({ fetch: app.fetch, port: 0 }, (info) => resolve(`http://localhost:${info.port}`)) + }) + return { url, bodies, close: () => server?.close() } +} + +let upstream: Awaited> | undefined +afterEach(() => upstream?.close()) + +function deps(url: string, overrides: Partial = {}): ProxyDeps { + return { + upstream: url, + pricing: { default: 1, models: {} }, + capacity: new CapacityTracker(0), + reconcile: vi.fn().mockReturnValue({ adjusted: true, newBalance: 0, delta: 0 }), + maxBodySize: 10 * 1024 * 1024, + ...overrides, + } +} + +function chat(url: string, body: Record) { + return new Request(`${url}/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +describe('stream usage reporting', () => { + it('forces include_usage even when the client turns it off', async () => { + upstream = await startUpstream() + const handler = createProxyHandler(deps(upstream.url)) + const res = await handler(chat(upstream.url, { + model: 'm', messages: [], stream: true, + stream_options: { include_usage: false, other: 'kept' }, + }), 'a'.repeat(64)) + await res.text() + expect(upstream.bodies[0].stream_options).toEqual({ include_usage: true, other: 'kept' }) + }) + + it('forces include_usage when stream_options is not an object', async () => { + upstream = await startUpstream() + const handler = createProxyHandler(deps(upstream.url)) + const res = await handler(chat(upstream.url, { model: 'm', messages: [], stream: true, stream_options: 'nope' }), 'a'.repeat(64)) + await res.text() + expect(upstream.bodies[0].stream_options).toEqual({ include_usage: true }) + }) +}) diff --git a/src/proxy/handler.ts b/src/proxy/handler.ts index 4f10d06..515a24b 100644 --- a/src/proxy/handler.ts +++ b/src/proxy/handler.ts @@ -1,7 +1,8 @@ import { TokenCounter } from './token-counter.js' import { createStreamingProxy } from './streaming.js' -import { resolveModelPrice, tokenCostToSats } from './pricing.js' +import { isServedModel, resolveModelPrice, tokenCostToSats } from './pricing.js' import type { CapacityTracker } from './capacity.js' +import { reconcileHold, unmeteredHold, type Hold } from './hold.js' import type { ModelPricing } from '../config.js' import type { Logger } from '../logger.js' @@ -10,18 +11,47 @@ const ALLOWED_PATH_PREFIXES = ['/v1/chat/completions', '/v1/completions', '/v1/e export interface ProxyDeps { upstream: string + /** Bearer token for the upstream API, if it needs one. */ + upstreamKey?: string pricing: ModelPricing capacity: CapacityTracker - reconcile: (paymentHash: string, actualCost: number) => { adjusted: boolean; newBalance: number; delta: number } + /** + * Settles a payment hash at its actual cost. Only used when the caller + * passes no hold; the server always passes one. + */ + reconcile?: (paymentHash: string, actualCost: number) => { adjusted: boolean; newBalance: number; delta: number } maxBodySize: number /** When true, skip token-based reconciliation — a flat per-request fee was charged upfront. */ flatPricing?: boolean + /** Most completion tokens one request may ask for (default: 2048). */ + maxTokens?: number /** Timeout in ms for upstream requests (default: 120_000). */ upstreamTimeout?: number + /** + * Model names this gateway serves (auto-detected upstream models plus + * configured prices). Requests for any other name are refused before + * reaching the upstream. Empty or omitted: no check. + */ + models?: readonly string[] /** Logger instance — if omitted, errors are silent. */ logger?: Logger } +/** Default cap on completion tokens when the caller configures none. */ +const DEFAULT_MAX_TOKENS = 2048 + +/** Most choices (`n` / `best_of`) one request may ask for. */ +const MAX_CHOICES = 8 + +/** A positive integer, or undefined for anything else. */ +function positiveInt(value: unknown): number | undefined { + return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? value : undefined +} + +function jsonError(status: number, body: Record): Response { + return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }) +} + /** * Extracts the model name from an OpenAI-compatible request body. */ @@ -38,11 +68,18 @@ export function createProxyHandler(deps: ProxyDeps) { return async function handleProxy( req: Request, paymentHash: string | undefined, + suppliedHold?: Hold, ): Promise { + const hold = suppliedHold + ?? (paymentHash && deps.reconcile ? reconcileHold(deps.reconcile, paymentHash) : unmeteredHold()) + // Refund helper for every path that serves no inference + const refund = () => hold.settle(0) + // Validate request before acquiring capacity — cheap checks first to avoid // tying up capacity slots during body reads or for invalid requests const requestPath = new URL(req.url).pathname if (!ALLOWED_PATH_PREFIXES.some(p => requestPath === p)) { + refund() return new Response( JSON.stringify({ error: 'Not found' }), { status: 404, headers: { 'Content-Type': 'application/json' } }, @@ -52,6 +89,7 @@ export function createProxyHandler(deps: ProxyDeps) { const contentType = req.headers.get('content-type') const mediaType = contentType?.split(';')[0]?.trim().toLowerCase() if (mediaType !== 'application/json') { + refund() return new Response( JSON.stringify({ error: 'Content-Type must be application/json' }), { status: 415, headers: { 'Content-Type': 'application/json' } }, @@ -62,6 +100,7 @@ export function createProxyHandler(deps: ProxyDeps) { if (contentLength !== null) { const len = parseInt(contentLength, 10) if (!Number.isFinite(len) || len > deps.maxBodySize) { + refund() return new Response( JSON.stringify({ error: 'Request body too large' }), { status: 413, headers: { 'Content-Type': 'application/json' } }, @@ -82,7 +121,7 @@ export function createProxyHandler(deps: ProxyDeps) { while (true) { if (bodyAbort.aborted) { await reader.cancel('body read deadline exceeded').catch(() => {}) - if (paymentHash) deps.reconcile(paymentHash, 0) + refund() return new Response( JSON.stringify({ error: 'Request body read timed out' }), { status: 408, headers: { 'Content-Type': 'application/json' } }, @@ -100,7 +139,7 @@ export function createProxyHandler(deps: ProxyDeps) { totalBytes += result.value.byteLength if (totalBytes > deps.maxBodySize) { await reader.cancel('body too large').catch(() => {}) - if (paymentHash) deps.reconcile(paymentHash, 0) + refund() return new Response( JSON.stringify({ error: 'Request body too large' }), { status: 413, headers: { 'Content-Type': 'application/json' } }, @@ -112,7 +151,7 @@ export function createProxyHandler(deps: ProxyDeps) { } catch (err) { await reader.cancel('body read failed').catch(() => {}) // Refund any pre-reserved Lightning payment on body read failure - if (paymentHash) deps.reconcile(paymentHash, 0) + refund() if (err instanceof DOMException && err.name === 'TimeoutError') { return new Response( JSON.stringify({ error: 'Request body read timed out' }), @@ -132,6 +171,7 @@ export function createProxyHandler(deps: ProxyDeps) { try { const parsed = JSON.parse(bodyText) if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + refund() return new Response( JSON.stringify({ error: 'Request body must be a JSON object' }), { status: 400, headers: { 'Content-Type': 'application/json' } }, @@ -139,14 +179,87 @@ export function createProxyHandler(deps: ProxyDeps) { } body = parsed } catch { + refund() return new Response( JSON.stringify({ error: 'Invalid JSON body' }), { status: 400, headers: { 'Content-Type': 'application/json' } }, ) } + // Refuse models this gateway does not serve. Pricing looks models up by + // name, so a name the upstream resolves to a priced model but pricing + // does not recognise would otherwise be billed at the default price. + const requestedModel = extractModel(body) + if (!isServedModel(requestedModel, deps.models ?? [])) { + refund() + return new Response( + JSON.stringify({ error: `Unknown model: ${requestedModel.slice(0, 200) || '(none)'}`, models: deps.models }), + { status: 400, headers: { 'Content-Type': 'application/json' } }, + ) + } + + // Bound the completion. The client's max_tokens (or max_completion_tokens) + // is clamped to the operator cap, and set to it when absent, so no request + // can generate without limit. Per-token billing then holds the worst case: + // every byte of the body as a prompt token (a token is at least one byte) + // plus max_tokens for each choice asked for. + const generates = requestPath !== '/v1/embeddings' + const cap = deps.maxTokens ?? DEFAULT_MAX_TOKENS + let maxTokens = 0 + let choices = 1 + if (generates) { + const nRaw = body.n ?? 1 + const bestOfRaw = body.best_of ?? 1 + const n = positiveInt(nRaw) + const bestOf = positiveInt(bestOfRaw) + if (n === undefined || bestOf === undefined || n > MAX_CHOICES || bestOf > MAX_CHOICES) { + refund() + return jsonError(400, { error: `n and best_of must be integers from 1 to ${MAX_CHOICES}` }) + } + choices = Math.max(n, bestOf) + const requested = positiveInt(body.max_completion_tokens) ?? positiveInt(body.max_tokens) + maxTokens = Math.min(requested ?? cap, cap) + } + + if (!deps.flatPricing && hold.metered) { + const price = resolveModelPrice(deps.pricing, requestedModel) + const promptBound = new TextEncoder().encode(bodyText).byteLength + if (hold.ceiling !== undefined) { + // Paid for this request alone: shrink the completion to fit what was paid + const affordableTokens = Math.floor(hold.ceiling * 1000 / price) + if (promptBound + maxTokens * choices > affordableTokens) { + maxTokens = Math.floor((affordableTokens - promptBound) / choices) + if (promptBound > affordableTokens || (generates && maxTokens < 1)) { + refund() + return jsonError(402, { + error: 'Request is too large for the amount paid per request', + paid_sats: hold.ceiling, + }) + } + } + } else { + const worstCase = tokenCostToSats(promptBound + maxTokens * choices, price) + if (!hold.reserve(worstCase)) { + refund() + return jsonError(402, { + error: 'Insufficient balance to reserve this request\'s maximum cost. Lower max_tokens or top up.', + reserve_sats: worstCase, + max_tokens: maxTokens, + }) + } + } + } + + // max_tokens is always set, since not every upstream honours + // max_completion_tokens; a client's max_completion_tokens is clamped too. + if (generates) { + body.max_tokens = maxTokens + if (body.max_completion_tokens !== undefined) body.max_completion_tokens = maxTokens + } + // Now acquire capacity — body is validated and parsed, no slow client can hold a slot if (!deps.capacity.tryAcquire()) { + refund() return new Response( JSON.stringify({ error: 'Service at capacity, try again later' }), { status: 503, headers: { 'Content-Type': 'application/json', 'Retry-After': '5' } }, @@ -156,13 +269,16 @@ export function createProxyHandler(deps: ProxyDeps) { const start = Date.now() let streamingResponse = false try { - const model = extractModel(body) - const pricePerThousand = resolveModelPrice(deps.pricing, model) + const pricePerThousand = resolveModelPrice(deps.pricing, requestedModel) const isStreaming = body.stream === true - // Inject stream_options for usage reporting if streaming - if (isStreaming && !body.stream_options) { - body.stream_options = { include_usage: true } + // Always ask for usage on streams: billing depends on it, so a client + // must not be able to switch it off with its own stream_options. + if (isStreaming) { + const clientOptions = typeof body.stream_options === 'object' && body.stream_options !== null && !Array.isArray(body.stream_options) + ? body.stream_options as Record + : {} + body.stream_options = { ...clientOptions, include_usage: true } } // Build upstream URL using the already-validated requestPath @@ -174,15 +290,16 @@ export function createProxyHandler(deps: ProxyDeps) { try { upstreamRes = await fetch(upstreamUrl, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + ...(deps.upstreamKey && { Authorization: `Bearer ${deps.upstreamKey}` }), + }, body: JSON.stringify(body), signal: AbortSignal.timeout(timeout), }) } catch (err) { // Upstream unreachable - refund estimated cost - if (paymentHash) { - deps.reconcile(paymentHash, 0) - } + refund() deps.logger?.error('upstream error', { endpoint: new URL(req.url).pathname, method: req.method, @@ -198,9 +315,7 @@ export function createProxyHandler(deps: ProxyDeps) { // If upstream returned an error, refund and return a generic error // (don't forward raw upstream body — may leak internal details) if (!upstreamRes.ok) { - if (paymentHash) { - deps.reconcile(paymentHash, 0) - } + refund() // Consume and discard the upstream error body to prevent connection leaks await upstreamRes.body?.cancel().catch(() => {}) const status = upstreamRes.status >= 400 && upstreamRes.status < 600 @@ -219,14 +334,13 @@ export function createProxyHandler(deps: ProxyDeps) { proxy = createStreamingProxy(upstreamRes.body, (tokenCount) => { // Release capacity slot when stream ends (not in finally) deps.capacity.release() - if (!deps.flatPricing && paymentHash) { - const satCost = tokenCostToSats(tokenCount, pricePerThousand) - deps.reconcile(paymentHash, satCost) + if (!deps.flatPricing && hold.metered) { + hold.settle(tokenCostToSats(tokenCount, pricePerThousand)) } }, undefined, deps.maxBodySize) } catch { // createStreamingProxy failed — capacity will be released by finally - if (paymentHash) deps.reconcile(paymentHash, 0) + refund() return new Response( JSON.stringify({ error: 'Internal streaming error' }), { status: 500, headers: { 'Content-Type': 'application/json' } }, @@ -250,7 +364,7 @@ export function createProxyHandler(deps: ProxyDeps) { if (upstreamContentLength !== null) { const len = parseInt(upstreamContentLength, 10) if (Number.isFinite(len) && len > deps.maxBodySize) { - if (paymentHash) deps.reconcile(paymentHash, 0) + refund() await upstreamRes.body?.cancel().catch(() => {}) return new Response( JSON.stringify({ error: 'Upstream response too large' }), @@ -274,7 +388,7 @@ export function createProxyHandler(deps: ProxyDeps) { totalBytes += value.byteLength if (totalBytes > deps.maxBodySize) { await reader.cancel('response too large').catch(() => {}) - if (paymentHash) deps.reconcile(paymentHash, 0) + refund() return new Response( JSON.stringify({ error: 'Upstream response too large' }), { status: 502, headers: { 'Content-Type': 'application/json' } }, @@ -282,7 +396,7 @@ export function createProxyHandler(deps: ProxyDeps) { } if (Date.now() > deadline) { await reader.cancel('deadline exceeded').catch(() => {}) - if (paymentHash) deps.reconcile(paymentHash, 0) + refund() return new Response( JSON.stringify({ error: 'Upstream response timed out' }), { status: 504, headers: { 'Content-Type': 'application/json' } }, @@ -292,7 +406,7 @@ export function createProxyHandler(deps: ProxyDeps) { } chunks.push(decoder.decode()) // flush remaining } catch { - if (paymentHash) deps.reconcile(paymentHash, 0) + refund() return new Response( JSON.stringify({ error: 'Upstream response read failed' }), { status: 502, headers: { 'Content-Type': 'application/json' } }, @@ -307,6 +421,7 @@ export function createProxyHandler(deps: ProxyDeps) { try { responseBody = JSON.parse(responseText) } catch { + refund() return new Response( JSON.stringify({ error: 'Upstream returned invalid JSON' }), { status: 502, headers: { 'Content-Type': 'application/json' } }, @@ -319,8 +434,8 @@ export function createProxyHandler(deps: ProxyDeps) { const tokenCount = counter.finalCount() const satCost = tokenCostToSats(tokenCount, pricePerThousand) - if (!deps.flatPricing && paymentHash) { - deps.reconcile(paymentHash, satCost) + if (!deps.flatPricing && hold.metered) { + hold.settle(satCost) } return new Response(JSON.stringify(responseBody), { diff --git a/src/proxy/hold.test.ts b/src/proxy/hold.test.ts new file mode 100644 index 0000000..df4da54 --- /dev/null +++ b/src/proxy/hold.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from 'vitest' +import { memoryStorage } from '@forgesworn/toll-booth' +import { creditHold, fixedHold, usdHold } from './hold.js' + +const hash = 'c'.repeat(64) + +describe('creditHold', () => { + it('grows by debiting the balance and refunds the excess on settle', () => { + const storage = memoryStorage() + storage.credit(hash, 100) + storage.debit(hash, 10) // taken by toll-booth before the proxy runs + const hold = creditHold(storage, hash, 10) + expect(hold.reserve(50)).toBe(true) + expect(storage.balance(hash)).toBe(50) + hold.settle(7) + expect(storage.balance(hash)).toBe(93) + }) + + it('refuses to grow past the balance and leaves it untouched', () => { + const storage = memoryStorage() + storage.credit(hash, 20) + storage.debit(hash, 10) + const hold = creditHold(storage, hash, 10) + expect(hold.reserve(30)).toBe(false) + expect(storage.balance(hash)).toBe(10) + hold.settle(0) + expect(storage.balance(hash)).toBe(20) + }) + + it('only settles once', () => { + const storage = memoryStorage() + storage.credit(hash, 10) + storage.debit(hash, 10) + const hold = creditHold(storage, hash, 10) + hold.settle(0) + hold.settle(0) + expect(storage.balance(hash)).toBe(10) + }) +}) + +describe('fixedHold', () => { + it('caps the request at what was paid', () => { + const hold = fixedHold(5) + expect(hold.ceiling).toBe(5) + expect(hold.reserve(5)).toBe(true) + expect(hold.reserve(6)).toBe(false) + }) +}) + +describe('usdHold', () => { + it('refunds a credit hold only when nothing was served', () => { + const storage = memoryStorage() + storage.credit(hash, 100, 'usd') + storage.debit(hash, 5, 'usd') + usdHold(storage, hash, 5, true).settle(3) + expect(storage.balance(hash, 'usd')).toBe(95) + usdHold(storage, hash, 5, true).settle(0) + expect(storage.balance(hash, 'usd')).toBe(100) + }) +}) diff --git a/src/proxy/hold.ts b/src/proxy/hold.ts new file mode 100644 index 0000000..b4e33be --- /dev/null +++ b/src/proxy/hold.ts @@ -0,0 +1,121 @@ +import type { StorageBackend } from '@forgesworn/toll-booth' + +/** + * What one proxied request has been charged, and how that charge can move. + * + * toll-booth takes the route price before the request reaches the proxy. + * A hold carries that amount for this one request, so concurrent requests + * on the same credential each settle their own charge instead of sharing + * toll-booth's per-payment-hash estimate (which the last request to start + * overwrites). + */ +export interface Hold { + /** Sats (or cents, for a USD credential) taken so far for this request. */ + readonly held: number + /** + * True when the hold is denominated in sats and can be settled against a + * token count. False for unmetered requests and USD credentials. + */ + readonly metered: boolean + /** + * The most this request may cost when the hold cannot grow (a per-request + * payment, a session, the free tier). Undefined when it can grow, or when + * the request is unmetered. + */ + readonly ceiling: number | undefined + /** Grow the hold to `total`. Returns false when the credential cannot cover it. */ + reserve(total: number): boolean + /** Charge `actual` in total and release the rest of the hold. Only the first call counts. */ + settle(actual: number): void +} + +/** No payment behind the request (open or allowlist auth, or an unpriced route). */ +export function unmeteredHold(): Hold { + return { held: 0, metered: false, ceiling: undefined, reserve: () => true, settle: () => {} } +} + +/** + * A fixed amount that was paid for this request alone: an IETF Payment + * charge, a session deduction or a free-tier allowance. It cannot grow, and + * toll-booth offers no way to hand any of it back. + */ +export function fixedHold(amount: number): Hold { + return { + held: amount, + metered: true, + ceiling: amount, + reserve: (total) => total <= amount, + settle: () => {}, + } +} + +/** + * A draw on a prepaid credit balance (L402, Cashu, LNURLcash). The hold + * grows by debiting the balance, and settling refunds whatever was held + * beyond the actual cost. + */ +export function creditHold(storage: StorageBackend, paymentHash: string, initial: number): Hold { + let held = initial + let settled = false + return { + get held() { return held }, + metered: true, + ceiling: undefined, + reserve(total) { + if (settled) return false + const extra = total - held + if (extra <= 0) return true + if (!storage.debit(paymentHash, extra).success) return false + held = total + return true + }, + settle(actual) { + if (settled) return + settled = true + const delta = held - Math.max(0, actual) + if (delta !== 0) storage.adjustCredits(paymentHash, delta) + }, + } +} + +/** + * A USD credential (x402, or Cashu in a USD unit). Token prices are in sats, + * so the route price stands as a flat charge; the hold is only refunded when + * the request fails before any inference is served. + */ +export function usdHold(storage: StorageBackend, paymentHash: string, amount: number, refundable: boolean): Hold { + let settled = false + return { + held: amount, + metered: false, + ceiling: undefined, + reserve: () => true, + settle(actual) { + if (settled) return + settled = true + if (actual === 0 && refundable && amount > 0) storage.adjustCredits(paymentHash, amount, 'usd') + }, + } +} + +/** + * Adapts the older reconcile callback (toll-booth's engine.reconcile, or a + * test double) to a hold. Used when the caller supplies no hold. + */ +export function reconcileHold( + reconcile: (paymentHash: string, actualCost: number) => unknown, + paymentHash: string, +): Hold { + let settled = false + return { + held: 0, + metered: true, + ceiling: undefined, + reserve: () => true, + settle(actual) { + if (settled) return + settled = true + reconcile(paymentHash, actual) + }, + } +} diff --git a/src/proxy/pricing.test.ts b/src/proxy/pricing.test.ts index 510b075..6956056 100644 --- a/src/proxy/pricing.test.ts +++ b/src/proxy/pricing.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { resolveModelPrice, tokenCostToSats } from './pricing.js' +import { isServedModel, resolveModelPrice, tokenCostToSats } from './pricing.js' import type { ModelPricing } from '../config.js' const pricing: ModelPricing = { @@ -67,3 +67,29 @@ describe('tokenCostToSats', () => { expect(tokenCostToSats(-100, 2)).toBe(0) }) }) + +describe('isServedModel', () => { + const served = ['gemma3:4b', 'llama3'] + + it('accepts listed models, ignoring case and an implicit :latest tag', () => { + expect(isServedModel('gemma3:4b', served)).toBe(true) + expect(isServedModel('GEMMA3:4B', served)).toBe(true) + expect(isServedModel('llama3:latest', served)).toBe(true) + }) + + it('refuses aliases the upstream might resolve to a listed model', () => { + expect(isServedModel('registry.ollama.ai/library/gemma3:4b', served)).toBe(false) + expect(isServedModel('library/gemma3:4b', served)).toBe(false) + expect(isServedModel('', served)).toBe(false) + }) + + it('refuses nothing when no models are known', () => { + expect(isServedModel('anything', [])).toBe(true) + }) +}) + +describe('resolveModelPrice with implicit tags', () => { + it('prices an untagged request at the :latest entry', () => { + expect(resolveModelPrice({ default: 1, models: { 'llama3:latest': 7 } }, 'llama3')).toBe(7) + }) +}) diff --git a/src/proxy/pricing.ts b/src/proxy/pricing.ts index 9fdfd99..9163254 100644 --- a/src/proxy/pricing.ts +++ b/src/proxy/pricing.ts @@ -1,11 +1,36 @@ import type { ModelPricing } from '../config.js' +/** + * The name a model is known by for matching: lower-cased, with Ollama's + * implicit `:latest` tag made explicit (`llama3` and `llama3:latest` are + * the same model to Ollama). + */ +export function canonicalModelName(model: string): string { + const lower = model.trim().toLowerCase() + return lower.includes(':') ? lower : `${lower}:latest` +} + +/** + * Whether a requested model is one this gateway serves: one the upstream + * listed, or one the operator priced. Matching is by canonical name only, + * so an alias the upstream would resolve to a priced model (for example + * `registry.ollama.ai/library/gemma3:4b`) cannot be used to fall through + * to the default price. An empty list means nothing is known, so nothing + * is refused. + */ +export function isServedModel(model: string, served: readonly string[]): boolean { + if (served.length === 0) return true + if (!model) return false + const wanted = canonicalModelName(model) + return served.some(name => canonicalModelName(name) === wanted) +} + /** * Resolves the price per 1k tokens for a model. * * Resolution order: * 1. Exact match on model name - * 2. Case-insensitive match + * 2. Case-insensitive match, treating a missing tag as `:latest` * 3. Strip Ollama tag (model:tag -> model) and retry * 4. Fall back to default price */ @@ -21,6 +46,12 @@ export function resolveModelPrice(pricing: ModelPricing, model: string): number if (key.toLowerCase() === lower) return value } + // Same model once the implicit :latest tag is spelt out + const canonical = canonicalModelName(model) + for (const [key, value] of Object.entries(pricing.models)) { + if (canonicalModelName(key) === canonical) return value + } + // Strip Ollama tag (e.g. llama3:latest -> llama3) const colonIdx = lower.indexOf(':') if (colonIdx !== -1) { diff --git a/src/proxy/token-counter.test.ts b/src/proxy/token-counter.test.ts index 5b7507b..5932798 100644 --- a/src/proxy/token-counter.test.ts +++ b/src/proxy/token-counter.test.ts @@ -45,7 +45,7 @@ describe('TokenCounter', () => { expect(counter.finalCount()).toBe(11) }) - it('excludes reasoning tokens from billing', () => { + it('bills reasoning tokens reported in completion_tokens', () => { const counter = new TokenCounter() // Reasoning chunks (not content) counter.ingestSSEChunk('data: {"choices":[{"delta":{"reasoning":"thinking..."}}]}\n\n') @@ -55,8 +55,24 @@ describe('TokenCounter', () => { // Usage includes all tokens (reasoning + content + prompt) counter.ingestSSEChunk('data: {"choices":[],"usage":{"prompt_tokens":10,"completion_tokens":50,"total_tokens":60}}\n\n') counter.ingestSSEChunk('data: [DONE]\n\n') - // Should bill: prompt(10) + max(content_chunks(1), byte_floor(ceil(6/4)=2)) = 12, NOT total_tokens(60) - expect(counter.finalCount()).toBe(12) + // Bills prompt(10) + completion(50), matching the non-streaming path + expect(counter.finalCount()).toBe(60) + }) + + it('counts reasoning chunks in the fallback estimate when no usage is reported', () => { + const counter = new TokenCounter() + counter.ingestSSEChunk('data: {"choices":[{"delta":{"reasoning_content":"a long chain of thought here"}}]}\n\n') + counter.ingestSSEChunk('data: {"choices":[{"delta":{"content":"ok"}}]}\n\n') + counter.ingestSSEChunk('data: [DONE]\n\n') + // 2 chunks; byte floor ceil((28 + 2) / 4) = 8 + expect(counter.finalCount()).toBe(8) + }) + + it('ignores usage figures that are not non-negative numbers', () => { + const counter = new TokenCounter() + counter.ingestSSEChunk('data: {"choices":[{"delta":{"content":"Hi"}}]}\n\n') + counter.ingestSSEChunk('data: {"choices":[],"usage":{"prompt_tokens":"10","completion_tokens":-5}}\n\n') + expect(counter.finalCount()).toBe(1) }) it('ignores non-content chunks in count', () => { @@ -84,14 +100,23 @@ describe('TokenCounter', () => { expect(counter.finalCount()).toBe(18) }) - it('falls back to chunk count when reasoning chunks detected', () => { + it('parses a usage event split across network chunks', () => { const counter = new TokenCounter() - counter.ingestSSEChunk('data: {"choices":[{"delta":{"reasoning":"thinking..."}}]}\n\n') - counter.ingestSSEChunk('data: {"choices":[{"delta":{"content":"answer"}}]}\n\n') - counter.ingestSSEChunk('data: {"choices":[],"usage":{"prompt_tokens":10,"completion_tokens":50,"total_tokens":60}}\n\n') - counter.ingestSSEChunk('data: [DONE]\n\n') - // Should use max(chunk_count(1), byte_floor(ceil(6/4)=2)) not completion_tokens (50) — reasoning excluded - expect(counter.finalCount()).toBe(12) + counter.ingestSSEChunk('data: {"choices":[{"delta":{"content":"Hi"}}]}\n\ndata: {"choices":[],"usage":{"prompt_tok') + counter.ingestSSEChunk('ens":100,"completion_tokens":400}}\n\ndata: [DONE]\n\n') + expect(counter.finalCount()).toBe(500) + }) + + it('parses a final event with no trailing newline', () => { + const counter = new TokenCounter() + counter.ingestSSEChunk('data: {"choices":[],"usage":{"prompt_tokens":3,"completion_tokens":4}}') + expect(counter.finalCount()).toBe(7) + }) + + it('accepts data fields without a space and CRLF line endings', () => { + const counter = new TokenCounter() + counter.ingestSSEChunk('data:{"choices":[],"usage":{"prompt_tokens":2,"completion_tokens":2}}\r\n\r\n') + expect(counter.finalCount()).toBe(4) }) it('handles multi-event chunks', () => { diff --git a/src/proxy/token-counter.ts b/src/proxy/token-counter.ts index 42e102f..1d94a21 100644 --- a/src/proxy/token-counter.ts +++ b/src/proxy/token-counter.ts @@ -4,60 +4,80 @@ interface UsageData { total_tokens?: number } +/** Longest partial SSE line kept between chunks (bounds memory on hostile streams). */ +const MAX_PARTIAL_LINE = 1024 * 1024 + +/** A usage figure, if it is a non-negative finite number; otherwise undefined. */ +function tokenCount(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? Math.ceil(value) : undefined +} + /** * Counts tokens from OpenAI-compatible responses. * * Priority: * 1. Buffered usage (from non-streaming JSON response) * 2. Usage from final SSE chunk (stream_options: { include_usage: true }) - * 3. Content chunk count (fallback - 1 chunk ~= 1 token) + * 3. Generated chunk count (fallback - 1 chunk ~= 1 token, with a byte floor) */ export class TokenCounter { private bufferedUsage: UsageData | null = null private sseUsage: UsageData | null = null private contentChunkCount = 0 private totalContentBytes = 0 - private hasReasoningChunks = false + private partialLine = '' /** Set usage from a buffered (non-streaming) JSON response. */ setBufferedUsage(usage: Record): void { this.bufferedUsage = { - prompt_tokens: typeof usage.prompt_tokens === 'number' ? usage.prompt_tokens : undefined, - completion_tokens: typeof usage.completion_tokens === 'number' ? usage.completion_tokens : undefined, - total_tokens: typeof usage.total_tokens === 'number' ? usage.total_tokens : undefined, + prompt_tokens: tokenCount(usage.prompt_tokens), + completion_tokens: tokenCount(usage.completion_tokens), + total_tokens: tokenCount(usage.total_tokens), } } - /** Ingest an SSE chunk (may contain multiple events). */ + /** Ingest an SSE chunk (may contain multiple events, or part of one). */ ingestSSEChunk(chunk: string): void { - const lines = chunk.split('\n') - for (const line of lines) { - if (!line.startsWith('data: ')) continue - const data = line.slice(6).trim() + // Network chunks do not respect line boundaries: keep the trailing + // partial line until the rest of it arrives, so an event split across + // reads (often the final usage event) is still parsed. + const lines = (this.partialLine + chunk).split('\n') + this.partialLine = lines.pop() ?? '' + if (this.partialLine.length > MAX_PARTIAL_LINE) this.partialLine = '' + this.ingestLines(lines) + } + + private ingestLines(lines: string[]): void { + for (const rawLine of lines) { + const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine + // The space after the colon is optional in SSE + if (!line.startsWith('data:')) continue + const data = line.slice(5).trim() if (data === '[DONE]') continue try { const parsed = JSON.parse(data) // Check for usage in this chunk - if (parsed.usage) { + if (parsed.usage && typeof parsed.usage === 'object') { this.sseUsage = { - prompt_tokens: parsed.usage.prompt_tokens, - completion_tokens: parsed.usage.completion_tokens, - total_tokens: parsed.usage.total_tokens, + prompt_tokens: tokenCount(parsed.usage.prompt_tokens), + completion_tokens: tokenCount(parsed.usage.completion_tokens), + total_tokens: tokenCount(parsed.usage.total_tokens), } } - // Count content and reasoning chunks + // Count generated chunks (content and reasoning). Only used when the + // upstream reports no usage, as a floor-backed estimate. const choices = parsed.choices if (Array.isArray(choices)) { for (const choice of choices) { - if (choice.delta?.content !== undefined && choice.delta.content !== '') { - this.contentChunkCount++ - this.totalContentBytes += new TextEncoder().encode(choice.delta.content).byteLength - } - if (choice.delta?.reasoning !== undefined || choice.delta?.reasoning_content !== undefined) { - this.hasReasoningChunks = true + for (const field of ['content', 'reasoning', 'reasoning_content'] as const) { + const text = choice.delta?.[field] + if (typeof text === 'string' && text !== '') { + this.contentChunkCount++ + this.totalContentBytes += new TextEncoder().encode(text).byteLength + } } } } @@ -69,13 +89,21 @@ export class TokenCounter { /** Returns the final token count using the best available source. * - * For buffered (non-streaming) responses, uses prompt_tokens + completion_tokens - * from the usage object since there are no SSE chunks to count. + * Reported usage wins: prompt_tokens + completion_tokens. Completion tokens + * include any reasoning/thinking tokens, which the upstream generated and + * the operator paid for in compute, so they are billed like any other. * - * For streaming responses, uses prompt_tokens + content chunk count. - * This avoids billing for reasoning/thinking tokens that some models produce. */ + * Without reported usage (upstream ignored include_usage), the completion + * is estimated from generated chunks with a byte-based floor. */ finalCount(): number { - // Buffered response — use reported usage directly (no chunks to count) + // A stream that ends without a trailing newline still has a last event + if (this.partialLine) { + const last = this.partialLine + this.partialLine = '' + this.ingestLines([last]) + } + + // Buffered response: use reported usage directly (no chunks to count) if (this.bufferedUsage) { const prompt = this.bufferedUsage.prompt_tokens ?? 0 const completion = this.bufferedUsage.completion_tokens ?? 0 @@ -85,20 +113,13 @@ export class TokenCounter { // Streaming response const usage = this.sseUsage const promptTokens = usage?.prompt_tokens ?? 0 + if (usage?.completion_tokens !== undefined) { + return promptTokens + usage.completion_tokens + } // Byte-based floor: ~4 bytes per token is a conservative estimate. // Prevents a malicious upstream from bundling all content in one chunk to avoid billing. const byteFloor = Math.ceil(this.totalContentBytes / 4) - - // If reasoning chunks were detected, use content chunk count to exclude them. - // Otherwise, prefer completion_tokens from SSE usage (more accurate than chunk count). - if (this.hasReasoningChunks) { - const completionEstimate = Math.max(this.contentChunkCount, byteFloor) - return promptTokens + completionEstimate - } - if (usage?.completion_tokens !== undefined) { - return promptTokens + usage.completion_tokens - } return promptTokens + Math.max(this.contentChunkCount, byteFloor) } } diff --git a/src/root-key.test.ts b/src/root-key.test.ts new file mode 100644 index 0000000..5dd9fe3 --- /dev/null +++ b/src/root-key.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest' +import { mkdtempSync, readFileSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { loadOrCreateRootKey, ROOT_KEY_FILE } from './root-key.js' + +describe('loadOrCreateRootKey', () => { + it('generates a key once and returns the same key afterwards', () => { + const dir = mkdtempSync(join(tmpdir(), 'satgate-key-')) + const first = loadOrCreateRootKey(dir) + expect(first.created).toBe(true) + expect(first.key).toMatch(/^[0-9a-f]{64}$/) + expect(statSync(first.path).mode & 0o777).toBe(0o600) + const second = loadOrCreateRootKey(dir) + expect(second.created).toBe(false) + expect(second.key).toBe(first.key) + }) + + it('refuses a file that does not hold a key', () => { + const dir = mkdtempSync(join(tmpdir(), 'satgate-key-')) + writeFileSync(join(dir, ROOT_KEY_FILE), 'not a key') + expect(() => loadOrCreateRootKey(dir)).toThrow(/root key/) + expect(readFileSync(join(dir, ROOT_KEY_FILE), 'utf-8')).toBe('not a key') + }) +}) diff --git a/src/root-key.ts b/src/root-key.ts new file mode 100644 index 0000000..5806bd4 --- /dev/null +++ b/src/root-key.ts @@ -0,0 +1,33 @@ +import { readFileSync, writeFileSync, mkdirSync } from 'node:fs' +import { randomBytes } from 'node:crypto' +import { join } from 'node:path' + +/** File name of the persisted root key, kept beside the SQLite database. */ +export const ROOT_KEY_FILE = 'satgate.root-key' + +/** + * Loads the macaroon root key from `dir`, or generates one and saves it + * there (mode 0600). Credentials are signed with this key, so if it + * changed on every restart, every credential a client had paid for would + * stop working while its balance sat in the database. + */ +export function loadOrCreateRootKey(dir: string): { key: string; path: string; created: boolean } { + const path = join(dir, ROOT_KEY_FILE) + let existing: string | undefined + try { + existing = readFileSync(path, 'utf-8').trim() + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err + } + if (existing !== undefined) { + if (!/^[0-9a-fA-F]{64}$/.test(existing)) { + throw new Error(`${path} does not hold a 64-hex-character root key`) + } + return { key: existing, path, created: false } + } + const key = randomBytes(32).toString('hex') + mkdirSync(dir, { recursive: true }) + // 'wx' fails if another process created the file first + writeFileSync(path, `${key}\n`, { mode: 0o600, flag: 'wx' }) + return { key, path, created: true } +} diff --git a/src/server.test.ts b/src/server.test.ts index 3ef133e..6ba3462 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { serve } from '@hono/node-server' -import { createTokenTollServer } from './server.js' +import { createTokenTollServer, socketClientIp } from './server.js' import { Hono } from 'hono' function mockUpstream() { @@ -151,3 +151,104 @@ describe('createTokenTollServer', () => { expect(res.status).toBe(402) }) }) + +describe('route pricing', () => { + const base = { + upstream: 'http://127.0.0.1:1', + port: 0, + rootKey: 'a'.repeat(64), + rootKeyGenerated: false, + storage: 'memory' as const, + dbPath: '', + pricing: { default: 1, models: {} }, + freeTier: { creditsPerDay: 0 }, + capacity: { maxConcurrent: 0 }, + tiers: [], + trustProxy: false, + estimatedCostSats: 10, + maxBodySize: 10 * 1024 * 1024, + authMode: 'lightning' as const, + allowlist: [], + tunnel: false, + } + + async function probePrice(app: { request: (path: string, init?: RequestInit) => Response | Promise }) { + const res = await app.request('/v1/chat/completions', { method: 'HEAD' }) + expect(res.status).toBe(402) + return res.headers.get('X-L402-Price-Sats') + } + + it('advertises the flat --price in flat mode, not the per-token estimate', async () => { + const { app } = createTokenTollServer({ ...base, flatPricing: true, price: 3 }) + expect(await probePrice(app)).toBe('3') + }) + + it('advertises the estimated cost in per-token mode', async () => { + const { app } = createTokenTollServer({ ...base, flatPricing: false, price: 3 }) + expect(await probePrice(app)).toBe('10') + }) +}) + +describe('free flat pricing', () => { + it('serves inference without a challenge when the flat price is 0', async () => { + const upstream = new Hono() + upstream.post('/v1/chat/completions', (c) => c.json({ choices: [], usage: { prompt_tokens: 1, completion_tokens: 1 } })) + let server: ReturnType | undefined + const url = await new Promise((resolve) => { + server = serve({ fetch: upstream.fetch, port: 0 }, (info) => resolve(`http://localhost:${info.port}`)) + }) + try { + const { app } = createTokenTollServer({ + upstream: url, + port: 0, + rootKey: 'a'.repeat(64), + rootKeyGenerated: false, + storage: 'memory', + dbPath: '', + pricing: { default: 1, models: {} }, + freeTier: { creditsPerDay: 0 }, + capacity: { maxConcurrent: 0 }, + tiers: [], + trustProxy: false, + estimatedCostSats: 10, + maxBodySize: 10 * 1024 * 1024, + authMode: 'lightning', + allowlist: [], + flatPricing: true, + price: 0, + tunnel: false, + }) + const res = await app.request('/v1/chat/completions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: 'llama3', messages: [] }), + }) + expect(res.status).toBe(200) + } finally { + server?.close() + } + }) +}) + +describe('socketClientIp', () => { + it('reads the TCP peer address under the Node server', async () => { + const probe = new Hono() + probe.get('/ip', (c) => c.text(socketClientIp(c))) + let server: ReturnType | undefined + const url = await new Promise((resolve) => { + server = serve({ fetch: probe.fetch, port: 0, hostname: '127.0.0.1' }, (info) => resolve(`http://127.0.0.1:${info.port}`)) + }) + try { + const ip = await (await fetch(`${url}/ip`, { headers: { 'X-Forwarded-For': '203.0.113.9' } })).text() + expect(ip).toMatch(/127\.0\.0\.1$/) + } finally { + server?.close() + } + }) + + it('falls back to 0.0.0.0 without a socket', async () => { + const probe = new Hono() + probe.get('/ip', (c) => c.text(socketClientIp(c))) + expect(await (await probe.request('/ip')).text()).toBe('0.0.0.0') + }) +}) diff --git a/src/server.ts b/src/server.ts index 7747bff..00ec6f1 100644 --- a/src/server.ts +++ b/src/server.ts @@ -19,17 +19,34 @@ import { import type { PaymentRail } from '@forgesworn/toll-booth' import { meltNoteToLightning } from './lnurlcash-melt.js' import { createHonoTollBooth } from '@forgesworn/toll-booth/hono' +import { getConnInfo } from '@hono/node-server/conninfo' import type { TollBoothEnv } from '@forgesworn/toll-booth/hono' import type { TokenTollConfig } from './config.js' import { createNoopLogger } from './logger.js' import { createAuthMiddleware } from './auth/middleware.js' import { createProxyHandler } from './proxy/handler.js' import { CapacityTracker } from './proxy/capacity.js' +import { creditHold, fixedHold, unmeteredHold, usdHold, type Hold } from './proxy/hold.js' import { generateWellKnown } from './discovery/well-known.js' import { generateLlmsTxt } from './discovery/llms-txt.js' import { generateOpenApiSpec } from './discovery/openapi.js' import { createHttpFacilitator } from './x402/facilitator.js' +/** + * The address of the TCP peer. Used for per-client limits when forwarded + * headers are not trusted; toll-booth's own fallback is a shared 0.0.0.0. + */ +export function socketClientIp(c: Context): string { + try { + return getConnInfo(c).remote.address ?? '0.0.0.0' + } catch { + return '0.0.0.0' // no socket (e.g. app.request in tests) + } +} + +/** The inference endpoints: the only routes that cost money. */ +const PAID_PATHS = ['/v1/chat/completions', '/v1/completions', '/v1/embeddings'] as const + export interface TokenTollServer { app: Hono close: () => void @@ -159,15 +176,11 @@ export function createTokenTollServer(config: TokenTollConfig): TokenTollServer const hmacSecret = createHash('sha256') .update(`toll-booth-ietf-hmac-v1${config.rootKey}`) .digest('hex') - rails.push(createIETFPaymentRail({ - hmacSecret, - realm: config.realm ?? 'satgate', - backend: config.backend, - storage, - serviceName: config.serviceName, - })) - // IETF Payment session intent (deposit/bearer/top-up/close for streaming) + // IETF Payment session intent (deposit/bearer/top-up/close for streaming). + // It must come before the charge rail: rails are tried in order and the + // charge rail claims every "Authorization: Payment" header, so a session + // credential behind it would only ever be checked as a charge and fail. if (config.sessionIntent) { const sessionRail = createIETFSessionRail({ hmacSecret, @@ -185,12 +198,24 @@ export function createTokenTollServer(config: TokenTollConfig): TokenTollServer // Start auto-close sweep for expired sessions sessionRail.startSweep() } + + rails.push(createIETFPaymentRail({ + hmacSecret, + realm: config.realm ?? 'satgate', + backend: config.backend, + storage, + serviceName: config.serviceName, + })) } + // Route price in sats. Flat mode charges the configured per-request price; + // per-token mode holds the estimated cost up front and reconciles it after. + const routePriceSats = config.flatPricing ? config.price : config.estimatedCostSats + // Dual-currency pricing entry const pricingEntry = config.defaultPriceUsd !== undefined - ? { sats: config.estimatedCostSats, usd: config.defaultPriceUsd } - : config.estimatedCostSats + ? { sats: routePriceSats, usd: config.defaultPriceUsd } + : routePriceSats // Create toll-booth engine const engine = createTollBooth({ @@ -198,12 +223,15 @@ export function createTokenTollServer(config: TokenTollConfig): TokenTollServer storage, upstream: config.upstream, backend: config.backend, - pricing: { - '/v1/chat/completions': pricingEntry, - '/v1/completions': pricingEntry, - '/v1/embeddings': pricingEntry, - }, + // A flat price of 0 means free inference: leave the routes unpriced so + // toll-booth passes them through instead of issuing 0-sat invoices. + pricing: config.flatPricing && config.price === 0 + ? {} + : Object.fromEntries(PAID_PATHS.map(path => [path, pricingEntry])), defaultInvoiceAmount: config.tiers[0]?.amountSats ?? 1000, + ...(config.maxPendingInvoicesPerIp > 0 && { + invoiceRateLimit: { maxPendingPerIp: config.maxPendingInvoicesPerIp }, + }), freeTier: config.freeTier.creditsPerDay > 0 ? { creditsPerDay: config.freeTier.creditsPerDay } : undefined, ...(rails.length > 0 && { rails }), serviceName: config.serviceName, @@ -213,7 +241,15 @@ export function createTokenTollServer(config: TokenTollConfig): TokenTollServer }) // Create Hono toll-booth adapter - const tollBooth = createHonoTollBooth({ engine }) + // Behind a trusted proxy, toll-booth reads X-Forwarded-For. Otherwise the + // socket address identifies the client, rather than toll-booth's shared + // 0.0.0.0, so free-tier and invoice limits are per client. + const tollBooth = createHonoTollBooth({ + engine, + trustProxy: config.trustProxy, + ...(config.trustedProxies?.length && { trustedProxies: config.trustedProxies }), + ...(!config.trustProxy && { getClientIp: socketClientIp }), + }) // Mount payment routes const paymentApp = tollBooth.createPaymentApp({ @@ -223,9 +259,24 @@ export function createTokenTollServer(config: TokenTollConfig): TokenTollServer defaultAmount: config.tiers[0]?.amountSats ?? 1000, backend: config.backend, serviceName: config.serviceName, + ...(config.maxPendingInvoicesPerIp > 0 && { maxPendingPerIp: config.maxPendingInvoicesPerIp }), }) app.route('/', paymentApp) + // createTollBooth has no housekeeping of its own: drop invoices nobody paid + // within a day, and expired Cashu claims, so storage and each client's + // pending-invoice count do not grow without end. + const INVOICE_MAX_AGE_MS = 86_400_000 + const pruneTimer = setInterval(() => { + try { + storage.pruneExpiredInvoices(INVOICE_MAX_AGE_MS) + storage.pruneStaleRecords(INVOICE_MAX_AGE_MS) + } catch (err) { + logger.warn(`Storage prune failed: ${err instanceof Error ? err.message : err}`) + } + }, 3_600_000) + pruneTimer.unref() + // Discoverability endpoints (no auth required) const models: string[] = config.models ?? [] @@ -254,9 +305,11 @@ export function createTokenTollServer(config: TokenTollConfig): TokenTollServer return c.text(generateLlmsTxt({ pricing: config.pricing, models, + ...(config.flatPricing && { flatPriceSats: config.price }), + ...(config.lightning && { lightning: config.lightning, ietfPayment: true }), ...(config.x402 && { x402: { network: config.x402.network } }), ...(config.cashu && { cashu: true }), - ...(config.realm && { ietfPayment: true }), + ...(config.lnurlcash && { lnurlcash: true }), })) }) @@ -264,7 +317,10 @@ export function createTokenTollServer(config: TokenTollConfig): TokenTollServer return c.json(generateOpenApiSpec({ models, pricing: config.pricing, + lightning: !!config.lightning, x402: !!config.x402, + cashu: !!config.cashu, + lnurlcash: !!config.lnurlcash, })) }) @@ -309,6 +365,7 @@ export function createTokenTollServer(config: TokenTollConfig): TokenTollServer const maxModelsBytes = 1024 * 1024 // 1 MiB limit for /v1/models response const res = await fetch(`${config.upstream}/v1/models`, { signal: AbortSignal.timeout(10_000), + ...(config.upstreamKey && { headers: { Authorization: `Bearer ${config.upstreamKey}` } }), }) // Read body incrementally to enforce size limit const reader = res.body?.getReader() @@ -344,27 +401,60 @@ export function createTokenTollServer(config: TokenTollConfig): TokenTollServer // AI proxy routes (behind auth middleware) const proxyHandler = createProxyHandler({ upstream: config.upstream, + upstreamKey: config.upstreamKey, pricing: config.pricing, capacity, - reconcile: (paymentHash, actualCost) => engine.reconcile(paymentHash, actualCost), maxBodySize: config.maxBodySize, flatPricing: config.flatPricing, + maxTokens: config.maxTokens, + models: [...new Set([...(config.models ?? []), ...Object.keys(config.pricing.models)])], logger, }) - if (config.authMode === 'lightning' || config.authMode === 'cashu') { - app.use('/v1/*', tollBooth.authMiddleware) - } else { - const authMiddleware = createAuthMiddleware({ - authMode: config.authMode, - allowlist: config.allowlist, - }) - app.use('/v1/*', authMiddleware) + const paidAuth = config.authMode === 'lightning' || config.authMode === 'cashu' + + /** + * The hold for one request: what toll-booth has already taken for it and + * how that charge may move. Settled per request by the proxy, so + * concurrent requests on one credential cannot clobber each other. + */ + function holdFor(c: Context): Hold { + if (!paidAuth) return unmeteredHold() + const paymentHash = c.get('tollBoothPaymentHash') + if (!paymentHash) { + // Free tier: the route price came out of today's allowance + return c.get('tollBoothFreeRemaining') !== undefined ? fixedHold(routePriceSats) : unmeteredHold() + } + const held = c.get('tollBoothEstimatedCost') ?? 0 + const creditBalance = c.get('tollBoothCreditBalance') + const isUsd = c.req.header('payment-signature') !== undefined + || c.req.header('x-payment') !== undefined + || (config.cashu?.unit === 'usd' && c.req.header('x-cashu') !== undefined) + if (isUsd) return usdHold(storage, paymentHash, held, creditBalance !== undefined) + // A per-request payment (IETF Payment charge) reports no balance + if (creditBalance === undefined) return fixedHold(held) + // IETF Payment session: the payment id is the session id + if (storage.getSession(paymentHash)) return fixedHold(held) + return creditHold(storage, paymentHash, held) } + const proxy = (c: Context) => proxyHandler(c.req.raw, undefined, holdFor(c)) + + // Only the inference endpoints cost money, so only POSTs to them go + // through payment or allowlist auth. GET /v1/models stays free, and other + // methods and paths fall through to a 404 instead of minting invoices. + const authMiddleware = paidAuth + ? tollBooth.authMiddleware + : createAuthMiddleware({ + authMode: config.authMode, + allowlist: config.allowlist, + publicUrl: config.publicUrl, + trustProxy: config.trustProxy, + }) + // Forward toll-booth credit/free-tier context as response headers. // Must run AFTER next() so c.header() applies to the actual response. - app.use('/v1/*', async (c: Context, next) => { + const forwardPaymentHeaders = async (c: Context, next: () => Promise) => { await next() const creditBalance = c.get('tollBoothCreditBalance') if (creditBalance !== undefined) { @@ -378,27 +468,34 @@ export function createTokenTollServer(config: TokenTollConfig): TokenTollServer if (freeRemaining !== undefined) { c.header('X-Free-Remaining', String(freeRemaining)) } - }) + } - app.post('/v1/chat/completions', async (c: Context) => { - const paymentHash = (config.authMode === 'lightning' || config.authMode === 'cashu') ? c.get('tollBoothPaymentHash') : undefined - return proxyHandler(c.req.raw, paymentHash) - }) + // HEAD is a free price probe. It is answered here rather than by + // toll-booth, which would verify (and debit) any credential sent with it. + // Hono dispatches HEAD to GET routes; a plain GET is still a 404. + const priceProbe = (c: Context) => { + if (c.req.method !== 'HEAD') return c.notFound() + if (!paidAuth || (config.flatPricing && config.price === 0)) return c.body(null, 200) + c.header('X-L402-Price-Sats', String(routePriceSats)) + if (config.defaultPriceUsd !== undefined) c.header('X-L402-Price-Usd', String(config.defaultPriceUsd)) + c.header('WWW-Authenticate', 'L402 price-only') + c.header('Cache-Control', 'no-store') + return c.body(null, 402) + } - app.post('/v1/completions', async (c: Context) => { - const paymentHash = (config.authMode === 'lightning' || config.authMode === 'cashu') ? c.get('tollBoothPaymentHash') : undefined - return proxyHandler(c.req.raw, paymentHash) - }) + for (const path of PAID_PATHS) { + app.get(path, priceProbe) + app.post(path, authMiddleware, forwardPaymentHeaders) + } - app.post('/v1/embeddings', async (c: Context) => { - const paymentHash = (config.authMode === 'lightning' || config.authMode === 'cashu') ? c.get('tollBoothPaymentHash') : undefined - return proxyHandler(c.req.raw, paymentHash) - }) + app.post('/v1/chat/completions', proxy) + app.post('/v1/completions', proxy) + app.post('/v1/embeddings', proxy) return { app, close: () => { - // Cleanup if needed + clearInterval(pruneTimer) }, } } diff --git a/src/upstream-key.test.ts b/src/upstream-key.test.ts new file mode 100644 index 0000000..52f1d8b --- /dev/null +++ b/src/upstream-key.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, afterEach } from 'vitest' +import { Hono } from 'hono' +import { serve } from '@hono/node-server' +import { createTokenTollServer } from './server.js' +import { loadConfig } from './config.js' + +let server: ReturnType | undefined +afterEach(() => server?.close()) + +async function recordingUpstream() { + const seen: Array = [] + const app = new Hono() + app.post('/v1/chat/completions', (c) => { + seen.push(c.req.header('authorization')) + return c.json({ choices: [], usage: { prompt_tokens: 1, completion_tokens: 1 } }) + }) + app.get('/v1/models', (c) => { + seen.push(c.req.header('authorization')) + return c.json({ data: [{ id: 'm' }] }) + }) + const url = await new Promise((resolve) => { + server = serve({ fetch: app.fetch, port: 0 }, (info) => resolve(`http://localhost:${info.port}`)) + }) + return { url, seen } +} + +function openConfig(upstream: string, upstreamKey?: string) { + return { + ...loadConfig({ upstream }), + authMode: 'allowlist' as const, + allowlist: ['client-secret'], + upstreamKey, + } +} + +describe('upstream API key', () => { + it('sends the configured key upstream instead of the client credential', async () => { + const up = await recordingUpstream() + const { app } = createTokenTollServer(openConfig(up.url, 'sk-upstream')) + const res = await app.request('/v1/chat/completions', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: 'Bearer client-secret' }, + body: JSON.stringify({ model: 'm', messages: [] }), + }) + expect(res.status).toBe(200) + await app.request('/v1/models') + expect(up.seen).toEqual(['Bearer sk-upstream', 'Bearer sk-upstream']) + }) + + it('sends no Authorization header when no key is configured', async () => { + const up = await recordingUpstream() + const { app } = createTokenTollServer(openConfig(up.url)) + await app.request('/v1/chat/completions', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: 'Bearer client-secret' }, + body: JSON.stringify({ model: 'm', messages: [] }), + }) + expect(up.seen).toEqual([undefined]) + }) + + it('reads UPSTREAM_API_KEY', () => { + expect(loadConfig({ upstream: 'http://x' }, { UPSTREAM_API_KEY: ' sk-env \n' }).upstreamKey).toBe('sk-env') + expect(loadConfig({ upstream: 'http://x' }).upstreamKey).toBeUndefined() + }) +}) diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 0000000..a7a4dbc --- /dev/null +++ b/src/version.ts @@ -0,0 +1,20 @@ +import { readFileSync } from 'node:fs' + +/** + * satgate's version, from its own package.json. + * + * The file sits one level up from src/ when run from source, and two + * levels up from dist/src/ when compiled, so both are tried. The name is + * checked so that an unrelated package.json is never read by mistake. + */ +export function readPackageVersion(): string | undefined { + for (const relative of ['../package.json', '../../package.json']) { + try { + const pkg = JSON.parse(readFileSync(new URL(relative, import.meta.url), 'utf-8')) as { name?: string; version?: string } + if (pkg.name === 'satgate' && typeof pkg.version === 'string') return pkg.version + } catch { + // Try the next location + } + } + return undefined +} diff --git a/test/e2e/billing.test.ts b/test/e2e/billing.test.ts new file mode 100644 index 0000000..fbd8151 --- /dev/null +++ b/test/e2e/billing.test.ts @@ -0,0 +1,400 @@ +import { describe, it, expect, afterEach } from 'vitest' +import { Hono } from 'hono' +import { serve } from '@hono/node-server' +import { createTokenTollServer } from '../../src/server.js' +import type { TokenTollConfig } from '../../src/config.js' +import { createPreimageBackend, buyL402Credential, buyIetfCharge } from './helpers/l402-wallet.js' + +/** + * Upstream that reports usage taken from the request: a user message of + * "tokens=N" is answered with N completion tokens. The first `hold` + * requests wait at a barrier until all of them have arrived, so they overlap. + */ +async function startUpstream(opts: { hold?: number } = {}) { + const bodies: Record[] = [] + let waiting: Array<() => void> = [] + let released = false + const app = new Hono() + const handler = async (c: any) => { + const body = await c.req.json() + bodies.push(body) + if (opts.hold && opts.hold > 1 && !released) { + await new Promise((resolve) => { + waiting.push(resolve) + if (waiting.length >= opts.hold!) { + released = true + for (const r of waiting) r() + waiting = [] + } + }) + } + const content = String(body.messages?.[0]?.content ?? body.prompt ?? '') + const match = /tokens=(\d+)/.exec(content) + const completion = match ? Number(match[1]) : 1 + return c.json({ + model: body.model, + choices: [{ message: { role: 'assistant', content: 'ok' } }], + usage: { prompt_tokens: 0, completion_tokens: completion, total_tokens: completion }, + }) + } + app.post('/v1/chat/completions', handler) + app.post('/v1/completions', handler) + let server: ReturnType | undefined + const url = await new Promise((resolve) => { + server = serve({ fetch: app.fetch, port: 0 }, (info) => resolve(`http://localhost:${info.port}`)) + }) + return { url, bodies, close: () => server?.close() } +} + +export function paidConfig(upstream: string, overrides: Partial = {}): TokenTollConfig { + return { + upstream, + port: 0, + rootKey: 'b'.repeat(64), + rootKeyGenerated: false, + storage: 'memory', + dbPath: '', + pricing: { default: 1, models: {} }, + freeTier: { creditsPerDay: 0 }, + capacity: { maxConcurrent: 0 }, + tiers: [], + trustProxy: false, + estimatedCostSats: 10, + maxBodySize: 10 * 1024 * 1024, + authMode: 'lightning', + lightning: 'phoenixd', + allowlist: [], + flatPricing: false, + price: 1, + tunnel: false, + verbose: false, + logFormat: 'pretty', + serviceName: 'satgate', + sessionIntent: false, + maxSessionDepositSats: 100_000, + maxSessionDurationMs: 86_400_000, + announce: false, + announceRelays: [], + announceKey: '', + ...overrides, + } as TokenTollConfig +} + +function chat(auth: string, content: string, extra: Record = {}) { + return { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: auth }, + body: JSON.stringify({ model: 'llama3', messages: [{ role: 'user', content }], ...extra }), + } +} + +let upstream: Awaited> | undefined +afterEach(() => upstream?.close()) + +describe('per-token billing on one credential', () => { + it('settles concurrent requests independently', async () => { + upstream = await startUpstream({ hold: 2 }) + const { backend, preimages } = createPreimageBackend() + const { app } = createTokenTollServer(paidConfig(upstream.url, { backend })) + const auth = await buyL402Credential(app, preimages) + + // 1000 sats of credit. Two overlapping requests cost 1 and 3 sats. + const [a, b] = await Promise.all([ + app.request('/v1/chat/completions', chat(auth, 'tokens=1000')), + app.request('/v1/chat/completions', chat(auth, 'tokens=3000')), + ]) + expect(a.status).toBe(200) + expect(b.status).toBe(200) + + // A third request shows the balance after its own 10-sat hold + const probe = await app.request('/v1/chat/completions', chat(auth, 'tokens=1')) + expect(probe.status).toBe(200) + expect(probe.headers.get('X-Credit-Balance')).toBe(String(1000 - 1 - 3 - 10)) + }) + + it('refunds the hold when the request body is not valid JSON', async () => { + upstream = await startUpstream() + const { backend, preimages } = createPreimageBackend() + const { app } = createTokenTollServer(paidConfig(upstream.url, { backend })) + const auth = await buyL402Credential(app, preimages) + + const bad = await app.request('/v1/chat/completions', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: auth }, + body: '{not json', + }) + expect(bad.status).toBe(400) + + const probe = await app.request('/v1/chat/completions', chat(auth, 'tokens=1')) + expect(probe.headers.get('X-Credit-Balance')).toBe(String(1000 - 10)) + }) +}) + +describe('model names', () => { + it('refuses a model alias outside the served list without charging', async () => { + upstream = await startUpstream() + const { backend, preimages } = createPreimageBackend() + const { app } = createTokenTollServer(paidConfig(upstream.url, { + backend, + models: ['gemma3:4b'], + pricing: { default: 1, models: { 'gemma3:4b': 50 } }, + })) + const auth = await buyL402Credential(app, preimages) + + const res = await app.request('/v1/chat/completions', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: auth }, + body: JSON.stringify({ model: 'registry.ollama.ai/library/gemma3:4b', messages: [{ role: 'user', content: 'hi' }] }), + }) + expect(res.status).toBe(400) + expect(upstream.bodies).toHaveLength(0) + + const probe = await app.request('/v1/chat/completions', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: auth }, + body: JSON.stringify({ model: 'gemma3:4b', messages: [{ role: 'user', content: 'tokens=1' }] }), + }) + expect(probe.status).toBe(200) + expect(probe.headers.get('X-Credit-Balance')).toBe(String(1000 - 10)) + }) +}) + +describe('max_tokens reservation', () => { + it('clamps max_tokens to the operator cap, and sets it when absent', async () => { + upstream = await startUpstream() + const { app } = createTokenTollServer(paidConfig(upstream.url, { authMode: 'open', lightning: undefined, maxTokens: 100 })) + await app.request('/v1/chat/completions', chat('', 'hi', { max_tokens: 5000 })) + await app.request('/v1/chat/completions', chat('', 'hi')) + await app.request('/v1/chat/completions', chat('', 'hi', { max_completion_tokens: 50 })) + expect(upstream.bodies.map(b => b.max_tokens)).toEqual([100, 100, 50]) + expect(upstream.bodies[2].max_completion_tokens).toBe(50) + }) + + it('reserves the worst case from the balance and refunds down to the actual cost', async () => { + upstream = await startUpstream() + const { backend, preimages } = createPreimageBackend() + // 100 sats per 1k tokens: a 1000-token completion costs 100 sats + const { app } = createTokenTollServer(paidConfig(upstream.url, { + backend, + pricing: { default: 100, models: {} }, + estimatedCostSats: 10, + maxTokens: 4000, + })) + const auth = await buyL402Credential(app, preimages) + + const res = await app.request('/v1/chat/completions', chat(auth, 'tokens=1000', { max_tokens: 4000 })) + expect(res.status).toBe(200) + // Charged the actual 1000 tokens (100 sats), not the 10-sat estimate + const probe = await app.request('/v1/chat/completions', chat(auth, 'tokens=0', { max_tokens: 1 })) + expect(probe.headers.get('X-Credit-Balance')).toBe(String(1000 - 100 - 10)) + }) + + it('refuses a request whose worst case the balance cannot cover', async () => { + upstream = await startUpstream() + const { backend, preimages } = createPreimageBackend() + const { app } = createTokenTollServer(paidConfig(upstream.url, { + backend, + pricing: { default: 100, models: {} }, + estimatedCostSats: 10, + maxTokens: 20_000, + })) + const auth = await buyL402Credential(app, preimages) + + // 20k tokens at 100 sats/1k is 2000 sats; the credential holds 1000 + const res = await app.request('/v1/chat/completions', chat(auth, 'tokens=1', { max_tokens: 20_000 })) + expect(res.status).toBe(402) + expect(upstream.bodies).toHaveLength(0) + const body = await res.json() + expect(body.reserve_sats).toBeGreaterThan(1000) + + // Nothing was kept: the next small request sees the full balance less its hold + const probe = await app.request('/v1/chat/completions', chat(auth, 'tokens=0', { max_tokens: 1 })) + expect(probe.headers.get('X-Credit-Balance')).toBe(String(1000 - 10)) + }) + + it('counts every choice asked for with n', async () => { + upstream = await startUpstream() + const { backend, preimages } = createPreimageBackend() + const { app } = createTokenTollServer(paidConfig(upstream.url, { + backend, + pricing: { default: 100, models: {} }, + maxTokens: 3000, + })) + const auth = await buyL402Credential(app, preimages) + // 3000 tokens is 300 sats; four choices is 1200, more than the balance + const res = await app.request('/v1/chat/completions', chat(auth, 'hi', { max_tokens: 3000, n: 4 })) + expect(res.status).toBe(402) + const tooMany = await app.request('/v1/chat/completions', chat(auth, 'hi', { n: 50 })) + expect(tooMany.status).toBe(400) + }) +}) + +describe('IETF Payment per-request charges', () => { + it('shrinks max_tokens so the completion fits what was paid', async () => { + upstream = await startUpstream() + const { backend, preimages } = createPreimageBackend() + // 10 sats at 1 sat/1k buys 10k tokens in total + const { app } = createTokenTollServer(paidConfig(upstream.url, { backend, estimatedCostSats: 10, maxTokens: 20_000 })) + const auth = await buyIetfCharge(app, preimages) + const res = await app.request('/v1/chat/completions', chat(auth, 'tokens=1', { max_tokens: 20_000 })) + expect(res.status).toBe(200) + const sent = upstream.bodies[0] + const bodyBytes = Buffer.byteLength(JSON.stringify({ model: 'llama3', messages: [{ role: 'user', content: 'tokens=1' }], max_tokens: 20_000 })) + expect(sent.max_tokens).toBe(10_000 - bodyBytes) + }) + + it('refuses a prompt larger than what was paid', async () => { + upstream = await startUpstream() + const { backend, preimages } = createPreimageBackend() + const { app } = createTokenTollServer(paidConfig(upstream.url, { backend, estimatedCostSats: 10 })) + const auth = await buyIetfCharge(app, preimages) + const res = await app.request('/v1/chat/completions', chat(auth, 'x'.repeat(12_000))) + expect(res.status).toBe(402) + expect(upstream.bodies).toHaveLength(0) + }) +}) + +describe('IETF Payment sessions', () => { + it('opens a session with a session credential rather than checking it as a charge', async () => { + upstream = await startUpstream() + const { backend, preimages } = createPreimageBackend() + const refundingBackend = { ...backend, sendPayment: async () => ({ paid: true, preimage: '0'.repeat(64) }) } + const { app } = createTokenTollServer(paidConfig(upstream.url, { backend: refundingBackend as typeof backend, sessionIntent: true, realm: 'satgate-test' })) + const challengeRes = await app.request('/v1/chat/completions', chat('', 'hi')) + expect(challengeRes.status).toBe(402) + const header = challengeRes.headers.get('WWW-Authenticate') ?? '' + const match = /Payment (id="[^"]+", realm="[^"]+", method="[^"]+", intent="session", request="[^"]+", expires="[^"]+")/.exec(header) + expect(match).not.toBeNull() + const challenge: Record = {} + for (const [, key, value] of match![1].matchAll(/(\w+)="([^"]*)"/g)) challenge[key] = value + const body = await challengeRes.json() as { ietf_session: { payment_hash: string } } + const preimage = preimages.get(body.ietf_session.payment_hash)! + const credential = Buffer.from(JSON.stringify({ challenge, payload: { action: 'open', preimage } })).toString('base64url') + + const res = await app.request('/v1/chat/completions', chat(`Payment ${credential}`, 'tokens=1')) + expect(res.status).toBe(200) + }) +}) + +describe('client IPs behind a proxy', () => { + async function freeTierApp(trustProxy: boolean, trustedProxies: string[] = []) { + upstream = await startUpstream() + const { backend } = createPreimageBackend() + // One request's worth of free credit per IP per day + return createTokenTollServer(paidConfig(upstream.url, { + backend, trustProxy, trustedProxies, freeTier: { creditsPerDay: 10 }, estimatedCostSats: 10, + })).app + } + + function from(ip: string, extra: Record = {}) { + return { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Forwarded-For': ip, ...extra }, + body: JSON.stringify({ model: 'llama3', messages: [{ role: 'user', content: 'tokens=1' }], max_tokens: 1 }), + } + } + + it('gives each forwarded client its own free-tier allowance', async () => { + const app = await freeTierApp(true) + expect((await app.request('/v1/chat/completions', from('203.0.113.1'))).status).toBe(200) + expect((await app.request('/v1/chat/completions', from('203.0.113.1'))).status).toBe(402) + expect((await app.request('/v1/chat/completions', from('203.0.113.2'))).status).toBe(200) + }) + + it('skips trusted proxy hops when reading X-Forwarded-For', async () => { + const app = await freeTierApp(true, ['10.0.0.0/8']) + expect((await app.request('/v1/chat/completions', from('203.0.113.7, 10.1.2.3'))).status).toBe(200) + // A different spoofed first hop is still the same client behind the same proxy hop + expect((await app.request('/v1/chat/completions', from('198.51.100.9, 203.0.113.7, 10.1.2.3'))).status).toBe(402) + // Another client through the same proxy has its own allowance + expect((await app.request('/v1/chat/completions', from('203.0.113.8, 10.1.2.3'))).status).toBe(200) + }) +}) + +describe('which routes are paid', () => { + function countingBackend() { + const { backend, preimages } = createPreimageBackend() + let invoices = 0 + const counted = { ...backend, createInvoice: async (amount: number, memo?: string) => { invoices++; return backend.createInvoice(amount, memo) } } + return { backend: counted, preimages, invoices: () => invoices } + } + + it('mints no invoice for other methods or unknown /v1 paths', async () => { + upstream = await startUpstream() + const wallet = countingBackend() + const { app } = createTokenTollServer(paidConfig(upstream.url, { backend: wallet.backend })) + expect((await app.request('/v1/chat/completions')).status).toBe(404) + expect((await app.request('/v1/chat/completions', { method: 'PUT' })).status).toBe(404) + expect((await app.request('/v1/anything', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })).status).toBe(404) + expect(wallet.invoices()).toBe(0) + }) + + it('keeps GET /v1/models free under allowlist auth', async () => { + upstream = await startUpstream() + const { app } = createTokenTollServer(paidConfig(upstream.url, { authMode: 'allowlist', allowlist: ['secret'], lightning: undefined })) + const res = await app.request('/v1/models') + expect(res.status).toBe(200) + }) + + it('answers a HEAD price probe without touching a credential sent with it', async () => { + upstream = await startUpstream() + const wallet = countingBackend() + const { app } = createTokenTollServer(paidConfig(upstream.url, { backend: wallet.backend })) + const auth = await buyL402Credential(app, wallet.preimages) + const probe = await app.request('/v1/chat/completions', { method: 'HEAD', headers: { Authorization: auth } }) + expect(probe.status).toBe(402) + expect(probe.headers.get('X-L402-Price-Sats')).toBe('10') + const res = await app.request('/v1/chat/completions', chat(auth, 'tokens=0', { max_tokens: 1 })) + expect(res.headers.get('X-Credit-Balance')).toBe(String(1000 - 10)) + }) +}) + +describe('client IPs without a proxy', () => { + it('keys the free tier on the socket address rather than one shared bucket', async () => { + upstream = await startUpstream() + const { backend } = createPreimageBackend() + const { app } = createTokenTollServer(paidConfig(upstream.url, { backend, freeTier: { creditsPerDay: 10 }, estimatedCostSats: 10 })) + let server: ReturnType | undefined + const url = await new Promise((resolve) => { + server = serve({ fetch: app.fetch, port: 0, hostname: '127.0.0.1' }, (info) => resolve(`http://127.0.0.1:${info.port}`)) + }) + try { + const body = JSON.stringify({ model: 'llama3', messages: [{ role: 'user', content: 'tokens=1' }], max_tokens: 1 }) + const init = { method: 'POST', headers: { 'Content-Type': 'application/json' }, body } + // Over the socket: 127.0.0.1 uses its allowance + expect((await fetch(`${url}/v1/chat/completions`, init)).status).toBe(200) + expect((await fetch(`${url}/v1/chat/completions`, init)).status).toBe(402) + // A client with no socket address has its own allowance, not 127.0.0.1's + expect((await app.request('/v1/chat/completions', init)).status).toBe(200) + } finally { + server?.close() + } + }) +}) + +describe('invoice minting limits', () => { + it('stops issuing invoices to a client holding too many unpaid ones', async () => { + upstream = await startUpstream() + const { backend } = createPreimageBackend() + const { app } = createTokenTollServer(paidConfig(upstream.url, { backend, maxPendingInvoicesPerIp: 2 })) + const post = () => app.request('/v1/chat/completions', chat('', 'hi')) + expect((await post()).status).toBe(402) + expect((await post()).status).toBe(402) + expect((await post()).status).toBe(429) + const direct = await app.request('/create-invoice', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }) + expect(direct.status).toBe(429) + }) +}) + +describe('LNURLcash-only configuration', () => { + it('challenges unpaid requests instead of serving them openly', async () => { + upstream = await startUpstream() + const { loadConfig } = await import('../../src/config.js') + const config = loadConfig({ upstream: upstream.url, lnurlcashMints: 'mint.example.com' }) + const { app } = createTokenTollServer({ ...config, storage: 'memory' }) + const res = await app.request('/v1/chat/completions', chat('', 'hi')) + expect(res.status).toBe(402) + expect(res.headers.get('X-LNURLcash')).toBeTruthy() + expect(upstream.bodies).toHaveLength(0) + }) +}) diff --git a/test/e2e/helpers/l402-wallet.ts b/test/e2e/helpers/l402-wallet.ts new file mode 100644 index 0000000..cc6dc02 --- /dev/null +++ b/test/e2e/helpers/l402-wallet.ts @@ -0,0 +1,57 @@ +import { createHash, randomBytes } from 'node:crypto' +import type { LightningBackend } from '@forgesworn/toll-booth' + +/** + * A Lightning backend whose invoices are "paid" by looking up the preimage: + * enough to drive the L402 flow end to end without a node. + */ +export function createPreimageBackend() { + const preimages = new Map() + const backend: LightningBackend = { + async createInvoice(amountSats: number) { + const preimage = randomBytes(32) + const paymentHash = createHash('sha256').update(preimage).digest('hex') + preimages.set(paymentHash, preimage.toString('hex')) + return { bolt11: `lnbc${amountSats}test${paymentHash.slice(0, 16)}`, paymentHash } + }, + async checkInvoice(paymentHash: string) { + return { paid: preimages.has(paymentHash), preimage: preimages.get(paymentHash) } + }, + } + return { backend, preimages } +} + +type Fetchable = { request: (path: string, init?: RequestInit) => Response | Promise } + +/** Triggers a 402 and returns a paid L402 Authorization header value. */ +export async function buyL402Credential(app: Fetchable, preimages: Map, path = '/v1/chat/completions'): Promise { + const res = await app.request(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: 'llama3', messages: [] }), + }) + if (res.status !== 402) throw new Error(`expected 402, got ${res.status}`) + const body = await res.json() as { l402: { macaroon: string; payment_hash: string } } + const preimage = preimages.get(body.l402.payment_hash) + if (!preimage) throw new Error('no preimage for challenge invoice') + return `L402 ${body.l402.macaroon}:${preimage}` +} + +/** Triggers a 402 and returns a paid IETF Payment (charge intent) Authorization header value. */ +export async function buyIetfCharge(app: Fetchable, preimages: Map, path = '/v1/chat/completions'): Promise { + const res = await app.request(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: 'llama3', messages: [] }), + }) + if (res.status !== 402) throw new Error(`expected 402, got ${res.status}`) + const header = res.headers.get('WWW-Authenticate') ?? '' + const match = /Payment (id="[^"]+", realm="[^"]+", method="[^"]+", intent="[^"]+", request="[^"]+", expires="[^"]+")/.exec(header) + if (!match) throw new Error('no IETF Payment challenge') + const challenge: Record = {} + for (const [, key, value] of match[1].matchAll(/(\w+)="([^"]*)"/g)) challenge[key] = value + const body = await res.json() as { ietf_payment: { payment_hash: string } } + const preimage = preimages.get(body.ietf_payment.payment_hash) + if (!preimage) throw new Error('no preimage for challenge invoice') + return `Payment ${Buffer.from(JSON.stringify({ challenge, payload: { preimage } })).toString('base64url')}` +} diff --git a/test/unit/landing-page.test.ts b/test/unit/landing-page.test.ts new file mode 100644 index 0000000..de19bbe --- /dev/null +++ b/test/unit/landing-page.test.ts @@ -0,0 +1,23 @@ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const html = readFileSync(fileURLToPath(new URL('../../src/page/index.html', import.meta.url)), 'utf8') + +describe('landing page', () => { + it('takes the free allowance from the server rather than a fixed figure', () => { + expect(html).not.toMatch(/FREE_TOKEN_BUDGET = [1-9]/) + expect(html).not.toMatch(/free tokens to start/) + expect(html).toContain('freeTier.credits_per_day') + }) + + it('does not describe IETF Payment as a way to pay', () => { + expect(html).not.toMatch(/Pay with Lightning or IETF Payment/) + }) + + it('only asks for a large card image when it provides one', () => { + const large = /twitter:card" content="summary_large_image"/.test(html) + const image = /property="og:image"/.test(html) + expect(large && !image).toBe(false) + }) +}) diff --git a/test/unit/packaging.test.ts b/test/unit/packaging.test.ts new file mode 100644 index 0000000..7316d11 --- /dev/null +++ b/test/unit/packaging.test.ts @@ -0,0 +1,33 @@ +import { existsSync, mkdtempSync, readFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const root = fileURLToPath(new URL('../../', import.meta.url)) +const manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) + +describe('npm package contents', () => { + it('copies the landing page into the build output during the build', () => { + expect(manifest.scripts.build).toContain('scripts/copy-page.mjs') + const out = mkdtempSync(join(tmpdir(), 'satgate-dist-')) + const result = spawnSync(process.execPath, [join(root, 'scripts', 'copy-page.mjs'), out], { encoding: 'utf8' }) + expect(result.status).toBe(0) + // dist/src/server.js looks for ../page/index.html + expect(existsSync(join(out, 'page', 'index.html'))).toBe(true) + }) + + it('publishes the build output', () => { + expect(manifest.files).toContain('dist') + }) +}) + +describe('Docker image', () => { + it('exposes the port satgate listens on by default', async () => { + const { loadConfig } = await import('../../src/config.js') + const dockerfile = readFileSync(join(root, 'Dockerfile'), 'utf8') + const exposed = /^EXPOSE (\d+)$/m.exec(dockerfile)?.[1] + expect(Number(exposed)).toBe(loadConfig({ upstream: 'http://x' }).port) + }) +}) diff --git a/test/unit/version.test.ts b/test/unit/version.test.ts new file mode 100644 index 0000000..e331653 --- /dev/null +++ b/test/unit/version.test.ts @@ -0,0 +1,46 @@ +import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { spawnSync } from 'node:child_process' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { createRequire } from 'node:module' +import { describe, expect, it } from 'vitest' +import { readPackageVersion } from '../../src/version.js' +import { lightningLabel } from '../../src/cli.js' + +const root = fileURLToPath(new URL('../../', import.meta.url)) +const manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) + +describe('readPackageVersion', () => { + it('reads the real version when run from source', () => { + expect(readPackageVersion()).toBe(manifest.version) + }) + + it('reads the package root from the compiled dist/src layout', () => { + const pkg = mkdtempSync(join(tmpdir(), 'satgate-pkg-')) + mkdirSync(join(pkg, 'dist', 'src'), { recursive: true }) + writeFileSync(join(pkg, 'package.json'), JSON.stringify({ name: 'satgate', version: '9.8.7' })) + // A dist/package.json that is not satgate's must be ignored + writeFileSync(join(pkg, 'dist', 'package.json'), JSON.stringify({ name: 'other', version: '0.0.0' })) + copyFileSync(join(root, 'src', 'version.ts'), join(pkg, 'dist', 'src', 'version.ts')) + const tsx = pathToFileURL(createRequire(import.meta.url).resolve('tsx/esm')).href + const moduleUrl = pathToFileURL(join(pkg, 'dist', 'src', 'version.ts')).href + const result = spawnSync(process.execPath, [ + '--import', tsx, + '--input-type=module', + '-e', `const m = await import(${JSON.stringify(moduleUrl)}); console.log(m.readPackageVersion())`, + ], { encoding: 'utf8' }) + expect(result.stderr).toBe('') + expect(result.stdout.trim()).toBe('9.8.7') + }) +}) + +describe('lightningLabel', () => { + it('omits the URL for nwc, which has none', () => { + expect(lightningLabel({ lightning: 'nwc', lightningUrl: undefined })).toBe('nwc') + }) + + it('shows the URL for URL-based backends', () => { + expect(lightningLabel({ lightning: 'phoenixd', lightningUrl: 'http://localhost:9740' })).toBe('phoenixd (http://localhost:9740)') + }) +})