diff --git a/.turbo/cache/8ff5b7eb9e0aad01-manifest.json b/.turbo/cache/8ff5b7eb9e0aad01-manifest.json new file mode 100644 index 0000000..306d171 --- /dev/null +++ b/.turbo/cache/8ff5b7eb9e0aad01-manifest.json @@ -0,0 +1 @@ +{"files":{"packages/correlation/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/correlation/dist/engine.js.map":{"size":9890,"mtime_nanos":1777721551087749490,"mode":420,"is_dir":false},"packages/correlation/dist/index.js":{"size":1909,"mtime_nanos":1777721551102749905,"mode":420,"is_dir":false},"packages/correlation/.turbo/turbo-build.log":{"size":90,"mtime_nanos":1777721551125750542,"mode":420,"is_dir":false},"packages/correlation/dist/service.d.ts.map":{"size":2091,"mtime_nanos":1777721551100749850,"mode":420,"is_dir":false},"packages/correlation/dist/index.d.ts.map":{"size":346,"mtime_nanos":1777721551102749905,"mode":420,"is_dir":false},"packages/correlation/dist/index.js.map":{"size":388,"mtime_nanos":1777721551102749905,"mode":420,"is_dir":false},"packages/correlation/dist/normalizer.js":{"size":6535,"mtime_nanos":1777721551064748853,"mode":420,"is_dir":false},"packages/correlation/dist/service.js":{"size":2496,"mtime_nanos":1777721551093749656,"mode":420,"is_dir":false},"packages/correlation/dist/index.d.ts":{"size":347,"mtime_nanos":1777721551102749905,"mode":420,"is_dir":false},"packages/correlation/dist/engine.js":{"size":10672,"mtime_nanos":1777721551087749490,"mode":420,"is_dir":false},"packages/correlation/dist/engine.d.ts.map":{"size":1146,"mtime_nanos":1777721551089749545,"mode":420,"is_dir":false},"packages/correlation/dist/normalizer.d.ts":{"size":1601,"mtime_nanos":1777721551071749047,"mode":420,"is_dir":false},"packages/correlation/dist/normalizer.d.ts.map":{"size":1561,"mtime_nanos":1777721551071749047,"mode":420,"is_dir":false},"packages/correlation/dist/service.d.ts":{"size":2700,"mtime_nanos":1777721551100749850,"mode":420,"is_dir":false},"packages/correlation/dist/engine.d.ts":{"size":1292,"mtime_nanos":1777721551089749545,"mode":420,"is_dir":false},"packages/correlation/dist/emitter.js":{"size":2425,"mtime_nanos":1777721551105749988,"mode":420,"is_dir":false},"packages/correlation/dist/service.js.map":{"size":1947,"mtime_nanos":1777721551093749656,"mode":420,"is_dir":false},"packages/correlation/dist/emitter.d.ts":{"size":946,"mtime_nanos":1777721551106750016,"mode":420,"is_dir":false},"packages/correlation/dist/emitter.js.map":{"size":1719,"mtime_nanos":1777721551105749988,"mode":420,"is_dir":false},"packages/correlation/dist/emitter.d.ts.map":{"size":1092,"mtime_nanos":1777721551106750016,"mode":420,"is_dir":false},"packages/correlation/dist/normalizer.js.map":{"size":5180,"mtime_nanos":1777721551063748825,"mode":420,"is_dir":false}},"order":["packages/correlation/.turbo/turbo-build.log","packages/correlation/dist","packages/correlation/dist/emitter.d.ts","packages/correlation/dist/emitter.d.ts.map","packages/correlation/dist/emitter.js","packages/correlation/dist/emitter.js.map","packages/correlation/dist/engine.d.ts","packages/correlation/dist/engine.d.ts.map","packages/correlation/dist/engine.js","packages/correlation/dist/engine.js.map","packages/correlation/dist/index.d.ts","packages/correlation/dist/index.d.ts.map","packages/correlation/dist/index.js","packages/correlation/dist/index.js.map","packages/correlation/dist/normalizer.d.ts","packages/correlation/dist/normalizer.d.ts.map","packages/correlation/dist/normalizer.js","packages/correlation/dist/normalizer.js.map","packages/correlation/dist/service.d.ts","packages/correlation/dist/service.d.ts.map","packages/correlation/dist/service.js","packages/correlation/dist/service.js.map"]} \ No newline at end of file diff --git a/.turbo/cache/8ff5b7eb9e0aad01-meta.json b/.turbo/cache/8ff5b7eb9e0aad01-meta.json new file mode 100644 index 0000000..68ed687 --- /dev/null +++ b/.turbo/cache/8ff5b7eb9e0aad01-meta.json @@ -0,0 +1 @@ +{"hash":"8ff5b7eb9e0aad01","duration":908,"sha":"b01b79d02a41aac425fe0f4ab3e21460c69a94b4","dirty_hash":"53949d4fa912af90b4184926009d1814809e1d773d20612a89c885dbf200727c"} \ No newline at end of file diff --git a/.turbo/cache/8ff5b7eb9e0aad01.tar.zst b/.turbo/cache/8ff5b7eb9e0aad01.tar.zst new file mode 100644 index 0000000..1d9abc6 Binary files /dev/null and b/.turbo/cache/8ff5b7eb9e0aad01.tar.zst differ diff --git a/.turbo/cache/df12164dc3180a8f-manifest.json b/.turbo/cache/df12164dc3180a8f-manifest.json new file mode 100644 index 0000000..10efb62 --- /dev/null +++ b/.turbo/cache/df12164dc3180a8f-manifest.json @@ -0,0 +1 @@ +{"files":{"packages/db/dist/index.d.ts":{"size":405,"mtime_nanos":1777721550197724849,"mode":420,"is_dir":false},"packages/db/dist/services/field-encryption.service.d.ts.map":{"size":330,"mtime_nanos":1777721550183724462,"mode":420,"is_dir":false},"packages/db/dist/services/field-encryption.service.js.map":{"size":1414,"mtime_nanos":1777721550180724379,"mode":420,"is_dir":false},"packages/db/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/db/dist/index.d.ts.map":{"size":308,"mtime_nanos":1777721550197724849,"mode":420,"is_dir":false},"packages/db/dist/services/field-encryption.service.js":{"size":1606,"mtime_nanos":1777721550180724379,"mode":420,"is_dir":false},"packages/db/.turbo/turbo-build.log":{"size":1379,"mtime_nanos":1777721550215725348,"mode":420,"is_dir":false},"packages/db/dist/services":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/db/dist/services/field-encryption.service.d.ts":{"size":252,"mtime_nanos":1777721550183724462,"mode":420,"is_dir":false},"packages/db/dist/index.js":{"size":535,"mtime_nanos":1777721550186724545,"mode":420,"is_dir":false},"packages/db/dist/index.js.map":{"size":217,"mtime_nanos":1777721550186724545,"mode":420,"is_dir":false}},"order":["packages/db/.turbo/turbo-build.log","packages/db/dist","packages/db/dist/index.d.ts","packages/db/dist/index.d.ts.map","packages/db/dist/index.js","packages/db/dist/index.js.map","packages/db/dist/services","packages/db/dist/services/field-encryption.service.d.ts","packages/db/dist/services/field-encryption.service.d.ts.map","packages/db/dist/services/field-encryption.service.js","packages/db/dist/services/field-encryption.service.js.map"]} \ No newline at end of file diff --git a/.turbo/cache/df12164dc3180a8f-meta.json b/.turbo/cache/df12164dc3180a8f-meta.json new file mode 100644 index 0000000..c91c339 --- /dev/null +++ b/.turbo/cache/df12164dc3180a8f-meta.json @@ -0,0 +1 @@ +{"hash":"df12164dc3180a8f","duration":1557,"sha":"b01b79d02a41aac425fe0f4ab3e21460c69a94b4","dirty_hash":"53949d4fa912af90b4184926009d1814809e1d773d20612a89c885dbf200727c"} \ No newline at end of file diff --git a/.turbo/cache/df12164dc3180a8f.tar.zst b/.turbo/cache/df12164dc3180a8f.tar.zst new file mode 100644 index 0000000..7183ad7 Binary files /dev/null and b/.turbo/cache/df12164dc3180a8f.tar.zst differ diff --git a/plans/FRE-4522-rate-limit-config.md b/plans/FRE-4522-rate-limit-config.md new file mode 100644 index 0000000..ba68614 --- /dev/null +++ b/plans/FRE-4522-rate-limit-config.md @@ -0,0 +1,67 @@ +# FRE-4522 - Update spamshield.config.ts with per-minute + daily rate limit structure + +## Parent Issue +FRE-4507 - Implement Redis rate limiting middleware + +## Goal ID +2c5a8678-b505-4e9c-8ec4-c41faa9626ff + +## Description +Update the `spamshield.config.ts` file to include per-minute AND daily rate limit structure for each subscription tier. + +### Current State +The current `spamshield.config.ts` only has single value rate limits: +```typescript +export const spamRateLimits = { + BASIC: 100, + PLUS: 500, + PREMIUM: 2000, +} as const; +``` + +### Required Changes +Refactor `spamRateLimits` to include both per-minute and daily limits: + +```typescript +export const spamRateLimits = { + BASIC: { perMinute: 100, perDay: 1000 }, + PLUS: { perMinute: 500, perDay: 5000 }, + PREMIUM: { perMinute: 2000, perDay: 20000 }, +} as const; +``` + +### Type Definition +Add type definition for the rate limit structure: +```typescript +export interface TierRateLimits { + perMinute: number; + perDay: number; +} + +export type SubscriptionTierRateLimits = Record; +``` + +## Acceptance Criteria +- [ ] Update `spamRateLimits` to include `perMinute` and `perDay` properties +- [ ] Add `TierRateLimits` interface definition +- [ ] Update `SubscriptionTierRateLimits` type +- [ ] Ensure type safety with `as const` assertion +- [ ] All existing imports/exports continue to work + +## File to Modify +`services/spamshield/src/config/spamshield.config.ts` + +## Priority +HIGH (Blocker for FRE-4523 - middleware depends on config structure) + +## Status +done + +## Assigned To +d20f6f1c-1f24-4405-a122-2f93e0d6c94a (Founding Engineer) + +## Dependencies +- None (foundational config change) + +## Notes +This is the first child issue in the FRE-4507 implementation sequence. The config structure must be updated before the middleware can be implemented. diff --git a/plans/FRE-4523-rate-limit-middleware.md b/plans/FRE-4523-rate-limit-middleware.md new file mode 100644 index 0000000..91e0619 --- /dev/null +++ b/plans/FRE-4523-rate-limit-middleware.md @@ -0,0 +1,74 @@ +# FRE-4523 - Create spam-rate-limit.middleware.ts using Redis service + +## Parent Issue +FRE-4507 - Implement Redis rate limiting middleware + +## Goal ID +2c5a8678-b505-4e9c-8ec4-c41faa9626ff + +## Description +Create a new `spam-rate-limit.middleware.ts` file that implements Redis-backed rate limiting for the SpamShield service using the existing Redis service from `packages/shared-notifications/`. + +### Requirements +The middleware should: +1. Use the RedisService from `@shieldai/shared-notifications` +2. Implement per-minute AND daily rate limit tracking +3. Check rate limits before processing spam classification requests +4. Return appropriate HTTP 429 responses when limits are exceeded +5. Support tier-based rate limiting (BASIC, PLUS, PREMIUM) + +### Rate Limit Keys +Use Redis key patterns: +- Per-minute: `ratelimit:spam:{userId}:{tier}:min:{timestamp}` +- Per-day: `ratelimit:spam:{userId}:{tier}:day:{date}` + +Where: +- `timestamp` = current minute (Date.now() / 60000) +- `date` = current date (YYYY-MM-DD) + +### Expected Behavior +```typescript +// Check rate limit before processing +const rateLimitCheck = await rateLimitMiddleware.checkLimit(userId, tier); + +if (rateLimitCheck.exceeded) { + // Return 429 with retry-after header + return reply.code(429).send({ + error: 'Rate limit exceeded', + limit: rateLimitCheck.limit, + remaining: rateLimitCheck.remaining, + resetAt: rateLimitCheck.resetAt, + }); +} + +// Continue with spam classification +``` + +## Acceptance Criteria +- [ ] Create `services/spamshield/src/middleware/spam-rate-limit.middleware.ts` +- [ ] Import and use RedisService from `@shieldai/shared-notifications` +- [ ] Implement `checkLimit(userId, tier)` method returning rate limit status +- [ ] Implement `incrementCounter(userId, tier)` method +- [ ] Support per-minute and per-day limit tracking +- [ ] Return proper rate limit metadata (remaining, resetAt, limit) +- [ ] Handle Redis connection errors gracefully +- [ ] Export middleware class and factory function + +## File to Create +`services/spamshield/src/middleware/spam-rate-limit.middleware.ts` + +## Dependencies +- FRE-4522 (spamshield.config.ts with rate limit structure) +- `@shieldai/shared-notifications` (RedisService) + +## Priority +HIGH (Core middleware implementation) + +## Status +done + +## Assigned To +d20f6f1c-1f24-4405-a122-2f93e0d6c94a (Founding Engineer) + +## Notes +This middleware will be integrated into the spam classification pipeline to enforce rate limits before processing requests. diff --git a/plans/FRE-4524-spamshield-routes.md b/plans/FRE-4524-spamshield-routes.md new file mode 100644 index 0000000..eb573d9 --- /dev/null +++ b/plans/FRE-4524-spamshield-routes.md @@ -0,0 +1,134 @@ +# FRE-4524 - Create spamshield.routes.ts with spam classification endpoints + +## Parent Issue +FRE-4507 - Implement Redis rate limiting middleware + +## Goal ID +2c5a8678-b505-4e9c-8ec4-c41faa9626ff + +## Description +Create a new `spamshield.routes.ts` file that exposes spam classification API endpoints with rate limit middleware integration. + +### Required Endpoints + +#### POST /api/v1/spam/classify/sms +Classify an SMS message as spam or not spam. + +**Request Body:** +```typescript +{ + phoneNumber: string; // E.164 format + message: string; + userId: string; + tier: 'BASIC' | 'PLUS' | 'PREMIUM'; +} +``` + +**Response:** +```typescript +{ + isSpam: boolean; + score: number; + features: string[]; + rateLimit: { + remaining: number; + resetAt: Date; + limit: number; + }; +} +``` + +**Rate Limit:** Applied via spam-rate-limit.middleware.ts + +#### POST /api/v1/spam/classify/call +Classify a call based on metadata and context. + +**Request Body:** +```typescript +{ + phoneNumber: string; // E.164 format + callMetadata: { + duration?: number; + timeOfDay?: string; + frequency?: number; + }; + userId: string; + tier: 'BASIC' | 'PLUS' | 'PREMIUM'; +} +``` + +**Response:** +```typescript +{ + decision: 'BLOCK' | 'FLAG' | 'ALLOW'; + confidence: number; + reasons: string[]; + rateLimit: { + remaining: number; + resetAt: Date; + limit: number; + }; +} +``` + +**Rate Limit:** Applied via spam-rate-limit.middleware.ts + +#### GET /api/v1/spam/rate-limit/status +Get current rate limit status for a user. + +**Query Parameters:** +- `userId`: string (required) +- `tier`: 'BASIC' | 'PLUS' | 'PREMIUM' (required) + +**Response:** +```typescript +{ + userId: string; + tier: string; + currentLimits: { + perMinute: { + used: number; + limit: number; + remaining: number; + resetAt: Date; + }; + perDay: { + used: number; + limit: number; + remaining: number; + resetAt: Date; + }; + }; +} +``` + +## Acceptance Criteria +- [ ] Create `services/spamshield/src/routes/spamshield.routes.ts` +- [ ] Implement POST /api/v1/spam/classify/sms endpoint +- [ ] Implement POST /api/v1/spam/classify/call endpoint +- [ ] Implement GET /api/v1/spam/rate-limit/status endpoint +- [ ] Integrate spam-rate-limit.middleware.ts for classification endpoints +- [ ] Return rate limit metadata in responses +- [ ] Handle 429 responses when limits exceeded +- [ ] Proper TypeScript typing for request/response objects +- [ ] Export route registrar function + +## File to Create +`services/spamshield/src/routes/spamshield.routes.ts` + +## Dependencies +- FRE-4522 (spamshield.config.ts with rate limit structure) +- FRE-4523 (spam-rate-limit.middleware.ts) +- `@shieldai/types` (for type definitions) + +## Priority +MEDIUM (Depends on middleware implementation) + +## Status +todo + +## Assigned To +d20f6f1c-1f24-4405-a122-2f93e0d6c94a (Founding Engineer) + +## Notes +Routes should follow the existing pattern in `packages/api/src/routes/` for consistency. The spamshield service routes will be registered in the API gateway. diff --git a/plans/FRE-4525-rate-limit-tests.md b/plans/FRE-4525-rate-limit-tests.md new file mode 100644 index 0000000..942350d --- /dev/null +++ b/plans/FRE-4525-rate-limit-tests.md @@ -0,0 +1,97 @@ +# FRE-4525 - Add rate limit tests + +## Parent Issue +FRE-4507 - Implement Redis rate limiting middleware + +## Goal ID +2c5a8678-b505-4e9c-8ec4-c41faa9626ff + +## Description +Add comprehensive unit and integration tests for the spam rate limiting functionality across the config, middleware, and routes. + +### Test Coverage Requirements + +#### 1. Config Tests (spamshield.config.test.ts) +- [ ] Test `spamRateLimits` structure has correct perMinute and perDay values +- [ ] Test BASIC tier: 100/min, 1000/day +- [ ] Test PLUS tier: 500/min, 5000/day +- [ ] Test PREMIUM tier: 2000/min, 20000/day +- [ ] Test type safety with `as const` assertion +- [ ] Test `TierRateLimits` interface compatibility + +#### 2. Middleware Tests (spam-rate-limit.middleware.test.ts) +- [ ] Test rate limit check for BASIC tier (per-minute) +- [ ] Test rate limit check for BASIC tier (per-day) +- [ ] Test rate limit check for PLUS tier (per-minute) +- [ ] Test rate limit check for PLUS tier (per-day) +- [ ] Test rate limit check for PREMIUM tier (per-minute) +- [ ] Test rate limit check for PREMIUM tier (per-day) +- [ ] Test counter increment functionality +- [ ] Test rate limit reset after minute boundary +- [ ] Test rate limit reset after day boundary +- [ ] Test 429 response when limit exceeded +- [ ] Test retry-after header calculation +- [ ] Test Redis connection error handling +- [ ] Test key pattern generation + +#### 3. Route Tests (spamshield.routes.test.ts) +- [ ] Test POST /api/v1/spam/classify/sms with valid request +- [ ] Test POST /api/v1/spam/classify/sms with rate limit header +- [ ] Test POST /api/v1/spam/classify/call with valid request +- [ ] Test POST /api/v1/spam/classify/call with rate limit header +- [ ] Test GET /api/v1/spam/rate-limit/status returns correct data +- [ ] Test 429 response on classification endpoints when rate limited +- [ ] Test rate limit metadata in successful responses +- [ ] Test tier-based rate limit enforcement + +#### 4. Integration Tests (spam-rate-limit.integration.test.ts) +- [ ] End-to-end rate limit flow with mock Redis +- [ ] Concurrent request handling +- [ ] Rate limit key expiration +- [ ] Multiple users with different tiers +- [ ] Cross-day rate limit reset +- [ ] Cross-minute rate limit reset + +### Test Files to Create +1. `services/spamshield/test/spamshield.config.test.ts` +2. `services/spamshield/test/spam-rate-limit.middleware.test.ts` +3. `services/spamshield/test/spamshield.routes.test.ts` +4. `services/spamshield/test/spam-rate-limit.integration.test.ts` + +### Mock Requirements +- Mock RedisService for unit tests +- Mock SpamShieldService for route tests +- Use vitest for test framework (existing in project) + +## Acceptance Criteria +- [ ] All config tests pass (5 tests) +- [ ] All middleware tests pass (13 tests) +- [ ] All route tests pass (8 tests) +- [ ] All integration tests pass (6 tests) +- [ ] Minimum 90% code coverage for rate limiting code +- [ ] Tests follow existing test patterns in `services/spamshield/test/` +- [ ] Use vitest framework with proper mocking + +## Files to Create +- `services/spamshield/test/spamshield.config.test.ts` +- `services/spamshield/test/spam-rate-limit.middleware.test.ts` +- `services/spamshield/test/spamshield.routes.test.ts` +- `services/spamshield/test/spam-rate-limit.integration.test.ts` + +## Dependencies +- FRE-4522 (spamshield.config.ts) +- FRE-4523 (spam-rate-limit.middleware.ts) +- FRE-4524 (spamshield.routes.ts) +- `vitest` (existing test framework) + +## Priority +LOW (Can be implemented in parallel with routes, but depends on middleware) + +## Status +todo + +## Assigned To +d20f6f1c-1f24-4405-a122-2f93e0d6c94a (Founding Engineer) + +## Notes +Tests should be written to validate the complete rate limiting flow. Integration tests require a running Redis instance or mock. diff --git a/services/spamshield/src/classifier/sms-classifier.ts b/services/spamshield/src/classifier/sms-classifier.ts index 050a905..02ee7e9 100644 --- a/services/spamshield/src/classifier/sms-classifier.ts +++ b/services/spamshield/src/classifier/sms-classifier.ts @@ -1,4 +1,12 @@ import { SpamShieldService } from '../services/spamshield.service'; +import { + HIGH_RISK_LINK_SCORE, + SHORT_AGGRESSIVE_SCORE, + EXCESSIVE_NUMBERS_SCORE, + URGENT_NEGATIVE_SCORE, + REPUTATION_SCORE_WEIGHT, + SMS_SPAM_THRESHOLD, +} from '../constants/sms-classifier.constants'; export interface SmsClassificationResult { isSpam: boolean; @@ -58,31 +66,31 @@ export class BertSmsClassifier implements SmsClassifier { // High-risk patterns if (hasLinks && length > 100) { - spamScore += 0.3; + spamScore += HIGH_RISK_LINK_SCORE; } // Short aggressive messages if (length < 20 && hasNumbers) { - spamScore += 0.2; + spamScore += SHORT_AGGRESSIVE_SCORE; } // Excessive numbers if (/\d{3,}/.test(text)) { - spamScore += 0.15; + spamScore += EXCESSIVE_NUMBERS_SCORE; } // Negative/urgent language if (sentiment === 'negative' && language === 'unknown') { - spamScore += 0.2; + spamScore += URGENT_NEGATIVE_SCORE; } // Combine with reputation score if available const reputation = await this.spamShield.checkReputation('placeholder'); if (reputation.isSpam) { - spamScore += 0.25; + spamScore += REPUTATION_SCORE_WEIGHT; } - const isSpam = spamScore > 0.5; + const isSpam = spamScore > SMS_SPAM_THRESHOLD; // Update metrics this.metrics.totalClassified++; diff --git a/services/spamshield/src/config/spamshield.config.ts b/services/spamshield/src/config/spamshield.config.ts index 1e6c599..be740a1 100644 --- a/services/spamshield/src/config/spamshield.config.ts +++ b/services/spamshield/src/config/spamshield.config.ts @@ -1,7 +1,16 @@ -export const spamRateLimits = { - BASIC: 100, - PLUS: 500, - PREMIUM: 2000, +export type SubscriptionTier = 'BASIC' | 'PLUS' | 'PREMIUM'; + +export interface TierRateLimits { + perMinute: number; + perDay: number; +} + +export type SubscriptionTierRateLimits = Record; + +export const spamRateLimits: SubscriptionTierRateLimits = { + BASIC: { perMinute: 100, perDay: 1000 }, + PLUS: { perMinute: 500, perDay: 5000 }, + PREMIUM: { perMinute: 2000, perDay: 20000 }, } as const; export const spamFeatureFlagDefaults = { @@ -47,7 +56,27 @@ export const spamConfig = { maxPhoneNumberLength: 20, minPhoneNumberLength: 10, defaultConfidenceThreshold: 0.7, - maxMetadataSize: 1024 * 10, + maxMetadataSize: 1024 * 4, circuitBreakerThreshold: 5, circuitBreakerTimeout: 60000, } as const; + +export const metadataLimits = { + maxMetadataSizeBytes: 4096, + maxMetadataKeys: 20, + maxMetadataValueSizeBytes: 512, +} as const; + +/** Reputation and Spam Score Constants */ +export const defaultReputationConfidence = 0.7; +export const defaultSpamScore = 0.0; +export const highReputationThreshold = 0.8; +export const lowReputationThreshold = 0.3; + +/** Feature Weights for Reputation Scoring */ +export const featureWeights = { + reputationWeight: 0.4, + ruleWeight: 0.3, + behavioralWeight: 0.2, + userHistoryWeight: 0.1, +} as const; diff --git a/services/spamshield/src/constants/sms-classifier.constants.ts b/services/spamshield/src/constants/sms-classifier.constants.ts new file mode 100644 index 0000000..3154b77 --- /dev/null +++ b/services/spamshield/src/constants/sms-classifier.constants.ts @@ -0,0 +1,20 @@ +/** + * SMS Classifier Constants + * + * Scoring weights and thresholds for SMS spam classification. + * These values control the contribution of different features to the final spam score. + */ + +/** Feature Scoring Weights */ +export const HIGH_RISK_LINK_SCORE = 0.3; // Links + long message (>100 chars) +export const SHORT_AGGRESSIVE_SCORE = 0.2; // Short message (<20 chars) with numbers +export const EXCESSIVE_NUMBERS_SCORE = 0.15; // Messages with 3+ digit numbers +export const URGENT_NEGATIVE_SCORE = 0.2; // Negative sentiment + unknown language +export const REPUTATION_SCORE_WEIGHT = 0.25; // Reputation-based spam indicator + +/** Classification Thresholds */ +export const SMS_SPAM_THRESHOLD = 0.5; // Final score threshold for spam classification + +/** Length Analysis */ +export const OPTIMAL_SMS_LENGTH = 160; // Standard SMS character limit +export const MAX_LENGTH_BONUS = 0.3; // Maximum score from length overflow diff --git a/services/spamshield/src/middleware/spam-rate-limit.middleware.ts b/services/spamshield/src/middleware/spam-rate-limit.middleware.ts new file mode 100644 index 0000000..9d67d24 --- /dev/null +++ b/services/spamshield/src/middleware/spam-rate-limit.middleware.ts @@ -0,0 +1,177 @@ +import { RedisService } from '@shieldai/shared-notifications'; +import { TierRateLimits, SubscriptionTier, spamRateLimits } from '../config/spamshield.config'; + +export interface RateLimitStatus { + exceeded: boolean; + limit: number; + remaining: number; + resetAt: Date; + retryAfterSeconds: number; +} + +export interface RateLimitOptions { + windowMs?: number; + dailyWindowMs?: number; +} + +export class SpamRateLimitMiddleware { + private redisService: RedisService; + private options: RateLimitOptions; + + constructor(redisService: RedisService, options?: RateLimitOptions) { + this.redisService = redisService; + this.options = { + windowMs: options?.windowMs || 60000, + dailyWindowMs: options?.dailyWindowMs || 86400000, + }; + } + + private getMinuteKey(userId: string, tier: SubscriptionTier): string { + const windowMs = this.options.windowMs ?? 60000; + const minuteTimestamp = Math.floor(Date.now() / windowMs); + return `ratelimit:spam:${userId}:${tier}:min:${minuteTimestamp}`; + } + + private getDayKey(userId: string, tier: SubscriptionTier): string { + const date = new Date().toISOString().split('T')[0]; + return `ratelimit:spam:${userId}:${tier}:day:${date}`; + } + + private getResetTime(windowMs: number): Date { + const now = Date.now(); + const resetTimestamp = Math.ceil(now / windowMs) * windowMs; + return new Date(resetTimestamp); + } + + async checkLimit( + userId: string, + tier: SubscriptionTier, + ): Promise { + const tierLimits = spamRateLimits[tier]; + const minuteKey = this.getMinuteKey(userId, tier); + const dayKey = this.getDayKey(userId, tier); + + try { + const [minuteCount, dayCount] = await Promise.all([ + this.redisService.getCounter(minuteKey), + this.redisService.getCounter(dayKey), + ]); + + const minuteExceeded = minuteCount >= tierLimits.perMinute; + const dayExceeded = dayCount >= tierLimits.perDay; + const exceeded = minuteExceeded || dayExceeded; + + const effectiveLimit = exceeded + ? Math.min(tierLimits.perMinute, tierLimits.perDay) + : Math.min(tierLimits.perMinute, tierLimits.perDay); + + const effectiveCount = exceeded + ? Math.min(minuteCount, dayCount) + : Math.min(minuteCount, dayCount); + + const windowMs = this.options.windowMs ?? 60000; + return { + exceeded, + limit: effectiveLimit, + remaining: Math.max(0, effectiveLimit - effectiveCount), + resetAt: this.getResetTime(windowMs), + retryAfterSeconds: Math.ceil( + (this.getResetTime(windowMs).getTime() - Date.now()) / 1000, + ), + }; + } catch (error) { + console.error('[SpamRateLimit] Redis error:', error); + const windowMs = this.options.windowMs ?? 60000; + return { + exceeded: false, + limit: tierLimits.perMinute, + remaining: tierLimits.perMinute, + resetAt: this.getResetTime(windowMs), + retryAfterSeconds: windowMs / 1000, + }; + } + } + + async incrementCounter( + userId: string, + tier: SubscriptionTier, + ): Promise<{ minuteCount: number; dayCount: number }> { + const minuteKey = this.getMinuteKey(userId, tier); + const dayKey = this.getDayKey(userId, tier); + + try { + const windowMs = this.options.windowMs ?? 60000; + const dailyWindowMs = this.options.dailyWindowMs ?? 86400000; + const [minuteCount, dayCount] = await Promise.all([ + this.redisService.increment(minuteKey, Math.ceil(windowMs / 1000)), + this.redisService.increment(dayKey, Math.ceil(dailyWindowMs / 1000)), + ]); + + return { minuteCount, dayCount }; + } catch (error) { + console.error('[SpamRateLimit] Increment error:', error); + return { minuteCount: 0, dayCount: 0 }; + } + } + + async checkAndIncrement( + userId: string, + tier: SubscriptionTier, + ): Promise<{ allowed: boolean; status: RateLimitStatus }> { + const status = await this.checkLimit(userId, tier); + + if (!status.exceeded) { + await this.incrementCounter(userId, tier); + const updatedStatus = await this.checkLimit(userId, tier); + return { allowed: true, status: updatedStatus }; + } + + return { allowed: false, status }; + } + + async getUsage(userId: string, tier: SubscriptionTier): Promise<{ + minuteUsed: number; + minuteLimit: number; + minuteRemaining: number; + dayUsed: number; + dayLimit: number; + dayRemaining: number; + }> { + const tierLimits = spamRateLimits[tier]; + const minuteKey = this.getMinuteKey(userId, tier); + const dayKey = this.getDayKey(userId, tier); + + try { + const [minuteCount, dayCount] = await Promise.all([ + this.redisService.getCounter(minuteKey), + this.redisService.getCounter(dayKey), + ]); + + return { + minuteUsed: minuteCount, + minuteLimit: tierLimits.perMinute, + minuteRemaining: Math.max(0, tierLimits.perMinute - minuteCount), + dayUsed: dayCount, + dayLimit: tierLimits.perDay, + dayRemaining: Math.max(0, tierLimits.perDay - dayCount), + }; + } catch (error) { + console.error('[SpamRateLimit] Usage fetch error:', error); + return { + minuteUsed: 0, + minuteLimit: tierLimits.perMinute, + minuteRemaining: tierLimits.perMinute, + dayUsed: 0, + dayLimit: tierLimits.perDay, + dayRemaining: tierLimits.perDay, + }; + } + } +} + +export function createSpamRateLimitMiddleware( + redisService: RedisService, + options?: RateLimitOptions, +): SpamRateLimitMiddleware { + return new SpamRateLimitMiddleware(redisService, options); +} diff --git a/services/spamshield/src/services/spamshield.service.ts b/services/spamshield/src/services/spamshield.service.ts index 68933ba..d73696c 100644 --- a/services/spamshield/src/services/spamshield.service.ts +++ b/services/spamshield/src/services/spamshield.service.ts @@ -2,7 +2,7 @@ import { PrismaClient, SpamFeedback, SpamRule, SpamAuditLog } from '@prisma/clie import { FieldEncryptionService } from '@shieldai/db'; import { generateRequestId } from '@shieldai/types'; import { emitSpamShieldAlert } from '@shieldai/correlation'; -import { spamConfig, spamFeatureFlags } from '../config/spamshield.config'; +import { spamConfig, spamFeatureFlags, metadataLimits } from '../config/spamshield.config'; import { CircuitBreaker, CircuitBreakerError, CircuitState, CircuitBreakerMetrics } from '../circuit-breaker'; import { validatePhoneNumber as validateE164 } from '../utils/phone-validation'; import { CarrierApi, CarrierCall, CarrierSms, CarrierDecision } from '../carriers/carrier-types'; @@ -246,7 +246,8 @@ export class SpamShieldService { userId: string, phoneNumber: string, isSpam: boolean, - label?: string + label?: string, + metadata?: Record ): Promise { if (!spamFeatureFlags.enableFeedbackLoop) { throw new Error('Feedback loop disabled via feature flag'); @@ -256,6 +257,10 @@ export class SpamShieldService { const encrypted = FieldEncryptionService.encrypt(validated); const hash = FieldEncryptionService.hashPhoneNumber(validated); + const validatedMetadata = metadata + ? this.validateMetadata(metadata) + : { source: 'user_feedback' }; + await prisma.spamFeedback.create({ data: { userId, @@ -263,7 +268,7 @@ export class SpamShieldService { phoneNumberHash: hash, isSpam, label, - metadata: JSON.stringify({ source: 'user_feedback' }), + metadata: JSON.stringify(validatedMetadata), }, }); } @@ -543,4 +548,49 @@ export class SpamShieldService { select: { id: true, pattern: true }, }); } + + private validateMetadata(metadata: Record): Record { + const metadataStr = JSON.stringify(metadata); + + if (metadataStr.length > metadataLimits.maxMetadataSizeBytes) { + console.log(`[SpamShield] Metadata size ${metadataStr.length}B exceeds limit ${metadataLimits.maxMetadataSizeBytes}B, truncating`); + } + + const entries = Object.entries(metadata); + const truncatedEntries: [string, any][] = []; + + for (let i = 0; i < Math.min(entries.length, metadataLimits.maxMetadataKeys); i++) { + const [key, value] = entries[i]; + const valueStr = String(value); + + if (valueStr.length > metadataLimits.maxMetadataValueSizeBytes) { + truncatedEntries.push([key, valueStr.slice(0, metadataLimits.maxMetadataValueSizeBytes)]); + } else { + truncatedEntries.push([key, value]); + } + } + + const result = Object.fromEntries(truncatedEntries); + const resultStr = JSON.stringify(result); + + if (resultStr.length > metadataLimits.maxMetadataSizeBytes) { + const shrunk: Record = {}; + let currentSize = 2; + + for (const [key, value] of truncatedEntries) { + const entrySize = key.length + String(value).length + 3; + if (currentSize + entrySize <= metadataLimits.maxMetadataSizeBytes) { + shrunk[key] = value; + currentSize += entrySize; + } else { + break; + } + } + + console.log(`[SpamShield] Metadata reduced to ${Object.keys(shrunk).length} keys to fit size limit`); + return shrunk; + } + + return result; + } }