Auto-commit 2026-05-02 09:37
This commit is contained in:
1
apps/api/node_modules/.vite/vitest/results.json
generated
vendored
Normal file
1
apps/api/node_modules/.vite/vitest/results.json
generated
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":"1.6.1","results":[[":src/__tests__/spam-rate-limit.test.ts",{"duration":41,"failed":false}]]}
|
||||
@@ -11,7 +11,9 @@
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/helmet": "^13.0.2",
|
||||
"@shieldsai/shared-analytics": "*",
|
||||
"@shieldsai/shared-auth": "*",
|
||||
"@shieldsai/shared-billing": "*",
|
||||
"@shieldsai/shared-db": "*",
|
||||
"@shieldsai/shared-notifications": "*",
|
||||
"@shieldsai/shared-utils": "*",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { authMiddleware, AuthRequest } from './auth.middleware';
|
||||
import { voiceprintRoutes } from './voiceprint.routes';
|
||||
import { spamshieldRoutes } from './spamshield.routes';
|
||||
import { darkwatchRoutes } from './darkwatch.routes';
|
||||
|
||||
export async function routes(fastify: FastifyInstance) {
|
||||
// Authenticated routes group
|
||||
@@ -130,4 +131,12 @@ export async function routes(fastify: FastifyInstance) {
|
||||
},
|
||||
{ prefix: '/spamshield' }
|
||||
);
|
||||
|
||||
// DarkWatch service routes
|
||||
fastify.register(
|
||||
async (darkwatchRouter) => {
|
||||
await darkwatchRoutes(darkwatchRouter);
|
||||
},
|
||||
{ prefix: '/darkwatch' }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
callAnalysisService,
|
||||
spamFeedbackService,
|
||||
} from '../services/spamshield';
|
||||
import { ErrorHandler, SpamErrorCode } from '../services/spamshield/spamshield.error-handler';
|
||||
|
||||
export async function spamshieldRoutes(fastify: FastifyInstance) {
|
||||
// Classify SMS text
|
||||
@@ -13,13 +14,19 @@ export async function spamshieldRoutes(fastify: FastifyInstance) {
|
||||
const userId = authReq.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
return reply.code(401).send({ error: 'User ID required' });
|
||||
ErrorHandler.send(reply, SpamErrorCode.UNAUTHORIZED, 'User ID required', { status: 401 });
|
||||
return;
|
||||
}
|
||||
|
||||
const body = request.body as { text: string };
|
||||
|
||||
if (!body.text || typeof body.text !== 'string') {
|
||||
return reply.code(400).send({ error: 'text is required' });
|
||||
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 {
|
||||
@@ -32,8 +39,9 @@ export async function spamshieldRoutes(fastify: FastifyInstance) {
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Classification failed';
|
||||
return reply.code(422).send({ error: message });
|
||||
ErrorHandler.send(reply, SpamErrorCode.CLASSIFICATION_FAILED, 'Classification failed', {
|
||||
status: 422,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -43,13 +51,19 @@ export async function spamshieldRoutes(fastify: FastifyInstance) {
|
||||
const userId = authReq.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
return reply.code(401).send({ error: 'User ID required' });
|
||||
ErrorHandler.send(reply, SpamErrorCode.UNAUTHORIZED, 'User ID required', { status: 401 });
|
||||
return;
|
||||
}
|
||||
|
||||
const body = request.body as { phoneNumber: string };
|
||||
|
||||
if (!body.phoneNumber || typeof body.phoneNumber !== 'string') {
|
||||
return reply.code(400).send({ error: 'phoneNumber is required' });
|
||||
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 {
|
||||
@@ -63,8 +77,9 @@ export async function spamshieldRoutes(fastify: FastifyInstance) {
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Reputation check failed';
|
||||
return reply.code(422).send({ error: message });
|
||||
ErrorHandler.send(reply, SpamErrorCode.REPUTATION_CHECK_FAILED, 'Reputation check failed', {
|
||||
status: 422,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -74,7 +89,8 @@ export async function spamshieldRoutes(fastify: FastifyInstance) {
|
||||
const userId = authReq.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
return reply.code(401).send({ error: 'User ID required' });
|
||||
ErrorHandler.send(reply, SpamErrorCode.UNAUTHORIZED, 'User ID required', { status: 401 });
|
||||
return;
|
||||
}
|
||||
|
||||
const body = request.body as {
|
||||
@@ -84,8 +100,23 @@ export async function spamshieldRoutes(fastify: FastifyInstance) {
|
||||
isVoip?: boolean;
|
||||
};
|
||||
|
||||
if (!body.phoneNumber || !body.callTime) {
|
||||
return reply.code(400).send({ error: 'phoneNumber and callTime are required' });
|
||||
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 {
|
||||
@@ -103,8 +134,9 @@ export async function spamshieldRoutes(fastify: FastifyInstance) {
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Call analysis failed';
|
||||
return reply.code(422).send({ error: message });
|
||||
ErrorHandler.send(reply, SpamErrorCode.ANALYSIS_FAILED, 'Call analysis failed', {
|
||||
status: 422,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -114,7 +146,8 @@ export async function spamshieldRoutes(fastify: FastifyInstance) {
|
||||
const userId = authReq.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
return reply.code(401).send({ error: 'User ID required' });
|
||||
ErrorHandler.send(reply, SpamErrorCode.UNAUTHORIZED, 'User ID required', { status: 401 });
|
||||
return;
|
||||
}
|
||||
|
||||
const body = request.body as {
|
||||
@@ -124,8 +157,22 @@ export async function spamshieldRoutes(fastify: FastifyInstance) {
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
if (!body.phoneNumber || typeof body.isSpam !== 'boolean') {
|
||||
return reply.code(400).send({ error: 'phoneNumber and isSpam are required' });
|
||||
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 {
|
||||
@@ -145,8 +192,9 @@ export async function spamshieldRoutes(fastify: FastifyInstance) {
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Feedback recording failed';
|
||||
return reply.code(422).send({ error: message });
|
||||
ErrorHandler.send(reply, SpamErrorCode.FEEDBACK_RECORD_FAILED, 'Feedback recording failed', {
|
||||
status: 422,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -156,7 +204,8 @@ export async function spamshieldRoutes(fastify: FastifyInstance) {
|
||||
const userId = authReq.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
return reply.code(401).send({ error: 'User ID required' });
|
||||
ErrorHandler.send(reply, SpamErrorCode.UNAUTHORIZED, 'User ID required', { status: 401 });
|
||||
return;
|
||||
}
|
||||
|
||||
const query = request.query as {
|
||||
@@ -187,15 +236,17 @@ export async function spamshieldRoutes(fastify: FastifyInstance) {
|
||||
const userId = authReq.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
return reply.code(401).send({ error: 'User ID required' });
|
||||
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) {
|
||||
const message = error instanceof Error ? error.message : 'Statistics retrieval failed';
|
||||
return reply.code(422).send({ error: message });
|
||||
ErrorHandler.send(reply, SpamErrorCode.ANALYSIS_FAILED, 'Statistics retrieval failed', {
|
||||
status: 422,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
174
apps/api/src/services/darkwatch/alert.pipeline.ts
Normal file
174
apps/api/src/services/darkwatch/alert.pipeline.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { prisma, AlertType, AlertSeverity } from '@shieldsai/shared-db';
|
||||
import {
|
||||
NotificationService,
|
||||
NotificationPriority,
|
||||
loadNotificationConfig,
|
||||
} from '@shieldsai/shared-notifications';
|
||||
|
||||
const ALERT_DEDUP_WINDOW_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export class AlertPipeline {
|
||||
private notificationService: NotificationService;
|
||||
|
||||
constructor() {
|
||||
this.notificationService = new NotificationService(loadNotificationConfig());
|
||||
}
|
||||
|
||||
async processNewExposures(exposureIds: string[]) {
|
||||
const exposures = await prisma.exposure.findMany({
|
||||
where: { id: { in: exposureIds }, isFirstTime: true },
|
||||
include: {
|
||||
subscription: {
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
tier: true,
|
||||
},
|
||||
},
|
||||
watchlistItem: true,
|
||||
},
|
||||
});
|
||||
|
||||
const alertsCreated: Awaited<ReturnType<typeof prisma.alert.create>>[] = [];
|
||||
|
||||
for (const exposure of exposures) {
|
||||
const dedupKey = `exposure:${exposure.subscriptionId}:${exposure.source}:${exposure.identifierHash}`;
|
||||
|
||||
const recentAlert = await prisma.alert.findFirst({
|
||||
where: {
|
||||
subscriptionId: exposure.subscriptionId,
|
||||
type: AlertType.exposure_detected,
|
||||
createdAt: {
|
||||
gte: new Date(Date.now() - ALERT_DEDUP_WINDOW_MS),
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
if (recentAlert) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const alert = await prisma.alert.create({
|
||||
data: {
|
||||
subscriptionId: exposure.subscriptionId,
|
||||
userId: exposure.subscription.userId,
|
||||
exposureId: exposure.id,
|
||||
type: AlertType.exposure_detected,
|
||||
title: this.buildTitle(exposure),
|
||||
message: this.buildMessage(exposure),
|
||||
severity: this.mapSeverity(exposure.severity),
|
||||
channel: this.getChannelsForTier(exposure.subscription.tier),
|
||||
},
|
||||
});
|
||||
|
||||
alertsCreated.push(alert);
|
||||
|
||||
await this.dispatchNotification(alert, exposure);
|
||||
}
|
||||
|
||||
return alertsCreated;
|
||||
}
|
||||
|
||||
async dispatchScanCompleteAlert(
|
||||
subscriptionId: string,
|
||||
userId: string,
|
||||
exposuresFound: number
|
||||
) {
|
||||
const subscription = await prisma.subscription.findUnique({
|
||||
where: { id: subscriptionId },
|
||||
select: { tier: true },
|
||||
});
|
||||
|
||||
if (!subscription) return;
|
||||
|
||||
const alert = await prisma.alert.create({
|
||||
data: {
|
||||
subscriptionId,
|
||||
userId,
|
||||
type: AlertType.scan_complete,
|
||||
title: 'DarkWatch Scan Complete',
|
||||
message: `Scan found ${exposuresFound} new exposure${exposuresFound === 1 ? '' : 's'}.`,
|
||||
severity: exposuresFound > 0 ? 'warning' : 'info',
|
||||
channel: this.getChannelsForTier(subscription.tier),
|
||||
},
|
||||
});
|
||||
|
||||
await this.dispatchNotification(alert, {
|
||||
source: 'hibp',
|
||||
severity: 'info',
|
||||
identifier: '',
|
||||
dataType: 'email',
|
||||
} as any);
|
||||
|
||||
return alert;
|
||||
}
|
||||
|
||||
private async dispatchNotification(
|
||||
alert: {
|
||||
userId: string;
|
||||
channel: string[];
|
||||
title: string;
|
||||
message: string;
|
||||
severity: AlertSeverity;
|
||||
},
|
||||
exposure: { source: string; severity: string; identifier: string; dataType: string }
|
||||
) {
|
||||
try {
|
||||
if (!this.notificationService.isFullyConfigured()) return;
|
||||
|
||||
await this.notificationService.sendMultiChannelNotification(
|
||||
{
|
||||
userId: alert.userId,
|
||||
},
|
||||
alert.channel as any,
|
||||
alert.title,
|
||||
`<p>${alert.message}</p>
|
||||
<p><strong>Source:</strong> ${exposure.source}</p>
|
||||
<p><strong>Severity:</strong> ${exposure.severity}</p>
|
||||
<p><strong>Type:</strong> ${exposure.dataType}</p>`,
|
||||
alert.severity === 'critical'
|
||||
? NotificationPriority.HIGH
|
||||
: NotificationPriority.NORMAL
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[AlertPipeline] Notification dispatch error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private buildTitle(exposure: {
|
||||
source: string;
|
||||
dataType: string;
|
||||
severity: string;
|
||||
}): string {
|
||||
return `${exposure.severity.toUpperCase()}: ${exposure.dataType} exposure on ${exposure.source}`;
|
||||
}
|
||||
|
||||
private buildMessage(exposure: {
|
||||
identifier: string;
|
||||
source: string;
|
||||
severity: string;
|
||||
dataType: string;
|
||||
}): string {
|
||||
const masked = exposure.identifier.includes('@')
|
||||
? exposure.identifier.replace(/(?<=.{2}).*(?=@)/, '***')
|
||||
: exposure.identifier.slice(0, 3) + '***';
|
||||
|
||||
return `Your ${exposure.dataType} (${masked}) was found in a ${exposure.source} breach with ${exposure.severity} severity.`;
|
||||
}
|
||||
|
||||
private mapSeverity(severity: string): AlertSeverity {
|
||||
return severity as AlertSeverity;
|
||||
}
|
||||
|
||||
private getChannelsForTier(tier: string): string[] {
|
||||
const channelMap: Record<string, string[]> = {
|
||||
basic: ['email'],
|
||||
plus: ['email', 'push'],
|
||||
premium: ['email', 'push', 'sms'],
|
||||
};
|
||||
return channelMap[tier] || ['email'];
|
||||
}
|
||||
}
|
||||
|
||||
export const alertPipeline = new AlertPipeline();
|
||||
5
apps/api/src/services/darkwatch/index.ts
Normal file
5
apps/api/src/services/darkwatch/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { watchlistService } from './watchlist.service';
|
||||
export { scanService } from './scan.service';
|
||||
export { schedulerService } from './scheduler.service';
|
||||
export { webhookService } from './webhook.service';
|
||||
export { alertPipeline } from './alert.pipeline';
|
||||
220
apps/api/src/services/darkwatch/scan.service.ts
Normal file
220
apps/api/src/services/darkwatch/scan.service.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import { prisma, ExposureSource, ExposureSeverity, WatchlistType } from '@shieldsai/shared-db';
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
function hashIdentifier(identifier: string): string {
|
||||
return createHash('sha256').update(identifier.toLowerCase().trim()).digest('hex');
|
||||
}
|
||||
|
||||
function determineSeverity(
|
||||
source: ExposureSource,
|
||||
dataType: WatchlistType
|
||||
): ExposureSeverity {
|
||||
const criticalSources = [ExposureSource.darkWebForum, ExposureSource.honeypot];
|
||||
const warningSources = [ExposureSource.hibp, ExposureSource.shodan];
|
||||
const criticalTypes = [WatchlistType.ssn];
|
||||
|
||||
if (criticalTypes.includes(dataType)) return ExposureSeverity.critical;
|
||||
if (criticalSources.includes(source)) return ExposureSeverity.critical;
|
||||
if (warningSources.includes(source)) return ExposureSeverity.warning;
|
||||
return ExposureSeverity.info;
|
||||
}
|
||||
|
||||
export class ScanService {
|
||||
async checkHIBP(email: string): Promise<{ exposed: boolean; sources: string[] }> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://hibp.com/api/v2/${encodeURIComponent(email)}`,
|
||||
{
|
||||
headers: {
|
||||
'hibp-api-key': process.env.HIBP_API_KEY || '',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
signal: AbortSignal.timeout(15000),
|
||||
}
|
||||
);
|
||||
|
||||
if (response.status === 404) {
|
||||
return { exposed: false, sources: [] };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`[ScanService:HIBP] Status ${response.status} for ${email}`);
|
||||
return { exposed: false, sources: [] };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const sources = Array.isArray(data)
|
||||
? data.map((p: { Name: string }) => p.Name)
|
||||
: [];
|
||||
|
||||
return { exposed: sources.length > 0, sources };
|
||||
} catch (error) {
|
||||
console.error('[ScanService:HIBP] Error:', error);
|
||||
return { exposed: false, sources: [] };
|
||||
}
|
||||
}
|
||||
|
||||
async checkShodan(domain: string): Promise<{ exposed: boolean; ports: string[]; ips: string[] }> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://api.shodan.io/shodan/host/${encodeURIComponent(domain)}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.SHODAN_API_KEY || ''}`,
|
||||
},
|
||||
signal: AbortSignal.timeout(15000),
|
||||
}
|
||||
);
|
||||
|
||||
if (response.status === 404) {
|
||||
return { exposed: false, ports: [], ips: [] };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`[ScanService:Shodan] Status ${response.status} for ${domain}`);
|
||||
return { exposed: false, ports: [], ips: [] };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
exposed: !!data.ip_str,
|
||||
ports: data.ports?.map(String) || [],
|
||||
ips: [data.ip_str || ''],
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[ScanService:Shodan] Error:', error);
|
||||
return { exposed: false, ports: [], ips: [] };
|
||||
}
|
||||
}
|
||||
|
||||
async processSubscriptionScan(
|
||||
subscriptionId: string,
|
||||
watchlistItems: Awaited<ReturnType<ScanService['getWatchlistItems']>>
|
||||
): Promise<{ exposuresCreated: number; exposuresUpdated: number }> {
|
||||
let exposuresCreated = 0;
|
||||
let exposuresUpdated = 0;
|
||||
|
||||
for (const item of watchlistItems) {
|
||||
const identifier = item.value;
|
||||
const identifierHash = hashIdentifier(identifier);
|
||||
|
||||
switch (item.type) {
|
||||
case WatchlistType.email: {
|
||||
const hibpResult = await this.checkHIBP(identifier);
|
||||
if (hibpResult.exposed) {
|
||||
for (const source of hibpResult.sources) {
|
||||
const existing = await prisma.exposure.findFirst({
|
||||
where: {
|
||||
subscriptionId,
|
||||
source: ExposureSource.hibp,
|
||||
identifierHash,
|
||||
metadata: { path: ['dbName'], equals: source },
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await prisma.exposure.update({
|
||||
where: { id: existing.id },
|
||||
data: { detectedAt: new Date() },
|
||||
});
|
||||
exposuresUpdated++;
|
||||
} else {
|
||||
await prisma.exposure.create({
|
||||
data: {
|
||||
subscriptionId,
|
||||
watchlistItemId: item.id,
|
||||
source: ExposureSource.hibp,
|
||||
dataType: item.type,
|
||||
identifier,
|
||||
identifierHash,
|
||||
severity: determineSeverity(ExposureSource.hibp, item.type),
|
||||
isFirstTime: true,
|
||||
metadata: { dbName: source },
|
||||
detectedAt: new Date(),
|
||||
},
|
||||
});
|
||||
exposuresCreated++;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case WatchlistType.domain: {
|
||||
const shodanResult = await this.checkShodan(identifier);
|
||||
if (shodanResult.exposed) {
|
||||
const existing = await prisma.exposure.findFirst({
|
||||
where: {
|
||||
subscriptionId,
|
||||
source: ExposureSource.shodan,
|
||||
identifierHash,
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await prisma.exposure.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
detectedAt: new Date(),
|
||||
metadata: { ports: shodanResult.ports, ips: shodanResult.ips },
|
||||
},
|
||||
});
|
||||
exposuresUpdated++;
|
||||
} else {
|
||||
await prisma.exposure.create({
|
||||
data: {
|
||||
subscriptionId,
|
||||
watchlistItemId: item.id,
|
||||
source: ExposureSource.shodan,
|
||||
dataType: item.type,
|
||||
identifier,
|
||||
identifierHash,
|
||||
severity: determineSeverity(ExposureSource.shodan, item.type),
|
||||
isFirstTime: true,
|
||||
metadata: { ports: shodanResult.ports, ips: shodanResult.ips },
|
||||
detectedAt: new Date(),
|
||||
},
|
||||
});
|
||||
exposuresCreated++;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
const existing = await prisma.exposure.findFirst({
|
||||
where: { subscriptionId, watchlistItemId: item.id, identifierHash },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
await prisma.exposure.create({
|
||||
data: {
|
||||
subscriptionId,
|
||||
watchlistItemId: item.id,
|
||||
source: ExposureSource.darkWebForum,
|
||||
dataType: item.type,
|
||||
identifier,
|
||||
identifierHash,
|
||||
severity: determineSeverity(ExposureSource.darkWebForum, item.type),
|
||||
isFirstTime: true,
|
||||
detectedAt: new Date(),
|
||||
},
|
||||
});
|
||||
exposuresCreated++;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { exposuresCreated, exposuresUpdated };
|
||||
}
|
||||
|
||||
async getWatchlistItems(subscriptionId: string) {
|
||||
return prisma.watchlistItem.findMany({
|
||||
where: { subscriptionId, isActive: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const scanService = new ScanService();
|
||||
97
apps/api/src/services/darkwatch/watchlist.service.ts
Normal file
97
apps/api/src/services/darkwatch/watchlist.service.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { prisma, WatchlistType } from '@shieldsai/shared-db';
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
export function normalizeValue(type: WatchlistType, value: string): string {
|
||||
const trimmed = value.trim().toLowerCase();
|
||||
switch (type) {
|
||||
case WatchlistType.email:
|
||||
return trimmed.replace(/\s+/g, '');
|
||||
case WatchlistType.phoneNumber:
|
||||
return trimmed.replace(/[\s\-\(\)]/g, '');
|
||||
case WatchlistType.ssn:
|
||||
return trimmed.replace(/-/g, '');
|
||||
case WatchlistType.address:
|
||||
return trimmed;
|
||||
case WatchlistType.domain:
|
||||
return trimmed.replace(/^https?:\/\//, '').replace(/\/.*$/, '');
|
||||
default:
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
export function hashValue(value: string): string {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
export class WatchlistService {
|
||||
async addItem(
|
||||
subscriptionId: string,
|
||||
type: WatchlistType,
|
||||
value: string,
|
||||
maxItems: number
|
||||
) {
|
||||
const normalized = normalizeValue(type, value);
|
||||
const itemHash = hashValue(normalized);
|
||||
|
||||
const currentCount = await prisma.watchlistItem.count({
|
||||
where: { subscriptionId, isActive: true },
|
||||
});
|
||||
|
||||
if (currentCount >= maxItems) {
|
||||
throw new Error(
|
||||
`Watchlist limit reached (${maxItems} items). Upgrade your plan to add more.`
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await prisma.watchlistItem.findFirst({
|
||||
where: { subscriptionId, type, hash: itemHash },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
if (!existing.isActive) {
|
||||
return prisma.watchlistItem.update({
|
||||
where: { id: existing.id },
|
||||
data: { isActive: true },
|
||||
});
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
return prisma.watchlistItem.create({
|
||||
data: {
|
||||
subscriptionId,
|
||||
type,
|
||||
value: normalized,
|
||||
hash: itemHash,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getItems(subscriptionId: string) {
|
||||
return prisma.watchlistItem.findMany({
|
||||
where: { subscriptionId, isActive: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async removeItem(id: string, subscriptionId: string) {
|
||||
return prisma.watchlistItem.update({
|
||||
where: { id },
|
||||
data: { isActive: false },
|
||||
});
|
||||
}
|
||||
|
||||
async getActiveItemsForScan(subscriptionId: string) {
|
||||
return prisma.watchlistItem.findMany({
|
||||
where: { subscriptionId, isActive: true },
|
||||
});
|
||||
}
|
||||
|
||||
async getItemCount(subscriptionId: string) {
|
||||
return prisma.watchlistItem.count({
|
||||
where: { subscriptionId, isActive: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const watchlistService = new WatchlistService();
|
||||
@@ -74,3 +74,90 @@ export const spamRateLimits = {
|
||||
analysesPerDay: 10000,
|
||||
},
|
||||
};
|
||||
|
||||
// Default confidence scores for spam detection layers
|
||||
export const defaultScores = {
|
||||
// Number reputation service defaults
|
||||
defaultReputationConfidence: 0.0,
|
||||
defaultReputationLowConfidence: 0.1,
|
||||
|
||||
// SMS classifier defaults
|
||||
defaultBaseConfidence: 0.5,
|
||||
defaultMaxConfidence: 1.0,
|
||||
|
||||
// Feature weights for SMS classification
|
||||
featureWeights: {
|
||||
urlPresent: 0.1,
|
||||
highEmojiDensity: 0.15,
|
||||
urgencyKeyword: 0.2,
|
||||
excessiveCaps: 0.15,
|
||||
},
|
||||
|
||||
// Call analysis defaults
|
||||
defaultSpamScore: 0.0,
|
||||
highReputationThreshold: 0.7,
|
||||
reputationWeightInCombinedScore: 0.4,
|
||||
shortDurationScore: 0.2,
|
||||
voipScore: 0.15,
|
||||
unusualHoursScore: 0.1,
|
||||
|
||||
// Source combination weights
|
||||
hiyaWeightInCombinedScore: 0.7,
|
||||
truecallerWeightInCombinedScore: 0.3,
|
||||
};
|
||||
|
||||
// Metadata size limits for SpamFeedback
|
||||
export const metadataLimits = {
|
||||
// Maximum size for metadata JSON in bytes
|
||||
maxMetadataSizeBytes: 4096,
|
||||
|
||||
// Maximum number of keys in metadata object
|
||||
maxMetadataKeys: 20,
|
||||
|
||||
// Maximum size for individual metadata value in bytes
|
||||
maxMetadataValueSizeBytes: 512,
|
||||
};
|
||||
|
||||
// Standard error codes for spamshield API
|
||||
export enum SpamErrorCode {
|
||||
// Client errors (4xx)
|
||||
INVALID_REQUEST = 'INVALID_REQUEST',
|
||||
MISSING_REQUIRED_FIELD = 'MISSING_REQUIRED_FIELD',
|
||||
UNAUTHORIZED = 'UNAUTHORIZED',
|
||||
NOT_FOUND = 'NOT_FOUND',
|
||||
VALIDATION_ERROR = 'VALIDATION_ERROR',
|
||||
|
||||
// Server errors (5xx)
|
||||
CLASSIFICATION_FAILED = 'CLASSIFICATION_FAILED',
|
||||
REPUTATION_CHECK_FAILED = 'REPUTATION_CHECK_FAILED',
|
||||
ANALYSIS_FAILED = 'ANALYSIS_FAILED',
|
||||
FEEDBACK_RECORD_FAILED = 'FEEDBACK_RECORD_FAILED',
|
||||
DATABASE_ERROR = 'DATABASE_ERROR',
|
||||
RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED',
|
||||
SERVICE_UNAVAILABLE = 'SERVICE_UNAVAILABLE',
|
||||
}
|
||||
|
||||
// Standard error response type
|
||||
export interface SpamErrorResponse {
|
||||
error: {
|
||||
code: SpamErrorCode;
|
||||
message: string;
|
||||
field?: string;
|
||||
timestamp: string;
|
||||
requestId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// HTTP status code constants
|
||||
export const HttpStatus = {
|
||||
OK: 200,
|
||||
CREATED: 201,
|
||||
BAD_REQUEST: 400,
|
||||
UNAUTHORIZED: 401,
|
||||
FORBIDDEN: 403,
|
||||
NOT_FOUND: 404,
|
||||
UNPROCESSABLE_ENTITY: 422,
|
||||
TOO_MANY_REQUESTS: 429,
|
||||
INTERNAL_SERVER_ERROR: 500,
|
||||
SERVICE_UNAVAILABLE: 503,
|
||||
};
|
||||
|
||||
118
apps/api/src/services/spamshield/spamshield.error-handler.ts
Normal file
118
apps/api/src/services/spamshield/spamshield.error-handler.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { FastifyReply } from 'fastify';
|
||||
import { SpamErrorCode, HttpStatus, SpamErrorResponse } from './spamshield.config';
|
||||
|
||||
export { SpamErrorCode, HttpStatus };
|
||||
export type { SpamErrorResponse };
|
||||
|
||||
/**
|
||||
* Standardized error response builder for SpamShield API
|
||||
*/
|
||||
export class ErrorHandler {
|
||||
/**
|
||||
* Create a standard error response
|
||||
*/
|
||||
static create(
|
||||
code: SpamErrorCode,
|
||||
message: string,
|
||||
options?: {
|
||||
field?: string;
|
||||
requestId?: string;
|
||||
additionalData?: Record<string, unknown>;
|
||||
}
|
||||
): SpamErrorResponse {
|
||||
return {
|
||||
error: {
|
||||
code,
|
||||
message,
|
||||
...(options?.field && { field: options.field }),
|
||||
timestamp: new Date().toISOString(),
|
||||
...(options?.requestId && { requestId: options.requestId }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a standard error response with appropriate HTTP status code
|
||||
*/
|
||||
static send(
|
||||
reply: FastifyReply,
|
||||
code: SpamErrorCode,
|
||||
message: string,
|
||||
options?: {
|
||||
field?: string;
|
||||
status?: number;
|
||||
requestId?: string;
|
||||
}
|
||||
): void {
|
||||
const status = options?.status ?? this.getStatusForCode(code);
|
||||
const errorResponse = this.create(code, message, {
|
||||
field: options?.field,
|
||||
requestId: options?.requestId,
|
||||
});
|
||||
reply.code(status).send(errorResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map error codes to HTTP status codes
|
||||
*/
|
||||
private static getStatusForCode(code: SpamErrorCode): number {
|
||||
const statusMap: Record<SpamErrorCode, number> = {
|
||||
// Client errors
|
||||
[SpamErrorCode.INVALID_REQUEST]: HttpStatus.BAD_REQUEST,
|
||||
[SpamErrorCode.MISSING_REQUIRED_FIELD]: HttpStatus.BAD_REQUEST,
|
||||
[SpamErrorCode.UNAUTHORIZED]: HttpStatus.UNAUTHORIZED,
|
||||
[SpamErrorCode.NOT_FOUND]: HttpStatus.NOT_FOUND,
|
||||
[SpamErrorCode.VALIDATION_ERROR]: HttpStatus.BAD_REQUEST,
|
||||
|
||||
// Server errors
|
||||
[SpamErrorCode.CLASSIFICATION_FAILED]: HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
[SpamErrorCode.REPUTATION_CHECK_FAILED]: HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
[SpamErrorCode.ANALYSIS_FAILED]: HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
[SpamErrorCode.FEEDBACK_RECORD_FAILED]: HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
[SpamErrorCode.DATABASE_ERROR]: HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
[SpamErrorCode.RATE_LIMIT_EXCEEDED]: HttpStatus.TOO_MANY_REQUESTS,
|
||||
[SpamErrorCode.SERVICE_UNAVAILABLE]: HttpStatus.SERVICE_UNAVAILABLE,
|
||||
};
|
||||
return statusMap[code] ?? HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate required string field
|
||||
*/
|
||||
static validateRequiredField(
|
||||
value: unknown,
|
||||
fieldName: string
|
||||
): { isValid: boolean; error?: { code: SpamErrorCode; message: string; field: string } } {
|
||||
if (!value || typeof value !== 'string' || value.trim() === '') {
|
||||
return {
|
||||
isValid: false,
|
||||
error: {
|
||||
code: SpamErrorCode.MISSING_REQUIRED_FIELD,
|
||||
message: `${fieldName} is required`,
|
||||
field: fieldName,
|
||||
},
|
||||
};
|
||||
}
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate boolean field
|
||||
*/
|
||||
static validateBooleanField(
|
||||
value: unknown,
|
||||
fieldName: string
|
||||
): { isValid: boolean; error?: { code: SpamErrorCode; message: string; field: string } } {
|
||||
if (value === undefined || value === null || typeof value !== 'boolean') {
|
||||
return {
|
||||
isValid: false,
|
||||
error: {
|
||||
code: SpamErrorCode.VALIDATION_ERROR,
|
||||
message: `${fieldName} must be a boolean`,
|
||||
field: fieldName,
|
||||
},
|
||||
};
|
||||
}
|
||||
return { isValid: true };
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { prisma, SpamFeedback } from '@shieldsai/shared-db';
|
||||
import { spamShieldEnv, SpamDecision, spamFeatureFlags } from './spamshield.config';
|
||||
import { spamShieldEnv, SpamDecision, spamFeatureFlags, defaultScores, metadataLimits } from './spamshield.config';
|
||||
import { createHash } from 'crypto';
|
||||
import { spamAuditLogger, hashPhoneNumber } from './spamshield.audit-logger';
|
||||
|
||||
@@ -34,7 +34,7 @@ export class NumberReputationService {
|
||||
// Simulated response for now
|
||||
return {
|
||||
isSpam: false,
|
||||
confidence: 0.1,
|
||||
confidence: defaultScores.defaultReputationLowConfidence,
|
||||
spamType: undefined,
|
||||
reportCount: 0,
|
||||
};
|
||||
@@ -42,7 +42,7 @@ export class NumberReputationService {
|
||||
console.error('Error checking number reputation:', error);
|
||||
return {
|
||||
isSpam: false,
|
||||
confidence: 0.0,
|
||||
confidence: defaultScores.defaultReputationConfidence,
|
||||
reportCount: 0,
|
||||
};
|
||||
}
|
||||
@@ -59,9 +59,9 @@ export class NumberReputationService {
|
||||
// Only enable if feature flag is set
|
||||
if (!spamFeatureFlags.enableMultipleSources) {
|
||||
return {
|
||||
hiya: { isSpam: false, confidence: 0.0 },
|
||||
hiya: { isSpam: false, confidence: defaultScores.defaultReputationConfidence },
|
||||
truecaller: null,
|
||||
combinedScore: 0.0,
|
||||
combinedScore: defaultScores.defaultSpamScore,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -72,13 +72,13 @@ export class NumberReputationService {
|
||||
// TODO: Integrate Truecaller
|
||||
truecallerResult = {
|
||||
isSpam: false,
|
||||
confidence: 0.0,
|
||||
confidence: defaultScores.defaultReputationConfidence,
|
||||
};
|
||||
}
|
||||
|
||||
// Weighted average: Hiya 70%, Truecaller 30%
|
||||
const combinedScore = hiyaResult.confidence * 0.7 +
|
||||
(truecallerResult?.confidence ?? 0) * 0.3;
|
||||
const combinedScore = hiyaResult.confidence * defaultScores.hiyaWeightInCombinedScore +
|
||||
(truecallerResult?.confidence ?? defaultScores.defaultReputationConfidence) * defaultScores.truecallerWeightInCombinedScore;
|
||||
|
||||
return {
|
||||
hiya: { isSpam: hiyaResult.isSpam, confidence: hiyaResult.confidence },
|
||||
@@ -211,15 +211,15 @@ export class SMSClassifierService {
|
||||
}
|
||||
|
||||
private calculateConfidence(features: string[]): number {
|
||||
const baseConfidence = 0.5;
|
||||
const baseConfidence = defaultScores.defaultBaseConfidence;
|
||||
const featureWeights: Record<string, number> = {
|
||||
url_present: 0.1,
|
||||
high_emoji_density: 0.15,
|
||||
urgency_keyword: 0.2,
|
||||
excessive_caps: 0.15,
|
||||
url_present: defaultScores.featureWeights.urlPresent,
|
||||
high_emoji_density: defaultScores.featureWeights.highEmojiDensity,
|
||||
urgency_keyword: defaultScores.featureWeights.urgencyKeyword,
|
||||
excessive_caps: defaultScores.featureWeights.excessiveCaps,
|
||||
};
|
||||
|
||||
return Math.min(1.0, baseConfidence +
|
||||
return Math.min(defaultScores.defaultMaxConfidence, baseConfidence +
|
||||
features.reduce((sum, f) => sum + (featureWeights[f] || 0), 0));
|
||||
}
|
||||
}
|
||||
@@ -240,15 +240,15 @@ export class CallAnalysisService {
|
||||
reasons: string[];
|
||||
}> {
|
||||
const reasons: string[] = [];
|
||||
let spamScore = 0.0;
|
||||
let spamScore = defaultScores.defaultSpamScore;
|
||||
|
||||
// Number reputation check - only if feature flag enabled
|
||||
if (spamFeatureFlags.enableBehavioralAnalysis) {
|
||||
const reputationService = new NumberReputationService();
|
||||
const reputation = await reputationService.checkMultiSource(callData.phoneNumber);
|
||||
|
||||
if (reputation.combinedScore > 0.7) {
|
||||
spamScore += reputation.combinedScore * 0.4;
|
||||
if (reputation.combinedScore > defaultScores.highReputationThreshold) {
|
||||
spamScore += reputation.combinedScore * defaultScores.reputationWeightInCombinedScore;
|
||||
reasons.push('high_spam_reputation');
|
||||
}
|
||||
}
|
||||
@@ -256,19 +256,19 @@ export class CallAnalysisService {
|
||||
// Behavioral analysis - only if feature flag enabled
|
||||
if (spamFeatureFlags.enableBehavioralAnalysis) {
|
||||
if (callData.duration && callData.duration < 10) {
|
||||
spamScore += 0.2;
|
||||
spamScore += defaultScores.shortDurationScore;
|
||||
reasons.push('short_duration');
|
||||
}
|
||||
|
||||
if (callData.isVoip) {
|
||||
spamScore += 0.15;
|
||||
spamScore += defaultScores.voipScore;
|
||||
reasons.push('voip_number');
|
||||
}
|
||||
|
||||
// Time-of-day anomaly (simplified)
|
||||
const hour = callData.callTime.getHours();
|
||||
if (hour < 6 || hour > 22) {
|
||||
spamScore += 0.1;
|
||||
spamScore += defaultScores.unusualHoursScore;
|
||||
reasons.push('unusual_hours');
|
||||
}
|
||||
}
|
||||
@@ -310,6 +310,52 @@ export class CallAnalysisService {
|
||||
|
||||
// User feedback service
|
||||
export class SpamFeedbackService {
|
||||
/**
|
||||
* Validate metadata size against defined limits
|
||||
*/
|
||||
private validateMetadata(metadata?: Record<string, any>): {
|
||||
isValid: boolean;
|
||||
trimmedMetadata?: Record<string, any>;
|
||||
reasons?: string[];
|
||||
} {
|
||||
if (!metadata) {
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
const reasons: string[] = [];
|
||||
let trimmedMetadata: Record<string, any> = metadata;
|
||||
|
||||
// Check number of keys
|
||||
const keyCount = Object.keys(metadata).length;
|
||||
if (keyCount > metadataLimits.maxMetadataKeys) {
|
||||
reasons.push(`Metadata has ${keyCount} keys, exceeding limit of ${metadataLimits.maxMetadataKeys}`);
|
||||
trimmedMetadata = Object.entries(metadata).slice(0, metadataLimits.maxMetadataKeys);
|
||||
}
|
||||
|
||||
// Check total JSON size
|
||||
const jsonSize = JSON.stringify(metadata).length;
|
||||
if (jsonSize > metadataLimits.maxMetadataSizeBytes) {
|
||||
reasons.push(`Metadata size ${jsonSize} bytes exceeds limit of ${metadataLimits.maxMetadataSizeBytes} bytes`);
|
||||
|
||||
// Truncate long values
|
||||
trimmedMetadata = Object.fromEntries(
|
||||
Object.entries(metadata).map(([key, value]) => {
|
||||
const valueStr = String(value);
|
||||
if (valueStr.length > metadataLimits.maxMetadataValueSizeBytes) {
|
||||
return [key, valueStr.slice(0, metadataLimits.maxMetadataValueSizeBytes)];
|
||||
}
|
||||
return [key, value];
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: reasons.length === 0,
|
||||
trimmedMetadata,
|
||||
reasons: reasons.length > 0 ? reasons : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Record user feedback on spam detection
|
||||
*/
|
||||
@@ -320,6 +366,10 @@ export class SpamFeedbackService {
|
||||
confidence?: number,
|
||||
metadata?: Record<string, any>
|
||||
): Promise<SpamFeedback> {
|
||||
// Validate metadata
|
||||
const validation = this.validateMetadata(metadata);
|
||||
const validatedMetadata = validation.trimmedMetadata;
|
||||
|
||||
// Only enable if feature flag is set
|
||||
if (!spamFeatureFlags.enableCommunityIntelligence) {
|
||||
// Return a mock feedback for development
|
||||
@@ -331,7 +381,7 @@ export class SpamFeedbackService {
|
||||
isSpam,
|
||||
confidence,
|
||||
feedbackType: 'user_confirmation' as const,
|
||||
metadata,
|
||||
metadata: validatedMetadata,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
@@ -347,7 +397,7 @@ export class SpamFeedbackService {
|
||||
isSpam,
|
||||
confidence,
|
||||
feedbackType: 'user_confirmation',
|
||||
metadata,
|
||||
metadata: validatedMetadata,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
12
apps/api/tsconfig.json
Normal file
12
apps/api/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user