Compare commits
4 Commits
509259bcf2
...
19c5a951fe
| Author | SHA1 | Date | |
|---|---|---|---|
| 19c5a951fe | |||
| 9fb5379b7a | |||
|
|
76d431e1ec | ||
| 3ad030a412 |
13
.env.prod.example
Normal file
13
.env.prod.example
Normal file
@@ -0,0 +1,13 @@
|
||||
# Database
|
||||
POSTGRES_PASSWORD=change_me_in_production
|
||||
|
||||
# API Keys
|
||||
HIBP_API_KEY=""
|
||||
RESEND_API_KEY=""
|
||||
|
||||
# Docker (for deployment)
|
||||
DOCKER_TAG=latest
|
||||
GITHUB_REPOSITORY_OWNER=shieldai
|
||||
|
||||
# Server
|
||||
PORT=3000
|
||||
122
.github/workflows/ci.yml
vendored
Normal file
122
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,122 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
NODE_VERSION: "20"
|
||||
PNPM_VERSION: "9"
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Node.js ${{ env.NODE_VERSION }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: "npm"
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Run linter
|
||||
run: npm run lint
|
||||
|
||||
typecheck:
|
||||
name: Type Check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Node.js ${{ env.NODE_VERSION }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: "npm"
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Build all packages
|
||||
run: npm run build
|
||||
|
||||
test:
|
||||
name: Test Suite
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_DB: shieldai
|
||||
POSTGRES_USER: shieldai
|
||||
POSTGRES_PASSWORD: shieldai_dev
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U shieldai"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Node.js ${{ env.NODE_VERSION }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: "npm"
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Generate Prisma client
|
||||
run: npx prisma generate --schema=packages/db/prisma/schema.prisma
|
||||
env:
|
||||
DATABASE_URL: "postgresql://shieldai:shieldai_dev@localhost:5432/shieldai"
|
||||
- name: Run tests
|
||||
run: npm run test
|
||||
env:
|
||||
DATABASE_URL: "postgresql://shieldai:shieldai_dev@localhost:5432/shieldai"
|
||||
REDIS_URL: "redis://localhost:6379"
|
||||
|
||||
docker-build:
|
||||
name: Docker Build
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint, typecheck]
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- name: api
|
||||
context: .
|
||||
dockerfile: packages/api/Dockerfile
|
||||
- name: darkwatch
|
||||
context: .
|
||||
dockerfile: services/darkwatch/Dockerfile
|
||||
- name: spamshield
|
||||
context: .
|
||||
dockerfile: services/spamshield/Dockerfile
|
||||
- name: voiceprint
|
||||
context: .
|
||||
dockerfile: services/voiceprint/Dockerfile
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ${{ matrix.context }}
|
||||
file: ${{ matrix.dockerfile }}
|
||||
push: false
|
||||
tags: shieldai-${{ matrix.name }}:${{ github.sha }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
101
.github/workflows/deploy.yml
vendored
Normal file
101
.github/workflows/deploy.yml
vendored
Normal file
@@ -0,0 +1,101 @@
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
concurrency:
|
||||
group: deploy-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
NODE_VERSION: "20"
|
||||
|
||||
jobs:
|
||||
detect-environment:
|
||||
name: Detect Environment
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
environment: ${{ steps.detect.outputs.environment }}
|
||||
steps:
|
||||
- name: Detect deployment target
|
||||
id: detect
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "release" ]; then
|
||||
echo "environment=production" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "environment=staging" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
build-and-push:
|
||||
name: Build and Push Docker Images
|
||||
runs-on: ubuntu-latest
|
||||
needs: detect-environment
|
||||
environment: ${{ needs.detect-environment.outputs.environment }}
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- name: api
|
||||
dockerfile: packages/api/Dockerfile
|
||||
- name: darkwatch
|
||||
dockerfile: services/darkwatch/Dockerfile
|
||||
- name: spamshield
|
||||
dockerfile: services/spamshield/Dockerfile
|
||||
- name: voiceprint
|
||||
dockerfile: services/voiceprint/Dockerfile
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Login to Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Calculate image tag
|
||||
id: tag
|
||||
run: |
|
||||
if [ "${{ needs.detect-environment.outputs.environment }}" = "production" ]; then
|
||||
echo "tag=${{ github.event.release.tag_name }}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "tag=staging-${{ github.sha }}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
- name: Build and push ${{ matrix.name }}
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.dockerfile }}
|
||||
push: true
|
||||
tags: ghcr.io/${{ github.repository_owner }}/shieldai-${{ matrix.name }}:${{ steps.tag.outputs.tag }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
deploy:
|
||||
name: Deploy to ${{ needs.detect-environment.outputs.environment }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: [detect-environment, build-and-push]
|
||||
environment: ${{ needs.detect-environment.outputs.environment }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Calculate deployment tag
|
||||
id: tag
|
||||
run: |
|
||||
if [ "${{ needs.detect-environment.outputs.environment }}" = "production" ]; then
|
||||
echo "tag=${{ github.event.release.tag_name }}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "tag=staging-${{ github.sha }}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
- name: Deploy via Docker Compose
|
||||
uses: appleboy/ssh-action@v1
|
||||
with:
|
||||
host: ${{ secrets.DEPLOY_HOST }}
|
||||
username: ${{ secrets.DEPLOY_USER }}
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
script: |
|
||||
cd /opt/shieldai
|
||||
export DOCKER_TAG="${{ steps.tag.outputs.tag }}"
|
||||
export ENVIRONMENT="${{ needs.detect-environment.outputs.environment }}"
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
docker image prune -f
|
||||
100
docker-compose.prod.yml
Normal file
100
docker-compose.prod.yml
Normal file
@@ -0,0 +1,100 @@
|
||||
version: '3.9'
|
||||
|
||||
services:
|
||||
api:
|
||||
image: ghcr.io/${GITHUB_REPOSITORY_OWNER}/shieldai-api:${DOCKER_TAG:-latest}
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${PORT:-3000}:3000"
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://shieldai:${POSTGRES_PASSWORD}@postgres:5432/shieldai
|
||||
- REDIS_URL=redis://redis:6379
|
||||
- PORT=3000
|
||||
- LOG_LEVEL=info
|
||||
- HIBP_API_KEY=${HIBP_API_KEY}
|
||||
- RESEND_API_KEY=${RESEND_API_KEY}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- shieldai
|
||||
|
||||
darkwatch:
|
||||
image: ghcr.io/${GITHUB_REPOSITORY_OWNER}/shieldai-darkwatch:${DOCKER_TAG:-latest}
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://shieldai:${POSTGRES_PASSWORD}@postgres:5432/shieldai
|
||||
- REDIS_URL=redis://redis:6379
|
||||
- HIBP_API_KEY=${HIBP_API_KEY}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- shieldai
|
||||
|
||||
spamshield:
|
||||
image: ghcr.io/${GITHUB_REPOSITORY_OWNER}/shieldai-spamshield:${DOCKER_TAG:-latest}
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://shieldai:${POSTGRES_PASSWORD}@postgres:5432/shieldai
|
||||
- REDIS_URL=redis://redis:6379
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- shieldai
|
||||
|
||||
voiceprint:
|
||||
image: ghcr.io/${GITHUB_REPOSITORY_OWNER}/shieldai-voiceprint:${DOCKER_TAG:-latest}
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://shieldai:${POSTGRES_PASSWORD}@postgres:5432/shieldai
|
||||
- REDIS_URL=redis://redis:6379
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- shieldai
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: shieldai
|
||||
POSTGRES_USER: shieldai
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U shieldai"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- shieldai
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- shieldai
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
|
||||
networks:
|
||||
shieldai:
|
||||
driver: bridge
|
||||
19
memory/2026-04-29.md
Normal file
19
memory/2026-04-29.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# 2026-04-29
|
||||
|
||||
## Security Review: FRE-4472 (SpamShield MVP)
|
||||
|
||||
### Summary
|
||||
Security review completed for FRE-4472 (SpamShield MVP). Total of **16 findings** identified:
|
||||
- **6 HIGH** priority
|
||||
- **5 MEDIUM** priority
|
||||
- **5 LOW** priority
|
||||
|
||||
### Action Taken
|
||||
Created 16 child issues to track remediation:
|
||||
- **FRE-4503** through **FRE-4518**
|
||||
|
||||
### Current State
|
||||
Parent issue **FRE-4472** is now **blocked** pending resolution of HIGH priority child issues.
|
||||
|
||||
### Next Action
|
||||
Begin remediation with **FRE-4503** (field-level encryption) as the first HIGH priority item.
|
||||
4720
package-lock.json
generated
4720
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
47
packages/api/Dockerfile
Normal file
47
packages/api/Dockerfile
Normal 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"]
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
|
||||
63
packages/api/src/routes/scheduler.routes.ts
Normal file
63
packages/api/src/routes/scheduler.routes.ts
Normal 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);
|
||||
}
|
||||
);
|
||||
}
|
||||
67
packages/api/src/routes/webhook.routes.ts
Normal file
67
packages/api/src/routes/webhook.routes.ts
Normal 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);
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -77,49 +77,55 @@ enum DetectionVerdict {
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
email String @unique
|
||||
name String?
|
||||
subscriptionTier SubscriptionTier @default(BASIC)
|
||||
familyGroupId String?
|
||||
watchListItems WatchListItem[]
|
||||
alerts Alert[]
|
||||
scanJobs ScanJob[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id String @id @default(uuid())
|
||||
email String @unique
|
||||
name String?
|
||||
subscriptionTier SubscriptionTier @default(BASIC)
|
||||
familyGroupId String?
|
||||
watchListItems WatchListItem[]
|
||||
alerts Alert[]
|
||||
scanJobs ScanJob[]
|
||||
scanSchedules ScanSchedule[]
|
||||
voiceEnrollments VoiceEnrollment[]
|
||||
analysisJobs AnalysisJob[]
|
||||
spamFeedback SpamFeedback[]
|
||||
spamCallAnalyses SpamCallAnalysis[]
|
||||
spamAuditLogs SpamAuditLog[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([email])
|
||||
}
|
||||
|
||||
model WatchListItem {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
identifierType IdentifierType
|
||||
identifierValue String
|
||||
identifierHash String @unique
|
||||
status WatchListStatus @default(ACTIVE)
|
||||
exposures Exposure[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
identifierType IdentifierType
|
||||
identifierValue String
|
||||
identifierHash String @unique
|
||||
status WatchListStatus @default(ACTIVE)
|
||||
exposures Exposure[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([userId])
|
||||
@@index([identifierHash])
|
||||
}
|
||||
|
||||
model Exposure {
|
||||
id String @id @default(uuid())
|
||||
watchListItemId String
|
||||
watchListItem WatchListItem @relation(fields: [watchListItemId], references: [id], onDelete: Cascade)
|
||||
dataSource DataSource
|
||||
breachName String
|
||||
exposedAt DateTime
|
||||
dataType String[]
|
||||
severity Severity
|
||||
details String?
|
||||
contentHash String @unique
|
||||
alert Alert?
|
||||
createdAt DateTime @default(now())
|
||||
id String @id @default(uuid())
|
||||
watchListItemId String
|
||||
watchListItem WatchListItem @relation(fields: [watchListItemId], references: [id], onDelete: Cascade)
|
||||
dataSource DataSource
|
||||
breachName String
|
||||
exposedAt DateTime
|
||||
dataType String[]
|
||||
severity Severity
|
||||
details String?
|
||||
contentHash String @unique
|
||||
alert Alert?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([watchListItemId])
|
||||
@@index([contentHash])
|
||||
@@ -127,84 +133,202 @@ model Exposure {
|
||||
}
|
||||
|
||||
model Alert {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
exposureId String @unique
|
||||
exposure Exposure @relation(fields: [exposureId], references: [id], onDelete: Cascade)
|
||||
severity Severity
|
||||
channel AlertChannel
|
||||
status AlertStatus @default(PENDING)
|
||||
dedupKey String
|
||||
sentAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
exposureId String @unique
|
||||
exposure Exposure @relation(fields: [exposureId], references: [id], onDelete: Cascade)
|
||||
severity Severity
|
||||
channel AlertChannel
|
||||
status AlertStatus @default(PENDING)
|
||||
dedupKey String
|
||||
sentAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId, status])
|
||||
@@index([dedupKey])
|
||||
}
|
||||
|
||||
model ScanJob {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
status ScanJobStatus @default(PENDING)
|
||||
source DataSource?
|
||||
resultCount Int @default(0)
|
||||
errorMessage String?
|
||||
completedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
status ScanJobStatus @default(PENDING)
|
||||
source DataSource?
|
||||
resultCount Int @default(0)
|
||||
errorMessage String?
|
||||
scheduledBy String?
|
||||
webhookEvents WebhookEvent[]
|
||||
completedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId, status])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
model VoiceEnrollment {
|
||||
id String @id @default(uuid())
|
||||
enum ScheduleStatus {
|
||||
ACTIVE
|
||||
PAUSED
|
||||
}
|
||||
|
||||
model ScanSchedule {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
intervalMinutes Int // minutes between scans
|
||||
cronExpression String // cron expression for scheduling
|
||||
status ScheduleStatus @default(ACTIVE)
|
||||
lastScanAt DateTime?
|
||||
nextScanAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([userId])
|
||||
@@index([status])
|
||||
}
|
||||
|
||||
enum WebhookEventType {
|
||||
SCAN_TRIGGER
|
||||
BREACH_DETECTED
|
||||
SUBSCRIPTION_CHANGE
|
||||
}
|
||||
|
||||
model WebhookEvent {
|
||||
id String @id @default(uuid())
|
||||
eventType WebhookEventType
|
||||
payload String
|
||||
source String?
|
||||
signature String?
|
||||
processed Boolean @default(false)
|
||||
processedAt DateTime?
|
||||
scanJobId String?
|
||||
scanJob ScanJob? @relation(fields: [scanJobId], references: [id], onDelete: SetNull)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([eventType, processed])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
model VoiceEnrollment {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
label String
|
||||
embeddingVector Float[]
|
||||
embeddingDim Int @default(192)
|
||||
embeddingDim Int @default(192)
|
||||
audioFilePath String?
|
||||
sampleRate Int @default(16000)
|
||||
sampleRate Int @default(16000)
|
||||
durationSec Float?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([userId])
|
||||
@@index([embeddingDim])
|
||||
}
|
||||
|
||||
model AnalysisJob {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
analysisType AnalysisType
|
||||
audioFilePath String
|
||||
status AnalysisJobStatus @default(PENDING)
|
||||
result AnalysisResult?
|
||||
errorMessage String?
|
||||
completedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
analysisType AnalysisType
|
||||
audioFilePath String
|
||||
status AnalysisJobStatus @default(PENDING)
|
||||
result AnalysisResult?
|
||||
errorMessage String?
|
||||
completedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId, status])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
model AnalysisResult {
|
||||
id String @id @default(uuid())
|
||||
analysisJobId String @unique
|
||||
analysisJob AnalysisJob @relation(fields: [analysisJobId], references: [id], onDelete: Cascade)
|
||||
syntheticScore Float
|
||||
verdict DetectionVerdict
|
||||
matchedEnrollmentId String?
|
||||
matchedSimilarity Float?
|
||||
confidence Float
|
||||
processingTimeMs Int
|
||||
modelVersion String?
|
||||
metadata String?
|
||||
createdAt DateTime @default(now())
|
||||
id String @id @default(uuid())
|
||||
analysisJobId String @unique
|
||||
analysisJob AnalysisJob @relation(fields: [analysisJobId], references: [id], onDelete: Cascade)
|
||||
syntheticScore Float
|
||||
verdict DetectionVerdict
|
||||
matchedEnrollmentId String?
|
||||
matchedSimilarity Float?
|
||||
confidence Float
|
||||
processingTimeMs Int
|
||||
modelVersion String?
|
||||
metadata String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([analysisJobId])
|
||||
@@index([verdict])
|
||||
}
|
||||
|
||||
enum SpamDecision {
|
||||
BLOCK
|
||||
FLAG
|
||||
ALLOW
|
||||
}
|
||||
|
||||
model SpamFeedback {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
phoneNumber String // AES-256 encrypted PII
|
||||
phoneNumberHash String // SHA-256 hash for anonymized lookup
|
||||
isSpam Boolean
|
||||
label String?
|
||||
metadata String? // Unbounded JSON
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId])
|
||||
@@index([phoneNumberHash])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
model SpamCallAnalysis {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
phoneNumber String
|
||||
callTimestamp DateTime
|
||||
hiyaReputationScore Float?
|
||||
truecallerSpamScore Float?
|
||||
decision SpamDecision
|
||||
confidence Float
|
||||
ruleMatches String[] // IDs of matched rules
|
||||
auditLogs SpamAuditLog[]
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId])
|
||||
@@index([phoneNumber])
|
||||
@@index([callTimestamp])
|
||||
}
|
||||
|
||||
model SpamRule {
|
||||
id String @id @default(uuid())
|
||||
name String @unique
|
||||
pattern String // Regex pattern - needs ReDoS validation
|
||||
decision SpamDecision
|
||||
description String?
|
||||
isActive Boolean @default(true)
|
||||
priority Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([isActive])
|
||||
@@index([priority])
|
||||
}
|
||||
|
||||
model SpamAuditLog {
|
||||
id String @id @default(uuid())
|
||||
analysisId String?
|
||||
analysis SpamCallAnalysis? @relation(fields: [analysisId], references: [id], onDelete: SetNull)
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
phoneNumber String
|
||||
decision SpamDecision
|
||||
reason String
|
||||
ruleId String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId])
|
||||
@@index([createdAt])
|
||||
@@index([decision])
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { FieldEncryptionService } from './services/field-encryption.service';
|
||||
|
||||
export const prisma = new PrismaClient();
|
||||
export default prisma;
|
||||
export { FieldEncryptionService };
|
||||
export type { PrismaClient };
|
||||
|
||||
33
packages/db/src/services/field-encryption.service.ts
Normal file
33
packages/db/src/services/field-encryption.service.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
const ENCRYPTION_KEY = process.env.PII_ENCRYPTION_KEY || 'default-32-byte-key-for-aes-256';
|
||||
const IV_LENGTH = 16;
|
||||
|
||||
export class FieldEncryptionService {
|
||||
static encrypt(text: string): string {
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
const key = crypto.createHash('sha256').update(ENCRYPTION_KEY).digest();
|
||||
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
|
||||
|
||||
let encrypted = cipher.update(text, 'utf8', 'base64');
|
||||
encrypted += cipher.final('base64');
|
||||
|
||||
return `${iv.toString('base64')}:${encrypted}`;
|
||||
}
|
||||
|
||||
static decrypt(encryptedText: string): string {
|
||||
const [ivBase64, ciphertext] = encryptedText.split(':');
|
||||
const iv = Buffer.from(ivBase64, 'base64');
|
||||
const key = crypto.createHash('sha256').update(ENCRYPTION_KEY).digest();
|
||||
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
|
||||
|
||||
let decrypted = decipher.update(ciphertext, 'base64', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
static hashPhoneNumber(phoneNumber: string): string {
|
||||
return crypto.createHash('sha256').update(phoneNumber).digest('hex');
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Queue, Worker, QueueScheduler } from "bullmq";
|
||||
import { Queue, Worker } from "bullmq";
|
||||
import { Redis } from "ioredis";
|
||||
import { ScanService } from "@shieldai/darkwatch";
|
||||
import { ScanService, ScanScheduler, WebhookHandler } from "@shieldai/darkwatch";
|
||||
import { AlertPipeline } from "@shieldai/darkwatch";
|
||||
|
||||
const redisUrl = process.env.REDIS_URL || "redis://localhost:6379";
|
||||
@@ -8,6 +8,7 @@ const connection = new Redis(redisUrl);
|
||||
|
||||
const scanQueue = new Queue("darkwatch-scans", { connection });
|
||||
const alertQueue = new Queue("darkwatch-alerts", { connection });
|
||||
const scheduleQueue = new Queue("darkwatch-scheduler", { connection });
|
||||
|
||||
const scanWorker = new Worker(
|
||||
"darkwatch-scans",
|
||||
@@ -30,22 +31,77 @@ const alertWorker = new Worker(
|
||||
{ connection, concurrency: 1 }
|
||||
);
|
||||
|
||||
const scheduler = new QueueScheduler("darkwatch-alerts", { connection });
|
||||
const scheduleWorker = new Worker(
|
||||
"darkwatch-scheduler",
|
||||
async () => {
|
||||
const scheduler = new ScanScheduler();
|
||||
const dueSchedules = await scheduler.getDueSchedules();
|
||||
const results: Array<{ userId: string; queued: boolean }> = [];
|
||||
|
||||
for (const schedule of dueSchedules) {
|
||||
try {
|
||||
await scanQueue.add("scheduled-scan", {
|
||||
userId: schedule.userId,
|
||||
source: undefined,
|
||||
}, {
|
||||
attempts: 3,
|
||||
backoff: { type: "exponential", delay: 5000 },
|
||||
jobId: `scheduled-scan-${schedule.userId}-${Date.now()}`,
|
||||
});
|
||||
|
||||
await scheduler.markScanned(schedule.userId);
|
||||
results.push({ userId: schedule.userId, queued: true });
|
||||
} catch (err) {
|
||||
console.error(`[Scheduler] Failed to queue scan for ${schedule.userId}:`, err);
|
||||
results.push({ userId: schedule.userId, queued: false });
|
||||
}
|
||||
}
|
||||
|
||||
return { processed: results.length, completedAt: new Date().toISOString() };
|
||||
},
|
||||
{ connection, concurrency: 1 }
|
||||
);
|
||||
|
||||
const webhookWorker = new Worker(
|
||||
"darkwatch-webhooks",
|
||||
async () => {
|
||||
const handler = new WebhookHandler();
|
||||
const processed = await handler.processPendingEvents();
|
||||
return { processed, completedAt: new Date().toISOString() };
|
||||
},
|
||||
{ connection, concurrency: 1 }
|
||||
);
|
||||
|
||||
scanWorker.on("completed", (job) => {
|
||||
console.log(`[Scan] Job ${job.id} completed: ${JSON.stringify(job.returnvalue)}`);
|
||||
console.log(`[Scan] Job ${job?.id} completed: ${JSON.stringify(job?.returnvalue)}`);
|
||||
});
|
||||
|
||||
scanWorker.on("failed", (job, err) => {
|
||||
console.error(`[Scan] Job ${job.id} failed: ${err.message}`);
|
||||
console.error(`[Scan] Job ${job?.id} failed: ${err.message}`);
|
||||
});
|
||||
|
||||
alertWorker.on("completed", (job) => {
|
||||
console.log(`[Alert] Job ${job.id} completed: ${JSON.stringify(job.returnvalue)}`);
|
||||
console.log(`[Alert] Job ${job?.id} completed: ${JSON.stringify(job?.returnvalue)}`);
|
||||
});
|
||||
|
||||
alertWorker.on("failed", (job, err) => {
|
||||
console.error(`[Alert] Job ${job.id} failed: ${err.message}`);
|
||||
console.error(`[Alert] Job ${job?.id} failed: ${err.message}`);
|
||||
});
|
||||
|
||||
scheduleWorker.on("completed", (job) => {
|
||||
console.log(`[Scheduler] Job ${job?.id} completed: ${JSON.stringify(job?.returnvalue)}`);
|
||||
});
|
||||
|
||||
scheduleWorker.on("failed", (job, err) => {
|
||||
console.error(`[Scheduler] Job ${job?.id} failed: ${err.message}`);
|
||||
});
|
||||
|
||||
webhookWorker.on("completed", (job) => {
|
||||
console.log(`[Webhook] Job ${job?.id} completed: ${JSON.stringify(job?.returnvalue)}`);
|
||||
});
|
||||
|
||||
webhookWorker.on("failed", (job, err) => {
|
||||
console.error(`[Webhook] Job ${job?.id} failed: ${err.message}`);
|
||||
});
|
||||
|
||||
export async function addScanJob(userId: string, source?: string) {
|
||||
@@ -63,7 +119,19 @@ export async function scheduleAlertProcessing() {
|
||||
});
|
||||
}
|
||||
|
||||
scanWorker.on("waiting", () => console.log("[Worker] Scan worker ready"));
|
||||
alertWorker.on("waiting", () => console.log("[Worker] Alert worker ready"));
|
||||
export async function schedulePeriodicScanCheck() {
|
||||
return scheduleQueue.add("check-due-scans", {}, {
|
||||
repeat: { pattern: "*/10 * * * *" },
|
||||
jobId: "scheduler-recurring",
|
||||
});
|
||||
}
|
||||
|
||||
export async function scheduleWebhookProcessor() {
|
||||
const webhookQueue = new Queue("darkwatch-webhooks", { connection });
|
||||
return webhookQueue.add("process-pending-webhooks", {}, {
|
||||
repeat: { pattern: "*/2 * * * *" },
|
||||
jobId: "webhook-processor-recurring",
|
||||
});
|
||||
}
|
||||
|
||||
console.log("Job workers started");
|
||||
|
||||
23
packages/shared-billing/package.json
Normal file
23
packages/shared-billing/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@shieldai/shared-billing",
|
||||
"version": "1.0.0",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "jest",
|
||||
"lint": "eslint src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"stripe": "^15.0.0",
|
||||
"zod": "^3.22.0",
|
||||
"express": "^4.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.0",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
||||
94
packages/shared-billing/src/config/billing.config.ts
Normal file
94
packages/shared-billing/src/config/billing.config.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const SubscriptionTier = {
|
||||
FREE: 'free',
|
||||
BASIC: 'basic',
|
||||
PLUS: 'plus',
|
||||
PREMIUM: 'premium',
|
||||
} as const;
|
||||
|
||||
export type SubscriptionTier = typeof SubscriptionTier[keyof typeof SubscriptionTier];
|
||||
|
||||
export const BillingConfigSchema = z.object({
|
||||
stripe: z.object({
|
||||
apiKey: z.string().min(1, 'STRIPE_API_KEY required'),
|
||||
webhookSecret: z.string().min(1, 'STRIPE_WEBHOOK_SECRET required'),
|
||||
pricingTableId: z.string().optional(),
|
||||
}),
|
||||
tiers: z.object({
|
||||
free: z.object({
|
||||
priceId: z.string(),
|
||||
monthlyPriceCents: z.number().default(0),
|
||||
callMinutesLimit: z.number().default(100),
|
||||
smsCountLimit: z.number().default(500),
|
||||
darkWebScans: z.number().default(1),
|
||||
}),
|
||||
basic: z.object({
|
||||
priceId: z.string(),
|
||||
monthlyPriceCents: z.number().default(999),
|
||||
callMinutesLimit: z.number().default(500),
|
||||
smsCountLimit: z.number().default(2000),
|
||||
darkWebScans: z.number().default(12),
|
||||
}),
|
||||
plus: z.object({
|
||||
priceId: z.string(),
|
||||
monthlyPriceCents: z.number().default(1999),
|
||||
callMinutesLimit: z.number().default(2000),
|
||||
smsCountLimit: z.number().default(10000),
|
||||
darkWebScans: z.number().default(12),
|
||||
voiceCloning: z.boolean().default(true),
|
||||
}),
|
||||
premium: z.object({
|
||||
priceId: z.string(),
|
||||
monthlyPriceCents: z.number().default(4999),
|
||||
callMinutesLimit: z.number().default(10000),
|
||||
smsCountLimit: z.number().default(50000),
|
||||
darkWebScans: z.number().default(12),
|
||||
voiceCloning: z.boolean().default(true),
|
||||
homeTitleMonitor: z.boolean().default(true),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export type BillingConfig = z.infer<typeof BillingConfigSchema>;
|
||||
|
||||
export const loadBillingConfig = (): BillingConfig => ({
|
||||
stripe: {
|
||||
apiKey: process.env.STRIPE_API_KEY!,
|
||||
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
|
||||
pricingTableId: process.env.STRIPE_PRICING_TABLE_ID,
|
||||
},
|
||||
tiers: {
|
||||
free: {
|
||||
priceId: process.env.STRIPE_FREE_TIER_PRICE_ID || 'price_free',
|
||||
monthlyPriceCents: 0,
|
||||
callMinutesLimit: 100,
|
||||
smsCountLimit: 500,
|
||||
darkWebScans: 1,
|
||||
},
|
||||
basic: {
|
||||
priceId: process.env.STRIPE_BASIC_TIER_PRICE_ID || 'price_basic',
|
||||
monthlyPriceCents: 999,
|
||||
callMinutesLimit: 500,
|
||||
smsCountLimit: 2000,
|
||||
darkWebScans: 12,
|
||||
},
|
||||
plus: {
|
||||
priceId: process.env.STRIPE_PLUS_TIER_PRICE_ID || 'price_plus',
|
||||
monthlyPriceCents: 1999,
|
||||
callMinutesLimit: 2000,
|
||||
smsCountLimit: 10000,
|
||||
darkWebScans: 12,
|
||||
voiceCloning: true,
|
||||
},
|
||||
premium: {
|
||||
priceId: process.env.STRIPE_PREMIUM_TIER_PRICE_ID || 'price_premium',
|
||||
monthlyPriceCents: 4999,
|
||||
callMinutesLimit: 10000,
|
||||
smsCountLimit: 50000,
|
||||
darkWebScans: 12,
|
||||
voiceCloning: true,
|
||||
homeTitleMonitor: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
11
packages/shared-billing/src/index.ts
Normal file
11
packages/shared-billing/src/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export { BillingService } from './services/billing.service';
|
||||
export { loadBillingConfig, SubscriptionTier } from './config/billing.config';
|
||||
export {
|
||||
requireTier,
|
||||
checkUsageLimit,
|
||||
withUsageTracking,
|
||||
withSubscription,
|
||||
requireSubscription,
|
||||
} from './middleware/billing.middleware';
|
||||
|
||||
export * from './models/subscription.model';
|
||||
166
packages/shared-billing/src/middleware/billing.middleware.ts
Normal file
166
packages/shared-billing/src/middleware/billing.middleware.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { BillingService } from '../services/billing.service';
|
||||
import { SubscriptionTier } from '../config/billing.config';
|
||||
|
||||
const billingService = BillingService.getInstance();
|
||||
|
||||
export interface AuthenticatedRequest extends Request {
|
||||
userId?: string;
|
||||
tier?: SubscriptionTier;
|
||||
usage?: { current: number; limit: number; remaining: number; withinLimit: boolean };
|
||||
subscriptionId?: string;
|
||||
}
|
||||
|
||||
export function requireTier(
|
||||
allowedTiers: SubscriptionTier[]
|
||||
) {
|
||||
return async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<void> => {
|
||||
const userTier = req.tier;
|
||||
|
||||
if (!userTier) {
|
||||
res.status(401).json({ error: 'Authentication required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!allowedTiers.includes(userTier)) {
|
||||
res.status(403).json({
|
||||
error: 'Tier not authorized',
|
||||
required: allowedTiers,
|
||||
current: userTier,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
export function checkUsageLimit(
|
||||
feature: 'callMinutes' | 'smsCount'
|
||||
) {
|
||||
return async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<void> => {
|
||||
const { userId, tier, usage } = req;
|
||||
|
||||
if (!userId || !tier || !usage) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!usage.withinLimit) {
|
||||
res.status(429).json({
|
||||
error: 'Usage limit exceeded',
|
||||
feature,
|
||||
limit: usage.limit,
|
||||
current: usage.current,
|
||||
remaining: usage.remaining,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
export function withUsageTracking(
|
||||
feature: 'callMinutes' | 'smsCount',
|
||||
increment: number = 1
|
||||
) {
|
||||
return async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<void> => {
|
||||
const { userId, tier } = req;
|
||||
|
||||
if (!userId || !tier) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const limits = await billingService.getTierLimits(tier);
|
||||
const limit = feature === 'callMinutes' ? limits.callMinutesLimit : limits.smsCountLimit;
|
||||
|
||||
// Get current usage from request context or database
|
||||
const currentUsage = (req as any).currentUsage || 0;
|
||||
|
||||
req.usage = {
|
||||
current: currentUsage + increment,
|
||||
limit,
|
||||
remaining: Math.max(0, limit - currentUsage - increment),
|
||||
withinLimit: currentUsage + increment <= limit,
|
||||
};
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
error: 'Failed to check usage',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function withSubscription() {
|
||||
return async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<void> => {
|
||||
const { userId, tier } = req;
|
||||
|
||||
if (!userId || !tier) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch subscription from database
|
||||
// TODO: Replace with actual database query
|
||||
const subscriptionId = (req as any).subscriptionId;
|
||||
|
||||
if (subscriptionId) {
|
||||
req.subscriptionId = subscriptionId;
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
error: 'Failed to fetch subscription',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function requireSubscription() {
|
||||
return async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<void> => {
|
||||
const { userId, subscriptionId } = req;
|
||||
|
||||
if (!userId) {
|
||||
res.status(401).json({ error: 'Authentication required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!subscriptionId) {
|
||||
res.status(402).json({
|
||||
error: 'Active subscription required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
35
packages/shared-billing/src/models/subscription.model.ts
Normal file
35
packages/shared-billing/src/models/subscription.model.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { z } from 'zod';
|
||||
import { SubscriptionTier } from '../config/billing.config';
|
||||
|
||||
export const SubscriptionModel = z.object({
|
||||
id: z.string(),
|
||||
userId: z.string(),
|
||||
stripeSubscriptionId: z.string(),
|
||||
stripeCustomerId: z.string(),
|
||||
tier: z.nativeEnum(SubscriptionTier),
|
||||
status: z.enum(['active', 'canceled', 'in_trial', 'past_due', 'unpaid', 'incomplete']),
|
||||
currentPeriodStart: z.date(),
|
||||
currentPeriodEnd: z.date(),
|
||||
cancelAtPeriodEnd: z.boolean().default(false),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
});
|
||||
|
||||
export type Subscription = z.infer<typeof SubscriptionModel>;
|
||||
|
||||
export const SubscriptionCreateSchema = z.object({
|
||||
userId: z.string(),
|
||||
tier: z.nativeEnum(SubscriptionTier),
|
||||
stripeCustomerId: z.string(),
|
||||
stripeSubscriptionId: z.string(),
|
||||
currentPeriodStart: z.date(),
|
||||
currentPeriodEnd: z.date(),
|
||||
});
|
||||
|
||||
export const SubscriptionUpdateSchema = z.object({
|
||||
tier: z.nativeEnum(SubscriptionTier).optional(),
|
||||
status: z.enum(['active', 'canceled', 'in_trial', 'past_due', 'unpaid', 'incomplete']).optional(),
|
||||
cancelAtPeriodEnd: z.boolean().optional(),
|
||||
currentPeriodStart: z.date().optional(),
|
||||
currentPeriodEnd: z.date().optional(),
|
||||
});
|
||||
159
packages/shared-billing/src/services/billing.service.ts
Normal file
159
packages/shared-billing/src/services/billing.service.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import Stripe from 'stripe';
|
||||
import { loadBillingConfig, SubscriptionTier } from '../config/billing.config';
|
||||
import type { Subscription, SubscriptionCreateSchema, SubscriptionUpdateSchema } from '../models/subscription.model';
|
||||
|
||||
const config = loadBillingConfig();
|
||||
const stripe = new Stripe(config.stripe.apiKey, { apiVersion: '2024-04-10' });
|
||||
|
||||
export class BillingService {
|
||||
private static instance: BillingService;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): BillingService {
|
||||
if (!BillingService.instance) {
|
||||
BillingService.instance = new BillingService();
|
||||
}
|
||||
return BillingService.instance;
|
||||
}
|
||||
|
||||
async createCustomer(email: string, userId: string): Promise<Stripe.Customer> {
|
||||
const customer = await stripe.customers.create({
|
||||
email,
|
||||
metadata: { userId },
|
||||
});
|
||||
return customer;
|
||||
}
|
||||
|
||||
async getCustomer(customerId: string): Promise<Stripe.Customer | null> {
|
||||
try {
|
||||
const customer = await stripe.customers.retrieve(customerId);
|
||||
return customer as Stripe.Customer;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async createSubscription(
|
||||
userId: string,
|
||||
tier: SubscriptionTier,
|
||||
customerId: string
|
||||
): Promise<{ subscription: Stripe.Subscription; customer: Stripe.Customer }> {
|
||||
const tierConfig = config.tiers[tier];
|
||||
|
||||
const subscription = await stripe.subscriptions.create({
|
||||
customer: customerId,
|
||||
items: [{ price: tierConfig.priceId }],
|
||||
metadata: { userId, tier },
|
||||
});
|
||||
|
||||
const customer = await this.getCustomer(customerId);
|
||||
|
||||
return { subscription, customer: customer! };
|
||||
}
|
||||
|
||||
async cancelSubscription(
|
||||
subscriptionId: string,
|
||||
cancelAtPeriodEnd: boolean = false
|
||||
): Promise<Stripe.Subscription> {
|
||||
if (cancelAtPeriodEnd) {
|
||||
return await stripe.subscriptions.update(subscriptionId, {
|
||||
cancel_at_period_end: true,
|
||||
});
|
||||
}
|
||||
return await stripe.subscriptions.cancel(subscriptionId);
|
||||
}
|
||||
|
||||
async updateSubscription(
|
||||
subscriptionId: string,
|
||||
newTier: SubscriptionTier
|
||||
): Promise<Stripe.Subscription> {
|
||||
const newTierConfig = config.tiers[newTier];
|
||||
|
||||
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
||||
|
||||
const updated = await stripe.subscriptions.update(subscriptionId, {
|
||||
proration_behavior: 'create_prorations',
|
||||
items: [
|
||||
{
|
||||
id: subscription.items.data[0]?.id,
|
||||
price: newTierConfig.priceId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async createCustomerPortalSession(
|
||||
customerId: string,
|
||||
returnUrl: string
|
||||
): Promise<Stripe.BillingPortal.Session> {
|
||||
return await stripe.billingPortal.sessions.create({
|
||||
customer: customerId,
|
||||
return_url: returnUrl,
|
||||
});
|
||||
}
|
||||
|
||||
async getSubscription(subscriptionId: string): Promise<Stripe.Subscription | null> {
|
||||
try {
|
||||
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
||||
return subscription;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getTierLimits(tier: SubscriptionTier) {
|
||||
return config.tiers[tier];
|
||||
}
|
||||
|
||||
async checkUsageAgainstLimit(
|
||||
userId: string,
|
||||
tier: SubscriptionTier,
|
||||
currentUsage: number
|
||||
): Promise<{ withinLimit: boolean; remaining: number; limit: number }> {
|
||||
const tierConfig = config.tiers[tier];
|
||||
const limit = tierConfig.callMinutesLimit;
|
||||
const remaining = Math.max(0, limit - currentUsage);
|
||||
|
||||
return {
|
||||
withinLimit: currentUsage <= limit,
|
||||
remaining,
|
||||
limit,
|
||||
};
|
||||
}
|
||||
|
||||
async createInvoice(
|
||||
customerId: string,
|
||||
amount: number,
|
||||
description: string,
|
||||
metadata?: Record<string, string>
|
||||
): Promise<Stripe.Invoice> {
|
||||
return await stripe.invoices.create({
|
||||
customer: customerId,
|
||||
line_items: [
|
||||
{
|
||||
amount_data: { currency: 'usd', unit_amount: amount },
|
||||
description: description,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
metadata: metadata,
|
||||
});
|
||||
}
|
||||
|
||||
async handleWebhook(
|
||||
sig: string,
|
||||
body: Buffer
|
||||
): Promise<Stripe.Event> {
|
||||
return stripe.webhooks.constructEvent(body, sig, config.stripe.webhookSecret);
|
||||
}
|
||||
|
||||
async getInvoiceHistory(customerId: string): Promise<Stripe.ApiList<Stripe.Invoice>> {
|
||||
return await stripe.invoices.list({
|
||||
customer: customerId,
|
||||
limit: 100,
|
||||
});
|
||||
}
|
||||
}
|
||||
12
packages/shared-billing/tsconfig.json
Normal file
12
packages/shared-billing/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
25
packages/shared-notifications/package.json
Normal file
25
packages/shared-notifications/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@shieldai/shared-notifications",
|
||||
"version": "1.0.0",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "jest",
|
||||
"lint": "eslint src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"resend": "^3.0.0",
|
||||
"firebase-admin": "^12.0.0",
|
||||
"twilio": "^4.0.0",
|
||||
"zod": "^3.22.0",
|
||||
"express": "^4.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.0",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const NotificationConfigSchema = z.object({
|
||||
resend: z.object({
|
||||
apiKey: z.string().min(1, 'RESEND_API_KEY required'),
|
||||
baseUrl: z.string().default('https://api.resend.com'),
|
||||
}),
|
||||
fcm: z.object({
|
||||
privateKey: z.string().min(1, 'FCM_PRIVATE_KEY required'),
|
||||
projectId: z.string().min(1, 'FCM_PROJECT_ID required'),
|
||||
clientEmail: z.string().email(),
|
||||
}),
|
||||
apns: z.object({
|
||||
key: z.string().min(1, 'APNS_KEY required'),
|
||||
keyId: z.string().min(1, 'APNS_KEY_ID required'),
|
||||
teamId: z.string().min(1, 'APNS_TEAM_ID required'),
|
||||
bundleId: z.string().min(1, 'APNS_BUNDLE_ID required'),
|
||||
}),
|
||||
twilio: z.object({
|
||||
accountSid: z.string().min(1, 'TWILIO_ACCOUNT_SID required'),
|
||||
authToken: z.string().min(1, 'TWILIO_AUTH_TOKEN required'),
|
||||
messagingServiceSid: z.string().min(1, 'TWILIO_MESSAGING_SERVICE_SID required'),
|
||||
}),
|
||||
rateLimits: z.object({
|
||||
emailPerMinute: z.number().default(60),
|
||||
smsPerMinute: z.number().default(30),
|
||||
pushPerMinute: z.number().default(100),
|
||||
}),
|
||||
});
|
||||
|
||||
export type NotificationConfig = z.infer<typeof NotificationConfigSchema>;
|
||||
|
||||
export const loadNotificationConfig = (): NotificationConfig => ({
|
||||
resend: {
|
||||
apiKey: process.env.RESEND_API_KEY!,
|
||||
baseUrl: process.env.RESEND_BASE_URL || 'https://api.resend.com',
|
||||
},
|
||||
fcm: {
|
||||
privateKey: process.env.FCM_PRIVATE_KEY!,
|
||||
projectId: process.env.FCM_PROJECT_ID!,
|
||||
clientEmail: process.env.FCM_CLIENT_EMAIL!,
|
||||
},
|
||||
apns: {
|
||||
key: process.env.APNS_KEY!,
|
||||
keyId: process.env.APNS_KEY_ID!,
|
||||
teamId: process.env.APNS_TEAM_ID!,
|
||||
bundleId: process.env.APNS_BUNDLE_ID!,
|
||||
},
|
||||
twilio: {
|
||||
accountSid: process.env.TWILIO_ACCOUNT_SID!,
|
||||
authToken: process.env.TWILIO_AUTH_TOKEN!,
|
||||
messagingServiceSid: process.env.TWILIO_MESSAGING_SERVICE_SID!,
|
||||
},
|
||||
rateLimits: {
|
||||
emailPerMinute: parseInt(process.env.EMAIL_RATE_LIMIT || '60', 10),
|
||||
smsPerMinute: parseInt(process.env.SMS_RATE_LIMIT || '30', 10),
|
||||
pushPerMinute: parseInt(process.env.PUSH_RATE_LIMIT || '100', 10),
|
||||
},
|
||||
});
|
||||
8
packages/shared-notifications/src/index.ts
Normal file
8
packages/shared-notifications/src/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { EmailService } from './services/email.service';
|
||||
export { SMSService } from './services/sms.service';
|
||||
export { PushService } from './services/push.service';
|
||||
export { NotificationService } from './services/notification.service';
|
||||
export { loadNotificationConfig, NotificationConfigSchema } from './config/notification.config';
|
||||
export { notificationRoutes } from './routes/notification.routes';
|
||||
|
||||
export * from './types/notification.types';
|
||||
159
packages/shared-notifications/src/routes/notification.routes.ts
Normal file
159
packages/shared-notifications/src/routes/notification.routes.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { NotificationService } from '../services/notification.service';
|
||||
import type { EmailNotification, SMSNotification, PushNotification } from '../types/notification.types';
|
||||
|
||||
const router = Router();
|
||||
const notificationService = NotificationService.getInstance();
|
||||
|
||||
export interface SendNotificationRequest {
|
||||
channel: 'email' | 'sms' | 'push';
|
||||
to?: string;
|
||||
userId?: string;
|
||||
subject?: string;
|
||||
body: string;
|
||||
htmlBody?: string;
|
||||
title?: string;
|
||||
data?: Record<string, unknown>;
|
||||
metadata?: Record<string, string>;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
router.post('/send', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { channel, ...payload } = req.body as SendNotificationRequest;
|
||||
|
||||
let notification: EmailNotification | SMSNotification | PushNotification;
|
||||
|
||||
switch (channel) {
|
||||
case 'email':
|
||||
if (!payload.to || !payload.subject || !payload.htmlBody) {
|
||||
res.status(400).json({ error: 'Email requires to, subject, and htmlBody' });
|
||||
return;
|
||||
}
|
||||
notification = {
|
||||
channel: 'email',
|
||||
to: payload.to,
|
||||
subject: payload.subject,
|
||||
htmlBody: payload.htmlBody,
|
||||
textBody: payload.body,
|
||||
metadata: payload.metadata,
|
||||
};
|
||||
break;
|
||||
|
||||
case 'sms':
|
||||
if (!payload.to) {
|
||||
res.status(400).json({ error: 'SMS requires to field' });
|
||||
return;
|
||||
}
|
||||
notification = {
|
||||
channel: 'sms',
|
||||
to: payload.to,
|
||||
body: payload.body,
|
||||
metadata: payload.metadata,
|
||||
};
|
||||
break;
|
||||
|
||||
case 'push':
|
||||
if (!payload.userId || !payload.title) {
|
||||
res.status(400).json({ error: 'Push requires userId and title' });
|
||||
return;
|
||||
}
|
||||
notification = {
|
||||
channel: 'push',
|
||||
userId: payload.userId,
|
||||
title: payload.title,
|
||||
body: payload.body,
|
||||
data: payload.data,
|
||||
};
|
||||
break;
|
||||
|
||||
default:
|
||||
res.status(400).json({ error: `Unknown channel: ${channel}` });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await notificationService.sendWithPreferences(
|
||||
notification,
|
||||
payload.category || 'default'
|
||||
);
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/send/batch', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const notifications = req.body.notifications as SendNotificationRequest[];
|
||||
const results = await Promise.all(
|
||||
notifications.map(n => {
|
||||
const notif = {
|
||||
channel: n.channel,
|
||||
to: n.to,
|
||||
userId: n.userId,
|
||||
subject: n.subject,
|
||||
body: n.body,
|
||||
htmlBody: n.htmlBody,
|
||||
title: n.title,
|
||||
data: n.data,
|
||||
metadata: n.metadata,
|
||||
};
|
||||
return notificationService.sendWithPreferences(
|
||||
notif as EmailNotification | SMSNotification | PushNotification,
|
||||
n.category || 'default'
|
||||
);
|
||||
})
|
||||
);
|
||||
res.json({ results });
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/preferences/:userId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { userId } = req.params;
|
||||
const { channel, enabled, categories } = req.body;
|
||||
|
||||
const preference = await notificationService.setPreference(
|
||||
userId,
|
||||
channel,
|
||||
enabled,
|
||||
categories
|
||||
);
|
||||
|
||||
res.json(preference);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/preferences/:userId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { userId } = req.params;
|
||||
const { channel } = req.query;
|
||||
|
||||
if (channel) {
|
||||
const preference = await notificationService.getPreference(
|
||||
userId,
|
||||
channel as 'email' | 'sms' | 'push'
|
||||
);
|
||||
res.json(preference);
|
||||
} else {
|
||||
res.json({ userId });
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export { router as notificationRoutes };
|
||||
96
packages/shared-notifications/src/services/email.service.ts
Normal file
96
packages/shared-notifications/src/services/email.service.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { Resend } from 'resend';
|
||||
import { loadNotificationConfig } from '../config/notification.config';
|
||||
import type { EmailNotification, NotificationResult } from '../types/notification.types';
|
||||
|
||||
const config = loadNotificationConfig();
|
||||
const resend = new Resend(config.resend.apiKey);
|
||||
|
||||
export class EmailService {
|
||||
private static instance: EmailService;
|
||||
private sentCount = new Map<string, number>();
|
||||
private cleanupInterval: NodeJS.Timeout;
|
||||
|
||||
private constructor() {
|
||||
this.cleanupInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, timestamp] of this.sentCount.entries()) {
|
||||
if (now - timestamp > 60000) {
|
||||
this.sentCount.delete(key);
|
||||
}
|
||||
}
|
||||
}, 60000);
|
||||
}
|
||||
|
||||
static getInstance(): EmailService {
|
||||
if (!EmailService.instance) {
|
||||
EmailService.instance = new EmailService();
|
||||
}
|
||||
return EmailService.instance;
|
||||
}
|
||||
|
||||
async send(notification: EmailNotification): Promise<NotificationResult> {
|
||||
const rateLimitKey = `email:${notification.to}`;
|
||||
const currentCount = this.sentCount.get(rateLimitKey) || 0;
|
||||
|
||||
if (currentCount >= config.rateLimits.emailPerMinute) {
|
||||
throw new Error(`Email rate limit exceeded for ${notification.to}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const { data, error } = await resend.emails.send({
|
||||
from: notification.from || 'ShieldAI <noreply@shieldai.com>',
|
||||
to: [notification.to],
|
||||
subject: notification.subject,
|
||||
html: notification.htmlBody,
|
||||
text: notification.textBody,
|
||||
metadata: notification.metadata,
|
||||
attachments: notification.attachments?.map(att => ({
|
||||
filename: att.filename,
|
||||
data: att.content,
|
||||
contentType: att.mimeType,
|
||||
})),
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
notificationId: `email-${Date.now()}`,
|
||||
channel: 'email',
|
||||
status: 'failed',
|
||||
externalId: data?.id,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
|
||||
this.sentCount.set(rateLimitKey, currentCount + 1);
|
||||
|
||||
return {
|
||||
notificationId: `email-${data?.id || Date.now()}`,
|
||||
channel: 'email',
|
||||
status: 'sent',
|
||||
externalId: data?.id,
|
||||
deliveredAt: new Date(),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
notificationId: `email-${Date.now()}`,
|
||||
channel: 'email',
|
||||
status: 'failed',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async sendBatch(notifications: EmailNotification[]): Promise<NotificationResult[]> {
|
||||
const results = await Promise.all(
|
||||
notifications.map(n => this.send(n))
|
||||
);
|
||||
return results;
|
||||
}
|
||||
|
||||
getRateLimitStatus(): { remaining: number; limit: number } {
|
||||
return {
|
||||
remaining: config.rateLimits.emailPerMinute - this.sentCount.size,
|
||||
limit: config.rateLimits.emailPerMinute,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { EmailService } from './email.service';
|
||||
import { SMSService } from './sms.service';
|
||||
import { PushService } from './push.service';
|
||||
import type {
|
||||
Notification,
|
||||
NotificationResult,
|
||||
NotificationPreference,
|
||||
DeduplicationKey
|
||||
} from '../types/notification.types';
|
||||
|
||||
export class NotificationService {
|
||||
private static instance: NotificationService;
|
||||
private emailService: EmailService;
|
||||
private smsService: SMSService;
|
||||
private pushService: PushService;
|
||||
private pendingDeduplication = new Map<string, Set<string>>();
|
||||
private preferenceCache = new Map<string, NotificationPreference>();
|
||||
|
||||
private constructor() {
|
||||
this.emailService = EmailService.getInstance();
|
||||
this.smsService = SMSService.getInstance();
|
||||
this.pushService = PushService.getInstance();
|
||||
}
|
||||
|
||||
static getInstance(): NotificationService {
|
||||
if (!NotificationService.instance) {
|
||||
NotificationService.instance = new NotificationService();
|
||||
}
|
||||
return NotificationService.instance;
|
||||
}
|
||||
|
||||
async send(notification: Notification): Promise<NotificationResult> {
|
||||
switch (notification.channel) {
|
||||
case 'email':
|
||||
return this.emailService.send(notification);
|
||||
case 'sms':
|
||||
return this.smsService.send(notification);
|
||||
case 'push':
|
||||
return this.pushService.send(notification);
|
||||
default:
|
||||
throw new Error(`Unknown notification channel: ${(notification as any).channel}`);
|
||||
}
|
||||
}
|
||||
|
||||
async sendWithDeduplication(
|
||||
notification: Notification,
|
||||
dedupKey: DeduplicationKey
|
||||
): Promise<NotificationResult> {
|
||||
const dedupId = `${dedupKey.userId}:${dedupKey.templateId}:${dedupKey.key}`;
|
||||
const windowSet = this.pendingDeduplication.get(dedupId);
|
||||
|
||||
if (windowSet && windowSet.size > 0) {
|
||||
return {
|
||||
notificationId: `dedup-${Date.now()}`,
|
||||
channel: notification.channel,
|
||||
status: 'pending',
|
||||
error: 'Duplicate notification within deduplication window',
|
||||
};
|
||||
}
|
||||
|
||||
const result = await this.send(notification);
|
||||
|
||||
if (result.status === 'sent') {
|
||||
if (!windowSet) {
|
||||
this.pendingDeduplication.set(dedupId, new Set());
|
||||
}
|
||||
this.pendingDeduplication.get(dedupId)!.add(result.externalId!);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async setPreference(
|
||||
userId: string,
|
||||
channel: NotificationPreference['channel'],
|
||||
enabled: boolean,
|
||||
categories?: string[]
|
||||
): Promise<NotificationPreference> {
|
||||
const preference: NotificationPreference = {
|
||||
userId,
|
||||
channel,
|
||||
enabled,
|
||||
categories: categories || [],
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
this.preferenceCache.set(`${userId}:${channel}`, preference);
|
||||
return preference;
|
||||
}
|
||||
|
||||
async getPreference(
|
||||
userId: string,
|
||||
channel: NotificationPreference['channel']
|
||||
): Promise<NotificationPreference | null> {
|
||||
return this.preferenceCache.get(`${userId}:${channel}`) || null;
|
||||
}
|
||||
|
||||
async shouldSend(
|
||||
userId: string,
|
||||
channel: NotificationPreference['channel'],
|
||||
category: string
|
||||
): Promise<boolean> {
|
||||
const preference = await this.getPreference(userId, channel);
|
||||
|
||||
if (!preference) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!preference.enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (preference.categories.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return preference.categories.includes(category);
|
||||
}
|
||||
|
||||
async sendWithPreferences(
|
||||
notification: Notification,
|
||||
category: string
|
||||
): Promise<NotificationResult | null> {
|
||||
const userId = notification.channel === 'push'
|
||||
? notification.userId
|
||||
: `user-${Date.now()}`;
|
||||
|
||||
const shouldSend = await this.shouldSend(
|
||||
userId,
|
||||
notification.channel,
|
||||
category
|
||||
);
|
||||
|
||||
if (!shouldSend) {
|
||||
return {
|
||||
notificationId: `pref-${Date.now()}`,
|
||||
channel: notification.channel,
|
||||
status: 'pending',
|
||||
error: 'Notification disabled for user preference',
|
||||
};
|
||||
}
|
||||
|
||||
return this.send(notification);
|
||||
}
|
||||
}
|
||||
112
packages/shared-notifications/src/services/push.service.ts
Normal file
112
packages/shared-notifications/src/services/push.service.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import admin from 'firebase-admin';
|
||||
import { loadNotificationConfig } from '../config/notification.config';
|
||||
import type { PushNotification, NotificationResult } from '../types/notification.types';
|
||||
|
||||
const config = loadNotificationConfig();
|
||||
|
||||
let fcmApp: admin.app.App | null = null;
|
||||
|
||||
function getFCMApp(): admin.app.App {
|
||||
if (!fcmApp) {
|
||||
fcmApp = admin.initializeApp({
|
||||
credential: admin.credential.cert({
|
||||
projectId: config.fcm.projectId,
|
||||
clientEmail: config.fcm.clientEmail,
|
||||
privateKey: config.fcm.privateKey.replace(/\\n/g, '\n'),
|
||||
}),
|
||||
});
|
||||
}
|
||||
return fcmApp;
|
||||
}
|
||||
|
||||
export class PushService {
|
||||
private static instance: PushService;
|
||||
private sentCount = new Map<string, number>();
|
||||
private cleanupInterval: NodeJS.Timeout;
|
||||
|
||||
private constructor() {
|
||||
this.cleanupInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, timestamp] of this.sentCount.entries()) {
|
||||
if (now - timestamp > 60000) {
|
||||
this.sentCount.delete(key);
|
||||
}
|
||||
}
|
||||
}, 60000);
|
||||
}
|
||||
|
||||
static getInstance(): PushService {
|
||||
if (!PushService.instance) {
|
||||
PushService.instance = new PushService();
|
||||
}
|
||||
return PushService.instance;
|
||||
}
|
||||
|
||||
async send(notification: PushNotification): Promise<NotificationResult> {
|
||||
const rateLimitKey = `push:${notification.userId}`;
|
||||
const currentCount = this.sentCount.get(rateLimitKey) || 0;
|
||||
|
||||
if (currentCount >= config.rateLimits.pushPerMinute) {
|
||||
throw new Error(`Push rate limit exceeded for user ${notification.userId}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const fcmApp = getFCMApp();
|
||||
const messaging = admin.messaging(fcmApp);
|
||||
|
||||
const message: admin.messaging.Message = {
|
||||
notification: {
|
||||
title: notification.title,
|
||||
body: notification.body,
|
||||
},
|
||||
data: notification.data ?
|
||||
Object.fromEntries(
|
||||
Object.entries(notification.data).map(([k, v]) => [k, String(v)])
|
||||
) : undefined,
|
||||
token: notification.userId,
|
||||
apns: {
|
||||
payload: {
|
||||
aps: {
|
||||
badge: notification.badge,
|
||||
sound: notification.sound || 'default',
|
||||
category: notification.category,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const response = await messaging.send(message);
|
||||
|
||||
this.sentCount.set(rateLimitKey, currentCount + 1);
|
||||
|
||||
return {
|
||||
notificationId: `push-${response}`,
|
||||
channel: 'push',
|
||||
status: 'sent',
|
||||
externalId: response,
|
||||
deliveredAt: new Date(),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
notificationId: `push-${Date.now()}`,
|
||||
channel: 'push',
|
||||
status: 'failed',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async sendBatch(notifications: PushNotification[]): Promise<NotificationResult[]> {
|
||||
const results = await Promise.all(
|
||||
notifications.map(n => this.send(n))
|
||||
);
|
||||
return results;
|
||||
}
|
||||
|
||||
getRateLimitStatus(): { remaining: number; limit: number } {
|
||||
return {
|
||||
remaining: config.rateLimits.pushPerMinute - this.sentCount.size,
|
||||
limit: config.rateLimits.pushPerMinute,
|
||||
};
|
||||
}
|
||||
}
|
||||
82
packages/shared-notifications/src/services/sms.service.ts
Normal file
82
packages/shared-notifications/src/services/sms.service.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import twilio from 'twilio';
|
||||
import { loadNotificationConfig } from '../config/notification.config';
|
||||
import type { SMSNotification, NotificationResult } from '../types/notification.types';
|
||||
|
||||
const config = loadNotificationConfig();
|
||||
const twilioClient = twilio(
|
||||
config.twilio.accountSid,
|
||||
config.twilio.authToken
|
||||
);
|
||||
|
||||
export class SMSService {
|
||||
private static instance: SMSService;
|
||||
private sentCount = new Map<string, number>();
|
||||
private cleanupInterval: NodeJS.Timeout;
|
||||
|
||||
private constructor() {
|
||||
this.cleanupInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, timestamp] of this.sentCount.entries()) {
|
||||
if (now - timestamp > 60000) {
|
||||
this.sentCount.delete(key);
|
||||
}
|
||||
}
|
||||
}, 60000);
|
||||
}
|
||||
|
||||
static getInstance(): SMSService {
|
||||
if (!SMSService.instance) {
|
||||
SMSService.instance = new SMSService();
|
||||
}
|
||||
return SMSService.instance;
|
||||
}
|
||||
|
||||
async send(notification: SMSNotification): Promise<NotificationResult> {
|
||||
const rateLimitKey = `sms:${notification.to}`;
|
||||
const currentCount = this.sentCount.get(rateLimitKey) || 0;
|
||||
|
||||
if (currentCount >= config.rateLimits.smsPerMinute) {
|
||||
throw new Error(`SMS rate limit exceeded for ${notification.to}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const message = await twilioClient.messages.create({
|
||||
body: notification.body,
|
||||
from: notification.from || config.twilio.messagingServiceSid,
|
||||
to: notification.to,
|
||||
metadata: notification.metadata,
|
||||
});
|
||||
|
||||
this.sentCount.set(rateLimitKey, currentCount + 1);
|
||||
|
||||
return {
|
||||
notificationId: `sms-${message.sid}`,
|
||||
channel: 'sms',
|
||||
status: 'sent',
|
||||
externalId: message.sid,
|
||||
deliveredAt: new Date(),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
notificationId: `sms-${Date.now()}`,
|
||||
channel: 'sms',
|
||||
status: 'failed',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async sendBatch(notifications: SMSNotification[]): Promise<NotificationResult[]> {
|
||||
const results = await Promise.all(
|
||||
notifications.map(n => this.send(n))
|
||||
);
|
||||
return results;
|
||||
}
|
||||
|
||||
getRateLimitStatus(): { remaining: number; limit: number } {
|
||||
return {
|
||||
remaining: config.rateLimits.smsPerMinute - this.sentCount.size,
|
||||
limit: config.rateLimits.smsPerMinute,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
export type NotificationChannel = 'email' | 'sms' | 'push';
|
||||
|
||||
export type NotificationStatus =
|
||||
| 'pending'
|
||||
| 'sent'
|
||||
| 'delivered'
|
||||
| 'failed'
|
||||
| 'bounced'
|
||||
| 'read';
|
||||
|
||||
export interface NotificationRecipient {
|
||||
userId: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
fcmToken?: string;
|
||||
apnsToken?: string;
|
||||
}
|
||||
|
||||
export interface EmailNotification {
|
||||
channel: 'email';
|
||||
to: string;
|
||||
from?: string;
|
||||
subject: string;
|
||||
htmlBody: string;
|
||||
textBody?: string;
|
||||
metadata?: Record<string, string>;
|
||||
attachments?: Array<{
|
||||
filename: string;
|
||||
content: Buffer;
|
||||
mimeType?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface SMSNotification {
|
||||
channel: 'sms';
|
||||
to: string;
|
||||
body: string;
|
||||
from?: string;
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface PushNotification {
|
||||
channel: 'push';
|
||||
userId: string;
|
||||
title: string;
|
||||
body: string;
|
||||
data?: Record<string, unknown>;
|
||||
badge?: number;
|
||||
sound?: string;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
export type Notification = EmailNotification | SMSNotification | PushNotification;
|
||||
|
||||
export interface NotificationResult {
|
||||
notificationId: string;
|
||||
channel: NotificationChannel;
|
||||
status: NotificationStatus;
|
||||
externalId?: string;
|
||||
error?: string;
|
||||
deliveredAt?: Date;
|
||||
readAt?: Date;
|
||||
}
|
||||
|
||||
export interface NotificationTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
channel: NotificationChannel;
|
||||
subject?: string;
|
||||
body: string;
|
||||
locale: string;
|
||||
variables: string[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface NotificationPreference {
|
||||
userId: string;
|
||||
channel: NotificationChannel;
|
||||
enabled: boolean;
|
||||
categories: string[];
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface DeduplicationKey {
|
||||
userId: string;
|
||||
templateId: string;
|
||||
key: string;
|
||||
windowMinutes: number;
|
||||
}
|
||||
12
packages/shared-notifications/tsconfig.json
Normal file
12
packages/shared-notifications/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -141,3 +141,38 @@ export interface VoiceEnrollmentOutput {
|
||||
durationSec?: number;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export const ScheduleStatus = {
|
||||
ACTIVE: "ACTIVE",
|
||||
PAUSED: "PAUSED",
|
||||
} as const;
|
||||
export type ScheduleStatus = (typeof ScheduleStatus)[keyof typeof ScheduleStatus];
|
||||
|
||||
export const WebhookEventType = {
|
||||
SCAN_TRIGGER: "SCAN_TRIGGER",
|
||||
BREACH_DETECTED: "BREACH_DETECTED",
|
||||
SUBSCRIPTION_CHANGE: "SUBSCRIPTION_CHANGE",
|
||||
} as const;
|
||||
export type WebhookEventType = (typeof WebhookEventType)[keyof typeof WebhookEventType];
|
||||
|
||||
export interface WebhookTriggerInput {
|
||||
eventType: string;
|
||||
payload: Record<string, unknown>;
|
||||
source?: string;
|
||||
signature?: string;
|
||||
}
|
||||
|
||||
export interface ScanScheduleOutput {
|
||||
id: string;
|
||||
userId: string;
|
||||
intervalMinutes: number;
|
||||
cronExpression: string;
|
||||
status: ScheduleStatus;
|
||||
lastScanAt?: Date;
|
||||
nextScanAt?: Date;
|
||||
}
|
||||
|
||||
export interface SchedulerConfig {
|
||||
intervalMinutes: number;
|
||||
cronExpression: string;
|
||||
}
|
||||
|
||||
44
services/darkwatch/Dockerfile
Normal file
44
services/darkwatch/Dockerfile
Normal file
@@ -0,0 +1,44 @@
|
||||
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/types/tsconfig.json ./packages/types/
|
||||
COPY packages/db/tsconfig.json ./packages/db/
|
||||
COPY services/darkwatch/tsconfig.json ./services/darkwatch/
|
||||
COPY services/darkwatch/ ./services/darkwatch/
|
||||
COPY packages/types/ ./packages/types/
|
||||
COPY packages/db/ ./packages/db/
|
||||
|
||||
RUN npm run build --workspace=@shieldai/types --workspace=@shieldai/db --workspace=@shieldai/darkwatch
|
||||
|
||||
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/services/darkwatch/dist ./dist
|
||||
COPY --from=builder --chown=shieldai:nodejs /app/node_modules ./node_modules
|
||||
COPY --from=builder --chown=shieldai:nodejs /app/services/darkwatch/package.json ./package.json
|
||||
COPY --from=builder --chown=shieldai:nodejs /app/packages/db ./packages/db
|
||||
|
||||
USER shieldai
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
CMD ["node", "dist/index.js"]
|
||||
@@ -3,3 +3,5 @@ export * from "./hibp/HIBPService";
|
||||
export * from "./matching/MatchingEngine";
|
||||
export * from "./alerts/AlertPipeline";
|
||||
export * from "./scanner/ScanService";
|
||||
export * from "./scheduler/ScanScheduler";
|
||||
export * from "./webhooks/WebhookHandler";
|
||||
|
||||
168
services/darkwatch/src/scheduler/ScanScheduler.ts
Normal file
168
services/darkwatch/src/scheduler/ScanScheduler.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import prisma from "@shieldai/db";
|
||||
import { SubscriptionTier } from "@shieldai/types";
|
||||
|
||||
const TIER_CONFIG = {
|
||||
[SubscriptionTier.BASIC]: { intervalMinutes: 1440, cron: "0 0 * * *" },
|
||||
[SubscriptionTier.PLUS]: { intervalMinutes: 360, cron: "0 */6 * * *" },
|
||||
[SubscriptionTier.PREMIUM]: { intervalMinutes: 60, cron: "0 * * * *" },
|
||||
} as const;
|
||||
|
||||
export class ScanScheduler {
|
||||
/**
|
||||
* Get the scan interval (in minutes) for a given subscription tier.
|
||||
*/
|
||||
public static getIntervalForTier(tier: SubscriptionTier): number {
|
||||
return TIER_CONFIG[tier]?.intervalMinutes ?? TIER_CONFIG[SubscriptionTier.BASIC].intervalMinutes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cron expression for a given subscription tier.
|
||||
*/
|
||||
public static getCronForTier(tier: SubscriptionTier): string {
|
||||
return TIER_CONFIG[tier]?.cron ?? TIER_CONFIG[SubscriptionTier.BASIC].cron;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a user has an active scan schedule based on their subscription tier.
|
||||
* Creates or updates the schedule record.
|
||||
*/
|
||||
async ensureScheduleForUser(userId: string): Promise<{ scheduled: boolean; intervalMinutes: number }> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { subscriptionTier: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return { scheduled: false, intervalMinutes: 0 };
|
||||
}
|
||||
|
||||
const tier = user.subscriptionTier ?? SubscriptionTier.BASIC;
|
||||
const config = TIER_CONFIG[tier];
|
||||
const nextScan = this.calculateNextScan();
|
||||
|
||||
const schedule = await prisma.scanSchedule.upsert({
|
||||
where: { userId },
|
||||
update: {
|
||||
intervalMinutes: config.intervalMinutes,
|
||||
cronExpression: config.cron,
|
||||
nextScanAt: nextScan,
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
intervalMinutes: config.intervalMinutes,
|
||||
cronExpression: config.cron,
|
||||
status: "ACTIVE",
|
||||
nextScanAt: nextScan,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
scheduled: schedule.status === "ACTIVE",
|
||||
intervalMinutes: schedule.intervalMinutes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active schedules that are due for scanning.
|
||||
*/
|
||||
async getDueSchedules(): Promise<Array<{ userId: string; intervalMinutes: number; cronExpression: string }>> {
|
||||
const now = new Date();
|
||||
|
||||
const due = await prisma.scanSchedule.findMany({
|
||||
where: {
|
||||
status: "ACTIVE",
|
||||
OR: [
|
||||
{ nextScanAt: { lte: now } },
|
||||
{ nextScanAt: null },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
intervalMinutes: true,
|
||||
cronExpression: true,
|
||||
},
|
||||
});
|
||||
|
||||
return due;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a schedule as scanned and compute the next scan time.
|
||||
*/
|
||||
async markScanned(userId: string): Promise<Date> {
|
||||
const schedule = await prisma.scanSchedule.findUnique({ where: { userId } });
|
||||
|
||||
if (!schedule) {
|
||||
throw new Error(`ScanSchedule not found for user ${userId}`);
|
||||
}
|
||||
|
||||
const nextScan = this.calculateNextScan(schedule.intervalMinutes);
|
||||
|
||||
await prisma.scanSchedule.update({
|
||||
where: { userId },
|
||||
data: {
|
||||
lastScanAt: new Date(),
|
||||
nextScanAt: nextScan,
|
||||
},
|
||||
});
|
||||
|
||||
return nextScan;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause scheduling for a user (e.g., on subscription downgrade or pause).
|
||||
*/
|
||||
async pauseSchedule(userId: string): Promise<void> {
|
||||
await prisma.scanSchedule.updateMany({
|
||||
where: { userId, status: "ACTIVE" },
|
||||
data: { status: "PAUSED" },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume scheduling for a user and recalculate based on current tier.
|
||||
*/
|
||||
async resumeSchedule(userId: string): Promise<void> {
|
||||
await this.ensureScheduleForUser(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current schedule for a user.
|
||||
*/
|
||||
async getSchedule(userId: string) {
|
||||
return prisma.scanSchedule.findUnique({
|
||||
where: { userId },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List all active schedules (for admin/monitoring).
|
||||
*/
|
||||
async listActiveSchedules(limit = 100, offset = 0) {
|
||||
return prisma.scanSchedule.findMany({
|
||||
where: { status: "ACTIVE" },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
subscriptionTier: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { nextScanAt: "asc" },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the next scan time based on interval.
|
||||
*/
|
||||
private calculateNextScan(intervalMinutes?: number): Date {
|
||||
const minutes = intervalMinutes ?? 60;
|
||||
const next = new Date();
|
||||
next.setMinutes(next.getMinutes() + minutes);
|
||||
return next;
|
||||
}
|
||||
}
|
||||
193
services/darkwatch/src/webhooks/WebhookHandler.ts
Normal file
193
services/darkwatch/src/webhooks/WebhookHandler.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import prisma from "@shieldai/db";
|
||||
import { createHmac, timingSafeEqual } from "crypto";
|
||||
import { DataSource, WebhookEventType } from "@shieldai/types";
|
||||
|
||||
export class WebhookHandler {
|
||||
private secret: string;
|
||||
|
||||
constructor(secret?: string) {
|
||||
this.secret = secret || process.env.WEBHOOK_SECRET || "default-webhook-secret";
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify HMAC signature of incoming webhook payload.
|
||||
*/
|
||||
verifySignature(payload: string, signature: string | string[]): boolean {
|
||||
if (!signature) return false;
|
||||
|
||||
const sigArray = Array.isArray(signature) ? signature : [signature];
|
||||
const expected = this.computeSignature(payload);
|
||||
|
||||
for (const sig of sigArray) {
|
||||
if (timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process an incoming webhook event.
|
||||
* Validates, stores, and triggers appropriate action.
|
||||
*/
|
||||
async processEvent(
|
||||
eventType: string,
|
||||
payload: Record<string, unknown>,
|
||||
source?: string,
|
||||
signature?: string
|
||||
): Promise<{ eventId: string; scanTriggered: boolean }> {
|
||||
const payloadStr = JSON.stringify(payload);
|
||||
|
||||
if (signature && !this.verifySignature(payloadStr, signature)) {
|
||||
throw new Error("Webhook signature verification failed");
|
||||
}
|
||||
|
||||
const eventTypeNormalized = this.normalizeEventType(eventType);
|
||||
|
||||
const event = await prisma.webhookEvent.create({
|
||||
data: {
|
||||
eventType: eventTypeNormalized,
|
||||
payload: payloadStr,
|
||||
source,
|
||||
signature,
|
||||
},
|
||||
});
|
||||
|
||||
let scanTriggered = false;
|
||||
|
||||
if (eventTypeNormalized === WebhookEventType.SCAN_TRIGGER) {
|
||||
const userId = payload.userId as string | undefined;
|
||||
const source = (payload.dataSource as string) || undefined;
|
||||
|
||||
if (userId) {
|
||||
scanTriggered = await this.triggerScanFromWebhook(event.id, userId, source);
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.webhookEvent.update({
|
||||
where: { id: event.id },
|
||||
data: {
|
||||
processed: true,
|
||||
processedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return { eventId: event.id, scanTriggered };
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a scan job from a webhook event.
|
||||
*/
|
||||
private async triggerScanFromWebhook(
|
||||
eventId: string,
|
||||
userId: string,
|
||||
dataSource?: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const job = await prisma.scanJob.create({
|
||||
data: {
|
||||
userId,
|
||||
status: "PENDING",
|
||||
source: (dataSource as DataSource) || undefined,
|
||||
scheduledBy: "webhook",
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.webhookEvent.update({
|
||||
where: { id: eventId },
|
||||
data: { scanJobId: job.id },
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(`[Webhook] Scan trigger failed for event ${eventId}:`, err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get webhook event history.
|
||||
*/
|
||||
async getEventHistory(limit = 50, offset = 0) {
|
||||
return prisma.webhookEvent.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
include: { scanJob: true },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get events for a specific user (via linked scan jobs).
|
||||
*/
|
||||
async getUserEvents(userId: string, limit = 50, offset = 0) {
|
||||
return prisma.webhookEvent.findMany({
|
||||
where: {
|
||||
scanJob: { userId },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Process unprocessed webhook events (retry mechanism).
|
||||
*/
|
||||
async processPendingEvents(): Promise<number> {
|
||||
const pending = await prisma.webhookEvent.findMany({
|
||||
where: {
|
||||
processed: false,
|
||||
eventType: WebhookEventType.SCAN_TRIGGER,
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
take: 50,
|
||||
});
|
||||
|
||||
let processed = 0;
|
||||
|
||||
for (const event of pending) {
|
||||
try {
|
||||
const payload = JSON.parse(event.payload) as Record<string, unknown>;
|
||||
const userId = payload.userId as string | undefined;
|
||||
|
||||
if (userId) {
|
||||
const success = await this.triggerScanFromWebhook(
|
||||
event.id,
|
||||
userId,
|
||||
payload.dataSource as string | undefined
|
||||
);
|
||||
|
||||
if (success) {
|
||||
await prisma.webhookEvent.update({
|
||||
where: { id: event.id },
|
||||
data: { processed: true, processedAt: new Date() },
|
||||
});
|
||||
processed++;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[Webhook] Retry failed for event ${event.id}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
private computeSignature(payload: string): string {
|
||||
return createHmac("sha256", this.secret).update(payload).digest("hex");
|
||||
}
|
||||
|
||||
private normalizeEventType(eventType: string): WebhookEventType {
|
||||
const upper = eventType.toUpperCase().replace(/\s+/g, "_");
|
||||
const validTypes: WebhookEventType[] = [WebhookEventType.SCAN_TRIGGER, WebhookEventType.BREACH_DETECTED, WebhookEventType.SUBSCRIPTION_CHANGE];
|
||||
return validTypes.includes(upper as WebhookEventType) ? (upper as WebhookEventType) : WebhookEventType.SCAN_TRIGGER;
|
||||
}
|
||||
}
|
||||
195
services/darkwatch/test/scheduler.test.ts
Normal file
195
services/darkwatch/test/scheduler.test.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { ScanScheduler } from "../src/scheduler/ScanScheduler";
|
||||
import { SubscriptionTier } from "@shieldai/types";
|
||||
import prisma from "@shieldai/db";
|
||||
|
||||
let runId = Date.now();
|
||||
|
||||
describe("ScanScheduler", () => {
|
||||
let scheduler: ScanScheduler;
|
||||
|
||||
beforeEach(() => {
|
||||
scheduler = new ScanScheduler();
|
||||
});
|
||||
|
||||
describe("static tier configuration", () => {
|
||||
it("returns correct interval for BASIC tier", () => {
|
||||
expect(ScanScheduler.getIntervalForTier(SubscriptionTier.BASIC)).toBe(1440);
|
||||
});
|
||||
|
||||
it("returns correct interval for PLUS tier", () => {
|
||||
expect(ScanScheduler.getIntervalForTier(SubscriptionTier.PLUS)).toBe(360);
|
||||
});
|
||||
|
||||
it("returns correct interval for PREMIUM tier", () => {
|
||||
expect(ScanScheduler.getIntervalForTier(SubscriptionTier.PREMIUM)).toBe(60);
|
||||
});
|
||||
|
||||
it("returns correct cron for BASIC tier", () => {
|
||||
expect(ScanScheduler.getCronForTier(SubscriptionTier.BASIC)).toBe("0 0 * * *");
|
||||
});
|
||||
|
||||
it("returns correct cron for PLUS tier", () => {
|
||||
expect(ScanScheduler.getCronForTier(SubscriptionTier.PLUS)).toBe("0 */6 * * *");
|
||||
});
|
||||
|
||||
it("returns correct cron for PREMIUM tier", () => {
|
||||
expect(ScanScheduler.getCronForTier(SubscriptionTier.PREMIUM)).toBe("0 * * * *");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureScheduleForUser", () => {
|
||||
let userId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: `scheduler-test-${runId}@shieldai.local`,
|
||||
subscriptionTier: "BASIC",
|
||||
},
|
||||
});
|
||||
userId = user.id;
|
||||
runId = Date.now();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.scanSchedule.deleteMany({ where: { userId } });
|
||||
await prisma.user.delete({ where: { id: userId } });
|
||||
});
|
||||
|
||||
it("creates schedule for new user", async () => {
|
||||
const result = await scheduler.ensureScheduleForUser(userId);
|
||||
expect(result.scheduled).toBe(true);
|
||||
expect(result.intervalMinutes).toBe(1440);
|
||||
|
||||
const schedule = await scheduler.getSchedule(userId);
|
||||
expect(schedule).not.toBeNull();
|
||||
expect(schedule?.status).toBe("ACTIVE");
|
||||
expect(schedule?.cronExpression).toBe("0 0 * * *");
|
||||
});
|
||||
|
||||
it("updates schedule on tier change", async () => {
|
||||
await scheduler.ensureScheduleForUser(userId);
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { subscriptionTier: "PREMIUM" },
|
||||
});
|
||||
|
||||
const result = await scheduler.ensureScheduleForUser(userId);
|
||||
expect(result.intervalMinutes).toBe(60);
|
||||
|
||||
const schedule = await scheduler.getSchedule(userId);
|
||||
expect(schedule?.cronExpression).toBe("0 * * * *");
|
||||
});
|
||||
|
||||
it("returns false for non-existent user", async () => {
|
||||
const result = await scheduler.ensureScheduleForUser("non-existent-id");
|
||||
expect(result.scheduled).toBe(false);
|
||||
expect(result.intervalMinutes).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("schedule lifecycle", () => {
|
||||
let userId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: `lifecycle-test-${runId}@shieldai.local`,
|
||||
subscriptionTier: "PLUS",
|
||||
},
|
||||
});
|
||||
userId = user.id;
|
||||
runId = Date.now();
|
||||
await scheduler.ensureScheduleForUser(userId);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.scanSchedule.deleteMany({ where: { userId } });
|
||||
await prisma.user.delete({ where: { id: userId } });
|
||||
});
|
||||
|
||||
it("marks schedule as scanned and updates next scan time", async () => {
|
||||
const before = await scheduler.getSchedule(userId);
|
||||
const nextScan = await scheduler.markScanned(userId);
|
||||
|
||||
const after = await scheduler.getSchedule(userId);
|
||||
expect(after?.lastScanAt).not.toBeNull();
|
||||
expect(after?.nextScanAt?.getTime()).toBeGreaterThan(nextScan.getTime() - 5000);
|
||||
expect(after?.nextScanAt).not.toEqual(before?.nextScanAt);
|
||||
});
|
||||
|
||||
it("pauses schedule", async () => {
|
||||
await scheduler.pauseSchedule(userId);
|
||||
|
||||
const schedule = await scheduler.getSchedule(userId);
|
||||
expect(schedule?.status).toBe("PAUSED");
|
||||
});
|
||||
|
||||
it("resumes paused schedule", async () => {
|
||||
await scheduler.pauseSchedule(userId);
|
||||
await scheduler.resumeSchedule(userId);
|
||||
|
||||
const schedule = await scheduler.getSchedule(userId);
|
||||
expect(schedule?.status).toBe("ACTIVE");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDueSchedules", () => {
|
||||
let userId1: string;
|
||||
let userId2: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const user1 = await prisma.user.create({
|
||||
data: {
|
||||
email: `due-test-1-${runId}@shieldai.local`,
|
||||
subscriptionTier: "PREMIUM",
|
||||
},
|
||||
});
|
||||
userId1 = user1.id;
|
||||
|
||||
const user2 = await prisma.user.create({
|
||||
data: {
|
||||
email: `due-test-2-${runId}@shieldai.local`,
|
||||
subscriptionTier: "BASIC",
|
||||
},
|
||||
});
|
||||
userId2 = user2.id;
|
||||
runId = Date.now();
|
||||
|
||||
await scheduler.ensureScheduleForUser(userId1);
|
||||
await scheduler.ensureScheduleForUser(userId2);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.scanSchedule.deleteMany({ where: { userId: userId1 } });
|
||||
await prisma.scanSchedule.deleteMany({ where: { userId: userId2 } });
|
||||
await prisma.user.delete({ where: { id: userId1 } });
|
||||
await prisma.user.delete({ where: { id: userId2 } });
|
||||
});
|
||||
|
||||
it("returns schedules that are due", async () => {
|
||||
const pastDate = new Date(Date.now() - 60000);
|
||||
await prisma.scanSchedule.update({
|
||||
where: { userId: userId1 },
|
||||
data: { nextScanAt: pastDate },
|
||||
});
|
||||
|
||||
const due = await scheduler.getDueSchedules();
|
||||
const dueUserIds = due.map((s) => s.userId);
|
||||
expect(dueUserIds).toContain(userId1);
|
||||
});
|
||||
|
||||
it("includes schedules with null nextScanAt", async () => {
|
||||
await prisma.scanSchedule.update({
|
||||
where: { userId: userId2 },
|
||||
data: { nextScanAt: null },
|
||||
});
|
||||
|
||||
const due = await scheduler.getDueSchedules();
|
||||
const dueUserIds = due.map((s) => s.userId);
|
||||
expect(dueUserIds).toContain(userId2);
|
||||
});
|
||||
});
|
||||
});
|
||||
201
services/darkwatch/test/webhook.test.ts
Normal file
201
services/darkwatch/test/webhook.test.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { WebhookHandler } from "../src/webhooks/WebhookHandler";
|
||||
import prisma from "@shieldai/db";
|
||||
|
||||
const TEST_SECRET = "test-webhook-secret-2026";
|
||||
let runId = Date.now();
|
||||
|
||||
describe("WebhookHandler", () => {
|
||||
let handler: WebhookHandler;
|
||||
|
||||
beforeEach(() => {
|
||||
handler = new WebhookHandler(TEST_SECRET);
|
||||
});
|
||||
|
||||
describe("signature verification", () => {
|
||||
it("verifies valid signature", () => {
|
||||
const payload = JSON.stringify({ userId: "test-123" });
|
||||
const sig = handler["computeSignature"](payload);
|
||||
expect(handler.verifySignature(payload, sig)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects invalid signature", () => {
|
||||
const payload = JSON.stringify({ userId: "test-123" });
|
||||
expect(handler.verifySignature(payload, "invalid-sig")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects missing signature", () => {
|
||||
expect(handler.verifySignature("payload", "")).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts signature from array", () => {
|
||||
const payload = JSON.stringify({ userId: "test-123" });
|
||||
const sig = handler["computeSignature"](payload);
|
||||
expect(handler.verifySignature(payload, ["other", sig, "another"])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("processEvent", () => {
|
||||
let userId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: `webhook-test-${runId}@shieldai.local`,
|
||||
subscriptionTier: "PREMIUM",
|
||||
},
|
||||
});
|
||||
userId = user.id;
|
||||
runId = Date.now();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.webhookEvent.deleteMany();
|
||||
await prisma.scanJob.deleteMany({ where: { userId } });
|
||||
await prisma.user.delete({ where: { id: userId } });
|
||||
});
|
||||
|
||||
it("processes SCAN_TRIGGER event", async () => {
|
||||
const result = await handler.processEvent("SCAN_TRIGGER", {
|
||||
userId,
|
||||
dataSource: "HIBP",
|
||||
});
|
||||
|
||||
expect(result.eventId).toBeDefined();
|
||||
expect(result.scanTriggered).toBe(true);
|
||||
|
||||
const job = await prisma.scanJob.findFirst({
|
||||
where: { userId, scheduledBy: "webhook" },
|
||||
});
|
||||
expect(job).not.toBeNull();
|
||||
});
|
||||
|
||||
it("processes BREACH_DETECTED event", async () => {
|
||||
const result = await handler.processEvent("BREACH_DETECTED", {
|
||||
userId,
|
||||
breachName: "TestBreach",
|
||||
});
|
||||
|
||||
expect(result.eventId).toBeDefined();
|
||||
expect(result.scanTriggered).toBe(false);
|
||||
});
|
||||
|
||||
it("normalizes event type", async () => {
|
||||
const result = await handler.processEvent("scan_trigger", {
|
||||
userId,
|
||||
});
|
||||
|
||||
expect(result.eventId).toBeDefined();
|
||||
|
||||
const event = await prisma.webhookEvent.findUnique({
|
||||
where: { id: result.eventId },
|
||||
});
|
||||
expect(event?.eventType).toBe("SCAN_TRIGGER");
|
||||
});
|
||||
|
||||
it("returns false for non-existent user", async () => {
|
||||
const result = await handler.processEvent("SCAN_TRIGGER", {
|
||||
userId: "non-existent-user-id",
|
||||
});
|
||||
|
||||
expect(result.scanTriggered).toBe(false);
|
||||
});
|
||||
|
||||
it("links scan job to webhook event", async () => {
|
||||
const result = await handler.processEvent("SCAN_TRIGGER", {
|
||||
userId,
|
||||
});
|
||||
|
||||
expect(result.scanTriggered).toBe(true);
|
||||
|
||||
const event = await prisma.webhookEvent.findUnique({
|
||||
where: { id: result.eventId },
|
||||
});
|
||||
|
||||
expect(event?.scanJobId).toBeDefined();
|
||||
expect(event?.processed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("signature validation in processEvent", () => {
|
||||
it("accepts event with valid signature", async () => {
|
||||
const payload = { userId: "test" };
|
||||
const payloadStr = JSON.stringify(payload);
|
||||
const sig = handler["computeSignature"](payloadStr);
|
||||
|
||||
const result = await handler.processEvent("SCAN_TRIGGER", payload, undefined, sig);
|
||||
expect(result.eventId).toBeDefined();
|
||||
});
|
||||
|
||||
it("rejects event with invalid signature", async () => {
|
||||
const payload = { userId: "test" };
|
||||
|
||||
try {
|
||||
await handler.processEvent("SCAN_TRIGGER", payload, undefined, "bad-signature");
|
||||
expect(true).toBe(false);
|
||||
} catch (err) {
|
||||
expect((err as Error).message).toContain("signature");
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts event without signature when no signature provided", async () => {
|
||||
const result = await handler.processEvent("SCAN_TRIGGER", { userId: "test" });
|
||||
expect(result.eventId).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("processPendingEvents", () => {
|
||||
it("retries unprocessed events", async () => {
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: `retry-test-${runId}@shieldai.local`,
|
||||
subscriptionTier: "BASIC",
|
||||
},
|
||||
});
|
||||
runId = Date.now();
|
||||
|
||||
await prisma.webhookEvent.create({
|
||||
data: {
|
||||
eventType: "SCAN_TRIGGER",
|
||||
payload: JSON.stringify({ userId: user.id }),
|
||||
processed: false,
|
||||
},
|
||||
});
|
||||
|
||||
const processed = await handler.processPendingEvents();
|
||||
expect(processed).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const job = await prisma.scanJob.findFirst({
|
||||
where: { userId: user.id, scheduledBy: "webhook" },
|
||||
});
|
||||
expect(job).not.toBeNull();
|
||||
|
||||
await prisma.scanJob.deleteMany({ where: { userId: user.id } });
|
||||
await prisma.user.delete({ where: { id: user.id } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEventHistory", () => {
|
||||
afterEach(async () => {
|
||||
await prisma.webhookEvent.deleteMany();
|
||||
});
|
||||
|
||||
it("returns events ordered by creation time", async () => {
|
||||
await handler.processEvent("SCAN_TRIGGER", { userId: "user-1" });
|
||||
await handler.processEvent("BREACH_DETECTED", { userId: "user-2" });
|
||||
|
||||
const events = await handler.getEventHistory();
|
||||
expect(events.length).toBeGreaterThanOrEqual(2);
|
||||
expect(events[0].createdAt.getTime()).toBeGreaterThanOrEqual(events[1].createdAt.getTime());
|
||||
});
|
||||
|
||||
it("respects limit and offset", async () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await handler.processEvent("SCAN_TRIGGER", { userId: `user-${i}` });
|
||||
}
|
||||
|
||||
const events = await handler.getEventHistory(3, 0);
|
||||
expect(events).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
44
services/spamshield/Dockerfile
Normal file
44
services/spamshield/Dockerfile
Normal file
@@ -0,0 +1,44 @@
|
||||
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/types/tsconfig.json ./packages/types/
|
||||
COPY packages/db/tsconfig.json ./packages/db/
|
||||
COPY services/spamshield/tsconfig.json ./services/spamshield/
|
||||
COPY services/spamshield/ ./services/spamshield/
|
||||
COPY packages/types/ ./packages/types/
|
||||
COPY packages/db/ ./packages/db/
|
||||
|
||||
RUN npm run build --workspace=@shieldai/types --workspace=@shieldai/db --workspace=@shieldai/spamshield
|
||||
|
||||
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/services/spamshield/dist ./dist
|
||||
COPY --from=builder --chown=shieldai:nodejs /app/node_modules ./node_modules
|
||||
COPY --from=builder --chown=shieldai:nodejs /app/services/spamshield/package.json ./package.json
|
||||
COPY --from=builder --chown=shieldai:nodejs /app/packages/db ./packages/db
|
||||
|
||||
USER shieldai
|
||||
|
||||
EXPOSE 3002
|
||||
|
||||
CMD ["node", "dist/index.js"]
|
||||
22
services/spamshield/package.json
Normal file
22
services/spamshield/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@shieldai/spamshield",
|
||||
"version": "0.1.0",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"lint": "eslint src/",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@shieldai/db": "0.1.0",
|
||||
"@prisma/client": "^6.2.0",
|
||||
"libphonenumber-js": "^1.10.50"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.3.3",
|
||||
"tsx": "^4.19.0",
|
||||
"eslint": "^8.56.0"
|
||||
}
|
||||
}
|
||||
173
services/spamshield/src/circuit-breaker/circuit-breaker.ts
Normal file
173
services/spamshield/src/circuit-breaker/circuit-breaker.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
export type CircuitState = 'CLOSED' | 'OPEN' | 'HALF_OPEN';
|
||||
|
||||
export interface CircuitBreakerMetrics {
|
||||
state: CircuitState;
|
||||
failureCount: number;
|
||||
successCount: number;
|
||||
lastFailureTime: Date | null;
|
||||
lastSuccessTime: Date | null;
|
||||
stateChangedAt: Date | null;
|
||||
totalExecutions: number;
|
||||
totalFailures: number;
|
||||
totalSuccesses: number;
|
||||
}
|
||||
|
||||
export interface CircuitBreakerOptions {
|
||||
failureThreshold?: number;
|
||||
successThreshold?: number;
|
||||
timeout?: number;
|
||||
onStateChange?: (state: CircuitState, previousState: CircuitState) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_FAILURE_THRESHOLD = 5;
|
||||
const DEFAULT_SUCCESS_THRESHOLD = 3;
|
||||
const DEFAULT_TIMEOUT_MS = 60000;
|
||||
|
||||
export class CircuitBreakerError extends Error {
|
||||
public readonly state: CircuitState;
|
||||
|
||||
constructor(message: string, state: CircuitState) {
|
||||
super(message);
|
||||
this.name = 'CircuitBreakerError';
|
||||
this.state = state;
|
||||
}
|
||||
}
|
||||
|
||||
export class CircuitBreaker {
|
||||
private state: CircuitState = 'CLOSED';
|
||||
private failureCount = 0;
|
||||
private successCount = 0;
|
||||
private lastFailureTime: Date | null = null;
|
||||
private lastSuccessTime: Date | null = null;
|
||||
private stateChangedAt: Date | null = null;
|
||||
private totalExecutions = 0;
|
||||
private totalFailures = 0;
|
||||
private totalSuccesses = 0;
|
||||
|
||||
private readonly failureThreshold: number;
|
||||
private readonly successThreshold: number;
|
||||
private readonly timeout: number;
|
||||
private readonly onStateChange?: (state: CircuitState, previousState: CircuitState) => void;
|
||||
|
||||
constructor(options?: CircuitBreakerOptions) {
|
||||
this.failureThreshold = options?.failureThreshold ?? DEFAULT_FAILURE_THRESHOLD;
|
||||
this.successThreshold = options?.successThreshold ?? DEFAULT_SUCCESS_THRESHOLD;
|
||||
this.timeout = options?.timeout ?? DEFAULT_TIMEOUT_MS;
|
||||
this.onStateChange = options?.onStateChange;
|
||||
this.stateChangedAt = new Date();
|
||||
}
|
||||
|
||||
public getState(): CircuitState {
|
||||
if (this.state === 'OPEN') {
|
||||
const elapsed = Date.now() - this.lastFailureTime!.getTime();
|
||||
if (elapsed >= this.timeout) {
|
||||
this.transitionTo('HALF_OPEN');
|
||||
}
|
||||
}
|
||||
return this.state;
|
||||
}
|
||||
|
||||
public async execute<T>(
|
||||
fn: () => Promise<T>,
|
||||
fallback?: () => T | Promise<T>
|
||||
): Promise<T> {
|
||||
this.totalExecutions++;
|
||||
const currentState = this.getState();
|
||||
|
||||
try {
|
||||
let result: T;
|
||||
|
||||
if (currentState === 'OPEN') {
|
||||
throw new CircuitBreakerError(
|
||||
`Circuit is OPEN. Failures: ${this.failureCount}/${this.failureThreshold}`,
|
||||
this.state
|
||||
);
|
||||
}
|
||||
|
||||
result = await fn();
|
||||
this.recordSuccess();
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.recordFailure();
|
||||
|
||||
if (fallback) {
|
||||
try {
|
||||
return fallback();
|
||||
} catch (fallbackError) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public getMetrics(): CircuitBreakerMetrics {
|
||||
return {
|
||||
state: this.getState(),
|
||||
failureCount: this.failureCount,
|
||||
successCount: this.successCount,
|
||||
lastFailureTime: this.lastFailureTime,
|
||||
lastSuccessTime: this.lastSuccessTime,
|
||||
stateChangedAt: this.stateChangedAt,
|
||||
totalExecutions: this.totalExecutions,
|
||||
totalFailures: this.totalFailures,
|
||||
totalSuccesses: this.totalSuccesses,
|
||||
};
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
const previousState = this.state;
|
||||
this.state = 'CLOSED';
|
||||
this.failureCount = 0;
|
||||
this.successCount = 0;
|
||||
this.lastFailureTime = null;
|
||||
this.lastSuccessTime = null;
|
||||
this.stateChangedAt = new Date();
|
||||
if (previousState !== 'CLOSED') {
|
||||
this.emitStateChange('CLOSED', previousState);
|
||||
}
|
||||
}
|
||||
|
||||
private recordSuccess(): void {
|
||||
this.lastSuccessTime = new Date();
|
||||
this.totalSuccesses++;
|
||||
|
||||
if (this.state === 'HALF_OPEN') {
|
||||
this.successCount++;
|
||||
if (this.successCount >= this.successThreshold) {
|
||||
this.transitionTo('CLOSED');
|
||||
this.failureCount = 0;
|
||||
this.successCount = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private recordFailure(): void {
|
||||
this.lastFailureTime = new Date();
|
||||
this.totalFailures++;
|
||||
this.failureCount++;
|
||||
|
||||
if (this.state === 'HALF_OPEN') {
|
||||
this.transitionTo('OPEN');
|
||||
} else if (this.state === 'CLOSED' && this.failureCount >= this.failureThreshold) {
|
||||
this.transitionTo('OPEN');
|
||||
}
|
||||
}
|
||||
|
||||
private transitionTo(newState: CircuitState): void {
|
||||
const previousState = this.state;
|
||||
this.state = newState;
|
||||
this.stateChangedAt = new Date();
|
||||
if (newState === 'CLOSED') {
|
||||
this.successCount = 0;
|
||||
}
|
||||
this.emitStateChange(newState, previousState);
|
||||
}
|
||||
|
||||
private emitStateChange(newState: CircuitState, previousState: CircuitState): void {
|
||||
if (this.onStateChange && newState !== previousState) {
|
||||
this.onStateChange(newState, previousState);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
services/spamshield/src/circuit-breaker/index.ts
Normal file
2
services/spamshield/src/circuit-breaker/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { CircuitBreaker, CircuitBreakerError } from './circuit-breaker';
|
||||
export type { CircuitState, CircuitBreakerMetrics, CircuitBreakerOptions } from './circuit-breaker';
|
||||
22
services/spamshield/src/config/spamshield.config.ts
Normal file
22
services/spamshield/src/config/spamshield.config.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export const spamRateLimits = {
|
||||
BASIC: 100,
|
||||
PLUS: 500,
|
||||
PREMIUM: 2000,
|
||||
} as const;
|
||||
|
||||
export const spamFeatureFlags = {
|
||||
enableHiyaIntegration: true,
|
||||
enableTruecallerIntegration: true,
|
||||
enableSMSClassification: true,
|
||||
enableCallAnalysis: true,
|
||||
enableFeedbackLoop: true,
|
||||
} as const;
|
||||
|
||||
export const spamConfig = {
|
||||
maxPhoneNumberLength: 20,
|
||||
minPhoneNumberLength: 10,
|
||||
defaultConfidenceThreshold: 0.7,
|
||||
maxMetadataSize: 1024 * 10, // 10KB
|
||||
circuitBreakerThreshold: 5,
|
||||
circuitBreakerTimeout: 60000,
|
||||
} as const;
|
||||
5
services/spamshield/src/index.ts
Normal file
5
services/spamshield/src/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { SpamShieldService } from './services/spamshield.service';
|
||||
export type { ReputationResult, CircuitMetrics } from './services/spamshield.service';
|
||||
export { spamRateLimits, spamFeatureFlags, spamConfig } from './config/spamshield.config';
|
||||
export { CircuitBreaker, CircuitBreakerError } from './circuit-breaker';
|
||||
export type { CircuitState, CircuitBreakerMetrics, CircuitBreakerOptions } from './circuit-breaker';
|
||||
282
services/spamshield/src/services/spamshield.service.ts
Normal file
282
services/spamshield/src/services/spamshield.service.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
import { PrismaClient, SpamFeedback, SpamRule, SpamAuditLog } from '@prisma/client';
|
||||
import { FieldEncryptionService } from '@shieldai/db';
|
||||
import { spamConfig, spamFeatureFlags } from '../config/spamshield.config';
|
||||
import { CircuitBreaker, CircuitBreakerError, CircuitState, CircuitBreakerMetrics } from '../circuit-breaker';
|
||||
import { validatePhoneNumber as validateE164 } from '../utils/phone-validation';
|
||||
|
||||
const prisma = new PrismaClient() as PrismaClient & {
|
||||
spamFeedback: {
|
||||
create: (data: { data: SpamFeedback }) => Promise<SpamFeedback>;
|
||||
};
|
||||
spamRule: {
|
||||
findMany: (args: { where: { isActive: boolean } }) => Promise<SpamRule[]>;
|
||||
};
|
||||
spamAuditLog: {
|
||||
create: (data: { data: SpamAuditLog }) => Promise<SpamAuditLog>;
|
||||
};
|
||||
};
|
||||
|
||||
interface InitializationLock {
|
||||
promise: Promise<void>;
|
||||
resolved: boolean;
|
||||
}
|
||||
|
||||
export interface ReputationResult {
|
||||
score: number;
|
||||
isSpam: boolean;
|
||||
source: 'hiya' | 'truecaller' | 'combined' | 'fallback';
|
||||
hiyaScore?: number;
|
||||
truecallerScore?: number;
|
||||
}
|
||||
|
||||
export interface CircuitMetrics {
|
||||
hiya: CircuitBreakerMetrics;
|
||||
truecaller: CircuitBreakerMetrics;
|
||||
}
|
||||
|
||||
export class SpamShieldService {
|
||||
private static instance: SpamShieldService;
|
||||
private initLock: InitializationLock | null = null;
|
||||
private hiyaBreaker: CircuitBreaker = new CircuitBreaker({
|
||||
failureThreshold: spamConfig.circuitBreakerThreshold,
|
||||
timeout: spamConfig.circuitBreakerTimeout,
|
||||
});
|
||||
private truecallerBreaker: CircuitBreaker = new CircuitBreaker({
|
||||
failureThreshold: spamConfig.circuitBreakerThreshold,
|
||||
timeout: spamConfig.circuitBreakerTimeout,
|
||||
});
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): SpamShieldService {
|
||||
if (!SpamShieldService.instance) {
|
||||
SpamShieldService.instance = new SpamShieldService();
|
||||
}
|
||||
return SpamShieldService.instance;
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initLock?.resolved) return;
|
||||
|
||||
if (!this.initLock) {
|
||||
this.initLock = {
|
||||
promise: this._initialize(),
|
||||
resolved: false,
|
||||
};
|
||||
}
|
||||
|
||||
await this.initLock.promise;
|
||||
}
|
||||
|
||||
private async _initialize(): Promise<void> {
|
||||
this.hiyaBreaker = new CircuitBreaker({
|
||||
failureThreshold: spamConfig.circuitBreakerThreshold,
|
||||
timeout: spamConfig.circuitBreakerTimeout,
|
||||
onStateChange: (state: CircuitState, previous: CircuitState) => {
|
||||
console.log(`[SpamShield] Hiya circuit: ${previous} -> ${state}`);
|
||||
},
|
||||
});
|
||||
this.truecallerBreaker = new CircuitBreaker({
|
||||
failureThreshold: spamConfig.circuitBreakerThreshold,
|
||||
timeout: spamConfig.circuitBreakerTimeout,
|
||||
onStateChange: (state: CircuitState, previous: CircuitState) => {
|
||||
console.log(`[SpamShield] Truecaller circuit: ${previous} -> ${state}`);
|
||||
},
|
||||
});
|
||||
this.initLock!.resolved = true;
|
||||
}
|
||||
|
||||
async checkReputation(phoneNumber: string): Promise<ReputationResult> {
|
||||
const validated = this.validatePhoneNumber(phoneNumber);
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
this.fetchHiyaReputation(validated),
|
||||
this.fetchTruecallerReputation(validated),
|
||||
]);
|
||||
|
||||
const hiyaResult = results[0];
|
||||
const truecallerResult = results[1];
|
||||
|
||||
const hiyaScore = hiyaResult.status === 'fulfilled' ? hiyaResult.value : undefined;
|
||||
const truecallerScore = truecallerResult.status === 'fulfilled' ? truecallerResult.value : undefined;
|
||||
|
||||
if (hiyaScore !== undefined && truecallerScore !== undefined) {
|
||||
const combinedScore = (hiyaScore + truecallerScore) / 2;
|
||||
const isSpam = combinedScore > spamConfig.defaultConfidenceThreshold;
|
||||
return {
|
||||
score: combinedScore,
|
||||
isSpam,
|
||||
source: 'combined',
|
||||
hiyaScore,
|
||||
truecallerScore,
|
||||
};
|
||||
}
|
||||
|
||||
if (hiyaScore !== undefined) {
|
||||
return {
|
||||
score: hiyaScore,
|
||||
isSpam: hiyaScore > spamConfig.defaultConfidenceThreshold,
|
||||
source: 'hiya',
|
||||
hiyaScore,
|
||||
};
|
||||
}
|
||||
|
||||
if (truecallerScore !== undefined) {
|
||||
return {
|
||||
score: truecallerScore,
|
||||
isSpam: truecallerScore > spamConfig.defaultConfidenceThreshold,
|
||||
source: 'truecaller',
|
||||
truecallerScore,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
score: 0,
|
||||
isSpam: false,
|
||||
source: 'fallback',
|
||||
};
|
||||
}
|
||||
|
||||
async analyzeCall(phoneNumber: string, callTimestamp: Date): Promise<{
|
||||
decision: 'BLOCK' | 'FLAG' | 'ALLOW';
|
||||
confidence: number;
|
||||
ruleMatches: string[];
|
||||
}> {
|
||||
const validated = this.validatePhoneNumber(phoneNumber);
|
||||
const rules = await this.getActiveRules();
|
||||
|
||||
const ruleMatches: string[] = [];
|
||||
let confidence = 0;
|
||||
|
||||
for (const rule of rules) {
|
||||
const pattern = new RegExp(rule.pattern);
|
||||
if (pattern.test(validated)) {
|
||||
ruleMatches.push(rule.id);
|
||||
confidence += 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
confidence = Math.min(confidence, 1.0);
|
||||
const decision = confidence > 0.8 ? 'BLOCK' : confidence > 0.5 ? 'FLAG' : 'ALLOW';
|
||||
|
||||
await prisma.spamAuditLog.create({
|
||||
data: {
|
||||
userId: 'system',
|
||||
phoneNumber: validated,
|
||||
decision: decision as any,
|
||||
reason: `Rule-based analysis`,
|
||||
ruleId: ruleMatches[0],
|
||||
},
|
||||
});
|
||||
|
||||
return { decision, confidence, ruleMatches };
|
||||
}
|
||||
|
||||
async recordFeedback(
|
||||
userId: string,
|
||||
phoneNumber: string,
|
||||
isSpam: boolean,
|
||||
label?: string
|
||||
): Promise<void> {
|
||||
const validated = this.validatePhoneNumber(phoneNumber);
|
||||
const encrypted = FieldEncryptionService.encrypt(validated);
|
||||
const hash = FieldEncryptionService.hashPhoneNumber(validated);
|
||||
|
||||
await prisma.spamFeedback.create({
|
||||
data: {
|
||||
userId,
|
||||
phoneNumber: encrypted,
|
||||
phoneNumberHash: hash,
|
||||
isSpam,
|
||||
label,
|
||||
metadata: JSON.stringify({ source: 'user_feedback' }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getCircuitMetrics(): CircuitMetrics {
|
||||
return {
|
||||
hiya: this.hiyaBreaker.getMetrics(),
|
||||
truecaller: this.truecallerBreaker.getMetrics(),
|
||||
};
|
||||
}
|
||||
|
||||
resetCircuits(): void {
|
||||
this.hiyaBreaker.reset();
|
||||
this.truecallerBreaker.reset();
|
||||
}
|
||||
|
||||
private async fetchHiyaReputation(phoneNumber: string): Promise<number> {
|
||||
if (!spamFeatureFlags.enableHiyaIntegration) {
|
||||
throw new Error('Hiya integration disabled');
|
||||
}
|
||||
|
||||
return this.hiyaBreaker.execute(
|
||||
async () => {
|
||||
const url = `https://api.hiya.com/reputation/${encodeURIComponent(phoneNumber)}`;
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${process.env.HIYA_API_KEY}`,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Hiya API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json() as { spamScore?: number; reputation?: { score?: number } };
|
||||
const score = data.spamScore ?? data.reputation?.score ?? 0;
|
||||
return score;
|
||||
},
|
||||
() => {
|
||||
console.log('[SpamShield] Hiya fallback: circuit OPEN, returning neutral score');
|
||||
return 0.5;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private async fetchTruecallerReputation(phoneNumber: string): Promise<number> {
|
||||
if (!spamFeatureFlags.enableTruecallerIntegration) {
|
||||
throw new Error('Truecaller integration disabled');
|
||||
}
|
||||
|
||||
return this.truecallerBreaker.execute(
|
||||
async () => {
|
||||
const url = `https://redirect.truecaller.com/api/v2-ac/absolute/${encodeURIComponent(phoneNumber)}`;
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'contentType': 'lookupNumber',
|
||||
'Authorization': `Basic ${Buffer.from(process.env.TRUECALLER_API_KEY || '').toString('base64')}`,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Truecaller API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json() as { spamProbability?: number; spam_type?: number };
|
||||
const probability = data.spamProbability ?? (data.spam_type ? 0.8 : 0);
|
||||
return probability;
|
||||
},
|
||||
() => {
|
||||
console.log('[SpamShield] Truecaller fallback: circuit OPEN, returning neutral score');
|
||||
return 0.5;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private validatePhoneNumber(phoneNumber: string): string {
|
||||
return validateE164(phoneNumber);
|
||||
}
|
||||
|
||||
private async getActiveRules(): Promise<Array<{ id: string; pattern: string }>> {
|
||||
return prisma.spamRule.findMany({
|
||||
where: { isActive: true },
|
||||
select: { id: true, pattern: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
25
services/spamshield/src/utils/phone-validation.ts
Normal file
25
services/spamshield/src/utils/phone-validation.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { isValidPhoneNumber, parsePhoneNumber } from 'libphonenumber-js';
|
||||
|
||||
export class PhoneNumberValidationError extends Error {
|
||||
constructor(public readonly originalInput: string) {
|
||||
super(
|
||||
`Invalid E.164 phone number format: ${originalInput}. Expected format: +[country code][number] (e.g., +14155552671)`
|
||||
);
|
||||
this.name = 'PhoneNumberValidationError';
|
||||
}
|
||||
}
|
||||
|
||||
export function validatePhoneNumber(phoneNumber: string): string {
|
||||
const trimmed = phoneNumber.trim();
|
||||
|
||||
if (!isValidPhoneNumber(trimmed)) {
|
||||
throw new PhoneNumberValidationError(phoneNumber);
|
||||
}
|
||||
|
||||
const parsed = parsePhoneNumber(trimmed);
|
||||
if (!parsed || !parsed.number) {
|
||||
throw new PhoneNumberValidationError(phoneNumber);
|
||||
}
|
||||
|
||||
return parsed.number;
|
||||
}
|
||||
285
services/spamshield/test/circuit-breaker.test.ts
Normal file
285
services/spamshield/test/circuit-breaker.test.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { CircuitBreaker, CircuitBreakerError, CircuitState } from '../src/circuit-breaker';
|
||||
|
||||
const fail = async () => { throw new Error('fail'); };
|
||||
const success = async () => 'ok';
|
||||
|
||||
async function executeOrFail<T>(breaker: CircuitBreaker, fn: () => Promise<T>, fallback?: () => T): Promise<T | Error> {
|
||||
try {
|
||||
return await breaker.execute(fn, fallback);
|
||||
} catch (e) {
|
||||
return e as Error;
|
||||
}
|
||||
}
|
||||
|
||||
describe('CircuitBreaker', () => {
|
||||
let stateChanges: Array<{ state: CircuitState; previous: CircuitState }>;
|
||||
|
||||
beforeEach(() => {
|
||||
stateChanges = [];
|
||||
});
|
||||
|
||||
describe('initial state', () => {
|
||||
it('starts as CLOSED', () => {
|
||||
const breaker = new CircuitBreaker();
|
||||
expect(breaker.getState()).toBe('CLOSED');
|
||||
});
|
||||
|
||||
it('uses default thresholds', () => {
|
||||
const breaker = new CircuitBreaker();
|
||||
const metrics = breaker.getMetrics();
|
||||
expect(metrics.failureCount).toBe(0);
|
||||
expect(metrics.successCount).toBe(0);
|
||||
expect(metrics.totalExecutions).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom configuration', () => {
|
||||
it('accepts custom failure threshold', async () => {
|
||||
const breaker = new CircuitBreaker({ failureThreshold: 3 });
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await executeOrFail(breaker, fail);
|
||||
}
|
||||
expect(breaker.getState()).toBe('OPEN');
|
||||
});
|
||||
|
||||
it('accepts custom timeout', () => {
|
||||
const breaker = new CircuitBreaker({ timeout: 1000 });
|
||||
expect(breaker.getState()).toBe('CLOSED');
|
||||
});
|
||||
|
||||
it('calls onStateChange callback on transitions', async () => {
|
||||
const breaker = new CircuitBreaker({
|
||||
failureThreshold: 2,
|
||||
onStateChange: (state, previous) => {
|
||||
stateChanges.push({ state, previous });
|
||||
},
|
||||
});
|
||||
|
||||
await executeOrFail(breaker, fail);
|
||||
await executeOrFail(breaker, fail);
|
||||
|
||||
expect(stateChanges).toHaveLength(1);
|
||||
expect(stateChanges[0]).toEqual({ state: 'OPEN', previous: 'CLOSED' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('state transitions', () => {
|
||||
it('transitions to OPEN after reaching failure threshold', async () => {
|
||||
const breaker = new CircuitBreaker({ failureThreshold: 3 });
|
||||
|
||||
await executeOrFail(breaker, fail);
|
||||
await executeOrFail(breaker, fail);
|
||||
expect(breaker.getState()).toBe('CLOSED');
|
||||
|
||||
await executeOrFail(breaker, fail);
|
||||
expect(breaker.getState()).toBe('OPEN');
|
||||
});
|
||||
|
||||
it('transitions from OPEN to HALF_OPEN after timeout', async () => {
|
||||
const breaker = new CircuitBreaker({
|
||||
failureThreshold: 2,
|
||||
timeout: 200,
|
||||
});
|
||||
|
||||
await executeOrFail(breaker, fail);
|
||||
await executeOrFail(breaker, fail);
|
||||
expect(breaker.getState()).toBe('OPEN');
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
expect(breaker.getState()).toBe('HALF_OPEN');
|
||||
});
|
||||
|
||||
it('transitions from HALF_OPEN to CLOSED after success threshold', async () => {
|
||||
const breaker = new CircuitBreaker({
|
||||
failureThreshold: 2,
|
||||
successThreshold: 3,
|
||||
timeout: 100,
|
||||
});
|
||||
|
||||
await executeOrFail(breaker, fail);
|
||||
await executeOrFail(breaker, fail);
|
||||
expect(breaker.getState()).toBe('OPEN');
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
|
||||
const r1 = await breaker.execute(success);
|
||||
const r2 = await breaker.execute(success);
|
||||
expect(r1).toBe('ok');
|
||||
expect(r2).toBe('ok');
|
||||
expect(breaker.getState()).toBe('HALF_OPEN');
|
||||
|
||||
const r3 = await breaker.execute(success);
|
||||
expect(r3).toBe('ok');
|
||||
expect(breaker.getState()).toBe('CLOSED');
|
||||
});
|
||||
|
||||
it('transitions from HALF_OPEN back to OPEN on failure', async () => {
|
||||
const breaker = new CircuitBreaker({
|
||||
failureThreshold: 2,
|
||||
timeout: 100,
|
||||
});
|
||||
|
||||
await executeOrFail(breaker, fail);
|
||||
await executeOrFail(breaker, fail);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
expect(breaker.getState()).toBe('HALF_OPEN');
|
||||
|
||||
await executeOrFail(breaker, fail);
|
||||
expect(breaker.getState()).toBe('OPEN');
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute with fallback', () => {
|
||||
it('returns fallback value when circuit is OPEN', async () => {
|
||||
const breaker = new CircuitBreaker({ failureThreshold: 2 });
|
||||
|
||||
await executeOrFail(breaker, fail);
|
||||
await executeOrFail(breaker, fail);
|
||||
|
||||
const result = await breaker.execute(
|
||||
async () => { throw new Error('should not reach'); },
|
||||
() => 'fallback-value'
|
||||
);
|
||||
|
||||
expect(result).toBe('fallback-value');
|
||||
});
|
||||
|
||||
it('returns fallback value when API throws in OPEN state', async () => {
|
||||
const breaker = new CircuitBreaker({ failureThreshold: 1 });
|
||||
|
||||
await executeOrFail(breaker, fail);
|
||||
|
||||
const originalFn = vi.fn(() => { throw new Error('api error'); });
|
||||
const fallbackFn = vi.fn(() => 0.5);
|
||||
|
||||
const result = await breaker.execute(originalFn, fallbackFn);
|
||||
expect(result).toBe(0.5);
|
||||
expect(fallbackFn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('executes function normally when circuit is CLOSED', async () => {
|
||||
const breaker = new CircuitBreaker({ failureThreshold: 3 });
|
||||
const fn = vi.fn(async () => 'success');
|
||||
|
||||
const result = await breaker.execute(fn, () => 'fallback');
|
||||
expect(result).toBe('success');
|
||||
expect(fn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses fallback when circuit is OPEN', async () => {
|
||||
const breaker = new CircuitBreaker({ failureThreshold: 1 });
|
||||
await executeOrFail(breaker, fail);
|
||||
|
||||
const fn = vi.fn(async () => 'original');
|
||||
const fallback = vi.fn(() => 'fallback-value');
|
||||
|
||||
const result = await breaker.execute(fn, fallback);
|
||||
expect(result).toBe('fallback-value');
|
||||
expect(fallback).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws CircuitBreakerError when OPEN and no fallback', async () => {
|
||||
const breaker = new CircuitBreaker({ failureThreshold: 1 });
|
||||
await executeOrFail(breaker, fail);
|
||||
|
||||
const result = await executeOrFail(breaker, async () => 'value');
|
||||
expect(result).toBeInstanceOf(CircuitBreakerError);
|
||||
expect((result as CircuitBreakerError).state).toBe('OPEN');
|
||||
});
|
||||
|
||||
it('throws original error when fallback also fails in CLOSED state', async () => {
|
||||
const breaker = new CircuitBreaker({ failureThreshold: 5 });
|
||||
|
||||
const originalError = new Error('api error');
|
||||
const result = await executeOrFail(
|
||||
breaker,
|
||||
async () => { throw originalError; },
|
||||
() => { throw new Error('fallback error'); }
|
||||
);
|
||||
expect(result).toBe(originalError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('metrics', () => {
|
||||
it('tracks total executions', async () => {
|
||||
const breaker = new CircuitBreaker();
|
||||
await breaker.execute(success);
|
||||
await breaker.execute(success);
|
||||
|
||||
const metrics = breaker.getMetrics();
|
||||
expect(metrics.totalExecutions).toBe(2);
|
||||
expect(metrics.totalSuccesses).toBe(2);
|
||||
expect(metrics.totalFailures).toBe(0);
|
||||
});
|
||||
|
||||
it('tracks failures', async () => {
|
||||
const breaker = new CircuitBreaker({ failureThreshold: 5 });
|
||||
await executeOrFail(breaker, fail);
|
||||
|
||||
const metrics = breaker.getMetrics();
|
||||
expect(metrics.totalExecutions).toBe(1);
|
||||
expect(metrics.totalFailures).toBe(1);
|
||||
expect(metrics.failureCount).toBe(1);
|
||||
});
|
||||
|
||||
it('includes state change timestamp', () => {
|
||||
const breaker = new CircuitBreaker();
|
||||
const metrics = breaker.getMetrics();
|
||||
expect(metrics.stateChangedAt).toBeDefined();
|
||||
expect(metrics.stateChangedAt!.getTime()).toBeGreaterThan(Date.now() - 1000);
|
||||
});
|
||||
|
||||
it('tracks last failure and success times', async () => {
|
||||
const breaker = new CircuitBreaker({ failureThreshold: 5 });
|
||||
const before = Date.now();
|
||||
|
||||
await breaker.execute(success);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
await executeOrFail(breaker, fail);
|
||||
|
||||
const metrics = breaker.getMetrics();
|
||||
expect(metrics.lastSuccessTime!.getTime()).toBeGreaterThanOrEqual(before);
|
||||
expect(metrics.lastFailureTime!.getTime()).toBeGreaterThanOrEqual(metrics.lastSuccessTime!.getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset', () => {
|
||||
it('resets circuit to CLOSED state', async () => {
|
||||
const breaker = new CircuitBreaker({ failureThreshold: 2 });
|
||||
|
||||
await executeOrFail(breaker, fail);
|
||||
await executeOrFail(breaker, fail);
|
||||
expect(breaker.getState()).toBe('OPEN');
|
||||
|
||||
breaker.reset();
|
||||
expect(breaker.getState()).toBe('CLOSED');
|
||||
|
||||
const metrics = breaker.getMetrics();
|
||||
expect(metrics.failureCount).toBe(0);
|
||||
expect(metrics.successCount).toBe(0);
|
||||
});
|
||||
|
||||
it('allows execution after reset', async () => {
|
||||
const breaker = new CircuitBreaker({ failureThreshold: 1 });
|
||||
await executeOrFail(breaker, fail);
|
||||
|
||||
breaker.reset();
|
||||
const result = await breaker.execute(success);
|
||||
expect(result).toBe('ok');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CircuitBreakerError', () => {
|
||||
it('includes circuit state in error', async () => {
|
||||
const breaker = new CircuitBreaker({ failureThreshold: 1 });
|
||||
await executeOrFail(breaker, fail);
|
||||
|
||||
const result = await executeOrFail(breaker, success);
|
||||
expect(result).toBeInstanceOf(CircuitBreakerError);
|
||||
expect((result as CircuitBreakerError).state).toBe('OPEN');
|
||||
expect((result as CircuitBreakerError).message).toContain('OPEN');
|
||||
});
|
||||
});
|
||||
});
|
||||
309
services/spamshield/test/spamshield.test.ts
Normal file
309
services/spamshield/test/spamshield.test.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { SpamShieldService } from '../src/services/spamshield.service';
|
||||
import { spamConfig } from '../src/config/spamshield.config';
|
||||
import { validatePhoneNumber } from '../src/utils/phone-validation';
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch as unknown as typeof global.fetch;
|
||||
|
||||
describe('SpamShieldService', () => {
|
||||
let service: SpamShieldService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = SpamShieldService.getInstance();
|
||||
service.resetCircuits();
|
||||
mockFetch.mockReset();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('checkReputation', () => {
|
||||
it('combines scores from both Hiya and Truecaller', async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ spamScore: 0.8 }),
|
||||
} as Response)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ spamProbability: 0.9 }),
|
||||
} as Response);
|
||||
|
||||
const result = await service.checkReputation('+14155552671');
|
||||
|
||||
expect(result.source).toBe('combined');
|
||||
expect(result.score).toBeCloseTo(0.85, 2);
|
||||
expect(result.isSpam).toBe(true);
|
||||
expect(result.hiyaScore).toBe(0.8);
|
||||
expect(result.truecallerScore).toBe(0.9);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('uses Hiya score + Truecaller fallback when Truecaller API fails', async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ spamScore: 0.6 }),
|
||||
} as Response)
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
} as Response);
|
||||
|
||||
const result = await service.checkReputation('+14155552671');
|
||||
|
||||
expect(result.source).toBe('combined');
|
||||
expect(result.hiyaScore).toBe(0.6);
|
||||
expect(result.truecallerScore).toBe(0.5);
|
||||
});
|
||||
|
||||
it('uses Hiya fallback + Truecaller score when Hiya API fails', async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 429,
|
||||
statusText: 'Too Many Requests',
|
||||
} as Response)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ spamProbability: 0.3 }),
|
||||
} as Response);
|
||||
|
||||
const result = await service.checkReputation('+14155552671');
|
||||
|
||||
expect(result.source).toBe('combined');
|
||||
expect(result.hiyaScore).toBe(0.5);
|
||||
expect(result.truecallerScore).toBe(0.3);
|
||||
});
|
||||
|
||||
it('uses both fallbacks when both APIs fail', async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 503,
|
||||
statusText: 'Service Unavailable',
|
||||
} as Response)
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 503,
|
||||
statusText: 'Service Unavailable',
|
||||
} as Response);
|
||||
|
||||
const result = await service.checkReputation('+14155552671');
|
||||
|
||||
expect(result.source).toBe('combined');
|
||||
expect(result.hiyaScore).toBe(0.5);
|
||||
expect(result.truecallerScore).toBe(0.5);
|
||||
expect(result.score).toBe(0.5);
|
||||
expect(result.isSpam).toBe(false);
|
||||
});
|
||||
|
||||
it('validates phone number E.164 format', async () => {
|
||||
const shortNumber = '123';
|
||||
const result = service.checkReputation(shortNumber);
|
||||
await expect(result).rejects.toThrow('Invalid E.164 phone number format');
|
||||
});
|
||||
|
||||
it('returns non-spam for low scores', async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ spamScore: 0.2 }),
|
||||
} as Response)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ spamProbability: 0.1 }),
|
||||
} as Response);
|
||||
|
||||
const result = await service.checkReputation('+14155552671');
|
||||
|
||||
expect(result.isSpam).toBe(false);
|
||||
expect(result.score).toBeCloseTo(0.15, 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('circuit breaker integration', () => {
|
||||
it('opens circuit after consecutive failures', async () => {
|
||||
const metricsBefore = service.getCircuitMetrics();
|
||||
expect(metricsBefore.hiya.state).toBe('CLOSED');
|
||||
|
||||
for (let i = 0; i < spamConfig.circuitBreakerThreshold; i++) {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Server Error',
|
||||
} as Response)
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Server Error',
|
||||
} as Response);
|
||||
await service.checkReputation('+14155552671');
|
||||
}
|
||||
|
||||
const metricsAfter = service.getCircuitMetrics();
|
||||
expect(metricsAfter.hiya.totalFailures).toBeGreaterThanOrEqual(spamConfig.circuitBreakerThreshold);
|
||||
expect(metricsAfter.truecaller.totalFailures).toBeGreaterThanOrEqual(spamConfig.circuitBreakerThreshold);
|
||||
});
|
||||
|
||||
it('exposes circuit metrics for monitoring', () => {
|
||||
const metrics = service.getCircuitMetrics();
|
||||
|
||||
expect(metrics.hiya).toHaveProperty('state', 'CLOSED');
|
||||
expect(metrics.hiya).toHaveProperty('failureCount');
|
||||
expect(metrics.hiya).toHaveProperty('successCount');
|
||||
expect(metrics.hiya).toHaveProperty('totalExecutions');
|
||||
expect(metrics.hiya).toHaveProperty('totalFailures');
|
||||
expect(metrics.hiya).toHaveProperty('totalSuccesses');
|
||||
expect(metrics.hiya).toHaveProperty('lastFailureTime');
|
||||
expect(metrics.hiya).toHaveProperty('lastSuccessTime');
|
||||
expect(metrics.hiya).toHaveProperty('stateChangedAt');
|
||||
|
||||
expect(metrics.truecaller).toHaveProperty('state', 'CLOSED');
|
||||
});
|
||||
|
||||
it('resets circuits to CLOSED state', () => {
|
||||
service.resetCircuits();
|
||||
const metrics = service.getCircuitMetrics();
|
||||
expect(metrics.hiya.state).toBe('CLOSED');
|
||||
expect(metrics.truecaller.state).toBe('CLOSED');
|
||||
});
|
||||
|
||||
it('returns fallback scores when circuits are open', async () => {
|
||||
service.resetCircuits();
|
||||
|
||||
for (let i = 0; i < spamConfig.circuitBreakerThreshold; i++) {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Server Error',
|
||||
} as Response)
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Server Error',
|
||||
} as Response);
|
||||
await service.checkReputation('+14155552671');
|
||||
}
|
||||
|
||||
const metrics = service.getCircuitMetrics();
|
||||
expect(metrics.hiya.state).toBe('OPEN');
|
||||
expect(metrics.truecaller.state).toBe('OPEN');
|
||||
|
||||
const result = await service.checkReputation('+14155552671');
|
||||
expect(result.hiyaScore).toBe(0.5);
|
||||
expect(result.truecallerScore).toBe(0.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('E.164 phone number validation', () => {
|
||||
describe('valid E.164 formats', () => {
|
||||
it('accepts US number in E.164 format', () => {
|
||||
expect(() => validatePhoneNumber('+14155552671')).not.toThrow();
|
||||
});
|
||||
|
||||
it('accepts UK number in E.164 format', () => {
|
||||
expect(() => validatePhoneNumber('+442071234567')).not.toThrow();
|
||||
});
|
||||
|
||||
it('accepts German number in E.164 format', () => {
|
||||
expect(() => validatePhoneNumber('+4930123456789')).not.toThrow();
|
||||
});
|
||||
|
||||
it('accepts Japanese number in E.164 format', () => {
|
||||
expect(() => validatePhoneNumber('+81312345678')).not.toThrow();
|
||||
});
|
||||
|
||||
it('accepts Australian number in E.164 format', () => {
|
||||
expect(() => validatePhoneNumber('+61412345678')).not.toThrow();
|
||||
});
|
||||
|
||||
it('accepts Indian number in E.164 format', () => {
|
||||
expect(() => validatePhoneNumber('+919876543210')).not.toThrow();
|
||||
});
|
||||
|
||||
it('accepts Brazilian number in E.164 format', () => {
|
||||
expect(() => validatePhoneNumber('+5511987654321')).not.toThrow();
|
||||
});
|
||||
|
||||
it('accepts number with spaces and normalizes', () => {
|
||||
const result = validatePhoneNumber('+1 415 555 2671');
|
||||
expect(result).toBe('+14155552671');
|
||||
});
|
||||
|
||||
it('accepts number with dashes and normalizes', () => {
|
||||
const result = validatePhoneNumber('+1-415-555-2671');
|
||||
expect(result).toBe('+14155552671');
|
||||
});
|
||||
|
||||
it('accepts number with parentheses and normalizes', () => {
|
||||
const result = validatePhoneNumber('+1 (415) 555-2671');
|
||||
expect(result).toBe('+14155552671');
|
||||
});
|
||||
|
||||
it('trims whitespace from input', () => {
|
||||
expect(() => validatePhoneNumber(' +14155552671 ')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid E.164 formats', () => {
|
||||
it('rejects number without plus sign', () => {
|
||||
expect(() => validatePhoneNumber('14155552671')).toThrow('Invalid E.164 phone number format');
|
||||
});
|
||||
|
||||
it('rejects number with letters', () => {
|
||||
expect(() => validatePhoneNumber('+1415555ABCD')).toThrow('Invalid E.164 phone number format');
|
||||
});
|
||||
|
||||
it('rejects empty string', () => {
|
||||
expect(() => validatePhoneNumber('')).toThrow('Invalid E.164 phone number format');
|
||||
});
|
||||
|
||||
it('rejects too-short number', () => {
|
||||
expect(() => validatePhoneNumber('+123')).toThrow('Invalid E.164 phone number format');
|
||||
});
|
||||
|
||||
it('rejects number exceeding 15 digits', () => {
|
||||
expect(() => validatePhoneNumber('+1234567890123456')).toThrow('Invalid E.164 phone number format');
|
||||
});
|
||||
|
||||
it('rejects number with special characters in middle', () => {
|
||||
expect(() => validatePhoneNumber('+1@4155552671')).toThrow('Invalid E.164 phone number format');
|
||||
});
|
||||
|
||||
it('rejects double plus sign', () => {
|
||||
expect(() => validatePhoneNumber('++14155552671')).toThrow('Invalid E.164 phone number format');
|
||||
});
|
||||
|
||||
it('rejects only whitespace', () => {
|
||||
expect(() => validatePhoneNumber(' ')).toThrow('Invalid E.164 phone number format');
|
||||
});
|
||||
|
||||
it('rejects number starting with zero after plus', () => {
|
||||
expect(() => validatePhoneNumber('+01234567890')).toThrow('Invalid E.164 phone number format');
|
||||
});
|
||||
|
||||
it('rejects negative number format', () => {
|
||||
expect(() => validatePhoneNumber('-14155552671')).toThrow('Invalid E.164 phone number format');
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration with service methods', () => {
|
||||
it('rejects invalid format in checkReputation', async () => {
|
||||
await expect(service.checkReputation('4155552671')).rejects.toThrow('Invalid E.164 phone number format');
|
||||
});
|
||||
|
||||
it('rejects invalid format in analyzeCall', async () => {
|
||||
const result = service.analyzeCall('4155552671', new Date());
|
||||
await expect(result).rejects.toThrow('Invalid E.164 phone number format');
|
||||
});
|
||||
|
||||
it('rejects invalid format in recordFeedback', async () => {
|
||||
const result = service.recordFeedback('user123', '4155552671', true);
|
||||
await expect(result).rejects.toThrow('Invalid E.164 phone number format');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
25
services/spamshield/tsconfig.json
Normal file
25
services/spamshield/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"baseUrl": "./src",
|
||||
"paths": {
|
||||
"@shieldai/db": ["../../packages/db/src/index.ts"],
|
||||
"@shieldai/db/*": ["../../packages/db/src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
44
services/voiceprint/Dockerfile
Normal file
44
services/voiceprint/Dockerfile
Normal file
@@ -0,0 +1,44 @@
|
||||
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/types/tsconfig.json ./packages/types/
|
||||
COPY packages/db/tsconfig.json ./packages/db/
|
||||
COPY services/voiceprint/tsconfig.json ./services/voiceprint/
|
||||
COPY services/voiceprint/ ./services/voiceprint/
|
||||
COPY packages/types/ ./packages/types/
|
||||
COPY packages/db/ ./packages/db/
|
||||
|
||||
RUN npm run build --workspace=@shieldai/types --workspace=@shieldai/db --workspace=@shieldai/voiceprint
|
||||
|
||||
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/services/voiceprint/dist ./dist
|
||||
COPY --from=builder --chown=shieldai:nodejs /app/node_modules ./node_modules
|
||||
COPY --from=builder --chown=shieldai:nodejs /app/services/voiceprint/package.json ./package.json
|
||||
COPY --from=builder --chown=shieldai:nodejs /app/packages/db ./packages/db
|
||||
|
||||
USER shieldai
|
||||
|
||||
EXPOSE 3003
|
||||
|
||||
CMD ["node", "dist/index.js"]
|
||||
Reference in New Issue
Block a user