Skip to content

Commit d2ec1f3

Browse files
committed
Implement all three somzilla issues
Issue #493: Clear private keys from orchestrator API responses - Added ResponseRedactionInterceptor to globally redact sensitive fields (privateKey, encryptedSecret, apiKey, token, etc.) from all API responses - Recursively scans nested objects and arrays for sensitive patterns - Added comprehensive unit tests (10 tests) Issue #495: Add request id propagation across services - Added RequestIdInterceptor to ensure every HTTP request has a unique x-request-id header propagated via AsyncLocalStorage/RequestContextService - Interceptor reads from header or generates UUID, sets response header, and bootstraps into context for downstream services - Added comprehensive unit tests (4 tests) Issue #707: Idempotent settlement on duplicate tradeId - Added Settlement Prisma model with unique tradeId constraint - Created SettlementModule with service and controller for idempotent settlement processing using tradeId as idempotency key - Service handles race conditions (P2002) with cache fallback + direct DB lookup - Added comprehensive unit tests (9 tests) Misc: - Updated Wallet model with sentSettlements/receivedSettlements relations - Registered global interceptors (RequestIdInterceptor, ResponseRedactionInterceptor) and SettlementModule in AppModule
1 parent 8174316 commit d2ec1f3

11 files changed

Lines changed: 1099 additions & 1 deletion

prisma/schema.prisma

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,12 @@ model Wallet {
220220
/// Transactions received by this wallet
221221
receivedTransactions Transaction[] @relation("ReceivedTransactions")
222222
223+
/// Settlements sent from this wallet
224+
sentSettlements Settlement[] @relation("SentSettlements")
225+
226+
/// Settlements received by this wallet
227+
receivedSettlements Settlement[] @relation("ReceivedSettlements")
228+
223229
@@unique([network, publicKey])
224230
@@index([userId, network])
225231
@@index([status, network])
@@ -862,3 +868,40 @@ model Transaction {
862868
@@index([createdAt])
863869
@@index([idempotencyKey])
864870
}
871+
872+
/// Settlement records for idempotent trade settlement.
873+
/// Each settlement is uniquely identified by a client-supplied tradeId.
874+
model Settlement {
875+
id String @id @default(uuid())
876+
877+
/// Client-supplied trade identifier, used for idempotency.
878+
/// Duplicate submissions with the same tradeId return the existing result.
879+
tradeId String @unique
880+
881+
/// Wallet references
882+
senderWalletId String
883+
senderWallet Wallet @relation("SentSettlements", fields: [senderWalletId], references: [id])
884+
885+
receiverWalletId String
886+
receiverWallet Wallet @relation("ReceivedSettlements", fields: [receiverWalletId], references: [id])
887+
888+
/// Settlement amount (stored as string for precision)
889+
amount String
890+
891+
/// Settlement status
892+
status String @default("COMPLETED")
893+
894+
/// Optional metadata for audit and tracking
895+
metadata Json?
896+
897+
/// Operational timestamps
898+
createdAt DateTime @default(now())
899+
updatedAt DateTime @updatedAt
900+
901+
@@index([tradeId])
902+
@@index([senderWalletId])
903+
@@index([receiverWalletId])
904+
@@index([senderWalletId, createdAt])
905+
@@index([receiverWalletId, createdAt])
906+
}
907+

src/app.module.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Module } from '@nestjs/common';
2-
import { APP_GUARD } from '@nestjs/core';
2+
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
33
import { AppController } from './app.controller';
44
import { ConfigModule } from '@nestjs/config';
55
import { EventEmitterModule } from '@nestjs/event-emitter';
@@ -26,6 +26,9 @@ import { TransactionsModule } from './transactions/transactions.module';
2626
import { DevelopersModule } from './developers/developers.module';
2727
import { ProjectsModule } from './projects/projects.module';
2828
import { HealthModule } from './health/health.module';
29+
import { RequestIdInterceptor } from './common/interceptors/request-id.interceptor';
30+
import { ResponseRedactionInterceptor } from './common/interceptors/response-redaction.interceptor';
31+
import { SettlementModule } from './settlement/settlement.module';
2932

3033
@Module({
3134
imports: [
@@ -55,6 +58,7 @@ import { HealthModule } from './health/health.module';
5558
DevelopersModule,
5659
ProjectsModule,
5760
HealthModule,
61+
SettlementModule,
5862
],
5963
controllers: [AppController],
6064
providers: [
@@ -68,6 +72,15 @@ import { HealthModule } from './health/health.module';
6872
provide: APP_GUARD,
6973
useClass: RateLimitGuard,
7074
},
75+
// Global interceptors
76+
{
77+
provide: APP_INTERCEPTOR,
78+
useClass: RequestIdInterceptor,
79+
},
80+
{
81+
provide: APP_INTERCEPTOR,
82+
useClass: ResponseRedactionInterceptor,
83+
},
7184
],
7285
})
7386
export class AppModule {}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { RequestIdInterceptor } from './request-id.interceptor';
2+
import { Test, TestingModule } from '@nestjs/testing';
3+
import { ExecutionContext, CallHandler } from '@nestjs/common';
4+
import { of, throwError } from 'rxjs';
5+
import { RequestContextService } from '../request-context/request-context.service';
6+
7+
describe('RequestIdInterceptor', () => {
8+
let interceptor: RequestIdInterceptor;
9+
10+
beforeEach(async () => {
11+
const module: TestingModule = await Test.createTestingModule({
12+
providers: [RequestIdInterceptor],
13+
}).compile();
14+
interceptor = module.get(RequestIdInterceptor);
15+
});
16+
17+
function createMockContext(
18+
headers: Record<string, string | string[] | undefined> = {},
19+
): ExecutionContext {
20+
const responseHeaders: Record<string, string> = {};
21+
return {
22+
switchToHttp: () => ({
23+
getRequest: () => ({
24+
headers,
25+
}),
26+
getResponse: () => ({
27+
setHeader: (key: string, value: string) => {
28+
responseHeaders[key] = value;
29+
},
30+
getHeader: (key: string) => responseHeaders[key],
31+
}),
32+
}),
33+
getHandler: () => ({}),
34+
getClass: () => ({}),
35+
} as unknown as ExecutionContext;
36+
}
37+
38+
it('should use the incoming x-request-id header when provided', (done) => {
39+
const incomingId = 'client-provided-id-123';
40+
const context = createMockContext({ 'x-request-id': incomingId });
41+
42+
const callHandler: CallHandler = {
43+
handle: () => {
44+
// Verify requestId is set in the AsyncLocalStorage context
45+
const storedId = RequestContextService.getCurrentRequestId();
46+
expect(storedId).toBe(incomingId);
47+
return of({ success: true });
48+
},
49+
};
50+
51+
interceptor.intercept(context, callHandler).subscribe({
52+
next: (body: any) => {
53+
// Verify the response has the header set
54+
const response = context.switchToHttp().getResponse();
55+
expect(response.getHeader('x-request-id')).toBe(incomingId);
56+
expect(body).toEqual({ success: true });
57+
done();
58+
},
59+
});
60+
});
61+
62+
it('should generate a UUID request ID when the header is absent', (done) => {
63+
const context = createMockContext({});
64+
65+
const callHandler: CallHandler = {
66+
handle: () => {
67+
const storedId = RequestContextService.getCurrentRequestId();
68+
expect(storedId).toBeDefined();
69+
expect(typeof storedId).toBe('string');
70+
expect(storedId).toMatch(
71+
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
72+
);
73+
return of({ success: true });
74+
},
75+
};
76+
77+
interceptor.intercept(context, callHandler).subscribe({
78+
next: () => {
79+
const response = context.switchToHttp().getResponse();
80+
expect(response.getHeader('x-request-id')).toBeDefined();
81+
done();
82+
},
83+
});
84+
});
85+
86+
it('should still set the request ID on the response when the handler throws', (done) => {
87+
const context = createMockContext({ 'x-request-id': 'error-test-id' });
88+
89+
const callHandler: CallHandler = {
90+
handle: () => throwError(() => new Error('handler error')),
91+
};
92+
93+
interceptor.intercept(context, callHandler).subscribe({
94+
error: () => {
95+
const response = context.switchToHttp().getResponse();
96+
expect(response.getHeader('x-request-id')).toBe('error-test-id');
97+
done();
98+
},
99+
});
100+
});
101+
102+
it('should propagate to RequestContextService and be retrievable', (done) => {
103+
const incomingId = 'propagation-test-id';
104+
const context = createMockContext({ 'x-request-id': incomingId });
105+
106+
const callHandler: CallHandler = {
107+
handle: () => {
108+
const idFromService = RequestContextService.getCurrentRequestId();
109+
expect(idFromService).toBe(incomingId);
110+
return of({ data: 'ok' });
111+
},
112+
};
113+
114+
interceptor.intercept(context, callHandler).subscribe({
115+
next: () => {
116+
// After the handler processes, the context should still be accessible
117+
// within the same async flow
118+
const idAfter = RequestContextService.getCurrentRequestId();
119+
expect(idAfter).toBe(incomingId);
120+
done();
121+
},
122+
});
123+
});
124+
});
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import {
2+
Injectable,
3+
NestInterceptor,
4+
ExecutionContext,
5+
CallHandler,
6+
Logger,
7+
} from '@nestjs/common';
8+
import { Observable, tap } from 'rxjs';
9+
import { Request, Response } from 'express';
10+
import { randomUUID } from 'crypto';
11+
import { RequestContextService } from '../request-context/request-context.service';
12+
13+
/**
14+
* RequestIdInterceptor
15+
*
16+
* Ensures every HTTP request has a unique `x-request-id` header value and
17+
* propagates it through the application via `RequestContextService` (AsyncLocalStorage).
18+
*
19+
* Behaviour:
20+
* 1. Reads `x-request-id` from the incoming request headers if present.
21+
* 2. If absent, generates a new UUID.
22+
* 3. Sets `x-request-id` on the outgoing response headers.
23+
* 4. Stores the request ID in `RequestContextService` so downstream services
24+
* (loggers, audit trails, outbound HTTP calls) can access it without
25+
* needing the Express request object.
26+
*
27+
* This interceptor is registered globally in `AppModule` and runs before
28+
* controller- or method-scoped interceptors.
29+
*/
30+
@Injectable()
31+
export class RequestIdInterceptor implements NestInterceptor {
32+
private readonly logger = new Logger(RequestIdInterceptor.name);
33+
34+
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
35+
const httpContext = context.switchToHttp();
36+
const request = httpContext.getRequest<Request>();
37+
const response = httpContext.getResponse<Response>();
38+
39+
// Resolve request ID from header or generate a new one
40+
const headerValue =
41+
request.headers['x-request-id'] ||
42+
request.headers['X-Request-Id'];
43+
44+
const requestId: string =
45+
typeof headerValue === 'string' && headerValue.length > 0
46+
? headerValue
47+
: randomUUID();
48+
49+
// Store on the request object for legacy middleware and logging access
50+
(request as any).requestId = requestId;
51+
52+
// Set the response header
53+
response.setHeader('x-request-id', requestId);
54+
55+
// Propagate into the AsyncLocalStorage context for this request's lifecycle.
56+
// We use enterWith (not run) because the context must persist across
57+
// the asynchronous Observable pipeline. The middleware layer also calls
58+
// run(), so the context is already active in most cases; this call
59+
// ensures it's set even if the interceptor runs first or middleware
60+
// hasn't yet bootstrapped it.
61+
RequestContextService.bootstrapRequestId(requestId);
62+
63+
return next.handle().pipe(
64+
tap({
65+
next: () => {
66+
// Ensure the header is always set even if previously missed
67+
if (!response.getHeader('x-request-id')) {
68+
response.setHeader('x-request-id', requestId);
69+
}
70+
},
71+
error: () => {
72+
// On error, still ensure the ID is on the response
73+
if (!response.getHeader('x-request-id')) {
74+
response.setHeader('x-request-id', requestId);
75+
}
76+
},
77+
}),
78+
);
79+
}
80+
}

0 commit comments

Comments
 (0)