diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..654ccda
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,18 @@
+node_modules
+dist
+.git
+.github
+coverage
+tests
+docs
+scripts
+*.md
+.env
+.env.*
+!.env.example
+.editorconfig
+.prettierrc.json
+.prettierignore
+.gitignore
+eslint.config.js
+vitest.config.ts
diff --git a/.env.example b/.env.example
index 7d6a248..21a8b7e 100644
--- a/.env.example
+++ b/.env.example
@@ -7,6 +7,11 @@ REDIS_URL=redis://localhost:6379
# Prefix used for Redis keys so multiple environments can share one Redis cluster
SERVICE_NAME=arce
+# Optional API key for authenticating protected endpoints.
+# When set, all /check-limit, /consume, and /api/dashboard-data requests
+# must include a matching x-api-key header. When empty, auth is disabled.
+API_KEY=
+
# Baseline limit applied to normal traffic
DEFAULT_LIMIT_PER_MINUTE=100
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ba42d72..7eed1a7 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -13,6 +13,17 @@ jobs:
ci:
runs-on: ubuntu-latest
+ services:
+ redis:
+ image: redis:7-alpine
+ ports:
+ - 6379:6379
+ options: >-
+ --health-cmd "redis-cli ping"
+ --health-interval 5s
+ --health-timeout 3s
+ --health-retries 10
+
steps:
- uses: actions/checkout@v4
@@ -23,4 +34,4 @@ jobs:
- run: npm ci
- run: npm run build
- run: npm run lint
- - run: npm test
\ No newline at end of file
+ - run: npm test
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..8a675dc
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,36 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+## [1.1.0] — 2026-04-29
+
+### Security
+
+- **API key authentication** — protected endpoints (`/check-limit`, `/consume`, `/api/dashboard-data`) now require a valid `x-api-key` header when `API_KEY` is configured. Authentication is disabled when the variable is empty, preserving the local development workflow.
+- **Dashboard XSS fix** — replaced all `innerHTML` usage in the dashboard with safe DOM construction (`textContent` and `createElement`). User-controlled data can no longer execute scripts in the operator's browser.
+- **Input length limits** — `userId` (max 256), `ip` (max 64), and `identifier` (max 256) fields now enforce maximum length to prevent memory exhaustion via oversized Redis keys.
+- **Error handler hardened** — `ZodError` returns 400 with safe validation details. All other errors return a generic 500 without leaking internal messages or stack traces.
+- **Security headers** — all responses now include `X-Content-Type-Options`, `X-Frame-Options`, `X-XSS-Protection`, and `Referrer-Policy`.
+
+### Changed
+
+- **Parallel I/O on hot path** — `recordOutcome` and `recordDecision` now execute via `Promise.all()` instead of sequential awaits, reducing latency by one Redis round-trip.
+- **Graceful shutdown timeout** — the server now force-exits after 10 seconds if `server.close()` hangs on connection draining.
+- **Redis reconnect strategy** — the Redis client now retries with exponential backoff (up to 10 attempts) instead of silently logging errors.
+
+### Added
+
+- **Comprehensive test suite** — added 50+ tests across 7 new test files:
+ - Abuse detector edge cases and threshold boundaries
+ - Schema validation for all `superRefine` rules and field constraints
+ - Identity utility tests (all `resolveSubject` branches, `buildFingerprint` paths)
+ - Hashing determinism and format tests
+ - Time bucketing tests
+ - Express middleware tests (allow, deny, error forwarding)
+- **`.dockerignore`** — excludes dev-only files from the build context.
+- **Docker hardening** — non-root user (`USER node`), `HEALTHCHECK` directive, `npm ci` for reproducible installs, only copies necessary runtime assets.
+- **CI improvements** — added `format:check`, `typecheck`, `npm audit`, Redis service container, and Node 20 + 22 matrix testing.
+
+## [1.0.0] — 2026-04-29
+
+Initial release. Distributed adaptive traffic control with Redis-backed rate limiting, abuse detection, and operator dashboard.
diff --git a/Dockerfile b/Dockerfile
index 34d5459..8f6835d 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,7 +1,7 @@
FROM node:24-alpine AS build
WORKDIR /app
COPY package*.json ./
-RUN npm install
+RUN npm ci
COPY tsconfig.json ./
COPY configs ./configs
COPY apps ./apps
@@ -11,8 +11,16 @@ RUN npm run build
FROM node:24-alpine
WORKDIR /app
COPY package*.json ./
-RUN npm install --omit=dev
+RUN npm ci --omit=dev
+
COPY --from=build /app/dist ./dist
-COPY --from=build /app/apps ./apps
+COPY --from=build /app/apps/dashboard/public ./apps/dashboard/public
+
EXPOSE 4000
+
+USER node
+
+HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
+ CMD wget --no-verbose --tries=1 --spider http://localhost:4000/health || exit 1
+
CMD ["node", "dist/apps/server/main.js"]
diff --git a/README.md b/README.md
index 1bbe555..fa555ef 100644
--- a/README.md
+++ b/README.md
@@ -101,6 +101,7 @@ Additional design notes:
- [docs/architecture.md](./docs/architecture.md)
- [docs/design-decisions.md](./docs/design-decisions.md)
+- [docs/system-diagram.md](./docs/system-diagram.md)
## Design Decisions
@@ -208,6 +209,21 @@ http://localhost:4000/dashboard
If you need non-default settings, use the variables documented in [.env.example](./.env.example).
+## Authentication
+
+When `API_KEY` is set in the environment, ARCE requires all protected endpoints (`/check-limit`, `/consume`, `/api/dashboard-data`) to include a matching `x-api-key` header.
+
+When `API_KEY` is not set, authentication is disabled so local development remains frictionless.
+
+```bash
+curl -X POST http://localhost:4000/consume \
+ -H "Content-Type: application/json" \
+ -H "x-api-key: your-secret-key" \
+ -d '{"algorithm":"token_bucket","route":"/api/orders","method":"GET","ip":"203.0.113.8","scope":"ip"}'
+```
+
+Public endpoints (`/`, `/health`, `/dashboard`, `/static/*`) do not require authentication.
+
## API Example
Check a limit without consuming:
@@ -251,7 +267,8 @@ Example request shape:
import { ArceClient } from "./src/sdk/client";
const client = new ArceClient({
- baseUrl: "http://localhost:4000"
+ baseUrl: "http://localhost:4000",
+ headers: { "x-api-key": process.env.ARCE_API_KEY ?? "" }
});
const decision = await client.consume({
@@ -297,14 +314,15 @@ app.use(
## Configuration
-| Variable | Default | Purpose |
-| ----------------------------- | ------------------------ | ---------------------------------------------------- |
-| `PORT` | `4000` | HTTP port for the ARCE server |
-| `REDIS_URL` | `redis://localhost:6379` | Redis connection string |
-| `SERVICE_NAME` | `arce` | Prefix for Redis keys |
-| `DEFAULT_LIMIT_PER_MINUTE` | `100` | Baseline limit for normal traffic |
-| `SUSPICIOUS_LIMIT_PER_MINUTE` | `20` | Lower bound for suspicious traffic |
-| `BLOCK_DURATION_SECONDS` | `300` | Temporary block duration for critical abuse patterns |
+| Variable | Default | Purpose |
+| ----------------------------- | ------------------------ | ---------------------------------------------------------------- |
+| `PORT` | `4000` | HTTP port for the ARCE server |
+| `REDIS_URL` | `redis://localhost:6379` | Redis connection string |
+| `SERVICE_NAME` | `arce` | Prefix for Redis keys |
+| `API_KEY` | _(empty, auth disabled)_ | API key for protecting `/check-limit`, `/consume`, and dashboard |
+| `DEFAULT_LIMIT_PER_MINUTE` | `100` | Baseline limit for normal traffic |
+| `SUSPICIOUS_LIMIT_PER_MINUTE` | `20` | Lower bound for suspicious traffic |
+| `BLOCK_DURATION_SECONDS` | `300` | Temporary block duration for critical abuse patterns |
## Development
@@ -312,11 +330,13 @@ app.use(
npm run build
npm run typecheck
npm run lint
+npm run format:check
npm run test
npm run smoke
```
See [CONTRIBUTING.md](./CONTRIBUTING.md) for contribution and PR expectations.
+See [CHANGELOG.md](./CHANGELOG.md) for a history of changes.
## Roadmap
diff --git a/apps/dashboard/public/dashboard.js b/apps/dashboard/public/dashboard.js
index cee1ca6..47fcdb0 100644
--- a/apps/dashboard/public/dashboard.js
+++ b/apps/dashboard/public/dashboard.js
@@ -9,6 +9,26 @@ function formatTime(isoString) {
});
}
+function el(tag, className, children) {
+ const element = document.createElement(tag);
+
+ if (className) {
+ element.className = className;
+ }
+
+ if (typeof children === "string") {
+ element.textContent = children;
+ } else if (Array.isArray(children)) {
+ children.forEach(function (child) {
+ element.appendChild(child);
+ });
+ } else if (children instanceof Node) {
+ element.appendChild(children);
+ }
+
+ return element;
+}
+
function renderTotals(data) {
document.getElementById("requests-total").textContent = formatNumber(
data.totals.requests
@@ -26,97 +46,130 @@ function renderTotals(data) {
data.totals.anomalies
);
document.getElementById("last-updated").textContent =
- `Updated ${new Date().toLocaleTimeString()}`;
+ "Updated " + new Date().toLocaleTimeString();
}
function renderChart(series) {
- const container = document.getElementById("traffic-chart");
- const maxRequests = Math.max(...series.map((point) => point.requests), 1);
-
- container.innerHTML = series
- .map((point) => {
- const requestHeight = Math.max(
- 4,
- Math.round((point.requests / maxRequests) * 170)
- );
- const blockedHeight =
- point.blocked > 0
- ? Math.max(3, Math.round((point.blocked / maxRequests) * 36))
- : 0;
-
- return `
-
-
-
-
${formatTime(point.timestamp)}
-
- `;
- })
- .join("");
+ var container = document.getElementById("traffic-chart");
+ var maxRequests = Math.max.apply(
+ null,
+ series
+ .map(function (point) {
+ return point.requests;
+ })
+ .concat([1])
+ );
+
+ container.innerHTML = "";
+
+ series.forEach(function (point) {
+ var requestHeight = Math.max(
+ 4,
+ Math.round((point.requests / maxRequests) * 170)
+ );
+ var blockedHeight =
+ point.blocked > 0
+ ? Math.max(3, Math.round((point.blocked / maxRequests) * 36))
+ : 0;
+
+ var barBlocked = el("div", "bar-blocked");
+ barBlocked.style.height = blockedHeight + "px";
+
+ var barFill = el("div", "bar-fill");
+ barFill.style.height = requestHeight + "px";
+
+ var barLabel = el("div", "bar-label", formatTime(point.timestamp));
+
+ var bar = el("div", "bar", [barBlocked, barFill, barLabel]);
+ bar.title =
+ formatTime(point.timestamp) +
+ " | requests=" +
+ point.requests +
+ ", blocked=" +
+ point.blocked +
+ ", anomalies=" +
+ point.anomalies;
+
+ container.appendChild(bar);
+ });
}
function renderBlocks(blocks) {
- const container = document.getElementById("active-blocks");
+ var container = document.getElementById("active-blocks");
+ container.innerHTML = "";
if (!blocks.length) {
- container.innerHTML =
- 'No active blocks right now.
';
+ container.appendChild(el("div", "empty", "No active blocks right now."));
return;
}
- container.innerHTML = blocks
- .map(
- (block) => `
-
- ${block.subject}
- ${block.reason}
-
- TTL ${(block.ttlMs / 1000).toFixed(0)}s
- Expires ${formatTime(block.expiresAt)}
-
-
- `
- )
- .join("");
+ blocks.forEach(function (block) {
+ var subjectEl = el("strong", null, block.subject);
+ var reasonEl = el("p", null, block.reason);
+ var ttlBadge = el(
+ "span",
+ "badge",
+ "TTL " + (block.ttlMs / 1000).toFixed(0) + "s"
+ );
+ var expiresBadge = el(
+ "span",
+ "badge",
+ "Expires " + formatTime(block.expiresAt)
+ );
+ var badgeRow = el("div", "badge-row", [ttlBadge, expiresBadge]);
+ var article = el("article", "list-item", [subjectEl, reasonEl, badgeRow]);
+
+ container.appendChild(article);
+ });
}
function renderAnomalies(events) {
- const container = document.getElementById("recent-anomalies");
+ var container = document.getElementById("recent-anomalies");
+ container.innerHTML = "";
if (!events.length) {
- container.innerHTML = 'No flagged anomalies yet.
';
+ container.appendChild(el("div", "empty", "No flagged anomalies yet."));
return;
}
- container.innerHTML = events
- .map((event) => {
- const badges = event.anomalies
- .map((anomaly) => `${anomaly.code}`)
- .join("");
-
- return `
-
- ${event.subject}
- ${event.reasons.join(" ")}
-
- Tier ${event.tier}
- Risk ${event.riskScore}
- ${formatTime(event.timestamp)}
- ${badges}
-
-
- `;
- })
- .join("");
+ events.forEach(function (event) {
+ var subjectEl = el("strong", null, event.subject);
+ var reasonEl = el("p", null, event.reasons.join(" "));
+ var tierBadge = el("span", "badge", "Tier " + event.tier);
+ var riskBadge = el("span", "badge", "Risk " + event.riskScore);
+ var timeBadge = el("span", "badge", formatTime(event.timestamp));
+
+ var badges = [tierBadge, riskBadge, timeBadge];
+
+ event.anomalies.forEach(function (anomaly) {
+ badges.push(el("span", "badge", anomaly.code));
+ });
+
+ var badgeRow = el("div", "badge-row", badges);
+ var article = el("article", "list-item", [subjectEl, reasonEl, badgeRow]);
+
+ container.appendChild(article);
+ });
}
async function refresh() {
- const response = await fetch("/api/dashboard-data");
- const data = await response.json();
- renderTotals(data);
- renderChart(data.recentSeries);
- renderBlocks(data.activeBlocks);
- renderAnomalies(data.recentAnomalies);
+ try {
+ var response = await fetch("/api/dashboard-data");
+
+ if (!response.ok) {
+ document.getElementById("last-updated").textContent =
+ "Error: " + response.status;
+ return;
+ }
+
+ var data = await response.json();
+ renderTotals(data);
+ renderChart(data.recentSeries);
+ renderBlocks(data.activeBlocks);
+ renderAnomalies(data.recentAnomalies);
+ } catch {
+ document.getElementById("last-updated").textContent = "Connection error";
+ }
}
refresh();
diff --git a/apps/server/main.ts b/apps/server/main.ts
index bd93e37..6f74403 100644
--- a/apps/server/main.ts
+++ b/apps/server/main.ts
@@ -8,6 +8,8 @@ import { MetricsStore } from "../../src/store/metrics-store";
import { createRedisConnection } from "../../src/store/redis";
import { RedisRateLimiterStore } from "../../src/store/rate-limiter-store";
+const SHUTDOWN_TIMEOUT_MS = 10_000;
+
async function bootstrap(): Promise {
const redis = await createRedisConnection();
const prefix = runtimeConfig.serviceName;
@@ -36,8 +38,18 @@ async function bootstrap(): Promise {
});
const shutdown = (): void => {
+ console.log("Shutting down gracefully...");
+
+ const forceExit = setTimeout(() => {
+ console.error("Graceful shutdown timed out. Forcing exit.");
+ process.exit(1);
+ }, SHUTDOWN_TIMEOUT_MS);
+
+ forceExit.unref();
+
server.close(async () => {
await redis.quit();
+ clearTimeout(forceExit);
process.exit(0);
});
};
diff --git a/configs/runtime.ts b/configs/runtime.ts
index 1fa4d3d..790c607 100644
--- a/configs/runtime.ts
+++ b/configs/runtime.ts
@@ -17,6 +17,7 @@ export const runtimeConfig = {
port: readNumber("PORT", 4000),
redisUrl: process.env.REDIS_URL ?? "redis://localhost:6379",
serviceName: process.env.SERVICE_NAME ?? "arce",
+ apiKey: process.env.API_KEY ?? "",
defaultLimitPerMinute: readNumber("DEFAULT_LIMIT_PER_MINUTE", 100),
suspiciousLimitPerMinute: readNumber("SUSPICIOUS_LIMIT_PER_MINUTE", 20),
blockDurationSeconds: readNumber("BLOCK_DURATION_SECONDS", 300)
diff --git a/docs/system-diagram.md b/docs/system-diagram.md
new file mode 100644
index 0000000..f922973
--- /dev/null
+++ b/docs/system-diagram.md
@@ -0,0 +1,233 @@
+# ARCE System Diagram
+
+This document translates the current codebase into diagrams that are useful for design reviews, demos, and onboarding.
+
+It is based on the implementation in:
+
+- `apps/server/main.ts`
+- `src/api/*`
+- `src/core/*`
+- `src/store/*`
+- `src/sdk/*`
+- `apps/dashboard/public/*`
+- `configs/*`
+- `.github/workflows/ci.yml`
+
+## 1. End-to-End System Overview
+
+```mermaid
+flowchart TB
+ subgraph Actors["External Actors"]
+ ApiClients["API clients"]
+ HostApps["Node/Express apps using ARCE"]
+ Operators["Operators using the dashboard"]
+ Developers["Developers and CI"]
+ end
+
+ subgraph Integration["Integration Surface"]
+ SDK["ArceClient
src/sdk/client.ts"]
+ Middleware["createArceMiddleware
src/sdk/express-middleware.ts"]
+ end
+
+ subgraph Runtime["ARCE Server Runtime"]
+ Bootstrap["Bootstrap
apps/server/main.ts"]
+ App["Express app
src/api/app.ts"]
+ Root["GET /"]
+ Health["GET /health"]
+ Check["POST /check-limit"]
+ Consume["POST /consume"]
+ Dashboard["GET /dashboard"]
+ DashboardData["GET /api/dashboard-data"]
+ StaticAssets["Dashboard static files
apps/dashboard/public/*"]
+ Schema["Zod request validation
src/api/schemas.ts"]
+ Service["LimiterService"]
+ Detector["AbuseDetector
analyzeBehavior()"]
+ Policy["AdaptivePolicy
buildEffectivePolicy()"]
+ BehaviorStore["BehaviorStore"]
+ LimiterStore["RedisRateLimiterStore"]
+ MetricsStore["MetricsStore"]
+ Snapshot["getDashboardSnapshot()"]
+ end
+
+ subgraph RedisLayer["Redis Shared State"]
+ RedisServer["Redis 7 server"]
+ LimitState["Limiter state
arce:limit:*"]
+ BehaviorCounters["Behavior counters
req10s / fingerprint / route / missing-ua"]
+ DenialHistory["Denial history
arce:behavior:denials:*"]
+ BlockState["Block TTLs and indexes
arce:block:* / arce:blocks*"]
+ MetricState["Metric totals and minute buckets
arce:metrics:*"]
+ RecentAnomalyState["Recent anomaly feed"]
+ end
+
+ subgraph Tooling["Configuration, Delivery, and Validation"]
+ Env[".env + configs/runtime.ts"]
+ Compose["docker-compose.yml"]
+ Docker["Dockerfile"]
+ Tests["Vitest / Supertest / smoke tests"]
+ CI["GitHub Actions CI"]
+ Dist["Compiled dist/* output"]
+ end
+
+ ApiClients -->|direct HTTP| App
+ ApiClients -->|Node integration| SDK
+ HostApps --> Middleware
+ Middleware -->|build payload + consume| SDK
+ SDK -->|POST JSON| App
+
+ Operators -->|open page| Dashboard
+ Operators -->|poll data| DashboardData
+
+ Developers -->|npm run dev / start| Bootstrap
+ Developers -->|npm test / smoke| Tests
+ CI -->|npm ci, build, lint, test| Tests
+ Docker --> Dist
+ Dist --> Bootstrap
+ Compose --> RedisServer
+ Env --> Bootstrap
+
+ Bootstrap --> App
+ Bootstrap --> BehaviorStore
+ Bootstrap --> LimiterStore
+ Bootstrap --> MetricsStore
+ Bootstrap --> RedisServer
+
+ App --> Root
+ App --> Health
+ App --> Check
+ App --> Consume
+ App --> Dashboard
+ App --> DashboardData
+ Dashboard --> StaticAssets
+
+ Check --> Schema
+ Consume --> Schema
+ Schema --> Service
+
+ Service --> BehaviorStore
+ BehaviorStore --> RedisServer
+ RedisServer --> BehaviorCounters
+ RedisServer --> DenialHistory
+ RedisServer --> BlockState
+
+ Service --> Detector
+ Detector --> Policy
+ Policy -->|tier and limit| Service
+ Policy -->|blocked tier| BehaviorStore
+
+ Service --> LimiterStore
+ LimiterStore -->|Lua enforcement| RedisServer
+ RedisServer --> LimitState
+
+ Service --> MetricsStore
+ MetricsStore --> RedisServer
+ RedisServer --> MetricState
+ RedisServer --> RecentAnomalyState
+
+ DashboardData --> Snapshot
+ Snapshot --> MetricsStore
+ Snapshot --> BehaviorStore
+```
+
+## 2. Request Lifecycle
+
+This sequence shows the hot path for `POST /consume`.
+
+```mermaid
+sequenceDiagram
+ participant Caller as Caller
+ participant Client as ArceClient or direct HTTP client
+ participant API as limit handler
+ participant Schema as Zod schema
+ participant Service as LimiterService
+ participant Behavior as BehaviorStore
+ participant Redis as Redis
+ participant Detector as AbuseDetector
+ participant Policy as AdaptivePolicy
+ participant Limiter as RedisRateLimiterStore
+ participant Metrics as MetricsStore
+
+ Caller->>Client: send subject + route + algorithm
+ Client->>API: POST /consume
+ API->>Schema: validate request body
+ Schema-->>API: normalized payload
+ API->>Service: evaluate(payload, true)
+
+ Service->>Behavior: observe(subject, event)
+ Behavior->>Redis: update counters and read recent state
+ Redis-->>Behavior: behavior snapshot
+ Behavior-->>Service: recent behavior
+
+ Service->>Detector: analyzeBehavior(snapshot)
+ Detector-->>Service: risk score + anomalies
+ Service->>Policy: buildEffectivePolicy(...)
+ Policy-->>Service: tier + effective limit
+
+ alt active block already exists
+ Service->>Behavior: recordOutcome(denied)
+ else blocked tier recommended
+ Service->>Behavior: registerBlock(subject, reason, ttl)
+ Service->>Behavior: recordOutcome(denied)
+ else enforce adaptive limit
+ Service->>Limiter: evaluate(subject, algorithm, policy, cost, consume)
+ Limiter->>Redis: execute Lua script atomically
+ Redis-->>Limiter: allowed, remaining, retry, reset
+ Limiter-->>Service: limiter decision
+ Service->>Behavior: recordOutcome(allowed or denied)
+ end
+
+ Service->>Metrics: recordDecision(result)
+ Metrics->>Redis: update totals, minute buckets, anomaly feed
+ Service-->>API: EnforcementResult
+ API-->>Caller: 200 or 429 JSON
+```
+
+## 3. Redis Responsibility Map
+
+This diagram shows how Redis is partitioned by responsibility instead of treating every key as one blob of rate-limiter state.
+
+```mermaid
+flowchart LR
+ BehaviorStore["BehaviorStore"] --> Req10s["arce:behavior:req10s:[subject]
10-second volume buckets"]
+ BehaviorStore --> Fingerprints["arce:behavior:fingerprint:[subject]
per-minute duplicate fingerprints"]
+ BehaviorStore --> Routes["arce:behavior:route:[subject]
per-minute route fan-out"]
+ BehaviorStore --> Denials["arce:behavior:denials:[subject]
recent denials"]
+ BehaviorStore --> MissingUa["arce:behavior:missing-ua:[subject]
missing user-agent counts"]
+ BehaviorStore --> LiveBlock["arce:block:[subject]
temporary block TTL"]
+ BehaviorStore --> BlockIndex["arce:blocks and arce:blocks:meta
active block index and metadata"]
+
+ LimiterStore["RedisRateLimiterStore"] --> Token["token bucket state
tokens + updatedAt"]
+ LimiterStore --> Leaky["leaky bucket state
level + updatedAt"]
+ LimiterStore --> Sliding["sliding window state
sorted set of request timestamps"]
+
+ MetricsStore["MetricsStore"] --> Totals["arce:metrics:totals
global counters"]
+ MetricsStore --> Series["arce:metrics:req / blocked / rate-limited / anomalies
minute series"]
+ MetricsStore --> Recent["arce:metrics:recent-anomalies
dashboard event feed"]
+```
+
+## 4. Build, Run, and Verify Path
+
+```mermaid
+flowchart LR
+ Source["TypeScript source
apps / src / configs"] --> Build["npm run build
tsc"]
+ Build --> Dist["dist/* compiled JavaScript"]
+ Dist --> Start["node dist/apps/server/main.js"]
+ Start --> Runtime["ARCE server process"]
+ Redis["redis:7 via docker-compose"] --> Runtime
+ Env[".env settings"] --> Runtime
+
+ Dev["Developer"] -->|npm run dev| Runtime
+ Dev -->|npm test| TestSuite["Vitest + Supertest"]
+ Dev -->|npm run smoke| Smoke["scripts/smoke-test.mjs"]
+
+ CI[".github/workflows/ci.yml"] --> Install["npm ci"]
+ Install --> CIBuild["npm run build"]
+ CIBuild --> Lint["npm run lint"]
+ Lint --> CITest["npm test"]
+```
+
+## Reading Guide
+
+- Use diagram 1 when you need the full system picture.
+- Use diagram 2 when you need to explain exactly how one rate-limit decision is produced.
+- Use diagram 3 when you need to reason about Redis data ownership and lifecycle.
+- Use diagram 4 when you need to explain developer workflow, packaging, and CI.
diff --git a/package.json b/package.json
index 18045d6..c9f55af 100644
--- a/package.json
+++ b/package.json
@@ -27,7 +27,7 @@
"traffic-control",
"express"
],
- "author": "",
+ "author": "ARCE contributors",
"license": "MIT",
"engines": {
"node": ">=20"
diff --git a/src/api/app.ts b/src/api/app.ts
index bcbd244..5c05797 100644
--- a/src/api/app.ts
+++ b/src/api/app.ts
@@ -6,6 +6,8 @@ import express, {
} from "express";
import path from "node:path";
+import { ZodError } from "zod";
+
import {
createDashboardDataHandler,
createDashboardPageHandler
@@ -13,6 +15,7 @@ import {
import { createHealthHandler } from "./handlers/health";
import { createLimitHandler } from "./handlers/limit";
import { createRootHandler } from "./handlers/root";
+import { createAuthMiddleware } from "./middleware/auth";
import { LimiterService } from "../core/limiter-service";
import type { RedisClient } from "../store/redis";
@@ -25,20 +28,40 @@ interface CreateServerAppArgs {
export function createServerApp(args: CreateServerAppArgs): Express {
const app = express();
const staticDir = path.resolve(args.dashboardPublicDir);
+ const auth = createAuthMiddleware();
app.disable("x-powered-by");
+
+ // Security headers
+ app.use((_request, response, next) => {
+ response.setHeader("X-Content-Type-Options", "nosniff");
+ response.setHeader("X-Frame-Options", "DENY");
+ response.setHeader("X-XSS-Protection", "1; mode=block");
+ response.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
+ next();
+ });
+
app.use(express.json({ limit: "1mb" }));
app.use("/static", express.static(staticDir));
+ // Public endpoints
app.get("/", createRootHandler());
app.get("/health", createHealthHandler(args.redis));
- app.post("/check-limit", createLimitHandler(args.limiterService, false));
- app.post("/consume", createLimitHandler(args.limiterService, true));
+ app.get("/dashboard", createDashboardPageHandler(staticDir));
+
+ // Protected endpoints
+ app.post(
+ "/check-limit",
+ auth,
+ createLimitHandler(args.limiterService, false)
+ );
+ app.post("/consume", auth, createLimitHandler(args.limiterService, true));
app.get(
"/api/dashboard-data",
+ auth,
createDashboardDataHandler(args.limiterService)
);
- app.get("/dashboard", createDashboardPageHandler(staticDir));
+
app.use((_request, response) => {
response.status(404).json({
error: "Route not found"
@@ -52,13 +75,21 @@ export function createServerApp(args: CreateServerAppArgs): Express {
response: Response,
_next: NextFunction
) => {
- if (error instanceof Error) {
+ if (error instanceof ZodError) {
response.status(400).json({
- error: error.message
+ error: "Validation failed",
+ issues: error.issues.map((issue) => ({
+ path: issue.path.join("."),
+ message: issue.message
+ }))
});
return;
}
+ if (error instanceof Error) {
+ console.error("Unhandled error:", error);
+ }
+
response.status(500).json({
error: "Unexpected server error"
});
diff --git a/src/api/middleware/auth.ts b/src/api/middleware/auth.ts
new file mode 100644
index 0000000..86acb3e
--- /dev/null
+++ b/src/api/middleware/auth.ts
@@ -0,0 +1,32 @@
+import type { RequestHandler } from "express";
+
+import { runtimeConfig } from "../../../configs/runtime";
+
+/**
+ * API key authentication middleware.
+ *
+ * When `API_KEY` is set in the environment, all protected endpoints require
+ * a matching `x-api-key` header. When `API_KEY` is not set, authentication
+ * is disabled so local development remains frictionless.
+ */
+export function createAuthMiddleware(): RequestHandler {
+ return (request, response, next) => {
+ const configuredKey = runtimeConfig.apiKey;
+
+ if (!configuredKey) {
+ next();
+ return;
+ }
+
+ const provided = request.header("x-api-key");
+
+ if (!provided || provided !== configuredKey) {
+ response.status(401).json({
+ error: "Unauthorized. Provide a valid x-api-key header."
+ });
+ return;
+ }
+
+ next();
+ };
+}
diff --git a/src/api/schemas.ts b/src/api/schemas.ts
index 8e671fd..0f8ec20 100644
--- a/src/api/schemas.ts
+++ b/src/api/schemas.ts
@@ -5,9 +5,9 @@ export const limitRequestSchema = z
algorithm: z.enum(["token_bucket", "sliding_window", "leaky_bucket"]),
route: z.string().trim().min(1).default("/"),
method: z.string().trim().min(1).default("GET"),
- userId: z.string().trim().min(1).optional(),
- ip: z.string().trim().min(1).optional(),
- identifier: z.string().trim().min(1).optional(),
+ userId: z.string().trim().min(1).max(256).optional(),
+ ip: z.string().trim().min(1).max(64).optional(),
+ identifier: z.string().trim().min(1).max(256).optional(),
scope: z.enum(["user", "ip", "hybrid", "custom"]).optional(),
fingerprint: z.string().trim().min(1).max(256).optional(),
cost: z.number().int().min(1).max(10).default(1),
diff --git a/src/core/limiter-service.ts b/src/core/limiter-service.ts
index 2020992..afe375d 100644
--- a/src/core/limiter-service.ts
+++ b/src/core/limiter-service.ts
@@ -124,12 +124,14 @@ export class LimiterService {
};
}
- await this.behaviorStore.recordOutcome(subject, {
- allowed: result.allowed,
- blocked: result.blocked,
- timestampMs: nowMs
- });
- await this.metricsStore.recordDecision(result);
+ await Promise.all([
+ this.behaviorStore.recordOutcome(subject, {
+ allowed: result.allowed,
+ blocked: result.blocked,
+ timestampMs: nowMs
+ }),
+ this.metricsStore.recordDecision(result)
+ ]);
return result;
}
diff --git a/src/store/redis.ts b/src/store/redis.ts
index 77c4457..5fa1308 100644
--- a/src/store/redis.ts
+++ b/src/store/redis.ts
@@ -6,7 +6,23 @@ export type RedisClient = ReturnType;
export async function createRedisConnection(): Promise {
const client = createClient({
- url: runtimeConfig.redisUrl
+ url: runtimeConfig.redisUrl,
+ socket: {
+ reconnectStrategy(retries: number) {
+ if (retries > 10) {
+ console.error(
+ `Redis reconnection failed after ${retries} attempts. Giving up.`
+ );
+ return new Error("Redis reconnection limit reached.");
+ }
+
+ const delay = Math.min(retries * 200, 5_000);
+ console.warn(
+ `Redis connection lost. Retrying in ${delay}ms (attempt ${retries})...`
+ );
+ return delay;
+ }
+ }
});
client.on("error", (error) => {
diff --git a/tests/unit/abuse-detector.test.ts b/tests/unit/abuse-detector.test.ts
new file mode 100644
index 0000000..99888c5
--- /dev/null
+++ b/tests/unit/abuse-detector.test.ts
@@ -0,0 +1,274 @@
+import { describe, expect, it } from "vitest";
+
+import { analyzeBehavior } from "../../src/core/abuse-detector";
+import type { BehaviorSnapshot } from "../../src/types";
+
+function baseline(): BehaviorSnapshot {
+ return {
+ recentTenSecondCount: 0,
+ trailingMinuteCount: 0,
+ averageTenSecondCount: 0,
+ duplicateFingerprintCount: 0,
+ duplicateFingerprintRatio: 0,
+ uniqueRouteCount: 0,
+ deniedLastFiveMinutes: 0,
+ missingUserAgentCount: 0,
+ activeBlock: null
+ };
+}
+
+describe("analyzeBehavior", () => {
+ describe("burst spike detection", () => {
+ it("does not flag when below thresholds", () => {
+ const snapshot = {
+ ...baseline(),
+ recentTenSecondCount: 14,
+ averageTenSecondCount: 5
+ };
+ const result = analyzeBehavior(snapshot);
+ expect(
+ result.anomalies.find((a) => a.code === "burst_spike")
+ ).toBeUndefined();
+ });
+
+ it("does not flag high count with low ratio", () => {
+ const snapshot = {
+ ...baseline(),
+ recentTenSecondCount: 15,
+ averageTenSecondCount: 10
+ };
+ const result = analyzeBehavior(snapshot);
+ expect(
+ result.anomalies.find((a) => a.code === "burst_spike")
+ ).toBeUndefined();
+ });
+
+ it("flags when count >= 15 and ratio >= 3", () => {
+ const snapshot = {
+ ...baseline(),
+ recentTenSecondCount: 15,
+ averageTenSecondCount: 5
+ };
+ const result = analyzeBehavior(snapshot);
+ const anomaly = result.anomalies.find((a) => a.code === "burst_spike");
+ expect(anomaly).toBeDefined();
+ expect(anomaly!.severity).toBe("high");
+ expect(result.riskScore).toBe(35);
+ });
+
+ it("uses raw count as ratio when average is zero", () => {
+ const snapshot = {
+ ...baseline(),
+ recentTenSecondCount: 15,
+ averageTenSecondCount: 0
+ };
+ const result = analyzeBehavior(snapshot);
+ expect(
+ result.anomalies.find((a) => a.code === "burst_spike")
+ ).toBeDefined();
+ });
+ });
+
+ describe("repeated identical requests", () => {
+ it("does not flag when below thresholds", () => {
+ const snapshot = {
+ ...baseline(),
+ duplicateFingerprintCount: 7,
+ duplicateFingerprintRatio: 0.8
+ };
+ const result = analyzeBehavior(snapshot);
+ expect(
+ result.anomalies.find((a) => a.code === "repeated_identical_requests")
+ ).toBeUndefined();
+ });
+
+ it("does not flag high count with low ratio", () => {
+ const snapshot = {
+ ...baseline(),
+ duplicateFingerprintCount: 10,
+ duplicateFingerprintRatio: 0.6
+ };
+ const result = analyzeBehavior(snapshot);
+ expect(
+ result.anomalies.find((a) => a.code === "repeated_identical_requests")
+ ).toBeUndefined();
+ });
+
+ it("flags when count >= 8 and ratio >= 0.7", () => {
+ const snapshot = {
+ ...baseline(),
+ duplicateFingerprintCount: 8,
+ duplicateFingerprintRatio: 0.7
+ };
+ const result = analyzeBehavior(snapshot);
+ const anomaly = result.anomalies.find(
+ (a) => a.code === "repeated_identical_requests"
+ );
+ expect(anomaly).toBeDefined();
+ expect(anomaly!.severity).toBe("high");
+ expect(result.riskScore).toBe(30);
+ });
+ });
+
+ describe("wide route scan", () => {
+ it("does not flag few routes", () => {
+ const snapshot = {
+ ...baseline(),
+ uniqueRouteCount: 11,
+ trailingMinuteCount: 40
+ };
+ const result = analyzeBehavior(snapshot);
+ expect(
+ result.anomalies.find((a) => a.code === "wide_route_scan")
+ ).toBeUndefined();
+ });
+
+ it("does not flag many routes with low traffic", () => {
+ const snapshot = {
+ ...baseline(),
+ uniqueRouteCount: 15,
+ trailingMinuteCount: 20
+ };
+ const result = analyzeBehavior(snapshot);
+ expect(
+ result.anomalies.find((a) => a.code === "wide_route_scan")
+ ).toBeUndefined();
+ });
+
+ it("flags when routes >= 12 and trailing count >= 30", () => {
+ const snapshot = {
+ ...baseline(),
+ uniqueRouteCount: 12,
+ trailingMinuteCount: 30
+ };
+ const result = analyzeBehavior(snapshot);
+ const anomaly = result.anomalies.find(
+ (a) => a.code === "wide_route_scan"
+ );
+ expect(anomaly).toBeDefined();
+ expect(anomaly!.severity).toBe("medium");
+ expect(result.riskScore).toBe(25);
+ });
+ });
+
+ describe("repeated denials", () => {
+ it("does not flag below threshold", () => {
+ const snapshot = { ...baseline(), deniedLastFiveMinutes: 4 };
+ const result = analyzeBehavior(snapshot);
+ expect(
+ result.anomalies.find((a) => a.code === "repeated_denials")
+ ).toBeUndefined();
+ });
+
+ it("flags when denied >= 5", () => {
+ const snapshot = { ...baseline(), deniedLastFiveMinutes: 5 };
+ const result = analyzeBehavior(snapshot);
+ const anomaly = result.anomalies.find(
+ (a) => a.code === "repeated_denials"
+ );
+ expect(anomaly).toBeDefined();
+ expect(anomaly!.severity).toBe("medium");
+ expect(result.riskScore).toBe(15);
+ });
+ });
+
+ describe("missing user agent", () => {
+ it("does not flag low volume", () => {
+ const snapshot = {
+ ...baseline(),
+ missingUserAgentCount: 15,
+ recentTenSecondCount: 9
+ };
+ const result = analyzeBehavior(snapshot);
+ expect(
+ result.anomalies.find((a) => a.code === "missing_user_agent")
+ ).toBeUndefined();
+ });
+
+ it("flags when count >= 10 and recent traffic >= 10", () => {
+ const snapshot = {
+ ...baseline(),
+ missingUserAgentCount: 10,
+ recentTenSecondCount: 10
+ };
+ const result = analyzeBehavior(snapshot);
+ const anomaly = result.anomalies.find(
+ (a) => a.code === "missing_user_agent"
+ );
+ expect(anomaly).toBeDefined();
+ expect(anomaly!.severity).toBe("low");
+ expect(result.riskScore).toBe(10);
+ });
+ });
+
+ describe("active block override", () => {
+ it("overrides risk score to at least 90 when an active block exists", () => {
+ const snapshot = {
+ ...baseline(),
+ activeBlock: {
+ reason: "test block",
+ ttlMs: 60_000,
+ expiresAtMs: Date.now() + 60_000
+ }
+ };
+ const result = analyzeBehavior(snapshot);
+ expect(result.riskScore).toBeGreaterThanOrEqual(90);
+ expect(result.reasons).toContain(
+ "An active temporary block is already in effect."
+ );
+ });
+ });
+
+ describe("recommended block", () => {
+ it("does not recommend block below 75", () => {
+ const snapshot = {
+ ...baseline(),
+ recentTenSecondCount: 15,
+ averageTenSecondCount: 5,
+ duplicateFingerprintCount: 8,
+ duplicateFingerprintRatio: 0.7
+ };
+ const result = analyzeBehavior(snapshot);
+ // 35 + 30 = 65
+ expect(result.riskScore).toBe(65);
+ expect(result.recommendedBlock).toBe(false);
+ });
+
+ it("recommends block at 75 or above", () => {
+ const snapshot = {
+ ...baseline(),
+ recentTenSecondCount: 15,
+ averageTenSecondCount: 5,
+ duplicateFingerprintCount: 8,
+ duplicateFingerprintRatio: 0.7,
+ uniqueRouteCount: 12,
+ trailingMinuteCount: 30
+ };
+ const result = analyzeBehavior(snapshot);
+ // 35 + 30 + 25 = 90
+ expect(result.riskScore).toBe(90);
+ expect(result.recommendedBlock).toBe(true);
+ });
+ });
+
+ describe("cumulative scoring", () => {
+ it("accumulates risk from multiple signals", () => {
+ const snapshot = {
+ ...baseline(),
+ recentTenSecondCount: 20,
+ averageTenSecondCount: 5,
+ duplicateFingerprintCount: 10,
+ duplicateFingerprintRatio: 0.8,
+ uniqueRouteCount: 14,
+ trailingMinuteCount: 40,
+ deniedLastFiveMinutes: 8,
+ missingUserAgentCount: 12
+ };
+ const result = analyzeBehavior(snapshot);
+ // 35 + 30 + 25 + 15 + 10 = 115
+ expect(result.riskScore).toBe(115);
+ expect(result.anomalies).toHaveLength(5);
+ expect(result.recommendedBlock).toBe(true);
+ });
+ });
+});
diff --git a/tests/unit/express-middleware.test.ts b/tests/unit/express-middleware.test.ts
new file mode 100644
index 0000000..c0c3a5f
--- /dev/null
+++ b/tests/unit/express-middleware.test.ts
@@ -0,0 +1,123 @@
+import { describe, expect, it, vi } from "vitest";
+
+import { createArceMiddleware } from "../../src/sdk/express-middleware";
+import type { ArceClient } from "../../src/sdk/client";
+import type { EnforcementResult } from "../../src/types";
+import type { Request, Response } from "express";
+
+function mockResult(allowed: boolean): EnforcementResult {
+ return {
+ mode: "consume",
+ allowed,
+ blocked: !allowed,
+ subject: "ip:10.0.0.1",
+ fingerprint: "GET:/test",
+ algorithm: "token_bucket",
+ cost: 1,
+ anomalies: [],
+ behavior: {
+ recentTenSecondCount: 1,
+ trailingMinuteCount: 1,
+ averageTenSecondCount: 0,
+ duplicateFingerprintCount: 0,
+ duplicateFingerprintRatio: 0,
+ uniqueRouteCount: 1,
+ deniedLastFiveMinutes: 0,
+ missingUserAgentCount: 0,
+ activeBlock: null
+ },
+ effectivePolicy: {
+ algorithm: "token_bucket",
+ tier: "normal",
+ baseLimitPerMinute: 100,
+ effectiveLimitPerMinute: 100,
+ capacity: 100,
+ windowMs: 60_000,
+ refillTokensPerMs: 100 / 60_000,
+ leakRatePerMs: 100 / 60_000,
+ riskScore: 0,
+ reasons: [],
+ blockDurationSeconds: 0
+ },
+ decision: { allowed, remaining: 99, retryAfterMs: 0, resetAfterMs: 60_000 },
+ evaluatedAt: new Date().toISOString()
+ };
+}
+
+function mockRequest(overrides: Partial = {}): Request {
+ return {
+ originalUrl: "/test",
+ method: "GET",
+ ip: "10.0.0.1",
+ headers: { "user-agent": "test-agent" },
+ header: vi.fn().mockReturnValue(undefined),
+ socket: { remoteAddress: "10.0.0.1" },
+ ...overrides
+ } as unknown as Request;
+}
+
+function mockResponse(): Response & {
+ _status: number;
+ _json: unknown;
+ _headers: Record;
+} {
+ const res = {
+ _status: 200,
+ _json: null,
+ _headers: {} as Record,
+ status(code: number) {
+ res._status = code;
+ return res;
+ },
+ json(data: unknown) {
+ res._json = data;
+ return res;
+ },
+ setHeader(key: string, value: string) {
+ res._headers[key] = value;
+ return res;
+ }
+ };
+ return res as never;
+}
+
+describe("createArceMiddleware", () => {
+ it("calls next() on allowed request", async () => {
+ const consume = vi.fn().mockResolvedValue(mockResult(true));
+ const client = { consume } as unknown as ArceClient;
+ const middleware = createArceMiddleware({ client });
+ const req = mockRequest();
+ const res = mockResponse();
+ const next = vi.fn();
+
+ await (middleware as (...args: unknown[]) => Promise)(req, res, next);
+ expect(next).toHaveBeenCalled();
+ expect(res._headers["x-rate-limit-remaining"]).toBe("99");
+ });
+
+ it("returns 429 on denied request", async () => {
+ const consume = vi.fn().mockResolvedValue(mockResult(false));
+ const client = { consume } as unknown as ArceClient;
+ const middleware = createArceMiddleware({ client });
+ const req = mockRequest();
+ const res = mockResponse();
+ const next = vi.fn();
+
+ await (middleware as (...args: unknown[]) => Promise)(req, res, next);
+ expect(next).not.toHaveBeenCalled();
+ expect(res._status).toBe(429);
+ });
+
+ it("forwards errors to next()", async () => {
+ const error = new Error("connection failed");
+ const consume = vi.fn().mockRejectedValue(error);
+ const client = { consume } as unknown as ArceClient;
+ const middleware = createArceMiddleware({ client });
+ const req = mockRequest();
+ const res = mockResponse();
+ const next = vi.fn();
+
+ await (middleware as (...args: unknown[]) => Promise)(req, res, next);
+ expect(next).toHaveBeenCalledWith(error);
+ });
+});
diff --git a/tests/unit/hashing.test.ts b/tests/unit/hashing.test.ts
new file mode 100644
index 0000000..7916e96
--- /dev/null
+++ b/tests/unit/hashing.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from "vitest";
+
+import { hashValue } from "../../src/utils/hashing";
+
+describe("hashValue", () => {
+ it("returns a 16-character hex string", () => {
+ const result = hashValue("test-input");
+ expect(result).toHaveLength(16);
+ expect(result).toMatch(/^[0-9a-f]{16}$/);
+ });
+
+ it("returns deterministic output for the same input", () => {
+ const first = hashValue("hello");
+ const second = hashValue("hello");
+ expect(first).toBe(second);
+ });
+
+ it("returns different output for different inputs", () => {
+ const a = hashValue("input-a");
+ const b = hashValue("input-b");
+ expect(a).not.toBe(b);
+ });
+
+ it("handles empty string", () => {
+ const result = hashValue("");
+ expect(result).toHaveLength(16);
+ expect(result).toMatch(/^[0-9a-f]{16}$/);
+ });
+});
diff --git a/tests/unit/identity.test.ts b/tests/unit/identity.test.ts
new file mode 100644
index 0000000..1068a97
--- /dev/null
+++ b/tests/unit/identity.test.ts
@@ -0,0 +1,85 @@
+import { describe, expect, it } from "vitest";
+
+import { resolveSubject, buildFingerprint } from "../../src/utils/identity";
+import type { LimitRequestPayload } from "../../src/types";
+
+function basePayload(
+ overrides: Partial = {}
+): LimitRequestPayload {
+ return {
+ algorithm: "token_bucket",
+ route: "/test",
+ method: "GET",
+ ...overrides
+ };
+}
+
+describe("resolveSubject", () => {
+ it("returns hybrid key when scope is hybrid", () => {
+ const result = resolveSubject(
+ basePayload({ scope: "hybrid", userId: "u1", ip: "1.2.3.4" })
+ );
+ expect(result).toBe("hybrid:user:u1:ip:1.2.3.4");
+ });
+
+ it("returns user key when scope is user", () => {
+ const result = resolveSubject(basePayload({ scope: "user", userId: "u1" }));
+ expect(result).toBe("user:u1");
+ });
+
+ it("returns ip key when scope is ip", () => {
+ const result = resolveSubject(basePayload({ scope: "ip", ip: "10.0.0.1" }));
+ expect(result).toBe("ip:10.0.0.1");
+ });
+
+ it("returns custom key when identifier is provided", () => {
+ const result = resolveSubject(basePayload({ identifier: "device-abc" }));
+ expect(result).toBe("custom:device-abc");
+ });
+
+ it("falls back to userId when no scope is set", () => {
+ const result = resolveSubject(basePayload({ userId: "u1" }));
+ expect(result).toBe("user:u1");
+ });
+
+ it("falls back to ip when no scope and no userId", () => {
+ const result = resolveSubject(basePayload({ ip: "10.0.0.1" }));
+ expect(result).toBe("ip:10.0.0.1");
+ });
+
+ it("throws when no identifier can be derived", () => {
+ expect(() => resolveSubject(basePayload())).toThrow(
+ "Unable to derive a subject key"
+ );
+ });
+});
+
+describe("buildFingerprint", () => {
+ it("uses the provided fingerprint when available", () => {
+ const result = buildFingerprint(
+ basePayload({ fingerprint: "GET:/api?q=1" })
+ );
+ expect(result).toBe("GET:/api?q=1");
+ });
+
+ it("trims whitespace from provided fingerprint", () => {
+ const result = buildFingerprint(
+ basePayload({ fingerprint: " GET:/api " })
+ );
+ expect(result).toBe("GET:/api");
+ });
+
+ it("builds a default fingerprint from method and route", () => {
+ const result = buildFingerprint(
+ basePayload({ method: "POST", route: "/orders" })
+ );
+ expect(result).toBe("POST:/orders");
+ });
+
+ it("uppercases the method in default fingerprint", () => {
+ const result = buildFingerprint(
+ basePayload({ method: "post", route: "/orders" })
+ );
+ expect(result).toBe("POST:/orders");
+ });
+});
diff --git a/tests/unit/schemas.test.ts b/tests/unit/schemas.test.ts
new file mode 100644
index 0000000..16ad6d5
--- /dev/null
+++ b/tests/unit/schemas.test.ts
@@ -0,0 +1,153 @@
+import { describe, expect, it } from "vitest";
+
+import { limitRequestSchema } from "../../src/api/schemas";
+
+describe("limitRequestSchema", () => {
+ it("accepts minimal valid payload", () => {
+ const r = limitRequestSchema.safeParse({
+ algorithm: "token_bucket",
+ ip: "10.0.0.1",
+ scope: "ip"
+ });
+ expect(r.success).toBe(true);
+ });
+
+ it("rejects missing algorithm", () => {
+ expect(limitRequestSchema.safeParse({ ip: "10.0.0.1" }).success).toBe(
+ false
+ );
+ });
+
+ it("rejects invalid algorithm", () => {
+ expect(
+ limitRequestSchema.safeParse({ algorithm: "invalid", ip: "1.2.3.4" })
+ .success
+ ).toBe(false);
+ });
+
+ it("rejects when no identifier provided", () => {
+ const r = limitRequestSchema.safeParse({ algorithm: "token_bucket" });
+ expect(r.success).toBe(false);
+ });
+
+ it("rejects hybrid without userId", () => {
+ const r = limitRequestSchema.safeParse({
+ algorithm: "token_bucket",
+ ip: "1.2.3.4",
+ scope: "hybrid"
+ });
+ expect(r.success).toBe(false);
+ });
+
+ it("rejects hybrid without ip", () => {
+ const r = limitRequestSchema.safeParse({
+ algorithm: "token_bucket",
+ userId: "u1",
+ scope: "hybrid"
+ });
+ expect(r.success).toBe(false);
+ });
+
+ it("accepts hybrid with both", () => {
+ const r = limitRequestSchema.safeParse({
+ algorithm: "token_bucket",
+ userId: "u1",
+ ip: "1.2.3.4",
+ scope: "hybrid"
+ });
+ expect(r.success).toBe(true);
+ });
+
+ it("rejects user scope without userId", () => {
+ const r = limitRequestSchema.safeParse({
+ algorithm: "token_bucket",
+ ip: "1.2.3.4",
+ scope: "user"
+ });
+ expect(r.success).toBe(false);
+ });
+
+ it("rejects ip scope without ip", () => {
+ const r = limitRequestSchema.safeParse({
+ algorithm: "token_bucket",
+ userId: "u1",
+ scope: "ip"
+ });
+ expect(r.success).toBe(false);
+ });
+
+ it("rejects custom scope without identifier", () => {
+ const r = limitRequestSchema.safeParse({
+ algorithm: "token_bucket",
+ ip: "1.2.3.4",
+ scope: "custom"
+ });
+ expect(r.success).toBe(false);
+ });
+
+ it("accepts custom scope with identifier", () => {
+ const r = limitRequestSchema.safeParse({
+ algorithm: "token_bucket",
+ identifier: "d1",
+ scope: "custom"
+ });
+ expect(r.success).toBe(true);
+ });
+
+ it("enforces max length on userId", () => {
+ const r = limitRequestSchema.safeParse({
+ algorithm: "token_bucket",
+ userId: "x".repeat(257),
+ scope: "user"
+ });
+ expect(r.success).toBe(false);
+ });
+
+ it("enforces max length on ip", () => {
+ const r = limitRequestSchema.safeParse({
+ algorithm: "token_bucket",
+ ip: "x".repeat(65),
+ scope: "ip"
+ });
+ expect(r.success).toBe(false);
+ });
+
+ it("enforces max length on identifier", () => {
+ const r = limitRequestSchema.safeParse({
+ algorithm: "token_bucket",
+ identifier: "x".repeat(257),
+ scope: "custom"
+ });
+ expect(r.success).toBe(false);
+ });
+
+ it("enforces cost range", () => {
+ expect(
+ limitRequestSchema.safeParse({
+ algorithm: "token_bucket",
+ ip: "1.2.3.4",
+ cost: 0
+ }).success
+ ).toBe(false);
+ expect(
+ limitRequestSchema.safeParse({
+ algorithm: "token_bucket",
+ ip: "1.2.3.4",
+ cost: 11
+ }).success
+ ).toBe(false);
+ });
+
+ it("defaults cost, route, and method", () => {
+ const r = limitRequestSchema.safeParse({
+ algorithm: "token_bucket",
+ ip: "1.2.3.4"
+ });
+ expect(r.success).toBe(true);
+ if (r.success) {
+ expect(r.data.cost).toBe(1);
+ expect(r.data.route).toBe("/");
+ expect(r.data.method).toBe("GET");
+ }
+ });
+});
diff --git a/tests/unit/time.test.ts b/tests/unit/time.test.ts
new file mode 100644
index 0000000..683e82e
--- /dev/null
+++ b/tests/unit/time.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, it } from "vitest";
+
+import { bucketStart } from "../../src/utils/time";
+
+describe("bucketStart", () => {
+ it("floors to the nearest minute by default", () => {
+ // 2026-01-01T00:01:30.500Z → 2026-01-01T00:01:00.000Z
+ const ts = new Date("2026-01-01T00:01:30.500Z").getTime();
+ const result = bucketStart(ts);
+ expect(result).toBe(new Date("2026-01-01T00:01:00.000Z").getTime());
+ });
+
+ it("returns the same value when already on a boundary", () => {
+ const ts = new Date("2026-01-01T00:02:00.000Z").getTime();
+ expect(bucketStart(ts)).toBe(ts);
+ });
+
+ it("supports custom window sizes", () => {
+ const ts = new Date("2026-01-01T00:00:15.000Z").getTime();
+ const result = bucketStart(ts, 10_000);
+ expect(result).toBe(new Date("2026-01-01T00:00:10.000Z").getTime());
+ });
+
+ it("handles zero timestamp", () => {
+ expect(bucketStart(0)).toBe(0);
+ });
+
+ it("handles 10-second windows correctly", () => {
+ const ts = new Date("2026-01-01T00:00:37.000Z").getTime();
+ const result = bucketStart(ts, 10_000);
+ expect(result).toBe(new Date("2026-01-01T00:00:30.000Z").getTime());
+ });
+});