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

@@ -88,6 +88,26 @@ Real-time kanban board at `localhost:4040`:
- Click cards for detail view with move/delete actions
- Server-Sent Events for live updates across tabs
### API Rate Limiting
All API endpoints (`/api/*`) are rate limited to prevent abuse. Limits are applied per client IP:
| Scope | Limit | Window |
|-------|-------|--------|
| General API (`/api/*`) | 100 requests | 1 minute |
| SSE connections (`/api/events`) | 10 connections | 1 minute |
Every API response includes standard rate limit headers:
| Header | Description |
|--------|-------------|
| `RateLimit-Limit` | Maximum requests allowed per window |
| `RateLimit-Remaining` | Requests remaining in the current window |
| `RateLimit-Reset` | Seconds until the rate limit window resets |
| `Retry-After` | Seconds to wait before retrying (only on 429 responses) |
When a client exceeds the limit, the API returns `429 Too Many Requests`.
### AI Engine (`--engine`)
When enabled, three components run:

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>

View File

@@ -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"
}
}

View File

@@ -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>

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));

254
pnpm-lock.yaml generated
View File

@@ -51,6 +51,9 @@ importers:
express:
specifier: ^5.1.0
version: 5.2.1
lucide-react:
specifier: ^1.7.0
version: 1.7.0(react@19.2.4)
react:
specifier: ^19.0.0
version: 19.2.4
@@ -76,6 +79,9 @@ importers:
vite:
specifier: ^6.0.0
version: 6.4.1(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)
vitest:
specifier: ^4.1.1
version: 4.1.1(@types/node@25.5.0)(vite@6.4.1(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3))
packages/engine:
dependencies:
@@ -1130,6 +1136,9 @@ packages:
resolution: {integrity: sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==}
engines: {node: '>=18.0.0'}
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@tokenizer/inflate@0.4.1':
resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==}
engines: {node: '>=18'}
@@ -1155,9 +1164,15 @@ packages:
'@types/body-parser@1.19.6':
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
'@types/connect@3.4.38':
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@@ -1208,6 +1223,35 @@ packages:
peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
'@vitest/expect@4.1.1':
resolution: {integrity: sha512-xAV0fqBTk44Rn6SjJReEQkHP3RrqbJo6JQ4zZ7/uVOiJZRarBtblzrOfFIZeYUrukp2YD6snZG6IBqhOoHTm+A==}
'@vitest/mocker@4.1.1':
resolution: {integrity: sha512-h3BOylsfsCLPeceuCPAAJ+BvNwSENgJa4hXoXu4im0bs9Lyp4URc4JYK4pWLZ4pG/UQn7AT92K6IByi6rE6g3A==}
peerDependencies:
msw: ^2.4.9
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
msw:
optional: true
vite:
optional: true
'@vitest/pretty-format@4.1.1':
resolution: {integrity: sha512-GM+TEQN5WhOygr1lp7skeVjdLPqqWMHsfzXrcHAqZJi/lIVh63H0kaRCY8MDhNWikx19zBUK8ceaLB7X5AH9NQ==}
'@vitest/runner@4.1.1':
resolution: {integrity: sha512-f7+FPy75vN91QGWsITueq0gedwUZy1fLtHOCMeQpjs8jTekAHeKP80zfDEnhrleviLHzVSDXIWuCIOFn3D3f8A==}
'@vitest/snapshot@4.1.1':
resolution: {integrity: sha512-kMVSgcegWV2FibXEx9p9WIKgje58lcTbXgnJixfcg15iK8nzCXhmalL0ZLtTWLW9PH1+1NEDShiFFedB3tEgWg==}
'@vitest/spy@4.1.1':
resolution: {integrity: sha512-6Ti/KT5OVaiupdIZEuZN7l3CZcR0cxnxt70Z0//3CtwgObwA6jZhmVBA3yrXSVN3gmwjgd7oDNLlsXz526gpRA==}
'@vitest/utils@4.1.1':
resolution: {integrity: sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ==}
accepts@2.0.0:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
engines: {node: '>= 0.6'}
@@ -1242,6 +1286,10 @@ packages:
any-promise@1.3.0:
resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
ast-types@0.13.4:
resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==}
engines: {node: '>=4'}
@@ -1302,6 +1350,10 @@ packages:
caniuse-lite@1.0.30001781:
resolution: {integrity: sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==}
chai@6.2.2:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
@@ -1407,6 +1459,9 @@ packages:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'}
es-module-lexer@2.0.0:
resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==}
es-object-atoms@1.1.1:
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
engines: {node: '>= 0.4'}
@@ -1442,6 +1497,9 @@ packages:
resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
engines: {node: '>=4.0'}
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
esutils@2.0.3:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
@@ -1450,6 +1508,10 @@ packages:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'}
expect-type@1.3.0:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
express@5.2.1:
resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
engines: {node: '>= 18'}
@@ -1683,6 +1745,14 @@ packages:
resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==}
engines: {node: '>=12'}
lucide-react@1.7.0:
resolution: {integrity: sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
marked@15.0.12:
resolution: {integrity: sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==}
engines: {node: '>= 18'}
@@ -1755,6 +1825,9 @@ packages:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'}
obug@2.1.1:
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
on-finished@2.4.1:
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
engines: {node: '>= 0.8'}
@@ -1813,6 +1886,9 @@ packages:
path-to-regexp@8.3.0:
resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
pend@1.2.0:
resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
@@ -1941,6 +2017,9 @@ packages:
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
engines: {node: '>= 0.4'}
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
signal-exit@3.0.7:
resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
@@ -1964,6 +2043,9 @@ packages:
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
engines: {node: '>=0.10.0'}
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
statuses@2.0.2:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
@@ -1971,6 +2053,9 @@ packages:
std-env@3.10.0:
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
std-env@4.0.0:
resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==}
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
@@ -2001,10 +2086,21 @@ packages:
thenify@3.3.1:
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
tinyexec@1.0.4:
resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==}
engines: {node: '>=18'}
tinyglobby@0.2.15:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'}
tinyrainbow@3.1.0:
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
engines: {node: '>=14.0.0'}
toidentifier@1.0.1:
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
engines: {node: '>=0.6'}
@@ -2098,10 +2194,50 @@ packages:
yaml:
optional: true
vitest@4.1.1:
resolution: {integrity: sha512-yF+o4POL41rpAzj5KVILUxm1GCjKnELvaqmU9TLLUbMfDzuN0UpUR9uaDs+mCtjPe+uYPksXDRLQGGPvj1cTmA==}
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@opentelemetry/api': ^1.9.0
'@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
'@vitest/browser-playwright': 4.1.1
'@vitest/browser-preview': 4.1.1
'@vitest/browser-webdriverio': 4.1.1
'@vitest/ui': 4.1.1
happy-dom: '*'
jsdom: '*'
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
'@edge-runtime/vm':
optional: true
'@opentelemetry/api':
optional: true
'@types/node':
optional: true
'@vitest/browser-playwright':
optional: true
'@vitest/browser-preview':
optional: true
'@vitest/browser-webdriverio':
optional: true
'@vitest/ui':
optional: true
happy-dom:
optional: true
jsdom:
optional: true
web-streams-polyfill@3.3.3:
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
engines: {node: '>= 8'}
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
hasBin: true
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
@@ -3399,6 +3535,8 @@ snapshots:
dependencies:
tslib: 2.8.1
'@standard-schema/spec@1.1.0': {}
'@tokenizer/inflate@0.4.1':
dependencies:
debug: 4.4.3
@@ -3436,10 +3574,17 @@ snapshots:
'@types/connect': 3.4.38
'@types/node': 25.5.0
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
assertion-error: 2.0.1
'@types/connect@3.4.38':
dependencies:
'@types/node': 25.5.0
'@types/deep-eql@4.0.2': {}
'@types/estree@1.0.8': {}
'@types/express-serve-static-core@5.1.1':
@@ -3503,6 +3648,47 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@vitest/expect@4.1.1':
dependencies:
'@standard-schema/spec': 1.1.0
'@types/chai': 5.2.3
'@vitest/spy': 4.1.1
'@vitest/utils': 4.1.1
chai: 6.2.2
tinyrainbow: 3.1.0
'@vitest/mocker@4.1.1(vite@6.4.1(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
'@vitest/spy': 4.1.1
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 6.4.1(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)
'@vitest/pretty-format@4.1.1':
dependencies:
tinyrainbow: 3.1.0
'@vitest/runner@4.1.1':
dependencies:
'@vitest/utils': 4.1.1
pathe: 2.0.3
'@vitest/snapshot@4.1.1':
dependencies:
'@vitest/pretty-format': 4.1.1
'@vitest/utils': 4.1.1
magic-string: 0.30.21
pathe: 2.0.3
'@vitest/spy@4.1.1': {}
'@vitest/utils@4.1.1':
dependencies:
'@vitest/pretty-format': 4.1.1
convert-source-map: 2.0.0
tinyrainbow: 3.1.0
accepts@2.0.0:
dependencies:
mime-types: 3.0.2
@@ -3531,6 +3717,8 @@ snapshots:
any-promise@1.3.0: {}
assertion-error@2.0.1: {}
ast-types@0.13.4:
dependencies:
tslib: 2.8.1
@@ -3591,6 +3779,8 @@ snapshots:
caniuse-lite@1.0.30001781: {}
chai@6.2.2: {}
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
@@ -3675,6 +3865,8 @@ snapshots:
es-errors@1.3.0: {}
es-module-lexer@2.0.0: {}
es-object-atoms@1.1.1:
dependencies:
es-errors: 1.3.0
@@ -3753,10 +3945,16 @@ snapshots:
estraverse@5.3.0: {}
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.8
esutils@2.0.3: {}
etag@1.8.1: {}
expect-type@1.3.0: {}
express@5.2.1:
dependencies:
accepts: 2.0.0
@@ -4036,6 +4234,14 @@ snapshots:
lru-cache@7.18.3: {}
lucide-react@1.7.0(react@19.2.4):
dependencies:
react: 19.2.4
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
marked@15.0.12: {}
math-intrinsics@1.1.0: {}
@@ -4084,6 +4290,8 @@ snapshots:
object-inspect@1.13.4: {}
obug@2.1.1: {}
on-finished@2.4.1:
dependencies:
ee-first: 1.1.1
@@ -4141,6 +4349,8 @@ snapshots:
path-to-regexp@8.3.0: {}
pathe@2.0.3: {}
pend@1.2.0: {}
picocolors@1.1.1: {}
@@ -4335,6 +4545,8 @@ snapshots:
side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2
siginfo@2.0.0: {}
signal-exit@3.0.7: {}
smart-buffer@4.2.0: {}
@@ -4357,10 +4569,14 @@ snapshots:
source-map@0.6.1:
optional: true
stackback@0.0.2: {}
statuses@2.0.2: {}
std-env@3.10.0: {}
std-env@4.0.0: {}
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
@@ -4393,11 +4609,17 @@ snapshots:
dependencies:
any-promise: 1.3.0
tinybench@2.9.0: {}
tinyexec@1.0.4: {}
tinyglobby@0.2.15:
dependencies:
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
tinyrainbow@3.1.0: {}
toidentifier@1.0.1: {}
token-types@6.1.2:
@@ -4455,8 +4677,40 @@ snapshots:
tsx: 4.21.0
yaml: 2.8.3
vitest@4.1.1(@types/node@25.5.0)(vite@6.4.1(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
'@vitest/expect': 4.1.1
'@vitest/mocker': 4.1.1(vite@6.4.1(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3))
'@vitest/pretty-format': 4.1.1
'@vitest/runner': 4.1.1
'@vitest/snapshot': 4.1.1
'@vitest/spy': 4.1.1
'@vitest/utils': 4.1.1
es-module-lexer: 2.0.0
expect-type: 1.3.0
magic-string: 0.30.21
obug: 2.1.1
pathe: 2.0.3
picomatch: 4.0.4
std-env: 4.0.0
tinybench: 2.9.0
tinyexec: 1.0.4
tinyglobby: 0.2.15
tinyrainbow: 3.1.0
vite: 6.4.1(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 25.5.0
transitivePeerDependencies:
- msw
web-streams-polyfill@3.3.3: {}
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0
stackback: 0.0.2
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0