diff --git a/app/ai-service/main.py b/app/ai-service/main.py index b067e438..f8c00393 100644 --- a/app/ai-service/main.py +++ b/app/ai-service/main.py @@ -482,6 +482,19 @@ async def health_check(): return {"status": "healthy", "service": "chainforge-ai-service", "version": "1.0.0"} +@app.get("/api/v1/pii/patterns") +async def get_pii_patterns(): + return { + "email": PIIScrubberService.EMAIL_REGEXES, + "phone": PIIScrubberService.PHONE_REGEXES, + "name": PIIScrubberService.NAME_REGEXES, + "location": PIIScrubberService.LOCATION_REGEXES, + "date": PIIScrubberService.DATE_REGEXES, + "id": PIIScrubberService.ID_REGEXES, + } + + + @app.get("/health/dependencies") async def health_dependencies(): """Lightweight dependency probe for staging and CI. diff --git a/app/backend/src/app.module.ts b/app/backend/src/app.module.ts index 3935b65e..d548437c 100644 --- a/app/backend/src/app.module.ts +++ b/app/backend/src/app.module.ts @@ -45,6 +45,8 @@ import { AdaptiveRateLimitGuard } from './common/guards/adaptive-rate-limit.guar import { DeprecationInterceptor } from './common/interceptors/deprecation.interceptor'; import { HttpCacheInterceptor } from './common/interceptors/http-cache.interceptor'; import { SandboxModule } from './sandbox/sandbox.module'; +import { RedisService as CustomRedisService } from '../cache/redis.service'; + @Module({ imports: [ @@ -135,6 +137,8 @@ import { SandboxModule } from './sandbox/sandbox.module'; controllers: [AppController], providers: [ AppService, + CustomRedisService, + { provide: APP_FILTER, useClass: AllExceptionsFilter, diff --git a/app/backend/src/common/interceptors/pii-scrub.interceptor.ts b/app/backend/src/common/interceptors/pii-scrub.interceptor.ts new file mode 100644 index 00000000..79ee9e94 --- /dev/null +++ b/app/backend/src/common/interceptors/pii-scrub.interceptor.ts @@ -0,0 +1,275 @@ +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, + UnprocessableEntityException, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { ConfigService } from '@nestjs/config'; +import { RedisService } from '../../../cache/redis.service'; +import axios from 'axios'; + +interface CompiledPattern { + label: string; + regex: RegExp; +} + +const DEFAULT_PATTERNS: Record = { + email: ['\\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Z|a-z]{2,}\\b'], + phone: [ + '\\+?\\d{1,4}[-.\\s]?\\(?\\d{1,3}?\\)?[-.\\s]?\\d{3}[-.\\s]?\\d{4}\\b', + '\\b0\\d{10}\\b', + '\\+234\\s?\\d{3}\\s?\\d{3}\\s?\\d{4}\\b', + ], + name: [ + '\\b(?:Mr|Mrs|Ms|Miss|Dr|Prof)\\.?\\s+[A-Z][a-z]+(?:\\s+[A-Z][a-z]+){0,2}\\b', + '\\b[A-Z][a-z]+\\s+[A-Z][a-z]+\\b', + ], + location: [ + '\\b(?:in|at|from|near)\\s+([A-Z][a-z]+(?:\\s+[A-Z][a-z]+){0,2}(?:\\s+(?:Camp|State|Region|District|City|Village|Way|Island))?)\\b', + '\\d+\\s+[A-Z][a-z]+\\s+[A-Z][a-z]+\\s+(?:Way|Street|Avenue|Road|Island)\\b', + ], + date: [ + '\\b\\d{1,2}[/-]\\d{1,2}[/-]\\d{2,4}\\b', + '\\b\\d{4}[/-]\\d{1,2}[/-]\\d{1,2}\\b', + '\\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Sept|Oct|Nov|Dec)[a-z]*\\s+\\d{1,2},?\\s+\\d{4}\\b', + '\\b\\d{1,2}\\s+(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Sept|Oct|Nov|Dec)[a-z]*\\s+\\d{4}\\b', + ], + id: [ + '\\b\\d{11}\\b', // NIN + '\\b[A-Z]{2}\\d{8}\\b', // Voter ID + ], +}; + +const MAP_KEY_TO_TOKEN: Record = { + email: 'EMAIL_ADDRESS', + phone: 'PHONE_NUMBER', + name: 'RECIPIENT_NAME', + location: 'LOCATION', + date: 'EVENT_DATE', + id: 'ID_NUMBER', +}; + +@Injectable() +export class PiiScrubInterceptor implements NestInterceptor { + private cachedPatterns: CompiledPattern[] | null = null; + private lastFetchedMemoryTime = 0; + + constructor( + private readonly configService: ConfigService, + private readonly redisService: RedisService, + ) {} + + async intercept( + context: ExecutionContext, + next: CallHandler, + ): Promise> { + const request = context.switchToHttp().getRequest(); + if (!request || !request.body || typeof request.body !== 'object') { + return next.handle(); + } + + const mode = this.configService.get('PII_SCRUB_MODE') || 'redact'; + if (mode === 'off') { + return next.handle(); + } + + const highRiskKeysConfig = + this.configService.get('PII_HIGH_RISK_KEYS'); + const highRiskKeys = highRiskKeysConfig + ? highRiskKeysConfig.split(',').map(k => k.trim()) + : [ + 'email', + 'phone', + 'name', + 'nin', + 'recipientref', + 'metadata', + 'content', + 'input', + 'output', + ]; + + const userSub = + request.user?.sub || request.user?.id || request.user?.apiKeyId; + const patterns = await this.getCompiledPatterns(); + + const offendingPaths: string[] = []; + + const traverseAndScrub = (obj: any, path = ''): any => { + if (obj === null || obj === undefined) return obj; + + if (Array.isArray(obj)) { + return obj.map((item, index) => + traverseAndScrub(item, path ? `${path}[${index}]` : `[${index}]`), + ); + } + + if (typeof obj === 'object') { + const result: any = {}; + for (const key of Object.keys(obj)) { + const val = obj[key]; + const currentPath = path ? `${path}.${key}` : key; + const isHighRisk = this.checkIsHighRiskKey(key, highRiskKeys); + + if (isHighRisk && typeof val === 'string') { + // Check allowlist: if val matches userSub, skip scrubbing + if (userSub && String(val) === String(userSub)) { + result[key] = val; + } else { + const matches = this.checkPiiMatches(val, patterns); + if (matches.length > 0) { + if (mode === 'reject') { + offendingPaths.push(currentPath); + result[key] = val; // keep original for completeness + } else { + // redact mode + result[key] = this.redactPii(val, patterns); + } + } else { + result[key] = val; + } + } + } else { + result[key] = traverseAndScrub(val, currentPath); + } + } + return result; + } + + return obj; + }; + + request.body = traverseAndScrub(request.body); + + if (offendingPaths.length > 0) { + throw new UnprocessableEntityException({ + statusCode: 422, + message: `PII validation failed: offending field paths contain PII: ${offendingPaths.join(', ')}`, + errors: offendingPaths, + }); + } + + return next.handle(); + } + + private checkIsHighRiskKey(key: string, highRiskKeys: string[]): boolean { + const lowerKey = key.toLowerCase(); + return highRiskKeys.some(k => { + const lowerK = k.toLowerCase(); + if (lowerK === 'name') { + return ( + lowerKey === 'name' || + lowerKey === 'fullname' || + lowerKey === 'firstname' || + lowerKey === 'lastname' || + lowerKey === 'recipientname' || + lowerKey === 'username' || + (lowerKey.endsWith('name') && + !lowerKey.includes('campaign') && + !lowerKey.includes('org') && + !lowerKey.includes('app')) + ); + } + return ( + lowerKey === lowerK || + lowerKey.endsWith(lowerK) || + lowerKey.startsWith(lowerK) || + lowerKey.includes('_' + lowerK) || + lowerKey.includes(lowerK + '_') + ); + }); + } + + private checkPiiMatches( + val: string, + patterns: CompiledPattern[], + ): CompiledPattern[] { + const matched: CompiledPattern[] = []; + for (const pattern of patterns) { + // Reset lastIndex for safety when reusing RegExp with global flag + pattern.regex.lastIndex = 0; + if (pattern.regex.test(val)) { + matched.push(pattern); + } + } + return matched; + } + + private redactPii(val: string, patterns: CompiledPattern[]): string { + let scrubbed = val; + for (const pattern of patterns) { + pattern.regex.lastIndex = 0; + scrubbed = scrubbed.replace(pattern.regex, `[${pattern.label}]`); + } + return scrubbed; + } + + private async getCompiledPatterns(): Promise { + const now = Date.now(); + if (this.cachedPatterns && now - this.lastFetchedMemoryTime < 60000) { + return this.cachedPatterns; + } + + // Try Redis + try { + const redisPatterns = + await this.redisService.get>('pii:patterns'); + if (redisPatterns) { + this.cachedPatterns = this.compilePatterns(redisPatterns); + this.lastFetchedMemoryTime = now; + return this.cachedPatterns; + } + } catch (e) { + // Log / fallback silently + } + + // Try AI Service + const aiServiceUrl = + this.configService.get('AI_SERVICE_URL') || + 'http://localhost:8000'; + try { + const response = await axios.get(`${aiServiceUrl}/api/v1/pii/patterns`, { + timeout: 3000, + }); + if (response.data && typeof response.data === 'object') { + const fetched = response.data as Record; + this.cachedPatterns = this.compilePatterns(fetched); + this.lastFetchedMemoryTime = now; + try { + await this.redisService.set('pii:patterns', fetched, 3600); + } catch (err) { + // Redis set fail is non-fatal + } + return this.cachedPatterns; + } + } catch (err) { + // AI Service offline or slow + } + + // Fallback to defaults + this.cachedPatterns = this.compilePatterns(DEFAULT_PATTERNS); + // Set memory refresh time slightly shorter in failure mode so we retry in 10s + this.lastFetchedMemoryTime = now - 50000; + return this.cachedPatterns; + } + + private compilePatterns(raw: Record): CompiledPattern[] { + const compiled: CompiledPattern[] = []; + for (const [key, patterns] of Object.entries(raw)) { + const label = MAP_KEY_TO_TOKEN[key] || 'PII'; + for (const pattern of patterns) { + try { + compiled.push({ + label, + regex: new RegExp(pattern, 'g'), + }); + } catch (e) { + // Skip invalid patterns + } + } + } + return compiled; + } +} diff --git a/app/backend/src/main.ts b/app/backend/src/main.ts index 9fa25572..08a83e80 100644 --- a/app/backend/src/main.ts +++ b/app/backend/src/main.ts @@ -12,6 +12,8 @@ import { join } from 'node:path'; import compression from 'compression'; import { RequestIdInterceptor } from './common/interceptors/request-id.interceptor'; +import { PiiScrubInterceptor } from './common/interceptors/pii-scrub.interceptor'; +import { RedisService as CustomRedisService } from '../cache/redis.service'; import { buildCorsOptions, createCorsOriginValidator, @@ -19,6 +21,7 @@ import { createRateLimiter, } from './common/security/security.module'; + async function bootstrap() { // Load environment variables const candidates = [ @@ -77,9 +80,14 @@ async function bootstrap() { }), ); + // Register PII Scrubbing Interceptor + const redisService = app.get(CustomRedisService); + app.useGlobalInterceptors(new PiiScrubInterceptor(configService, redisService)); + // Global interceptors app.useGlobalInterceptors(new LoggingInterceptor(logger)); + // Swagger/OpenAPI Documentation const document = SwaggerModule.createDocument(app, buildSwaggerConfig()); SwaggerModule.setup('api/docs', app, document, { diff --git a/app/backend/test/pii-interceptor.spec.ts b/app/backend/test/pii-interceptor.spec.ts new file mode 100644 index 00000000..fd11a185 --- /dev/null +++ b/app/backend/test/pii-interceptor.spec.ts @@ -0,0 +1,259 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { RedisService } from '../cache/redis.service'; +import { PiiScrubInterceptor } from '../src/common/interceptors/pii-scrub.interceptor'; +import { + ExecutionContext, + CallHandler, + UnprocessableEntityException, +} from '@nestjs/common'; +import { of } from 'rxjs'; +import axios from 'axios'; + +jest.mock('axios'); + +describe('PiiScrubInterceptor', () => { + let interceptor: PiiScrubInterceptor; + let redisService: jest.Mocked; + + const mockConfig: Record = { + PII_SCRUB_MODE: 'redact', + PII_HIGH_RISK_KEYS: + 'email,phone,name,nin,recipientRef,metadata,content,input,output', + AI_SERVICE_URL: 'http://localhost:8000', + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PiiScrubInterceptor, + { + provide: ConfigService, + useValue: { + get: jest.fn((key: string) => mockConfig[key]), + }, + }, + { + provide: RedisService, + useValue: { + get: jest.fn(), + set: jest.fn(), + }, + }, + ], + }).compile(); + + interceptor = module.get(PiiScrubInterceptor); + redisService = module.get(RedisService); + + // Reset mocks + jest.clearAllMocks(); + }); + + const createMockContext = (body: any, user?: any): ExecutionContext => { + const req = { body, user }; + return { + switchToHttp: () => ({ + getRequest: () => req, + getResponse: () => ({}), + }), + getType: () => 'http', + getClass: () => ({}), + getHandler: () => ({}), + } as unknown as ExecutionContext; + }; + + const mockCallHandler: CallHandler = { + handle: () => of('next-called'), + }; + + describe('PII scrubbing - redact mode', () => { + it('should redact common PII patterns in high-risk keys', async () => { + redisService.get.mockResolvedValue(null); + (axios.get as jest.Mock).mockRejectedValue( + new Error('AI service offline'), + ); // Force fallback to default patterns + + const body = { + metadata: { + recipientEmail: 'john.doe@example.com', + phone_number: '+234 803 123 4567', + notes: 'This is fine.', + }, + campaignName: 'Clean Water Project', // not high risk key + otherField: 'jane.smith@example.com', // not high risk key + }; + + const context = createMockContext(body); + await interceptor.intercept(context, mockCallHandler); + + const req = context.switchToHttp().getRequest(); + expect(req.body.metadata.recipientEmail).toBe('[EMAIL_ADDRESS]'); + expect(req.body.metadata.phone_number).toBe('[PHONE_NUMBER]'); + expect(req.body.metadata.notes).toBe('This is fine.'); + expect(req.body.campaignName).toBe('Clean Water Project'); // untouched + expect(req.body.otherField).toBe('jane.smith@example.com'); // untouched since key is not high risk + }); + + it('should handle deep nesting and arrays', async () => { + redisService.get.mockResolvedValue(null); + (axios.get as jest.Mock).mockRejectedValue( + new Error('AI service offline'), + ); + + const body = { + metadata: { + nested: { + email: 'test@example.com', + }, + list: [{ name: 'John Doe' }, { phone: '+2348031234567' }], + }, + }; + + const context = createMockContext(body); + await interceptor.intercept(context, mockCallHandler); + + const req = context.switchToHttp().getRequest(); + expect(req.body.metadata.nested.email).toBe('[EMAIL_ADDRESS]'); + expect(req.body.metadata.list[0].name).toBe('[RECIPIENT_NAME]'); + expect(req.body.metadata.list[1].phone).toBe('[PHONE_NUMBER]'); + }); + }); + + describe('PII scrubbing - reject mode', () => { + beforeEach(() => { + mockConfig.PII_SCRUB_MODE = 'reject'; + }); + + afterEach(() => { + mockConfig.PII_SCRUB_MODE = 'redact'; + }); + + it('should throw 422 Unprocessable Entity Listing all offending paths', async () => { + redisService.get.mockResolvedValue(null); + (axios.get as jest.Mock).mockRejectedValue( + new Error('AI service offline'), + ); + + const body = { + metadata: { + recipientEmail: 'bad@example.com', + phone: '08031234567', + }, + recipientRef: '99999999999', // matches NIN pattern + }; + + const context = createMockContext(body); + await expect( + interceptor.intercept(context, mockCallHandler), + ).rejects.toThrow(UnprocessableEntityException); + + try { + await interceptor.intercept(context, mockCallHandler); + } catch (err: any) { + expect(err.getStatus()).toBe(422); + const response = err.getResponse(); + expect(response.errors).toContain('metadata.recipientEmail'); + expect(response.errors).toContain('metadata.phone'); + expect(response.errors).toContain('recipientRef'); + } + }); + }); + + describe('PII scrubbing - off mode', () => { + beforeEach(() => { + mockConfig.PII_SCRUB_MODE = 'off'; + }); + + afterEach(() => { + mockConfig.PII_SCRUB_MODE = 'redact'; + }); + + it('should not redact or reject when scrub mode is off', async () => { + const body = { + metadata: { + recipientEmail: 'test@example.com', + }, + }; + + const context = createMockContext(body); + await interceptor.intercept(context, mockCallHandler); + + const req = context.switchToHttp().getRequest(); + expect(req.body.metadata.recipientEmail).toBe('test@example.com'); + }); + }); + + describe('Allowlist / JWT subject bypass', () => { + it('should bypass scrubbing for recipientRef if it matches request user ID/subject', async () => { + redisService.get.mockResolvedValue(null); + (axios.get as jest.Mock).mockRejectedValue( + new Error('AI service offline'), + ); + + const body = { + recipientRef: 'user-jwt-subject-123', + metadata: { + recipientEmail: 'john@example.com', // still scrubbed + }, + }; + + // Mock user is authenticated with sub = 'user-jwt-subject-123' + const context = createMockContext(body, { sub: 'user-jwt-subject-123' }); + await interceptor.intercept(context, mockCallHandler); + + const req = context.switchToHttp().getRequest(); + expect(req.body.recipientRef).toBe('user-jwt-subject-123'); // bypassed PII scrub + expect(req.body.metadata.recipientEmail).toBe('[EMAIL_ADDRESS]'); // still scrubbed + }); + }); + + describe('Patterns source caching and refresh', () => { + it('should fetch from Redis if available', async () => { + const mockPatterns = { + email: ['[a-z]+@[a-z]+\\.com'], + }; + redisService.get.mockResolvedValue(mockPatterns); + + const body = { + email: 'hello@world.com', + }; + + const context = createMockContext(body); + await interceptor.intercept(context, mockCallHandler); + + const req = context.switchToHttp().getRequest(); + expect(redisService.get).toHaveBeenCalledWith('pii:patterns'); + expect(axios.get).not.toHaveBeenCalled(); + expect(req.body.email).toBe('[EMAIL_ADDRESS]'); + }); + + it('should fetch from AI Service and cache in Redis on Redis cache miss', async () => { + redisService.get.mockResolvedValue(null); + const mockPatterns = { + email: ['[a-z]+@[a-z]+\\.com'], + }; + (axios.get as jest.Mock).mockResolvedValue({ data: mockPatterns }); + + const body = { + email: 'hello@world.com', + }; + + const context = createMockContext(body); + await interceptor.intercept(context, mockCallHandler); + + const req = context.switchToHttp().getRequest(); + expect(redisService.get).toHaveBeenCalledWith('pii:patterns'); + expect(axios.get).toHaveBeenCalledWith( + 'http://localhost:8000/api/v1/pii/patterns', + { timeout: 3000 }, + ); + expect(redisService.set).toHaveBeenCalledWith( + 'pii:patterns', + mockPatterns, + 3600, + ); + expect(req.body.email).toBe('[EMAIL_ADDRESS]'); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fdd7d856..89a56385 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -136,6 +136,9 @@ importers: class-validator: specifier: ^0.14.3 version: 0.14.4 + compression: + specifier: 1.7.5 + version: 1.7.5 dotenv: specifier: ^17.2.3 version: 17.4.2 @@ -185,6 +188,9 @@ importers: '@nestjs/testing': specifier: ^11.0.1 version: 11.1.17(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)) + '@types/compression': + specifier: 1.7.5 + version: 1.7.5 '@types/express': specifier: ^5.0.0 version: 5.0.6 @@ -362,7 +368,7 @@ importers: version: 9.39.4(jiti@2.6.1) eslint-config-next: specifier: ^16.2.1 - version: 16.2.4(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + version: 16.2.4(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) jest: specifier: ^30.4.2 version: 30.4.2(@types/node@20.19.37)(ts-node@10.9.2(@swc/core@1.15.30(@swc/helpers@0.5.15))(@types/node@20.19.37)(typescript@5.9.3)) @@ -3242,6 +3248,9 @@ packages: '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + '@types/compression@1.7.5': + resolution: {integrity: sha512-AAQvK5pxMpaT+nDvhHrsBhLSYG5yQdtkaJE1WYieSNY2mVFKAgmU4ks65rkZD5oqnGCFLyQpUr1CqI4DmUMyDg==} + '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} @@ -4370,8 +4379,8 @@ packages: resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} engines: {node: '>= 0.6'} - compression@1.8.1: - resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==} + compression@1.7.5: + resolution: {integrity: sha512-bQJ0YRck5ak3LgtnpKkiabX5pNF7tMUh1BSy2ZBOTh0Dim0BUu6aPPwByIns6/A5Prh8PufSPerMDUklpzes2Q==} engines: {node: '>= 0.8.0'} concat-map@0.0.1: @@ -6973,8 +6982,8 @@ packages: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} - on-headers@1.1.0: - resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==} + on-headers@1.0.2: + resolution: {integrity: sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==} engines: {node: '>= 0.8'} once@1.4.0: @@ -9792,7 +9801,7 @@ snapshots: bplist-parser: 0.3.2 chalk: 4.1.2 ci-info: 3.9.0 - compression: 1.8.1 + compression: 1.7.5 connect: 3.7.0 debug: 4.4.3(supports-color@10.2.2) env-editor: 0.4.2 @@ -12192,6 +12201,10 @@ snapshots: '@types/connect': 3.4.38 '@types/node': 25.9.1 + '@types/compression@1.7.5': + dependencies: + '@types/express': 5.0.6 + '@types/connect@3.4.38': dependencies: '@types/node': 25.9.1 @@ -13661,13 +13674,13 @@ snapshots: dependencies: mime-db: 1.54.0 - compression@1.8.1: + compression@1.7.5: dependencies: bytes: 3.1.2 compressible: 2.0.18 debug: 2.6.9 negotiator: 0.6.4 - on-headers: 1.1.0 + on-headers: 1.0.2 safe-buffer: 5.2.1 vary: 1.1.2 transitivePeerDependencies: @@ -14114,13 +14127,13 @@ snapshots: - supports-color - typescript - eslint-config-next@16.2.4(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): + eslint-config-next@16.2.4(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): dependencies: '@next/eslint-plugin-next': 16.2.4 eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react-hooks: 7.1.1(eslint@9.39.4(jiti@2.6.1)) @@ -14210,33 +14223,6 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): - dependencies: - '@rtsao/scc': 1.1.0 - array-includes: 3.1.9 - array.prototype.findlastindex: 1.2.6 - array.prototype.flat: 1.3.3 - array.prototype.flatmap: 1.3.3 - debug: 3.2.7 - doctrine: 2.1.0 - eslint: 9.39.4(jiti@2.6.1) - eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) - hasown: 2.0.2 - is-core-module: 2.16.1 - is-glob: 4.0.3 - minimatch: 3.1.5 - object.fromentries: 2.0.8 - object.groupby: 1.0.3 - object.values: 1.2.1 - semver: 6.3.1 - string.prototype.trimend: 1.0.9 - tsconfig-paths: 3.15.0 - transitivePeerDependencies: - - eslint-import-resolver-typescript - - eslint-import-resolver-webpack - - supports-color - eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.4(jiti@2.6.1)): dependencies: aria-query: 5.3.2 @@ -17178,7 +17164,7 @@ snapshots: dependencies: ee-first: 1.1.1 - on-headers@1.1.0: {} + on-headers@1.0.2: {} once@1.4.0: dependencies: