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:
20
README.md
20
README.md
@@ -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:
|
||||
|
||||
@@ -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));
|
||||
|
||||
254
pnpm-lock.yaml
generated
254
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user