T1/Phase 1: scaffold Vite SPA + Hono API + Bun worker
- apps/web: Vite + React 19 + TanStack Router/Query + shadcn/ui - apps/api: Hono + Bun on :3001 with /api/health, /api/auth/*, /mcp stubs - apps/worker: Bun worker stub, DB connection, graceful SIGTERM - apps/web-legacy/: old Next.js code moved aside (preserved for T2-T8 reference) - Dockerfiles: api (Bun), worker (Bun), spa (multi-stage Caddy) - Caddyfile: serves dist + reverse-proxies /api/* + /mcp to api - docker-compose.yml: 4-service target (api, spa, db, worker) - packages/db/src/client.ts: shared Drizzle client for api + worker - db/client.ts: root-level alias for convenience Parent: t_e1cbd87d -> t_24c9c3fd (T0)
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
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)
|
||||
// Edge Runtime cleanup (no .unref support)
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, bucket] of buckets.entries()) {
|
||||
if (now - bucket.lastRefill > 120000) { // 2 minutes stale
|
||||
buckets.delete(key);
|
||||
}
|
||||
}
|
||||
}, 300000);
|
||||
|
||||
// ── 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).*)',
|
||||
],
|
||||
};
|
||||
Reference in New Issue
Block a user