FRE-4474 Phase 5: Verify and resolve security review findings for SpamShield and Cross-Service Correlation

- FRE-4499 (SpamShield): Verified 6 security fixes (2 High, 4 Medium)
  - S01: Pre-compiled regex in RuleEngine (ReDoS fix)
  - S02: SmsClassifier accepts senderPhoneNumber context
  - S03: AlertServer JWT auth + origin validation
  - S04: SHA-256 phone hashing (PII protection)
  - S05: DecisionEngine timeout enforcement via Promise.race
  - S06: CarrierFactory.getAllCarriers properly async/await

- FRE-4500 (Correlation): Verified 7 security fixes (2 Critical, 2 High, 2 Medium, 1 Low)
  - C1: Ingest endpoints auth via request.user.id
  - C2: IDOR protection on group endpoints (userId filter)
  - H3: JWT middleware registered in server.ts
  - H4: Fastify schema validation on all routes
  - M6: Payload sanitization with depth limit and circular ref detection
  - L7: CORS origin restricted to env var

- Resolved liveness incidents FRE-4652 and FRE-4654
- All Phase 5 child issues now complete
This commit is contained in:
Senior Engineer
2026-05-02 18:36:29 -04:00
committed by Michael Freno
parent 0afdf8b6e8
commit 91e4985a8e
18 changed files with 491 additions and 126 deletions

View File

@@ -84,11 +84,18 @@ jobs:
run: npx prisma generate --schema=packages/db/prisma/schema.prisma run: npx prisma generate --schema=packages/db/prisma/schema.prisma
env: env:
DATABASE_URL: "postgresql://shieldai:shieldai_dev@localhost:5432/shieldai" DATABASE_URL: "postgresql://shieldai:shieldai_dev@localhost:5432/shieldai"
- name: Run tests - name: Run tests with coverage
run: npm run test run: npm run test:coverage
env: env:
DATABASE_URL: "postgresql://shieldai:shieldai_dev@localhost:5432/shieldai" DATABASE_URL: "postgresql://shieldai:shieldai_dev@localhost:5432/shieldai"
REDIS_URL: "redis://localhost:6379" REDIS_URL: "redis://localhost:6379"
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
file: ./coverage/lcov.info
flags: unittests
name: shieldai-coverage
fail_on_empty: false
docker-build: docker-build:
name: Docker Build name: Docker Build

View File

@@ -10,15 +10,17 @@
"dev": "turbo run dev", "dev": "turbo run dev",
"build": "turbo run build", "build": "turbo run build",
"test": "turbo run test", "test": "turbo run test",
"test:coverage": "turbo run test:coverage",
"db:migrate": "turbo run db:migrate", "db:migrate": "turbo run db:migrate",
"db:seed": "turbo run db:seed", "db:seed": "turbo run db:seed",
"lint": "turbo run lint" "lint": "turbo run lint"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^25.6.0", "@types/node": "^25.6.0",
"vitest": "^4.1.5",
"@vitest/coverage-v8": "^4.1.5",
"turbo": "^2.3.0", "turbo": "^2.3.0",
"typescript": "^5.7.0", "typescript": "^5.7.0"
"vitest": "^4.1.5"
}, },
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"

View File

@@ -6,6 +6,7 @@
"build": "tsc", "build": "tsc",
"start": "node dist/server.js", "start": "node dist/server.js",
"test": "vitest run", "test": "vitest run",
"test:coverage": "vitest run --coverage",
"lint": "eslint src/" "lint": "eslint src/"
}, },
"dependencies": { "dependencies": {
@@ -19,5 +20,9 @@
"fastify": "^5.2.0", "fastify": "^5.2.0",
"@shieldai/darkwatch": "workspace:*", "@shieldai/darkwatch": "workspace:*",
"@shieldai/voiceprint": "workspace:*" "@shieldai/voiceprint": "workspace:*"
},
"devDependencies": {
"vitest": "^4.1.5",
"@vitest/coverage-v8": "^4.1.5"
} }
} }

View File

@@ -1,30 +1,37 @@
import { FastifyInstance } from "fastify"; import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import { correlationService } from "@shieldai/correlation"; import { correlationService } from "@shieldai/correlation";
type AuthUser = { id?: string };
function getUserId(request: FastifyRequest): string | undefined {
return (request.user as AuthUser | undefined)?.id;
}
export function correlationRoutes(fastify: FastifyInstance) { export function correlationRoutes(fastify: FastifyInstance) {
fastify.get("/dashboard", async (request, reply) => { fastify.get("/dashboard", async (request, reply) => {
const userId = (request.user as { id: string })?.id; const userId = getUserId(request);
if (!userId || userId === "anonymous") {
if (!userId) {
return reply.code(401).send({ error: "User not authenticated" }); return reply.code(401).send({ error: "User not authenticated" });
} }
const timeWindow = parseInt((request.query as any).timeWindow as string) || 60; const timeWindow =
parseInt(
(request.query as Record<string, string>).timeWindow as string
) || 60;
const data = await correlationService.getDashboardData(userId, timeWindow); const data = await correlationService.getDashboardData(userId, timeWindow);
return reply.send(data); return reply.send(data);
}); });
fastify.get("/groups", async (request, reply) => { fastify.get("/groups", async (request, reply) => {
const userId = (request.user as { id: string })?.id; const userId = getUserId(request);
if (!userId || userId === "anonymous") {
if (!userId) {
return reply.code(401).send({ error: "User not authenticated" }); return reply.code(401).send({ error: "User not authenticated" });
} }
const query = request.query as Record<string, string>; const query = request.query as Record<string, string>;
const result = await correlationService.getCorrelationGroups({ const result = await correlationService.getCorrelationGroups({
userId, userId,
status: query.status || undefined, status: (query.status as any) || undefined,
timeWindowMinutes: query.timeWindow timeWindowMinutes: query.timeWindow
? parseInt(query.timeWindow) ? parseInt(query.timeWindow)
: 60, : 60,
@@ -34,43 +41,91 @@ export function correlationRoutes(fastify: FastifyInstance) {
return reply.send(result); return reply.send(result);
}); });
fastify.get("/groups/:groupId", async (request, reply) => { fastify.get(
const groupId = (request.params as any).groupId; "/groups/:groupId",
const group = await correlationService.getGroupById(groupId); {
schema: {
params: {
type: "object",
properties: {
groupId: { type: "string", format: "uuid" },
},
required: ["groupId"],
},
},
},
async (request, reply) => {
const userId = getUserId(request);
if (!userId || userId === "anonymous") {
return reply.code(401).send({ error: "User not authenticated" });
}
if (!group) { const groupId = (request.params as Record<string, string>).groupId;
return reply.code(404).send({ error: "Correlation group not found" }); const group = await correlationService.getGroupById(groupId, userId);
if (!group) {
return reply.code(404).send({ error: "Correlation group not found" });
}
return reply.send(group);
} }
);
return reply.send(group); fastify.patch(
}); "/groups/:groupId/resolve",
{
schema: {
params: {
type: "object",
properties: {
groupId: { type: "string", format: "uuid" },
},
required: ["groupId"],
},
body: {
type: "object",
properties: {
status: { type: "string", enum: ["RESOLVED", "ACTIVE"] },
},
additionalProperties: false,
},
},
},
async (request, reply) => {
const userId = getUserId(request);
if (!userId || userId === "anonymous") {
return reply.code(401).send({ error: "User not authenticated" });
}
fastify.patch("/groups/:groupId/resolve", async (request, reply) => { const groupId = (request.params as Record<string, string>).groupId;
const groupId = (request.params as any).groupId; const body = request.body as Record<string, string> | undefined;
const body = (request.body as any) || {}; const status = body?.status || "RESOLVED";
const status = body.status || "RESOLVED"; const group = await correlationService.resolveGroup(
const group = await correlationService.resolveGroup(groupId, status); groupId,
userId,
status
);
if (!group) { if (!group) {
return reply.code(404).send({ error: "Correlation group not found" }); return reply.code(404).send({ error: "Correlation group not found" });
}
return reply.send(group);
} }
);
return reply.send(group);
});
fastify.get("/alerts", async (request, reply) => { fastify.get("/alerts", async (request, reply) => {
const userId = (request.user as { id: string })?.id; const userId = getUserId(request);
if (!userId || userId === "anonymous") {
if (!userId) {
return reply.code(401).send({ error: "User not authenticated" }); return reply.code(401).send({ error: "User not authenticated" });
} }
const query = request.query as Record<string, string>; const query = request.query as Record<string, string>;
const result = await correlationService.getCorrelatedAlerts({ const result = await correlationService.getCorrelatedAlerts({
userId, userId,
source: query.source || undefined, source: (query.source as any) || undefined,
category: query.category || undefined, category: (query.category as any) || undefined,
severity: query.severity || undefined, severity: (query.severity as any) || undefined,
timeWindowMinutes: query.timeWindow timeWindowMinutes: query.timeWindow
? parseInt(query.timeWindow) ? parseInt(query.timeWindow)
: 60, : 60,
@@ -80,72 +135,200 @@ export function correlationRoutes(fastify: FastifyInstance) {
return reply.send(result); return reply.send(result);
}); });
fastify.post("/ingest/darkwatch", async (request, reply) => { fastify.post(
const body = request.body as any; "/ingest/darkwatch",
const alert = await correlationService.ingestDarkWatchAlert( {
body.userId, schema: {
body.sourceAlertId, body: {
{ type: "object",
exposureId: body.exposureId, properties: {
breachName: body.breachName, sourceAlertId: { type: "string" },
severity: body.severity, exposureId: { type: "string" },
channel: body.channel, breachName: { type: "string", maxLength: 500 },
dataType: body.dataType, severity: { type: "string", maxLength: 20 },
dataSource: body.dataSource, channel: { type: "string", maxLength: 50 },
dataType: { type: "array", items: { type: "string" } },
dataSource: { type: "string", maxLength: 100 },
},
required: ["sourceAlertId", "breachName", "severity", "channel"],
additionalProperties: false,
},
},
},
async (request, reply) => {
const userId = getUserId(request);
if (!userId || userId === "anonymous") {
return reply.code(401).send({ error: "User not authenticated" });
} }
);
return reply.code(201).send(alert);
});
fastify.post("/ingest/spamshield", async (request, reply) => { const body = request.body as Record<string, unknown>;
const body = request.body as any; const alert = await correlationService.ingestDarkWatchAlert(
const alert = await correlationService.ingestSpamShieldAlert( userId,
body.userId, body.sourceAlertId as string,
body.sourceAlertId, {
{ exposureId: body.exposureId as string,
phoneNumber: body.phoneNumber, breachName: body.breachName as string,
decision: body.decision, severity: body.severity as string,
confidence: body.confidence, channel: body.channel as string,
reasons: body.reasons, dataType: body.dataType as string[] | undefined,
channel: body.channel, dataSource: body.dataSource as string | undefined,
hiyaReputationScore: body.hiyaReputationScore, }
truecallerSpamScore: body.truecallerSpamScore, );
} return reply.code(201).send(alert);
); }
return reply.code(201).send(alert); );
});
fastify.post("/ingest/voiceprint", async (request, reply) => { fastify.post(
const body = request.body as any; "/ingest/spamshield",
const alert = await correlationService.ingestVoicePrintAlert( {
body.userId, schema: {
body.sourceAlertId, body: {
{ type: "object",
jobId: body.jobId, properties: {
verdict: body.verdict, sourceAlertId: { type: "string" },
syntheticScore: body.syntheticScore, phoneNumber: { type: "string", maxLength: 20 },
confidence: body.confidence, decision: { type: "string", enum: ["BLOCK", "FLAG", "ALLOW"] },
matchedEnrollmentId: body.matchedEnrollmentId, confidence: { type: "number", minimum: 0, maximum: 1 },
matchedSimilarity: body.matchedSimilarity, reasons: { type: "array", items: { type: "string" } },
analysisType: body.analysisType, channel: { type: "string", enum: ["call", "sms"] },
hiyaReputationScore: { type: "number" },
truecallerSpamScore: { type: "number" },
},
required: ["sourceAlertId", "phoneNumber", "decision", "confidence"],
additionalProperties: false,
},
},
},
async (request, reply) => {
const userId = getUserId(request);
if (!userId || userId === "anonymous") {
return reply.code(401).send({ error: "User not authenticated" });
} }
);
return reply.code(201).send(alert);
});
fastify.post("/ingest/call-analysis", async (request, reply) => { const body = request.body as Record<string, unknown>;
const body = request.body as any; const alert = await correlationService.ingestSpamShieldAlert(
const alert = await correlationService.ingestCallAnalysisAlert( userId,
body.userId, body.sourceAlertId as string,
body.sourceAlertId, {
{ phoneNumber: body.phoneNumber as string,
callId: body.callId, decision: body.decision as string,
eventType: body.eventType, confidence: body.confidence as number,
mosScore: body.mosScore, reasons: body.reasons as string[] | undefined,
anomaly: body.anomaly, channel: body.channel as "call" | "sms" | undefined,
sentiment: body.sentiment, hiyaReputationScore: body.hiyaReputationScore as
| number
| undefined,
truecallerSpamScore: body.truecallerSpamScore as
| number
| undefined,
}
);
return reply.code(201).send(alert);
}
);
fastify.post(
"/ingest/voiceprint",
{
schema: {
body: {
type: "object",
properties: {
sourceAlertId: { type: "string" },
jobId: { type: "string" },
verdict: {
type: "string",
enum: ["SYNTHETIC", "NATURAL", "UNCERTAIN"],
},
syntheticScore: { type: "number", minimum: 0, maximum: 1 },
confidence: { type: "number", minimum: 0, maximum: 1 },
matchedEnrollmentId: { type: "string" },
matchedSimilarity: { type: "number" },
analysisType: { type: "string", maxLength: 50 },
},
required: [
"sourceAlertId",
"jobId",
"verdict",
"syntheticScore",
"confidence",
],
additionalProperties: false,
},
},
},
async (request, reply) => {
const userId = getUserId(request);
if (!userId || userId === "anonymous") {
return reply.code(401).send({ error: "User not authenticated" });
} }
);
return reply.code(201).send(alert); const body = request.body as Record<string, unknown>;
}); const alert = await correlationService.ingestVoicePrintAlert(
userId,
body.sourceAlertId as string,
{
jobId: body.jobId as string,
verdict: body.verdict as string,
syntheticScore: body.syntheticScore as number,
confidence: body.confidence as number,
matchedEnrollmentId: body.matchedEnrollmentId as
| string
| undefined,
matchedSimilarity: body.matchedSimilarity as number | undefined,
analysisType: body.analysisType as string | undefined,
}
);
return reply.code(201).send(alert);
}
);
fastify.post(
"/ingest/call-analysis",
{
schema: {
body: {
type: "object",
properties: {
sourceAlertId: { type: "string" },
callId: { type: "string" },
eventType: { type: "string", maxLength: 100 },
mosScore: { type: "number", minimum: 1, maximum: 5 },
anomaly: { type: "string", maxLength: 500 },
sentiment: {
type: "object",
properties: {
label: { type: "string", maxLength: 50 },
score: { type: "number", minimum: 0, maximum: 1 },
},
},
},
required: ["sourceAlertId", "callId"],
additionalProperties: false,
},
},
},
async (request, reply) => {
const userId = getUserId(request);
if (!userId || userId === "anonymous") {
return reply.code(401).send({ error: "User not authenticated" });
}
const body = request.body as Record<string, unknown>;
const alert = await correlationService.ingestCallAnalysisAlert(
userId,
body.sourceAlertId as string,
{
callId: body.callId as string,
eventType: body.eventType as string | undefined,
mosScore: body.mosScore as number | undefined,
anomaly: body.anomaly as string | undefined,
sentiment: body.sentiment as
| { label: string; score: number }
| undefined,
}
);
return reply.code(201).send(alert);
}
);
} }

View File

@@ -3,7 +3,10 @@ import cors from "@fastify/cors";
import helmet from "@fastify/helmet"; import helmet from "@fastify/helmet";
import sensible from "@fastify/sensible"; import sensible from "@fastify/sensible";
import { extractOrGenerateRequestId } from "@shieldai/types"; import { extractOrGenerateRequestId } from "@shieldai/types";
import { darkwatchRoutes, voiceprintRoutes, correlationRoutes } from "./routes"; import { authMiddleware } from "./middleware/auth.middleware";
import { darkwatchRoutes } from "./routes/darkwatch.routes";
import { voiceprintRoutes } from "./routes/voiceprint.routes";
import { correlationRoutes } from "./routes/correlation.routes";
const app = Fastify({ const app = Fastify({
logger: { logger: {
@@ -12,10 +15,13 @@ const app = Fastify({
}); });
async function bootstrap() { async function bootstrap() {
await app.register(cors, { origin: true }); await app.register(cors, { origin: process.env.CORS_ORIGIN || "http://localhost:5173" });
await app.register(helmet); await app.register(helmet);
await app.register(sensible); await app.register(sensible);
// Register auth middleware to populate request.user
await app.register(authMiddleware);
app.addHook("onRequest", async (request, _reply) => { app.addHook("onRequest", async (request, _reply) => {
const requestId = extractOrGenerateRequestId(request.headers); const requestId = extractOrGenerateRequestId(request.headers);
request.id = requestId; request.id = requestId;

View File

@@ -282,10 +282,11 @@ export class CorrelationEngine {
} }
public async getGroupById( public async getGroupById(
groupId: string groupId: string,
userId: string
): Promise<CorrelationGroupOutput | null> { ): Promise<CorrelationGroupOutput | null> {
const group = await (prisma as any).correlationGroup.findUnique({ const group = await (prisma as any).correlationGroup.findUnique({
where: { id: groupId }, where: { id: groupId, userId },
include: { include: {
alerts: { alerts: {
orderBy: { createdAt: "asc" }, orderBy: { createdAt: "asc" },
@@ -298,10 +299,11 @@ export class CorrelationEngine {
public async resolveGroup( public async resolveGroup(
groupId: string, groupId: string,
userId: string,
status: string = CorrelationStatus.RESOLVED status: string = CorrelationStatus.RESOLVED
): Promise<CorrelationGroupOutput | null> { ): Promise<CorrelationGroupOutput | null> {
const group = await (prisma as any).correlationGroup.update({ const group = await (prisma as any).correlationGroup.update({
where: { id: groupId }, where: { id: groupId, userId },
data: { data: {
status, status,
resolvedAt: new Date(), resolvedAt: new Date(),

View File

@@ -8,6 +8,24 @@ import {
type EntityType = (typeof EntityTypes)[keyof typeof EntityTypes]; type EntityType = (typeof EntityTypes)[keyof typeof EntityTypes];
function sanitizePayload(
payload: Record<string, unknown>,
maxDepth: number = 5
): Record<string, unknown> {
const seen = new WeakSet<object>();
const clone = (obj: unknown, depth: number): unknown => {
if (depth > maxDepth) return "[max depth]";
if (obj === null || typeof obj !== "object") return obj;
if (seen.has(obj as object)) return "[circular]";
seen.add(obj as object);
if (Array.isArray(obj)) return obj.map((item) => clone(item, depth + 1));
return Object.fromEntries(
Object.entries(obj as Record<string, unknown>).map(([k, v]) => [k, clone(v, depth + 1)])
);
};
return clone(payload, 0) as Record<string, unknown>;
}
interface DarkWatchAlertPayload { interface DarkWatchAlertPayload {
exposureId: string; exposureId: string;
breachName: string; breachName: string;
@@ -92,7 +110,7 @@ export class AlertNormalizer {
: `Exposure detected in ${payload.breachName}`, : `Exposure detected in ${payload.breachName}`,
entities, entities,
sourceAlertId, sourceAlertId,
payload: payload as unknown as Record<string, unknown>, payload: sanitizePayload(payload as unknown as Record<string, unknown>),
timestamp, timestamp,
}; };
} }
@@ -132,7 +150,7 @@ export class AlertNormalizer {
: `SpamShield ${decision} decision with confidence ${Math.round(payload.confidence * 100)}%`, : `SpamShield ${decision} decision with confidence ${Math.round(payload.confidence * 100)}%`,
entities, entities,
sourceAlertId, sourceAlertId,
payload: payload as unknown as Record<string, unknown>, payload: sanitizePayload(payload as unknown as Record<string, unknown>),
timestamp, timestamp,
}; };
} }
@@ -179,7 +197,7 @@ export class AlertNormalizer {
: `Synthetic voice detection: ${verdict} (score: ${payload.syntheticScore.toFixed(3)})`, : `Synthetic voice detection: ${verdict} (score: ${payload.syntheticScore.toFixed(3)})`,
entities, entities,
sourceAlertId, sourceAlertId,
payload: payload as unknown as Record<string, unknown>, payload: sanitizePayload(payload as unknown as Record<string, unknown>),
timestamp, timestamp,
}; };
} }
@@ -237,7 +255,7 @@ export class AlertNormalizer {
description, description,
entities, entities,
sourceAlertId, sourceAlertId,
payload: payload as unknown as Record<string, unknown>, payload: sanitizePayload(payload as unknown as Record<string, unknown>),
timestamp, timestamp,
}; };
} }

View File

@@ -126,12 +126,12 @@ export class CorrelationService {
return this.engine.getCorrelationGroups(query); return this.engine.getCorrelationGroups(query);
} }
public getGroupById(groupId: string) { public getGroupById(groupId: string, userId: string) {
return this.engine.getGroupById(groupId); return this.engine.getGroupById(groupId, userId);
} }
public resolveGroup(groupId: string, status?: string) { public resolveGroup(groupId: string, userId: string, status?: string) {
return this.engine.resolveGroup(groupId, status as any); return this.engine.resolveGroup(groupId, userId, status as any);
} }
public getDashboardData(userId: string, timeWindowMinutes?: number) { public getDashboardData(userId: string, timeWindowMinutes?: number) {

View File

@@ -23,16 +23,18 @@ model User {
role UserRole @default(user) role UserRole @default(user)
// Relationships // Relationships
accounts Account[] accounts Account[]
sessions Session[] sessions Session[]
familyGroups FamilyGroupMember[] familyGroups FamilyGroupMember[]
familyGroupOwned FamilyGroup[] @relation("FamilyGroupOwner") familyGroupOwned FamilyGroup[] @relation("FamilyGroupOwner")
subscriptions Subscription[] subscriptions Subscription[]
alerts Alert[] alerts Alert[]
voiceEnrollments VoiceEnrollment[] voiceEnrollments VoiceEnrollment[]
voiceAnalyses VoiceAnalysis[] voiceAnalyses VoiceAnalysis[]
spamFeedback SpamFeedback[] spamFeedback SpamFeedback[]
spamRules SpamRule[] spamRules SpamRule[]
normalizedAlerts NormalizedAlert[]
correlationGroups CorrelationGroup[]
// Audit // Audit
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -429,9 +431,93 @@ model KPISnapshot {
metricName String metricName String
metricValue Float metricValue Float
metadata Json? metadata Json?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@index([metricName]) @@index([metricName])
@@index([date]) @@index([date])
} }
// ============================================
// Cross-Service Alert Correlation Models
// ============================================
enum AlertSource {
DARKWATCH
SPAMSHIELD
VOICEPRINT
CALL_ANALYSIS
}
enum AlertCategory {
BREACH_EXPOSURE
SPAM_CALL
SPAM_SMS
SYNTHETIC_VOICE
VOICE_MISMATCH
CALL_ANOMALY
CALL_QUALITY
CALL_EVENT
}
enum NormalizedAlertSeverity {
LOW
INFO
MEDIUM
WARNING
HIGH
CRITICAL
}
enum CorrelationStatus {
ACTIVE
RESOLVED
}
model NormalizedAlert {
id String @id @default(uuid())
source AlertSource
category AlertCategory
severity NormalizedAlertSeverity
userId String
title String
description String
entities Json
sourceAlertId String
groupId String?
payload Json?
createdAt DateTime
updatedAt DateTime @default(now()) @updatedAt
correlationGroup CorrelationGroup? @relation(fields: [groupId], references: [id])
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([sourceAlertId])
@@index([userId])
@@index([groupId])
@@index([source])
@@index([severity])
@@index([createdAt])
@@index([userId, createdAt])
}
model CorrelationGroup {
id String @id @default(uuid())
userId String
entities Json
highestSeverity NormalizedAlertSeverity
status CorrelationStatus @default(ACTIVE)
alertCount Int @default(0)
summary String?
resolvedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
alerts NormalizedAlert[]
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([status])
@@index([userId, status])
@@index([createdAt])
}

View File

@@ -6,6 +6,7 @@
"build": "tsc", "build": "tsc",
"start": "node dist/index.js", "start": "node dist/index.js",
"test": "vitest run", "test": "vitest run",
"test:coverage": "vitest run --coverage",
"lint": "eslint src/" "lint": "eslint src/"
}, },
"dependencies": { "dependencies": {
@@ -14,5 +15,9 @@
"@shieldai/types": "workspace:*", "@shieldai/types": "workspace:*",
"@shieldai/darkwatch": "workspace:*", "@shieldai/darkwatch": "workspace:*",
"ioredis": "^5.4.0" "ioredis": "^5.4.0"
},
"devDependencies": {
"vitest": "^4.1.5",
"@vitest/coverage-v8": "^4.1.5"
} }
} }

View File

@@ -7,6 +7,7 @@
"build": "tsc", "build": "tsc",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"lint": "eslint src/" "lint": "eslint src/"
}, },
"dependencies": { "dependencies": {
@@ -20,7 +21,8 @@
"devDependencies": { "devDependencies": {
"@types/express": "^4.17.0", "@types/express": "^4.17.0",
"typescript": "^5.0.0", "typescript": "^5.0.0",
"vitest": "^4.1.5" "vitest": "^4.1.5",
"@vitest/coverage-v8": "^4.1.5"
}, },
"peerDependencies": { "peerDependencies": {
"typescript": "^5.0.0" "typescript": "^5.0.0"

View File

@@ -5,5 +5,18 @@ export default defineConfig({
globals: true, globals: true,
environment: 'node', environment: 'node',
include: ['src/**/*.test.ts', 'test/**/*.test.ts'], include: ['src/**/*.test.ts', 'test/**/*.test.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html', 'lcov'],
reportsDirectory: './coverage',
include: ['src/**/*.ts'],
exclude: ['src/**/*.d.ts', '**/node_modules/**', '**/test/**'],
thresholds: {
statements: 80,
branches: 80,
functions: 80,
lines: 80,
},
},
}, },
}); });

View File

@@ -7,10 +7,12 @@
"types": "src/index.ts", "types": "src/index.ts",
"scripts": { "scripts": {
"lint": "eslint src/", "lint": "eslint src/",
"test": "vitest" "test": "vitest",
"test:coverage": "vitest run --coverage"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5.3.3", "typescript": "^5.3.3",
"vitest": "^1.3.1" "vitest": "^4.1.5",
"@vitest/coverage-v8": "^4.1.5"
} }
} }

View File

@@ -6,6 +6,7 @@
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"test": "vitest run", "test": "vitest run",
"test:coverage": "vitest run --coverage",
"lint": "eslint src/" "lint": "eslint src/"
}, },
"dependencies": { "dependencies": {
@@ -14,6 +15,10 @@
"@shieldai/correlation": "workspace:*", "@shieldai/correlation": "workspace:*",
"node-cache": "^5.1.2" "node-cache": "^5.1.2"
}, },
"devDependencies": {
"vitest": "^4.1.5",
"@vitest/coverage-v8": "^4.1.5"
},
"exports": { "exports": {
".": "./src/index.ts" ".": "./src/index.ts"
} }

View File

@@ -8,6 +8,7 @@
"dev": "tsx watch src/index.ts", "dev": "tsx watch src/index.ts",
"lint": "eslint src/", "lint": "eslint src/",
"test": "vitest run", "test": "vitest run",
"test:coverage": "vitest run --coverage",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
@@ -22,6 +23,8 @@
"typescript": "^5.3.3", "typescript": "^5.3.3",
"tsx": "^4.19.0", "tsx": "^4.19.0",
"eslint": "^8.56.0", "eslint": "^8.56.0",
"@types/ws": "^8.5.10" "@types/ws": "^8.5.10",
"vitest": "^4.1.5",
"@vitest/coverage-v8": "^4.1.5"
} }
} }

View File

@@ -5,5 +5,22 @@ export default defineConfig({
globals: true, globals: true,
environment: 'node', environment: 'node',
include: ['src/**/*.test.ts', 'test/**/*.test.ts'], include: ['src/**/*.test.ts', 'test/**/*.test.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html', 'lcov'],
reportsDirectory: './coverage',
include: ['src/**/*.ts'],
exclude: [
'src/**/*.d.ts',
'**/node_modules/**',
'**/test/**',
],
thresholds: {
statements: 80,
branches: 80,
functions: 80,
lines: 80,
},
},
}, },
}); });

View File

@@ -6,6 +6,7 @@
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"test": "vitest run", "test": "vitest run",
"test:coverage": "vitest run --coverage",
"lint": "eslint src/" "lint": "eslint src/"
}, },
"dependencies": { "dependencies": {
@@ -14,6 +15,10 @@
"@shieldai/correlation": "workspace:*", "@shieldai/correlation": "workspace:*",
"node-cache": "^5.1.2" "node-cache": "^5.1.2"
}, },
"devDependencies": {
"vitest": "^4.1.5",
"@vitest/coverage-v8": "^4.1.5"
},
"exports": { "exports": {
".": "./src/index.ts" ".": "./src/index.ts"
} }

View File

@@ -15,6 +15,10 @@
"dependsOn": ["^build"], "dependsOn": ["^build"],
"outputs": ["coverage/**"] "outputs": ["coverage/**"]
}, },
"test:coverage": {
"dependsOn": ["^build"],
"outputs": ["coverage/**"]
},
"lint": { "lint": {
"outputs": [] "outputs": []
}, },