From 3a32dc233432e7f56b2627247a565d94b8199efd Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Sun, 27 Sep 2026 11:36:20 +0700 Subject: [PATCH] feat(cvm-server): load protection + type-check it in the gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Load protection (public read-only endpoint — protects relays + this box): - Per-pubkey sliding-window throttle and in-flight cap via inbound middleware. The transport can't return a JSON-RPC error, so over-budget callers are dropped and time out (logged). - Global semaphore caps concurrent upstream relay queries. - Short-TTL cache keyed by filter set, so repeated identical queries (e.g. a burst of get_community_stats) don't re-hit the relays. - All tunable via CVM_RATE_* / CVM_MAX_* / CVM_QUERY_CACHE_MS (documented in .env.example); maps pruned by a janitor. CI: - cvm-server/tsconfig.json type-checks the standalone server; new typecheck:cvm / check:cvm scripts, with typecheck:cvm wired into npm test so the server can't rot unnoticed (it was excluded from tsc + eslint). --- AGENTS.md | 11 +++- cvm-server/.env.example | 11 ++++ cvm-server/server.ts | 122 +++++++++++++++++++++++++++++++++------ cvm-server/tsconfig.json | 16 +++++ eslint.config.js | 4 +- package.json | 4 +- 6 files changed, 144 insertions(+), 24 deletions(-) create mode 100644 cvm-server/tsconfig.json diff --git a/AGENTS.md b/AGENTS.md index 72e70ae..9cd056e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -122,7 +122,11 @@ session with **no other memory of this project**. So: `cvm-server/.env`. The tracked manifest is `package.cvm.json`. Tools: `find_spots` / `find_events` / `find_coaches` (all Lab kinds 35001–35014) / `find_gear` / `find_crews` / `find_shops` / `find_tours` + - `get_community_stats` — kept at parity with the app's `useCVMClient`. + `get_community_stats` — kept at parity with the app's `useCVMClient`. Has a + per-pubkey sliding-window + in-flight throttle, a global upstream semaphore + and a short-TTL query cache (`CVM_RATE_*`, `CVM_MAX_*`, `CVM_QUERY_CACHE_MS`). + Type-checked by `cvm-server/tsconfig.json` via the `typecheck:cvm` script + (wired into `npm test`); `check:cvm` runs a Bun transpile. - **Routing / directions**: `RouteDialog` is the single in-app router, opened from the map spot sheet, the food cards, and the spot / shop / event detail pages (MapPage's old external "Open with" Google/OSM/Waze menu was removed). @@ -140,8 +144,9 @@ session with **no other memory of this project**. So: - **Lazy routes**: every route uses `lazyWithRetry` (`src/lib/lazyWithRetry.ts`) — retries, then one full reload per session, so a chunk-load failure self-heals instead of showing the error boundary. -- **Baselines** (verified 2026-09-26): `npm test` → **0 errors, 0 warnings**, - **101 tests**, build ok. `package.json` version **1.4.5**. +- **Baselines** (verified 2026-09-27): `npm test` → **0 errors, 0 warnings**, + **101 tests**, build ok. The gate also type-checks the CVM server + (`tsc -p cvm-server/tsconfig.json`). `package.json` version **1.4.5**. - ✅ Lint is **clean**: `no-explicit-any` = 0 · `react-hooks/exhaustive-deps` = 0 · `no-unused-vars` = 0 · `react-refresh` = 0. Non-component exports were **split into their own modules** (`contexts/language.ts`, `lib/sortOptions.ts`, diff --git a/cvm-server/.env.example b/cvm-server/.env.example index 9c19bd9..0ac6b1b 100644 --- a/cvm-server/.env.example +++ b/cvm-server/.env.example @@ -19,3 +19,14 @@ LOG_LEVEL=info # How often to re-publish the discovery announcements, in ms (0 disables). # Keeps recency-based health checks green between (re)starts. CVM_HEARTBEAT_MS=900000 + +# ── Rate limiting & load protection (public, read-only endpoint) ───────────── +# Per-pubkey sliding window: max requests per window. +CVM_RATE_LIMIT=30 +CVM_RATE_WINDOW_MS=300000 +# Max concurrent in-flight requests per pubkey. +CVM_MAX_INFLIGHT=2 +# Global cap on concurrent upstream relay queries. +CVM_MAX_UPSTREAM=8 +# Short-TTL cache for identical relay queries, in ms (0 disables caching). +CVM_QUERY_CACHE_MS=30000 diff --git a/cvm-server/server.ts b/cvm-server/server.ts index c5c44bc..27787b1 100644 --- a/cvm-server/server.ts +++ b/cvm-server/server.ts @@ -54,31 +54,87 @@ const LOG_LEVEL = (process.env.LOG_LEVEL || "info") as SdkLogLevel; // recency-based health checks. Set 0 to disable. const HEARTBEAT_MS = Number(process.env.CVM_HEARTBEAT_MS || 15 * 60 * 1000); +// ─── Rate limiting & load protection ───────────────────────────────────────── +// This is a public, read-only query endpoint, so this is load protection (keep +// the community relays and this box healthy), not a security boundary. Inbound +// middleware cannot return a JSON-RPC error on this transport, so a caller over +// budget is simply dropped (logged) and will time out. +const RATE_WINDOW_MS = Number(process.env.CVM_RATE_WINDOW_MS || 5 * 60 * 1000); +const RATE_LIMIT = Number(process.env.CVM_RATE_LIMIT || 30); // requests / window / pubkey +const MAX_INFLIGHT_PER_CLIENT = Number(process.env.CVM_MAX_INFLIGHT || 2); +const MAX_UPSTREAM = Number(process.env.CVM_MAX_UPSTREAM || 8); +const QUERY_CACHE_TTL_MS = Number(process.env.CVM_QUERY_CACHE_MS || 30_000); + +const requestLog = new Map(); +const inFlightByClient = new Map(); +const queryCache = new Map(); + +// Global semaphore capping concurrent upstream relay queries. +let upstreamActive = 0; +const upstreamQueue: Array<() => void> = []; +async function withUpstreamSlot(fn: () => Promise): Promise { + if (upstreamActive >= MAX_UPSTREAM) { + await new Promise((resolve) => upstreamQueue.push(resolve)); + } + upstreamActive += 1; + try { + return await fn(); + } finally { + upstreamActive -= 1; + upstreamQueue.shift()?.(); + } +} + +// Drop stale rate-limit entries and expired cache rows so the maps stay bounded. +const janitor = setInterval(() => { + const cutoff = Date.now() - RATE_WINDOW_MS; + for (const [pk, times] of requestLog) { + const kept = times.filter((t) => t > cutoff); + if (kept.length) requestLog.set(pk, kept); + else requestLog.delete(pk); + } + const cacheCutoff = Date.now() - QUERY_CACHE_TTL_MS; + for (const [key, row] of queryCache) { + if (row.at < cacheCutoff) queryCache.delete(key); + } +}, 60_000); +(janitor as { unref?: () => void }).unref?.(); + // ─── Nostr data pool ───────────────────────────────────────────────────────── // Used to query skate data from the community relays const dataPool = new SimplePool(); async function queryNostr(filters: Filter[], timeoutMs = 8000): Promise { - // nostr-tools v2: use querySync which handles eose cleanly - const allEvents: any[] = []; - await Promise.all( - filters.map(async (filter) => { - try { - const events = await dataPool.querySync(NOSTR_RELAYS, filter, { - maxWait: timeoutMs, - }); - allEvents.push(...events); - } catch (err) { - console.error("[queryNostr] error:", err); - } - }) - ); - // Audit F2 (server-side): only serve signature-verified events to agents. - // Unverified community-relay data must never become agent-trusted ground - // truth (forged coach profiles / listings would flow straight into agents). - return allEvents.filter((e) => { - try { return verifyEvent(e); } catch { return false; } + // Short-TTL cache keyed by the filter set — identical queries (e.g. a burst of + // get_community_stats) reuse the last result instead of hitting the relays. + const cacheKey = JSON.stringify(filters); + const cached = queryCache.get(cacheKey); + if (cached && Date.now() - cached.at < QUERY_CACHE_TTL_MS) return cached.events; + + return withUpstreamSlot(async () => { + // nostr-tools v2: use querySync which handles eose cleanly + const allEvents: any[] = []; + await Promise.all( + filters.map(async (filter) => { + try { + const events = await dataPool.querySync(NOSTR_RELAYS, filter, { + maxWait: timeoutMs, + }); + allEvents.push(...events); + } catch (err) { + console.error("[queryNostr] error:", err); + } + }) + ); + // Audit F2 (server-side): only serve signature-verified events to agents. + // Unverified community-relay data must never become agent-trusted ground + // truth (forged coach profiles / listings would flow straight into agents). + const verified = allEvents.filter((e) => { + try { return verifyEvent(e); } catch { return false; } + }); + queryCache.set(cacheKey, { at: Date.now(), events: verified }); + return verified; }); } @@ -851,6 +907,34 @@ async function main() { }, }); + // Per-pubkey throttle: sliding window + in-flight cap. Over-budget callers are + // dropped (not forwarded) and time out — the transport can't return an error. + transport.addInboundMiddleware(async (message, ctx, forward) => { + const pk = ctx.clientPubkey; + const now = Date.now(); + const recent = (requestLog.get(pk) ?? []).filter((t) => now - t < RATE_WINDOW_MS); + const inflight = inFlightByClient.get(pk) ?? 0; + + if (recent.length >= RATE_LIMIT || inflight >= MAX_INFLIGHT_PER_CLIENT) { + console.warn( + `[ratelimit] dropped request from ${pk.slice(0, 12)}… ` + + `(window ${recent.length}/${RATE_LIMIT}, in-flight ${inflight}/${MAX_INFLIGHT_PER_CLIENT})` + ); + return; + } + + recent.push(now); + requestLog.set(pk, recent); + inFlightByClient.set(pk, inflight + 1); + try { + await forward(message); + } finally { + const left = (inFlightByClient.get(pk) ?? 1) - 1; + if (left > 0) inFlightByClient.set(pk, left); + else inFlightByClient.delete(pk); + } + }); + await mcpServer.connect(transport); // ─── Heartbeat ───────────────────────────────────────────────────────────── diff --git a/cvm-server/tsconfig.json b/cvm-server/tsconfig.json new file mode 100644 index 0000000..09fda6c --- /dev/null +++ b/cvm-server/tsconfig.json @@ -0,0 +1,16 @@ +{ + // Type-checks the standalone Bun server. It is deliberately separate from the + // app's root tsconfig (which only includes `src`) and is run by `npm test` + // via the `typecheck:cvm` script, so the server can't silently rot. + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ESNext"], + "types": ["node"], + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["server.ts"] +} diff --git a/eslint.config.js b/eslint.config.js index e667548..8f22375 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -18,7 +18,9 @@ export default tseslint.config( // ESLint ruleset (react-hooks/set-state-in-effect, purity) not installed // here, and are never bundled into the app. ".agents", - // Standalone Deno/Node server — linted on its own, not by the web config. + // Standalone Bun server — type-checked by its own tsconfig (see the + // `typecheck:cvm` script, wired into `npm test`), not by the web ESLint + // config (it leans on `any` and has no React rules). "cvm-server", // Static utility HTML (icon generators, sticker sheets, QR pages) that // are intentionally minimal and don't need app-level meta/OG tags. diff --git a/package.json b/package.json index f57466c..5eb9ce1 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,9 @@ "scripts": { "dev": "npm i --silent && vite", "build": "npm i --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'Project built successfully!'", - "test": "npm i --silent && tsc --noEmit && eslint && vitest run --reporter=dot --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'All tests passed!'", + "typecheck:cvm": "tsc -p cvm-server/tsconfig.json", + "check:cvm": "bun build cvm-server/server.ts --target=bun --outfile=/tmp/cvm-check.js", + "test": "npm i --silent && tsc --noEmit && tsc -p cvm-server/tsconfig.json && eslint && vitest run --reporter=dot --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'All tests passed!'", "deploy": "npm i --silent && npm run build && npx -y nostr-deploy-cli deploy --skip-setup", "android:init": "npm run android:build:apk && npx cap add android && npx cap sync android && node scripts/inject-amber-queries.mjs && node scripts/inject-main-activity.mjs && node scripts/inject-network-security.mjs && node scripts/inject-signing-config.mjs", "android:sync": "npm run android:build:apk && npx cap sync android && node scripts/sync-android-version.mjs && node scripts/inject-amber-queries.mjs && node scripts/inject-main-activity.mjs && node scripts/inject-network-security.mjs && node scripts/inject-signing-config.mjs",