Auto-commit 2026-05-02 09:37
This commit is contained in:
1
.turbo/cache/8ff5b7eb9e0aad01-manifest.json
vendored
Normal file
1
.turbo/cache/8ff5b7eb9e0aad01-manifest.json
vendored
Normal file
@@ -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"]}
|
||||||
1
.turbo/cache/8ff5b7eb9e0aad01-meta.json
vendored
Normal file
1
.turbo/cache/8ff5b7eb9e0aad01-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"hash":"8ff5b7eb9e0aad01","duration":908,"sha":"b01b79d02a41aac425fe0f4ab3e21460c69a94b4","dirty_hash":"53949d4fa912af90b4184926009d1814809e1d773d20612a89c885dbf200727c"}
|
||||||
BIN
.turbo/cache/8ff5b7eb9e0aad01.tar.zst
vendored
Normal file
BIN
.turbo/cache/8ff5b7eb9e0aad01.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/df12164dc3180a8f-manifest.json
vendored
Normal file
1
.turbo/cache/df12164dc3180a8f-manifest.json
vendored
Normal file
@@ -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"]}
|
||||||
1
.turbo/cache/df12164dc3180a8f-meta.json
vendored
Normal file
1
.turbo/cache/df12164dc3180a8f-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"hash":"df12164dc3180a8f","duration":1557,"sha":"b01b79d02a41aac425fe0f4ab3e21460c69a94b4","dirty_hash":"53949d4fa912af90b4184926009d1814809e1d773d20612a89c885dbf200727c"}
|
||||||
BIN
.turbo/cache/df12164dc3180a8f.tar.zst
vendored
Normal file
BIN
.turbo/cache/df12164dc3180a8f.tar.zst
vendored
Normal file
Binary file not shown.
67
plans/FRE-4522-rate-limit-config.md
Normal file
67
plans/FRE-4522-rate-limit-config.md
Normal file
@@ -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<SubscriptionTier, TierRateLimits>;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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.
|
||||||
74
plans/FRE-4523-rate-limit-middleware.md
Normal file
74
plans/FRE-4523-rate-limit-middleware.md
Normal file
@@ -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.
|
||||||
134
plans/FRE-4524-spamshield-routes.md
Normal file
134
plans/FRE-4524-spamshield-routes.md
Normal file
@@ -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.
|
||||||
97
plans/FRE-4525-rate-limit-tests.md
Normal file
97
plans/FRE-4525-rate-limit-tests.md
Normal file
@@ -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.
|
||||||
@@ -1,4 +1,12 @@
|
|||||||
import { SpamShieldService } from '../services/spamshield.service';
|
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 {
|
export interface SmsClassificationResult {
|
||||||
isSpam: boolean;
|
isSpam: boolean;
|
||||||
@@ -58,31 +66,31 @@ export class BertSmsClassifier implements SmsClassifier {
|
|||||||
|
|
||||||
// High-risk patterns
|
// High-risk patterns
|
||||||
if (hasLinks && length > 100) {
|
if (hasLinks && length > 100) {
|
||||||
spamScore += 0.3;
|
spamScore += HIGH_RISK_LINK_SCORE;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Short aggressive messages
|
// Short aggressive messages
|
||||||
if (length < 20 && hasNumbers) {
|
if (length < 20 && hasNumbers) {
|
||||||
spamScore += 0.2;
|
spamScore += SHORT_AGGRESSIVE_SCORE;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Excessive numbers
|
// Excessive numbers
|
||||||
if (/\d{3,}/.test(text)) {
|
if (/\d{3,}/.test(text)) {
|
||||||
spamScore += 0.15;
|
spamScore += EXCESSIVE_NUMBERS_SCORE;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Negative/urgent language
|
// Negative/urgent language
|
||||||
if (sentiment === 'negative' && language === 'unknown') {
|
if (sentiment === 'negative' && language === 'unknown') {
|
||||||
spamScore += 0.2;
|
spamScore += URGENT_NEGATIVE_SCORE;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Combine with reputation score if available
|
// Combine with reputation score if available
|
||||||
const reputation = await this.spamShield.checkReputation('placeholder');
|
const reputation = await this.spamShield.checkReputation('placeholder');
|
||||||
if (reputation.isSpam) {
|
if (reputation.isSpam) {
|
||||||
spamScore += 0.25;
|
spamScore += REPUTATION_SCORE_WEIGHT;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isSpam = spamScore > 0.5;
|
const isSpam = spamScore > SMS_SPAM_THRESHOLD;
|
||||||
|
|
||||||
// Update metrics
|
// Update metrics
|
||||||
this.metrics.totalClassified++;
|
this.metrics.totalClassified++;
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
export const spamRateLimits = {
|
export type SubscriptionTier = 'BASIC' | 'PLUS' | 'PREMIUM';
|
||||||
BASIC: 100,
|
|
||||||
PLUS: 500,
|
export interface TierRateLimits {
|
||||||
PREMIUM: 2000,
|
perMinute: number;
|
||||||
|
perDay: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SubscriptionTierRateLimits = Record<SubscriptionTier, TierRateLimits>;
|
||||||
|
|
||||||
|
export const spamRateLimits: SubscriptionTierRateLimits = {
|
||||||
|
BASIC: { perMinute: 100, perDay: 1000 },
|
||||||
|
PLUS: { perMinute: 500, perDay: 5000 },
|
||||||
|
PREMIUM: { perMinute: 2000, perDay: 20000 },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const spamFeatureFlagDefaults = {
|
export const spamFeatureFlagDefaults = {
|
||||||
@@ -47,7 +56,27 @@ export const spamConfig = {
|
|||||||
maxPhoneNumberLength: 20,
|
maxPhoneNumberLength: 20,
|
||||||
minPhoneNumberLength: 10,
|
minPhoneNumberLength: 10,
|
||||||
defaultConfidenceThreshold: 0.7,
|
defaultConfidenceThreshold: 0.7,
|
||||||
maxMetadataSize: 1024 * 10,
|
maxMetadataSize: 1024 * 4,
|
||||||
circuitBreakerThreshold: 5,
|
circuitBreakerThreshold: 5,
|
||||||
circuitBreakerTimeout: 60000,
|
circuitBreakerTimeout: 60000,
|
||||||
} as const;
|
} 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;
|
||||||
|
|||||||
@@ -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
|
||||||
177
services/spamshield/src/middleware/spam-rate-limit.middleware.ts
Normal file
177
services/spamshield/src/middleware/spam-rate-limit.middleware.ts
Normal file
@@ -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<RateLimitStatus> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { PrismaClient, SpamFeedback, SpamRule, SpamAuditLog } from '@prisma/clie
|
|||||||
import { FieldEncryptionService } from '@shieldai/db';
|
import { FieldEncryptionService } from '@shieldai/db';
|
||||||
import { generateRequestId } from '@shieldai/types';
|
import { generateRequestId } from '@shieldai/types';
|
||||||
import { emitSpamShieldAlert } from '@shieldai/correlation';
|
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 { CircuitBreaker, CircuitBreakerError, CircuitState, CircuitBreakerMetrics } from '../circuit-breaker';
|
||||||
import { validatePhoneNumber as validateE164 } from '../utils/phone-validation';
|
import { validatePhoneNumber as validateE164 } from '../utils/phone-validation';
|
||||||
import { CarrierApi, CarrierCall, CarrierSms, CarrierDecision } from '../carriers/carrier-types';
|
import { CarrierApi, CarrierCall, CarrierSms, CarrierDecision } from '../carriers/carrier-types';
|
||||||
@@ -246,7 +246,8 @@ export class SpamShieldService {
|
|||||||
userId: string,
|
userId: string,
|
||||||
phoneNumber: string,
|
phoneNumber: string,
|
||||||
isSpam: boolean,
|
isSpam: boolean,
|
||||||
label?: string
|
label?: string,
|
||||||
|
metadata?: Record<string, any>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!spamFeatureFlags.enableFeedbackLoop) {
|
if (!spamFeatureFlags.enableFeedbackLoop) {
|
||||||
throw new Error('Feedback loop disabled via feature flag');
|
throw new Error('Feedback loop disabled via feature flag');
|
||||||
@@ -256,6 +257,10 @@ export class SpamShieldService {
|
|||||||
const encrypted = FieldEncryptionService.encrypt(validated);
|
const encrypted = FieldEncryptionService.encrypt(validated);
|
||||||
const hash = FieldEncryptionService.hashPhoneNumber(validated);
|
const hash = FieldEncryptionService.hashPhoneNumber(validated);
|
||||||
|
|
||||||
|
const validatedMetadata = metadata
|
||||||
|
? this.validateMetadata(metadata)
|
||||||
|
: { source: 'user_feedback' };
|
||||||
|
|
||||||
await prisma.spamFeedback.create({
|
await prisma.spamFeedback.create({
|
||||||
data: {
|
data: {
|
||||||
userId,
|
userId,
|
||||||
@@ -263,7 +268,7 @@ export class SpamShieldService {
|
|||||||
phoneNumberHash: hash,
|
phoneNumberHash: hash,
|
||||||
isSpam,
|
isSpam,
|
||||||
label,
|
label,
|
||||||
metadata: JSON.stringify({ source: 'user_feedback' }),
|
metadata: JSON.stringify(validatedMetadata),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -543,4 +548,49 @@ export class SpamShieldService {
|
|||||||
select: { id: true, pattern: true },
|
select: { id: true, pattern: true },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private validateMetadata(metadata: Record<string, any>): Record<string, any> {
|
||||||
|
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<string, any> = {};
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user