From 65f7d79ae165799bfbd5af84f9f8b09de7bfd524 Mon Sep 17 00:00:00 2001 From: TheCryptoDonkey Date: Wed, 23 Sep 2026 15:51:04 +0100 Subject: [PATCH 01/32] fix: charge the flat --price per request instead of the per-token estimate Flat mode always sent estimatedCostSats to toll-booth, so --price only appeared in the banner. A flat price of 0 now leaves the routes unpriced rather than issuing 0-sat invoices. --- src/server.test.ts | 78 ++++++++++++++++++++++++++++++++++++++++++++++ src/server.ts | 12 +++++-- 2 files changed, 87 insertions(+), 3 deletions(-) diff --git a/src/server.test.ts b/src/server.test.ts index 3ef133e..2b108b2 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -151,3 +151,81 @@ 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() + } + }) +}) diff --git a/src/server.ts b/src/server.ts index 7747bff..d64bb02 100644 --- a/src/server.ts +++ b/src/server.ts @@ -187,10 +187,14 @@ export function createTokenTollServer(config: TokenTollConfig): TokenTollServer } } + // 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,7 +202,9 @@ export function createTokenTollServer(config: TokenTollConfig): TokenTollServer storage, upstream: config.upstream, backend: config.backend, - pricing: { + // 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 ? {} : { '/v1/chat/completions': pricingEntry, '/v1/completions': pricingEntry, '/v1/embeddings': pricingEntry, From a5297d143c78f01aa95cc24cddbf13695bf92902 Mon Sep 17 00:00:00 2001 From: TheCryptoDonkey Date: Wed, 23 Sep 2026 15:51:47 +0100 Subject: [PATCH 02/32] fix: always request usage on streamed completions The proxy only injected stream_options.include_usage when the client sent no stream_options, so a client could send include_usage: false and fall back to the cheaper chunk-count estimate. --- src/proxy/handler-billing.test.ts | 74 +++++++++++++++++++++++++++++++ src/proxy/handler.ts | 10 +++-- 2 files changed, 81 insertions(+), 3 deletions(-) create mode 100644 src/proxy/handler-billing.test.ts 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..2532d0d 100644 --- a/src/proxy/handler.ts +++ b/src/proxy/handler.ts @@ -160,9 +160,13 @@ export function createProxyHandler(deps: ProxyDeps) { const pricePerThousand = resolveModelPrice(deps.pricing, model) 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 From 57c368420fb744c6baa72251e165b331606c82c0 Mon Sep 17 00:00:00 2001 From: TheCryptoDonkey Date: Wed, 23 Sep 2026 15:53:17 +0100 Subject: [PATCH 03/32] fix: bill reasoning tokens on streamed completions When a stream contained reasoning chunks, the counter ignored the reported completion_tokens and billed only visible content, so thinking models were underbilled when streaming but not when buffered. Reported usage now always wins, reasoning chunks count towards the fallback estimate, and usage figures that are not non-negative numbers are ignored. --- src/proxy/token-counter.test.ts | 32 ++++++++++------- src/proxy/token-counter.ts | 61 ++++++++++++++++----------------- 2 files changed, 49 insertions(+), 44 deletions(-) diff --git a/src/proxy/token-counter.test.ts b/src/proxy/token-counter.test.ts index 5b7507b..4cad05e 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,16 +100,6 @@ describe('TokenCounter', () => { expect(counter.finalCount()).toBe(18) }) - it('falls back to chunk count when reasoning chunks detected', () => { - 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) - }) - it('handles multi-event chunks', () => { const counter = new TokenCounter() const multiChunk = diff --git a/src/proxy/token-counter.ts b/src/proxy/token-counter.ts index 42e102f..32376a1 100644 --- a/src/proxy/token-counter.ts +++ b/src/proxy/token-counter.ts @@ -4,27 +4,31 @@ interface UsageData { total_tokens?: number } +/** 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 /** 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), } } @@ -40,24 +44,25 @@ export class TokenCounter { 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 +74,14 @@ 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) + // 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 +91,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) } } From 1c917370b60fb19514ea47647eaf85148d9527b0 Mon Sep 17 00:00:00 2001 From: TheCryptoDonkey Date: Wed, 23 Sep 2026 15:54:09 +0100 Subject: [PATCH 04/32] fix: parse SSE usage events split across network reads The token counter parsed each network read on its own, so an event split across two reads failed to parse. When that event was the final usage report, billing fell back to the chunk estimate with no prompt tokens. Partial lines are now carried over, and data fields without a space or with CRLF endings are accepted. --- src/proxy/token-counter.test.ts | 19 +++++++++++++++++++ src/proxy/token-counter.ts | 32 +++++++++++++++++++++++++++----- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/src/proxy/token-counter.test.ts b/src/proxy/token-counter.test.ts index 4cad05e..5932798 100644 --- a/src/proxy/token-counter.test.ts +++ b/src/proxy/token-counter.test.ts @@ -100,6 +100,25 @@ describe('TokenCounter', () => { expect(counter.finalCount()).toBe(18) }) + it('parses a usage event split across network chunks', () => { + const counter = new TokenCounter() + 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', () => { const counter = new TokenCounter() const multiChunk = diff --git a/src/proxy/token-counter.ts b/src/proxy/token-counter.ts index 32376a1..1d94a21 100644 --- a/src/proxy/token-counter.ts +++ b/src/proxy/token-counter.ts @@ -4,6 +4,9 @@ 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 @@ -22,6 +25,7 @@ export class TokenCounter { private sseUsage: UsageData | null = null private contentChunkCount = 0 private totalContentBytes = 0 + private partialLine = '' /** Set usage from a buffered (non-streaming) JSON response. */ setBufferedUsage(usage: Record): void { @@ -32,12 +36,23 @@ export class TokenCounter { } } - /** 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 { @@ -81,6 +96,13 @@ export class TokenCounter { * Without reported usage (upstream ignored include_usage), the completion * is estimated from generated chunks with a byte-based floor. */ finalCount(): number { + // 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 From 49b3ab4e25649518326e76bae30ec67fd5e1e279 Mon Sep 17 00:00:00 2001 From: TheCryptoDonkey Date: Wed, 23 Sep 2026 15:59:53 +0100 Subject: [PATCH 05/32] fix: settle each metered request against its own hold toll-booth tracks the estimated cost per payment hash, so when two requests on one credential overlapped the second overwrote the first and went unreconciled. satgate now settles each request against a hold it owns, drawing on the credit balance directly, and never calls the engine's reconcile. Requests that fail before inference (invalid JSON, a non-object body, capacity) now refund the hold as well; before, only upstream failures did. Per-request payments, sessions and the free tier are fixed holds that cannot grow or be refunded; USD credentials stay flat. --- src/proxy/handler.ts | 53 ++++++++----- src/proxy/hold.test.ts | 60 +++++++++++++++ src/proxy/hold.ts | 121 +++++++++++++++++++++++++++++ src/server.ts | 50 ++++++++---- test/e2e/billing.test.ts | 131 ++++++++++++++++++++++++++++++++ test/e2e/helpers/l402-wallet.ts | 38 +++++++++ 6 files changed, 417 insertions(+), 36 deletions(-) create mode 100644 src/proxy/hold.test.ts create mode 100644 src/proxy/hold.ts create mode 100644 test/e2e/billing.test.ts create mode 100644 test/e2e/helpers/l402-wallet.ts diff --git a/src/proxy/handler.ts b/src/proxy/handler.ts index 2532d0d..0d29e83 100644 --- a/src/proxy/handler.ts +++ b/src/proxy/handler.ts @@ -2,6 +2,7 @@ import { TokenCounter } from './token-counter.js' import { createStreamingProxy } from './streaming.js' import { 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' @@ -12,7 +13,11 @@ export interface ProxyDeps { upstream: 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 @@ -38,11 +43,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 +64,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 +75,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 +96,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 +114,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 +126,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 +146,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,6 +154,7 @@ 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' } }, @@ -147,6 +163,7 @@ export function createProxyHandler(deps: ProxyDeps) { // 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' } }, @@ -184,9 +201,7 @@ export function createProxyHandler(deps: ProxyDeps) { }) } 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, @@ -202,9 +217,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 @@ -223,14 +236,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' } }, @@ -254,7 +266,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' }), @@ -278,7 +290,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' } }, @@ -286,7 +298,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' } }, @@ -296,7 +308,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' } }, @@ -311,6 +323,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' } }, @@ -323,8 +336,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/server.ts b/src/server.ts index d64bb02..e63ff16 100644 --- a/src/server.ts +++ b/src/server.ts @@ -25,6 +25,7 @@ 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' @@ -352,13 +353,41 @@ export function createTokenTollServer(config: TokenTollConfig): TokenTollServer upstream: config.upstream, pricing: config.pricing, capacity, - reconcile: (paymentHash, actualCost) => engine.reconcile(paymentHash, actualCost), maxBodySize: config.maxBodySize, flatPricing: config.flatPricing, logger, }) - if (config.authMode === 'lightning' || config.authMode === 'cashu') { + 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)) + + if (paidAuth) { app.use('/v1/*', tollBooth.authMiddleware) } else { const authMiddleware = createAuthMiddleware({ @@ -386,20 +415,9 @@ export function createTokenTollServer(config: TokenTollConfig): TokenTollServer } }) - 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) - }) - - 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) - }) - - 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, diff --git a/test/e2e/billing.test.ts b/test/e2e/billing.test.ts new file mode 100644 index 0000000..146ca69 --- /dev/null +++ b/test/e2e/billing.test.ts @@ -0,0 +1,131 @@ +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 } 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)) + }) +}) diff --git a/test/e2e/helpers/l402-wallet.ts b/test/e2e/helpers/l402-wallet.ts new file mode 100644 index 0000000..a910535 --- /dev/null +++ b/test/e2e/helpers/l402-wallet.ts @@ -0,0 +1,38 @@ +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}` +} From 712aa914b099701819af36094ea35d339573fe92 Mon Sep 17 00:00:00 2001 From: TheCryptoDonkey Date: Wed, 23 Sep 2026 16:00:46 +0100 Subject: [PATCH 06/32] fix: refuse model names the 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, such as registry.ollama.ai/library/gemma3:4b, was served as that model and billed at the default price. Requests must now name an auto-detected or configured model, compared case-insensitively with Ollama's implicit :latest tag spelt out; anything else is refused with a 400 and the hold refunded. Pricing also matches an untagged name to its :latest entry. --- src/proxy/handler.ts | 20 +++++++++++++++++++- src/proxy/pricing.test.ts | 28 +++++++++++++++++++++++++++- src/proxy/pricing.ts | 33 ++++++++++++++++++++++++++++++++- src/server.ts | 1 + test/e2e/billing.test.ts | 29 +++++++++++++++++++++++++++++ 5 files changed, 108 insertions(+), 3 deletions(-) diff --git a/src/proxy/handler.ts b/src/proxy/handler.ts index 0d29e83..62a8c8e 100644 --- a/src/proxy/handler.ts +++ b/src/proxy/handler.ts @@ -1,6 +1,6 @@ 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' @@ -23,6 +23,12 @@ export interface ProxyDeps { flatPricing?: boolean /** 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 } @@ -161,6 +167,18 @@ export function createProxyHandler(deps: ProxyDeps) { ) } + // 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' } }, + ) + } + // Now acquire capacity — body is validated and parsed, no slow client can hold a slot if (!deps.capacity.tryAcquire()) { refund() 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/server.ts b/src/server.ts index e63ff16..2ea72c4 100644 --- a/src/server.ts +++ b/src/server.ts @@ -355,6 +355,7 @@ export function createTokenTollServer(config: TokenTollConfig): TokenTollServer capacity, maxBodySize: config.maxBodySize, flatPricing: config.flatPricing, + models: [...new Set([...(config.models ?? []), ...Object.keys(config.pricing.models)])], logger, }) diff --git a/test/e2e/billing.test.ts b/test/e2e/billing.test.ts index 146ca69..5f73b8c 100644 --- a/test/e2e/billing.test.ts +++ b/test/e2e/billing.test.ts @@ -129,3 +129,32 @@ describe('per-token billing on one credential', () => { 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)) + }) +}) From 10ae473a22d7f4fdf18ec16454093d841bace1eb Mon Sep 17 00:00:00 2001 From: TheCryptoDonkey Date: Wed, 23 Sep 2026 16:04:27 +0100 Subject: [PATCH 07/32] fix: cap max_tokens and reserve the worst-case cost up front Nothing bounded a completion, so a request could generate far more than the fixed hold covered and the reconciliation charge was clamped at the remaining balance. max_tokens is now clamped to an operator cap (--max-tokens, SATGATE_MAX_TOKENS or maxTokens; default 2048) and set when absent, and n/best_of are limited to 8. In per-token mode the proxy holds the worst case before calling the upstream: every body byte as a prompt token plus max_tokens per choice. Credit balances are debited for it and refunded to the actual cost, and a balance that cannot cover it gets a 402. Per-request payments (IETF Payment charge), sessions and the free tier cannot grow, so max_tokens is shrunk to fit what was paid, and a prompt larger than that is refused. The default hold now covers a 4 KiB body plus a full max_tokens completion at the dearest configured price. --- src/cli.ts | 3 + src/config.test.ts | 22 +++++++- src/config.ts | 31 ++++++++++- src/proxy/handler.ts | 79 ++++++++++++++++++++++++++- src/server.ts | 1 + test/e2e/billing.test.ts | 97 ++++++++++++++++++++++++++++++++- test/e2e/helpers/l402-wallet.ts | 19 +++++++ 7 files changed, 247 insertions(+), 5 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index b3d73a2..ef5ea29 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -32,6 +32,7 @@ function parseArgs(argv: string[]): CliArgs { 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-tokens': args.maxTokens = parseInt(argv[++i], 10); break case '--model-price': args.modelPrice = [...(args.modelPrice ?? []), argv[++i]] break @@ -100,6 +101,8 @@ 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) diff --git a/src/config.test.ts b/src/config.test.ts index ed527c7..0fe8b15 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -540,7 +540,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 +653,22 @@ 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/) + }) +}) diff --git a/src/config.ts b/src/config.ts index d6e2af2..c8bf679 100644 --- a/src/config.ts +++ b/src/config.ts @@ -25,6 +25,12 @@ export interface TokenTollConfig { trustProxy: boolean /** 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 /** Auto-detected model IDs from upstream. */ @@ -96,6 +102,7 @@ export interface CliArgs { freeTier?: number trustProxy?: boolean rootKey?: string + maxTokens?: number // New fields: lightning?: string lightningUrl?: string @@ -129,6 +136,7 @@ export interface FileConfig { tiers?: Array<{ amountSats: number; creditSats: number; label: string }> trustProxy?: boolean estimatedCostSats?: number + maxTokens?: number maxBodySize?: number // New fields: lightning?: string @@ -175,6 +183,12 @@ export function normaliseMintHost(entry: string): string | undefined { } } +/** Default cap on completion tokens per request. */ +export const DEFAULT_MAX_TOKENS = 2048 + +/** 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', @@ -348,8 +362,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) { @@ -524,6 +552,7 @@ export function loadConfig( tiers, trustProxy, estimatedCostSats, + maxTokens, maxBodySize, lightning, lightningUrl, diff --git a/src/proxy/handler.ts b/src/proxy/handler.ts index 62a8c8e..0380549 100644 --- a/src/proxy/handler.ts +++ b/src/proxy/handler.ts @@ -21,6 +21,8 @@ export interface ProxyDeps { 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 /** @@ -33,6 +35,21 @@ export interface ProxyDeps { 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. */ @@ -179,6 +196,65 @@ export function createProxyHandler(deps: ProxyDeps) { ) } + // 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() @@ -191,8 +267,7 @@ 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 // Always ask for usage on streams: billing depends on it, so a client diff --git a/src/server.ts b/src/server.ts index 2ea72c4..ada351e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -355,6 +355,7 @@ export function createTokenTollServer(config: TokenTollConfig): TokenTollServer capacity, maxBodySize: config.maxBodySize, flatPricing: config.flatPricing, + maxTokens: config.maxTokens, models: [...new Set([...(config.models ?? []), ...Object.keys(config.pricing.models)])], logger, }) diff --git a/test/e2e/billing.test.ts b/test/e2e/billing.test.ts index 5f73b8c..95d9b61 100644 --- a/test/e2e/billing.test.ts +++ b/test/e2e/billing.test.ts @@ -3,7 +3,7 @@ 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 } from './helpers/l402-wallet.js' +import { createPreimageBackend, buyL402Credential, buyIetfCharge } from './helpers/l402-wallet.js' /** * Upstream that reports usage taken from the request: a user message of @@ -158,3 +158,98 @@ describe('model names', () => { 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) + }) +}) diff --git a/test/e2e/helpers/l402-wallet.ts b/test/e2e/helpers/l402-wallet.ts index a910535..cc6dc02 100644 --- a/test/e2e/helpers/l402-wallet.ts +++ b/test/e2e/helpers/l402-wallet.ts @@ -36,3 +36,22 @@ 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 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')}` +} From 9648b4ed05e357bbdd6bc1873f8d876b4d46e532 Mon Sep 17 00:00:00 2001 From: TheCryptoDonkey Date: Wed, 23 Sep 2026 16:05:30 +0100 Subject: [PATCH 08/32] fix: try the IETF session rail before the charge rail Rails are tried in order and the charge rail claims every Authorization: Payment header, so session credentials were only ever verified as charges and refused. --- src/server.ts | 20 ++++++++++++-------- test/e2e/billing.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/src/server.ts b/src/server.ts index ada351e..237b640 100644 --- a/src/server.ts +++ b/src/server.ts @@ -160,15 +160,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, @@ -186,6 +182,14 @@ 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; diff --git a/test/e2e/billing.test.ts b/test/e2e/billing.test.ts index 95d9b61..1ae574d 100644 --- a/test/e2e/billing.test.ts +++ b/test/e2e/billing.test.ts @@ -253,3 +253,25 @@ describe('IETF Payment per-request charges', () => { 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) + }) +}) From b935bcce869f9f06b8db569fb42c3c51ea2263d1 Mon Sep 17 00:00:00 2001 From: TheCryptoDonkey Date: Wed, 23 Sep 2026 16:07:06 +0100 Subject: [PATCH 09/32] fix: pass trustProxy and trusted proxies to the toll-booth adapter createHonoTollBooth was built with the engine alone, so --trust-proxy did nothing: every client resolved to 0.0.0.0 and shared one free-tier bucket and one pending-invoice count. trustProxy is now passed through, and trusted proxy hops can be named with --trusted-proxies, TRUSTED_PROXIES or trustedProxies (naming them implies trustProxy). --- src/cli.ts | 6 +++++- src/config.test.ts | 17 +++++++++++++++++ src/config.ts | 23 ++++++++++++++++++++++- src/server.ts | 6 +++++- test/e2e/billing.test.ts | 35 +++++++++++++++++++++++++++++++++++ 5 files changed, 84 insertions(+), 3 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index ef5ea29..6c6be53 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -21,6 +21,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 @@ -122,7 +123,10 @@ function printHelp(): void { --config Config file (JSON or YAML) --max-concurrent Max concurrent inference requests --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) diff --git a/src/config.test.ts b/src/config.test.ts index 0fe8b15..65fe206 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -672,3 +672,20 @@ describe('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/) + }) +}) diff --git a/src/config.ts b/src/config.ts index c8bf679..be98261 100644 --- a/src/config.ts +++ b/src/config.ts @@ -23,6 +23,12 @@ 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 /** @@ -101,6 +107,7 @@ export interface CliArgs { dbPath?: string freeTier?: number trustProxy?: boolean + trustedProxies?: string rootKey?: string maxTokens?: number // New fields: @@ -135,6 +142,7 @@ export interface FileConfig { capacity?: { maxConcurrent?: number } tiers?: Array<{ amountSats: number; creditSats: number; label: string }> trustProxy?: boolean + trustedProxies?: string[] estimatedCostSats?: number maxTokens?: number maxBodySize?: number @@ -348,11 +356,23 @@ export function loadConfig( 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 ?? [] @@ -551,6 +571,7 @@ export function loadConfig( capacity: { maxConcurrent }, tiers, trustProxy, + trustedProxies, estimatedCostSats, maxTokens, maxBodySize, diff --git a/src/server.ts b/src/server.ts index 237b640..957d6da 100644 --- a/src/server.ts +++ b/src/server.ts @@ -224,7 +224,11 @@ export function createTokenTollServer(config: TokenTollConfig): TokenTollServer }) // Create Hono toll-booth adapter - const tollBooth = createHonoTollBooth({ engine }) + const tollBooth = createHonoTollBooth({ + engine, + trustProxy: config.trustProxy, + ...(config.trustedProxies?.length && { trustedProxies: config.trustedProxies }), + }) // Mount payment routes const paymentApp = tollBooth.createPaymentApp({ diff --git a/test/e2e/billing.test.ts b/test/e2e/billing.test.ts index 1ae574d..24654ce 100644 --- a/test/e2e/billing.test.ts +++ b/test/e2e/billing.test.ts @@ -275,3 +275,38 @@ describe('IETF Payment sessions', () => { 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) + }) +}) From f9dc744e7a36f00c090f15b9134df54e2c9bbef7 Mon Sep 17 00:00:00 2001 From: TheCryptoDonkey Date: Wed, 23 Sep 2026 16:08:19 +0100 Subject: [PATCH 10/32] fix: only put POSTs to the inference endpoints behind payment Payment auth was mounted on /v1/*, so any method on any /v1 path could mint an invoice. It now runs only for POST to /v1/chat/completions, /v1/completions and /v1/embeddings; other methods and paths are a 404 and GET /v1/models stays free. HEAD is answered by satgate as a free price probe, so a credential sent with it is never verified or debited. --- src/server.ts | 48 ++++++++++++++++++++++++++-------------- test/e2e/billing.test.ts | 38 +++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 16 deletions(-) diff --git a/src/server.ts b/src/server.ts index 957d6da..4df93a9 100644 --- a/src/server.ts +++ b/src/server.ts @@ -31,6 +31,9 @@ import { generateLlmsTxt } from './discovery/llms-txt.js' import { generateOpenApiSpec } from './discovery/openapi.js' import { createHttpFacilitator } from './x402/facilitator.js' +/** 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 @@ -209,11 +212,9 @@ export function createTokenTollServer(config: TokenTollConfig): TokenTollServer backend: config.backend, // 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 ? {} : { - '/v1/chat/completions': pricingEntry, - '/v1/completions': pricingEntry, - '/v1/embeddings': pricingEntry, - }, + pricing: config.flatPricing && config.price === 0 + ? {} + : Object.fromEntries(PAID_PATHS.map(path => [path, pricingEntry])), defaultInvoiceAmount: config.tiers[0]?.amountSats ?? 1000, freeTier: config.freeTier.creditsPerDay > 0 ? { creditsPerDay: config.freeTier.creditsPerDay } : undefined, ...(rails.length > 0 && { rails }), @@ -397,19 +398,16 @@ export function createTokenTollServer(config: TokenTollConfig): TokenTollServer const proxy = (c: Context) => proxyHandler(c.req.raw, undefined, holdFor(c)) - if (paidAuth) { - app.use('/v1/*', tollBooth.authMiddleware) - } else { - const authMiddleware = createAuthMiddleware({ - authMode: config.authMode, - allowlist: config.allowlist, - }) - app.use('/v1/*', authMiddleware) - } + // 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 }) // 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) { @@ -423,7 +421,25 @@ export function createTokenTollServer(config: TokenTollConfig): TokenTollServer if (freeRemaining !== undefined) { c.header('X-Free-Remaining', String(freeRemaining)) } - }) + } + + // 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) + } + + for (const path of PAID_PATHS) { + app.get(path, priceProbe) + app.post(path, authMiddleware, forwardPaymentHeaders) + } app.post('/v1/chat/completions', proxy) app.post('/v1/completions', proxy) diff --git a/test/e2e/billing.test.ts b/test/e2e/billing.test.ts index 24654ce..64cb4b1 100644 --- a/test/e2e/billing.test.ts +++ b/test/e2e/billing.test.ts @@ -310,3 +310,41 @@ describe('client IPs behind a proxy', () => { 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)) + }) +}) From 84eabde864f0eb28f8046bb30d2cb9169c6f873b Mon Sep 17 00:00:00 2001 From: TheCryptoDonkey Date: Wed, 23 Sep 2026 16:09:31 +0100 Subject: [PATCH 11/32] fix: identify clients by socket address when proxy headers are untrusted Without trustProxy the toll-booth Hono adapter files every client under 0.0.0.0, so a directly exposed satgate had one free-tier allowance and one pending-invoice count for everyone. satgate now supplies the TCP peer address in that case. --- src/server.test.ts | 25 ++++++++++++++++++++++++- src/server.ts | 17 +++++++++++++++++ test/e2e/billing.test.ts | 23 +++++++++++++++++++++++ 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/src/server.test.ts b/src/server.test.ts index 2b108b2..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() { @@ -229,3 +229,26 @@ describe('free flat pricing', () => { } }) }) + +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 4df93a9..0c461cf 100644 --- a/src/server.ts +++ b/src/server.ts @@ -19,6 +19,7 @@ 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' @@ -31,6 +32,18 @@ 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 @@ -225,10 +238,14 @@ export function createTokenTollServer(config: TokenTollConfig): TokenTollServer }) // Create Hono toll-booth adapter + // 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 diff --git a/test/e2e/billing.test.ts b/test/e2e/billing.test.ts index 64cb4b1..6bc6007 100644 --- a/test/e2e/billing.test.ts +++ b/test/e2e/billing.test.ts @@ -348,3 +348,26 @@ describe('which routes are paid', () => { 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() + } + }) +}) From 34de97edab85c475f8526c44f3a33b44bf806718 Mon Sep 17 00:00:00 2001 From: TheCryptoDonkey Date: Wed, 23 Sep 2026 16:10:07 +0100 Subject: [PATCH 12/32] fix: limit unpaid invoices per client and prune stale ones Anyone could mint Lightning invoices without limit, through 402 challenges or POST /create-invoice. Each client IP may now hold 20 unpaid invoices before getting a 429 (--max-pending-invoices, SATGATE_MAX_PENDING_INVOICES or maxPendingInvoicesPerIp; 0 disables). createTollBooth runs no housekeeping, so satgate now prunes invoices unpaid after a day, and expired Cashu claims, every hour; close() stops the timer. --- src/cli.ts | 3 +++ src/config.test.ts | 10 ++++++++++ src/config.ts | 19 +++++++++++++++++++ src/server.ts | 20 +++++++++++++++++++- test/e2e/billing.test.ts | 14 ++++++++++++++ 5 files changed, 65 insertions(+), 1 deletion(-) diff --git a/src/cli.ts b/src/cli.ts index 6c6be53..4e6c689 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -33,6 +33,7 @@ function parseArgs(argv: string[]): CliArgs { 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]] @@ -122,6 +123,8 @@ function printHelp(): void { Other: --config Config file (JSON or YAML) --max-concurrent Max concurrent inference requests + --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 / X-Real-IP for client IPs (free tier and invoice limits); only behind a proxy diff --git a/src/config.test.ts b/src/config.test.ts index 65fe206..86f26c3 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -689,3 +689,13 @@ describe('trusted proxies', () => { 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/) + }) +}) diff --git a/src/config.ts b/src/config.ts index be98261..ed08759 100644 --- a/src/config.ts +++ b/src/config.ts @@ -39,6 +39,11 @@ export interface TokenTollConfig { 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: @@ -110,6 +115,7 @@ export interface CliArgs { trustedProxies?: string rootKey?: string maxTokens?: number + maxPendingInvoices?: number // New fields: lightning?: string lightningUrl?: string @@ -146,6 +152,7 @@ export interface FileConfig { estimatedCostSats?: number maxTokens?: number maxBodySize?: number + maxPendingInvoicesPerIp?: number // New fields: lightning?: string lightningUrl?: string @@ -194,6 +201,9 @@ export function normaliseMintHost(entry: string): string | undefined { /** 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 @@ -405,6 +415,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 @@ -575,6 +593,7 @@ export function loadConfig( estimatedCostSats, maxTokens, maxBodySize, + maxPendingInvoicesPerIp, lightning, lightningUrl, lightningKey, diff --git a/src/server.ts b/src/server.ts index 0c461cf..4e3f0c8 100644 --- a/src/server.ts +++ b/src/server.ts @@ -229,6 +229,9 @@ export function createTokenTollServer(config: TokenTollConfig): TokenTollServer ? {} : 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, @@ -256,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 ?? [] @@ -465,7 +483,7 @@ export function createTokenTollServer(config: TokenTollConfig): TokenTollServer return { app, close: () => { - // Cleanup if needed + clearInterval(pruneTimer) }, } } diff --git a/test/e2e/billing.test.ts b/test/e2e/billing.test.ts index 6bc6007..ddadd17 100644 --- a/test/e2e/billing.test.ts +++ b/test/e2e/billing.test.ts @@ -371,3 +371,17 @@ describe('client IPs without a proxy', () => { } }) }) + +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) + }) +}) From 472835a4faa2f26e84b1c8bd51a6e7266c7a7c1d Mon Sep 17 00:00:00 2001 From: TheCryptoDonkey Date: Wed, 23 Sep 2026 16:11:01 +0100 Subject: [PATCH 13/32] fix: keep open mode off the public internet by default With no --lightning, satgate runs with no payment or auth, yet the Cloudflare tunnel defaulted on, so the quick start published an open inference proxy. The tunnel now defaults on only when payment or an allowlist is required; --tunnel opts in explicitly. The README, examples/basic.sh and llms.txt quick starts now pass --lightning, and no longer claim the bare command accepts payments. --- README.md | 16 +++++++++------- examples/basic.sh | 10 +++++++--- llms.txt | 4 ++-- src/cli.ts | 6 +++++- src/config.test.ts | 11 +++++++++++ src/config.ts | 8 ++++++-- 6 files changed, 40 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 569e9dd..216a5e5 100644 --- a/README.md +++ b/README.md @@ -14,10 +14,12 @@ satgate sits in front of Ollama, vLLM, llama.cpp — any OpenAI-compatible backe ## 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,7 +46,7 @@ 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. | @@ -137,7 +139,7 @@ graph TB 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. +- **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`. --- @@ -269,10 +271,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/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..1a95855 100644 --- a/llms.txt +++ b/llms.txt @@ -15,10 +15,10 @@ 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 per token 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. For production, create `satgate.yaml`: ```yaml upstream: http://localhost:11434 diff --git a/src/cli.ts b/src/cli.ts index 4e6c689..a212363 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -29,6 +29,7 @@ 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 @@ -108,7 +109,10 @@ function printHelp(): void { 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 diff --git a/src/config.test.ts b/src/config.test.ts index 86f26c3..abfc521 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -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', () => { diff --git a/src/config.ts b/src/config.ts index ed08759..53995ae 100644 --- a/src/config.ts +++ b/src/config.ts @@ -124,6 +124,7 @@ export interface CliArgs { allowlist?: string[] allowlistFile?: string noTunnel?: boolean + tunnel?: boolean verbose?: boolean logFormat?: string tokenPrice?: number @@ -516,9 +517,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 From 40b371ffe5d26ace491a5384a8016d39e46106ce Mon Sep 17 00:00:00 2001 From: TheCryptoDonkey Date: Wed, 23 Sep 2026 16:11:12 +0100 Subject: [PATCH 14/32] chore: drop local machine paths from the demo tape The hidden setup typed an absolute home-directory PATH and checkout path. It now changes to the repository root from wherever vhs is run and expects Node on PATH. --- demo/demo.tape | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) 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 From 4c1f0d833947255bea74cc2f835f9be2f2146c9e Mon Sep 17 00:00:00 2001 From: TheCryptoDonkey Date: Wed, 23 Sep 2026 16:11:53 +0100 Subject: [PATCH 15/32] fix: ship the landing page in the npm package tsc emits only TypeScript and the package publishes dist/, so npx satgate served a 404 at /. The build now copies src/page/index.html to dist/page/index.html, where the compiled server looks for it, and the Docker image takes it from the build output instead of copying it separately. --- Dockerfile | 2 +- package.json | 2 +- scripts/copy-page.mjs | 14 ++++++++++++++ test/unit/packaging.test.ts | 24 ++++++++++++++++++++++++ 4 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 scripts/copy-page.mjs create mode 100644 test/unit/packaging.test.ts diff --git a/Dockerfile b/Dockerfile index 7424b8c..156bbef 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,7 +18,6 @@ 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 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/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/test/unit/packaging.test.ts b/test/unit/packaging.test.ts new file mode 100644 index 0000000..e68d3a7 --- /dev/null +++ b/test/unit/packaging.test.ts @@ -0,0 +1,24 @@ +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') + }) +}) From d1fdd8aa26861bd35b6caa8c34ef8aae87b65926 Mon Sep 17 00:00:00 2001 From: TheCryptoDonkey Date: Wed, 23 Sep 2026 16:12:36 +0100 Subject: [PATCH 16/32] fix: report the real version and omit the nwc URL in the banner --version and the banner read ../package.json relative to the module, which from dist/src is dist/package.json: --version printed 'unknown version' and the banner fell back to v0.1.0. Both now find satgate's own package.json from source or compiled layouts. The banner also printed 'nwc (undefined)', since nwc has no backend URL. --- src/cli.ts | 26 ++++++++++------------ src/version.ts | 20 +++++++++++++++++ test/unit/version.test.ts | 46 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 15 deletions(-) create mode 100644 src/version.ts create mode 100644 test/unit/version.test.ts diff --git a/src/cli.ts b/src/cli.ts index a212363..d3d11bf 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -7,6 +7,7 @@ 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' function parseArgs(argv: string[]): CliArgs { const args: CliArgs = {} @@ -143,12 +144,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 } /** @@ -267,19 +270,12 @@ 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' @@ -294,7 +290,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/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/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)') + }) +}) From c6d5e5e8ac0598017ba4117a32c28e583c399a58 Mon Sep 17 00:00:00 2001 From: TheCryptoDonkey Date: Wed, 23 Sep 2026 16:13:34 +0100 Subject: [PATCH 17/32] fix: persist paid balances and the root key by default Storage defaulted to memory and the macaroon root key was regenerated on every start, so a restart lost every paid balance and invalidated every credential. With any payment rail configured, storage now defaults to SQLite, and a generated root key is saved beside the database (satgate.root-key, mode 0600) and reused. Choosing memory storage with payments enabled prints a loud warning. --- .gitignore | 1 + src/cli.ts | 22 +++++++++++++++++++++- src/config.test.ts | 9 +++++++++ src/config.ts | 15 +++++++++------ src/root-key.test.ts | 25 +++++++++++++++++++++++++ src/root-key.ts | 33 +++++++++++++++++++++++++++++++++ 6 files changed, 98 insertions(+), 7 deletions(-) create mode 100644 src/root-key.test.ts create mode 100644 src/root-key.ts diff --git a/.gitignore b/.gitignore index a58b587..e0e964e 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ dist/ docs/superpowers/ .superpowers/ .playwright-mcp/ +satgate.root-key diff --git a/src/cli.ts b/src/cli.ts index d3d11bf..ce1713c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -8,6 +8,7 @@ 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 = {} @@ -122,7 +123,8 @@ 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: @@ -241,6 +243,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') } diff --git a/src/config.test.ts b/src/config.test.ts index abfc521..5698f16 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -710,3 +710,12 @@ describe('pending invoice limit', () => { 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') + }) +}) diff --git a/src/config.ts b/src/config.ts index 53995ae..df23b45 100644 --- a/src/config.ts +++ b/src/config.ts @@ -251,12 +251,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 @@ -492,6 +486,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 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 } +} From 5507eb1f00c793aa4614da2cba450fa60c47bb46 Mon Sep 17 00:00:00 2001 From: TheCryptoDonkey Date: Wed, 23 Sep 2026 16:13:48 +0100 Subject: [PATCH 18/32] fix: limit concurrent inference to 8 requests by default MAX_CONCURRENT defaulted to unlimited, so a burst of paid or free requests queued without bound on the GPU. Beyond 8 in flight clients now get a quick 503; --max-concurrent 0 restores the old behaviour. --- src/cli.ts | 3 ++- src/config.test.ts | 9 ++++++++- src/config.ts | 8 +++++++- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index ce1713c..42b6811 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -129,7 +129,8 @@ function printHelp(): void { 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) diff --git a/src/config.test.ts b/src/config.test.ts index 5698f16..8bc5f40 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) }) @@ -719,3 +719,10 @@ describe('storage defaults', () => { 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) + }) +}) diff --git a/src/config.ts b/src/config.ts index df23b45..a52d223 100644 --- a/src/config.ts +++ b/src/config.ts @@ -199,6 +199,12 @@ 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 @@ -356,7 +362,7 @@ 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)`) } From b24a2115f9316d27d8bb6dd3ec2fbce45137c627 Mon Sep 17 00:00:00 2001 From: TheCryptoDonkey Date: Wed, 23 Sep 2026 16:14:34 +0100 Subject: [PATCH 19/32] feat: support upstreams that need an API key satgate sent no credentials upstream, so only local or keyless OpenAI-compatible endpoints worked. A bearer key can now be set with UPSTREAM_API_KEY, --upstream-key-file or upstreamKeyFile in the config file; it is sent on inference and model-list requests, and the client's own Authorization header is never forwarded. --- src/cli.ts | 16 ++++++++++ src/config.ts | 10 +++++++ src/proxy/handler.ts | 7 ++++- src/server.ts | 2 ++ src/upstream-key.test.ts | 65 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 src/upstream-key.test.ts diff --git a/src/cli.ts b/src/cli.ts index 42b6811..b4ff678 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -15,6 +15,7 @@ function parseArgs(argv: string[]): 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 @@ -83,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 @@ -231,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') @@ -272,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) ?? [] diff --git a/src/config.ts b/src/config.ts index a52d223..5156065 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 @@ -104,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 @@ -140,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 @@ -244,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)`) @@ -592,6 +601,7 @@ export function loadConfig( return { upstream: upstream.replace(/\/+$/, ''), + upstreamKey, port, rootKey, rootKeyGenerated, diff --git a/src/proxy/handler.ts b/src/proxy/handler.ts index 0380549..515a24b 100644 --- a/src/proxy/handler.ts +++ b/src/proxy/handler.ts @@ -11,6 +11,8 @@ 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 /** @@ -288,7 +290,10 @@ 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), }) diff --git a/src/server.ts b/src/server.ts index 4e3f0c8..baa9e9a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -360,6 +360,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() @@ -395,6 +396,7 @@ 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, maxBodySize: config.maxBodySize, 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() + }) +}) From 693183c1f1e5e5406784eb5a5583ebf93fa08def Mon Sep 17 00:00:00 2001 From: TheCryptoDonkey Date: Wed, 23 Sep 2026 16:15:27 +0100 Subject: [PATCH 20/32] fix: accept NIP-98 events signed for the public URL behind a proxy Allowlist auth compared the NIP-98 u tag with the URL the server saw, which behind a TLS-terminating proxy is http://127.0.0.1:..., so no client signing the real https URL could authenticate. The u tag may now also match the request rebuilt from --public-url, or from X-Forwarded-Proto and X-Forwarded-Host when the proxy is trusted. --- src/auth/allowlist.ts | 9 ++++- src/auth/middleware.test.ts | 70 +++++++++++++++++++++++++++++++++++++ src/auth/middleware.ts | 35 ++++++++++++++++++- src/server.ts | 7 +++- 4 files changed, 118 insertions(+), 3 deletions(-) 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/server.ts b/src/server.ts index baa9e9a..fed654b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -440,7 +440,12 @@ export function createTokenTollServer(config: TokenTollConfig): TokenTollServer // methods and paths fall through to a 404 instead of minting invoices. const authMiddleware = paidAuth ? tollBooth.authMiddleware - : createAuthMiddleware({ authMode: config.authMode, allowlist: config.allowlist }) + : 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. From b37104bb9558808cb6e76f2307af74b1e80ad137 Mon Sep 17 00:00:00 2001 From: TheCryptoDonkey Date: Wed, 23 Sep 2026 16:16:11 +0100 Subject: [PATCH 21/32] fix: derive the served llms.txt from the running configuration /llms.txt always claimed Lightning and NWC, listed per-token rates in flat mode, and priced tagged models at the default. It now names only the configured rails (Lightning, Cashu, LNURLcash, x402), the credential header for each, the flat price when flat pricing is on, and model rates through the same lookup billing uses. --- src/discovery/llms-txt.test.ts | 26 ++++++++++++++ src/discovery/llms-txt.ts | 65 ++++++++++++++++++++++------------ src/server.ts | 4 ++- 3 files changed, 72 insertions(+), 23 deletions(-) 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/server.ts b/src/server.ts index fed654b..76802b7 100644 --- a/src/server.ts +++ b/src/server.ts @@ -305,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 }), })) }) From 3040f64ef489b58bc13bf40549c5b99924f39dfd Mon Sep 17 00:00:00 2001 From: TheCryptoDonkey Date: Wed, 23 Sep 2026 16:16:43 +0100 Subject: [PATCH 22/32] fix: version the OpenAPI spec and declare every accepted scheme openapi.json always said version 1.0.0 and declared only L402. It now carries the package version, declares the IETF Payment scheme alongside L402 when Lightning is configured, describes x402, Cashu and LNURLcash as the headers they are, and lists every accepted scheme on the inference endpoints. --- src/discovery/openapi.test.ts | 19 +++++++++++++++++ src/discovery/openapi.ts | 39 ++++++++++++++++++++++++++++++----- src/server.ts | 3 +++ 3 files changed, 56 insertions(+), 5 deletions(-) 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/server.ts b/src/server.ts index 76802b7..00ec6f1 100644 --- a/src/server.ts +++ b/src/server.ts @@ -317,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, })) }) From acbb91c2d9388fc782598db74c0316e73234a7b7 Mon Sep 17 00:00:00 2001 From: TheCryptoDonkey Date: Wed, 23 Sep 2026 16:17:20 +0100 Subject: [PATCH 23/32] fix: show the playground's real free allowance and describe IETF Payment correctly The playground promised 250 free tokens whatever the server offered, and held clients to that figure client-side. It now reads the free-tier credit from /.well-known/l402, shows it in sats, and shows nothing when there is no free tier. The description no longer calls IETF Payment a way to pay (it is an authentication scheme over Lightning), and the social card is 'summary' since the page has no og:image. --- src/page/index.html | 17 ++++++++++++----- test/unit/landing-page.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 35 insertions(+), 5 deletions(-) create mode 100644 test/unit/landing-page.test.ts 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/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) + }) +}) From 1bd69fb288eaedbcafd1dc7bf6cef85047d5ae93 Mon Sep 17 00:00:00 2001 From: TheCryptoDonkey Date: Wed, 23 Sep 2026 16:17:41 +0100 Subject: [PATCH 24/32] docs: add a security policy pointing to private vulnerability reporting --- SECURITY.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 SECURITY.md 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. From 1187873f9ff921bf0cd85a24094bb3862655f2e5 Mon Sep 17 00:00:00 2001 From: TheCryptoDonkey Date: Wed, 23 Sep 2026 16:18:29 +0100 Subject: [PATCH 25/32] docs: put the changelog in version order under a Changelog title The release workflow inserts each new entry after the first top-level heading, which was the old 1.15.0 entry, so 1.15.3 to 1.17.2 landed between that heading and its body. Entries are now in descending version order, the 1.15.0 notes are back under their heading, and a '# Changelog' title gives future entries the right insertion point. --- CHANGELOG.md | 84 +++++++++++++++++++++++----------------------------- 1 file changed, 37 insertions(+), 47 deletions(-) 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 From 6c0a076b9f67d6bcddd3038598ff6004c7f25448 Mon Sep 17 00:00:00 2001 From: TheCryptoDonkey Date: Wed, 23 Sep 2026 16:18:34 +0100 Subject: [PATCH 26/32] chore: drop the stale pnpm lockfile pnpm-lock.yaml resolved toll-booth from a sibling checkout (file:../toll-booth) and had drifted from package-lock.json, which npm, CI and the Docker build actually use. It is now untracked and ignored. --- .gitignore | 1 + pnpm-lock.yaml | 2806 ------------------------------------------------ 2 files changed, 1 insertion(+), 2806 deletions(-) delete mode 100644 pnpm-lock.yaml diff --git a/.gitignore b/.gitignore index e0e964e..65531d6 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ docs/superpowers/ .superpowers/ .playwright-mcp/ satgate.root-key +pnpm-lock.yaml 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: {} From bb81b2a1713e5560f57368af5d88d9543cd456e8 Mon Sep 17 00:00:00 2001 From: TheCryptoDonkey Date: Wed, 23 Sep 2026 16:18:37 +0100 Subject: [PATCH 27/32] chore: stop tracking IDE project files --- .gitignore | 1 + .idea/.gitignore | 10 ---------- .idea/modules.xml | 8 -------- .idea/token-toll.iml | 8 -------- .idea/vcs.xml | 6 ------ 5 files changed, 1 insertion(+), 32 deletions(-) delete mode 100644 .idea/.gitignore delete mode 100644 .idea/modules.xml delete mode 100644 .idea/token-toll.iml delete mode 100644 .idea/vcs.xml diff --git a/.gitignore b/.gitignore index 65531d6..8e95054 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ docs/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 From bfb0b77faac44bd134c5ff839e1c81f6ea011499 Mon Sep 17 00:00:00 2001 From: TheCryptoDonkey Date: Wed, 23 Sep 2026 16:18:51 +0100 Subject: [PATCH 28/32] fix: expose the default port in the Docker image The image exposed 3002 while satgate listens on 3000 unless PORT is set. The production deploy sets PORT=3002 itself, so it is unaffected. --- Dockerfile | 2 +- test/unit/packaging.test.ts | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 156bbef..6eedee8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,5 +23,5 @@ COPY --from=build /build/dist/ ./dist/ 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/test/unit/packaging.test.ts b/test/unit/packaging.test.ts index e68d3a7..7316d11 100644 --- a/test/unit/packaging.test.ts +++ b/test/unit/packaging.test.ts @@ -22,3 +22,12 @@ describe('npm package contents', () => { 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) + }) +}) From 8282e0f9073ef56fcfb6e694bb59b695aaa2edfa Mon Sep 17 00:00:00 2001 From: TheCryptoDonkey Date: Wed, 23 Sep 2026 16:19:24 +0100 Subject: [PATCH 29/32] docs: make the README's claims match what satgate does Drop the '~400 lines' and 'GDPR-safe out of the box' claims, describe billing as it now works (reported usage, worst-case hold, what cannot be refunded) instead of 'never short-changed', note UPSTREAM_API_KEY for keyed upstreams, document the new defaults, and update the TypeScript badge to 6. --- README.md | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 216a5e5..2ecccb3 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,12 @@ [![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) @@ -49,7 +49,7 @@ curl -s https://satgate.trotters.dev/llms.txt | **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. | --- @@ -93,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.** @@ -103,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] @@ -128,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 @@ -138,7 +138,7 @@ 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. +- **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`. --- @@ -162,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. --- @@ -216,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. --- From c764fa8c4797172e3a2c8a6155062b87c52ed032 Mon Sep 17 00:00:00 2001 From: TheCryptoDonkey Date: Wed, 23 Sep 2026 16:20:15 +0100 Subject: [PATCH 30/32] docs: bring the static llms.txt in line with the code x402 is supported, not coming; --price is a flat per-request price, not a per-token default; --free-tier grants sats of credit, not requests; the estimatedCostSats default and trustProxy behaviour are described as they now work; LNURLcash and the nwc backend are listed; and NWC is described as a Lightning backend rather than a way for clients to pay. Also documents the flat 1 sat default when no pricing is set, and points to the forgesworn toll-booth and 402-mcp repositories. --- llms.txt | 127 ++++++++++++++++++++++++++++++++++++------------------- 1 file changed, 83 insertions(+), 44 deletions(-) diff --git a/llms.txt b/llms.txt index 1a95855..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 @@ -18,26 +18,35 @@ A public instance is running at https://satgate.trotters.dev — open it in a br LIGHTNING_KEY= npx satgate --upstream http://localhost:11434 --lightning phoenixd ``` -satgate queries `/v1/models` on startup, charges per token 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. 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 From 45861ad19d3c59c2583d60c96303a57d7ad474d4 Mon Sep 17 00:00:00 2001 From: TheCryptoDonkey Date: Wed, 23 Sep 2026 16:20:49 +0100 Subject: [PATCH 31/32] fix: require payment when LNURLcash is the only rail With --lnurlcash-mints and no --lightning or Cashu mints, auth mode was inferred as open, so the payment middleware was never mounted and inference was served free. LNURLcash now selects the bearer-ecash mode like Cashu does. --- src/cli.ts | 4 +++- src/config.test.ts | 8 ++++++++ src/config.ts | 8 +++++--- test/e2e/billing.test.ts | 13 +++++++++++++ 4 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index b4ff678..9ed2cd6 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -316,7 +316,9 @@ export async function main(argv: string[] = process.argv): Promise { 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' diff --git a/src/config.test.ts b/src/config.test.ts index 8bc5f40..1bc4514 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -726,3 +726,11 @@ describe('concurrency limit', () => { 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 5156065..6043b8e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -522,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 diff --git a/test/e2e/billing.test.ts b/test/e2e/billing.test.ts index ddadd17..fbd8151 100644 --- a/test/e2e/billing.test.ts +++ b/test/e2e/billing.test.ts @@ -385,3 +385,16 @@ describe('invoice minting limits', () => { 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) + }) +}) From 1741e90d02b143bfde540d437b7e5443bc52aa7c Mon Sep 17 00:00:00 2001 From: TheCryptoDonkey Date: Wed, 23 Sep 2026 16:26:07 +0100 Subject: [PATCH 32/32] fix(deploy): trust forwarded client addresses from the local Caddy only --- deploy/production.sh | 4 ++++ 1 file changed, 4 insertions(+) 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"