FRE-4529: Transfer ShieldAI code from FrenoCorp repo
Transferred ShieldAI-related files mistakenly placed in ~/code/FrenoCorp:
- Services: spamshield (feature-flags, audit-logger, error-handler), voiceprint (config, service, feature-flags), darkwatch (pipeline, scan, scheduler, watchlist, webhook)
- Packages: shared-analytics, shared-auth, shared-ui, shared-utils (new); shared-billing, jobs supplemented with unique FC files
- Server: alerts (FC version newer), routes (spamshield, darkwatch, voiceprint)
- Config: turbo.json, tsconfig.base.json, vite/vitest configs, drizzle, Dockerfile
- VoicePrint ML service
- Examples
Pending: apps/{api,web,mobile}/ structured merge, shared-db/db mapping
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
227
services/spamshield/src/feature-flags.ts
Normal file
227
services/spamshield/src/feature-flags.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* Feature Flag Management System
|
||||
* Centralized feature flag handling with type safety and runtime updates
|
||||
*/
|
||||
|
||||
import type { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Type for feature flag values
|
||||
*/
|
||||
export type FeatureFlagValue = boolean | string | number;
|
||||
|
||||
/**
|
||||
* Interface for a feature flag definition
|
||||
*/
|
||||
export interface FeatureFlag<T = FeatureFlagValue> {
|
||||
key: string;
|
||||
defaultValue: T;
|
||||
description?: string;
|
||||
allowedValues?: T[]; // For enum-like flags
|
||||
category?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Feature flag registry - stores all defined flags
|
||||
*/
|
||||
export interface FeatureFlagRegistry {
|
||||
[key: string]: FeatureFlag;
|
||||
}
|
||||
|
||||
/**
|
||||
* Feature flag resolver - handles flag resolution logic
|
||||
*/
|
||||
export class FeatureFlagResolver {
|
||||
private flags: FeatureFlagRegistry;
|
||||
private resolvedCache: Map<string, FeatureFlagValue> = new Map();
|
||||
|
||||
constructor(flags: FeatureFlagRegistry) {
|
||||
this.flags = flags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a feature flag value
|
||||
* Priority: Environment > Cache > Default
|
||||
*/
|
||||
resolve<T>(key: string, defaultValue: T): T {
|
||||
// Check cache first
|
||||
if (this.resolvedCache.has(key)) {
|
||||
return this.resolvedCache.get(key)! as T;
|
||||
}
|
||||
|
||||
// Check environment variable (allows runtime updates)
|
||||
const envValue = process.env[`FLAG_${key.toUpperCase()}`];
|
||||
if (envValue !== undefined) {
|
||||
// Try to parse as JSON first, then as boolean, then as string
|
||||
let parsed: FeatureFlagValue;
|
||||
try {
|
||||
parsed = JSON.parse(envValue);
|
||||
} catch {
|
||||
parsed = envValue.toLowerCase() === 'true' ? true :
|
||||
envValue.toLowerCase() === 'false' ? false :
|
||||
envValue;
|
||||
}
|
||||
|
||||
// Validate against allowed values if defined
|
||||
const flag = this.flags[key];
|
||||
if (flag && flag.allowedValues && !flag.allowedValues.includes(parsed)) {
|
||||
console.warn(`Invalid value for flag ${key}: ${parsed}. Using default.`);
|
||||
parsed = defaultValue as FeatureFlagValue;
|
||||
}
|
||||
|
||||
this.resolvedCache.set(key, parsed);
|
||||
return parsed as T;
|
||||
}
|
||||
|
||||
// Use cached value if available
|
||||
if (this.resolvedCache.has(key)) {
|
||||
return this.resolvedCache.get(key)! as T;
|
||||
}
|
||||
|
||||
// Return default
|
||||
this.resolvedCache.set(key, defaultValue as FeatureFlagValue);
|
||||
return defaultValue as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a flag is enabled (boolean check)
|
||||
*/
|
||||
isEnabled<T>(key: string, defaultValue: T): T {
|
||||
return this.resolve(key, defaultValue) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get flag definition
|
||||
*/
|
||||
getDefinition(key: string): FeatureFlag | undefined {
|
||||
return this.flags[key];
|
||||
}
|
||||
|
||||
/**
|
||||
* List all registered flags
|
||||
*/
|
||||
getAllFlags(): FeatureFlagRegistry {
|
||||
return { ...this.flags };
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the resolution cache (useful for testing)
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.resolvedCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Feature flag configuration with pre-defined flags
|
||||
*/
|
||||
export const featureFlags: FeatureFlagRegistry = {
|
||||
// SpamShield Feature Flags
|
||||
'spamshield.enable.number.reputation': {
|
||||
key: 'spamshield_enable_number_reputation',
|
||||
defaultValue: true,
|
||||
description: 'Enable number reputation checking (Hiya API integration)',
|
||||
category: 'spamshield',
|
||||
},
|
||||
'spamshield.enable.content.classification': {
|
||||
key: 'spamshield_enable_content_classification',
|
||||
defaultValue: true,
|
||||
description: 'Enable SMS content classification (BERT model)',
|
||||
category: 'spamshield',
|
||||
},
|
||||
'spamshield.enable.behavioral.analysis': {
|
||||
key: 'spamshield_enable_behavioral_analysis',
|
||||
defaultValue: true,
|
||||
description: 'Enable call behavioral analysis',
|
||||
category: 'spamshield',
|
||||
},
|
||||
'spamshield.enable.community.intelligence': {
|
||||
key: 'spamshield_enable_community_intelligence',
|
||||
defaultValue: true,
|
||||
description: 'Enable community intelligence sharing',
|
||||
category: 'spamshield',
|
||||
},
|
||||
'spamshield.enable.real.time.blocking': {
|
||||
key: 'spamshield_enable_real_time_blocking',
|
||||
defaultValue: true,
|
||||
description: 'Enable real-time spam blocking',
|
||||
category: 'spamshield',
|
||||
},
|
||||
'spamshield.enable.multiple.sources': {
|
||||
key: 'spamshield_enable_multiple_sources',
|
||||
defaultValue: false,
|
||||
description: 'Enable multiple reputation source aggregation (Truecaller, etc.)',
|
||||
category: 'spamshield',
|
||||
},
|
||||
'spamshield.enable.ml.classifier': {
|
||||
key: 'spamshield_enable_ml_classifier',
|
||||
defaultValue: false,
|
||||
description: 'Enable ML-based spam classification',
|
||||
category: 'spamshield',
|
||||
},
|
||||
|
||||
// VoicePrint Feature Flags
|
||||
'voiceprint.enable.ml.service': {
|
||||
key: 'voiceprint_enable_ml_service',
|
||||
defaultValue: false,
|
||||
description: 'Enable ML service integration for voice analysis',
|
||||
category: 'voiceprint',
|
||||
},
|
||||
'voiceprint.enable.faiss.index': {
|
||||
key: 'voiceprint_enable_faiss_index',
|
||||
defaultValue: true,
|
||||
description: 'Enable FAISS index for voice matching',
|
||||
category: 'voiceprint',
|
||||
},
|
||||
'voiceprint.enable.batch.analysis': {
|
||||
key: 'voiceprint_enable_batch_analysis',
|
||||
defaultValue: true,
|
||||
description: 'Enable batch voice analysis',
|
||||
category: 'voiceprint',
|
||||
},
|
||||
'voiceprint.enable.realtime.analysis': {
|
||||
key: 'voiceprint_enable_realtime_analysis',
|
||||
defaultValue: false,
|
||||
description: 'Enable real-time voice analysis',
|
||||
category: 'voiceprint',
|
||||
},
|
||||
'voiceprint.enable.mock.model': {
|
||||
key: 'voiceprint_enable_mock_model',
|
||||
defaultValue: true,
|
||||
description: 'Enable mock model for development',
|
||||
category: 'voiceprint',
|
||||
},
|
||||
|
||||
// General Platform Flags
|
||||
'platform.enable.audit.logs': {
|
||||
key: 'platform_enable_audit_logs',
|
||||
defaultValue: true,
|
||||
description: 'Enable comprehensive audit logging',
|
||||
category: 'platform',
|
||||
},
|
||||
'platform.enable.kpi.tracking': {
|
||||
key: 'platform_enable_kpi_tracking',
|
||||
defaultValue: true,
|
||||
description: 'Enable KPI snapshot tracking',
|
||||
category: 'platform',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a resolver instance with the default flags
|
||||
*/
|
||||
export const featureFlagResolver = new FeatureFlagResolver(featureFlags);
|
||||
|
||||
/**
|
||||
* Convenience function for quick flag checks
|
||||
*/
|
||||
export function isFeatureEnabled<T>(key: string, defaultValue: T): T {
|
||||
return featureFlagResolver.isEnabled(key, defaultValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a flag is enabled with type safety
|
||||
*/
|
||||
export function checkFlag<T>(key: string, defaultValue: T): T {
|
||||
return featureFlagResolver.resolve(key, defaultValue);
|
||||
}
|
||||
118
services/spamshield/src/spamshield.audit-logger.ts
Normal file
118
services/spamshield/src/spamshield.audit-logger.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
export type AuditClassificationType = 'sms' | 'call';
|
||||
|
||||
export interface AuditClassificationEntry {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
type: AuditClassificationType;
|
||||
phoneNumberHash: string;
|
||||
decision: 'spam' | 'ham' | 'block' | 'flag' | 'allow';
|
||||
confidence: number;
|
||||
reasons: string[];
|
||||
featureFlags: Record<string, boolean>;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const MAX_AUDIT_LOG_SIZE = 10_000;
|
||||
|
||||
class AuditLogger {
|
||||
private entries: AuditClassificationEntry[] = [];
|
||||
|
||||
logClassification(entry: Omit<AuditClassificationEntry, 'id' | 'timestamp'>): AuditClassificationEntry {
|
||||
const record: AuditClassificationEntry = {
|
||||
id: `audit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
...entry,
|
||||
};
|
||||
|
||||
this.entries.push(record);
|
||||
|
||||
if (this.entries.length > MAX_AUDIT_LOG_SIZE) {
|
||||
this.entries.shift();
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[SpamShield:Audit] type=${record.type} decision=${record.decision} ` +
|
||||
`confidence=${record.confidence.toFixed(3)} reasons=${record.reasons.join(',') || 'none'} ` +
|
||||
`phoneHash=${record.phoneNumberHash}`
|
||||
);
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
getEntries(
|
||||
filters?: {
|
||||
type?: AuditClassificationType;
|
||||
decision?: string;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
limit?: number;
|
||||
}
|
||||
): AuditClassificationEntry[] {
|
||||
let results = this.entries;
|
||||
|
||||
if (filters?.type) {
|
||||
results = results.filter(e => e.type === filters.type);
|
||||
}
|
||||
|
||||
if (filters?.decision) {
|
||||
results = results.filter(e => e.decision === filters.decision);
|
||||
}
|
||||
|
||||
if (filters?.startDate) {
|
||||
results = results.filter(e => new Date(e.timestamp) >= filters.startDate!);
|
||||
}
|
||||
|
||||
if (filters?.endDate) {
|
||||
results = results.filter(e => new Date(e.timestamp) <= filters.endDate!);
|
||||
}
|
||||
|
||||
if (filters?.limit) {
|
||||
results = results.slice(-filters.limit);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
getSummary(): {
|
||||
totalEntries: number;
|
||||
spamCount: number;
|
||||
hamCount: number;
|
||||
blockCount: number;
|
||||
flagCount: number;
|
||||
allowCount: number;
|
||||
avgConfidence: number;
|
||||
} {
|
||||
const spamCount = this.entries.filter(e => e.decision === 'spam' || e.decision === 'block').length;
|
||||
const hamCount = this.entries.filter(e => e.decision === 'ham' || e.decision === 'allow').length;
|
||||
const blockCount = this.entries.filter(e => e.decision === 'block').length;
|
||||
const flagCount = this.entries.filter(e => e.decision === 'flag').length;
|
||||
const allowCount = this.entries.filter(e => e.decision === 'allow').length;
|
||||
const avgConfidence =
|
||||
this.entries.length > 0
|
||||
? this.entries.reduce((s, e) => s + e.confidence, 0) / this.entries.length
|
||||
: 0;
|
||||
|
||||
return {
|
||||
totalEntries: this.entries.length,
|
||||
spamCount,
|
||||
hamCount,
|
||||
blockCount,
|
||||
flagCount,
|
||||
allowCount,
|
||||
avgConfidence: Math.round(avgConfidence * 1000) / 1000,
|
||||
};
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.entries = [];
|
||||
}
|
||||
}
|
||||
|
||||
export const spamAuditLogger = new AuditLogger();
|
||||
|
||||
export function hashPhoneNumber(phoneNumber: string): string {
|
||||
const hash = createHash('sha256').update(phoneNumber.trim()).digest('hex');
|
||||
return `sha256_${hash}`;
|
||||
}
|
||||
118
services/spamshield/src/spamshield.error-handler.ts
Normal file
118
services/spamshield/src/spamshield.error-handler.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { FastifyReply } from 'fastify';
|
||||
import { SpamErrorCode, HttpStatus, SpamErrorResponse } from './spamshield.config';
|
||||
|
||||
export { SpamErrorCode, HttpStatus };
|
||||
export type { SpamErrorResponse };
|
||||
|
||||
/**
|
||||
* Standardized error response builder for SpamShield API
|
||||
*/
|
||||
export class ErrorHandler {
|
||||
/**
|
||||
* Create a standard error response
|
||||
*/
|
||||
static create(
|
||||
code: SpamErrorCode,
|
||||
message: string,
|
||||
options?: {
|
||||
field?: string;
|
||||
requestId?: string;
|
||||
additionalData?: Record<string, unknown>;
|
||||
}
|
||||
): SpamErrorResponse {
|
||||
return {
|
||||
error: {
|
||||
code,
|
||||
message,
|
||||
...(options?.field && { field: options.field }),
|
||||
timestamp: new Date().toISOString(),
|
||||
...(options?.requestId && { requestId: options.requestId }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a standard error response with appropriate HTTP status code
|
||||
*/
|
||||
static send(
|
||||
reply: FastifyReply,
|
||||
code: SpamErrorCode,
|
||||
message: string,
|
||||
options?: {
|
||||
field?: string;
|
||||
status?: number;
|
||||
requestId?: string;
|
||||
}
|
||||
): void {
|
||||
const status = options?.status ?? this.getStatusForCode(code);
|
||||
const errorResponse = this.create(code, message, {
|
||||
field: options?.field,
|
||||
requestId: options?.requestId,
|
||||
});
|
||||
reply.code(status).send(errorResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map error codes to HTTP status codes
|
||||
*/
|
||||
private static getStatusForCode(code: SpamErrorCode): number {
|
||||
const statusMap: Record<SpamErrorCode, number> = {
|
||||
// Client errors
|
||||
[SpamErrorCode.INVALID_REQUEST]: HttpStatus.BAD_REQUEST,
|
||||
[SpamErrorCode.MISSING_REQUIRED_FIELD]: HttpStatus.BAD_REQUEST,
|
||||
[SpamErrorCode.UNAUTHORIZED]: HttpStatus.UNAUTHORIZED,
|
||||
[SpamErrorCode.NOT_FOUND]: HttpStatus.NOT_FOUND,
|
||||
[SpamErrorCode.VALIDATION_ERROR]: HttpStatus.BAD_REQUEST,
|
||||
|
||||
// Server errors
|
||||
[SpamErrorCode.CLASSIFICATION_FAILED]: HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
[SpamErrorCode.REPUTATION_CHECK_FAILED]: HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
[SpamErrorCode.ANALYSIS_FAILED]: HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
[SpamErrorCode.FEEDBACK_RECORD_FAILED]: HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
[SpamErrorCode.DATABASE_ERROR]: HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
[SpamErrorCode.RATE_LIMIT_EXCEEDED]: HttpStatus.TOO_MANY_REQUESTS,
|
||||
[SpamErrorCode.SERVICE_UNAVAILABLE]: HttpStatus.SERVICE_UNAVAILABLE,
|
||||
};
|
||||
return statusMap[code] ?? HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate required string field
|
||||
*/
|
||||
static validateRequiredField(
|
||||
value: unknown,
|
||||
fieldName: string
|
||||
): { isValid: boolean; error?: { code: SpamErrorCode; message: string; field: string } } {
|
||||
if (!value || typeof value !== 'string' || value.trim() === '') {
|
||||
return {
|
||||
isValid: false,
|
||||
error: {
|
||||
code: SpamErrorCode.MISSING_REQUIRED_FIELD,
|
||||
message: `${fieldName} is required`,
|
||||
field: fieldName,
|
||||
},
|
||||
};
|
||||
}
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate boolean field
|
||||
*/
|
||||
static validateBooleanField(
|
||||
value: unknown,
|
||||
fieldName: string
|
||||
): { isValid: boolean; error?: { code: SpamErrorCode; message: string; field: string } } {
|
||||
if (value === undefined || value === null || typeof value !== 'boolean') {
|
||||
return {
|
||||
isValid: false,
|
||||
error: {
|
||||
code: SpamErrorCode.VALIDATION_ERROR,
|
||||
message: `${fieldName} must be a boolean`,
|
||||
field: fieldName,
|
||||
},
|
||||
};
|
||||
}
|
||||
return { isValid: true };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user