From bb74071dc291432e55cfa9a76558cdd441628b20 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Fri, 2 Oct 2026 19:57:29 -0700 Subject: [PATCH] fix(server): throttle the cost-bearing proxies by default The OpenAI endpoints (/api/realtime/token, /api/openai/hud-summary) now allow 30 requests per minute per client IP and the Google Places endpoints 120, the caps the Pinokio build already ships. GEV_RATELIMIT_OPENAI_PER_MIN and GEV_RATELIMIT_GOOGLE_PER_MIN still override them and exactly 0 disables them; an unreadable value falls back to the default rather than to unlimited. Salvaged from #683 onto current main. Co-authored-by: Sameh Khamis Co-Authored-By: Claude Opus 5.5 (1M context) --- .env.example | 43 ++-- CHANGELOG.md | 11 + README.md | 2 +- SECURITY.md | 2 +- docs/CURRENT-STATE.md | 13 +- scripts/format-scope.json | 3 +- server/providers/common/rate-limit.js | 51 +++-- server/providers/openai/hud-summary.js | 10 +- server/providers/openai/rate-limit.js | 35 +++- server/providers/openai/realtime.js | 6 +- server/providers/places.js | 2 +- server/providers/places/google.js | 25 ++- src/costEndpointThrottle.test.mjs | 267 +++++++++++++++++++++++++ 13 files changed, 409 insertions(+), 61 deletions(-) create mode 100644 src/costEndpointThrottle.test.mjs diff --git a/.env.example b/.env.example index 071d42d3c..ea4346d8f 100644 --- a/.env.example +++ b/.env.example @@ -34,16 +34,19 @@ GOOGLE_MAPS_API_KEY= # single shared key keeps working — splitting is opt-in. # SERVER-SIDE ONLY. GOOGLE_MAPS_SERVER_API_KEY= -# Optional: opt-in per-IP rate limit for the Google Places cost endpoint -# (/api/google/nearby-places), in requests per minute. -# DEFAULT IS UNLIMITED — unset (or 0) means no throttling, unchanged behavior. -# Set a positive integer to cap per-IP Places quota spend when self-hosting. -# Recommended whenever HOST is not localhost. NOTE: this is a per-IP, -# process-local guard (in-memory, resets on restart; over-limit requests get a -# sanitized 429) — it is NOT a billing cap. For hard spend protection set -# provider-side budget alerts: Google Cloud Console -> Billing -> Budgets & alerts -# (plus per-API quotas under APIs & Services -> Quotas). -# GEV_RATELIMIT_GOOGLE_PER_MIN=60 +# Per-IP rate limit for the Google Places cost endpoints +# (/api/google/nearby-places + /api/google/text-search), in requests per minute. +# DEFAULTS TO 120, the value the Pinokio build already ships. Raise or lower it +# with a positive integer; 60 is a sensible tightening whenever HOST is not +# localhost. Set it to exactly 0 to run unthrottled. An unreadable value (a +# typo, a stray unit) falls back to the default rather than to unlimited, so a +# slip cannot quietly disarm the guard. +# NOTE: this is a per-IP, process-local guard (in-memory, resets on restart; +# over-limit requests get a sanitized 429) — it is NOT a billing cap. For hard +# spend protection set provider-side budget alerts: Google Cloud Console -> +# Billing -> Budgets & alerts (plus per-API quotas under APIs & Services -> +# Quotas). +# GEV_RATELIMIT_GOOGLE_PER_MIN=60 # uncomment to tighten from the default 120 # Optional: Google Photorealistic 3D Tiles through Cesium ion, Bing world # imagery, and Cesium World Terrain. The free Community plan is for eligible @@ -65,15 +68,19 @@ OPENAI_REALTIME_REASONING_EFFORT=low OPENAI_REALTIME_CONTEXT_TOKENS=3000 OPENAI_REALTIME_CONTEXT_RETENTION=0.5 OPENAI_HUD_SUMMARY_MODEL=gpt-5-nano -# Optional: opt-in per-IP rate limit for the OpenAI cost endpoints +# Per-IP rate limit for the OpenAI cost endpoints # (/api/realtime/token + /api/openai/hud-summary), in requests per minute. -# DEFAULT IS UNLIMITED — unset (or 0) means no throttling, unchanged behavior. -# Set a positive integer to cap per-IP spend when self-hosting on a network. -# Recommended whenever HOST is not localhost. NOTE: this is a per-IP, -# process-local guard (in-memory, resets on restart; over-limit requests get a -# sanitized 429) — it is NOT a billing cap. For hard spend protection set a -# provider-side budget: OpenAI platform -> Settings -> Limits (usage limits). -# GEV_RATELIMIT_OPENAI_PER_MIN=30 +# DEFAULTS TO 30, the value the Pinokio build also ships. That clears ordinary +# use several times over: an open tab asks for a HUD summary every 15s (4/min) +# and a voice session mints one token. Raise or lower it with a positive +# integer; set it to exactly 0 to run unthrottled. An unreadable value (a typo, +# a stray unit) falls back to the default rather than to unlimited, so a slip +# cannot quietly disarm the guard. +# NOTE: this is a per-IP, process-local guard (in-memory, resets on restart; +# over-limit requests get a sanitized 429) — it is NOT a billing cap. For hard +# spend protection set a provider-side budget: OpenAI platform -> Settings -> +# Limits (usage limits). +# GEV_RATELIMIT_OPENAI_PER_MIN=30 # the default; uncomment only to change it # Optional: OpenSky auth mode (oauth | basic | auto | anon) # Default: oauth — requires client credentials below diff --git a/CHANGELOG.md b/CHANGELOG.md index 07253f568..5724d1c87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ ## [Unreleased] +- Throttle the cost-bearing proxies by default instead of on request. The + OpenAI endpoints (`/api/realtime/token`, `/api/openai/hud-summary`) now allow + 30 requests per minute per client IP and the Google Places endpoints + (`/api/google/nearby-places`, `/api/google/text-search`) 120 — the caps the + Pinokio build already ships, so the packaged app is unaffected and only an + unconfigured server changes, from unlimited to what the product already runs + with. `GEV_RATELIMIT_OPENAI_PER_MIN` and `GEV_RATELIMIT_GOOGLE_PER_MIN` still + override the caps, and exactly `0` disables them; a value that cannot be read + as a number now falls back to the default rather than to unlimited, so a typo + cannot silently disarm the guard (daikaginza, #683). + - Tighten the local MCP server and embed mode. The panel's `panel_request` needs the key in its MCP server's panel page, and refuses Provider Settings, credential and model endpoints, `/mcp` and development server diff --git a/README.md b/README.md index 13ff96215..cc9f142ad 100644 --- a/README.md +++ b/README.md @@ -512,7 +512,7 @@ Everything above is the deliberately cheap baseline — enough to get a real tas ### 🔒 Sharing an instance -By default nobody else can reach your server — it binds to localhost. To share on your LAN, opt in explicitly (`npm run dev -- --host 0.0.0.0 --port 4173`, or `HOST=0.0.0.0 ./scripts/dev-fresh.sh` on macOS/Linux) — but know that ⚠️ **a LAN-visible server brokers your configured API keys to anyone who can reach it.** Set the per-IP throttles (`GEV_RATELIMIT_OPENAI_PER_MIN`, `GEV_RATELIMIT_GOOGLE_PER_MIN` — see `.env.example`) and, before anything else, **configure provider quotas, usage limits, and billing alerts**: app-level throttles are not billing caps, and a budget alert alone does not stop spending. Full threat model in [SECURITY.md](SECURITY.md). +By default nobody else can reach your server — it binds to localhost. To share on your LAN, opt in explicitly (`npm run dev -- --host 0.0.0.0 --port 4173`, or `HOST=0.0.0.0 ./scripts/dev-fresh.sh` on macOS/Linux) — but know that ⚠️ **a LAN-visible server brokers your configured API keys to anyone who can reach it.** Per-IP throttles on the cost-bearing endpoints are on by default (`GEV_RATELIMIT_OPENAI_PER_MIN`, `GEV_RATELIMIT_GOOGLE_PER_MIN` — see `.env.example` to tune them), but before anything else, **configure provider quotas, usage limits, and billing alerts**: app-level throttles are not billing caps, and a budget alert alone does not stop spending. Full threat model in [SECURITY.md](SECURITY.md). Provider Settings is disabled when the server is shared, so remote users cannot access the key-entry panel. diff --git a/SECURITY.md b/SECURITY.md index 28b1201f3..f12efbdbd 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -80,7 +80,7 @@ The dev server is a **key broker**: every server-side key above is spendable by - **Local-only by default.** `./scripts/dev-fresh.sh` (and the Vite config itself) bind to `localhost`, so only your machine can reach the server — and only local names are accepted (`allowedHosts` stays restricted, which also blunts DNS-rebinding tricks). - **LAN exposure is an explicit opt-in**: `HOST=0.0.0.0 ./scripts/dev-fresh.sh`. The launcher prints a prominent warning plus your LAN URL. Understand what opting in means: **every device on that network can drive the proxies and spend your OpenAI / Google / OpenSky / AISStream / TomTom / FIRMS quota** for as long as the server runs. Do this only on networks you trust. -- **App-level throttles (opt-in):** `GEV_RATELIMIT_OPENAI_PER_MIN` and `GEV_RATELIMIT_GOOGLE_PER_MIN` cap the cost-bearing endpoints per client IP per minute (over-limit requests receive a sanitized `429`). They are **per-IP, process-local, in-memory guards** — they reset on restart and are **not billing caps**. +- **App-level throttles (on by default):** `GEV_RATELIMIT_OPENAI_PER_MIN` (default `30`) and `GEV_RATELIMIT_GOOGLE_PER_MIN` (default `120`) — the values the Pinokio build already ships — cap the cost-bearing endpoints per client IP per minute (over-limit requests receive a sanitized `429`). Both defaults sit well above what the app itself generates; set either to exactly `0` to run unthrottled. They are **per-IP, process-local, in-memory guards** — they reset on restart, a caller with several addresses gets a bucket per address, and they are **not billing caps**. - **Provider-side budgets are the real backstop.** For hard spend protection, configure limits where the money is: OpenAI platform usage limits, Google Cloud budget alerts + per-API quotas, and equivalent controls for any other keyed provider. - **Pinokio LAN and Cloudflare sharing are refused.** The current supported Pinokio release re-reads sharing state when an app registers its Open URL and diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index fc5a026fe..7ab39ff59 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -915,10 +915,21 @@ errors. ## Places and CCTV request bounds With a Google key configured, nearby and text search reject missing, blank, -non-numeric and out-of-range coordinates before the opt-in limiter and upstream +non-numeric and out-of-range coordinates before the per-IP limiter and upstream request. Text search also requires a nonblank query. Keyless requests retain their `configured: false` response. +The cost-bearing proxies are throttled per client IP without configuration: +`/api/realtime/token` and `/api/openai/hud-summary` share 30 requests per +minute per IP, `/api/google/nearby-places` and `/api/google/text-search` share +120 — the caps the Pinokio build already sets, so the packaged app is +unaffected. `GEV_RATELIMIT_OPENAI_PER_MIN` and `GEV_RATELIMIT_GOOGLE_PER_MIN` override +those; exactly `0` disables the limiter, while a value that cannot be read as a +number falls back to the default rather than to unlimited. Over-limit requests +receive a sanitized `429` with `Retry-After: 5` and never reach the provider. +The client key is the socket peer address only — a forwarded-for header is not +trusted, so a proxied deployment shares one bucket per upstream hop. + CCTV media waits at most 15 seconds for upstream response headers and returns 504 on timeout. Its timer stops when headers arrive, so live bodies can continue streaming; a separate 30-second idle deadline then bounds the gap between diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 5cd8ad77c..901eab7bb 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -893,5 +893,6 @@ "src/view/index.test.mjs", "src/tools/views.test.mjs", "src/app/embed.test.mjs", - "src/tools/globePanel.test.mjs" + "src/tools/globePanel.test.mjs", + "src/costEndpointThrottle.test.mjs" ] diff --git a/server/providers/common/rate-limit.js b/server/providers/common/rate-limit.js index 4b5f967c3..8b8bfe83a 100644 --- a/server/providers/common/rate-limit.js +++ b/server/providers/common/rate-limit.js @@ -2,24 +2,49 @@ import { makeRateLimiter } from '../../../src/sources/rateLimit.js'; export { makeRateLimiter } from '../../../src/sources/rateLimit.js'; /** - * Opt-in per-IP rate limiter for the cost-bearing API proxies (OpenAI / Google). - * DEFAULT IS UNLIMITED: when the env var is unset, `0`, or non-numeric, this - * returns `null` and the caller skips the check entirely — a runtime no-op that - * preserves the original behavior. Only a positive integer N enables a fixed - * 60s window of N requests/IP (built lazily once, then reused so its per-IP - * window state persists across requests). The global backstop is set to a - * generous multiple of the per-IP cap so a single host can't starve the rest. + * Resolve the per-IP, per-minute cap for a cost-bearing proxy from its env var. * - * @param {string|undefined} envValue - Raw env value (requests/min/IP). + * The default is ON. An unset or blank value takes `defaultPerMin`; a positive + * integer overrides it; an explicit `0` disables the limiter for deployments + * that genuinely want no throttle. + * + * A value that cannot be read as a number — a typo, a stray unit, a negative — + * also takes the default rather than disabling the guard. That asymmetry is the + * point: `GEV_RATELIMIT_OPENAI_PER_MIN=3O` should not quietly hand an exposed + * server's API key to whoever asks. Removing the throttle stays possible, but + * only by saying `0`, which cannot be typed by accident. + * + * @param {string|number|undefined|null} envValue - Raw env value (requests/min/IP). + * @param {number} defaultPerMin - Cap to apply when the value is absent or unreadable. + * @returns {number} Requests/min/IP; `0` means unlimited. + */ +export function resolvePerMinuteCap(envValue, defaultPerMin) { + const raw = typeof envValue === 'string' ? envValue.trim() : envValue; + if (raw === undefined || raw === null || raw === '') return defaultPerMin; + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed < 0) return defaultPerMin; + return Math.floor(parsed); // includes the explicit 0 = unlimited opt-out +} + +/** + * Per-IP rate limiter for the cost-bearing API proxies (OpenAI / Google). + * Returns `null` only when the cap resolves to `0` (the explicit opt-out), in + * which case the caller skips the check entirely. Otherwise a fixed 60s window + * of N requests/IP, built lazily once and reused so its per-IP window state + * persists across requests. The global backstop is a generous multiple of the + * per-IP cap so a single host can't starve the rest. + * + * @param {string|number|undefined|null} envValue - Raw env value (requests/min/IP). + * @param {number} defaultPerMin - Cap to apply when the value is absent or unreadable. * @returns {((key:string)=>boolean)|null} An `allow(key)` fn, or null when unlimited. */ -export function makeOptInRateLimiter(envValue) { - const max = Number(envValue); - if (!Number.isFinite(max) || max <= 0) return null; // unset/0/garbage -> unlimited +export function makeCostRateLimiter(envValue, defaultPerMin) { + const max = resolvePerMinuteCap(envValue, defaultPerMin); + if (max <= 0) return null; // explicit opt-out return makeRateLimiter({ windowMs: 60_000, - max: Math.floor(max), - globalMax: Math.floor(max) * 20, + max, + globalMax: max * 20, }); } diff --git a/server/providers/openai/hud-summary.js b/server/providers/openai/hud-summary.js index 68dd78304..bdb94ac58 100644 --- a/server/providers/openai/hud-summary.js +++ b/server/providers/openai/hud-summary.js @@ -2,7 +2,7 @@ import { HUD_SUMMARY_INSTRUCTIONS, keylessHudSummaryResponse, } from '../../../src/hudSummaryResponse.js'; -import { enforceOptInRateLimit, openAiRateLimiter } from './rate-limit.js'; +import { enforceRateLimit, openAiRateLimiter } from './rate-limit.js'; import { readRequestBody } from '../common/request.js'; import { OPENAI_HUD_SUMMARY_MODEL_DEFAULT } from './constants.js'; @@ -46,10 +46,10 @@ async function handleHudSummary(req, res) { return; } - // Opt-in per-IP throttle (GEV_RATELIMIT_OPENAI_PER_MIN). Keyless HUD - // fallback has no provider cost and resolves above without consuming a - // paid-endpoint quota slot. - if (!enforceOptInRateLimit(openAiRateLimiter(), req, res)) return; + // Per-IP throttle (GEV_RATELIMIT_OPENAI_PER_MIN). On by default; 0 disables. + // Keyless HUD fallback has no provider cost and resolves above without + // consuming a paid-endpoint quota slot. + if (!enforceRateLimit(openAiRateLimiter(), req, res)) return; try { const body = await readRequestBody(req, 64 * 1024); diff --git a/server/providers/openai/rate-limit.js b/server/providers/openai/rate-limit.js index 0cdecf00d..a2aaf4ecb 100644 --- a/server/providers/openai/rate-limit.js +++ b/server/providers/openai/rate-limit.js @@ -1,33 +1,46 @@ -import { makeOptInRateLimiter, clientKey } from '../common/rate-limit.js'; +import { makeCostRateLimiter, clientKey } from '../common/rate-limit.js'; + +/** + * Requests/min/IP applied when GEV_RATELIMIT_OPENAI_PER_MIN is unset. Both + * places that already name a value agree on it: `.env.example` recommends 30 + * and the Pinokio build ships 30 (pinokio/_ENVIRONMENT), so the packaged app + * is unaffected and only an unconfigured server changes. The HUD asks for a + * summary every 15s (HUD_SUMMARY_INTERVAL_MS), so an open tab spends 4/min and + * a voice session adds one token mint; the cap clears ordinary use several + * times over and bites only a caller working the endpoint far harder than the + * app does. + */ +const OPENAI_DEFAULT_PER_MIN = 30; // Built LAZILY on first request, NOT at module load: `.env` values are applied to process.env later // (the plugin config hook calls loadEnv → process.env, AFTER this module is imported), so reading -// process.env here at import time would always see them unset and silently stay unlimited even when +// process.env here at import time would always see them unset and fall back to the default even when // configured via .env. Building on first request (like the OPENAI_API_KEY reads) sees the loaded env; -// the result is cached so the limiter's per-IP window state persists. `null` = unlimited (default). +// the result is cached so the limiter's per-IP window state persists. `null` = the explicit 0 opt-out. let _openAiRateLimiter; -/** OpenAI cost endpoints (realtime/token + hud-summary). Null = unlimited (default). */ +/** OpenAI cost endpoints (realtime/token + hud-summary). Null only when set to 0. */ function openAiRateLimiter() { if (_openAiRateLimiter === undefined) - _openAiRateLimiter = makeOptInRateLimiter( + _openAiRateLimiter = makeCostRateLimiter( process.env.GEV_RATELIMIT_OPENAI_PER_MIN, + OPENAI_DEFAULT_PER_MIN, ); return _openAiRateLimiter; } /** - * Apply an opt-in limiter to a request, writing a 429 when over the cap. - * When `limiter` is null (unlimited, the default) this is a no-op returning - * `true`, so the handler proceeds exactly as before. + * Apply the limiter to a request, writing a 429 when over the cap. + * When `limiter` is null — the explicit `0` opt-out — this is a no-op + * returning `true`, so the handler proceeds unthrottled. * * @param {((key:string)=>boolean)|null} limiter * @param {import('http').IncomingMessage} req * @param {import('http').ServerResponse} res * @returns {boolean} True if the request may proceed; false if a 429 was sent. */ -function enforceOptInRateLimit(limiter, req, res) { - if (!limiter) return true; // unlimited (default) — no behavior change +function enforceRateLimit(limiter, req, res) { + if (!limiter) return true; // explicit 0 opt-out — unthrottled if (limiter(clientKey(req))) return true; res.statusCode = 429; res.setHeader('Content-Type', 'application/json'); @@ -36,4 +49,4 @@ function enforceOptInRateLimit(limiter, req, res) { return false; } -export { enforceOptInRateLimit, openAiRateLimiter }; +export { enforceRateLimit, openAiRateLimiter, OPENAI_DEFAULT_PER_MIN }; diff --git a/server/providers/openai/realtime.js b/server/providers/openai/realtime.js index 85539400f..2f513b7e2 100644 --- a/server/providers/openai/realtime.js +++ b/server/providers/openai/realtime.js @@ -1,4 +1,4 @@ -import { enforceOptInRateLimit, openAiRateLimiter } from './rate-limit.js'; +import { enforceRateLimit, openAiRateLimiter } from './rate-limit.js'; import { resolveVoiceModel, isKnownVoiceTier, @@ -31,8 +31,8 @@ function createRealtimeTokenHandler({ return; } - // Opt-in per-IP throttle (GEV_RATELIMIT_OPENAI_PER_MIN). No-op when unset. - if (!enforceOptInRateLimit(openAiRateLimiter(), req, res)) return; + // Per-IP throttle (GEV_RATELIMIT_OPENAI_PER_MIN). On by default; 0 disables. + if (!enforceRateLimit(openAiRateLimiter(), req, res)) return; const apiKey = resolveApiKey(); if (!apiKey) { diff --git a/server/providers/places.js b/server/providers/places.js index 648be2aa8..12f0688c6 100644 --- a/server/providers/places.js +++ b/server/providers/places.js @@ -7,7 +7,7 @@ export { export { installRouteMiddleware } from './places/routes.js'; export { makeRateLimiter, - makeOptInRateLimiter, + makeCostRateLimiter, clientKey, } from './common/rate-limit.js'; export { haversineKm } from './common/geo.js'; diff --git a/server/providers/places/google.js b/server/providers/places/google.js index f7baacc90..b15cf79bb 100644 --- a/server/providers/places/google.js +++ b/server/providers/places/google.js @@ -2,7 +2,7 @@ import { googleServerApiKey, keylessGooglePlacesResponse, } from './google-key.js'; -import { makeOptInRateLimiter, clientKey } from '../common/rate-limit.js'; +import { makeCostRateLimiter, clientKey } from '../common/rate-limit.js'; import { admitSameSite } from '../common/same-site.js'; import { projectNearbyPlaces, @@ -10,14 +10,27 @@ import { } from '../../../src/data/placeProviderPayloads.js'; // Construct lazily after the standalone environment has loaded. -// undefined = not built yet; null = unlimited; fn = active limiter +// undefined = not built yet; null = the explicit 0 opt-out; fn = active limiter let _googleRateLimiter; -/** Google cost endpoint (nearby-places). Null = unlimited (default). */ +/** + * Requests/min/IP applied when GEV_RATELIMIT_GOOGLE_PER_MIN is unset. This is + * the value the Pinokio build already ships (pinokio/_ENVIRONMENT), so the + * packaged app keeps behaving exactly as it does today and only an + * unconfigured server changes — from unlimited to what the product already + * runs with. Places calls are user-driven (a search, a nearby lookup, an + * installation probe) rather than polled, so this sits far above what the app + * generates and bites only a caller enumerating the endpoint. `.env.example` + * suggests a tighter 60 when the host is not localhost; that still applies. + */ +export const GOOGLE_DEFAULT_PER_MIN = 120; + +/** Google cost endpoints (nearby-places + text-search). Null only when set to 0. */ function googleRateLimiter() { if (_googleRateLimiter === undefined) - _googleRateLimiter = makeOptInRateLimiter( + _googleRateLimiter = makeCostRateLimiter( process.env.GEV_RATELIMIT_GOOGLE_PER_MIN, + GOOGLE_DEFAULT_PER_MIN, ); return _googleRateLimiter; } @@ -84,7 +97,7 @@ export function googlePlacesContextProxy({ } const { latitude, longitude } = coordinates; - // Opt-in per-IP throttle (GEV_RATELIMIT_GOOGLE_PER_MIN). No-op when unset. + // Per-IP throttle (GEV_RATELIMIT_GOOGLE_PER_MIN). On by default; 0 disables. // Inlined (not the shared helper) so the 429 body keeps this endpoint's // `places: []` contract that the client expects on every error response. const _grl = googleRateLimiter(); @@ -204,7 +217,7 @@ export function googlePlacesContextProxy({ } const { latitude, longitude } = coordinates; - // Opt-in per-IP throttle (GEV_RATELIMIT_GOOGLE_PER_MIN). No-op when unset. + // Per-IP throttle (GEV_RATELIMIT_GOOGLE_PER_MIN). On by default; 0 disables. // Inlined (like nearby-places) so the 429 body keeps the `places: []` // contract the client expects on every error response. const _grl = googleRateLimiter(); diff --git a/src/costEndpointThrottle.test.mjs b/src/costEndpointThrottle.test.mjs new file mode 100644 index 000000000..f4df8a864 --- /dev/null +++ b/src/costEndpointThrottle.test.mjs @@ -0,0 +1,267 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Readable } from 'node:stream'; +import { readPinokioEnvironment } from '../scripts/pinokio-environment.mjs'; +import { openAiRealtimeProxy } from 'gods-eye-view/server/providers/openai'; +import { + makeCostRateLimiter, + resolvePerMinuteCap, +} from '../server/providers/common/rate-limit.js'; +import { OPENAI_DEFAULT_PER_MIN } from '../server/providers/openai/rate-limit.js'; +import { handleHudSummary } from '../server/providers/openai/hud-summary.js'; +import { + googlePlacesContextProxy, + GOOGLE_DEFAULT_PER_MIN, +} from '../server/providers/places/google.js'; + +// Both limiters are cached at module scope on first use, so the env value that +// matters is the one in place when a route is first exercised — restoring the +// variable afterwards does not rebuild the limiter. Each route is therefore +// driven by exactly one test here; a second test wanting a different cap would +// need its own process, not just a different env value. + +/** Set an env var for one test and put the old value back afterwards. */ +function env(t, name, value) { + const old = process.env[name]; + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + t.after(() => { + if (old === undefined) delete process.env[name]; + else process.env[name] = old; + }); +} + +function install(plugin) { + const routes = new Map(); + plugin.configureServer({ + middlewares: { use: (route, handler) => routes.set(route, handler) }, + restart: async () => {}, + }); + return routes; +} + +function request( + handler, + { url = '/', method = 'POST', remoteAddress = '127.0.0.1' } = {}, +) { + return new Promise((resolve, reject) => { + const req = Readable.from([]); + Object.assign(req, { + method, + url, + headers: { host: 'localhost:4173', 'content-type': 'application/json' }, + socket: { remoteAddress }, + }); + const headers = {}; + const res = { + statusCode: 200, + setHeader(name, value) { + headers[name.toLowerCase()] = value; + }, + end(body = '') { + resolve({ status: this.statusCode, headers, body: String(body) }); + }, + }; + Promise.resolve(handler(req, res)).catch(reject); + }); +} + +test('an unset throttle resolves to the documented default, not to unlimited', () => { + // The defect #16 reports: the cap was opt-in, so an unconfigured server let + // anyone who could reach it mint tokens on the operator's key without bound. + assert.equal(resolvePerMinuteCap(undefined, 30), 30); + assert.equal(resolvePerMinuteCap('', 30), 30); + assert.equal(resolvePerMinuteCap(' ', 30), 30); + assert.ok( + typeof makeCostRateLimiter(undefined, 30) === 'function', + 'an unconfigured cost endpoint must still be throttled', + ); +}); + +test('an explicit 0 is the documented way to run unthrottled', () => { + assert.equal(resolvePerMinuteCap('0', 30), 0); + assert.equal( + makeCostRateLimiter('0', 30), + null, + 'operators who mean "no throttle" must still be able to say so', + ); +}); + +test('a configured value wins, and a fractional one floors', () => { + assert.equal(resolvePerMinuteCap('45', 30), 45); + assert.equal(resolvePerMinuteCap(45, 30), 45); + assert.equal(resolvePerMinuteCap('30.7', 30), 30); +}); + +test('an unreadable value keeps the guard instead of removing it', () => { + // `3O` (letter O) and `60/min` are the shapes a hand-edited .env produces. + // Reading either as "unlimited" would disarm the spend guard on a typo — + // the opposite of what the operator was reaching for when they typed it. + for (const typo of ['3O', '60/min', 'thirty', '-5', 'NaN']) { + assert.equal( + resolvePerMinuteCap(typo, 30), + 30, + `${typo} must fall back to the default, not to unlimited`, + ); + } +}); + +test('the default cap clears the app’s own demand with room to spare', () => { + // src/hud.js HUD_SUMMARY_INTERVAL_MS = 15000 -> 4 summaries/min per open + // tab, plus one token mint per voice session. The cap has to sit well above + // that or it would throttle the app rather than an abuser. + const hudRequestsPerMinutePerTab = 60_000 / 15_000; + assert.ok( + OPENAI_DEFAULT_PER_MIN >= hudRequestsPerMinutePerTab * 4, + `default ${OPENAI_DEFAULT_PER_MIN}/min leaves too little headroom over ${hudRequestsPerMinutePerTab}/min of ordinary use`, + ); +}); + +test('the realtime token route throttles by default, and says so in a 429', async (t) => { + env(t, 'GEV_RATELIMIT_OPENAI_PER_MIN', undefined); + env(t, 'OPENAI_API_KEY', 'fixture-upstream-secret'); + let upstreamCalls = 0; + t.mock.method(globalThis, 'fetch', async () => { + upstreamCalls += 1; + return Response.json({ value: 'fixture-ephemeral' }); + }); + + const handler = install(openAiRealtimeProxy({})).get('/api/realtime/token'); + const statuses = []; + for (let i = 0; i < OPENAI_DEFAULT_PER_MIN + 1; i += 1) { + statuses.push((await request(handler)).status); + } + + assert.deepEqual( + statuses.slice(0, OPENAI_DEFAULT_PER_MIN), + Array(OPENAI_DEFAULT_PER_MIN).fill(200), + 'the default must not throttle ordinary use', + ); + const overLimit = await request(handler); + assert.equal(overLimit.status, 429); + assert.equal(overLimit.headers['retry-after'], '5'); + assert.equal(JSON.parse(overLimit.body).error, 'Rate limit exceeded'); + assert.equal( + overLimit.body.includes('fixture-upstream-secret'), + false, + 'a throttled reply must not leak the server-held key', + ); + assert.ok( + upstreamCalls <= OPENAI_DEFAULT_PER_MIN, + 'a throttled request must be refused before it reaches OpenAI, or it still costs money', + ); +}); + +test('the HUD summary route throttles the paid path, never the free one', async (t) => { + env(t, 'OPENAI_API_KEY', 'fixture-upstream-secret'); + let upstreamCalls = 0; + t.mock.method(globalThis, 'fetch', async () => { + upstreamCalls += 1; + return Response.json({ output_text: 'five word hud summary here' }); + }); + + // Its own client address: the limiter is cached at module scope and shared + // across the OpenAI routes, so the realtime case above has already spent + // 127.0.0.1's budget inside this process. + const from = '10.9.9.9'; + const statuses = []; + for (let i = 0; i < OPENAI_DEFAULT_PER_MIN; i += 1) + statuses.push( + (await request(handleHudSummary, { remoteAddress: from })).status, + ); + assert.deepEqual( + statuses, + Array(OPENAI_DEFAULT_PER_MIN).fill(200), + 'the default must not throttle ordinary HUD use', + ); + + const overLimit = await request(handleHudSummary, { remoteAddress: from }); + assert.equal(overLimit.status, 429); + assert.equal(overLimit.headers['retry-after'], '5'); + assert.equal(JSON.parse(overLimit.body).error, 'Rate limit exceeded'); + assert.equal( + overLimit.body.includes('fixture-upstream-secret'), + false, + 'a throttled reply must not leak the server-held key', + ); + assert.ok( + upstreamCalls <= OPENAI_DEFAULT_PER_MIN, + 'a throttled request must be refused before it reaches OpenAI, or it still costs money', + ); + + // The keyless HUD answer is deliberately NOT throttled: it is composed + // locally, spends nothing upstream, and capping it would only take the free + // HUD away from an unconfigured install. Same exhausted client, no key. + delete process.env.OPENAI_API_KEY; + const free = await request(handleHudSummary, { remoteAddress: from }); + assert.equal(free.status, 200, 'the free path answers past a spent budget'); + assert.equal(JSON.parse(free.body).configured, false); + assert.equal( + upstreamCalls <= OPENAI_DEFAULT_PER_MIN, + true, + 'the free path reaches no provider', + ); +}); + +test('the Google places route throttles by default and keeps its places[] contract', async (t) => { + env(t, 'GEV_RATELIMIT_GOOGLE_PER_MIN', undefined); + let upstreamCalls = 0; + const handler = install( + googlePlacesContextProxy({ + resolveApiKey: () => 'fixture-google-server-key', + fetchImpl: async () => { + upstreamCalls += 1; + return Response.json({ places: [] }); + }, + }), + ).get('/api/google/nearby-places'); + + const url = '/?lat=37.77493&lon=-122.41942'; + const statuses = []; + for (let i = 0; i < GOOGLE_DEFAULT_PER_MIN; i += 1) { + statuses.push((await request(handler, { url, method: 'GET' })).status); + } + assert.ok( + statuses.every((s) => s === 200), + 'the default must not throttle ordinary use', + ); + + const overLimit = await request(handler, { url, method: 'GET' }); + assert.equal(overLimit.status, 429); + assert.equal(overLimit.headers['retry-after'], '5'); + // Every error response on this route carries places[]; the client reads it + // unconditionally, so a throttled reply must not be the one shape that omits it. + assert.deepEqual(JSON.parse(overLimit.body).places, []); + assert.equal( + upstreamCalls, + GOOGLE_DEFAULT_PER_MIN, + 'a throttled request must be refused before it reaches Google, or it still costs money', + ); +}); + +test('the code defaults match the caps the Pinokio build ships', () => { + // These two numbers used to live only in pinokio/_ENVIRONMENT, and the value + // this repo suggested elsewhere had already drifted away from them. Now that + // they are the server's defaults, the packaged app and an unconfigured + // checkout must agree, or a Pinokio user's throttle silently stops matching + // the one everybody else gets. If you deliberately want them to differ, this + // assertion is the place to say so. + const shipped = readPinokioEnvironment( + path.join( + path.dirname(fileURLToPath(import.meta.url)), + '..', + 'pinokio', + '_ENVIRONMENT', + ), + ); + assert.equal( + Number(shipped.GEV_RATELIMIT_OPENAI_PER_MIN), + OPENAI_DEFAULT_PER_MIN, + ); + assert.equal( + Number(shipped.GEV_RATELIMIT_GOOGLE_PER_MIN), + GOOGLE_DEFAULT_PER_MIN, + ); +});