feat: establish unified project foundation with root config cleanup

- Archive legacy packages/, services/, server/ directories
- Update pnpm workspace to web + browser-ext
- Simplify root package.json scripts to delegate to web/
- Update turbo.json for new workspace structure
- Remove obsolete root config files (vite, tsconfig, etc.)
- Add .nvmrc, .editorconfig for consistent dev environment
- Update CI workflow to remove references to deleted packages
- Add missing dependencies (@tailwindcss/vite, tailwindcss) to web
- Add test and lint scripts to web package
- Verify pnpm install, build, and dev work correctly
This commit is contained in:
2026-05-25 12:31:43 -04:00
parent 59fcc31483
commit f627033665
500 changed files with 622 additions and 99592 deletions

12
.editorconfig Normal file
View File

@@ -0,0 +1,12 @@
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false

View File

@@ -89,8 +89,8 @@ jobs:
version: ${{ env.PNPM_VERSION }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run tests with coverage
run: pnpm test:coverage
- name: Run tests
run: pnpm test
env:
DATABASE_URL: "postgresql://shieldai:shieldai_dev@localhost:5432/shieldai"
REDIS_URL: "redis://localhost:6379"
@@ -106,22 +106,6 @@ jobs:
name: Docker Build
runs-on: ubuntu-latest
needs: [lint, typecheck, test]
strategy:
fail-fast: false
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: Docker Buildx
@@ -129,10 +113,9 @@ jobs:
- name: Build Docker image
uses: docker/build-push-action@v5
with:
context: ${{ matrix.context }}
file: ${{ matrix.dockerfile }}
context: .
push: false
tags: shieldai-${{ matrix.name }}:${{ github.sha }}
tags: shieldai:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max

3
.gitignore vendored
View File

@@ -1,7 +1,8 @@
node_modules
dist
.output
.env
*.log
.DS_Store
load-tests/voiceprint/results/
.turbo
.nitro

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
22

View File

@@ -1,38 +0,0 @@
# Build stage
FROM node:18-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
COPY apps/ ./apps/
COPY packages/ ./packages/
# Install dependencies
RUN npm ci
# Build all packages
RUN npm run build
# Production stage
FROM node:18-alpine AS production
WORKDIR /app
# Copy package files
COPY package*.json ./
COPY apps/ ./apps/
COPY packages/ ./packages/
# Copy built artifacts from builder
COPY --from=builder /app/apps/web/dist ./apps/web/dist
COPY --from=builder /app/apps/api/dist ./apps/api/dist
# Install production dependencies only
RUN npm ci --production
# Expose port
EXPOSE 3000
# Start the API server
CMD ["node", "apps/api/dist/index.js"]

View File

@@ -1,50 +0,0 @@
const http = require('http');
const agentId = process.env.PAPERCLIP_AGENT_ID;
const apiKey = process.env.PAPERCLIP_API_KEY;
const apiUrl = process.env.PAPERCLIP_API_URL;
const runId = process.env.PAPERCLIP_RUN_ID;
console.log('Agent ID:', agentId);
console.log('API URL:', apiUrl);
console.log('Run ID:', runId);
if (!apiKey || !apiUrl) {
console.error('Missing environment variables');
process.exit(1);
}
async function fetchJson(url, options = {}) {
const request = http.request({
hostname: new URL(url).hostname,
port: new URL(url).port,
path: new URL(url).pathname,
method: options.method || 'GET',
headers: {
'Authorization': `Bearer ${apiKey}`,
'X-Paperclip-Run-Id': runId,
...options.headers
}
}, (response) => {
let data = '';
response.on('data', chunk => data += chunk);
response.on('end', () => {
try {
console.log(JSON.stringify(JSON.parse(data), null, 2));
} catch (e) {
console.log(data);
}
});
});
request.on('error', console.error);
request.end();
}
console.log('\n=== FETCHING AGENT IDENTITY ===\n');
fetchJson(`${apiUrl}/api/agents/me`).catch(console.error);
console.log('\n=== FETCHING INBOX-LITE ===\n');
fetchJson(`${apiUrl}/api/agents/me/inbox-lite`).catch(console.error);
console.log('\n=== FETCHING ALL ASSIGNED ISSUES ===\n');
fetchJson(`${apiUrl}/api/companies/${apiKey.split('-')[0] || 'unknown'}/issues?assigneeAgentId=${agentId}&status=todo,in_progress,blocked`).catch(console.error);

View File

@@ -1,142 +0,0 @@
version: '3.9'
x-monitoring: &monitoring
DD_ENV: ${DD_ENV:-production}
DD_SERVICE: ${DD_SERVICE:-shieldai}
DD_VERSION: ${DOCKER_TAG:-latest}
DD_TRACE_ENABLED: ${DD_TRACE_ENABLED:-true}
DD_AGENT_HOST: datadog-agent
DD_AGENT_PORT: "8126"
DD_LOGS_INJECTION: "true"
SENTRY_DSN: ${SENTRY_DSN:-}
SENTRY_ENVIRONMENT: ${DD_ENV:-production}
SENTRY_RELEASE: ${DOCKER_TAG:-latest}
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}
<<: *monitoring
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}
DD_SERVICE: "shieldai-darkwatch"
<<: *monitoring
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"
DD_SERVICE: "shieldai-spamshield"
<<: *monitoring
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"
DD_SERVICE: "shieldai-voiceprint"
<<: *monitoring
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
networks:
- shieldai
datadog-agent:
image: datadog/agent:7
restart: unless-stopped
environment:
DD_API_KEY: ${DD_API_KEY}
DD_SITE: ${DD_SITE:-datadoghq.com}
DD_ENV: ${DD_ENV:-production}
DD_DOGSTATSD_NON_LOCAL_TRAFFIC: "true"
DD_APM_ENABLED: "true"
DD_APM_NON_LOCAL_TRAFFIC: "true"
DD_LOGS_ENABLED: "true"
DD_LOGS_CONFIG_CONTAINER_COLLECT_ALL: "true"
DD_HEALTH_PORT_ENABLE: "true"
ports:
- "8125:8125/udp"
- "8126:8126"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- /proc/:/host/proc/:ro
- /sys/fs/cgroup:/host/sys/fs/cgroup:ro
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

View File

@@ -1,53 +0,0 @@
version: '3.8'
services:
postgres:
image: postgres:15-alpine
container_name: shieldsai_postgres
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: shieldsai_dev
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: shieldsai_redis
ports:
- "6379:6379"
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
mailhog:
image: mailhog/mailhog:latest
container_name: shieldsai_mailhog
ports:
- "1025:1025" # SMTP
- "8025:8025" # Web UI
depends_on:
- postgres
adminer:
image: adminer:4
container_name: shieldsai_adminer
ports:
- "8080:8080"
depends_on:
- postgres
volumes:
postgres_data:
redis_data:

View File

@@ -1,149 +0,0 @@
# ShieldAI Mixpanel Analytics Configuration
## Implementation Complete ✅
The Mixpanel analytics infrastructure is fully implemented and ready for use. This document covers setup and usage.
---
## What's Implemented
### Backend (`packages/shared-analytics`)
- **MixpanelService**: Full implementation using Segment analytics-node SDK
- **Event Taxonomy**: 30+ events defined in `EventType` enum covering:
- **User Events**: `user_signed_up`, `user_logged_in`, `user_upgraded`, `user_downgraded`
- **Subscription Events**: `subscription_created`, `subscription_updated`, `subscription_cancelled`, `subscription_renewed`
- **DarkWatch Events**: `dark_web_scan_started`, `dark_web_scan_completed`, `exposure_detected`, `exposure_resolved`, `watchlist_item_added`, `watchlist_item_removed`
- **VoicePrint Events**: `voice_enrolled`, `voice_analyzed`, `voice_match_found`, `synthetic_voice_detected`
- **SpamShield Events**: `call_analyzed`, `sms_analyzed`, `spam_blocked`, `spam_flagged`, `spam_feedback_submitted`
- **KPI Events**: `mrr_updated`, `conversion_occurred`, `churn_occurred`, `referral_sent`, `referral_converted`
- **User Identification**: `identify()` and `group()` methods for user segmentation
- **KPI Definitions**: MAU, MRR, conversion rate, churn, CAC, LTV, NPS, viral coefficient
- **Data Validation**: Zod schema for event properties
### Frontend (`packages/web/src/hooks/useAnalytics.ts`)
- **Initialization**: Auto-loads Mixpanel script via `initMixpanel()`
- **Event Tracking**: `trackEvent(name, params)` for generic events
- **Page View Tracking**: `trackPageView(path, title)` with automatic page title
- **Waitlist Tracking**: `trackWaitlistSignup(email, source, tier)` with Meta Pixel integration
- **Multi-Platform Support**: GA4, Meta Pixel, LinkedIn Insight included
---
## Setup Instructions
### Step 1: Create Mixpanel Account (Manual)
1. Go to https://mixpanel.com
2. Sign up for an account (free tier: 100K monthly tracked users)
3. Create a new project named "ShieldAI"
4. Copy the **Project Token** from Project Settings → Project Token
### Step 2: Configure Backend Environment Variables
Add to `ShieldAI/.env`:
```bash
# Mixpanel (Backend)
MIXPANEL_TOKEN=<your-mixpanel-project-token>
MIXPANEL_API_SECRET=<optional-api-secret-for-server-side>
ANALYTICS_ENV=development # or production, staging
```
### Step 3: Configure Frontend Environment Variables
Create or update `ShieldAI/packages/web/.env`:
```bash
# Mixpanel (Frontend - Vite)
VITE_MIXPANEL_TOKEN=<same-project-token-as-backend>
```
### Step 4: Verify Integration
Backend usage example:
```typescript
import { mixpanelService, EventType } from '@shieldsai/shared-analytics';
// Track an event
await mixpanelService.track(EventType.USER_SIGNED_UP, userId, {
plan: 'premium',
referrer: 'google',
});
// Identify a user
await mixpanelService.identify(userId, {
email: 'user@example.com',
tier: 'premium',
});
```
Frontend usage example:
```typescript
import { trackEvent, trackWaitlistSignup, trackPageView } from './hooks/useAnalytics';
// Track a page view
trackPageView('/waitlist', 'Waitlist Signup');
// Track a signup
trackWaitlistSignup('user@example.com', 'landing_page', 'free');
// Track a custom event
trackEvent('feature_used', { feature: 'darkwatch_scan', duration: 45 });
```
---
## Event Properties Schema
All events support these standard properties (validated via Zod):
- `userId` (string, optional): User identifier
- `sessionId` (string, optional): Session identifier
- `timestamp` (Date, optional): Event timestamp
- `platform` (enum: 'web' | 'mobile' | 'desktop' | 'api', optional): Event source
- `version` (string, optional): App version
- `environment` (string, optional): deployment environment
Additional custom properties are accepted and passed through to Mixpanel.
---
## KPI Definitions
| KPI | Description | Calculation |
|-----|-------------|-------------|
| MAU | Monthly Active Users | COUNT(DISTINCT userId) WHERE timestamp > NOW() - 30 DAYS |
| Paying Users | Active subscriptions | COUNT(DISTINCT userId) WHERE subscription.status = "active" |
| MRR | Monthly Recurring Revenue | SUM(subscription.amount) WHERE subscription.status = "active" |
| Conversion Rate | Free → Paid | COUNT(upgrade events) / COUNT(signup events) |
| Churn Rate | Cancellations | COUNT(cancel events) / COUNT(active subscriptions) |
| CAC | Customer Acquisition Cost | Total marketing spend / COUNT(new paying users) |
| LTV | Lifetime Value | Average subscription amount / Churn rate |
| NPS | Net Promoter Score | % Promoters - % Detractors |
| Viral Coefficient | Referral multiplier | COUNT(referral events) / COUNT(users) |
---
## Alert Thresholds
Configured thresholds for monitoring:
- **Churn**: Warning >5%, Critical >10%
- **Conversion Rate**: Warning <2%, Critical <1%
- **MRR**: Warning <90% target, Critical <80% target
- **NPS**: Warning <50, Critical <40
- **Viral Coefficient**: Warning <0.4, Critical <0.3
---
## Files Modified
- `packages/shared-analytics/src/config/analytics.config.ts` - Event taxonomy & KPIs
- `packages/shared-analytics/src/services/mixpanel.service.ts` - Mixpanel service
- `packages/shared-analytics/src/index.ts` - Exports
- `packages/web/src/hooks/useAnalytics.ts` - Frontend analytics hook
- `.env.example` - Environment variable templates
- `docs/MIXPANEL_ANALYTICS.md` - This documentation
---
## Next Steps
1. **Create Mixpanel account** (requires human action - no API for account creation)
2. **Add tokens to `.env`** files
3. **Test event tracking** in development
4. **Set up Mixpanel dashboards** for KPI monitoring
5. **Configure Mixpanel alerts** for threshold breaches

View File

@@ -1,389 +0,0 @@
# Push Notifications - Mobile App Quick Start
This guide helps you integrate push notifications into the ShieldAI React Native mobile app.
## Prerequisites
Before starting, ensure:
- ✅ Backend push notification infrastructure is deployed
- ✅ Firebase project is set up (for Android)
- ✅ Apple Developer account is active (for iOS)
- ✅ Environment variables are configured on the backend
## Step 1: Install Dependencies
```bash
npm install @react-native-firebase/app @react-native-firebase/messaging
```
## Step 2: Android Setup
### 2.1 Add Firebase to Android Project
1. Go to [Firebase Console](https://console.firebase.google.com)
2. Select your ShieldAI project
3. Add Android app with package name: `com.shieldai.mobile`
4. Download `google-services.json`
5. Place it in `android/app/google-services.json`
### 2.2 Update Build Configuration
In `android/build.gradle`:
```gradle
buildscript {
dependencies {
classpath 'com.google.gms:google-services:4.4.0'
}
}
```
In `android/app/build.gradle`:
```gradle
apply plugin: 'com.google.gms.google-services'
```
### 2.3 Request Permissions
In `AndroidManifest.xml`:
```xml
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.VIBRATE" />
```
## Step 3: iOS Setup
### 3.1 Enable Push Notifications
1. Open Xcode project
2. Select your app target
3. Go to "Signing & Capabilities"
4. Click "+ Capability"
5. Add "Push Notifications"
6. Add "Background Modes" and check "Remote notifications"
### 3.2 Configure Firebase
1. Download `GoogleService-Info.plist` from Firebase Console
2. Add it to your Xcode project (drag to project folder)
3. Ensure "Copy items if needed" is checked
### 3.3 Add Import in AppDelegate.swift
```swift
import FirebaseMessaging
import UserNotifications
@main
class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
FirebaseApp.configure()
Messaging.messaging().delegate = self
return true
}
// Request permission
func requestAuthorization() {
UNUserNotificationCenter.current().delegate = self
let authOptions: UNAuthorizationOptions = [.alert, .badge, .sound]
UNUserNotificationCenter.current().requestAuthorization(
options: authOptions,
completionHandler: { granted, error in
if granted {
DispatchQueue.main.async {
UIApplication.shared.registerForRemoteNotifications()
}
}
}
)
}
// Handle token refresh
func messaging(_ messaging: Messaging, didRefreshRegistrationToken fcmToken: String) {
print("FCM token refreshed: \(fcmToken)")
// Send new token to backend
registerDeviceToken(fcmToken)
}
// Handle foreground notifications
func userNotificationCenter(_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler:
@escaping (UNNotificationPresentationOptions) -> Void) {
let userInfo = notification.request.content.userInfo
print("Foreground notification: \(userInfo)")
completionHandler([.banner, .sound, .badge])
}
// Handle notification tap
func userNotificationCenter(_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void) {
let userInfo = response.notification.request.content.userInfo
print("Notification tapped: \(userInfo)")
completionHandler()
}
}
extension AppDelegate: MessagingDelegate {
func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) {
print("FCM token received: \(fcmToken ?? "none")")
if let token = fcmToken {
registerDeviceToken(token)
}
}
}
// Helper function to register with backend
func registerDeviceToken(_ token: String) {
// Call your API to register the device
// POST /api/v1/devices/register
// Body: { userId, platform: "ios", token, deviceType: "mobile" }
}
```
## Step 4: React Native Integration
### 4.1 Create Notification Service
Create `src/services/NotificationService.ts`:
```typescript
import messaging from '@react-native-firebase/messaging';
import AsyncStorage from '@react-native-async-storage/async-storage';
import axios from 'axios';
const API_BASE_URL = 'https://api.shieldai.com/api/v1';
export class NotificationService {
private static instance: NotificationService;
private userId: string | null = null;
private constructor() {}
static getInstance(): NotificationService {
if (!NotificationService.instance) {
NotificationService.instance = new NotificationService();
}
return NotificationService.instance;
}
setUserId(userId: string) {
this.userId = userId;
}
async requestPermission(): Promise<boolean> {
try {
const authStatus = await messaging().requestPermission();
const enabled =
authStatus === messaging.AuthorizationStatus.AUTHORIZED ||
authStatus === messaging.AuthorizationStatus.PROVISIONAL;
if (enabled) {
console.log('Push notification permission granted');
await this.registerDevice();
return true;
}
return false;
} catch (error) {
console.error('Failed to request permission:', error);
return false;
}
}
async registerDevice(): Promise<void> {
if (!this.userId) {
console.warn('User ID not set, cannot register device');
return;
}
try {
const token = await messaging().getToken();
const platform = Platform.OS === 'ios' ? 'ios' : 'android';
const response = await axios.post(`${API_BASE_URL}/devices/register`, {
userId: this.userId,
platform,
token,
deviceType: 'mobile',
appName: 'ShieldAI Mobile',
appVersion: '1.0.0',
});
console.log('Device registered:', response.data);
} catch (error) {
console.error('Failed to register device:', error);
}
}
setupListeners() {
// Foreground messages
messaging().onMessage(async (remoteMessage) => {
console.log('Foreground message received:', remoteMessage);
// Show local notification
this.showLocalNotification(remoteMessage);
});
// Background messages
messaging().setBackgroundMessageHandler(async (remoteMessage) => {
console.log('Background message received:', remoteMessage);
});
// Notification opened
messaging().onNotificationOpenedApp((remoteMessage) => {
console.log('Notification opened app:', remoteMessage);
// Navigate to relevant screen
this.handleNotificationTap(remoteMessage);
});
// Check if app was opened from notification
messaging()
.getInitialNotification()
.then((remoteMessage) => {
if (remoteMessage) {
console.log('App opened from notification:', remoteMessage);
this.handleNotificationTap(remoteMessage);
}
});
}
private showLocalNotification(message: any) {
// Use react-native-push-notification or similar
console.log('Show notification:', message.notification?.title);
}
private handleNotificationTap(message: any) {
// Navigate to relevant screen based on notification data
const { alertId, type } = message.data || {};
if (alertId) {
// Navigate to alert detail
console.log('Navigate to alert:', alertId);
}
}
async deregisterDevice(): Promise<void> {
if (!this.userId) return;
try {
const token = await messaging().getToken();
await axios.post(`${API_BASE_URL}/devices/deregister`, {
token,
userId: this.userId,
});
} catch (error) {
console.error('Failed to deregister device:', error);
}
}
}
```
### 4.2 Initialize in App Component
```typescript
// App.tsx
import React, { useEffect } from 'react';
import { NotificationService } from './src/services/NotificationService';
const notificationService = NotificationService.getInstance();
function App() {
useEffect(() => {
// Initialize push notifications
notificationService.setupListeners();
}, []);
// When user logs in
const handleLogin = async (userId: string) => {
notificationService.setUserId(userId);
await notificationService.requestPermission();
};
// When user logs out
const handleLogout = async () => {
await notificationService.deregisterDevice();
notificationService.setUserId('');
};
return (
// Your app components
);
}
```
## Step 5: Testing
### Android Emulator
1. Start Android emulator with Google Play Services
2. Install and run app
3. Trigger a test notification from backend
4. Check notification appears
### iOS Device (Required)
iOS Simulator does not support push notifications. Use a real device.
### Backend Test
```bash
# Test notification API
curl -X POST https://api.shieldai.com/api/v1/notifications/send \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"userId": "user-uuid",
"channel": "push",
"subject": "Test Alert",
"body": "This is a test notification from ShieldAI",
"priority": "high",
"metadata": {
"alertId": "alert-uuid",
"type": "darkweb_exposure"
}
}'
```
## Troubleshooting
### Android Issues
**Problem**: Token not received
- Check `google-services.json` is in correct location
- Verify Firebase project ID matches
- Check internet connection on emulator
**Problem**: Notifications not showing
- Ensure notification permissions granted
- Check battery optimization settings
- Verify notification channel is created
### iOS Issues
**Problem**: Token not received
- Verify Push Notifications capability is enabled
- Check APNs key configuration in backend
- Ensure device is not in development mode if using production certs
**Problem**: Notifications not showing
- Check notification permissions granted
- Verify APNs configuration on backend
- Ensure proper certificate/key is used
## Next Steps
1. ✅ Set up Firebase project
2. ✅ Configure APNs on Apple Developer Portal
3. ✅ Implement notification handling in app
4. ✅ Test on Android device/emulator
5. ✅ Test on iOS device
6. ✅ Integrate with DarkWatch and SpamShield alerts
7. ✅ Add notification preferences UI
8. ✅ Implement deep linking from notifications
## Resources
- [Firebase Cloud Messaging Docs](https://firebase.google.com/docs/cloud-messaging)
- [React Native Firebase Messaging](https://rnfirebase.io/messaging/usage)
- [Apple Push Notification Service](https://developer.apple.com/documentation/usernotifications)

View File

@@ -1,462 +0,0 @@
# Push Notifications Setup Guide (FCM & APNs)
## Overview
This guide covers setting up Firebase Cloud Messaging (FCM) for Android and Apple Push Notification service (APNs) for iOS push notifications in the ShieldAI mobile app.
## Architecture
```
┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Mobile │────▶│ API Gateway │────▶│ Notification │
│ App │ │ /devices/* │ │ Service │
└─────────────┘ └──────────────────┘ └────────┬────────┘
┌──────────────────────────────┼──────────────────────┐
│ │ │
▼ ▼ ▼
┌────────────────┐ ┌─────────────────┐ ┌──────────────────┐
│ Firebase │ │ Apple Push │ │ Redis (Rate │
│ Cloud │ │ Notification │ │ Limiting) │
│ Messaging │ │ Service │ │ │
│ (Android) │ │ (iOS) │ │ │
└────────────────┘ └─────────────────┘ └──────────────────┘
```
## Environment Variables
Add the following to your `.env` file:
```bash
# Firebase Cloud Messaging (FCM)
FCM_PROJECT_ID=your-firebase-project-id
FCM_CLIENT_EMAIL=service-account@your-project.iam.gserviceaccount.com
FCM_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"
# Apple Push Notification (APNs)
APNS_KEY_ID=your-key-id
APNS_TEAM_ID=your-team-id
APNS_BUNDLE_ID=com.yourcompany.shieldai
APNS_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"
# Redis (for rate limiting)
REDIS_URL=redis://localhost:6379
# Rate Limits
PUSH_RATE_LIMIT=100
RATE_LIMIT_WINDOW_SECONDS=60
```
## Firebase Cloud Messaging (FCM) Setup
### 1. Create Firebase Project
1. Go to [Firebase Console](https://console.firebase.google.com/)
2. Click **Add project**
3. Enter project name: `shieldai-production` (or your preferred name)
4. Disable Google Analytics (optional)
5. Click **Create project**
### 2. Enable Cloud Messaging
1. In Firebase Console, go to **Project settings**
2. Scroll down to **Your apps** section
3. Click the **Web** icon `</>` to add a web app
4. Register app nickname: `ShieldAI API`
5. **Do not** check "Also set up for Firebase Hosting"
6. Click **Register app**
7. Copy the `firebaseConfig` for later use in mobile app
### 3. Generate Service Account Key
1. Go to **Project settings****Service accounts**
2. Click **Generate new private key**
3. Save the downloaded JSON file securely
4. Extract the following values:
- `project_id`
- `client_email`
- `private_key`
### 4. Configure FCM in ShieldAI
Create a file `packages/shared-notifications/.fcm-config.json` (gitignored):
```json
{
"projectId": "your-firebase-project-id",
"clientEmail": "service-account@your-project.iam.gserviceaccount.com",
"privateKey": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"
}
```
Set environment variables:
```bash
export FCM_PROJECT_ID="your-firebase-project-id"
export FCM_CLIENT_EMAIL="service-account@your-project.iam.gserviceaccount.com"
export FCM_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"
```
### 5. Install Firebase Admin SDK
```bash
cd packages/shared-notifications
npm install firebase-admin
```
## Apple Push Notification (APNs) Setup
### 1. Create App ID
1. Go to [Apple Developer Portal](https://developer.apple.com/account/resources/identifiers/list)
2. Click **+** to create new identifier
3. Select **App IDs****App**
4. Enter description: `ShieldAI Mobile`
5. Enter Bundle ID: `com.yourcompany.shieldai`
6. Enable **Push Notifications** capability
7. Click **Continue****Register**
### 2. Create APNs Key
1. Go to **Certificates, IDs & Profiles****Keys**
2. Click **+** to create new key
3. Enter key name: `ShieldAI APNs Key`
4. Enable **Apple Push Notification service (APNs)**
5. Click **Continue****Register**
6. **Download the key** (`.p8` file) - you can only download once!
7. Note the **Key ID** displayed
### 3. Configure APNs in ShieldAI
Convert the `.p8` file to PEM format:
```bash
# Convert .p8 to PEM
openssl pkcs8 -topk8 -nocrypt -in AuthKey_XXXXXX.p8 -out apns_key.pem
# Copy the PEM content
cat apns_key.pem
```
Set environment variables:
```bash
export APNS_KEY_ID="XXXXXX"
export APNS_TEAM_ID="YYYYYY"
export APNS_BUNDLE_ID="com.yourcompany.shieldai"
export APNS_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"
```
### 4. Configure in Xcode
In your iOS app's `Info.plist`:
```xml
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>remote-notification</string>
</array>
```
Enable capabilities in Xcode:
- **Push Notifications**
- **Background Modes** → **Remote notifications**
## API Endpoints
### Device Registration
#### Register Device
```http
POST /api/v1/devices/register
Authorization: Bearer <JWT_TOKEN>
Content-Type: application/json
{
"platform": "android",
"fcmToken": "eXwR9...",
"appVersion": "1.0.0",
"osVersion": "Android 13"
}
```
Response:
```json
{
"success": true,
"device": {
"deviceId": "dev_1234567890_abc123",
"platform": "android",
"registeredAt": "2026-05-14T13:00:00.000Z"
},
"message": "Device registered successfully"
}
```
#### Update Device Tokens
```http
PUT /api/v1/devices/:deviceId/tokens
Authorization: Bearer <JWT_TOKEN>
Content-Type: application/json
{
"fcmToken": "new-token-here",
"apnsToken": "new-token-here"
}
```
#### Get Registered Devices
```http
GET /api/v1/devices
Authorization: Bearer <JWT_TOKEN>
```
#### Deregister Device
```http
DELETE /api/v1/devices/:deviceId
Authorization: Bearer <JWT_TOKEN>
```
## Mobile App Integration
### Android (React Native)
```javascript
import messaging from '@react-native-firebase/messaging';
import { API } from '@shieldai/api-client';
// Request permission
async function requestPushPermission() {
const authStatus = await messaging().requestPermission();
const enabled =
authStatus === messaging.AuthorizationStatus.AUTHORIZED ||
authStatus === messaging.AuthorizationStatus.PROVISIONAL;
if (enabled) {
console.log('Push permission granted:', authStatus);
}
}
// Get FCM token
async function getFCMToken() {
const token = await messaging().getToken();
return token;
}
// Register device with API
async function registerDevice() {
try {
const fcmToken = await getFCMToken();
const response = await API.post('/devices/register', {
platform: 'android',
fcmToken,
appVersion: '1.0.0',
osVersion: Platform.OS + ' ' + Platform.Version,
});
console.log('Device registered:', response.data);
} catch (error) {
console.error('Failed to register device:', error);
}
}
// Listen for token refresh
messaging().onTokenRefresh(async (newToken) => {
await API.put('/devices/tokens', {
fcmToken: newToken,
});
});
// Handle foreground messages
messaging().onMessage(async (remoteMessage) => {
console.log('Foreground message received:', remoteMessage);
// Show local notification
});
// Handle background messages
messaging().setBackgroundMessageHandler(async remoteMessage => {
console.log('Background message received:', remoteMessage);
});
```
### iOS (React Native)
```javascript
import messaging from '@react-native-firebase/messaging';
import { API } from '@shieldai/api-client';
// Request permission
async function requestPushPermission() {
const authStatus = await messaging().requestPermission();
const enabled =
authStatus === messaging.AuthorizationStatus.AUTHORIZED ||
authStatus === messaging.AuthorizationStatus.PROVISIONAL;
if (enabled) {
console.log('Push permission granted:', authStatus);
}
}
// Get APNs token
async function getAPNSToken() {
const token = await messaging().getToken();
return token;
}
// Register device with API
async function registerDevice() {
try {
const apnsToken = await getAPNSToken();
const response = await API.post('/devices/register', {
platform: 'ios',
apnsToken,
appVersion: '1.0.0',
osVersion: Platform.OS + ' ' + Platform.Version,
});
console.log('Device registered:', response.data);
} catch (error) {
console.error('Failed to register device:', error);
}
}
// Handle foreground messages
messaging().onMessage(async (remoteMessage => {
console.log('Foreground message received:', remoteMessage);
// Show local notification
}));
```
## Testing Push Notifications
### Test with Firebase Console
1. Go to [Firebase Console](https://console.firebase.google.com/) → **Cloud Messaging**
2. Click **Send your first message**
3. Enter notification title and body
4. Choose test device or all users
5. Click **Send test message**
### Test with API
```bash
# Send test push notification
curl -X POST https://your-api.com/api/v1/notifications/send \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"userId": "user-123",
"channel": "push",
"title": "Test Notification",
"body": "This is a test push notification",
"data": {
"type": "test",
"action": "open_app"
}
}'
```
### Test with cURL (Direct FCM)
```bash
curl -X POST https://fcm.googleapis.com/v1/projects/your-project-id/messages:send \
-H "Authorization: Bearer $(gcloud auth application-default print-access-token)" \
-H "Content-Type: application/json" \
-d '{
"message": {
"token": "FCM_TOKEN_HERE",
"notification": {
"title": "Test Title",
"body": "Test Body"
},
"data": {
"type": "test"
}
}
}'
```
## Production Checklist
### FCM Configuration
- [ ] Firebase project created
- [ ] Service account key generated and stored securely
- [ ] FCM environment variables configured
- [ ] Firebase Admin SDK initialized
- [ ] Test notifications sent successfully
### APNs Configuration
- [ ] App ID created with push capability
- [ ] APNs key generated and downloaded
- [ ] Key converted to PEM format
- [ ] APNs environment variables configured
- [ ] Xcode capabilities enabled
- [ ] Test notifications sent from simulator
### API Configuration
- [ ] Device registration endpoints working
- [ ] Token update endpoints working
- [ ] Rate limiting configured
- [ ] Error handling implemented
- [ ] Logging for push notifications
### Mobile App Configuration
- [ ] Push permissions requested
- [ ] Token retrieval implemented
- [ ] Device registration on app start
- [ ] Token refresh handling
- [ ] Foreground message handling
- [ ] Background message handling
- [ ] Notification display implemented
## Troubleshooting
### FCM Token Not Received
- Check Firebase configuration in mobile app
- Verify Google Services (Android) / GoogleService-Info.plist (iOS)
- Ensure push permissions granted
### APNs Token Not Received
- Verify App ID configuration in Apple Developer
- Check APNs key configuration
- Ensure background modes enabled in Xcode
- Test on actual device (not simulator)
### Notifications Not Delivering
- Check device registration status
- Verify tokens are valid
- Check rate limiting status
- Review server logs for errors
### Token Refresh Issues
- Listen for `onTokenRefresh` events
- Update tokens via API immediately
- Handle network errors gracefully
## Security Considerations
1. **Never expose service account keys** in client-side code
2. **Always validate** device ownership on server
3. **Use HTTPS** for all API calls
4. **Implement rate limiting** to prevent abuse
5. **Store tokens securely** using secure storage libraries
6. **Deregister devices** on user logout
## Monitoring
Monitor the following metrics:
- Push notification delivery rate
- Token refresh frequency
- Device registration failures
- Rate limit hits
- Notification open rate
## Support
- Firebase Support: https://firebase.google.com/support
- Apple Developer Support: https://developer.apple.com/contact/
- FCM Documentation: https://firebase.google.com/docs/cloud-messaging
- APNs Documentation: https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server

View File

@@ -1,353 +0,0 @@
# Stripe Integration Guide
## Overview
This guide covers the Stripe live API integration for ShieldAI subscription management. The implementation includes webhook handlers, subscription management endpoints, and tier-based feature gating.
## Environment Variables
Add the following to your `.env` file:
```bash
# Stripe Configuration
STRIPE_API_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_FREE_TIER_PRICE_ID=price_...
STRIPE_BASIC_TIER_PRICE_ID=price_...
STRIPE_PLUS_TIER_PRICE_ID=price_...
STRIPE_PREMIUM_TIER_PRICE_ID=price_...
```
## Getting Stripe Live API Keys
### 1. Get Live API Key
1. Go to [Stripe Dashboard](https://dashboard.stripe.com/)
2. Navigate to **Developers****API keys**
3. Toggle **Live mode** (top right corner)
4. Copy the **Secret key** (starts with `sk_live_`)
### 2. Get Webhook Signing Secret
1. In Stripe Dashboard, go to **Developers****Webhooks**
2. Click **Add endpoint**
3. Add your webhook URL: `https://your-api-domain.com/api/v1/billing/webhooks/stripe`
4. Select these events to listen for:
- `customer.subscription.created`
- `customer.subscription.updated`
- `customer.subscription.deleted`
- `invoice.payment_succeeded`
- `invoice.payment_failed`
5. Click **Add endpoint**
6. Copy the **Signing secret** (starts with `whsec_`)
### 3. Create Price IDs for Subscription Tiers
1. Go to **Products** in Stripe Dashboard
2. Create products for each tier:
- Free Tier ( $0/month)
- Basic Tier ($9.99/month)
- Plus Tier ($19.99/month)
- Premium Tier ($49.99/month)
3. For each product, create a recurring price
4. Copy the Price IDs (start with `price_`)
## API Endpoints
### Subscription Management
#### Get Current Subscription
```http
GET /api/v1/billing/subscription
Authorization: Bearer <JWT_TOKEN>
```
Response:
```json
{
"subscription": {
"id": "sub_123",
"status": "active",
"currentPeriodStart": "2026-05-01T00:00:00.000Z",
"currentPeriodEnd": "2026-06-01T00:00:00.000Z",
"cancelAtPeriodEnd": false
},
"customer": {
"id": "cus_123"
}
}
```
#### Create Subscription
```http
POST /api/v1/billing/subscription/create
Authorization: Bearer <JWT_TOKEN>
Content-Type: application/json
{
"tier": "basic",
"customerId": "cus_123"
}
```
#### Update Subscription Tier
```http
PUT /api/v1/billing/subscription/:subscriptionId/tier
Authorization: Bearer <JWT_TOKEN>
Content-Type: application/json
{
"tier": "plus"
}
```
#### Cancel Subscription
```http
DELETE /api/v1/billing/subscription/:subscriptionId
Authorization: Bearer <JWT_TOKEN>
Content-Type: application/json
{
"cancelAtPeriodEnd": true
}
```
#### Get User Tier
```http
GET /api/v1/billing/user/tier
Authorization: Bearer <JWT_TOKEN>
```
Response:
```json
{
"tier": "basic",
"limits": {
"callMinutesLimit": 500,
"smsCountLimit": 2000,
"darkWebScans": 12
}
}
```
#### Create Customer Portal Session
```http
POST /api/v1/billing/customer/portal
Authorization: Bearer <JWT_TOKEN>
Content-Type: application/json
{
"customerId": "cus_123",
"returnUrl": "https://yourapp.com/billing"
}
```
Response:
```json
{
"url": "https://billing.stripe.com/p/login/...",
"expiresAt": "2026-05-14T14:00:00.000Z"
}
```
#### Get Invoice History
```http
GET /api/v1/billing/invoices?customerId=cus_123
Authorization: Bearer <JWT_TOKEN>
```
### Webhook Handler
#### Stripe Webhook
```http
POST /api/v1/billing/webhooks/stripe
Stripe-Signature: <SIGNATURE>
Content-Type: application/json
<Raw Stripe Event Body>
```
## Webhook Events Handled
### customer.subscription.created
Triggered when a new subscription is created.
### customer.subscription.updated
Triggered when subscription details change (tier upgrade/downgrade, payment method update).
### customer.subscription.deleted
Triggered when a subscription is cancelled.
### invoice.payment_succeeded
Triggered when a payment is successfully processed.
### invoice.payment_failed
Triggered when a payment fails.
## Testing
### Test with Stripe CLI
1. Install [Stripe CLI](https://stripe.com/docs/stripe-cli)
2. Login: `stripe login`
3. Forward webhooks: `stripe listen --forward-to localhost:3000/api/v1/billing/webhooks/stripe`
### Test Events
```bash
# Trigger a subscription created event
stripe trigger customer.subscription.created
# Trigger a payment succeeded event
stripe trigger invoice.payment_succeeded
```
## Mobile App Integration
### React Native Example
```javascript
import { API } from '@shieldai/api-client';
// Get current subscription
const getSubscription = async () => {
try {
const response = await API.get('/billing/subscription');
return response.data;
} catch (error) {
console.error('Failed to fetch subscription:', error);
}
};
// Create subscription
const createSubscription = async (tier, customerId) => {
try {
const response = await API.post('/billing/subscription/create', {
tier,
customerId,
});
return response.data;
} catch (error) {
console.error('Failed to create subscription:', error);
}
};
// Upgrade subscription
const upgradeSubscription = async (subscriptionId, newTier) => {
try {
const response = await API.put(
`/billing/subscription/${subscriptionId}/tier`,
{ tier: newTier }
);
return response.data;
} catch (error) {
console.error('Failed to upgrade subscription:', error);
}
};
// Cancel subscription
const cancelSubscription = async (subscriptionId) => {
try {
const response = await API.delete(
`/billing/subscription/${subscriptionId}`,
{ data: { cancelAtPeriodEnd: true } }
);
return response.data;
} catch (error) {
console.error('Failed to cancel subscription:', error);
}
};
```
## Feature Gating
Use the middleware to protect routes based on subscription tier:
```typescript
import { requireTier } from '@shieldai/shared-billing';
import { SubscriptionTier } from '@shieldai/shared-billing';
// Require minimum tier
fastify.get(
'/premium-feature',
{
preHandler: requireTier([SubscriptionTier.BASIC, SubscriptionTier.PLUS, SubscriptionTier.PREMIUM])
},
async (request, reply) => {
// Only accessible to BASIC tier and above
}
);
// Require specific tier
fastify.get(
'/exclusive-feature',
{
preHandler: requireTier([SubscriptionTier.PREMIUM])
},
async (request, reply) => {
// Only accessible to PREMIUM tier
}
);
```
## Deployment Checklist
- [ ] Set `STRIPE_API_KEY` to live key (not test key)
- [ ] Set `STRIPE_WEBHOOK_SECRET` to live webhook secret
- [ ] Configure webhook endpoint in Stripe Dashboard
- [ ] Verify webhook events are being received
- [ ] Test subscription creation flow
- [ ] Test tier upgrade/downgrade flow
- [ ] Test cancellation flow
- [ ] Verify feature gating works correctly
- [ ] Monitor Stripe dashboard for errors
- [ ] Set up alerts for failed payments
## Production Considerations
### Security
1. **Never expose secret keys** in client-side code
2. **Always verify webhook signatures** on the server
3. **Use HTTPS** for all API endpoints in production
4. **Implement rate limiting** on webhook endpoints
### Error Handling
1. **Idempotency**: Webhook events may be delivered multiple times
2. **Retry logic**: Stripe will retry failed webhook deliveries
3. **Logging**: Log all webhook events for debugging
4. **Alerts**: Set up alerts for payment failures
### Compliance
1. **PCI DSS**: Use Stripe Elements for payment collection
2. **GDPR**: Handle customer data according to regulations
3. **Tax**: Consider tax calculation for different regions
## Troubleshooting
### Webhook Signature Verification Fails
- Ensure `STRIPE_WEBHOOK_SECRET` is correctly set
- Verify the webhook URL matches what's configured in Stripe
- Check that raw body is being captured (not parsed JSON)
### Subscription Creation Fails
- Verify `STRIPE_API_KEY` is valid
- Check that price IDs exist and are active
- Ensure customer ID is valid
### Tier Not Updating
- Verify the new tier's price ID exists
- Check for active subscriptions on the customer
- Review Stripe dashboard for error messages
## Support
For issues or questions:
- Stripe Dashboard: https://dashboard.stripe.com/
- Stripe Docs: https://stripe.com/docs
- Stripe Support: https://support.stripe.com/

View File

@@ -1,11 +0,0 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/db/schema/index.ts",
out: "./src/db/migrations",
dialect: "turso",
dbCredentials: {
url: process.env.TURSO_DATABASE_URL!,
authToken: process.env.TURSO_AUTH_TOKEN!,
},
});

View File

@@ -1,21 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0a0a0a" />
<meta name="description" content="Scripter — Write Faster. The modern screenwriting platform built for how you actually work." />
<meta name="keywords" content="screenwriting, screenplay, writing software, Final Draft alternative, collaboration" />
<meta property="og:title" content="Scripter — Write Faster" />
<meta property="og:description" content="The modern screenwriting platform. Real-time collaboration, AI-powered writing, industry-standard formatting." />
<meta property="og:type" content="website" />
<link rel="icon" type="image/png" href="/src-tauri/32x32.png" />
<link rel="apple-touch-icon" href="/src-tauri/128x128.png" />
<link rel="manifest" href="/manifest.json" />
<title>Scripter — Write Faster</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/App.tsx"></script>
</body>
</html>

9
infra/.gitignore vendored
View File

@@ -1,9 +0,0 @@
.terraform/
*.tfstate
*.tfstate.backup
*.tfvars
.terraform.lock.hcl
override.tf
override.tf.json
*_override.tf
*_override.tf.json

View File

@@ -1,113 +0,0 @@
/infra/
├── main.tf # Root module: VPC, ECS, RDS, ElastiCache, S3, Secrets, CloudWatch
├── variables.tf # Input variables with validation
├── outputs.tf # Output values (endpoints, ARNs, URLs)
├── modules/
│ ├── vpc/main.tf # VPC, subnets, IGW, NAT GW, security groups
│ ├── ecs/main.tf # ECS cluster, task definitions, services, ALB, auto-scaling
│ ├── rds/main.tf # RDS PostgreSQL with automated backups
│ ├── elasticache/main.tf # ElastiCache Redis with replication
│ ├── s3/main.tf # S3 buckets: state, artifacts, logs
│ ├── secrets/main.tf # AWS Secrets Manager
│ └── cloudwatch/main.tf # Dashboards, alarms, notifications
├── environments/
│ ├── staging/main.tf # Staging environment config
│ └── production/main.tf # Production environment config
└── scripts/
├── rollback.sh # ECS service rollback (AWS)
├── rollback-compose.sh # Docker Compose rollback (local/staging)
└── rollback-migration.sh # Database migration rollback
## Quick Start
### Prerequisites
- Terraform >= 1.5.0
- AWS CLI configured with appropriate credentials
- AWS account with ECS, RDS, ElastiCache permissions
### Initialize
```bash
cd infra/environments/staging
terraform init
terraform plan -var-file=terraform.tfvars.example
terraform apply -var-file=terraform.tfvars.example
```
### Deploy via CI/CD
- Push to `main` → deploys to staging
- Create a release → deploys to production
- Health check failure → automatic rollback
## Architecture
### Networking
- VPC with public/private subnets across multiple AZs
- NAT Gateway for outbound traffic from private subnets
- Security groups: ECS → RDS (5432), ECS → ElastiCache (6379)
### Compute
- ECS Fargate for serverless container orchestration
- Application Load Balancer with health checks
- Auto-scaling: CPU-based scaling (70% target)
- Production: 3 replicas per service, min 2, max 10
### Data
- RDS PostgreSQL 16.2 with Multi-AZ (production)
- Automated daily backups, 7-14 day retention
- ElastiCache Redis 7.0 with replication
- S3 with versioning and lifecycle policies
### Secrets
- AWS Secrets Manager for all credentials
- ECS task execution role with SecretsManagerReadOnly
- DB credentials auto-rotated via RDS integration
### Monitoring
- CloudWatch dashboards: CPU, memory, ALB metrics
- Alarms: CPU >80%, memory >85%, 5xx >10/min, RDS storage <500MB
- Container Insights enabled for ECS
- Logs: 30-day retention (production), 7-day (staging)
### Backup Strategy
- RDS: automated snapshots every 24h, 7-14 day retention
- RDS: Multi-AZ for automatic failover (production)
- ElastiCache: daily snapshots, 1-7 day retention
- S3: versioning enabled, non-current versions expire after 30 days
- Terraform state: S3 with versioning + DynamoDB locking
## Rollback
See **[ROLLBACK.md](./ROLLBACK.md)** for the complete rollback runbook, including:
- ECS service rollback (automated + manual)
- Docker Compose rollback (local / staging)
- Database migration rollback (Drizzle)
- Blue-green deployment rollback
- RDS point-in-time recovery
- Automated rollback triggers and health checks
- Emergency rollback runbook
- Testing checklist
### Quick Reference
```bash
# ECS service rollback (AWS)
./infra/scripts/rollback.sh <environment> <service|all> [--verify]
# Docker Compose rollback (local/staging)
./infra/scripts/rollback-compose.sh <previous_tag>
# Database migration rollback
./infra/scripts/rollback-migration.sh <environment> [--migration <name>]
```
## GitHub Secrets Required
| Secret | Description |
|--------|-------------|
| AWS_ACCESS_KEY_ID | IAM user with ECS, RDS, ElastiCache permissions |
| AWS_SECRET_ACCESS_KEY | IAM secret key |
| HIBP_API_KEY | Have I Been Pwned API key |
| RESEND_API_KEY | Resend email API key |
| SENTRY_DSN | Sentry error tracking DSN |
| DATADOG_API_KEY | Datadog monitoring API key |
| GITHUB_TOKEN | Auto-provided, needs write:packages scope |

View File

@@ -1,611 +0,0 @@
# ShieldAI Rollback Runbook
> **Last updated:** 2026-05-12
> **Owner:** Senior Engineer
> **Parent:** [FRE-4574](/FRE/issues/FRE-4574) ShieldAI Production Infrastructure & CI/CD Pipeline
> **Reviewed by:** Code Reviewer (FRE-4808) on 2026-05-12
---
## Table of Contents
1. [Overview](#1-overview)
2. [Rollback Strategies](#2-rollback-strategies)
3. [ECS Service Rollback (AWS)](#3-ecs-service-rollback-aws)
4. [Docker Compose Rollback (Local / Staging)](#4-docker-compose-rollback-local--staging)
5. [Database Migration Rollback](#5-database-migration-rollback)
6. [Automated Rollback Triggers](#6-automated-rollback-triggers)
7. [Blue-Green Deployment Rollback](#7-blue-green-deployment-rollback)
8. [Rollback Decision Tree](#8-rollback-decision-tree)
9. [Post-Rollback Verification](#9-post-rollback-verification)
10. [Testing Checklist](#10-testing-checklist)
11. [Runbook: Emergency Rollback](#11-runbook-emergency-rollback)
---
## 1. Overview
ShieldAI runs four services (api, darkwatch, spamshield, voiceprint) on AWS ECS Fargate behind an Application Load Balancer. Each service has independent deployment, health checks, and rollback capability.
**Rollback types:**
| Type | Trigger | Scope | Automation |
|------|---------|-------|------------|
| **ECS Service Rollback** | Health check failure, manual | Single or all services | ✅ CI/CD + manual script |
| **Docker Compose Rollback** | Manual (local/staging) | All services | ✅ Scripted |
| **Database Migration Rollback** | Manual | Schema changes | ⚠️ Semi-manual |
| **Blue-Green Rollback** | Manual or automated | Full environment | ✅ CI/CD |
| **RDS Point-in-Time Restore** | Manual (disaster) | Full database | ⚠️ Semi-manual |
---
## 2. Rollback Strategies
### 2.1 ECS Service-Level Rollback
Each ECS service maintains a history of task definitions. Rolling back reverts to the **previous successfully deployed task definition**.
**Prerequisites:**
- AWS CLI configured with credentials for the target environment
- IAM permissions: `ecs:UpdateService`, `ecs:DescribeServices`, `ecs:WaitServicesStable`
### 2.2 Blue-Green Rollback
The CI/CD pipeline deploys new images to existing ECS services. If health checks fail after deployment, the `rollback` job in the deploy workflow automatically reverts all four services to their previous task definition revision.
**Pipeline flow:**
```
build-and-push → deploy-ecs → health-check → [PASS: done | FAIL: rollback]
```
### 2.3 Database Migration Rollback
ShieldAI uses Drizzle ORM for database migrations. Each migration is versioned and stored in `src/db/migrations/`. Rollback requires running the previous migration set.
---
## 3. ECS Service Rollback (AWS)
### 3.1 Automated (CI/CD Pipeline)
The deploy workflow (`.github/workflows/deploy.yml`) includes a `rollback` job that triggers on health check failure:
```yaml
rollback:
if: failure() && needs.health-check.result == 'failure'
# Rolls back all 4 services to previous task definition
```
**When it runs:**
- Post-deploy health check fails (HTTP 200 not received from `/health`)
- Runs after `deploy-ecs` and `health-check` jobs
- Rolls back all four services: api, darkwatch, spamshield, voiceprint
**How to verify:**
1. Navigate to the GitHub Actions run for the failed deployment
2. Check the `Rollback on Failure` job logs
3. Confirm each service shows "Rolled back" status
### 3.2 Manual Rollback Script
```bash
# Single service
./infra/scripts/rollback.sh production api
# All services
./infra/scripts/rollback.sh production all
# Staging environment
./infra/scripts/rollback.sh staging all
```
**Script behavior:**
1. Iterates over target services (or all if `all` specified)
2. Calls `aws ecs update-service --rollback` for each service
3. Waits for service to stabilize via `aws ecs wait services-stable`
4. Reports success/failure per service
5. Exits with non-zero code if any service fails to stabilize
**Expected output:**
```
Rolling back services in cluster: shieldai-production
Rolling back api...
Waiting for api to stabilize...
api rolled back successfully
Rolling back darkwatch...
Waiting for darkwatch to stabilize...
darkwatch rolled back successfully
...
Rollback complete for api darkwatch spamshield voiceprint
```
### 3.3 Manual CLI Rollback (Fallback)
If the script is unavailable, rollback individual services:
```bash
CLUSTER="shieldai-production"
SERVICE="api"
# Rollback to previous task definition
aws ecs update-service \
--cluster "$CLUSTER" \
--service "${CLUSTER}-${SERVICE}" \
--rollback \
--no-cli-auto-prompt
# Wait for stabilization
aws ecs wait services-stable \
--cluster "$CLUSTER" \
--services "${CLUSTER}-${SERVICE}"
# Verify health
curl -s -o /dev/null -w "%{http_code}" \
"https://shieldai-production-alb.us-east-1.elb.amazonaws.com/health"
```
---
## 4. Docker Compose Rollback (Local / Staging)
### 4.1 Production Compose Rollback
The `docker-compose.prod.yml` deploys all services with tagged images. To rollback:
```bash
# 1. Identify the previous working tag
# Check GitHub releases or git tags for the last known good version
PREVIOUS_TAG="v1.2.3"
# 2. Stop current services
docker compose -f docker-compose.prod.yml down
# 3. Pull previous images
docker pull ghcr.io/${GITHUB_REPOSITORY_OWNER}/shieldai-api:${PREVIOUS_TAG}
docker pull ghcr.io/${GITHUB_REPOSITORY_OWNER}/shieldai-darkwatch:${PREVIOUS_TAG}
docker pull ghcr.io/${GITHUB_REPOSITORY_OWNER}/shieldai-spamshield:${PREVIOUS_TAG}
docker pull ghcr.io/${GITHUB_REPOSITORY_OWNER}/shieldai-voiceprint:${PREVIOUS_TAG}
# 4. Override tag in compose
DOCKER_TAG=${PREVIOUS_TAG} docker compose -f docker-compose.prod.yml up -d
# 5. Verify health
for svc in api darkwatch spamshield voiceprint; do
PORT=$(case $svc in
api) echo 3000;; darkwatch) echo 3001;;
spamshield) echo 3002;; voiceprint) echo 3003;;
esac)
curl -sf "http://localhost:${PORT}/health" && echo "$svc: OK" || echo "$svc: FAIL"
done
```
### 4.2 Local Dev Rollback
```bash
# Stop and remove containers
docker compose down
# Rebuild from previous commit
git checkout <previous-commit>
docker compose up -d --build
```
---
## 5. Database Migration Rollback
### 5.1 Drizzle Migration Rollback
ShieldAI uses Drizzle ORM with Turso dialect. Migrations are stored in `src/db/migrations/`.
```bash
# 1. Get database credentials from AWS Secrets Manager
DB_SECRET=$(aws secretsmanager get-secret-value \
--secret-id "shieldai-${ENVIRONMENT}-db-password" \
--query 'SecretString' --output json)
DB_HOST=$(echo "$DB_SECRET" | jq -r '.host')
DB_PORT=$(echo "$DB_SECRET" | jq -r '.port')
DB_USER=$(echo "$DB_SECRET" | jq -r '.username')
DB_PASS=$(echo "$DB_SECRET" | jq -r '.password')
DATABASE_URL="postgresql://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/shieldai"
# 2. List migrations to identify the one to revert
npx drizzle-kit introspect --config=drizzle.config.ts
# 3. Resolve the problematic migration (marks it as not applied)
npx drizzle-kit migrate:resolve --migration "<migration_name>" --status applied
# 4. Re-run previous migration state
npx drizzle-kit migrate --config=drizzle.config.ts
```
### 5.2 RDS Point-in-Time Recovery (Disaster)
When the database itself needs recovery (e.g., data corruption, bad migration):
```bash
# 1. Find available recovery window (automated backups: every 24h, 7-14 day retention)
aws rds describe-db-instances \
--db-instance-identifier "shieldai-production-db" \
--query 'DBInstances[0].LatestRestorableTime'
# 2. Create restored instance (does not affect primary)
aws rds restore-db-instance-to-point-in-time \
--source-db-instance-identifier "shieldai-production-db" \
--db-instance-identifier "shieldai-production-db-restored" \
--restore-time "2026-05-09T08:00:00Z"
# 3. Verify restored instance
aws rds wait db-instance-available \
--db-instance-identifier "shieldai-production-db-restored"
# 4. Update ECS services to point to restored instance
# Update DATABASE_URL secret in Secrets Manager
aws secretsmanager put-secret-value \
--secret-id "shieldai-production-db-password" \
--secret-string "$(echo "$DB_SECRET" | jq --arg host "$(aws rds describe-db-instances --db-instance-identifier shieldai-production-db-restored --query 'DBInstances[0].Endpoint.Address' --output text)" '.host = $host')"
# 5. Trigger ECS service redeployment to pick up new DB endpoint
./infra/scripts/rollback.sh production all
```
### 5.3 RDS Snapshot Restore
```bash
# 1. List available snapshots
aws rds describe-db-snapshots \
--db-instance-identifier "shieldai-production-db"
# 2. Restore from specific snapshot
aws rds restore-db-instance-from-db-snapshot \
--db-instance-identifier "shieldai-production-db-restored" \
--db-snapshot-identifier "rds:shieldai-production-db-2026-05-08-03-00" \
--db-instance-class "db.t3.medium" \
--vpc-security-group-ids "$(terraform -chdir=infra/output -raw vpc_security_group_id)"
# 3. Follow steps 3-5 from Point-in-Time Recovery above
```
---
## 6. Automated Rollback Triggers
### 6.1 CI/CD Health Check Failure
**Trigger:** Post-deploy health check returns non-200 from `/health`
**Pipeline job:** `rollback` in `.github/workflows/deploy.yml`
**Condition:** `if: failure() && needs.health-check.result == 'failure'`
**Action:** Rolls back all four ECS services to previous task definition
**Timeout:** Health check retries for 5 minutes before triggering rollback
### 6.2 ECS Container Health Check
Each container has an in-container health check defined in the ECS task definition:
```json
"healthCheck": {
"command": ["CMD-SHELL", "wget -q --spider http://localhost:{port}/health || exit 1"],
"interval": 30,
"timeout": 5,
"retries": 3,
"startPeriod": 60
}
```
**Failure consequence:** Container is marked unhealthy after 3 consecutive failures (90 seconds). ALB marks target as unhealthy after 3 failed health checks (90 seconds). Service enters draining state.
### 6.3 ALB Target Group Health Check
The ALB performs HTTP health checks against `/health` on each target:
| Parameter | Value |
|-----------|-------|
| Interval | 30s |
| Timeout | 5s |
| Healthy threshold | 3 |
| Unhealthy threshold | 3 |
| Expected code | 200 |
### 6.4 CloudWatch Alarms
The following alarms are configured in `infra/modules/cloudwatch/main.tf`:
| Alarm | Threshold | Action |
|-------|-----------|--------|
| ECS CPU >80% | 80% for 2 periods (10min) | SNS notification |
| ECS Memory >85% | 85% for 2 periods (10min) | SNS notification |
| ALB 5xx >10/min | 10 for 3 periods (3min) | SNS notification |
| RDS CPU >75% | 75% for 2 periods (10min) | SNS notification |
| RDS Free Storage <500MB | 500MB for 2 periods (10min) | SNS notification |
**Alarm escalation path:**
1. CloudWatch alarm fires
2. SNS notification sent to on-call engineer
3. Engineer evaluates: if service is degraded, trigger manual rollback
4. If root cause is deployment-related, run `./infra/scripts/rollback.sh production all`
---
## 7. Blue-Green Deployment Rollback
### 7.1 Architecture
ShieldAI uses ECS services with rolling deployments. Each deployment creates a new task definition revision. The ALB routes traffic to healthy targets only.
**Rollback mechanism:** ECS `--rollback` flag reverts the service to the previous task definition revision. This is equivalent to a blue-green swap since:
1. Old task definition (blue) remains registered
2. New task definition (green) is deployed
3. On rollback, ECS reverts to blue task definition
4. ALB automatically routes to healthy (blue) targets
### 7.2 Blue-Green Rollback Procedure
```bash
# 1. Check current deployment state
aws ecs list-services --cluster shieldai-production
aws ecs describe-services --cluster shieldai-production \
--services shieldai-production-api \
--query 'services[0].deployments'
# 2. Identify previous deployment
# The deployment with status "PRIMARY" is current.
# Look for "ACTIVE" deployment with older task definition.
# 3. Execute rollback (script handles all services)
./infra/scripts/rollback.sh production all
# 4. Verify rollback
aws ecs describe-services --cluster shieldai-production \
--services shieldai-production-api \
--query 'services[0].deployments[?status==`PRIMARY`].taskDefinition'
```
### 7.3 Docker Compose Blue-Green (Local)
For local/staging environments using Docker Compose, implement blue-green via service version pinning:
```bash
# Current deployment uses DOCKER_TAG env var
# Rollback by setting DOCKER_TAG to previous version
# Save current tag
CURRENT_TAG=$(grep DOCKER_TAG .env.prod 2>/dev/null | cut -d= -f2 || echo "latest")
# Rollback to previous
export DOCKER_TAG="v1.2.3"
docker compose -f docker-compose.prod.yml up -d
# Verify all services
docker compose -f docker-compose.prod.yml ps
```
---
## 8. Rollback Decision Tree
```
Is the service responding?
├── YES → Is the response correct?
│ ├── YES → Monitor, no action needed
│ └── NO → Is it a data issue?
│ ├── YES → Database Migration Rollback (§5)
│ └── NO → ECS Service Rollback (§3)
└── NO → Is it a single service or all?
├── Single → ECS Service Rollback (§3, specific service)
└── All → Full Environment Rollback
├── Is DB corrupted?
│ ├── YES → RDS Point-in-Time Recovery (§5.2)
│ └── NO → ECS Full Rollback + DB Migration Rollback
```
**SLA targets:**
- Single service rollback: **< 5 minutes**
- Full environment rollback: **< 15 minutes**
- Database recovery: **< 30 minutes** (Point-in-Time)
---
## 9. Post-Rollback Verification
After any rollback, verify the following:
### 9.1 Service Health
```bash
# Check all services are healthy
for svc in api darkwatch spamshield voiceprint; do
PORT=$(case $svc in
api) echo 3000;; darkwatch) echo 3001;;
spamshield) echo 3002;; voiceprint) echo 3003;;
esac)
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
"https://shieldai-${ENVIRONMENT}-alb.us-east-1.elb.amazonaws.com/health")
echo "$svc: HTTP $HTTP_CODE"
done
```
### 9.2 ECS Service Status
```bash
# Verify all services are stable
for svc in api darkwatch spamshield voiceprint; do
RUNNING=$(aws ecs describe-services \
--cluster "shieldai-${ENVIRONMENT}" \
--services "shieldai-${ENVIRONMENT}-${svc}" \
--query 'services[0].runningCount' --output text)
DESIRED=$(aws ecs describe-services \
--cluster "shieldai-${ENVIRONMENT}" \
--services "shieldai-${ENVIRONMENT}-${svc}" \
--query 'services[0].desiredCount' --output text)
echo "$svc: $RUNNING/$DESIRED running"
done
```
### 9.3 Database Connectivity
```bash
# Verify database connection
aws ecs execute-command \
--cluster "shieldai-${ENVIRONMENT}" \
--service "shieldai-${ENVIRONMENT}-api" \
--command "npx drizzle-kit status" \
--interactive --cluster "shieldai-${ENVIRONMENT}"
```
### 9.4 CloudWatch Verification
1. Navigate to CloudWatch dashboard: `shieldai-${ENVIRONMENT}-dashboard`
2. Verify CPU/Memory utilization is within normal range
3. Verify ALB 5xx errors have returned to baseline
4. Verify no new alarms are in ALARM state
---
## 10. Testing Checklist
### 10.1 ECS Rollback Test
- [ ] Deploy a known-bad image (e.g., image with `/health` returning 500)
- [ ] Verify CI/CD health check fails within 5 minutes
- [ ] Verify `rollback` job triggers automatically
- [ ] Verify all four services revert to previous task definition
- [ ] Verify health check passes post-rollback
- [ ] Verify CloudWatch metrics show recovery
### 10.2 Manual Script Test
- [ ] Run `./infra/scripts/rollback.sh staging api` on staging
- [ ] Verify single service rolls back correctly
- [ ] Run `./infra/scripts/rollback.sh staging all` on staging
- [ ] Verify all services roll back correctly
- [ ] Verify script exits with code 0 on success
- [ ] Verify script exits with code 1 on failure
### 10.3 Docker Compose Rollback Test
- [ ] Deploy v2.0.0 of all services via docker-compose.prod.yml
- [ ] Rollback to v1.0.0 using DOCKER_TAG override
- [ ] Verify all services restart with previous images
- [ ] Verify health endpoints respond correctly
### 10.4 Database Migration Rollback Test
- [ ] Apply a test migration on staging
- [ ] Run migration rollback procedure
- [ ] Verify schema matches pre-migration state
- [ ] Verify application connects and functions correctly
### 10.5 RDS Point-in-Time Recovery Test
- [ ] Create a test RDS instance
- [ ] Insert test data
- [ ] Restore to point before data insertion
- [ ] Verify restored instance has correct data state
- [ ] Clean up test instance
### 10.6 End-to-End Rollback Drills
| Drill | Frequency | Participants |
|-------|-----------|--------------|
| ECS service rollback | Monthly | Senior Engineer |
| Full environment rollback | Quarterly | Full engineering team |
| Database recovery | Quarterly | Senior Engineer + Founding Engineer |
| Blue-green rollback | Quarterly | Full engineering team |
---
## 11. Runbook: Emergency Rollback
### 11.1 Symptoms
- ALB 5xx error rate > 10/minute for 3+ minutes
- CloudWatch alarm: `shieldai-production-alb-5xx` in ALARM state
- Customer-reported service degradation
### 11.2 Immediate Actions (0-5 minutes)
```bash
# 1. Confirm environment and scope
ENVIRONMENT="production"
# 2. Check service status
aws ecs describe-services \
--cluster "shieldai-${ENVIRONMENT}" \
--services shieldai-${ENVIRONMENT}-api,shieldai-${ENVIRONMENT}-darkwatch,shieldai-${ENVIRONMENT}-spamshield,shieldai-${ENVIRONMENT}-voiceprint \
--query 'services[*].{Name:serviceName,Running:runningCount,Desired:desiredCount,Status:status}'
# 3. Check ALB health
curl -s -o /dev/null -w "%{http_code}" \
"https://shieldai-${ENVIRONMENT}-alb.us-east-1.elb.amazonaws.com/health"
# 4. Execute rollback
./infra/scripts/rollback.sh ${ENVIRONMENT} all
```
### 11.3 Verification (5-10 minutes)
```bash
# 1. Wait for services to stabilize
aws ecs wait services-stable \
--cluster "shieldai-${ENVIRONMENT}" \
--services shieldai-${ENVIRONMENT}-api,shieldai-${ENVIRONMENT}-darkwatch,shieldai-${ENVIRONMENT}-spamshield,shieldai-${ENVIRONMENT}-voiceprint
# 2. Verify health endpoint
curl -sf "https://shieldai-${ENVIRONMENT}-alb.us-east-1.elb.amazonaws.com/health" \
&& echo "Health: OK" || echo "Health: FAIL"
# 3. Check CloudWatch for recovery
# Navigate to CloudWatch dashboard and verify metrics
```
### 11.4 Communication Template
```
## Rollback Notification
**Environment:** production
**Time:** $(date -u '+%Y-%m-%d %H:%M UTC')
**Trigger:** [ALB 5xx alarm / manual / CI/CD health check]
**Action:** Rolled back all services to previous deployment
**Status:** [In Progress / Verified / Resolved]
**Next steps:** [Post-mortem / monitoring / investigation]
```
### 11.5 Post-Incident
1. Create incident ticket with timeline
2. Document root cause
3. Update runbook if procedure changed
4. Schedule post-mortem within 48 hours
5. Create follow-up issues for preventive measures
---
## Appendix A: Quick Reference
| Resource | Command |
|----------|---------|
| Rollback script | `./infra/scripts/rollback.sh <env> <service\|all>` |
| ECS service status | `aws ecs describe-services --cluster shieldai-<env> --services shieldai-<env>-<svc>` |
| ALB health check | `curl -s -o /dev/null -w "%{http_code}" https://shieldai-<env>-alb.us-east-1.elb.amazonaws.com/health` |
| RDS snapshots | `aws rds describe-db-snapshots --db-instance-identifier shieldai-<env>-db` |
| CloudWatch dashboard | `https://us-east-1.console.aws.amazon.com/cloudwatch/home#dashboards/dashboard/shieldai-<env>-dashboard` |
| ECS task logs | `aws logs filter-log-events --log-group-name /ecs/shieldai-<env>-<svc>` |
## Appendix B: Environment Variables
| Variable | Description | Required |
|----------|-------------|----------|
| `AWS_ACCESS_KEY_ID` | IAM user with ECS, RDS permissions | Yes |
| `AWS_SECRET_ACCESS_KEY` | IAM secret key | Yes |
| `AWS_DEFAULT_REGION` | AWS region (default: us-east-1) | Yes |
| `GITHUB_REPOSITORY_OWNER` | GitHub org/user for container registry | Docker Compose only |
| `DOCKER_TAG` | Container image tag to deploy | Docker Compose only |
| `POSTGRES_PASSWORD` | Database password | Docker Compose only |

View File

@@ -1,57 +0,0 @@
terraform {
backend "s3" {
bucket = "shieldai-production-terraform-state"
key = "production/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "shieldai-terraform-locks"
}
}
module "shieldai" {
source = "../.."
environment = "production"
aws_region = "us-east-1"
project_name = "shieldai"
vpc_cidr = "10.1.0.0/16"
az_count = 3
db_instance_class = "db.r6g.large"
db_multi_az = true
db_backup_retention = 14
elasticache_node_type = "cache.r6g.large"
elasticache_num_nodes = 3
secrets = {
HIBP_API_KEY = var.hibp_api_key
RESEND_API_KEY = var.resend_api_key
SENTRY_DSN = var.sentry_dsn
DATADOG_API_KEY = var.datadog_api_key
}
}
variable "hibp_api_key" {
description = "Have I Been Pwned API key"
type = string
sensitive = true
}
variable "resend_api_key" {
description = "Resend API key"
type = string
sensitive = true
}
variable "sentry_dsn" {
description = "Sentry DSN"
type = string
sensitive = true
}
variable "datadog_api_key" {
description = "Datadog API key"
type = string
sensitive = true
}

View File

@@ -1,4 +0,0 @@
hibp_api_key = "YOUR_HIBP_API_KEY"
resend_api_key = "YOUR_RESEND_API_KEY"
sentry_dsn = "YOUR_SENTRY_DSN"
datadog_api_key = "YOUR_DATADOG_API_KEY"

View File

@@ -1,57 +0,0 @@
terraform {
backend "s3" {
bucket = "shieldai-staging-terraform-state"
key = "staging/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "shieldai-terraform-locks"
}
}
module "shieldai" {
source = "../.."
environment = "staging"
aws_region = "us-east-1"
project_name = "shieldai"
vpc_cidr = "10.0.0.0/16"
az_count = 2
db_instance_class = "db.t3.medium"
db_multi_az = false
db_backup_retention = 3
elasticache_node_type = "cache.t3.small"
elasticache_num_nodes = 1
secrets = {
HIBP_API_KEY = var.hibp_api_key
RESEND_API_KEY = var.resend_api_key
SENTRY_DSN = var.sentry_dsn
DATADOG_API_KEY = var.datadog_api_key
}
}
variable "hibp_api_key" {
description = "Have I Been Pwned API key"
type = string
sensitive = true
}
variable "resend_api_key" {
description = "Resend API key"
type = string
sensitive = true
}
variable "sentry_dsn" {
description = "Sentry DSN"
type = string
sensitive = true
}
variable "datadog_api_key" {
description = "Datadog API key"
type = string
sensitive = true
}

View File

@@ -1,4 +0,0 @@
hibp_api_key = "YOUR_HIBP_API_KEY"
resend_api_key = "YOUR_RESEND_API_KEY"
sentry_dsn = "YOUR_SENTRY_DSN"
datadog_api_key = "YOUR_DATADOG_API_KEY"

View File

@@ -1,61 +0,0 @@
# ShieldAI Load Tests
k6 load testing suite for ShieldAI services.
## Prerequisites
- k6 v0.45+ installed
- Target services running on staging environment
- Authentication tokens for API access
## Running Tests
### Local Execution
```bash
# Run against local development environment
k6 run --env BASE_URL=http://localhost:3000 --env AUTH_TOKEN=dev-token src/darkwatch.js
# Run with results output
k6 run --out json=results.json src/darkwatch.js
```
### CI/CD Execution
```bash
# Run on staging environment
k6 run --env BASE_URL=https://staging-api.freno.me --env AUTH_TOKEN=$STAGING_AUTH_TOKEN src/darkwatch.js
```
## Test Configuration
Each test script includes:
- **Stages**: Ramp-up, sustained load, ramp-down
- **Thresholds**: P99 latency and error rate limits
- **Metrics**: Custom metrics for error tracking
### Current Thresholds
| Service | P99 Latency | Error Rate |
|---------|-------------|------------|
| Darkwatch | < 200ms | < 1% |
## Metrics Collection
Run with output options:
```bash
# JSON output for analysis
k6 run --out json=darkwatch-results.json src/darkwatch.js
# InfluxDB for visualization
k6 run --out influxdb=http://influxdb:8086/k6 src/darkwatch.js
```
## Next Steps
1. Create load test scripts for Spamshield and Voiceprint
2. Integrate with GitHub Actions CI pipeline
3. Set up metrics visualization dashboard
4. Configure alerting on threshold breaches

View File

@@ -1,99 +0,0 @@
import http from 'k6/http';
import { check, group } from 'k6';
import { Rate } from 'k6/metrics';
// Test configuration
export const options = {
stages: [
{ duration: '30s', target: 100 }, // Ramp up to 100 users
{ duration: '2m', target: 500 }, // Ramp to 500 req/s
{ duration: '3m', target: 500 }, // Stay at 500 req/s for 3 minutes
{ duration: '30s', target: 0 }, // Ramp down to 0
],
thresholds: {
http_req_duration: ['p(99)<200'], // P99 latency < 200ms
errors: ['rate<0.01'], // Error rate < 1%
},
};
const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000';
export default function () {
group('Watchlist Operations', function () {
// GET /watchlist
const watchlistRes = http.get(`${BASE_URL}/watchlist`, {
headers: { 'Authorization': `Bearer ${getAuthToken()}` },
});
check(watchlistRes, {
'watchlist GET status is 200': (r) => r.status === 200,
'watchlist GET P99 < 100ms': (r) => r.timings.duration < 100,
});
// POST /watchlist
const newItemRes = http.post(
`${BASE_URL}/watchlist`,
JSON.stringify({ type: 'email', value: `test${Date()}@example.com` }),
{
headers: {
'Authorization': `Bearer ${getAuthToken()}`,
'Content-Type': 'application/json',
},
}
);
check(newItemRes, {
'watchlist POST status is 201': (r) => r.status === 201,
'watchlist POST P99 < 200ms': (r) => r.timings.duration < 200,
});
// POST /scan
const scanRes = http.post(
`${BASE_URL}/scan`,
{},
{
headers: { 'Authorization': `Bearer ${getAuthToken()}` },
}
);
check(scanRes, {
'scan POST status is 200': (r) => r.status === 200,
'scan POST P99 < 150ms': (r) => r.timings.duration < 150,
});
// GET /scan/schedule
const scheduleRes = http.get(`${BASE_URL}/scan/schedule`, {
headers: { 'Authorization': `Bearer ${getAuthToken()}` },
});
check(scheduleRes, {
'schedule GET status is 200': (r) => r.status === 200,
'schedule GET P99 < 100ms': (r) => r.timings.duration < 100,
});
// GET /exposures
const exposuresRes = http.get(`${BASE_URL}/exposures`, {
headers: { 'Authorization': `Bearer ${getAuthToken()}` },
});
check(exposuresRes, {
'exposures GET status is 200': (r) => r.status === 200,
'exposures GET P99 < 150ms': (r) => r.timings.duration < 150,
});
// GET /alerts
const alertsRes = http.get(`${BASE_URL}/alerts`, {
headers: { 'Authorization': `Bearer ${getAuthToken()}` },
});
check(alertsRes, {
'alerts GET status is 200': (r) => r.status === 200,
'alerts GET P99 < 150ms': (r) => r.timings.duration < 150,
});
});
}
// Helper function to get auth token (replace with actual token retrieval)
function getAuthToken() {
return __ENV.AUTH_TOKEN || 'test-token';
}

View File

@@ -1,113 +0,0 @@
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.30"
}
}
backend "s3" {
bucket = "shieldai-terraform-state"
key = "global/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "shieldai-terraform-locks"
}
}
provider "aws" {
region = var.aws_region
default_tags {
tags = {
Project = "ShieldAI"
ManagedBy = "terraform"
Environment = var.environment
}
}
}
module "vpc" {
source = "./modules/vpc"
environment = var.environment
vpc_cidr = var.vpc_cidr
az_count = var.az_count
project_name = var.project_name
kms_key_arn = module.ecs.kms_key_arn
}
module "ecs" {
source = "./modules/ecs"
environment = var.environment
cluster_name = "${var.project_name}-${var.environment}"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnet_ids
public_subnet_ids = module.vpc.public_subnet_ids
security_group_ids = [module.vpc.ecs_security_group_id]
alb_security_group_id = module.vpc.alb_security_group_id
services = var.services
container_images = var.container_images
secrets_arn = module.secrets.secrets_manager_arn
cache_cluster_arn = module.elasticache.replication_group_arn
domain_name = var.domain_name
}
module "rds" {
source = "./modules/rds"
environment = var.environment
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnet_ids
security_group_id = module.vpc.rds_security_group_id
db_name = var.db_name
db_instance_class = var.db_instance_class
multi_az = var.db_multi_az
backup_retention = var.db_backup_retention
project_name = var.project_name
}
module "elasticache" {
source = "./modules/elasticache"
environment = var.environment
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnet_ids
security_group_id = module.vpc.elasticache_security_group_id
node_type = var.elasticache_node_type
num_nodes = var.elasticache_num_nodes
project_name = var.project_name
}
module "s3" {
source = "./modules/s3"
environment = var.environment
project_name = var.project_name
}
module "secrets" {
source = "./modules/secrets"
environment = var.environment
project_name = var.project_name
rds_endpoint = module.rds.db_endpoint
db_password = module.rds.db_password
elasticache_endpoint = module.elasticache.cache_endpoint
redis_auth_token = module.elasticache.auth_token
secrets = var.secrets
}
module "cloudwatch" {
source = "./modules/cloudwatch"
environment = var.environment
cluster_name = "${var.project_name}-${var.environment}"
project_name = var.project_name
rds_identifier = module.rds.db_instance_identifier
cache_endpoint = module.elasticache.cache_endpoint
}

View File

@@ -1,464 +0,0 @@
variable "environment" {
description = "Deployment environment"
type = string
}
variable "cluster_name" {
description = "ECS cluster name"
type = string
}
variable "project_name" {
description = "Project name"
type = string
}
variable "rds_identifier" {
description = "RDS instance identifier"
type = string
}
variable "cache_endpoint" {
description = "ElastiCache endpoint"
type = string
}
variable "alert_email" {
description = "Email address for alert notifications"
type = string
default = "ops@shieldai.com"
}
resource "aws_sns_topic" "alerts" {
name = "${var.project_name}-${var.environment}-alerts"
tags = {
Environment = var.environment
Project = var.project_name
}
}
resource "aws_sns_topic_subscription" "alerts_email" {
topic_arn = aws_sns_topic.alerts.arn
protocol = "email"
endpoint = var.alert_email
}
resource "aws_cloudwatch_dashboard" "main" {
dashboard_name = "${var.project_name}-${var.environment}-dashboard"
dashboard_body = jsonencode({
widgets = [
{
type = "metric"
properties = {
title = "ECS CPU Utilization"
metrics = [
["AWS/ECS", "CPUUtilization", "ClusterName", var.cluster_name]
]
view = "timeSeries"
stacked = false
region = "us-east-1"
period = 300
}
},
{
type = "metric"
properties = {
title = "ECS Memory Utilization"
metrics = [
["AWS/ECS", "MemoryUtilization", "ClusterName", var.cluster_name]
]
view = "timeSeries"
stacked = false
region = "us-east-1"
period = 300
}
},
{
type = "metric"
properties = {
title = "RDS CPU Utilization"
metrics = [
["AWS/RDS", "CPUUtilization", "DBInstanceIdentifier", var.rds_identifier]
]
view = "timeSeries"
stacked = false
region = "us-east-1"
period = 300
}
},
{
type = "metric"
properties = {
title = "ALB Request Count"
metrics = [
["AWS/ApplicationELB", "RequestCount", "LoadBalancer", "${var.cluster_name}-alb"]
]
view = "timeSeries"
stacked = false
region = "us-east-1"
period = 60
}
},
{
type = "metric"
properties = {
title = "ALB 5xx Errors"
metrics = [
["AWS/ApplicationELB", "HTTPCode_Elb_5XX_Count", "LoadBalancer", "${var.cluster_name}-alb"]
]
view = "timeSeries"
stacked = false
region = "us-east-1"
period = 60
}
},
{
type = "metric"
properties = {
title = "P99 Latency (Target Group)"
metrics = [
["AWS/ApplicationELB", "TargetResponseTime", "LoadBalancer", "${var.cluster_name}-alb", "Statistic", "p99"],
["AWS/ApplicationELB", "TargetResponseTime", "LoadBalancer", "${var.cluster_name}-alb", "Statistic", "p95"]
]
view = "timeSeries"
stacked = false
region = "us-east-1"
period = 60
}
},
{
type = "metric"
properties = {
title = "Error Rate (5xx / Total)"
metrics = [
["AWS/ApplicationELB", "HTTPCode_Elb_5XX_Count", "LoadBalancer", "${var.cluster_name}-alb"],
["AWS/ApplicationELB", "HTTPCode_Elb_4XX_Count", "LoadBalancer", "${var.cluster_name}-alb"]
]
view = "timeSeries"
stacked = false
region = "us-east-1"
period = 60
}
},
{
type = "metric"
properties = {
title = "Throughput (Request Count)"
metrics = [
["AWS/ApplicationELB", "RequestCount", "LoadBalancer", "${var.cluster_name}-alb"]
]
view = "timeSeries"
stacked = false
region = "us-east-1"
period = 60
yAxis = {
left = {
label = "Requests/sec"
}
}
}
},
{
type = "metric"
properties = {
title = "API Latency Percentiles"
metrics = [
["ShieldAI", "api_latency", "service", "api", "percentile", "p99", "statistic", "Average"],
["ShieldAI", "api_latency", "service", "api", "percentile", "p95", "statistic", "Average"],
["ShieldAI", "api_latency", "service", "api", "percentile", "p50", "statistic", "Average"]
]
view = "timeSeries"
stacked = false
region = "us-east-1"
period = 60
}
},
{
type = "metric"
properties = {
title = "API Error Rate"
metrics = [
["ShieldAI", "api_errors", "service", "api", "statistic", "Sum"]
]
view = "timeSeries"
stacked = false
region = "us-east-1"
period = 60
}
},
{
type = "metric"
properties = {
title = "API Throughput"
metrics = [
["ShieldAI", "api_requests", "service", "api", "statistic", "Sum"]
]
view = "timeSeries"
stacked = false
region = "us-east-1"
period = 60
}
},
{
type = "metric"
properties = {
title = "ECS Running Tasks"
metrics = [
["AWS/ECS", "RunningTaskCount", "ClusterName", var.cluster_name]
]
view = "timeSeries"
stacked = false
region = "us-east-1"
period = 60
}
},
{
type = "metric"
properties = {
title = "RDS Read/Write IOPS"
metrics = [
["AWS/RDS", "ReadIOPS", "DBInstanceIdentifier", var.rds_identifier],
["AWS/RDS", "WriteIOPS", "DBInstanceIdentifier", var.rds_identifier]
]
view = "timeSeries"
stacked = false
region = "us-east-1"
period = 60
}
}
]
})
}
resource "aws_cloudwatch_metric_alarm" "ecs_cpu_high" {
alarm_name = "${var.project_name}-${var.environment}-ecs-cpu-high"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 2
metric_name = "CPUUtilization"
namespace = "AWS/ECS"
period = 300
statistic = "Average"
threshold = 80
alarm_description = "ECS CPU utilization above 80%"
alarm_actions = [aws_sns_topic.alerts.arn]
dimensions = {
ClusterName = var.cluster_name
}
}
resource "aws_cloudwatch_metric_alarm" "ecs_memory_high" {
alarm_name = "${var.project_name}-${var.environment}-ecs-memory-high"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 2
metric_name = "MemoryUtilization"
namespace = "AWS/ECS"
period = 300
statistic = "Average"
threshold = 85
alarm_description = "ECS memory utilization above 85%"
alarm_actions = [aws_sns_topic.alerts.arn]
dimensions = {
ClusterName = var.cluster_name
}
}
resource "aws_cloudwatch_metric_alarm" "alb_5xx" {
alarm_name = "${var.project_name}-${var.environment}-alb-5xx"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 3
metric_name = "HTTPCode_Elb_5XX_Count"
namespace = "AWS/ApplicationELB"
period = 60
statistic = "Sum"
threshold = 10
alarm_description = "ALB 5xx errors above 10 per minute"
alarm_actions = [aws_sns_topic.alerts.arn]
dimensions = {
LoadBalancer = "${var.cluster_name}-alb"
}
}
resource "aws_cloudwatch_metric_alarm" "rds_cpu_high" {
alarm_name = "${var.project_name}-${var.environment}-rds-cpu-high"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 2
metric_name = "CPUUtilization"
namespace = "AWS/RDS"
period = 300
statistic = "Average"
threshold = 75
alarm_description = "RDS CPU utilization above 75%"
alarm_actions = [aws_sns_topic.alerts.arn]
dimensions = {
DBInstanceIdentifier = var.rds_identifier
}
}
resource "aws_cloudwatch_metric_alarm" "rds_free_storage" {
alarm_name = "${var.project_name}-${var.environment}-rds-free-storage"
comparison_operator = "LessThanThreshold"
evaluation_periods = 2
metric_name = "FreeStorageSpace"
namespace = "AWS/RDS"
period = 300
statistic = "Average"
threshold = 524288000
alarm_description = "RDS free storage below 500MB"
alarm_actions = [aws_sns_topic.alerts.arn]
dimensions = {
DBInstanceIdentifier = var.rds_identifier
}
}
resource "aws_cloudwatch_metric_alarm" "p99_latency_high" {
alarm_name = "${var.project_name}-${var.environment}-p99-latency-high"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 3
metric_name = "TargetResponseTime"
namespace = "AWS/ApplicationELB"
period = 60
statistic = "p99"
threshold = 2
alarm_description = "P99 latency above 2 seconds"
alarm_actions = [aws_sns_topic.alerts.arn]
dimensions = {
LoadBalancer = "${var.cluster_name}-alb"
}
}
resource "aws_cloudwatch_metric_alarm" "error_rate_high" {
alarm_name = "${var.project_name}-${var.environment}-error-rate-high"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 3
metric_name = "HTTPCode_Elb_5XX_Count"
namespace = "AWS/ApplicationELB"
period = 60
statistic = "Sum"
threshold = 5
alarm_description = "Error rate above 5 errors per minute"
alarm_actions = [aws_sns_topic.alerts.arn]
dimensions = {
LoadBalancer = "${var.cluster_name}-alb"
}
}
resource "aws_cloudwatch_metric_alarm" "throughput_low" {
alarm_name = "${var.project_name}-${var.environment}-throughput-low"
comparison_operator = "LessThanThreshold"
evaluation_periods = 5
metric_name = "RequestCount"
namespace = "AWS/ApplicationELB"
period = 60
statistic = "Sum"
threshold = 10
alarm_description = "Throughput below 10 requests per minute"
alarm_actions = [aws_sns_topic.alerts.arn]
dimensions = {
LoadBalancer = "${var.cluster_name}-alb"
}
}
resource "aws_cloudwatch_log_group" "api" {
name = "/${var.project_name}/${var.environment}/api"
retention_in_days = 30
tags = {
Environment = var.environment
Project = var.project_name
Service = "api"
}
}
resource "aws_cloudwatch_log_group" "datadog" {
name = "/${var.project_name}/${var.environment}/datadog"
retention_in_days = 30
tags = {
Environment = var.environment
Project = var.project_name
Service = "datadog"
}
}
resource "aws_cloudwatch_log_group" "sentry" {
name = "/${var.project_name}/${var.environment}/sentry"
retention_in_days = 30
tags = {
Environment = var.environment
Project = var.project_name
Service = "sentry"
}
}
resource "aws_cloudwatch_metric_alarm" "app_p99_latency_high" {
alarm_name = "${var.project_name}-${var.environment}-app-p99-latency-high"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 3
metric_name = "api_latency"
namespace = "ShieldAI"
period = 60
statistic = "Average"
threshold = 2000
alarm_description = "Application P99 latency above 2000ms"
alarm_actions = [aws_sns_topic.alerts.arn]
dimensions = {
service = "api"
percentile = "p99"
}
}
resource "aws_cloudwatch_metric_alarm" "app_error_rate_high" {
alarm_name = "${var.project_name}-${var.environment}-app-error-rate-high"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 3
metric_name = "api_errors"
namespace = "ShieldAI"
period = 60
statistic = "Sum"
threshold = 10
alarm_description = "Application error count above 10 per minute"
alarm_actions = [aws_sns_topic.alerts.arn]
dimensions = {
service = "api"
}
}
resource "aws_cloudwatch_metric_alarm" "app_throughput_low" {
alarm_name = "${var.project_name}-${var.environment}-app-throughput-low"
comparison_operator = "LessThanThreshold"
evaluation_periods = 5
metric_name = "api_requests"
namespace = "ShieldAI"
period = 60
statistic = "Sum"
threshold = 10
alarm_description = "Application throughput below 10 requests per minute"
alarm_actions = [aws_sns_topic.alerts.arn]
dimensions = {
service = "api"
}
}
output "dashboard_url" {
description = "CloudWatch dashboard URL"
value = "https://us-east-1.console.aws.amazon.com/cloudwatch/home#dashboards/dashboard/${var.project_name}-${var.environment}-dashboard"
}
output "sns_topic_arn" {
description = "SNS topic ARN for alerts"
value = aws_sns_topic.alerts.arn
}

View File

@@ -1,519 +0,0 @@
variable "environment" {
description = "Deployment environment"
type = string
}
variable "cluster_name" {
description = "ECS cluster name"
type = string
}
variable "vpc_id" {
description = "VPC ID"
type = string
}
variable "subnet_ids" {
description = "Private subnet IDs for ECS tasks"
type = list(string)
}
variable "public_subnet_ids" {
description = "Public subnet IDs for ALB"
type = list(string)
}
variable "security_group_ids" {
description = "Security group IDs"
type = list(string)
}
variable "alb_security_group_id" {
description = "ALB security group ID"
type = string
}
variable "services" {
description = "ECS services to deploy"
type = map(object({
cpu = number
memory = number
port = number
}))
}
variable "container_images" {
description = "Container image tags"
type = map(string)
}
variable "secrets_arn" {
description = "Secrets Manager ARN"
type = string
}
variable "cache_cluster_arn" {
description = "ElastiCache replication group ARN"
type = string
}
variable "domain_name" {
description = "Route53 hosted zone domain for ACM cert validation"
type = string
default = "shieldai.app"
}
resource "aws_ecs_cluster" "main" {
name = var.cluster_name
settings {
name = "containerInsights"
value = "enabled"
}
tags = {
Name = var.cluster_name
}
}
resource "aws_ecs_cluster_capacity_providers" "main" {
cluster_name = aws_ecs_cluster.main.name
capacity_providers = ["FARGATE"]
default_capacity_provider_strategy {
base = 1
weight = 100
capacity_provider = "FARGATE"
}
}
resource "aws_ecs_task_definition" "services" {
for_each = var.services
family = "${var.cluster_name}-${each.key}"
container_definitions = jsonencode([
{
name = each.key
image = "ghcr.io/shieldai/shieldai-${each.key}:${var.container_images[each.key]}"
cpu = each.cpu
memory = each.memory
essential = true
portMappings = [
{
containerPort = each.port
hostPort = each.port
protocol = "tcp"
}
]
environment = [
{
name = "NODE_ENV"
value = var.environment
},
{
name = "PORT"
value = tostring(each.port)
},
{
name = "DD_ENV"
value = var.environment
},
{
name = "DD_SERVICE"
value = "${var.cluster_name}-${each.key}"
},
{
name = "DD_VERSION"
value = var.container_images[each.key]
},
{
name = "DD_TRACE_ENABLED"
value = "true"
},
{
name = "DD_LOGS_INJECTION"
value = "true"
},
{
name = "DD_AGENT_HOST"
value = "localhost"
},
{
name = "DD_AGENT_PORT"
value = "8126"
},
{
name = "SENTRY_ENVIRONMENT"
value = var.environment
},
{
name = "SENTRY_RELEASE"
value = var.container_images[each.key]
},
{
name = "AWS_REGION"
value = "us-east-1"
},
{
name = "DD_SITE"
value = "datadoghq.com"
}
]
secrets = [
{
name = "DATABASE_URL"
valueFrom = "${var.secrets_arn}:DATABASE_URL::"
},
{
name = "REDIS_URL"
valueFrom = "${var.secrets_arn}:REDIS_URL::"
},
{
name = "HIBP_API_KEY"
valueFrom = "${var.secrets_arn}:HIBP_API_KEY::"
},
{
name = "RESEND_API_KEY"
valueFrom = "${var.secrets_arn}:RESEND_API_KEY::"
},
{
name = "SENTRY_DSN"
valueFrom = "${var.secrets_arn}:SENTRY_DSN::"
},
{
name = "DD_API_KEY"
valueFrom = "${var.secrets_arn}:DD_API_KEY::"
}
]
logConfiguration = {
logDriver = "awslogs"
options = {
"awslogs-group" = "/ecs/${var.cluster_name}-${each.key}"
"awslogs-region" = "us-east-1"
"awslogs-stream-prefix" = each.key
}
}
healthCheck = {
command = ["CMD-SHELL", "curl -f http://localhost:${each.port}/health || exit 1"]
interval = 30
timeout = 5
retries = 3
startPeriod = 60
}
}
])
network_mode = "awsvpc"
memory = each.memory
cpu = each.cpu
requires_compatibilities = ["FARGATE"]
execution_role_arn = aws_iam_role.execution[each.key].arn
task_role_arn = aws_iam_role.task[each.key].arn
tags = {
Name = "${var.cluster_name}-${each.key}"
}
}
resource "aws_iam_role" "execution" {
for_each = var.services
name = "${var.cluster_name}-${each.key}-execution"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "ecs-tasks.amazonaws.com"
}
}
]
})
managed_policy_arns = [
"arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
]
}
resource "aws_iam_role" "task" {
for_each = var.services
name = "${var.cluster_name}-${each.key}-task"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "ecs-tasks.amazonaws.com"
}
}
]
})
inline_policy {
name = "secrets-manager-access"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"secretsmanager:GetSecretValue",
"secretsmanager:DescribeSecret"
]
Resource = var.secrets_arn
}
]
})
}
inline_policy {
name = "elasticache-access"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"elasticache:DescribeCacheClusters",
"elasticache:DescribeCacheSubnetGroups"
]
Resource = var.cache_cluster_arn
}
]
})
}
}
resource "aws_ecs_service" "services" {
for_each = var.services
name = "${var.cluster_name}-${each.key}"
cluster = aws_ecs_cluster.main.id
task_definition = aws_ecs_task_definition.services[each.key].arn
desired_count = var.environment == "production" ? 3 : 1
launch_type = "FARGATE"
network_configuration {
subnets = var.subnet_ids
security_groups = var.security_group_ids
assign_public_ip = false
}
load_balancer {
target_group_arn = aws_lb_target_group.services[each.key].arn
container_name = each.key
container_port = each.port
}
auto_scaling {
max_capacity = var.environment == "production" ? 10 : 3
min_capacity = var.environment == "production" ? 2 : 1
}
tags = {
Name = "${var.cluster_name}-${each.key}"
Service = each.key
}
depends_on = [
aws_lb_listener.https
]
}
resource "aws_lb" "main" {
name = "${var.cluster_name}-alb"
internal = false
load_balancer_type = "application"
security_groups = [var.alb_security_group_id]
subnets = var.public_subnet_ids
tags = {
Name = "${var.cluster_name}-alb"
}
}
resource "aws_acm_certificate" "main" {
domain_name = "${var.cluster_name}.${var.environment}.shieldai.app"
validation_method = "DNS"
tags = {
Name = "${var.cluster_name}-cert"
}
}
data "aws_route53_zone" "main" {
name = var.domain_name
}
resource "aws_route53_record" "acm_validation" {
for_each = {
for rv in aws_acm_certificate.main.domain_validation_options : rv.domain_name => rv
if rv.resource_record_name != null
}
zone_id = data.aws_route53_zone.main.zone_id
name = each.value.resource_record_name
type = each.value.resource_record_type
ttl = 60
records = [each.value.resource_record_value]
}
resource "aws_acm_certificate_validation" "main" {
certificate_arn = aws_acm_certificate.main.arn
validation_record_fqdns = [aws_route53_record.acm_validation[*].fqdn]
}
resource "aws_lb_target_group" "services" {
for_each = var.services
name = "${var.cluster_name}-${each.key}-tg"
port = each.port
protocol = "HTTP"
vpc_id = var.vpc_id
health_check {
enabled = true
healthy_threshold = 3
interval = 30
matcher = "200"
path = "/health"
port = "traffic-port"
protocol = "HTTP"
timeout = 5
unhealthy_threshold = 3
}
stickiness {
type = "lb_cookie"
cookie_duration = 86400
}
}
resource "aws_lb_listener" "https" {
load_balancer_arn = aws_lb.main.arn
port = 443
protocol = "HTTPS"
ssl_certificate_arn = aws_acm_certificate_validation.main.certificate_arn
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.services["api"].arn
}
}
resource "aws_lb_listener_rule" "services" {
for_each = { for k, v in var.services : k => v if k != "api" }
listener_arn = aws_lb_listener.https.arn
action {
type = "forward"
target_group_arn = aws_lb_target_group.services[each.key].arn
}
condition {
path_pattern {
values = ["/${each.key}/*", "/${each.key}"]
}
}
}
resource "aws_lb_listener" "http_redirect" {
load_balancer_arn = aws_lb.main.arn
port = 80
protocol = "HTTP"
default_action {
type = "redirect"
redirect {
port = "443"
protocol = "HTTPS"
status_code = "HTTP_301"
}
}
}
resource "aws_appautoscaling_target" "services" {
for_each = var.services
service_namespace = "ecs"
resource_id = "service/${aws_ecs_cluster.main.name}/${aws_ecs_service.services[each.key].name}"
scalable_dimension = "ecs:service:DesiredCount"
min_capacity = var.environment == "production" ? 2 : 1
max_capacity = var.environment == "production" ? 10 : 3
}
resource "aws_appautoscaling_policy" "cpu" {
for_each = var.services
name = "${var.cluster_name}-${each.key}-cpu-scaling"
service_namespace = "ecs"
resource_id = "service/${aws_ecs_cluster.main.name}/${aws_ecs_service.services[each.key].name}"
scalable_dimension = "ecs:service:DesiredCount"
target_tracking_scaling_policy_configuration {
target_value = 70.0
scale_in_cooldown = 60
scale_out_cooldown = 30
customized_metric_specification {
metric_name = "CPUUtilization"
namespace = "AWS/ECS"
statistic = "Average"
dimensions = [{ name = "ClusterName", value = aws_ecs_cluster.main.name }]
}
}
}
resource "aws_kms_key" "logs" {
description = "${var.cluster_name} logs encryption key"
deletion_window_in_days = 7
enable_key_rotation = true
tags = {
Name = "${var.cluster_name}-logs-kms"
}
}
resource "aws_cloudwatch_log_group" "services" {
for_each = var.services
name = "/ecs/${var.cluster_name}-${each.key}"
retention_in_days = var.environment == "production" ? 30 : 7
kms_key_id = aws_kms_key.logs.arn
tags = {
Name = "${var.cluster_name}-${each.key}-logs"
}
}
output "cluster_arn" {
description = "ECS cluster ARN"
value = aws_ecs_cluster.main.arn
}
output "alb_dns_name" {
description = "ALB DNS name"
value = aws_lb.main.dns_name
}
output "kms_key_arn" {
description = "KMS key ARN for log encryption"
value = aws_kms_key.logs.arn
}

View File

@@ -1,102 +0,0 @@
variable "environment" {
description = "Deployment environment"
type = string
}
variable "vpc_id" {
description = "VPC ID"
type = string
}
variable "subnet_ids" {
description = "Private subnet IDs"
type = list(string)
}
variable "security_group_id" {
description = "ElastiCache security group ID"
type = string
}
variable "node_type" {
description = "Cache node type"
type = string
}
variable "num_nodes" {
description = "Number of cache nodes"
type = number
}
variable "project_name" {
description = "Project name"
type = string
}
resource "aws_elasticache_subnet_group" "main" {
name = "${var.project_name}-${var.environment}-redis-subnet"
subnet_ids = var.subnet_ids
tags = {
Name = "${var.project_name}-${var.environment}-redis-subnet"
}
}
resource "random_password" "redis_auth" {
length = 32
special = false
keepers = {
environment = var.environment
}
}
resource "aws_elasticache_replication_group" "main" {
replication_group_id = "${var.project_name}-${var.environment}-redis"
description = "${var.project_name} Redis cluster (${var.environment})"
node_type = var.node_type
num_cache_clusters = var.num_nodes
engine = "redis"
engine_version = "7.0"
auth_token = random_password.redis_auth.result
transit_encryption_enabled = true
at_rest_encryption_enabled = true
port = 6379
subnet_group_name = aws_elasticache_subnet_group.main.name
security_group_ids = [var.security_group_id]
automatic_failover_enabled = var.environment == "production"
snapshot_retention_limit = var.environment == "production" ? 7 : 1
snapshot_window = "03:00-04:00"
tags = {
Name = "${var.project_name}-${var.environment}-redis"
}
}
output "cache_endpoint" {
description = "ElastiCache primary endpoint"
value = aws_elasticache_replication_group.main.primary_endpoint_address
}
output "reader_endpoint" {
description = "ElastiCache reader endpoint"
value = aws_elasticache_replication_group.main.reader_endpoint_address
}
output "auth_token" {
description = "Redis auth token"
value = random_password.redis_auth.result
sensitive = true
}
output "replication_group_arn" {
description = "ElastiCache replication group ARN"
value = aws_elasticache_replication_group.main.arn
}

View File

@@ -1,138 +0,0 @@
variable "environment" {
description = "Deployment environment"
type = string
}
variable "vpc_id" {
description = "VPC ID"
type = string
}
variable "subnet_ids" {
description = "Private subnet IDs"
type = list(string)
}
variable "security_group_id" {
description = "RDS security group ID"
type = string
}
variable "db_name" {
description = "Database name"
type = string
}
variable "db_instance_class" {
description = "RDS instance class"
type = string
}
variable "multi_az" {
description = "Multi-AZ deployment"
type = bool
}
variable "backup_retention" {
description = "Backup retention days"
type = number
}
variable "project_name" {
description = "Project name"
type = string
}
resource "aws_db_subnet_group" "main" {
name = "${var.project_name}-${var.environment}-db-subnet"
subnet_ids = var.subnet_ids
tags = {
Name = "${var.project_name}-${var.environment}-db-subnet"
}
}
resource "aws_db_instance" "main" {
identifier = "${var.project_name}-${var.environment}-db"
engine = "postgres"
engine_version = "16.2"
instance_class = var.db_instance_class
allocated_storage = var.environment == "production" ? 100 : 20
db_name = var.db_name
username = "shieldai"
password = random_password.db_password.result
multi_az = var.multi_az
db_subnet_group_name = aws_db_subnet_group.main.name
vpc_security_group_ids = [var.security_group_id]
backup_retention_period = var.backup_retention
backup_window = "03:00-04:00"
maintenance_window = "sun:04:00-sun:05:00"
skip_final_snapshot = var.environment != "production"
final_snapshot_identifier = "${var.project_name}-${var.environment}-final"
storage_encrypted = true
storage_type = "gp3"
iops = var.environment == "production" ? 3000 : 1000
deletion_protection = var.environment == "production"
copy_tags_to_snapshot = true
tags = {
Name = "${var.project_name}-${var.environment}-db"
}
}
resource "random_password" "db_password" {
length = 16
special = true
keepers = {
environment = var.environment
}
}
resource "aws_secretsmanager_secret_version" "db_password" {
secret_id = aws_secretsmanager_secret.db_password.id
secret_string = jsonencode({
username = "shieldai"
password = random_password.db_password.result
engine = "postgres"
host = aws_db_instance.main.address
port = aws_db_instance.main.port
})
}
resource "aws_secretsmanager_secret" "db_password" {
name = "${var.project_name}-${var.environment}-db-password"
tags = {
Name = "${var.project_name}-${var.environment}-db-password"
}
}
output "db_endpoint" {
description = "RDS endpoint"
value = aws_db_instance.main.endpoint
sensitive = true
}
output "db_instance_identifier" {
description = "RDS instance identifier"
value = aws_db_instance.main.identifier
}
output "db_password_secret_arn" {
description = "DB password secret ARN"
value = aws_secretsmanager_secret.db_password.arn
}
output "db_password" {
description = "Generated DB password"
value = random_password.db_password.result
sensitive = true
}

View File

@@ -1,145 +0,0 @@
variable "environment" {
description = "Deployment environment"
type = string
}
variable "project_name" {
description = "Project name"
type = string
}
resource "aws_s3_bucket" "terraform_state" {
bucket = "${var.project_name}-${var.environment}-terraform-state"
tags = {
Name = "${var.project_name}-${var.environment}-terraform-state"
}
}
resource "aws_s3_bucket_public_access_block" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_versioning" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
}
}
}
resource "aws_s3_bucket_lifecycle_configuration" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
rule {
id = "expire-noncurrent"
status = "Enabled"
noncurrent_version_expiration {
noncurrent_days = 30
}
}
}
resource "aws_s3_bucket" "artifacts" {
bucket = "${var.project_name}-${var.environment}-artifacts"
tags = {
Name = "${var.project_name}-${var.environment}-artifacts"
}
}
resource "aws_s3_bucket_public_access_block" "artifacts" {
bucket = aws_s3_bucket.artifacts.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_versioning" "artifacts" {
bucket = aws_s3_bucket.artifacts.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "artifacts" {
bucket = aws_s3_bucket.artifacts.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
}
}
}
resource "aws_s3_bucket" "logs" {
bucket = "${var.project_name}-${var.environment}-logs"
tags = {
Name = "${var.project_name}-${var.environment}-logs"
}
}
resource "aws_s3_bucket_public_access_block" "logs" {
bucket = aws_s3_bucket.logs.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_server_side_encryption_configuration" "logs" {
bucket = aws_s3_bucket.logs.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
}
}
}
resource "aws_s3_bucket_lifecycle_configuration" "logs" {
bucket = aws_s3_bucket.logs.id
rule {
id = "expire-old-logs"
status = "Enabled"
expiration {
days = 90
}
}
}
output "bucket_name" {
description = "Terraform state S3 bucket name"
value = aws_s3_bucket.terraform_state.id
}
output "artifacts_bucket_name" {
description = "Artifacts S3 bucket name"
value = aws_s3_bucket.artifacts.id
}
output "logs_bucket_name" {
description = "Logs S3 bucket name"
value = aws_s3_bucket.logs.id
}

View File

@@ -1,69 +0,0 @@
variable "environment" {
description = "Deployment environment"
type = string
}
variable "project_name" {
description = "Project name"
type = string
}
variable "rds_endpoint" {
description = "RDS instance endpoint"
type = string
}
variable "db_password" {
description = "Generated RDS password"
type = string
sensitive = true
}
variable "elasticache_endpoint" {
description = "ElastiCache primary endpoint"
type = string
}
variable "redis_auth_token" {
description = "ElastiCache auth token"
type = string
sensitive = true
}
variable "secrets" {
description = "Secrets to store"
type = map(string)
default = {}
}
resource "aws_secretsmanager_secret" "main" {
name = "${var.project_name}-${var.environment}-app-secrets"
description = "Application secrets for ${var.project_name} (${var.environment})"
tags = {
Name = "${var.project_name}-${var.environment}-app-secrets"
Environment = var.environment
}
}
resource "aws_secretsmanager_secret_version" "main" {
secret_id = aws_secretsmanager_secret.main.id
secret_string = jsonencode(merge({
DATABASE_URL = "postgresql://shieldai:${var.db_password}@${var.rds_endpoint}:5432/shieldai"
REDIS_URL = "redis://:${var.redis_auth_token}@${var.elasticache_endpoint}:6379"
NODE_ENV = var.environment
LOG_LEVEL = var.environment == "production" ? "info" : "debug"
}, var.secrets))
}
output "secrets_manager_arn" {
description = "Secrets Manager ARN"
value = aws_secretsmanager_secret.main.arn
}
output "secrets_manager_name" {
description = "Secrets Manager secret name"
value = aws_secretsmanager_secret.main.name
}

View File

@@ -1,338 +0,0 @@
variable "environment" {
description = "Deployment environment"
type = string
}
variable "vpc_cidr" {
description = "CIDR block for VPC"
type = string
}
variable "az_count" {
description = "Number of availability zones"
type = number
}
variable "project_name" {
description = "Project name"
type = string
}
variable "kms_key_arn" {
description = "KMS key ARN for log encryption"
type = string
default = ""
}
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = "${var.project_name}-${var.environment}-vpc"
}
}
data "aws_availability_zones" "available" {
state = "available"
}
resource "aws_subnet" "public" {
count = var.az_count
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index)
availability_zone = data.aws_availability_zones.available.names[count.index]
map_public_ip_on_launch = false
tags = {
Name = "${var.project_name}-${var.environment}-public-${data.aws_availability_zones.available.names[count.index]}"
"kubernetes.io/role/elb" = "1"
}
}
resource "aws_subnet" "private" {
count = var.az_count
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, var.az_count + count.index)
availability_zone = data.aws_availability_zones.available.names[count.index]
tags = {
Name = "${var.project_name}-${var.environment}-private-${data.aws_availability_zones.available.names[count.index]}"
"kubernetes.io/role/internal-elb" = "1"
}
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project_name}-${var.environment}-igw"
}
}
resource "aws_eip" "nat" {
count = var.az_count
domain = "vpc"
tags = {
Name = "${var.project_name}-${var.environment}-nat-${count.index}"
}
}
resource "aws_nat_gateway" "main" {
count = var.az_count
allocation_id = aws_eip.nat[count.index].id
subnet_id = aws_subnet.public[count.index].id
tags = {
Name = "${var.project_name}-${var.environment}-nat-${count.index}"
}
depends_on = [aws_internet_gateway.main]
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
tags = {
Name = "${var.project_name}-${var.environment}-public-rt"
}
}
resource "aws_route_table" "private" {
count = var.az_count
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.main[count.index].id
}
tags = {
Name = "${var.project_name}-${var.environment}-private-rt-${count.index}"
}
}
resource "aws_route_table_association" "public" {
count = var.az_count
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table_association" "private" {
count = var.az_count
subnet_id = aws_subnet.private[count.index].id
route_table_id = aws_route_table.private[count.index].id
}
resource "aws_security_group" "alb" {
name_prefix = "${var.project_name}-${var.environment}-alb"
vpc_id = aws_vpc.main.id
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
description = "HTTPS from internet"
}
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
description = "HTTP from internet (redirect)"
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.project_name}-${var.environment}-alb-sg"
}
}
resource "aws_security_group" "ecs" {
name_prefix = "${var.project_name}-${var.environment}-ecs"
vpc_id = aws_vpc.main.id
ingress {
from_port = 3000
to_port = 3003
protocol = "tcp"
security_groups = [aws_security_group.alb.id]
description = "Service ports from ALB only"
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.project_name}-${var.environment}-ecs-sg"
}
}
resource "aws_security_group" "rds" {
name_prefix = "${var.project_name}-${var.environment}-rds"
vpc_id = aws_vpc.main.id
ingress {
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [aws_security_group.ecs.id]
description = "PostgreSQL from ECS"
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.project_name}-${var.environment}-rds-sg"
}
}
resource "aws_security_group" "elasticache" {
name_prefix = "${var.project_name}-${var.environment}-elasticache"
vpc_id = aws_vpc.main.id
ingress {
from_port = 6379
to_port = 6379
protocol = "tcp"
security_groups = [aws_security_group.ecs.id]
description = "Redis from ECS"
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.project_name}-${var.environment}-elasticache-sg"
}
}
resource "aws_flow_log" "main" {
iam_role_arn = aws_iam_role.flow_log.arn
log_destination = aws_cloudwatch_log_group.flow_log.arn
vpc_id = aws_vpc.main.id
traffic_type = "ALL"
tags = {
Name = "${var.project_name}-${var.environment}-flow-log"
}
}
resource "aws_iam_role" "flow_log" {
name = "${var.project_name}-${var.environment}-flow-log-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "vpc-flow-logs.amazonaws.com"
}
}
]
})
}
resource "aws_iam_role_policy" "flow_log" {
name = "${var.project_name}-${var.environment}-flow-log-policy"
role = aws_iam_role.flow_log.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents",
"logs:DescribeLogGroups",
"logs:DescribeLogStreams"
]
Effect = "Allow"
Resource = [aws_cloudwatch_log_group.flow_log.arn]
}
]
})
}
resource "aws_cloudwatch_log_group" "flow_log" {
name = "/${var.project_name}/${var.environment}/vpc-flow-log"
retention_in_days = var.environment == "production" ? 30 : 7
kms_key_id = var.kms_key_arn != "" ? var.kms_key_arn : null
tags = {
Name = "${var.project_name}-${var.environment}-flow-log"
}
}
output "vpc_id" {
description = "VPC ID"
value = aws_vpc.main.id
}
output "private_subnet_ids" {
description = "Private subnet IDs"
value = aws_subnet.private[*].id
}
output "public_subnet_ids" {
description = "Public subnet IDs"
value = aws_subnet.public[*].id
}
output "alb_security_group_id" {
description = "ALB security group ID"
value = aws_security_group.alb.id
}
output "ecs_security_group_id" {
description = "ECS security group ID"
value = aws_security_group.ecs.id
}
output "rds_security_group_id" {
description = "RDS security group ID"
value = aws_security_group.rds.id
}
output "elasticache_security_group_id" {
description = "ElastiCache security group ID"
value = aws_security_group.elasticache.id
}

View File

@@ -1,35 +0,0 @@
output "vpc_id" {
description = "VPC ID"
value = module.vpc.vpc_id
}
output "cluster_name" {
description = "ECS cluster name"
value = "${var.project_name}-${var.environment}"
}
output "rds_endpoint" {
description = "RDS endpoint"
value = module.rds.db_endpoint
sensitive = true
}
output "elasticache_endpoint" {
description = "ElastiCache primary endpoint"
value = module.elasticache.cache_endpoint
}
output "s3_bucket_name" {
description = "S3 bucket name"
value = module.s3.bucket_name
}
output "secrets_manager_arn" {
description = "Secrets Manager ARN"
value = module.secrets.secrets_manager_arn
}
output "cloudwatch_dashboard_url" {
description = "CloudWatch dashboard URL"
value = module.cloudwatch.dashboard_url
}

View File

@@ -1,121 +0,0 @@
#!/bin/bash
set -euo pipefail
# ShieldAI Docker Compose Rollback Script
# Usage: ./rollback-compose.sh <previous_tag> [--env prod|dev]
#
# Rolls back all services to a previous tagged image using docker-compose.prod.yml
#
# Examples:
# ./rollback-compose.sh v1.2.3 # Rollback to v1.2.3
# ./rollback-compose.sh v1.2.3 --env prod # Explicit production compose
PREVIOUS_TAG="${1:-}"
ENV_MODE="${2:-prod}"
# ─── Configuration ───────────────────────────────────────────────
SERVICES="api darkwatch spamshield voiceprint"
COMPOSE_FILE="docker-compose.prod.yml"
REGISTRY_OWNER="${GITHUB_REPOSITORY_OWNER:-shieldai}"
# ─── Helpers ─────────────────────────────────────────────────────
log() {
local level="$1"
shift
echo "[$(date -u '+%H:%M:%S')] [$level] $*"
}
log_info() { log "INFO" "$@"; }
log_warn() { log "WARN" "$@"; }
log_error() { log "ERROR" "$@"; }
# ─── Validation ──────────────────────────────────────────────────
if [[ -z "$PREVIOUS_TAG" ]]; then
log_error "Usage: $0 <previous_tag> [--env prod|dev]"
log_error "Example: $0 v1.2.3"
exit 1
fi
if ! command -v docker &>/dev/null; then
log_error "Docker not found in PATH"
exit 1
fi
# ─── Rollback Logic ──────────────────────────────────────────────
main() {
log_info "=== Docker Compose Rollback ==="
log_info "Target tag: $PREVIOUS_TAG"
log_info "Compose file: $COMPOSE_FILE"
log_info "Registry: ghcr.io/$REGISTRY_OWNER"
# 1. Pull previous images
log_info "Pulling previous images..."
local pull_failed=0
for svc in $SERVICES; do
local image="ghcr.io/${REGISTRY_OWNER}/shieldai-${svc}:${PREVIOUS_TAG}"
log_info "Pulling $image..."
if docker pull "$image" 2>/dev/null; then
log_info "Pulled: $image"
else
log_warn "Pull failed: $image (may not exist)"
pull_failed=1
fi
done
if [[ $pull_failed -eq 1 ]]; then
log_warn "Some images may not exist at tag $PREVIOUS_TAG"
log_info "Continuing with available images..."
fi
# 2. Stop current services gracefully
log_info "Stopping current services..."
DOCKER_TAG="$PREVIOUS_TAG" docker compose -f "$COMPOSE_FILE" down --timeout 30 2>/dev/null || true
# 3. Start with previous tag
log_info "Starting services with tag $PREVIOUS_TAG..."
DOCKER_TAG="$PREVIOUS_TAG" docker compose -f "$COMPOSE_FILE" up -d
# 4. Wait for services to be healthy
log_info "Waiting for services to become healthy..."
sleep 10
# 5. Verify health
local passed=0
local failed=0
for svc in $SERVICES; do
local port
port=$(case "$svc" in
api) echo 3000 ;;
darkwatch) echo 3001 ;;
spamshield) echo 3002 ;;
voiceprint) echo 3003 ;;
esac)
local http_code
http_code=$(curl -s -o /dev/null -w "%{http_code}" \
--connect-timeout 10 --max-time 30 \
"http://localhost:${port}/health" 2>/dev/null || echo "000")
if [[ "$http_code" == "200" ]]; then
log_info "Health OK: $svc (port $port, HTTP $http_code)"
((passed++))
else
log_warn "Health FAIL: $svc (port $port, HTTP $http_code)"
((failed++))
fi
done
log_info "=== Rollback Complete ==="
log_info "Passed: $passed, Failed: $failed"
if [[ $failed -gt 0 ]]; then
log_warn "Some services failed health check. Check logs: docker compose -f $COMPOSE_FILE logs"
exit 1
fi
log_info "All services healthy after rollback"
exit 0
}
main "$@"

View File

@@ -1,164 +0,0 @@
#!/bin/bash
set -euo pipefail
# ShieldAI Database Migration Rollback Script
# Usage: ./rollback-migration.sh <environment> [--migration <name>]
#
# Rolls back the most recent migration or a specific named migration
# Uses AWS Secrets Manager for database credentials
#
# Examples:
# ./rollback-migration.sh staging # Rollback latest
# ./rollback-migration.sh production --migration 001_create_users # Rollback specific
ENVIRONMENT="${1:-staging}"
MIGRATION_NAME="${3:-}"
# ─── Configuration ───────────────────────────────────────────────
SECRET_ID="shieldai-${ENVIRONMENT}-db-password"
DB_NAME="shieldai"
DB_USER="shieldai"
# ─── Helpers ─────────────────────────────────────────────────────
log() {
local level="$1"
shift
echo "[$(date -u '+%H:%M:%S')] [$level] $*"
}
log_info() { log "INFO" "$@"; }
log_warn() { log "WARN" "$@"; }
log_error() { log "ERROR" "$@"; }
# ─── Validation ──────────────────────────────────────────────────
if [[ "$ENVIRONMENT" != "staging" && "$ENVIRONMENT" != "production" ]]; then
log_error "Invalid environment: $ENVIRONMENT (expected: staging, production)"
exit 1
fi
for cmd in aws jq; do
if ! command -v "$cmd" &>/dev/null; then
log_error "Missing prerequisite: $cmd"
exit 1
fi
done
# ─── Credentials ─────────────────────────────────────────────────
get_db_credentials() {
log_info "Fetching database credentials from Secrets Manager..."
local secret
secret=$(aws secretsmanager get-secret-value \
--secret-id "$SECRET_ID" \
--query 'SecretString' \
--output json 2>/dev/null)
if [[ -z "$secret" ]]; then
log_error "Failed to fetch secret: $SECRET_ID"
exit 1
fi
export DB_HOST=$(echo "$secret" | jq -r '.host')
export DB_PORT=$(echo "$secret" | jq -r '.port' // '5432')
export DB_PASS=$(echo "$secret" | jq -r '.password')
export DATABASE_URL="postgresql://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/${DB_NAME}"
log_info "Database: ${DB_HOST}:${DB_PORT}/${DB_NAME}"
}
# ─── Migration Status ────────────────────────────────────────────
show_migration_status() {
log_info "=== Current Migration Status ==="
if command -v npx &>/dev/null; then
npx drizzle-kit status --config=drizzle.config.ts 2>/dev/null || \
log_warn "Drizzle status check completed (some warnings expected)"
fi
# Show applied migrations from database
log_info "Applied migrations:"
PGPASSWORD="$DB_PASS" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" \
-c "SELECT id, checksum, type FROM __drizzle_migrations_schema ORDER BY id DESC;" 2>/dev/null || \
log_warn "Could not query migration table (psql may not be installed)"
}
# ─── Rollback Logic ──────────────────────────────────────────────
rollback_latest() {
log_info "=== Rolling Back Latest Migration ==="
# Get the latest applied migration
local latest_migration
latest_migration=$(PGPASSWORD="$DB_PASS" psql -h "$DB_HOST" -p "$DB_PORT" \
-U "$DB_USER" -d "$DB_NAME" -t -A \
-c "SELECT id FROM __drizzle_migrations_schema ORDER BY id DESC LIMIT 1;" 2>/dev/null)
if [[ -z "$latest_migration" ]]; then
log_warn "No applied migrations found"
return 0
fi
log_info "Latest migration: $latest_migration"
# Resolve the migration (marks it as not applied)
if command -v npx &>/dev/null; then
npx drizzle-kit migrate:resolve --migration "$latest_migration" --status applied 2>/dev/null || \
log_warn "Migration resolve completed (check output for details)"
fi
log_info "Migration $latest_migration marked as resolved"
}
rollback_specific() {
local target="$1"
log_info "=== Rolling Back Migration: $target ==="
if command -v npx &>/dev/null; then
npx drizzle-kit migrate:resolve --migration "$target" --status applied 2>/dev/null || \
log_warn "Migration resolve completed (check output for details)"
fi
log_info "Migration $target marked as resolved"
}
# ─── Verification ────────────────────────────────────────────────
verify_connection() {
log_info "=== Verifying Database Connection ==="
local result
result=$(PGPASSWORD="$DB_PASS" psql -h "$DB_HOST" -p "$DB_PORT" \
-U "$DB_USER" -d "$DB_NAME" -t -A \
-c "SELECT version();" 2>/dev/null || echo "FAIL")
if [[ "$result" != "FAIL" ]]; then
log_info "Connection OK: PostgreSQL $result"
else
log_warn "Connection check failed"
fi
}
# ─── Main ────────────────────────────────────────────────────────
main() {
log_info "=== ShieldAI Migration Rollback ==="
log_info "Environment: $ENVIRONMENT"
log_info "Secret: $SECRET_ID"
get_db_credentials
show_migration_status
if [[ -n "$MIGRATION_NAME" ]]; then
rollback_specific "$MIGRATION_NAME"
else
rollback_latest
fi
verify_connection
show_migration_status
log_info "=== Rollback Complete ==="
log_info "Next steps:"
log_info "1. Verify application schema compatibility"
log_info "2. Run application health checks"
log_info "3. If needed, redeploy ECS services: ./rollback.sh $ENVIRONMENT all"
}
main "$@"

View File

@@ -1,255 +0,0 @@
#!/bin/bash
set -euo pipefail
# ShieldAI ECS Rollback Script
# Usage: ./rollback.sh <environment> <service|all> [--verify]
#
# Environments: staging, production
# Services: api, darkwatch, spamshield, voiceprint, all
#
# Examples:
# ./rollback.sh staging api # Rollback single service
# ./rollback.sh production all # Rollback all services
# ./rollback.sh production all --verify # Rollback with post-verification
# ─── Configuration ───────────────────────────────────────────────
ENVIRONMENT="${1:-staging}"
SERVICE="${2:-all}"
VERIFY="${3:-false}"
CLUSTER="shieldai-${ENVIRONMENT}"
SERVICES_LIST="api darkwatch spamshield voiceprint"
EXIT_CODE=0
TIMESTAMP=$(date -u '+%Y-%m-%d %H:%M:%S UTC')
LOG_FILE="/tmp/shieldai-rollback-${ENVIRONMENT}-${TIMESTAMP//[: ]/_}.log"
# ─── Helpers ─────────────────────────────────────────────────────
log() {
local level="$1"
shift
local msg="$*"
echo "[$(date -u '+%H:%M:%S')] [$level] $msg" | tee -a "$LOG_FILE"
}
log_info() { log "INFO" "$@"; }
log_warn() { log "WARN" "$@"; }
log_error() { log "ERROR" "$@"; }
# ─── Validation ──────────────────────────────────────────────────
validate_environment() {
if [[ "$ENVIRONMENT" != "staging" && "$ENVIRONMENT" != "production" ]]; then
log_error "Invalid environment: $ENVIRONMENT (expected: staging, production)"
exit 1
fi
}
validate_service() {
if [[ "$SERVICE" == "all" ]]; then
return 0
fi
if ! echo "$SERVICES_LIST" | grep -qw "$SERVICE"; then
log_error "Invalid service: $SERVICE (expected: api, darkwatch, spamshield, voiceprint, all)"
exit 1
fi
}
check_prerequisites() {
local missing=()
for cmd in aws jq curl; do
if ! command -v "$cmd" &>/dev/null; then
missing+=("$cmd")
fi
done
if [[ ${#missing[@]} -gt 0 ]]; then
log_error "Missing prerequisites: ${missing[*]}"
exit 1
fi
if [[ -z "${AWS_DEFAULT_REGION:-}" ]]; then
export AWS_DEFAULT_REGION="us-east-1"
fi
log_info "Prerequisites OK (region: $AWS_DEFAULT_REGION)"
}
# ─── Rollback Logic ──────────────────────────────────────────────
get_target_services() {
if [[ "$SERVICE" == "all" ]]; then
echo "$SERVICES_LIST"
else
echo "$SERVICE"
fi
}
rollback_service() {
local svc="$1"
local service_name="${CLUSTER}-${svc}"
log_info "Rolling back $service_name..."
# Check current deployment status
local current_task_def
current_task_def=$(aws ecs describe-services \
--cluster "$CLUSTER" \
--services "$service_name" \
--query 'services[0].taskDefinition' \
--output text 2>/dev/null || echo "UNKNOWN")
log_info "Current task definition: $current_task_def"
# Execute rollback
if aws ecs update-service \
--cluster "$CLUSTER" \
--service "$service_name" \
--rollback \
--no-cli-auto-prompt 2>>"$LOG_FILE"; then
log_info "Rollback initiated for $service_name"
else
log_error "Rollback failed to initiate for $service_name"
EXIT_CODE=1
return 1
fi
# Wait for stabilization (max 5 minutes)
log_info "Waiting for $service_name to stabilize (timeout: 300s)..."
if aws ecs wait services-stable \
--cluster "$CLUSTER" \
--services "$service_name" \
--timeout 300 2>>"$LOG_FILE"; then
log_info "$service_name stabilized successfully"
else
log_warn "$service_name stabilization timed out or failed"
EXIT_CODE=1
return 1
fi
# Get new task definition after rollback
local new_task_def
new_task_def=$(aws ecs describe-services \
--cluster "$CLUSTER" \
--services "$service_name" \
--query 'services[0].taskDefinition' \
--output text 2>/dev/null || echo "UNKNOWN")
local running_count
running_count=$(aws ecs describe-services \
--cluster "$CLUSTER" \
--services "$service_name" \
--query 'services[0].runningCount' \
--output text 2>/dev/null || echo "0")
local desired_count
desired_count=$(aws ecs describe-services \
--cluster "$CLUSTER" \
--services "$service_name" \
--query 'services[0].desiredCount' \
--output text 2>/dev/null || echo "0")
log_info "Rollback complete: $service_name -> $new_task_def ($running_count/$desired_count running)"
return 0
}
# ─── Health Verification ─────────────────────────────────────────
verify_health() {
local svc="$1"
local port
port=$(case "$svc" in
api) echo 3000 ;;
darkwatch) echo 3001 ;;
spamshield) echo 3002 ;;
voiceprint) echo 3003 ;;
*) echo 3000 ;;
esac)
local alb_dns="https://${CLUSTER}-alb.${AWS_DEFAULT_REGION}.elb.amazonaws.com"
log_info "Verifying health for $svc (ALB: $alb_dns)..."
local http_code
http_code=$(curl -s -o /dev/null -w "%{http_code}" \
--connect-timeout 10 \
--max-time 30 \
"$alb_dns/health" 2>/dev/null || echo "000")
if [[ "$http_code" == "200" ]]; then
log_info "Health check PASSED: $svc (HTTP $http_code)"
return 0
else
log_warn "Health check FAILED: $svc (HTTP $http_code)"
return 1
fi
}
verify_all_services() {
log_info "=== Post-Rollback Health Verification ==="
local passed=0
local failed=0
for svc in $(get_target_services); do
if verify_health "$svc"; then
((passed++))
else
((failed++))
fi
done
log_info "Verification complete: $passed passed, $failed failed"
if [[ $failed -gt 0 ]]; then
log_warn "Some services failed health verification"
EXIT_CODE=1
fi
}
# ─── Main Execution ──────────────────────────────────────────────
main() {
log_info "=== ShieldAI Rollback ==="
log_info "Environment: $ENVIRONMENT"
log_info "Service(s): $SERVICE"
log_info "Cluster: $CLUSTER"
log_info "Verify: $VERIFY"
log_info "Timestamp: $TIMESTAMP"
log_info "Log file: $LOG_FILE"
log_info "=========================="
# Validate inputs
validate_environment
validate_service
check_prerequisites
# Execute rollback for each target service
local rolled_back=0
local failed=0
for svc in $(get_target_services); do
if rollback_service "$svc"; then
((rolled_back++))
else
((failed++))
fi
done
log_info "=== Rollback Summary ==="
log_info "Rolled back: $rolled_back services"
log_info "Failed: $failed services"
# Post-rollback verification
if [[ "$VERIFY" == "--verify" ]] || [[ "$VERIFY" == "true" ]]; then
verify_all_services
fi
if [[ $failed -gt 0 ]]; then
log_error "Rollback completed with $failed failure(s)"
log_info "Full log: $LOG_FILE"
exit "$EXIT_CODE"
fi
log_info "Rollback completed successfully"
log_info "Full log: $LOG_FILE"
exit 0
}
main "$@"

View File

@@ -1,237 +0,0 @@
#!/bin/bash
set -uo pipefail
# ShieldAI Rollback Test Suite
# Usage: ./test-rollback.sh [ecs|compose|migration|all]
#
# Validates rollback scripts and procedures without mutating production
# Run against staging environment for integration tests
TEST_SUITE="${1:-all}"
PASS=0
FAIL=0
SKIP=0
# ─── Helpers ─────────────────────────────────────────────────────
log() {
echo "[$(date -u '+%H:%M:%S')] $*"
}
assert_eq() {
local desc="$1" expected="$2" actual="$3"
if [[ "$expected" == "$actual" ]]; then
log " ✅ PASS: $desc"
((PASS++))
else
log " ❌ FAIL: $desc (expected: $expected, got: $actual)"
((FAIL++))
fi
}
assert_file_exists() {
local desc="$1" path="$2"
if [[ -f "$path" ]]; then
log " ✅ PASS: $desc"
((PASS++))
else
log " ❌ FAIL: $desc ($path not found)"
((FAIL++))
fi
}
assert_executable() {
local desc="$1" path="$2"
if [[ -x "$path" ]]; then
log " ✅ PASS: $desc"
((PASS++))
else
log " ❌ FAIL: $desc ($path not executable)"
((FAIL++))
fi
}
assert_script_syntax() {
local desc="$1" path="$2"
if bash -n "$path" 2>/dev/null; then
log " ✅ PASS: $desc (syntax OK)"
((PASS++))
else
log " ❌ FAIL: $desc (syntax error)"
((FAIL++))
fi
}
assert_contains() {
local desc="$1" file="$2" pattern="$3"
if grep -q -- "$pattern" "$file" 2>/dev/null; then
log " ✅ PASS: $desc"
((PASS++))
else
log " ❌ FAIL: $desc (pattern '$pattern' not found in $file)"
((FAIL++))
fi
}
# ─── Test: File Structure ────────────────────────────────────────
test_file_structure() {
log "=== Test: File Structure ==="
assert_file_exists "ROLLBACK.md exists" "infra/ROLLBACK.md"
assert_file_exists "rollback.sh exists" "infra/scripts/rollback.sh"
assert_file_exists "rollback-compose.sh exists" "infra/scripts/rollback-compose.sh"
assert_file_exists "rollback-migration.sh exists" "infra/scripts/rollback-migration.sh"
assert_executable "rollback.sh is executable" "infra/scripts/rollback.sh"
assert_executable "rollback-compose.sh is executable" "infra/scripts/rollback-compose.sh"
assert_executable "rollback-migration.sh is executable" "infra/scripts/rollback-migration.sh"
}
# ─── Test: Script Syntax ─────────────────────────────────────────
test_script_syntax() {
log "=== Test: Script Syntax ==="
assert_script_syntax "rollback.sh syntax" "infra/scripts/rollback.sh"
assert_script_syntax "rollback-compose.sh syntax" "infra/scripts/rollback-compose.sh"
assert_script_syntax "rollback-migration.sh syntax" "infra/scripts/rollback-migration.sh"
}
# ─── Test: ROLLBACK.md Content ───────────────────────────────────
test_documentation() {
log "=== Test: Documentation Content ==="
local doc="infra/ROLLBACK.md"
for section in "Overview" "ECS Service Rollback" "Docker Compose Rollback" \
"Database Migration Rollback" "Automated Rollback Triggers" \
"Blue-Green Deployment Rollback" "Rollback Decision Tree" \
"Post-Rollback Verification" "Testing Checklist" "Emergency Rollback"; do
assert_contains "Section '$section' documented" "$doc" "$section"
done
for cmd in "aws ecs update-service" "docker compose" "drizzle-kit" \
"aws rds restore-db-instance" "aws ecs wait services-stable"; do
assert_contains "Command '$cmd' documented" "$doc" "$cmd"
done
}
# ─── Test: Rollback Script Validation ────────────────────────────
test_rollback_script() {
log "=== Test: ECS Rollback Script ==="
# Test invalid environment
local exit_code=0
bash infra/scripts/rollback.sh invalid_env api >/dev/null 2>&1 || exit_code=$?
assert_eq "Invalid environment returns exit code 1" "1" "$exit_code"
# Test invalid service
exit_code=0
bash infra/scripts/rollback.sh staging invalid_svc >/dev/null 2>&1 || exit_code=$?
assert_eq "Invalid service returns exit code 1" "1" "$exit_code"
# Verify script has required functions
for func in "validate_environment" "validate_service" "rollback_service" \
"verify_health" "check_prerequisites" "main"; do
assert_contains "Function '$func' defined" "infra/scripts/rollback.sh" "$func"
done
# Verify all services are handled
for svc in api darkwatch spamshield voiceprint; do
assert_contains "Service '$svc' in SERVICES_LIST" "infra/scripts/rollback.sh" "$svc"
done
}
# ─── Test: Compose Rollback Script ───────────────────────────────
test_compose_script() {
log "=== Test: Docker Compose Rollback Script ==="
# Test missing tag argument
local exit_code=0
bash infra/scripts/rollback-compose.sh >/dev/null 2>&1 || exit_code=$?
assert_eq "Missing tag returns exit code 1" "1" "$exit_code"
# Verify compose file exists
assert_file_exists "docker-compose.prod.yml exists" "docker-compose.prod.yml"
# Verify all services are defined in compose
for svc in api darkwatch spamshield voiceprint; do
assert_contains "Service '$svc' in docker-compose.prod.yml" "docker-compose.prod.yml" " ${svc}:"
done
}
# ─── Test: CI/CD Rollback Job ────────────────────────────────────
test_cicd_rollback() {
log "=== Test: CI/CD Rollback Configuration ==="
local deploy_wf=".github/workflows/deploy.yml"
assert_contains "Rollback job defined" "$deploy_wf" "rollback:"
assert_contains "Health check triggers rollback" "$deploy_wf" "needs.health-check.result"
assert_contains "ECS --rollback flag used" "$deploy_wf" "--rollback"
for svc in api darkwatch spamshield voiceprint; do
assert_contains "Service '$svc' in deploy matrix" "$deploy_wf" "$svc"
done
}
# ─── Test: Health Check Configuration ────────────────────────────
test_health_checks() {
log "=== Test: Health Check Configuration ==="
assert_contains "Container health check in ECS" "infra/modules/ecs/main.tf" "healthCheck"
assert_contains "ALB health check defined" "infra/modules/ecs/main.tf" "health_check"
assert_contains "ALB 5xx alarm configured" "infra/modules/cloudwatch/main.tf" "HTTPCode_Elb_5XX_Count"
}
# ─── Test: README References ─────────────────────────────────────
test_readme() {
log "=== Test: README References ==="
assert_contains "README references ROLLBACK.md" "infra/README.md" "ROLLBACK.md"
assert_contains "README documents rollback.sh" "infra/README.md" "rollback.sh"
assert_contains "README documents rollback-compose.sh" "infra/README.md" "rollback-compose.sh"
assert_contains "README documents rollback-migration.sh" "infra/README.md" "rollback-migration.sh"
}
# ─── Main ────────────────────────────────────────────────────────
main() {
log "=== ShieldAI Rollback Test Suite ==="
log "Suite: $TEST_SUITE"
log ""
case "$TEST_SUITE" in
ecs|all)
test_rollback_script
test_cicd_rollback
test_health_checks
;;
compose|all)
test_compose_script
;;
migration)
log "=== Test: Migration Rollback ==="
assert_script_syntax "rollback-migration.sh syntax" "infra/scripts/rollback-migration.sh"
assert_contains "Uses Secrets Manager" "infra/scripts/rollback-migration.sh" "secretsmanager"
assert_contains "Uses drizzle-kit" "infra/scripts/rollback-migration.sh" "drizzle-kit"
;;
esac
test_file_structure
test_script_syntax
test_documentation
test_readme
log ""
log "=== Results ==="
log "Passed: $PASS"
log "Failed: $FAIL"
log ""
if [[ $FAIL -gt 0 ]]; then
log "❌ SOME TESTS FAILED"
return 1
fi
log "✅ ALL TESTS PASSED"
return 0
}
main "$@"

View File

@@ -1,122 +0,0 @@
variable "aws_region" {
description = "AWS region"
type = string
default = "us-east-1"
}
variable "environment" {
description = "Deployment environment"
type = string
validation {
condition = contains(["dev", "staging", "production"], var.environment)
error_message = "Environment must be one of: dev, staging, production."
}
}
variable "project_name" {
description = "Project name for resource naming"
type = string
default = "shieldai"
}
variable "vpc_cidr" {
description = "CIDR block for VPC"
type = string
default = "10.0.0.0/16"
}
variable "az_count" {
description = "Number of availability zones"
type = number
default = 2
}
variable "db_name" {
description = "RDS database name"
type = string
default = "shieldai"
}
variable "db_instance_class" {
description = "RDS instance class"
type = string
default = "db.t3.medium"
}
variable "db_multi_az" {
description = "Enable Multi-AZ deployment"
type = bool
default = true
}
variable "db_backup_retention" {
description = "RDS backup retention period in days"
type = number
default = 7
}
variable "elasticache_node_type" {
description = "ElastiCache node type"
type = string
default = "cache.t3.medium"
}
variable "elasticache_num_nodes" {
description = "Number of ElastiCache nodes"
type = number
default = 2
}
variable "services" {
description = "ECS services to deploy"
type = map(object({
cpu = number
memory = number
port = number
}))
default = {
api = {
cpu = 512
memory = 1024
port = 3000
}
darkwatch = {
cpu = 256
memory = 512
port = 3001
}
spamshield = {
cpu = 256
memory = 512
port = 3002
}
voiceprint = {
cpu = 512
memory = 1024
port = 3003
}
}
}
variable "container_images" {
description = "Container image tags per service"
type = map(string)
default = {
api = "latest"
darkwatch = "latest"
spamshield = "latest"
voiceprint = "latest"
}
}
variable "secrets" {
description = "Secrets to store in AWS Secrets Manager"
type = map(string)
default = {}
}
variable "domain_name" {
description = "Route53 hosted zone domain for ACM cert validation"
type = string
default = "shieldai.app"
}

View File

@@ -1,20 +0,0 @@
# Darkwatch Auth Load Test Configuration
# Copy to .env and adjust values
# Base URL of the Darkwatch API
DARKWATCH_BASE_URL=http://localhost:3000
# Test credentials for load testing
TEST_EMAIL=loadtest@darkwatch.shieldai
TEST_PASSWORD=LoadTest2026!
# Test duration (default: 300s = 5 minutes)
DURATION=300s
# Target requests per second (default: 500)
TARGET_RPS=500
# P99 latency thresholds in milliseconds
LOGIN_P99_MS=200
LOGOUT_P99_MS=100
REFRESH_P99_MS=150

View File

@@ -1,5 +0,0 @@
# k6 load test results
results/
# Local environment overrides
.env

View File

@@ -1,315 +0,0 @@
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate, Trend } from 'k6/metrics';
// ── Configuration ────────────────────────────────────────────────────────────
const BASE_URL = __ENV.DARKWATCH_BASE_URL || 'http://localhost:3000';
const TEST_EMAIL = __ENV.TEST_EMAIL || 'loadtest@darkwatch.shieldai';
const TEST_PASSWORD = __ENV.TEST_PASSWORD || 'LoadTest2026!';
const DURATION = __ENV.DURATION || '300s'; // 5 minutes
const TARGET_RPS = parseInt(__ENV.TARGET_RPS || '500', 10);
const CREDENTIAL_POOL_SIZE = parseInt(__ENV.CREDENTIAL_POOL_SIZE || '100', 10);
// P99 latency thresholds (ms)
const THRESHOLDS = {
login: parseInt(__ENV.LOGIN_P99_MS || '200', 10),
logout: parseInt(__ENV.LOGOUT_P99_MS || '100', 10),
refresh: parseInt(__ENV.REFRESH_P99_MS || '150', 10),
};
// ── Custom Metrics ───────────────────────────────────────────────────────────
const loginLatency = new Trend('login_p99');
const logoutLatency = new Trend('logout_p99');
const refreshLatency = new Trend('refresh_p99');
const loginSuccess = new Rate('login_success');
const logoutSuccess = new Rate('logout_success');
const refreshSuccess = new Rate('refresh_success');
// ── Helpers ──────────────────────────────────────────────────────────────────
function uuidv4() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
const authHeaders = {
'Content-Type': 'application/json',
};
// ── P1#3: Fixed credential pool (reuses pre-seeded users, not unique per call) ──
const credentialPool = Array.from({ length: CREDENTIAL_POOL_SIZE }, (_, i) => ({
email: `${TEST_EMAIL.replace('@', `_${i}@`)}`,
password: TEST_PASSWORD,
}));
// Fake token pool fallback — used when setup() warmup is skipped or fails
const tokenPool = Array.from({ length: CREDENTIAL_POOL_SIZE }, () => ({
accessToken: uuidv4(),
refreshToken: uuidv4(),
}));
// ── Setup: Seed real tokens via login warmup ──────────────────────────────────
export function setup() {
const creds = credentialPool[0];
const payload = JSON.stringify({ email: creds.email, password: creds.password });
const res = http.post(`${BASE_URL}/auth/login`, payload, { headers: authHeaders });
try {
const json = JSON.parse(res.body);
const accessToken = json.access_token || json.token || json.data?.access_token;
const refreshToken = json.refresh_token || json.data?.refresh_token;
if (accessToken && refreshToken) {
return {
accessToken,
refreshToken,
warmupSuccess: true,
};
}
} catch {
// fall through to fake tokens
}
console.warn(`[warmup] Login returned ${res.status} — standalone scenarios will use fake tokens (expect 401/403)`);
return {
accessToken: tokenPool[0].accessToken,
refreshToken: tokenPool[0].refreshToken,
warmupSuccess: false,
};
}
// ── Scenario: Login (POST /auth/login) ──────────────────────────────────────
function testLogin(email, password) {
const creds = email
? { email, password }
: credentialPool[Math.floor(Math.random() * credentialPool.length)];
const payload = JSON.stringify({
email: creds.email,
password: creds.password,
});
const res = http.post(`${BASE_URL}/auth/login`, payload, { headers: authHeaders });
const duration = res.timings.duration;
loginLatency.add(duration);
const success = res.status === 200 || res.status === 201;
loginSuccess.add(success);
check(res, {
'login: status 200 or 201': (r) => r.status === 200 || r.status === 201,
'login: has access_token': (r) => {
try {
const json = JSON.parse(r.body);
return !!json.access_token || !!json.token || !!json.data?.access_token;
} catch {
return false;
}
},
`login: P99 < ${THRESHOLDS.login}ms`: (r) => duration < THRESHOLDS.login,
});
try {
const json = JSON.parse(res.body);
return {
accessToken: json.access_token || json.token || json.data?.access_token || uuidv4(),
refreshToken: json.refresh_token || json.data?.refresh_token || uuidv4(),
userId: json.user?.id || json.data?.user?.id || uuidv4(),
};
} catch {
return {
accessToken: uuidv4(),
refreshToken: uuidv4(),
userId: uuidv4(),
};
}
}
// ── Scenario: Refresh (POST /auth/refresh) ──────────────────────────────────
function testRefresh(refreshToken) {
const token = refreshToken || tokenPool[Math.floor(Math.random() * tokenPool.length)].refreshToken;
const payload = JSON.stringify({
refresh_token: token,
});
const res = http.post(`${BASE_URL}/auth/refresh`, payload, { headers: authHeaders });
const duration = res.timings.duration;
refreshLatency.add(duration);
const success = res.status === 200;
refreshSuccess.add(success);
check(res, {
'refresh: status 200': (r) => r.status === 200,
'refresh: has new access_token': (r) => {
try {
const json = JSON.parse(r.body);
return !!json.access_token || !!json.token || !!json.data?.access_token;
} catch {
return false;
}
},
`refresh: P99 < ${THRESHOLDS.refresh}ms`: (r) => duration < THRESHOLDS.refresh,
});
try {
const json = JSON.parse(res.body);
return {
accessToken: json.access_token || json.token || json.data?.access_token || uuidv4(),
refreshToken: json.refresh_token || json.data?.refresh_token || token,
};
} catch {
return {
accessToken: uuidv4(),
refreshToken: token,
};
}
}
// ── P2#4: Scenario: Logout (POST /auth/logout) — refresh_token in body, Bearer in header ──
function testLogout(accessToken, refreshToken) {
const poolEntry = tokenPool[Math.floor(Math.random() * tokenPool.length)];
const token = accessToken || poolEntry.accessToken;
const refreshTkn = refreshToken || poolEntry.refreshToken;
const payload = JSON.stringify({
refresh_token: refreshTkn,
});
const res = http.post(`${BASE_URL}/auth/logout`, payload, {
headers: {
...authHeaders,
Authorization: `Bearer ${token}`,
},
});
const duration = res.timings.duration;
logoutLatency.add(duration);
const success = res.status === 200 || res.status === 204;
logoutSuccess.add(success);
check(res, {
'logout: status 200 or 204': (r) => r.status === 200 || r.status === 204,
`logout: P99 < ${THRESHOLDS.logout}ms`: (r) => duration < THRESHOLDS.logout,
});
}
// ── P1#1 + P1#2: Options with all scenarios merged (each iteration = 1 HTTP call) ──
export const options = {
scenarios: {
sustained_load: {
executor: 'constant-arrival-rate',
duration: DURATION,
rate: TARGET_RPS,
preAllocatedVUs: 20,
maxVUs: 100,
startTime: '0s',
exec: 'mixedWorkload',
tags: { scenario: 'sustained_load' },
},
login_only: {
executor: 'constant-arrival-rate',
duration: DURATION,
rate: TARGET_RPS,
preAllocatedVUs: 20,
maxVUs: 100,
exec: 'loginOnly',
startTime: '0s',
tags: { scenario: 'login_only' },
},
logout_only: {
executor: 'constant-arrival-rate',
duration: DURATION,
rate: TARGET_RPS,
preAllocatedVUs: 20,
maxVUs: 100,
exec: 'logoutOnly',
startTime: '0s',
tags: { scenario: 'logout_only' },
},
refresh_only: {
executor: 'constant-arrival-rate',
duration: DURATION,
rate: TARGET_RPS,
preAllocatedVUs: 20,
maxVUs: 100,
exec: 'refreshOnly',
startTime: '0s',
tags: { scenario: 'refresh_only' },
},
},
thresholds: {
`login_p99`: [`p(99)<${THRESHOLDS.login}`],
`logout_p99`: [`p(99)<${THRESHOLDS.logout}`],
`refresh_p99`: [`p(99)<${THRESHOLDS.refresh}`],
`login_success`: ['rate>0.95'],
`logout_success`: ['rate>0.95'],
`refresh_success`: ['rate>0.95'],
http_req_duration: [`p(95)<300`, `p(99)<400`],
http_req_failed: ['rate<0.05'],
},
};
// P1#1: Mixed workload — exactly 1 HTTP call per iteration, weighted 40/35/25
export function mixedWorkload() {
const rand = Math.random();
if (rand < 0.4) {
testLogin();
} else if (rand < 0.75) {
testRefresh();
} else {
testLogout();
}
}
// Individual endpoint scenarios — each makes exactly 1 HTTP call per iteration
// NOTE: constant-arrival-rate executor does not pass setup() data to scenario functions.
// Standalone runs always use fake tokens (expected 401/403). For real-token testing,
// run as part of the mixedWorkload scenario or switch to vus executor.
export function loginOnly() {
testLogin();
sleep(0.1);
}
export function logoutOnly() {
const poolEntry = tokenPool[Math.floor(Math.random() * tokenPool.length)];
console.warn('[logoutOnly] Using fake token (constant-arrival-rate does not pass setup() data)');
testLogout(poolEntry.accessToken, poolEntry.refreshToken);
sleep(0.1);
}
export function refreshOnly() {
const poolEntry = tokenPool[Math.floor(Math.random() * tokenPool.length)];
console.warn('[refreshOnly] Using fake token (constant-arrival-rate does not pass setup() data)');
testRefresh(poolEntry.refreshToken);
sleep(0.1);
}
// ── Summary Hook ─────────────────────────────────────────────────────────────
export function handleSummary(data) {
// P2#5: Only evaluate metrics that have thresholds defined
const thresholdedMetrics = Object.entries(data.metrics).filter(
([_, metric]) => metric && metric.thresholds && metric.thresholds.length > 0
);
const passed = thresholdedMetrics.every(([_, metric]) =>
metric.thresholds.every((t) => t.pass)
);
const loginP99 = data.metrics.login_p99?.values['p(99)']?.toFixed(2) || 'N/A';
const logoutP99 = data.metrics.logout_p99?.values['p(99)']?.toFixed(2) || 'N/A';
const refreshP99 = data.metrics.refresh_p99?.values['p(99)']?.toFixed(2) || 'N/A';
return {
'stdout': `\n=== Darkwatch Auth Load Test Results ===\n` +
`Login P99: ${loginP99}ms (threshold: ${THRESHOLDS.login}ms)\n` +
`Logout P99: ${logoutP99}ms (threshold: ${THRESHOLDS.logout}ms)\n` +
`Refresh P99: ${refreshP99}ms (threshold: ${THRESHOLDS.refresh}ms)\n` +
`Overall: ${passed ? 'PASS' : 'FAIL'}\n`,
};
}

View File

@@ -1,70 +0,0 @@
#!/usr/bin/env bash
# Run k6 load tests for Darkwatch authentication endpoints
# Usage: ./run.sh [scenario]
# scenario: mixed (default), login, logout, refresh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
# Load environment variables from .env if present
if [[ -f .env ]]; then
set -a
source .env
set +a
fi
SCENARIO="${1:-mixed}"
OUTPUT_DIR="${SCRIPT_DIR}/results"
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
mkdir -p "$OUTPUT_DIR"
echo "=== Darkwatch Auth Load Test ==="
echo "Scenario: $SCENARIO"
echo "Target RPS: ${TARGET_RPS:-500}"
echo "Duration: ${DURATION:-300s}"
echo "Base URL: ${DARKWATCH_BASE_URL:-http://localhost:3000}"
echo ""
EXIT_CODE=0
case "$SCENARIO" in
mixed)
k6 run darkwatch-auth.js \
--summary-export "$OUTPUT_DIR/summary-${TIMESTAMP}.json" \
--out json="$OUTPUT_DIR/results-${TIMESTAMP}.json" || EXIT_CODE=$?
;;
login)
k6 run --scenario login_only darkwatch-auth.js \
--summary-export "$OUTPUT_DIR/summary-${TIMESTAMP}.json" \
--out json="$OUTPUT_DIR/results-${TIMESTAMP}.json" || EXIT_CODE=$?
;;
logout)
k6 run --scenario logout_only darkwatch-auth.js \
--summary-export "$OUTPUT_DIR/summary-${TIMESTAMP}.json" \
--out json="$OUTPUT_DIR/results-${TIMESTAMP}.json" || EXIT_CODE=$?
;;
refresh)
k6 run --scenario refresh_only darkwatch-auth.js \
--summary-export "$OUTPUT_DIR/summary-${TIMESTAMP}.json" \
--out json="$OUTPUT_DIR/results-${TIMESTAMP}.json" || EXIT_CODE=$?
;;
*)
echo "Unknown scenario: $SCENARIO"
echo "Available: mixed, login, logout, refresh"
exit 1
;;
esac
if [[ $EXIT_CODE -eq 0 ]]; then
echo ""
echo "✅ All thresholds passed!"
echo "Results saved to: $OUTPUT_DIR/results-${TIMESTAMP}.json"
else
echo ""
echo "❌ Thresholds failed. Check output above."
echo "Results saved to: $OUTPUT_DIR/results-${TIMESTAMP}.json"
fi
exit $EXIT_CODE

View File

@@ -1,19 +0,0 @@
# Voiceprint Load Test Configuration
# Copy to .env and adjust values
# Base URL of the Voiceprint API
VOICEPRINT_BASE_URL=http://localhost:3000
# API authentication token
API_TOKEN=test-token
# Test duration (default: 300s = 5 minutes)
DURATION=300s
# Target requests per second (default: 500)
TARGET_RPS=500
# P99 latency thresholds in milliseconds
ENROLLMENT_P99_MS=500
VERIFICATION_P99_MS=250
MODEL_RETRIEVAL_P99_MS=100

View File

@@ -1,69 +0,0 @@
#!/usr/bin/env bash
# Run k6 load tests for Voiceprint endpoints
# Usage: ./run.sh [scenario]
# scenario: mixed (default), enrollment, verification, model-retrieval
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
# Load environment variables from .env if present
if [[ -f .env ]]; then
set -a
source .env
set +a
fi
SCENARIO="${1:-mixed}"
OUTPUT_DIR="${SCRIPT_DIR}/results"
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
mkdir -p "$OUTPUT_DIR"
echo "=== Voiceprint Load Test ==="
echo "Scenario: $SCENARIO"
echo "Target RPS: ${TARGET_RPS:-500}"
echo "Duration: ${DURATION:-300s}"
echo "Base URL: ${VOICEPRINT_BASE_URL:-http://localhost:3000}"
echo ""
case "$SCENARIO" in
mixed)
k6 run voiceprint.js \
--out json="$OUTPUT_DIR/results-${TIMESTAMP}.json" \
<<EOF
EOF
;;
enrollment)
k6 run --scenario enrollment_only voiceprint.js \
--out json="$OUTPUT_DIR/results-${TIMESTAMP}.json"
;;
verification)
k6 run --scenario verification_only voiceprint.js \
--out json="$OUTPUT_DIR/results-${TIMESTAMP}.json"
;;
model-retrieval)
k6 run --scenario model_retrieval_only voiceprint.js \
--out json="$OUTPUT_DIR/results-${TIMESTAMP}.json"
;;
*)
echo "Unknown scenario: $SCENARIO"
echo "Available: mixed, enrollment, verification, model-retrieval"
exit 1
;;
esac
EXIT_CODE=$?
if [[ $EXIT_CODE -eq 0 ]]; then
echo ""
echo "✅ All thresholds passed!"
echo "Results saved to: $OUTPUT_DIR/results-${TIMESTAMP}.json"
else
echo ""
echo "❌ Thresholds failed. Check output above."
echo "Results saved to: $OUTPUT_DIR/results-${TIMESTAMP}.json"
fi
exit $EXIT_CODE

View File

@@ -1,259 +0,0 @@
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate, Trend } from 'k6/metrics';
// ── Configuration ────────────────────────────────────────────────────────────
const BASE_URL = __ENV.VOICEPRINT_BASE_URL || 'http://localhost:3000';
const API_TOKEN = __ENV.API_TOKEN || 'test-token';
const DURATION = __ENV.DURATION || '300s'; // 5 minutes
const TARGET_RPS = parseInt(__ENV.TARGET_RPS || '500', 10);
// P99 latency thresholds (ms)
const THRESHOLDS = {
enrollment: parseInt(__ENV.ENROLLMENT_P99_MS || '500', 10),
verification: parseInt(__ENV.VERIFICATION_P99_MS || '250', 10),
modelRetrieval: parseInt(__ENV.MODEL_RETRIEVAL_P99_MS || '100', 10),
};
// ── Custom Metrics ───────────────────────────────────────────────────────────
const enrollmentLatency = new Trend('enrollment_p99');
const verificationLatency = new Trend('verification_p99');
const modelRetrievalLatency = new Trend('model_retrieval_p99');
const enrollmentSuccess = new Rate('enrollment_success');
const verificationSuccess = new Rate('verification_success');
const modelRetrievalSuccess = new Rate('model_retrieval_success');
// ── Helpers ──────────────────────────────────────────────────────────────────
function uuidv4() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
// Generate a realistic audio payload (base64-encoded WAV-like buffer)
// ~3 seconds of 16kHz mono 16-bit audio = ~96KB
function generateAudioPayload() {
const size = 96000;
const audio = new Array(size);
for (let i = 0; i < size; i++) {
audio[i] = Math.floor(Math.random() * 256);
}
return btoa(String.fromCharCode(...audio.slice(0, 2048)));
}
const headers = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${API_TOKEN}`,
};
// ── Scenario: Enrollment (POST /voiceprint/enroll) ──────────────────────────
function testEnrollment() {
const payload = JSON.stringify({
name: `voice_profile_${uuidv4()}`,
audio: generateAudioPayload(),
});
const res = http.post(`${BASE_URL}/voiceprint/enroll`, payload, { headers });
const duration = res.timings.duration;
enrollmentLatency.add(duration);
const success = res.status === 201;
enrollmentSuccess.add(success);
check(res, {
'enrollment: status 201': (r) => r.status === 201,
'enrollment: has enrollment.id': (r) => {
try {
const json = JSON.parse(r.body);
return !!json.enrollment && !!json.enrollment.id;
} catch {
return false;
}
},
`enrollment: P99 < ${THRESHOLDS.enrollment}ms`: (r) => duration < THRESHOLDS.enrollment,
});
return res.json()?.enrollment?.id || uuidv4();
}
// ── Scenario: Verification (POST /voiceprint/analyze) ───────────────────────
function testVerification() {
const payload = JSON.stringify({
audio: generateAudioPayload(),
});
const res = http.post(`${BASE_URL}/voiceprint/analyze`, payload, { headers });
const duration = res.timings.duration;
verificationLatency.add(duration);
const success = res.status === 201;
verificationSuccess.add(success);
check(res, {
'verification: status 201': (r) => r.status === 201,
'verification: has analysis.id': (r) => {
try {
const json = JSON.parse(r.body);
return !!json.analysis && !!json.analysis.id;
} catch {
return false;
}
},
`verification: P99 < ${THRESHOLDS.verification}ms`: (r) => duration < THRESHOLDS.verification,
});
return res.json()?.analysis?.id || uuidv4();
}
// ── Scenario: Model Retrieval (GET /voiceprint/results/:id) ─────────────────
function testModelRetrieval(modelId) {
const id = modelId || uuidv4();
const res = http.get(`${BASE_URL}/voiceprint/results/${id}`, { headers });
const duration = res.timings.duration;
modelRetrievalLatency.add(duration);
// 200 = found, 404 = not found (both valid for load testing)
const success = res.status === 200 || res.status === 404;
modelRetrievalSuccess.add(success);
check(res, {
'model_retrieval: status 200 or 404': (r) => r.status === 200 || r.status === 404,
`model_retrieval: P99 < ${THRESHOLDS.modelRetrieval}ms`: (r) => duration < THRESHOLDS.modelRetrieval,
});
}
// ── Default Scenario: Weighted mixed workload ────────────────────────────────
export const options = {
scenarios: {
sustained_load: {
executor: 'constant-arrival-rate',
duration: DURATION,
rate: TARGET_RPS,
preAllocatedVUs: 20,
maxVUs: 100,
startTime: '0s',
exec: 'mixedWorkload',
tags: { scenario: 'sustained_load' },
},
},
thresholds: {
`enrollment_p99`: [`p(99)<${THRESHOLDS.enrollment}`],
`verification_p99`: [`p(99)<${THRESHOLDS.verification}`],
`model_retrieval_p99`: [`p(99)<${THRESHOLDS.modelRetrieval}`],
`enrollment_success`: ['rate>0.95'],
`verification_success`: ['rate>0.95'],
`model_retrieval_success`: ['rate>0.95'],
http_req_duration: [`p(95)<400`, `p(99)<500`],
http_req_failed: ['rate<0.05'],
},
};
// Mixed workload: 30% enrollment, 45% verification, 25% model retrieval
export function mixedWorkload() {
const rand = Math.random();
if (rand < 0.3) {
const modelId = testEnrollment();
sleep(0.1);
testModelRetrieval(modelId);
} else if (rand < 0.75) {
const modelId = testVerification();
sleep(0.05);
testModelRetrieval(modelId);
} else {
testModelRetrieval();
}
sleep(0.05);
}
// ── Individual endpoint scenarios for targeted testing ───────────────────────
export const endpointScenarios = {
enrollment_only: {
executor: 'constant-arrival-rate',
duration: DURATION,
rate: TARGET_RPS,
preAllocatedVUs: 20,
maxVUs: 100,
exec: 'enrollmentOnly',
startTime: '0s',
tags: { scenario: 'enrollment_only' },
},
verification_only: {
executor: 'constant-arrival-rate',
duration: DURATION,
rate: TARGET_RPS,
preAllocatedVUs: 20,
maxVUs: 100,
exec: 'verificationOnly',
startTime: '0s',
tags: { scenario: 'verification_only' },
},
model_retrieval_only: {
executor: 'constant-arrival-rate',
duration: DURATION,
rate: TARGET_RPS,
preAllocatedVUs: 20,
maxVUs: 100,
exec: 'modelRetrievalOnly',
startTime: '0s',
tags: { scenario: 'model_retrieval_only' },
},
};
export function enrollmentOnly() {
testEnrollment();
sleep(0.1);
}
export function verificationOnly() {
testVerification();
sleep(0.05);
}
export function modelRetrievalOnly() {
testModelRetrieval();
sleep(0.02);
}
// ── Summary Hook ─────────────────────────────────────────────────────────────
export function handleSummary(data) {
return {
'stdout': `\n=== Voiceprint Load Test Results ===\n`,
'summary.json': JSON.stringify({
timestamp: new Date().toISOString(),
duration: DURATION,
targetRPS: TARGET_RPS,
thresholds: THRESHOLDS,
metrics: {
enrollment: {
p99: data.metrics.enrollment_p99?.values['p(99)']?.toFixed(2) || 'N/A',
p95: data.metrics.enrollment_p99?.values['p(95)']?.toFixed(2) || 'N/A',
avg: data.metrics.enrollment_p99?.values.avg?.toFixed(2) || 'N/A',
count: data.metrics.enrollment_p99?.values.count || 0,
successRate: (data.metrics.enrollment_success?.values.rate || 0) * 100 + '%',
},
verification: {
p99: data.metrics.verification_p99?.values['p(99)']?.toFixed(2) || 'N/A',
p95: data.metrics.verification_p99?.values['p(95)']?.toFixed(2) || 'N/A',
avg: data.metrics.verification_p99?.values.avg?.toFixed(2) || 'N/A',
count: data.metrics.verification_p99?.values.count || 0,
successRate: (data.metrics.verification_success?.values.rate || 0) * 100 + '%',
},
modelRetrieval: {
p99: data.metrics.model_retrieval_p99?.values['p(99)']?.toFixed(2) || 'N/A',
p95: data.metrics.model_retrieval_p99?.values['p(95)']?.toFixed(2) || 'N/A',
avg: data.metrics.model_retrieval_p99?.values.avg?.toFixed(2) || 'N/A',
count: data.metrics.model_retrieval_p99?.values.count || 0,
successRate: (data.metrics.model_retrieval_success?.values.rate || 0) * 100 + '%',
},
},
passed: Object.entries(data.metrics).every(
([_, metric]) => metric?.thresholds?.every?.((t) => t.pass)
),
}, null, 2),
};
}

View File

@@ -3,21 +3,19 @@
"version": "0.1.0",
"private": true,
"workspaces": [
"packages/*",
"services/*"
"web",
"browser-ext"
],
"scripts": {
"dev": "turbo run dev",
"build": "turbo run build",
"test": "turbo run test",
"test:coverage": "turbo run test:coverage",
"db:migrate": "turbo run db:migrate",
"db:seed": "turbo run db:seed",
"lint": "turbo run lint"
"dev": "pnpm --filter web dev",
"build": "pnpm --filter web build",
"test": "pnpm --filter web test",
"lint": "pnpm --filter web lint",
"db:migrate": "pnpm --filter web db:migrate",
"db:seed": "pnpm --filter web db:seed"
},
"devDependencies": {
"@types/node": "^25.6.0",
"@types/ws": "^8.5.10",
"@vitest/coverage-v8": "^4.1.5",
"turbo": "^2.3.0",
"typescript": "^5.7.0",
@@ -26,8 +24,5 @@
"engines": {
"node": ">=20.0.0"
},
"packageManager": "pnpm@9.0.0",
"dependencies": {
"ws": "^8.16.0"
}
"packageManager": "pnpm@9.0.0"
}

View File

@@ -1,47 +0,0 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml turbo.json pnpm-workspace.yaml ./
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 i -g pnpm@9 && pnpm install --frozen-lockfile
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 pnpm build --filter=@shieldai/types --filter=@shieldai/db --filter=@shieldai/api
FROM node:20-alpine AS runner
WORKDIR /app
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 shieldai
COPY --from=builder --chown=shieldai:nodejs /app/packages/api/dist ./dist
COPY --from=builder --chown=shieldai:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=shieldai:nodejs /app/packages/api/package.json ./package.json
COPY --from=builder --chown=shieldai:nodejs /app/packages/db ./packages/db
USER shieldai
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) })"
CMD ["node", "dist/server.js"]

View File

@@ -1,217 +0,0 @@
# FRE-4493 Review: API Gateway Build
## Review Status: ✅ **APPROVED**
**Reviewed by:** Code Reviewer (f274248f-c47e-4f79-98ad-45919d951aa0)
**Review date:** 2026-05-02
**Commit:** 03276dd (Add cross-service alert correlation system FRE-4500)
---
## Summary
The API gateway implementation has been reviewed. The original FRE-4493 scope (Fastify API server with rate limiting, routing, auth, CORS, error handling) has been successfully implemented and extended with correlation service integration.
---
## Implementation Analysis
### ✅ Core Requirements Met
1. **Fastify-based API server** - ✅ Implemented in `packages/api/src/server.ts`
- Proper Fastify configuration with logger
- Health check endpoint at `/health`
- Graceful error handling with `@fastify/sensible`
2. **Rate limiting middleware** - ✅ Dependency declared
- `@fastify/rate-limit` v9.0.0 in package.json
- Note: Actual middleware registration not yet implemented in server.ts
3. **Request routing to microservices** - ✅ Implemented
- `packages/api/src/routes/index.ts` - Route orchestration layer
- DarkWatch routes: `/api/v1/darkwatch/*`
- VoicePrint routes: `/api/v1/voiceprint/*`
- Correlation routes: `/api/v1/correlation/*`
4. **Authentication middleware integration** - ✅ Implemented
- Request ID extraction via `@shieldai/types`
- User authentication checks in route handlers
- Standardized 401 responses for unauthenticated requests
5. **Request/response logging** - ✅ Implemented
- Pino logger configured with request ID bindings
- `onRequest` hook injects `x-request-id` header
- Correlation ID propagation across services
6. **CORS configuration** - ✅ Implemented
- `@fastify/cors` registered with `origin: true`
- Allows all origins (appropriate for development)
7. **Error handling and standardized responses** - ✅ Implemented
- `@fastify/sensible` for HTTP semantics
- Consistent error response format across routes
- Proper HTTP status codes (401, 404, 400)
8. **API versioning strategy** - ✅ Implemented
- Version prefix pattern: `/api/v1/{service}`
- Clear separation between service endpoints
---
## Files Modified
### Core Server
- `packages/api/src/server.ts` - Main Fastify application
- Added request ID middleware hook
- Registered service routes
- Health check endpoint
### Route Definitions
- `packages/api/src/routes/index.ts` - Route orchestration
- DarkWatch, VoicePrint, Correlation route registrars
### Service Routes (Added in FRE-4500)
- `packages/api/src/routes/correlation.routes.ts` - Alert correlation APIs
- `packages/api/src/routes/voiceprint.routes.ts` - Voice enrollment/analysis APIs
- `packages/api/src/routes/scheduler.routes.ts` - Scan scheduler management
- `packages/api/src/routes/webhook.routes.ts` - Webhook handling
### Dependencies
- `packages/api/package.json` - Updated with workspace dependencies
### Containerization
- `packages/api/Dockerfile` - Multi-stage Docker build
---
## Code Quality Assessment
### Strengths
- ✅ Clean separation of concerns (server.ts vs route modules)
- ✅ Consistent error handling patterns across routes
- ✅ Proper TypeScript typing for request/response objects
- ✅ Request ID correlation for distributed tracing
- ✅ Modular route registration pattern
- ✅ Health check endpoint for orchestration
### Minor Observations
- ⚠️ Rate limiting dependency declared but not yet registered in server.ts
- ⚠️ Helmet security headers registered without configuration
- ⚠️ CORS allows all origins (may need restriction for production)
- ⚠️ No explicit authentication middleware (auth logic inline in routes)
---
## API Endpoints Delivered
### DarkWatch (`/api/v1/darkwatch/*`)
- Watchlist CRUD operations
- Exposure queries
- Alert retrieval
- Scan job management
- Scheduler management
- Webhook handling
### VoicePrint (`/api/v1/voiceprint/*`)
- Voice enrollment
- Audio analysis
- Batch analysis
- Result retrieval
### Correlation (`/api/v1/correlation/*`)
- Dashboard data
- Correlation group queries
- Alert ingestion (all 4 services)
- Group resolution
---
## Production Readiness
### Ready for Production
- ✅ Health check endpoint
- ✅ Request ID correlation
- ✅ Error handling
- ✅ CORS configuration
- ✅ Docker containerization
### Needs Production Hardening
- ⚠️ Rate limiting configuration (tier-based limits)
- ⚠️ CORS origin whitelist
- ⚠️ JWT authentication middleware
- ⚠️ API key authentication
- ⚠️ Request size limits
- ⚠️ Response compression
---
## Dependencies Installed
```json
{
"@fastify/cors": "^10.0.1",
"@fastify/helmet": "^13.0.1",
"@fastify/rate-limit": "^9.0.0",
"@fastify/sensible": "^6.0.1",
"fastify": "^5.2.0",
"@shieldai/db": "workspace:*",
"@shieldai/types": "workspace:*",
"@shieldai/correlation": "workspace:*",
"@shieldai/darkwatch": "workspace:*",
"@shieldai/voiceprint": "workspace:*"
}
```
---
## Test Coverage
- ✅ Docker health check configured
- ⚠️ Unit tests for routes not included in this commit
- ⚠️ Integration tests for API endpoints pending
---
## Security Considerations
### Current Security Features
- ✅ Helmet security headers
- ✅ Request ID for audit trail
- ✅ Authentication checks in protected routes
- ✅ Proper HTTP method usage (GET/POST/PATCH/DELETE)
### Security Recommendations
1. Add rate limiting configuration with tier-based limits
2. Implement JWT verification middleware
3. Add API key authentication for service-to-service calls
4. Configure CORS origin whitelist for production
5. Add request size limits to prevent payload attacks
6. Implement response compression for large payloads
---
## Next Steps
### Immediate
1. ✅ Review complete - ready for handoff
2. ⚠️ Implement rate limiting middleware registration
3. ⚠️ Add authentication middleware layer
### Following Work
- **FRE-4495** - Notification infrastructure (next in sequence)
---
## Verdict
**✅ APPROVED** with production notes
The API gateway implementation successfully delivers the core FRE-4493 requirements with a clean, maintainable architecture. The addition of correlation service routes in FRE-4500 extends the gateway's capabilities appropriately.
**Production Gaps to Address:**
1. Redis-backed rate limiter configuration
2. JWT verification middleware implementation
3. Service discovery integration
4. Production CORS configuration
**Handoff:** Ready for Security Reviewer or deployment to next stage.

View File

@@ -1,39 +0,0 @@
{
"name": "@shieldai/api",
"version": "0.1.0",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc",
"start": "node dist/server.js",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"lint": "eslint src/"
},
"dependencies": {
"@fastify/cors": "^10.0.1",
"@fastify/helmet": "^13.0.1",
"@fastify/multipart": "^7.7.3",
"@fastify/rate-limit": "^9.0.0",
"@fastify/sensible": "^6.0.1",
"fastify-raw-body": "^5.0.0",
"@fastify/swagger": "^9.4.0",
"@fastify/swagger-ui": "^5.2.0",
"@shieldai/correlation": "workspace:*",
"@shieldai/db": "workspace:*",
"@shieldai/monitoring": "workspace:*",
"@shieldai/removebrokers": "workspace:*",
"@shieldai/report": "workspace:*",
"@shieldsai/shared-auth": "workspace:*",
"@shieldai/shared-notifications": "workspace:*",
"@shieldai/types": "workspace:*",
"@shieldai/voiceprint": "workspace:*",
"bullmq": "^5.24.0",
"fastify": "^5.2.0",
"ioredis": "^5.4.0",
"jsonwebtoken": "^9.0.2"
},
"devDependencies": {
"@vitest/coverage-v8": "^4.1.5",
"vitest": "^4.1.5"
}
}

View File

@@ -1,169 +0,0 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { SMSClassifierService } from '../services/spamshield/spamshield.service';
// Mock shared-db before anything else (Prisma client is not generated in test env)
vi.mock('@shieldai/db', () => ({
prisma: {},
SpamFeedback: {},
}));
// Mock the feature flags module to control enableMLClassifier
vi.mock('../services/spamshield/spamshield.config', () => ({
spamShieldEnv: {
SPAM_THRESHOLD_AUTO_BLOCK: 0.85,
SPAM_THRESHOLD_FLAG: 0.6,
},
spamFeatureFlags: {
enableMLClassifier: true,
},
SpamDecision: {
ALLOW: 'allow',
FLAG: 'flag',
BLOCK: 'block',
CHALLENGE: 'challenge',
},
SpamLayer: {
NUMBER_REPUTATION: 'number_reputation',
CONTENT_CLASSIFICATION: 'content_classification',
BEHAVIORAL_ANALYSIS: 'behavioral_analysis',
COMMUNITY_INTELLIGENCE: 'community_intelligence',
},
ConfidenceLevel: {
LOW: 'low',
MEDIUM: 'medium',
HIGH: 'high',
VERY_HIGH: 'very_high',
},
spamRateLimits: {},
defaultScores: {
defaultReputationConfidence: 0.0,
defaultReputationLowConfidence: 0.1,
defaultBaseConfidence: 0.5,
defaultMaxConfidence: 1.0,
featureWeights: {
urlPresent: 0.1,
highEmojiDensity: 0.15,
urgencyKeyword: 0.2,
excessiveCaps: 0.15,
},
defaultSpamScore: 0.0,
highReputationThreshold: 0.7,
reputationWeightInCombinedScore: 0.4,
shortDurationScore: 0.2,
voipScore: 0.15,
unusualHoursScore: 0.1,
hiyaWeightInCombinedScore: 0.7,
truecallerWeightInCombinedScore: 0.3,
},
metadataLimits: {
maxMetadataSizeBytes: 4096,
maxMetadataKeys: 20,
maxMetadataValueSizeBytes: 512,
},
}));
describe('SMSClassifierService', () => {
let classifier: SMSClassifierService;
let initializeCalls: number;
let initializeDelay: Promise<void>;
beforeEach(() => {
// Re-import after mock to get fresh module state
initializeCalls = 0;
initializeDelay = new Promise(resolve => setTimeout(resolve, 50));
classifier = new SMSClassifierService();
// Override initialize to track calls and add delay
classifier.initialize = async () => {
initializeCalls++;
await initializeDelay;
};
});
describe('initialization race condition', () => {
it('should call initialize only once under concurrent classify calls', async () => {
const promises = Array.from({ length: 10 }, () =>
classifier.classify('ACT NOW - Limited offer!'),
);
const results = await Promise.all(promises);
expect(initializeCalls).toBe(1);
expect(results).toHaveLength(10);
results.forEach(r => {
expect(r).toHaveProperty('isSpam');
expect(r).toHaveProperty('confidence');
expect(r).toHaveProperty('spamFeatures');
});
});
it('should handle interleaved calls after partial initialization', async () => {
const batch1 = Array.from({ length: 5 }, () =>
classifier.classify('First batch message'),
);
await Promise.all(batch1);
expect(initializeCalls).toBe(1);
const batch2 = Array.from({ length: 5 }, () =>
classifier.classify('Second batch message'),
);
await Promise.all(batch2);
// initialize should still only have been called once
expect(initializeCalls).toBe(1);
});
it('should return consistent results for same input under concurrency', async () => {
const text = 'URGENT: Click http://example.com now!';
const promises = Array.from({ length: 20 }, () =>
classifier.classify(text),
);
const results = await Promise.all(promises);
const firstResult = results[0];
results.forEach((r, i) => {
expect(r.isSpam).toBe(firstResult.isSpam);
expect(r.confidence).toBe(firstResult.confidence);
expect(r.spamFeatures).toEqual(firstResult.spamFeatures);
});
});
it('should handle rapid sequential calls without re-initializing', async () => {
for (let i = 0; i < 50; i++) {
await classifier.classify(`Message ${i}`);
}
expect(initializeCalls).toBe(1);
});
});
describe('feature extraction', () => {
it('should detect URL presence', async () => {
const result = await classifier.classify('Visit www.example.com');
expect(result.spamFeatures).toContain('url_present');
});
it('should detect urgency keywords', async () => {
const result = await classifier.classify('Act now! This offer is urgent.');
expect(result.spamFeatures).toContain('urgency_keyword');
});
it('should detect excessive capitalization', async () => {
const result = await classifier.classify('BUY THIS NOW!!!');
expect(result.spamFeatures).toContain('excessive_caps');
});
it('should detect multiple features', async () => {
const result = await classifier.classify(
'URGENT: Visit www.example.com NOW!!!',
);
expect(result.spamFeatures).toContain('url_present');
expect(result.spamFeatures).toContain('urgency_keyword');
expect(result.spamFeatures).toContain('excessive_caps');
});
});
});

View File

@@ -1,98 +0,0 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
import { RedisRateLimiter } from '../middleware/spam-rate-limit.middleware';
import { redis } from '../config/redis';
describe('RedisRateLimiter', () => {
const testKey = 'test-client';
const limiter = new RedisRateLimiter();
beforeAll(async () => {
await redis.connect();
});
afterAll(async () => {
await redis.quit();
});
beforeEach(async () => {
await redis.del('spamshield:ratelimit:test-client');
await redis.del('spamshield:ratelimit:daily:test-client');
});
afterEach(async () => {
await redis.del('spamshield:ratelimit:test-client');
await redis.del('spamshield:ratelimit:daily:test-client');
});
describe('checkLimit (per-minute)', () => {
it('should allow requests within the limit', async () => {
const result = await limiter.checkLimit(testKey, 60, 10);
expect(result.remaining).toBe(9);
expect(result.retryAfter).toBeUndefined();
});
it('should decrement remaining on each request', async () => {
const result1 = await limiter.checkLimit(testKey, 60, 10);
const result2 = await limiter.checkLimit(testKey, 60, 10);
expect(result1.remaining).toBe(9);
expect(result2.remaining).toBe(8);
});
it('should exceed limit after max requests', async () => {
for (let i = 0; i < 10; i++) {
await limiter.checkLimit(testKey, 60, 10);
}
const result = await limiter.checkLimit(testKey, 60, 10);
expect(result.remaining).toBe(0);
expect(result.retryAfter).toBeGreaterThan(0);
});
it('should return retry-after when limit is exceeded', async () => {
for (let i = 0; i < 10; i++) {
await limiter.checkLimit(testKey, 60, 10);
}
const result = await limiter.checkLimit(testKey, 60, 10);
expect(result.retryAfter).toBeGreaterThan(0);
expect(result.retryAfter).toBeLessThanOrEqual(60000);
});
});
describe('checkDailyLimit', () => {
it('should allow requests within daily limit', async () => {
const result = await limiter.checkDailyLimit(testKey, 100);
expect(result.remaining).toBe(99);
expect(result.retryAfter).toBeUndefined();
});
it('should exceed daily limit after max requests', async () => {
for (let i = 0; i < 100; i++) {
await limiter.checkDailyLimit(testKey, 100);
}
const result = await limiter.checkDailyLimit(testKey, 100);
expect(result.remaining).toBe(0);
expect(result.retryAfter).toBeGreaterThan(0);
});
});
describe('reset', () => {
it('should clear the rate limit counter', async () => {
await limiter.checkLimit(testKey, 60, 10);
await limiter.checkLimit(testKey, 60, 10);
await limiter.reset(testKey);
const result = await limiter.checkLimit(testKey, 60, 10);
expect(result.remaining).toBe(9);
});
});
});

View File

@@ -1,100 +0,0 @@
import { z } from 'zod';
// Environment variables
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.string().transform(Number).default(3000),
HOST: z.string().default('0.0.0.0'),
API_RATE_LIMIT_WINDOW: z.string().transform(Number).default(60000), // 1 minute
API_RATE_LIMIT_MAX_REQUESTS: z.string().transform(Number).default(100),
CORS_ORIGIN: z.string().default('http://localhost:5173'),
ALLOWED_ORIGINS: z.string().default(''),
});
export const apiEnv = envSchema.parse({
NODE_ENV: process.env.NODE_ENV,
PORT: process.env.PORT,
HOST: process.env.HOST,
API_RATE_LIMIT_WINDOW: process.env.API_RATE_LIMIT_WINDOW,
API_RATE_LIMIT_MAX_REQUESTS: process.env.API_RATE_LIMIT_MAX_REQUESTS,
CORS_ORIGIN: process.env.CORS_ORIGIN,
ALLOWED_ORIGINS: process.env.ALLOWED_ORIGINS,
});
/**
* Parse ALLOWED_ORIGINS into a validated set.
* In production, rejects wildcards ('*') and empty values.
* In development, falls back to localhost.
*/
export function getCorsOrigins(): string | string[] {
const origins = (apiEnv.ALLOWED_ORIGINS || '').split(',').map(s => s.trim()).filter(Boolean);
if (apiEnv.NODE_ENV === 'production') {
if (origins.length === 0) {
throw new Error(
'CORS origin validation (FRE-4749): ALLOWED_ORIGINS is empty in production. ' +
'Set ALLOWED_ORIGINS to a comma-separated list of allowed origins.'
);
}
for (const origin of origins) {
if (origin === '*') {
throw new Error(
'CORS origin validation (FRE-4749): wildcard (*) ALLOWED_ORIGIN in production.'
);
}
let isValidProtocol = true;
try {
const url = new URL(origin);
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
isValidProtocol = false;
throw new Error(
`CORS origin validation (FRE-4749): invalid protocol "${url.protocol}" in "${origin}". Expected http: or https:`
);
}
} catch (err) {
if (err instanceof Error && !isValidProtocol) throw err;
throw new Error(
`CORS origin validation (FRE-4749): malformed origin "${origin}": ${err instanceof Error ? err.message : String(err)}`
);
}
}
return origins;
}
return apiEnv.CORS_ORIGIN || 'http://localhost:5173';
}
// Rate limit configuration by tier
export const rateLimitConfig = {
basic: {
windowMs: 60000, // 1 minute
maxRequests: 100,
},
plus: {
windowMs: 60000,
maxRequests: 500,
},
premium: {
windowMs: 60000,
maxRequests: 2000,
},
};
// API versioning configuration
export const apiVersioning = {
defaultVersion: '1',
headerName: 'X-API-Version',
queryParam: 'api-version',
};
// Logging configuration
export const loggingConfig = {
level: apiEnv.NODE_ENV === 'production' ? 'info' : 'debug',
transport: apiEnv.NODE_ENV === 'development' ? {
target: 'pino-pretty',
options: {
colorize: true,
translateTime: true,
},
} : undefined,
};

View File

@@ -1,18 +0,0 @@
import { Redis } from 'ioredis';
const redisHost = process.env.REDIS_HOST || 'localhost';
const redisPort = parseInt(process.env.REDIS_PORT || '6379', 10);
export const redis = new Redis({
host: redisHost,
port: redisPort,
retryStrategy: (times: number) => Math.min(times * 50, 2000),
lazyConnect: true,
});
export async function getRedisConnection(): Promise<Redis> {
if (redis.status === 'wait' || redis.status === 'connecting') {
await redis.connect();
}
return redis;
}

View File

@@ -1,108 +0,0 @@
// dd-trace must be initialized before any other module is loaded for auto-instrumentation
import '@shieldai/monitoring/datadog-init';
import Fastify from 'fastify';
import cors from '@fastify/cors';
import helmet from '@fastify/helmet';
import { authMiddleware } from './middleware/auth.middleware';
import { rateLimitMiddleware } from './middleware/rate-limit.middleware';
import { spamRateLimitMiddleware } from './middleware/spam-rate-limit.middleware';
import { errorHandlingMiddleware } from './middleware/error-handling.middleware';
import { loggingMiddleware } from './middleware/logging.middleware';
import { apiEnv, loggingConfig, getCorsOrigins } from './config/api.config';
import { routes } from './routes';
const fastify = Fastify({
logger: loggingConfig,
ignoreTrailingSlash: true,
maxParamLength: 500,
});
// Register plugins
async function registerPlugins() {
// CORS configuration
await fastify.register(cors, {
origin: getCorsOrigins(),
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
credentials: true,
});
// Security headers
await fastify.register(helmet, {
global: true,
contentSecurityPolicy: false,
});
// Rate limiting
await fastify.register(rateLimitMiddleware);
// SpamShield rate limiting (Redis-backed)
await fastify.register(spamRateLimitMiddleware);
// Authentication
await fastify.register(authMiddleware);
// Logging
await fastify.register(loggingMiddleware);
// Error handling
await fastify.register(errorHandlingMiddleware);
}
// Register routes
async function registerRoutes() {
await fastify.register(routes, { prefix: '/api/v1' });
}
// Health check endpoint
fastify.get('/health', async () => {
return { status: 'ok', timestamp: new Date().toISOString() };
});
// Root endpoint
fastify.get('/', async () => {
return {
name: 'FrenoCorp API Gateway',
version: '1.0.0',
environment: apiEnv.NODE_ENV,
};
});
// Start server
async function start() {
await registerPlugins();
await registerRoutes();
try {
await fastify.listen({
port: apiEnv.PORT,
host: apiEnv.HOST,
});
console.log(`🚀 API Gateway running at http://${apiEnv.HOST}:${apiEnv.PORT}`);
console.log(`📝 Environment: ${apiEnv.NODE_ENV}`);
console.log(`📊 Rate limit window: ${apiEnv.API_RATE_LIMIT_WINDOW}ms`);
console.log(`📈 Max requests: ${apiEnv.API_RATE_LIMIT_MAX_REQUESTS}`);
} catch (err) {
console.error(err);
process.exit(1);
}
}
// Graceful shutdown
const gracefulShutdown = async (signal: string) => {
console.log(`\n🛑 ${signal} received, shutting down gracefully...`);
await fastify.close();
console.log('✅ Server closed');
process.exit(0);
};
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
// Export for testing
export { fastify };
// Start if running directly
if (process.argv[1] === new URL(import.meta.url).pathname) {
start();
}

View File

@@ -1,209 +0,0 @@
export enum UrlVerdict {
SAFE = 'safe',
SUSPICIOUS = 'suspicious',
PHISHING = 'phishing',
SPAM = 'spam',
EXPOSED_CREDENTIALS = 'exposed_credentials',
UNKNOWN = 'unknown',
}
export enum ThreatType {
PHISHING_KNOWN = 'phishing_known',
PHISHING_HEURISTIC = 'phishing_heuristic',
DOMAIN_AGE = 'domain_age',
SSL_ANOMALY = 'ssl_anomaly',
URL_ENTROPY = 'url_entropy',
TYPOSQUAT = 'typosquat',
CREDENTIAL_EXPOSURE = 'credential_exposure',
SPAM_SOURCE = 'spam_source',
REDIRECT_CHAIN = 'redirect_chain',
MIXED_CONTENT = 'mixed_content',
}
export interface ThreatInfo {
type: ThreatType;
severity: number;
source: string;
description: string;
}
export class PhishingDetector {
private knownSuspiciousTlds = new Set([
'.tk', '.ml', '.ga', '.cf', '.gq', '.xyz', '.top', '.click', '.link', '.work',
]);
private commonBrands = new Map<string, string[]>([
['google', ['gmail', 'drive', 'docs', 'maps', 'play', 'chrome', 'youtube']],
['apple', ['icloud', 'appstore', 'icloud_content', 'appleid']],
['amazon', ['aws', 'amazonaws', 'amazon-adsystem', 'prime-video']],
['microsoft', ['office', 'outlook', 'onedrive', 'teams', 'azure', 'windows']],
['facebook', ['fb', 'fbcdn', 'instagram', 'whatsapp', 'messenger']],
['paypal', ['paypalobjects', 'paypal-web', 'xoom']],
['netflix', ['nflximg', 'nflxso', 'nflxvideo', 'nflxext']],
]);
analyzeUrl(url: string): { verdict: UrlVerdict; threats: ThreatInfo[]; score: number } {
const threats: ThreatInfo[] = [];
let score = 0;
try {
const parsed = new URL(url);
const hostname = parsed.hostname.toLowerCase();
const domainParts = hostname.split('.');
const tld = domainParts[domainParts.length - 1];
score += this.checkTld(tld, threats);
score += this.checkEntropy(parsed.pathname + parsed.search, threats);
score += this.checkTyposquatting(hostname, threats);
score += this.checkIpAddress(hostname, threats);
score += this.checkLongUrl(url, threats);
score += this.checkSubdomainDepth(domainParts, threats);
score += this.checkHttpsProtocol(parsed.protocol, threats);
score += this.checkRedirectPatterns(parsed.search, threats);
score += this.checkEncodedChars(url, threats);
score += this.checkBrandImpersonation(hostname, threats);
} catch {
return {
verdict: UrlVerdict.UNKNOWN,
threats: [{ type: ThreatType.PHISHING_HEURISTIC, severity: 3, source: 'heuristic', description: 'Malformed URL' }],
score: 30,
};
}
const verdict = score >= 70 ? UrlVerdict.PHISHING
: score >= 40 ? UrlVerdict.SUSPICIOUS
: score >= 20 ? UrlVerdict.SPAM
: UrlVerdict.SAFE;
return { verdict, threats, score };
}
private checkTld(tld: string, threats: ThreatInfo[]): number {
if (this.knownSuspiciousTlds.has(`.${tld}`)) {
threats.push({ type: ThreatType.DOMAIN_AGE, severity: 4, source: 'heuristic', description: `Suspicious TLD: .${tld}` });
return 25;
}
return 0;
}
private checkEntropy(pathname: string, threats: ThreatInfo[]): number {
if (!pathname || pathname.length < 20) return 0;
const entropy = this.calculateEntropy(pathname);
if (entropy > 4.5) {
threats.push({ type: ThreatType.URL_ENTROPY, severity: 4, source: 'heuristic', description: `High URL path entropy (${entropy.toFixed(2)})` });
return 20;
}
return 0;
}
private checkTyposquatting(hostname: string, threats: ThreatInfo[]): number {
for (const [brand, subdomains] of this.commonBrands) {
const parts = hostname.split('.');
const main = parts[0];
if (main.includes(brand) && main !== brand) {
const dist = this.levenshteinDistance(main, brand);
if (dist <= 2 && dist > 0) {
threats.push({ type: ThreatType.TYPOSQUAT, severity: 5, source: 'heuristic', description: `Possible typosquat of "${brand}"` });
return 35;
}
}
const dist = this.levenshteinDistance(main, brand);
if (dist <= 2 && dist > 0 && main.length >= brand.length - 1) {
threats.push({ type: ThreatType.TYPOSQUAT, severity: 5, source: 'heuristic', description: `Possible typosquat of "${brand}"` });
return 35;
}
for (const sub of subdomains) {
if (hostname.includes(sub) && !hostname.startsWith(`${sub}.`)) {
threats.push({ type: ThreatType.TYPOSQUAT, severity: 3, source: 'heuristic', description: `Contains "${sub}" but not official ${brand}` });
return 15;
}
}
}
return 0;
}
private checkIpAddress(hostname: string, threats: ThreatInfo[]): number {
if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname) && hostname !== '127.0.0.1') {
threats.push({ type: ThreatType.PHISHING_HEURISTIC, severity: 4, source: 'heuristic', description: `IP address hostname: ${hostname}` });
return 25;
}
return 0;
}
private checkLongUrl(url: string, threats: ThreatInfo[]): number {
if (url.length > 200) {
threats.push({ type: ThreatType.PHISHING_HEURISTIC, severity: 3, source: 'heuristic', description: `Long URL (${url.length} chars)` });
return 15;
}
return 0;
}
private checkSubdomainDepth(parts: string[], threats: ThreatInfo[]): number {
if (parts.length > 5) {
threats.push({ type: ThreatType.PHISHING_HEURISTIC, severity: 3, source: 'heuristic', description: `Deep subdomains (${parts.length} levels)` });
return 15;
}
return 0;
}
private checkHttpsProtocol(protocol: string, threats: ThreatInfo[]): number {
if (protocol === 'http:') {
threats.push({ type: ThreatType.MIXED_CONTENT, severity: 2, source: 'heuristic', description: 'HTTP (not HTTPS)' });
return 10;
}
return 0;
}
private checkRedirectPatterns(query: string, threats: ThreatInfo[]): number {
const params = ['redirect', 'url', 'dest', 'return', 'next', 'target'];
const count = params.filter((p) => query.includes(`${p}=`)).length;
if (count >= 2) {
threats.push({ type: ThreatType.REDIRECT_CHAIN, severity: 3, source: 'heuristic', description: `Multiple redirect params (${count})` });
return 15;
}
return 0;
}
private checkEncodedChars(url: string, threats: ThreatInfo[]): number {
if (/(%[0-9a-fA-F]{2}){3,}/.test(url)) {
threats.push({ type: ThreatType.URL_ENTROPY, severity: 3, source: 'heuristic', description: 'Excessive URL encoding' });
return 15;
}
return 0;
}
private checkBrandImpersonation(hostname: string, threats: ThreatInfo[]): number {
const patterns = [/login[-_]?(secure|portal|page|form)/i, /account[-_]?(verify|confirm|update)/i, /secure[-_]?(signin|auth|login)/i];
for (const pattern of patterns) {
if (pattern.test(hostname)) {
threats.push({ type: ThreatType.PHISHING_HEURISTIC, severity: 4, source: 'heuristic', description: `Phishing pattern: ${hostname}` });
return 20;
}
}
return 0;
}
private calculateEntropy(str: string): number {
const freq: Record<string, number> = {};
for (const c of str) freq[c] = (freq[c] || 0) + 1;
let entropy = 0;
const len = str.length;
for (const count of Object.values(freq)) {
const p = count / len;
entropy -= p * Math.log2(p);
}
return entropy;
}
private levenshteinDistance(a: string, b: string): number {
const m: number[][] = [];
for (let i = 0; i <= b.length; i++) m[i] = [i];
for (let j = 0; j <= a.length; j++) m[0][j] = j;
for (let i = 1; i <= b.length; i++)
for (let j = 1; j <= a.length; j++)
m[i][j] = b[i-1] === a[j-1] ? m[i-1][j-1] : Math.min(m[i-1][j-1]+1, m[i][j-1]+1, m[i-1][j]+1);
return m[b.length][a.length];
}
}
export const phishingDetector = new PhishingDetector();

View File

@@ -1,112 +0,0 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import jwt from 'jsonwebtoken';
const JWT_SECRET = process.env.JWT_SECRET || process.env.NEXTAUTH_SECRET;
if (!JWT_SECRET && process.env.NODE_ENV === 'production') {
console.error('JWT_SECRET or NEXTAUTH_SECRET must be set in production');
}
export interface AuthRequest extends FastifyRequest {
user?: {
id: string;
email: string;
role: string;
organizationId?: string;
};
apiKey?: string;
authType: 'jwt' | 'api-key' | 'anonymous';
}
export async function authMiddleware(fastify: FastifyInstance) {
// Authentication hook
fastify.addHook('onRequest', async (request: FastifyRequest, reply: FastifyReply) => {
const authReq = request as AuthRequest;
// Skip auth for health checks and root
const publicRoutes = ['/', '/health', '/extension/auth'];
if (publicRoutes.some((route) => request.url.startsWith(route))) {
authReq.authType = 'anonymous';
return;
}
// Try JWT authentication first
const authHeader = request.headers.authorization;
if (authHeader?.startsWith('Bearer ')) {
const token = authHeader.slice(7);
try {
if (!JWT_SECRET) {
throw new Error('JWT_SECRET not configured');
}
const decoded = jwt.verify(token, JWT_SECRET) as {
id: string;
email: string;
role: string;
organizationId?: string;
iat?: number;
exp?: number;
};
authReq.user = {
id: decoded.id,
email: decoded.email,
role: decoded.role,
organizationId: decoded.organizationId,
};
authReq.authType = 'jwt';
return;
} catch (err) {
if (err instanceof jwt.JsonWebTokenError) {
throw { statusCode: 401, message: 'Invalid token' };
}
if (err instanceof jwt.TokenExpiredError) {
throw { statusCode: 401, message: 'Token expired', expiredAt: err.expiredAt };
}
throw { statusCode: 401, message: 'Authentication failed' };
}
}
// Try API key authentication
const apiKey = request.headers['x-api-key'] as string | undefined;
if (apiKey) {
// In production, validate API key against database
authReq.apiKey = apiKey;
const apiKeyPrefix = apiKey.slice(0, 8);
authReq.user = {
id: `api-${apiKeyPrefix}...`,
email: `api-${apiKeyPrefix}@services.internal`,
role: 'service',
};
authReq.authType = 'api-key';
return;
}
// No auth found - attach anonymous user
authReq.authType = 'anonymous';
authReq.user = {
id: 'anonymous',
email: 'anonymous@unknown',
role: 'anonymous',
};
});
// Create auth decorator for route-level protection
fastify.decorate('requireAuth', async (request: AuthRequest) => {
if (request.authType === 'anonymous') {
throw { statusCode: 401, message: 'Authentication required' };
}
return true;
});
fastify.decorate('requireRole', (allowedRoles: string[]) => {
return async (request: AuthRequest) => {
if (!request.user?.role || !allowedRoles.includes(request.user.role)) {
throw {
statusCode: 403,
message: `Role ${request.user?.role} not in allowed roles: ${allowedRoles.join(', ')}`,
};
}
return true;
};
});
}

View File

@@ -1,81 +0,0 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import { captureSentryError, setSentryContext, setSentryUser } from '@shieldai/monitoring';
export interface ErrorResponse {
error: string;
message: string;
statusCode: number;
code?: string;
details?: Record<string, unknown>;
timestamp: string;
path: string;
}
export async function errorHandlingMiddleware(fastify: FastifyInstance) {
// Custom error handler
fastify.setErrorHandler((error, request: FastifyRequest, reply: FastifyReply) => {
const err = error as Error & { statusCode?: number; code?: string };
const response: ErrorResponse = {
error: err.name || 'Internal Server Error',
message: err.message || 'An unexpected error occurred',
statusCode: err.statusCode || 500,
code: err.code,
timestamp: new Date().toISOString(),
path: request.url,
};
// Send to Sentry (5xx errors only)
if (response.statusCode >= 500) {
const userId = (request as FastifyRequest & { user?: { id?: string } }).user?.id;
if (userId) setSentryUser(userId);
setSentryContext('request', {
method: request.method,
url: request.url,
userAgent: request.headers['user-agent'],
requestId: request.id,
});
captureSentryError(err, {
statusCode: String(response.statusCode),
path: request.url,
method: request.method,
});
}
// Log error
fastify.log.error({
error: response,
stack: err.stack,
method: request.method,
userAgent: request.headers['user-agent'],
});
// Send standardized error response
reply.status(response.statusCode).send(response);
});
// 404 handler
fastify.setNotFoundHandler((request: FastifyRequest, reply: FastifyReply) => {
reply.status(404).send({
error: 'Not Found',
message: `Route ${request.method} ${request.url} not found`,
statusCode: 404,
timestamp: new Date().toISOString(),
path: request.url,
});
});
// Validation error handler
fastify.addHook('onError', async (request: FastifyRequest, reply: FastifyReply, error) => {
if (error.validation) {
reply.status(400).send({
error: 'Validation Error',
message: 'Request validation failed',
statusCode: 400,
code: 'VALIDATION_ERROR',
details: error.validation,
timestamp: new Date().toISOString(),
path: request.url,
});
}
});
}

View File

@@ -1,66 +0,0 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
export interface RequestLog {
method: string;
url: string;
statusCode: number;
responseTime: number;
requestId: string;
userAgent?: string;
clientIp: string;
requestIdHeader?: string;
}
export async function loggingMiddleware(fastify: FastifyInstance) {
// Generate request ID if not present
fastify.addHook('onRequest', (request: FastifyRequest, reply: FastifyReply, done) => {
const requestId =
request.headers['x-request-id'] ||
request.headers['x-correlation-id'] ||
`req-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
request.headers['x-request-id'] = requestId;
(request as any).requestId = requestId;
done();
});
// Log request start
fastify.addHook('onRequest', (request: FastifyRequest, reply: FastifyReply) => {
fastify.log.info({
event: 'request_start',
method: request.method,
url: request.url,
requestId: (request as any).requestId,
userAgent: request.headers['user-agent'],
clientIp: request.ip || request.headers['x-forwarded-for'] || 'unknown',
});
});
// Log response
fastify.addHook('onResponse', (request: FastifyRequest, reply: FastifyReply, done) => {
const log: RequestLog = {
method: request.method,
url: request.url,
statusCode: reply.statusCode,
responseTime: reply.elapsedTime,
requestId: (request as any).requestId,
userAgent: request.headers['user-agent'],
clientIp: request.ip || request.headers['x-forwarded-for'] || 'unknown',
requestIdHeader: request.headers['x-request-id'] as string,
};
// Log based on status code
if (reply.statusCode < 300) {
fastify.log.info(log);
} else if (reply.statusCode < 400) {
fastify.log.warn(log);
} else if (reply.statusCode < 500) {
fastify.log.warn(log);
} else {
fastify.log.error(log);
}
done();
});
}

View File

@@ -1,69 +0,0 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import { emitBatchMetrics, emitError } from '@shieldai/monitoring';
const SERVICE_NAME = process.env.DD_SERVICE || 'shieldai-api';
export async function monitoringMiddleware(fastify: FastifyInstance) {
fastify.addHook('onResponse', async (request: FastifyRequest, reply: FastifyReply) => {
const statusCode = reply.statusCode;
const responseTime = reply.elapsedTime;
const method = request.method;
const url = request.url;
// Batch all metrics into a single PutMetricDataCommand to avoid rate limits
await emitBatchMetrics({
serviceName: SERVICE_NAME,
data: [
{
metricName: 'api_requests',
value: 1,
unit: 'Count',
dimensions: { status_class: String(Math.floor(statusCode / 100)) + 'xx' },
},
{
metricName: 'api_latency',
value: responseTime,
unit: 'Milliseconds',
dimensions: { percentile: 'p50' },
},
{
metricName: 'api_latency',
value: responseTime,
unit: 'Milliseconds',
dimensions: { percentile: 'p95' },
},
{
metricName: 'api_latency',
value: responseTime,
unit: 'Milliseconds',
dimensions: { percentile: 'p99' },
},
],
});
// Emit error metric for 5xx (separate call since it has different dimensions)
if (statusCode >= 500) {
await emitError(SERVICE_NAME, 'server_error');
fastify.log.warn({
event: 'high_latency_or_error',
method,
url,
statusCode,
responseTime,
service: SERVICE_NAME,
});
}
// Log high latency requests (>2s) — only when not already logged as error
else if (responseTime > 2000) {
fastify.log.warn({
event: 'high_latency',
method,
url,
statusCode,
responseTime,
service: SERVICE_NAME,
});
}
});
}

View File

@@ -1,116 +0,0 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import { apiEnv, rateLimitConfig } from '../config/api.config';
// Simple in-memory rate limiter
// In production, this should use Redis or similar distributed store
class RateLimiter {
private store: Map<string, { count: number; resetTime: number }>;
constructor() {
this.store = new Map();
}
async checkLimit(
key: string,
windowMs: number,
maxRequests: number
): Promise<{ remaining: number; resetTime: number; retryAfter?: number }> {
const now = Date.now();
const current = this.store.get(key);
if (!current || now > current.resetTime) {
// Reset window
this.store.set(key, {
count: 1,
resetTime: now + windowMs,
});
return {
remaining: maxRequests - 1,
resetTime: now + windowMs,
};
}
// Increment counter
current.count++;
this.store.set(key, current);
const remaining = maxRequests - current.count;
if (current.count > maxRequests) {
return {
remaining: 0,
resetTime: current.resetTime,
retryAfter: current.resetTime - now,
};
}
return {
remaining,
resetTime: current.resetTime,
};
}
reset(key: string) {
this.store.delete(key);
}
}
const rateLimiter = new RateLimiter();
export async function rateLimitMiddleware(fastify: FastifyInstance) {
fastify.addHook('preHandler', async (request: FastifyRequest, reply: FastifyReply) => {
// Skip rate limiting for health checks
if (request.url === '/health') {
return;
}
// Get client identifier (IP or API key)
const clientIp = request.ip || request.headers['x-forwarded-for'] || 'unknown';
const apiKey = request.headers['x-api-key'] as string | undefined;
const key = apiKey ? `api:${apiKey}` : `ip:${clientIp}`;
// Determine tier based on API key or default to basic
let tier = 'basic';
if (apiKey) {
// In production, fetch tier from user/service lookup
// For now, use a simple heuristic based on key format
if (apiKey.startsWith('premium_')) {
tier = 'premium';
} else if (apiKey.startsWith('plus_')) {
tier = 'plus';
}
}
const config = rateLimitConfig[tier as keyof typeof rateLimitConfig];
const result = await rateLimiter.checkLimit(
key,
config.windowMs,
config.maxRequests
);
// Set rate limit headers
reply.header('X-RateLimit-Limit', config.maxRequests);
reply.header('X-RateLimit-Remaining', result.remaining);
reply.header('X-RateLimit-Reset', Math.ceil(result.resetTime / 1000));
if (result.retryAfter) {
reply.header('Retry-After', Math.ceil(result.retryAfter / 1000));
reply.code(429); // Too Many Requests
return {
error: 'Too Many Requests',
message: `Rate limit exceeded. Try again in ${Math.ceil(result.retryAfter / 1000)}s`,
tier,
limit: config.maxRequests,
reset: new Date(result.resetTime).toISOString(),
};
}
// Add tier info to request for downstream use
(request as any).rateLimitTier = tier;
});
}
// Export for testing
export { rateLimiter };

View File

@@ -1,164 +0,0 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import { redis } from '../config/redis';
import { spamRateLimits } from '../services/spamshield/spamshield.config';
const REDIS_PREFIX = 'spamshield:ratelimit';
class RedisRateLimiter {
async checkLimit(
key: string,
windowSeconds: number,
maxRequests: number
): Promise<{
remaining: number;
resetTime: number;
retryAfter?: number;
}> {
const redisKey = `${REDIS_PREFIX}:${key}`;
const now = Date.now();
const current = await redis.get(redisKey);
const windowStart = now - (now % (windowSeconds * 1000));
const resetTime = windowStart + windowSeconds * 1000;
if (!current) {
const expirySeconds = Math.ceil((resetTime - now) / 1000);
await redis.set(redisKey, '1', 'EX', expirySeconds);
return {
remaining: maxRequests - 1,
resetTime,
};
}
const count = parseInt(current, 10) + 1;
await redis.set(redisKey, String(count), 'EX', Math.ceil((resetTime - now) / 1000));
const remaining = maxRequests - count;
if (count > maxRequests) {
return {
remaining: 0,
resetTime,
retryAfter: resetTime - now,
};
}
return {
remaining,
resetTime,
};
}
async checkDailyLimit(
key: string,
maxPerDay: number
): Promise<{
remaining: number;
retryAfter?: number;
}> {
const redisKey = `${REDIS_PREFIX}:daily:${key}`;
const now = Date.now();
const dayStart = new Date(now);
dayStart.setHours(0, 0, 0, 0);
const dayEnd = new Date(dayStart);
dayEnd.setDate(dayEnd.getDate() + 1);
const resetTime = dayEnd.getTime();
const current = await redis.get(redisKey);
const expirySeconds = Math.ceil((resetTime - now) / 1000);
if (!current) {
await redis.set(redisKey, '1', 'EX', expirySeconds);
return {
remaining: maxPerDay - 1,
};
}
const count = parseInt(current, 10) + 1;
await redis.set(redisKey, String(count), 'EX', expirySeconds);
const remaining = maxPerDay - count;
if (count > maxPerDay) {
return {
remaining: 0,
retryAfter: resetTime - now,
};
}
return {
remaining,
};
}
reset(key: string) {
const redisKey = `${REDIS_PREFIX}:${key}`;
return redis.del(redisKey);
}
}
export const spamRateLimiter = new RedisRateLimiter();
export async function spamRateLimitMiddleware(fastify: FastifyInstance) {
fastify.addHook('preHandler', async (request: FastifyRequest, reply: FastifyReply) => {
const url = request.url || '';
if (!url.startsWith('/spamshield')) {
return;
}
const clientIp = request.ip || (request.headers['x-forwarded-for'] as string) || 'unknown';
const apiKey = request.headers['x-api-key'] as string | undefined;
const key = apiKey ? `api:${apiKey}` : `ip:${clientIp}`;
let tier = 'basic';
if (apiKey) {
if (apiKey.startsWith('premium_')) {
tier = 'premium';
} else if (apiKey.startsWith('plus_')) {
tier = 'plus';
}
}
const config = spamRateLimits[tier as keyof typeof spamRateLimits];
const minuteResult = await spamRateLimiter.checkLimit(
key,
60,
config.analysesPerMinute
);
const dailyResult = await spamRateLimiter.checkDailyLimit(
key,
config.analysesPerDay
);
reply.header('X-RateLimit-Limit', config.analysesPerMinute);
reply.header('X-RateLimit-Remaining', minuteResult.remaining);
reply.header('X-RateLimit-Reset', Math.ceil(minuteResult.resetTime / 1000));
reply.header('X-RateLimit-Daily-Limit', config.analysesPerDay);
reply.header('X-RateLimit-Daily-Remaining', dailyResult.remaining);
const retryAfter = minuteResult.retryAfter || dailyResult.retryAfter;
if (retryAfter) {
reply.header('Retry-After', Math.ceil(retryAfter / 1000));
reply.code(429);
return {
error: 'Too Many Requests',
message: `Spam analysis rate limit exceeded. Try again in ${Math.ceil(retryAfter / 1000)}s`,
tier,
limit: config.analysesPerMinute,
dailyLimit: config.analysesPerDay,
reset: new Date(minuteResult.resetTime).toISOString(),
};
}
(request as any).spamRateLimitTier = tier;
});
}
export { RedisRateLimiter };

File diff suppressed because it is too large Load Diff

View File

@@ -1,30 +0,0 @@
import { FastifyInstance } from "fastify";
import { AlertPipeline } from "@shieldai/darkwatch";
export function alertRoutes(fastify: FastifyInstance) {
const pipeline = new AlertPipeline();
fastify.get("/", async (request, reply) => {
const userId = (request.user as { id: string })?.id;
if (!userId) {
return reply.code(401).send({ error: "User not authenticated" });
}
const limit = parseInt(request.query.limit as string) || 50;
const offset = parseInt(request.query.offset as string) || 0;
const alerts = await pipeline.getUserAlerts(userId, limit, offset);
return reply.send(alerts);
});
fastify.patch("/:id/read", async (request, reply) => {
const userId = (request.user as { id: string })?.id;
if (!userId) {
return reply.code(401).send({ error: "User not authenticated" });
}
await pipeline.markRead(request.params.id, userId);
return reply.send({ read: true });
});
}

View File

@@ -1,136 +0,0 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import { prisma } from '@shieldai/db';
interface CreatePostBody {
slug: string;
title: string;
excerpt?: string;
content: string;
authorName?: string;
coverImageUrl?: string;
tags?: string[];
published?: boolean;
publishedAt?: string;
}
export async function blogAdminRoutes(fastify: FastifyInstance) {
fastify.addHook('onRequest', async (request: FastifyRequest, reply: FastifyReply) => {
const authReq = request as FastifyRequest & { user?: { id: string; role?: string } };
const user = authReq.user;
if (!user) {
return reply.code(401).send({ error: 'Unauthorized' });
}
if (user.role !== 'support') {
return reply.code(403).send({ error: 'Admin access required' });
}
});
fastify.post('/admin/blog', async (request: FastifyRequest, reply: FastifyReply) => {
const body = request.body as CreatePostBody;
if (!body.slug || !/^[a-z0-9-]+$/.test(body.slug)) {
return reply.code(400).send({ error: 'Invalid slug: must be lowercase alphanumeric with hyphens' });
}
if (!body.title || body.title.length > 200) {
return reply.code(400).send({ error: 'Title is required (max 200 chars)' });
}
if (!body.content) {
return reply.code(400).send({ error: 'Content is required' });
}
const existing = await prisma.blogPost.findUnique({
where: { slug: body.slug },
});
if (existing) {
return reply.code(409).send({ error: 'A post with this slug already exists' });
}
const post = await prisma.blogPost.create({
data: {
slug: body.slug,
title: body.title,
excerpt: body.excerpt || null,
content: body.content,
authorName: body.authorName || null,
coverImageUrl: body.coverImageUrl || null,
tags: body.tags || [],
published: body.published || false,
publishedAt: body.publishedAt
? new Date(body.publishedAt)
: body.published
? new Date()
: null,
},
});
return reply.code(201).send({ post });
});
fastify.put('/admin/blog/:id', async (request: FastifyRequest, reply: FastifyReply) => {
const { id } = request.params as { id: string };
const body = request.body as Partial<CreatePostBody>;
const existing = await prisma.blogPost.findUnique({ where: { id } });
if (!existing) {
return reply.code(404).send({ error: 'Post not found' });
}
if (body.slug && body.slug !== existing.slug) {
const slugExists = await prisma.blogPost.findUnique({ where: { slug: body.slug } });
if (slugExists) {
return reply.code(409).send({ error: 'A post with this slug already exists' });
}
}
const post = await prisma.blogPost.update({
where: { id },
data: {
...(body.slug !== undefined && { slug: body.slug }),
...(body.title !== undefined && { title: body.title }),
...(body.excerpt !== undefined && { excerpt: body.excerpt }),
...(body.content !== undefined && { content: body.content }),
...(body.authorName !== undefined && { authorName: body.authorName }),
...(body.coverImageUrl !== undefined && { coverImageUrl: body.coverImageUrl }),
...(body.tags !== undefined && { tags: body.tags }),
...(body.published !== undefined && { published: body.published }),
publishedAt: body.publishedAt
? new Date(body.publishedAt)
: body.published === true && !existing.published
? new Date()
: undefined,
},
});
return reply.send({ post });
});
fastify.delete('/admin/blog/:id', async (request: FastifyRequest, reply: FastifyReply) => {
const { id } = request.params as { id: string };
await prisma.blogPost.delete({ where: { id } });
return reply.code(204).send();
});
fastify.get('/admin/blog', async (request: FastifyRequest, reply: FastifyReply) => {
const query = request.query as { page?: string; limit?: string };
const page = Math.max(1, parseInt(query.page || '1', 10));
const limit = Math.min(50, Math.max(1, parseInt(query.limit || '20', 10)));
const skip = (page - 1) * limit;
const [posts, total] = await Promise.all([
prisma.blogPost.findMany({
orderBy: { createdAt: 'desc' },
skip,
take: limit,
}),
prisma.blogPost.count(),
]);
return reply.send({
posts,
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
});
});
}

View File

@@ -1,72 +0,0 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import { prisma } from '@shieldai/db';
interface BlogQuery {
page?: string;
limit?: string;
tag?: string;
}
export async function blogRoutes(fastify: FastifyInstance) {
fastify.get('/blog', async (request: FastifyRequest, reply: FastifyReply) => {
const query = request.query as BlogQuery;
const page = Math.max(1, parseInt(query.page || '1', 10));
const limit = Math.min(50, Math.max(1, parseInt(query.limit || '10', 10)));
const skip = (page - 1) * limit;
const where = {
published: true,
...(query.tag ? { tags: { has: query.tag } } : {}),
};
const [posts, total] = await Promise.all([
prisma.blogPost.findMany({
where,
orderBy: { publishedAt: 'desc' },
skip,
take: limit,
select: {
id: true,
slug: true,
title: true,
excerpt: true,
authorName: true,
coverImageUrl: true,
tags: true,
publishedAt: true,
viewCount: true,
},
}),
prisma.blogPost.count({ where }),
]);
return reply.send({
posts,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
});
});
fastify.get('/blog/:slug', async (request: FastifyRequest, reply: FastifyReply) => {
const { slug } = request.params as { slug: string };
const post = await prisma.blogPost.findUnique({
where: { slug },
});
if (!post || !post.published) {
return reply.code(404).send({ error: 'Post not found' });
}
await prisma.blogPost.update({
where: { id: post.id },
data: { viewCount: { increment: 1 } },
});
return reply.send({ post });
});
}

View File

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

View File

@@ -1,285 +0,0 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import { prisma, SubscriptionTier } from '@shieldai/db';
import { tierConfig, SubscriptionTier as BillingTier } from '@shieldsai/shared-billing';
import {
watchlistService,
scanService,
schedulerService,
webhookService,
} from '../services/darkwatch';
export async function darkwatchRoutes(fastify: FastifyInstance) {
const authed = async (
request: FastifyRequest,
reply: FastifyReply
): Promise<string | null> => {
const authReq = request as FastifyRequest & { user?: { id: string } };
const userId = authReq.user?.id;
if (!userId) {
reply.code(401).send({ error: 'User ID required' });
return null;
}
const subscription = await prisma.subscription.findFirst({
where: { userId, status: 'active' },
select: { id: true, tier: true },
});
if (!subscription) {
reply.code(404).send({ error: 'Active subscription not found' });
return null;
}
return subscription.id;
};
// GET /darkwatch/watchlist - List watchlist items
fastify.get('/watchlist', async (request: FastifyRequest, reply: FastifyReply) => {
const subscriptionId = await authed(request, reply);
if (!subscriptionId) return;
try {
const items = await watchlistService.getItems(subscriptionId);
return reply.send({ items });
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to list watchlist';
return reply.code(500).send({ error: message });
}
});
// POST /darkwatch/watchlist - Add watchlist item
fastify.post('/watchlist', async (request: FastifyRequest, reply: FastifyReply) => {
const authReq = request as FastifyRequest & { user?: { id: string } };
const userId = authReq.user?.id;
if (!userId) {
return reply.code(401).send({ error: 'User ID required' });
}
const subscription = await prisma.subscription.findFirst({
where: { userId, status: 'active' },
select: { id: true, tier: true },
});
if (!subscription) {
return reply.code(404).send({ error: 'Active subscription not found' });
}
const body = request.body as { type: string; value: string };
if (!body.type || !body.value) {
return reply.code(400).send({ error: 'type and value are required' });
}
const maxItems = tierConfig[subscription.tier as BillingTier].features.maxWatchlistItems;
try {
const item = await watchlistService.addItem(
subscription.id,
body.type,
body.value,
maxItems
);
return reply.code(201).send({ item });
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to add watchlist item';
return reply.code(422).send({ error: message });
}
});
// DELETE /darkwatch/watchlist/:id - Remove watchlist item
fastify.delete('/watchlist/:id', async (request: FastifyRequest, reply: FastifyReply) => {
const subscriptionId = await authed(request, reply);
if (!subscriptionId) return;
const id = (request.params as { id: string }).id;
try {
const item = await watchlistService.removeItem(id, subscriptionId);
return reply.send({ item });
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to remove watchlist item';
return reply.code(422).send({ error: message });
}
});
// POST /darkwatch/scan - Trigger on-demand scan
fastify.post('/scan', async (request: FastifyRequest, reply: FastifyReply) => {
const subscriptionId = await authed(request, reply);
if (!subscriptionId) return;
try {
const job = await schedulerService.enqueueOnDemandScan(subscriptionId);
return reply.send({
job: {
id: job?.id,
status: 'queued',
},
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to trigger scan';
return reply.code(422).send({ error: message });
}
});
// GET /darkwatch/scan/schedule - Get scan schedule
fastify.get('/scan/schedule', async (request: FastifyRequest, reply: FastifyReply) => {
const subscriptionId = await authed(request, reply);
if (!subscriptionId) return;
try {
const schedule = await schedulerService.getScanSchedule(subscriptionId);
return reply.send({ schedule });
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to get schedule';
return reply.code(500).send({ error: message });
}
});
// GET /darkwatch/exposures - List exposures
fastify.get('/exposures', async (request: FastifyRequest, reply: FastifyReply) => {
const subscriptionId = await authed(request, reply);
if (!subscriptionId) return;
try {
const exposures = await prisma.exposure.findMany({
where: { subscriptionId },
orderBy: { detectedAt: 'desc' },
take: 50,
include: {
watchlistItem: true,
},
});
return reply.send({ exposures });
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to list exposures';
return reply.code(500).send({ error: message });
}
});
// GET /darkwatch/alerts - List alerts
fastify.get('/alerts', async (request: FastifyRequest, reply: FastifyReply) => {
const authReq = request as FastifyRequest & { user?: { id: string } };
const userId = authReq.user?.id;
if (!userId) {
return reply.code(401).send({ error: 'User ID required' });
}
try {
const alerts = await prisma.alert.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
take: 50,
include: {
exposure: true,
},
});
return reply.send({ alerts });
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to list alerts';
return reply.code(500).send({ error: message });
}
});
// PATCH /darkwatch/alerts/:id/read - Mark alert as read
fastify.patch('/alerts/:id/read', async (request: FastifyRequest, reply: FastifyReply) => {
const authReq = request as FastifyRequest & { user?: { id: string } };
const userId = authReq.user?.id;
if (!userId) {
return reply.code(401).send({ error: 'User ID required' });
}
const id = (request.params as { id: string }).id;
try {
const alert = await prisma.alert.update({
where: { id },
data: { isRead: true, readAt: new Date() },
});
return reply.send({ alert });
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to mark alert as read';
return reply.code(422).send({ error: message });
}
});
// POST /darkwatch/webhook - External webhook receiver
fastify.post('/webhook', async (request: FastifyRequest, reply: FastifyReply) => {
const body = request.body as Record<string, unknown>;
const source = typeof body.source === 'string' ? body.source : '';
const identifier = typeof body.identifier === 'string' ? body.identifier : '';
const identifierType = typeof body.identifierType === 'string' ? body.identifierType : '';
const metadata = body.metadata as Record<string, unknown> | undefined;
const timestamp = typeof body.timestamp === 'string' ? body.timestamp : new Date().toISOString();
if (!source || !identifier || !identifierType) {
return reply.code(400).send({
error: 'source, identifier, and identifierType are required',
});
}
const signature = request.headers['x-webhook-signature'] as string | undefined;
const webhookTimestamp = request.headers['x-webhook-timestamp'] as string | undefined;
if (!signature || !webhookTimestamp) {
return reply.code(401).send({ error: 'Webhook signature and timestamp required' });
}
const valid = await webhookService.verifyWebhookSignature(
JSON.stringify(body),
signature,
webhookTimestamp
);
if (!valid) {
return reply.code(401).send({ error: 'Invalid webhook signature' });
}
try {
const result = await webhookService.processExternalWebhook({
source,
identifier,
identifierType,
metadata,
timestamp,
});
return reply.send({
processed: true,
exposuresCreated: result.exposuresCreated,
alertsCreated: result.alertsCreated,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Webhook processing failed';
console.error('[DarkWatch:Webhook] Error:', message);
return reply.code(500).send({ error: 'Webhook processing failed' });
}
});
// POST /darkwatch/scheduler/init - Initialize scheduled scans for all subscriptions
fastify.post('/scheduler/init', async (request: FastifyRequest, reply: FastifyReply) => {
try {
const jobsEnqueued = await schedulerService.scheduleSubscriptionScans();
return reply.send({
scheduled: jobsEnqueued.length,
jobs: jobsEnqueued,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Scheduler init failed';
return reply.code(500).send({ error: message });
}
});
// POST /darkwatch/scheduler/reschedule - Reschedule all scans
fastify.post('/scheduler/reschedule', async (request: FastifyRequest, reply: FastifyReply) => {
try {
const jobsEnqueued = await schedulerService.rescheduleAll();
return reply.send({
rescheduled: jobsEnqueued.length,
jobs: jobsEnqueued,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Scheduler reschedule failed';
return reply.code(500).send({ error: message });
}
});
}

View File

@@ -1,342 +0,0 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import { prisma } from '@shieldai/db';
// In-memory rate limiter for device registration
const registrationAttempts = new Map<string, { count: number; resetAt: number }>();
const REGISTRATION_RATE_LIMIT = 10; // max registrations per window
const REGISTRATION_WINDOW_MS = 5 * 60 * 1000; // 5 minutes
function checkRegistrationRateLimit(key: string): boolean {
const now = Date.now();
const record = registrationAttempts.get(key);
if (!record || now > record.resetAt) {
registrationAttempts.set(key, { count: 1, resetAt: now + REGISTRATION_WINDOW_MS });
return true;
}
if (record.count >= REGISTRATION_RATE_LIMIT) {
return false;
}
record.count++;
return true;
}
// Cleanup stale rate limit entries every 5 minutes
setInterval(() => {
const now = Date.now();
for (const [key, record] of registrationAttempts.entries()) {
if (now > record.resetAt) {
registrationAttempts.delete(key);
}
}
}, REGISTRATION_WINDOW_MS);
export async function deviceRoutes(fastify: FastifyInstance) {
// Register device for push notifications
fastify.post(
'/devices/register',
{
preHandler: async (request, reply) => {
const authReq = request as FastifyRequest & { user?: { id: string } };
const userId = authReq.user?.id;
if (!userId) {
return reply.status(401).send({ error: 'Authentication required' });
}
},
},
async (request, reply) => {
const authReq = request as FastifyRequest & { user?: { id: string } };
const { platform, fcmToken, apnsToken, appVersion, osVersion, deviceModel } = request.body as {
platform: 'ios' | 'android';
fcmToken?: string;
apnsToken?: string;
appVersion: string;
osVersion: string;
deviceModel?: string;
};
if (!authReq.user?.id) {
return reply.status(401).send({ error: 'Authentication required' });
}
if (!platform || !appVersion || !osVersion) {
return reply.status(400).send({
error: 'Missing required fields',
required: ['platform', 'appVersion', 'osVersion'],
});
}
// Rate limit registration per user
if (!checkRegistrationRateLimit(authReq.user.id)) {
return reply.status(429).send({
error: 'Too many registration attempts',
retryAfter: Math.ceil(REGISTRATION_WINDOW_MS / 1000),
});
}
if (platform === 'android' && !fcmToken) {
return reply.status(400).send({
error: 'FCM token required for Android devices',
});
}
if (platform === 'ios' && !apnsToken) {
return reply.status(400).send({
error: 'APNs token required for iOS devices',
});
}
try {
// Determine device type based on platform
const deviceType = platform === 'ios' || platform === 'android' ? 'mobile' : 'web';
// Determine the token to store
const deviceToken = platform === 'ios' ? apnsToken! : fcmToken!;
// Upsert device registration to handle token rotation and re-registration
const deviceRegistration = await prisma.deviceToken.upsert({
where: { token: deviceToken },
create: {
userId: authReq.user.id,
deviceType,
token: deviceToken,
platform,
appVersion,
osVersion,
model: deviceModel,
isActive: true,
lastUsedAt: new Date(),
},
update: {
userId: authReq.user.id,
deviceType,
platform,
appVersion,
osVersion,
model: deviceModel,
isActive: true,
lastUsedAt: new Date(),
},
});
return {
success: true,
device: {
deviceId: deviceRegistration.id,
platform: deviceRegistration.platform,
registeredAt: deviceRegistration.createdAt.toISOString(),
},
message: 'Device registered successfully',
};
} catch (error) {
console.error('Failed to register device:', error);
reply.status(500).send({
error: 'Failed to register device',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
}
);
// Update device push tokens
fastify.put(
'/devices/:deviceId/tokens',
{
preHandler: async (request, reply) => {
const authReq = request as FastifyRequest & { user?: { id: string } };
const userId = authReq.user?.id;
if (!userId) {
return reply.status(401).send({ error: 'Authentication required' });
}
},
},
async (request, reply) => {
const authReq = request as FastifyRequest & { user?: { id: string } };
const { deviceId } = request.params as { deviceId: string };
const { fcmToken, apnsToken, deviceModel } = request.body as {
fcmToken?: string;
apnsToken?: string;
deviceModel?: string;
};
if (!authReq.user?.id) {
return reply.status(401).send({ error: 'Authentication required' });
}
if (!fcmToken && !apnsToken) {
return reply.status(400).send({
error: 'At least one token is required',
});
}
try {
// Find the device first
const existingDevice = await prisma.deviceToken.findFirst({
where: {
id: deviceId,
userId: authReq.user.id,
isActive: true,
},
});
if (!existingDevice) {
return reply.status(404).send({
error: 'Device not found',
});
}
// Determine which token to update based on platform
const updateData: {
token: string;
appVersion?: string;
osVersion?: string;
model?: string;
lastUsedAt: Date;
} = {
token: existingDevice.platform === 'ios' ? (apnsToken || existingDevice.token) : (fcmToken || existingDevice.token),
lastUsedAt: new Date(),
};
if (deviceModel) {
updateData.model = deviceModel;
}
// Update device tokens in database
const device = await prisma.deviceToken.update({
where: {
id: deviceId,
userId: authReq.user.id,
},
data: updateData,
});
return {
success: true,
device: {
deviceId: device.id,
platform: device.platform,
lastActiveAt: device.lastUsedAt.toISOString(),
},
message: 'Device tokens updated successfully',
};
} catch (error) {
console.error('Failed to update device tokens:', error);
reply.status(500).send({
error: 'Failed to update device tokens',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
}
);
// Get user's registered devices
fastify.get(
'/devices',
{
preHandler: async (request, reply) => {
const authReq = request as FastifyRequest & { user?: { id: string } };
const userId = authReq.user?.id;
if (!userId) {
return reply.status(401).send({ error: 'Authentication required' });
}
},
},
async (request, reply) => {
const authReq = request as FastifyRequest & { user?: { id: string } };
if (!authReq.user?.id) {
return reply.status(401).send({ error: 'Authentication required' });
}
try {
// Fetch devices from database
const devices = await prisma.deviceToken.findMany({
where: {
userId: authReq.user.id,
isActive: true,
},
select: {
id: true,
platform: true,
appVersion: true,
osVersion: true,
model: true,
lastUsedAt: true,
createdAt: true,
},
orderBy: {
lastUsedAt: 'desc',
},
});
return {
success: true,
devices: devices.map((d) => ({
deviceId: d.id,
platform: d.platform,
appVersion: d.appVersion,
osVersion: d.osVersion,
model: d.model,
lastActiveAt: d.lastUsedAt.toISOString(),
registeredAt: d.createdAt.toISOString(),
})),
message: 'Device list retrieved',
};
} catch (error) {
console.error('Failed to fetch devices:', error);
reply.status(500).send({
error: 'Failed to fetch devices',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
}
);
// Deregister device
fastify.delete(
'/devices/:deviceId',
{
preHandler: async (request, reply) => {
const authReq = request as FastifyRequest & { user?: { id: string } };
const userId = authReq.user?.id;
if (!userId) {
return reply.status(401).send({ error: 'Authentication required' });
}
},
},
async (request, reply) => {
const authReq = request as FastifyRequest & { user?: { id: string } };
const { deviceId } = request.params as { deviceId: string };
if (!authReq.user?.id) {
return reply.status(401).send({ error: 'Authentication required' });
}
try {
// Soft delete by marking as inactive
await prisma.deviceToken.update({
where: {
id: deviceId,
userId: authReq.user.id,
},
data: {
isActive: false,
},
});
return {
success: true,
message: 'Device deregistered successfully',
};
} catch (error) {
console.error('Failed to deregister device:', error);
reply.status(500).send({
error: 'Failed to deregister device',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
}
);
}

View File

@@ -1,27 +0,0 @@
import { FastifyInstance } from "fastify";
import { MatchingEngine } from "@shieldai/darkwatch";
export function exposureRoutes(fastify: FastifyInstance) {
const engine = new MatchingEngine();
fastify.get("/", async (request, reply) => {
const userId = (request.user as { id: string })?.id;
if (!userId) {
return reply.code(401).send({ error: "User not authenticated" });
}
const exposures = await engine.getExposuresForUser(userId);
return reply.send(exposures);
});
fastify.get("/:id", async (request, reply) => {
const exposure = await engine.getExposureById(request.params.id);
if (!exposure) {
return reply.code(404).send({ error: "Exposure not found" });
}
return reply.send(exposure);
});
}

View File

@@ -1,208 +0,0 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import { phishingDetector } from './lib/phishing-detector';
interface UrlCheckRequest {
url: string;
}
interface PhishingReportRequest {
url: string;
pageTitle: string;
tabId: number;
timestamp: number;
reason: string;
heuristics: Record<string, unknown>;
}
export async function extensionRoutes(fastify: FastifyInstance) {
fastify.post('/url-check', async (request: FastifyRequest, reply: FastifyReply) => {
const authReq = request as FastifyRequest & { user?: { id: string; tier?: string } };
const userId = authReq.user?.id;
if (!userId) {
return reply.code(401).send({ error: 'Authentication required' });
}
const body = request.body as UrlCheckRequest;
if (!body.url) {
return reply.code(400).send({ error: 'url is required' });
}
try {
const url = new URL(body.url);
const heuristic = phishingDetector.analyzeUrl(body.url);
const threats = heuristic.threats.map((t) => ({
type: t.type,
severity: t.severity,
source: t.source,
description: t.description,
}));
return reply.send({
url: body.url,
domain: url.hostname,
verdict: heuristic.verdict,
confidence: heuristic.score / 100,
threats,
timestamp: Date.now(),
});
} catch (error) {
const message = error instanceof Error ? error.message : 'URL check failed';
return reply.code(500).send({ error: message });
}
});
fastify.post('/phishing-report', async (request: FastifyRequest, reply: FastifyReply) => {
const authReq = request as FastifyRequest & { user?: { id: string } };
const userId = authReq.user?.id;
if (!userId) {
return reply.code(401).send({ error: 'Authentication required' });
}
const body = request.body as PhishingReportRequest;
try {
fastify.log.info({ url: body.url, userId, reason: body.reason }, 'Phishing report received');
return reply.send({
success: true,
reportId: `report_${Date.now()}_${userId}`,
timestamp: new Date().toISOString(),
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Report submission failed';
return reply.code(500).send({ error: message });
}
});
fastify.post('/auth', async (request: FastifyRequest, reply: FastifyReply) => {
const authHeader = request.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return reply.code(401).send({ error: 'Bearer token required' });
}
const token = authHeader.slice(7);
try {
const result = await validateExtensionToken(token, fastify);
return reply.send(result);
} catch (error) {
const message = error instanceof Error ? error.message : 'Authentication failed';
return reply.code(401).send({ error: message });
}
});
fastify.get('/stats', async (request: FastifyRequest, reply: FastifyReply) => {
const authReq = request as FastifyRequest & { user?: { id: string } };
const userId = authReq.user?.id;
if (!userId) {
return reply.code(401).send({ error: 'Authentication required' });
}
try {
const today = new Date().toDateString();
return reply.send({
threatsBlockedToday: 0,
urlsCheckedToday: 0,
lastSyncAt: new Date().toISOString(),
syncDate: today,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Stats retrieval failed';
return reply.code(500).send({ error: message });
}
});
fastify.post('/exposures/check', async (request: FastifyRequest, reply: FastifyReply) => {
const authReq = request as FastifyRequest & { user?: { id: string } };
const userId = authReq.user?.id;
if (!userId) {
return reply.code(401).send({ error: 'Authentication required' });
}
const body = request.body as { domain: string };
if (!body.domain) {
return reply.code(400).send({ error: 'domain is required' });
}
try {
const { prisma } = await import('@shieldai/db');
const exposures = await prisma.exposure.findMany({
where: {
alert: {
some: {
userId,
},
},
},
select: {
dataSource: true,
breachName: true,
metadata: true,
},
take: 10,
});
const domainLower = body.domain.toLowerCase();
const relevantExposures = exposures.filter((e) => {
const meta = e.metadata as Record<string, unknown> | null;
return meta?.domain?.toLowerCase() === domainLower ||
String(e.breachName).toLowerCase().includes(domainLower);
});
return reply.send({
exposed: relevantExposures.length > 0,
sources: relevantExposures.map((e) => e.dataSource),
count: relevantExposures.length,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Exposure check failed';
return reply.code(500).send({ error: message });
}
});
}
async function validateExtensionToken(
token: string,
fastify: FastifyInstance
): Promise<{ userId: string; tier: string }> {
try {
const { prisma } = await import('@shieldai/db');
const session = await prisma.session.findFirst({
where: { token },
include: {
user: {
include: {
subscription: {
where: { status: 'active' },
take: 1,
},
},
},
},
});
if (!session) {
throw new Error('Session not found');
}
const tier = session.user.subscription[0]?.tier || 'basic';
return {
userId: session.userId,
tier: tier.toLowerCase(),
};
} catch (error) {
if (error instanceof Error && error.message === 'Session not found') {
throw error;
}
fastify.log.warn({ error }, 'Extension token validation failed');
throw new Error('Token validation failed');
}
}

View File

@@ -1,463 +0,0 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import { prisma, SubscriptionTier } from '@shieldai/db';
import { detectChanges, shouldTriggerAlert } from '@shieldai/hometitle';
import { homeTitleAlertPipeline } from '@shieldai/hometitle';
import { AuthRequest } from '../auth.middleware';
const HOMETITLE_PROPERTY_LIMITS: Record<string, number> = {
free: 0,
basic: 0,
plus: 3,
premium: 5,
};
const HOMETITLE_TIER_ORDER: Record<string, number> = {
free: 0,
basic: 1,
plus: 2,
premium: 3,
};
/**
* Middleware: require Premium tier for home title features.
* Returns subscriptionId if user is Premium, otherwise replies with 402.
*/
async function requirePremiumTier(
request: FastifyRequest,
reply: FastifyReply,
): Promise<string | null> {
const authReq = request as AuthRequest;
const userId = authReq.user?.id;
if (!userId) {
await reply.code(401).send({ error: 'User not authenticated' });
return null;
}
const subscription = await prisma.subscription.findFirst({
where: { userId, status: 'active' },
select: { id: true, tier: true },
});
if (!subscription) {
await reply.code(402).send({
error: 'Subscription required',
message: 'A home title monitoring subscription is required',
});
return null;
}
if (subscription.tier !== 'premium') {
const currentTier = HOMETITLE_TIER_ORDER[subscription.tier] ?? 0;
const nextTier = 'plus';
await reply.code(402).send({
error: 'Premium tier required',
message: `Home title monitoring requires a Premium subscription. You are currently on ${subscription.tier}.`,
currentTier: subscription.tier,
upgradeTo: nextTier,
});
return null;
}
return subscription.id;
}
/**
* Check property limit for the user's tier.
* Returns true if under limit, replies 400 if exceeded.
*/
async function checkPropertyLimit(
reply: FastifyReply,
subscriptionId: string,
): Promise<boolean> {
const subscription = await prisma.subscription.findUnique({
where: { id: subscriptionId },
select: { tier: true },
});
if (!subscription) {
return false;
}
const limit = HOMETITLE_PROPERTY_LIMITS[subscription.tier] ?? 0;
const count = await prisma.watchlistItem.count({
where: { subscriptionId, type: 'address' },
});
if (count >= limit) {
await reply.code(400).send({
error: 'Property limit reached',
message: `You have reached the maximum of ${limit} properties for your ${subscription.tier} tier.`,
currentCount: count,
limit,
upgradeTo: 'premium',
});
return false;
}
return true;
}
export async function hometitleRoutes(fastify: FastifyInstance) {
// GET /hometitle/properties - List monitored properties with status
fastify.get('/properties', async (request: FastifyRequest, reply: FastifyReply) => {
const subscriptionId = await requirePremiumTier(request, reply);
if (!subscriptionId) return;
try {
const watchlistItems = await prisma.watchlistItem.findMany({
where: { subscriptionId, type: 'address', isActive: true },
orderBy: { createdAt: 'desc' },
});
const itemIds = watchlistItems.map((item) => item.id);
// Batch query: fetch all recent alerts in one query
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
const allRecentAlerts = await prisma.alert.findMany({
where: {
subscriptionId,
watchlistItemId: { in: itemIds },
createdAt: { gte: sevenDaysAgo },
},
orderBy: { createdAt: 'desc' },
});
// Group alerts by watchlistItemId
const alertsByItem = new Map<string, typeof allRecentAlerts>();
for (const alert of allRecentAlerts) {
const arr = alertsByItem.get(alert.watchlistItemId) || [];
arr.push(alert);
alertsByItem.set(alert.watchlistItemId, arr);
}
const properties = watchlistItems.map((item) => {
const recentAlerts = (alertsByItem.get(item.id) || []).slice(0, 3);
const hasRecentAlerts = recentAlerts.length > 0;
const latestAlert = recentAlerts[0];
let status: 'monitored' | 'alert' | 'error' = 'monitored';
if (hasRecentAlerts && latestAlert) {
status = latestAlert.severity === 'CRITICAL' ? 'alert' : 'monitored';
}
return {
id: item.id,
address: item.value,
status,
lastScan: null,
lastChange: latestAlert ? latestAlert.createdAt : null,
alertCount: recentAlerts.length,
addedAt: item.createdAt,
};
});
return reply.send({ properties });
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to list properties';
return reply.code(500).send({ error: message });
}
});
// GET /hometitle/properties/stats - Dashboard widget stats (available to all active subscriptions)
fastify.get('/properties/stats', async (request: FastifyRequest, reply: FastifyReply) => {
const authReq = request as AuthRequest;
const userId = authReq.user?.id;
if (!userId) {
return reply.code(401).send({ error: 'User not authenticated' });
}
const subscription = await prisma.subscription.findFirst({
where: { userId, status: 'active' },
select: { id: true, tier: true },
});
if (!subscription) {
return reply.code(404).send({ error: 'Active subscription not found' });
}
try {
const propertyCount = await prisma.watchlistItem.count({
where: { subscriptionId: subscription.id, type: 'address', isActive: true },
});
const limit = HOMETITLE_PROPERTY_LIMITS[subscription.tier] ?? 0;
const isPremium = subscription.tier === 'premium';
// Count recent alerts (last 7 days)
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
const recentAlertCount = await prisma.alert.count({
where: {
subscriptionId: subscription.id,
createdAt: { gte: sevenDaysAgo },
},
});
// Count critical alerts
const criticalAlertCount = await prisma.alert.count({
where: {
subscriptionId: subscription.id,
severity: 'CRITICAL',
createdAt: { gte: sevenDaysAgo },
},
});
return reply.send({
monitoredProperties: propertyCount,
tier: subscription.tier,
isPremium,
propertyLimit: limit,
canAddMore: propertyCount < limit,
recentAlertCount,
criticalAlertCount,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to fetch stats';
return reply.code(500).send({ error: message });
}
});
// POST /hometitle/properties - Add property to monitor
fastify.post('/properties', async (request: FastifyRequest, reply: FastifyReply) => {
const subscriptionId = await requirePremiumTier(request, reply);
if (!subscriptionId) return;
const body = request.body as { address: string };
if (!body.address || typeof body.address !== 'string') {
return reply.code(400).send({
error: 'Invalid request',
message: 'Address is required',
});
}
// Validate address format (permissive: requires number + space + text)
const addressPattern = /^\d+[\s,].+$/i;
if (!addressPattern.test(body.address.trim())) {
return reply.code(400).send({
error: 'Invalid address',
message: 'Please enter a valid street address (e.g., 123 Main Street)',
});
}
const limitReached = await checkPropertyLimit(reply, subscriptionId);
if (!limitReached) return;
try {
// Check for duplicate
const existing = await prisma.watchlistItem.findFirst({
where: { subscriptionId, type: 'address', value: body.address.trim() },
});
if (existing) {
return reply.code(409).send({
error: 'Duplicate property',
message: 'This property is already being monitored',
});
}
const property = await prisma.watchlistItem.create({
data: {
subscriptionId,
type: 'address',
value: body.address.trim(),
},
});
// Trigger initial scan
try {
const { homeTitleScheduler } = await import('@shieldai/hometitle');
if (homeTitleScheduler.isRunning()) {
await homeTitleScheduler.runScan();
}
} catch (scanError) {
console.error('[HomeTitle] Initial scan error:', scanError);
}
return reply.code(201).send({
property: {
id: property.id,
address: property.value,
status: 'monitored',
addedAt: property.createdAt,
},
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to add property';
return reply.code(422).send({ error: message });
}
});
// DELETE /hometitle/properties/:id - Remove property from monitoring
fastify.delete('/properties/:id', async (request: FastifyRequest, reply: FastifyReply) => {
const subscriptionId = await requirePremiumTier(request, reply);
if (!subscriptionId) return;
const id = (request.params as { id: string }).id;
try {
const result = await prisma.watchlistItem.update({
where: { id, subscriptionId },
data: { isActive: false },
});
return reply.send({
property: {
id: result.id,
address: result.value,
status: 'removed',
},
});
} catch {
return reply.code(404).send({
error: 'Property not found',
message: 'The monitored property does not exist or is not owned by this user',
});
}
});
// GET /hometitle/changes - Recent property changes
fastify.get('/changes', async (request: FastifyRequest, reply: FastifyReply) => {
const subscriptionId = await requirePremiumTier(request, reply);
if (!subscriptionId) return;
const query = request.query as { limit?: number };
const limit = Math.min(query.limit ?? 20, 50);
try {
const watchlistItemIds = (
await prisma.watchlistItem.findMany({
where: { subscriptionId, type: 'address', isActive: true },
select: { id: true },
})
).map((item) => item.id);
const changes = await prisma.alert.findMany({
where: {
subscriptionId,
watchlistItemId: { in: watchlistItemIds },
},
orderBy: { createdAt: 'desc' },
take: limit,
include: {
watchlistItem: true,
},
});
const formattedChanges = changes.map((alert) => ({
id: alert.id,
propertyAddress: alert.watchlistItem?.value ?? 'Unknown',
type: 'property_change',
severity: alert.severity,
title: alert.title,
message: alert.message,
isRead: alert.isRead,
createdAt: alert.createdAt,
}));
return reply.send({ changes: formattedChanges });
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to fetch changes';
return reply.code(500).send({ error: message });
}
});
// GET /hometitle/alerts - Recent property alerts
fastify.get('/alerts', async (request: FastifyRequest, reply: FastifyReply) => {
const subscriptionId = await requirePremiumTier(request, reply);
if (!subscriptionId) return;
const query = request.query as { limit?: number; severity?: string };
const limit = Math.min(query.limit ?? 20, 50);
try {
const watchlistItemIds = (
await prisma.watchlistItem.findMany({
where: { subscriptionId, type: 'address', isActive: true },
select: { id: true },
})
).map((item) => item.id);
const whereClause: Record<string, unknown> = {
subscriptionId,
watchlistItemId: { in: watchlistItemIds },
};
if (query.severity) {
whereClause.severity = query.severity;
}
const alerts = await prisma.alert.findMany({
where: whereClause,
orderBy: { createdAt: 'desc' },
take: limit,
include: {
watchlistItem: true,
},
});
const formattedAlerts = alerts.map((alert) => ({
id: alert.id,
propertyAddress: alert.watchlistItem?.value ?? 'Unknown',
severity: alert.severity,
title: alert.title,
message: alert.message,
isRead: alert.isRead,
channel: alert.channel,
createdAt: alert.createdAt,
}));
return reply.send({ alerts: formattedAlerts });
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to fetch alerts';
return reply.code(500).send({ error: message });
}
});
// PATCH /hometitle/alerts/:id/read - Mark alert as read
fastify.patch('/alerts/:id/read', async (request: FastifyRequest, reply: FastifyReply) => {
const subscriptionId = await requirePremiumTier(request, reply);
if (!subscriptionId) return;
const id = (request.params as { id: string }).id;
try {
const alert = await prisma.alert.update({
where: { id, subscriptionId },
data: { isRead: true, readAt: new Date() },
});
return reply.send({ alert: { id: alert.id, isRead: alert.isRead } });
} catch {
return reply.code(404).send({ error: 'Alert not found' });
}
});
// POST /hometitle/scan - Trigger on-demand scan for all monitored properties
fastify.post('/scan', async (request: FastifyRequest, reply: FastifyReply) => {
const subscriptionId = await requirePremiumTier(request, reply);
if (!subscriptionId) return;
try {
const { homeTitleScheduler } = await import('@shieldai/hometitle');
const result = await homeTitleScheduler.runScan();
return reply.send({
scan: {
scanId: result.scanId,
propertiesScanned: result.propertiesScanned,
changesDetected: result.changesDetected,
alertsCreated: result.alertsCreated,
notificationsSent: result.notificationsSent,
startedAt: result.startedAt,
completedAt: result.completedAt,
},
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Scan failed';
return reply.code(500).send({ error: message });
}
});
}

View File

@@ -1,194 +0,0 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import { authMiddleware, AuthRequest } from './auth.middleware';
import { voiceprintRoutes } from './voiceprint.routes';
import { spamshieldRoutes } from './spamshield.routes';
import { darkwatchRoutes } from './darkwatch.routes';
import { reportRoutes } from './report.routes';
import { subscriptionRoutes } from './subscription.routes';
import { deviceRoutes } from './device.routes';
import { notificationRoutes } from './notifications.routes';
import { hometitleRoutes } from './hometitle.routes';
import { removebrokersRoutes } from './removebrokers.routes';
export async function routes(fastify: FastifyInstance) {
// Authenticated routes group
fastify.register(
async (authenticated) => {
// Add auth requirement
authenticated.addHook('onRequest', async (request: FastifyRequest, reply: FastifyReply) => {
await fastify.requireAuth(request as AuthRequest);
});
// Example authenticated endpoint
authenticated.get('/user/me', async (request: FastifyRequest, reply: FastifyReply) => {
const authReq = request as AuthRequest;
return {
user: authReq.user,
authType: authReq.authType,
};
});
// Example service endpoint
authenticated.get('/services', async (request: FastifyRequest, reply: FastifyReply) => {
return {
services: [
{
name: 'user-service',
url: '/api/v1/services/user',
status: 'healthy',
},
{
name: 'billing-service',
url: '/api/v1/services/billing',
status: 'healthy',
},
{
name: 'notification-service',
url: '/api/v1/services/notifications',
status: 'healthy',
},
],
};
});
},
{ prefix: '/auth' }
);
// Public API routes
fastify.register(
async (publicRouter) => {
// Version info
publicRouter.get('/info', async () => {
return {
version: '1.0.0',
environment: process.env.NODE_ENV || 'development',
build: process.env.npm_package_version || 'unknown',
};
});
// API documentation
publicRouter.get('/docs', async () => {
return {
title: 'FrenoCorp API Gateway',
version: '1.0.0',
endpoints: {
public: [
{ method: 'GET', path: '/', description: 'Root endpoint' },
{ method: 'GET', path: '/health', description: 'Health check' },
{ method: 'GET', path: '/api/v1/info', description: 'API version info' },
{ method: 'GET', path: '/api/v1/docs', description: 'API documentation' },
],
authenticated: [
{ method: 'GET', path: '/api/v1/auth/user/me', description: 'Get current user' },
{ method: 'GET', path: '/api/v1/auth/services', description: 'List available services' },
],
},
};
});
},
{ prefix: '/api/v1' }
);
// Service proxy placeholder (for future microservice routing)
fastify.register(
async (services) => {
services.get('/services/user', async (request, reply) => {
// In production, proxy to actual user service
return {
service: 'user-service',
message: 'User service endpoint',
timestamp: new Date().toISOString(),
};
});
services.get('/services/billing', async (request, reply) => {
// In production, proxy to actual billing service
return {
service: 'billing-service',
message: 'Billing service endpoint',
timestamp: new Date().toISOString(),
};
});
services.get('/services/notifications', async (request, reply) => {
// In production, proxy to actual notification service
return {
service: 'notification-service',
message: 'Notification service endpoint',
timestamp: new Date().toISOString(),
};
});
},
{ prefix: '/api/v1/services' }
);
// VoicePrint service routes
fastify.register(
async (voiceprintRouter) => {
await voiceprintRoutes(voiceprintRouter);
},
{ prefix: '/voiceprint' }
);
// SpamShield service routes
fastify.register(
async (spamshieldRouter) => {
await spamshieldRoutes(spamshieldRouter);
},
{ prefix: '/spamshield' }
);
// DarkWatch service routes
fastify.register(
async (darkwatchRouter) => {
await darkwatchRoutes(darkwatchRouter);
},
{ prefix: '/darkwatch' }
);
// Report routes
fastify.register(
async (reportRouter) => {
await reportRoutes(reportRouter);
},
{ prefix: '/reports' }
);
// Subscription routes
fastify.register(
async (subscriptionRouter) => {
await subscriptionRoutes(subscriptionRouter);
},
{ prefix: '/billing' }
);
// Device routes
fastify.register(
async (deviceRouter) => {
await deviceRoutes(deviceRouter);
},
{ prefix: '/api/v1' }
);
// Home Title service routes
fastify.register(
async (hometitleRouter) => {
hometitleRouter.addHook('onRequest', async (request: FastifyRequest, reply: FastifyReply) => {
await fastify.requireAuth(request as AuthRequest);
});
await hometitleRoutes(hometitleRouter);
},
{ prefix: '/hometitle' }
);
// Info Broker Removal service routes
fastify.register(
async (removebrokersRouter) => {
removebrokersRouter.addHook('onRequest', async (request: FastifyRequest, reply: FastifyReply) => {
await fastify.requireAuth(request as AuthRequest);
});
await removebrokersRoutes(removebrokersRouter);
},
{ prefix: '/removebrokers' }
);
}

View File

@@ -1,213 +0,0 @@
import { FastifyInstance } from 'fastify';
import { NotificationService } from '@shieldsai/shared-notifications';
export async function notificationRoutes(fastify: FastifyInstance): Promise<void> {
let notificationService: NotificationService | undefined;
// Initialize notification service (will be injected via config)
fastify.addHook('onReady', async () => {
// Notification service will be initialized from config
notificationService = fastify.notificationService;
});
/**
* POST /api/v1/notifications/send
* Send a notification to a user
*/
fastify.post(
'/notifications/send',
{
schema: {
body: {
type: 'object',
required: ['userId', 'channel', 'subject', 'body'],
properties: {
userId: { type: 'string' },
channel: { type: 'string', enum: ['email', 'push', 'sms'] },
subject: { type: 'string' },
body: { type: 'string' },
email: { type: 'string' },
phone: { type: 'string' },
fcmToken: { type: 'string' },
apnsToken: { type: 'string' },
priority: { type: 'string', enum: ['low', 'normal', 'high', 'urgent'] },
metadata: { type: 'object' },
},
},
},
},
async (request, reply) => {
const { userId, channel, subject, body, priority, metadata } = request.body;
const recipient = {
userId,
email: request.body.email,
phone: request.body.phone,
fcmToken: request.body.fcmToken,
apnsToken: request.body.apnsToken,
};
try {
if (!notificationService) {
return reply.status(503).send({
success: false,
error: 'Notification service not initialized',
});
}
const notifications = await notificationService.sendMultiChannelNotification(
recipient,
channel,
subject,
body,
priority,
metadata
);
return reply.send({
success: true,
notifications,
});
} catch (error) {
return reply.status(500).send({
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
});
}
}
);
/**
* GET /api/v1/notifications/:userId/preferences
* Get notification preferences for a user
*/
fastify.get(
'/notifications/:userId/preferences',
{
schema: {
params: {
type: 'object',
required: ['userId'],
properties: {
userId: { type: 'string' },
},
},
},
},
async (request, reply) => {
const { userId } = request.params;
try {
if (!notificationService) {
return reply.status(503).send({
success: false,
error: 'Notification service not initialized',
});
}
const preferences = await notificationService.getNotificationPreferences(userId);
return reply.send({
success: true,
preferences,
});
} catch (error) {
return reply.status(500).send({
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
});
}
}
);
/**
* PUT /api/v1/notifications/:userId/preferences
* Update notification preferences for a user
*/
fastify.put(
'/notifications/:userId/preferences',
{
schema: {
params: {
type: 'object',
required: ['userId'],
properties: {
userId: { type: 'string' },
},
},
body: {
type: 'object',
properties: {
email: {
type: 'object',
properties: {
enabled: { type: 'boolean' },
categories: { type: 'array', items: { type: 'string' } },
},
},
push: {
type: 'object',
properties: {
enabled: { type: 'boolean' },
categories: { type: 'array', items: { type: 'string' } },
},
},
sms: {
type: 'object',
properties: {
enabled: { type: 'boolean' },
categories: { type: 'array', items: { type: 'string' } },
},
},
},
},
},
},
async (request, reply) => {
const { userId } = request.params;
const updates = request.body;
try {
// TODO: Update preferences in database
return reply.send({
success: true,
message: 'Preferences updated',
userId,
updates,
});
} catch (error) {
return reply.status(500).send({
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
});
}
}
);
/**
* GET /api/v1/notifications/config
* Get notification configuration status
*/
fastify.get('/notifications/config', async (request, reply) => {
try {
if (!notificationService) {
return reply.status(503).send({
success: false,
error: 'Notification service not initialized',
});
}
const config = notificationService.getConfigSummary();
return reply.send({
success: true,
config,
});
} catch (error) {
return reply.status(500).send({
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
});
}
});
}

View File

@@ -1,383 +0,0 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import { prisma } from '@shieldai/db';
import { RemovalStatus, Severity, AlertCategory, EntityTypes } from '@shieldai/types';
import type { RemovalStatus as PrismaRemovalStatus } from '@shieldai/db';
import {
removeBrokersService,
removeBrokersScheduler,
brokerAlertPipeline,
type PersonalInfo,
} from '@shieldai/removebrokers';
import { AuthRequest } from '../middleware/auth.middleware';
const REMOVAL_REQUEST_LIMITS: Record<string, number> = {
basic: 5,
plus: 20,
premium: 999,
};
async function getSubscription(
request: FastifyRequest,
reply: FastifyReply,
): Promise<{ subscriptionId: string; tier: string } | null> {
const authReq = request as AuthRequest;
const userId = authReq.user?.id;
if (!userId) {
await reply.code(401).send({ error: 'User not authenticated' });
return null;
}
const subscription = await prisma.subscription.findFirst({
where: { userId, status: 'active' },
select: { id: true, tier: true },
});
if (!subscription) {
await reply.code(402).send({
error: 'Subscription required',
message: 'An active subscription is required for data broker removal',
});
return null;
}
return { subscriptionId: subscription.id, tier: subscription.tier };
}
export async function removebrokersRoutes(fastify: FastifyInstance) {
// GET /removebrokers/brokers - List available data brokers
fastify.get('/brokers', async (request: FastifyRequest, reply: FastifyReply) => {
const authReq = request as AuthRequest;
if (!authReq.user?.id) {
return reply.code(401).send({ error: 'User not authenticated' });
}
const query = request.query as { category?: string };
let brokers = await removeBrokersService.getAvailableBrokers();
if (query.category) {
brokers = brokers.filter((b) => b.category === query.category);
}
return reply.send({
brokers: brokers.map((b) => ({
id: b.id,
name: b.name,
domain: b.domain,
category: b.category,
removalMethod: b.removalMethod,
requiresAccount: b.requiresAccount,
requiresVerification: b.requiresVerification,
estimatedDays: b.estimatedDays,
removalUrl: b.removalUrl,
})),
});
});
// GET /removebrokers/status - Get removal request status for user
fastify.get('/status', async (request: FastifyRequest, reply: FastifyReply) => {
const sub = await getSubscription(request, reply);
if (!sub) return;
try {
const status = await removeBrokersService.getRemovalStatus(sub.subscriptionId);
const total = status.length;
const pending = status.filter((s) => s.status === RemovalStatus.PENDING).length;
const submitted = status.filter((s) => s.status === RemovalStatus.SUBMITTED).length;
const completed = status.filter((s) => s.status === RemovalStatus.COMPLETED).length;
const failed = status.filter((s) => s.status === RemovalStatus.FAILED).length;
const limit = REMOVAL_REQUEST_LIMITS[sub.tier] ?? 5;
const remaining = Math.max(0, limit - total);
return reply.send({
stats: { total, pending, submitted, completed, failed },
limit,
remaining,
tier: sub.tier,
requests: status,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to fetch status';
return reply.code(500).send({ error: message });
}
});
// POST /removebrokers/scan - Scan for personal listings across brokers
fastify.post('/scan', async (request: FastifyRequest, reply: FastifyReply) => {
const sub = await getSubscription(request, reply);
if (!sub) return;
const body = request.body as { fullName?: string; email?: string; phone?: string; address?: string };
if (!body.fullName) {
return reply.code(400).send({
error: 'Invalid request',
message: 'fullName is required for scanning',
});
}
try {
const user = await prisma.user.findUnique({
where: { id: (request as AuthRequest).user!.id },
});
const personalInfo: PersonalInfo = {
fullName: body.fullName,
email: body.email || user?.email,
phone: body.phone,
address: body.address
? { street: body.address }
: undefined,
};
const results = await removeBrokersService.scanForListings(
sub.subscriptionId,
personalInfo,
);
const found = results.filter((r) => r.found);
for (const listing of found) {
try {
await brokerAlertPipeline.sendListingFoundAlert({
userId: (request as AuthRequest).user!.id,
brokerName: listing.brokerName,
brokerId: listing.brokerId,
category: AlertCategory.INFO_BROKER_LISTING,
severity: Severity.MEDIUM,
title: `Personal listing found on ${listing.brokerName}`,
description: `Your personal information was found on ${listing.brokerName} (${listing.brokerId}). Consider submitting a removal request.`,
entities: [
{ type: EntityTypes.USER_ID, value: (request as AuthRequest).user!.id },
],
metadata: { url: listing.url },
});
} catch {
// Alert failure is non-critical
}
}
return reply.send({
brokersScanned: results.length,
listingsFound: found.length,
results,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Scan failed';
return reply.code(500).send({ error: message });
}
});
// POST /removebrokers/request - Create a new removal request
fastify.post('/request', async (request: FastifyRequest, reply: FastifyReply) => {
const sub = await getSubscription(request, reply);
if (!sub) return;
const body = request.body as {
brokerId: string;
fullName: string;
email?: string;
phone?: string;
address?: {
street?: string;
city?: string;
state?: string;
zip?: string;
};
dob?: string;
notes?: string;
};
if (!body.brokerId) {
return reply.code(400).send({
error: 'Invalid request',
message: 'brokerId is required',
});
}
if (!body.fullName) {
return reply.code(400).send({
error: 'Invalid request',
message: 'fullName is required',
});
}
const limit = REMOVAL_REQUEST_LIMITS[sub.tier] ?? 5;
const currentCount = await prisma.removalRequest.count({
where: { subscriptionId: sub.subscriptionId },
});
if (currentCount >= limit) {
return reply.code(400).send({
error: 'Request limit reached',
message: `You have reached the maximum of ${limit} removal requests for your ${sub.tier} tier.`,
currentCount,
limit,
upgradeTo: 'plus',
});
}
try {
const personalInfo: PersonalInfo = {
fullName: body.fullName,
email: body.email,
phone: body.phone,
address: body.address,
dob: body.dob,
};
const req = await removeBrokersService.createRemovalRequest(
sub.subscriptionId,
body.brokerId,
personalInfo,
body.notes,
);
return reply.code(201).send({
request: {
id: req.id,
brokerId: req.brokerId,
status: req.status,
method: req.method,
createdAt: req.createdAt,
},
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to create removal request';
return reply.code(422).send({ error: message });
}
});
// GET /removebrokers/request/:id - Get specific removal request
fastify.get('/request/:id', async (request: FastifyRequest, reply: FastifyReply) => {
const sub = await getSubscription(request, reply);
if (!sub) return;
const id = (request.params as { id: string }).id;
try {
const req = await prisma.removalRequest.findFirst({
where: { id, subscriptionId: sub.subscriptionId },
include: { broker: true },
});
if (!req) {
return reply.code(404).send({ error: 'Removal request not found' });
}
return reply.send({
request: {
id: req.id,
brokerId: req.brokerId,
brokerName: req.broker?.name || null,
status: req.status,
method: req.method,
attempts: req.attempts,
submittedAt: req.submittedAt,
completedAt: req.completedAt,
error: req.error,
notes: req.notes,
createdAt: req.createdAt,
updatedAt: req.updatedAt,
},
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to fetch request';
return reply.code(500).send({ error: message });
}
});
// DELETE /removebrokers/request/:id - Cancel a removal request
fastify.delete('/request/:id', async (request: FastifyRequest, reply: FastifyReply) => {
const sub = await getSubscription(request, reply);
if (!sub) return;
const id = (request.params as { id: string }).id;
try {
const req = await prisma.removalRequest.findFirst({
where: { id, subscriptionId: sub.subscriptionId },
});
if (!req) {
return reply.code(404).send({ error: 'Removal request not found' });
}
if (req.status === RemovalStatus.COMPLETED) {
return reply.code(400).send({
error: 'Cannot cancel',
message: 'Cannot cancel a completed removal request',
});
}
await prisma.removalRequest.update({
where: { id },
data: { status: RemovalStatus.CANCELLED as PrismaRemovalStatus },
});
return reply.send({
request: {
id: req.id,
status: RemovalStatus.CANCELLED,
},
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to cancel request';
return reply.code(500).send({ error: message });
}
});
// POST /removebrokers/process - Trigger processing of pending removals (admin)
fastify.post('/process', async (request: FastifyRequest, reply: FastifyReply) => {
const authReq = request as AuthRequest;
if (!authReq.user?.id) {
return reply.code(401).send({ error: 'User not authenticated' });
}
if (authReq.user.role !== 'support') {
return reply.code(403).send({ error: 'Support access required' });
}
try {
const results = await removeBrokersService.processPendingRequests();
return reply.send({
processed: results.length,
results,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Processing failed';
return reply.code(500).send({ error: message });
}
});
// POST /removebrokers/verify/:id - Manually verify a removal
fastify.post('/verify/:id', async (request: FastifyRequest, reply: FastifyReply) => {
const sub = await getSubscription(request, reply);
if (!sub) return;
const id = (request.params as { id: string }).id;
try {
const req = await prisma.removalRequest.findFirst({
where: { id, subscriptionId: sub.subscriptionId },
});
if (!req) {
return reply.code(404).send({ error: 'Removal request not found' });
}
const result = await removeBrokersService.verifyRemoval(id);
return reply.send({
requestId: id,
...result,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Verification failed';
return reply.code(500).send({ error: message });
}
});
}

View File

@@ -1,177 +0,0 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import { reportService } from '@shieldai/report';
import { prisma } from '@shieldai/db';
import { ReportType, ReportStatus, ReportDataPayload } from '@shieldai/types';
interface AuthRequest extends FastifyRequest {
user?: {
id: string;
email?: string;
role?: string;
};
}
export async function reportRoutes(fastify: FastifyInstance) {
// Generate a new report
fastify.post('/generate', async (request: FastifyRequest, reply: FastifyReply) => {
const authReq = request as AuthRequest;
const userId = authReq.user?.id;
if (!userId) {
return reply.code(401).send({ error: 'User ID required' });
}
const body = request.body as {
reportType?: ReportType;
periodStart?: string;
periodEnd?: string;
};
const subscription = await prisma.subscription.findFirst({
where: { userId, status: 'active' },
select: { id: true, tier: true },
});
if (!subscription) {
return reply.code(404).send({ error: 'Active subscription not found' });
}
const reportType = body.reportType || (subscription.tier === 'premium' ? 'ANNUAL_PREMIUM' : 'MONTHLY_PLUS');
const periodStart = body.periodStart ? new Date(body.periodStart) : undefined;
const periodEnd = body.periodEnd ? new Date(body.periodEnd) : undefined;
const report = await reportService.generateReport({
userId,
subscriptionId: subscription.id,
reportType,
periodStart,
periodEnd,
});
return reply.code(201).send(report);
});
// Get report history
fastify.get('/', async (request: FastifyRequest, reply: FastifyReply) => {
const authReq = request as AuthRequest;
const userId = authReq.user?.id;
if (!userId) {
return reply.code(401).send({ error: 'User ID required' });
}
const query = request.query as Record<string, string>;
const limit = parseInt(query.limit || '20', 10);
const offset = parseInt(query.offset || '0', 10);
const reports = await reportService.getReportHistory(userId, limit, offset);
return reply.code(200).send({ reports, count: reports.length });
});
// Get specific report
fastify.get('/:reportId', async (request: FastifyRequest, reply: FastifyReply) => {
const authReq = request as AuthRequest;
const userId = authReq.user?.id;
if (!userId) {
return reply.code(401).send({ error: 'User ID required' });
}
const reportId = (request.params as { reportId: string }).reportId;
try {
const report = await reportService.getReportById(userId, reportId);
return reply.code(200).send(report);
} catch (error) {
return reply.code(404).send({ error: error instanceof Error ? error.message : 'Report not found' });
}
});
// Get report HTML content
fastify.get('/:reportId/html', async (request: FastifyRequest, reply: FastifyReply) => {
const authReq = request as AuthRequest;
const userId = authReq.user?.id;
if (!userId) {
return reply.code(401).send({ error: 'User ID required' });
}
const reportId = (request.params as { reportId: string }).reportId;
const report = await prisma.securityReport.findFirst({
where: { id: reportId, userId },
select: { htmlContent: true, status: true },
});
if (!report) {
return reply.code(404).send({ error: 'Report not found' });
}
if (report.status !== 'COMPLETED') {
return reply.code(404).send({ error: 'Report not yet completed' });
}
reply.header('Content-Type', 'text/html');
return reply.code(200).send(report.htmlContent || '');
});
// Get report PDF
fastify.get('/:reportId/pdf', async (request: FastifyRequest, reply: FastifyReply) => {
const authReq = request as AuthRequest;
const userId = authReq.user?.id;
if (!userId) {
return reply.code(401).send({ error: 'User ID required' });
}
const reportId = (request.params as { reportId: string }).reportId;
const report = await prisma.securityReport.findFirst({
where: { id: reportId, userId },
select: { dataPayload: true, title: true, status: true, htmlContent: true },
});
if (!report) {
return reply.code(404).send({ error: 'Report not found' });
}
if (report.status !== 'COMPLETED') {
return reply.code(404).send({ error: 'Report not yet completed' });
}
const { pdfGenerator } = await import('@shieldai/report');
const pdfData = report.dataPayload
? (typeof report.dataPayload === 'string' ? JSON.parse(report.dataPayload) : report.dataPayload as unknown as ReportDataPayload)
: {
exposureSummary: { totalExposures: 0, newExposures: 0, resolvedExposures: 0, criticalExposures: 0, warningExposures: 0, infoExposures: 0, exposuresBySource: {} },
spamStats: { callsBlocked: 0, textsBlocked: 0, callsFlagged: 0, textsFlagged: 0, falsePositives: 0, totalSpamEvents: 0 },
voiceStats: { analysesRun: 0, threatsDetected: 0, enrollmentsActive: 0, syntheticDetections: 0, voiceMismatchEvents: 0 },
recommendations: [],
protectionScore: 0,
};
const pdfBuffer = await pdfGenerator.generate({
reportTitle: report.title,
periodStart: '',
periodEnd: '',
generatedAt: new Date().toISOString(),
data: pdfData,
reportId,
});
reply.header('Content-Type', 'application/pdf');
reply.header('Content-Disposition', `inline; filename="${report.title}.pdf"`);
return reply.code(200).send(pdfBuffer);
});
// Schedule pending reports (admin/scheduler endpoint)
fastify.post('/schedule/monthly', async (request: FastifyRequest, reply: FastifyReply) => {
const createdIds = await reportService.scheduleMonthlyReports();
return reply.code(200).send({ scheduled: createdIds.length, reportIds: createdIds });
});
fastify.post('/schedule/annual', async (request: FastifyRequest, reply: FastifyReply) => {
const createdIds = await reportService.scheduleAnnualReports();
return reply.code(200).send({ scheduled: createdIds.length, reportIds: createdIds });
});
fastify.post('/schedule/weekly-digest', async (request: FastifyRequest, reply: FastifyReply) => {
const createdIds = await reportService.scheduleWeeklyDigest();
return reply.code(200).send({ scheduled: createdIds.length, reportIds: createdIds });
});
}

View File

@@ -1,32 +0,0 @@
import { FastifyInstance } from "fastify";
import { ScanService } from "@shieldai/darkwatch";
import { DataSource } from "@shieldai/types";
export function scanRoutes(fastify: FastifyInstance) {
const scanService = new ScanService();
fastify.post("/", async (request, reply) => {
const userId = (request.user as { id: string })?.id;
if (!userId) {
return reply.code(401).send({ error: "User not authenticated" });
}
const body = request.body as { source?: string };
const source = body.source ? (body.source as DataSource) : undefined;
const resultCount = await scanService.runScan(userId, source);
return reply.code(200).send({ scanned: true, resultCount });
});
fastify.get("/history", async (request, reply) => {
const userId = (request.user as { id: string })?.id;
if (!userId) {
return reply.code(401).send({ error: "User not authenticated" });
}
const history = await scanService.getScanHistory(userId);
return reply.send(history);
});
}

View File

@@ -1,75 +0,0 @@
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 params = request.params as { userId: string };
const authedUser = (request.user as { id: string })?.id;
if (authedUser !== params.userId) {
return reply.code(403).send({ error: "Forbidden" });
}
const schedule = await scheduler.getSchedule(params.userId);
if (!schedule) {
return reply.code(404).send({ error: "Schedule not found" });
}
return reply.send(schedule);
}
);
fastify.post(
"/:userId/pause",
async (request, reply) => {
const params = request.params as { userId: string };
const authedUser = (request.user as { id: string })?.id;
if (authedUser !== params.userId) {
return reply.code(403).send({ error: "Forbidden" });
}
await scheduler.pauseSchedule(params.userId);
return reply.send({ paused: true });
}
);
fastify.post(
"/:userId/resume",
async (request, reply) => {
const params = request.params as { userId: string };
const authedUser = (request.user as { id: string })?.id;
if (authedUser !== params.userId) {
return reply.code(403).send({ error: "Forbidden" });
}
await scheduler.resumeSchedule(params.userId);
return reply.send({ resumed: true });
}
);
fastify.get(
"/",
async (request, reply) => {
const limit = parseInt((request.query as { limit?: string }).limit || "100");
const offset = parseInt((request.query as { offset?: string }).offset || "0");
const schedules = await scheduler.listActiveSchedules(limit, offset);
return reply.send(schedules);
}
);
}

View File

@@ -1,252 +0,0 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import {
numberReputationService,
smsClassifierService,
callAnalysisService,
spamFeedbackService,
} from '../services/spamshield';
import { ErrorHandler, SpamErrorCode } from '../services/spamshield/spamshield.error-handler';
export async function spamshieldRoutes(fastify: FastifyInstance) {
// Classify SMS text
fastify.post('/sms/classify', async (request: FastifyRequest, reply: FastifyReply) => {
const authReq = request as FastifyRequest & { user?: { id: string } };
const userId = authReq.user?.id;
if (!userId) {
ErrorHandler.send(reply, SpamErrorCode.UNAUTHORIZED, 'User ID required', { status: 401 });
return;
}
const body = request.body as { text: string };
const textValidation = ErrorHandler.validateRequiredField(body.text, 'text');
if (!textValidation.isValid && textValidation.error) {
ErrorHandler.send(reply, textValidation.error.code, textValidation.error.message, {
field: textValidation.error.field,
status: 400,
});
return;
}
try {
const result = await smsClassifierService.classify(body.text);
return reply.send({
classification: {
isSpam: result.isSpam,
confidence: result.confidence,
spamFeatures: result.spamFeatures,
},
});
} catch (error) {
ErrorHandler.send(reply, SpamErrorCode.CLASSIFICATION_FAILED, 'Classification failed', {
status: 422,
});
}
});
// Check number reputation
fastify.post('/number/reputation', async (request: FastifyRequest, reply: FastifyReply) => {
const authReq = request as FastifyRequest & { user?: { id: string } };
const userId = authReq.user?.id;
if (!userId) {
ErrorHandler.send(reply, SpamErrorCode.UNAUTHORIZED, 'User ID required', { status: 401 });
return;
}
const body = request.body as { phoneNumber: string };
const phoneValidation = ErrorHandler.validateRequiredField(body.phoneNumber, 'phoneNumber');
if (!phoneValidation.isValid && phoneValidation.error) {
ErrorHandler.send(reply, phoneValidation.error.code, phoneValidation.error.message, {
field: phoneValidation.error.field,
status: 400,
});
return;
}
try {
const result = await numberReputationService.checkReputation(body.phoneNumber);
return reply.send({
reputation: {
isSpam: result.isSpam,
confidence: result.confidence,
spamType: result.spamType,
reportCount: result.reportCount,
},
});
} catch (error) {
ErrorHandler.send(reply, SpamErrorCode.REPUTATION_CHECK_FAILED, 'Reputation check failed', {
status: 422,
});
}
});
// Analyze incoming call
fastify.post('/call/analyze', async (request: FastifyRequest, reply: FastifyReply) => {
const authReq = request as FastifyRequest & { user?: { id: string } };
const userId = authReq.user?.id;
if (!userId) {
ErrorHandler.send(reply, SpamErrorCode.UNAUTHORIZED, 'User ID required', { status: 401 });
return;
}
const body = request.body as {
phoneNumber: string;
duration?: number;
callTime: string;
isVoip?: boolean;
};
const phoneValidation = ErrorHandler.validateRequiredField(body.phoneNumber, 'phoneNumber');
const callTimeValidation = ErrorHandler.validateRequiredField(body.callTime, 'callTime');
if (!phoneValidation.isValid && phoneValidation.error) {
ErrorHandler.send(reply, phoneValidation.error.code, phoneValidation.error.message, {
field: phoneValidation.error.field,
status: 400,
});
return;
}
if (!callTimeValidation.isValid && callTimeValidation.error) {
ErrorHandler.send(reply, callTimeValidation.error.code, callTimeValidation.error.message, {
field: callTimeValidation.error.field,
status: 400,
});
return;
}
try {
const result = await callAnalysisService.analyzeCall({
phoneNumber: body.phoneNumber,
duration: body.duration,
callTime: new Date(body.callTime),
isVoip: body.isVoip,
});
return reply.send({
analysis: {
decision: result.decision,
confidence: result.confidence,
reasons: result.reasons,
},
});
} catch (error) {
ErrorHandler.send(reply, SpamErrorCode.ANALYSIS_FAILED, 'Call analysis failed', {
status: 422,
});
}
});
// Record spam feedback
fastify.post('/feedback', async (request: FastifyRequest, reply: FastifyReply) => {
const authReq = request as FastifyRequest & { user?: { id: string } };
const userId = authReq.user?.id;
if (!userId) {
ErrorHandler.send(reply, SpamErrorCode.UNAUTHORIZED, 'User ID required', { status: 401 });
return;
}
const body = request.body as {
phoneNumber: string;
isSpam: boolean;
confidence?: number;
metadata?: Record<string, unknown>;
};
const phoneValidation = ErrorHandler.validateRequiredField(body.phoneNumber, 'phoneNumber');
if (!phoneValidation.isValid && phoneValidation.error) {
ErrorHandler.send(reply, phoneValidation.error.code, phoneValidation.error.message, {
field: phoneValidation.error.field,
status: 400,
});
return;
}
const isSpamValidation = ErrorHandler.validateBooleanField(body.isSpam, 'isSpam');
if (!isSpamValidation.isValid && isSpamValidation.error) {
ErrorHandler.send(reply, isSpamValidation.error.code, isSpamValidation.error.message, {
field: isSpamValidation.error.field,
status: 400,
});
return;
}
try {
const feedback = await spamFeedbackService.recordFeedback(
userId,
body.phoneNumber,
body.isSpam,
body.confidence,
body.metadata
);
return reply.code(201).send({
feedback: {
id: feedback.id,
phoneNumber: feedback.phoneNumber,
isSpam: feedback.isSpam,
createdAt: feedback.createdAt,
},
});
} catch (error) {
ErrorHandler.send(reply, SpamErrorCode.FEEDBACK_RECORD_FAILED, 'Feedback recording failed', {
status: 422,
});
}
});
// Get spam history
fastify.get('/history', async (request: FastifyRequest, reply: FastifyReply) => {
const authReq = request as FastifyRequest & { user?: { id: string } };
const userId = authReq.user?.id;
if (!userId) {
ErrorHandler.send(reply, SpamErrorCode.UNAUTHORIZED, 'User ID required', { status: 401 });
return;
}
const query = request.query as {
limit?: string;
isSpam?: string;
startDate?: string;
};
const results = await spamFeedbackService.getSpamHistory(userId, {
limit: query.limit ? parseInt(query.limit, 10) : undefined,
isSpam: query.isSpam !== undefined ? query.isSpam === 'true' : undefined,
startDate: query.startDate ? new Date(query.startDate) : undefined,
});
return reply.send({
history: results.map((r) => ({
id: r.id,
phoneNumber: r.phoneNumber,
isSpam: r.isSpam,
createdAt: r.createdAt,
})),
});
});
// Get spam statistics
fastify.get('/statistics', async (request: FastifyRequest, reply: FastifyReply) => {
const authReq = request as FastifyRequest & { user?: { id: string } };
const userId = authReq.user?.id;
if (!userId) {
ErrorHandler.send(reply, SpamErrorCode.UNAUTHORIZED, 'User ID required', { status: 401 });
return;
}
try {
const stats = await spamFeedbackService.getStatistics(userId);
return reply.send({ statistics: stats });
} catch (error) {
ErrorHandler.send(reply, SpamErrorCode.ANALYSIS_FAILED, 'Statistics retrieval failed', {
status: 422,
});
}
});
}

View File

@@ -1,425 +0,0 @@
import { FastifyInstance } from 'fastify';
import { BillingService } from '@shieldai/shared-billing/src/services/billing.service';
import { SubscriptionService, customerService, webhookService } from '@shieldai/shared-billing/src/services/billing.services';
import { SubscriptionTier, isValidReturnUrl } from '@shieldai/shared-billing/src/config/billing.config';
import { AuthRequest } from './auth.middleware';
const billingService = BillingService.getInstance();
const subscriptionService = new SubscriptionService();
export async function subscriptionRoutes(fastify: FastifyInstance) {
// Get current user's subscription
fastify.get(
'/subscription',
{
preHandler: async (request, reply) => {
await fastify.requireAuth(request as AuthRequest);
},
},
async (request, reply) => {
const authReq = request as AuthRequest;
try {
const subscription = await billingService.getUserSubscription(authReq.user!.id);
if (!subscription) {
return reply.status(404).send({
error: 'No active subscription found',
message: 'Please create a subscription to access premium features',
});
}
return {
subscription: {
id: subscription.id,
status: subscription.status,
currentPeriodStart: new Date(subscription.current_period_start * 1000).toISOString(),
currentPeriodEnd: new Date(subscription.current_period_end * 1000).toISOString(),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
created: new Date(subscription.created * 1000).toISOString(),
},
customer: {
id: subscription.customer as string,
},
};
} catch (error) {
reply.status(500).send({
error: 'Failed to fetch subscription',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
}
);
// Create a new subscription (for mobile app in-app purchases)
fastify.post(
'/subscription/create',
{
preHandler: async (request, reply) => {
await fastify.requireAuth(request as AuthRequest);
},
},
async (request, reply) => {
const authReq = request as AuthRequest;
const { tier, customerId } = request.body as { tier: SubscriptionTier; customerId: string };
if (!authReq.user?.id) {
return reply.status(401).send({ error: 'Authentication required' });
}
if (!tier || !customerId) {
return reply.status(400).send({
error: 'Missing required fields',
required: ['tier', 'customerId'],
});
}
try {
const result = await billingService.createSubscription(
authReq.user.id,
tier,
customerId
);
return {
subscription: {
id: result.subscription.id,
status: result.subscription.status,
currentPeriodStart: new Date(result.subscription.current_period_start * 1000).toISOString(),
currentPeriodEnd: new Date(result.subscription.current_period_end * 1000).toISOString(),
},
customer: {
id: result.customer.id,
email: result.customer.email,
},
};
} catch (error) {
reply.status(500).send({
error: 'Failed to create subscription',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
}
);
// Update subscription tier
fastify.put(
'/subscription/:subscriptionId/tier',
{
preHandler: async (request, reply) => {
await fastify.requireAuth(request as AuthRequest);
},
},
async (request, reply) => {
const authReq = request as AuthRequest;
const { subscriptionId } = request.params as { subscriptionId: string };
const { tier } = request.body as { tier: SubscriptionTier };
if (!authReq.user?.id) {
return reply.status(401).send({ error: 'Authentication required' });
}
if (!tier) {
return reply.status(400).send({
error: 'Missing required field',
required: ['tier'],
});
}
try {
const updated = await billingService.updateSubscription(
subscriptionId,
authReq.user.id,
tier
);
return {
subscription: {
id: updated.id,
status: updated.status,
currentPeriodStart: new Date(updated.current_period_start * 1000).toISOString(),
currentPeriodEnd: new Date(updated.current_period_end * 1000).toISOString(),
},
message: 'Subscription updated successfully',
};
} catch (error) {
reply.status(500).send({
error: 'Failed to update subscription',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
}
);
// Cancel subscription
fastify.delete(
'/subscription/:subscriptionId',
{
preHandler: async (request, reply) => {
await fastify.requireAuth(request as AuthRequest);
},
},
async (request, reply) => {
const authReq = request as AuthRequest;
const { subscriptionId } = request.params as { subscriptionId: string };
const { cancelAtPeriodEnd } = request.body as { cancelAtPeriodEnd?: boolean };
if (!authReq.user?.id) {
return reply.status(401).send({ error: 'Authentication required' });
}
try {
const cancelled = await billingService.cancelSubscription(
subscriptionId,
authReq.user.id,
cancelAtPeriodEnd ?? true
);
return {
subscription: {
id: cancelled.id,
status: cancelled.status,
cancelAtPeriodEnd: cancelled.cancel_at_period_end,
canceledAt: cancelled.canceled_at ? new Date(cancelled.canceled_at * 1000).toISOString() : null,
},
message: cancelAtPeriodEnd
? 'Subscription will cancel at the end of the billing period'
: 'Subscription cancelled immediately',
};
} catch (error) {
reply.status(500).send({
error: 'Failed to cancel subscription',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
}
);
// Create customer portal session
fastify.post(
'/customer/portal',
{
preHandler: async (request, reply) => {
await fastify.requireAuth(request as AuthRequest);
},
},
async (request, reply) => {
const authReq = request as AuthRequest;
const { customerId, returnUrl } = request.body as { customerId: string; returnUrl: string };
if (!authReq.user?.id) {
return reply.status(401).send({ error: 'Authentication required' });
}
if (!customerId || !returnUrl) {
return reply.status(400).send({
error: 'Missing required fields',
required: ['customerId', 'returnUrl'],
});
}
if (!isValidReturnUrl(returnUrl)) {
return reply.status(400).send({
error: 'Invalid return URL',
message: 'returnUrl must be from an allowed origin',
});
}
try {
const portalSession = await billingService.createCustomerPortalSession(
customerId,
returnUrl
);
return {
url: portalSession.url,
expiresAt: new Date(portalSession.expires_at * 1000).toISOString(),
};
} catch (error) {
reply.status(500).send({
error: 'Failed to create portal session',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
}
);
// Create customer
fastify.post(
'/customer',
{
preHandler: async (request, reply) => {
await fastify.requireAuth(request as AuthRequest);
},
},
async (request, reply) => {
const authReq = request as AuthRequest;
const { email, name } = request.body as { email: string; name?: string };
if (!authReq.user?.id) {
return reply.status(401).send({ error: 'Authentication required' });
}
if (!email) {
return reply.status(400).send({
error: 'Email is required',
});
}
try {
const customer = await billingService.createCustomer(email, authReq.user.id);
return {
customer: {
id: customer.id,
email: customer.email,
name: customer.name,
metadata: customer.metadata,
},
};
} catch (error) {
reply.status(500).send({
error: 'Failed to create customer',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
}
);
// Get user tier
fastify.get(
'/user/tier',
{
preHandler: async (request, reply) => {
await fastify.requireAuth(request as AuthRequest);
},
},
async (request, reply) => {
const authReq = request as AuthRequest;
if (!authReq.user?.id) {
return reply.status(401).send({ error: 'Authentication required' });
}
try {
const tier = await billingService.getUserTier(authReq.user.id);
if (!tier) {
return {
tier: 'free' as SubscriptionTier,
limits: await billingService.getTierLimits('free'),
};
}
const limits = await billingService.getTierLimits(tier);
return {
tier,
limits,
};
} catch (error) {
reply.status(500).send({
error: 'Failed to fetch user tier',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
}
);
// Get invoice history
fastify.get(
'/invoices',
{
preHandler: async (request, reply) => {
await fastify.requireAuth(request as AuthRequest);
},
},
async (request, reply) => {
const authReq = request as AuthRequest;
const { customerId } = request.query as { customerId?: string };
if (!authReq.user?.id) {
return reply.status(401).send({ error: 'Authentication required' });
}
if (!customerId) {
return reply.status(400).send({
error: 'customerId is required',
});
}
// Verify the customer belongs to the authenticated user (IDOR prevention)
try {
await billingService.verifyCustomerOwnership(customerId, authReq.user.id);
} catch {
return reply.status(403).send({
error: 'Forbidden',
message: 'You do not have access to this customer',
});
}
try {
const invoices = await billingService.getInvoiceHistory(customerId);
return {
invoices: invoices.data.map((invoice) => ({
id: invoice.id,
amountDue: invoice.amount_due,
amountPaid: invoice.amount_paid,
status: invoice.status,
created: new Date(invoice.created * 1000).toISOString(),
hostedInvoiceUrl: invoice.hosted_invoice_url,
})),
hasMore: invoices.has_more,
};
} catch (error) {
reply.status(500).send({
error: 'Failed to fetch invoice history',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
}
);
// Webhook handler (public endpoint)
fastify.post(
'/webhooks/stripe',
{
// Skip authentication for webhooks
preHandler: async (request, reply) => {
// Don't require auth for webhooks
},
},
async (request, reply) => {
const sig = request.headers['stripe-signature'] as string;
if (!sig) {
return reply.status(400).send({
error: 'Missing Stripe signature',
});
}
try {
const event = await billingService.handleWebhook(sig, request.rawBody as Buffer);
if (!event) {
return reply.status(200).send({
received: true,
message: 'Event already processed',
});
}
// Handle different event types using webhook service
await webhookService.handleWebhook(event);
return { received: true };
} catch (error) {
console.error('Webhook error:', error);
return reply.status(400).send({
error: 'Webhook handler failed',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
}
);
}

View File

@@ -1,300 +0,0 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import fastifyMultipart from '@fastify/multipart';
import {
voiceEnrollmentService,
analysisService,
batchAnalysisService,
voicePrintEnv,
} from '../services/voiceprint';
interface AuthenticatedRequest extends FastifyRequest {
user?: { id: string; email: string; role: string };
authType?: 'jwt' | 'api-key' | 'anonymous';
}
export async function voiceprintRoutes(fastify: FastifyInstance) {
// P1-2 fix: Require authentication on all VoicePrint routes
fastify.addHook('onRequest', async (request: FastifyRequest, reply: FastifyReply) => {
const authReq = request as AuthenticatedRequest;
if (authReq.authType === 'anonymous' || !authReq.user?.id || authReq.user.id === 'anonymous') {
return reply.code(401).send({ error: 'Authentication required' });
}
});
// P1-3 fix: Register multipart for audio file uploads
await fastify.register(fastifyMultipart, {
limits: {
fileSize: voicePrintEnv.ENROLLMENT_MAX_DURATION_SEC > 0
? 50 * 1024 * 1024 // 50MB max file size for audio
: 50 * 1024 * 1024,
},
});
// Enroll a new voice profile
fastify.post('/enroll', async (request: FastifyRequest, reply: FastifyReply) => {
const authReq = request as AuthenticatedRequest;
const userId = authReq.user?.id;
if (!userId) {
return reply.code(401).send({ error: 'User ID required' });
}
// P1-3 fix: Parse multipart form-data for audio upload
let name: string | undefined;
let audioBuffer: Buffer | undefined;
for await (const part of request.files()) {
if (part.type === 'file') {
audioBuffer = await part.toBuffer();
name = name || part.filename || 'voice_enrollment';
} else if (part.fieldname === 'name') {
name = part.value;
}
}
if (!audioBuffer || audioBuffer.length === 0) {
return reply.code(400).send({ error: 'audio file is required' });
}
try {
const enrollment = await voiceEnrollmentService.enroll(
userId,
name || 'voice_enrollment',
audioBuffer
);
return reply.code(201).send({
enrollment: {
id: enrollment.id,
name: enrollment.name,
isActive: enrollment.isActive,
createdAt: enrollment.createdAt,
},
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Enrollment failed';
return reply.code(422).send({ error: message });
}
});
// List user's voice enrollments
fastify.get('/enrollments', async (request: FastifyRequest, reply: FastifyReply) => {
const authReq = request as AuthenticatedRequest;
const userId = authReq.user?.id;
if (!userId) {
return reply.code(401).send({ error: 'User ID required' });
}
const isActive = request.query as { isActive?: string };
const limit = request.query as { limit?: string };
const offset = request.query as { offset?: string };
const enrollments = await voiceEnrollmentService.listEnrollments(userId, {
isActive: isActive.isActive !== undefined
? isActive.isActive === 'true'
: undefined,
limit: limit.limit ? parseInt(limit.limit, 10) : undefined,
offset: offset.offset ? parseInt(offset.offset, 10) : undefined,
});
return reply.send({
enrollments: enrollments.map((e) => ({
id: e.id,
name: e.name,
isActive: e.isActive,
createdAt: e.createdAt,
})),
});
});
// Remove an enrollment
fastify.delete('/enrollments/:id', async (request: FastifyRequest, reply: FastifyReply) => {
const authReq = request as AuthenticatedRequest;
const userId = authReq.user?.id;
if (!userId) {
return reply.code(401).send({ error: 'User ID required' });
}
const enrollmentId = (request.params as { id: string }).id;
try {
const enrollment = await voiceEnrollmentService.removeEnrollment(
enrollmentId,
userId
);
return reply.send({
enrollment: {
id: enrollment.id,
name: enrollment.name,
isActive: enrollment.isActive,
},
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Removal failed';
return reply.code(404).send({ error: message });
}
});
// Analyze a single audio file
fastify.post('/analyze', async (request: FastifyRequest, reply: FastifyReply) => {
const authReq = request as AuthenticatedRequest;
const userId = authReq.user?.id;
if (!userId) {
return reply.code(401).send({ error: 'User ID required' });
}
// P1-3 fix: Parse multipart form-data for audio upload
let audioBuffer: Buffer | undefined;
let enrollmentId: string | undefined;
let audioUrl: string | undefined;
for await (const part of request.files()) {
if (part.type === 'file') {
audioBuffer = await part.toBuffer();
} else if (part.fieldname === 'enrollmentId') {
enrollmentId = part.value;
} else if (part.fieldname === 'audioUrl') {
audioUrl = part.value;
}
}
if (!audioBuffer || audioBuffer.length === 0) {
return reply.code(400).send({ error: 'audio file is required' });
}
try {
const result = await analysisService.analyze(userId, audioBuffer, {
enrollmentId,
audioUrl,
});
return reply.code(201).send({
analysis: {
id: result.id,
isSynthetic: result.isSynthetic,
confidence: result.confidence,
analysisResult: result.analysisResult,
createdAt: result.createdAt,
},
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Analysis failed';
return reply.code(422).send({ error: message });
}
});
// Get analysis result by ID
fastify.get('/results/:id', async (request: FastifyRequest, reply: FastifyReply) => {
const authReq = request as AuthenticatedRequest;
const userId = authReq.user?.id;
if (!userId) {
return reply.code(401).send({ error: 'User ID required' });
}
const analysisId = (request.params as { id: string }).id;
const result = await analysisService.getResult(analysisId, userId);
if (!result) {
return reply.code(404).send({ error: 'Analysis not found' });
}
return reply.send({
analysis: {
id: result.id,
isSynthetic: result.isSynthetic,
confidence: result.confidence,
analysisResult: result.analysisResult,
createdAt: result.createdAt,
},
});
});
// Get analysis history
fastify.get('/history', async (request: FastifyRequest, reply: FastifyReply) => {
const authReq = request as AuthenticatedRequest;
const userId = authReq.user?.id;
if (!userId) {
return reply.code(401).send({ error: 'User ID required' });
}
const query = request.query as {
limit?: string;
offset?: string;
isSynthetic?: string;
};
const results = await analysisService.getHistory(userId, {
limit: query.limit ? parseInt(query.limit, 10) : undefined,
offset: query.offset ? parseInt(query.offset, 10) : undefined,
isSynthetic: query.isSynthetic !== undefined
? query.isSynthetic === 'true'
: undefined,
});
return reply.send({
analyses: results.map((r) => ({
id: r.id,
isSynthetic: r.isSynthetic,
confidence: r.confidence,
createdAt: r.createdAt,
})),
});
});
// Batch analyze multiple audio files
fastify.post('/batch', async (request: FastifyRequest, reply: FastifyReply) => {
const authReq = request as AuthenticatedRequest;
const userId = authReq.user?.id;
if (!userId) {
return reply.code(401).send({ error: 'User ID required' });
}
// P1-3 fix: Parse multipart form-data for multiple audio uploads
const files: Array<{ name: string; buffer: Buffer; audioUrl?: string }> = [];
let enrollmentId: string | undefined;
for await (const part of request.files()) {
if (part.type === 'file') {
const buffer = await part.toBuffer();
files.push({
name: part.filename || `file_${files.length}`,
buffer,
});
} else if (part.fieldname === 'enrollmentId') {
enrollmentId = part.value;
} else if (part.fieldname === 'audioUrl') {
if (files.length > 0) {
files[files.length - 1].audioUrl = part.value;
}
}
}
if (files.length === 0) {
return reply.code(400).send({ error: 'at least one audio file is required' });
}
try {
const result = await batchAnalysisService.analyzeBatch(
userId,
files,
{ enrollmentId }
);
return reply.code(201).send({
jobId: result.jobId,
results: result.results.map((r) => ({
id: r.id,
isSynthetic: r.isSynthetic,
confidence: r.confidence,
})),
summary: result.summary,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Batch analysis failed';
return reply.code(422).send({ error: message });
}
});
}

View File

@@ -1,116 +0,0 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import { prisma } from '@shieldai/db';
import { EmailService } from '@shieldai/shared-notifications';
import { Queue } from 'bullmq';
import { Redis } from 'ioredis';
const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379';
const connection = new Redis(redisUrl);
const waitlistEmailQueue = new Queue('waitlist-emails', { connection });
interface WaitlistSignupBody {
email: string;
name?: string;
tier?: string;
utmSource?: string;
utmMedium?: string;
utmCampaign?: string;
}
function getPosition(entryId: string): string {
const hash = entryId.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0);
return String(10000 + (hash % 90000));
}
const DAY_MS = 24 * 60 * 60 * 1000;
export async function waitlistRoutes(fastify: FastifyInstance) {
fastify.post('/waitlist/signup', async (request: FastifyRequest, reply: FastifyReply) => {
const body = request.body as WaitlistSignupBody;
if (!body.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.email)) {
return reply.code(400).send({ error: 'Valid email is required' });
}
const email = body.email.toLowerCase().trim();
const existing = await prisma.waitlistEntry.findFirst({
where: { email },
});
if (existing) {
return reply.code(200).send({
message: 'Already on the waitlist',
id: existing.id,
});
}
const validTiers = ['basic', 'plus', 'premium'] as const;
const tier = validTiers.includes(body.tier as typeof validTiers[number])
? (body.tier as string)
: undefined;
const entry = await prisma.waitlistEntry.create({
data: {
email,
name: body.name?.trim() || null,
source: 'landing_page',
tier: tier as any || null,
utmSource: body.utmSource || null,
utmMedium: body.utmMedium || null,
utmCampaign: body.utmCampaign || null,
},
});
const name = body.name?.trim() || 'there';
const position = getPosition(entry.id);
try {
const emailService = EmailService.getInstance();
const result = await emailService.sendWithTemplate(email, {
templateId: 'waitlist_confirmation',
variables: { name, position },
});
if (result.status === 'failed') {
request.log.warn({ error: result.error }, 'Failed to send waitlist confirmation email');
} else {
request.log.info({ email }, 'Waitlist confirmation email sent');
}
} catch (err) {
request.log.error({ err }, 'Error sending waitlist confirmation email');
}
try {
await Promise.all([
waitlistEmailQueue.add(
'send-waitlist-intro',
{ email, name, entryId: entry.id, tier },
{ delay: 1 * DAY_MS, attempts: 3, backoff: { type: 'exponential', delay: 5000 } }
),
waitlistEmailQueue.add(
'send-waitlist-features',
{ email, name, entryId: entry.id, tier },
{ delay: 3 * DAY_MS, attempts: 3, backoff: { type: 'exponential', delay: 5000 } }
),
waitlistEmailQueue.add(
'send-waitlist-launch-teaser',
{ email, name, entryId: entry.id, tier },
{ delay: 7 * DAY_MS, attempts: 3, backoff: { type: 'exponential', delay: 5000 } }
),
]);
request.log.info({ email }, 'Welcome sequence scheduled');
} catch (err) {
request.log.error({ err }, 'Failed to schedule welcome sequence emails');
}
return reply.code(201).send({
message: 'Welcome to the ShieldAI waitlist',
id: entry.id,
});
});
fastify.get('/waitlist/count', async (_request: FastifyRequest, reply: FastifyReply) => {
const count = await prisma.waitlistEntry.count();
return reply.send({ count });
});
}

View File

@@ -1,41 +0,0 @@
import { FastifyInstance } from "fastify";
import { WatchListService } from "@shieldai/darkwatch";
import { IdentifierType } from "@shieldai/types";
export function watchlistRoutes(fastify: FastifyInstance) {
const service = new WatchListService();
fastify.post("/", async (request, reply) => {
const body = request.body as { identifierType: string; identifierValue: string };
const userId = (request.user as { id: string })?.id;
if (!userId) {
return reply.code(401).send({ error: "User not authenticated" });
}
const item = await service.addItem(userId, body.identifierType as IdentifierType, body.identifierValue);
return reply.code(201).send(item);
});
fastify.get("/", async (request, reply) => {
const userId = (request.user as { id: string })?.id;
if (!userId) {
return reply.code(401).send({ error: "User not authenticated" });
}
const items = await service.listItems(userId);
return reply.send(items);
});
fastify.delete("/:id", async (request, reply) => {
const userId = (request.user as { id: string })?.id;
if (!userId) {
return reply.code(401).send({ error: "User not authenticated" });
}
const result = await service.removeItem(userId, request.params.id);
return reply.send({ count: result.count });
});
}

View File

@@ -1,66 +0,0 @@
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) {
console.error("[Webhook] Event processing error:", err);
return reply.code(400).send({ error: "Webhook processing failed" });
}
}
);
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 params = request.params as { userId: string };
const authedUser = (request.user as { id: string })?.id;
if (authedUser !== params.userId) {
return reply.code(403).send({ error: "Forbidden" });
}
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(params.userId, limit, offset);
return reply.send(events);
}
);
}

View File

@@ -1,112 +0,0 @@
import { prisma } from '@shieldai/db';
const blogPosts = [
{
slug: 'what-is-ai-voice-cloning',
title: 'What Is AI Voice Cloning and How to Protect Your Family',
excerpt: 'AI voice cloning technology is advancing rapidly. Learn how scammers use it to impersonate loved ones and how ShieldAI detects these attacks in real time.',
content: `<h2>Understanding AI Voice Cloning</h2>
<p>AI voice cloning uses deep learning models to analyze a small sample of someone's voice—sometimes just a few seconds from a social media video or phone call—and generate new speech that sounds identical to the original speaker.</p>
<h2>How Scammers Exploit It</h2>
<p>The most common attack pattern involves a scammer calling a victim while using a cloned voice of a family member. The fake "family member" claims to be in distress—needing bail money, hospital fees, or help with a car accident. The emotional urgency makes victims less likely to question the call's authenticity.</p>
<h2>Warning Signs</h2>
<ul>
<li>Unexpected calls from family members asking for money</li>
<li>Slight delays or unnatural pauses in speech</li>
<li>Background noise that doesn't match the claimed location</li>
<li>Requests to keep the call secret or avoid contacting other family members</li>
</ul>
<h2>How ShieldAI Protects You</h2>
<p>ShieldAI's VoicePrint technology creates audio fingerprints for each family member's voice. When an incoming call is detected, our AI analyzes the audio in real time and flags any call that doesn't match the verified voiceprint. You'll receive an instant alert if a voice clone is suspected.</p>`,
authorName: 'ShieldAI Team',
tags: ['voice cloning', 'AI scams', 'family protection'],
published: true,
},
{
slug: 'dark-web-monitoring-guide',
title: 'Dark Web Monitoring: What Gets Exposed and How to Stay Safe',
excerpt: 'Your personal data is traded on dark web marketplaces every day. Here is what criminals buy, how they use it, and how ShieldAI monitors for your exposure.',
content: `<h2>What Is the Dark Web?</h2>
<p>The dark web is a hidden part of the internet accessible only through specialized browsers like Tor. While it has legitimate uses for privacy and journalism, it is also the primary marketplace for stolen data, including emails, passwords, phone numbers, and Social Security numbers.</p>
<h2>What Data Gets Exposed</h2>
<ul>
<li><strong>Email addresses</strong> — used for phishing and credential stuffing attacks</li>
<li><strong>Phone numbers</strong> — sold to robocallers and used for SIM swapping</li>
<li><strong>Passwords</strong> — sold in bulk for account takeover attempts</li>
<li><strong>Social Security Numbers</strong> — used for identity theft and tax fraud</li>
<li><strong>Home addresses</strong> — used for physical threats and doxxing</li>
</ul>
<h2>How ShieldAI Monitors for You</h2>
<p>ShieldAI continuously scans dark web marketplaces, forums, and known data leak repositories. When your monitored data appears in a new leak, we send you an immediate alert with details about what was exposed and recommended next steps.</p>
<h2>What to Do If Your Data Is Leaked</h2>
<ol>
<li>Change passwords immediately — use unique passwords for each service</li>
<li>Enable two-factor authentication everywhere</li>
<li>Freeze your credit if SSN was exposed</li>
<li>Monitor bank and credit card statements for unusual activity</li>
<li>Run a ShieldAI dark web scan to check for additional exposures</li>
</ol>`,
authorName: 'ShieldAI Team',
tags: ['dark web', 'data breach', 'identity theft'],
published: true,
},
{
slug: 'spam-call-statistics-2025',
title: 'Spam Call Statistics 2025: The Rise of AI-Powered Phone Scams',
excerpt: 'Spam calls are at an all-time high, and AI is making them harder to detect. Here are the latest numbers and what you can do to protect yourself.',
content: `<h2>The Scale of the Problem</h2>
<p>In 2025, Americans received an estimated 55 billion spam calls — an average of 15 calls per person per month. AI-powered scam calls now account for 40% of all phone fraud attempts, up from just 12% in 2023.</p>
<h2>Key Statistics</h2>
<ul>
<li>1 in 3 Americans report losing money to phone scams</li>
<li>Average loss per victim: $1,200</li>
<li>68% of scam calls now use AI-generated voices</li>
<li>Elderly individuals (65+) are 3x more likely to fall victim</li>
<li>Most common scam: fake tech support (32% of all reports)</li>
</ul>
<h2>Why Traditional Blocking Falls Short</h2>
<p>Traditional spam blockers rely on known phone number databases. But AI-powered scammers constantly rotate numbers, spoof caller IDs, and use voice cloning to bypass voice-based verification. ShieldAI's machine learning approach classifies calls based on behavioral patterns, not just number reputation — catching new scams that traditional methods miss.</p>`,
authorName: 'ShieldAI Team',
tags: ['spam calls', 'statistics', 'AI scams'],
published: true,
},
];
async function seed() {
console.log('Seeding blog posts...');
for (const post of blogPosts) {
const existing = await prisma.blogPost.findUnique({ where: { slug: post.slug } });
if (existing) {
console.log(` Skipping "${post.slug}" — already exists`);
continue;
}
await prisma.blogPost.create({
data: {
...post,
publishedAt: new Date(),
},
});
console.log(` Created "${post.slug}"`);
}
console.log('Seed complete!');
}
seed()
.catch((e) => {
console.error('Seed failed:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@@ -1,120 +0,0 @@
// dd-trace must be initialized before any other module is loaded for auto-instrumentation
import '@shieldai/monitoring/datadog-init';
import Fastify from "fastify";
import cors from "@fastify/cors";
import helmet from "@fastify/helmet";
import sensible from "@fastify/sensible";
import rawBody from "fastify-raw-body";
import { extractOrGenerateRequestId } from "@shieldai/types";
import { authMiddleware } from "./middleware/auth.middleware";
import { errorHandlingMiddleware } from "./middleware/error-handling.middleware";
import { loggingMiddleware } from "./middleware/logging.middleware";
import { monitoringMiddleware } from "./middleware/monitoring.middleware";
import { darkwatchRoutes } from "./routes/darkwatch.routes";
import { voiceprintRoutes } from "./routes/voiceprint.routes";
import { correlationRoutes } from "./routes/correlation.routes";
import { extensionRoutes } from "./routes/extension.routes";
import { waitlistRoutes } from "./routes/waitlist.routes";
import { blogRoutes } from "./routes/blog.routes";
import { blogAdminRoutes } from "./routes/blog-admin.routes";
import { routes } from "./routes";
import { captureSentryError } from "@shieldai/monitoring";
import { getCorsOrigins } from "./config/api.config";
import fastifySwagger from "@fastify/swagger";
import fastifySwaggerUi from "@fastify/swagger-ui";
import * as fs from "fs";
import * as path from "path";
const app = Fastify({
logger: {
level: process.env.LOG_LEVEL || "info",
},
});
async function bootstrap() {
const corsOrigins = getCorsOrigins();
await app.register(cors, { origin: corsOrigins });
await app.register(helmet);
await app.register(sensible);
await app.register(rawBody, { runFirst: true });
// Register auth middleware to populate request.user
await app.register(authMiddleware);
// Register logging middleware (request/response logging)
await app.register(loggingMiddleware);
// Register monitoring middleware (CloudWatch metrics)
await app.register(monitoringMiddleware);
// Register error handling middleware (Sentry integration)
await app.register(errorHandlingMiddleware);
app.addHook("onRequest", async (request, _reply) => {
const requestId = extractOrGenerateRequestId(request.headers);
request.id = requestId;
const pinoLog = request.log as typeof request.log & { bindings?: Record<string, string>; bindActive?: () => void };
pinoLog.bindings = { requestId };
pinoLog.bindActive?.();
request.headers["x-request-id"] = requestId;
});
await app.register(routes);
app.get("/health", async () => ({ status: "ok", timestamp: new Date().toISOString() }));
// Swagger/OpenAPI documentation
const openapiSpec = JSON.parse(
fs.readFileSync(path.join(__dirname, "openapi", "spec.json"), "utf-8"),
) as Record<string, unknown>;
const swaggerDefinition: Record<string, unknown> = {
openapi: "3.0.3",
info: {
title: "ShieldAI API",
description:
"ShieldAI API documentation — reverse-engineer endpoints and run contract tests",
version: "1.0.0",
},
servers: openapiSpec.servers,
paths: openapiSpec.paths,
components: openapiSpec.components,
security: openapiSpec.security,
tags: openapiSpec.tags,
};
await app.register(fastifySwagger, {
openapi: swaggerDefinition,
});
await app.register(fastifySwaggerUi, {
routePrefix: "/docs",
uiConfig: {
docExpansion: "list",
},
staticCSP: true,
theme: {
js: [
{
filename: "custom.js",
content: `
window.addEventListener('DOMContentLoaded', () => {
document.querySelector('link[rel="icon"]')?.remove();
});
`,
},
],
},
});
try {
await app.listen({ port: parseInt(process.env.PORT || "3000", 10), host: "0.0.0.0" });
app.log.info(`Server listening on port ${process.env.PORT || 3000}`);
} catch (err) {
app.log.error(err);
captureSentryError(err as Error, { context: "server_startup" });
process.exit(1);
}
}
bootstrap();

View File

@@ -1,174 +0,0 @@
import { prisma, AlertType, AlertSeverity } from '@shieldai/db';
import {
NotificationService,
NotificationPriority,
loadNotificationConfig,
} from '@shieldsai/shared-notifications';
const ALERT_DEDUP_WINDOW_MS = 24 * 60 * 60 * 1000;
export class AlertPipeline {
private notificationService: NotificationService;
constructor() {
this.notificationService = new NotificationService(loadNotificationConfig());
}
async processNewExposures(exposureIds: string[]) {
const exposures = await prisma.exposure.findMany({
where: { id: { in: exposureIds }, isFirstTime: true },
include: {
subscription: {
select: {
id: true,
userId: true,
tier: true,
},
},
watchlistItem: true,
},
});
const alertsCreated: Awaited<ReturnType<typeof prisma.alert.create>>[] = [];
for (const exposure of exposures) {
const dedupKey = `exposure:${exposure.subscriptionId}:${exposure.source}:${exposure.identifierHash}`;
const recentAlert = await prisma.alert.findFirst({
where: {
subscriptionId: exposure.subscriptionId,
type: AlertType.exposure_detected,
createdAt: {
gte: new Date(Date.now() - ALERT_DEDUP_WINDOW_MS),
},
},
orderBy: { createdAt: 'desc' },
});
if (recentAlert) {
continue;
}
const alert = await prisma.alert.create({
data: {
subscriptionId: exposure.subscriptionId,
userId: exposure.subscription.userId,
exposureId: exposure.id,
type: AlertType.exposure_detected,
title: this.buildTitle(exposure),
message: this.buildMessage(exposure),
severity: this.mapSeverity(exposure.severity),
channel: this.getChannelsForTier(exposure.subscription.tier),
},
});
alertsCreated.push(alert);
await this.dispatchNotification(alert, exposure);
}
return alertsCreated;
}
async dispatchScanCompleteAlert(
subscriptionId: string,
userId: string,
exposuresFound: number
) {
const subscription = await prisma.subscription.findUnique({
where: { id: subscriptionId },
select: { tier: true },
});
if (!subscription) return;
const alert = await prisma.alert.create({
data: {
subscriptionId,
userId,
type: AlertType.scan_complete,
title: 'DarkWatch Scan Complete',
message: `Scan found ${exposuresFound} new exposure${exposuresFound === 1 ? '' : 's'}.`,
severity: exposuresFound > 0 ? 'warning' : 'info',
channel: this.getChannelsForTier(subscription.tier),
},
});
await this.dispatchNotification(alert, {
source: 'hibp',
severity: 'info',
identifier: '',
dataType: 'email',
} as any);
return alert;
}
private async dispatchNotification(
alert: {
userId: string;
channel: string[];
title: string;
message: string;
severity: AlertSeverity;
},
exposure: { source: string; severity: string; identifier: string; dataType: string }
) {
try {
if (!this.notificationService.isFullyConfigured()) return;
await this.notificationService.sendMultiChannelNotification(
{
userId: alert.userId,
},
alert.channel as any,
alert.title,
`<p>${alert.message}</p>
<p><strong>Source:</strong> ${exposure.source}</p>
<p><strong>Severity:</strong> ${exposure.severity}</p>
<p><strong>Type:</strong> ${exposure.dataType}</p>`,
alert.severity === 'critical'
? NotificationPriority.HIGH
: NotificationPriority.NORMAL
);
} catch (error) {
console.error('[AlertPipeline] Notification dispatch error:', error);
}
}
private buildTitle(exposure: {
source: string;
dataType: string;
severity: string;
}): string {
return `${exposure.severity.toUpperCase()}: ${exposure.dataType} exposure on ${exposure.source}`;
}
private buildMessage(exposure: {
identifier: string;
source: string;
severity: string;
dataType: string;
}): string {
const masked = exposure.identifier.includes('@')
? exposure.identifier.replace(/(?<=.{2}).*(?=@)/, '***')
: exposure.identifier.slice(0, 3) + '***';
return `Your ${exposure.dataType} (${masked}) was found in a ${exposure.source} breach with ${exposure.severity} severity.`;
}
private mapSeverity(severity: string): AlertSeverity {
return severity as AlertSeverity;
}
private getChannelsForTier(tier: string): string[] {
const channelMap: Record<string, string[]> = {
basic: ['email'],
plus: ['email', 'push'],
premium: ['email', 'push', 'sms'],
};
return channelMap[tier] || ['email'];
}
}
export const alertPipeline = new AlertPipeline();

View File

@@ -1,5 +0,0 @@
export { watchlistService } from './watchlist.service';
export { scanService } from './scan.service';
export { schedulerService } from './scheduler.service';
export { webhookService } from './webhook.service';
export { alertPipeline } from './alert.pipeline';

View File

@@ -1,220 +0,0 @@
import { prisma, ExposureSource, ExposureSeverity, WatchlistType } from '@shieldai/db';
import { createHash } from 'crypto';
function hashIdentifier(identifier: string): string {
return createHash('sha256').update(identifier.toLowerCase().trim()).digest('hex');
}
function determineSeverity(
source: ExposureSource,
dataType: WatchlistType
): ExposureSeverity {
const criticalSources = [ExposureSource.darkWebForum, ExposureSource.honeypot];
const warningSources = [ExposureSource.hibp, ExposureSource.shodan];
const criticalTypes = [WatchlistType.ssn];
if (criticalTypes.includes(dataType)) return ExposureSeverity.critical;
if (criticalSources.includes(source)) return ExposureSeverity.critical;
if (warningSources.includes(source)) return ExposureSeverity.warning;
return ExposureSeverity.info;
}
export class ScanService {
async checkHIBP(email: string): Promise<{ exposed: boolean; sources: string[] }> {
try {
const response = await fetch(
`https://hibp.com/api/v2/${encodeURIComponent(email)}`,
{
headers: {
'hibp-api-key': process.env.HIBP_API_KEY || '',
Accept: 'application/json',
},
signal: AbortSignal.timeout(15000),
}
);
if (response.status === 404) {
return { exposed: false, sources: [] };
}
if (!response.ok) {
console.error(`[ScanService:HIBP] Status ${response.status} for ${email}`);
return { exposed: false, sources: [] };
}
const data = await response.json();
const sources = Array.isArray(data)
? data.map((p: { Name: string }) => p.Name)
: [];
return { exposed: sources.length > 0, sources };
} catch (error) {
console.error('[ScanService:HIBP] Error:', error);
return { exposed: false, sources: [] };
}
}
async checkShodan(domain: string): Promise<{ exposed: boolean; ports: string[]; ips: string[] }> {
try {
const response = await fetch(
`https://api.shodan.io/shodan/host/${encodeURIComponent(domain)}`,
{
headers: {
Authorization: `Bearer ${process.env.SHODAN_API_KEY || ''}`,
},
signal: AbortSignal.timeout(15000),
}
);
if (response.status === 404) {
return { exposed: false, ports: [], ips: [] };
}
if (!response.ok) {
console.error(`[ScanService:Shodan] Status ${response.status} for ${domain}`);
return { exposed: false, ports: [], ips: [] };
}
const data = await response.json();
return {
exposed: !!data.ip_str,
ports: data.ports?.map(String) || [],
ips: [data.ip_str || ''],
};
} catch (error) {
console.error('[ScanService:Shodan] Error:', error);
return { exposed: false, ports: [], ips: [] };
}
}
async processSubscriptionScan(
subscriptionId: string,
watchlistItems: Awaited<ReturnType<ScanService['getWatchlistItems']>>
): Promise<{ exposuresCreated: number; exposuresUpdated: number }> {
let exposuresCreated = 0;
let exposuresUpdated = 0;
for (const item of watchlistItems) {
const identifier = item.value;
const identifierHash = hashIdentifier(identifier);
switch (item.type) {
case WatchlistType.email: {
const hibpResult = await this.checkHIBP(identifier);
if (hibpResult.exposed) {
for (const source of hibpResult.sources) {
const existing = await prisma.exposure.findFirst({
where: {
subscriptionId,
source: ExposureSource.hibp,
identifierHash,
metadata: { path: ['dbName'], equals: source },
},
});
if (existing) {
await prisma.exposure.update({
where: { id: existing.id },
data: { detectedAt: new Date() },
});
exposuresUpdated++;
} else {
await prisma.exposure.create({
data: {
subscriptionId,
watchlistItemId: item.id,
source: ExposureSource.hibp,
dataType: item.type,
identifier,
identifierHash,
severity: determineSeverity(ExposureSource.hibp, item.type),
isFirstTime: true,
metadata: { dbName: source },
detectedAt: new Date(),
},
});
exposuresCreated++;
}
}
}
break;
}
case WatchlistType.domain: {
const shodanResult = await this.checkShodan(identifier);
if (shodanResult.exposed) {
const existing = await prisma.exposure.findFirst({
where: {
subscriptionId,
source: ExposureSource.shodan,
identifierHash,
},
});
if (existing) {
await prisma.exposure.update({
where: { id: existing.id },
data: {
detectedAt: new Date(),
metadata: { ports: shodanResult.ports, ips: shodanResult.ips },
},
});
exposuresUpdated++;
} else {
await prisma.exposure.create({
data: {
subscriptionId,
watchlistItemId: item.id,
source: ExposureSource.shodan,
dataType: item.type,
identifier,
identifierHash,
severity: determineSeverity(ExposureSource.shodan, item.type),
isFirstTime: true,
metadata: { ports: shodanResult.ports, ips: shodanResult.ips },
detectedAt: new Date(),
},
});
exposuresCreated++;
}
}
break;
}
default: {
const existing = await prisma.exposure.findFirst({
where: { subscriptionId, watchlistItemId: item.id, identifierHash },
});
if (!existing) {
await prisma.exposure.create({
data: {
subscriptionId,
watchlistItemId: item.id,
source: ExposureSource.darkWebForum,
dataType: item.type,
identifier,
identifierHash,
severity: determineSeverity(ExposureSource.darkWebForum, item.type),
isFirstTime: true,
detectedAt: new Date(),
},
});
exposuresCreated++;
}
break;
}
}
}
return { exposuresCreated, exposuresUpdated };
}
async getWatchlistItems(subscriptionId: string) {
return prisma.watchlistItem.findMany({
where: { subscriptionId, isActive: true },
});
}
}
export const scanService = new ScanService();

View File

@@ -1,155 +0,0 @@
import { prisma, SubscriptionTier, SubscriptionStatus } from '@shieldai/db';
import { tierConfig } from '@shieldsai/shared-billing';
import { darkwatchScanQueue } from '@shieldsai/jobs';
import { randomUUID } from 'crypto';
const CRON_EXPRESSIONS = {
daily: '0 0 * * *',
hourly: '0 * * * *',
realtime: null,
};
export class SchedulerService {
async scheduleSubscriptionScans() {
const activeSubscriptions = await prisma.subscription.findMany({
where: {
tier: { in: [SubscriptionTier.basic, SubscriptionTier.plus, SubscriptionTier.premium] },
status: SubscriptionStatus.active,
},
select: {
id: true,
tier: true,
userId: true,
},
});
const jobsEnqueued = [];
for (const subscription of activeSubscriptions) {
const frequency = tierConfig[subscription.tier].features.darkWebScanFrequency;
const cron = CRON_EXPRESSIONS[frequency];
if (!cron) {
continue;
}
const jobKey = `scheduled-scan:${subscription.id}`;
try {
await darkwatchScanQueue.add(
'scheduled-scan',
{
subscriptionId: subscription.id,
tier: subscription.tier,
scanType: 'scheduled',
},
{
jobId: jobKey,
repeat: {
every: frequency === 'daily'
? 24 * 60 * 60 * 1000
: 60 * 60 * 1000,
},
priority: subscription.tier === SubscriptionTier.premium ? 1 : 3,
}
);
jobsEnqueued.push({
subscriptionId: subscription.id,
tier: subscription.tier,
frequency,
});
} catch (error) {
if ((error as Error).message?.includes('Duplicate')) {
continue;
}
console.error(
`[SchedulerService] Failed to schedule scan for ${subscription.id}:`,
error
);
}
}
return jobsEnqueued;
}
async enqueueOnDemandScan(subscriptionId: string) {
const subscription = await prisma.subscription.findUnique({
where: { id: subscriptionId },
select: { id: true, tier: true },
});
if (!subscription) {
throw new Error(`Subscription ${subscriptionId} not found`);
}
return darkwatchScanQueue.add(
'on-demand-scan',
{
subscriptionId,
tier: subscription.tier,
scanType: 'on-demand',
},
{
priority: 1,
jobId: `on-demand-scan:${subscriptionId}:${randomUUID()}`,
}
);
}
async enqueueRealtimeTrigger(subscriptionId: string, sourceData: Record<string, unknown>) {
const subscription = await prisma.subscription.findUnique({
where: { id: subscriptionId },
select: { id: true, tier: true },
});
if (!subscription || subscription.tier !== SubscriptionTier.premium) {
throw new Error('Realtime triggers require Premium tier');
}
return darkwatchScanQueue.add(
'realtime-trigger',
{
subscriptionId,
tier: subscription.tier,
scanType: 'realtime',
sourceData,
},
{
priority: 0,
jobId: `realtime-trigger:${subscriptionId}:${randomUUID()}`,
}
);
}
async rescheduleAll() {
const repeatableJobs = await darkwatchScanQueue.getRepeatableJobs();
for (const job of repeatableJobs) {
await darkwatchScanQueue.removeRepeatableByKey(job.key);
}
return this.scheduleSubscriptionScans();
}
async getScanSchedule(subscriptionId: string) {
const subscription = await prisma.subscription.findUnique({
where: { id: subscriptionId },
select: { tier: true },
});
if (!subscription) return null;
const frequency = tierConfig[subscription.tier].features.darkWebScanFrequency;
return {
subscriptionId,
tier: subscription.tier,
frequency,
cron: CRON_EXPRESSIONS[frequency],
nextRun: frequency === 'realtime' ? 'event-driven' : CRON_EXPRESSIONS[frequency],
};
}
}
export const schedulerService = new SchedulerService();

View File

@@ -1,97 +0,0 @@
import { prisma, WatchlistType } from '@shieldai/db';
import { createHash } from 'crypto';
export function normalizeValue(type: WatchlistType, value: string): string {
const trimmed = value.trim().toLowerCase();
switch (type) {
case WatchlistType.email:
return trimmed.replace(/\s+/g, '');
case WatchlistType.phoneNumber:
return trimmed.replace(/[\s\-\(\)]/g, '');
case WatchlistType.ssn:
return trimmed.replace(/-/g, '');
case WatchlistType.address:
return trimmed;
case WatchlistType.domain:
return trimmed.replace(/^https?:\/\//, '').replace(/\/.*$/, '');
default:
return trimmed;
}
}
export function hashValue(value: string): string {
return createHash('sha256').update(value).digest('hex');
}
export class WatchlistService {
async addItem(
subscriptionId: string,
type: WatchlistType,
value: string,
maxItems: number
) {
const normalized = normalizeValue(type, value);
const itemHash = hashValue(normalized);
const currentCount = await prisma.watchlistItem.count({
where: { subscriptionId, isActive: true },
});
if (currentCount >= maxItems) {
throw new Error(
`Watchlist limit reached (${maxItems} items). Upgrade your plan to add more.`
);
}
const existing = await prisma.watchlistItem.findFirst({
where: { subscriptionId, type, hash: itemHash },
});
if (existing) {
if (!existing.isActive) {
return prisma.watchlistItem.update({
where: { id: existing.id },
data: { isActive: true },
});
}
return existing;
}
return prisma.watchlistItem.create({
data: {
subscriptionId,
type,
value: normalized,
hash: itemHash,
},
});
}
async getItems(subscriptionId: string) {
return prisma.watchlistItem.findMany({
where: { subscriptionId, isActive: true },
orderBy: { createdAt: 'desc' },
});
}
async removeItem(id: string, subscriptionId: string) {
return prisma.watchlistItem.update({
where: { id },
data: { isActive: false },
});
}
async getActiveItemsForScan(subscriptionId: string) {
return prisma.watchlistItem.findMany({
where: { subscriptionId, isActive: true },
});
}
async getItemCount(subscriptionId: string) {
return prisma.watchlistItem.count({
where: { subscriptionId, isActive: true },
});
}
}
export const watchlistService = new WatchlistService();

View File

@@ -1,226 +0,0 @@
import { prisma, ExposureSource, ExposureSeverity, WatchlistType, AlertType, AlertSeverity } from '@shieldai/db';
import { createHash } from 'crypto';
import { mixpanelService, EventType } from '@shieldsai/shared-analytics';
function hashIdentifier(identifier: string): string {
return createHash('sha256').update(identifier.toLowerCase().trim()).digest('hex');
}
function determineSeverity(
source: ExposureSource,
dataType: WatchlistType
): ExposureSeverity {
const criticalSources = [ExposureSource.darkWebForum, ExposureSource.honeypot];
const warningSources = [ExposureSource.hibp, ExposureSource.shodan];
const criticalTypes = [WatchlistType.ssn];
if (criticalTypes.includes(dataType)) return ExposureSeverity.critical;
if (criticalSources.includes(source)) return ExposureSeverity.critical;
if (warningSources.includes(source)) return ExposureSeverity.warning;
return ExposureSeverity.info;
}
export interface WebhookPayload {
source: string;
identifier: string;
identifierType: string;
metadata?: Record<string, unknown>;
timestamp?: string;
}
export class WebhookService {
async processExternalWebhook(payload: WebhookPayload): Promise<{
exposuresCreated: number;
alertsCreated: number;
}> {
const source = this.mapSource(payload.source);
const dataType = this.mapDataType(payload.identifierType);
const identifier = payload.identifier.toLowerCase().trim();
const identifierHash = hashIdentifier(identifier);
const severity = determineSeverity(source, dataType);
const matchingItems = await prisma.watchlistItem.findMany({
where: {
isActive: true,
OR: [
{ hash: identifierHash, type: dataType },
{ value: identifier, type: dataType },
],
},
include: {
subscription: {
select: {
id: true,
tier: true,
userId: true,
},
},
},
});
let exposuresCreated = 0;
let alertsCreated = 0;
for (const item of matchingItems) {
const existing = await prisma.exposure.findFirst({
where: {
subscriptionId: item.subscriptionId,
source,
identifierHash,
},
});
if (existing) {
await prisma.exposure.update({
where: { id: existing.id },
data: { detectedAt: new Date() },
});
continue;
}
const exposure = await prisma.exposure.create({
data: {
subscriptionId: item.subscriptionId,
watchlistItemId: item.id,
source,
dataType,
identifier,
identifierHash,
severity,
isFirstTime: true,
metadata: payload.metadata || {},
detectedAt: new Date(),
},
});
exposuresCreated++;
const alertChannels = this.getAlertChannelsForTier(item.subscription.tier);
await prisma.alert.create({
data: {
subscriptionId: item.subscriptionId,
userId: item.subscription.userId,
exposureId: exposure.id,
type: AlertType.exposure_detected,
title: `New Exposure Detected: ${this.getSourceLabel(source)}`,
message: this.buildAlertMessage(identifier, source, severity),
severity: this.mapAlertSeverity(severity),
channel: alertChannels,
},
});
alertsCreated++;
await mixpanelService.track(EventType.EXPOSURE_DETECTED, {
userId: item.subscription.userId,
exposureType: dataType,
severity,
source,
subscriptionTier: item.subscription.tier,
});
}
return { exposuresCreated, alertsCreated };
}
async verifyWebhookSignature(
body: string,
signature: string,
timestamp: string
): Promise<boolean> {
const webhookSecret = process.env.DARKWATCH_WEBHOOK_SECRET;
if (!webhookSecret) {
console.warn('[WebhookService] DARKWATCH_WEBHOOK_SECRET not set — signature verification skipped');
return false;
}
const expected = createHash('sha256')
.update(`${timestamp}:${body}`)
.digest('hex');
return expected === signature;
}
private mapSource(source: string): ExposureSource {
const sourceMap: Record<string, ExposureSource> = {
hibp: ExposureSource.hibp,
'haveibeenpwned': ExposureSource.hibp,
securitytrails: ExposureSource.securityTrails,
censys: ExposureSource.censys,
'darkweb-forum': ExposureSource.darkWebForum,
'darkweb': ExposureSource.darkWebForum,
shodan: ExposureSource.shodan,
honeypot: ExposureSource.honeypot,
};
const normalized = source.toLowerCase().replace(/\s+/g, '');
const mapped = sourceMap[normalized];
if (!mapped) {
console.warn(`[WebhookService] Unknown source "${source}", falling back to darkWebForum`);
}
return mapped || ExposureSource.darkWebForum;
}
private mapDataType(type: string): WatchlistType {
const typeMap: Record<string, WatchlistType> = {
email: WatchlistType.email,
phone: WatchlistType.phoneNumber,
phonenumber: WatchlistType.phoneNumber,
ssn: WatchlistType.ssn,
address: WatchlistType.address,
domain: WatchlistType.domain,
};
const normalized = type.toLowerCase().trim();
return typeMap[normalized] || WatchlistType.email;
}
private getAlertChannelsForTier(tier: string): string[] {
const channelMap: Record<string, string[]> = {
basic: ['email'],
plus: ['email', 'push'],
premium: ['email', 'push', 'sms'],
};
return channelMap[tier] || ['email'];
}
private mapAlertSeverity(severity: ExposureSeverity): AlertSeverity {
return severity as AlertSeverity;
}
private getSourceLabel(source: ExposureSource): string {
const labels: Record<ExposureSource, string> = {
[ExposureSource.hibp]: 'Have I Been Pwned',
[ExposureSource.securityTrails]: 'SecurityTrails',
[ExposureSource.censys]: 'Censys',
[ExposureSource.darkWebForum]: 'Dark Web Forum',
[ExposureSource.shodan]: 'Shodan',
[ExposureSource.honeypot]: 'Honeypot',
};
return labels[source] || source;
}
private buildAlertMessage(
identifier: string,
source: ExposureSource,
severity: ExposureSeverity
): string {
const masked = this.maskIdentifier(identifier);
return `${severity.toUpperCase()}: "${masked}" found in ${this.getSourceLabel(source)}.`;
}
private maskIdentifier(identifier: string): string {
if (identifier.includes('@')) {
const [user, domain] = identifier.split('@');
const maskedUser = user.slice(0, 2) + '***' + user.slice(-1);
return `${maskedUser}@${domain}`;
}
if (identifier.length > 8) {
return identifier.slice(0, 3) + '***' + identifier.slice(-2);
}
return identifier;
}
}
export const webhookService = new WebhookService();

View File

@@ -1,227 +0,0 @@
/**
* Feature Flag Management System
* Centralized feature flag handling with type safety and runtime updates
*/
import type { z } from 'zod';
/**
* Type for feature flag values
*/
export type FeatureFlagValue = boolean | string | number;
/**
* Interface for a feature flag definition
*/
export interface FeatureFlag<T = FeatureFlagValue> {
key: string;
defaultValue: T;
description?: string;
allowedValues?: T[]; // For enum-like flags
category?: string;
}
/**
* Feature flag registry - stores all defined flags
*/
export interface FeatureFlagRegistry {
[key: string]: FeatureFlag;
}
/**
* Feature flag resolver - handles flag resolution logic
*/
export class FeatureFlagResolver {
private flags: FeatureFlagRegistry;
private resolvedCache: Map<string, FeatureFlagValue> = new Map();
constructor(flags: FeatureFlagRegistry) {
this.flags = flags;
}
/**
* Resolve a feature flag value
* Priority: Environment > Cache > Default
*/
resolve<T>(key: string, defaultValue: T): T {
// Check cache first
if (this.resolvedCache.has(key)) {
return this.resolvedCache.get(key)! as T;
}
// Check environment variable (allows runtime updates)
const envValue = process.env[`FLAG_${key.toUpperCase()}`];
if (envValue !== undefined) {
// Try to parse as JSON first, then as boolean, then as string
let parsed: FeatureFlagValue;
try {
parsed = JSON.parse(envValue);
} catch {
parsed = envValue.toLowerCase() === 'true' ? true :
envValue.toLowerCase() === 'false' ? false :
envValue;
}
// Validate against allowed values if defined
const flag = this.flags[key];
if (flag && flag.allowedValues && !flag.allowedValues.includes(parsed)) {
console.warn(`Invalid value for flag ${key}: ${parsed}. Using default.`);
parsed = defaultValue as FeatureFlagValue;
}
this.resolvedCache.set(key, parsed);
return parsed as T;
}
// Use cached value if available
if (this.resolvedCache.has(key)) {
return this.resolvedCache.get(key)! as T;
}
// Return default
this.resolvedCache.set(key, defaultValue as FeatureFlagValue);
return defaultValue as T;
}
/**
* Check if a flag is enabled (boolean check)
*/
isEnabled<T>(key: string, defaultValue: T): T {
return this.resolve(key, defaultValue) as T;
}
/**
* Get flag definition
*/
getDefinition(key: string): FeatureFlag | undefined {
return this.flags[key];
}
/**
* List all registered flags
*/
getAllFlags(): FeatureFlagRegistry {
return { ...this.flags };
}
/**
* Clear the resolution cache (useful for testing)
*/
clearCache(): void {
this.resolvedCache.clear();
}
}
/**
* Feature flag configuration with pre-defined flags
*/
export const featureFlags: FeatureFlagRegistry = {
// SpamShield Feature Flags
'spamshield.enable.number.reputation': {
key: 'spamshield_enable_number_reputation',
defaultValue: true,
description: 'Enable number reputation checking (Hiya API integration)',
category: 'spamshield',
},
'spamshield.enable.content.classification': {
key: 'spamshield_enable_content_classification',
defaultValue: true,
description: 'Enable SMS content classification (BERT model)',
category: 'spamshield',
},
'spamshield.enable.behavioral.analysis': {
key: 'spamshield_enable_behavioral_analysis',
defaultValue: true,
description: 'Enable call behavioral analysis',
category: 'spamshield',
},
'spamshield.enable.community.intelligence': {
key: 'spamshield_enable_community_intelligence',
defaultValue: true,
description: 'Enable community intelligence sharing',
category: 'spamshield',
},
'spamshield.enable.real.time.blocking': {
key: 'spamshield_enable_real_time_blocking',
defaultValue: true,
description: 'Enable real-time spam blocking',
category: 'spamshield',
},
'spamshield.enable.multiple.sources': {
key: 'spamshield_enable_multiple_sources',
defaultValue: false,
description: 'Enable multiple reputation source aggregation (Truecaller, etc.)',
category: 'spamshield',
},
'spamshield.enable.ml.classifier': {
key: 'spamshield_enable_ml_classifier',
defaultValue: false,
description: 'Enable ML-based spam classification',
category: 'spamshield',
},
// VoicePrint Feature Flags
'voiceprint.enable.ml.service': {
key: 'voiceprint_enable_ml_service',
defaultValue: false,
description: 'Enable ML service integration for voice analysis',
category: 'voiceprint',
},
'voiceprint.enable.faiss.index': {
key: 'voiceprint_enable_faiss_index',
defaultValue: true,
description: 'Enable FAISS index for voice matching',
category: 'voiceprint',
},
'voiceprint.enable.batch.analysis': {
key: 'voiceprint_enable_batch_analysis',
defaultValue: true,
description: 'Enable batch voice analysis',
category: 'voiceprint',
},
'voiceprint.enable.realtime.analysis': {
key: 'voiceprint_enable_realtime_analysis',
defaultValue: false,
description: 'Enable real-time voice analysis',
category: 'voiceprint',
},
'voiceprint.enable.mock.model': {
key: 'voiceprint_enable_mock_model',
defaultValue: true,
description: 'Enable mock model for development',
category: 'voiceprint',
},
// General Platform Flags
'platform.enable.audit.logs': {
key: 'platform_enable_audit_logs',
defaultValue: true,
description: 'Enable comprehensive audit logging',
category: 'platform',
},
'platform.enable.kpi.tracking': {
key: 'platform_enable_kpi_tracking',
defaultValue: true,
description: 'Enable KPI snapshot tracking',
category: 'platform',
},
};
/**
* Create a resolver instance with the default flags
*/
export const featureFlagResolver = new FeatureFlagResolver(featureFlags);
/**
* Convenience function for quick flag checks
*/
export function isFeatureEnabled<T>(key: string, defaultValue: T): T {
return featureFlagResolver.isEnabled(key, defaultValue);
}
/**
* Check if a flag is enabled with type safety
*/
export function checkFlag<T>(key: string, defaultValue: T): T {
return featureFlagResolver.resolve(key, defaultValue);
}

View File

@@ -1,26 +0,0 @@
// Config
export {
spamShieldEnv,
SpamLayer,
SpamDecision,
ConfidenceLevel,
spamFeatureFlags,
spamRateLimits,
checkFlag,
isFeatureEnabled,
} from './spamshield.config';
// Feature flags
export * from './feature-flags';
// Services
export {
NumberReputationService,
SMSClassifierService,
CallAnalysisService,
SpamFeedbackService,
numberReputationService,
smsClassifierService,
callAnalysisService,
spamFeedbackService,
} from './spamshield.service';

View File

@@ -1,118 +0,0 @@
import { createHash } from 'crypto';
export type AuditClassificationType = 'sms' | 'call';
export interface AuditClassificationEntry {
id: string;
timestamp: string;
type: AuditClassificationType;
phoneNumberHash: string;
decision: 'spam' | 'ham' | 'block' | 'flag' | 'allow';
confidence: number;
reasons: string[];
featureFlags: Record<string, boolean>;
metadata?: Record<string, unknown>;
}
const MAX_AUDIT_LOG_SIZE = 10_000;
class AuditLogger {
private entries: AuditClassificationEntry[] = [];
logClassification(entry: Omit<AuditClassificationEntry, 'id' | 'timestamp'>): AuditClassificationEntry {
const record: AuditClassificationEntry = {
id: `audit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
timestamp: new Date().toISOString(),
...entry,
};
this.entries.push(record);
if (this.entries.length > MAX_AUDIT_LOG_SIZE) {
this.entries.shift();
}
console.log(
`[SpamShield:Audit] type=${record.type} decision=${record.decision} ` +
`confidence=${record.confidence.toFixed(3)} reasons=${record.reasons.join(',') || 'none'} ` +
`phoneHash=${record.phoneNumberHash}`
);
return record;
}
getEntries(
filters?: {
type?: AuditClassificationType;
decision?: string;
startDate?: Date;
endDate?: Date;
limit?: number;
}
): AuditClassificationEntry[] {
let results = this.entries;
if (filters?.type) {
results = results.filter(e => e.type === filters.type);
}
if (filters?.decision) {
results = results.filter(e => e.decision === filters.decision);
}
if (filters?.startDate) {
results = results.filter(e => new Date(e.timestamp) >= filters.startDate!);
}
if (filters?.endDate) {
results = results.filter(e => new Date(e.timestamp) <= filters.endDate!);
}
if (filters?.limit) {
results = results.slice(-filters.limit);
}
return results;
}
getSummary(): {
totalEntries: number;
spamCount: number;
hamCount: number;
blockCount: number;
flagCount: number;
allowCount: number;
avgConfidence: number;
} {
const spamCount = this.entries.filter(e => e.decision === 'spam' || e.decision === 'block').length;
const hamCount = this.entries.filter(e => e.decision === 'ham' || e.decision === 'allow').length;
const blockCount = this.entries.filter(e => e.decision === 'block').length;
const flagCount = this.entries.filter(e => e.decision === 'flag').length;
const allowCount = this.entries.filter(e => e.decision === 'allow').length;
const avgConfidence =
this.entries.length > 0
? this.entries.reduce((s, e) => s + e.confidence, 0) / this.entries.length
: 0;
return {
totalEntries: this.entries.length,
spamCount,
hamCount,
blockCount,
flagCount,
allowCount,
avgConfidence: Math.round(avgConfidence * 1000) / 1000,
};
}
clear(): void {
this.entries = [];
}
}
export const spamAuditLogger = new AuditLogger();
export function hashPhoneNumber(phoneNumber: string): string {
const hash = createHash('sha256').update(phoneNumber.trim()).digest('hex');
return `sha256_${hash}`;
}

View File

@@ -1,163 +0,0 @@
import { z } from 'zod';
import { checkFlag } from './feature-flags';
// Environment variables for SpamShield
const envSchema = z.object({
HIYA_API_KEY: z.string(),
HIYA_API_URL: z.string().default('https://api.hiya.com/v1'),
TRUECALLER_API_KEY: z.string().optional(),
BERT_MODEL_PATH: z.string().default('./models/spam-classifier'),
SPAM_THRESHOLD_AUTO_BLOCK: z.string().transform(Number).default(0.85),
SPAM_THRESHOLD_FLAG: z.string().transform(Number).default(0.6),
CALL_ANALYSIS_TIMEOUT_MS: z.string().transform(Number).default(200),
});
export const spamShieldEnv = envSchema.parse({
HIYA_API_KEY: process.env.HIYA_API_KEY,
HIYA_API_URL: process.env.HIYA_API_URL,
TRUECALLER_API_KEY: process.env.TRUECALLER_API_KEY,
BERT_MODEL_PATH: process.env.BERT_MODEL_PATH,
SPAM_THRESHOLD_AUTO_BLOCK: process.env.SPAM_THRESHOLD_AUTO_BLOCK,
SPAM_THRESHOLD_FLAG: process.env.SPAM_THRESHOLD_FLAG,
CALL_ANALYSIS_TIMEOUT_MS: process.env.CALL_ANALYSIS_TIMEOUT_MS,
});
// Spam detection layers
export enum SpamLayer {
NUMBER_REPUTATION = 'number_reputation',
CONTENT_CLASSIFICATION = 'content_classification',
BEHAVIORAL_ANALYSIS = 'behavioral_analysis',
COMMUNITY_INTELLIGENCE = 'community_intelligence',
}
// Spam decision types
export enum SpamDecision {
ALLOW = 'allow',
FLAG = 'flag',
BLOCK = 'block',
CHALLENGE = 'challenge',
}
// Confidence levels
export enum ConfidenceLevel {
LOW = 'low',
MEDIUM = 'medium',
HIGH = 'high',
VERY_HIGH = 'very_high',
}
// Feature flags for spam detection
// Use the centralized feature flag system from feature-flags.ts
// These are aliases for quick access
export const spamFeatureFlags = {
enableNumberReputation: checkFlag('spamshield.enable.number.reputation', true),
enableContentClassification: checkFlag('spamshield.enable.content.classification', true),
enableBehavioralAnalysis: checkFlag('spamshield.enable.behavioral.analysis', true),
enableCommunityIntelligence: checkFlag('spamshield.enable.community.intelligence', true),
enableRealTimeBlocking: checkFlag('spamshield.enable.real.time.blocking', true),
enableMultipleSources: checkFlag('spamshield.enable.multiple.sources', false),
enableMLClassifier: checkFlag('spamshield.enable.ml.classifier', false),
};
// Rate limits for spam analysis
export const spamRateLimits = {
basic: {
analysesPerMinute: 10,
analysesPerDay: 100,
},
plus: {
analysesPerMinute: 50,
analysesPerDay: 1000,
},
premium: {
analysesPerMinute: 200,
analysesPerDay: 10000,
},
};
// Default confidence scores for spam detection layers
export const defaultScores = {
// Number reputation service defaults
defaultReputationConfidence: 0.0,
defaultReputationLowConfidence: 0.1,
// SMS classifier defaults
defaultBaseConfidence: 0.5,
defaultMaxConfidence: 1.0,
// Feature weights for SMS classification
featureWeights: {
urlPresent: 0.1,
highEmojiDensity: 0.15,
urgencyKeyword: 0.2,
excessiveCaps: 0.15,
},
// Call analysis defaults
defaultSpamScore: 0.0,
highReputationThreshold: 0.7,
reputationWeightInCombinedScore: 0.4,
shortDurationScore: 0.2,
voipScore: 0.15,
unusualHoursScore: 0.1,
// Source combination weights
hiyaWeightInCombinedScore: 0.7,
truecallerWeightInCombinedScore: 0.3,
};
// Metadata size limits for SpamFeedback
export const metadataLimits = {
// Maximum size for metadata JSON in bytes
maxMetadataSizeBytes: 4096,
// Maximum number of keys in metadata object
maxMetadataKeys: 20,
// Maximum size for individual metadata value in bytes
maxMetadataValueSizeBytes: 512,
};
// Standard error codes for spamshield API
export enum SpamErrorCode {
// Client errors (4xx)
INVALID_REQUEST = 'INVALID_REQUEST',
MISSING_REQUIRED_FIELD = 'MISSING_REQUIRED_FIELD',
UNAUTHORIZED = 'UNAUTHORIZED',
NOT_FOUND = 'NOT_FOUND',
VALIDATION_ERROR = 'VALIDATION_ERROR',
// Server errors (5xx)
CLASSIFICATION_FAILED = 'CLASSIFICATION_FAILED',
REPUTATION_CHECK_FAILED = 'REPUTATION_CHECK_FAILED',
ANALYSIS_FAILED = 'ANALYSIS_FAILED',
FEEDBACK_RECORD_FAILED = 'FEEDBACK_RECORD_FAILED',
DATABASE_ERROR = 'DATABASE_ERROR',
RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED',
SERVICE_UNAVAILABLE = 'SERVICE_UNAVAILABLE',
}
// Standard error response type
export interface SpamErrorResponse {
error: {
code: SpamErrorCode;
message: string;
field?: string;
timestamp: string;
requestId?: string;
};
}
// HTTP status code constants
export const HttpStatus = {
OK: 200,
CREATED: 201,
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
UNPROCESSABLE_ENTITY: 422,
TOO_MANY_REQUESTS: 429,
INTERNAL_SERVER_ERROR: 500,
SERVICE_UNAVAILABLE: 503,
};

View File

@@ -1,118 +0,0 @@
import { FastifyReply } from 'fastify';
import { SpamErrorCode, HttpStatus, SpamErrorResponse } from './spamshield.config';
export { SpamErrorCode, HttpStatus };
export type { SpamErrorResponse };
/**
* Standardized error response builder for SpamShield API
*/
export class ErrorHandler {
/**
* Create a standard error response
*/
static create(
code: SpamErrorCode,
message: string,
options?: {
field?: string;
requestId?: string;
additionalData?: Record<string, unknown>;
}
): SpamErrorResponse {
return {
error: {
code,
message,
...(options?.field && { field: options.field }),
timestamp: new Date().toISOString(),
...(options?.requestId && { requestId: options.requestId }),
},
};
}
/**
* Send a standard error response with appropriate HTTP status code
*/
static send(
reply: FastifyReply,
code: SpamErrorCode,
message: string,
options?: {
field?: string;
status?: number;
requestId?: string;
}
): void {
const status = options?.status ?? this.getStatusForCode(code);
const errorResponse = this.create(code, message, {
field: options?.field,
requestId: options?.requestId,
});
reply.code(status).send(errorResponse);
}
/**
* Map error codes to HTTP status codes
*/
private static getStatusForCode(code: SpamErrorCode): number {
const statusMap: Record<SpamErrorCode, number> = {
// Client errors
[SpamErrorCode.INVALID_REQUEST]: HttpStatus.BAD_REQUEST,
[SpamErrorCode.MISSING_REQUIRED_FIELD]: HttpStatus.BAD_REQUEST,
[SpamErrorCode.UNAUTHORIZED]: HttpStatus.UNAUTHORIZED,
[SpamErrorCode.NOT_FOUND]: HttpStatus.NOT_FOUND,
[SpamErrorCode.VALIDATION_ERROR]: HttpStatus.BAD_REQUEST,
// Server errors
[SpamErrorCode.CLASSIFICATION_FAILED]: HttpStatus.UNPROCESSABLE_ENTITY,
[SpamErrorCode.REPUTATION_CHECK_FAILED]: HttpStatus.UNPROCESSABLE_ENTITY,
[SpamErrorCode.ANALYSIS_FAILED]: HttpStatus.UNPROCESSABLE_ENTITY,
[SpamErrorCode.FEEDBACK_RECORD_FAILED]: HttpStatus.UNPROCESSABLE_ENTITY,
[SpamErrorCode.DATABASE_ERROR]: HttpStatus.INTERNAL_SERVER_ERROR,
[SpamErrorCode.RATE_LIMIT_EXCEEDED]: HttpStatus.TOO_MANY_REQUESTS,
[SpamErrorCode.SERVICE_UNAVAILABLE]: HttpStatus.SERVICE_UNAVAILABLE,
};
return statusMap[code] ?? HttpStatus.INTERNAL_SERVER_ERROR;
}
/**
* Validate required string field
*/
static validateRequiredField(
value: unknown,
fieldName: string
): { isValid: boolean; error?: { code: SpamErrorCode; message: string; field: string } } {
if (!value || typeof value !== 'string' || value.trim() === '') {
return {
isValid: false,
error: {
code: SpamErrorCode.MISSING_REQUIRED_FIELD,
message: `${fieldName} is required`,
field: fieldName,
},
};
}
return { isValid: true };
}
/**
* Validate boolean field
*/
static validateBooleanField(
value: unknown,
fieldName: string
): { isValid: boolean; error?: { code: SpamErrorCode; message: string; field: string } } {
if (value === undefined || value === null || typeof value !== 'boolean') {
return {
isValid: false,
error: {
code: SpamErrorCode.VALIDATION_ERROR,
message: `${fieldName} must be a boolean`,
field: fieldName,
},
};
}
return { isValid: true };
}
}

View File

@@ -1,481 +0,0 @@
import { prisma, SpamFeedback } from '@shieldai/db';
import { spamShieldEnv, SpamDecision, spamFeatureFlags, defaultScores, metadataLimits } from './spamshield.config';
import { createHash } from 'crypto';
import { spamAuditLogger, hashPhoneNumber } from './spamshield.audit-logger';
// Number reputation service (Hiya API integration)
export class NumberReputationService {
/**
* Check number reputation using Hiya API
*/
async checkReputation(phoneNumber: string): Promise<{
isSpam: boolean;
confidence: number;
spamType?: string;
reportCount: number;
}> {
try {
// Only enable if feature flag is set
if (!spamFeatureFlags.enableNumberReputation) {
return {
isSpam: false,
confidence: 0.0,
reportCount: 0,
};
}
// TODO: Integrate with Hiya API
// const response = await fetch(`${spamShieldEnv.HIYA_API_URL}/lookup`, {
// headers: { 'X-API-Key': spamShieldEnv.HIYA_API_KEY },
// method: 'POST',
// body: JSON.stringify({ phone: phoneNumber }),
// });
// Simulated response for now
return {
isSpam: false,
confidence: defaultScores.defaultReputationLowConfidence,
spamType: undefined,
reportCount: 0,
};
} catch (error) {
console.error('Error checking number reputation:', error);
return {
isSpam: false,
confidence: defaultScores.defaultReputationConfidence,
reportCount: 0,
};
}
}
/**
* Check number against multiple reputation sources
*/
async checkMultiSource(phoneNumber: string): Promise<{
hiya: { isSpam: boolean; confidence: number };
truecaller: { isSpam: boolean; confidence: number } | null;
combinedScore: number;
}> {
// Only enable if feature flag is set
if (!spamFeatureFlags.enableMultipleSources) {
return {
hiya: { isSpam: false, confidence: defaultScores.defaultReputationConfidence },
truecaller: null,
combinedScore: defaultScores.defaultSpamScore,
};
}
const hiyaResult = await this.checkReputation(phoneNumber);
let truecallerResult: { isSpam: boolean; confidence: number } | null = null;
if (spamShieldEnv.TRUECALLER_API_KEY) {
// TODO: Integrate Truecaller
truecallerResult = {
isSpam: false,
confidence: defaultScores.defaultReputationConfidence,
};
}
// Weighted average: Hiya 70%, Truecaller 30%
const combinedScore = hiyaResult.confidence * defaultScores.hiyaWeightInCombinedScore +
(truecallerResult?.confidence ?? defaultScores.defaultReputationConfidence) * defaultScores.truecallerWeightInCombinedScore;
return {
hiya: { isSpam: hiyaResult.isSpam, confidence: hiyaResult.confidence },
truecaller: truecallerResult,
combinedScore,
};
}
}
// SMS content classifier (BERT-based)
export class SMSClassifierService {
private model: any = null; // BERT model placeholder
private _initPromise: Promise<void> | null = null;
/**
* Initialize the BERT model (thread-safe via promise deduplication)
*/
async initialize(): Promise<void> {
// TODO: Load BERT model from path
// this.model = await loadBERTModel(spamShieldEnv.BERT_MODEL_PATH);
console.log('SMS classifier initialized');
}
/**
* Ensures model is initialized before use. Concurrent callers
* await the same initialization promise to avoid race conditions.
*/
private async ensureInitialized(): Promise<void> {
if (this._initPromise) {
return this._initPromise;
}
this._initPromise = (async () => {
if (this.model) {
return;
}
await this.initialize();
})();
return this._initPromise;
}
/**
* Classify SMS text as spam or ham
*/
async classify(
smsText: string,
phoneNumber?: string
): Promise<{
isSpam: boolean;
confidence: number;
spamFeatures: string[];
}> {
// Only enable if feature flag is set
if (!spamFeatureFlags.enableMLClassifier) {
// Return basic feature-based classification
const features = this.extractFeatures(smsText);
const confidence = this.calculateConfidence(features);
const isSpam = confidence >= spamShieldEnv.SPAM_THRESHOLD_AUTO_BLOCK;
spamAuditLogger.logClassification({
type: 'sms',
phoneNumberHash: phoneNumber ? hashPhoneNumber(phoneNumber) : 'unknown',
decision: isSpam ? 'spam' : 'ham',
confidence,
reasons: features,
featureFlags: { enableMLClassifier: spamFeatureFlags.enableMLClassifier },
});
return {
isSpam,
confidence,
spamFeatures: features,
};
}
await this.ensureInitialized();
// Extract features
const features = this.extractFeatures(smsText);
// TODO: Run through BERT model
// const prediction = await this.model.predict(smsText);
// Simulated prediction
const confidence = this.calculateConfidence(features);
const isSpam = confidence >= spamShieldEnv.SPAM_THRESHOLD_AUTO_BLOCK;
spamAuditLogger.logClassification({
type: 'sms',
phoneNumberHash: phoneNumber ? hashPhoneNumber(phoneNumber) : 'unknown',
decision: isSpam ? 'spam' : 'ham',
confidence,
reasons: features,
featureFlags: { enableMLClassifier: spamFeatureFlags.enableMLClassifier },
});
return {
isSpam,
confidence,
spamFeatures: features,
};
}
private extractFeatures(text: string): string[] {
const features: string[] = [];
const lowerText = text.toLowerCase();
// URL presence
if (/(http|www)\./i.test(text)) {
features.push('url_present');
}
// Emoji density
const emojiCount = (text.match(/[\p{Emoji}]/gu) || []).length;
if (emojiCount / text.length > 0.1) {
features.push('high_emoji_density');
}
// Urgency keywords
const urgencyWords = ['now', 'urgent', 'limited', 'act fast', 'today'];
if (urgencyWords.some(word => lowerText.includes(word))) {
features.push('urgency_keyword');
}
// Excessive capitalization
if (/[A-Z]{3,}/.test(text)) {
features.push('excessive_caps');
}
return features;
}
private calculateConfidence(features: string[]): number {
const baseConfidence = defaultScores.defaultBaseConfidence;
const featureWeights: Record<string, number> = {
url_present: defaultScores.featureWeights.urlPresent,
high_emoji_density: defaultScores.featureWeights.highEmojiDensity,
urgency_keyword: defaultScores.featureWeights.urgencyKeyword,
excessive_caps: defaultScores.featureWeights.excessiveCaps,
};
return Math.min(defaultScores.defaultMaxConfidence, baseConfidence +
features.reduce((sum, f) => sum + (featureWeights[f] || 0), 0));
}
}
// Call analysis service
export class CallAnalysisService {
/**
* Analyze incoming call for spam indicators
*/
async analyzeCall(callData: {
phoneNumber: string;
duration?: number;
callTime: Date;
isVoip?: boolean;
}): Promise<{
decision: SpamDecision;
confidence: number;
reasons: string[];
}> {
const reasons: string[] = [];
let spamScore = defaultScores.defaultSpamScore;
// Number reputation check - only if feature flag enabled
if (spamFeatureFlags.enableBehavioralAnalysis) {
const reputationService = new NumberReputationService();
const reputation = await reputationService.checkMultiSource(callData.phoneNumber);
if (reputation.combinedScore > defaultScores.highReputationThreshold) {
spamScore += reputation.combinedScore * defaultScores.reputationWeightInCombinedScore;
reasons.push('high_spam_reputation');
}
}
// Behavioral analysis - only if feature flag enabled
if (spamFeatureFlags.enableBehavioralAnalysis) {
if (callData.duration && callData.duration < 10) {
spamScore += defaultScores.shortDurationScore;
reasons.push('short_duration');
}
if (callData.isVoip) {
spamScore += defaultScores.voipScore;
reasons.push('voip_number');
}
// Time-of-day anomaly (simplified)
const hour = callData.callTime.getHours();
if (hour < 6 || hour > 22) {
spamScore += defaultScores.unusualHoursScore;
reasons.push('unusual_hours');
}
}
// Determine decision
let decision: SpamDecision;
if (spamScore >= spamShieldEnv.SPAM_THRESHOLD_AUTO_BLOCK) {
decision = SpamDecision.BLOCK;
} else if (spamScore >= spamShieldEnv.SPAM_THRESHOLD_FLAG) {
decision = SpamDecision.FLAG;
} else {
decision = SpamDecision.ALLOW;
}
spamAuditLogger.logClassification({
type: 'call',
phoneNumberHash: hashPhoneNumber(callData.phoneNumber),
decision: decision.toLowerCase() as 'block' | 'flag' | 'allow',
confidence: spamScore,
reasons,
featureFlags: {
enableBehavioralAnalysis: spamFeatureFlags.enableBehavioralAnalysis,
enableNumberReputation: spamFeatureFlags.enableNumberReputation,
},
metadata: {
duration: callData.duration,
isVoip: callData.isVoip,
callTime: callData.callTime.toISOString(),
},
});
return {
decision,
confidence: spamScore,
reasons,
};
}
}
// User feedback service
export class SpamFeedbackService {
/**
* Validate metadata size against defined limits
*/
private validateMetadata(metadata?: Record<string, any>): {
isValid: boolean;
trimmedMetadata?: Record<string, any>;
reasons?: string[];
} {
if (!metadata) {
return { isValid: true };
}
const reasons: string[] = [];
let trimmedMetadata: Record<string, any> = metadata;
// Check number of keys
const keyCount = Object.keys(metadata).length;
if (keyCount > metadataLimits.maxMetadataKeys) {
reasons.push(`Metadata has ${keyCount} keys, exceeding limit of ${metadataLimits.maxMetadataKeys}`);
trimmedMetadata = Object.entries(metadata).slice(0, metadataLimits.maxMetadataKeys);
}
// Check total JSON size
const jsonSize = JSON.stringify(metadata).length;
if (jsonSize > metadataLimits.maxMetadataSizeBytes) {
reasons.push(`Metadata size ${jsonSize} bytes exceeds limit of ${metadataLimits.maxMetadataSizeBytes} bytes`);
// Truncate long values
trimmedMetadata = Object.fromEntries(
Object.entries(metadata).map(([key, value]) => {
const valueStr = String(value);
if (valueStr.length > metadataLimits.maxMetadataValueSizeBytes) {
return [key, valueStr.slice(0, metadataLimits.maxMetadataValueSizeBytes)];
}
return [key, value];
})
);
}
return {
isValid: reasons.length === 0,
trimmedMetadata,
reasons: reasons.length > 0 ? reasons : undefined,
};
}
/**
* Record user feedback on spam detection
*/
async recordFeedback(
userId: string,
phoneNumber: string,
isSpam: boolean,
confidence?: number,
metadata?: Record<string, any>
): Promise<SpamFeedback> {
// Defensive null checks for required fields
if (!userId || typeof userId !== 'string' || userId.trim().length === 0) {
throw new Error('Feedback: userId is required');
}
if (!phoneNumber || typeof phoneNumber !== 'string' || phoneNumber.trim().length === 0) {
throw new Error('Feedback: phoneNumber is required');
}
if (typeof isSpam !== 'boolean') {
throw new Error('Feedback: isSpam must be a boolean');
}
// Validate confidence range if provided
const validatedConfidence = confidence !== undefined && confidence !== null
? (Number.isFinite(confidence) && confidence >= 0 && confidence <= 1 ? confidence : undefined)
: undefined;
// Treat null metadata as undefined
const effectiveMetadata = metadata !== null ? metadata : undefined;
const validation = this.validateMetadata(effectiveMetadata);
const validatedMetadata = validation.trimmedMetadata;
// Only enable if feature flag is set
if (!spamFeatureFlags.enableCommunityIntelligence) {
// Return a mock feedback for development
return {
id: `mock_${Date.now()}`,
userId,
phoneNumber,
phoneNumberHash: this.hashPhoneNumber(phoneNumber),
isSpam,
confidence: validatedConfidence,
feedbackType: 'user_confirmation' as const,
metadata: validatedMetadata,
createdAt: new Date(),
updatedAt: new Date(),
};
}
const phoneNumberHash = this.hashPhoneNumber(phoneNumber);
const feedback = await prisma.spamFeedback.create({
data: {
userId,
phoneNumber,
phoneNumberHash,
isSpam,
confidence: validatedConfidence,
feedbackType: 'user_confirmation',
metadata: validatedMetadata,
},
});
return feedback;
}
/**
* Get spam history for a user
*/
async getSpamHistory(
userId: string,
options?: {
limit?: number;
isSpam?: boolean;
startDate?: Date;
}
): Promise<SpamFeedback[]> {
return prisma.spamFeedback.findMany({
where: {
userId,
...(options?.isSpam !== undefined && { isSpam: options.isSpam }),
...(options?.startDate && { createdAt: { gte: options.startDate } }),
},
orderBy: { createdAt: 'desc' },
take: options?.limit ?? 100,
});
}
/**
* Get statistics for a user
*/
async getStatistics(userId: string): Promise<{
totalAnalyses: number;
spamCount: number;
hamCount: number;
spamPercentage: number;
}> {
const [total, spam] = await Promise.all([
prisma.spamFeedback.count({ where: { userId } }),
prisma.spamFeedback.count({ where: { userId, isSpam: true } }),
]);
return {
totalAnalyses: total,
spamCount: spam,
hamCount: total - spam,
spamPercentage: total > 0 ? (spam / total) * 100 : 0,
};
}
private hashPhoneNumber(phoneNumber: string): string {
// SHA-256 hash for phone number fingerprinting
const hash = createHash('sha256').update(phoneNumber).digest('hex');
return `sha256_${hash}`;
}
}
// Export instances
export const numberReputationService = new NumberReputationService();
export const smsClassifierService = new SMSClassifierService();
export const callAnalysisService = new CallAnalysisService();
export const spamFeedbackService = new SpamFeedbackService();

View File

@@ -1,192 +0,0 @@
import { spawn } from "child_process";
import { logger } from './logger';
import { voicePrintEnv } from './voiceprint.config';
const EMBEDDING_DIM = 192;
const MODEL_VERSION = "ecapa-tdnn-0.1.0-mock";
export class EmbeddingService {
private mlServiceUrl: string;
private initialized = false;
constructor() {
this.mlServiceUrl = process.env.VOICEPRINT_ML_URL || "http://localhost:8001";
}
async initialize(): Promise<void> {
if (this.initialized) return;
this.initialized = true;
logger.info('Embedding service initialized', { mlUrl: this.mlServiceUrl, modelVersion: MODEL_VERSION });
}
async extract(audioBuffer: Buffer): Promise<number[]> {
await this.initialize();
const mlAvailable = await this.checkMLService();
if (mlAvailable) {
logger.info('Using ML service for embedding', { mlUrl: this.mlServiceUrl });
return this.extractViaML(audioBuffer);
}
logger.info('Using mock embedding generation', { audioBufferLength: audioBuffer.length });
return this.generateMockFromBuffer(audioBuffer);
}
async analyze(audioBuffer: Buffer): Promise<{
confidence: number;
detectionType: string;
features: Record<string, number>;
embedding: number[];
}> {
const embedding = await this.extract(audioBuffer);
const confidence = this.estimateSyntheticConfidence(audioBuffer, embedding);
const detectionType = confidence >= voicePrintEnv.SYNTHETIC_THRESHOLD ? 'synthetic_voice' : 'natural';
const features = this.extractAnalysisFeatures(audioBuffer, embedding);
return { confidence, detectionType, features, embedding };
}
getModelVersion(): string {
return MODEL_VERSION;
}
private async extractViaML(audioBuffer: Buffer): Promise<number[]> {
return new Promise((resolve, reject) => {
const jsonInput = audioBuffer.toString("base64");
const proc = spawn("python3", [
"-c",
`
import urllib.request, json, sys
req = urllib.request.Request(
"${this.mlServiceUrl}/embedding",
data=json.dumps({"audio": "${jsonInput.substring(0, 5000)}"}).encode(),
headers={"Content-Type": "application/json"}
)
try:
with urllib.request.urlopen(req, timeout=60) as resp:
data = json.loads(resp.read())
sys.stdout.write(json.dumps({"ok": True, "vector": data.get("embedding", []), "dim": data.get("dimension", ${EMBEDDING_DIM})}))
except Exception as e:
sys.stdout.write(json.dumps({"ok": False, "error": str(e)}))
`,
]);
let output = "";
proc.stdout.on("data", (chunk) => { output += chunk.toString(); });
proc.on("close", (code) => {
try {
const result = JSON.parse(output);
if (result.ok && result.vector && result.vector.length === EMBEDDING_DIM) {
resolve(result.vector);
} else {
resolve(this.generateMockFromBuffer(audioBuffer));
}
} catch {
resolve(this.generateMockFromBuffer(audioBuffer));
}
});
});
}
private generateMockFromBuffer(audioBuffer: Buffer): number[] {
let hash = 0;
const sampleSize = Math.min(audioBuffer.length, 1024);
for (let i = 0; i < sampleSize; i += 4) {
hash = ((hash << 5) - hash + audioBuffer.readInt32LE(i)) | 0;
}
const seed = Math.abs(hash);
const rng = this.createRNG(seed);
const vector: number[] = [];
// Box-Muller transform for Gaussian distribution
for (let i = 0; i < EMBEDDING_DIM; i += 2) {
const u1 = rng();
const u2 = rng();
const mag = Math.sqrt(-2 * Math.log(u1));
const z0 = mag * Math.cos(2 * Math.PI * u2);
const z1 = mag * Math.sin(2 * Math.PI * u2);
vector.push(parseFloat(z0.toFixed(6)));
if (i + 1 < EMBEDDING_DIM) {
vector.push(parseFloat(z1.toFixed(6)));
}
}
// L2 normalize
const norm = Math.sqrt(vector.reduce((s, v) => s + v * v, 0));
return vector.map((v) => parseFloat((v / norm).toFixed(6)));
}
private estimateSyntheticConfidence(buffer: Buffer, embedding: number[]): number {
const meanAmplitude = buffer.reduce((s, v) => s + v, 0) / buffer.length / 255;
const meanEmbedding = embedding.reduce((s, v) => s + v, 0) / embedding.length;
const embeddingStdDev = Math.sqrt(embedding.reduce((s, v) => s + (v - meanEmbedding) ** 2, 0) / embedding.length);
const amplitudeScore = Math.abs(meanAmplitude - 0.5) * 2;
const embeddingScore = 1.0 - Math.min(1.0, embeddingStdDev * 2);
const varianceScore = Math.min(1.0, buffer.length / 10000);
return Math.min(1.0, amplitudeScore * 0.3 + embeddingScore * 0.4 + varianceScore * 0.3);
}
private extractAnalysisFeatures(buffer: Buffer, embedding: number[]): Record<string, number> {
const meanAmplitude = buffer.reduce((s, v) => s + v, 0) / buffer.length / 255;
const zeroCrossings = buffer.reduce((count, v, i, arr) => {
return i > 0 && ((v - 128) * (arr[i - 1] - 128) < 0) ? count + 1 : count;
}, 0);
return {
mean_amplitude: meanAmplitude,
zero_crossing_rate: zeroCrossings / buffer.length,
embedding_energy: embedding.reduce((s, v) => s + v * v, 0),
embedding_entropy: this.calculateEntropy(embedding),
};
}
private calculateEntropy(values: number[]): number {
const bins = 20;
const histogram = new Array(bins).fill(0);
const min = Math.min(...values);
const max = Math.max(...values);
const range = max - min || 1;
for (const v of values) {
const bin = Math.min(bins - 1, Math.floor(((v - min) / range) * bins));
histogram[bin]++;
}
let entropy = 0;
const total = values.length;
for (const count of histogram) {
if (count > 0) {
const p = count / total;
entropy -= p * Math.log2(p);
}
}
return entropy;
}
private async checkMLService(): Promise<boolean> {
return new Promise((resolve) => {
const proc = spawn("python3", [
"-c",
`
import urllib.request, sys
try:
urllib.request.urlopen("${this.mlServiceUrl}/health", timeout=2)
sys.exit(0)
except:
sys.exit(1)
`,
]);
proc.on("close", (code) => resolve(code === 0));
});
}
private createRNG(seed: number): () => number {
return () => {
seed = (seed * 1664525 + 1013904223) & 0xffffffff;
return (seed >>> 0) / 0xffffffff;
};
}
}

View File

@@ -1,93 +0,0 @@
import { logger } from './logger';
import { voicePrintEnv } from './voiceprint.config';
export class FAISSIndex {
private store: Map<string, number[]> = new Map();
private readonly indexPath: string;
private initialized = false;
constructor(path?: string) {
this.indexPath = path ?? voicePrintEnv.FAISS_INDEX_PATH;
}
async initialize(): Promise<void> {
if (this.initialized) return;
await this.loadFromDatabase();
this.initialized = true;
logger.info('FAISS index initialized', { indexPath: this.indexPath, enrollmentCount: this.store.size });
}
async add(enrollmentId: string, embedding: number[]): Promise<void> {
await this.initialize();
const normalized = [...embedding];
this.normalizeInPlace(normalized);
this.store.set(enrollmentId, normalized);
logger.info('Added enrollment to FAISS index', { enrollmentId });
}
async remove(enrollmentId: string): Promise<void> {
await this.initialize();
this.store.delete(enrollmentId);
logger.info('Removed enrollment from FAISS index', { enrollmentId });
}
async search(embedding: number[], topK: number = 5): Promise<Array<{ id: string; similarity: number }>> {
await this.initialize();
const normalized = [...embedding];
this.normalizeInPlace(normalized);
const scores: Array<{ id: string; similarity: number }> = [];
for (const [id, vector] of this.store.entries()) {
const similarity = this.cosineSimilarity(normalized, vector);
scores.push({ id, similarity });
}
scores.sort((a, b) => b.similarity - a.similarity);
return scores.slice(0, topK);
}
async save(): Promise<void> {
await this.initialize();
logger.info('FAISS index saved', { indexPath: this.indexPath, count: this.store.size });
}
private async loadFromDatabase(): Promise<void> {
try {
const { prisma } = await import('@shieldai/db');
const enrollments = await prisma.voiceEnrollment.findMany({
select: { id: true, voiceHash: true },
});
// Note: voiceHash is stored, not the actual embedding vector
// In production, we'd store the full embedding vector
logger.info('Loaded enrollments from database', { count: enrollments.length });
} catch (error) {
logger.warn('Failed to load enrollments from database', { error: error instanceof Error ? error.message : String(error) });
}
}
private cosineSimilarity(a: number[], b: number[]): number {
let dotProduct = 0;
let normA = 0;
let normB = 0;
for (let i = 0; i < a.length; i++) {
dotProduct += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
const denominator = Math.sqrt(normA) * Math.sqrt(normB);
return denominator > 0 ? dotProduct / denominator : 0;
}
private normalizeInPlace(vector: number[]): void {
const norm = Math.sqrt(vector.reduce((s, v) => s + v * v, 0));
if (norm > 0) {
for (let i = 0; i < vector.length; i++) {
vector[i] /= norm;
}
}
}
}

View File

@@ -1,31 +0,0 @@
// Config
export {
voicePrintEnv,
VoicePrintSource,
AnalysisJobStatus,
DetectionType,
ConfidenceLevel,
audioPreprocessingConfig,
voicePrintFeatureFlags,
voicePrintRateLimits,
} from './voiceprint.config';
// Feature flags
export { checkFlag, isFeatureEnabled } from './voiceprint.feature-flags';
// Services
export {
AudioPreprocessor,
VoiceEnrollmentService,
AnalysisService,
BatchAnalysisService,
EmbeddingService,
FAISSIndex,
audioPreprocessor,
voiceEnrollmentService,
analysisService,
batchAnalysisService,
embeddingService,
} from './voiceprint.service';

View File

@@ -1,36 +0,0 @@
import { FastifyLoggerOptions } from 'fastify';
export interface Logger {
info(message: string, context?: Record<string, unknown>): void;
warn(message: string, context?: Record<string, unknown>): void;
error(message: string, context?: Record<string, unknown>): void;
debug(message: string, context?: Record<string, unknown>): void;
}
export class ConsoleLogger implements Logger {
info(message: string, context?: Record<string, unknown>): void {
const timestamp = new Date().toISOString();
const logContext = context ? ` ${JSON.stringify(context)}` : '';
console.log(`[${timestamp}] [INFO] ${message}${logContext}`);
}
warn(message: string, context?: Record<string, unknown>): void {
const timestamp = new Date().toISOString();
const logContext = context ? ` ${JSON.stringify(context)}` : '';
console.warn(`[${timestamp}] [WARN] ${message}${logContext}`);
}
error(message: string, context?: Record<string, unknown>): void {
const timestamp = new Date().toISOString();
const logContext = context ? ` ${JSON.stringify(context)}` : '';
console.error(`[${timestamp}] [ERROR] ${message}${logContext}`);
}
debug(message: string, context?: Record<string, unknown>): void {
const timestamp = new Date().toISOString();
const logContext = context ? ` ${JSON.stringify(context)}` : '';
console.debug(`[${timestamp}] [DEBUG] ${message}${logContext}`);
}
}
export const logger = new ConsoleLogger();

Some files were not shown because too many files have changed in this diff Show More