feat(HAI-001): add API rate limiting and replace emojis with Lucide icons

- Add rate limiting middleware to dashboard API endpoints
- Install lucide-react and replace emoji usage with Lucide icons across dashboard components
- Add rate limiter unit tests
- Document API rate limiting in README
- Refactor engine executor/triage and remove unused reviewer module
This commit is contained in:
Dustin Byrne
2026-03-25 20:33:37 -04:00
12 changed files with 497 additions and 9 deletions

View File

@@ -1 +1,2 @@
export { createServer, type ServerOptions } from "./server.js";
export { rateLimit, RATE_LIMITS, type RateLimitOptions } from "./rate-limit.js";

View File

@@ -0,0 +1,117 @@
import { describe, it, expect, beforeEach } from "vitest";
import { rateLimit } from "./rate-limit.js";
import type { Request, Response, NextFunction } from "express";
function mockReq(ip = "127.0.0.1"): Partial<Request> {
return { ip, socket: { remoteAddress: ip } as any };
}
function mockRes(): Partial<Response> & { _status: number; _json: any; _headers: Record<string, string> } {
const res: any = {
_status: 200,
_json: null,
_headers: {} as Record<string, string>,
setHeader(name: string, value: string) {
res._headers[name] = value;
return res;
},
status(code: number) {
res._status = code;
return res;
},
json(body: any) {
res._json = body;
return res;
},
};
return res;
}
describe("rateLimit", () => {
let middleware: ReturnType<typeof rateLimit>;
beforeEach(() => {
middleware = rateLimit({ windowMs: 60_000, max: 3 });
});
it("allows requests under the limit", () => {
const req = mockReq();
const res = mockRes();
let called = false;
const next: NextFunction = () => { called = true; };
middleware(req as Request, res as unknown as Response, next);
expect(called).toBe(true);
expect(res._headers["RateLimit-Limit"]).toBe("3");
expect(res._headers["RateLimit-Remaining"]).toBe("2");
expect(res._headers["RateLimit-Reset"]).toBeDefined();
});
it("blocks requests over the limit with 429", () => {
const req = mockReq();
let nextCalls = 0;
const next: NextFunction = () => { nextCalls++; };
// Exhaust the limit (3 allowed)
for (let i = 0; i < 3; i++) {
middleware(req as Request, mockRes() as unknown as Response, next);
}
expect(nextCalls).toBe(3);
// 4th request should be blocked
const res = mockRes();
middleware(req as Request, res as unknown as Response, next);
expect(nextCalls).toBe(3); // next not called
expect(res._status).toBe(429);
expect(res._json).toEqual({ error: "Too many requests, please try again later." });
expect(res._headers["Retry-After"]).toBeDefined();
expect(res._headers["RateLimit-Remaining"]).toBe("0");
});
it("tracks different IPs independently", () => {
const next: NextFunction = () => {};
// Exhaust limit for IP A
for (let i = 0; i < 4; i++) {
middleware(mockReq("1.1.1.1") as Request, mockRes() as unknown as Response, next);
}
// IP B should still be allowed
const res = mockRes();
let called = false;
middleware(mockReq("2.2.2.2") as Request, res as unknown as Response, () => { called = true; });
expect(called).toBe(true);
expect(res._headers["RateLimit-Remaining"]).toBe("2");
});
it("resets after window expires", async () => {
// Use a very short window
const shortMiddleware = rateLimit({ windowMs: 50, max: 1 });
const req = mockReq();
let nextCalls = 0;
const next: NextFunction = () => { nextCalls++; };
shortMiddleware(req as Request, mockRes() as unknown as Response, next);
expect(nextCalls).toBe(1);
// Should be blocked
shortMiddleware(req as Request, mockRes() as unknown as Response, next);
expect(nextCalls).toBe(1);
// Wait for window to expire
await new Promise((resolve) => setTimeout(resolve, 60));
// Should be allowed again
shortMiddleware(req as Request, mockRes() as unknown as Response, next);
expect(nextCalls).toBe(2);
});
it("uses custom message", () => {
const mw = rateLimit({ max: 0, message: "Slow down!" });
const res = mockRes();
mw(mockReq() as Request, res as unknown as Response, () => {});
expect(res._json).toEqual({ error: "Slow down!" });
});
});

View File

@@ -0,0 +1,85 @@
import type { Request, Response, NextFunction } from "express";
export interface RateLimitOptions {
/** Time window in milliseconds (default: 60000 = 1 minute) */
windowMs?: number;
/** Max requests per window (default: 100) */
max?: number;
/** Message returned when rate limited */
message?: string;
}
interface ClientRecord {
count: number;
resetTime: number;
}
/**
* In-memory sliding-window rate limiter middleware.
* Tracks requests per IP and returns 429 when the limit is exceeded.
* Adds standard rate-limit headers to every response.
*/
export function rateLimit(options: RateLimitOptions = {}) {
const {
windowMs = 60_000,
max = 100,
message = "Too many requests, please try again later.",
} = options;
const clients = new Map<string, ClientRecord>();
// Periodically clean up expired entries to prevent memory leaks
const cleanup = setInterval(() => {
const now = Date.now();
for (const [key, record] of clients) {
if (now >= record.resetTime) {
clients.delete(key);
}
}
}, windowMs);
// Allow the timer to not keep the process alive
if (cleanup.unref) {
cleanup.unref();
}
return (req: Request, res: Response, next: NextFunction): void => {
const key = req.ip ?? req.socket.remoteAddress ?? "unknown";
const now = Date.now();
let record = clients.get(key);
if (!record || now >= record.resetTime) {
record = { count: 0, resetTime: now + windowMs };
clients.set(key, record);
}
record.count++;
const remaining = Math.max(0, max - record.count);
const resetSeconds = Math.ceil((record.resetTime - now) / 1000);
// Set rate limit headers on every response
res.setHeader("RateLimit-Limit", String(max));
res.setHeader("RateLimit-Remaining", String(remaining));
res.setHeader("RateLimit-Reset", String(resetSeconds));
if (record.count > max) {
res.setHeader("Retry-After", String(resetSeconds));
res.status(429).json({ error: message });
return;
}
next();
};
}
/** Default rate limit configs for different endpoint patterns */
export const RATE_LIMITS = {
/** General API: 100 req/min */
api: { windowMs: 60_000, max: 100 },
/** Mutation endpoints (POST/PUT/PATCH/DELETE): 30 req/min */
mutation: { windowMs: 60_000, max: 30 },
/** SSE connections: 10 per minute */
sse: { windowMs: 60_000, max: 10 },
} as const;

View File

@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url";
import type { TaskStore, MergeResult } from "@hai/core";
import { createApiRoutes } from "./routes.js";
import { createSSE } from "./sse.js";
import { rateLimit, RATE_LIMITS } from "./rate-limit.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -28,8 +29,11 @@ export function createServer(store: TaskStore, options?: ServerOptions) {
app.use(express.static(clientDir));
// SSE endpoint
app.get("/api/events", createSSE(store));
// Rate limiting — stricter limit on SSE connections
app.get("/api/events", rateLimit(RATE_LIMITS.sse), createSSE(store));
// Rate limiting — mutation endpoints (POST/PUT/PATCH/DELETE)
app.use("/api", rateLimit(RATE_LIMITS.api));
// REST API
app.use("/api", createApiRoutes(store, options));