diff --git a/app/backend/src/ai-verification.dto.ts b/app/backend/src/ai-verification.dto.ts new file mode 100644 index 00000000..fc151d1b --- /dev/null +++ b/app/backend/src/ai-verification.dto.ts @@ -0,0 +1,28 @@ +import { + IsString, + IsNotEmpty, + IsUUID, + IsEnum, + IsObject, +} from 'class-validator'; + +export enum VerificationStatus { + VERIFIED = 'verified', + REJECTED = 'rejected', + NEEDS_REVIEW = 'needs_review', +} + +export class AiVerificationPayloadDto { + @IsUUID() + eventId: string; + + @IsString() + @IsNotEmpty() + sessionId: string; + + @IsEnum(VerificationStatus) + status: VerificationStatus; + + @IsObject() + details: Record; +} diff --git a/app/backend/src/app.module.ts b/app/backend/src/app.module.ts index 2fee3e5b..2aca195e 100644 --- a/app/backend/src/app.module.ts +++ b/app/backend/src/app.module.ts @@ -47,6 +47,7 @@ import { SandboxModule } from './sandbox/sandbox.module'; import { CacheModule } from './common/cache/cache.module'; import { CacheResponseInterceptor } from './common/interceptors/cache-response.interceptor'; +import { WebhooksModule } from './webhooks/webhooks.module'; @Module({ imports: [ ConfigModule.forRoot({ @@ -116,6 +117,7 @@ import { CacheResponseInterceptor } from './common/interceptors/cache-response.i EntityLinkingModule, DeploymentMetadataModule, SandboxModule, + WebhooksModule, RedisModule.forRootAsync({ imports: [ConfigModule], useFactory: (configService: ConfigService) => ({ diff --git a/app/backend/src/audit/ai-verification.dto.ts b/app/backend/src/audit/ai-verification.dto.ts new file mode 100644 index 00000000..014fa4e3 --- /dev/null +++ b/app/backend/src/audit/ai-verification.dto.ts @@ -0,0 +1,31 @@ +import { + IsString, + IsNotEmpty, + IsEnum, + IsObject, + IsUUID, +} from 'class-validator'; + +export enum VerificationStatus { + COMPLETED = 'completed', + FAILED = 'failed', +} + +export class AiVerificationPayloadDto { + @IsUUID('4') + idempotencyKey: string; + + @IsString() + @IsNotEmpty() + sessionId: string; + + @IsString() + @IsNotEmpty() + stepId: string; + + @IsEnum(VerificationStatus) + status: VerificationStatus; + + @IsObject() + output: Record; +} diff --git a/app/backend/src/audit/audit.controller.spec.ts b/app/backend/src/audit/audit.controller.spec.ts index b7bc0548..206fdaed 100644 --- a/app/backend/src/audit/audit.controller.spec.ts +++ b/app/backend/src/audit/audit.controller.spec.ts @@ -1,10 +1,12 @@ import { Test, TestingModule } from '@nestjs/testing'; import { AuditController } from './audit.controller'; import { AuditService } from './audit.service'; +import { MetricsService } from '../metrics/metrics.service'; describe('AuditController', () => { let controller: AuditController; let service: AuditService; + let metricsService: MetricsService; const mockExportResult = { data: [ @@ -31,10 +33,18 @@ describe('AuditController', () => { buildCsv: jest.fn().mockReturnValue('id,actorHash,...\nlog-1,...'), }; + const mockMetricsService = { + getMetrics: jest.fn().mockResolvedValue('# HELP ...'), + }; + beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [AuditController], providers: [ + { + provide: MetricsService, + useValue: mockMetricsService, + }, { provide: AuditService, useValue: mockAuditService, @@ -44,6 +54,7 @@ describe('AuditController', () => { controller = module.get(AuditController); service = module.get(AuditService); + metricsService = module.get(MetricsService); }); it('should be defined', () => { @@ -117,4 +128,19 @@ describe('AuditController', () => { }); }); }); + + describe('getMetrics', () => { + it('should call metricsService.getMetrics and set headers', async () => { + const res = { + set: jest.fn(), + send: jest.fn(), + } as any; + + await controller.getMetrics(res); + + expect(metricsService.getMetrics).toHaveBeenCalled(); + expect(res.set).toHaveBeenCalledWith('Content-Type', 'text/plain'); + expect(res.send).toHaveBeenCalledWith('# HELP ...'); + }); + }); }); diff --git a/app/backend/src/audit/audit.controller.ts b/app/backend/src/audit/audit.controller.ts index 94126efe..27b431fd 100644 --- a/app/backend/src/audit/audit.controller.ts +++ b/app/backend/src/audit/audit.controller.ts @@ -9,12 +9,16 @@ import { ApiUnauthorizedResponse, ApiBearerAuth, } from '@nestjs/swagger'; +import { MetricsService } from '../metrics/metrics.service'; @ApiTags('Audit') @ApiBearerAuth('JWT-auth') @Controller('audit') export class AuditController { - constructor(private readonly auditService: AuditService) {} + constructor( + private readonly auditService: AuditService, + private metricsService: MetricsService, + ) {} @Get() @Version('1') @@ -130,4 +134,13 @@ export class AuditController { return result; } + + @Get('metrics') + @ApiOperation({ summary: 'Get service metrics' }) + @ApiOkResponse({ description: 'Prometheus metrics exported successfully.' }) + async getMetrics(@Res() res: Response) { + res.set('Content-Type', 'text/plain'); + const metrics = await this.metricsService.getMetrics(); + res.send(metrics); + } } diff --git a/app/backend/src/audit/audit.module.ts b/app/backend/src/audit/audit.module.ts index 8e64cdd1..f36c64c3 100644 --- a/app/backend/src/audit/audit.module.ts +++ b/app/backend/src/audit/audit.module.ts @@ -1,9 +1,11 @@ import { Module, Global } from '@nestjs/common'; import { AuditService } from './audit.service'; import { AuditController } from './audit.controller'; +import { MetricsModule } from '../metrics/metrics.module'; @Global() @Module({ + imports: [MetricsModule], providers: [AuditService], controllers: [AuditController], exports: [AuditService], diff --git a/app/backend/src/audit/audit.service.ts b/app/backend/src/audit/audit.service.ts index dad30538..8ab36ec5 100644 --- a/app/backend/src/audit/audit.service.ts +++ b/app/backend/src/audit/audit.service.ts @@ -4,6 +4,7 @@ import { Type } from 'class-transformer'; import { IsInt, IsOptional, Max, Min } from 'class-validator'; import { PrismaService } from '../prisma/prisma.service'; +import { MetricsService } from '../metrics/metrics.service'; import { Prisma } from '@prisma/client'; export interface AuditLogParams { @@ -65,22 +66,40 @@ export interface ExportAuditResult { @Injectable() export class AuditService { - constructor(private prisma: PrismaService) {} + constructor( + private prisma: PrismaService, + private metrics: MetricsService, + ) {} anonymize(value: string): string { return createHash('sha256').update(value).digest('hex').slice(0, 16); } async record(params: AuditLogParams) { - return this.prisma.auditLog.create({ - data: { - actorId: params.actorId, - entity: params.entity, - entityId: params.entityId, - action: params.action, - metadata: (params.metadata as Prisma.InputJsonValue) ?? {}, - }, + const end = this.metrics.dbQueryDuration.startTimer({ + operation: 'create', + entity: 'AuditLog', }); + try { + const result = await this.prisma.auditLog.create({ + data: { + actorId: params.actorId, + entity: params.entity, + entityId: params.entityId, + action: params.action, + metadata: (params.metadata as Prisma.InputJsonValue) ?? {}, + }, + }); + end(); + return result; + } catch (error) { + this.metrics.dbErrorsTotal.inc({ + operation: 'create', + entity: 'AuditLog', + }); + end(); + throw error; + } } async findLogs(query: AuditQuery) { @@ -101,17 +120,30 @@ export class AuditService { if (query.endTime) where.timestamp.lte = new Date(query.endTime); } - const [rows, total] = await this.prisma.$transaction([ - this.prisma.auditLog.findMany({ - where, - orderBy: { timestamp: 'desc' }, - skip, - take: limit, - }), - this.prisma.auditLog.count({ where }), - ]); - - return { data: rows, total, page, limit }; + const end = this.metrics.dbQueryDuration.startTimer({ + operation: 'findMany', + entity: 'AuditLog', + }); + try { + const [rows, total] = await this.prisma.$transaction([ + this.prisma.auditLog.findMany({ + where, + orderBy: { timestamp: 'desc' }, + skip, + take: limit, + }), + this.prisma.auditLog.count({ where }), + ]); + end(); + return { data: rows, total, page, limit }; + } catch (error) { + this.metrics.dbErrorsTotal.inc({ + operation: 'findMany', + entity: 'AuditLog', + }); + end(); + throw error; + } } async exportLogs(query: ExportAuditQuery): Promise { @@ -137,27 +169,41 @@ export class AuditService { if (query.to) where.timestamp.lte = new Date(query.to); } - const [rows, total] = await this.prisma.$transaction([ - this.prisma.auditLog.findMany({ - where, - orderBy: { timestamp: 'desc' }, - skip, - take: limit, - }), - this.prisma.auditLog.count({ where }), - ]); - - const data: AnonymizedAuditLog[] = rows.map(row => ({ - id: row.id, - actorHash: this.anonymize(row.actorId), - entity: row.entity, - entityHash: this.anonymize(row.entityId), - action: row.action, - timestamp: row.timestamp, - metadata: row.metadata, - })); - - return { data, total, page, limit }; + const end = this.metrics.dbQueryDuration.startTimer({ + operation: 'export', + entity: 'AuditLog', + }); + try { + const [rows, total] = await this.prisma.$transaction([ + this.prisma.auditLog.findMany({ + where, + orderBy: { timestamp: 'desc' }, + skip, + take: limit, + }), + this.prisma.auditLog.count({ where }), + ]); + end(); + + const data: AnonymizedAuditLog[] = rows.map(row => ({ + id: row.id, + actorHash: this.anonymize(row.actorId), + entity: row.entity, + entityHash: this.anonymize(row.entityId), + action: row.action, + timestamp: row.timestamp, + metadata: row.metadata, + })); + + return { data, total, page, limit }; + } catch (error) { + this.metrics.dbErrorsTotal.inc({ + operation: 'export', + entity: 'AuditLog', + }); + end(); + throw error; + } } buildCsv(rows: AnonymizedAuditLog[]): string { diff --git a/app/backend/src/audit/config.ts b/app/backend/src/audit/config.ts new file mode 100644 index 00000000..72574eab --- /dev/null +++ b/app/backend/src/audit/config.ts @@ -0,0 +1,5 @@ +import { registerAs } from '@nestjs/config'; + +export default registerAs('app', () => ({ + aiWebhookSecret: process.env.AI_WEBHOOK_SECRET, +})); diff --git a/app/backend/src/audit/hmac.guard.spec.ts b/app/backend/src/audit/hmac.guard.spec.ts new file mode 100644 index 00000000..e9b40a29 --- /dev/null +++ b/app/backend/src/audit/hmac.guard.spec.ts @@ -0,0 +1,79 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ExecutionContext, UnauthorizedException } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import * as crypto from 'crypto'; +import { HmacGuard } from './hmac.guard'; +import appConfig from '../config/config'; + +describe('HmacGuard', () => { + let guard: HmacGuard; + const secret = 'test-secret'; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ + load: [appConfig], + }), + ], + providers: [HmacGuard], + }) + .overrideProvider(appConfig.KEY) + .useValue({ aiWebhookSecret: secret }) + .compile(); + + guard = module.get(HmacGuard); + }); + + const createMockContext = (headers: any, rawBody: Buffer | null) => + ({ + switchToHttp: () => ({ + getRequest: () => ({ + headers, + rawBody, + }), + }), + }) as unknown as ExecutionContext; + + it('should be defined', () => { + expect(guard).toBeDefined(); + }); + + it('should throw UnauthorizedException if signature header is missing', () => { + const context = createMockContext({}, Buffer.from('')); + expect(() => guard.canActivate(context)).toThrow(UnauthorizedException); + }); + + it('should throw an error if rawBody is not available', () => { + const context = createMockContext( + { 'x-signature-hmac-sha256': 'any-sig' }, + null, + ); + expect(() => guard.canActivate(context)).toThrow( + 'Raw body not available. Ensure `rawBody: true` is set in NestFactory.', + ); + }); + + it('should throw UnauthorizedException for an invalid signature', () => { + const body = JSON.stringify({ data: 'test' }); + const context = createMockContext( + { 'x-signature-hmac-sha256': 'invalid-signature' }, + Buffer.from(body), + ); + expect(() => guard.canActivate(context)).toThrow(UnauthorizedException); + }); + + it('should return true for a valid signature', () => { + const body = JSON.stringify({ data: 'test' }); + const hmac = crypto.createHmac('sha256', secret); + const signature = hmac.update(body).digest('hex'); + + const context = createMockContext( + { 'x-signature-hmac-sha256': signature }, + Buffer.from(body), + ); + + const result = guard.canActivate(context); + expect(result).toBe(true); + }); +}); diff --git a/app/backend/src/audit/hmac.guard.ts b/app/backend/src/audit/hmac.guard.ts new file mode 100644 index 00000000..3ee115ca --- /dev/null +++ b/app/backend/src/audit/hmac.guard.ts @@ -0,0 +1,43 @@ +import { + Injectable, + CanActivate, + ExecutionContext, + UnauthorizedException, + Inject, +} from '@nestjs/common'; +import { ConfigType } from '@nestjs/config'; +import { Request } from 'express'; +import * as crypto from 'crypto'; +import appConfig from '../config/config'; + +@Injectable() +export class HmacGuard implements CanActivate { + constructor( + @Inject(appConfig.KEY) + private readonly config: ConfigType, + ) {} + + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + const signature = request.headers['x-signature-hmac-sha256'] as string; + + if (!signature) { + throw new UnauthorizedException('Missing signature header'); + } + + if (!request.rawBody) { + throw new Error( + 'Raw body not available. Ensure `rawBody: true` is set in NestFactory.', + ); + } + + const hmac = crypto.createHmac('sha256', this.config.aiWebhookSecret); + const digest = hmac.update(request.rawBody).digest('hex'); + + if (digest !== signature) { + throw new UnauthorizedException('Invalid signature'); + } + + return true; + } +} diff --git a/app/backend/src/audit/metrics.interceptor.ts b/app/backend/src/audit/metrics.interceptor.ts new file mode 100644 index 00000000..4913ecb6 --- /dev/null +++ b/app/backend/src/audit/metrics.interceptor.ts @@ -0,0 +1,41 @@ +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import { Request, Response } from 'express'; +import { MetricsService } from './metrics.service'; + +@Injectable() +export class MetricsInterceptor implements NestInterceptor { + constructor(private metricsService: MetricsService) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const httpContext = context.switchToHttp(); + const request = httpContext.getRequest(); + const response = httpContext.getResponse(); + const startTime = Date.now(); + + return next.handle().pipe( + tap(() => { + const duration = (Date.now() - startTime) / 1000; + const route = request.route?.path ?? request.path; + const statusCode = response.statusCode; + + this.metricsService.httpRequestDuration.observe( + { method: request.method, route, status_code: statusCode }, + duration, + ); + + this.metricsService.httpRequestsTotal.inc({ + method: request.method, + route, + status_code: statusCode, + }); + }), + ); + } +} diff --git a/app/backend/src/audit/metrics.module.ts b/app/backend/src/audit/metrics.module.ts new file mode 100644 index 00000000..1d385177 --- /dev/null +++ b/app/backend/src/audit/metrics.module.ts @@ -0,0 +1,9 @@ +import { Module, Global } from '@nestjs/common'; +import { MetricsService } from './metrics.service'; + +@Global() +@Module({ + providers: [MetricsService], + exports: [MetricsService], +}) +export class MetricsModule {} diff --git a/app/backend/src/audit/metrics.service.ts b/app/backend/src/audit/metrics.service.ts new file mode 100644 index 00000000..8eed86c4 --- /dev/null +++ b/app/backend/src/audit/metrics.service.ts @@ -0,0 +1,62 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { + Counter, + Histogram, + Registry, + collectDefaultMetrics, +} from 'prom-client'; + +@Injectable() +export class MetricsService implements OnModuleInit { + private readonly registry = new Registry(); + + public httpRequestDuration: Histogram; + public httpRequestsTotal: Counter; + public dbQueryDuration: Histogram; + public dbErrorsTotal: Counter; + + constructor() { + this.registry.setDefaultLabels({ + app: 'soter-backend', + }); + } + + onModuleInit() { + collectDefaultMetrics({ register: this.registry }); + this.registerCustomMetrics(); + } + + private registerCustomMetrics() { + this.httpRequestDuration = new Histogram({ + name: 'http_request_duration_seconds', + help: 'Duration of HTTP requests in seconds', + labelNames: ['method', 'route', 'status_code'], + registers: [this.registry], + }); + + this.httpRequestsTotal = new Counter({ + name: 'http_requests_total', + help: 'Total number of HTTP requests', + labelNames: ['method', 'route', 'status_code'], + registers: [this.registry], + }); + + this.dbQueryDuration = new Histogram({ + name: 'db_query_duration_seconds', + help: 'Duration of database queries in seconds', + labelNames: ['operation', 'entity'], + registers: [this.registry], + }); + + this.dbErrorsTotal = new Counter({ + name: 'db_errors_total', + help: 'Total number of database query errors', + labelNames: ['operation', 'entity'], + registers: [this.registry], + }); + } + + getMetrics() { + return this.registry.metrics(); + } +} diff --git a/app/backend/src/audit/webhook.controller.spec.ts b/app/backend/src/audit/webhook.controller.spec.ts new file mode 100644 index 00000000..aefdb042 --- /dev/null +++ b/app/backend/src/audit/webhook.controller.spec.ts @@ -0,0 +1,69 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { WebhookController } from './webhook.controller'; +import { SessionService } from '../session/session.service'; +import { HmacGuard } from './hmac.guard'; +import { + AiVerificationPayloadDto, + VerificationStatus, +} from './dto/ai-verification.dto'; + +describe('WebhookController', () => { + let controller: WebhookController; + let sessionService: SessionService; + + const mockSessionService = { + submitToStep: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [WebhookController], + providers: [ + { + provide: SessionService, + useValue: mockSessionService, + }, + ], + }) + .overrideGuard(HmacGuard) + .useValue({ canActivate: () => true }) // Mock the guard to always pass + .compile(); + + controller = module.get(WebhookController); + sessionService = module.get(SessionService); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('handleAiVerification', () => { + it('should call sessionService.submitToStep with the correct parameters', async () => { + const payload: AiVerificationPayloadDto = { + idempotencyKey: 'd9e1b233-8083-4a25-8236-c69a997c306a', + sessionId: 'session-123', + stepId: 'step-456', + status: VerificationStatus.COMPLETED, + output: { verificationScore: 0.95 }, + }; + + mockSessionService.submitToStep.mockResolvedValue({ + isIdempotent: false, + }); + + const result = await controller.handleAiVerification(payload); + + expect(sessionService.submitToStep).toHaveBeenCalledWith( + payload.sessionId, + payload.stepId, + { + submissionKey: payload.idempotencyKey, + payload: payload.output, + }, + payload.status, + ); + + expect(result).toEqual({ status: 'received', isIdempotent: false }); + }); + }); +}); diff --git a/app/backend/src/audit/webhook.controller.ts b/app/backend/src/audit/webhook.controller.ts new file mode 100644 index 00000000..c99c7444 --- /dev/null +++ b/app/backend/src/audit/webhook.controller.ts @@ -0,0 +1,54 @@ +import { + Controller, + Post, + Body, + UseGuards, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiOkResponse, + ApiUnauthorizedResponse, + ApiHeader, +} from '@nestjs/swagger'; +import { SessionService } from '../session/session.service'; +import { HmacGuard } from './hmac.guard'; +import { AiVerificationPayloadDto } from './dto/ai-verification.dto'; + +@ApiTags('Webhooks') +@Controller('webhooks') +export class WebhookController { + constructor(private readonly sessionService: SessionService) {} + + @Post('ai-verification') + @UseGuards(HmacGuard) + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Receive AI verification results', + description: + 'A secure endpoint for AI services to post back verification results. Requires HMAC signature.', + }) + @ApiHeader({ + name: 'X-Signature-Hmac-Sha256', + description: 'HMAC-SHA256 signature of the raw request body.', + required: true, + }) + @ApiOkResponse({ + description: 'Webhook received and processed successfully.', + }) + @ApiUnauthorizedResponse({ + description: 'Invalid or missing HMAC signature.', + }) + async handleAiVerification(@Body() payload: AiVerificationPayloadDto) { + const result = await this.sessionService.submitToStep( + payload.sessionId, + payload.stepId, + { submissionKey: payload.idempotencyKey, payload: payload.output }, + payload.status, + ); + + return { status: 'received', isIdempotent: result.isIdempotent }; + } +} diff --git a/app/backend/src/audit/webhook.module.ts b/app/backend/src/audit/webhook.module.ts new file mode 100644 index 00000000..34ab8736 --- /dev/null +++ b/app/backend/src/audit/webhook.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { SessionModule } from '../session/session.module'; +import { WebhookController } from './webhook.controller'; + +@Module({ + imports: [SessionModule], + controllers: [WebhookController], +}) +export class WebhookModule {} diff --git a/app/backend/src/common/interceptors/cache-response.interceptor.spec.ts b/app/backend/src/common/interceptors/cache-response.interceptor.spec.ts index 1ec2d310..89bb494c 100644 --- a/app/backend/src/common/interceptors/cache-response.interceptor.spec.ts +++ b/app/backend/src/common/interceptors/cache-response.interceptor.spec.ts @@ -64,19 +64,19 @@ describe('CacheResponseInterceptor', () => { }; }); - it('should skip caching when no metadata is present', (done) => { + it('should skip caching when no metadata is present', done => { jest.spyOn(reflector, 'get').mockReturnValue(undefined); interceptor .intercept(mockExecutionContext, mockCallHandler) - .subscribe((result) => { + .subscribe(result => { expect(result).toEqual({ data: 'test' }); expect(redisService.get).not.toHaveBeenCalled(); done(); }); }); - it('should return cached response on cache hit', (done) => { + it('should return cached response on cache hit', done => { const cacheOptions = { ttl: 300 }; const cachedData = { data: 'cached' }; @@ -85,7 +85,7 @@ describe('CacheResponseInterceptor', () => { interceptor .intercept(mockExecutionContext, mockCallHandler) - .subscribe((result) => { + .subscribe(result => { expect(result).toEqual(cachedData); expect(redisService.get).toHaveBeenCalled(); expect(mockCallHandler.handle).not.toHaveBeenCalled(); @@ -93,7 +93,7 @@ describe('CacheResponseInterceptor', () => { }); }); - it('should execute handler and cache result on cache miss', (done) => { + it('should execute handler and cache result on cache miss', done => { const cacheOptions = { ttl: 300 }; const handlerData = { data: 'fresh' }; @@ -104,11 +104,11 @@ describe('CacheResponseInterceptor', () => { interceptor .intercept(mockExecutionContext, mockCallHandler) - .subscribe((result) => { + .subscribe(result => { expect(result).toEqual(handlerData); expect(redisService.get).toHaveBeenCalled(); expect(mockCallHandler.handle).toHaveBeenCalled(); - + // Set is called asynchronously, give it a moment setTimeout(() => { expect(redisService.set).toHaveBeenCalledWith( @@ -121,7 +121,7 @@ describe('CacheResponseInterceptor', () => { }); }); - it('should use custom key generator when provided', (done) => { + it('should use custom key generator when provided', done => { const customKey = 'custom:key:123'; const cacheOptions = { ttl: 300, @@ -132,21 +132,19 @@ describe('CacheResponseInterceptor', () => { jest.spyOn(redisService, 'get').mockResolvedValue(null); jest.spyOn(redisService, 'set').mockResolvedValue(undefined); - interceptor - .intercept(mockExecutionContext, mockCallHandler) - .subscribe({ - next: () => { - expect(cacheOptions.keyGenerator).toHaveBeenCalled(); - expect(redisService.get).toHaveBeenCalledWith( - expect.stringContaining(customKey), - ); - done(); - }, - error: (err) => done(err), - }); + interceptor.intercept(mockExecutionContext, mockCallHandler).subscribe({ + next: () => { + expect(cacheOptions.keyGenerator).toHaveBeenCalled(); + expect(redisService.get).toHaveBeenCalledWith( + expect.stringContaining(customKey), + ); + done(); + }, + error: err => done(err), + }); }); - it('should include query params in cache key', (done) => { + it('should include query params in cache key', done => { const cacheOptions = { ttl: 300 }; mockExecutionContext.switchToHttp = jest.fn().mockReturnValue({ getRequest: jest.fn().mockReturnValue({ @@ -162,18 +160,16 @@ describe('CacheResponseInterceptor', () => { jest.spyOn(redisService, 'get').mockResolvedValue(null); jest.spyOn(redisService, 'set').mockResolvedValue(undefined); - interceptor - .intercept(mockExecutionContext, mockCallHandler) - .subscribe({ - next: () => { - expect(redisService.get).toHaveBeenCalled(); - // Key should be different due to query params - const cacheKey = (redisService.get as jest.Mock).mock.calls[0][0]; - expect(cacheKey).toBeTruthy(); - done(); - }, - error: (err) => done(err), - }); + interceptor.intercept(mockExecutionContext, mockCallHandler).subscribe({ + next: () => { + expect(redisService.get).toHaveBeenCalled(); + // Key should be different due to query params + const cacheKey = (redisService.get as jest.Mock).mock.calls[0][0]; + expect(cacheKey).toBeTruthy(); + done(); + }, + error: err => done(err), + }); }); }); }); diff --git a/app/backend/src/common/interceptors/cache-response.interceptor.ts b/app/backend/src/common/interceptors/cache-response.interceptor.ts index 430706b8..23e68e36 100644 --- a/app/backend/src/common/interceptors/cache-response.interceptor.ts +++ b/app/backend/src/common/interceptors/cache-response.interceptor.ts @@ -41,7 +41,7 @@ export class CacheResponseInterceptor implements NestInterceptor { // Try to retrieve from cache return from(this.redisService.get(cacheKey)).pipe( - switchMap((cachedResponse) => { + switchMap(cachedResponse => { if (cachedResponse !== null) { this.logger.debug(`Cache HIT: ${cacheKey}`); return of(cachedResponse); @@ -51,7 +51,7 @@ export class CacheResponseInterceptor implements NestInterceptor { // Cache miss: execute handler and cache the result return next.handle().pipe( - tap((response) => { + tap(response => { // Fire-and-forget cache set (don't await in tap) void this.redisService .set(cacheKey, response, options.ttl) @@ -60,7 +60,7 @@ export class CacheResponseInterceptor implements NestInterceptor { `Cached response for key: ${cacheKey} (TTL: ${options.ttl}s)`, ); }) - .catch((err) => { + .catch(err => { this.logger.warn( `Failed to cache response for key ${cacheKey}: ${String(err)}`, ); @@ -119,13 +119,13 @@ export class CacheResponseInterceptor implements NestInterceptor { } if (Array.isArray(obj)) { - return obj.map((item) => this.sortObject(item)); + return obj.map(item => this.sortObject(item)); } const sorted: any = {}; Object.keys(obj) .sort() - .forEach((key) => { + .forEach(key => { sorted[key] = this.sortObject(obj[key]); }); diff --git a/app/backend/src/hmac-auth.guard.ts b/app/backend/src/hmac-auth.guard.ts new file mode 100644 index 00000000..2c13849f --- /dev/null +++ b/app/backend/src/hmac-auth.guard.ts @@ -0,0 +1,58 @@ +import { + Injectable, + CanActivate, + ExecutionContext, + Logger, + UnauthorizedException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Request } from 'express'; +import * as crypto from 'crypto'; + +@Injectable() +export class HmacAuthGuard implements CanActivate { + private readonly logger = new Logger(HmacAuthGuard.name); + private readonly secret: Buffer; + + constructor(private configService: ConfigService) { + const secretKey = this.configService.get('AI_WEBHOOK_SECRET'); + if (!secretKey) { + throw new Error('AI_WEBHOOK_SECRET is not configured.'); + } + this.secret = Buffer.from(secretKey, 'utf8'); + } + + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + const signature = request.header('X-Signature-256'); + + if (!signature) { + this.logger.warn('Missing X-Signature-256 header'); + throw new UnauthorizedException('Missing signature'); + } + + if (!request.rawBody) { + this.logger.error( + 'rawBody is not available on the request. Ensure rawBody middleware is used.', + ); + throw new UnauthorizedException('Invalid request configuration'); + } + + const expectedSignature = `sha256=${crypto + .createHmac('sha256', this.secret) + .update(request.rawBody) + .digest('hex')}`; + + const isValid = crypto.timingSafeEqual( + Buffer.from(signature), + Buffer.from(expectedSignature), + ); + + if (!isValid) { + this.logger.warn('Invalid HMAC signature'); + throw new UnauthorizedException('Invalid signature'); + } + + return true; + } +} diff --git a/app/backend/src/main.ts b/app/backend/src/main.ts index 41ad6b0a..d6335211 100644 --- a/app/backend/src/main.ts +++ b/app/backend/src/main.ts @@ -41,6 +41,17 @@ async function bootstrap() { // Enable shutdown hooks app.enableShutdownHooks(); + // Add raw body parsing for webhook signature verification + app.use( + json({ + limit: '10mb', + verify: (req: any, res, buf) => { + req.rawBody = buf; + }, + }), + ); + app.use(urlencoded({ extended: true, limit: '10mb' })); + const configService = app.get(ConfigService); // Security middleware (order matters) diff --git a/app/backend/src/webhooks.controller.spec.ts b/app/backend/src/webhooks.controller.spec.ts new file mode 100644 index 00000000..fc19fefb --- /dev/null +++ b/app/backend/src/webhooks.controller.spec.ts @@ -0,0 +1,62 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { WebhooksController } from './webhooks.controller'; +import { WebhooksService } from './webhooks.service'; +import { HmacAuthGuard } from './guards/hmac-auth.guard'; +import { + AiVerificationPayloadDto, + VerificationStatus, +} from './dto/ai-verification.dto'; +import { ConfigService } from '@nestjs/config'; + +describe('WebhooksController', () => { + let controller: WebhooksController; + let service: WebhooksService; + + const mockWebhooksService = { + processAiVerification: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [WebhooksController], + providers: [ + { + provide: WebhooksService, + useValue: mockWebhooksService, + }, + // Mock ConfigService for the guard + { + provide: ConfigService, + useValue: { + get: jest.fn().mockReturnValue('test-secret'), + }, + }, + ], + }) + .overrideGuard(HmacAuthGuard) + .useValue({ canActivate: () => true }) // Mock the guard to always pass + .compile(); + + controller = module.get(WebhooksController); + service = module.get(WebhooksService); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('handleAiVerification', () => { + it('should call the service with the correct payload', async () => { + const payload: AiVerificationPayloadDto = { + eventId: 'evt_123', + sessionId: 'sess_456', + status: VerificationStatus.VERIFIED, + details: { score: 0.9 }, + }; + + await controller.handleAiVerification(payload); + + expect(service.processAiVerification).toHaveBeenCalledWith(payload); + }); + }); +}); diff --git a/app/backend/src/webhooks.controller.ts b/app/backend/src/webhooks.controller.ts new file mode 100644 index 00000000..a498b821 --- /dev/null +++ b/app/backend/src/webhooks.controller.ts @@ -0,0 +1,34 @@ +import { + Controller, + Post, + Body, + UseGuards, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { WebhooksService } from './webhooks.service'; +import { HmacAuthGuard } from './guards/hmac-auth.guard'; +import { AiVerificationPayloadDto } from './dto/ai-verification.dto'; +import { ApiTags, ApiOperation, ApiResponse, ApiHeader } from '@nestjs/swagger'; + +@ApiTags('Webhooks') +@Controller('webhooks') +export class WebhooksController { + constructor(private readonly webhooksService: WebhooksService) {} + + @Post('ai-verification') + @UseGuards(HmacAuthGuard) // Correctly typed guard + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Receive AI verification results' }) + @ApiHeader({ + name: 'X-Signature-256', + description: 'HMAC SHA256 signature of the request body.', + required: true, + }) + @ApiResponse({ status: 200, description: 'Webhook processed successfully.' }) + @ApiResponse({ status: 401, description: 'Invalid signature.' }) + @ApiResponse({ status: 409, description: 'Event already processed.' }) + async handleAiVerification(@Body() payload: AiVerificationPayloadDto) { + return this.webhooksService.processAiVerification(payload); + } +} diff --git a/app/backend/src/webhooks.module.ts b/app/backend/src/webhooks.module.ts new file mode 100644 index 00000000..5a6edd80 --- /dev/null +++ b/app/backend/src/webhooks.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { WebhooksController } from './webhooks.controller'; +import { WebhooksService } from './webhooks.service'; +import { SessionModule } from '../session/session.module'; +import { PrismaModule } from '../prisma/prisma.module'; + +@Module({ + imports: [SessionModule, PrismaModule], + controllers: [WebhooksController], + providers: [WebhooksService], +}) +export class WebhooksModule {} diff --git a/app/backend/src/webhooks.service.spec.ts b/app/backend/src/webhooks.service.spec.ts new file mode 100644 index 00000000..9034db31 --- /dev/null +++ b/app/backend/src/webhooks.service.spec.ts @@ -0,0 +1,119 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { WebhooksService } from './webhooks.service'; +import { SessionService } from '../session/session.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { + AiVerificationPayloadDto, + VerificationStatus, +} from './dto/ai-verification.dto'; +import { ConflictException, NotFoundException } from '@nestjs/common'; +import { SessionStatus, StepStatus } from '@prisma/client'; + +describe('WebhooksService', () => { + let service: WebhooksService; + let prisma: PrismaService; + let sessionService: SessionService; + + const mockPrisma = { + webhookEvent: { + findUnique: jest.fn(), + create: jest.fn(), + }, + $transaction: jest + .fn() + .mockImplementation(callback => callback(mockPrisma)), + }; + + const mockSessionService = { + getSession: jest.fn(), + submitToStep: jest.fn(), + }; + + const payload: AiVerificationPayloadDto = { + eventId: 'evt_123', + sessionId: 'sess_456', + status: VerificationStatus.VERIFIED, + details: { score: 0.9 }, + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + WebhooksService, + { provide: PrismaService, useValue: mockPrisma }, + { provide: SessionService, useValue: mockSessionService }, + ], + }).compile(); + + service = module.get(WebhooksService); + prisma = module.get(PrismaService); + sessionService = module.get(SessionService); + + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('processAiVerification', () => { + it('should throw ConflictException if event is already processed', async () => { + mockPrisma.webhookEvent.findUnique.mockResolvedValue({ id: '1' }); + + await expect(service.processAiVerification(payload)).rejects.toThrow( + ConflictException, + ); + }); + + it('should throw NotFoundException if session is not found or not active', async () => { + mockPrisma.webhookEvent.findUnique.mockResolvedValue(null); + mockSessionService.getSession.mockResolvedValue(null); + + await expect(service.processAiVerification(payload)).rejects.toThrow( + NotFoundException, + ); + }); + + it('should throw NotFoundException if a suitable step is not found', async () => { + mockPrisma.webhookEvent.findUnique.mockResolvedValue(null); + mockSessionService.getSession.mockResolvedValue({ + id: 'sess_456', + status: SessionStatus.pending, + steps: [{ stepName: 'other_step', status: StepStatus.pending }], + }); + + await expect(service.processAiVerification(payload)).rejects.toThrow( + NotFoundException, + ); + }); + + it('should process the webhook successfully', async () => { + const stepId = 'step_789'; + mockPrisma.webhookEvent.findUnique.mockResolvedValue(null); + mockSessionService.getSession.mockResolvedValue({ + id: 'sess_456', + status: SessionStatus.pending, + steps: [ + { + id: stepId, + stepName: 'identity_verification', + status: StepStatus.in_progress, + }, + ], + }); + + const result = await service.processAiVerification(payload); + + expect(prisma.webhookEvent.create).toHaveBeenCalled(); + expect(sessionService.submitToStep).toHaveBeenCalledWith( + payload.sessionId, + stepId, + { + submissionKey: payload.eventId, + payload: { status: payload.status, details: payload.details }, + }, + ); + expect(result).toEqual({ status: 'success', eventId: payload.eventId }); + }); + }); +}); diff --git a/app/backend/src/webhooks.service.ts b/app/backend/src/webhooks.service.ts new file mode 100644 index 00000000..1da28c95 --- /dev/null +++ b/app/backend/src/webhooks.service.ts @@ -0,0 +1,75 @@ +import { + Injectable, + Logger, + ConflictException, + NotFoundException, +} from '@nestjs/common'; +import { SessionService } from '../session/session.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { AiVerificationPayloadDto } from './dto/ai-verification.dto'; +import { Prisma, SessionStatus, StepStatus } from '@prisma/client'; + +@Injectable() +export class WebhooksService { + private readonly logger = new Logger(WebhooksService.name); + + constructor( + private readonly sessionService: SessionService, + private readonly prisma: PrismaService, + ) {} + + async processAiVerification(payload: AiVerificationPayloadDto) { + const { eventId, sessionId, status, details } = payload; + + // 1. Idempotency Check + const existingEvent = await this.prisma.webhookEvent.findUnique({ + where: { eventId }, + }); + + if (existingEvent) { + this.logger.log(`Webhook event ${eventId} already processed. Skipping.`); + throw new ConflictException('Event already processed'); + } + + // 2. Find the relevant session and step + const session = await this.sessionService.getSession(sessionId); + if (!session || session.status !== SessionStatus.pending) { + throw new NotFoundException(`Active session ${sessionId} not found.`); + } + + const verificationStep = session.steps.find( + step => + step.stepName === 'identity_verification' && + step.status === StepStatus.in_progress, + ); + + if (!verificationStep) { + throw new NotFoundException( + `Pending identity_verification step not found for session ${sessionId}.`, + ); + } + + // 3. Record the event and submit to the session step + await this.prisma.$transaction(async tx => { + await tx.webhookEvent.create({ + data: { + eventId, + payload: payload as unknown as Prisma.InputJsonValue, + source: 'ai_service', + }, + }); + + // The submitToStep method in the provided context takes three arguments. + // We will adapt the call to match it. + await this.sessionService.submitToStep(sessionId, verificationStep.id, { + submissionKey: eventId, // Use eventId for idempotency in the session + payload: { status, details }, + }); + }); + + this.logger.log( + `Successfully processed AI verification for event ${eventId}`, + ); + return { status: 'success', eventId }; + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b79aba81..8b00caa9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9214,9 +9214,9 @@ snapshots: '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) '@babel/helpers': 7.29.2 - '@babel/parser': 7.29.7 - '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/parser': 7.29.2 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 '@babel/types': 7.29.7 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 @@ -9376,7 +9376,7 @@ snapshots: '@babel/parser@7.29.2': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@babel/parser@7.29.7': dependencies: @@ -9811,12 +9811,12 @@ snapshots: '@babel/traverse@7.29.0': dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 '@babel/helper-globals': 7.28.0 '@babel/parser': 7.29.2 '@babel/template': 7.28.6 - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -10647,36 +10647,36 @@ snapshots: '@jest/core@29.7.0(ts-node@10.9.2(@swc/core@1.15.30(@swc/helpers@0.5.15))(@types/node@25.9.1)(typescript@5.9.3))': dependencies: - '@jest/console': 29.7.0 - '@jest/reporters': 29.7.0 - '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 25.9.1 + '@jest/console': 30.3.0 + '@jest/pattern': 30.0.1 + '@jest/reporters': 30.3.0 + '@jest/test-result': 30.3.0 + '@jest/transform': 30.3.0 + '@jest/types': 30.3.0 + '@types/node': 22.19.15 ansi-escapes: 4.3.2 chalk: 4.1.2 - ci-info: 3.9.0 - exit: 0.1.2 + ci-info: 4.4.0 + exit-x: 0.2.2 graceful-fs: 4.2.11 - jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@25.9.1)(ts-node@10.9.2(@swc/core@1.15.30(@swc/helpers@0.5.15))(@types/node@25.9.1)(typescript@5.9.3)) - jest-haste-map: 29.7.0 - jest-message-util: 29.7.0 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-resolve-dependencies: 29.7.0 - jest-runner: 29.7.0 - jest-runtime: 29.7.0 - jest-snapshot: 29.7.0 - jest-util: 29.7.0 - jest-validate: 29.7.0 - jest-watcher: 29.7.0 - micromatch: 4.0.8 - pretty-format: 29.7.0 + jest-changed-files: 30.3.0 + jest-config: 30.3.0(@types/node@22.19.15)(ts-node@10.9.2(@swc/core@1.15.30(@swc/helpers@0.5.15))(@types/node@20.19.37)(typescript@5.9.3)) + jest-haste-map: 30.3.0 + jest-message-util: 30.3.0 + jest-regex-util: 30.0.1 + jest-resolve: 30.3.0 + jest-resolve-dependencies: 30.3.0 + jest-runner: 30.3.0 + jest-runtime: 30.3.0 + jest-snapshot: 30.3.0 + jest-util: 30.3.0 + jest-validate: 30.3.0 + jest-watcher: 30.3.0 + pretty-format: 30.3.0 slash: 3.0.0 - strip-ansi: 6.0.1 transitivePeerDependencies: - babel-plugin-macros + - esbuild-register - supports-color - ts-node @@ -12423,24 +12423,24 @@ snapshots: '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.28.0 '@types/babel__generator@7.27.0': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@types/babel__template@7.4.4': dependencies: '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@types/babel__traverse@7.28.0': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@types/body-parser@1.19.6': dependencies: @@ -14420,6 +14420,21 @@ snapshots: transitivePeerDependencies: - supports-color + eslint-import-resolver-typescript@3.10.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@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): + dependencies: + '@nolyfill/is-core-module': 1.0.39 + debug: 4.4.3 + eslint: 9.39.4(jiti@2.6.1) + get-tsconfig: 4.13.6 + is-bun-module: 2.0.0 + stable-hash: 0.0.5 + tinyglobby: 0.2.15 + unrs-resolver: 1.11.1 + optionalDependencies: + eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + transitivePeerDependencies: + - supports-color + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 @@ -14484,6 +14499,33 @@ 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 @@ -15621,7 +15663,7 @@ snapshots: istanbul-lib-instrument@5.2.1: dependencies: '@babel/core': 7.29.0 - '@babel/parser': 7.29.2 + '@babel/parser': 7.29.7 '@istanbuljs/schema': 0.1.3 istanbul-lib-coverage: 3.2.2 semver: 6.3.1 @@ -15765,22 +15807,22 @@ snapshots: - supports-color - ts-node - jest-cli@29.7.0(@types/node@25.9.1)(ts-node@10.9.2(@swc/core@1.15.30(@swc/helpers@0.5.15))(@types/node@25.9.1)(typescript@5.9.3)): + jest-cli@30.3.0(@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)): dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.15.30(@swc/helpers@0.5.15))(@types/node@25.9.1)(typescript@5.9.3)) - '@jest/test-result': 29.7.0 - '@jest/types': 29.6.3 + '@jest/core': 30.3.0(ts-node@10.9.2(@swc/core@1.15.30(@swc/helpers@0.5.15))(@types/node@20.19.37)(typescript@5.9.3)) + '@jest/test-result': 30.3.0 + '@jest/types': 30.3.0 chalk: 4.1.2 - create-jest: 29.7.0(@types/node@25.9.1)(ts-node@10.9.2(@swc/core@1.15.30(@swc/helpers@0.5.15))(@types/node@25.9.1)(typescript@5.9.3)) - exit: 0.1.2 + exit-x: 0.2.2 import-local: 3.2.0 - jest-config: 29.7.0(@types/node@25.9.1)(ts-node@10.9.2(@swc/core@1.15.30(@swc/helpers@0.5.15))(@types/node@25.9.1)(typescript@5.9.3)) - jest-util: 29.7.0 - jest-validate: 29.7.0 + jest-config: 30.3.0(@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)) + jest-util: 30.3.0 + jest-validate: 30.3.0 yargs: 17.7.2 transitivePeerDependencies: - '@types/node' - babel-plugin-macros + - esbuild-register - supports-color - ts-node @@ -16581,22 +16623,10 @@ snapshots: jest@29.7.0(@types/node@25.9.1)(ts-node@10.9.2(@swc/core@1.15.30(@swc/helpers@0.5.15))(@types/node@25.9.1)(typescript@5.9.3)): dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.15.30(@swc/helpers@0.5.15))(@types/node@25.9.1)(typescript@5.9.3)) - '@jest/types': 29.6.3 - import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@25.9.1)(ts-node@10.9.2(@swc/core@1.15.30(@swc/helpers@0.5.15))(@types/node@25.9.1)(typescript@5.9.3)) - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - - jest@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)): - dependencies: - '@jest/core': 30.4.2(ts-node@10.9.2(@swc/core@1.15.30(@swc/helpers@0.5.15))(@types/node@20.19.37)(typescript@5.9.3)) - '@jest/types': 30.4.1 + '@jest/core': 30.3.0(ts-node@10.9.2(@swc/core@1.15.30(@swc/helpers@0.5.15))(@types/node@20.19.37)(typescript@5.9.3)) + '@jest/types': 30.3.0 import-local: 3.2.0 - jest-cli: 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)) + jest-cli: 30.3.0(@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)) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -17133,8 +17163,8 @@ snapshots: dependencies: '@babel/core': 7.29.0 '@babel/generator': 7.29.7 - '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 flow-enums-runtime: 0.0.6 nullthrows: 1.1.1 transitivePeerDependencies: @@ -17164,7 +17194,7 @@ snapshots: dependencies: '@babel/core': 7.29.0 '@babel/generator': 7.29.7 - '@babel/parser': 7.29.7 + '@babel/parser': 7.29.2 '@babel/types': 7.29.7 flow-enums-runtime: 0.0.6 metro: 0.83.5