FRE-4471: Scaffold DarkWatch MVP — monorepo, schema, services, API routes, tests
- Turborepo monorepo structure (packages: api, db, types, jobs; services: darkwatch) - Prisma schema: User, WatchListItem, Exposure, Alert, ScanJob models - WatchListService: CRUD with normalization, dedup, tier-based limits - HIBPService: API integration with severity scoring - MatchingEngine: exact-match with content hash dedup - AlertPipeline: dedup window, email notifications - ScanService: orchestrates watch list -> HIBP -> match -> alert flow - BullMQ job workers for scan and alert processing - Fastify API routes: watchlist, exposures, alerts, scan - Docker Compose: PostgreSQL 16 + Redis 7 - 15 unit tests passing - Implementation plan document uploaded
This commit is contained in:
21
packages/api/package.json
Normal file
21
packages/api/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@shieldai/api",
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/server.js",
|
||||
"test": "vitest run",
|
||||
"lint": "eslint src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^10.0.1",
|
||||
"@fastify/helmet": "^13.0.1",
|
||||
"@fastify/rate-limit": "^9.0.0",
|
||||
"@fastify/sensible": "^6.0.1",
|
||||
"@shieldai/db": "0.1.0",
|
||||
"@shieldai/types": "0.1.0",
|
||||
"fastify": "^5.2.0",
|
||||
"@shieldai/darkwatch": "0.1.0"
|
||||
}
|
||||
}
|
||||
30
packages/api/src/routes/alert.routes.ts
Normal file
30
packages/api/src/routes/alert.routes.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { AlertPipeline } from "@shieldai/darkwatch";
|
||||
|
||||
export function alertRoutes(fastify: FastifyInstance) {
|
||||
const pipeline = new AlertPipeline();
|
||||
|
||||
fastify.get("/", 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.limit as string) || 50;
|
||||
const offset = parseInt(request.query.offset as string) || 0;
|
||||
const alerts = await pipeline.getUserAlerts(userId, limit, offset);
|
||||
return reply.send(alerts);
|
||||
});
|
||||
|
||||
fastify.patch("/:id/read", async (request, reply) => {
|
||||
const userId = (request.user as { id: string })?.id;
|
||||
|
||||
if (!userId) {
|
||||
return reply.code(401).send({ error: "User not authenticated" });
|
||||
}
|
||||
|
||||
await pipeline.markRead(request.params.id, userId);
|
||||
return reply.send({ read: true });
|
||||
});
|
||||
}
|
||||
27
packages/api/src/routes/exposure.routes.ts
Normal file
27
packages/api/src/routes/exposure.routes.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { MatchingEngine } from "@shieldai/darkwatch";
|
||||
|
||||
export function exposureRoutes(fastify: FastifyInstance) {
|
||||
const engine = new MatchingEngine();
|
||||
|
||||
fastify.get("/", async (request, reply) => {
|
||||
const userId = (request.user as { id: string })?.id;
|
||||
|
||||
if (!userId) {
|
||||
return reply.code(401).send({ error: "User not authenticated" });
|
||||
}
|
||||
|
||||
const exposures = await engine.getExposuresForUser(userId);
|
||||
return reply.send(exposures);
|
||||
});
|
||||
|
||||
fastify.get("/:id", async (request, reply) => {
|
||||
const exposure = await engine.getExposureById(request.params.id);
|
||||
|
||||
if (!exposure) {
|
||||
return reply.code(404).send({ error: "Exposure not found" });
|
||||
}
|
||||
|
||||
return reply.send(exposure);
|
||||
});
|
||||
}
|
||||
15
packages/api/src/routes/index.ts
Normal file
15
packages/api/src/routes/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
|
||||
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;
|
||||
|
||||
root.register(watchlist, { prefix: "/watchlist" });
|
||||
root.register(exposures, { prefix: "/exposures" });
|
||||
root.register(alerts, { prefix: "/alerts" });
|
||||
root.register(scans, { prefix: "/scan" });
|
||||
}, { prefix: "/api/v1/darkwatch" });
|
||||
}
|
||||
32
packages/api/src/routes/scan.routes.ts
Normal file
32
packages/api/src/routes/scan.routes.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { ScanService } from "@shieldai/darkwatch";
|
||||
import { DataSource } from "@shieldai/types";
|
||||
|
||||
export function scanRoutes(fastify: FastifyInstance) {
|
||||
const scanService = new ScanService();
|
||||
|
||||
fastify.post("/", 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 { source?: string };
|
||||
const source = body.source ? (body.source as DataSource) : undefined;
|
||||
const resultCount = await scanService.runScan(userId, source);
|
||||
|
||||
return reply.code(200).send({ scanned: true, resultCount });
|
||||
});
|
||||
|
||||
fastify.get("/history", async (request, reply) => {
|
||||
const userId = (request.user as { id: string })?.id;
|
||||
|
||||
if (!userId) {
|
||||
return reply.code(401).send({ error: "User not authenticated" });
|
||||
}
|
||||
|
||||
const history = await scanService.getScanHistory(userId);
|
||||
return reply.send(history);
|
||||
});
|
||||
}
|
||||
41
packages/api/src/routes/watchlist.routes.ts
Normal file
41
packages/api/src/routes/watchlist.routes.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { WatchListService } from "@shieldai/darkwatch";
|
||||
import { IdentifierType } from "@shieldai/types";
|
||||
|
||||
export function watchlistRoutes(fastify: FastifyInstance) {
|
||||
const service = new WatchListService();
|
||||
|
||||
fastify.post("/", async (request, reply) => {
|
||||
const body = request.body as { identifierType: string; identifierValue: string };
|
||||
const userId = (request.user as { id: string })?.id;
|
||||
|
||||
if (!userId) {
|
||||
return reply.code(401).send({ error: "User not authenticated" });
|
||||
}
|
||||
|
||||
const item = await service.addItem(userId, body.identifierType as IdentifierType, body.identifierValue);
|
||||
return reply.code(201).send(item);
|
||||
});
|
||||
|
||||
fastify.get("/", async (request, reply) => {
|
||||
const userId = (request.user as { id: string })?.id;
|
||||
|
||||
if (!userId) {
|
||||
return reply.code(401).send({ error: "User not authenticated" });
|
||||
}
|
||||
|
||||
const items = await service.listItems(userId);
|
||||
return reply.send(items);
|
||||
});
|
||||
|
||||
fastify.delete("/:id", async (request, reply) => {
|
||||
const userId = (request.user as { id: string })?.id;
|
||||
|
||||
if (!userId) {
|
||||
return reply.code(401).send({ error: "User not authenticated" });
|
||||
}
|
||||
|
||||
const result = await service.removeItem(userId, request.params.id);
|
||||
return reply.send({ count: result.count });
|
||||
});
|
||||
}
|
||||
31
packages/api/src/server.ts
Normal file
31
packages/api/src/server.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import Fastify from "fastify";
|
||||
import cors from "@fastify/cors";
|
||||
import helmet from "@fastify/helmet";
|
||||
import sensible from "@fastify/sensible";
|
||||
import { darkwatchRoutes } from "./routes";
|
||||
|
||||
const app = Fastify({
|
||||
logger: {
|
||||
level: process.env.LOG_LEVEL || "info",
|
||||
},
|
||||
});
|
||||
|
||||
async function bootstrap() {
|
||||
await app.register(cors, { origin: true });
|
||||
await app.register(helmet);
|
||||
await app.register(sensible);
|
||||
|
||||
await app.register(darkwatchRoutes);
|
||||
|
||||
app.get("/health", async () => ({ status: "ok", timestamp: new Date().toISOString() }));
|
||||
|
||||
try {
|
||||
await app.listen({ port: parseInt(process.env.PORT || "3000", 10), host: "0.0.0.0" });
|
||||
app.log.info(`Server listening on port ${process.env.PORT || 3000}`);
|
||||
} catch (err) {
|
||||
app.log.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
8
packages/api/tsconfig.json
Normal file
8
packages/api/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user