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
This commit is contained in:
29
apps/api/package.json
Normal file
29
apps/api/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
144
apps/api/src/__tests__/sms-classifier-race-condition.test.ts
Normal file
144
apps/api/src/__tests__/sms-classifier-race-condition.test.ts
Normal file
@@ -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<void>;
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
98
apps/api/src/__tests__/spam-rate-limit.test.ts
Normal file
98
apps/api/src/__tests__/spam-rate-limit.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
55
apps/api/src/config/api.config.ts
Normal file
55
apps/api/src/config/api.config.ts
Normal file
@@ -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,
|
||||||
|
};
|
||||||
18
apps/api/src/config/redis.ts
Normal file
18
apps/api/src/config/redis.ts
Normal file
@@ -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<Redis> {
|
||||||
|
if (redis.status === 'wait' || redis.status === 'connecting') {
|
||||||
|
await redis.connect();
|
||||||
|
}
|
||||||
|
return redis;
|
||||||
|
}
|
||||||
106
apps/api/src/index.ts
Normal file
106
apps/api/src/index.ts
Normal file
@@ -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();
|
||||||
|
}
|
||||||
86
apps/api/src/middleware/auth.middleware.ts
Normal file
86
apps/api/src/middleware/auth.middleware.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||||
|
|
||||||
|
export interface AuthRequest extends FastifyRequest {
|
||||||
|
user?: {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
organizationId?: string;
|
||||||
|
};
|
||||||
|
apiKey?: string;
|
||||||
|
authType: 'jwt' | 'api-key' | 'anonymous';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function authMiddleware(fastify: FastifyInstance) {
|
||||||
|
// Authentication hook
|
||||||
|
fastify.addHook('onRequest', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const authReq = request as AuthRequest;
|
||||||
|
// Skip auth for health checks and root
|
||||||
|
const publicRoutes = ['/', '/health'];
|
||||||
|
if (publicRoutes.some((route) => request.url.startsWith(route))) {
|
||||||
|
authReq.authType = 'anonymous';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try JWT authentication first
|
||||||
|
const authHeader = request.headers.authorization;
|
||||||
|
if (authHeader?.startsWith('Bearer ')) {
|
||||||
|
const token = authHeader.slice(7);
|
||||||
|
try {
|
||||||
|
// In production, decode and verify JWT
|
||||||
|
// For now, we'll attach a placeholder user
|
||||||
|
authReq.user = {
|
||||||
|
id: 'user-placeholder',
|
||||||
|
email: 'user@example.com',
|
||||||
|
role: 'user',
|
||||||
|
};
|
||||||
|
authReq.authType = 'jwt';
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
// JWT invalid, continue to API key check
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try API key authentication
|
||||||
|
const apiKey = request.headers['x-api-key'] as string | undefined;
|
||||||
|
if (apiKey) {
|
||||||
|
// In production, validate API key against database
|
||||||
|
authReq.apiKey = apiKey;
|
||||||
|
authReq.user = {
|
||||||
|
id: `api-${apiKey}`,
|
||||||
|
email: `api-${apiKey}@services.internal`,
|
||||||
|
role: 'service',
|
||||||
|
};
|
||||||
|
authReq.authType = 'api-key';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No auth found - attach anonymous user
|
||||||
|
authReq.authType = 'anonymous';
|
||||||
|
authReq.user = {
|
||||||
|
id: 'anonymous',
|
||||||
|
email: 'anonymous@unknown',
|
||||||
|
role: 'anonymous',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create auth decorator for route-level protection
|
||||||
|
fastify.decorate('requireAuth', async (request: AuthRequest) => {
|
||||||
|
if (request.authType === 'anonymous') {
|
||||||
|
throw { statusCode: 401, message: 'Authentication required' };
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.decorate('requireRole', (allowedRoles: string[]) => {
|
||||||
|
return async (request: AuthRequest) => {
|
||||||
|
if (!request.user?.role || !allowedRoles.includes(request.user.role)) {
|
||||||
|
throw {
|
||||||
|
statusCode: 403,
|
||||||
|
message: `Role ${request.user?.role} not in allowed roles: ${allowedRoles.join(', ')}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
62
apps/api/src/middleware/error-handling.middleware.ts
Normal file
62
apps/api/src/middleware/error-handling.middleware.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||||
|
|
||||||
|
export interface ErrorResponse {
|
||||||
|
error: string;
|
||||||
|
message: string;
|
||||||
|
statusCode: number;
|
||||||
|
code?: string;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
timestamp: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function errorHandlingMiddleware(fastify: FastifyInstance) {
|
||||||
|
// Custom error handler
|
||||||
|
fastify.setErrorHandler((error, request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const response: ErrorResponse = {
|
||||||
|
error: error.name || 'Internal Server Error',
|
||||||
|
message: error.message || 'An unexpected error occurred',
|
||||||
|
statusCode: error.statusCode || 500,
|
||||||
|
code: (error as any).code,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
path: request.url,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Log error
|
||||||
|
fastify.log.error({
|
||||||
|
error: response,
|
||||||
|
stack: error.stack,
|
||||||
|
method: request.method,
|
||||||
|
userAgent: request.headers['user-agent'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send standardized error response
|
||||||
|
reply.status(response.statusCode).send(response);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 404 handler
|
||||||
|
fastify.setNotFoundHandler((request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
reply.status(404).send({
|
||||||
|
error: 'Not Found',
|
||||||
|
message: `Route ${request.method} ${request.url} not found`,
|
||||||
|
statusCode: 404,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
path: request.url,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Validation error handler
|
||||||
|
fastify.addHook('onError', async (request: FastifyRequest, reply: FastifyReply, error) => {
|
||||||
|
if (error.validation) {
|
||||||
|
reply.status(400).send({
|
||||||
|
error: 'Validation Error',
|
||||||
|
message: 'Request validation failed',
|
||||||
|
statusCode: 400,
|
||||||
|
code: 'VALIDATION_ERROR',
|
||||||
|
details: error.validation,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
path: request.url,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
66
apps/api/src/middleware/logging.middleware.ts
Normal file
66
apps/api/src/middleware/logging.middleware.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||||
|
|
||||||
|
export interface RequestLog {
|
||||||
|
method: string;
|
||||||
|
url: string;
|
||||||
|
statusCode: number;
|
||||||
|
responseTime: number;
|
||||||
|
requestId: string;
|
||||||
|
userAgent?: string;
|
||||||
|
clientIp: string;
|
||||||
|
requestIdHeader?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loggingMiddleware(fastify: FastifyInstance) {
|
||||||
|
// Generate request ID if not present
|
||||||
|
fastify.addHook('onRequest', (request: FastifyRequest, reply: FastifyReply, done) => {
|
||||||
|
const requestId =
|
||||||
|
request.headers['x-request-id'] ||
|
||||||
|
request.headers['x-correlation-id'] ||
|
||||||
|
`req-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||||
|
|
||||||
|
request.headers['x-request-id'] = requestId;
|
||||||
|
(request as any).requestId = requestId;
|
||||||
|
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log request start
|
||||||
|
fastify.addHook('onRequest', (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
fastify.log.info({
|
||||||
|
event: 'request_start',
|
||||||
|
method: request.method,
|
||||||
|
url: request.url,
|
||||||
|
requestId: (request as any).requestId,
|
||||||
|
userAgent: request.headers['user-agent'],
|
||||||
|
clientIp: request.ip || request.headers['x-forwarded-for'] || 'unknown',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log response
|
||||||
|
fastify.addHook('onResponse', (request: FastifyRequest, reply: FastifyReply, done) => {
|
||||||
|
const log: RequestLog = {
|
||||||
|
method: request.method,
|
||||||
|
url: request.url,
|
||||||
|
statusCode: reply.statusCode,
|
||||||
|
responseTime: reply.elapsedTime,
|
||||||
|
requestId: (request as any).requestId,
|
||||||
|
userAgent: request.headers['user-agent'],
|
||||||
|
clientIp: request.ip || request.headers['x-forwarded-for'] || 'unknown',
|
||||||
|
requestIdHeader: request.headers['x-request-id'] as string,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Log based on status code
|
||||||
|
if (reply.statusCode < 300) {
|
||||||
|
fastify.log.info(log);
|
||||||
|
} else if (reply.statusCode < 400) {
|
||||||
|
fastify.log.warn(log);
|
||||||
|
} else if (reply.statusCode < 500) {
|
||||||
|
fastify.log.warn(log);
|
||||||
|
} else {
|
||||||
|
fastify.log.error(log);
|
||||||
|
}
|
||||||
|
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
}
|
||||||
116
apps/api/src/middleware/rate-limit.middleware.ts
Normal file
116
apps/api/src/middleware/rate-limit.middleware.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||||
|
import { apiEnv, rateLimitConfig } from '../config/api.config';
|
||||||
|
|
||||||
|
// Simple in-memory rate limiter
|
||||||
|
// In production, this should use Redis or similar distributed store
|
||||||
|
class RateLimiter {
|
||||||
|
private store: Map<string, { count: number; resetTime: number }>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.store = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkLimit(
|
||||||
|
key: string,
|
||||||
|
windowMs: number,
|
||||||
|
maxRequests: number
|
||||||
|
): Promise<{ remaining: number; resetTime: number; retryAfter?: number }> {
|
||||||
|
const now = Date.now();
|
||||||
|
const current = this.store.get(key);
|
||||||
|
|
||||||
|
if (!current || now > current.resetTime) {
|
||||||
|
// Reset window
|
||||||
|
this.store.set(key, {
|
||||||
|
count: 1,
|
||||||
|
resetTime: now + windowMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
remaining: maxRequests - 1,
|
||||||
|
resetTime: now + windowMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment counter
|
||||||
|
current.count++;
|
||||||
|
this.store.set(key, current);
|
||||||
|
|
||||||
|
const remaining = maxRequests - current.count;
|
||||||
|
|
||||||
|
if (current.count > maxRequests) {
|
||||||
|
return {
|
||||||
|
remaining: 0,
|
||||||
|
resetTime: current.resetTime,
|
||||||
|
retryAfter: current.resetTime - now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
remaining,
|
||||||
|
resetTime: current.resetTime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(key: string) {
|
||||||
|
this.store.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rateLimiter = new RateLimiter();
|
||||||
|
|
||||||
|
export async function rateLimitMiddleware(fastify: FastifyInstance) {
|
||||||
|
fastify.addHook('preHandler', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
// Skip rate limiting for health checks
|
||||||
|
if (request.url === '/health') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get client identifier (IP or API key)
|
||||||
|
const clientIp = request.ip || request.headers['x-forwarded-for'] || 'unknown';
|
||||||
|
const apiKey = request.headers['x-api-key'] as string | undefined;
|
||||||
|
const key = apiKey ? `api:${apiKey}` : `ip:${clientIp}`;
|
||||||
|
|
||||||
|
// Determine tier based on API key or default to basic
|
||||||
|
let tier = 'basic';
|
||||||
|
if (apiKey) {
|
||||||
|
// In production, fetch tier from user/service lookup
|
||||||
|
// For now, use a simple heuristic based on key format
|
||||||
|
if (apiKey.startsWith('premium_')) {
|
||||||
|
tier = 'premium';
|
||||||
|
} else if (apiKey.startsWith('plus_')) {
|
||||||
|
tier = 'plus';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = rateLimitConfig[tier as keyof typeof rateLimitConfig];
|
||||||
|
const result = await rateLimiter.checkLimit(
|
||||||
|
key,
|
||||||
|
config.windowMs,
|
||||||
|
config.maxRequests
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set rate limit headers
|
||||||
|
reply.header('X-RateLimit-Limit', config.maxRequests);
|
||||||
|
reply.header('X-RateLimit-Remaining', result.remaining);
|
||||||
|
reply.header('X-RateLimit-Reset', Math.ceil(result.resetTime / 1000));
|
||||||
|
|
||||||
|
if (result.retryAfter) {
|
||||||
|
reply.header('Retry-After', Math.ceil(result.retryAfter / 1000));
|
||||||
|
reply.code(429); // Too Many Requests
|
||||||
|
|
||||||
|
return {
|
||||||
|
error: 'Too Many Requests',
|
||||||
|
message: `Rate limit exceeded. Try again in ${Math.ceil(result.retryAfter / 1000)}s`,
|
||||||
|
tier,
|
||||||
|
limit: config.maxRequests,
|
||||||
|
reset: new Date(result.resetTime).toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add tier info to request for downstream use
|
||||||
|
(request as any).rateLimitTier = tier;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export for testing
|
||||||
|
export { rateLimiter };
|
||||||
164
apps/api/src/middleware/spam-rate-limit.middleware.ts
Normal file
164
apps/api/src/middleware/spam-rate-limit.middleware.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||||
|
import { redis } from '../config/redis';
|
||||||
|
import { spamRateLimits } from '../services/spamshield/spamshield.config';
|
||||||
|
|
||||||
|
const REDIS_PREFIX = 'spamshield:ratelimit';
|
||||||
|
|
||||||
|
class RedisRateLimiter {
|
||||||
|
async checkLimit(
|
||||||
|
key: string,
|
||||||
|
windowSeconds: number,
|
||||||
|
maxRequests: number
|
||||||
|
): Promise<{
|
||||||
|
remaining: number;
|
||||||
|
resetTime: number;
|
||||||
|
retryAfter?: number;
|
||||||
|
}> {
|
||||||
|
const redisKey = `${REDIS_PREFIX}:${key}`;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const current = await redis.get(redisKey);
|
||||||
|
const windowStart = now - (now % (windowSeconds * 1000));
|
||||||
|
const resetTime = windowStart + windowSeconds * 1000;
|
||||||
|
|
||||||
|
if (!current) {
|
||||||
|
const expirySeconds = Math.ceil((resetTime - now) / 1000);
|
||||||
|
await redis.set(redisKey, '1', 'EX', expirySeconds);
|
||||||
|
|
||||||
|
return {
|
||||||
|
remaining: maxRequests - 1,
|
||||||
|
resetTime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = parseInt(current, 10) + 1;
|
||||||
|
await redis.set(redisKey, String(count), 'EX', Math.ceil((resetTime - now) / 1000));
|
||||||
|
|
||||||
|
const remaining = maxRequests - count;
|
||||||
|
|
||||||
|
if (count > maxRequests) {
|
||||||
|
return {
|
||||||
|
remaining: 0,
|
||||||
|
resetTime,
|
||||||
|
retryAfter: resetTime - now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
remaining,
|
||||||
|
resetTime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkDailyLimit(
|
||||||
|
key: string,
|
||||||
|
maxPerDay: number
|
||||||
|
): Promise<{
|
||||||
|
remaining: number;
|
||||||
|
retryAfter?: number;
|
||||||
|
}> {
|
||||||
|
const redisKey = `${REDIS_PREFIX}:daily:${key}`;
|
||||||
|
const now = Date.now();
|
||||||
|
const dayStart = new Date(now);
|
||||||
|
dayStart.setHours(0, 0, 0, 0);
|
||||||
|
const dayEnd = new Date(dayStart);
|
||||||
|
dayEnd.setDate(dayEnd.getDate() + 1);
|
||||||
|
const resetTime = dayEnd.getTime();
|
||||||
|
|
||||||
|
const current = await redis.get(redisKey);
|
||||||
|
const expirySeconds = Math.ceil((resetTime - now) / 1000);
|
||||||
|
|
||||||
|
if (!current) {
|
||||||
|
await redis.set(redisKey, '1', 'EX', expirySeconds);
|
||||||
|
|
||||||
|
return {
|
||||||
|
remaining: maxPerDay - 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = parseInt(current, 10) + 1;
|
||||||
|
await redis.set(redisKey, String(count), 'EX', expirySeconds);
|
||||||
|
|
||||||
|
const remaining = maxPerDay - count;
|
||||||
|
|
||||||
|
if (count > maxPerDay) {
|
||||||
|
return {
|
||||||
|
remaining: 0,
|
||||||
|
retryAfter: resetTime - now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
remaining,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(key: string) {
|
||||||
|
const redisKey = `${REDIS_PREFIX}:${key}`;
|
||||||
|
return redis.del(redisKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const spamRateLimiter = new RedisRateLimiter();
|
||||||
|
|
||||||
|
export async function spamRateLimitMiddleware(fastify: FastifyInstance) {
|
||||||
|
fastify.addHook('preHandler', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const url = request.url || '';
|
||||||
|
|
||||||
|
if (!url.startsWith('/spamshield')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientIp = request.ip || (request.headers['x-forwarded-for'] as string) || 'unknown';
|
||||||
|
const apiKey = request.headers['x-api-key'] as string | undefined;
|
||||||
|
const key = apiKey ? `api:${apiKey}` : `ip:${clientIp}`;
|
||||||
|
|
||||||
|
let tier = 'basic';
|
||||||
|
if (apiKey) {
|
||||||
|
if (apiKey.startsWith('premium_')) {
|
||||||
|
tier = 'premium';
|
||||||
|
} else if (apiKey.startsWith('plus_')) {
|
||||||
|
tier = 'plus';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = spamRateLimits[tier as keyof typeof spamRateLimits];
|
||||||
|
|
||||||
|
const minuteResult = await spamRateLimiter.checkLimit(
|
||||||
|
key,
|
||||||
|
60,
|
||||||
|
config.analysesPerMinute
|
||||||
|
);
|
||||||
|
|
||||||
|
const dailyResult = await spamRateLimiter.checkDailyLimit(
|
||||||
|
key,
|
||||||
|
config.analysesPerDay
|
||||||
|
);
|
||||||
|
|
||||||
|
reply.header('X-RateLimit-Limit', config.analysesPerMinute);
|
||||||
|
reply.header('X-RateLimit-Remaining', minuteResult.remaining);
|
||||||
|
reply.header('X-RateLimit-Reset', Math.ceil(minuteResult.resetTime / 1000));
|
||||||
|
reply.header('X-RateLimit-Daily-Limit', config.analysesPerDay);
|
||||||
|
reply.header('X-RateLimit-Daily-Remaining', dailyResult.remaining);
|
||||||
|
|
||||||
|
const retryAfter = minuteResult.retryAfter || dailyResult.retryAfter;
|
||||||
|
|
||||||
|
if (retryAfter) {
|
||||||
|
reply.header('Retry-After', Math.ceil(retryAfter / 1000));
|
||||||
|
reply.code(429);
|
||||||
|
|
||||||
|
return {
|
||||||
|
error: 'Too Many Requests',
|
||||||
|
message: `Spam analysis rate limit exceeded. Try again in ${Math.ceil(retryAfter / 1000)}s`,
|
||||||
|
tier,
|
||||||
|
limit: config.analysesPerMinute,
|
||||||
|
dailyLimit: config.analysesPerDay,
|
||||||
|
reset: new Date(minuteResult.resetTime).toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
(request as any).spamRateLimitTier = tier;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export { RedisRateLimiter };
|
||||||
285
apps/api/src/routes/darkwatch.routes.ts
Normal file
285
apps/api/src/routes/darkwatch.routes.ts
Normal file
@@ -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<string | null> => {
|
||||||
|
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<string, unknown>;
|
||||||
|
|
||||||
|
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<string, unknown> | 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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
142
apps/api/src/routes/index.ts
Normal file
142
apps/api/src/routes/index.ts
Normal file
@@ -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' }
|
||||||
|
);
|
||||||
|
}
|
||||||
213
apps/api/src/routes/notifications.routes.ts
Normal file
213
apps/api/src/routes/notifications.routes.ts
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
import { FastifyInstance } from 'fastify';
|
||||||
|
import { NotificationService } from '@shieldsai/shared-notifications';
|
||||||
|
|
||||||
|
export async function notificationRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
|
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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
252
apps/api/src/routes/spamshield.routes.ts
Normal file
252
apps/api/src/routes/spamshield.routes.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
257
apps/api/src/routes/voiceprint.routes.ts
Normal file
257
apps/api/src/routes/voiceprint.routes.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
174
apps/api/src/services/darkwatch/alert.pipeline.ts
Normal file
174
apps/api/src/services/darkwatch/alert.pipeline.ts
Normal file
@@ -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<ReturnType<typeof prisma.alert.create>>[] = [];
|
||||||
|
|
||||||
|
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,
|
||||||
|
`<p>${alert.message}</p>
|
||||||
|
<p><strong>Source:</strong> ${exposure.source}</p>
|
||||||
|
<p><strong>Severity:</strong> ${exposure.severity}</p>
|
||||||
|
<p><strong>Type:</strong> ${exposure.dataType}</p>`,
|
||||||
|
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<string, string[]> = {
|
||||||
|
basic: ['email'],
|
||||||
|
plus: ['email', 'push'],
|
||||||
|
premium: ['email', 'push', 'sms'],
|
||||||
|
};
|
||||||
|
return channelMap[tier] || ['email'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const alertPipeline = new AlertPipeline();
|
||||||
5
apps/api/src/services/darkwatch/index.ts
Normal file
5
apps/api/src/services/darkwatch/index.ts
Normal file
@@ -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';
|
||||||
220
apps/api/src/services/darkwatch/scan.service.ts
Normal file
220
apps/api/src/services/darkwatch/scan.service.ts
Normal file
@@ -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<ReturnType<ScanService['getWatchlistItems']>>
|
||||||
|
): 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();
|
||||||
155
apps/api/src/services/darkwatch/scheduler.service.ts
Normal file
155
apps/api/src/services/darkwatch/scheduler.service.ts
Normal file
@@ -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<string, unknown>) {
|
||||||
|
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();
|
||||||
97
apps/api/src/services/darkwatch/watchlist.service.ts
Normal file
97
apps/api/src/services/darkwatch/watchlist.service.ts
Normal file
@@ -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();
|
||||||
226
apps/api/src/services/darkwatch/webhook.service.ts
Normal file
226
apps/api/src/services/darkwatch/webhook.service.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
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<boolean> {
|
||||||
|
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<string, ExposureSource> = {
|
||||||
|
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<string, WatchlistType> = {
|
||||||
|
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<string, string[]> = {
|
||||||
|
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, string> = {
|
||||||
|
[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();
|
||||||
227
apps/api/src/services/spamshield/feature-flags.ts
Normal file
227
apps/api/src/services/spamshield/feature-flags.ts
Normal file
@@ -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<T = FeatureFlagValue> {
|
||||||
|
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<string, FeatureFlagValue> = new Map();
|
||||||
|
|
||||||
|
constructor(flags: FeatureFlagRegistry) {
|
||||||
|
this.flags = flags;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a feature flag value
|
||||||
|
* Priority: Environment > Cache > Default
|
||||||
|
*/
|
||||||
|
resolve<T>(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<T>(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<T>(key: string, defaultValue: T): T {
|
||||||
|
return featureFlagResolver.isEnabled(key, defaultValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a flag is enabled with type safety
|
||||||
|
*/
|
||||||
|
export function checkFlag<T>(key: string, defaultValue: T): T {
|
||||||
|
return featureFlagResolver.resolve(key, defaultValue);
|
||||||
|
}
|
||||||
26
apps/api/src/services/spamshield/index.ts
Normal file
26
apps/api/src/services/spamshield/index.ts
Normal file
@@ -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';
|
||||||
118
apps/api/src/services/spamshield/spamshield.audit-logger.ts
Normal file
118
apps/api/src/services/spamshield/spamshield.audit-logger.ts
Normal file
@@ -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<string, boolean>;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_AUDIT_LOG_SIZE = 10_000;
|
||||||
|
|
||||||
|
class AuditLogger {
|
||||||
|
private entries: AuditClassificationEntry[] = [];
|
||||||
|
|
||||||
|
logClassification(entry: Omit<AuditClassificationEntry, 'id' | 'timestamp'>): 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}`;
|
||||||
|
}
|
||||||
163
apps/api/src/services/spamshield/spamshield.config.ts
Normal file
163
apps/api/src/services/spamshield/spamshield.config.ts
Normal file
@@ -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,
|
||||||
|
};
|
||||||
118
apps/api/src/services/spamshield/spamshield.error-handler.ts
Normal file
118
apps/api/src/services/spamshield/spamshield.error-handler.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
}
|
||||||
|
): 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<SpamErrorCode, number> = {
|
||||||
|
// 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
462
apps/api/src/services/spamshield/spamshield.service.ts
Normal file
462
apps/api/src/services/spamshield/spamshield.service.ts
Normal file
@@ -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<void> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the BERT model (thread-safe via promise deduplication)
|
||||||
|
*/
|
||||||
|
async initialize(): Promise<void> {
|
||||||
|
// 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<void> {
|
||||||
|
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<string, number> = {
|
||||||
|
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<string, any>): {
|
||||||
|
isValid: boolean;
|
||||||
|
trimmedMetadata?: Record<string, any>;
|
||||||
|
reasons?: string[];
|
||||||
|
} {
|
||||||
|
if (!metadata) {
|
||||||
|
return { isValid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const reasons: string[] = [];
|
||||||
|
let trimmedMetadata: Record<string, any> = 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<string, any>
|
||||||
|
): Promise<SpamFeedback> {
|
||||||
|
// 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<SpamFeedback[]> {
|
||||||
|
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();
|
||||||
30
apps/api/src/services/voiceprint/index.ts
Normal file
30
apps/api/src/services/voiceprint/index.ts
Normal file
@@ -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';
|
||||||
102
apps/api/src/services/voiceprint/voiceprint.config.ts
Normal file
102
apps/api/src/services/voiceprint/voiceprint.config.ts
Normal file
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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';
|
||||||
594
apps/api/src/services/voiceprint/voiceprint.service.ts
Normal file
594
apps/api/src/services/voiceprint/voiceprint.service.ts
Normal file
@@ -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<Buffer> {
|
||||||
|
// 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<VoiceEnrollment> {
|
||||||
|
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<VoiceEnrollment[]> {
|
||||||
|
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<VoiceEnrollment | null> {
|
||||||
|
return prisma.voiceEnrollment.findFirst({
|
||||||
|
where: {
|
||||||
|
id: enrollmentId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove (deactivate) an enrollment.
|
||||||
|
*/
|
||||||
|
async removeEnrollment(
|
||||||
|
enrollmentId: string,
|
||||||
|
userId: string
|
||||||
|
): Promise<VoiceEnrollment> {
|
||||||
|
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<Array<{ enrollment: VoiceEnrollment; similarity: number }>> {
|
||||||
|
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<VoiceAnalysis> {
|
||||||
|
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<VoiceAnalysis | null> {
|
||||||
|
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<VoiceAnalysis[]> {
|
||||||
|
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<void> {
|
||||||
|
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<number[]> {
|
||||||
|
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<string, number>;
|
||||||
|
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<string, number> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<Array<{ id: string; similarity: number }>> {
|
||||||
|
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<void> {
|
||||||
|
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();
|
||||||
12
apps/api/tsconfig.json
Normal file
12
apps/api/tsconfig.json
Normal file
@@ -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"]
|
||||||
|
}
|
||||||
22
apps/mobile/package.json
Normal file
22
apps/mobile/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
24
apps/web/package.json
Normal file
24
apps/web/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
12
packages/shared-db/drizzle.config.ts
Normal file
12
packages/shared-db/drizzle.config.ts
Normal file
@@ -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,
|
||||||
|
});
|
||||||
23
packages/shared-db/package.json
Normal file
23
packages/shared-db/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
437
packages/shared-db/prisma/schema.prisma
Normal file
437
packages/shared-db/prisma/schema.prisma
Normal file
@@ -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])
|
||||||
|
}
|
||||||
50
packages/shared-db/src/client.ts
Normal file
50
packages/shared-db/src/client.ts
Normal file
@@ -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';
|
||||||
21
packages/shared-db/src/index.ts
Normal file
21
packages/shared-db/src/index.ts
Normal file
@@ -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';
|
||||||
12
packages/shared-db/tsconfig.json
Normal file
12
packages/shared-db/tsconfig.json
Normal file
@@ -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"]
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
packages:
|
packages:
|
||||||
|
- "apps/*"
|
||||||
- "packages/*"
|
- "packages/*"
|
||||||
- "services/*"
|
- "services/*"
|
||||||
|
|||||||
Reference in New Issue
Block a user