Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
23 changes: 15 additions & 8 deletions .context/security/rate-limiting.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,29 +60,33 @@ Single source of truth. First match wins; rules are evaluated top to bottom.
| 2 | `/api/v1/admin/` | `admin` | `session-user` | 30/min — core admin (users, logs, invitations, flags) |
| 3 | `/api/v1/auth/` | `auth` | `ip` | 5/min — app-layer auth endpoints |
| 4 | `/api/auth/` | `auth` | `ip` | 5/min — better-auth's own routes |
| 5 | `/api/v1/webhooks/` | `api` | `api-key` | 100/min keyed on `Authorization: Bearer <key>` |
| 6 | `/api/v1/embed/` | `api` | `embed-token` | 100/min keyed on `X-Embed-Token` header + IP composite |
| 7 | `/api/v1/inbound/` | `api` | `ip` | 100/min — server-to-server (Slack, Postmark, etc.) |
| 8 | `/api/v1/contact` | `api` | `ip` | 100/min — unauthenticated public submission |
| 9 | `/api/v1/` (catch-all) | `api` | `session-user` | 100/min — everything else |
| 5 | `/api/v1/mcp/` | `mcp` | `api-key` | 300/min — LLM-agent transport keyed per api-key |
| 6 | `/api/v1/webhooks/` | `api` | `api-key` | 100/min keyed on `Authorization: Bearer <key>` |
| 7 | `/api/v1/embed/` | `api` | `embed-token` | 100/min keyed on `X-Embed-Token` header + IP composite |
| 8 | `/api/v1/inbound/` | `api` | `ip` | 100/min — server-to-server (Slack, Postmark, etc.) |
| 9 | `/api/v1/contact` | `api` | `ip` | 100/min — unauthenticated public submission |
| 10 | `/api/v1/` (catch-all) | `api` | `session-user` | 100/min — everything else |

**Order matters.** The orchestration rule (1) must come before the broader admin rule (2) — otherwise `/api/v1/admin/orchestration/agents` would match `/api/v1/admin/` first and land on the tighter 30/min admin tier. The consumer-specific rules (5–8) must come before the catch-all (9) — otherwise webhooks would key on session-user instead of api-key, and so on.
**Order matters.** The orchestration rule (1) must come before the broader admin rule (2) — otherwise `/api/v1/admin/orchestration/agents` would match `/api/v1/admin/` first and land on the tighter 30/min admin tier. The MCP rule (5) and the consumer-specific rules (6–9) must come before the catch-all (10) — otherwise MCP would key on session-user (and fall back to IP, defeating the api-key keying), webhooks would key on session-user, and so on.

Tests in `tests/unit/lib/security/rate-limit-policy.test.ts` lock the order in place.

## Section Tiers

Four section tiers, each backed by a single limiter instance in [`RATE_LIMIT_TIERS`](../../lib/security/rate-limit.ts):
Five section tiers, each backed by a single limiter instance in [`RATE_LIMIT_TIERS`](../../lib/security/rate-limit.ts):

| Tier | Cap | Env override | Limiter |
| --------------- | ------- | ----------------------- | --------------------------- |
| `admin` | 30/min | `RATE_LIMIT_ADMIN` | `adminLimiter` |
| `orchestration` | 120/min | `RATE_LIMIT_ORCH_ADMIN` | `orchestrationAdminLimiter` |
| `api` | 100/min | `RATE_LIMIT_API` | `apiLimiter` |
| `mcp` | 300/min | `RATE_LIMIT_MCP` | `mcpLimiter` |
| `auth` | 5/min | (none — security floor) | `authLimiter` |

Caps are per-window (1 minute) using the sliding-window algorithm from `lib/security/rate-limit.ts`. Bumps via env vars are intended for development; production should run on the defaults.

**Why `mcp` is separate from `api`.** MCP is a distinct interface — server-to-server, always API-key-authenticated, much chattier per session than human-driven REST traffic (LLM agents iterate through tool calls inside a conversation). The 100/min `api` cap is too tight for legitimate agent workloads; the 300/min default leaves room for normal activity while still rate-limiting a runaway agent loop within ~5 seconds. Per-customer budgets are tunable separately via `McpRateLimiter` against the `apiKey.rateLimit` field; the section tier here is the coarse ceiling above that.

## Key Strategies

How the dispatcher identifies the caller when building the bucket token. Token format: `mw:${tier}:${key}:${identifier}`.
Expand Down Expand Up @@ -117,6 +121,7 @@ These are NOT tiers. They're tighter caps on specific expensive operations, appl
| `inviteLimiter` | 10/15min per IP | Sending invitations |
| `uploadLimiter` | 10/15min per IP | File uploads |
| `cspReportLimiter` | 20/min per IP | CSP violation reports |
| `exportLimiter` | 10/min per admin user | Bulk-export endpoints (conversations export) |
| `agentChatLimiter` | per-agent RPM (dynamic) | Consumer chat with `rateLimitRpm` override |
| `apiKeyChatLimiter` | per-key RPM (dynamic) | Webhook triggers with `rateLimitRpm` override |

Expand Down Expand Up @@ -202,6 +207,7 @@ See [`tests/unit/lib/security/rate-limit-middleware.test.ts`](../../tests/unit/l
| `RATE_LIMIT_ADMIN` | Override the `admin` tier cap (per-minute) | `30` |
| `RATE_LIMIT_ORCH_ADMIN` | Override the `orchestration` tier cap (per-minute) | `120` |
| `RATE_LIMIT_API` | Override the `api` tier cap (per-minute) | `100` |
| `RATE_LIMIT_MCP` | Override the `mcp` tier cap (per-minute) | `300` |
| `RATE_LIMIT_STORE` | Backing store for the **async** limiter variants only | `memory` |
| `REDIS_URL` | Redis connection string (required if `RATE_LIMIT_STORE=redis`) | — |
| `RATE_LIMIT_BYPASS` | Test/dev escape hatch — `true` short-circuits the dispatcher | unset |
Expand Down Expand Up @@ -241,12 +247,13 @@ Three edits, in order:
2. **Extend the registry**:

```typescript
export type RateLimitTier = 'admin' | 'orchestration' | 'api' | 'auth' | 'billing';
export type RateLimitTier = 'admin' | 'orchestration' | 'api' | 'mcp' | 'auth' | 'billing';

export const RATE_LIMIT_TIERS: Record<RateLimitTier, RateLimiter> = {
admin: adminLimiter,
orchestration: orchestrationAdminLimiter,
api: apiLimiter,
mcp: mcpLimiter,
auth: authLimiter,
billing: billingAdminLimiter,
};
Expand Down
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,7 @@ NEXT_PUBLIC_APP_URL="http://localhost:3000"
# RATE_LIMIT_API=1000 # General /api/v1/ catch-all (default 100)
# RATE_LIMIT_ADMIN=500 # Core admin endpoints (default 30)
# RATE_LIMIT_ORCH_ADMIN=600 # Admin orchestration UI (default 120)
# RATE_LIMIT_MCP=1000 # MCP transport endpoint (default 300, keyed per api-key)

# RATE_LIMIT_BYPASS — test/dev escape hatch. When 'true' or '1', the
# rate-limit dispatcher short-circuits and returns null for every request.
Expand Down
5 changes: 0 additions & 5 deletions app/api/v1/admin/orchestration/agent-profiles/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ import { successResponse } from '@/lib/api/responses';
import { NotFoundError, ValidationError } from '@/lib/api/errors';
import { validateRequestBody } from '@/lib/api/validation';
import { getRouteLogger } from '@/lib/api/context';
import { adminLimiter, createRateLimitResponse } from '@/lib/security/rate-limit';
import { getClientIP } from '@/lib/security/ip';
import { updateAgentProfileSchema } from '@/lib/validations/orchestration';
import { cuidSchema } from '@/lib/validations/common';
Expand Down Expand Up @@ -58,8 +57,6 @@ export const GET = withAdminAuth<{ id: string }>(async (request, _session, { par

export const PATCH = withAdminAuth<{ id: string }>(async (request, session, { params }) => {
const clientIP = getClientIP(request);
const rateLimit = adminLimiter.check(clientIP);
if (!rateLimit.success) return createRateLimitResponse(rateLimit);

const log = await getRouteLogger(request);
const { id: rawId } = await params;
Expand Down Expand Up @@ -105,8 +102,6 @@ export const PATCH = withAdminAuth<{ id: string }>(async (request, session, { pa

export const DELETE = withAdminAuth<{ id: string }>(async (request, session, { params }) => {
const clientIP = getClientIP(request);
const rateLimit = adminLimiter.check(clientIP);
if (!rateLimit.success) return createRateLimitResponse(rateLimit);

const log = await getRouteLogger(request);
const { id: rawId } = await params;
Expand Down
3 changes: 0 additions & 3 deletions app/api/v1/admin/orchestration/agent-profiles/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ import { paginatedResponse, successResponse } from '@/lib/api/responses';
import { ConflictError } from '@/lib/api/errors';
import { validateQueryParams, validateRequestBody } from '@/lib/api/validation';
import { getRouteLogger } from '@/lib/api/context';
import { adminLimiter, createRateLimitResponse } from '@/lib/security/rate-limit';
import { getClientIP } from '@/lib/security/ip';
import {
agentProfileFormSchema,
Expand Down Expand Up @@ -68,8 +67,6 @@ export const GET = withAdminAuth(async (request, _session) => {

export const POST = withAdminAuth(async (request, session) => {
const clientIP = getClientIP(request);
const rateLimit = adminLimiter.check(clientIP);
if (!rateLimit.success) return createRateLimitResponse(rateLimit);

const log = await getRouteLogger(request);
const body = await validateRequestBody(request, agentProfileFormSchema);
Expand Down
6 changes: 0 additions & 6 deletions app/api/v1/admin/orchestration/agents/[id]/budget/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,10 @@ import { prisma } from '@/lib/db/client';
import { successResponse } from '@/lib/api/responses';
import { NotFoundError, ValidationError } from '@/lib/api/errors';
import { getRouteLogger } from '@/lib/api/context';
import { adminLimiter, createRateLimitResponse } from '@/lib/security/rate-limit';
import { getClientIP } from '@/lib/security/ip';
import { cuidSchema } from '@/lib/validations/common';
import { checkBudget } from '@/lib/orchestration/llm/cost-tracker';

export const GET = withAdminAuth<{ id: string }>(async (request, _session, { params }) => {
const clientIP = getClientIP(request);
const rateLimit = adminLimiter.check(clientIP);
if (!rateLimit.success) return createRateLimitResponse(rateLimit);

const log = await getRouteLogger(request);
const { id: rawId } = await params;
const parsed = cuidSchema.safeParse(rawId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import { successResponse } from '@/lib/api/responses';
import { NotFoundError, ValidationError } from '@/lib/api/errors';
import { validateRequestBody } from '@/lib/api/validation';
import { getRouteLogger } from '@/lib/api/context';
import { adminLimiter, createRateLimitResponse } from '@/lib/security/rate-limit';
import { getClientIP } from '@/lib/security/ip';
import { capabilityDispatcher } from '@/lib/orchestration/capabilities';
import { findUnsetEnvVarReferences } from '@/lib/orchestration/env-template';
Expand Down Expand Up @@ -68,8 +67,6 @@ function parseIds(raw: RouteParams): { agentId: string; capabilityId: string } {

export const PATCH = withAdminAuth<RouteParams>(async (request, session, { params }) => {
const clientIP = getClientIP(request);
const rateLimit = adminLimiter.check(clientIP);
if (!rateLimit.success) return createRateLimitResponse(rateLimit);

const log = await getRouteLogger(request);
const { agentId, capabilityId } = parseIds(await params);
Expand Down Expand Up @@ -120,8 +117,6 @@ export const PATCH = withAdminAuth<RouteParams>(async (request, session, { param

export const DELETE = withAdminAuth<RouteParams>(async (request, session, { params }) => {
const clientIP = getClientIP(request);
const rateLimit = adminLimiter.check(clientIP);
if (!rateLimit.success) return createRateLimitResponse(rateLimit);

const log = await getRouteLogger(request);
const { agentId, capabilityId } = parseIds(await params);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import { successResponse } from '@/lib/api/responses';
import { ConflictError, NotFoundError, ValidationError } from '@/lib/api/errors';
import { validateRequestBody } from '@/lib/api/validation';
import { getRouteLogger } from '@/lib/api/context';
import { adminLimiter, createRateLimitResponse } from '@/lib/security/rate-limit';
import { getClientIP } from '@/lib/security/ip';
import { capabilityDispatcher } from '@/lib/orchestration/capabilities';
import { findUnsetEnvVarReferences } from '@/lib/orchestration/env-template';
Expand Down Expand Up @@ -68,10 +67,6 @@ function parseAgentId(raw: string): string {
}

export const GET = withAdminAuth<{ id: string }>(async (request, _session, { params }) => {
const clientIP = getClientIP(request);
const rateLimit = adminLimiter.check(clientIP);
if (!rateLimit.success) return createRateLimitResponse(rateLimit);

const log = await getRouteLogger(request);
const { id: rawAgentId } = await params;
const agentId = parseAgentId(rawAgentId);
Expand All @@ -91,8 +86,6 @@ export const GET = withAdminAuth<{ id: string }>(async (request, _session, { par

export const POST = withAdminAuth<{ id: string }>(async (request, session, { params }) => {
const clientIP = getClientIP(request);
const rateLimit = adminLimiter.check(clientIP);
if (!rateLimit.success) return createRateLimitResponse(rateLimit);

const log = await getRouteLogger(request);
const { id: rawAgentId } = await params;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,6 @@ import { prisma } from '@/lib/db/client';
import { successResponse } from '@/lib/api/responses';
import { ValidationError } from '@/lib/api/errors';
import { getRouteLogger } from '@/lib/api/context';
import { adminLimiter, createRateLimitResponse } from '@/lib/security/rate-limit';
import { getClientIP } from '@/lib/security/ip';
import { cuidSchema } from '@/lib/validations/common';

interface UsageRow {
Expand All @@ -25,10 +23,6 @@ interface UsageRow {
}

export const GET = withAdminAuth<{ id: string }>(async (request, _session, { params }) => {
const clientIP = getClientIP(request);
const rateLimit = adminLimiter.check(clientIP);
if (!rateLimit.success) return createRateLimitResponse(rateLimit);

const log = await getRouteLogger(request);
const { id: rawId } = await params;
const parsed = cuidSchema.safeParse(rawId);
Expand Down
3 changes: 0 additions & 3 deletions app/api/v1/admin/orchestration/agents/[id]/clone/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,13 @@ import { prisma } from '@/lib/db/client';
import { successResponse } from '@/lib/api/responses';
import { NotFoundError, ValidationError, ConflictError } from '@/lib/api/errors';
import { getRouteLogger } from '@/lib/api/context';
import { adminLimiter, createRateLimitResponse } from '@/lib/security/rate-limit';
import { getClientIP } from '@/lib/security/ip';
import { cloneAgentBodySchema } from '@/lib/validations/orchestration';
import { cuidSchema } from '@/lib/validations/common';
import { logAdminAction } from '@/lib/orchestration/audit/admin-audit-logger';

export const POST = withAdminAuth<{ id: string }>(async (request, session, { params }) => {
const clientIP = getClientIP(request);
const rateLimit = adminLimiter.check(clientIP);
if (!rateLimit.success) return createRateLimitResponse(rateLimit);

const log = await getRouteLogger(request);
const { id: rawId } = await params;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import { prisma } from '@/lib/db/client';
import { successResponse } from '@/lib/api/responses';
import { validateRequestBody } from '@/lib/api/validation';
import { getRouteLogger } from '@/lib/api/context';
import { adminLimiter, createRateLimitResponse } from '@/lib/security/rate-limit';
import { getClientIP } from '@/lib/security/ip';
import { NotFoundError, ValidationError } from '@/lib/api/errors';
import { logAdminAction } from '@/lib/orchestration/audit/admin-audit-logger';
Expand All @@ -29,8 +28,6 @@ async function findToken(agentId: string, tokenId: string) {

export const PATCH = withAdminAuth<Params>(async (request, session, { params }) => {
const clientIP = getClientIP(request);
const rateLimit = adminLimiter.check(clientIP);
if (!rateLimit.success) return createRateLimitResponse(rateLimit);

const { id: rawAgentId, tokenId: rawTokenId } = await params;
const agentIdParsed = cuidSchema.safeParse(rawAgentId);
Expand Down Expand Up @@ -73,8 +70,6 @@ export const PATCH = withAdminAuth<Params>(async (request, session, { params })

export const DELETE = withAdminAuth<Params>(async (request, session, { params }) => {
const clientIP = getClientIP(request);
const rateLimit = adminLimiter.check(clientIP);
if (!rateLimit.success) return createRateLimitResponse(rateLimit);

const { id: rawAgentId, tokenId: rawTokenId } = await params;
const agentIdParsed = cuidSchema.safeParse(rawAgentId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import { prisma } from '@/lib/db/client';
import { successResponse } from '@/lib/api/responses';
import { validateRequestBody } from '@/lib/api/validation';
import { getRouteLogger } from '@/lib/api/context';
import { adminLimiter, createRateLimitResponse } from '@/lib/security/rate-limit';
import { getClientIP } from '@/lib/security/ip';
import { NotFoundError, ValidationError } from '@/lib/api/errors';
import { logAdminAction } from '@/lib/orchestration/audit/admin-audit-logger';
Expand All @@ -22,10 +21,6 @@ import { cuidSchema } from '@/lib/validations/common';
type Params = { id: string };

export const GET = withAdminAuth<Params>(async (request, _session, { params }) => {
const clientIP = getClientIP(request);
const rateLimit = adminLimiter.check(clientIP);
if (!rateLimit.success) return createRateLimitResponse(rateLimit);

const { id: rawId } = await params;
const parsed = cuidSchema.safeParse(rawId);
if (!parsed.success)
Expand All @@ -51,8 +46,6 @@ export const GET = withAdminAuth<Params>(async (request, _session, { params }) =

export const POST = withAdminAuth<Params>(async (request, session, { params }) => {
const clientIP = getClientIP(request);
const rateLimit = adminLimiter.check(clientIP);
if (!rateLimit.success) return createRateLimitResponse(rateLimit);

const { id: rawId } = await params;
const parsed = cuidSchema.safeParse(rawId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,6 @@ import { prisma } from '@/lib/db/client';
import { successResponse } from '@/lib/api/responses';
import { NotFoundError, ValidationError } from '@/lib/api/errors';
import { getRouteLogger } from '@/lib/api/context';
import { adminLimiter, createRateLimitResponse } from '@/lib/security/rate-limit';
import { getClientIP } from '@/lib/security/ip';
import { logger } from '@/lib/logging';
import {
systemInstructionsHistorySchema,
Expand All @@ -37,10 +35,6 @@ function parseAgentId(raw: string): string {
}

export const GET = withAdminAuth<{ id: string }>(async (request, _session, { params }) => {
const clientIP = getClientIP(request);
const rateLimit = adminLimiter.check(clientIP);
if (!rateLimit.success) return createRateLimitResponse(rateLimit);

const log = await getRouteLogger(request);
const { id: rawId } = await params;
const id = parseAgentId(rawId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ import { successResponse } from '@/lib/api/responses';
import { ForbiddenError, NotFoundError, ValidationError } from '@/lib/api/errors';
import { validateRequestBody } from '@/lib/api/validation';
import { getRouteLogger } from '@/lib/api/context';
import { adminLimiter, createRateLimitResponse } from '@/lib/security/rate-limit';
import { getClientIP } from '@/lib/security/ip';
import { logger } from '@/lib/logging';
import {
Expand All @@ -48,8 +47,6 @@ function parseAgentId(raw: string): string {

export const POST = withAdminAuth<{ id: string }>(async (request, session, { params }) => {
const clientIP = getClientIP(request);
const rateLimit = adminLimiter.check(clientIP);
if (!rateLimit.success) return createRateLimitResponse(rateLimit);

const log = await getRouteLogger(request);
const { id: rawId } = await params;
Expand Down
Loading
Loading