- MCP server: stateless JSON-RPC 2.0 with 18 tools (tasks, habits, projects, notes, domains, search, activity) - Webhooks API: CRUD routes under /api/domains/[domainId]/webhooks/ with test endpoint and deliveries log - Webhook delivery: HMAC-SHA256 signed POST with retry (exponential backoff, max 6) - Worker rewrite: Drizzle ORM instead of PocketBase, polls jobs table, handles webhook_delivery, recurring_spawn, ai_dispatch - Rate limiting: token bucket per IP/API key (100 req/min REST, 300 req/min MCP) - Keyboard help overlay: ? opens Radix Dialog with search/filter, Esc closes - AI @mention stub: @agent in command palette dispatches CustomEvent - Mobile responsive: bottom nav, single-column kanban, day view calendar, 44px touch targets - Accessibility: skip-to-content link, focus rings, aria-labels, color contrast - E2E tests: mcp.spec.ts, webhooks.spec.ts, realtime.spec.ts added - Schema: api_keys and webhook_deliveries tables with migration - Removed old PocketBase-style database.ts from worker
167 lines
5.2 KiB
TypeScript
167 lines
5.2 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server';
|
|
|
|
// ── Token bucket rate limiter ─────────────────────────────────────────────────────
|
|
|
|
interface TokenBucket {
|
|
tokens: number;
|
|
lastRefill: number;
|
|
}
|
|
|
|
const buckets = new Map<string, TokenBucket>();
|
|
|
|
// REST API: 100 req/min, MCP: 300 req/min
|
|
const RATE_LIMITS: Record<string, { maxTokens: number; refillMs: number }> = {
|
|
rest: { maxTokens: 100, refillMs: 60000 },
|
|
mcp: { maxTokens: 300, refillMs: 60000 },
|
|
};
|
|
|
|
function getBucketKey(request: NextRequest): string {
|
|
// Use API key if present, otherwise IP
|
|
const apiKey = request.headers.get('Authorization')?.replace('Bearer ', '').trim();
|
|
if (apiKey) return `apikey:${apiKey}`;
|
|
|
|
const forwardedFor = request.headers.get('x-forwarded-for');
|
|
const ip = forwardedFor?.split(',')[0]?.trim() || '127.0.0.1';
|
|
return `ip:${ip}`;
|
|
}
|
|
|
|
function getRateLimitType(request: NextRequest): 'mcp' | 'rest' {
|
|
const pathname = request.nextUrl.pathname;
|
|
if (pathname === '/api/mcp') return 'mcp';
|
|
return 'rest';
|
|
}
|
|
|
|
function checkRateLimit(request: NextRequest): { allowed: boolean; limit: number; remaining: number; resetMs: number } {
|
|
const key = getBucketKey(request);
|
|
const type = getRateLimitType(request);
|
|
const config = RATE_LIMITS[type];
|
|
const now = Date.now();
|
|
|
|
let bucket = buckets.get(key);
|
|
if (!bucket) {
|
|
bucket = { tokens: config.maxTokens, lastRefill: now };
|
|
buckets.set(key, bucket);
|
|
}
|
|
|
|
// Refill tokens
|
|
const elapsed = now - bucket.lastRefill;
|
|
const tokensToAdd = Math.floor(elapsed / config.refillMs) * config.maxTokens;
|
|
if (tokensToAdd > 0) {
|
|
bucket.tokens = Math.min(config.maxTokens, bucket.tokens + tokensToAdd);
|
|
bucket.lastRefill = now;
|
|
}
|
|
|
|
const allowed = bucket.tokens >= 1;
|
|
if (allowed) {
|
|
bucket.tokens -= 1;
|
|
}
|
|
|
|
// Calculate reset time
|
|
const resetMs = bucket.lastRefill + config.refillMs;
|
|
|
|
return {
|
|
allowed,
|
|
limit: config.maxTokens,
|
|
remaining: Math.max(0, Math.floor(bucket.tokens)),
|
|
resetMs,
|
|
};
|
|
}
|
|
|
|
// Periodically clean up stale buckets (every 5 minutes)
|
|
setInterval(() => {
|
|
const now = Date.now();
|
|
for (const [key, bucket] of buckets.entries()) {
|
|
if (now - bucket.lastRefill > 120000) { // 2 minutes stale
|
|
buckets.delete(key);
|
|
}
|
|
}
|
|
}, 300000).unref();
|
|
|
|
// ── Excluded routes ──────────────────────────────────────────────────────────────
|
|
|
|
const EXCLUDED_ROUTES = [
|
|
'/api/health',
|
|
'/api/realtime',
|
|
'/api/auth',
|
|
'/_next',
|
|
'/favicon.ico',
|
|
];
|
|
|
|
function isExcluded(pathname: string): boolean {
|
|
return EXCLUDED_ROUTES.some(route => pathname.startsWith(route));
|
|
}
|
|
|
|
// ── Middleware ────────────────────────────────────────────────────────────────────
|
|
|
|
export function middleware(request: NextRequest) {
|
|
const pathname = request.nextUrl.pathname;
|
|
|
|
// Skip rate limiting for excluded routes
|
|
if (isExcluded(pathname)) {
|
|
return NextResponse.next();
|
|
}
|
|
|
|
// Check if user is authenticated
|
|
const token = request.cookies.get('next-auth.session-token')?.value
|
|
|| request.cookies.get('__Secure-next-auth.session-token')?.value;
|
|
|
|
// If no token and trying to access protected routes, redirect to login
|
|
const protectedRoutes = [
|
|
'/dashboard', '/tasks', '/habits', '/projects', '/notes', '/reports',
|
|
'/calendar', '/analytics', '/agents', '/settings',
|
|
];
|
|
if (!token && protectedRoutes.some((route) => pathname === route || pathname.startsWith(`${route}/`))) {
|
|
const loginUrl = new URL('/login', request.url);
|
|
return NextResponse.redirect(loginUrl);
|
|
}
|
|
|
|
// Rate limiting for API routes
|
|
if (pathname.startsWith('/api/')) {
|
|
const result = checkRateLimit(request);
|
|
|
|
const response = result.allowed
|
|
? NextResponse.next()
|
|
: NextResponse.json(
|
|
{ error: { code: 'RATE_LIMITED', message: 'Too many requests. Please slow down.' } },
|
|
{ status: 429 }
|
|
);
|
|
|
|
response.headers.set('X-RateLimit-Limit', String(result.limit));
|
|
response.headers.set('X-RateLimit-Remaining', String(result.remaining));
|
|
response.headers.set('X-RateLimit-Reset', String(Math.ceil(result.resetMs / 1000)));
|
|
|
|
return response;
|
|
}
|
|
|
|
// Create response for non-API routes
|
|
const response = NextResponse.next();
|
|
|
|
// Forward proxy headers for proper client IP detection
|
|
const forwardedFor = request.headers.get('x-forwarded-for');
|
|
const forwardedProto = request.headers.get('x-forwarded-proto');
|
|
|
|
if (forwardedFor) {
|
|
response.headers.set('x-real-ip', forwardedFor.split(',')[0].trim());
|
|
}
|
|
|
|
if (forwardedProto) {
|
|
response.headers.set('x-forwarded-proto', forwardedProto);
|
|
}
|
|
|
|
return response;
|
|
}
|
|
|
|
export const config = {
|
|
matcher: [
|
|
/*
|
|
* Match all request paths except:
|
|
* - api/auth routes (login, logout, etc.)
|
|
* - _next/static (static files)
|
|
* - _next/image (image optimization)
|
|
* - favicon.ico (favicon)
|
|
* - public files (public folder)
|
|
*/
|
|
'/((?!api/auth|_next/static|_next/image|favicon.ico|public).*)',
|
|
],
|
|
};
|