Compare commits

...

21 Commits

Author SHA1 Message Date
f118d3a4f3 more package declarations 2026-05-17 21:52:38 -04:00
a8a5930ced security: fix 10 security review findings (FRE-4572)
CRITICAL:
- SEC-001: Auth tokens now stored in SecureStore (Keychain/Keystore)
- SEC-002: Biometric bypass removed - alerts user and disables when unavailable

HIGH:
- SEC-003: Push projectId moved to EXPO_PUBLIC_EAS_PROJECT_ID env var
- SEC-004: Token refresh mechanism added with refreshSession/hydrateTokens
- SEC-005: debug already gated on __DEV__ (confirmed)

MEDIUM:
- SEC-006: All PII stores (darkwatch, voiceprint, spamshield, settings, auth) now use encrypted AsyncStorage
- SEC-007: Certificate pinning documented with TODO for production
- SEC-008: Login brute force protection: 5 attempts then 5-minute lockout

LOW:
- SEC-009: Watch list input validation with format checks per entity type
- SEC-010: Upgrade Plan button shows billing coming soon alert
2026-05-17 19:15:42 -04:00
06ca3ec0cf Fix Mixpanel analytics review findings FRE-5281
P0: Fix validation bypass - validated properties now override raw properties
P1: Add unit tests for shared-analytics package (3 test files)
P1: Refactor spamshield to use shared-analytics, deprecate duplicate
P2: Normalize phone numbers to E.164 before hashing
P2: Add graceful error handling for missing env vars in config
P3: Add singleton pattern to MixpanelService
P3: Include timestamp in validated properties schema
2026-05-17 15:37:21 -04:00
986941e201 feat: add iOS and Android screenshot capture guides (FRE-4572) 2026-05-17 11:39:41 -04:00
6a8d3648d8 feat: add EAS build config and app store asset structure (FRE-4572)
- Create eas.json with development, preview, and production build profiles
- Add submit configuration for iOS App Store and Google Play
- Create app store metadata with listing copy, keywords, and requirements
- Add screenshot capture guides for iOS and Android
- Add marketing asset directory structure
2026-05-17 11:38:29 -04:00
64b70073ec Fix uuid dependency and silent error swallowing FRE-4572
- Replace uuid package with expo-crypto randomUUID (already a dependency)
- Add error logging to darkWatchStore refreshExposures catch block
- TypeScript compiles clean
2026-05-17 11:12:44 -04:00
90a223bc79 fix: address code review findings for mobile app (FRE-4572)
P0 fixes:
- Replace crypto.randomUUID() with uuid v4 (not available in RN)
- Replace Platform.Version with expo-device osVersion
- Fix auth navigation types, remove unused App route

P1 fixes:
- Push notification handler respects user preferences (useRef pattern)
- Fix stale closure: use zustand subscribe + useRef for live preferences
- Add retry logging for device registration failures
- Replace emoji tab icons with @expo/vector-icons Ionicons
- Document API integration TODOs in all local-only stores

P2 fixes:
- Add __DEV__ global declaration (global.d.ts)
- Fix package.json main field to expo/AppEntry.js
- Add retry logging for push device registration
- Add z-index/elevation to LoadingOverlay
- Add visual indicator to EmptyState icon

P3 fixes:
- Type navigation with NavigationProp<RootStackParamList>
- Move getSeverityColor to theme.ts (single source of truth)
- Add useMemo for SpamShield filter computations
- Verified usesNonExemptEncryption: false is correct for expo-secure-store

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-17 10:51:14 -04:00
a071aa736e feat: scaffold ShieldAI React Native mobile app MVP (FRE-4572)
Build complete Expo/React Native mobile app with:
- Auth flow: email/password login, registration, biometric auth
- Dashboard: exposure summary, spam stats, voice protection status
- DarkWatch: watch list management, exposure feed, alert toggles
- SpamShield: call/text history, whitelist/blacklist management
- VoicePrint: family member enrollment, voice analysis
- Settings: tier management, notification preferences, security
- Push notification integration via FCM/APNs
- Offline-first state management with Zustand + AsyncStorage
- Integration with @shieldai/mobile-api-client for API services
- React Navigation with auth-aware routing (stack + bottom tabs)
- Dark theme with consistent design system (colors, spacing, typography)
- Network status monitoring and offline request queuing

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-17 10:12:46 -04:00
Founding Engineer
7fb8b83810 Fix open redirect in Stripe customer portal returnUrl (FRE-5399)
- Add isValidReturnUrl validation at route level for fast rejection
- Add defense-in-depth validation in BillingService.createCustomerPortalSession
- Fix isValidReturnUrl bug: origin comparison was never reached due to
  incorrect protocol check, allowing substring attacks (e.g., app.shieldai.com.evil.com)
- Export isValidReturnUrl from shared-billing package index
- Add unit tests for all attack vectors

Files changed:
- packages/api/src/routes/subscription.routes.ts
- packages/shared-billing/src/services/billing.service.ts
- packages/shared-billing/src/config/billing.config.ts
- packages/shared-billing/src/index.ts
- packages/shared-billing/src/__tests__/billing.config.test.ts
2026-05-17 05:39:13 -04:00
e72a0ba5cf Fix FRE-5402: Add missing @shieldai/removebrokers dependency and fix compilation blockers
- Add @shieldai/removebrokers workspace dependency to API package.json
- Fix misleading error message: 'Admin access required' -> 'Support access required'
- Export RemovalRequest, InfoBroker, BrokerListing types from @shieldai/db
- Export RemovalStatus, RemovalMethod, BrokerCategory enums from @shieldai/db
- Fix BrokerAlertPipeline: correlationPipeline -> correlationService.ingestGenericAlert
- Add @shieldai/correlation dependency to removebrokers package
- Fix removalUrl null vs undefined type mismatch in RemoveBrokersService
- Fix shared-billing package.json typo: @shieldsai -> @shieldai for shared-notifications
2026-05-17 03:07:22 -04:00
7410813f4e Fix review findings for info broker removal service FRE-5402
P0 fixes:
- Add CANCELLED status to RemovalStatus enum (types + Prisma schema)
- Use CANCELLED instead of REJECTED for user-initiated cancellations
- Add null guard for req.broker?.name in GET /request/:id
- Remove unsafe 'as any' casts in RemoveBrokersService.ts
- Add type-safe toPersonalInfo() validator for JSON deserialization
- Type RemovalRequestWithBroker properly in getRemovalStatus()
- Fix alert: any to NormalizedAlertInput in BrokerAlertPipeline

P1 fixes:
- Fix admin role check: remove non-existent 'admin', only check 'support'
- Fix BrokerDefinition.category type from string to BrokerCategory
- Add complete OpenAPI spec for all removebrokers routes and schemas
2026-05-17 02:30:00 -04:00
e9e547be78 fix: address code review findings for info broker removal service
- Fix Prisma enum casing: snake_case -> UPPERCASE to match TypeScript types
- Add admin auth guard on POST /process endpoint (P0 security)
- Fix DELETE /request/:id to return valid enum status (REJECTED not cancelled)
- Fix brokerName bug: was set to brokerId, now resolves actual broker name
- Add missing BrokerCategory enum export to types package
- Add HOME_TITLE to AlertSource enum
- Replace unsafe 'as any' casts with proper enum imports
- Fix broker ID with space (familytree Now -> familytreenow)
- Add missing Prisma relation fields for RemovalRequest and BrokerListing
- Add FALSE_POSITIVE to CorrelationStatus enum

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-17 01:45:54 -04:00
Founding Engineer
bd881045f4 Add Info Broker Removal service (FRE-5402)
New service for helping clients remove personal listings from data broker sites.

Service features:
- BrokerRegistry: Catalog of 20+ data brokers with removal methods
- RemoveBrokersService: Core service for scanning, creating removal requests,
  submitting removals, and verifying completions
- RemoveBrokersScheduler: Automated processing of pending removals and
  verification of completed removals
- BrokerAlertPipeline: Alert integration for listing discoveries and removal status

API endpoints (/removebrokers):
- GET /brokers - List available data brokers
- GET /status - Get removal request status and stats
- POST /scan - Scan for personal listings across brokers
- POST /request - Create a new removal request
- GET /request/:id - Get specific removal request details
- DELETE /request/:id - Cancel a removal request
- POST /process - Trigger processing of pending removals
- POST /verify/:id - Manually verify a removal completion

DB models: InfoBroker, RemovalRequest, BrokerListing
Types: BrokerStatus, RemovalStatus, RemovalMethod, and related interfaces
2026-05-17 00:58:23 -04:00
590e15e66e Fix code review findings for FCM/APNs push notifications (FRE-5345)
- P0: Add missing jwt import (remove duplicate getAPNSToken from push.service.ts)
- P0: Fix race condition in getFCMApp() with promise-based initialization lock
- P0: Fix preHandler short-circuit in device.routes.ts (add return before reply.send)
- P1: Replace non-null assertions with safe defaults in notification config
- P1: Add rate limiting on device registration endpoint (10 req/5min per user)
- P2: Add push notification deduplication using content hash
- P2: Add APNs payload size validation (256KB limit)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-16 22:16:47 -04:00
9f65ebce5d FRE-5398: Fix invoice endpoint customer IDOR (M-3)
- Make verifyCustomerOwnership public in BillingService
- Add ownership verification before fetching invoice history
- Returns 403 if customerId does not belong to authenticated user

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-16 09:57:57 -04:00
d6f574ff8e FRE-5350: Add property scanner service with ATTOM, USPS, county data integration
- ATTOM Property API integration for structured property data
- USPS address standardization via API
- County clerk/recorder feed scraping for deed changes and liens
- Rate limiting, caching, and retry logic
- Unit tests for each data source adapter
- PropertyRecord, CountyDeedRecord, DataSourceType types in types.ts
- Consolidated type exports in index.ts

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-16 09:50:21 -04:00
24c31f1b1b FRE-5400: Consolidate webhook secret to single config source
WebhookService.constructEvent now reads from config.stripe.webhookSecret
instead of process.env.STRIPE_WEBHOOK_SECRET, matching BillingService.handleWebhook.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-15 23:30:31 -04:00
7c2b585c16 FRE-5401: Migrate webhook idempotency to distributed Redis store
Replace in-memory Map<string, number> with Redis-based idempotency
using setIfNotExists (NX) for distributed multi-instance deployments.
Removes cleanupOldEvents (no longer needed with Redis TTL).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-15 20:27:12 -04:00
cba5390309 FRE-5348: Fix P1 billing issues
- Add null check for subscription items in updateSubscription
- Implement webhook handlers with Prisma DB persistence
- cancelSubscription already correctly passes cancel_at_period_end

All P1 issues validated and fixed. Ready for Security Review.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-15 14:18:46 -04:00
7ed1a340b9 FRE-5353 Home Title: Dashboard widget + tier gating
- Add hometitle API routes: properties CRUD, changes, alerts, scan
- Implement Premium tier gating with 402 responses for non-Premium users
- Enforce max 5 properties per Premium subscription (0 for Free/Basic, 3 for Plus)
- Build DashboardPage with PropertyCard, AddPropertyForm, AlertsList components
- Add dashboard CSS styles with responsive design
- Register hometitle routes under /hometitle prefix with auth middleware

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-14 22:51:35 -04:00
08fedf55e6 docs: Add Mixpanel analytics configuration and documentation
- Add MIXPANEL_TOKEN, MIXPANEL_API_SECRET, ANALYTICS_ENV to .env.example
- Add packages/web/.env.example with VITE_MIXPANEL_TOKEN and other analytics vars
- Update docs/MIXPANEL_ANALYTICS.md with complete setup instructions
- Document event taxonomy (30+ events across User, Subscription, DarkWatch, VoicePrint, SpamShield)
- Add KPI definitions (MAU, MRR, conversion, churn, CAC, LTV, NPS, viral coefficient)
- Include integration examples for backend and frontend usage
- Document alert thresholds for monitoring

Implementation was already complete in packages/shared-analytics and packages/web.
This completes the configuration and documentation for Mixpanel setup.

FRE-5281
2026-05-14 22:38:10 -04:00
153 changed files with 28140 additions and 2095 deletions

View File

@@ -27,3 +27,41 @@ SENTRY_TRACES_SAMPLE_RATE="0.1"
# Google Analytics 4
GA4_MEASUREMENT_ID=""
GA4_API_SECRET=""
# Mixpanel Product Analytics
MIXPANEL_TOKEN=""
MIXPANEL_API_SECRET=""
ANALYTICS_ENV="development"
# ============================================
# Push Notifications Configuration
# ============================================
# Firebase Cloud Messaging (FCM) - Android
FCM_PROJECT_ID=""
FCM_CLIENT_EMAIL=""
FCM_PRIVATE_KEY=""
# Apple Push Notification Service (APNs) - iOS
APNS_KEY_ID=""
APNS_TEAM_ID=""
APNS_BUNDLE_ID=""
APNS_KEY=""
# Twilio - SMS (optional)
TWILIO_ACCOUNT_SID=""
TWILIO_AUTH_TOKEN=""
TWILIO_MESSAGING_SERVICE_SID=""
# Notification Rate Limits
PUSH_RATE_LIMIT=100
EMAIL_RATE_LIMIT=60
SMS_RATE_LIMIT=30
RATE_LIMIT_WINDOW_SECONDS=60
# Frontend Environment Variables (Vite)
# Add these to packages/web/.env or your frontend .env files:
# VITE_MIXPANEL_TOKEN=<same-as-backend-token>
# VITE_GA_MEASUREMENT_ID=<same-as-backend-id>
# VITE_META_PIXEL_ID=""
# VITE_LINKEDIN_PARTNER_ID=""

View File

@@ -1,57 +1,149 @@
# ShieldAI Mixpanel Analytics Configuration
## Current Implementation Status
## Implementation Complete ✅
**Already Implemented:**
The Mixpanel analytics infrastructure is fully implemented and ready for use. This document covers setup and usage.
### Backend (packages/shared-analytics)
- Full MixpanelService with Segment analytics integration
- Event taxonomy defined in `EventType` enum:
- User events: `user_signed_up`, `user_logged_in`, `user_upgraded`, etc.
- Subscription events: `subscription_created`, `subscription_cancelled`, etc.
- Product-specific events: DarkWatch, VoicePrint, SpamShield events
- User identification and group tracking
- KPI definitions (MAU, MRR, conversion rate, churn, etc.)
---
### Frontend (packages/web)
- Analytics hook (`useAnalytics.ts`) with:
- Mixpanel initialization via `VITE_MIXPANEL_TOKEN`
- Event tracking (`trackEvent`)
- Page view tracking (`trackPageView`)
- Waitlist signup tracking (`trackWaitlistSignup`)
- GA4, Meta Pixel, and LinkedIn Insight integration
## What's Implemented
## Required Actions
### 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
### 1. Create Mixpanel Project (Manual - Requires Account)
- Sign up for Mixpanel at https://mixpanel.com
- Create project: "ShieldAI"
- Get project token from Project Settings → Project Token
### 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
### 2. Configure Environment Variables
---
## 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
# Backend (.env)
MIXPANEL_TOKEN=<your-mixpanel-token>
MIXPANEL_API_SECRET=<optional-api-secret>
# Frontend (.env)
VITE_MIXPANEL_TOKEN=<your-mixpanel-token>
# Mixpanel (Backend)
MIXPANEL_TOKEN=<your-mixpanel-project-token>
MIXPANEL_API_SECRET=<optional-api-secret-for-server-side>
ANALYTICS_ENV=development # or production, staging
```
### 3. Event Taxonomy Documentation
See `packages/shared-analytics/src/config/analytics.config.ts` for full event definitions.
### 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>
```
### 4. User Property Definitions
Standard properties tracked:
- `userId`: User identifier
- `sessionId`: Session identifier
- `platform`: web, mobile, desktop, api
- `version`: App version
- `environment`: development, production, staging
### Step 4: Verify Integration
Backend usage example:
```typescript
import { mixpanelService, EventType } from '@shieldsai/shared-analytics';
## Integration Points
// Track an event
await mixpanelService.track(EventType.USER_SIGNED_UP, userId, {
plan: 'premium',
referrer: 'google',
});
- `DarkWatch`: Exposure detection events
- `SpamShield`: Spam detection and blocking events
- `VoicePrint`: Voice enrollment and analysis events
- `Waitlist`: Signup tracking with source attribution
// Identify a user
await mixpanelService.identify(userId, {
email: 'user@example.com',
tier: 'premium',
});
```
Frontend usage example:
```typescript
import { trackEvent, trackWaitlistSignup, trackPageView } from './hooks/useAnalytics';
// Track a page view
trackPageView('/waitlist', 'Waitlist Signup');
// Track a signup
trackWaitlistSignup('user@example.com', 'landing_page', 'free');
// Track a custom event
trackEvent('feature_used', { feature: 'darkwatch_scan', duration: 45 });
```
---
## Event Properties Schema
All events support these standard properties (validated via Zod):
- `userId` (string, optional): User identifier
- `sessionId` (string, optional): Session identifier
- `timestamp` (Date, optional): Event timestamp
- `platform` (enum: 'web' | 'mobile' | 'desktop' | 'api', optional): Event source
- `version` (string, optional): App version
- `environment` (string, optional): deployment environment
Additional custom properties are accepted and passed through to Mixpanel.
---
## KPI Definitions
| KPI | Description | Calculation |
|-----|-------------|-------------|
| MAU | Monthly Active Users | COUNT(DISTINCT userId) WHERE timestamp > NOW() - 30 DAYS |
| Paying Users | Active subscriptions | COUNT(DISTINCT userId) WHERE subscription.status = "active" |
| MRR | Monthly Recurring Revenue | SUM(subscription.amount) WHERE subscription.status = "active" |
| Conversion Rate | Free → Paid | COUNT(upgrade events) / COUNT(signup events) |
| Churn Rate | Cancellations | COUNT(cancel events) / COUNT(active subscriptions) |
| CAC | Customer Acquisition Cost | Total marketing spend / COUNT(new paying users) |
| LTV | Lifetime Value | Average subscription amount / Churn rate |
| NPS | Net Promoter Score | % Promoters - % Detractors |
| Viral Coefficient | Referral multiplier | COUNT(referral events) / COUNT(users) |
---
## Alert Thresholds
Configured thresholds for monitoring:
- **Churn**: Warning >5%, Critical >10%
- **Conversion Rate**: Warning <2%, Critical <1%
- **MRR**: Warning <90% target, Critical <80% target
- **NPS**: Warning <50, Critical <40
- **Viral Coefficient**: Warning <0.4, Critical <0.3
---
## Files Modified
- `packages/shared-analytics/src/config/analytics.config.ts` - Event taxonomy & KPIs
- `packages/shared-analytics/src/services/mixpanel.service.ts` - Mixpanel service
- `packages/shared-analytics/src/index.ts` - Exports
- `packages/web/src/hooks/useAnalytics.ts` - Frontend analytics hook
- `.env.example` - Environment variable templates
- `docs/MIXPANEL_ANALYTICS.md` - This documentation
---
## Next Steps
1. **Create Mixpanel account** (requires human action - no API for account creation)
2. **Add tokens to `.env`** files
3. **Test event tracking** in development
4. **Set up Mixpanel dashboards** for KPI monitoring
5. **Configure Mixpanel alerts** for threshold breaches

View File

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

View File

@@ -0,0 +1,462 @@
# 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

353
docs/STRIPE_INTEGRATION.md Normal file
View File

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

View File

@@ -9,23 +9,28 @@
"test:coverage": "vitest run --coverage",
"lint": "eslint src/"
},
"dependencies": {
"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/darkwatch": "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"
"ioredis": "^5.4.0",
"jsonwebtoken": "^9.0.2"
},
"devDependencies": {
"@vitest/coverage-v8": "^4.1.5",

View File

@@ -1,4 +1,11 @@
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?: {
@@ -27,17 +34,35 @@ export async function authMiddleware(fastify: FastifyInstance) {
if (authHeader?.startsWith('Bearer ')) {
const token = authHeader.slice(7);
try {
// In production, decode and verify JWT
// For now, we'll attach a placeholder user
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: 'user-placeholder',
email: 'user@example.com',
role: 'user',
id: decoded.id,
email: decoded.email,
role: decoded.role,
organizationId: decoded.organizationId,
};
authReq.authType = 'jwt';
return;
} catch (err) {
// JWT invalid, continue to API key check
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' };
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -7,39 +7,97 @@ 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", async (request, reply) => {
const userId = getUserId(request);
if (!userId || userId === "anonymous") {
return reply.code(401).send({ error: "User not authenticated" });
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);
}
);
const timeWindow =
parseInt(
(request.query as Record<string, string>).timeWindow as string
) || 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" });
}
fastify.get("/groups", 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);
}
const query = request.query as Record<string, string>;
const result = await correlationService.getCorrelationGroups({
userId,
status: (query.status as any) || undefined,
timeWindowMinutes: query.timeWindow
? parseInt(query.timeWindow)
: 60,
limit: query.limit ? parseInt(query.limit) : 50,
offset: query.offset ? parseInt(query.offset) : 0,
});
return reply.send(result);
});
);
fastify.get(
"/groups/:groupId",
@@ -114,26 +172,47 @@ export function correlationRoutes(fastify: FastifyInstance) {
}
);
fastify.get("/alerts", async (request, reply) => {
const userId = getUserId(request);
if (!userId || userId === "anonymous") {
return reply.code(401).send({ error: "User not authenticated" });
}
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>;
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: query.timeWindow
? parseInt(query.timeWindow)
: 60,
limit: query.limit ? parseInt(query.limit) : 50,
offset: query.offset ? parseInt(query.offset) : 0,
});
return reply.send(result);
});
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",

View File

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

View File

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

View File

@@ -4,6 +4,11 @@ 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
@@ -148,4 +153,42 @@ export async function routes(fastify: FastifyInstance) {
},
{ prefix: '/reports' }
);
// Subscription routes
fastify.register(
async (subscriptionRouter) => {
await subscriptionRoutes(subscriptionRouter);
},
{ prefix: '/billing' }
);
// Device routes
fastify.register(
async (deviceRouter) => {
await deviceRoutes(deviceRouter);
},
{ prefix: '/api/v1' }
);
// Home Title service routes
fastify.register(
async (hometitleRouter) => {
hometitleRouter.addHook('onRequest', async (request: FastifyRequest, reply: FastifyReply) => {
await fastify.requireAuth(request as AuthRequest);
});
await hometitleRoutes(hometitleRouter);
},
{ prefix: '/hometitle' }
);
// Info Broker Removal service routes
fastify.register(
async (removebrokersRouter) => {
removebrokersRouter.addHook('onRequest', async (request: FastifyRequest, reply: FastifyReply) => {
await fastify.requireAuth(request as AuthRequest);
});
await removebrokersRoutes(removebrokersRouter);
},
{ prefix: '/removebrokers' }
);
}

View File

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

View File

@@ -169,4 +169,9 @@ export async function reportRoutes(fastify: FastifyInstance) {
const createdIds = await reportService.scheduleAnnualReports();
return reply.code(200).send({ scheduled: createdIds.length, reportIds: createdIds });
});
fastify.post('/schedule/weekly-digest', async (request: FastifyRequest, reply: FastifyReply) => {
const createdIds = await reportService.scheduleWeeklyDigest();
return reply.code(200).send({ scheduled: createdIds.length, reportIds: createdIds });
});
}

View File

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

View File

@@ -4,6 +4,7 @@ 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";
@@ -16,8 +17,13 @@ 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: {
@@ -30,6 +36,7 @@ async function bootstrap() {
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);
@@ -52,16 +59,54 @@ async function bootstrap() {
request.headers["x-request-id"] = requestId;
});
await app.register(darkwatchRoutes);
await app.register(voiceprintRoutes);
await app.register(correlationRoutes);
await app.register(extensionRoutes, { prefix: '/extension' });
await app.register(waitlistRoutes);
await app.register(blogRoutes, { prefix: '/blog' });
await app.register(blogAdminRoutes);
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}`);

View File

@@ -0,0 +1,36 @@
-- Create device types enum
DO $$ BEGIN
CREATE TYPE "DeviceType" AS ENUM('mobile', 'web', 'desktop');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- Create platform enum
DO $$ BEGIN
CREATE TYPE "Platform" AS ENUM('ios', 'android', 'web');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- Create device_tokens table
CREATE TABLE IF NOT EXISTS "device_tokens" (
"id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"userId" UUID NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
"deviceType" "DeviceType" NOT NULL DEFAULT 'mobile',
"token" TEXT NOT NULL UNIQUE,
"platform" "Platform" NOT NULL,
"appName" TEXT,
"appVersion" TEXT,
"osVersion" TEXT,
"model" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"lastUsedAt" TIMESTAMP NOT NULL DEFAULT NOW(),
"createdAt" TIMESTAMP NOT NULL DEFAULT NOW(),
"updatedAt" TIMESTAMP NOT NULL DEFAULT NOW()
);
-- Create indexes
CREATE INDEX IF NOT EXISTS "device_tokens_userId_idx" ON "device_tokens"("userId");
CREATE INDEX IF NOT EXISTS "device_tokens_deviceType_idx" ON "device_tokens"("deviceType");
CREATE INDEX IF NOT EXISTS "device_tokens_platform_idx" ON "device_tokens"("platform");
CREATE INDEX IF NOT EXISTS "device_tokens_isActive_idx" ON "device_tokens"("isActive");

View File

@@ -25,6 +25,7 @@ model User {
// Relationships
accounts Account[]
sessions Session[]
deviceTokens DeviceToken[]
familyGroups FamilyGroupMember[]
familyGroupOwned FamilyGroup[] @relation("FamilyGroupOwner")
subscriptions Subscription[]
@@ -107,6 +108,42 @@ model Session {
@@index([userId])
}
model DeviceToken {
id String @id @default(uuid())
userId String
deviceType DeviceType
token String @unique
platform Platform
appName String?
appVersion String?
osVersion String?
model String?
isActive Boolean @default(true)
lastUsedAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@index([deviceType])
@@index([platform])
@@index([isActive])
}
enum DeviceType {
mobile
web
desktop
}
enum Platform {
ios
android
web
}
// ============================================
// Family & Subscription Models
// ============================================
@@ -167,6 +204,9 @@ model Subscription {
watchlistItems WatchlistItem[]
exposures Exposure[]
alerts Alert[]
propertyWatchlistItems PropertyWatchlistItem[]
removalRequests RemovalRequest[]
brokerListings BrokerListing[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -506,6 +546,8 @@ enum AlertSource {
SPAMSHIELD
VOICEPRINT
CALL_ANALYSIS
HOME_TITLE
INFO_BROKER
}
enum AlertCategory {
@@ -517,6 +559,9 @@ enum AlertCategory {
CALL_ANOMALY
CALL_QUALITY
CALL_EVENT
HOME_TITLE
INFO_BROKER_LISTING
INFO_BROKER_REMOVAL
}
enum NormalizedAlertSeverity {
@@ -531,6 +576,7 @@ enum NormalizedAlertSeverity {
enum CorrelationStatus {
ACTIVE
RESOLVED
FALSE_POSITIVE
}
model NormalizedAlert {
@@ -588,6 +634,7 @@ model CorrelationGroup {
enum ReportType {
MONTHLY_PLUS
ANNUAL_PREMIUM
WEEKLY_DIGEST
}
enum ReportStatus {
@@ -676,3 +723,196 @@ model BlogPost {
@@index([published, publishedAt])
@@index([tags])
}
// ============================================
// Home Title Service Models
// ============================================
enum PropertyChangeType {
tax_change
deed_change
ownership_transfer
lien_filing
metadata_change
}
enum PropertyChangeSeverity {
info
warning
critical
}
model PropertyWatchlistItem {
id String @id @default(uuid())
subscriptionId String
address String
parcelId String?
ownerName String?
streetAddress String
city String? @default("")
state String? @default("")
zipCode String? @default("")
latitude Float?
longitude Float?
isActive Boolean @default(true)
subscription Subscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
snapshots PropertySnapshot[]
changes PropertyChange[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([subscriptionId, parcelId])
@@index([subscriptionId])
@@index([parcelId])
@@index([address])
}
model PropertySnapshot {
id String @id @default(uuid())
propertyWatchlistItemId String
subscriptionId String
capturedAt DateTime
ownerName String
address Json // { streetNumber, streetName, streetType, unit, city, state, zip, latitude?, longitude? }
deedDate String?
taxId String?
propertyType String @default("residential")
taxAmount Float?
lienCount Int @default(0)
propertyWatchlistItem PropertyWatchlistItem @relation(fields: [propertyWatchlistItemId], references: [id], onDelete: Cascade)
changes PropertyChange[] @relation("SnapshotChanges")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([propertyWatchlistItemId])
@@index([subscriptionId])
@@index([capturedAt])
}
model PropertyChange {
id String @id @default(uuid())
propertyWatchlistItemId String
snapshotId String?
changeType PropertyChangeType
severity PropertyChangeSeverity @default(info)
details Json?
detectedAt DateTime @default(now())
propertyWatchlistItem PropertyWatchlistItem @relation(fields: [propertyWatchlistItemId], references: [id], onDelete: Cascade)
snapshot PropertySnapshot? @relation("SnapshotChanges", fields: [snapshotId], references: [id])
@@index([propertyWatchlistItemId])
@@index([snapshotId])
@@index([changeType])
}
// ============================================
// Info Broker Removal Models
// ============================================
enum BrokerCategory {
PEOPLE_SEARCH
BACKGROUND_CHECK
PUBLIC_RECORDS
REVERSE_LOOKUP
SOCIAL_MEDIA
}
enum RemovalMethod {
AUTOMATED
MANUAL_FORM
EMAIL
PHONE
MAIL
NONE
}
enum RemovalStatus {
PENDING
SUBMITTED
IN_PROGRESS
COMPLETED
FAILED
REJECTED
CANCELLED
}
model InfoBroker {
id String @id @default(uuid())
name String
domain String @unique
category BrokerCategory
removalMethod RemovalMethod
removalUrl String?
requiresAccount Boolean @default(false)
requiresVerification Boolean @default(false)
estimatedDays Int @default(14)
isActive Boolean @default(true)
removalRequests RemovalRequest[]
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@index([category])
@@index([isActive])
@@index([removalMethod])
}
model RemovalRequest {
id String @id @default(uuid())
subscriptionId String
brokerId String
status RemovalStatus @default(PENDING)
personalInfo Json // { fullName, email?, phone?, address?, dob? }
method RemovalMethod
attempts Int @default(0)
nextRetryAt DateTime?
submittedAt DateTime?
completedAt DateTime?
error String?
notes String?
metadata Json? // Broker response data, tracking info
broker InfoBroker @relation(fields: [brokerId], references: [id])
subscription Subscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
brokerListings BrokerListing[]
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@index([subscriptionId])
@@index([brokerId])
@@index([status])
@@index([submittedAt])
@@index([subscriptionId, status])
}
model BrokerListing {
id String @id @default(uuid())
subscriptionId String
brokerId String
removalRequestId String?
url String
dataFound Json // Fields found on the listing
screenshotUrl String?
isRemoved Boolean @default(false)
removedAt DateTime?
removalRequest RemovalRequest? @relation(fields: [removalRequestId], references: [id])
subscription Subscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
scannedAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@index([subscriptionId])
@@index([brokerId])
@@index([removalRequestId])
@@index([isRemoved])
@@index([subscriptionId, isRemoved])
}

View File

@@ -44,6 +44,9 @@ export type {
FamilyGroupMember,
Subscription,
WatchlistItem,
PropertyWatchlistItem,
PropertySnapshot,
PropertyChange,
Exposure,
Alert,
VoiceEnrollment,
@@ -57,11 +60,16 @@ export type {
SecurityReport,
WaitlistEntry,
BlogPost,
InfoBroker,
RemovalRequest,
BrokerListing,
UserRole,
FamilyMemberRole,
SubscriptionTier,
SubscriptionStatus,
WatchlistType,
PropertyChangeType,
PropertyChangeSeverity,
ExposureSource,
ExposureSeverity,
AlertType,
@@ -72,6 +80,9 @@ export type {
RuleAction,
ReportType,
ReportStatus,
RemovalStatus,
RemovalMethod,
BrokerCategory,
AnalysisType,
AnalysisJobStatus,
DetectionVerdict,

View File

@@ -2,7 +2,9 @@
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
"rootDir": "./src",
"module": "ES2022",
"moduleResolution": "Bundler"
},
"include": ["src/**/*.ts"]
}

View File

@@ -75,16 +75,42 @@ async function processReportGeneration(
const userName = user?.name || notifyEmail.split('@')[0];
await emailService.sendWithTemplate(notifyEmail, {
templateId: 'report_ready',
variables: {
name: userName,
report_title: report.title,
report_summary: report.summary || 'Your protection report contains detailed statistics and recommendations.',
report_url: `${dashboardUrl}/reports/${report.id}`,
pdf_url: report.pdfUrl || `${dashboardUrl}/api/v1/reports/${report.id}/pdf`,
},
});
const templateId = report.reportType === 'WEEKLY_DIGEST' ? 'weekly_digest' : 'report_ready';
if (report.reportType === 'WEEKLY_DIGEST' && report.dataPayload) {
const payload = typeof report.dataPayload === 'string' ? JSON.parse(report.dataPayload) : report.dataPayload;
await emailService.sendWithTemplate(notifyEmail, {
templateId: 'weekly_digest',
variables: {
name: userName,
period_start: new Date(report.periodStart).toLocaleDateString('en-US', { month: 'long', day: 'numeric' }),
period_end: new Date(report.periodEnd).toLocaleDateString('en-US', { month: 'long', day: 'numeric' }),
protection_score: payload.protectionScore || 0,
new_exposures: payload.exposureSummary?.newExposures || 0,
critical_exposures: payload.exposureSummary?.criticalExposures || 0,
spam_events_blocked: payload.spamStats?.totalSpamEvents || 0,
calls_blocked: payload.spamStats?.callsBlocked || 0,
texts_blocked: payload.spamStats?.textsBlocked || 0,
voice_threats: payload.voiceStats?.threatsDetected || 0,
enrollments_active: payload.voiceStats?.enrollmentsActive || 0,
properties_monitored: payload.homeTitleStats?.propertiesMonitored || 0,
changes_detected: payload.homeTitleStats?.changesDetected || 0,
report_url: `${dashboardUrl}/reports/${report.id}`,
},
});
} else {
await emailService.sendWithTemplate(notifyEmail, {
templateId: 'report_ready',
variables: {
name: userName,
report_title: report.title,
report_summary: report.summary || 'Your protection report contains detailed statistics and recommendations.',
report_url: `${dashboardUrl}/reports/${report.id}`,
pdf_url: report.pdfUrl || `${dashboardUrl}/api/v1/reports/${report.id}/pdf`,
},
});
}
await prisma.securityReport.update({
where: { id: report.id },
@@ -245,6 +271,13 @@ export async function scheduleMonthlyReportTrigger() {
});
}
export async function scheduleWeeklyDigestTrigger() {
return reportSchedulerQueue.add('trigger-weekly-digest', {}, {
repeat: { pattern: '0 8 * * 1' },
jobId: 'weekly-digest-trigger',
});
}
export async function scheduleAnnualReportTrigger() {
return reportSchedulerQueue.add('trigger-annual-reports', {}, {
repeat: { pattern: '0 0 1 1 *' },

View File

@@ -0,0 +1,237 @@
# @shieldai/mobile-api-client
React Native API client library for ShieldAI services. Provides type-safe access to all API endpoints with built-in authentication, offline support, and error handling.
## Installation
```bash
npm install @shieldai/mobile-api-client
# or
yarn add @shieldai/mobile-api-client
```
## Setup
### Initialize the client
```typescript
import { createApiClient } from '@shieldai/mobile-api-client';
createApiClient({
baseURL: 'https://api.shieldai.freno.me/api/v1',
timeout: 30000,
debug: __DEV__, // Enable debug logging in development
});
```
### Authentication
```typescript
import { authService } from '@shieldai/mobile-api-client';
// Login
const { user, tokens } = await authService.login({
email: 'user@example.com',
password: 'password123',
});
// Register
const { user: newUser } = await authService.register({
email: 'user@example.com',
password: 'password123',
firstName: 'John',
lastName: 'Doe',
});
// Get current user
const currentUser = await authService.getCurrentUser();
// Logout
await authService.logout();
// Check authentication status
const isAuthenticated = await authService.isAuthenticated();
```
### Device Management
```typescript
import { deviceService } from '@shieldai/mobile-api-client';
import * as Notifications from 'expo-notifications';
// Register device for push notifications
async function registerForPushNotifications() {
const token = (await Notifications.getExpoPushTokenAsync({
projectId: 'your-project-id',
})).data;
await deviceService.registerDevice({
platform: Platform.OS === 'ios' ? 'ios' : 'android',
pushToken: token,
modelName: Platform.OS === 'ios' ? 'iPhone' : 'Android',
osVersion: Platform.Version.toString(),
appVersion: '1.0.0',
});
}
// Get all user devices
const { devices } = await deviceService.getDevices();
// Update push token
await deviceService.updatePushToken('new-token');
```
### Subscriptions
```typescript
import { subscriptionService } from '@shieldai/mobile-api-client';
// Get current subscription
const { subscription, tier, usage } = await subscriptionService.getSubscription();
// Get available tiers
const tiers = await subscriptionService.getTiers();
// Create subscription
const newSubscription = await subscriptionService.createSubscription({
tier: 'premium',
});
// Update subscription
await subscriptionService.updateSubscription({
tier: 'enterprise',
});
// Cancel subscription
await subscriptionService.cancelSubscription();
// Create checkout session
const { url } = await subscriptionService.createCheckoutSession('premium');
Linking.openURL(url);
// Create customer portal session
const { url: portalUrl } = await subscriptionService.createCustomerPortalSession();
Linking.openURL(portalUrl);
```
### Notifications
```typescript
import { notificationService } from '@shieldai/mobile-api-client';
// Get notifications
const { notifications, unreadCount } = await notificationService.getNotifications({
page: 1,
limit: 20,
unreadOnly: false,
});
// Mark as read
await notificationService.markAsRead(notificationId);
// Mark all as read
await notificationService.markAllAsRead();
// Get unread count
const count = await notificationService.getUnreadCount();
// Update preferences
await notificationService.updatePreferences({
emailNotifications: true,
pushNotifications: true,
notificationTypes: {
darkwatch_alert: true,
spam_blocked: true,
voiceprint_analysis: true,
},
});
```
## Features
### Automatic Token Refresh
The client automatically handles JWT token refresh when access tokens expire:
```typescript
// No manual handling needed - just make the request
const user = await authService.getCurrentUser();
// If token expired, it will be refreshed automatically
```
### Offline Support
Requests are automatically queued when offline and replayed when connection is restored:
```typescript
import { requestQueue } from '@shieldai/mobile-api-client';
// Subscribe to queue status changes
const unsubscribe = requestQueue.subscribe(() => {
const status = requestQueue.getStatus();
console.log(`Queued requests: ${status.size}`);
});
// Cleanup
unsubscribe();
```
### Error Handling
```typescript
import { authService } from '@shieldai/mobile-api-client';
try {
await authService.login({ email, password });
} catch (error) {
if (error.response?.status === 401) {
// Invalid credentials
} else if (error.response?.status === 422) {
// Validation error
} else if (error.offline) {
// Offline mode - request queued
} else {
// Network error
}
}
```
## API Reference
### Services
- `authService` - Authentication and user management
- `deviceService` - Device registration and push tokens
- `subscriptionService` - Billing and subscription management
- `notificationService` - Push notifications and preferences
### Types
All TypeScript types are exported for type-safe development:
```typescript
import type { User, Device, Subscription, Notification } from '@shieldai/mobile-api-client';
```
## Development
```bash
# Install dependencies
npm install
# Build
npm run build
# Watch mode
npm run dev
# Type check
npx tsc --noEmit
# Lint
npm run lint
```
## License
MIT

View File

@@ -0,0 +1,29 @@
{
"name": "@shieldai/mobile-api-client",
"version": "1.0.0",
"description": "React Native API client library for ShieldAI services",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"lint": "eslint src/",
"test": "jest"
},
"keywords": ["react-native", "api-client", "shieldai"],
"author": "ShieldAI Team",
"license": "MIT",
"peerDependencies": {
"react-native": ">=0.72.0",
"expo": ">=49.0.0"
},
"dependencies": {
"expo-secure-store": "^12.8.0",
"@react-native-async-storage/async-storage": "1.23.1",
"axios": "^1.6.0"
},
"devDependencies": {
"@types/react": "^18.2.0",
"typescript": "^5.3.0"
}
}

View File

@@ -0,0 +1,249 @@
/**
* API Client for ShieldAI services
* Handles authentication, request/response interception, and error handling
*/
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios';
import { tokenStorage } from '../storage/token-storage';
import { requestQueue } from '../utils/request-queue';
import type { AuthTokens, AuthResponse, RefreshTokenRequest } from '../types';
export interface ApiClientConfig {
baseURL: string;
timeout?: number;
debug?: boolean;
}
export class ApiClient {
private client: AxiosInstance;
private config: ApiClientConfig;
private isRefreshing = false;
private refreshSubscribers: Set<(token: string) => void> = new Set();
constructor(config: ApiClientConfig) {
this.config = {
baseURL: config.baseURL,
timeout: config.timeout ?? 30000,
debug: config.debug ?? false,
};
this.client = axios.create({
baseURL: this.config.baseURL,
timeout: this.config.timeout,
headers: {
'Content-Type': 'application/json',
},
});
this.setupInterceptors();
}
private setupInterceptors(): void {
// Request interceptor - add auth token
this.client.interceptors.request.use(
async (config) => {
if (this.config.debug) {
console.log('[API] Request:', config.method?.toUpperCase(), config.url);
}
// Add auth token if available
const token = await tokenStorage.getAccessToken();
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
// Queue request if offline
if (!requestQueue.isOnline() && this.requiresNetwork(config)) {
await requestQueue.enqueue(config);
throw new Error('OFFLINE');
}
return config;
},
(error) => {
if (error.message === 'OFFLINE') {
return Promise.reject({ offline: true, config: error.config });
}
return Promise.reject(error);
}
);
// Response interceptor - handle errors and token refresh
this.client.interceptors.response.use(
(response) => {
if (this.config.debug) {
console.log('[API] Response:', response.status, response.config.url);
}
return response;
},
async (error: AxiosError) => {
const originalRequest = error.config as AxiosRequestConfig & { _retry?: boolean };
// Handle 401 - unauthorized
if (error.response?.status === 401 && !originalRequest._retry) {
if (this.isRefreshing) {
// Wait for refresh to complete
return new Promise((resolve) => {
this.refreshSubscribers.add((token) => {
originalRequest.headers = originalRequest.headers || {};
originalRequest.headers.Authorization = `Bearer ${token}`;
resolve(this.client(originalRequest));
});
});
}
originalRequest._retry = true;
this.isRefreshing = true;
try {
const refreshToken = await tokenStorage.getRefreshToken();
if (!refreshToken) {
throw new Error('No refresh token');
}
const newTokens = await this.refreshAccessToken(refreshToken);
await tokenStorage.saveTokens(newTokens.accessToken, newTokens.refreshToken);
// Retry failed requests
this.refreshSubscribers.forEach((callback) => callback(newTokens.accessToken));
this.refreshSubscribers.clear();
originalRequest.headers = originalRequest.headers || {};
originalRequest.headers.Authorization = `Bearer ${newTokens.accessToken}`;
return this.client(originalRequest);
} catch (refreshError) {
// Refresh failed - clear tokens and redirect to login
await tokenStorage.clearTokens();
this.refreshSubscribers.clear();
return Promise.reject(refreshError);
} finally {
this.isRefreshing = false;
}
}
return Promise.reject(error);
}
);
}
private requiresNetwork(config: AxiosRequestConfig): boolean {
// Don't queue GET requests that can be cached
const method = (config.method || 'get').toLowerCase();
return method !== 'get';
}
private async refreshAccessToken(refreshToken: string): Promise<AuthTokens> {
const response = await this.client.post<AuthResponse>('/auth/refresh', {
refreshToken,
});
return {
accessToken: response.data.tokens.accessToken,
refreshToken: response.data.tokens.refreshToken,
expiresIn: response.data.tokens.expiresIn,
tokenType: response.data.tokens.tokenType,
};
}
// Subscribe to token refresh
onTokenRefresh(callback: (token: string) => void): () => void {
this.refreshSubscribers.add(callback);
return () => this.refreshSubscribers.delete(callback);
}
// Public API methods
async get<T>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.client.get<T>(url, config);
}
async post<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.client.post<T>(url, data, config);
}
async put<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.client.put<T>(url, data, config);
}
async patch<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.client.patch<T>(url, data, config);
}
async delete<T>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.client.delete<T>(url, config);
}
// Auth methods
async login(email: string, password: string): Promise<AuthResponse> {
const response = await this.post<AuthResponse>('/auth/login', {
email,
password,
});
if (response.data.tokens) {
await tokenStorage.saveTokens(
response.data.tokens.accessToken,
response.data.tokens.refreshToken
);
}
return response.data;
}
async register(data: {
email: string;
password: string;
firstName?: string;
lastName?: string;
}): Promise<AuthResponse> {
const response = await this.post<AuthResponse>('/auth/register', data);
if (response.data.tokens) {
await tokenStorage.saveTokens(
response.data.tokens.accessToken,
response.data.tokens.refreshToken
);
}
return response.data;
}
async logout(): Promise<void> {
try {
await this.post('/auth/logout');
} finally {
await tokenStorage.clearTokens();
}
}
async isAuthenticated(): Promise<boolean> {
const token = await tokenStorage.getAccessToken();
return !!token;
}
// Health check
async healthCheck(): Promise<{ status: string; version: string }> {
const response = await this.get<{ status: string; version: string }>('/health');
return response.data;
}
// Get the underlying axios instance for advanced usage
getClient(): AxiosInstance {
return this.client;
}
}
// Default client instance
let defaultClient: ApiClient | null = null;
export const createApiClient = (config: ApiClientConfig): ApiClient => {
defaultClient = new ApiClient(config);
return defaultClient;
};
export const getApiClient = (): ApiClient => {
if (!defaultClient) {
throw new Error('API Client not initialized. Call createApiClient first.');
}
return defaultClient;
};

View File

@@ -0,0 +1,46 @@
/**
* Authentication API service
*/
import { getApiClient } from './api-client';
import type {
User,
AuthResponse,
LoginCredentials,
RegisterData,
AuthTokens
} from '../types';
export class AuthService {
private api = getApiClient();
async login(credentials: LoginCredentials): Promise<AuthResponse> {
const response = await this.api.login(credentials.email, credentials.password);
return response;
}
async register(data: RegisterData): Promise<AuthResponse> {
const response = await this.api.register(data);
return response;
}
async logout(): Promise<void> {
await this.api.logout();
}
async getCurrentUser(): Promise<User> {
const response = await this.api.get<{ user: User; authType: string }>('/auth/user/me');
return response.data.user;
}
async refreshToken(): Promise<AuthTokens> {
const response = await this.api.get<AuthTokens>('/auth/refresh-token');
return response.data;
}
async isAuthenticated(): Promise<boolean> {
return await this.api.isAuthenticated();
}
}
export const authService = new AuthService();

View File

@@ -0,0 +1,47 @@
/**
* Device API service
*/
import { getApiClient } from './api-client';
import type { Device, DeviceRegistration, DeviceListResponse } from '../types';
export class DeviceService {
private api = getApiClient();
async registerDevice(data: DeviceRegistration): Promise<Device> {
const response = await this.api.post<Device>('/api/v1/devices/register', data);
return response.data;
}
async updatePushToken(pushToken: string): Promise<Device> {
const response = await this.api.patch<Device>('/api/v1/devices/push-token', {
pushToken,
});
return response.data;
}
async getDevices(): Promise<DeviceListResponse> {
const response = await this.api.get<DeviceListResponse>('/api/v1/devices');
return response.data;
}
async getDevice(deviceId: string): Promise<Device> {
const response = await this.api.get<Device>(`/api/v1/devices/${deviceId}`);
return response.data;
}
async deleteDevice(deviceId: string): Promise<void> {
await this.api.delete(`/api/v1/devices/${deviceId}`);
}
async getCurrentDevice(): Promise<Device | null> {
try {
const response = await this.api.get<Device>('/api/v1/devices/current');
return response.data;
} catch {
return null;
}
}
}
export const deviceService = new DeviceService();

View File

@@ -0,0 +1,53 @@
/**
* Notification API service
*/
import { getApiClient } from './api-client';
import type { Notification, NotificationListResponse, NotificationPreferences } from '../types';
export class NotificationService {
private api = getApiClient();
async getNotifications(params?: {
page?: number;
limit?: number;
unreadOnly?: boolean;
}): Promise<NotificationListResponse> {
const response = await this.api.get<NotificationListResponse>('/notifications', { params });
return response.data;
}
async getNotification(notificationId: string): Promise<Notification> {
const response = await this.api.get<Notification>(`/notifications/${notificationId}`);
return response.data;
}
async markAsRead(notificationId: string): Promise<void> {
await this.api.patch(`/notifications/${notificationId}/read`);
}
async markAllAsRead(): Promise<void> {
await this.api.post('/notifications/read-all');
}
async deleteNotification(notificationId: string): Promise<void> {
await this.api.delete(`/notifications/${notificationId}`);
}
async getPreferences(): Promise<NotificationPreferences> {
const response = await this.api.get<NotificationPreferences>('/notifications/preferences');
return response.data;
}
async updatePreferences(preferences: NotificationPreferences): Promise<NotificationPreferences> {
const response = await this.api.put<NotificationPreferences>('/notifications/preferences', preferences);
return response.data;
}
async getUnreadCount(): Promise<number> {
const response = await this.api.get<{ count: number }>('/notifications/unread-count');
return response.data.count;
}
}
export const notificationService = new NotificationService();

View File

@@ -0,0 +1,53 @@
/**
* Subscription API service
*/
import { getApiClient } from './api-client';
import type {
Subscription,
SubscriptionTier,
SubscriptionStatusResponse,
CreateSubscriptionRequest,
UpdateSubscriptionRequest,
} from '../types';
export class SubscriptionService {
private api = getApiClient();
async getSubscription(): Promise<SubscriptionStatusResponse> {
const response = await this.api.get<SubscriptionStatusResponse>('/billing/subscription');
return response.data;
}
async createSubscription(data: CreateSubscriptionRequest): Promise<Subscription> {
const response = await this.api.post<Subscription>('/billing/subscription', data);
return response.data;
}
async updateSubscription(data: UpdateSubscriptionRequest): Promise<Subscription> {
const response = await this.api.patch<Subscription>('/billing/subscription', data);
return response.data;
}
async cancelSubscription(): Promise<Subscription> {
const response = await this.api.delete<Subscription>('/billing/subscription');
return response.data;
}
async getTiers(): Promise<SubscriptionTier[]> {
const response = await this.api.get<SubscriptionTier[]>('/billing/tiers');
return response.data;
}
async createCheckoutSession(tier: string): Promise<{ url: string }> {
const response = await this.api.post<{ url: string }>('/billing/checkout', { tier });
return response.data;
}
async createCustomerPortalSession(): Promise<{ url: string }> {
const response = await this.api.post<{ url: string }>('/billing/customer-portal');
return response.data;
}
}
export const subscriptionService = new SubscriptionService();

View File

@@ -0,0 +1,53 @@
/**
* ShieldAI Mobile API Client
*
* A comprehensive TypeScript API client library for React Native apps
* to interact with ShieldAI backend services.
*
* @example
* ```typescript
* import { createApiClient, authService, deviceService } from '@shieldai/mobile-api-client';
*
* // Initialize the client
* createApiClient({
* baseURL: 'https://api.shieldai.freno.me/api/v1',
* timeout: 30000,
* debug: __DEV__,
* });
*
* // Login
* const { user, tokens } = await authService.login({
* email: 'user@example.com',
* password: 'password123',
* });
*
* // Register device for push notifications
* await deviceService.registerDevice({
* platform: 'ios',
* pushToken: '...',
* });
* ```
*/
// Core API client
export {
createApiClient,
getApiClient,
ApiClient,
ApiClientConfig,
} from './api/api-client';
// Services
export { authService, AuthService } from './api/auth.service';
export { deviceService, DeviceService } from './api/device.service';
export { subscriptionService, SubscriptionService } from './api/subscription.service';
export { notificationService, NotificationService } from './api/notification.service';
// Types
export * from './types';
// Storage
export { storage, tokenStorage, StorageAdapter } from './storage/token-storage';
// Utils
export { requestQueue, RequestQueue } from './utils/request-queue';

View File

@@ -0,0 +1,21 @@
// Type declarations for React Native and Expo packages
declare module 'expo-secure-store' {
export function getItemAsync(key: string): Promise<string | null>;
export function setItemAsync(key: string, value: string): Promise<void>;
export function deleteItemAsync(key: string): Promise<void>;
}
declare module '@react-native-async-storage/async-storage' {
export function getItem(key: string): Promise<string | null>;
export function setItem(key: string, value: string): Promise<void>;
export function removeItem(key: string): Promise<void>;
export function clear(): Promise<void>;
}
declare module 'react-native' {
export import Platform = require('react-native/Libraries/Utilities/Platform');
export const Platform: {
OS: 'ios' | 'android' | 'web' | 'windows' | 'macos';
Version: number;
};
}

View File

@@ -0,0 +1,93 @@
/**
* Secure storage for authentication tokens
* Uses expo-secure-store for production, AsyncStorage for fallback
*/
import * as SecureStore from 'expo-secure-store';
import AsyncStorage from '@react-native-async-storage/async-storage';
const ACCESS_TOKEN_KEY = '@shieldai:access_token';
const REFRESH_TOKEN_KEY = '@shieldai:refresh_token';
export interface StorageAdapter {
getItem: (key: string) => Promise<string | null>;
setItem: (key: string, value: string) => Promise<void>;
removeItem: (key: string) => Promise<void>;
}
class SecureStorageAdapter implements StorageAdapter {
async getItem(key: string): Promise<string | null> {
try {
return await SecureStore.getItemAsync(key);
} catch {
// Fallback to AsyncStorage if SecureStore fails
return await AsyncStorage.getItem(key);
}
}
async setItem(key: string, value: string): Promise<void> {
try {
await SecureStore.setItemAsync(key, value);
} catch {
await AsyncStorage.setItem(key, value);
}
}
async removeItem(key: string): Promise<void> {
try {
await SecureStore.deleteItemAsync(key);
} catch {
await AsyncStorage.removeItem(key);
}
}
}
class InMemoryStorageAdapter implements StorageAdapter {
private store: Map<string, string> = new Map();
async getItem(key: string): Promise<string | null> {
return this.store.get(key) || null;
}
async setItem(key: string, value: string): Promise<void> {
this.store.set(key, value);
}
async removeItem(key: string): Promise<void> {
this.store.delete(key);
}
}
// Detect environment and choose appropriate storage
const getStorageAdapter = (): StorageAdapter => {
if (process.env.NODE_ENV === 'test') {
return new InMemoryStorageAdapter();
}
return new SecureStorageAdapter();
};
export const storage = getStorageAdapter();
export const tokenStorage = {
async getAccessToken(): Promise<string | null> {
return await storage.getItem(ACCESS_TOKEN_KEY);
},
async getRefreshToken(): Promise<string | null> {
return await storage.getItem(REFRESH_TOKEN_KEY);
},
async saveTokens(accessToken: string, refreshToken: string): Promise<void> {
await Promise.all([
storage.setItem(ACCESS_TOKEN_KEY, accessToken),
storage.setItem(REFRESH_TOKEN_KEY, refreshToken),
]);
},
async clearTokens(): Promise<void> {
await Promise.all([
storage.removeItem(ACCESS_TOKEN_KEY),
storage.removeItem(REFRESH_TOKEN_KEY),
]);
},
};

View File

@@ -0,0 +1,46 @@
/**
* Authentication types for ShieldAI API
*/
export interface User {
id: string;
email: string;
firstName?: string;
lastName?: string;
createdAt: string;
updatedAt: string;
}
export interface AuthTokens {
accessToken: string;
refreshToken: string;
expiresIn: number;
tokenType: 'Bearer';
}
export interface LoginCredentials {
email: string;
password: string;
}
export interface RegisterData {
email: string;
password: string;
firstName?: string;
lastName?: string;
}
export interface AuthResponse {
user: User;
tokens: AuthTokens;
}
export interface RefreshTokenRequest {
refreshToken: string;
}
export interface AuthError {
code: string;
message: string;
statusCode: number;
}

View File

@@ -0,0 +1,37 @@
/**
* Shared API types
*/
export interface ApiResponse<T> {
data: T;
success: boolean;
message?: string;
}
export interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
export interface ErrorResponse {
code: string;
message: string;
details?: Record<string, string[]>;
statusCode: number;
}
export interface HealthStatus {
status: 'healthy' | 'degraded' | 'unhealthy';
timestamp: string;
version: string;
services?: Record<string, { status: 'healthy' | 'degraded' | 'unhealthy' }>;
}
export interface VersionInfo {
version: string;
environment: string;
build: string;
}

View File

@@ -0,0 +1,30 @@
/**
* Device types for push notification and device management
*/
export interface Device {
id: string;
userId: string;
platform: 'ios' | 'android';
pushToken?: string;
modelName?: string;
osVersion?: string;
appVersion?: string;
isActive: boolean;
lastActiveAt: string;
createdAt: string;
updatedAt: string;
}
export interface DeviceRegistration {
platform: 'ios' | 'android';
pushToken: string;
modelName?: string;
osVersion?: string;
appVersion?: string;
}
export interface DeviceListResponse {
devices: Device[];
total: number;
}

View File

@@ -0,0 +1,5 @@
export * from './auth.types';
export * from './device.types';
export * from './subscription.types';
export * from './notification.types';
export * from './common.types';

View File

@@ -0,0 +1,27 @@
/**
* Notification types
*/
export interface Notification {
id: string;
userId: string;
type: 'darkwatch_alert' | 'spam_blocked' | 'voiceprint_analysis' | 'subscription' | 'system';
title: string;
message: string;
data?: Record<string, unknown>;
isRead: boolean;
createdAt: string;
readAt?: string;
}
export interface NotificationListResponse {
notifications: Notification[];
total: number;
unreadCount: number;
}
export interface NotificationPreferences {
emailNotifications: boolean;
pushNotifications: boolean;
notificationTypes: Record<string, boolean>;
}

View File

@@ -0,0 +1,49 @@
/**
* Subscription and billing types
*/
export interface Subscription {
id: string;
userId: string;
tier: 'free' | 'basic' | 'premium' | 'enterprise';
status: 'active' | 'canceled' | 'past_due' | 'trialing';
stripeCustomerId: string;
stripeSubscriptionId?: string;
currentPeriodStart: string;
currentPeriodEnd: string;
cancelAtPeriodEnd: boolean;
createdAt: string;
updatedAt: string;
}
export interface SubscriptionTier {
id: string;
name: string;
description: string;
price: number;
currency: string;
interval: 'month' | 'year';
features: string[];
}
export interface CreateSubscriptionRequest {
tier: 'free' | 'basic' | 'premium' | 'enterprise';
paymentMethodId?: string;
}
export interface UpdateSubscriptionRequest {
tier?: 'free' | 'basic' | 'premium' | 'enterprise';
cancelAtPeriodEnd?: boolean;
}
export interface SubscriptionStatusResponse {
subscription: Subscription;
tier: SubscriptionTier;
usage: {
currentPeriod: {
start: string;
end: string;
};
features: Record<string, { used: number; limit: number | null }>;
};
}

View File

@@ -0,0 +1,141 @@
/**
* Request queue for offline support
* Queues API requests when offline and replays when online
*/
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { AxiosRequestConfig, AxiosResponse } from 'axios';
const QUEUE_KEY = '@shieldai:api_queue';
const MAX_QUEUE_SIZE = 100;
export interface QueuedRequest {
id: string;
config: AxiosRequestConfig;
timestamp: number;
retryCount: number;
maxRetries: number;
}
export interface QueueStatus {
size: number;
oldestRequest: number | null;
newestRequest: number | null;
}
export class RequestQueue {
private queue: QueuedRequest[] = [];
private isProcessing = false;
private listeners: Set<() => void> = new Set();
constructor() {
this.loadFromStorage();
}
private notifyListeners(): void {
this.listeners.forEach((listener) => listener());
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
async loadFromStorage(): Promise<void> {
try {
const data = await AsyncStorage.getItem(QUEUE_KEY);
if (data) {
this.queue = JSON.parse(data);
}
} catch (error) {
console.error('Failed to load request queue:', error);
this.queue = [];
}
}
private async saveToStorage(): Promise<void> {
try {
await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(this.queue));
} catch (error) {
console.error('Failed to save request queue:', error);
}
}
async enqueue(config: AxiosRequestConfig): Promise<string> {
const id = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const queuedRequest: QueuedRequest = {
id,
config,
timestamp: Date.now(),
retryCount: 0,
maxRetries: 3,
};
// Limit queue size
if (this.queue.length >= MAX_QUEUE_SIZE) {
this.queue.shift(); // Remove oldest
}
this.queue.push(queuedRequest);
await this.saveToStorage();
this.notifyListeners();
return id;
}
async dequeue(): Promise<QueuedRequest | null> {
if (this.queue.length === 0) return null;
const request = this.queue.shift();
if (request) {
await this.saveToStorage();
this.notifyListeners();
}
return request ?? null;
}
async remove(id: string): Promise<void> {
const index = this.queue.findIndex((r) => r.id === id);
if (index !== -1) {
this.queue.splice(index, 1);
await this.saveToStorage();
this.notifyListeners();
}
}
async retry(id: string): Promise<void> {
const index = this.queue.findIndex((r) => r.id === id);
if (index !== -1) {
this.queue[index].retryCount += 1;
await this.saveToStorage();
this.notifyListeners();
}
}
async clear(): Promise<void> {
this.queue = [];
await AsyncStorage.removeItem(QUEUE_KEY);
this.notifyListeners();
}
getStatus(): QueueStatus {
if (this.queue.length === 0) {
return { size: 0, oldestRequest: null, newestRequest: null };
}
return {
size: this.queue.length,
oldestRequest: this.queue[0]?.timestamp ?? null,
newestRequest: this.queue[this.queue.length - 1]?.timestamp ?? null,
};
}
isOnline(): boolean {
// In React Native, you'd use NetInfo here
// For now, return true (assume online)
return true;
}
}
export const requestQueue = new RequestQueue();

View File

@@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020", "DOM"],
"declaration": true,
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noImplicitThis": true,
"alwaysStrict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": false,
"inlineSourceMap": true,
"inlineSources": true,
"experimentalDecorators": true,
"strictPropertyInitialization": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"moduleResolution": "node",
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["./src/**/*"],
"exclude": ["node_modules", "dist"]
}

20
packages/mobile/.gitignore vendored Normal file
View File

@@ -0,0 +1,20 @@
node_modules/
.expo/
.expo-shared/
dist/
ios/
android/
*.jks
*.p8
*.p12
*.mobileprovision
*.orig.*
*.pub
.jscache
*.log
*.pid
*.tgz
*.npm
*.lock
.DS_Store
.env

29
packages/mobile/App.tsx Normal file
View File

@@ -0,0 +1,29 @@
import React, { useEffect } from 'react';
import { StatusBar } from 'expo-status-bar';
import { NavigationContainer } from '@react-navigation/native';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { useAuthStore } from '@/store/authStore';
import { AuthNavigator } from '@/navigation/AuthNavigator';
import { MainTabNavigator } from '@/navigation/MainTabNavigator';
import { usePushNotifications } from '@/hooks';
import '@/services/api';
export default function App() {
const { isAuthenticated } = useAuthStore();
const { registerForPushNotifications } = usePushNotifications();
useEffect(() => {
if (isAuthenticated) {
registerForPushNotifications();
}
}, [isAuthenticated, registerForPushNotifications]);
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<NavigationContainer>
<StatusBar style="light" />
{isAuthenticated ? <MainTabNavigator /> : <AuthNavigator />}
</NavigationContainer>
</GestureHandlerRootView>
);
}

66
packages/mobile/app.json Normal file
View File

@@ -0,0 +1,66 @@
{
"expo": {
"name": "ShieldAI",
"slug": "shieldai",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"scheme": "shieldai",
"userInterfaceStyle": "automatic",
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#0a0e1a"
},
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.frenocorp.shieldai",
"infoPlist": {
"NSFaceIDUsageDescription": "ShieldAI uses Face ID to securely access your account.",
"NSCameraUsageDescription": "ShieldAI needs camera access for VoicePrint enrollment.",
"NSMicrophoneUsageDescription": "ShieldAI needs microphone access for voice analysis."
},
"config": {
"usesNonExemptEncryption": false
}
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#0a0e1a"
},
"package": "com.frenocorp.shieldai",
"permissions": [
"RECEIVE_BOOT_COMPLETED",
"VIBRATE",
"android.permission.CAMERA",
"android.permission.RECORD_AUDIO"
]
},
"web": {
"bundler": "metro",
"output": "static",
"favicon": "./assets/favicon.png"
},
"plugins": [
"expo-secure-store",
"expo-local-authentication",
[
"expo-notifications",
{
"icon": "./assets/notification-icon.png",
"color": "#4f8cff",
"sounds": []
}
]
],
"experiments": {
"typedRoutes": true
},
"extra": {
"eas": {
"projectId": "shieldai-project-id"
}
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

View File

@@ -0,0 +1,67 @@
# ShieldAI - App Store Metadata
## App Information
- **App Name:** ShieldAI
- **Subtitle:** Your Digital Identity Shield
- **Description:** ShieldAI protects your digital identity in real-time. Monitor data breaches, block spam calls and texts, and verify voices with AI-powered protection. Get instant alerts when your personal information appears online, manage your watch list for new threats, and keep your family safe with VoicePrint verification.
- **Keyword:** privacy,security,spam,identity,breach,protection,voice,ai
- **Support URL:** https://shieldai.frenocorp.com/support
- **Privacy URL:** https://shieldai.frenocorp.com/privacy
- **Marketing URL:** https://shieldai.frenocorp.com
## iOS App Store Requirements
### Screenshots (Required)
- iPhone 6.7" (1290x2796): 3-5 screenshots
- iPhone 6.1" (1170x2532): 3-5 screenshots
- iPad 10.9" (2048x2732): 3-5 screenshots (if tablet supported)
- Format: PNG, no rounded corners, no device frame overlays
### App Icon
- 1024x1024 PNG, no transparency, no rounded corners
- Place at: `assets/store/ios/app-icon-1024.png`
### Preview Video (Optional)
- 9:16 or 16:9, 20-90 seconds
- MP4 format, under 100MB
## Google Play Store Requirements
### Screenshots (Required)
- Phone (1080x1920 recommended): 2-8 screenshots
- Tablet (1600x2560 recommended): 2-8 screenshots (if tablet supported)
- Format: PNG or JPEG, under 8MB each
### App Icon
- 512x512 PNG, 32-bit with transparency, 1MB or less
- Place at: `assets/store/android/app-icon-512.png`
### Feature Graphic
- 1024x500 PNG, 1MB or less, no transparency
- Place at: `assets/store/android/feature-graphic.png`
### Video (Optional)
- YouTube URL or uploaded video
## Tier-Based Feature Highlights for Screenshots
### Free Tier Screenshots
1. Dashboard - Basic exposure summary
2. SpamShield - Call/text spam detection
3. Settings - Account and notifications
### Plus Tier Screenshots
1. DarkWatch - Watch list and exposure feed
2. Alert notification preview
3. Real-time breach monitoring
### Premium Tier Screenshots
1. VoicePrint - Family enrollment
2. Voice analysis results
3. Deepfake detection status
## Screenshot Notes
- Screenshots should show the app in both light and dark mode
- Highlight key differentiators: real-time alerts, AI voice verification, family protection
- Use consistent device mockups across stores
- Localization-ready: avoid hardcoded text in screenshot UI

View File

@@ -0,0 +1,19 @@
# Android Screenshots
Place screenshots in this directory before submission.
## Required Sizes
- `phone-1080x1920.png` - 1080x1920 (standard phone)
- `tablet-1600x2560.png` - 1600x2560 (if tablet supported)
## Screens to Capture
1. Dashboard - exposure summary cards
2. DarkWatch - watch list with entities
3. SpamShield - call/text history
4. VoicePrint - family member enrollment
5. Settings - tier and notifications
## Notes
- Use Android emulator or connected device for captures
- Minimum 2 screenshots required, up to 8 allowed
- Landscape screenshots optional for productivity apps

View File

@@ -0,0 +1,19 @@
# iOS Screenshots
Place screenshots in this directory before submission.
## Required Sizes
- `iphone-6.7in.png` - 1290x2796 (iPhone 14 Pro Max, 15 Pro Max)
- `iphone-6.1in.png` - 1170x2532 (iPhone 14, 15)
## Screens to Capture
1. Dashboard - exposure summary cards
2. DarkWatch - watch list with entities
3. SpamShield - call/text history
4. VoicePrint - family member enrollment
5. Settings - tier and notifications
## Notes
- Use Xcode simulator or `xcrun simctl io booted screenshot` for captures
- No device frame overlays in screenshots
- Ensure dark mode and light mode are represented

View File

@@ -0,0 +1,11 @@
# Marketing Assets
Place marketing materials here.
## Required
- `app-store-preview.png` - 1200x628 OG image for social sharing
- `feature-graphic.png` - 1024x500 for Google Play feature graphic
## Optional
- `promo-video.mp4` - 30s promo for app store video preview
- `press-kit.zip` - Press kit with logos, screenshots, and brand guidelines

View File

@@ -0,0 +1,9 @@
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
plugins: [
'react-native-reanimated/plugin',
],
};
};

58
packages/mobile/eas.json Normal file
View File

@@ -0,0 +1,58 @@
{
"cli": {
"version": ">= 10.0.0",
"appVersionSource": "remote"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"ios": {
"resourceClass": "m1-medium"
},
"android": {
"buildType": "apk"
},
"env": {
"NODE_ENV": "development"
}
},
"preview": {
"distribution": "internal",
"ios": {
"simulator": true,
"resourceClass": "m1-medium"
},
"android": {
"buildType": "apk"
},
"env": {
"NODE_ENV": "development"
}
},
"production": {
"ios": {
"resourceClass": "m1-medium"
},
"android": {
"buildType": "app-bundle"
},
"env": {
"NODE_ENV": "production"
}
}
},
"submit": {
"production": {
"ios": {
"ascAppId": "shieldai-app-id",
"appleId": "apple@frenocorp.com",
"appleTeamId": "FRENOCORP-TEAM-ID"
},
"android": {
"serviceAccountKeyPath": "./google-play-service-account.json",
"track": "internal"
}
}
}
}

View File

@@ -0,0 +1,8 @@
const { getDefaultConfig } = require('expo/metro-config');
const config = getDefaultConfig(__dirname);
config.resolver.sourceExts = ['js', 'jsx', 'ts', 'tsx', 'json'];
config.watchFolders = [__dirname];
module.exports = config;

View File

@@ -1,22 +1,61 @@
{
"name": "mobile",
"version": "0.1.0",
"name": "@shieldai/mobile",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "node_modules/expo/AppEntry.js",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint src/"
"dev": "expo start",
"dev:ios": "expo run:ios",
"dev:android": "expo run:android",
"build": "expo build",
"build:ios": "expo build:ios",
"build:android": "expo build:android",
"lint": "eslint src/",
"typecheck": "tsc --noEmit",
"test": "jest",
"clean": "rm -rf node_modules .expo .expo-shared"
},
"dependencies": {
"solid-js": "^1.8.14",
"@shieldsai/shared-auth": "workspace:*",
"@shieldsai/shared-ui": "workspace:*",
"@shieldsai/shared-utils": "workspace:*"
"@shieldai/mobile-api-client": "workspace:*",
"@expo/vector-icons": "^14.0.0",
"@react-native-async-storage/async-storage": "1.23.1",
"@react-native-community/netinfo": "11.4.1",
"@react-navigation/bottom-tabs": "^6.5.0",
"@react-navigation/native": "^6.1.0",
"@react-navigation/stack": "^6.3.0",
"expo": "~51.0.0",
"expo-av": "~14.0.0",
"expo-constants": "~16.0.0",
"expo-crypto": "~13.0.0",
"expo-device": "~6.0.0",
"expo-file-system": "~17.0.0",
"expo-image-picker": "~15.0.0",
"expo-linking": "~6.3.0",
"expo-local-authentication": "~14.0.0",
"expo-notifications": "~0.28.0",
"expo-secure-store": "~13.0.0",
"expo-status-bar": "~1.12.0",
"expo-updates": "~0.18.0",
"react": "18.2.0",
"react-native": "0.74.5",
"react-native-gesture-handler": "~2.16.0",
"react-native-reanimated": "~3.10.0",
"react-native-safe-area-context": "4.10.5",
"react-native-screens": "3.31.0",
"react-native-svg": "15.2.0",
"zustand": "^4.4.0"
},
"devDependencies": {
"typescript": "^5.3.3",
"vite": "^5.1.4",
"@types/node": "^25.6.0"
"@babel/core": "^7.24.0",
"@types/react": "^18.2.0",
"@types/react-test-renderer": "^18.0.0",
"babel-preset-expo": "^11.0.0",
"eslint": "^8.57.0",
"eslint-plugin-react": "^7.34.0",
"eslint-plugin-react-native": "^4.1.0",
"jest": "^29.7.0",
"react-test-renderer": "18.2.0",
"typescript": "^5.3.0"
}
}

View File

@@ -0,0 +1,65 @@
import React from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { COLORS, BORDER_RADIUS, FONT_SIZES } from '@/constants/theme';
interface ButtonProps {
title: string;
onPress: () => void;
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
disabled?: boolean;
loading?: boolean;
fullWidth?: boolean;
}
export function Button({
title,
onPress,
variant = 'primary',
disabled = false,
loading = false,
fullWidth = false,
}: ButtonProps) {
const variantColors = {
primary: { bg: COLORS.primary, text: '#fff' },
secondary: { bg: COLORS.secondary, text: '#fff' },
danger: { bg: COLORS.danger, text: '#fff' },
ghost: { bg: 'transparent', text: COLORS.primary },
};
const colors = variantColors[variant];
return (
<TouchableOpacity
style={[
styles.button,
{ backgroundColor: colors.bg, opacity: disabled || loading ? 0.5 : 1 },
fullWidth && styles.fullWidth,
]}
onPress={onPress}
disabled={disabled || loading}
activeOpacity={0.7}
>
<Text style={[styles.text, { color: colors.text }]}>
{loading ? '...' : title}
</Text>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
button: {
borderRadius: BORDER_RADIUS.md,
paddingVertical: 12,
paddingHorizontal: 24,
alignItems: 'center',
justifyContent: 'center',
marginVertical: 4,
},
fullWidth: {
width: '100%',
},
text: {
fontSize: FONT_SIZES.md,
fontWeight: '600',
},
});

View File

@@ -0,0 +1,69 @@
import React from 'react';
import { StyleSheet, Text, View, ViewStyle } from 'react-native';
import { COLORS, BORDER_RADIUS, FONT_SIZES, SPACING } from '@/constants/theme';
interface SectionHeaderProps {
title: string;
action?: string;
onActionPress?: () => void;
}
export function SectionHeader({ title, action, onActionPress }: SectionHeaderProps) {
return (
<View style={styles.container}>
<Text style={styles.title}>{title}</Text>
{action && onActionPress && (
<Text style={styles.action} onPress={onActionPress}>
{action}
</Text>
)}
</View>
);
}
interface CardProps {
title?: string;
children: React.ReactNode;
style?: ViewStyle;
}
export function Card({ title, children, style }: CardProps) {
return (
<View style={[styles.card, style]}>
{title && <Text style={styles.cardTitle}>{title}</Text>}
{children}
</View>
);
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: SPACING.md,
paddingVertical: SPACING.sm,
},
title: {
color: COLORS.text,
fontSize: FONT_SIZES.lg,
fontWeight: '600',
},
action: {
color: COLORS.primary,
fontSize: FONT_SIZES.sm,
},
card: {
backgroundColor: COLORS.card,
borderRadius: BORDER_RADIUS.lg,
padding: SPACING.md,
marginHorizontal: SPACING.md,
marginVertical: SPACING.xs,
},
cardTitle: {
color: COLORS.text,
fontSize: FONT_SIZES.md,
fontWeight: '600',
marginBottom: SPACING.sm,
},
});

View File

@@ -0,0 +1,51 @@
import React from 'react';
import { StyleSheet, TextInput, TextInputProps, Text, View } from 'react-native';
import { COLORS, BORDER_RADIUS, FONT_SIZES } from '@/constants/theme';
interface InputProps extends Omit<TextInputProps, 'style'> {
label?: string;
error?: string;
containerStyle?: object;
}
export function Input({ label, error, containerStyle, ...props }: InputProps) {
return (
<View style={[styles.container, containerStyle]}>
{label && <Text style={styles.label}>{label}</Text>}
<TextInput
style={[
styles.input,
error ? { borderColor: COLORS.danger } : null,
]}
placeholderTextColor={COLORS.textMuted}
{...props}
/>
{error && <Text style={styles.error}>{error}</Text>}
</View>
);
}
const styles = StyleSheet.create({
container: {
marginBottom: 16,
},
label: {
color: COLORS.textSecondary,
fontSize: FONT_SIZES.sm,
marginBottom: 6,
},
input: {
backgroundColor: COLORS.backgroundLight,
borderColor: COLORS.border,
borderWidth: 1,
borderRadius: BORDER_RADIUS.md,
padding: 12,
color: COLORS.text,
fontSize: FONT_SIZES.md,
},
error: {
color: COLORS.danger,
fontSize: FONT_SIZES.xs,
marginTop: 4,
},
});

View File

@@ -0,0 +1,82 @@
import React from 'react';
import { StyleSheet, View, ActivityIndicator, Text } from 'react-native';
import { COLORS, SPACING } from '@/constants/theme';
interface LoadingOverlayProps {
visible: boolean;
}
export function LoadingOverlay({ visible }: LoadingOverlayProps) {
if (!visible) return null;
return (
<View style={styles.overlay}>
<ActivityIndicator size="large" color={COLORS.primary} />
</View>
);
}
interface EmptyStateProps {
title: string;
message?: string;
}
export function EmptyState({ title, message }: EmptyStateProps) {
return (
<View style={styles.emptyContainer}>
<View style={styles.emptyContent}>
<View style={styles.emptyIcon}>
<Text style={styles.emptyIconText}>📭</Text>
</View>
<View style={styles.emptyText}>
<Text style={styles.emptyTitle}>{title}</Text>
{message && <Text style={styles.emptyMessage}>{message}</Text>}
</View>
</View>
</View>
);
}
const styles = StyleSheet.create({
overlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.3)',
justifyContent: 'center',
alignItems: 'center',
zIndex: 999,
elevation: 999,
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: SPACING.xl,
},
emptyContent: {
alignItems: 'center',
},
emptyIcon: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: COLORS.card,
marginBottom: SPACING.md,
justifyContent: 'center',
alignItems: 'center',
},
emptyIconText: {
fontSize: 24,
},
emptyText: {
alignItems: 'center',
},
emptyTitle: {
color: COLORS.textSecondary,
fontSize: 16,
fontWeight: '600',
},
emptyMessage: {
color: COLORS.textMuted,
fontSize: 14,
},
});

View File

@@ -0,0 +1,45 @@
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { COLORS, FONT_SIZES, SPACING } from '@/constants/theme';
interface StatCardProps {
title: string;
value: string | number;
icon?: string;
color?: string;
subtitle?: string;
}
export function StatCard({ title, value, color = COLORS.primary, subtitle }: StatCardProps) {
return (
<View style={[styles.card, { borderLeftColor: color }]}>
<Text style={styles.title}>{title}</Text>
<Text style={[styles.value, { color }]}>{value}</Text>
{subtitle && <Text style={styles.subtitle}>{subtitle}</Text>}
</View>
);
}
const styles = StyleSheet.create({
card: {
backgroundColor: COLORS.card,
borderRadius: 8,
padding: SPACING.md,
marginBottom: SPACING.sm,
borderLeftWidth: 3,
},
title: {
color: COLORS.textSecondary,
fontSize: FONT_SIZES.sm,
marginBottom: SPACING.xs,
},
value: {
fontSize: FONT_SIZES.xxl,
fontWeight: 'bold',
},
subtitle: {
color: COLORS.textMuted,
fontSize: FONT_SIZES.xs,
marginTop: SPACING.xs,
},
});

View File

@@ -0,0 +1,5 @@
export * from './Button';
export * from './Input';
export * from './StatCard';
export * from './Card';
export * from './Loading';

View File

@@ -0,0 +1,11 @@
export const TAB_ORDER = ['Dashboard', 'DarkWatch', 'SpamShield', 'VoicePrint', 'Settings'] as const;
export const AUTH_STORAGE_KEY = '@shieldai_auth';
export const BIOMETRIC_ENABLED_KEY = '@shieldai_biometric';
export const ONBOARDING_COMPLETE_KEY = '@shieldai_onboarding';
export const NOTIFICATION_TYPES = {
DARKWATCH_ALERT: 'darkwatch_alert',
SPAM_BLOCKED: 'spam_blocked',
VOICEPRINT_ANALYSIS: 'voiceprint_analysis',
} as const;

View File

@@ -0,0 +1,95 @@
export const COLORS = {
primary: '#4f8cff',
primaryDark: '#3a6fd8',
secondary: '#6c5ce7',
accent: '#00cec9',
success: '#00b894',
warning: '#fdcb6e',
danger: '#ff6b6b',
background: '#0a0e1a',
backgroundLight: '#111827',
card: '#1a2035',
cardLight: '#243049',
text: '#e8eaf0',
textSecondary: '#8b95a8',
textMuted: '#5a6577',
border: '#2a3550',
overlay: 'rgba(0, 0, 0, 0.6)',
} as const;
export const SPACING = {
xs: 4,
sm: 8,
md: 16,
lg: 24,
xl: 32,
xxl: 48,
} as const;
export const FONT_SIZES = {
xs: 12,
sm: 14,
md: 16,
lg: 18,
xl: 20,
xxl: 24,
xxxl: 32,
} as const;
export const BORDER_RADIUS = {
sm: 4,
md: 8,
lg: 12,
xl: 16,
round: 999,
} as const;
export const SHADOWS = {
sm: { shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.1, shadowRadius: 2, elevation: 2 },
md: { shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.15, shadowRadius: 4, elevation: 4 },
lg: { shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.2, shadowRadius: 8, elevation: 8 },
} as const;
export const API_URL = process.env.EXPO_PUBLIC_API_URL || 'https://api.shieldai.freno.me/api/v1';
export const EAS_PROJECT_ID = process.env.EXPO_PUBLIC_EAS_PROJECT_ID || '';
export const getSeverityColor = (severity: string): string => {
switch (severity) {
case 'critical': return COLORS.danger;
case 'high': return COLORS.warning;
case 'medium': return COLORS.accent;
default: return COLORS.textSecondary;
}
};
export const TIER_FEATURES = {
free: {
name: 'Free',
maxExposures: 5,
spamProtection: false,
voicePrint: false,
darkWatch: false,
},
basic: {
name: 'Basic',
maxExposures: 50,
spamProtection: true,
voicePrint: false,
darkWatch: true,
},
premium: {
name: 'Premium',
maxExposures: 200,
spamProtection: true,
voicePrint: true,
darkWatch: true,
},
enterprise: {
name: 'Enterprise',
maxExposures: -1,
spamProtection: true,
voicePrint: true,
darkWatch: true,
},
} as const;

1
packages/mobile/src/global.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
declare const __DEV__: boolean;

View File

@@ -0,0 +1,3 @@
export * from './usePushNotifications';
export * from './useBiometricAuth';
export * from './useNetworkStatus';

View File

@@ -0,0 +1,78 @@
import { useCallback, useState, useEffect } from 'react';
import * as LocalAuthentication from 'expo-local-authentication';
import { Alert } from 'react-native';
import { useSettingsStore } from '@/store/settingsStore';
export function useBiometricAuth() {
const { isBiometricEnabled } = useSettingsStore();
const [biometricsAvailable, setBiometricsAvailable] = useState(false);
useEffect(() => {
(async () => {
const hasHardware = await LocalAuthentication.hasHardwareAsync();
const isEnrolled = await LocalAuthentication.isEnrolledAsync();
setBiometricsAvailable(hasHardware && isEnrolled);
})();
}, []);
const authenticate = useCallback(async (): Promise<boolean> => {
if (!isBiometricEnabled) return true;
try {
const isAvailable = await LocalAuthentication.hasHardwareAsync();
const isEnrolled = await LocalAuthentication.isEnrolledAsync();
if (!isAvailable || !isEnrolled) {
Alert.alert(
'Biometric Unavailable',
'Biometric authentication requires hardware and enrollment. Please use your password.',
[{ text: 'OK' }]
);
useSettingsStore.getState().toggleBiometric(false);
return false;
}
const result = await LocalAuthentication.authenticateAsync({
promptMessage: 'Authenticate with biometrics',
fallbackLabel: 'Use passcode',
cancelLabel: 'Cancel',
disableDeviceFallback: false,
});
return result.success;
} catch {
return false;
}
}, [isBiometricEnabled]);
const enableBiometric = useCallback(async (): Promise<boolean> => {
const isAvailable = await LocalAuthentication.hasHardwareAsync();
const isEnrolled = await LocalAuthentication.isEnrolledAsync();
if (!isAvailable || !isEnrolled) {
Alert.alert(
'Biometric Not Available',
'Your device does not support biometric authentication or no credentials are enrolled.',
[{ text: 'OK' }]
);
return false;
}
const result = await LocalAuthentication.authenticateAsync({
promptMessage: 'Enable biometric authentication',
fallbackLabel: 'Use passcode',
});
if (result.success) {
useSettingsStore.getState().toggleBiometric(true);
}
return result.success;
}, []);
const disableBiometric = useCallback(() => {
useSettingsStore.getState().toggleBiometric(false);
}, []);
return { authenticate, enableBiometric, disableBiometric, isBiometricEnabled, biometricsAvailable };
}

View File

@@ -0,0 +1,18 @@
import { useEffect, useState } from 'react';
import NetInfo from '@react-native-community/netinfo';
export function useNetworkStatus() {
const [isConnected, setIsConnected] = useState(true);
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
const unsubscribe = NetInfo.addEventListener((state) => {
setIsConnected(!!state.isConnected);
setIsOnline(!!state.isInternetReachable);
});
return unsubscribe;
}, []);
return { isConnected, isOnline, isOffline: !isOnline };
}

View File

@@ -0,0 +1,86 @@
import { useEffect, useCallback, useRef } from 'react';
import * as Notifications from 'expo-notifications';
import { Platform } from 'react-native';
import * as Device from 'expo-device';
import { deviceService, notificationService } from '@shieldai/mobile-api-client';
import { useSettingsStore } from '@/store/settingsStore';
import { EAS_PROJECT_ID } from '@/constants/theme';
export function usePushNotifications() {
const preferencesRef = useRef(useSettingsStore.getState().preferences);
useEffect(() => {
const subscription = useSettingsStore.subscribe((state) => {
preferencesRef.current = state.preferences;
});
return subscription;
}, []);
Notifications.setNotificationHandler({
handleNotification: async () => {
const prefs = preferencesRef.current;
return {
shouldShowAlert: prefs.pushNotifications,
shouldPlaySound: prefs.pushNotifications,
shouldSetBadge: false,
};
},
});
const registerForPushNotifications = useCallback(async () => {
try {
const { status: existingStatus } = await Notifications.getPermissionsAsync();
let finalStatus = existingStatus;
if (existingStatus !== 'granted') {
const { status } = await Notifications.requestPermissionsAsync();
finalStatus = status;
}
if (finalStatus !== 'granted') {
return null;
}
if (!EAS_PROJECT_ID) {
console.warn('EAS_PROJECT_ID not configured — push notifications disabled');
return null;
}
const token = (await Notifications.getExpoPushTokenAsync({
projectId: EAS_PROJECT_ID,
})).data;
try {
await deviceService.registerDevice({
platform: Platform.OS === 'ios' ? 'ios' : 'android',
pushToken: token,
modelName: Platform.OS === 'ios' ? 'iPhone' : 'Android',
osVersion: Device.osVersion || '0',
appVersion: '1.0.0',
});
} catch (deviceError) {
console.warn('Device registration failed (will retry on next launch):', deviceError);
}
return token;
} catch (error) {
console.error('Failed to register for push notifications:', error);
return null;
}
}, []);
useEffect(() => {
const subscription = Notifications.addNotificationReceivedListener((notification) => {
const type = notification.request.content.data?.type;
const prefs = preferencesRef.current;
if (type === 'darkwatch_alert' && !prefs.darkwatchAlert) return;
if (type === 'spam_blocked' && !prefs.spamBlocked) return;
if (type === 'voiceprint_analysis' && !prefs.voiceprintAnalysis) return;
});
return () => subscription.remove();
}, []);
return { registerForPushNotifications };
}

View File

@@ -0,0 +1,24 @@
import React from 'react';
import { createStackNavigator } from '@react-navigation/stack';
import { LoginScreen, RegisterScreen } from '@/screens/auth';
type AuthStackParamList = {
Login: undefined;
Register: undefined;
};
const Stack = createStackNavigator<AuthStackParamList>();
export function AuthNavigator() {
return (
<Stack.Navigator
screenOptions={{
headerShown: false,
cardStyle: { backgroundColor: '#0a0e1a' },
}}
>
<Stack.Screen name="Login" component={LoginScreen} />
<Stack.Screen name="Register" component={RegisterScreen} />
</Stack.Navigator>
);
}

View File

@@ -0,0 +1,124 @@
import React from 'react';
import { Text, ViewStyle, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { DashboardScreen } from '@/screens/dashboard';
import { DarkWatchScreen } from '@/screens/darkwatch';
import { SpamShieldScreen } from '@/screens/spamshield';
import { VoicePrintScreen } from '@/screens/voiceprint';
import { SettingsScreen } from '@/screens/settings';
import { COLORS, FONT_SIZES } from '@/constants/theme';
type MainTabParamList = {
Dashboard: undefined;
DarkWatch: undefined;
SpamShield: undefined;
VoicePrint: undefined;
Settings: undefined;
};
const Tab = createBottomTabNavigator<MainTabParamList>();
const iconMap: Record<string, keyof typeof Ionicons.glyphMap> = {
Dashboard: 'shield-outline',
DarkWatch: 'eye-outline',
SpamShield: 'ban-outline',
VoicePrint: 'mic-outline',
Settings: 'settings-outline',
};
const iconActiveMap: Record<string, keyof typeof Ionicons.glyphMap> = {
Dashboard: 'shield',
DarkWatch: 'eye',
SpamShield: 'ban',
VoicePrint: 'mic',
Settings: 'settings',
};
function TabIcon({ routeName, color, focused }: { routeName: string; color: string; focused: boolean }) {
const iconName = focused
? (iconActiveMap[routeName] as keyof typeof Ionicons.glyphMap)
: (iconMap[routeName] as keyof typeof Ionicons.glyphMap);
return <Ionicons name={iconName} size={24} color={color} />;
}
export function MainTabNavigator() {
return (
<Tab.Navigator
screenOptions={{
headerStyle: {
backgroundColor: COLORS.background,
},
headerTintColor: COLORS.text,
headerTitleStyle: {
fontSize: FONT_SIZES.lg,
fontWeight: '600',
},
tabBarStyle: {
backgroundColor: COLORS.backgroundLight,
borderTopColor: COLORS.border,
borderTopWidth: 1,
height: 60,
paddingBottom: 8,
paddingTop: 8,
} as ViewStyle,
tabBarActiveTintColor: COLORS.primary,
tabBarInactiveTintColor: COLORS.textMuted,
tabBarLabelStyle: {
fontSize: FONT_SIZES.xs,
},
tabBarIconStyle: {
marginTop: 4,
},
tabBarShowLabel: true,
}}
>
<Tab.Screen
name="Dashboard"
component={DashboardScreen}
options={{
headerTitle: 'Dashboard',
tabBarLabel: 'Home',
tabBarIcon: ({ color, focused }) => <TabIcon routeName="Dashboard" color={color} focused={focused} />,
}}
/>
<Tab.Screen
name="DarkWatch"
component={DarkWatchScreen}
options={{
headerTitle: 'DarkWatch',
tabBarLabel: 'DarkWatch',
tabBarIcon: ({ color, focused }) => <TabIcon routeName="DarkWatch" color={color} focused={focused} />,
}}
/>
<Tab.Screen
name="SpamShield"
component={SpamShieldScreen}
options={{
headerTitle: 'SpamShield',
tabBarLabel: 'SpamShield',
tabBarIcon: ({ color, focused }) => <TabIcon routeName="SpamShield" color={color} focused={focused} />,
}}
/>
<Tab.Screen
name="VoicePrint"
component={VoicePrintScreen}
options={{
headerTitle: 'VoicePrint',
tabBarLabel: 'VoicePrint',
tabBarIcon: ({ color, focused }) => <TabIcon routeName="VoicePrint" color={color} focused={focused} />,
}}
/>
<Tab.Screen
name="Settings"
component={SettingsScreen}
options={{
headerTitle: 'Settings',
tabBarLabel: 'Settings',
tabBarIcon: ({ color, focused }) => <TabIcon routeName="Settings" color={color} focused={focused} />,
}}
/>
</Tab.Navigator>
);
}

View File

@@ -0,0 +1,2 @@
export * from './AuthNavigator';
export * from './MainTabNavigator';

View File

@@ -0,0 +1,179 @@
import React, { useState } from 'react';
import { StyleSheet, Text, View, SafeAreaView, KeyboardAvoidingView, Platform, ScrollView, Alert } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useNavigation, NavigationProp } from '@react-navigation/native';
import { useAuthStore } from '@/store/authStore';
import { Button, Input } from '@/components';
import { COLORS, FONT_SIZES, SPACING } from '@/constants/theme';
type RootStackParamList = {
Login: undefined;
Register: undefined;
};
const LOGIN_ATTEMPT_KEY = '@shieldai_login_attempts';
const MAX_ATTEMPTS = 5;
const LOCKOUT_MS = 5 * 60 * 1000;
export function LoginScreen() {
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { login, isLoading, clearError } = useAuthStore();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [formError, setFormError] = useState('');
const handleLogin = async () => {
setFormError('');
clearError();
if (!email || !password) {
setFormError('Please fill in all fields');
return;
}
const now = Date.now();
const stored = await AsyncStorage.getItem(LOGIN_ATTEMPT_KEY);
let attempts = { count: 0, lockedUntil: 0 };
if (stored) {
try {
attempts = JSON.parse(stored);
} catch { /* ignore corrupt data */ }
}
if (attempts.lockedUntil > now) {
const remaining = Math.ceil((attempts.lockedUntil - now) / 1000 / 60);
setFormError(`Too many failed attempts. Try again in ${remaining} minute${remaining !== 1 ? 's' : ''}.`);
return;
}
if (attempts.count >= MAX_ATTEMPTS && attempts.lockedUntil <= now) {
attempts.lockedUntil = now + LOCKOUT_MS;
attempts.count = 0;
await AsyncStorage.setItem(LOGIN_ATTEMPT_KEY, JSON.stringify(attempts));
const remaining = Math.ceil(LOCKOUT_MS / 1000 / 60);
setFormError(`Too many failed attempts. Locked for ${remaining} minutes.`);
return;
}
try {
await login(email, password);
await AsyncStorage.removeItem(LOGIN_ATTEMPT_KEY);
} catch (err: any) {
attempts.count = (attempts.count || 0) + 1;
if (attempts.count >= MAX_ATTEMPTS) {
attempts.lockedUntil = Date.now() + LOCKOUT_MS;
}
await AsyncStorage.setItem(LOGIN_ATTEMPT_KEY, JSON.stringify(attempts));
const remaining = MAX_ATTEMPTS - attempts.count;
const warning = remaining > 0 ? ` ${remaining} attempt${remaining !== 1 ? 's' : ''} remaining.` : '';
setFormError(err.message || 'Login failed. Please try again.' + warning);
Alert.alert('Login Failed', err.message || 'Please check your credentials and try again.' + warning);
}
};
return (
<SafeAreaView style={styles.container}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.keyboardView}
>
<ScrollView contentContainerStyle={styles.scrollContent}>
<View style={styles.header}>
<Text style={styles.logo}>ShieldAI</Text>
<Text style={styles.tagline}>Your digital protection suite</Text>
</View>
<View style={styles.form}>
{formError && <Text style={styles.error}>{formError}</Text>}
<Input
label="Email"
placeholder="you@example.com"
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
autoComplete="email"
/>
<Input
label="Password"
placeholder="••••••••"
value={password}
onChangeText={setPassword}
secureTextEntry
autoComplete="password"
/>
<Button
title={isLoading ? 'Signing in...' : 'Sign In'}
onPress={handleLogin}
disabled={isLoading}
fullWidth
/>
<View style={styles.footer}>
<Text style={styles.footerText}>Don't have an account? </Text>
<Text style={styles.link} onPress={() => navigation.navigate('Register')}>
Sign Up
</Text>
</View>
</View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: COLORS.background,
},
keyboardView: {
flex: 1,
},
scrollContent: {
flexGrow: 1,
justifyContent: 'center',
padding: SPACING.lg,
},
header: {
alignItems: 'center',
marginBottom: SPACING.xxl,
},
logo: {
color: COLORS.primary,
fontSize: FONT_SIZES.xxxl,
fontWeight: 'bold',
},
tagline: {
color: COLORS.textSecondary,
fontSize: FONT_SIZES.md,
marginTop: SPACING.sm,
},
form: {
width: '100%',
},
error: {
color: COLORS.danger,
fontSize: FONT_SIZES.sm,
marginBottom: SPACING.md,
textAlign: 'center',
},
footer: {
flexDirection: 'row',
justifyContent: 'center',
marginTop: SPACING.lg,
},
footerText: {
color: COLORS.textSecondary,
fontSize: FONT_SIZES.sm,
},
link: {
color: COLORS.primary,
fontSize: FONT_SIZES.sm,
fontWeight: '600',
},
});

View File

@@ -0,0 +1,178 @@
import React, { useState } from 'react';
import { StyleSheet, Text, View, SafeAreaView, KeyboardAvoidingView, Platform, ScrollView, Alert } from 'react-native';
import { useNavigation, NavigationProp } from '@react-navigation/native';
import { useAuthStore } from '@/store/authStore';
import { Button, Input } from '@/components';
import { COLORS, FONT_SIZES, SPACING } from '@/constants/theme';
type RootStackParamList = {
Login: undefined;
Register: undefined;
};
export function RegisterScreen() {
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { register, isLoading } = useAuthStore();
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [formError, setFormError] = useState('');
const handleRegister = async () => {
setFormError('');
if (!firstName || !lastName || !email || !password) {
setFormError('Please fill in all fields');
return;
}
if (password !== confirmPassword) {
setFormError('Passwords do not match');
return;
}
if (password.length < 8) {
setFormError('Password must be at least 8 characters');
return;
}
try {
await register(email, password, firstName, lastName);
} catch (err: any) {
setFormError(err.message || 'Registration failed. Please try again.');
Alert.alert('Registration Failed', err.message || 'Please try again with different credentials.');
}
};
return (
<SafeAreaView style={styles.container}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.keyboardView}
>
<ScrollView contentContainerStyle={styles.scrollContent}>
<View style={styles.header}>
<Text style={styles.title}>Create Account</Text>
<Text style={styles.subtitle}>Join ShieldAI today</Text>
</View>
<View style={styles.form}>
{formError && <Text style={styles.error}>{formError}</Text>}
<Input
label="First Name"
placeholder="John"
value={firstName}
onChangeText={setFirstName}
autoCapitalize="words"
/>
<Input
label="Last Name"
placeholder="Doe"
value={lastName}
onChangeText={setLastName}
autoCapitalize="words"
/>
<Input
label="Email"
placeholder="you@example.com"
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
autoComplete="email"
/>
<Input
label="Password"
placeholder="••••••••"
value={password}
onChangeText={setPassword}
secureTextEntry
autoComplete="password"
/>
<Input
label="Confirm Password"
placeholder="••••••••"
value={confirmPassword}
onChangeText={setConfirmPassword}
secureTextEntry
autoComplete="password"
/>
<Button
title={isLoading ? 'Creating account...' : 'Create Account'}
onPress={handleRegister}
disabled={isLoading}
fullWidth
/>
<View style={styles.footer}>
<Text style={styles.footerText}>Already have an account? </Text>
<Text style={styles.link} onPress={() => navigation.navigate('Login')}>
Sign In
</Text>
</View>
</View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: COLORS.background,
},
keyboardView: {
flex: 1,
},
scrollContent: {
flexGrow: 1,
padding: SPACING.lg,
},
header: {
alignItems: 'center',
marginBottom: SPACING.xl,
},
title: {
color: COLORS.text,
fontSize: FONT_SIZES.xxxl,
fontWeight: 'bold',
},
subtitle: {
color: COLORS.textSecondary,
fontSize: FONT_SIZES.md,
marginTop: SPACING.xs,
},
form: {
width: '100%',
},
error: {
color: COLORS.danger,
fontSize: FONT_SIZES.sm,
marginBottom: SPACING.md,
textAlign: 'center',
},
footer: {
flexDirection: 'row',
justifyContent: 'center',
marginTop: SPACING.lg,
},
footerText: {
color: COLORS.textSecondary,
fontSize: FONT_SIZES.sm,
},
link: {
color: COLORS.primary,
fontSize: FONT_SIZES.sm,
fontWeight: '600',
},
});

View File

@@ -0,0 +1,2 @@
export * from './LoginScreen';
export * from './RegisterScreen';

View File

@@ -0,0 +1,337 @@
import React, { useState } from 'react';
import { StyleSheet, Text, View, SafeAreaView, ScrollView, FlatList, TouchableOpacity, Alert, Modal } from 'react-native';
import { useDarkWatchStore } from '@/store/darkWatchStore';
import { Card, Button, Input, EmptyState } from '@/components';
import { COLORS, FONT_SIZES, SPACING, BORDER_RADIUS, getSeverityColor } from '@/constants/theme';
import type { WatchListItem } from '@/types';
export function DarkWatchScreen() {
const { watchList, exposures, addWatchItem, removeWatchItem, toggleAlert } = useDarkWatchStore();
const [showAddModal, setShowAddModal] = useState(false);
const [newItemName, setNewItemName] = useState('');
const [newItemValue, setNewItemValue] = useState('');
const [newItemType, setNewItemType] = useState<WatchListItem['entityType']>('person');
const [validationError, setValidationError] = useState('');
const validateWatchItem = (name: string, value: string, type: string): string | null => {
if (!name.trim() || !value.trim()) return 'Name and value are required';
if (name.length > 100) return 'Name must be 100 characters or less';
if (value.length > 200) return 'Value must be 200 characters or less';
if (type === 'email' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
return 'Enter a valid email address';
}
if (type === 'phone' && !/^\+?[\d\s-()]{7,20}$/.test(value)) {
return 'Enter a valid phone number';
}
return null;
};
const handleAddItem = async () => {
const error = validateWatchItem(newItemName, newItemValue, newItemType);
if (error) {
setValidationError(error);
return;
}
setValidationError('');
await addWatchItem({
name: newItemName,
entityType: newItemType,
value: newItemValue,
alertEnabled: true,
});
setNewItemName('');
setNewItemValue('');
setShowAddModal(false);
};
const handleRemoveItem = (id: string, name: string) => {
Alert.alert(
'Remove Watch Item',
`Remove "${name}" from your watch list?`,
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Remove',
style: 'destructive',
onPress: () => removeWatchItem(id),
},
]
);
};
const renderItem = ({ item }: { item: WatchListItem }) => (
<View style={styles.watchItem}>
<View style={styles.watchItemLeft}>
<View style={[styles.entityBadge, { backgroundColor: COLORS.primary }]} />
<View style={styles.watchItemInfo}>
<Text style={styles.watchItemName}>{item.name}</Text>
<Text style={styles.watchItemValue}>{item.value}</Text>
</View>
</View>
<View style={styles.watchItemActions}>
<TouchableOpacity
style={[styles.alertToggle, { opacity: item.alertEnabled ? 1 : 0.4 }]}
onPress={() => toggleAlert(item.id)}
>
<Text style={styles.alertToggleText}>{item.alertEnabled ? '🔔' : '🔕'}</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.removeButton}
onPress={() => handleRemoveItem(item.id, item.name)}
>
<Text style={styles.removeButtonText}></Text>
</TouchableOpacity>
</View>
</View>
);
return (
<SafeAreaView style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>DarkWatch</Text>
<Text style={styles.subtitle}>Monitor your digital footprint</Text>
</View>
<ScrollView style={styles.content}>
<Card title="Watch List">
<Button
title="+ Add Watch Item"
onPress={() => setShowAddModal(true)}
variant="secondary"
fullWidth
/>
{watchList.length === 0 ? (
<EmptyState
title="No watch items yet"
message="Add people, emails, or phone numbers to monitor"
/>
) : (
<FlatList
data={watchList}
renderItem={renderItem}
keyExtractor={(item) => item.id}
scrollEnabled={false}
/>
)}
</Card>
<Card title="Recent Exposures">
{exposures.length === 0 ? (
<EmptyState title="No recent exposures" message="DarkWatch is monitoring for new data leaks" />
) : (
<FlatList
data={exposures}
keyExtractor={(item) => item.id}
scrollEnabled={false}
renderItem={({ item }) => (
<View style={styles.exposureItem}>
<Text style={styles.exposureSource}>{item.source}</Text>
<Text style={[styles.exposureSeverity, { color: getSeverityColor(item.severity) }]}>
{item.severity.toUpperCase()}
</Text>
</View>
)}
/>
)}
</Card>
</ScrollView>
<Modal visible={showAddModal} animationType="slide" transparent>
<View style={styles.modalOverlay}>
<View style={styles.modalContent}>
<Text style={styles.modalTitle}>Add Watch Item</Text>
{validationError && (
<Text style={{ color: COLORS.danger, fontSize: FONT_SIZES.sm, marginBottom: SPACING.sm }}>
{validationError}
</Text>
)}
<Input
label="Name"
placeholder="e.g., John Doe"
value={newItemName}
onChangeText={setNewItemName}
/>
<Input
label="Value"
placeholder={`Enter ${newItemType}`}
value={newItemValue}
onChangeText={setNewItemValue}
/>
<View style={styles.typeSelector}>
{(['person', 'email', 'phone', 'address'] as const).map((type) => (
<TouchableOpacity
key={type}
style={[
styles.typeButton,
{ backgroundColor: newItemType === type ? COLORS.primary : COLORS.card },
]}
onPress={() => setNewItemType(type)}
>
<Text style={[
styles.typeButtonText,
{ color: newItemType === type ? '#fff' : COLORS.textSecondary }
]}>
{type.charAt(0).toUpperCase() + type.slice(1)}
</Text>
</TouchableOpacity>
))}
</View>
<View style={styles.modalActions}>
<Button
title="Cancel"
onPress={() => setShowAddModal(false)}
variant="ghost"
/>
<Button
title="Add"
onPress={handleAddItem}
variant="primary"
/>
</View>
</View>
</View>
</Modal>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: COLORS.background,
},
header: {
padding: SPACING.md,
borderBottomWidth: 1,
borderBottomColor: COLORS.border,
},
title: {
color: COLORS.text,
fontSize: FONT_SIZES.xxl,
fontWeight: 'bold',
},
subtitle: {
color: COLORS.textSecondary,
fontSize: FONT_SIZES.sm,
marginTop: SPACING.xs,
},
content: {
flex: 1,
},
watchItem: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: SPACING.sm,
borderBottomWidth: 1,
borderBottomColor: COLORS.border,
},
watchItemLeft: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
entityBadge: {
width: 36,
height: 36,
borderRadius: 18,
marginRight: SPACING.sm,
justifyContent: 'center',
alignItems: 'center',
},
watchItemInfo: {
flex: 1,
},
watchItemName: {
color: COLORS.text,
fontSize: FONT_SIZES.md,
fontWeight: '500',
},
watchItemValue: {
color: COLORS.textSecondary,
fontSize: FONT_SIZES.sm,
},
watchItemActions: {
flexDirection: 'row',
gap: SPACING.sm,
},
alertToggle: {
padding: SPACING.xs,
},
alertToggleText: {
fontSize: 16,
},
removeButton: {
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: COLORS.cardLight,
justifyContent: 'center',
alignItems: 'center',
},
removeButtonText: {
color: COLORS.danger,
fontSize: 14,
},
exposureItem: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingVertical: SPACING.sm,
borderBottomWidth: 1,
borderBottomColor: COLORS.border,
},
exposureSource: {
color: COLORS.text,
fontSize: FONT_SIZES.md,
},
exposureSeverity: {
fontSize: FONT_SIZES.xs,
fontWeight: '600',
},
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.7)',
justifyContent: 'flex-end',
},
modalContent: {
backgroundColor: COLORS.backgroundLight,
borderTopLeftRadius: BORDER_RADIUS.xl,
borderTopRightRadius: BORDER_RADIUS.xl,
padding: SPACING.lg,
paddingBottom: SPACING.xxl,
},
modalTitle: {
color: COLORS.text,
fontSize: FONT_SIZES.xl,
fontWeight: 'bold',
marginBottom: SPACING.md,
},
typeSelector: {
flexDirection: 'row',
gap: SPACING.sm,
marginBottom: SPACING.md,
},
typeButton: {
flex: 1,
paddingVertical: SPACING.sm,
borderRadius: BORDER_RADIUS.md,
alignItems: 'center',
},
typeButtonText: {
fontSize: FONT_SIZES.sm,
fontWeight: '500',
},
modalActions: {
flexDirection: 'row',
gap: SPACING.md,
marginTop: SPACING.md,
},
});

View File

@@ -0,0 +1 @@
export * from './DarkWatchScreen';

View File

@@ -0,0 +1,134 @@
import React, { useEffect } from 'react';
import { StyleSheet, Text, View, SafeAreaView, ScrollView, RefreshControl } from 'react-native';
import { useDashboardStore } from '@/store/dashboardStore';
import { useAuthStore } from '@/store/authStore';
import { StatCard, Card, LoadingOverlay } from '@/components';
import { COLORS, FONT_SIZES, SPACING } from '@/constants/theme';
export function DashboardScreen() {
const { data, isLoading, refreshDashboard } = useDashboardStore();
const { user } = useAuthStore();
useEffect(() => {
if (!data) {
refreshDashboard();
}
}, [data, refreshDashboard]);
return (
<SafeAreaView style={styles.container}>
<View style={styles.header}>
<Text style={styles.greeting}>
Welcome back, {user?.firstName || 'User'}
</Text>
<Text style={styles.tier}>
{user?.tier ? user?.tier.charAt(0).toUpperCase() + user?.tier.slice(1) : 'Free'} Plan
</Text>
</View>
<ScrollView
refreshControl={
<RefreshControl
refreshing={isLoading}
onRefresh={refreshDashboard}
tintColor={COLORS.primary}
/>
}
>
<Card title="Exposure Summary">
<StatCard
title="Total Exposures"
value={data?.exposureSummary.total ?? 0}
color={COLORS.primary}
/>
<StatCard
title="Unresolved"
value={data?.exposureSummary.unresolved ?? 0}
color={COLORS.warning}
/>
<StatCard
title="Critical"
value={data?.exposureSummary.critical ?? 0}
color={COLORS.danger}
/>
</Card>
<Card title="SpamShield Stats">
<StatCard
title="Blocked Today"
value={data?.spamStats.blockedToday ?? 0}
color={COLORS.success}
/>
<StatCard
title="Total Blocked"
value={data?.spamStats.blockedTotal ?? 0}
color={COLORS.accent}
/>
<StatCard
title="Spam Score"
value={`${data?.spamStats.spamScore ?? 0}%`}
color={COLORS.secondary}
/>
</Card>
<Card title="VoicePrint Status">
<View style={styles.voiceStatus}>
<View style={styles.statusRow}>
<View style={[styles.statusDot, { backgroundColor: data?.voiceProtectionStatus.isMonitoring ? COLORS.success : COLORS.textMuted }]} />
<Text style={styles.statusText}>
{data?.voiceProtectionStatus.isMonitoring ? 'Monitoring Active' : 'Monitoring Inactive'}
</Text>
</View>
<StatCard
title="Profiles Enrolled"
value={data?.voiceProtectionStatus.profilesEnrolled ?? 0}
color={COLORS.accent}
/>
</View>
</Card>
</ScrollView>
<LoadingOverlay visible={isLoading} />
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: COLORS.background,
},
header: {
padding: SPACING.md,
borderBottomWidth: 1,
borderBottomColor: COLORS.border,
},
greeting: {
color: COLORS.text,
fontSize: FONT_SIZES.xxl,
fontWeight: 'bold',
},
tier: {
color: COLORS.primary,
fontSize: FONT_SIZES.sm,
marginTop: SPACING.xs,
},
voiceStatus: {
gap: SPACING.sm,
},
statusRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: SPACING.sm,
},
statusDot: {
width: 8,
height: 8,
borderRadius: 4,
marginRight: SPACING.sm,
},
statusText: {
color: COLORS.text,
fontSize: FONT_SIZES.md,
},
});

View File

@@ -0,0 +1 @@
export * from './DashboardScreen';

View File

@@ -0,0 +1,292 @@
import React from 'react';
import { StyleSheet, Text, View, SafeAreaView, ScrollView, TouchableOpacity, Switch, Alert } from 'react-native';
import { useAuthStore } from '@/store/authStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useBiometricAuth } from '@/hooks';
import { Card, Button } from '@/components';
import { COLORS, FONT_SIZES, SPACING } from '@/constants/theme';
import { TIER_FEATURES } from '@/constants/theme';
export function SettingsScreen() {
const { user, logout } = useAuthStore();
const { preferences, updatePreferences, isBiometricEnabled } = useSettingsStore();
const { enableBiometric, disableBiometric } = useBiometricAuth();
const handleLogout = () => {
Alert.alert(
'Logout',
'Are you sure you want to logout?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Logout',
style: 'destructive',
onPress: () => logout(),
},
]
);
};
const handleBiometricToggle = async (value: boolean) => {
if (value) {
const success = await enableBiometric();
if (!success) {
updatePreferences({});
}
} else {
disableBiometric();
}
};
const tier = user?.tier || 'free';
const features = TIER_FEATURES[tier as keyof typeof TIER_FEATURES];
return (
<SafeAreaView style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>Settings</Text>
</View>
<ScrollView style={styles.content}>
<Card title="Account">
<View style={styles.profileRow}>
<View style={styles.avatar}>
<Text style={styles.avatarText}>
{user?.firstName?.charAt(0) || 'U'}
</Text>
</View>
<View style={styles.profileInfo}>
<Text style={styles.profileName}>
{user?.firstName} {user?.lastName}
</Text>
<Text style={styles.profileEmail}>{user?.email}</Text>
</View>
</View>
</Card>
<Card title="Subscription">
<View style={styles.tierRow}>
<Text style={styles.tierLabel}>Current Plan</Text>
<View style={[styles.tierBadge, { backgroundColor: COLORS.primary }]}>
<Text style={styles.tierBadgeText}>{features.name}</Text>
</View>
</View>
<View style={styles.featuresList}>
<FeatureRow label="Spam Protection" available={features.spamProtection} />
<FeatureRow label="VoicePrint" available={features.voicePrint} />
<FeatureRow label="DarkWatch" available={features.darkWatch} />
<FeatureRow
label="Max Exposures/Month"
value={features.maxExposures === -1 ? 'Unlimited' : `${features.maxExposures}`}
/>
</View>
<Button
title="Upgrade Plan"
onPress={() =>
Alert.alert(
'Upgrade Plan',
'Billing integration coming soon. Contact support@shieldai.freno.me to upgrade.',
[{ text: 'OK' }]
)
}
variant="secondary"
fullWidth
/>
</Card>
<Card title="Notifications">
<ToggleRow
label="Push Notifications"
value={preferences.pushNotifications}
onToggle={() => updatePreferences({ pushNotifications: !preferences.pushNotifications })}
/>
<ToggleRow
label="Email Notifications"
value={preferences.emailNotifications}
onToggle={() => updatePreferences({ emailNotifications: !preferences.emailNotifications })}
/>
<ToggleRow
label="DarkWatch Alerts"
value={preferences.darkwatchAlert}
onToggle={() => updatePreferences({ darkwatchAlert: !preferences.darkwatchAlert })}
/>
<ToggleRow
label="Spam Blocked Alerts"
value={preferences.spamBlocked}
onToggle={() => updatePreferences({ spamBlocked: !preferences.spamBlocked })}
/>
<ToggleRow
label="VoicePrint Analysis"
value={preferences.voiceprintAnalysis}
onToggle={() => updatePreferences({ voiceprintAnalysis: !preferences.voiceprintAnalysis })}
/>
</Card>
<Card title="Security">
<ToggleRow
label="Biometric Authentication"
value={isBiometricEnabled}
onToggle={handleBiometricToggle}
/>
</Card>
<View style={styles.logoutSection}>
<Button title="Logout" onPress={handleLogout} variant="danger" fullWidth />
</View>
<View style={styles.version}>
<Text style={styles.versionText}>ShieldAI v1.0.0</Text>
</View>
</ScrollView>
</SafeAreaView>
);
}
interface ToggleRowProps {
label: string;
value: boolean;
onToggle: (value: boolean) => void;
}
function ToggleRow({ label, value, onToggle }: ToggleRowProps) {
return (
<View style={styles.toggleRow}>
<Text style={styles.toggleLabel}>{label}</Text>
<Switch
value={value}
onValueChange={onToggle}
trackColor={{ true: COLORS.primary, false: COLORS.border }}
thumbColor="#fff"
/>
</View>
);
}
interface FeatureRowProps {
label: string;
available?: boolean;
value?: string;
}
function FeatureRow({ label, available, value }: FeatureRowProps) {
return (
<View style={styles.featureRow}>
<Text style={styles.featureLabel}>{label}</Text>
<Text style={styles.featureValue}>
{value ?? (available !== undefined ? (available ? '✓' : '✕') : '')}
</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: COLORS.background,
},
header: {
padding: SPACING.md,
borderBottomWidth: 1,
borderBottomColor: COLORS.border,
},
title: {
color: COLORS.text,
fontSize: FONT_SIZES.xxl,
fontWeight: 'bold',
},
content: {
flex: 1,
},
profileRow: {
flexDirection: 'row',
alignItems: 'center',
},
avatar: {
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: COLORS.primary,
justifyContent: 'center',
alignItems: 'center',
marginRight: SPACING.md,
},
avatarText: {
color: '#fff',
fontSize: FONT_SIZES.xxl,
fontWeight: 'bold',
},
profileInfo: {
flex: 1,
},
profileName: {
color: COLORS.text,
fontSize: FONT_SIZES.lg,
fontWeight: '600',
},
profileEmail: {
color: COLORS.textSecondary,
fontSize: FONT_SIZES.sm,
marginTop: 2,
},
tierRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: SPACING.md,
},
tierLabel: {
color: COLORS.textSecondary,
fontSize: FONT_SIZES.md,
},
tierBadge: {
paddingHorizontal: SPACING.md,
paddingVertical: SPACING.xs,
borderRadius: 16,
},
tierBadgeText: {
color: '#fff',
fontSize: FONT_SIZES.sm,
fontWeight: '600',
},
featuresList: {
gap: SPACING.sm,
},
featureRow: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingVertical: SPACING.xs,
},
featureLabel: {
color: COLORS.textSecondary,
fontSize: FONT_SIZES.sm,
},
featureValue: {
color: COLORS.text,
fontSize: FONT_SIZES.sm,
fontWeight: '500',
},
toggleRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: SPACING.sm,
borderBottomWidth: 1,
borderBottomColor: COLORS.border,
},
toggleLabel: {
color: COLORS.text,
fontSize: FONT_SIZES.md,
},
logoutSection: {
paddingHorizontal: SPACING.md,
paddingVertical: SPACING.md,
},
version: {
alignItems: 'center',
paddingVertical: SPACING.lg,
},
versionText: {
color: COLORS.textMuted,
fontSize: FONT_SIZES.xs,
},
});

View File

@@ -0,0 +1 @@
export * from './SettingsScreen';

View File

@@ -0,0 +1,303 @@
import React, { useState, useMemo } from 'react';
import { StyleSheet, Text, View, SafeAreaView, ScrollView, TouchableOpacity, Alert } from 'react-native';
import { useSpamShieldStore } from '@/store/spamShieldStore';
import { Card, Button, Input, StatCard, EmptyState } from '@/components';
import { COLORS, FONT_SIZES, SPACING, BORDER_RADIUS } from '@/constants/theme';
type TabType = 'history' | 'whitelist' | 'blacklist';
export function SpamShieldScreen() {
const {
callHistory,
textHistory,
whitelist,
blacklist,
addToWhitelist,
addToBlacklist,
removeFromWhitelist,
removeFromBlacklist,
} = useSpamShieldStore();
const [activeTab, setActiveTab] = useState<TabType>('history');
const [newNumber, setNewNumber] = useState('');
const [newLabel, setNewLabel] = useState('');
const blockedCount = useMemo(
() => callHistory.filter((r) => r.isBlocked).length,
[callHistory]
);
const totalCount = useMemo(
() => callHistory.length + textHistory.length,
[callHistory, textHistory]
);
const handleAddToList = (list: 'whitelist' | 'blacklist') => {
if (!newNumber) return;
if (list === 'whitelist') {
addToWhitelist(newNumber, newLabel || 'Contact');
} else {
addToBlacklist(newNumber, newLabel || 'Blocked');
}
setNewNumber('');
setNewLabel('');
};
const handleRemoveFromList = (list: 'whitelist' | 'blacklist', number: string) => {
Alert.alert(
`Remove from ${list}`,
`Remove ${number} from your ${list}?`,
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Remove',
style: 'destructive',
onPress: () => {
if (list === 'whitelist') removeFromWhitelist(number);
else removeFromBlacklist(number);
},
},
]
);
};
return (
<SafeAreaView style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>SpamShield</Text>
<Text style={styles.subtitle}>Call & text protection</Text>
</View>
<ScrollView style={styles.content}>
<Card>
<StatCard title="Blocked Today" value={blockedCount} color={COLORS.success} />
<StatCard title="Total Blocked" value={totalCount} color={COLORS.accent} />
</Card>
<View style={styles.tabs}>
{(['history', 'whitelist', 'blacklist'] as const).map((tab) => (
<TouchableOpacity
key={tab}
style={[styles.tab, { backgroundColor: activeTab === tab ? COLORS.primary : 'transparent' }]}
onPress={() => setActiveTab(tab)}
>
<Text style={[
styles.tabText,
{ color: activeTab === tab ? '#fff' : COLORS.textSecondary }
]}>
{tab.charAt(0).toUpperCase() + tab.slice(1)}
</Text>
</TouchableOpacity>
))}
</View>
{activeTab === 'history' && (
<Card title="Recent Activity">
{(callHistory.length === 0 && textHistory.length === 0) ? (
<EmptyState title="No call or text history" message="Blocked spam will appear here" />
) : (
<>
{callHistory.map((record) => (
<View key={record.id} style={styles.historyItem}>
<View style={styles.historyItemLeft}>
<Text style={styles.historyType}>{record.type.toUpperCase()}</Text>
<Text style={styles.historyNumber}>{record.phoneNumber}</Text>
</View>
<View style={[
styles.historyBadge,
{ backgroundColor: record.isBlocked ? COLORS.success : COLORS.warning }
]}>
<Text style={styles.historyBadgeText}>
{record.isBlocked ? 'BLOCKED' : 'ALLOWED'}
</Text>
</View>
</View>
))}
</>
)}
</Card>
)}
{activeTab === 'whitelist' && (
<Card title="Whitelist">
<View style={styles.addToList}>
<Input
placeholder="Phone number"
value={newNumber}
onChangeText={setNewNumber}
keyboardType="phone-pad"
/>
<Input
placeholder="Label (optional)"
value={newLabel}
onChangeText={setNewLabel}
/>
<Button
title="Add to Whitelist"
onPress={() => handleAddToList('whitelist')}
variant="primary"
fullWidth
/>
</View>
{whitelist.length === 0 ? (
<EmptyState title="Whitelist is empty" message="Add trusted contacts here" />
) : (
whitelist.map((item) => (
<View key={item.number} style={styles.listItem}>
<View style={styles.listItemLeft}>
<Text style={styles.listItemNumber}>{item.number}</Text>
<Text style={styles.listItemLabel}>{item.label}</Text>
</View>
<TouchableOpacity onPress={() => handleRemoveFromList('whitelist', item.number)}>
<Text style={styles.removeText}>Remove</Text>
</TouchableOpacity>
</View>
))
)}
</Card>
)}
{activeTab === 'blacklist' && (
<Card title="Blacklist">
<View style={styles.addToList}>
<Input
placeholder="Phone number"
value={newNumber}
onChangeText={setNewNumber}
keyboardType="phone-pad"
/>
<Input
placeholder="Label (optional)"
value={newLabel}
onChangeText={setNewLabel}
/>
<Button
title="Add to Blacklist"
onPress={() => handleAddToList('blacklist')}
variant="danger"
fullWidth
/>
</View>
{blacklist.length === 0 ? (
<EmptyState title="Blacklist is empty" message="Add numbers to block here" />
) : (
blacklist.map((item) => (
<View key={item.number} style={styles.listItem}>
<View style={styles.listItemLeft}>
<Text style={styles.listItemNumber}>{item.number}</Text>
<Text style={styles.listItemLabel}>{item.label}</Text>
</View>
<TouchableOpacity onPress={() => handleRemoveFromList('blacklist', item.number)}>
<Text style={styles.removeText}>Remove</Text>
</TouchableOpacity>
</View>
))
)}
</Card>
)}
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: COLORS.background,
},
header: {
padding: SPACING.md,
borderBottomWidth: 1,
borderBottomColor: COLORS.border,
},
title: {
color: COLORS.text,
fontSize: FONT_SIZES.xxl,
fontWeight: 'bold',
},
subtitle: {
color: COLORS.textSecondary,
fontSize: FONT_SIZES.sm,
marginTop: SPACING.xs,
},
content: {
flex: 1,
},
tabs: {
flexDirection: 'row',
marginHorizontal: SPACING.md,
marginVertical: SPACING.sm,
backgroundColor: COLORS.card,
borderRadius: BORDER_RADIUS.md,
padding: 4,
},
tab: {
flex: 1,
paddingVertical: SPACING.sm,
borderRadius: BORDER_RADIUS.sm,
alignItems: 'center',
},
tabText: {
fontSize: FONT_SIZES.sm,
fontWeight: '600',
},
historyItem: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: SPACING.sm,
borderBottomWidth: 1,
borderBottomColor: COLORS.border,
},
historyItemLeft: {
flex: 1,
},
historyType: {
color: COLORS.textSecondary,
fontSize: FONT_SIZES.xs,
textTransform: 'uppercase',
},
historyNumber: {
color: COLORS.text,
fontSize: FONT_SIZES.md,
},
historyBadge: {
paddingHorizontal: SPACING.sm,
paddingVertical: 4,
borderRadius: BORDER_RADIUS.round,
},
historyBadgeText: {
color: '#fff',
fontSize: FONT_SIZES.xs,
fontWeight: '600',
},
addToList: {
marginBottom: SPACING.md,
},
listItem: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: SPACING.sm,
borderBottomWidth: 1,
borderBottomColor: COLORS.border,
},
listItemLeft: {
flex: 1,
},
listItemNumber: {
color: COLORS.text,
fontSize: FONT_SIZES.md,
},
listItemLabel: {
color: COLORS.textSecondary,
fontSize: FONT_SIZES.sm,
},
removeText: {
color: COLORS.danger,
fontSize: FONT_SIZES.sm,
},
});

View File

@@ -0,0 +1 @@
export * from './SpamShieldScreen';

View File

@@ -0,0 +1,316 @@
import React, { useState } from 'react';
import { StyleSheet, Text, View, SafeAreaView, ScrollView, TouchableOpacity, Alert, Modal } from 'react-native';
import { useVoicePrintStore } from '@/store/voicePrintStore';
import { Card, Button, Input, EmptyState } from '@/components';
import { COLORS, FONT_SIZES, SPACING, BORDER_RADIUS } from '@/constants/theme';
export function VoicePrintScreen() {
const { profiles, analyses, isRecording, addProfile, removeProfile, startRecording, stopRecording } = useVoicePrintStore();
const [showEnrollModal, setShowEnrollModal] = useState(false);
const [profileName, setProfileName] = useState('');
const [relationship, setRelationship] = useState('');
const handleEnroll = async () => {
if (!profileName || !relationship) return;
await addProfile(profileName, relationship);
setProfileName('');
setRelationship('');
setShowEnrollModal(false);
};
const handleRemoveProfile = (id: string, name: string) => {
Alert.alert(
'Remove Voice Profile',
`Remove voice profile for "${name}"? This cannot be undone.`,
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Remove',
style: 'destructive',
onPress: () => removeProfile(id),
},
]
);
};
const handleRecording = () => {
if (isRecording) {
stopRecording();
} else {
startRecording();
}
};
return (
<SafeAreaView style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>VoicePrint</Text>
<Text style={styles.subtitle}>Voice authentication & protection</Text>
</View>
<ScrollView style={styles.content}>
<Card title="Voice Profiles">
<Button
title="+ Enroll Family Member"
onPress={() => setShowEnrollModal(true)}
variant="secondary"
fullWidth
/>
{profiles.length === 0 ? (
<EmptyState
title="No voice profiles"
message="Enroll family members to protect against voice impersonation"
/>
) : (
profiles.map((profile) => (
<View key={profile.id} style={styles.profileItem}>
<View style={styles.profileItemLeft}>
<View style={styles.avatar}>
<Text style={styles.avatarText}>
{profile.name.charAt(0).toUpperCase()}
</Text>
</View>
<View style={styles.profileInfo}>
<Text style={styles.profileName}>{profile.name}</Text>
<Text style={styles.profileRelationship}>{profile.relationship}</Text>
</View>
</View>
<View style={styles.profileItemRight}>
<Text style={styles.confidence}>
{profile.confidence}% match
</Text>
<TouchableOpacity onPress={() => handleRemoveProfile(profile.id, profile.name)}>
<Text style={styles.removeText}>Remove</Text>
</TouchableOpacity>
</View>
</View>
))
)}
</Card>
<Card title="Voice Analysis">
<View style={styles.recordingSection}>
<TouchableOpacity
style={[
styles.recordButton,
{ backgroundColor: isRecording ? COLORS.danger : COLORS.primary },
]}
onPress={handleRecording}
>
<View style={[styles.recordDot, { backgroundColor: isRecording ? '#fff' : COLORS.card }]} />
<Text style={styles.recordButtonText}>
{isRecording ? 'Stop Recording' : 'Test Voice Match'}
</Text>
</TouchableOpacity>
<Text style={styles.recordingHint}>
{isRecording
? 'Recording... Speak clearly into the microphone'
: 'Tap to test voice matching against enrolled profiles'
}
</Text>
</View>
{analyses.length === 0 ? (
<EmptyState title="No analysis results" message="Record voice samples to see match results" />
) : (
analyses.map((analysis) => (
<View key={analysis.id} style={styles.analysisItem}>
<View style={styles.analysisLeft}>
<Text style={styles.analysisTime}>
{new Date(analysis.timestamp).toLocaleString()}
</Text>
<Text style={[
styles.analysisResult,
{ color: analysis.isMatch ? COLORS.success : COLORS.danger }
]}>
{analysis.isMatch ? 'Match' : 'No Match'} - {analysis.confidence}%
</Text>
</View>
</View>
))
)}
</Card>
</ScrollView>
<Modal visible={showEnrollModal} animationType="slide" transparent>
<View style={styles.modalOverlay}>
<View style={styles.modalContent}>
<Text style={styles.modalTitle}>Enroll Family Member</Text>
<Input
label="Name"
placeholder="e.g., Mom"
value={profileName}
onChangeText={setProfileName}
/>
<Input
label="Relationship"
placeholder="e.g., Mother"
value={relationship}
onChangeText={setRelationship}
/>
<View style={styles.modalActions}>
<Button
title="Cancel"
onPress={() => setShowEnrollModal(false)}
variant="ghost"
/>
<Button
title="Enroll"
onPress={handleEnroll}
variant="primary"
/>
</View>
</View>
</View>
</Modal>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: COLORS.background,
},
header: {
padding: SPACING.md,
borderBottomWidth: 1,
borderBottomColor: COLORS.border,
},
title: {
color: COLORS.text,
fontSize: FONT_SIZES.xxl,
fontWeight: 'bold',
},
subtitle: {
color: COLORS.textSecondary,
fontSize: FONT_SIZES.sm,
marginTop: SPACING.xs,
},
content: {
flex: 1,
},
profileItem: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: SPACING.sm,
borderBottomWidth: 1,
borderBottomColor: COLORS.border,
},
profileItemLeft: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
avatar: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: COLORS.primary,
justifyContent: 'center',
alignItems: 'center',
marginRight: SPACING.sm,
},
avatarText: {
color: '#fff',
fontSize: FONT_SIZES.md,
fontWeight: '600',
},
profileInfo: {
flex: 1,
},
profileName: {
color: COLORS.text,
fontSize: FONT_SIZES.md,
fontWeight: '500',
},
profileRelationship: {
color: COLORS.textSecondary,
fontSize: FONT_SIZES.sm,
},
profileItemRight: {
alignItems: 'flex-end',
gap: SPACING.xs,
},
confidence: {
color: COLORS.accent,
fontSize: FONT_SIZES.sm,
},
removeText: {
color: COLORS.danger,
fontSize: FONT_SIZES.sm,
},
recordingSection: {
alignItems: 'center',
paddingVertical: SPACING.md,
},
recordButton: {
flexDirection: 'row',
alignItems: 'center',
gap: SPACING.sm,
paddingVertical: SPACING.md,
paddingHorizontal: SPACING.xl,
borderRadius: BORDER_RADIUS.round,
},
recordDot: {
width: 12,
height: 12,
borderRadius: 6,
},
recordButtonText: {
color: '#fff',
fontSize: FONT_SIZES.md,
fontWeight: '600',
},
recordingHint: {
color: COLORS.textMuted,
fontSize: FONT_SIZES.sm,
marginTop: SPACING.sm,
textAlign: 'center',
},
analysisItem: {
paddingVertical: SPACING.sm,
borderBottomWidth: 1,
borderBottomColor: COLORS.border,
},
analysisLeft: {
gap: 4,
},
analysisTime: {
color: COLORS.textMuted,
fontSize: FONT_SIZES.xs,
},
analysisResult: {
fontSize: FONT_SIZES.sm,
fontWeight: '500',
},
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.7)',
justifyContent: 'flex-end',
},
modalContent: {
backgroundColor: COLORS.backgroundLight,
borderTopLeftRadius: BORDER_RADIUS.xl,
borderTopRightRadius: BORDER_RADIUS.xl,
padding: SPACING.lg,
paddingBottom: SPACING.xxl,
},
modalTitle: {
color: COLORS.text,
fontSize: FONT_SIZES.xl,
fontWeight: 'bold',
marginBottom: SPACING.md,
},
modalActions: {
flexDirection: 'row',
gap: SPACING.md,
marginTop: SPACING.md,
},
});

View File

@@ -0,0 +1 @@
export * from './VoicePrintScreen';

View File

@@ -0,0 +1,13 @@
import { createApiClient } from '@shieldai/mobile-api-client';
import { API_URL } from '@/constants/theme';
// TODO SEC-007: Add certificate pinning for production builds.
// For Expo managed workflow, use expo-config-plugin with
// react-native-config or implement via EAS build post-install hook.
// Reference: https://docs.expo.dev/guides/using-network-security/
createApiClient({
baseURL: API_URL,
timeout: 30000,
debug: __DEV__,
});

View File

@@ -0,0 +1,27 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { StateStorage } from 'zustand/middleware';
import { createJSONStorage } from 'zustand/middleware';
import { encryptText, decryptText } from './secureStorage';
function createEncryptedAsyncStorage(): StateStorage {
return {
getItem: async (name: string): Promise<string | null> => {
const encrypted = await AsyncStorage.getItem(name);
if (encrypted === null) return null;
try {
return await decryptText(encrypted);
} catch {
return null;
}
},
setItem: async (name: string, value: string): Promise<void> => {
const encrypted = await encryptText(value);
await AsyncStorage.setItem(name, encrypted);
},
removeItem: async (name: string): Promise<void> => {
await AsyncStorage.removeItem(name);
},
};
}
export const encryptedStorage = createJSONStorage(() => createEncryptedAsyncStorage());

View File

@@ -0,0 +1,2 @@
export * from './api';
export * from './secureStorage';

View File

@@ -0,0 +1,75 @@
import * as SecureStore from 'expo-secure-store';
import { randomUUID } from 'expo-crypto';
const ACCESS_TOKEN_KEY = '@shieldai_access_token';
const REFRESH_TOKEN_KEY = '@shieldai_refresh_token';
export async function getAccessToken(): Promise<string | null> {
return SecureStore.getItemAsync(ACCESS_TOKEN_KEY);
}
export async function setAccessToken(token: string): Promise<void> {
await SecureStore.setItemAsync(ACCESS_TOKEN_KEY, token);
}
export async function getRefreshToken(): Promise<string | null> {
return SecureStore.getItemAsync(REFRESH_TOKEN_KEY);
}
export async function setRefreshToken(token: string): Promise<void> {
await SecureStore.setItemAsync(REFRESH_TOKEN_KEY, token);
}
export async function clearTokens(): Promise<void> {
try {
await SecureStore.deleteItemAsync(ACCESS_TOKEN_KEY);
} catch { /* may not exist */ }
try {
await SecureStore.deleteItemAsync(REFRESH_TOKEN_KEY);
} catch { /* may not exist */ }
}
// XOR cipher for PII in AsyncStorage.
// Key stored in SecureStore (Keychain/Keystore).
const CIPHER_KEY_FILE = '@shieldai_cipher_key';
async function getCipherKey(): Promise<number[]> {
let keyStr = await SecureStore.getItemAsync(CIPHER_KEY_FILE);
if (!keyStr) {
keyStr = randomUUID();
await SecureStore.setItemAsync(CIPHER_KEY_FILE, keyStr);
}
return keyStr.split('').map((c) => c.charCodeAt(0));
}
function xorEncode(str: string, key: number[]): string {
const codePoints: number[] = [];
for (let i = 0; i < str.length; i++) {
codePoints.push(str.charCodeAt(i) ^ key[i % key.length]);
}
// Encode as base64 using Buffer (available in RN)
return Buffer.from(codePoints).toString('base64');
}
function xorDecode(encoded: string, key: number[]): string {
try {
const codePoints = Array.from(Buffer.from(encoded, 'base64'));
const chars: string[] = [];
for (let i = 0; i < codePoints.length; i++) {
chars.push(String.fromCharCode(codePoints[i] ^ key[i % key.length]));
}
return chars.join('');
} catch {
return '';
}
}
export async function encryptText(plain: string): Promise<string> {
const key = await getCipherKey();
return xorEncode(plain, key);
}
export async function decryptText(encrypted: string): Promise<string> {
const key = await getCipherKey();
return xorDecode(encrypted, key);
}

View File

@@ -0,0 +1,152 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { User } from '@/types';
import { authService } from '@shieldai/mobile-api-client';
import { AUTH_STORAGE_KEY } from '@/constants';
import {
getAccessToken,
setAccessToken,
getRefreshToken,
setRefreshToken,
clearTokens,
} from '@/services/secureStorage';
import { encryptedStorage } from '@/services/encryptedStorage';
interface AuthState {
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
error: string | null;
login: (email: string, password: string) => Promise<void>;
register: (email: string, password: string, firstName: string, lastName: string) => Promise<void>;
logout: () => Promise<void>;
clearError: () => void;
setUser: (user: User | null) => void;
refreshSession: () => Promise<boolean>;
hydrateTokens: () => Promise<void>;
}
const toAppUser = (apiUser: any): User => ({
id: apiUser.id,
email: apiUser.email,
firstName: apiUser.firstName || '',
lastName: apiUser.lastName || '',
tier: (apiUser.tier as User['tier']) || 'free',
createdAt: apiUser.createdAt || new Date().toISOString(),
});
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
user: null,
isAuthenticated: false,
isLoading: false,
error: null,
login: async (email: string, password: string) => {
set({ isLoading: true, error: null });
try {
const { user: apiUser, tokens } = await authService.login({ email, password });
await setAccessToken(tokens.accessToken);
if (tokens.refreshToken) {
await setRefreshToken(tokens.refreshToken);
}
set({
user: toAppUser(apiUser),
isAuthenticated: true,
isLoading: false,
});
} catch (err: any) {
set({
error: err.message || 'Login failed',
isLoading: false,
});
throw err;
}
},
register: async (email: string, password: string, firstName: string, lastName: string) => {
set({ isLoading: true, error: null });
try {
const { user: apiUser, tokens } = await authService.register({
email,
password,
firstName,
lastName,
});
await setAccessToken(tokens.accessToken);
if (tokens.refreshToken) {
await setRefreshToken(tokens.refreshToken);
}
set({
user: toAppUser(apiUser),
isAuthenticated: true,
isLoading: false,
});
} catch (err: any) {
set({
error: err.message || 'Registration failed',
isLoading: false,
});
throw err;
}
},
logout: async () => {
try {
await authService.logout();
} finally {
await clearTokens();
set({
user: null,
isAuthenticated: false,
error: null,
});
}
},
refreshSession: async (): Promise<boolean> => {
const refreshToken = await getRefreshToken();
if (!refreshToken) return false;
try {
const newTokens = await authService.refreshToken();
await setAccessToken(newTokens.accessToken);
if (newTokens.refreshToken) {
await setRefreshToken(newTokens.refreshToken);
}
return true;
} catch {
await clearTokens();
set({ user: null, isAuthenticated: false });
return false;
}
},
hydrateTokens: async () => {
const token = await getAccessToken();
if (token) {
try {
const user = await authService.getCurrentUser();
if (user) {
set({ user: toAppUser(user), isAuthenticated: true });
}
} catch {
await clearTokens();
set({ user: null, isAuthenticated: false });
}
}
},
clearError: () => set({ error: null }),
setUser: (user) => set({ user, isAuthenticated: !!user }),
}),
{
name: AUTH_STORAGE_KEY,
storage: encryptedStorage,
partialize: (state) => ({
user: state.user,
isAuthenticated: state.isAuthenticated,
}),
}
)
);

View File

@@ -0,0 +1,68 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { randomUUID } from 'expo-crypto';
import type { WatchListItem, Exposure } from '@/types';
import { encryptedStorage } from '@/services/encryptedStorage';
/**
* TODO: Wire store operations to @shieldai/mobile-api-client for production.
* Current implementation is local-only (AsyncStorage) for offline-first MVP.
*/
interface DarkWatchState {
watchList: WatchListItem[];
exposures: Exposure[];
isLoading: boolean;
addWatchItem: (item: Omit<WatchListItem, 'id' | 'lastChecked'>) => Promise<void>;
removeWatchItem: (id: string) => void;
toggleAlert: (id: string) => void;
refreshExposures: () => Promise<void>;
}
export const useDarkWatchStore = create<DarkWatchState>()(
persist(
(set, get) => ({
watchList: [],
exposures: [],
isLoading: false,
addWatchItem: async (item) => {
const newItem: WatchListItem = {
...item,
id: randomUUID(),
lastChecked: new Date().toISOString(),
};
set((state) => ({ watchList: [...state.watchList, newItem] }));
},
removeWatchItem: (id) => {
set((state) => ({
watchList: state.watchList.filter((item) => item.id !== id),
}));
},
toggleAlert: (id) => {
set((state) => ({
watchList: state.watchList.map((item) =>
item.id === id ? { ...item, alertEnabled: !item.alertEnabled } : item
),
}));
},
refreshExposures: async () => {
set({ isLoading: true });
try {
// TODO: Wire to @shieldai/mobile-api-client for production
set({ isLoading: false });
} catch (error) {
console.error('[DarkWatch] Failed to refresh exposures:', error);
set({ isLoading: false });
}
},
}),
{
name: '@shieldai_darkwatch',
storage: encryptedStorage,
}
)
);

View File

@@ -0,0 +1,46 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { DashboardData } from '@/types';
interface DashboardState {
data: DashboardData | null;
isLoading: boolean;
lastUpdated: string | null;
refreshDashboard: () => Promise<void>;
setData: (data: DashboardData) => void;
}
export const useDashboardStore = create<DashboardState>()(
persist(
(set) => ({
data: null,
isLoading: false,
lastUpdated: null,
refreshDashboard: async () => {
set({ isLoading: true });
try {
const mockData: DashboardData = {
exposureSummary: { total: 0, unresolved: 0, critical: 0 },
spamStats: { blockedToday: 0, blockedTotal: 0, spamScore: 0 },
voiceProtectionStatus: { isMonitoring: false, profilesEnrolled: 0, lastAnalysis: '' },
};
set({
data: mockData,
isLoading: false,
lastUpdated: new Date().toISOString(),
});
} catch {
set({ isLoading: false });
}
},
setData: (data) => set({ data, lastUpdated: new Date().toISOString() }),
}),
{
name: '@shieldai_dashboard',
storage: createJSONStorage(() => AsyncStorage),
}
)
);

View File

@@ -0,0 +1,45 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { NotificationPreference } from '@/types';
import { encryptedStorage } from '@/services/encryptedStorage';
/**
* TODO: Wire updatePreferences to notificationService.updatePreferences() for production.
* Current implementation is local-only (AsyncStorage) for offline-first MVP.
*/
interface SettingsState {
preferences: NotificationPreference;
isBiometricEnabled: boolean;
updatePreferences: (prefs: Partial<NotificationPreference>) => void;
toggleBiometric: (enabled: boolean) => void;
}
const defaultPreferences: NotificationPreference = {
emailNotifications: true,
pushNotifications: true,
darkwatchAlert: true,
spamBlocked: true,
voiceprintAnalysis: true,
};
export const useSettingsStore = create<SettingsState>()(
persist(
(set) => ({
preferences: defaultPreferences,
isBiometricEnabled: false,
updatePreferences: (prefs) => {
set((state) => ({
preferences: { ...state.preferences, ...prefs },
}));
},
toggleBiometric: (enabled) => set({ isBiometricEnabled: enabled }),
}),
{
name: '@shieldai_settings',
storage: encryptedStorage,
}
)
);

View File

@@ -0,0 +1,63 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { SpamRecord } from '@/types';
import { encryptedStorage } from '@/services/encryptedStorage';
/**
* TODO: Wire store operations to @shieldai/mobile-api-client for production.
* Current implementation is local-only (AsyncStorage) for offline-first MVP.
*/
type PhoneList = { number: string; label: string }[];
interface SpamShieldState {
callHistory: SpamRecord[];
textHistory: SpamRecord[];
whitelist: PhoneList;
blacklist: PhoneList;
isLoading: false;
addToWhitelist: (number: string, label: string) => void;
addToBlacklist: (number: string, label: string) => void;
removeFromWhitelist: (number: string) => void;
removeFromBlacklist: (number: string) => void;
}
export const useSpamShieldStore = create<SpamShieldState>()(
persist(
(set) => ({
callHistory: [],
textHistory: [],
whitelist: [],
blacklist: [],
isLoading: false,
addToWhitelist: (number, label) => {
set((state) => ({
whitelist: [...state.whitelist, { number, label }],
}));
},
addToBlacklist: (number, label) => {
set((state) => ({
blacklist: [...state.blacklist, { number, label }],
}));
},
removeFromWhitelist: (number) => {
set((state) => ({
whitelist: state.whitelist.filter((item) => item.number !== number),
}));
},
removeFromBlacklist: (number) => {
set((state) => ({
blacklist: state.blacklist.filter((item) => item.number !== number),
}));
},
}),
{
name: '@shieldai_spamshield',
storage: encryptedStorage,
}
)
);

View File

@@ -0,0 +1,56 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { randomUUID } from 'expo-crypto';
import type { VoiceProfile, VoiceAnalysis } from '@/types';
import { encryptedStorage } from '@/services/encryptedStorage';
/**
* TODO: Wire store operations to @shieldai/mobile-api-client for production.
* Current implementation is local-only (AsyncStorage) for offline-first MVP.
*/
interface VoicePrintState {
profiles: VoiceProfile[];
analyses: VoiceAnalysis[];
isRecording: boolean;
isLoading: boolean;
addProfile: (name: string, relationship: string) => Promise<void>;
removeProfile: (id: string) => void;
startRecording: () => void;
stopRecording: () => void;
}
export const useVoicePrintStore = create<VoicePrintState>()(
persist(
(set) => ({
profiles: [],
analyses: [],
isRecording: false,
isLoading: false,
addProfile: async (name: string, relationship: string) => {
const newProfile: VoiceProfile = {
id: randomUUID(),
name,
relationship,
enrolledAt: new Date().toISOString(),
confidence: 0,
};
set((state) => ({ profiles: [...state.profiles, newProfile] }));
},
removeProfile: (id) => {
set((state) => ({
profiles: state.profiles.filter((p) => p.id !== id),
}));
},
startRecording: () => set({ isRecording: true }),
stopRecording: () => set({ isRecording: false }),
}),
{
name: '@shieldai_voiceprint',
storage: encryptedStorage,
}
)
);

View File

@@ -0,0 +1,79 @@
export type Tier = 'free' | 'basic' | 'premium' | 'enterprise';
export interface User {
id: string;
email: string;
firstName?: string;
lastName?: string;
tier: Tier;
createdAt: string;
}
export interface Exposure {
id: string;
source: string;
data: string;
severity: 'low' | 'medium' | 'high' | 'critical';
discoveredAt: string;
isResolved: boolean;
}
export interface SpamRecord {
id: string;
type: 'call' | 'text';
phoneNumber: string;
timestamp: string;
isBlocked: boolean;
spamScore: number;
}
export interface WatchListItem {
id: string;
name: string;
entityType: 'person' | 'email' | 'phone' | 'address';
value: string;
alertEnabled: boolean;
lastChecked: string;
}
export interface VoiceProfile {
id: string;
name: string;
relationship: string;
enrolledAt: string;
confidence: number;
}
export interface VoiceAnalysis {
id: string;
profileId: string;
isMatch: boolean;
confidence: number;
timestamp: string;
}
export interface NotificationPreference {
emailNotifications: boolean;
pushNotifications: boolean;
darkwatchAlert: boolean;
spamBlocked: boolean;
voiceprintAnalysis: boolean;
}
export interface DashboardData {
exposureSummary: {
total: number;
unresolved: number;
critical: number;
};
spamStats: {
blockedToday: number;
blockedTotal: number;
spamScore: number;
};
voiceProtectionStatus: {
isMonitoring: boolean;
profilesEnrolled: number;
lastAnalysis: string;
};
}

View File

@@ -0,0 +1,33 @@
{
"compilerOptions": {
"strict": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"jsx": "react-native",
"target": "esnext",
"module": "esnext",
"lib": ["es2019"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"moduleResolution": "node",
"forceConsistentCasingInFileNames": true,
"incremental": true,
"types": ["react-native", "node"]
},
"include": [
"src/**/*",
"App.tsx"
],
"exclude": [
"node_modules",
"build",
".expo"
]
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -404,5 +404,26 @@ describe('data-collector', () => {
expect(result.homeTitleStats).toBeUndefined();
});
it('includes homeTitleStats for WEEKLY_DIGEST', async () => {
mockPrisma.exposure.findMany.mockResolvedValue([]);
mockAlertCount.mockResolvedValue(0);
mockPrisma.spamFeedback.findMany.mockResolvedValue([]);
mockPrisma.voiceAnalysis.findMany.mockResolvedValue([]);
mockPrisma.voiceEnrollment.count.mockResolvedValue(0);
mockPrisma.watchlistItem.findMany.mockResolvedValue([
{ subscriptionId: 'sub-1', type: 'address', isActive: true },
]);
const result = await collectAllReportData(
'user-1', 'sub-1', 'WEEKLY_DIGEST', periodStart, periodEnd
);
expect(result.homeTitleStats).toBeDefined();
expect(result.homeTitleStats?.propertiesMonitored).toBe(1);
expect(result.exposureSummary).toBeDefined();
expect(result.spamStats).toBeDefined();
expect(result.voiceStats).toBeDefined();
});
});
});

View File

@@ -294,7 +294,7 @@ export async function collectAllReportData(
protectionScore,
};
if (reportType === 'ANNUAL_PREMIUM') {
if (reportType === 'ANNUAL_PREMIUM' || reportType === 'WEEKLY_DIGEST') {
payload.homeTitleStats = await collectHomeTitleStats(
subscriptionId,
periodStart,

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