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:
|
`cvm-server/.env`. The tracked manifest is `package.cvm.json`. Tools:
|
||||||
`find_spots` / `find_events` / `find_coaches` (all Lab kinds 35001–35014) /
|
`find_spots` / `find_events` / `find_coaches` (all Lab kinds 35001–35014) /
|
||||||
`find_gear` / `find_crews` / `find_shops` / `find_tours` +
|
`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
|
- **Routing / directions**: `RouteDialog` is the single in-app router, opened
|
||||||
from the map spot sheet, the food cards, and the spot / shop / event detail
|
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).
|
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`) —
|
- **Lazy routes**: every route uses `lazyWithRetry` (`src/lib/lazyWithRetry.ts`) —
|
||||||
retries, then one full reload per session, so a chunk-load failure self-heals
|
retries, then one full reload per session, so a chunk-load failure self-heals
|
||||||
instead of showing the error boundary.
|
instead of showing the error boundary.
|
||||||
- **Baselines** (verified 2026-09-26): `npm test` → **0 errors, 0 warnings**,
|
- **Baselines** (verified 2026-09-27): `npm test` → **0 errors, 0 warnings**,
|
||||||
**101 tests**, build ok. `package.json` version **1.4.5**.
|
**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
|
- ✅ Lint is **clean**: `no-explicit-any` = 0 · `react-hooks/exhaustive-deps` = 0
|
||||||
· `no-unused-vars` = 0 · `react-refresh` = 0. Non-component exports were
|
· `no-unused-vars` = 0 · `react-refresh` = 0. Non-component exports were
|
||||||
**split into their own modules** (`contexts/language.ts`, `lib/sortOptions.ts`,
|
**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).
|
# How often to re-publish the discovery announcements, in ms (0 disables).
|
||||||
# Keeps recency-based health checks green between (re)starts.
|
# Keeps recency-based health checks green between (re)starts.
|
||||||
CVM_HEARTBEAT_MS=900000
|
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,31 +54,87 @@ const LOG_LEVEL = (process.env.LOG_LEVEL || "info") as SdkLogLevel;
|
||||||
// recency-based health checks. Set 0 to disable.
|
// recency-based health checks. Set 0 to disable.
|
||||||
const HEARTBEAT_MS = Number(process.env.CVM_HEARTBEAT_MS || 15 * 60 * 1000);
|
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 ─────────────────────────────────────────────────────────
|
// ─── Nostr data pool ─────────────────────────────────────────────────────────
|
||||||
// Used to query skate data from the community relays
|
// Used to query skate data from the community relays
|
||||||
|
|
||||||
const dataPool = new SimplePool();
|
const dataPool = new SimplePool();
|
||||||
|
|
||||||
async function queryNostr(filters: Filter[], timeoutMs = 8000): Promise<any[]> {
|
async function queryNostr(filters: Filter[], timeoutMs = 8000): Promise<any[]> {
|
||||||
// nostr-tools v2: use querySync which handles eose cleanly
|
// Short-TTL cache keyed by the filter set — identical queries (e.g. a burst of
|
||||||
const allEvents: any[] = [];
|
// get_community_stats) reuse the last result instead of hitting the relays.
|
||||||
await Promise.all(
|
const cacheKey = JSON.stringify(filters);
|
||||||
filters.map(async (filter) => {
|
const cached = queryCache.get(cacheKey);
|
||||||
try {
|
if (cached && Date.now() - cached.at < QUERY_CACHE_TTL_MS) return cached.events;
|
||||||
const events = await dataPool.querySync(NOSTR_RELAYS, filter, {
|
|
||||||
maxWait: timeoutMs,
|
return withUpstreamSlot(async () => {
|
||||||
});
|
// nostr-tools v2: use querySync which handles eose cleanly
|
||||||
allEvents.push(...events);
|
const allEvents: any[] = [];
|
||||||
} catch (err) {
|
await Promise.all(
|
||||||
console.error("[queryNostr] error:", err);
|
filters.map(async (filter) => {
|
||||||
}
|
try {
|
||||||
})
|
const events = await dataPool.querySync(NOSTR_RELAYS, filter, {
|
||||||
);
|
maxWait: timeoutMs,
|
||||||
// Audit F2 (server-side): only serve signature-verified events to agents.
|
});
|
||||||
// Unverified community-relay data must never become agent-trusted ground
|
allEvents.push(...events);
|
||||||
// truth (forged coach profiles / listings would flow straight into agents).
|
} catch (err) {
|
||||||
return allEvents.filter((e) => {
|
console.error("[queryNostr] error:", err);
|
||||||
try { return verifyEvent(e); } catch { return false; }
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
// 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);
|
await mcpServer.connect(transport);
|
||||||
|
|
||||||
// ─── Heartbeat ─────────────────────────────────────────────────────────────
|
// ─── 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
|
// ESLint ruleset (react-hooks/set-state-in-effect, purity) not installed
|
||||||
// here, and are never bundled into the app.
|
// here, and are never bundled into the app.
|
||||||
".agents",
|
".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",
|
"cvm-server",
|
||||||
// Static utility HTML (icon generators, sticker sheets, QR pages) that
|
// Static utility HTML (icon generators, sticker sheets, QR pages) that
|
||||||
// are intentionally minimal and don't need app-level meta/OG tags.
|
// are intentionally minimal and don't need app-level meta/OG tags.
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,9 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "npm i --silent && vite",
|
"dev": "npm i --silent && vite",
|
||||||
"build": "npm i --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'Project built successfully!'",
|
"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",
|
"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: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",
|
"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