From fe754761d9ece826ba31b4333ce799c52e01e74a Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Sat, 2 May 2026 09:37:30 -0400 Subject: [PATCH] Auto-commit 2026-05-02 09:37 --- .turbo/cache/8ff5b7eb9e0aad01-manifest.json | 1 + .turbo/cache/8ff5b7eb9e0aad01-meta.json | 1 + .turbo/cache/8ff5b7eb9e0aad01.tar.zst | Bin 0 -> 12461 bytes .turbo/cache/df12164dc3180a8f-manifest.json | 1 + .turbo/cache/df12164dc3180a8f-meta.json | 1 + .turbo/cache/df12164dc3180a8f.tar.zst | Bin 0 -> 2410 bytes plans/FRE-4522-rate-limit-config.md | 67 +++++++ plans/FRE-4523-rate-limit-middleware.md | 74 ++++++++ plans/FRE-4524-spamshield-routes.md | 134 +++++++++++++ plans/FRE-4525-rate-limit-tests.md | 97 ++++++++++ .../src/classifier/sms-classifier.ts | 20 +- .../src/config/spamshield.config.ts | 39 +++- .../src/constants/sms-classifier.constants.ts | 20 ++ .../middleware/spam-rate-limit.middleware.ts | 177 ++++++++++++++++++ .../src/services/spamshield.service.ts | 56 +++++- 15 files changed, 674 insertions(+), 14 deletions(-) create mode 100644 .turbo/cache/8ff5b7eb9e0aad01-manifest.json create mode 100644 .turbo/cache/8ff5b7eb9e0aad01-meta.json create mode 100644 .turbo/cache/8ff5b7eb9e0aad01.tar.zst create mode 100644 .turbo/cache/df12164dc3180a8f-manifest.json create mode 100644 .turbo/cache/df12164dc3180a8f-meta.json create mode 100644 .turbo/cache/df12164dc3180a8f.tar.zst create mode 100644 plans/FRE-4522-rate-limit-config.md create mode 100644 plans/FRE-4523-rate-limit-middleware.md create mode 100644 plans/FRE-4524-spamshield-routes.md create mode 100644 plans/FRE-4525-rate-limit-tests.md create mode 100644 services/spamshield/src/constants/sms-classifier.constants.ts create mode 100644 services/spamshield/src/middleware/spam-rate-limit.middleware.ts 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 0000000000000000000000000000000000000000..1d9abc6e8ced8c28589f9cfe4b2a82539b847edb GIT binary patch literal 12461 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 +``` + +## 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; + } }