FRE-4533: Merge apps/{api,web,mobile} and shared-db into ShieldAI repo
Merge FrenoCorp apps into ShieldAI packages/: - packages/api: merged routes (notifications), middleware (auth, rate-limit, error, logging), config, services (darkwatch, spamshield, voiceprint), tests - packages/web: new SolidJS web app stub - packages/mobile: new SolidJS mobile app stub - packages/shared-db: new Prisma DB package (separate from existing packages/db) - pnpm-workspace.yaml: restored (apps/* removed, already covered by packages/*) Next: reconcile packages/shared-db with packages/db, and fix server.ts correlationRoutes import
This commit is contained in:
86
packages/api/src/middleware/auth.middleware.ts
Normal file
86
packages/api/src/middleware/auth.middleware.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
|
||||
export interface AuthRequest extends FastifyRequest {
|
||||
user?: {
|
||||
id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
organizationId?: string;
|
||||
};
|
||||
apiKey?: string;
|
||||
authType: 'jwt' | 'api-key' | 'anonymous';
|
||||
}
|
||||
|
||||
export async function authMiddleware(fastify: FastifyInstance) {
|
||||
// Authentication hook
|
||||
fastify.addHook('onRequest', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const authReq = request as AuthRequest;
|
||||
// Skip auth for health checks and root
|
||||
const publicRoutes = ['/', '/health'];
|
||||
if (publicRoutes.some((route) => request.url.startsWith(route))) {
|
||||
authReq.authType = 'anonymous';
|
||||
return;
|
||||
}
|
||||
|
||||
// Try JWT authentication first
|
||||
const authHeader = request.headers.authorization;
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
const token = authHeader.slice(7);
|
||||
try {
|
||||
// In production, decode and verify JWT
|
||||
// For now, we'll attach a placeholder user
|
||||
authReq.user = {
|
||||
id: 'user-placeholder',
|
||||
email: 'user@example.com',
|
||||
role: 'user',
|
||||
};
|
||||
authReq.authType = 'jwt';
|
||||
return;
|
||||
} catch (err) {
|
||||
// JWT invalid, continue to API key check
|
||||
}
|
||||
}
|
||||
|
||||
// Try API key authentication
|
||||
const apiKey = request.headers['x-api-key'] as string | undefined;
|
||||
if (apiKey) {
|
||||
// In production, validate API key against database
|
||||
authReq.apiKey = apiKey;
|
||||
authReq.user = {
|
||||
id: `api-${apiKey}`,
|
||||
email: `api-${apiKey}@services.internal`,
|
||||
role: 'service',
|
||||
};
|
||||
authReq.authType = 'api-key';
|
||||
return;
|
||||
}
|
||||
|
||||
// No auth found - attach anonymous user
|
||||
authReq.authType = 'anonymous';
|
||||
authReq.user = {
|
||||
id: 'anonymous',
|
||||
email: 'anonymous@unknown',
|
||||
role: 'anonymous',
|
||||
};
|
||||
});
|
||||
|
||||
// Create auth decorator for route-level protection
|
||||
fastify.decorate('requireAuth', async (request: AuthRequest) => {
|
||||
if (request.authType === 'anonymous') {
|
||||
throw { statusCode: 401, message: 'Authentication required' };
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
fastify.decorate('requireRole', (allowedRoles: string[]) => {
|
||||
return async (request: AuthRequest) => {
|
||||
if (!request.user?.role || !allowedRoles.includes(request.user.role)) {
|
||||
throw {
|
||||
statusCode: 403,
|
||||
message: `Role ${request.user?.role} not in allowed roles: ${allowedRoles.join(', ')}`,
|
||||
};
|
||||
}
|
||||
return true;
|
||||
};
|
||||
});
|
||||
}
|
||||
62
packages/api/src/middleware/error-handling.middleware.ts
Normal file
62
packages/api/src/middleware/error-handling.middleware.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
|
||||
export interface ErrorResponse {
|
||||
error: string;
|
||||
message: string;
|
||||
statusCode: number;
|
||||
code?: string;
|
||||
details?: Record<string, unknown>;
|
||||
timestamp: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export async function errorHandlingMiddleware(fastify: FastifyInstance) {
|
||||
// Custom error handler
|
||||
fastify.setErrorHandler((error, request: FastifyRequest, reply: FastifyReply) => {
|
||||
const response: ErrorResponse = {
|
||||
error: error.name || 'Internal Server Error',
|
||||
message: error.message || 'An unexpected error occurred',
|
||||
statusCode: error.statusCode || 500,
|
||||
code: (error as any).code,
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
};
|
||||
|
||||
// Log error
|
||||
fastify.log.error({
|
||||
error: response,
|
||||
stack: error.stack,
|
||||
method: request.method,
|
||||
userAgent: request.headers['user-agent'],
|
||||
});
|
||||
|
||||
// Send standardized error response
|
||||
reply.status(response.statusCode).send(response);
|
||||
});
|
||||
|
||||
// 404 handler
|
||||
fastify.setNotFoundHandler((request: FastifyRequest, reply: FastifyReply) => {
|
||||
reply.status(404).send({
|
||||
error: 'Not Found',
|
||||
message: `Route ${request.method} ${request.url} not found`,
|
||||
statusCode: 404,
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
});
|
||||
});
|
||||
|
||||
// Validation error handler
|
||||
fastify.addHook('onError', async (request: FastifyRequest, reply: FastifyReply, error) => {
|
||||
if (error.validation) {
|
||||
reply.status(400).send({
|
||||
error: 'Validation Error',
|
||||
message: 'Request validation failed',
|
||||
statusCode: 400,
|
||||
code: 'VALIDATION_ERROR',
|
||||
details: error.validation,
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
66
packages/api/src/middleware/logging.middleware.ts
Normal file
66
packages/api/src/middleware/logging.middleware.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
|
||||
export interface RequestLog {
|
||||
method: string;
|
||||
url: string;
|
||||
statusCode: number;
|
||||
responseTime: number;
|
||||
requestId: string;
|
||||
userAgent?: string;
|
||||
clientIp: string;
|
||||
requestIdHeader?: string;
|
||||
}
|
||||
|
||||
export async function loggingMiddleware(fastify: FastifyInstance) {
|
||||
// Generate request ID if not present
|
||||
fastify.addHook('onRequest', (request: FastifyRequest, reply: FastifyReply, done) => {
|
||||
const requestId =
|
||||
request.headers['x-request-id'] ||
|
||||
request.headers['x-correlation-id'] ||
|
||||
`req-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
|
||||
request.headers['x-request-id'] = requestId;
|
||||
(request as any).requestId = requestId;
|
||||
|
||||
done();
|
||||
});
|
||||
|
||||
// Log request start
|
||||
fastify.addHook('onRequest', (request: FastifyRequest, reply: FastifyReply) => {
|
||||
fastify.log.info({
|
||||
event: 'request_start',
|
||||
method: request.method,
|
||||
url: request.url,
|
||||
requestId: (request as any).requestId,
|
||||
userAgent: request.headers['user-agent'],
|
||||
clientIp: request.ip || request.headers['x-forwarded-for'] || 'unknown',
|
||||
});
|
||||
});
|
||||
|
||||
// Log response
|
||||
fastify.addHook('onResponse', (request: FastifyRequest, reply: FastifyReply, done) => {
|
||||
const log: RequestLog = {
|
||||
method: request.method,
|
||||
url: request.url,
|
||||
statusCode: reply.statusCode,
|
||||
responseTime: reply.elapsedTime,
|
||||
requestId: (request as any).requestId,
|
||||
userAgent: request.headers['user-agent'],
|
||||
clientIp: request.ip || request.headers['x-forwarded-for'] || 'unknown',
|
||||
requestIdHeader: request.headers['x-request-id'] as string,
|
||||
};
|
||||
|
||||
// Log based on status code
|
||||
if (reply.statusCode < 300) {
|
||||
fastify.log.info(log);
|
||||
} else if (reply.statusCode < 400) {
|
||||
fastify.log.warn(log);
|
||||
} else if (reply.statusCode < 500) {
|
||||
fastify.log.warn(log);
|
||||
} else {
|
||||
fastify.log.error(log);
|
||||
}
|
||||
|
||||
done();
|
||||
});
|
||||
}
|
||||
116
packages/api/src/middleware/rate-limit.middleware.ts
Normal file
116
packages/api/src/middleware/rate-limit.middleware.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { apiEnv, rateLimitConfig } from '../config/api.config';
|
||||
|
||||
// Simple in-memory rate limiter
|
||||
// In production, this should use Redis or similar distributed store
|
||||
class RateLimiter {
|
||||
private store: Map<string, { count: number; resetTime: number }>;
|
||||
|
||||
constructor() {
|
||||
this.store = new Map();
|
||||
}
|
||||
|
||||
async checkLimit(
|
||||
key: string,
|
||||
windowMs: number,
|
||||
maxRequests: number
|
||||
): Promise<{ remaining: number; resetTime: number; retryAfter?: number }> {
|
||||
const now = Date.now();
|
||||
const current = this.store.get(key);
|
||||
|
||||
if (!current || now > current.resetTime) {
|
||||
// Reset window
|
||||
this.store.set(key, {
|
||||
count: 1,
|
||||
resetTime: now + windowMs,
|
||||
});
|
||||
|
||||
return {
|
||||
remaining: maxRequests - 1,
|
||||
resetTime: now + windowMs,
|
||||
};
|
||||
}
|
||||
|
||||
// Increment counter
|
||||
current.count++;
|
||||
this.store.set(key, current);
|
||||
|
||||
const remaining = maxRequests - current.count;
|
||||
|
||||
if (current.count > maxRequests) {
|
||||
return {
|
||||
remaining: 0,
|
||||
resetTime: current.resetTime,
|
||||
retryAfter: current.resetTime - now,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
remaining,
|
||||
resetTime: current.resetTime,
|
||||
};
|
||||
}
|
||||
|
||||
reset(key: string) {
|
||||
this.store.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
const rateLimiter = new RateLimiter();
|
||||
|
||||
export async function rateLimitMiddleware(fastify: FastifyInstance) {
|
||||
fastify.addHook('preHandler', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
// Skip rate limiting for health checks
|
||||
if (request.url === '/health') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get client identifier (IP or API key)
|
||||
const clientIp = request.ip || request.headers['x-forwarded-for'] || 'unknown';
|
||||
const apiKey = request.headers['x-api-key'] as string | undefined;
|
||||
const key = apiKey ? `api:${apiKey}` : `ip:${clientIp}`;
|
||||
|
||||
// Determine tier based on API key or default to basic
|
||||
let tier = 'basic';
|
||||
if (apiKey) {
|
||||
// In production, fetch tier from user/service lookup
|
||||
// For now, use a simple heuristic based on key format
|
||||
if (apiKey.startsWith('premium_')) {
|
||||
tier = 'premium';
|
||||
} else if (apiKey.startsWith('plus_')) {
|
||||
tier = 'plus';
|
||||
}
|
||||
}
|
||||
|
||||
const config = rateLimitConfig[tier as keyof typeof rateLimitConfig];
|
||||
const result = await rateLimiter.checkLimit(
|
||||
key,
|
||||
config.windowMs,
|
||||
config.maxRequests
|
||||
);
|
||||
|
||||
// Set rate limit headers
|
||||
reply.header('X-RateLimit-Limit', config.maxRequests);
|
||||
reply.header('X-RateLimit-Remaining', result.remaining);
|
||||
reply.header('X-RateLimit-Reset', Math.ceil(result.resetTime / 1000));
|
||||
|
||||
if (result.retryAfter) {
|
||||
reply.header('Retry-After', Math.ceil(result.retryAfter / 1000));
|
||||
reply.code(429); // Too Many Requests
|
||||
|
||||
return {
|
||||
error: 'Too Many Requests',
|
||||
message: `Rate limit exceeded. Try again in ${Math.ceil(result.retryAfter / 1000)}s`,
|
||||
tier,
|
||||
limit: config.maxRequests,
|
||||
reset: new Date(result.resetTime).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// Add tier info to request for downstream use
|
||||
(request as any).rateLimitTier = tier;
|
||||
});
|
||||
}
|
||||
|
||||
// Export for testing
|
||||
export { rateLimiter };
|
||||
164
packages/api/src/middleware/spam-rate-limit.middleware.ts
Normal file
164
packages/api/src/middleware/spam-rate-limit.middleware.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { redis } from '../config/redis';
|
||||
import { spamRateLimits } from '../services/spamshield/spamshield.config';
|
||||
|
||||
const REDIS_PREFIX = 'spamshield:ratelimit';
|
||||
|
||||
class RedisRateLimiter {
|
||||
async checkLimit(
|
||||
key: string,
|
||||
windowSeconds: number,
|
||||
maxRequests: number
|
||||
): Promise<{
|
||||
remaining: number;
|
||||
resetTime: number;
|
||||
retryAfter?: number;
|
||||
}> {
|
||||
const redisKey = `${REDIS_PREFIX}:${key}`;
|
||||
const now = Date.now();
|
||||
|
||||
const current = await redis.get(redisKey);
|
||||
const windowStart = now - (now % (windowSeconds * 1000));
|
||||
const resetTime = windowStart + windowSeconds * 1000;
|
||||
|
||||
if (!current) {
|
||||
const expirySeconds = Math.ceil((resetTime - now) / 1000);
|
||||
await redis.set(redisKey, '1', 'EX', expirySeconds);
|
||||
|
||||
return {
|
||||
remaining: maxRequests - 1,
|
||||
resetTime,
|
||||
};
|
||||
}
|
||||
|
||||
const count = parseInt(current, 10) + 1;
|
||||
await redis.set(redisKey, String(count), 'EX', Math.ceil((resetTime - now) / 1000));
|
||||
|
||||
const remaining = maxRequests - count;
|
||||
|
||||
if (count > maxRequests) {
|
||||
return {
|
||||
remaining: 0,
|
||||
resetTime,
|
||||
retryAfter: resetTime - now,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
remaining,
|
||||
resetTime,
|
||||
};
|
||||
}
|
||||
|
||||
async checkDailyLimit(
|
||||
key: string,
|
||||
maxPerDay: number
|
||||
): Promise<{
|
||||
remaining: number;
|
||||
retryAfter?: number;
|
||||
}> {
|
||||
const redisKey = `${REDIS_PREFIX}:daily:${key}`;
|
||||
const now = Date.now();
|
||||
const dayStart = new Date(now);
|
||||
dayStart.setHours(0, 0, 0, 0);
|
||||
const dayEnd = new Date(dayStart);
|
||||
dayEnd.setDate(dayEnd.getDate() + 1);
|
||||
const resetTime = dayEnd.getTime();
|
||||
|
||||
const current = await redis.get(redisKey);
|
||||
const expirySeconds = Math.ceil((resetTime - now) / 1000);
|
||||
|
||||
if (!current) {
|
||||
await redis.set(redisKey, '1', 'EX', expirySeconds);
|
||||
|
||||
return {
|
||||
remaining: maxPerDay - 1,
|
||||
};
|
||||
}
|
||||
|
||||
const count = parseInt(current, 10) + 1;
|
||||
await redis.set(redisKey, String(count), 'EX', expirySeconds);
|
||||
|
||||
const remaining = maxPerDay - count;
|
||||
|
||||
if (count > maxPerDay) {
|
||||
return {
|
||||
remaining: 0,
|
||||
retryAfter: resetTime - now,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
remaining,
|
||||
};
|
||||
}
|
||||
|
||||
reset(key: string) {
|
||||
const redisKey = `${REDIS_PREFIX}:${key}`;
|
||||
return redis.del(redisKey);
|
||||
}
|
||||
}
|
||||
|
||||
export const spamRateLimiter = new RedisRateLimiter();
|
||||
|
||||
export async function spamRateLimitMiddleware(fastify: FastifyInstance) {
|
||||
fastify.addHook('preHandler', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const url = request.url || '';
|
||||
|
||||
if (!url.startsWith('/spamshield')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clientIp = request.ip || (request.headers['x-forwarded-for'] as string) || 'unknown';
|
||||
const apiKey = request.headers['x-api-key'] as string | undefined;
|
||||
const key = apiKey ? `api:${apiKey}` : `ip:${clientIp}`;
|
||||
|
||||
let tier = 'basic';
|
||||
if (apiKey) {
|
||||
if (apiKey.startsWith('premium_')) {
|
||||
tier = 'premium';
|
||||
} else if (apiKey.startsWith('plus_')) {
|
||||
tier = 'plus';
|
||||
}
|
||||
}
|
||||
|
||||
const config = spamRateLimits[tier as keyof typeof spamRateLimits];
|
||||
|
||||
const minuteResult = await spamRateLimiter.checkLimit(
|
||||
key,
|
||||
60,
|
||||
config.analysesPerMinute
|
||||
);
|
||||
|
||||
const dailyResult = await spamRateLimiter.checkDailyLimit(
|
||||
key,
|
||||
config.analysesPerDay
|
||||
);
|
||||
|
||||
reply.header('X-RateLimit-Limit', config.analysesPerMinute);
|
||||
reply.header('X-RateLimit-Remaining', minuteResult.remaining);
|
||||
reply.header('X-RateLimit-Reset', Math.ceil(minuteResult.resetTime / 1000));
|
||||
reply.header('X-RateLimit-Daily-Limit', config.analysesPerDay);
|
||||
reply.header('X-RateLimit-Daily-Remaining', dailyResult.remaining);
|
||||
|
||||
const retryAfter = minuteResult.retryAfter || dailyResult.retryAfter;
|
||||
|
||||
if (retryAfter) {
|
||||
reply.header('Retry-After', Math.ceil(retryAfter / 1000));
|
||||
reply.code(429);
|
||||
|
||||
return {
|
||||
error: 'Too Many Requests',
|
||||
message: `Spam analysis rate limit exceeded. Try again in ${Math.ceil(retryAfter / 1000)}s`,
|
||||
tier,
|
||||
limit: config.analysesPerMinute,
|
||||
dailyLimit: config.analysesPerDay,
|
||||
reset: new Date(minuteResult.resetTime).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
(request as any).spamRateLimitTier = tier;
|
||||
});
|
||||
}
|
||||
|
||||
export { RedisRateLimiter };
|
||||
Reference in New Issue
Block a user