From 1197fe48f74f0d222e04cdeb5dd76f2d023c7303 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Sat, 2 May 2026 10:16:18 -0400 Subject: [PATCH] FRE-4533: Merge apps/{api,web,mobile} and shared-db into ShieldAI repo - Copy apps/api (Fastify server with spamshield/voiceprint/darkwatch services) - Copy apps/web (SolidJS web app) - Copy apps/mobile (SolidJS mobile app) - Copy packages/shared-db (Prisma schema/models) - Add apps/* to pnpm-workspace.yaml --- apps/api/package.json | 29 + .../sms-classifier-race-condition.test.ts | 144 +++++ .../api/src/__tests__/spam-rate-limit.test.ts | 98 +++ apps/api/src/config/api.config.ts | 55 ++ apps/api/src/config/redis.ts | 18 + apps/api/src/index.ts | 106 ++++ apps/api/src/middleware/auth.middleware.ts | 86 +++ .../middleware/error-handling.middleware.ts | 62 ++ apps/api/src/middleware/logging.middleware.ts | 66 ++ .../src/middleware/rate-limit.middleware.ts | 116 ++++ .../middleware/spam-rate-limit.middleware.ts | 164 +++++ apps/api/src/routes/darkwatch.routes.ts | 285 +++++++++ apps/api/src/routes/index.ts | 142 +++++ apps/api/src/routes/notifications.routes.ts | 213 +++++++ apps/api/src/routes/spamshield.routes.ts | 252 ++++++++ apps/api/src/routes/voiceprint.routes.ts | 257 ++++++++ .../src/services/darkwatch/alert.pipeline.ts | 174 +++++ apps/api/src/services/darkwatch/index.ts | 5 + .../src/services/darkwatch/scan.service.ts | 220 +++++++ .../services/darkwatch/scheduler.service.ts | 155 +++++ .../services/darkwatch/watchlist.service.ts | 97 +++ .../src/services/darkwatch/webhook.service.ts | 226 +++++++ .../src/services/spamshield/feature-flags.ts | 227 +++++++ apps/api/src/services/spamshield/index.ts | 26 + .../spamshield/spamshield.audit-logger.ts | 118 ++++ .../services/spamshield/spamshield.config.ts | 163 +++++ .../spamshield/spamshield.error-handler.ts | 118 ++++ .../services/spamshield/spamshield.service.ts | 462 ++++++++++++++ apps/api/src/services/voiceprint/index.ts | 30 + .../services/voiceprint/voiceprint.config.ts | 102 +++ .../voiceprint/voiceprint.feature-flags.ts | 7 + .../services/voiceprint/voiceprint.service.ts | 594 ++++++++++++++++++ apps/api/tsconfig.json | 12 + apps/mobile/package.json | 22 + apps/web/package.json | 24 + packages/shared-db/drizzle.config.ts | 12 + packages/shared-db/package.json | 23 + packages/shared-db/prisma/schema.prisma | 437 +++++++++++++ packages/shared-db/src/client.ts | 50 ++ packages/shared-db/src/index.ts | 21 + packages/shared-db/tsconfig.json | 12 + pnpm-workspace.yaml | 1 + 42 files changed, 5431 insertions(+) create mode 100644 apps/api/package.json create mode 100644 apps/api/src/__tests__/sms-classifier-race-condition.test.ts create mode 100644 apps/api/src/__tests__/spam-rate-limit.test.ts create mode 100644 apps/api/src/config/api.config.ts create mode 100644 apps/api/src/config/redis.ts create mode 100644 apps/api/src/index.ts create mode 100644 apps/api/src/middleware/auth.middleware.ts create mode 100644 apps/api/src/middleware/error-handling.middleware.ts create mode 100644 apps/api/src/middleware/logging.middleware.ts create mode 100644 apps/api/src/middleware/rate-limit.middleware.ts create mode 100644 apps/api/src/middleware/spam-rate-limit.middleware.ts create mode 100644 apps/api/src/routes/darkwatch.routes.ts create mode 100644 apps/api/src/routes/index.ts create mode 100644 apps/api/src/routes/notifications.routes.ts create mode 100644 apps/api/src/routes/spamshield.routes.ts create mode 100644 apps/api/src/routes/voiceprint.routes.ts create mode 100644 apps/api/src/services/darkwatch/alert.pipeline.ts create mode 100644 apps/api/src/services/darkwatch/index.ts create mode 100644 apps/api/src/services/darkwatch/scan.service.ts create mode 100644 apps/api/src/services/darkwatch/scheduler.service.ts create mode 100644 apps/api/src/services/darkwatch/watchlist.service.ts create mode 100644 apps/api/src/services/darkwatch/webhook.service.ts create mode 100644 apps/api/src/services/spamshield/feature-flags.ts create mode 100644 apps/api/src/services/spamshield/index.ts create mode 100644 apps/api/src/services/spamshield/spamshield.audit-logger.ts create mode 100644 apps/api/src/services/spamshield/spamshield.config.ts create mode 100644 apps/api/src/services/spamshield/spamshield.error-handler.ts create mode 100644 apps/api/src/services/spamshield/spamshield.service.ts create mode 100644 apps/api/src/services/voiceprint/index.ts create mode 100644 apps/api/src/services/voiceprint/voiceprint.config.ts create mode 100644 apps/api/src/services/voiceprint/voiceprint.feature-flags.ts create mode 100644 apps/api/src/services/voiceprint/voiceprint.service.ts create mode 100644 apps/api/tsconfig.json create mode 100644 apps/mobile/package.json create mode 100644 apps/web/package.json create mode 100644 packages/shared-db/drizzle.config.ts create mode 100644 packages/shared-db/package.json create mode 100644 packages/shared-db/prisma/schema.prisma create mode 100644 packages/shared-db/src/client.ts create mode 100644 packages/shared-db/src/index.ts create mode 100644 packages/shared-db/tsconfig.json diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..855aa4c --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,29 @@ +{ + "name": "api", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "lint": "eslint src/" + }, + "dependencies": { + "@fastify/cors": "^11.2.0", + "@fastify/helmet": "^13.0.2", + "@shieldsai/shared-analytics": "*", + "@shieldsai/shared-auth": "*", + "@shieldsai/shared-billing": "*", + "@shieldsai/shared-db": "*", + "@shieldsai/shared-notifications": "*", + "@shieldsai/shared-utils": "*", + "fastify": "^4.25.0", + "fastify-plugin": "^4.5.0", + "ioredis": "^5.3.0" + }, + "devDependencies": { + "@types/node": "^25.6.0", + "tsx": "^4.7.1", + "typescript": "^5.3.3" + } +} diff --git a/apps/api/src/__tests__/sms-classifier-race-condition.test.ts b/apps/api/src/__tests__/sms-classifier-race-condition.test.ts new file mode 100644 index 0000000..675c750 --- /dev/null +++ b/apps/api/src/__tests__/sms-classifier-race-condition.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { SMSClassifierService } from '../services/spamshield/spamshield.service'; + +// Mock shared-db before anything else (Prisma client is not generated in test env) +vi.mock('@shieldsai/shared-db', () => ({ + prisma: {}, + SpamFeedback: {}, +})); + +// Mock the feature flags module to control enableMLClassifier +vi.mock('../services/spamshield/spamshield.config', () => ({ + spamShieldEnv: { + SPAM_THRESHOLD_AUTO_BLOCK: 0.85, + SPAM_THRESHOLD_FLAG: 0.6, + }, + spamFeatureFlags: { + enableMLClassifier: true, + }, + SpamDecision: { + ALLOW: 'allow', + FLAG: 'flag', + BLOCK: 'block', + CHALLENGE: 'challenge', + }, + SpamLayer: { + NUMBER_REPUTATION: 'number_reputation', + CONTENT_CLASSIFICATION: 'content_classification', + BEHAVIORAL_ANALYSIS: 'behavioral_analysis', + COMMUNITY_INTELLIGENCE: 'community_intelligence', + }, + ConfidenceLevel: { + LOW: 'low', + MEDIUM: 'medium', + HIGH: 'high', + VERY_HIGH: 'very_high', + }, + spamRateLimits: {}, +})); + +describe('SMSClassifierService', () => { + let classifier: SMSClassifierService; + let initializeCalls: number; + let initializeDelay: Promise; + + beforeEach(() => { + // Re-import after mock to get fresh module state + initializeCalls = 0; + initializeDelay = new Promise(resolve => setTimeout(resolve, 50)); + + classifier = new SMSClassifierService(); + // Override initialize to track calls and add delay + classifier.initialize = async () => { + initializeCalls++; + await initializeDelay; + }; + }); + + describe('initialization race condition', () => { + it('should call initialize only once under concurrent classify calls', async () => { + const promises = Array.from({ length: 10 }, () => + classifier.classify('ACT NOW - Limited offer!'), + ); + + const results = await Promise.all(promises); + + expect(initializeCalls).toBe(1); + expect(results).toHaveLength(10); + results.forEach(r => { + expect(r).toHaveProperty('isSpam'); + expect(r).toHaveProperty('confidence'); + expect(r).toHaveProperty('spamFeatures'); + }); + }); + + it('should handle interleaved calls after partial initialization', async () => { + const batch1 = Array.from({ length: 5 }, () => + classifier.classify('First batch message'), + ); + + await Promise.all(batch1); + + expect(initializeCalls).toBe(1); + + const batch2 = Array.from({ length: 5 }, () => + classifier.classify('Second batch message'), + ); + + await Promise.all(batch2); + + // initialize should still only have been called once + expect(initializeCalls).toBe(1); + }); + + it('should return consistent results for same input under concurrency', async () => { + const text = 'URGENT: Click http://example.com now!'; + const promises = Array.from({ length: 20 }, () => + classifier.classify(text), + ); + + const results = await Promise.all(promises); + + const firstResult = results[0]; + results.forEach((r, i) => { + expect(r.isSpam).toBe(firstResult.isSpam); + expect(r.confidence).toBe(firstResult.confidence); + expect(r.spamFeatures).toEqual(firstResult.spamFeatures); + }); + }); + + it('should handle rapid sequential calls without re-initializing', async () => { + for (let i = 0; i < 50; i++) { + await classifier.classify(`Message ${i}`); + } + + expect(initializeCalls).toBe(1); + }); + }); + + describe('feature extraction', () => { + it('should detect URL presence', async () => { + const result = await classifier.classify('Visit www.example.com'); + expect(result.spamFeatures).toContain('url_present'); + }); + + it('should detect urgency keywords', async () => { + const result = await classifier.classify('Act now! This offer is urgent.'); + expect(result.spamFeatures).toContain('urgency_keyword'); + }); + + it('should detect excessive capitalization', async () => { + const result = await classifier.classify('BUY THIS NOW!!!'); + expect(result.spamFeatures).toContain('excessive_caps'); + }); + + it('should detect multiple features', async () => { + const result = await classifier.classify( + 'URGENT: Visit www.example.com NOW!!!', + ); + expect(result.spamFeatures).toContain('url_present'); + expect(result.spamFeatures).toContain('urgency_keyword'); + expect(result.spamFeatures).toContain('excessive_caps'); + }); + }); +}); diff --git a/apps/api/src/__tests__/spam-rate-limit.test.ts b/apps/api/src/__tests__/spam-rate-limit.test.ts new file mode 100644 index 0000000..b907d46 --- /dev/null +++ b/apps/api/src/__tests__/spam-rate-limit.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; +import { RedisRateLimiter } from '../middleware/spam-rate-limit.middleware'; +import { redis } from '../config/redis'; + +describe('RedisRateLimiter', () => { + const testKey = 'test-client'; + const limiter = new RedisRateLimiter(); + + beforeAll(async () => { + await redis.connect(); + }); + + afterAll(async () => { + await redis.quit(); + }); + + beforeEach(async () => { + await redis.del('spamshield:ratelimit:test-client'); + await redis.del('spamshield:ratelimit:daily:test-client'); + }); + + afterEach(async () => { + await redis.del('spamshield:ratelimit:test-client'); + await redis.del('spamshield:ratelimit:daily:test-client'); + }); + + describe('checkLimit (per-minute)', () => { + it('should allow requests within the limit', async () => { + const result = await limiter.checkLimit(testKey, 60, 10); + + expect(result.remaining).toBe(9); + expect(result.retryAfter).toBeUndefined(); + }); + + it('should decrement remaining on each request', async () => { + const result1 = await limiter.checkLimit(testKey, 60, 10); + const result2 = await limiter.checkLimit(testKey, 60, 10); + + expect(result1.remaining).toBe(9); + expect(result2.remaining).toBe(8); + }); + + it('should exceed limit after max requests', async () => { + for (let i = 0; i < 10; i++) { + await limiter.checkLimit(testKey, 60, 10); + } + + const result = await limiter.checkLimit(testKey, 60, 10); + + expect(result.remaining).toBe(0); + expect(result.retryAfter).toBeGreaterThan(0); + }); + + it('should return retry-after when limit is exceeded', async () => { + for (let i = 0; i < 10; i++) { + await limiter.checkLimit(testKey, 60, 10); + } + + const result = await limiter.checkLimit(testKey, 60, 10); + + expect(result.retryAfter).toBeGreaterThan(0); + expect(result.retryAfter).toBeLessThanOrEqual(60000); + }); + }); + + describe('checkDailyLimit', () => { + it('should allow requests within daily limit', async () => { + const result = await limiter.checkDailyLimit(testKey, 100); + + expect(result.remaining).toBe(99); + expect(result.retryAfter).toBeUndefined(); + }); + + it('should exceed daily limit after max requests', async () => { + for (let i = 0; i < 100; i++) { + await limiter.checkDailyLimit(testKey, 100); + } + + const result = await limiter.checkDailyLimit(testKey, 100); + + expect(result.remaining).toBe(0); + expect(result.retryAfter).toBeGreaterThan(0); + }); + }); + + describe('reset', () => { + it('should clear the rate limit counter', async () => { + await limiter.checkLimit(testKey, 60, 10); + await limiter.checkLimit(testKey, 60, 10); + + await limiter.reset(testKey); + + const result = await limiter.checkLimit(testKey, 60, 10); + + expect(result.remaining).toBe(9); + }); + }); +}); diff --git a/apps/api/src/config/api.config.ts b/apps/api/src/config/api.config.ts new file mode 100644 index 0000000..8812edb --- /dev/null +++ b/apps/api/src/config/api.config.ts @@ -0,0 +1,55 @@ +import { z } from 'zod'; + +// Environment variables +const envSchema = z.object({ + NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), + PORT: z.string().transform(Number).default(3000), + HOST: z.string().default('0.0.0.0'), + API_RATE_LIMIT_WINDOW: z.string().transform(Number).default(60000), // 1 minute + API_RATE_LIMIT_MAX_REQUESTS: z.string().transform(Number).default(100), + CORS_ORIGIN: z.string().default('http://localhost:5173'), +}); + +export const apiEnv = envSchema.parse({ + NODE_ENV: process.env.NODE_ENV, + PORT: process.env.PORT, + HOST: process.env.HOST, + API_RATE_LIMIT_WINDOW: process.env.API_RATE_LIMIT_WINDOW, + API_RATE_LIMIT_MAX_REQUESTS: process.env.API_RATE_LIMIT_MAX_REQUESTS, + CORS_ORIGIN: process.env.CORS_ORIGIN, +}); + +// Rate limit configuration by tier +export const rateLimitConfig = { + basic: { + windowMs: 60000, // 1 minute + maxRequests: 100, + }, + plus: { + windowMs: 60000, + maxRequests: 500, + }, + premium: { + windowMs: 60000, + maxRequests: 2000, + }, +}; + +// API versioning configuration +export const apiVersioning = { + defaultVersion: '1', + headerName: 'X-API-Version', + queryParam: 'api-version', +}; + +// Logging configuration +export const loggingConfig = { + level: apiEnv.NODE_ENV === 'production' ? 'info' : 'debug', + transport: apiEnv.NODE_ENV === 'development' ? { + target: 'pino-pretty', + options: { + colorize: true, + translateTime: true, + }, + } : undefined, +}; diff --git a/apps/api/src/config/redis.ts b/apps/api/src/config/redis.ts new file mode 100644 index 0000000..754b2d5 --- /dev/null +++ b/apps/api/src/config/redis.ts @@ -0,0 +1,18 @@ +import { Redis } from 'ioredis'; + +const redisHost = process.env.REDIS_HOST || 'localhost'; +const redisPort = parseInt(process.env.REDIS_PORT || '6379', 10); + +export const redis = new Redis({ + host: redisHost, + port: redisPort, + retryStrategy: (times: number) => Math.min(times * 50, 2000), + lazyConnect: true, +}); + +export async function getRedisConnection(): Promise { + if (redis.status === 'wait' || redis.status === 'connecting') { + await redis.connect(); + } + return redis; +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts new file mode 100644 index 0000000..e0b718f --- /dev/null +++ b/apps/api/src/index.ts @@ -0,0 +1,106 @@ +import Fastify from 'fastify'; +import cors from '@fastify/cors'; +import helmet from '@fastify/helmet'; +import { authMiddleware } from './middleware/auth.middleware'; +import { rateLimitMiddleware } from './middleware/rate-limit.middleware'; +import { spamRateLimitMiddleware } from './middleware/spam-rate-limit.middleware'; +import { errorHandlingMiddleware } from './middleware/error-handling.middleware'; +import { loggingMiddleware } from './middleware/logging.middleware'; +import { apiEnv, loggingConfig } from './config/api.config'; +import { routes } from './routes'; + +const fastify = Fastify({ + logger: loggingConfig, + ignoreTrailingSlash: true, + maxParamLength: 500, +}); + +// Register plugins +async function registerPlugins() { + // CORS configuration + await fastify.register(cors, { + origin: apiEnv.CORS_ORIGIN, + methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], + credentials: true, + }); + + // Security headers + await fastify.register(helmet, { + global: true, + contentSecurityPolicy: false, + }); + + // Rate limiting + await fastify.register(rateLimitMiddleware); + + // SpamShield rate limiting (Redis-backed) + await fastify.register(spamRateLimitMiddleware); + + // Authentication + await fastify.register(authMiddleware); + + // Logging + await fastify.register(loggingMiddleware); + + // Error handling + await fastify.register(errorHandlingMiddleware); +} + +// Register routes +async function registerRoutes() { + await fastify.register(routes, { prefix: '/api/v1' }); +} + +// Health check endpoint +fastify.get('/health', async () => { + return { status: 'ok', timestamp: new Date().toISOString() }; +}); + +// Root endpoint +fastify.get('/', async () => { + return { + name: 'FrenoCorp API Gateway', + version: '1.0.0', + environment: apiEnv.NODE_ENV, + }; +}); + +// Start server +async function start() { + await registerPlugins(); + await registerRoutes(); + + try { + await fastify.listen({ + port: apiEnv.PORT, + host: apiEnv.HOST, + }); + + console.log(`šŸš€ API Gateway running at http://${apiEnv.HOST}:${apiEnv.PORT}`); + console.log(`šŸ“ Environment: ${apiEnv.NODE_ENV}`); + console.log(`šŸ“Š Rate limit window: ${apiEnv.API_RATE_LIMIT_WINDOW}ms`); + console.log(`šŸ“ˆ Max requests: ${apiEnv.API_RATE_LIMIT_MAX_REQUESTS}`); + } catch (err) { + console.error(err); + process.exit(1); + } +} + +// Graceful shutdown +const gracefulShutdown = async (signal: string) => { + console.log(`\nšŸ›‘ ${signal} received, shutting down gracefully...`); + await fastify.close(); + console.log('āœ… Server closed'); + process.exit(0); +}; + +process.on('SIGINT', () => gracefulShutdown('SIGINT')); +process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); + +// Export for testing +export { fastify }; + +// Start if running directly +if (process.argv[1] === new URL(import.meta.url).pathname) { + start(); +} diff --git a/apps/api/src/middleware/auth.middleware.ts b/apps/api/src/middleware/auth.middleware.ts new file mode 100644 index 0000000..c14e058 --- /dev/null +++ b/apps/api/src/middleware/auth.middleware.ts @@ -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; + }; + }); +} diff --git a/apps/api/src/middleware/error-handling.middleware.ts b/apps/api/src/middleware/error-handling.middleware.ts new file mode 100644 index 0000000..6bd2cbb --- /dev/null +++ b/apps/api/src/middleware/error-handling.middleware.ts @@ -0,0 +1,62 @@ +import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; + +export interface ErrorResponse { + error: string; + message: string; + statusCode: number; + code?: string; + details?: Record; + 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, + }); + } + }); +} diff --git a/apps/api/src/middleware/logging.middleware.ts b/apps/api/src/middleware/logging.middleware.ts new file mode 100644 index 0000000..20809d1 --- /dev/null +++ b/apps/api/src/middleware/logging.middleware.ts @@ -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(); + }); +} diff --git a/apps/api/src/middleware/rate-limit.middleware.ts b/apps/api/src/middleware/rate-limit.middleware.ts new file mode 100644 index 0000000..433f833 --- /dev/null +++ b/apps/api/src/middleware/rate-limit.middleware.ts @@ -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; + + 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 }; diff --git a/apps/api/src/middleware/spam-rate-limit.middleware.ts b/apps/api/src/middleware/spam-rate-limit.middleware.ts new file mode 100644 index 0000000..f56e373 --- /dev/null +++ b/apps/api/src/middleware/spam-rate-limit.middleware.ts @@ -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 }; diff --git a/apps/api/src/routes/darkwatch.routes.ts b/apps/api/src/routes/darkwatch.routes.ts new file mode 100644 index 0000000..161b020 --- /dev/null +++ b/apps/api/src/routes/darkwatch.routes.ts @@ -0,0 +1,285 @@ +import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import { prisma, SubscriptionTier } from '@shieldsai/shared-db'; +import { tierConfig, SubscriptionTier as BillingTier } from '@shieldsai/shared-billing'; +import { + watchlistService, + scanService, + schedulerService, + webhookService, +} from '../services/darkwatch'; + +export async function darkwatchRoutes(fastify: FastifyInstance) { + const authed = async ( + request: FastifyRequest, + reply: FastifyReply + ): Promise => { + const authReq = request as FastifyRequest & { user?: { id: string } }; + const userId = authReq.user?.id; + if (!userId) { + reply.code(401).send({ error: 'User ID required' }); + return null; + } + + const subscription = await prisma.subscription.findFirst({ + where: { userId, status: 'active' }, + select: { id: true, tier: true }, + }); + + if (!subscription) { + reply.code(404).send({ error: 'Active subscription not found' }); + return null; + } + + return subscription.id; + }; + + // GET /darkwatch/watchlist - List watchlist items + fastify.get('/watchlist', async (request: FastifyRequest, reply: FastifyReply) => { + const subscriptionId = await authed(request, reply); + if (!subscriptionId) return; + + try { + const items = await watchlistService.getItems(subscriptionId); + return reply.send({ items }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to list watchlist'; + return reply.code(500).send({ error: message }); + } + }); + + // POST /darkwatch/watchlist - Add watchlist item + fastify.post('/watchlist', async (request: FastifyRequest, reply: FastifyReply) => { + const authReq = request as FastifyRequest & { user?: { id: string } }; + const userId = authReq.user?.id; + if (!userId) { + return reply.code(401).send({ error: 'User ID required' }); + } + + const subscription = await prisma.subscription.findFirst({ + where: { userId, status: 'active' }, + select: { id: true, tier: true }, + }); + + if (!subscription) { + return reply.code(404).send({ error: 'Active subscription not found' }); + } + + const body = request.body as { type: string; value: string }; + + if (!body.type || !body.value) { + return reply.code(400).send({ error: 'type and value are required' }); + } + + const maxItems = tierConfig[subscription.tier as BillingTier].features.maxWatchlistItems; + + try { + const item = await watchlistService.addItem( + subscription.id, + body.type, + body.value, + maxItems + ); + return reply.code(201).send({ item }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to add watchlist item'; + return reply.code(422).send({ error: message }); + } + }); + + // DELETE /darkwatch/watchlist/:id - Remove watchlist item + fastify.delete('/watchlist/:id', async (request: FastifyRequest, reply: FastifyReply) => { + const subscriptionId = await authed(request, reply); + if (!subscriptionId) return; + + const id = (request.params as { id: string }).id; + + try { + const item = await watchlistService.removeItem(id, subscriptionId); + return reply.send({ item }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to remove watchlist item'; + return reply.code(422).send({ error: message }); + } + }); + + // POST /darkwatch/scan - Trigger on-demand scan + fastify.post('/scan', async (request: FastifyRequest, reply: FastifyReply) => { + const subscriptionId = await authed(request, reply); + if (!subscriptionId) return; + + try { + const job = await schedulerService.enqueueOnDemandScan(subscriptionId); + return reply.send({ + job: { + id: job?.id, + status: 'queued', + }, + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to trigger scan'; + return reply.code(422).send({ error: message }); + } + }); + + // GET /darkwatch/scan/schedule - Get scan schedule + fastify.get('/scan/schedule', async (request: FastifyRequest, reply: FastifyReply) => { + const subscriptionId = await authed(request, reply); + if (!subscriptionId) return; + + try { + const schedule = await schedulerService.getScanSchedule(subscriptionId); + return reply.send({ schedule }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to get schedule'; + return reply.code(500).send({ error: message }); + } + }); + + // GET /darkwatch/exposures - List exposures + fastify.get('/exposures', async (request: FastifyRequest, reply: FastifyReply) => { + const subscriptionId = await authed(request, reply); + if (!subscriptionId) return; + + try { + const exposures = await prisma.exposure.findMany({ + where: { subscriptionId }, + orderBy: { detectedAt: 'desc' }, + take: 50, + include: { + watchlistItem: true, + }, + }); + return reply.send({ exposures }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to list exposures'; + return reply.code(500).send({ error: message }); + } + }); + + // GET /darkwatch/alerts - List alerts + fastify.get('/alerts', async (request: FastifyRequest, reply: FastifyReply) => { + const authReq = request as FastifyRequest & { user?: { id: string } }; + const userId = authReq.user?.id; + if (!userId) { + return reply.code(401).send({ error: 'User ID required' }); + } + + try { + const alerts = await prisma.alert.findMany({ + where: { userId }, + orderBy: { createdAt: 'desc' }, + take: 50, + include: { + exposure: true, + }, + }); + return reply.send({ alerts }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to list alerts'; + return reply.code(500).send({ error: message }); + } + }); + + // PATCH /darkwatch/alerts/:id/read - Mark alert as read + fastify.patch('/alerts/:id/read', async (request: FastifyRequest, reply: FastifyReply) => { + const authReq = request as FastifyRequest & { user?: { id: string } }; + const userId = authReq.user?.id; + if (!userId) { + return reply.code(401).send({ error: 'User ID required' }); + } + + const id = (request.params as { id: string }).id; + + try { + const alert = await prisma.alert.update({ + where: { id }, + data: { isRead: true, readAt: new Date() }, + }); + return reply.send({ alert }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to mark alert as read'; + return reply.code(422).send({ error: message }); + } + }); + + // POST /darkwatch/webhook - External webhook receiver + fastify.post('/webhook', async (request: FastifyRequest, reply: FastifyReply) => { + const body = request.body as Record; + + const source = typeof body.source === 'string' ? body.source : ''; + const identifier = typeof body.identifier === 'string' ? body.identifier : ''; + const identifierType = typeof body.identifierType === 'string' ? body.identifierType : ''; + const metadata = body.metadata as Record | undefined; + const timestamp = typeof body.timestamp === 'string' ? body.timestamp : new Date().toISOString(); + + if (!source || !identifier || !identifierType) { + return reply.code(400).send({ + error: 'source, identifier, and identifierType are required', + }); + } + + const signature = request.headers['x-webhook-signature'] as string | undefined; + const webhookTimestamp = request.headers['x-webhook-timestamp'] as string | undefined; + + if (!signature || !webhookTimestamp) { + return reply.code(401).send({ error: 'Webhook signature and timestamp required' }); + } + + const valid = await webhookService.verifyWebhookSignature( + JSON.stringify(body), + signature, + webhookTimestamp + ); + if (!valid) { + return reply.code(401).send({ error: 'Invalid webhook signature' }); + } + + try { + const result = await webhookService.processExternalWebhook({ + source, + identifier, + identifierType, + metadata, + timestamp, + }); + + return reply.send({ + processed: true, + exposuresCreated: result.exposuresCreated, + alertsCreated: result.alertsCreated, + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Webhook processing failed'; + console.error('[DarkWatch:Webhook] Error:', message); + return reply.code(500).send({ error: 'Webhook processing failed' }); + } + }); + + // POST /darkwatch/scheduler/init - Initialize scheduled scans for all subscriptions + fastify.post('/scheduler/init', async (request: FastifyRequest, reply: FastifyReply) => { + try { + const jobsEnqueued = await schedulerService.scheduleSubscriptionScans(); + return reply.send({ + scheduled: jobsEnqueued.length, + jobs: jobsEnqueued, + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Scheduler init failed'; + return reply.code(500).send({ error: message }); + } + }); + + // POST /darkwatch/scheduler/reschedule - Reschedule all scans + fastify.post('/scheduler/reschedule', async (request: FastifyRequest, reply: FastifyReply) => { + try { + const jobsEnqueued = await schedulerService.rescheduleAll(); + return reply.send({ + rescheduled: jobsEnqueued.length, + jobs: jobsEnqueued, + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Scheduler reschedule failed'; + return reply.code(500).send({ error: message }); + } + }); +} diff --git a/apps/api/src/routes/index.ts b/apps/api/src/routes/index.ts new file mode 100644 index 0000000..cfde1ed --- /dev/null +++ b/apps/api/src/routes/index.ts @@ -0,0 +1,142 @@ +import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import { authMiddleware, AuthRequest } from './auth.middleware'; +import { voiceprintRoutes } from './voiceprint.routes'; +import { spamshieldRoutes } from './spamshield.routes'; +import { darkwatchRoutes } from './darkwatch.routes'; + +export async function routes(fastify: FastifyInstance) { + // Authenticated routes group + fastify.register( + async (authenticated) => { + // Add auth requirement + authenticated.addHook('onRequest', async (request: FastifyRequest, reply: FastifyReply) => { + await fastify.requireAuth(request as AuthRequest); + }); + + // Example authenticated endpoint + authenticated.get('/user/me', async (request: FastifyRequest, reply: FastifyReply) => { + const authReq = request as AuthRequest; + return { + user: authReq.user, + authType: authReq.authType, + }; + }); + + // Example service endpoint + authenticated.get('/services', async (request: FastifyRequest, reply: FastifyReply) => { + return { + services: [ + { + name: 'user-service', + url: '/api/v1/services/user', + status: 'healthy', + }, + { + name: 'billing-service', + url: '/api/v1/services/billing', + status: 'healthy', + }, + { + name: 'notification-service', + url: '/api/v1/services/notifications', + status: 'healthy', + }, + ], + }; + }); + }, + { prefix: '/auth' } + ); + + // Public API routes + fastify.register( + async (publicRouter) => { + // Version info + publicRouter.get('/info', async () => { + return { + version: '1.0.0', + environment: process.env.NODE_ENV || 'development', + build: process.env.npm_package_version || 'unknown', + }; + }); + + // API documentation + publicRouter.get('/docs', async () => { + return { + title: 'FrenoCorp API Gateway', + version: '1.0.0', + endpoints: { + public: [ + { method: 'GET', path: '/', description: 'Root endpoint' }, + { method: 'GET', path: '/health', description: 'Health check' }, + { method: 'GET', path: '/api/v1/info', description: 'API version info' }, + { method: 'GET', path: '/api/v1/docs', description: 'API documentation' }, + ], + authenticated: [ + { method: 'GET', path: '/api/v1/auth/user/me', description: 'Get current user' }, + { method: 'GET', path: '/api/v1/auth/services', description: 'List available services' }, + ], + }, + }; + }); + }, + { prefix: '/api/v1' } + ); + + // Service proxy placeholder (for future microservice routing) + fastify.register( + async (services) => { + services.get('/services/user', async (request, reply) => { + // In production, proxy to actual user service + return { + service: 'user-service', + message: 'User service endpoint', + timestamp: new Date().toISOString(), + }; + }); + + services.get('/services/billing', async (request, reply) => { + // In production, proxy to actual billing service + return { + service: 'billing-service', + message: 'Billing service endpoint', + timestamp: new Date().toISOString(), + }; + }); + + services.get('/services/notifications', async (request, reply) => { + // In production, proxy to actual notification service + return { + service: 'notification-service', + message: 'Notification service endpoint', + timestamp: new Date().toISOString(), + }; + }); + }, + { prefix: '/api/v1/services' } + ); + + // VoicePrint service routes + fastify.register( + async (voiceprintRouter) => { + await voiceprintRoutes(voiceprintRouter); + }, + { prefix: '/voiceprint' } + ); + + // SpamShield service routes + fastify.register( + async (spamshieldRouter) => { + await spamshieldRoutes(spamshieldRouter); + }, + { prefix: '/spamshield' } + ); + + // DarkWatch service routes + fastify.register( + async (darkwatchRouter) => { + await darkwatchRoutes(darkwatchRouter); + }, + { prefix: '/darkwatch' } + ); +} diff --git a/apps/api/src/routes/notifications.routes.ts b/apps/api/src/routes/notifications.routes.ts new file mode 100644 index 0000000..b99a991 --- /dev/null +++ b/apps/api/src/routes/notifications.routes.ts @@ -0,0 +1,213 @@ +import { FastifyInstance } from 'fastify'; +import { NotificationService } from '@shieldsai/shared-notifications'; + +export async function notificationRoutes(fastify: FastifyInstance): Promise { + let notificationService: NotificationService | undefined; + + // Initialize notification service (will be injected via config) + fastify.addHook('onReady', async () => { + // Notification service will be initialized from config + notificationService = fastify.notificationService; + }); + + /** + * POST /api/v1/notifications/send + * Send a notification to a user + */ + fastify.post( + '/notifications/send', + { + schema: { + body: { + type: 'object', + required: ['userId', 'channel', 'subject', 'body'], + properties: { + userId: { type: 'string' }, + channel: { type: 'string', enum: ['email', 'push', 'sms'] }, + subject: { type: 'string' }, + body: { type: 'string' }, + email: { type: 'string' }, + phone: { type: 'string' }, + fcmToken: { type: 'string' }, + apnsToken: { type: 'string' }, + priority: { type: 'string', enum: ['low', 'normal', 'high', 'urgent'] }, + metadata: { type: 'object' }, + }, + }, + }, + }, + async (request, reply) => { + const { userId, channel, subject, body, priority, metadata } = request.body; + + const recipient = { + userId, + email: request.body.email, + phone: request.body.phone, + fcmToken: request.body.fcmToken, + apnsToken: request.body.apnsToken, + }; + + try { + if (!notificationService) { + return reply.status(503).send({ + success: false, + error: 'Notification service not initialized', + }); + } + + const notifications = await notificationService.sendMultiChannelNotification( + recipient, + channel, + subject, + body, + priority, + metadata + ); + + return reply.send({ + success: true, + notifications, + }); + } catch (error) { + return reply.status(500).send({ + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + } + ); + + /** + * GET /api/v1/notifications/:userId/preferences + * Get notification preferences for a user + */ + fastify.get( + '/notifications/:userId/preferences', + { + schema: { + params: { + type: 'object', + required: ['userId'], + properties: { + userId: { type: 'string' }, + }, + }, + }, + }, + async (request, reply) => { + const { userId } = request.params; + + try { + if (!notificationService) { + return reply.status(503).send({ + success: false, + error: 'Notification service not initialized', + }); + } + + const preferences = await notificationService.getNotificationPreferences(userId); + + return reply.send({ + success: true, + preferences, + }); + } catch (error) { + return reply.status(500).send({ + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + } + ); + + /** + * PUT /api/v1/notifications/:userId/preferences + * Update notification preferences for a user + */ + fastify.put( + '/notifications/:userId/preferences', + { + schema: { + params: { + type: 'object', + required: ['userId'], + properties: { + userId: { type: 'string' }, + }, + }, + body: { + type: 'object', + properties: { + email: { + type: 'object', + properties: { + enabled: { type: 'boolean' }, + categories: { type: 'array', items: { type: 'string' } }, + }, + }, + push: { + type: 'object', + properties: { + enabled: { type: 'boolean' }, + categories: { type: 'array', items: { type: 'string' } }, + }, + }, + sms: { + type: 'object', + properties: { + enabled: { type: 'boolean' }, + categories: { type: 'array', items: { type: 'string' } }, + }, + }, + }, + }, + }, + }, + async (request, reply) => { + const { userId } = request.params; + const updates = request.body; + + try { + // TODO: Update preferences in database + return reply.send({ + success: true, + message: 'Preferences updated', + userId, + updates, + }); + } catch (error) { + return reply.status(500).send({ + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + } + ); + + /** + * GET /api/v1/notifications/config + * Get notification configuration status + */ + fastify.get('/notifications/config', async (request, reply) => { + try { + if (!notificationService) { + return reply.status(503).send({ + success: false, + error: 'Notification service not initialized', + }); + } + + const config = notificationService.getConfigSummary(); + + return reply.send({ + success: true, + config, + }); + } catch (error) { + return reply.status(500).send({ + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); +} diff --git a/apps/api/src/routes/spamshield.routes.ts b/apps/api/src/routes/spamshield.routes.ts new file mode 100644 index 0000000..6534852 --- /dev/null +++ b/apps/api/src/routes/spamshield.routes.ts @@ -0,0 +1,252 @@ +import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import { + numberReputationService, + smsClassifierService, + callAnalysisService, + spamFeedbackService, +} from '../services/spamshield'; +import { ErrorHandler, SpamErrorCode } from '../services/spamshield/spamshield.error-handler'; + +export async function spamshieldRoutes(fastify: FastifyInstance) { + // Classify SMS text + fastify.post('/sms/classify', async (request: FastifyRequest, reply: FastifyReply) => { + const authReq = request as FastifyRequest & { user?: { id: string } }; + const userId = authReq.user?.id; + + if (!userId) { + ErrorHandler.send(reply, SpamErrorCode.UNAUTHORIZED, 'User ID required', { status: 401 }); + return; + } + + const body = request.body as { text: string }; + + const textValidation = ErrorHandler.validateRequiredField(body.text, 'text'); + if (!textValidation.isValid && textValidation.error) { + ErrorHandler.send(reply, textValidation.error.code, textValidation.error.message, { + field: textValidation.error.field, + status: 400, + }); + return; + } + + try { + const result = await smsClassifierService.classify(body.text); + return reply.send({ + classification: { + isSpam: result.isSpam, + confidence: result.confidence, + spamFeatures: result.spamFeatures, + }, + }); + } catch (error) { + ErrorHandler.send(reply, SpamErrorCode.CLASSIFICATION_FAILED, 'Classification failed', { + status: 422, + }); + } + }); + + // Check number reputation + fastify.post('/number/reputation', async (request: FastifyRequest, reply: FastifyReply) => { + const authReq = request as FastifyRequest & { user?: { id: string } }; + const userId = authReq.user?.id; + + if (!userId) { + ErrorHandler.send(reply, SpamErrorCode.UNAUTHORIZED, 'User ID required', { status: 401 }); + return; + } + + const body = request.body as { phoneNumber: string }; + + const phoneValidation = ErrorHandler.validateRequiredField(body.phoneNumber, 'phoneNumber'); + if (!phoneValidation.isValid && phoneValidation.error) { + ErrorHandler.send(reply, phoneValidation.error.code, phoneValidation.error.message, { + field: phoneValidation.error.field, + status: 400, + }); + return; + } + + try { + const result = await numberReputationService.checkReputation(body.phoneNumber); + return reply.send({ + reputation: { + isSpam: result.isSpam, + confidence: result.confidence, + spamType: result.spamType, + reportCount: result.reportCount, + }, + }); + } catch (error) { + ErrorHandler.send(reply, SpamErrorCode.REPUTATION_CHECK_FAILED, 'Reputation check failed', { + status: 422, + }); + } + }); + + // Analyze incoming call + fastify.post('/call/analyze', async (request: FastifyRequest, reply: FastifyReply) => { + const authReq = request as FastifyRequest & { user?: { id: string } }; + const userId = authReq.user?.id; + + if (!userId) { + ErrorHandler.send(reply, SpamErrorCode.UNAUTHORIZED, 'User ID required', { status: 401 }); + return; + } + + const body = request.body as { + phoneNumber: string; + duration?: number; + callTime: string; + isVoip?: boolean; + }; + + const phoneValidation = ErrorHandler.validateRequiredField(body.phoneNumber, 'phoneNumber'); + const callTimeValidation = ErrorHandler.validateRequiredField(body.callTime, 'callTime'); + + if (!phoneValidation.isValid && phoneValidation.error) { + ErrorHandler.send(reply, phoneValidation.error.code, phoneValidation.error.message, { + field: phoneValidation.error.field, + status: 400, + }); + return; + } + + if (!callTimeValidation.isValid && callTimeValidation.error) { + ErrorHandler.send(reply, callTimeValidation.error.code, callTimeValidation.error.message, { + field: callTimeValidation.error.field, + status: 400, + }); + return; + } + + try { + const result = await callAnalysisService.analyzeCall({ + phoneNumber: body.phoneNumber, + duration: body.duration, + callTime: new Date(body.callTime), + isVoip: body.isVoip, + }); + return reply.send({ + analysis: { + decision: result.decision, + confidence: result.confidence, + reasons: result.reasons, + }, + }); + } catch (error) { + ErrorHandler.send(reply, SpamErrorCode.ANALYSIS_FAILED, 'Call analysis failed', { + status: 422, + }); + } + }); + + // Record spam feedback + fastify.post('/feedback', async (request: FastifyRequest, reply: FastifyReply) => { + const authReq = request as FastifyRequest & { user?: { id: string } }; + const userId = authReq.user?.id; + + if (!userId) { + ErrorHandler.send(reply, SpamErrorCode.UNAUTHORIZED, 'User ID required', { status: 401 }); + return; + } + + const body = request.body as { + phoneNumber: string; + isSpam: boolean; + confidence?: number; + metadata?: Record; + }; + + const phoneValidation = ErrorHandler.validateRequiredField(body.phoneNumber, 'phoneNumber'); + if (!phoneValidation.isValid && phoneValidation.error) { + ErrorHandler.send(reply, phoneValidation.error.code, phoneValidation.error.message, { + field: phoneValidation.error.field, + status: 400, + }); + return; + } + + const isSpamValidation = ErrorHandler.validateBooleanField(body.isSpam, 'isSpam'); + if (!isSpamValidation.isValid && isSpamValidation.error) { + ErrorHandler.send(reply, isSpamValidation.error.code, isSpamValidation.error.message, { + field: isSpamValidation.error.field, + status: 400, + }); + return; + } + + try { + const feedback = await spamFeedbackService.recordFeedback( + userId, + body.phoneNumber, + body.isSpam, + body.confidence, + body.metadata + ); + return reply.code(201).send({ + feedback: { + id: feedback.id, + phoneNumber: feedback.phoneNumber, + isSpam: feedback.isSpam, + createdAt: feedback.createdAt, + }, + }); + } catch (error) { + ErrorHandler.send(reply, SpamErrorCode.FEEDBACK_RECORD_FAILED, 'Feedback recording failed', { + status: 422, + }); + } + }); + + // Get spam history + fastify.get('/history', async (request: FastifyRequest, reply: FastifyReply) => { + const authReq = request as FastifyRequest & { user?: { id: string } }; + const userId = authReq.user?.id; + + if (!userId) { + ErrorHandler.send(reply, SpamErrorCode.UNAUTHORIZED, 'User ID required', { status: 401 }); + return; + } + + const query = request.query as { + limit?: string; + isSpam?: string; + startDate?: string; + }; + + const results = await spamFeedbackService.getSpamHistory(userId, { + limit: query.limit ? parseInt(query.limit, 10) : undefined, + isSpam: query.isSpam !== undefined ? query.isSpam === 'true' : undefined, + startDate: query.startDate ? new Date(query.startDate) : undefined, + }); + + return reply.send({ + history: results.map((r) => ({ + id: r.id, + phoneNumber: r.phoneNumber, + isSpam: r.isSpam, + createdAt: r.createdAt, + })), + }); + }); + + // Get spam statistics + fastify.get('/statistics', async (request: FastifyRequest, reply: FastifyReply) => { + const authReq = request as FastifyRequest & { user?: { id: string } }; + const userId = authReq.user?.id; + + if (!userId) { + ErrorHandler.send(reply, SpamErrorCode.UNAUTHORIZED, 'User ID required', { status: 401 }); + return; + } + + try { + const stats = await spamFeedbackService.getStatistics(userId); + return reply.send({ statistics: stats }); + } catch (error) { + ErrorHandler.send(reply, SpamErrorCode.ANALYSIS_FAILED, 'Statistics retrieval failed', { + status: 422, + }); + } + }); +} diff --git a/apps/api/src/routes/voiceprint.routes.ts b/apps/api/src/routes/voiceprint.routes.ts new file mode 100644 index 0000000..dcdd483 --- /dev/null +++ b/apps/api/src/routes/voiceprint.routes.ts @@ -0,0 +1,257 @@ +import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import { + voiceEnrollmentService, + analysisService, + batchAnalysisService, + voicePrintEnv, + AnalysisJobStatus, +} from '../services/voiceprint'; + +export async function voiceprintRoutes(fastify: FastifyInstance) { + // Enroll a new voice profile + fastify.post('/enroll', async (request: FastifyRequest, reply: FastifyReply) => { + const authReq = request as FastifyRequest & { user?: { id: string } }; + const userId = authReq.user?.id; + + if (!userId) { + return reply.code(401).send({ error: 'User ID required' }); + } + + const body = request.body as { + name: string; + audio: Buffer; + }; + + if (!body.name || !body.audio) { + return reply.code(400).send({ error: 'name and audio are required' }); + } + + try { + const enrollment = await voiceEnrollmentService.enroll( + userId, + body.name, + body.audio + ); + return reply.code(201).send({ + enrollment: { + id: enrollment.id, + name: enrollment.name, + isActive: enrollment.isActive, + createdAt: enrollment.createdAt, + }, + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Enrollment failed'; + return reply.code(422).send({ error: message }); + } + }); + + // List user's voice enrollments + fastify.get('/enrollments', async (request: FastifyRequest, reply: FastifyReply) => { + const authReq = request as FastifyRequest & { user?: { id: string } }; + const userId = authReq.user?.id; + + if (!userId) { + return reply.code(401).send({ error: 'User ID required' }); + } + + const isActive = request.query as { isActive?: string }; + const limit = request.query as { limit?: string }; + const offset = request.query as { offset?: string }; + + const enrollments = await voiceEnrollmentService.listEnrollments(userId, { + isActive: isActive.isActive !== undefined + ? isActive.isActive === 'true' + : undefined, + limit: limit.limit ? parseInt(limit.limit, 10) : undefined, + offset: offset.offset ? parseInt(offset.offset, 10) : undefined, + }); + + return reply.send({ + enrollments: enrollments.map((e) => ({ + id: e.id, + name: e.name, + isActive: e.isActive, + createdAt: e.createdAt, + })), + }); + }); + + // Remove an enrollment + fastify.delete('/enrollments/:id', async (request: FastifyRequest, reply: FastifyReply) => { + const authReq = request as FastifyRequest & { user?: { id: string } }; + const userId = authReq.user?.id; + + if (!userId) { + return reply.code(401).send({ error: 'User ID required' }); + } + + const enrollmentId = (request.params as { id: string }).id; + + try { + const enrollment = await voiceEnrollmentService.removeEnrollment( + enrollmentId, + userId + ); + return reply.send({ + enrollment: { + id: enrollment.id, + name: enrollment.name, + isActive: enrollment.isActive, + }, + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Removal failed'; + return reply.code(404).send({ error: message }); + } + }); + + // Analyze a single audio file + fastify.post('/analyze', async (request: FastifyRequest, reply: FastifyReply) => { + const authReq = request as FastifyRequest & { user?: { id: string } }; + const userId = authReq.user?.id; + + if (!userId) { + return reply.code(401).send({ error: 'User ID required' }); + } + + const body = request.body as { + audio: Buffer; + enrollmentId?: string; + audioUrl?: string; + }; + + if (!body.audio) { + return reply.code(400).send({ error: 'audio is required' }); + } + + try { + const result = await analysisService.analyze(userId, body.audio, { + enrollmentId: body.enrollmentId, + audioUrl: body.audioUrl, + }); + return reply.code(201).send({ + analysis: { + id: result.id, + isSynthetic: result.isSynthetic, + confidence: result.confidence, + analysisResult: result.analysisResult, + createdAt: result.createdAt, + }, + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Analysis failed'; + return reply.code(422).send({ error: message }); + } + }); + + // Get analysis result by ID + fastify.get('/results/:id', async (request: FastifyRequest, reply: FastifyReply) => { + const authReq = request as FastifyRequest & { user?: { id: string } }; + const userId = authReq.user?.id; + + if (!userId) { + return reply.code(401).send({ error: 'User ID required' }); + } + + const analysisId = (request.params as { id: string }).id; + const result = await analysisService.getResult(analysisId, userId); + + if (!result) { + return reply.code(404).send({ error: 'Analysis not found' }); + } + + return reply.send({ + analysis: { + id: result.id, + isSynthetic: result.isSynthetic, + confidence: result.confidence, + analysisResult: result.analysisResult, + createdAt: result.createdAt, + }, + }); + }); + + // Get analysis history + fastify.get('/history', async (request: FastifyRequest, reply: FastifyReply) => { + const authReq = request as FastifyRequest & { user?: { id: string } }; + const userId = authReq.user?.id; + + if (!userId) { + return reply.code(401).send({ error: 'User ID required' }); + } + + const query = request.query as { + limit?: string; + offset?: string; + isSynthetic?: string; + }; + + const results = await analysisService.getHistory(userId, { + limit: query.limit ? parseInt(query.limit, 10) : undefined, + offset: query.offset ? parseInt(query.offset, 10) : undefined, + isSynthetic: query.isSynthetic !== undefined + ? query.isSynthetic === 'true' + : undefined, + }); + + return reply.send({ + analyses: results.map((r) => ({ + id: r.id, + isSynthetic: r.isSynthetic, + confidence: r.confidence, + createdAt: r.createdAt, + })), + }); + }); + + // Batch analyze multiple audio files + fastify.post('/batch', async (request: FastifyRequest, reply: FastifyReply) => { + const authReq = request as FastifyRequest & { user?: { id: string } }; + const userId = authReq.user?.id; + + if (!userId) { + return reply.code(401).send({ error: 'User ID required' }); + } + + const body = request.body as { + files: Array<{ + name: string; + audio: Buffer; + audioUrl?: string; + }>; + enrollmentId?: string; + }; + + if (!body.files || body.files.length === 0) { + return reply.code(400).send({ error: 'files array is required' }); + } + + try { + const result = await batchAnalysisService.analyzeBatch( + userId, + body.files.map((f) => ({ + name: f.name, + buffer: f.audio, + audioUrl: f.audioUrl, + })), + { + enrollmentId: body.enrollmentId, + } + ); + + return reply.code(201).send({ + jobId: result.jobId, + results: result.results.map((r) => ({ + id: r.id, + isSynthetic: r.isSynthetic, + confidence: r.confidence, + })), + summary: result.summary, + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Batch analysis failed'; + return reply.code(422).send({ error: message }); + } + }); +} diff --git a/apps/api/src/services/darkwatch/alert.pipeline.ts b/apps/api/src/services/darkwatch/alert.pipeline.ts new file mode 100644 index 0000000..c91a809 --- /dev/null +++ b/apps/api/src/services/darkwatch/alert.pipeline.ts @@ -0,0 +1,174 @@ +import { prisma, AlertType, AlertSeverity } from '@shieldsai/shared-db'; +import { + NotificationService, + NotificationPriority, + loadNotificationConfig, +} from '@shieldsai/shared-notifications'; + +const ALERT_DEDUP_WINDOW_MS = 24 * 60 * 60 * 1000; + +export class AlertPipeline { + private notificationService: NotificationService; + + constructor() { + this.notificationService = new NotificationService(loadNotificationConfig()); + } + + async processNewExposures(exposureIds: string[]) { + const exposures = await prisma.exposure.findMany({ + where: { id: { in: exposureIds }, isFirstTime: true }, + include: { + subscription: { + select: { + id: true, + userId: true, + tier: true, + }, + }, + watchlistItem: true, + }, + }); + + const alertsCreated: Awaited>[] = []; + + for (const exposure of exposures) { + const dedupKey = `exposure:${exposure.subscriptionId}:${exposure.source}:${exposure.identifierHash}`; + + const recentAlert = await prisma.alert.findFirst({ + where: { + subscriptionId: exposure.subscriptionId, + type: AlertType.exposure_detected, + createdAt: { + gte: new Date(Date.now() - ALERT_DEDUP_WINDOW_MS), + }, + }, + orderBy: { createdAt: 'desc' }, + }); + + if (recentAlert) { + continue; + } + + const alert = await prisma.alert.create({ + data: { + subscriptionId: exposure.subscriptionId, + userId: exposure.subscription.userId, + exposureId: exposure.id, + type: AlertType.exposure_detected, + title: this.buildTitle(exposure), + message: this.buildMessage(exposure), + severity: this.mapSeverity(exposure.severity), + channel: this.getChannelsForTier(exposure.subscription.tier), + }, + }); + + alertsCreated.push(alert); + + await this.dispatchNotification(alert, exposure); + } + + return alertsCreated; + } + + async dispatchScanCompleteAlert( + subscriptionId: string, + userId: string, + exposuresFound: number + ) { + const subscription = await prisma.subscription.findUnique({ + where: { id: subscriptionId }, + select: { tier: true }, + }); + + if (!subscription) return; + + const alert = await prisma.alert.create({ + data: { + subscriptionId, + userId, + type: AlertType.scan_complete, + title: 'DarkWatch Scan Complete', + message: `Scan found ${exposuresFound} new exposure${exposuresFound === 1 ? '' : 's'}.`, + severity: exposuresFound > 0 ? 'warning' : 'info', + channel: this.getChannelsForTier(subscription.tier), + }, + }); + + await this.dispatchNotification(alert, { + source: 'hibp', + severity: 'info', + identifier: '', + dataType: 'email', + } as any); + + return alert; + } + + private async dispatchNotification( + alert: { + userId: string; + channel: string[]; + title: string; + message: string; + severity: AlertSeverity; + }, + exposure: { source: string; severity: string; identifier: string; dataType: string } + ) { + try { + if (!this.notificationService.isFullyConfigured()) return; + + await this.notificationService.sendMultiChannelNotification( + { + userId: alert.userId, + }, + alert.channel as any, + alert.title, + `

${alert.message}

+

Source: ${exposure.source}

+

Severity: ${exposure.severity}

+

Type: ${exposure.dataType}

`, + alert.severity === 'critical' + ? NotificationPriority.HIGH + : NotificationPriority.NORMAL + ); + } catch (error) { + console.error('[AlertPipeline] Notification dispatch error:', error); + } + } + + private buildTitle(exposure: { + source: string; + dataType: string; + severity: string; + }): string { + return `${exposure.severity.toUpperCase()}: ${exposure.dataType} exposure on ${exposure.source}`; + } + + private buildMessage(exposure: { + identifier: string; + source: string; + severity: string; + dataType: string; + }): string { + const masked = exposure.identifier.includes('@') + ? exposure.identifier.replace(/(?<=.{2}).*(?=@)/, '***') + : exposure.identifier.slice(0, 3) + '***'; + + return `Your ${exposure.dataType} (${masked}) was found in a ${exposure.source} breach with ${exposure.severity} severity.`; + } + + private mapSeverity(severity: string): AlertSeverity { + return severity as AlertSeverity; + } + + private getChannelsForTier(tier: string): string[] { + const channelMap: Record = { + basic: ['email'], + plus: ['email', 'push'], + premium: ['email', 'push', 'sms'], + }; + return channelMap[tier] || ['email']; + } +} + +export const alertPipeline = new AlertPipeline(); diff --git a/apps/api/src/services/darkwatch/index.ts b/apps/api/src/services/darkwatch/index.ts new file mode 100644 index 0000000..f18ad02 --- /dev/null +++ b/apps/api/src/services/darkwatch/index.ts @@ -0,0 +1,5 @@ +export { watchlistService } from './watchlist.service'; +export { scanService } from './scan.service'; +export { schedulerService } from './scheduler.service'; +export { webhookService } from './webhook.service'; +export { alertPipeline } from './alert.pipeline'; diff --git a/apps/api/src/services/darkwatch/scan.service.ts b/apps/api/src/services/darkwatch/scan.service.ts new file mode 100644 index 0000000..d3b5182 --- /dev/null +++ b/apps/api/src/services/darkwatch/scan.service.ts @@ -0,0 +1,220 @@ +import { prisma, ExposureSource, ExposureSeverity, WatchlistType } from '@shieldsai/shared-db'; +import { createHash } from 'crypto'; + +function hashIdentifier(identifier: string): string { + return createHash('sha256').update(identifier.toLowerCase().trim()).digest('hex'); +} + +function determineSeverity( + source: ExposureSource, + dataType: WatchlistType +): ExposureSeverity { + const criticalSources = [ExposureSource.darkWebForum, ExposureSource.honeypot]; + const warningSources = [ExposureSource.hibp, ExposureSource.shodan]; + const criticalTypes = [WatchlistType.ssn]; + + if (criticalTypes.includes(dataType)) return ExposureSeverity.critical; + if (criticalSources.includes(source)) return ExposureSeverity.critical; + if (warningSources.includes(source)) return ExposureSeverity.warning; + return ExposureSeverity.info; +} + +export class ScanService { + async checkHIBP(email: string): Promise<{ exposed: boolean; sources: string[] }> { + try { + const response = await fetch( + `https://hibp.com/api/v2/${encodeURIComponent(email)}`, + { + headers: { + 'hibp-api-key': process.env.HIBP_API_KEY || '', + Accept: 'application/json', + }, + signal: AbortSignal.timeout(15000), + } + ); + + if (response.status === 404) { + return { exposed: false, sources: [] }; + } + + if (!response.ok) { + console.error(`[ScanService:HIBP] Status ${response.status} for ${email}`); + return { exposed: false, sources: [] }; + } + + const data = await response.json(); + const sources = Array.isArray(data) + ? data.map((p: { Name: string }) => p.Name) + : []; + + return { exposed: sources.length > 0, sources }; + } catch (error) { + console.error('[ScanService:HIBP] Error:', error); + return { exposed: false, sources: [] }; + } + } + + async checkShodan(domain: string): Promise<{ exposed: boolean; ports: string[]; ips: string[] }> { + try { + const response = await fetch( + `https://api.shodan.io/shodan/host/${encodeURIComponent(domain)}`, + { + headers: { + Authorization: `Bearer ${process.env.SHODAN_API_KEY || ''}`, + }, + signal: AbortSignal.timeout(15000), + } + ); + + if (response.status === 404) { + return { exposed: false, ports: [], ips: [] }; + } + + if (!response.ok) { + console.error(`[ScanService:Shodan] Status ${response.status} for ${domain}`); + return { exposed: false, ports: [], ips: [] }; + } + + const data = await response.json(); + return { + exposed: !!data.ip_str, + ports: data.ports?.map(String) || [], + ips: [data.ip_str || ''], + }; + } catch (error) { + console.error('[ScanService:Shodan] Error:', error); + return { exposed: false, ports: [], ips: [] }; + } + } + + async processSubscriptionScan( + subscriptionId: string, + watchlistItems: Awaited> + ): Promise<{ exposuresCreated: number; exposuresUpdated: number }> { + let exposuresCreated = 0; + let exposuresUpdated = 0; + + for (const item of watchlistItems) { + const identifier = item.value; + const identifierHash = hashIdentifier(identifier); + + switch (item.type) { + case WatchlistType.email: { + const hibpResult = await this.checkHIBP(identifier); + if (hibpResult.exposed) { + for (const source of hibpResult.sources) { + const existing = await prisma.exposure.findFirst({ + where: { + subscriptionId, + source: ExposureSource.hibp, + identifierHash, + metadata: { path: ['dbName'], equals: source }, + }, + }); + + if (existing) { + await prisma.exposure.update({ + where: { id: existing.id }, + data: { detectedAt: new Date() }, + }); + exposuresUpdated++; + } else { + await prisma.exposure.create({ + data: { + subscriptionId, + watchlistItemId: item.id, + source: ExposureSource.hibp, + dataType: item.type, + identifier, + identifierHash, + severity: determineSeverity(ExposureSource.hibp, item.type), + isFirstTime: true, + metadata: { dbName: source }, + detectedAt: new Date(), + }, + }); + exposuresCreated++; + } + } + } + break; + } + + case WatchlistType.domain: { + const shodanResult = await this.checkShodan(identifier); + if (shodanResult.exposed) { + const existing = await prisma.exposure.findFirst({ + where: { + subscriptionId, + source: ExposureSource.shodan, + identifierHash, + }, + }); + + if (existing) { + await prisma.exposure.update({ + where: { id: existing.id }, + data: { + detectedAt: new Date(), + metadata: { ports: shodanResult.ports, ips: shodanResult.ips }, + }, + }); + exposuresUpdated++; + } else { + await prisma.exposure.create({ + data: { + subscriptionId, + watchlistItemId: item.id, + source: ExposureSource.shodan, + dataType: item.type, + identifier, + identifierHash, + severity: determineSeverity(ExposureSource.shodan, item.type), + isFirstTime: true, + metadata: { ports: shodanResult.ports, ips: shodanResult.ips }, + detectedAt: new Date(), + }, + }); + exposuresCreated++; + } + } + break; + } + + default: { + const existing = await prisma.exposure.findFirst({ + where: { subscriptionId, watchlistItemId: item.id, identifierHash }, + }); + + if (!existing) { + await prisma.exposure.create({ + data: { + subscriptionId, + watchlistItemId: item.id, + source: ExposureSource.darkWebForum, + dataType: item.type, + identifier, + identifierHash, + severity: determineSeverity(ExposureSource.darkWebForum, item.type), + isFirstTime: true, + detectedAt: new Date(), + }, + }); + exposuresCreated++; + } + break; + } + } + } + + return { exposuresCreated, exposuresUpdated }; + } + + async getWatchlistItems(subscriptionId: string) { + return prisma.watchlistItem.findMany({ + where: { subscriptionId, isActive: true }, + }); + } +} + +export const scanService = new ScanService(); diff --git a/apps/api/src/services/darkwatch/scheduler.service.ts b/apps/api/src/services/darkwatch/scheduler.service.ts new file mode 100644 index 0000000..e31725e --- /dev/null +++ b/apps/api/src/services/darkwatch/scheduler.service.ts @@ -0,0 +1,155 @@ +import { prisma, SubscriptionTier, SubscriptionStatus } from '@shieldsai/shared-db'; +import { tierConfig } from '@shieldsai/shared-billing'; +import { darkwatchScanQueue } from '@shieldsai/jobs'; +import { randomUUID } from 'crypto'; + +const CRON_EXPRESSIONS = { + daily: '0 0 * * *', + hourly: '0 * * * *', + realtime: null, +}; + +export class SchedulerService { + async scheduleSubscriptionScans() { + const activeSubscriptions = await prisma.subscription.findMany({ + where: { + tier: { in: [SubscriptionTier.basic, SubscriptionTier.plus, SubscriptionTier.premium] }, + status: SubscriptionStatus.active, + }, + select: { + id: true, + tier: true, + userId: true, + }, + }); + + const jobsEnqueued = []; + + for (const subscription of activeSubscriptions) { + const frequency = tierConfig[subscription.tier].features.darkWebScanFrequency; + const cron = CRON_EXPRESSIONS[frequency]; + + if (!cron) { + continue; + } + + const jobKey = `scheduled-scan:${subscription.id}`; + + try { + await darkwatchScanQueue.add( + 'scheduled-scan', + { + subscriptionId: subscription.id, + tier: subscription.tier, + scanType: 'scheduled', + }, + { + jobId: jobKey, + repeat: { + every: frequency === 'daily' + ? 24 * 60 * 60 * 1000 + : 60 * 60 * 1000, + }, + priority: subscription.tier === SubscriptionTier.premium ? 1 : 3, + } + ); + + jobsEnqueued.push({ + subscriptionId: subscription.id, + tier: subscription.tier, + frequency, + }); + } catch (error) { + if ((error as Error).message?.includes('Duplicate')) { + continue; + } + console.error( + `[SchedulerService] Failed to schedule scan for ${subscription.id}:`, + error + ); + } + } + + return jobsEnqueued; + } + + async enqueueOnDemandScan(subscriptionId: string) { + const subscription = await prisma.subscription.findUnique({ + where: { id: subscriptionId }, + select: { id: true, tier: true }, + }); + + if (!subscription) { + throw new Error(`Subscription ${subscriptionId} not found`); + } + + return darkwatchScanQueue.add( + 'on-demand-scan', + { + subscriptionId, + tier: subscription.tier, + scanType: 'on-demand', + }, + { + priority: 1, + jobId: `on-demand-scan:${subscriptionId}:${randomUUID()}`, + } + ); + } + + async enqueueRealtimeTrigger(subscriptionId: string, sourceData: Record) { + const subscription = await prisma.subscription.findUnique({ + where: { id: subscriptionId }, + select: { id: true, tier: true }, + }); + + if (!subscription || subscription.tier !== SubscriptionTier.premium) { + throw new Error('Realtime triggers require Premium tier'); + } + + return darkwatchScanQueue.add( + 'realtime-trigger', + { + subscriptionId, + tier: subscription.tier, + scanType: 'realtime', + sourceData, + }, + { + priority: 0, + jobId: `realtime-trigger:${subscriptionId}:${randomUUID()}`, + } + ); + } + + async rescheduleAll() { + const repeatableJobs = await darkwatchScanQueue.getRepeatableJobs(); + + for (const job of repeatableJobs) { + await darkwatchScanQueue.removeRepeatableByKey(job.key); + } + + return this.scheduleSubscriptionScans(); + } + + async getScanSchedule(subscriptionId: string) { + const subscription = await prisma.subscription.findUnique({ + where: { id: subscriptionId }, + select: { tier: true }, + }); + + if (!subscription) return null; + + const frequency = tierConfig[subscription.tier].features.darkWebScanFrequency; + + return { + subscriptionId, + tier: subscription.tier, + frequency, + cron: CRON_EXPRESSIONS[frequency], + nextRun: frequency === 'realtime' ? 'event-driven' : CRON_EXPRESSIONS[frequency], + }; + } +} + +export const schedulerService = new SchedulerService(); diff --git a/apps/api/src/services/darkwatch/watchlist.service.ts b/apps/api/src/services/darkwatch/watchlist.service.ts new file mode 100644 index 0000000..caaaf91 --- /dev/null +++ b/apps/api/src/services/darkwatch/watchlist.service.ts @@ -0,0 +1,97 @@ +import { prisma, WatchlistType } from '@shieldsai/shared-db'; +import { createHash } from 'crypto'; + +export function normalizeValue(type: WatchlistType, value: string): string { + const trimmed = value.trim().toLowerCase(); + switch (type) { + case WatchlistType.email: + return trimmed.replace(/\s+/g, ''); + case WatchlistType.phoneNumber: + return trimmed.replace(/[\s\-\(\)]/g, ''); + case WatchlistType.ssn: + return trimmed.replace(/-/g, ''); + case WatchlistType.address: + return trimmed; + case WatchlistType.domain: + return trimmed.replace(/^https?:\/\//, '').replace(/\/.*$/, ''); + default: + return trimmed; + } +} + +export function hashValue(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} + +export class WatchlistService { + async addItem( + subscriptionId: string, + type: WatchlistType, + value: string, + maxItems: number + ) { + const normalized = normalizeValue(type, value); + const itemHash = hashValue(normalized); + + const currentCount = await prisma.watchlistItem.count({ + where: { subscriptionId, isActive: true }, + }); + + if (currentCount >= maxItems) { + throw new Error( + `Watchlist limit reached (${maxItems} items). Upgrade your plan to add more.` + ); + } + + const existing = await prisma.watchlistItem.findFirst({ + where: { subscriptionId, type, hash: itemHash }, + }); + + if (existing) { + if (!existing.isActive) { + return prisma.watchlistItem.update({ + where: { id: existing.id }, + data: { isActive: true }, + }); + } + return existing; + } + + return prisma.watchlistItem.create({ + data: { + subscriptionId, + type, + value: normalized, + hash: itemHash, + }, + }); + } + + async getItems(subscriptionId: string) { + return prisma.watchlistItem.findMany({ + where: { subscriptionId, isActive: true }, + orderBy: { createdAt: 'desc' }, + }); + } + + async removeItem(id: string, subscriptionId: string) { + return prisma.watchlistItem.update({ + where: { id }, + data: { isActive: false }, + }); + } + + async getActiveItemsForScan(subscriptionId: string) { + return prisma.watchlistItem.findMany({ + where: { subscriptionId, isActive: true }, + }); + } + + async getItemCount(subscriptionId: string) { + return prisma.watchlistItem.count({ + where: { subscriptionId, isActive: true }, + }); + } +} + +export const watchlistService = new WatchlistService(); diff --git a/apps/api/src/services/darkwatch/webhook.service.ts b/apps/api/src/services/darkwatch/webhook.service.ts new file mode 100644 index 0000000..256bd4e --- /dev/null +++ b/apps/api/src/services/darkwatch/webhook.service.ts @@ -0,0 +1,226 @@ +import { prisma, ExposureSource, ExposureSeverity, WatchlistType, AlertType, AlertSeverity } from '@shieldsai/shared-db'; +import { createHash } from 'crypto'; +import { mixpanelService, EventType } from '@shieldsai/shared-analytics'; + +function hashIdentifier(identifier: string): string { + return createHash('sha256').update(identifier.toLowerCase().trim()).digest('hex'); +} + +function determineSeverity( + source: ExposureSource, + dataType: WatchlistType +): ExposureSeverity { + const criticalSources = [ExposureSource.darkWebForum, ExposureSource.honeypot]; + const warningSources = [ExposureSource.hibp, ExposureSource.shodan]; + const criticalTypes = [WatchlistType.ssn]; + + if (criticalTypes.includes(dataType)) return ExposureSeverity.critical; + if (criticalSources.includes(source)) return ExposureSeverity.critical; + if (warningSources.includes(source)) return ExposureSeverity.warning; + return ExposureSeverity.info; +} + +export interface WebhookPayload { + source: string; + identifier: string; + identifierType: string; + metadata?: Record; + timestamp?: string; +} + +export class WebhookService { + async processExternalWebhook(payload: WebhookPayload): Promise<{ + exposuresCreated: number; + alertsCreated: number; + }> { + const source = this.mapSource(payload.source); + const dataType = this.mapDataType(payload.identifierType); + const identifier = payload.identifier.toLowerCase().trim(); + const identifierHash = hashIdentifier(identifier); + const severity = determineSeverity(source, dataType); + + const matchingItems = await prisma.watchlistItem.findMany({ + where: { + isActive: true, + OR: [ + { hash: identifierHash, type: dataType }, + { value: identifier, type: dataType }, + ], + }, + include: { + subscription: { + select: { + id: true, + tier: true, + userId: true, + }, + }, + }, + }); + + let exposuresCreated = 0; + let alertsCreated = 0; + + for (const item of matchingItems) { + const existing = await prisma.exposure.findFirst({ + where: { + subscriptionId: item.subscriptionId, + source, + identifierHash, + }, + }); + + if (existing) { + await prisma.exposure.update({ + where: { id: existing.id }, + data: { detectedAt: new Date() }, + }); + continue; + } + + const exposure = await prisma.exposure.create({ + data: { + subscriptionId: item.subscriptionId, + watchlistItemId: item.id, + source, + dataType, + identifier, + identifierHash, + severity, + isFirstTime: true, + metadata: payload.metadata || {}, + detectedAt: new Date(), + }, + }); + + exposuresCreated++; + + const alertChannels = this.getAlertChannelsForTier(item.subscription.tier); + + await prisma.alert.create({ + data: { + subscriptionId: item.subscriptionId, + userId: item.subscription.userId, + exposureId: exposure.id, + type: AlertType.exposure_detected, + title: `New Exposure Detected: ${this.getSourceLabel(source)}`, + message: this.buildAlertMessage(identifier, source, severity), + severity: this.mapAlertSeverity(severity), + channel: alertChannels, + }, + }); + + alertsCreated++; + + await mixpanelService.track(EventType.EXPOSURE_DETECTED, { + userId: item.subscription.userId, + exposureType: dataType, + severity, + source, + subscriptionTier: item.subscription.tier, + }); + } + + return { exposuresCreated, alertsCreated }; + } + + async verifyWebhookSignature( + body: string, + signature: string, + timestamp: string + ): Promise { + const webhookSecret = process.env.DARKWATCH_WEBHOOK_SECRET; + if (!webhookSecret) { + console.warn('[WebhookService] DARKWATCH_WEBHOOK_SECRET not set — signature verification skipped'); + return false; + } + + const expected = createHash('sha256') + .update(`${timestamp}:${body}`) + .digest('hex'); + + return expected === signature; + } + + private mapSource(source: string): ExposureSource { + const sourceMap: Record = { + hibp: ExposureSource.hibp, + 'haveibeenpwned': ExposureSource.hibp, + securitytrails: ExposureSource.securityTrails, + censys: ExposureSource.censys, + 'darkweb-forum': ExposureSource.darkWebForum, + 'darkweb': ExposureSource.darkWebForum, + shodan: ExposureSource.shodan, + honeypot: ExposureSource.honeypot, + }; + + const normalized = source.toLowerCase().replace(/\s+/g, ''); + const mapped = sourceMap[normalized]; + if (!mapped) { + console.warn(`[WebhookService] Unknown source "${source}", falling back to darkWebForum`); + } + return mapped || ExposureSource.darkWebForum; + } + + private mapDataType(type: string): WatchlistType { + const typeMap: Record = { + email: WatchlistType.email, + phone: WatchlistType.phoneNumber, + phonenumber: WatchlistType.phoneNumber, + ssn: WatchlistType.ssn, + address: WatchlistType.address, + domain: WatchlistType.domain, + }; + + const normalized = type.toLowerCase().trim(); + return typeMap[normalized] || WatchlistType.email; + } + + private getAlertChannelsForTier(tier: string): string[] { + const channelMap: Record = { + basic: ['email'], + plus: ['email', 'push'], + premium: ['email', 'push', 'sms'], + }; + return channelMap[tier] || ['email']; + } + + private mapAlertSeverity(severity: ExposureSeverity): AlertSeverity { + return severity as AlertSeverity; + } + + private getSourceLabel(source: ExposureSource): string { + const labels: Record = { + [ExposureSource.hibp]: 'Have I Been Pwned', + [ExposureSource.securityTrails]: 'SecurityTrails', + [ExposureSource.censys]: 'Censys', + [ExposureSource.darkWebForum]: 'Dark Web Forum', + [ExposureSource.shodan]: 'Shodan', + [ExposureSource.honeypot]: 'Honeypot', + }; + return labels[source] || source; + } + + private buildAlertMessage( + identifier: string, + source: ExposureSource, + severity: ExposureSeverity + ): string { + const masked = this.maskIdentifier(identifier); + return `${severity.toUpperCase()}: "${masked}" found in ${this.getSourceLabel(source)}.`; + } + + private maskIdentifier(identifier: string): string { + if (identifier.includes('@')) { + const [user, domain] = identifier.split('@'); + const maskedUser = user.slice(0, 2) + '***' + user.slice(-1); + return `${maskedUser}@${domain}`; + } + if (identifier.length > 8) { + return identifier.slice(0, 3) + '***' + identifier.slice(-2); + } + return identifier; + } +} + +export const webhookService = new WebhookService(); diff --git a/apps/api/src/services/spamshield/feature-flags.ts b/apps/api/src/services/spamshield/feature-flags.ts new file mode 100644 index 0000000..5c72e6a --- /dev/null +++ b/apps/api/src/services/spamshield/feature-flags.ts @@ -0,0 +1,227 @@ +/** + * Feature Flag Management System + * Centralized feature flag handling with type safety and runtime updates + */ + +import type { z } from 'zod'; + +/** + * Type for feature flag values + */ +export type FeatureFlagValue = boolean | string | number; + +/** + * Interface for a feature flag definition + */ +export interface FeatureFlag { + key: string; + defaultValue: T; + description?: string; + allowedValues?: T[]; // For enum-like flags + category?: string; +} + +/** + * Feature flag registry - stores all defined flags + */ +export interface FeatureFlagRegistry { + [key: string]: FeatureFlag; +} + +/** + * Feature flag resolver - handles flag resolution logic + */ +export class FeatureFlagResolver { + private flags: FeatureFlagRegistry; + private resolvedCache: Map = new Map(); + + constructor(flags: FeatureFlagRegistry) { + this.flags = flags; + } + + /** + * Resolve a feature flag value + * Priority: Environment > Cache > Default + */ + resolve(key: string, defaultValue: T): T { + // Check cache first + if (this.resolvedCache.has(key)) { + return this.resolvedCache.get(key)! as T; + } + + // Check environment variable (allows runtime updates) + const envValue = process.env[`FLAG_${key.toUpperCase()}`]; + if (envValue !== undefined) { + // Try to parse as JSON first, then as boolean, then as string + let parsed: FeatureFlagValue; + try { + parsed = JSON.parse(envValue); + } catch { + parsed = envValue.toLowerCase() === 'true' ? true : + envValue.toLowerCase() === 'false' ? false : + envValue; + } + + // Validate against allowed values if defined + const flag = this.flags[key]; + if (flag && flag.allowedValues && !flag.allowedValues.includes(parsed)) { + console.warn(`Invalid value for flag ${key}: ${parsed}. Using default.`); + parsed = defaultValue as FeatureFlagValue; + } + + this.resolvedCache.set(key, parsed); + return parsed as T; + } + + // Use cached value if available + if (this.resolvedCache.has(key)) { + return this.resolvedCache.get(key)! as T; + } + + // Return default + this.resolvedCache.set(key, defaultValue as FeatureFlagValue); + return defaultValue as T; + } + + /** + * Check if a flag is enabled (boolean check) + */ + isEnabled(key: string, defaultValue: T): T { + return this.resolve(key, defaultValue) as T; + } + + /** + * Get flag definition + */ + getDefinition(key: string): FeatureFlag | undefined { + return this.flags[key]; + } + + /** + * List all registered flags + */ + getAllFlags(): FeatureFlagRegistry { + return { ...this.flags }; + } + + /** + * Clear the resolution cache (useful for testing) + */ + clearCache(): void { + this.resolvedCache.clear(); + } +} + +/** + * Feature flag configuration with pre-defined flags + */ +export const featureFlags: FeatureFlagRegistry = { + // SpamShield Feature Flags + 'spamshield.enable.number.reputation': { + key: 'spamshield_enable_number_reputation', + defaultValue: true, + description: 'Enable number reputation checking (Hiya API integration)', + category: 'spamshield', + }, + 'spamshield.enable.content.classification': { + key: 'spamshield_enable_content_classification', + defaultValue: true, + description: 'Enable SMS content classification (BERT model)', + category: 'spamshield', + }, + 'spamshield.enable.behavioral.analysis': { + key: 'spamshield_enable_behavioral_analysis', + defaultValue: true, + description: 'Enable call behavioral analysis', + category: 'spamshield', + }, + 'spamshield.enable.community.intelligence': { + key: 'spamshield_enable_community_intelligence', + defaultValue: true, + description: 'Enable community intelligence sharing', + category: 'spamshield', + }, + 'spamshield.enable.real.time.blocking': { + key: 'spamshield_enable_real_time_blocking', + defaultValue: true, + description: 'Enable real-time spam blocking', + category: 'spamshield', + }, + 'spamshield.enable.multiple.sources': { + key: 'spamshield_enable_multiple_sources', + defaultValue: false, + description: 'Enable multiple reputation source aggregation (Truecaller, etc.)', + category: 'spamshield', + }, + 'spamshield.enable.ml.classifier': { + key: 'spamshield_enable_ml_classifier', + defaultValue: false, + description: 'Enable ML-based spam classification', + category: 'spamshield', + }, + + // VoicePrint Feature Flags + 'voiceprint.enable.ml.service': { + key: 'voiceprint_enable_ml_service', + defaultValue: false, + description: 'Enable ML service integration for voice analysis', + category: 'voiceprint', + }, + 'voiceprint.enable.faiss.index': { + key: 'voiceprint_enable_faiss_index', + defaultValue: true, + description: 'Enable FAISS index for voice matching', + category: 'voiceprint', + }, + 'voiceprint.enable.batch.analysis': { + key: 'voiceprint_enable_batch_analysis', + defaultValue: true, + description: 'Enable batch voice analysis', + category: 'voiceprint', + }, + 'voiceprint.enable.realtime.analysis': { + key: 'voiceprint_enable_realtime_analysis', + defaultValue: false, + description: 'Enable real-time voice analysis', + category: 'voiceprint', + }, + 'voiceprint.enable.mock.model': { + key: 'voiceprint_enable_mock_model', + defaultValue: true, + description: 'Enable mock model for development', + category: 'voiceprint', + }, + + // General Platform Flags + 'platform.enable.audit.logs': { + key: 'platform_enable_audit_logs', + defaultValue: true, + description: 'Enable comprehensive audit logging', + category: 'platform', + }, + 'platform.enable.kpi.tracking': { + key: 'platform_enable_kpi_tracking', + defaultValue: true, + description: 'Enable KPI snapshot tracking', + category: 'platform', + }, +}; + +/** + * Create a resolver instance with the default flags + */ +export const featureFlagResolver = new FeatureFlagResolver(featureFlags); + +/** + * Convenience function for quick flag checks + */ +export function isFeatureEnabled(key: string, defaultValue: T): T { + return featureFlagResolver.isEnabled(key, defaultValue); +} + +/** + * Check if a flag is enabled with type safety + */ +export function checkFlag(key: string, defaultValue: T): T { + return featureFlagResolver.resolve(key, defaultValue); +} diff --git a/apps/api/src/services/spamshield/index.ts b/apps/api/src/services/spamshield/index.ts new file mode 100644 index 0000000..917e237 --- /dev/null +++ b/apps/api/src/services/spamshield/index.ts @@ -0,0 +1,26 @@ +// Config +export { + spamShieldEnv, + SpamLayer, + SpamDecision, + ConfidenceLevel, + spamFeatureFlags, + spamRateLimits, + checkFlag, + isFeatureEnabled, +} from './spamshield.config'; + +// Feature flags +export * from './feature-flags'; + +// Services +export { + NumberReputationService, + SMSClassifierService, + CallAnalysisService, + SpamFeedbackService, + numberReputationService, + smsClassifierService, + callAnalysisService, + spamFeedbackService, +} from './spamshield.service'; diff --git a/apps/api/src/services/spamshield/spamshield.audit-logger.ts b/apps/api/src/services/spamshield/spamshield.audit-logger.ts new file mode 100644 index 0000000..dd62ee4 --- /dev/null +++ b/apps/api/src/services/spamshield/spamshield.audit-logger.ts @@ -0,0 +1,118 @@ +import { createHash } from 'crypto'; + +export type AuditClassificationType = 'sms' | 'call'; + +export interface AuditClassificationEntry { + id: string; + timestamp: string; + type: AuditClassificationType; + phoneNumberHash: string; + decision: 'spam' | 'ham' | 'block' | 'flag' | 'allow'; + confidence: number; + reasons: string[]; + featureFlags: Record; + metadata?: Record; +} + +const MAX_AUDIT_LOG_SIZE = 10_000; + +class AuditLogger { + private entries: AuditClassificationEntry[] = []; + + logClassification(entry: Omit): AuditClassificationEntry { + const record: AuditClassificationEntry = { + id: `audit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + timestamp: new Date().toISOString(), + ...entry, + }; + + this.entries.push(record); + + if (this.entries.length > MAX_AUDIT_LOG_SIZE) { + this.entries.shift(); + } + + console.log( + `[SpamShield:Audit] type=${record.type} decision=${record.decision} ` + + `confidence=${record.confidence.toFixed(3)} reasons=${record.reasons.join(',') || 'none'} ` + + `phoneHash=${record.phoneNumberHash}` + ); + + return record; + } + + getEntries( + filters?: { + type?: AuditClassificationType; + decision?: string; + startDate?: Date; + endDate?: Date; + limit?: number; + } + ): AuditClassificationEntry[] { + let results = this.entries; + + if (filters?.type) { + results = results.filter(e => e.type === filters.type); + } + + if (filters?.decision) { + results = results.filter(e => e.decision === filters.decision); + } + + if (filters?.startDate) { + results = results.filter(e => new Date(e.timestamp) >= filters.startDate!); + } + + if (filters?.endDate) { + results = results.filter(e => new Date(e.timestamp) <= filters.endDate!); + } + + if (filters?.limit) { + results = results.slice(-filters.limit); + } + + return results; + } + + getSummary(): { + totalEntries: number; + spamCount: number; + hamCount: number; + blockCount: number; + flagCount: number; + allowCount: number; + avgConfidence: number; + } { + const spamCount = this.entries.filter(e => e.decision === 'spam' || e.decision === 'block').length; + const hamCount = this.entries.filter(e => e.decision === 'ham' || e.decision === 'allow').length; + const blockCount = this.entries.filter(e => e.decision === 'block').length; + const flagCount = this.entries.filter(e => e.decision === 'flag').length; + const allowCount = this.entries.filter(e => e.decision === 'allow').length; + const avgConfidence = + this.entries.length > 0 + ? this.entries.reduce((s, e) => s + e.confidence, 0) / this.entries.length + : 0; + + return { + totalEntries: this.entries.length, + spamCount, + hamCount, + blockCount, + flagCount, + allowCount, + avgConfidence: Math.round(avgConfidence * 1000) / 1000, + }; + } + + clear(): void { + this.entries = []; + } +} + +export const spamAuditLogger = new AuditLogger(); + +export function hashPhoneNumber(phoneNumber: string): string { + const hash = createHash('sha256').update(phoneNumber.trim()).digest('hex'); + return `sha256_${hash}`; +} diff --git a/apps/api/src/services/spamshield/spamshield.config.ts b/apps/api/src/services/spamshield/spamshield.config.ts new file mode 100644 index 0000000..af56605 --- /dev/null +++ b/apps/api/src/services/spamshield/spamshield.config.ts @@ -0,0 +1,163 @@ +import { z } from 'zod'; +import { checkFlag } from './feature-flags'; + +// Environment variables for SpamShield +const envSchema = z.object({ + HIYA_API_KEY: z.string(), + HIYA_API_URL: z.string().default('https://api.hiya.com/v1'), + TRUECALLER_API_KEY: z.string().optional(), + BERT_MODEL_PATH: z.string().default('./models/spam-classifier'), + SPAM_THRESHOLD_AUTO_BLOCK: z.string().transform(Number).default(0.85), + SPAM_THRESHOLD_FLAG: z.string().transform(Number).default(0.6), + CALL_ANALYSIS_TIMEOUT_MS: z.string().transform(Number).default(200), +}); + +export const spamShieldEnv = envSchema.parse({ + HIYA_API_KEY: process.env.HIYA_API_KEY, + HIYA_API_URL: process.env.HIYA_API_URL, + TRUECALLER_API_KEY: process.env.TRUECALLER_API_KEY, + BERT_MODEL_PATH: process.env.BERT_MODEL_PATH, + SPAM_THRESHOLD_AUTO_BLOCK: process.env.SPAM_THRESHOLD_AUTO_BLOCK, + SPAM_THRESHOLD_FLAG: process.env.SPAM_THRESHOLD_FLAG, + CALL_ANALYSIS_TIMEOUT_MS: process.env.CALL_ANALYSIS_TIMEOUT_MS, +}); + +// Spam detection layers +export enum SpamLayer { + NUMBER_REPUTATION = 'number_reputation', + CONTENT_CLASSIFICATION = 'content_classification', + BEHAVIORAL_ANALYSIS = 'behavioral_analysis', + COMMUNITY_INTELLIGENCE = 'community_intelligence', +} + +// Spam decision types +export enum SpamDecision { + ALLOW = 'allow', + FLAG = 'flag', + BLOCK = 'block', + CHALLENGE = 'challenge', +} + +// Confidence levels +export enum ConfidenceLevel { + LOW = 'low', + MEDIUM = 'medium', + HIGH = 'high', + VERY_HIGH = 'very_high', +} + +// Feature flags for spam detection +// Use the centralized feature flag system from feature-flags.ts +// These are aliases for quick access +export const spamFeatureFlags = { + enableNumberReputation: checkFlag('spamshield.enable.number.reputation', true), + enableContentClassification: checkFlag('spamshield.enable.content.classification', true), + enableBehavioralAnalysis: checkFlag('spamshield.enable.behavioral.analysis', true), + enableCommunityIntelligence: checkFlag('spamshield.enable.community.intelligence', true), + enableRealTimeBlocking: checkFlag('spamshield.enable.real.time.blocking', true), + enableMultipleSources: checkFlag('spamshield.enable.multiple.sources', false), + enableMLClassifier: checkFlag('spamshield.enable.ml.classifier', false), +}; + +// Rate limits for spam analysis +export const spamRateLimits = { + basic: { + analysesPerMinute: 10, + analysesPerDay: 100, + }, + plus: { + analysesPerMinute: 50, + analysesPerDay: 1000, + }, + premium: { + analysesPerMinute: 200, + analysesPerDay: 10000, + }, +}; + +// Default confidence scores for spam detection layers +export const defaultScores = { + // Number reputation service defaults + defaultReputationConfidence: 0.0, + defaultReputationLowConfidence: 0.1, + + // SMS classifier defaults + defaultBaseConfidence: 0.5, + defaultMaxConfidence: 1.0, + + // Feature weights for SMS classification + featureWeights: { + urlPresent: 0.1, + highEmojiDensity: 0.15, + urgencyKeyword: 0.2, + excessiveCaps: 0.15, + }, + + // Call analysis defaults + defaultSpamScore: 0.0, + highReputationThreshold: 0.7, + reputationWeightInCombinedScore: 0.4, + shortDurationScore: 0.2, + voipScore: 0.15, + unusualHoursScore: 0.1, + + // Source combination weights + hiyaWeightInCombinedScore: 0.7, + truecallerWeightInCombinedScore: 0.3, +}; + +// Metadata size limits for SpamFeedback +export const metadataLimits = { + // Maximum size for metadata JSON in bytes + maxMetadataSizeBytes: 4096, + + // Maximum number of keys in metadata object + maxMetadataKeys: 20, + + // Maximum size for individual metadata value in bytes + maxMetadataValueSizeBytes: 512, +}; + +// Standard error codes for spamshield API +export enum SpamErrorCode { + // Client errors (4xx) + INVALID_REQUEST = 'INVALID_REQUEST', + MISSING_REQUIRED_FIELD = 'MISSING_REQUIRED_FIELD', + UNAUTHORIZED = 'UNAUTHORIZED', + NOT_FOUND = 'NOT_FOUND', + VALIDATION_ERROR = 'VALIDATION_ERROR', + + // Server errors (5xx) + CLASSIFICATION_FAILED = 'CLASSIFICATION_FAILED', + REPUTATION_CHECK_FAILED = 'REPUTATION_CHECK_FAILED', + ANALYSIS_FAILED = 'ANALYSIS_FAILED', + FEEDBACK_RECORD_FAILED = 'FEEDBACK_RECORD_FAILED', + DATABASE_ERROR = 'DATABASE_ERROR', + RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED', + SERVICE_UNAVAILABLE = 'SERVICE_UNAVAILABLE', +} + +// Standard error response type +export interface SpamErrorResponse { + error: { + code: SpamErrorCode; + message: string; + field?: string; + timestamp: string; + requestId?: string; + }; +} + +// HTTP status code constants +export const HttpStatus = { + OK: 200, + CREATED: 201, + BAD_REQUEST: 400, + UNAUTHORIZED: 401, + FORBIDDEN: 403, + NOT_FOUND: 404, + UNPROCESSABLE_ENTITY: 422, + TOO_MANY_REQUESTS: 429, + INTERNAL_SERVER_ERROR: 500, + SERVICE_UNAVAILABLE: 503, +}; diff --git a/apps/api/src/services/spamshield/spamshield.error-handler.ts b/apps/api/src/services/spamshield/spamshield.error-handler.ts new file mode 100644 index 0000000..41b382d --- /dev/null +++ b/apps/api/src/services/spamshield/spamshield.error-handler.ts @@ -0,0 +1,118 @@ +import { FastifyReply } from 'fastify'; +import { SpamErrorCode, HttpStatus, SpamErrorResponse } from './spamshield.config'; + +export { SpamErrorCode, HttpStatus }; +export type { SpamErrorResponse }; + +/** + * Standardized error response builder for SpamShield API + */ +export class ErrorHandler { + /** + * Create a standard error response + */ + static create( + code: SpamErrorCode, + message: string, + options?: { + field?: string; + requestId?: string; + additionalData?: Record; + } + ): SpamErrorResponse { + return { + error: { + code, + message, + ...(options?.field && { field: options.field }), + timestamp: new Date().toISOString(), + ...(options?.requestId && { requestId: options.requestId }), + }, + }; + } + + /** + * Send a standard error response with appropriate HTTP status code + */ + static send( + reply: FastifyReply, + code: SpamErrorCode, + message: string, + options?: { + field?: string; + status?: number; + requestId?: string; + } + ): void { + const status = options?.status ?? this.getStatusForCode(code); + const errorResponse = this.create(code, message, { + field: options?.field, + requestId: options?.requestId, + }); + reply.code(status).send(errorResponse); + } + + /** + * Map error codes to HTTP status codes + */ + private static getStatusForCode(code: SpamErrorCode): number { + const statusMap: Record = { + // Client errors + [SpamErrorCode.INVALID_REQUEST]: HttpStatus.BAD_REQUEST, + [SpamErrorCode.MISSING_REQUIRED_FIELD]: HttpStatus.BAD_REQUEST, + [SpamErrorCode.UNAUTHORIZED]: HttpStatus.UNAUTHORIZED, + [SpamErrorCode.NOT_FOUND]: HttpStatus.NOT_FOUND, + [SpamErrorCode.VALIDATION_ERROR]: HttpStatus.BAD_REQUEST, + + // Server errors + [SpamErrorCode.CLASSIFICATION_FAILED]: HttpStatus.UNPROCESSABLE_ENTITY, + [SpamErrorCode.REPUTATION_CHECK_FAILED]: HttpStatus.UNPROCESSABLE_ENTITY, + [SpamErrorCode.ANALYSIS_FAILED]: HttpStatus.UNPROCESSABLE_ENTITY, + [SpamErrorCode.FEEDBACK_RECORD_FAILED]: HttpStatus.UNPROCESSABLE_ENTITY, + [SpamErrorCode.DATABASE_ERROR]: HttpStatus.INTERNAL_SERVER_ERROR, + [SpamErrorCode.RATE_LIMIT_EXCEEDED]: HttpStatus.TOO_MANY_REQUESTS, + [SpamErrorCode.SERVICE_UNAVAILABLE]: HttpStatus.SERVICE_UNAVAILABLE, + }; + return statusMap[code] ?? HttpStatus.INTERNAL_SERVER_ERROR; + } + + /** + * Validate required string field + */ + static validateRequiredField( + value: unknown, + fieldName: string + ): { isValid: boolean; error?: { code: SpamErrorCode; message: string; field: string } } { + if (!value || typeof value !== 'string' || value.trim() === '') { + return { + isValid: false, + error: { + code: SpamErrorCode.MISSING_REQUIRED_FIELD, + message: `${fieldName} is required`, + field: fieldName, + }, + }; + } + return { isValid: true }; + } + + /** + * Validate boolean field + */ + static validateBooleanField( + value: unknown, + fieldName: string + ): { isValid: boolean; error?: { code: SpamErrorCode; message: string; field: string } } { + if (value === undefined || value === null || typeof value !== 'boolean') { + return { + isValid: false, + error: { + code: SpamErrorCode.VALIDATION_ERROR, + message: `${fieldName} must be a boolean`, + field: fieldName, + }, + }; + } + return { isValid: true }; + } +} diff --git a/apps/api/src/services/spamshield/spamshield.service.ts b/apps/api/src/services/spamshield/spamshield.service.ts new file mode 100644 index 0000000..9dd8add --- /dev/null +++ b/apps/api/src/services/spamshield/spamshield.service.ts @@ -0,0 +1,462 @@ +import { prisma, SpamFeedback } from '@shieldsai/shared-db'; +import { spamShieldEnv, SpamDecision, spamFeatureFlags, defaultScores, metadataLimits } from './spamshield.config'; +import { createHash } from 'crypto'; +import { spamAuditLogger, hashPhoneNumber } from './spamshield.audit-logger'; + +// Number reputation service (Hiya API integration) +export class NumberReputationService { + /** + * Check number reputation using Hiya API + */ + async checkReputation(phoneNumber: string): Promise<{ + isSpam: boolean; + confidence: number; + spamType?: string; + reportCount: number; + }> { + try { + // Only enable if feature flag is set + if (!spamFeatureFlags.enableNumberReputation) { + return { + isSpam: false, + confidence: 0.0, + reportCount: 0, + }; + } + + // TODO: Integrate with Hiya API + // const response = await fetch(`${spamShieldEnv.HIYA_API_URL}/lookup`, { + // headers: { 'X-API-Key': spamShieldEnv.HIYA_API_KEY }, + // method: 'POST', + // body: JSON.stringify({ phone: phoneNumber }), + // }); + + // Simulated response for now + return { + isSpam: false, + confidence: defaultScores.defaultReputationLowConfidence, + spamType: undefined, + reportCount: 0, + }; + } catch (error) { + console.error('Error checking number reputation:', error); + return { + isSpam: false, + confidence: defaultScores.defaultReputationConfidence, + reportCount: 0, + }; + } + } + + /** + * Check number against multiple reputation sources + */ + async checkMultiSource(phoneNumber: string): Promise<{ + hiya: { isSpam: boolean; confidence: number }; + truecaller: { isSpam: boolean; confidence: number } | null; + combinedScore: number; + }> { + // Only enable if feature flag is set + if (!spamFeatureFlags.enableMultipleSources) { + return { + hiya: { isSpam: false, confidence: defaultScores.defaultReputationConfidence }, + truecaller: null, + combinedScore: defaultScores.defaultSpamScore, + }; + } + + const hiyaResult = await this.checkReputation(phoneNumber); + + let truecallerResult: { isSpam: boolean; confidence: number } | null = null; + if (spamShieldEnv.TRUECALLER_API_KEY) { + // TODO: Integrate Truecaller + truecallerResult = { + isSpam: false, + confidence: defaultScores.defaultReputationConfidence, + }; + } + + // Weighted average: Hiya 70%, Truecaller 30% + const combinedScore = hiyaResult.confidence * defaultScores.hiyaWeightInCombinedScore + + (truecallerResult?.confidence ?? defaultScores.defaultReputationConfidence) * defaultScores.truecallerWeightInCombinedScore; + + return { + hiya: { isSpam: hiyaResult.isSpam, confidence: hiyaResult.confidence }, + truecaller: truecallerResult, + combinedScore, + }; + } +} + +// SMS content classifier (BERT-based) +export class SMSClassifierService { + private model: any = null; // BERT model placeholder + private _initPromise: Promise | null = null; + + /** + * Initialize the BERT model (thread-safe via promise deduplication) + */ + async initialize(): Promise { + // TODO: Load BERT model from path + // this.model = await loadBERTModel(spamShieldEnv.BERT_MODEL_PATH); + console.log('SMS classifier initialized'); + } + + /** + * Ensures model is initialized before use. Concurrent callers + * await the same initialization promise to avoid race conditions. + */ + private async ensureInitialized(): Promise { + if (this._initPromise) { + return this._initPromise; + } + this._initPromise = (async () => { + if (this.model) { + return; + } + await this.initialize(); + })(); + return this._initPromise; + } + + /** + * Classify SMS text as spam or ham + */ + async classify( + smsText: string, + phoneNumber?: string + ): Promise<{ + isSpam: boolean; + confidence: number; + spamFeatures: string[]; + }> { + // Only enable if feature flag is set + if (!spamFeatureFlags.enableMLClassifier) { + // Return basic feature-based classification + const features = this.extractFeatures(smsText); + const confidence = this.calculateConfidence(features); + const isSpam = confidence >= spamShieldEnv.SPAM_THRESHOLD_AUTO_BLOCK; + + spamAuditLogger.logClassification({ + type: 'sms', + phoneNumberHash: phoneNumber ? hashPhoneNumber(phoneNumber) : 'unknown', + decision: isSpam ? 'spam' : 'ham', + confidence, + reasons: features, + featureFlags: { enableMLClassifier: spamFeatureFlags.enableMLClassifier }, + }); + + return { + isSpam, + confidence, + spamFeatures: features, + }; + } + + await this.ensureInitialized(); + + // Extract features + const features = this.extractFeatures(smsText); + + // TODO: Run through BERT model + // const prediction = await this.model.predict(smsText); + + // Simulated prediction + const confidence = this.calculateConfidence(features); + const isSpam = confidence >= spamShieldEnv.SPAM_THRESHOLD_AUTO_BLOCK; + + spamAuditLogger.logClassification({ + type: 'sms', + phoneNumberHash: phoneNumber ? hashPhoneNumber(phoneNumber) : 'unknown', + decision: isSpam ? 'spam' : 'ham', + confidence, + reasons: features, + featureFlags: { enableMLClassifier: spamFeatureFlags.enableMLClassifier }, + }); + + return { + isSpam, + confidence, + spamFeatures: features, + }; + } + + private extractFeatures(text: string): string[] { + const features: string[] = []; + const lowerText = text.toLowerCase(); + + // URL presence + if (/(http|www)\./i.test(text)) { + features.push('url_present'); + } + + // Emoji density + const emojiCount = (text.match(/[\p{Emoji}]/gu) || []).length; + if (emojiCount / text.length > 0.1) { + features.push('high_emoji_density'); + } + + // Urgency keywords + const urgencyWords = ['now', 'urgent', 'limited', 'act fast', 'today']; + if (urgencyWords.some(word => lowerText.includes(word))) { + features.push('urgency_keyword'); + } + + // Excessive capitalization + if (/[A-Z]{3,}/.test(text)) { + features.push('excessive_caps'); + } + + return features; + } + + private calculateConfidence(features: string[]): number { + const baseConfidence = defaultScores.defaultBaseConfidence; + const featureWeights: Record = { + url_present: defaultScores.featureWeights.urlPresent, + high_emoji_density: defaultScores.featureWeights.highEmojiDensity, + urgency_keyword: defaultScores.featureWeights.urgencyKeyword, + excessive_caps: defaultScores.featureWeights.excessiveCaps, + }; + + return Math.min(defaultScores.defaultMaxConfidence, baseConfidence + + features.reduce((sum, f) => sum + (featureWeights[f] || 0), 0)); + } +} + +// Call analysis service +export class CallAnalysisService { + /** + * Analyze incoming call for spam indicators + */ + async analyzeCall(callData: { + phoneNumber: string; + duration?: number; + callTime: Date; + isVoip?: boolean; + }): Promise<{ + decision: SpamDecision; + confidence: number; + reasons: string[]; + }> { + const reasons: string[] = []; + let spamScore = defaultScores.defaultSpamScore; + + // Number reputation check - only if feature flag enabled + if (spamFeatureFlags.enableBehavioralAnalysis) { + const reputationService = new NumberReputationService(); + const reputation = await reputationService.checkMultiSource(callData.phoneNumber); + + if (reputation.combinedScore > defaultScores.highReputationThreshold) { + spamScore += reputation.combinedScore * defaultScores.reputationWeightInCombinedScore; + reasons.push('high_spam_reputation'); + } + } + + // Behavioral analysis - only if feature flag enabled + if (spamFeatureFlags.enableBehavioralAnalysis) { + if (callData.duration && callData.duration < 10) { + spamScore += defaultScores.shortDurationScore; + reasons.push('short_duration'); + } + + if (callData.isVoip) { + spamScore += defaultScores.voipScore; + reasons.push('voip_number'); + } + + // Time-of-day anomaly (simplified) + const hour = callData.callTime.getHours(); + if (hour < 6 || hour > 22) { + spamScore += defaultScores.unusualHoursScore; + reasons.push('unusual_hours'); + } + } + + // Determine decision + let decision: SpamDecision; + if (spamScore >= spamShieldEnv.SPAM_THRESHOLD_AUTO_BLOCK) { + decision = SpamDecision.BLOCK; + } else if (spamScore >= spamShieldEnv.SPAM_THRESHOLD_FLAG) { + decision = SpamDecision.FLAG; + } else { + decision = SpamDecision.ALLOW; + } + + spamAuditLogger.logClassification({ + type: 'call', + phoneNumberHash: hashPhoneNumber(callData.phoneNumber), + decision: decision.toLowerCase() as 'block' | 'flag' | 'allow', + confidence: spamScore, + reasons, + featureFlags: { + enableBehavioralAnalysis: spamFeatureFlags.enableBehavioralAnalysis, + enableNumberReputation: spamFeatureFlags.enableNumberReputation, + }, + metadata: { + duration: callData.duration, + isVoip: callData.isVoip, + callTime: callData.callTime.toISOString(), + }, + }); + + return { + decision, + confidence: spamScore, + reasons, + }; + } +} + +// User feedback service +export class SpamFeedbackService { + /** + * Validate metadata size against defined limits + */ + private validateMetadata(metadata?: Record): { + isValid: boolean; + trimmedMetadata?: Record; + reasons?: string[]; + } { + if (!metadata) { + return { isValid: true }; + } + + const reasons: string[] = []; + let trimmedMetadata: Record = metadata; + + // Check number of keys + const keyCount = Object.keys(metadata).length; + if (keyCount > metadataLimits.maxMetadataKeys) { + reasons.push(`Metadata has ${keyCount} keys, exceeding limit of ${metadataLimits.maxMetadataKeys}`); + trimmedMetadata = Object.entries(metadata).slice(0, metadataLimits.maxMetadataKeys); + } + + // Check total JSON size + const jsonSize = JSON.stringify(metadata).length; + if (jsonSize > metadataLimits.maxMetadataSizeBytes) { + reasons.push(`Metadata size ${jsonSize} bytes exceeds limit of ${metadataLimits.maxMetadataSizeBytes} bytes`); + + // Truncate long values + trimmedMetadata = Object.fromEntries( + Object.entries(metadata).map(([key, value]) => { + const valueStr = String(value); + if (valueStr.length > metadataLimits.maxMetadataValueSizeBytes) { + return [key, valueStr.slice(0, metadataLimits.maxMetadataValueSizeBytes)]; + } + return [key, value]; + }) + ); + } + + return { + isValid: reasons.length === 0, + trimmedMetadata, + reasons: reasons.length > 0 ? reasons : undefined, + }; + } + + /** + * Record user feedback on spam detection + */ + async recordFeedback( + userId: string, + phoneNumber: string, + isSpam: boolean, + confidence?: number, + metadata?: Record + ): Promise { + // Validate metadata + const validation = this.validateMetadata(metadata); + const validatedMetadata = validation.trimmedMetadata; + + // Only enable if feature flag is set + if (!spamFeatureFlags.enableCommunityIntelligence) { + // Return a mock feedback for development + return { + id: `mock_${Date.now()}`, + userId, + phoneNumber, + phoneNumberHash: this.hashPhoneNumber(phoneNumber), + isSpam, + confidence, + feedbackType: 'user_confirmation' as const, + metadata: validatedMetadata, + createdAt: new Date(), + updatedAt: new Date(), + }; + } + + const phoneNumberHash = this.hashPhoneNumber(phoneNumber); + + const feedback = await prisma.spamFeedback.create({ + data: { + userId, + phoneNumber, + phoneNumberHash, + isSpam, + confidence, + feedbackType: 'user_confirmation', + metadata: validatedMetadata, + }, + }); + + return feedback; + } + + /** + * Get spam history for a user + */ + async getSpamHistory( + userId: string, + options?: { + limit?: number; + isSpam?: boolean; + startDate?: Date; + } + ): Promise { + return prisma.spamFeedback.findMany({ + where: { + userId, + ...(options?.isSpam !== undefined && { isSpam: options.isSpam }), + ...(options?.startDate && { createdAt: { gte: options.startDate } }), + }, + orderBy: { createdAt: 'desc' }, + take: options?.limit ?? 100, + }); + } + + /** + * Get statistics for a user + */ + async getStatistics(userId: string): Promise<{ + totalAnalyses: number; + spamCount: number; + hamCount: number; + spamPercentage: number; + }> { + const [total, spam] = await Promise.all([ + prisma.spamFeedback.count({ where: { userId } }), + prisma.spamFeedback.count({ where: { userId, isSpam: true } }), + ]); + + return { + totalAnalyses: total, + spamCount: spam, + hamCount: total - spam, + spamPercentage: total > 0 ? (spam / total) * 100 : 0, + }; + } + + private hashPhoneNumber(phoneNumber: string): string { + // SHA-256 hash for phone number fingerprinting + const hash = createHash('sha256').update(phoneNumber).digest('hex'); + return `sha256_${hash}`; + } +} + +// Export instances +export const numberReputationService = new NumberReputationService(); +export const smsClassifierService = new SMSClassifierService(); +export const callAnalysisService = new CallAnalysisService(); +export const spamFeedbackService = new SpamFeedbackService(); diff --git a/apps/api/src/services/voiceprint/index.ts b/apps/api/src/services/voiceprint/index.ts new file mode 100644 index 0000000..4d40bf7 --- /dev/null +++ b/apps/api/src/services/voiceprint/index.ts @@ -0,0 +1,30 @@ +// Config +export { + voicePrintEnv, + VoicePrintSource, + AnalysisJobStatus, + DetectionType, + ConfidenceLevel, + audioPreprocessingConfig, + voicePrintFeatureFlags, + voicePrintRateLimits, + checkFlag, + isFeatureEnabled, +} from './voiceprint.config'; + + + +// Services +export { + AudioPreprocessor, + VoiceEnrollmentService, + AnalysisService, + BatchAnalysisService, + EmbeddingService, + FAISSIndex, + audioPreprocessor, + voiceEnrollmentService, + analysisService, + batchAnalysisService, + embeddingService, +} from './voiceprint.service'; diff --git a/apps/api/src/services/voiceprint/voiceprint.config.ts b/apps/api/src/services/voiceprint/voiceprint.config.ts new file mode 100644 index 0000000..f117f5a --- /dev/null +++ b/apps/api/src/services/voiceprint/voiceprint.config.ts @@ -0,0 +1,102 @@ +import { z } from 'zod'; +import { checkFlag } from './voiceprint.feature-flags'; + +// Environment variables for VoicePrint +const envSchema = z.object({ + ECAPA_TDNN_MODEL_PATH: z.string().default('./models/ecapa-tdnn'), + ML_SERVICE_URL: z.string().default('http://localhost:8001'), + FAISS_INDEX_PATH: z.string().default('./data/voiceprint_faiss.index'), + AUDIO_STORAGE_BUCKET: z.string().default('voiceprint-audio'), + AUDIO_STORAGE_ENDPOINT: z.string().default('http://localhost:9000'), + SYNTHETIC_THRESHOLD: z.string().transform(Number).default(0.75), + ENROLLMENT_MIN_DURATION_SEC: z.string().transform(Number).default(3), + ENROLLMENT_MAX_DURATION_SEC: z.string().transform(Number).default(60), + EMBEDDING_DIMENSIONS: z.string().transform(Number).default(192), + BATCH_MAX_FILES: z.string().transform(Number).default(20), + ANALYSIS_TIMEOUT_MS: z.string().transform(Number).default(30000), +}); + +export const voicePrintEnv = envSchema.parse({ + ECAPA_TDNN_MODEL_PATH: process.env.ECAPA_TDNN_MODEL_PATH, + ML_SERVICE_URL: process.env.ML_SERVICE_URL, + FAISS_INDEX_PATH: process.env.FAISS_INDEX_PATH, + AUDIO_STORAGE_BUCKET: process.env.AUDIO_STORAGE_BUCKET, + AUDIO_STORAGE_ENDPOINT: process.env.AUDIO_STORAGE_ENDPOINT, + SYNTHETIC_THRESHOLD: process.env.SYNTHETIC_THRESHOLD, + ENROLLMENT_MIN_DURATION_SEC: process.env.ENROLLMENT_MIN_DURATION_SEC, + ENROLLMENT_MAX_DURATION_SEC: process.env.ENROLLMENT_MAX_DURATION_SEC, + EMBEDDING_DIMENSIONS: process.env.EMBEDDING_DIMENSIONS, + BATCH_MAX_FILES: process.env.BATCH_MAX_FILES, + ANALYSIS_TIMEOUT_MS: process.env.ANALYSIS_TIMEOUT_MS, +}); + +// Audio source types +export enum VoicePrintSource { + UPLOAD = 'upload', + S3 = 's3', + URL = 'url', + REALTIME = 'realtime', +} + +// Analysis job status +export enum AnalysisJobStatus { + PENDING = 'pending', + PROCESSING = 'processing', + COMPLETED = 'completed', + FAILED = 'failed', + CANCELLED = 'cancelled', +} + +// Detection result types +export enum DetectionType { + SYNTHETIC_VOICE = 'synthetic_voice', + VOICE_CLONE = 'voice_clone', + DEEPFAKE = 'deepfake', + NATURAL = 'natural', +} + +// Confidence levels +export enum ConfidenceLevel { + LOW = 'low', + MEDIUM = 'medium', + HIGH = 'high', + VERY_HIGH = 'very_high', +} + +// Audio preprocessing configuration +export const audioPreprocessingConfig = { + sampleRate: 16000, + channels: 1, + bitDepth: 16, + vadThreshold: 0.5, + noiseReduction: true, + maxSilenceDurationMs: 500, +}; + +// Feature flags - use centralized system +export const voicePrintFeatureFlags = { + enableMLService: checkFlag('voiceprint.enable.ml.service', false), + enableFAISSIndex: checkFlag('voiceprint.enable.faiss.index', true), + enableBatchAnalysis: checkFlag('voiceprint.enable.batch.analysis', true), + enableRealtimeAnalysis: checkFlag('voiceprint.enable.realtime.analysis', false), + enableMockModel: checkFlag('voiceprint.enable.mock.model', true), +}; + +// Rate limits for voice analysis +export const voicePrintRateLimits = { + basic: { + analysesPerMinute: 5, + enrollmentsPerDay: 10, + maxAudioFileSizeMB: 50, + }, + plus: { + analysesPerMinute: 30, + enrollmentsPerDay: 50, + maxAudioFileSizeMB: 200, + }, + premium: { + analysesPerMinute: 100, + enrollmentsPerDay: 500, + maxAudioFileSizeMB: 500, + }, +}; diff --git a/apps/api/src/services/voiceprint/voiceprint.feature-flags.ts b/apps/api/src/services/voiceprint/voiceprint.feature-flags.ts new file mode 100644 index 0000000..c4c664d --- /dev/null +++ b/apps/api/src/services/voiceprint/voiceprint.feature-flags.ts @@ -0,0 +1,7 @@ +/** + * VoicePrint Feature Flags + * Re-exports the checkFlag function from the centralized feature flag system + */ + +// Re-export the checkFlag function from the spamshield feature flags module +export { checkFlag } from '../spamshield/feature-flags'; diff --git a/apps/api/src/services/voiceprint/voiceprint.service.ts b/apps/api/src/services/voiceprint/voiceprint.service.ts new file mode 100644 index 0000000..5f45dd3 --- /dev/null +++ b/apps/api/src/services/voiceprint/voiceprint.service.ts @@ -0,0 +1,594 @@ +import { prisma, VoiceEnrollment, VoiceAnalysis } from '@shieldsai/shared-db'; +import { + voicePrintEnv, + AnalysisJobStatus, + DetectionType, + ConfidenceLevel, + audioPreprocessingConfig, + voicePrintFeatureFlags, +} from './voiceprint.config'; +import { checkFlag } from './voiceprint.feature-flags'; + +// Audio preprocessing service +export class AudioPreprocessor { + /** + * Normalize audio to 16kHz mono with VAD and noise reduction. + * Returns preprocessing metadata and the processed audio buffer. + */ + async preprocess( + audioBuffer: Buffer, + options?: { + sourceSampleRate?: number; + channels?: number; + } + ): Promise<{ + buffer: Buffer; + metadata: { + sampleRate: number; + channels: number; + duration: number; + format: string; + }; + }> { + const duration = this.estimateDuration(audioBuffer, options?.sourceSampleRate ?? 44100); + + if (duration < voicePrintEnv.ENROLLMENT_MIN_DURATION_SEC) { + throw new Error( + `Audio too short: ${duration.toFixed(1)}s < ${voicePrintEnv.ENROLLMENT_MIN_DURATION_SEC}s minimum` + ); + } + + if (duration > voicePrintEnv.ENROLLMENT_MAX_DURATION_SEC) { + throw new Error( + `Audio too long: ${duration.toFixed(1)}s > ${voicePrintEnv.ENROLLMENT_MAX_DURATION_SEC}s maximum` + ); + } + + // TODO: Integrate with Python librosa/torchaudio for actual preprocessing + // For MVP, return original buffer with target metadata + return { + buffer: audioBuffer, + metadata: { + sampleRate: audioPreprocessingConfig.sampleRate, + channels: audioPreprocessingConfig.channels, + duration, + format: 'wav', + }, + }; + } + + /** + * Apply Voice Activity Detection to remove silence segments. + */ + async applyVAD(buffer: Buffer): Promise { + // TODO: Integrate with Python webrtcvad or silero-vad + // For MVP, return original buffer + return buffer; + } + + /** + * Estimate audio duration from buffer size and sample rate. + */ + private estimateDuration( + buffer: Buffer, + sampleRate: number + ): number { + const bytesPerSample = 2; + const channels = 1; + const samples = buffer.length / (bytesPerSample * channels); + return samples / sampleRate; + } +} + +// Voice enrollment service +export class VoiceEnrollmentService { + /** + * Enroll a new voice profile from audio data. + */ + async enroll( + userId: string, + name: string, + audioBuffer: Buffer + ): Promise { + const preprocessor = new AudioPreprocessor(); + const processed = await preprocessor.preprocess(audioBuffer); + + const embeddingService = new EmbeddingService(); + const embedding = await embeddingService.extract(processed.buffer); + const voiceHash = this.computeEmbeddingHash(embedding); + + const enrollment = await prisma.voiceEnrollment.create({ + data: { + userId, + name, + voiceHash, + audioMetadata: { + ...processed.metadata, + embeddingDimensions: embedding.length, + enrollmentTimestamp: new Date().toISOString(), + }, + }, + }); + + // Index in FAISS for similarity search + const faissIndex = new FAISSIndex(); + await faissIndex.add(enrollment.id, embedding); + + return enrollment; + } + + /** + * List all enrollments for a user. + */ + async listEnrollments( + userId: string, + options?: { + isActive?: boolean; + limit?: number; + offset?: number; + } + ): Promise { + return prisma.voiceEnrollment.findMany({ + where: { + userId, + ...(options?.isActive !== undefined && { isActive: options.isActive }), + }, + orderBy: { createdAt: 'desc' }, + take: options?.limit ?? 50, + skip: options?.offset ?? 0, + }); + } + + /** + * Get a single enrollment by ID. + */ + async getEnrollment( + enrollmentId: string, + userId: string + ): Promise { + return prisma.voiceEnrollment.findFirst({ + where: { + id: enrollmentId, + userId, + }, + }); + } + + /** + * Remove (deactivate) an enrollment. + */ + async removeEnrollment( + enrollmentId: string, + userId: string + ): Promise { + const enrollment = await this.getEnrollment(enrollmentId, userId); + if (!enrollment) { + throw new Error('Enrollment not found'); + } + + const faissIndex = new FAISSIndex(); + await faissIndex.remove(enrollmentId); + + return prisma.voiceEnrollment.update({ + where: { id: enrollmentId }, + data: { isActive: false }, + }); + } + + /** + * Search for similar enrollments using FAISS. + */ + async findSimilar( + embedding: number[], + topK: number = 5 + ): Promise> { + const faissIndex = new FAISSIndex(); + const results = await faissIndex.search(embedding, topK); + + const enrollmentIds = results.map((r) => r.id); + const enrollments = await prisma.voiceEnrollment.findMany({ + where: { id: { in: enrollmentIds } }, + }); + + return results.map((r, i) => ({ + enrollment: enrollments[i], + similarity: r.similarity, + })); + } + + private computeEmbeddingHash(embedding: number[]): string { + let hash = 0; + for (let i = 0; i < embedding.length; i++) { + hash = ((hash << 5) - hash) + embedding[i]; + hash |= 0; + } + return `vp_${Math.abs(hash).toString(16)}_${embedding.length}`; + } +} + +// Audio analysis service +export class AnalysisService { + /** + * Analyze a single audio file for synthetic voice detection. + */ + async analyze( + userId: string, + audioBuffer: Buffer, + options?: { + enrollmentId?: string; + audioUrl?: string; + } + ): Promise { + const preprocessor = new AudioPreprocessor(); + const processed = await preprocessor.preprocess(audioBuffer); + + const audioHash = this.computeAudioHash(audioBuffer); + + const embeddingService = new EmbeddingService(); + const analysisResult = await embeddingService.analyze(processed.buffer); + + const isSynthetic = analysisResult.confidence >= voicePrintEnv.SYNTHETIC_THRESHOLD; + + const voiceAnalysis = await prisma.voiceAnalysis.create({ + data: { + userId, + enrollmentId: options?.enrollmentId, + audioHash, + isSynthetic, + confidence: analysisResult.confidence, + analysisResult: { + ...analysisResult, + processedMetadata: processed.metadata, + analysisTimestamp: new Date().toISOString(), + modelVersion: 'ecapa-tdnn-v1-mock', + }, + audioUrl: options?.audioUrl ?? '', + }, + }); + + return voiceAnalysis; + } + + /** + * Get analysis result by ID. + */ + async getResult( + analysisId: string, + userId: string + ): Promise { + return prisma.voiceAnalysis.findFirst({ + where: { + id: analysisId, + userId, + }, + }); + } + + /** + * Get analysis history for a user. + */ + async getHistory( + userId: string, + options?: { + limit?: number; + offset?: number; + isSynthetic?: boolean; + } + ): Promise { + return prisma.voiceAnalysis.findMany({ + where: { + userId, + ...(options?.isSynthetic !== undefined && { isSynthetic: options.isSynthetic }), + }, + orderBy: { createdAt: 'desc' }, + take: options?.limit ?? 50, + skip: options?.offset ?? 0, + }); + } + + private computeAudioHash(buffer: Buffer): string { + let hash = 0; + const sampleSize = Math.min(buffer.length, 1024); + for (let i = 0; i < sampleSize; i += 8) { + hash = ((hash << 5) - hash) + buffer.readUInt8(i); + hash |= 0; + } + return `audio_${Math.abs(hash).toString(16)}`; + } +} + +// Batch analysis service +export class BatchAnalysisService { + /** + * Analyze multiple audio files in a batch. + */ + async analyzeBatch( + userId: string, + files: Array<{ + name: string; + buffer: Buffer; + audioUrl?: string; + }>, + options?: { + enrollmentId?: string; + } + ): Promise<{ + jobId: string; + results: VoiceAnalysis[]; + summary: { + total: number; + synthetic: number; + natural: number; + failed: number; + }; + }> { + if (files.length > voicePrintEnv.BATCH_MAX_FILES) { + throw new Error( + `Batch too large: ${files.length} > ${voicePrintEnv.BATCH_MAX_FILES} max` + ); + } + + const analysisService = new AnalysisService(); + const results: VoiceAnalysis[] = []; + let synthetic = 0; + let natural = 0; + let failed = 0; + + for (const file of files) { + try { + const result = await analysisService.analyze(userId, file.buffer, { + enrollmentId: options?.enrollmentId, + audioUrl: file.audioUrl, + }); + results.push(result); + if (result.isSynthetic) { + synthetic++; + } else { + natural++; + } + } catch (error) { + console.error(`Batch analysis failed for ${file.name}:`, error); + failed++; + } + } + + const jobId = `batch_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + + return { + jobId, + results, + summary: { + total: files.length, + synthetic, + natural, + failed, + }, + }; + } +} + +// Embedding service — ECAPA-TDNN inference wrapper +export class EmbeddingService { + private initialized = false; + + /** + * Initialize the ECAPA-TDNN model. + */ + async initialize(): Promise { + if (this.initialized) return; + + // TODO: Connect to Python ML service for real inference + // const response = await fetch(`${voicePrintEnv.ML_SERVICE_URL}/initialize`, { + // method: 'POST', + // body: JSON.stringify({ modelPath: voicePrintEnv.ECAPA_TDNN_MODEL_PATH }), + // }); + + this.initialized = true; + console.log('Embedding service initialized (mock model)'); + } + + /** + * Extract voice embedding from audio. + */ + async extract(audioBuffer: Buffer): Promise { + await this.initialize(); + + // TODO: Call Python ML service + // const response = await fetch(`${voicePrintEnv.ML_SERVICE_URL}/embed`, { + // method: 'POST', + // body: audioBuffer, + // }); + // const data = await response.json(); + // return data.embedding; + + // Mock: generate deterministic embedding based on buffer content + const dims = voicePrintEnv.EMBEDDING_DIMENSIONS; + const embedding: number[] = new Array(dims); + let hash = 0; + for (let i = 0; i < Math.min(audioBuffer.length, 256); i++) { + hash = ((hash << 5) - hash) + audioBuffer[i]; + hash |= 0; + } + for (let i = 0; i < dims; i++) { + hash = ((hash << 5) - hash) + i; + hash |= 0; + embedding[i] = (Math.abs(hash) % 1000) / 1000.0; + } + + // L2 normalize + const norm = Math.sqrt(embedding.reduce((s, v) => s + v * v, 0)); + return embedding.map((v) => v / norm); + } + + /** + * Run full analysis: embedding + synthetic detection. + */ + async analyze(audioBuffer: Buffer): Promise<{ + confidence: number; + detectionType: DetectionType; + features: Record; + embedding: number[]; + }> { + const embedding = await this.extract(audioBuffer); + + // TODO: Run synthetic voice detection model + // For MVP, use heuristic based on embedding statistics + const confidence = this.estimateSyntheticConfidence(audioBuffer, embedding); + const detectionType = + confidence >= voicePrintEnv.SYNTHETIC_THRESHOLD + ? DetectionType.SYNTHETIC_VOICE + : DetectionType.NATURAL; + + const features = this.extractAnalysisFeatures(audioBuffer, embedding); + + return { + confidence, + detectionType, + features, + embedding, + }; + } + + private estimateSyntheticConfidence( + buffer: Buffer, + embedding: number[] + ): number { + // Heuristic features for synthetic detection + const meanAmplitude = + buffer.reduce((s, v) => s + v, 0) / buffer.length / 255; + const embeddingStdDev = + Math.sqrt( + embedding.reduce((s, v) => s + (v - embedding.reduce((a, b) => a + b) / embedding.length) ** 2, 0) / + embedding.length + ) || 0; + + // Combine features into confidence score + const amplitudeScore = Math.abs(meanAmplitude - 0.5) * 2; + const embeddingScore = 1.0 - Math.min(1.0, embeddingStdDev * 2); + + return Math.min( + 1.0, + amplitudeScore * 0.3 + embeddingScore * 0.4 + Math.random() * 0.3 + ); + } + + private extractAnalysisFeatures( + buffer: Buffer, + embedding: number[] + ): Record { + const meanAmplitude = + buffer.reduce((s, v) => s + v, 0) / buffer.length / 255; + const zeroCrossings = buffer.reduce((count, v, i, arr) => { + return i > 0 && ((v - 128) * (arr[i - 1] - 128) < 0) ? count + 1 : count; + }, 0); + + return { + mean_amplitude: meanAmplitude, + zero_crossing_rate: zeroCrossings / buffer.length, + embedding_energy: embedding.reduce((s, v) => s + v * v, 0), + embedding_entropy: this.calculateEntropy(embedding), + }; + } + + private calculateEntropy(values: number[]): number { + const bins = 20; + const histogram = new Array(bins).fill(0); + const min = Math.min(...values); + const max = Math.max(...values); + const range = max - min || 1; + + for (const v of values) { + const bin = Math.min(bins - 1, Math.floor(((v - min) / range) * bins)); + histogram[bin]++; + } + + let entropy = 0; + const total = values.length; + for (const count of histogram) { + if (count > 0) { + const p = count / total; + entropy -= p * Math.log2(p); + } + } + return entropy; + } +} + +// FAISS index wrapper for voice fingerprint matching +export class FAISSIndex { + private indexPath: string; + private initialized = false; + + constructor(path?: string) { + this.indexPath = path ?? voicePrintEnv.FAISS_INDEX_PATH; + } + + /** + * Initialize or load the FAISS index. + */ + async initialize(): Promise { + if (this.initialized) return; + + // TODO: Load FAISS index from disk + // const faiss = require('faiss-node'); + // this.index = faiss.readIndex(this.indexPath); + + this.initialized = true; + console.log(`FAISS index initialized at ${this.indexPath}`); + } + + /** + * Add an enrollment embedding to the index. + */ + async add(enrollmentId: string, embedding: number[]): Promise { + await this.initialize(); + + // TODO: Add to FAISS index + // this.index.add([embedding]); + // Store mapping: enrollmentId -> index position + console.log(`Added enrollment ${enrollmentId} to FAISS index`); + } + + /** + * Remove an enrollment from the index. + */ + async remove(enrollmentId: string): Promise { + await this.initialize(); + + // TODO: Remove from FAISS index + console.log(`Removed enrollment ${enrollmentId} from FAISS index`); + } + + /** + * Search for similar voice embeddings. + */ + async search( + embedding: number[], + topK: number = 5 + ): Promise> { + await this.initialize(); + + // TODO: Query FAISS index + // const [distances, indices] = this.index.search([embedding], topK); + // Map indices back to enrollment IDs + + // Mock: return empty results + return []; + } + + /** + * Save the index to disk. + */ + async save(): Promise { + await this.initialize(); + // TODO: Write FAISS index to disk + console.log(`FAISS index saved to ${this.indexPath}`); + } +} + +// Export singleton instances +export const audioPreprocessor = new AudioPreprocessor(); +export const voiceEnrollmentService = new VoiceEnrollmentService(); +export const analysisService = new AnalysisService(); +export const batchAnalysisService = new BatchAnalysisService(); +export const embeddingService = new EmbeddingService(); diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..e229935 --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/apps/mobile/package.json b/apps/mobile/package.json new file mode 100644 index 0000000..6d037a9 --- /dev/null +++ b/apps/mobile/package.json @@ -0,0 +1,22 @@ +{ + "name": "mobile", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "lint": "eslint src/" + }, + "dependencies": { + "solid-js": "^1.8.14", + "@shieldsai/shared-auth": "*", + "@shieldsai/shared-ui": "*", + "@shieldsai/shared-utils": "*" + }, + "devDependencies": { + "typescript": "^5.3.3", + "vite": "^5.1.4", + "@types/node": "^25.6.0" + } +} diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..fd55f6e --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,24 @@ +{ + "name": "web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "lint": "eslint src/" + }, + "dependencies": { + "solid-js": "^1.8.14", + "@shieldsai/shared-auth": "*", + "@shieldsai/shared-ui": "*", + "@shieldsai/shared-utils": "*" + }, + "devDependencies": { + "typescript": "^5.3.3", + "vite": "^5.1.4", + "vite-plugin-solid": "^2.8.2", + "@types/node": "^25.6.0" + } +} diff --git a/packages/shared-db/drizzle.config.ts b/packages/shared-db/drizzle.config.ts new file mode 100644 index 0000000..6b8d17e --- /dev/null +++ b/packages/shared-db/drizzle.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'drizzle-kit'; + +export default defineConfig({ + schema: './prisma/schema.prisma', + out: './migrations', + dialect: 'postgresql', + dbCredentials: { + url: process.env.DATABASE_URL!, + }, + verbose: true, + strict: true, +}); diff --git a/packages/shared-db/package.json b/packages/shared-db/package.json new file mode 100644 index 0000000..8c4193a --- /dev/null +++ b/packages/shared-db/package.json @@ -0,0 +1,23 @@ +{ + "name": "@shieldsai/shared-db", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "scripts": { + "db:generate": "prisma generate", + "db:push": "prisma db push", + "db:migrate": "prisma migrate deploy", + "db:studio": "prisma studio", + "db:format": "prisma format" + }, + "dependencies": { + "@prisma/client": "^5.14.0", + "zod": "^4.3.6" + }, + "devDependencies": { + "prisma": "^5.14.0", + "typescript": "^5.3.3" + } +} diff --git a/packages/shared-db/prisma/schema.prisma b/packages/shared-db/prisma/schema.prisma new file mode 100644 index 0000000..edd32af --- /dev/null +++ b/packages/shared-db/prisma/schema.prisma @@ -0,0 +1,437 @@ +// Prisma schema for ShieldAI +// All models for the multi-service SaaS platform + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +// ============================================ +// User & Authentication Models +// ============================================ + +model User { + id String @id @default(uuid()) + email String @unique + emailVerified DateTime? + name String? + image String? + role UserRole @default(user) + + // Relationships + accounts Account[] + sessions Session[] + familyGroups FamilyGroupMember[] + familyGroupOwned FamilyGroup[] @relation("FamilyGroupOwner") + subscriptions Subscription[] + alerts Alert[] + voiceEnrollments VoiceEnrollment[] + voiceAnalyses VoiceAnalysis[] + spamFeedback SpamFeedback[] + spamRules SpamRule[] + + // Audit + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([email]) + @@index([role]) +} + +enum UserRole { + user + family_admin + family_member + support +} + +model Account { + id String @id @default(uuid()) + userId String + provider String + providerAccountId String + access_token String? + refresh_token String? + expires_at Int? + token_type String? + scope String? + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([userId, provider, providerAccountId]) + @@index([userId]) +} + +model Session { + id String @id @default(uuid()) + userId String + sessionToken String @unique + expires DateTime + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([sessionToken]) + @@index([userId]) +} + +// ============================================ +// Family & Subscription Models +// ============================================ + +model FamilyGroup { + id String @id @default(uuid()) + name String + ownerId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + owner User @relation("FamilyGroupOwner", fields: [ownerId], references: [id]) + members FamilyGroupMember[] + subscriptions Subscription[] + + @@index([ownerId]) + @@index([name]) +} + +model FamilyGroupMember { + id String @id @default(uuid()) + groupId String + userId String + role FamilyMemberRole @default(member) + joinedAt DateTime @default(now()) + + group FamilyGroup @relation(fields: [groupId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([groupId, userId]) + @@index([groupId]) + @@index([userId]) +} + +enum FamilyMemberRole { + owner + admin + member +} + +model Subscription { + id String @id @default(uuid()) + userId String + familyGroupId String? + stripeId String? @unique + tier SubscriptionTier @default(basic) + status SubscriptionStatus @default(active) + currentPeriodStart DateTime + currentPeriodEnd DateTime + cancelAtPeriodEnd Boolean @default(false) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + familyGroup FamilyGroup? @relation(fields: [familyGroupId], references: [id]) + + watchlistItems WatchlistItem[] + exposures Exposure[] + alerts Alert[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId]) + @@index([familyGroupId]) + @@index([stripeId]) + @@index([tier]) +} + +enum SubscriptionTier { + basic + plus + premium +} + +enum SubscriptionStatus { + active + past_due + canceled + unpaid + trialing +} + +// ============================================ +// DarkWatch Models (Dark Web Monitoring) +// ============================================ + +model WatchlistItem { + id String @id @default(uuid()) + subscriptionId String + type WatchlistType + value String + hash String // SHA-256 hash for deduplication + isActive Boolean @default(true) + + subscription Subscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade) + exposures Exposure[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([subscriptionId, type, hash]) + @@index([subscriptionId]) + @@index([type]) + @@index([hash]) +} + +enum WatchlistType { + email + phoneNumber + ssn + address + domain +} + +model Exposure { + id String @id @default(uuid()) + subscriptionId String + watchlistItemId String? + source ExposureSource + dataType WatchlistType + identifier String + identifierHash String + severity ExposureSeverity @default(info) + metadata Json? // Additional source-specific data + isFirstTime Boolean @default(false) + + subscription Subscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade) + watchlistItem WatchlistItem? @relation(fields: [watchlistItemId], references: [id]) + alerts Alert[] + + detectedAt DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([subscriptionId]) + @@index([watchlistItemId]) + @@index([source]) + @@index([severity]) + @@index([detectedAt]) +} + +enum ExposureSource { + hibp // Have I Been Pwned + securityTrails + censys + darkWebForum + shodan + honeypot +} + +enum ExposureSeverity { + info + warning + critical +} + +// ============================================ +// Notification & Alert Models +// ============================================ + +model Alert { + id String @id @default(uuid()) + subscriptionId String + userId String + exposureId String? + type AlertType + title String + message String + severity AlertSeverity @default(info) + isRead Boolean @default(false) + readAt DateTime? + channel AlertChannel[] // Array of notification channels + + subscription Subscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + exposure Exposure? @relation(fields: [exposureId], references: [id]) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([subscriptionId]) + @@index([userId]) + @@index([isRead]) + @@index([createdAt]) +} + +enum AlertType { + exposure_detected + exposure_resolved + scan_complete + subscription_changed + system_warning +} + +enum AlertSeverity { + info + warning + critical +} + +enum AlertChannel { + email + push + sms +} + +// ============================================ +// VoicePrint Models (Voice Cloning Detection) +// ============================================ + +model VoiceEnrollment { + id String @id @default(uuid()) + userId String + name String + voiceHash String // FAISS embedding hash + audioMetadata Json? // Sample rate, duration, etc. + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + analyses VoiceAnalysis[] + + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId]) + @@index([voiceHash]) +} + +model VoiceAnalysis { + id String @id @default(uuid()) + enrollmentId String? + userId String + audioHash String // Content hash of audio file + isSynthetic Boolean + confidence Float // 0.0 to 1.0 + analysisResult Json // Full ML analysis results + audioUrl String // S3 storage URL + + enrollment VoiceEnrollment? @relation(fields: [enrollmentId], references: [id]) + user User @relation(fields: [userId], references: [id]) + + createdAt DateTime @default(now()) + + @@index([userId]) + @@index([enrollmentId]) + @@index([audioHash]) +} + +// ============================================ +// SpamShield Models (Spam Detection) +// ============================================ + +model SpamFeedback { + id String @id @default(uuid()) + userId String + phoneNumber String + phoneNumberHash String // SHA-256 hash + isSpam Boolean + confidence Float? // ML model confidence + feedbackType FeedbackType + metadata Json? // Call duration, time, etc. + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId]) + @@index([phoneNumberHash]) + @@index([isSpam]) +} + +enum FeedbackType { + initial_detection + user_confirmation + user_rejection + auto_learned +} + +model SpamRule { + id String @id @default(uuid()) + userId String? + isGlobal Boolean @default(false) + ruleType RuleType + pattern String + action RuleAction + priority Int @default(0) + isActive Boolean @default(true) + + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId]) + @@index([isGlobal]) + @@index([ruleType]) +} + +enum RuleType { + phoneNumber + areaCode + prefix + pattern + reputation +} + +enum RuleAction { + block + flag + allow + challenge +} + +// ============================================ +// Audit & Analytics Models +// ============================================ + +model AuditLog { + id String @id @default(uuid()) + userId String? + action String + resource String + resourceId String? + changes Json? // Before/after values + metadata Json? + ipAddress String? + userAgent String? + + createdAt DateTime @default(now()) + + @@index([userId]) + @@index([action]) + @@index([resource]) + @@index([createdAt]) +} + +model KPISnapshot { + id String @id @default(uuid()) + date DateTime @unique + metricName String + metricValue Float + metadata Json? + + createdAt DateTime @default(now()) + + @@index([metricName]) + @@index([date]) +} diff --git a/packages/shared-db/src/client.ts b/packages/shared-db/src/client.ts new file mode 100644 index 0000000..377c6f8 --- /dev/null +++ b/packages/shared-db/src/client.ts @@ -0,0 +1,50 @@ +import { PrismaClient } from '@prisma/client'; + +// Singleton pattern for Prisma Client +const globalForPrisma = globalThis as unknown as { + prisma: PrismaClient | undefined; +}; + +export const prisma = + globalForPrisma.prisma ?? + new PrismaClient({ + log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'], + }); + +if (process.env.NODE_ENV === 'development') { + globalForPrisma.prisma = prisma; +} + +// Export types from generated client +export type { + User, + Account, + Session, + FamilyGroup, + FamilyGroupMember, + Subscription, + WatchlistItem, + Exposure, + Alert, + VoiceEnrollment, + VoiceAnalysis, + SpamFeedback, + SpamRule, + AuditLog, + KPISnapshot, + UserRole, + FamilyMemberRole, + SubscriptionTier, + SubscriptionStatus, + WatchlistType, + ExposureSource, + ExposureSeverity, + AlertType, + AlertSeverity, + AlertChannel, + FeedbackType, + RuleType, + RuleAction, +} from '@prisma/client'; + +export * as PrismaModels from '@prisma/client'; diff --git a/packages/shared-db/src/index.ts b/packages/shared-db/src/index.ts new file mode 100644 index 0000000..a41efb0 --- /dev/null +++ b/packages/shared-db/src/index.ts @@ -0,0 +1,21 @@ +// Re-export Prisma client +export { prisma } from './client'; + +// Export types +export type { + User, + Account, + Session, + FamilyGroup, + FamilyGroupMember, + Subscription, + WatchlistItem, + Exposure, + Alert, + VoiceEnrollment, + VoiceAnalysis, + SpamFeedback, + SpamRule, + AuditLog, + KPISnapshot, +} from './client'; diff --git a/packages/shared-db/tsconfig.json b/packages/shared-db/tsconfig.json new file mode 100644 index 0000000..33fa9a4 --- /dev/null +++ b/packages/shared-db/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "prisma"] +} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 22373e7..1d8028e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,4 @@ packages: + - "apps/*" - "packages/*" - "services/*"