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:
2026-05-02 10:13:13 -04:00
parent 8687868632
commit 1e42c4a5c2
45 changed files with 4837 additions and 562 deletions

View 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 });
}
});
}

View File

@@ -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' }
);
}

View 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,
});
}
});
}

View File

@@ -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 });
}
});
}

View 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,
};

View 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"
}
}

View 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 },
};

View 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';

View 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();

View 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();

View 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)}`;
}

View 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"]
}

View 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"
}
}

View 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);
},
},
};

View 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';

View 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 });
}
}

View 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'),
});

View 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"]
}

View 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();

View 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"
}
}

View 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"
}
}