Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions claims.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,16 +106,16 @@
},
{
"id": "test-count",
"claim": "348 tests pass on Node's built-in test runner, the count docs/ROADMAP.md records for `npm test`.",
"value": "348",
"metric": "passing node:test cases (# tests 348 / # pass 348 / # fail 0)",
"method": "npm test → node --test test/*.test.js, run in the claims job after npm ci, and the TAP summary is asserted. The roadmap sentence ('348 tests on Node's built-in runner (`npm test`, 2026-09-27)') is verified by the run, not read back from the prose.",
"claim": "349 tests pass on Node's built-in test runner, the count docs/ROADMAP.md records for `npm test`.",
"value": "349",
"metric": "passing node:test cases (# tests 349 / # pass 349 / # fail 0)",
"method": "npm test → node --test test/*.test.js, run in the claims job after npm ci, and the TAP summary is asserted. The roadmap sentence ('349 tests on Node's built-in runner (`npm test`, 2026-10-17)') is verified by the run, not read back from the prose.",
"repro": "npm test 2>&1 | grep -E '^# (tests|pass|fail)'",
"evidence": "docs/ROADMAP.md",
"as_of": "2026-09-27",
"as_of": "2026-10-17",
"check": {
"cmd": "npm test 2>&1 | grep -E '^# (tests|pass|fail)'",
"expect": { "contains": ["# tests 348", "# pass 348", "# fail 0"] },
"expect": { "contains": ["# tests 349", "# pass 349", "# fail 0"] },
"timeout": 120
}
},
Expand Down
2 changes: 1 addition & 1 deletion docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
- **Session browser** with tool-call inspection, trace/waterfall view, spawn tracking and message timeline
- **Prompt tooling** — extraction (noise filtered), template clustering with outcome attribution, Claude-powered rewrites, and a prompt library that installs entries as native slash commands
- **Global search** across all platforms, insights dashboard, incremental session backup
- **React + Vite frontend** served by an Express backend; 348 tests on Node's built-in runner (`npm test`, 2026-09-27), CI on Node 22
- **React + Vite frontend** served by an Express backend; 349 tests on Node's built-in runner (`npm test`, 2026-10-17), CI on Node 22
- **Evidence-backed failure events and local review** with full-result invalidation, evidence navigation and narrow-screen session layout

## Current priorities
Expand Down
21 changes: 21 additions & 0 deletions lib/rate-limit.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Per-IP request limiter for every route (API, static files, SPA fallback).
//
// AgentXRay is a local dashboard bound to 127.0.0.1 by default, but every route
// reads from disk (session logs, the library repo, backups, the built UI), and
// HOST=0.0.0.0 exposes it on the LAN. The limit is deliberately generous so the UI
// (initial load, polling, SSE reconnects) and the test suite never come close:
// 600 requests per minute per client IP. Overflow gets 429 with RateLimit headers.
const { rateLimit } = require('express-rate-limit');

const WINDOW_MS = 60 * 1000;
const LIMIT = 600;

const limiter = rateLimit({
windowMs: WINDOW_MS,
limit: LIMIT,
standardHeaders: 'draft-8',
legacyHeaders: false,
message: { error: 'Too many requests, slow down.' },
});

module.exports = { limiter, WINDOW_MS, LIMIT };
66 changes: 53 additions & 13 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@
},
"license": "MIT",
"dependencies": {
"express": "^4.21.2"
"express": "^4.21.2",
"express-rate-limit": "^8.7.1"
},
"engines": {
"node": ">=22.13"
Expand Down
4 changes: 4 additions & 0 deletions server.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const mountBackupRoutes = require('./lib/routes/backup');
const mountWatchRoutes = require('./lib/routes/watch');
const mountLlmRoutes = require('./lib/routes/llm');
const { csrfGuard } = require('./lib/csrf');
const { limiter } = require('./lib/rate-limit');

const app = express();
const PORT = process.env.PORT || 3800;
Expand All @@ -23,6 +24,9 @@ const DIST_DIR = path.join(__dirname, 'frontend', 'dist');
// Serve the built React UI when present; the legacy vanilla UI stays at /legacy.
const HAS_DIST = fs.existsSync(path.join(DIST_DIR, 'index.html'));

// Rate-limit every route before anything touches the filesystem (lib/rate-limit.js).
app.use(limiter);

// Frontend staleness detection: the SPA polls this and prompts a reload
// when the serving process (and thus possibly the code) has changed.
const SERVER_BOOT_ID = `${Date.now().toString(36)}-${process.pid}`;
Expand Down
12 changes: 12 additions & 0 deletions test/api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,18 @@ describe('AgentXRay API', () => {
assert.ok(body.bootId.length > 0);
});

it('every route is rate-limited: 600 requests per minute per IP (lib/rate-limit.js)', async () => {
for (const route of ['/api/version', '/', '/no/such/page']) {
const res = await fetch(`${srv.base}${route}`);
assert.equal(res.status, 200, route);
const policy = res.headers.get('ratelimit-policy') || '';
assert.match(policy, /q=600/, `${route} policy: ${policy}`);
assert.match(policy, /w=60/, `${route} policy: ${policy}`);
assert.ok(res.headers.get('ratelimit'), `${route} has a RateLimit header`);
await res.arrayBuffer();
}
});

describe('sessions: codex', () => {
it('lists sessions newest-first with metadata', async () => {
const sessions = await getJson(srv.base, '/api/codex/sessions');
Expand Down
Loading