argusqa-os 9.5.5 → 9.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/glama.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://glama.ai/mcp/schemas/server.json",
3
3
  "name": "argus",
4
- "description": "AI-powered QA harness that audits web apps via Chrome DevTools Protocol. Catches JS errors, network failures, a11y violations, SEO issues, security headers, CSS regressions, and more — directly from Claude conversations. 7 MCP tools: argus_audit (fast 8-analyzer pass), argus_audit_full (Lighthouse + memory + responsive), argus_compare (dev vs staging diff), argus_last_report (retrieve last JSON report), argus_watch_snapshot (live tab snapshot without navigating), argus_get_context (LLM-optimized context + fix loop with snapshot_id diff), argus_design_audit (Figma design fidelity — 13 finding types). 130 test blocks, 581 hard assertions, 58 detection categories.",
4
+ "description": "AI-powered QA harness that audits web apps via Chrome DevTools Protocol. Catches JS errors, network failures, a11y violations, SEO issues, security headers, CSS regressions, and more — directly from Claude conversations. 9 MCP tools: argus_audit (fast 8-analyzer pass), argus_audit_full (Lighthouse + memory + responsive), argus_compare (dev vs staging diff), argus_last_report (retrieve last JSON report), argus_watch_snapshot (live tab snapshot without navigating), argus_get_context (LLM-optimized context + fix loop with snapshot_id diff), argus_design_audit (Figma design fidelity — 13 finding types), argus_visual_diff (screenshot baseline comparison, updateBaseline flag), argus_pr_validate (PR diff → affected routes → targeted audit → blocked flag). 137 test blocks, 634 hard assertions, 63 detection categories.",
5
5
  "maintainers": ["ironclawdevs27"],
6
6
  "tools": [
7
7
  {
@@ -31,6 +31,14 @@
31
31
  {
32
32
  "name": "argus_design_audit",
33
33
  "description": "Full Figma design-to-implementation fidelity audit. Fetches design spec from a Figma frame URL (requires FIGMA_API_TOKEN) and compares every extracted property against live DOM computed styles. Detects 13 mismatch finding types: CSS token values, component presence, fill/text color (RGB distance), typography (fontSize/fontWeight/lineHeight/fontFamily/letterSpacing), Auto Layout padding and gap, border-radius (per-corner), bounding-box overflow, absolute position drift (scroll-corrected x/y vs Figma bounds), border stroke (color+weight), box-shadow (offset+blur+spread+color), opacity, and text content. Selector fallback: tries [data-testid], [aria-label], #id, .class per node."
34
+ },
35
+ {
36
+ "name": "argus_visual_diff",
37
+ "description": "Screenshot baseline comparison for a URL. First call saves a baseline PNG to reports/baselines/screenshots/. Subsequent calls diff the current screenshot against the baseline using pixelmatch and return visual_regression (warning ≥0.1% / critical ≥5% pixels changed) + visual_diff_summary (always). Pass updateBaseline: true to force-refresh the stored baseline after intentional UI changes."
38
+ },
39
+ {
40
+ "name": "argus_pr_validate",
41
+ "description": "Targeted QA audit driven by a GitHub pull request diff. Fetches the PR's changed files, maps them to affected routes in your target config using path-slug heuristics (infrastructure changes trigger a full audit), then audits only those routes. Returns { findings, affectedRoutes, changedFiles, perRoute, summary, blocked, blockOn }. Use in CI to gate merges — blocked:true when findings meet the blockOn threshold (none/warning/critical, default: critical). Requires Chrome on --remote-debugging-port=9222. GITHUB_TOKEN env var recommended for private repos."
34
42
  }
35
43
  ]
36
44
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "argusqa-os",
3
- "version": "9.5.5",
3
+ "version": "9.6.0",
4
4
  "mcpName": "io.github.ironclawdevs27/argus",
5
5
  "description": "Argus — AI-powered automated dev-testing platform using Chrome DevTools MCP and Claude Code",
6
6
  "keywords": [
@@ -55,7 +55,8 @@
55
55
  "@opentelemetry/api": "^1.9.1",
56
56
  "@opentelemetry/sdk-node": "^0.218.0",
57
57
  "@slack/web-api": "^7.16.0",
58
- "dotenv": "^16.4.5",
58
+ "axe-core": "^4.12.0",
59
+ "dotenv": "^17.4.2",
59
60
  "express": "^5.2.1",
60
61
  "pino": "^10.3.1",
61
62
  "pino-pretty": "^13.1.3",
@@ -64,6 +65,6 @@
64
65
  "zod": "^4.4.3"
65
66
  },
66
67
  "devDependencies": {
67
- "vitest": "^4.1.7"
68
+ "vitest": "^4.1.8"
68
69
  }
69
70
  }
@@ -48,6 +48,7 @@ export class CdpBrowserAdapter {
48
48
  emulate(viewport) { return this._mcp.emulate({ viewport }); }
49
49
  emulateCpu(rate) { return this._mcp.emulate({ cpuThrottlingRate: rate }); }
50
50
  emulateColorScheme(scheme) { return this._mcp.emulate({ colorScheme: scheme }); }
51
+ emulateReducedMotion(pref) { return this._mcp.emulate({ reducedMotion: pref }); }
51
52
  resize(w, h) { return this._mcp.resize_page({ width: w, height: h }); }
52
53
 
53
54
  // ── Network & performance ───────────────────────────────────────────────────
package/src/cli/init.js CHANGED
@@ -287,11 +287,15 @@ async function main() {
287
287
  githubToken, githubRepo, sourceDir, envFile: envFilePath });
288
288
  const targetsContent = generateTargetsJs(finalRoutes, { framework, sourceDir, envFile: envFilePath });
289
289
 
290
- if (fs.existsSync('.env')) {
291
- logger.warn('.env already exists skipping write to preserve existing credentials. Delete it manually to regenerate.');
292
- } else {
293
- fs.writeFileSync('.env', envContent, 'utf8');
290
+ try {
291
+ fs.writeFileSync('.env', envContent, { flag: 'wx', encoding: 'utf8' });
294
292
  tick('Wrote .env');
293
+ } catch (err) {
294
+ if (err.code === 'EEXIST') {
295
+ logger.warn(' ⚠ .env already exists — skipping write to preserve existing credentials. Delete it manually to regenerate.');
296
+ } else {
297
+ throw err;
298
+ }
295
299
  }
296
300
 
297
301
  const targetsPath = path.join('src', 'config', 'targets.js');
@@ -68,6 +68,16 @@ export const thresholds = {
68
68
  warnPercent: parseFloat(process.env.VISUAL_WARN_PERCENT ?? '0.1'), // % pixels changed → warning
69
69
  critPercent: parseFloat(process.env.VISUAL_CRIT_PERCENT ?? '5.0'), // % pixels changed → critical
70
70
  },
71
+ a11y: {
72
+ contrastAA: parseFloat(process.env.A11Y_CONTRAST_AA ?? '4.5'), // WCAG AA normal text contrast ratio
73
+ maxAxeViolations: parseInt(process.env.A11Y_MAX_AXE ?? '50', 10), // cap axe-core violations per run
74
+ },
75
+ motion: {
76
+ animationPropertyCount: parseInt(process.env.MOTION_ANIM_COUNT ?? '1', 10), // flag interactive animations at this count
77
+ },
78
+ font: {
79
+ slowLoadMs: parseInt(process.env.FONT_SLOW_MS ?? '1000', 10), // ms threshold for slow font load warning
80
+ },
71
81
  };
72
82
 
73
83
  /**
package/src/mcp-server.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Argus MCP Server (v9.5.5)
3
+ * Argus MCP Server (v9.6.0)
4
4
  *
5
5
  * Exposes Argus as an MCP server so Claude (or any MCP client) can call
6
6
  * argus_audit, argus_audit_full, argus_compare, argus_last_report, and
@@ -32,6 +32,8 @@ import { WatchSession } from './orchestration/watch-mode.j
32
32
  import { CdpBrowserAdapter } from './adapters/browser.js';
33
33
  import { getFigmaFrame } from './adapters/figma.js';
34
34
  import { analyzeDesignFidelity } from './utils/design-fidelity-analyzer.js';
35
+ import { analyzeVisualRegression } from './utils/visual-diff-analyzer.js';
36
+ import { parsePrUrl, fetchPrFiles, mapFilesToRoutes } from './utils/pr-diff-analyzer.js';
35
37
 
36
38
  const REPORTS_DIR = path.resolve(process.cwd(), 'reports');
37
39
 
@@ -119,6 +121,19 @@ const TOOLS = [
119
121
  },
120
122
  },
121
123
  },
124
+ {
125
+ name: 'argus_visual_diff',
126
+ description: 'Screenshot baseline comparison for a URL — captures a PNG screenshot and compares it pixel-by-pixel against a stored baseline using pixelmatch. First call: saves baseline, returns visual_baseline_created (info). Subsequent calls: returns visual_regression (warning ≥0.1% / critical ≥5% pixels changed) + visual_diff_summary (always). Baseline stored in reports/baselines/screenshots/. Use in CI or fix loops to detect unintended visual regressions without a full audit. Pass updateBaseline: true to force-refresh the stored baseline (e.g. after intentional UI changes). Requires Chrome on --remote-debugging-port=9222.',
127
+ inputSchema: {
128
+ type: 'object',
129
+ properties: {
130
+ url: { type: 'string', description: 'Full URL to capture and compare (e.g. http://localhost:3000/dashboard). Must be reachable by the running Chrome instance.' },
131
+ updateBaseline: { type: 'boolean', description: 'When true, deletes the existing baseline PNG and saves a fresh one from the current screenshot. Use after intentional UI changes to reset the reference.', default: false },
132
+ baselineDir: { type: 'string', description: 'Optional override for the baseline storage directory. Defaults to reports/baselines/screenshots/.' },
133
+ },
134
+ required: ['url'],
135
+ },
136
+ },
122
137
  {
123
138
  name: 'argus_design_audit',
124
139
  description: 'Full design-to-implementation fidelity audit against a Figma frame. 13 mismatch finding types: CSS token values, component presence, fill/text color (RGB delta), typography (fontSize/fontWeight/lineHeight/fontFamily/letterSpacing), Auto Layout padding and gap, border-radius (per-corner), bounding-box overflow, absolute position drift (scroll-corrected x/y, 20px threshold), border stroke (color+weight), box-shadow (offset+blur+spread+color), opacity, and text content. Selector fallback: tries [data-testid], [aria-label], #id, .class per node. Requires FIGMA_API_TOKEN env var and Chrome on --remote-debugging-port=9222. Returns { findings, summary } where summary includes 13 mismatch-type counts.',
@@ -131,6 +146,20 @@ const TOOLS = [
131
146
  required: ['url', 'figmaFrameUrl'],
132
147
  },
133
148
  },
149
+ {
150
+ name: 'argus_pr_validate',
151
+ description: 'Runs a targeted Argus audit on the routes affected by a GitHub pull request. Fetches the PR diff, maps changed files to routes in your target config using path-slug heuristics (infrastructure changes trigger a full audit; targeted otherwise), and audits only those routes — faster than a full scan and focused on what the PR actually touched. Returns { findings, affectedRoutes, changedFiles, perRoute, summary, blocked, blockOn }. Use in CI to gate merges: check blocked:true or pipe findings to an AI verdict step. Requires Chrome on --remote-debugging-port=9222. GITHUB_TOKEN env var recommended for private repos.',
152
+ inputSchema: {
153
+ type: 'object',
154
+ properties: {
155
+ prUrl: { type: 'string', description: 'Full GitHub PR URL (e.g. https://github.com/owner/repo/pull/42). Used to fetch the list of changed files via the GitHub REST API.' },
156
+ targetUrl: { type: 'string', description: 'Base URL to audit (e.g. https://staging.example.com). Overrides TARGET_DEV_URL env var.' },
157
+ githubToken: { type: 'string', description: 'GitHub Personal Access Token or workflow GITHUB_TOKEN. Optional for public repos. Falls back to GITHUB_TOKEN env var.' },
158
+ blockOn: { type: 'string', enum: ['none', 'warning', 'critical'], description: '"critical" = block only when critical findings exist. "warning" = block on any warning or critical. "none" = never block. Defaults to ARGUS_BLOCK_ON env var, then "critical".', default: 'critical' },
159
+ },
160
+ required: ['prUrl'],
161
+ },
162
+ },
134
163
  ];
135
164
 
136
165
  // ── Helpers ───────────────────────────────────────────────────────────────────
@@ -282,6 +311,42 @@ async function handleGetContext({ url, snapshot_id: prevId, tabId } = {}) {
282
311
  });
283
312
  }
284
313
 
314
+ async function handleVisualDiff({ url, updateBaseline = false, baselineDir }) {
315
+ if (!url) throw new Error('argus_visual_diff: url is required');
316
+
317
+ return withMcp(async (mcp) => {
318
+ const browser = new CdpBrowserAdapter(mcp);
319
+ const opts = baselineDir ? { baselineDir } : {};
320
+
321
+ if (updateBaseline) {
322
+ // Delete existing baseline so analyzeVisualRegression treats it as first run
323
+ const path_ = await import('path');
324
+ const fs_ = await import('fs');
325
+ const { slugify } = await import('./utils/slug.js');
326
+ const { config } = await import('./config/targets.js');
327
+ const dir = baselineDir ?? path_.default.join(config.outputDir, 'baselines', 'screenshots');
328
+ const file = path_.default.join(dir, `${slugify(url)}.png`);
329
+ try { fs_.default.unlinkSync(file); } catch {}
330
+ }
331
+
332
+ const findings = await analyzeVisualRegression(browser, url, opts);
333
+ const regression = findings.find(f => f.type === 'visual_regression');
334
+ const baseline = findings.find(f => f.type === 'visual_baseline_created');
335
+ const summary = findings.find(f => f.type === 'visual_diff_summary');
336
+
337
+ return { content: [{ type: 'text', text: JSON.stringify({
338
+ findings,
339
+ summary: {
340
+ status: regression ? 'regression' : baseline ? 'baseline_created' : 'no_change',
341
+ diffPercent: summary?.diffPercent ?? 0,
342
+ diffPixels: summary?.diffPixels ?? 0,
343
+ totalPixels: summary?.totalPixels ?? 0,
344
+ severity: regression?.severity ?? 'info',
345
+ },
346
+ }, null, 2) }] };
347
+ });
348
+ }
349
+
285
350
  async function handleDesignAudit({ url, figmaFrameUrl }) {
286
351
  if (!url) throw new Error('argus_design_audit: url is required');
287
352
  if (!figmaFrameUrl) throw new Error('argus_design_audit: figmaFrameUrl is required');
@@ -318,6 +383,52 @@ async function handleDesignAudit({ url, figmaFrameUrl }) {
318
383
  });
319
384
  }
320
385
 
386
+ async function handlePrValidate({ prUrl, targetUrl, githubToken, blockOn } = {}) {
387
+ if (!prUrl) throw new Error('argus_pr_validate: prUrl is required');
388
+
389
+ const { routes } = await import('./config/targets.js');
390
+ const token = githubToken ?? process.env.GITHUB_TOKEN;
391
+ const base = targetUrl ?? process.env.TARGET_DEV_URL ?? 'http://localhost:3000';
392
+ const policy = blockOn ?? process.env.ARGUS_BLOCK_ON ?? 'critical';
393
+
394
+ const changedFiles = await fetchPrFiles(prUrl, token);
395
+ const affectedRoutes = mapFilesToRoutes(changedFiles, routes ?? []);
396
+
397
+ const allFindings = [];
398
+ const perRoute = [];
399
+
400
+ for (const route of affectedRoutes) {
401
+ const url = new URL(route.path, base).href;
402
+ const res = await handleAudit({ url, critical: route.critical ?? false });
403
+ const data = JSON.parse(res.content[0].text);
404
+ allFindings.push(...(data.findings ?? []));
405
+ perRoute.push({ route: route.path, ...data.summary });
406
+ }
407
+
408
+ const summary = {
409
+ critical: allFindings.filter(f => f.severity === 'critical').length,
410
+ warning: allFindings.filter(f => f.severity === 'warning').length,
411
+ info: allFindings.filter(f => f.severity === 'info').length,
412
+ };
413
+
414
+ const blocked =
415
+ policy === 'critical' ? summary.critical > 0 :
416
+ policy === 'warning' ? summary.critical + summary.warning > 0 :
417
+ false;
418
+
419
+ return { content: [{ type: 'text', text: JSON.stringify({
420
+ prUrl,
421
+ targetUrl: base,
422
+ affectedRoutes: affectedRoutes.map(r => r.path),
423
+ changedFiles,
424
+ findings: allFindings,
425
+ perRoute,
426
+ summary,
427
+ blocked,
428
+ blockOn: policy,
429
+ }, null, 2) }] };
430
+ }
431
+
321
432
  async function handleLastReport() {
322
433
  if (!fs.existsSync(REPORTS_DIR)) {
323
434
  return { content: [{ type: 'text', text: '{"error":"No reports found in reports/"}' }] };
@@ -336,7 +447,7 @@ async function handleLastReport() {
336
447
  // ── Server bootstrap ──────────────────────────────────────────────────────────
337
448
 
338
449
  const server = new Server(
339
- { name: 'argus', version: '9.5.5' },
450
+ { name: 'argus', version: '9.6.0' },
340
451
  { capabilities: { tools: {} } },
341
452
  );
342
453
 
@@ -351,7 +462,9 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
351
462
  case 'argus_last_report': return await handleLastReport();
352
463
  case 'argus_watch_snapshot': return await handleWatchSnapshot(req.params.arguments ?? {});
353
464
  case 'argus_get_context': return await handleGetContext(req.params.arguments ?? {});
465
+ case 'argus_visual_diff': return await handleVisualDiff(req.params.arguments ?? {});
354
466
  case 'argus_design_audit': return await handleDesignAudit(req.params.arguments ?? {});
467
+ case 'argus_pr_validate': return await handlePrValidate(req.params.arguments ?? {});
355
468
  default: throw new Error(`Unknown tool: ${req.params.name}`);
356
469
  }
357
470
  } catch (err) {
@@ -26,7 +26,7 @@ function openInBrowser(filePath) {
26
26
  try {
27
27
  const abs = path.resolve(filePath);
28
28
  if (process.platform === 'win32') {
29
- execFile('cmd', ['/c', 'start', '', abs], () => {});
29
+ execFile('cmd', ['/c', 'start', '', abs], () => {}); // lgtm[js/shell-command-injection-from-environment] — execFile with args array is injection-safe; abs is path.resolve() of an internally-computed report path
30
30
  } else if (process.platform === 'darwin') {
31
31
  execFile('open', [abs], () => {});
32
32
  } else {
@@ -21,7 +21,6 @@ import { unwrapEval } from '../utils/mcp-client.js';
21
21
  import { childLogger } from '../utils/logger.js';
22
22
 
23
23
  const logger = childLogger('env-comparison');
24
- import { normalizeArray } from '../utils/flow-runner.js';
25
24
  import { CdpBrowserAdapter } from '../adapters/browser.js';
26
25
 
27
26
  import { comparisonRoutes, config } from '../config/targets.js';
@@ -45,6 +45,11 @@ import '../utils/theme-analyzer.js';
45
45
  import '../utils/design-fidelity-analyzer.js';
46
46
  import '../utils/web-vitals-analyzer.js';
47
47
  import '../utils/visual-diff-analyzer.js';
48
+ import '../utils/a11y-deep-analyzer.js';
49
+ import '../utils/har-recorder.js';
50
+ import '../utils/motion-analyzer.js';
51
+ import '../utils/font-analyzer.js';
52
+ import '../utils/form-analyzer.js';
48
53
 
49
54
  import { getExpensive } from '../registry.js';
50
55
  import { deduplicateFindings as deduplicateErrors } from './report-processor.js';
@@ -485,7 +490,7 @@ export async function crawlRouteCheap(route, baseUrl, mcp) {
485
490
  const text = (msg.text ?? msg.message ?? '');
486
491
  if (text.toLowerCase().includes('has been blocked by cors policy')) continue;
487
492
  const severity = classifyConsoleMessage(msg, route.critical);
488
- if (severity !== null && msg.level !== 'log') {
493
+ if (msg.level !== 'log') {
489
494
  result.errors.push({
490
495
  type: 'console',
491
496
  level: msg.level,
@@ -1021,10 +1026,10 @@ export async function runCrawl(mcp, routeOverrides = null, baseUrlOverride = nul
1021
1026
  // Auth session persistence (B2)
1022
1027
  const sessionFile = auth?.sessionFile ?? '.argus-session.json';
1023
1028
  if (auth?.steps?.length > 0) {
1024
- if (!hasSession(sessionFile, auth.sessionMaxAgeMs)) {
1025
- logger.info(`[ARGUS] Auth: running login flow (${auth.steps.length} steps)...`);
1029
+ if (!hasSession(sessionFile, auth?.sessionMaxAgeMs)) {
1030
+ logger.info(`[ARGUS] Auth: running login flow (${auth?.steps?.length ?? 0} steps)...`);
1026
1031
  try {
1027
- await runLoginFlow(browser, targetBaseUrl, auth.steps);
1032
+ await runLoginFlow(browser, targetBaseUrl, auth?.steps ?? []);
1028
1033
  await saveSession(browser, sessionFile);
1029
1034
  } catch (err) {
1030
1035
  logger.warn(`[ARGUS] Auth: login flow failed — crawl will proceed unauthenticated: ${err.message}`);
@@ -1149,5 +1154,5 @@ export async function runCrawl(mcp, routeOverrides = null, baseUrlOverride = nul
1149
1154
  if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
1150
1155
  logger.info('[ARGUS] orchestrator.js loaded. Invoke runCrawl(mcp) from Claude Code with MCP tools connected.');
1151
1156
  logger.info('[ARGUS] Target base URL: ' + BASE_URL);
1152
- logger.info('[ARGUS] Routes to crawl: ' + (routes ?? []).map(r => r?.path ?? '(no path)').join(', '));
1157
+ logger.info('[ARGUS] Routes to crawl: ' + routes.map(r => r?.path ?? '(no path)').join(', '));
1153
1158
  }
@@ -108,7 +108,7 @@ export async function processReport(report, { outputDir, severityOverrides }) {
108
108
  const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
109
109
  const reportPath = path.join(outputDir, `error-report-${timestamp}.json`);
110
110
  try {
111
- fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
111
+ fs.writeFileSync(reportPath, JSON.stringify(report, null, 2)); // lgtm[js/network-data-to-file] — intentional: Argus persists crawl findings to a local JSON report file by design
112
112
  } catch (err) {
113
113
  logger.error(`[ARGUS] Failed to write report JSON: ${err.message}`);
114
114
  throw err;
@@ -99,7 +99,7 @@ async function uploadFileToSlack(filePath, channelId, filename) {
99
99
  const response = await fetch(uploadUrl, {
100
100
  method: 'PUT',
101
101
  headers: { 'Content-Type': 'application/octet-stream' },
102
- body: fileBuffer,
102
+ body: fileBuffer, // lgtm[js/file-access-to-http] — intentional screenshot upload to Slack pre-signed URL; file path is internally generated by Argus, not from HTTP request input
103
103
  signal: AbortSignal.timeout(30000),
104
104
  });
105
105
  if (!response.ok) {
@@ -35,10 +35,6 @@ import {
35
35
  import { postBugReport } from './slack-notifier.js';
36
36
  import { isSlackConfigured } from '../utils/slack-guard.js';
37
37
  import { generateHtmlReport } from '../utils/html-reporter.js';
38
- import {
39
- parseConsoleMsgResponse,
40
- parseNetworkReqResponse,
41
- } from '../utils/mcp-parsers.js';
42
38
 
43
39
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
44
40
  const REPORTS_DIR = path.resolve(__dirname, '../../reports');
@@ -50,6 +50,28 @@ app.use((req, res, next) => {
50
50
  next();
51
51
  });
52
52
 
53
+ // ── Rate limiting (per-IP, in-memory) ──────────────────────────────────────────
54
+ // 30 requests per minute per IP — prevents Slack endpoint flooding.
55
+ // Slack signature verification rejects invalid payloads, but rate limiting adds
56
+ // a defence-in-depth layer before signature verification even runs.
57
+ const RATE_WINDOW_MS = 60_000;
58
+ const RATE_MAX = 30;
59
+ const rateLimitMap = new Map();
60
+
61
+ function slackRateLimit(req, res, next) {
62
+ const key = req.ip ?? 'unknown';
63
+ const now = Date.now();
64
+ const entry = rateLimitMap.get(key) ?? { count: 0, start: now };
65
+ if (now - entry.start > RATE_WINDOW_MS) { entry.count = 0; entry.start = now; }
66
+ entry.count++;
67
+ rateLimitMap.set(key, entry);
68
+ if (entry.count > RATE_MAX) {
69
+ res.setHeader('Retry-After', Math.ceil(RATE_WINDOW_MS / 1000));
70
+ return res.status(429).json({ error: 'Too Many Requests' });
71
+ }
72
+ next();
73
+ }
74
+
53
75
  // ── Routes ─────────────────────────────────────────────────────────────────────
54
76
 
55
77
  app.get('/health', (req, res) => {
@@ -57,10 +79,10 @@ app.get('/health', (req, res) => {
57
79
  });
58
80
 
59
81
  // Slack slash commands
60
- app.post('/slack/commands', handleSlashCommand);
82
+ app.post('/slack/commands', slackRateLimit, handleSlashCommand);
61
83
 
62
84
  // Slack Block Kit interactions (button clicks)
63
- app.post('/slack/interactions', handleInteraction);
85
+ app.post('/slack/interactions', slackRateLimit, handleInteraction);
64
86
 
65
87
  // ── Start ──────────────────────────────────────────────────────────────────────
66
88
 
@@ -15,7 +15,6 @@
15
15
  */
16
16
 
17
17
  import crypto from 'crypto';
18
- import { postBugReport } from '../orchestration/slack-notifier.js';
19
18
  import { createMcpClient } from '../utils/mcp-client.js';
20
19
  import { runCrawl } from '../orchestration/crawl-and-report.js';
21
20
  import { WebClient } from '@slack/web-api';