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:
SkateSpots 2026-09-27 11:36:20 +07:00
parent b8757eccd0
commit 3a32dc2334
6 changed files with 144 additions and 24 deletions

View file

@ -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`,

View file

@ -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

View file

@ -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<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[]> {
// 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 ─────────────────────────────────────────────────────────────

16
cvm-server/tsconfig.json Normal file
View 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"]
}

View file

@ -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.

View file

@ -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",