feat(cvm-server): load protection + type-check it in the gate
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).
This commit is contained in:
parent
b8757eccd0
commit
3a32dc2334
6 changed files with 144 additions and 24 deletions
11
AGENTS.md
11
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`,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -54,12 +54,65 @@ 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<string, number[]>();
|
||||
const inFlightByClient = new Map<string, number>();
|
||||
const queryCache = new Map<string, { at: number; events: any[] }>();
|
||||
|
||||
// Global semaphore capping concurrent upstream relay queries.
|
||||
let upstreamActive = 0;
|
||||
const upstreamQueue: Array<() => void> = [];
|
||||
async function withUpstreamSlot<T>(fn: () => Promise<T>): Promise<T> {
|
||||
if (upstreamActive >= MAX_UPSTREAM) {
|
||||
await new Promise<void>((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<any[]> {
|
||||
// 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(
|
||||
|
|
@ -77,9 +130,12 @@ async function queryNostr(filters: Filter[], timeoutMs = 8000): Promise<any[]> {
|
|||
// 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) => {
|
||||
const verified = allEvents.filter((e) => {
|
||||
try { return verifyEvent(e); } catch { return false; }
|
||||
});
|
||||
queryCache.set(cacheKey, { at: Date.now(), events: verified });
|
||||
return verified;
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
|
@ -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 ─────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
16
cvm-server/tsconfig.json
Normal file
16
cvm-server/tsconfig.json
Normal file
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue