FRE-4529: Transfer ShieldAI code from FrenoCorp repo
Transferred ShieldAI-related files mistakenly placed in ~/code/FrenoCorp:
- Services: spamshield (feature-flags, audit-logger, error-handler), voiceprint (config, service, feature-flags), darkwatch (pipeline, scan, scheduler, watchlist, webhook)
- Packages: shared-analytics, shared-auth, shared-ui, shared-utils (new); shared-billing, jobs supplemented with unique FC files
- Server: alerts (FC version newer), routes (spamshield, darkwatch, voiceprint)
- Config: turbo.json, tsconfig.base.json, vite/vitest configs, drizzle, Dockerfile
- VoicePrint ML service
- Examples
Pending: apps/{api,web,mobile}/ structured merge, shared-db/db mapping
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
285
packages/api/src/routes/darkwatch.routes.ts
Normal file
285
packages/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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,33 +1,142 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
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 function darkwatchRoutes(fastify: FastifyInstance) {
|
||||
fastify.register(async (root) => {
|
||||
const watchlist = (await import("./watchlist.routes")).watchlistRoutes;
|
||||
const exposures = (await import("./exposure.routes")).exposureRoutes;
|
||||
const alerts = (await import("./alert.routes")).alertRoutes;
|
||||
const scans = (await import("./scan.routes")).scanRoutes;
|
||||
const scheduler = (await import("./scheduler.routes")).schedulerRoutes;
|
||||
const webhooks = (await import("./webhook.routes")).webhookRoutes;
|
||||
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);
|
||||
});
|
||||
|
||||
root.register(watchlist, { prefix: "/watchlist" });
|
||||
root.register(exposures, { prefix: "/exposures" });
|
||||
root.register(alerts, { prefix: "/alerts" });
|
||||
root.register(scans, { prefix: "/scan" });
|
||||
root.register(scheduler, { prefix: "/scheduler" });
|
||||
root.register(webhooks, { prefix: "/webhooks" });
|
||||
}, { prefix: "/api/v1/darkwatch" });
|
||||
}
|
||||
|
||||
export function voiceprintRoutes(fastify: FastifyInstance) {
|
||||
fastify.register(async (root) => {
|
||||
const voiceprint = (await import("./voiceprint.routes")).voiceprintRoutes;
|
||||
root.register(voiceprint);
|
||||
}, { prefix: "/api/v1/voiceprint" });
|
||||
}
|
||||
|
||||
export function correlationRoutes(fastify: FastifyInstance) {
|
||||
fastify.register(async (root) => {
|
||||
const correlation = (await import("./correlation.routes")).correlationRoutes;
|
||||
root.register(correlation);
|
||||
}, { prefix: "/api/v1/correlation" });
|
||||
// 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' }
|
||||
);
|
||||
}
|
||||
|
||||
252
packages/api/src/routes/spamshield.routes.ts
Normal file
252
packages/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,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,94 +1,257 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { VoiceEnrollmentService } from "@shieldai/voiceprint";
|
||||
import { AnalysisService } from "@shieldai/voiceprint";
|
||||
import { BatchAnalysisService } from "@shieldai/voiceprint";
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import {
|
||||
voiceEnrollmentService,
|
||||
analysisService,
|
||||
batchAnalysisService,
|
||||
voicePrintEnv,
|
||||
AnalysisJobStatus,
|
||||
} from '../services/voiceprint';
|
||||
|
||||
export function voiceprintRoutes(fastify: FastifyInstance) {
|
||||
const enrollmentService = new VoiceEnrollmentService();
|
||||
const analysisService = new AnalysisService();
|
||||
const batchService = new BatchAnalysisService();
|
||||
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;
|
||||
|
||||
fastify.post("/enroll", async (request, reply) => {
|
||||
const userId = (request.user as { id: string })?.id;
|
||||
if (!userId) return reply.code(401).send({ error: "User not authenticated" });
|
||||
|
||||
const body = request.body as { label: string; audio: string; sampleRate?: number };
|
||||
|
||||
const audioBuffer = Buffer.from(body.audio, "base64");
|
||||
const enrollment = await enrollmentService.enroll(
|
||||
{ label: body.label, audioBuffer, sampleRate: body.sampleRate },
|
||||
userId
|
||||
);
|
||||
return reply.code(201).send(enrollment);
|
||||
});
|
||||
|
||||
fastify.get("/enrollments", async (request, reply) => {
|
||||
const userId = (request.user as { id: string })?.id;
|
||||
if (!userId) return reply.code(401).send({ error: "User not authenticated" });
|
||||
|
||||
const enrollments = await enrollmentService.listEnrollments(userId);
|
||||
return reply.send(enrollments);
|
||||
});
|
||||
|
||||
fastify.delete("/enrollments/:id", async (request, reply) => {
|
||||
const userId = (request.user as { id: string })?.id;
|
||||
if (!userId) return reply.code(401).send({ error: "User not authenticated" });
|
||||
|
||||
const enrollmentId = (request.params as { id: string }).id;
|
||||
const result = await enrollmentService.removeEnrollment(userId, enrollmentId);
|
||||
return reply.send({ removed: result });
|
||||
});
|
||||
|
||||
fastify.post("/analyze", async (request, reply) => {
|
||||
const userId = (request.user as { id: string })?.id;
|
||||
if (!userId) return reply.code(401).send({ error: "User not authenticated" });
|
||||
|
||||
const body = request.body as { audio: string; sampleRate?: number; analysisType?: string };
|
||||
const audioBuffer = Buffer.from(body.audio, "base64");
|
||||
|
||||
const result = await analysisService.analyze(
|
||||
{ audioBuffer, sampleRate: body.sampleRate, analysisType: body.analysisType },
|
||||
userId
|
||||
);
|
||||
return reply.code(201).send(result);
|
||||
});
|
||||
|
||||
fastify.get("/results/:id", async (request, reply) => {
|
||||
const jobId = (request.params as { id: string }).id;
|
||||
const result = await analysisService.getResult(jobId);
|
||||
|
||||
if (!result) return reply.code(404).send({ error: "Analysis result not found" });
|
||||
return reply.send(result);
|
||||
});
|
||||
|
||||
fastify.get("/results", async (request, reply) => {
|
||||
const userId = (request.user as { id: string })?.id;
|
||||
if (!userId) return reply.code(401).send({ error: "User not authenticated" });
|
||||
|
||||
const limit = parseInt((request.query as { limit?: string }).limit || "20", 10);
|
||||
const results = await analysisService.getUserResults(userId, limit);
|
||||
return reply.send(results);
|
||||
});
|
||||
|
||||
fastify.post("/batch", async (request, reply) => {
|
||||
const userId = (request.user as { id: string })?.id;
|
||||
if (!userId) return reply.code(401).send({ error: "User not authenticated" });
|
||||
if (!userId) {
|
||||
return reply.code(401).send({ error: 'User ID required' });
|
||||
}
|
||||
|
||||
const body = request.body as {
|
||||
files: Array<{ name: string; audio: string; sampleRate?: number }>;
|
||||
analysisType?: string;
|
||||
name: string;
|
||||
audio: Buffer;
|
||||
};
|
||||
|
||||
const audioBuffers = body.files.map((f) => ({
|
||||
name: f.name,
|
||||
buffer: Buffer.from(f.audio, "base64"),
|
||||
sampleRate: f.sampleRate,
|
||||
}));
|
||||
if (!body.name || !body.audio) {
|
||||
return reply.code(400).send({ error: 'name and audio are required' });
|
||||
}
|
||||
|
||||
const result = await batchService.analyzeBatch(
|
||||
{ audioBuffers, analysisType: body.analysisType },
|
||||
userId
|
||||
);
|
||||
return reply.code(201).send(result);
|
||||
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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
173
packages/jobs/src/darkwatch.jobs.ts
Normal file
173
packages/jobs/src/darkwatch.jobs.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { prisma, SubscriptionTier } from '@shieldsai/shared-db';
|
||||
import { Queue, Worker, Job } from 'bullmq';
|
||||
import { Redis } from 'ioredis';
|
||||
import { tierConfig, getTierFeatures } from '@shieldsai/shared-billing';
|
||||
import { mixpanelService, EventType } from '@shieldsai/shared-analytics';
|
||||
|
||||
const redisHost = process.env.REDIS_HOST || 'localhost';
|
||||
const redisPort = parseInt(process.env.REDIS_PORT || '6379', 10);
|
||||
|
||||
const connection = new Redis({
|
||||
host: redisHost,
|
||||
port: redisPort,
|
||||
retryStrategy: (times: number) => Math.min(times * 50, 2000),
|
||||
});
|
||||
|
||||
const QUEUE_CONFIG = {
|
||||
darkwatchScan: {
|
||||
name: 'darkwatch-scan',
|
||||
concurrency: parseInt(process.env.DARKWATCH_CONCURRENCY || '5', 10),
|
||||
defaultJobTimeout: parseInt(process.env.DARKWATCH_JOB_TIMEOUT || '120000', 10),
|
||||
maxAttempts: parseInt(process.env.DARKWATCH_MAX_ATTEMPTS || '3', 10),
|
||||
},
|
||||
};
|
||||
|
||||
export const darkwatchScanQueue = new Queue(
|
||||
QUEUE_CONFIG.darkwatchScan.name,
|
||||
{ connection }
|
||||
);
|
||||
|
||||
async function processDarkwatchScan(
|
||||
job: Job<{
|
||||
subscriptionId: string;
|
||||
tier: string;
|
||||
scanType: 'scheduled' | 'on-demand' | 'realtime';
|
||||
sourceData?: Record<string, unknown>;
|
||||
}>
|
||||
) {
|
||||
const { subscriptionId, tier, scanType, sourceData } = job.data;
|
||||
|
||||
const { scanService } = await import(
|
||||
'../../../apps/api/src/services/darkwatch/scan.service'
|
||||
);
|
||||
const { alertPipeline } = await import(
|
||||
'../../../apps/api/src/services/darkwatch/alert.pipeline'
|
||||
);
|
||||
|
||||
job.updateProgress(10);
|
||||
console.log(
|
||||
`[DarkWatch:Scan] Starting ${scanType} scan for subscription ${subscriptionId} (tier: ${tier})`
|
||||
);
|
||||
|
||||
try {
|
||||
const subscription = await prisma.subscription.findUnique({
|
||||
where: { id: subscriptionId },
|
||||
select: { userId: true, tier: true },
|
||||
});
|
||||
|
||||
if (!subscription) {
|
||||
job.updateProgress(100);
|
||||
return { status: 'skipped', reason: 'subscription_not_found' };
|
||||
}
|
||||
|
||||
await mixpanelService.track(
|
||||
EventType.DARK_WEB_SCAN_STARTED,
|
||||
subscription.userId,
|
||||
{
|
||||
scanType,
|
||||
subscriptionTier: subscription.tier,
|
||||
}
|
||||
);
|
||||
|
||||
job.updateProgress(25);
|
||||
|
||||
const watchlistItems = await scanService.getWatchlistItems(subscriptionId);
|
||||
|
||||
if (watchlistItems.length === 0) {
|
||||
job.updateProgress(100);
|
||||
return { status: 'completed', exposuresCreated: 0, exposuresUpdated: 0 };
|
||||
}
|
||||
|
||||
job.updateProgress(50);
|
||||
|
||||
const { exposuresCreated, exposuresUpdated } =
|
||||
await scanService.processSubscriptionScan(subscriptionId, watchlistItems);
|
||||
|
||||
job.updateProgress(80);
|
||||
|
||||
const newExposureIds = await prisma.exposure.findMany({
|
||||
where: {
|
||||
subscriptionId,
|
||||
isFirstTime: true,
|
||||
detectedAt: { gte: new Date(Date.now() - 5 * 60 * 1000) },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (newExposureIds.length > 0) {
|
||||
await alertPipeline.processNewExposures(newExposureIds.map((e) => e.id));
|
||||
}
|
||||
|
||||
await alertPipeline.dispatchScanCompleteAlert(
|
||||
subscriptionId,
|
||||
subscription.userId,
|
||||
exposuresCreated
|
||||
);
|
||||
|
||||
job.updateProgress(95);
|
||||
|
||||
await mixpanelService.track(
|
||||
EventType.DARK_WEB_SCAN_COMPLETED,
|
||||
subscription.userId,
|
||||
{
|
||||
scanType,
|
||||
subscriptionTier: subscription.tier,
|
||||
exposuresCreated,
|
||||
exposuresUpdated,
|
||||
watchlistItemsScanned: watchlistItems.length,
|
||||
}
|
||||
);
|
||||
|
||||
job.updateProgress(100);
|
||||
|
||||
return {
|
||||
status: 'completed',
|
||||
exposuresCreated,
|
||||
exposuresUpdated,
|
||||
watchlistItemsScanned: watchlistItems.length,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Scan failed';
|
||||
console.error(`[DarkWatch:Scan] Job ${job.id} failed:`, message);
|
||||
job.updateProgress(100);
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
export const darkwatchScanWorker = new Worker(
|
||||
QUEUE_CONFIG.darkwatchScan.name,
|
||||
processDarkwatchScan,
|
||||
{
|
||||
connection,
|
||||
concurrency: QUEUE_CONFIG.darkwatchScan.concurrency,
|
||||
limiter: {
|
||||
max: 20,
|
||||
duration: 1000,
|
||||
},
|
||||
removeOnComplete: {
|
||||
age: 7 * 24 * 60 * 60,
|
||||
count: 1000,
|
||||
},
|
||||
removeOnFail: {
|
||||
age: 30 * 24 * 60 * 60,
|
||||
count: 100,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
darkwatchScanWorker.on('completed', (job, result) => {
|
||||
console.log(`[DarkWatch:Scan] Job ${job.id} completed:`, result);
|
||||
});
|
||||
|
||||
darkwatchScanWorker.on('failed', (job, err) => {
|
||||
console.error(`[DarkWatch:Scan] Job ${job?.id} failed:`, err.message);
|
||||
});
|
||||
|
||||
darkwatchScanWorker.on('error', (err) => {
|
||||
console.error('[DarkWatch:Scan] Worker error:', err.message);
|
||||
});
|
||||
|
||||
export default {
|
||||
darkwatchScanQueue,
|
||||
darkwatchScanWorker,
|
||||
};
|
||||
19
packages/shared-analytics/package.json
Normal file
19
packages/shared-analytics/package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@shieldsai/shared-analytics",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"lint": "eslint src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@segment/analytics-node": "^1.0.0",
|
||||
"googleapis": "^128.0.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
132
packages/shared-analytics/src/config/analytics.config.ts
Normal file
132
packages/shared-analytics/src/config/analytics.config.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// Environment variables for analytics
|
||||
const envSchema = z.object({
|
||||
MIXPANEL_TOKEN: z.string(),
|
||||
MIXPANEL_API_SECRET: z.string().optional(),
|
||||
GA4_MEASUREMENT_ID: z.string(),
|
||||
GA4_API_SECRET: z.string().optional(),
|
||||
STRIPE_WEBHOOK_SECRET: z.string(),
|
||||
ANALYTICS_ENV: z.enum(['development', 'production', 'staging']).default('development'),
|
||||
});
|
||||
|
||||
export const analyticsEnv = envSchema.parse({
|
||||
MIXPANEL_TOKEN: process.env.MIXPANEL_TOKEN,
|
||||
MIXPANEL_API_SECRET: process.env.MIXPANEL_API_SECRET,
|
||||
GA4_MEASUREMENT_ID: process.env.GA4_MEASUREMENT_ID,
|
||||
GA4_API_SECRET: process.env.GA4_API_SECRET,
|
||||
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
|
||||
ANALYTICS_ENV: process.env.ANALYTICS_ENV,
|
||||
});
|
||||
|
||||
// Event taxonomy
|
||||
export enum EventType {
|
||||
// User events
|
||||
USER_SIGNED_UP = 'user_signed_up',
|
||||
USER_LOGGED_IN = 'user_logged_in',
|
||||
USER_LOGGED_OUT = 'user_logged_out',
|
||||
USER_UPGRADED = 'user_upgraded',
|
||||
USER_DOWNGRADED = 'user_downgraded',
|
||||
|
||||
// Subscription events
|
||||
SUBSCRIPTION_CREATED = 'subscription_created',
|
||||
SUBSCRIPTION_UPDATED = 'subscription_updated',
|
||||
SUBSCRIPTION_CANCELLED = 'subscription_cancelled',
|
||||
SUBSCRIPTION_RENEWED = 'subscription_renewed',
|
||||
|
||||
// DarkWatch events
|
||||
DARK_WEB_SCAN_STARTED = 'dark_web_scan_started',
|
||||
DARK_WEB_SCAN_COMPLETED = 'dark_web_scan_completed',
|
||||
EXPOSURE_DETECTED = 'exposure_detected',
|
||||
EXPOSURE_RESOLVED = 'exposure_resolved',
|
||||
WATCHLIST_ITEM_ADDED = 'watchlist_item_added',
|
||||
WATCHLIST_ITEM_REMOVED = 'watchlist_item_removed',
|
||||
|
||||
// VoicePrint events
|
||||
VOICE_ENROLLED = 'voice_enrolled',
|
||||
VOICE_ANALYZED = 'voice_analyzed',
|
||||
VOICE_MATCH_FOUND = 'voice_match_found',
|
||||
SYNTHETIC_VOICE_DETECTED = 'synthetic_voice_detected',
|
||||
|
||||
// SpamShield events
|
||||
CALL_ANALYZED = 'call_analyzed',
|
||||
SMS_ANALYZED = 'sms_analyzed',
|
||||
SPAM_BLOCKED = 'spam_blocked',
|
||||
SPAM_FLAGGED = 'spam_flagged',
|
||||
SPAM_FEEDBACK_SUBMITTED = 'spam_feedback_submitted',
|
||||
|
||||
// KPI events
|
||||
MRR_UPDATED = 'mrr_updated',
|
||||
CONVERSION_OCCURRED = 'conversion_occurred',
|
||||
CHURN_OCCURRED = 'churn_occurred',
|
||||
REFERRAL_SENT = 'referral_sent',
|
||||
REFERRAL_CONVERTED = 'referral_converted',
|
||||
}
|
||||
|
||||
// Event properties schema
|
||||
export const eventPropertiesSchema = z.object({
|
||||
userId: z.string().optional(),
|
||||
sessionId: z.string().optional(),
|
||||
timestamp: z.date().optional(),
|
||||
platform: z.enum(['web', 'mobile', 'desktop', 'api']).optional(),
|
||||
version: z.string().optional(),
|
||||
environment: z.string().optional(),
|
||||
});
|
||||
|
||||
// KPI definitions
|
||||
export const kpiDefinitions = {
|
||||
mau: {
|
||||
name: 'Monthly Active Users',
|
||||
description: 'Unique users who performed an action in the last 30 days',
|
||||
calculation: 'COUNT(DISTINCT userId) WHERE timestamp > NOW() - INTERVAL 30 DAYS',
|
||||
},
|
||||
payingUsers: {
|
||||
name: 'Paying Users',
|
||||
description: 'Users with active subscriptions',
|
||||
calculation: 'COUNT(DISTINCT userId) WHERE subscription.status = "active"',
|
||||
},
|
||||
mrr: {
|
||||
name: 'Monthly Recurring Revenue',
|
||||
description: 'Total monthly subscription revenue',
|
||||
calculation: 'SUM(subscription.amount) WHERE subscription.status = "active"',
|
||||
},
|
||||
conversionRate: {
|
||||
name: 'Conversion Rate',
|
||||
description: 'Percentage of free users who upgrade to paid',
|
||||
calculation: 'COUNT(upgrade events) / COUNT(signup events)',
|
||||
},
|
||||
churn: {
|
||||
name: 'Churn Rate',
|
||||
description: 'Percentage of paying users who cancel',
|
||||
calculation: 'COUNT(cancel events) / COUNT(active subscriptions)',
|
||||
},
|
||||
cac: {
|
||||
name: 'Customer Acquisition Cost',
|
||||
description: 'Average cost to acquire a new paying user',
|
||||
calculation: 'Total marketing spend / COUNT(new paying users)',
|
||||
},
|
||||
ltv: {
|
||||
name: 'Lifetime Value',
|
||||
description: 'Average revenue per user over their lifetime',
|
||||
calculation: 'Average subscription amount / Churn rate',
|
||||
},
|
||||
nps: {
|
||||
name: 'Net Promoter Score',
|
||||
description: 'Customer satisfaction metric (-100 to 100)',
|
||||
calculation: '% Promoters - % Detractors',
|
||||
},
|
||||
viralCoefficient: {
|
||||
name: 'Viral Coefficient',
|
||||
description: 'Average number of referrals per user',
|
||||
calculation: 'COUNT(referral events) / COUNT(users)',
|
||||
},
|
||||
};
|
||||
|
||||
// Alert thresholds
|
||||
export const alertThresholds = {
|
||||
churn: { warning: 0.05, critical: 0.10 },
|
||||
conversionRate: { warning: 0.02, critical: 0.01 },
|
||||
mrr: { warning: 0.90, critical: 0.80 }, // Percentage of target
|
||||
nps: { warning: 50, critical: 40 },
|
||||
viralCoefficient: { warning: 0.4, critical: 0.3 },
|
||||
};
|
||||
18
packages/shared-analytics/src/index.ts
Normal file
18
packages/shared-analytics/src/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// Config
|
||||
export {
|
||||
analyticsEnv,
|
||||
EventType,
|
||||
eventPropertiesSchema,
|
||||
kpiDefinitions,
|
||||
alertThresholds,
|
||||
} from './config/analytics.config';
|
||||
|
||||
// Services
|
||||
export {
|
||||
MixpanelService,
|
||||
mixpanelService,
|
||||
} from './services/mixpanel.service';
|
||||
export {
|
||||
GA4Service,
|
||||
ga4Service,
|
||||
} from './services/ga4.service';
|
||||
104
packages/shared-analytics/src/services/ga4.service.ts
Normal file
104
packages/shared-analytics/src/services/ga4.service.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { google } from 'googleapis';
|
||||
import { analyticsEnv, EventType } from '../config/analytics.config';
|
||||
|
||||
// GA4 service
|
||||
export class GA4Service {
|
||||
private auth: any;
|
||||
|
||||
constructor() {
|
||||
this.auth = google.auth.fromAPIKey(analyticsEnv.GA4_API_SECRET || 'placeholder');
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize GA4 client
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
// TODO: Initialize GA4 client with measurement ID
|
||||
console.log('GA4 client initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send event to GA4
|
||||
*/
|
||||
async sendEvent(
|
||||
eventName: string,
|
||||
params: {
|
||||
client_id: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
): Promise<void> {
|
||||
// TODO: Implement GA4 event tracking
|
||||
// const measurementId = analyticsEnv.GA4_MEASUREMENT_ID;
|
||||
// await fetch(`https://www.google-analytics.com/mp/collect?measurement_id=${measurementId}&api_secret=${analyticsEnv.GA4_API_SECRET}`, {
|
||||
// method: 'POST',
|
||||
// body: JSON.stringify({
|
||||
// events: [{ name: eventName, params }],
|
||||
// }),
|
||||
// });
|
||||
|
||||
console.log('GA4 event:', eventName, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track page view
|
||||
*/
|
||||
async trackPageView(clientId: string, path: string, title?: string): Promise<void> {
|
||||
await this.sendEvent('page_view', {
|
||||
client_id: clientId,
|
||||
page_path: path,
|
||||
page_title: title,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Track e-commerce purchase
|
||||
*/
|
||||
async trackPurchase(
|
||||
clientId: string,
|
||||
transactionId: string,
|
||||
value: number,
|
||||
currency: string,
|
||||
items: Array<{ name: string; price: number; quantity: number }>
|
||||
): Promise<void> {
|
||||
await this.sendEvent('purchase', {
|
||||
client_id: clientId,
|
||||
transaction_id: transactionId,
|
||||
value,
|
||||
currency,
|
||||
items,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Track conversion
|
||||
*/
|
||||
async trackConversion(
|
||||
clientId: string,
|
||||
conversionName: string,
|
||||
metadata?: Record<string, any>
|
||||
): Promise<void> {
|
||||
await this.sendEvent('conversion', {
|
||||
client_id: clientId,
|
||||
conversion_name: conversionName,
|
||||
...metadata,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get analytics data (for dashboards)
|
||||
*/
|
||||
async getMetrics(
|
||||
dateRange: { startDate: string; endDate: string },
|
||||
metrics: string[],
|
||||
dimensions?: string[]
|
||||
): Promise<any> {
|
||||
// TODO: Implement GA4 Analytics Data API
|
||||
return {
|
||||
rows: [],
|
||||
totals: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Export instance
|
||||
export const ga4Service = new GA4Service();
|
||||
117
packages/shared-analytics/src/services/mixpanel.service.ts
Normal file
117
packages/shared-analytics/src/services/mixpanel.service.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { Analytics } from '@segment/analytics-node';
|
||||
import { analyticsEnv, EventType, eventPropertiesSchema } from '../config/analytics.config';
|
||||
import { hashPhoneNumber } from '../utils/phone-hash';
|
||||
|
||||
// Mixpanel service
|
||||
export class MixpanelService {
|
||||
private client: Analytics;
|
||||
|
||||
constructor() {
|
||||
this.client = new Analytics({
|
||||
apiKey: analyticsEnv.MIXPANEL_TOKEN,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Track an event in Mixpanel
|
||||
*/
|
||||
async track(
|
||||
event: EventType,
|
||||
distinctId: string,
|
||||
properties?: Record<string, any>
|
||||
): Promise<void> {
|
||||
const validatedProperties = eventPropertiesSchema.parse(properties);
|
||||
|
||||
this.client.track({
|
||||
event,
|
||||
distinctId,
|
||||
properties: {
|
||||
...validatedProperties,
|
||||
...properties,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify a user
|
||||
*/
|
||||
async identify(userId: string, traits?: Record<string, any>): Promise<void> {
|
||||
this.client.identify({
|
||||
distinctId: userId,
|
||||
traits,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Group users by subscription tier
|
||||
*/
|
||||
async group(groupId: string, groupKey: string, traits?: Record<string, any>): Promise<void> {
|
||||
this.client.group({
|
||||
groupKey,
|
||||
groupId,
|
||||
traits,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Track user sign-up
|
||||
*/
|
||||
async userSignedUp(userId: string, plan?: string, referrer?: string): Promise<void> {
|
||||
await this.track(EventType.USER_SIGNED_UP, userId, {
|
||||
plan,
|
||||
referrer,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Track subscription upgrade
|
||||
*/
|
||||
async userUpgraded(userId: string, fromTier: string, toTier: string, mrr: number): Promise<void> {
|
||||
await this.track(EventType.USER_UPGRADED, userId, {
|
||||
fromTier,
|
||||
toTier,
|
||||
mrr,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Track exposure detection
|
||||
*/
|
||||
async exposureDetected(
|
||||
userId: string,
|
||||
exposureType: string,
|
||||
severity: string,
|
||||
source: string
|
||||
): Promise<void> {
|
||||
await this.track(EventType.EXPOSURE_DETECTED, userId, {
|
||||
exposureType,
|
||||
severity,
|
||||
source,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Track spam detection
|
||||
*/
|
||||
async spamBlocked(userId: string, phoneNumber: string, confidence: number, method: string): Promise<void> {
|
||||
await this.track(EventType.SPAM_BLOCKED, userId, {
|
||||
phoneNumber: hashPhoneNumber(phoneNumber),
|
||||
confidence,
|
||||
method,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush pending events
|
||||
*/
|
||||
async flush(): Promise<void> {
|
||||
await this.client.flush();
|
||||
}
|
||||
}
|
||||
|
||||
// Export instance
|
||||
export const mixpanelService = new MixpanelService();
|
||||
12
packages/shared-analytics/src/utils/phone-hash.ts
Normal file
12
packages/shared-analytics/src/utils/phone-hash.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Hash a phone number for analytics purposes
|
||||
* Uses a consistent hashing algorithm to create a deterministic hash
|
||||
*/
|
||||
export function hashPhoneNumber(phoneNumber: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < phoneNumber.length; i++) {
|
||||
hash = ((hash << 5) - hash) + phoneNumber.charCodeAt(i);
|
||||
hash |= 0;
|
||||
}
|
||||
return `hash_${Math.abs(hash)}`;
|
||||
}
|
||||
12
packages/shared-analytics/tsconfig.json
Normal file
12
packages/shared-analytics/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"]
|
||||
}
|
||||
18
packages/shared-auth/package.json
Normal file
18
packages/shared-auth/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@shieldsai/shared-auth",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"lint": "eslint src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"next-auth": "^4.24.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
114
packages/shared-auth/src/config/auth.config.ts
Normal file
114
packages/shared-auth/src/config/auth.config.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { NextAuthOptions } from 'next-auth';
|
||||
import CredentialsProvider from 'next-auth/providers/credentials';
|
||||
import GoogleProvider from 'next-auth/providers/google';
|
||||
import AppleProvider from 'next-auth/providers/apple';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Environment variables
|
||||
const envSchema = z.object({
|
||||
NEXTAUTH_URL: z.string().url(),
|
||||
NEXTAUTH_SECRET: z.string().min(32),
|
||||
GOOGLE_CLIENT_ID: z.string(),
|
||||
GOOGLE_CLIENT_SECRET: z.string(),
|
||||
APPLE_CLIENT_ID: z.string(),
|
||||
APPLE_CLIENT_SECRET: z.string(),
|
||||
DATABASE_URL: z.string().url(),
|
||||
});
|
||||
|
||||
export const authEnv = envSchema.parse({
|
||||
NEXTAUTH_URL: process.env.NEXTAUTH_URL,
|
||||
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
|
||||
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
|
||||
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
|
||||
APPLE_CLIENT_ID: process.env.APPLE_CLIENT_ID,
|
||||
APPLE_CLIENT_SECRET: process.env.APPLE_CLIENT_SECRET,
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
});
|
||||
|
||||
// Role-based access control
|
||||
export type UserRole = 'user' | 'family_admin' | 'family_member' | 'support';
|
||||
|
||||
export const userRoles: UserRole[] = ['user', 'family_admin', 'family_member', 'support'];
|
||||
|
||||
// Family group types
|
||||
export type FamilyGroup = {
|
||||
id: string;
|
||||
name: string;
|
||||
members: string[]; // user IDs
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
// NextAuth options
|
||||
export const authOptions: NextAuthOptions = {
|
||||
providers: [
|
||||
CredentialsProvider({
|
||||
name: 'Credentials',
|
||||
credentials: {
|
||||
email: { label: 'Email', type: 'email' },
|
||||
password: { label: 'Password', type: 'password' },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
if (!credentials?.email || !credentials?.password) {
|
||||
throw new Error('Email and password required');
|
||||
}
|
||||
|
||||
// TODO: Validate against database
|
||||
const user = {
|
||||
id: '1',
|
||||
email: credentials.email,
|
||||
name: credentials.email.split('@')[0],
|
||||
role: 'user' as UserRole,
|
||||
};
|
||||
|
||||
return user;
|
||||
},
|
||||
}),
|
||||
GoogleProvider({
|
||||
clientId: authEnv.GOOGLE_CLIENT_ID,
|
||||
clientSecret: authEnv.GOOGLE_CLIENT_SECRET,
|
||||
}),
|
||||
AppleProvider({
|
||||
clientId: authEnv.APPLE_CLIENT_ID,
|
||||
clientSecret: authEnv.APPLE_CLIENT_SECRET,
|
||||
}),
|
||||
],
|
||||
session: {
|
||||
strategy: 'jwt',
|
||||
maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||
},
|
||||
pages: {
|
||||
signIn: '/auth/signin',
|
||||
signOut: '/auth/signout',
|
||||
error: '/auth/error',
|
||||
},
|
||||
callbacks: {
|
||||
async jwt({ token, user, account }) {
|
||||
if (user) {
|
||||
token.id = user.id;
|
||||
token.role = (user as any).role;
|
||||
}
|
||||
|
||||
if (account) {
|
||||
token.provider = account.provider;
|
||||
token.accessToken = account.access_token;
|
||||
}
|
||||
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }) {
|
||||
if (session.user) {
|
||||
session.user.id = token.id as string;
|
||||
session.user.role = token.role as UserRole;
|
||||
}
|
||||
|
||||
return session;
|
||||
},
|
||||
},
|
||||
events: {
|
||||
async createUser({ user }) {
|
||||
// TODO: Create default family group
|
||||
console.log('New user created:', user.email);
|
||||
},
|
||||
},
|
||||
};
|
||||
25
packages/shared-auth/src/index.ts
Normal file
25
packages/shared-auth/src/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
// Config
|
||||
export { authOptions, authEnv, userRoles } from './config/auth.config';
|
||||
export type { UserRole, FamilyGroup } from './config/auth.config';
|
||||
|
||||
// Middleware
|
||||
export { withAuth, withRole, protectApiRoute } from './middleware/auth.middleware';
|
||||
|
||||
// Models
|
||||
export {
|
||||
userSchema,
|
||||
familyGroupSchema,
|
||||
familyMemberSchema,
|
||||
sessionSchema,
|
||||
accountSchema,
|
||||
createUserSchema,
|
||||
createFamilyGroupSchema,
|
||||
addFamilyMemberSchema,
|
||||
} from './models/auth.models';
|
||||
export type {
|
||||
User,
|
||||
FamilyGroup as AuthFamilyGroup,
|
||||
FamilyMember,
|
||||
Session,
|
||||
Account,
|
||||
} from './models/auth.models';
|
||||
62
packages/shared-auth/src/middleware/auth.middleware.ts
Normal file
62
packages/shared-auth/src/middleware/auth.middleware.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { NextRequest, NextResponse } from 'next-auth/react';
|
||||
import { UserRole } from '../config/auth.config';
|
||||
|
||||
/**
|
||||
* Middleware to protect routes that require authentication
|
||||
*/
|
||||
export function withAuth(
|
||||
request: NextRequest,
|
||||
options?: {
|
||||
signInPath?: string;
|
||||
}
|
||||
): NextResponse {
|
||||
const token = request.cookies.get('next-auth.session-token')?.value;
|
||||
const signInPath = options?.signInPath ?? '/auth/signin';
|
||||
|
||||
if (!token) {
|
||||
const signInUrl = new URL(signInPath, request.url);
|
||||
signInUrl.searchParams.set('callbackUrl', request.nextUrl.pathname);
|
||||
return NextResponse.redirect(signInUrl);
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to check if user has required role
|
||||
*/
|
||||
export function withRole(
|
||||
response: NextResponse,
|
||||
request: NextRequest,
|
||||
requiredRoles: UserRole[]
|
||||
): NextResponse {
|
||||
const token = request.cookies.get('next-auth.session-token')?.value;
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// TODO: Decode JWT and check role
|
||||
// For now, allow all authenticated users
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to protect API routes
|
||||
*/
|
||||
export function protectApiRoute(request: NextRequest): NextResponse {
|
||||
const authHeader = request.headers.get('authorization');
|
||||
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return NextResponse.json({ error: 'Missing or invalid token' }, { status: 401 });
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
|
||||
try {
|
||||
// TODO: Verify JWT token
|
||||
return NextResponse.next();
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
81
packages/shared-auth/src/models/auth.models.ts
Normal file
81
packages/shared-auth/src/models/auth.models.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// User schema
|
||||
export const userSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
email: z.string().email(),
|
||||
name: z.string().min(1),
|
||||
image: z.string().url().optional(),
|
||||
role: z.enum(['user', 'family_admin', 'family_member', 'support']),
|
||||
emailVerified: z.date().optional(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
});
|
||||
|
||||
export type User = z.infer<typeof userSchema>;
|
||||
|
||||
// Family group schema
|
||||
export const familyGroupSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
name: z.string().min(1).max(100),
|
||||
ownerId: z.string().uuid(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
});
|
||||
|
||||
export type FamilyGroup = z.infer<typeof familyGroupSchema>;
|
||||
|
||||
// Family member schema
|
||||
export const familyMemberSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
groupId: z.string().uuid(),
|
||||
userId: z.string().uuid(),
|
||||
role: z.enum(['owner', 'admin', 'member']),
|
||||
joinedAt: z.date(),
|
||||
});
|
||||
|
||||
export type FamilyMember = z.infer<typeof familyMemberSchema>;
|
||||
|
||||
// Session schema
|
||||
export const sessionSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
userId: z.string().uuid(),
|
||||
sessionToken: z.string(),
|
||||
expires: z.date(),
|
||||
createdAt: z.date(),
|
||||
});
|
||||
|
||||
export type Session = z.infer<typeof sessionSchema>;
|
||||
|
||||
// Account schema (for OAuth)
|
||||
export const accountSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
userId: z.string().uuid(),
|
||||
provider: z.string(),
|
||||
providerAccountId: z.string(),
|
||||
access_token: z.string().optional(),
|
||||
refresh_token: z.string().optional(),
|
||||
expires_at: z.number().optional(),
|
||||
token_type: z.string().optional(),
|
||||
scope: z.string().optional(),
|
||||
});
|
||||
|
||||
export type Account = z.infer<typeof accountSchema>;
|
||||
|
||||
// Validation schemas for API
|
||||
export const createUserSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
name: z.string().min(1),
|
||||
});
|
||||
|
||||
export const createFamilyGroupSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
ownerId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export const addFamilyMemberSchema = z.object({
|
||||
groupId: z.string().uuid(),
|
||||
userId: z.string().uuid(),
|
||||
role: z.enum(['admin', 'member']).default('member'),
|
||||
});
|
||||
12
packages/shared-auth/tsconfig.json
Normal file
12
packages/shared-auth/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"]
|
||||
}
|
||||
223
packages/shared-billing/src/services/billing.services.ts
Normal file
223
packages/shared-billing/src/services/billing.services.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import { stripe, SubscriptionTier, tierConfig } from '../config/billing.config';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Subscription service
|
||||
export class SubscriptionService {
|
||||
/**
|
||||
* Create a new subscription for a customer
|
||||
*/
|
||||
async createSubscription(
|
||||
customerId: string,
|
||||
tier: SubscriptionTier,
|
||||
metadata?: Record<string, string>
|
||||
): Promise<Stripe.Subscription> {
|
||||
const priceId = tierConfig[tier].priceId;
|
||||
|
||||
const subscription = await stripe.subscriptions.create({
|
||||
customer: customerId,
|
||||
items: [{ price: priceId }],
|
||||
metadata: metadata,
|
||||
proration_behavior: 'create_prorations',
|
||||
});
|
||||
|
||||
return subscription;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a customer's subscription tier
|
||||
*/
|
||||
async updateSubscriptionTier(
|
||||
subscriptionId: string,
|
||||
newTier: SubscriptionTier
|
||||
): Promise<Stripe.Subscription> {
|
||||
const newPriceId = tierConfig[newTier].priceId;
|
||||
|
||||
const subscription = await stripe.subscriptions.update(subscriptionId, {
|
||||
items: [
|
||||
{
|
||||
price: newPriceId,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
proration_behavior: 'create_prorations',
|
||||
});
|
||||
|
||||
return subscription;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a subscription
|
||||
*/
|
||||
async cancelSubscription(
|
||||
subscriptionId: string,
|
||||
atPeriodEnd: boolean = true
|
||||
): Promise<Stripe.Subscription> {
|
||||
const subscription = await stripe.subscriptions.update(subscriptionId, {
|
||||
cancel_at_period_end: atPeriodEnd,
|
||||
});
|
||||
|
||||
return subscription;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get subscription by ID
|
||||
*/
|
||||
async getSubscription(subscriptionId: string): Promise<Stripe.Subscription | null> {
|
||||
try {
|
||||
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
||||
return subscription;
|
||||
} catch (error) {
|
||||
if (error instanceof Stripe.errors.StripeInvalidRequestError) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get customer's current subscription
|
||||
*/
|
||||
async getCustomerSubscription(customerId: string): Promise<Stripe.Subscription | null> {
|
||||
const subscriptions = await stripe.subscriptions.list({
|
||||
customer: customerId,
|
||||
status: 'active',
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
return subscriptions.data[0] || null;
|
||||
}
|
||||
}
|
||||
|
||||
// Customer service
|
||||
export class CustomerService {
|
||||
/**
|
||||
* Create a new Stripe customer
|
||||
*/
|
||||
async createCustomer(
|
||||
email: string,
|
||||
name?: string,
|
||||
metadata?: Record<string, string>
|
||||
): Promise<Stripe.Customer> {
|
||||
const customer = await stripe.customers.create({
|
||||
email,
|
||||
name,
|
||||
metadata,
|
||||
});
|
||||
|
||||
return customer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create customer by email
|
||||
*/
|
||||
async getOrCreateCustomer(
|
||||
email: string,
|
||||
name?: string
|
||||
): Promise<Stripe.Customer> {
|
||||
const existingCustomers = await stripe.customers.list({
|
||||
email,
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
if (existingCustomers.data.length > 0) {
|
||||
return existingCustomers.data[0];
|
||||
}
|
||||
|
||||
return this.createCustomer(email, name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a billing portal session
|
||||
*/
|
||||
async createBillingPortalSession(
|
||||
customerId: string,
|
||||
returnUrl: string
|
||||
): Promise<Stripe.BillingPortal.Session> {
|
||||
const session = await stripe.billingPortal.sessions.create({
|
||||
customer: customerId,
|
||||
return_url: returnUrl,
|
||||
});
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get customer by ID
|
||||
*/
|
||||
async getCustomer(customerId: string): Promise<Stripe.Customer | null> {
|
||||
try {
|
||||
const customer = await stripe.customers.retrieve(customerId);
|
||||
return customer as Stripe.Customer;
|
||||
} catch (error) {
|
||||
if (error instanceof Stripe.errors.StripeInvalidRequestError) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Webhook service
|
||||
export class WebhookService {
|
||||
/**
|
||||
* Construct webhook event from raw body
|
||||
*/
|
||||
constructEvent(
|
||||
rawBody: Buffer | string,
|
||||
signature: string
|
||||
): Stripe.Event {
|
||||
return stripe.webhooks.constructEvent(
|
||||
rawBody,
|
||||
signature,
|
||||
process.env.STRIPE_WEBHOOK_SECRET!
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle webhook event
|
||||
*/
|
||||
async handleWebhook(event: Stripe.Event): Promise<void> {
|
||||
switch (event.type) {
|
||||
case 'customer.subscription.created':
|
||||
case 'customer.subscription.updated':
|
||||
await this.handleSubscriptionChange(event.data.object);
|
||||
break;
|
||||
case 'customer.subscription.deleted':
|
||||
await this.handleSubscriptionDeleted(event.data.object);
|
||||
break;
|
||||
case 'invoice.payment_succeeded':
|
||||
await this.handlePaymentSucceeded(event.data.object);
|
||||
break;
|
||||
case 'invoice.payment_failed':
|
||||
await this.handlePaymentFailed(event.data.object);
|
||||
break;
|
||||
default:
|
||||
console.log(`Unhandled event type: ${event.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleSubscriptionChange(subscription: Stripe.Subscription) {
|
||||
console.log(`Subscription ${subscription.id} changed to ${subscription.status}`);
|
||||
// TODO: Update local database
|
||||
}
|
||||
|
||||
private async handleSubscriptionDeleted(subscription: Stripe.Subscription) {
|
||||
console.log(`Subscription ${subscription.id} deleted`);
|
||||
// TODO: Update local database
|
||||
}
|
||||
|
||||
private async handlePaymentSucceeded(invoice: Stripe.Invoice) {
|
||||
console.log(`Payment succeeded for invoice ${invoice.id}`);
|
||||
// TODO: Update usage tracking
|
||||
}
|
||||
|
||||
private async handlePaymentFailed(invoice: Stripe.Invoice) {
|
||||
console.log(`Payment failed for invoice ${invoice.id}`);
|
||||
// TODO: Send notification to customer
|
||||
}
|
||||
}
|
||||
|
||||
// Export instances
|
||||
export const subscriptionService = new SubscriptionService();
|
||||
export const customerService = new CustomerService();
|
||||
export const webhookService = new WebhookService();
|
||||
17
packages/shared-ui/package.json
Normal file
17
packages/shared-ui/package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@shieldsai/shared-ui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.tsx",
|
||||
"types": "src/index.tsx",
|
||||
"scripts": {
|
||||
"lint": "eslint src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"solid-js": "^1.8.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
16
packages/shared-utils/package.json
Normal file
16
packages/shared-utils/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "@shieldsai/shared-utils",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"lint": "eslint src/",
|
||||
"test": "vitest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.3.3",
|
||||
"vitest": "^1.3.1"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user