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:
@@ -1,3 +1,5 @@
|
||||
import { Settings } from "lucide-react";
|
||||
|
||||
interface HeaderProps {
|
||||
onOpenSettings?: () => void;
|
||||
}
|
||||
@@ -11,7 +13,7 @@ export function Header({ onOpenSettings }: HeaderProps) {
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<button className="btn-icon" onClick={onOpenSettings} title="Settings">
|
||||
⚙
|
||||
<Settings size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { Link } from "lucide-react";
|
||||
import type { Task, TaskCreateInput } from "@hai/core";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
|
||||
@@ -77,7 +78,7 @@ export function InlineCreateCard({ tasks, onSubmit, onCancel, addToast }: Inline
|
||||
className="btn btn-sm dep-trigger"
|
||||
onClick={() => setShowDeps((v) => !v)}
|
||||
>
|
||||
⛓{dependencies.length > 0 ? ` ${dependencies.length} deps` : " Deps"}
|
||||
<Link size={12} style={{ verticalAlign: 'middle' }} />{dependencies.length > 0 ? ` ${dependencies.length} deps` : " Deps"}
|
||||
</button>
|
||||
{showDeps && (
|
||||
<div className="dep-dropdown">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { Link, Clock } from "lucide-react";
|
||||
import type { Task, TaskDetail, Column } from "@hai/core";
|
||||
import { fetchTaskDetail } from "../api";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
@@ -80,10 +81,10 @@ export function TaskCard({ task, queued, onOpenDetail, addToast }: TaskCardProps
|
||||
<div className="card-meta">
|
||||
{task.dependencies && task.dependencies.length > 0 && (
|
||||
<span className="card-dep-badge">
|
||||
⛓ {task.dependencies.length} dep{task.dependencies.length > 1 ? "s" : ""}
|
||||
<Link size={12} style={{ verticalAlign: 'middle' }} /> {task.dependencies.length} dep{task.dependencies.length > 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
{queued && <span className="queued-badge">⏳ Queued</span>}
|
||||
{queued && <span className="queued-badge"><Clock size={12} style={{ verticalAlign: 'middle' }} /> Queued</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Task, TaskDetail } from "@hai/core";
|
||||
import { ClipboardList, GitBranch } from "lucide-react";
|
||||
import { TaskCard } from "./TaskCard";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
|
||||
@@ -21,7 +22,7 @@ export function WorktreeGroup({
|
||||
<div className="worktree-group">
|
||||
<div className="worktree-group-header">
|
||||
<span className="worktree-icon">
|
||||
{label === "Up Next" || label === "Unassigned" ? "📋" : "🌿"}
|
||||
{label === "Up Next" || label === "Unassigned" ? <ClipboardList size={14} /> : <GitBranch size={14} />}
|
||||
</span>
|
||||
<span className="worktree-label">{label}</span>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"dependencies": {
|
||||
"@hai/core": "workspace:*",
|
||||
"express": "^5.1.0",
|
||||
"lucide-react": "^1.7.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
@@ -23,6 +24,7 @@
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^6.0.0"
|
||||
"vite": "^6.0.0",
|
||||
"vitest": "^4.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
function cardHTML(task) {
|
||||
const deps =
|
||||
task.dependencies && task.dependencies.length
|
||||
? `<div class="card-meta"><span class="card-dep-badge">⛓ ${task.dependencies.length} dep${task.dependencies.length > 1 ? "s" : ""}</span></div>`
|
||||
? `<div class="card-meta"><span class="card-dep-badge">${task.dependencies.length} dep${task.dependencies.length > 1 ? "s" : ""}</span></div>`
|
||||
: "";
|
||||
return `<div class="card" data-id="${task.id}" draggable="true">
|
||||
<span class="card-id">${task.id}</span>
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { createServer, type ServerOptions } from "./server.js";
|
||||
export { rateLimit, RATE_LIMITS, type RateLimitOptions } from "./rate-limit.js";
|
||||
|
||||
117
packages/dashboard/src/rate-limit.test.ts
Normal file
117
packages/dashboard/src/rate-limit.test.ts
Normal 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!" });
|
||||
});
|
||||
});
|
||||
85
packages/dashboard/src/rate-limit.ts
Normal file
85
packages/dashboard/src/rate-limit.ts
Normal 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;
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user