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:
12
.editorconfig
Normal file
12
.editorconfig
Normal 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
|
||||
25
.github/workflows/ci.yml
vendored
25
.github/workflows/ci.yml
vendored
@@ -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
3
.gitignore
vendored
@@ -1,7 +1,8 @@
|
||||
node_modules
|
||||
dist
|
||||
.output
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
load-tests/voiceprint/results/
|
||||
.turbo
|
||||
.nitro
|
||||
|
||||
38
Dockerfile
38
Dockerfile
@@ -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"]
|
||||
@@ -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);
|
||||
@@ -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
|
||||
@@ -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:
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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/
|
||||
@@ -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!,
|
||||
},
|
||||
});
|
||||
21
index.html
21
index.html
@@ -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
9
infra/.gitignore
vendored
@@ -1,9 +0,0 @@
|
||||
.terraform/
|
||||
*.tfstate
|
||||
*.tfstate.backup
|
||||
*.tfvars
|
||||
.terraform.lock.hcl
|
||||
override.tf
|
||||
override.tf.json
|
||||
*_override.tf
|
||||
*_override.tf.json
|
||||
113
infra/README.md
113
infra/README.md
@@ -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 |
|
||||
@@ -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 |
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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';
|
||||
}
|
||||
113
infra/main.tf
113
infra/main.tf
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 "$@"
|
||||
@@ -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 "$@"
|
||||
@@ -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 "$@"
|
||||
@@ -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 "$@"
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
5
load-tests/darkwatch-auth/.gitignore
vendored
5
load-tests/darkwatch-auth/.gitignore
vendored
@@ -1,5 +0,0 @@
|
||||
# k6 load test results
|
||||
results/
|
||||
|
||||
# Local environment overrides
|
||||
.env
|
||||
@@ -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`,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
23
package.json
23
package.json
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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) },
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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' }
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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';
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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
Reference in New Issue
Block a user