feat: Phase 6 - MCP + Webhooks + Worker + Polish
- 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
This commit is contained in:
+121
-5
@@ -1,26 +1,142 @@
|
||||
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;
|
||||
|
||||
// Protected routes (v1 + v2)
|
||||
// If no token and trying to access protected routes, redirect to login
|
||||
const protectedRoutes = [
|
||||
'/dashboard', '/tasks', '/habits', '/projects', '/notes', '/reports',
|
||||
'/calendar', '/analytics', '/agents', '/settings',
|
||||
'/graph', '/domains',
|
||||
];
|
||||
if (!token && protectedRoutes.some((route) => request.nextUrl.pathname === route || request.nextUrl.pathname.startsWith(`${route}/`))) {
|
||||
if (!token && protectedRoutes.some((route) => pathname === route || pathname.startsWith(`${route}/`))) {
|
||||
const loginUrl = new URL('/login', request.url);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
// Create response
|
||||
// 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
|
||||
// Nginx Proxy Manager sets X-Forwarded-For and X-Forwarded-Proto
|
||||
const forwardedFor = request.headers.get('x-forwarded-for');
|
||||
const forwardedProto = request.headers.get('x-forwarded-proto');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user