Add tier-based scan scheduler and webhook triggers (FRE-4498)

- ScanScheduler: tier-based scheduling (BASIC=24h, PLUS=6h, PREMIUM=1h)
- WebhookHandler: HMAC-verified webhook ingestion with SCAN_TRIGGER support
- API routes: /scheduler and /webhooks endpoints under /api/v1/darkwatch
- Jobs: scheduled scan checker + webhook retry processor via BullMQ
- Schema: ScanSchedule, WebhookEvent models; ScanJob.scheduledBy field
- Types: ScheduleStatus, WebhookEventType, WebhookTriggerInput
- Tests: scheduler lifecycle + webhook signature/processing tests

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-04-30 10:57:56 -04:00
parent 76d431e1ec
commit 9fb5379b7a
43 changed files with 7819 additions and 93 deletions

47
packages/api/Dockerfile Normal file
View File

@@ -0,0 +1,47 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json turbo.json ./
COPY packages/api/package.json ./packages/api/
COPY packages/db/package.json ./packages/db/
COPY packages/types/package.json ./packages/types/
COPY packages/core/package.json ./packages/core/ 2>/dev/null || true
COPY packages/jobs/package.json ./packages/jobs/
COPY packages/shared-notifications/package.json ./packages/shared-notifications/
COPY services/darkwatch/package.json ./services/darkwatch/
COPY services/spamshield/package.json ./services/spamshield/
COPY services/voiceprint/package.json ./services/voiceprint/
RUN npm ci
COPY tsconfig.json ./
COPY packages/api/tsconfig.json ./packages/api/
COPY packages/db/tsconfig.json ./packages/db/
COPY packages/types/tsconfig.json ./packages/types/
COPY packages/api/ ./packages/api/
COPY packages/db/ ./packages/db/
COPY packages/types/ ./packages/types/
RUN npm run build --workspace=@shieldai/types --workspace=@shieldai/db --workspace=@shieldai/api
FROM node:20-alpine AS runner
WORKDIR /app
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 shieldai
COPY --from=builder --chown=shieldai:nodejs /app/packages/api/dist ./dist
COPY --from=builder --chown=shieldai:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=shieldai:nodejs /app/packages/api/package.json ./package.json
COPY --from=builder --chown=shieldai:nodejs /app/packages/db ./packages/db
USER shieldai
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) })"
CMD ["node", "dist/server.js"]

View File

@@ -6,11 +6,15 @@ export function darkwatchRoutes(fastify: FastifyInstance) {
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;
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" });
}

View File

@@ -0,0 +1,63 @@
import { FastifyInstance } from "fastify";
import { ScanScheduler } from "@shieldai/darkwatch";
export function schedulerRoutes(fastify: FastifyInstance) {
const scheduler = new ScanScheduler();
fastify.post(
"/ensure",
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 scheduler.ensureScheduleForUser(userId);
return reply.send(result);
}
);
fastify.get(
"/:userId",
async (request, reply) => {
const userId = (request.params as { userId: string }).userId;
const schedule = await scheduler.getSchedule(userId);
if (!schedule) {
return reply.code(404).send({ error: "Schedule not found" });
}
return reply.send(schedule);
}
);
fastify.post(
"/:userId/pause",
async (request, reply) => {
const userId = (request.params as { userId: string }).userId;
await scheduler.pauseSchedule(userId);
return reply.send({ paused: true });
}
);
fastify.post(
"/:userId/resume",
async (request, reply) => {
const userId = (request.params as { userId: string }).userId;
await scheduler.resumeSchedule(userId);
return reply.send({ resumed: true });
}
);
fastify.get(
"/",
async (request, reply) => {
const limit = parseInt((request.query as { limit?: string }).limit || "100");
const offset = parseInt((request.query as { offset?: string }).offset || "0");
const schedules = await scheduler.listActiveSchedules(limit, offset);
return reply.send(schedules);
}
);
}

View File

@@ -0,0 +1,67 @@
import { FastifyInstance } from "fastify";
import { WebhookHandler } from "@shieldai/darkwatch";
export function webhookRoutes(fastify: FastifyInstance) {
const handler = new WebhookHandler();
fastify.post(
"/",
async (request, reply) => {
const body = request.body as {
eventType: string;
payload: Record<string, unknown>;
source?: string;
};
const signature =
(request.headers["x-webhook-signature"] as string) ||
(request.headers["x-hub-signature-256"] as string) ||
undefined;
try {
const result = await handler.processEvent(
body.eventType,
body.payload,
body.source,
signature
);
return reply.code(200).send({
eventId: result.eventId,
scanTriggered: result.scanTriggered,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes("signature")) {
return reply.code(401).send({ error: message });
}
return reply.code(400).send({ error: message });
}
}
);
fastify.get(
"/history",
async (request, reply) => {
const limit = parseInt((request.query as { limit?: string }).limit || "50");
const offset = parseInt((request.query as { offset?: string }).offset || "0");
const events = await handler.getEventHistory(limit, offset);
return reply.send(events);
}
);
fastify.get(
"/user/:userId",
async (request, reply) => {
const userId = (request.params as { userId: string }).userId;
const limit = parseInt((request.query as { limit?: string }).limit || "50");
const offset = parseInt((request.query as { offset?: string }).offset || "0");
const events = await handler.getUserEvents(userId, limit, offset);
return reply.send(events);
}
);
}