diff --git a/backend/API_DOCUMENTATION.md b/backend/API_DOCUMENTATION.md index dadf8f4..7eb4b06 100644 --- a/backend/API_DOCUMENTATION.md +++ b/backend/API_DOCUMENTATION.md @@ -94,6 +94,47 @@ RATE_LIMIT_LOCKOUT_SECONDS=900 `/health` and `/metrics` are exempt through `@SkipRateLimit()`. +--- + +## 🔁 Idempotency Keys + +Mutating endpoints that create a resource (currently `POST /gigs` and `POST /escrows`) accept an +optional `Idempotency-Key` header so retries — e.g. after a client timeout — don't create +duplicate resources. + +### Client usage + +```bash +curl -X POST https://api.example.com/escrows \ + -H "Idempotency-Key: $(uuidgen)" \ + -H "Content-Type: application/json" \ + -d '{ "depositor": "G...", "beneficiary": "G...", "amountXLM": "100" }' +``` + +- Generate a fresh, unique key (a UUID is recommended) **per logical operation**, not per HTTP + attempt — reuse the same key when retrying the same request. +- The key is scoped to the specific endpoint (method + route), so the same key value can safely + be reused across different endpoints (e.g. once for `POST /gigs` and separately for + `POST /escrows`) without colliding. + +### Behavior + +| Situation | Response | +|---|---| +| No `Idempotency-Key` header | Request is processed normally; not cached. | +| First request with a given key | Request is processed; the response is cached. | +| Retry with the same key **and the same body** | The original cached response is replayed (same status code and body) — the handler does not run again. | +| Retry with the same key **and a different body** | `422 Unprocessable Entity` — the key has already been used for a different payload. | +| Concurrent request with the same key while the first is still in flight | `409 Conflict` — a request with this key is already being processed; wait and retry rather than assuming failure. | + +Cached responses are stored in Redis for `IDEMPOTENCY_KEY_TTL_SECONDS` (default 24h). Keys are +claimed atomically (`SET NX`), so concurrent duplicate requests cannot both create a resource. If +Redis is unavailable, idempotency protection is skipped and requests are processed normally +(fail-open) rather than blocking traffic. + +Response bodies are cached in full, so avoid decorating `@Idempotent()` onto endpoints that return +very large or streamed payloads. + ### Using Authentication in Swagger UI 1. Get your challenge and sign it @@ -334,6 +375,7 @@ REDIS_URL=redis://localhost:6379 RATE_LIMIT_ABUSE_WINDOW_SECONDS=300 RATE_LIMIT_ABUSE_THRESHOLD=5 RATE_LIMIT_LOCKOUT_SECONDS=900 +IDEMPOTENCY_KEY_TTL_SECONDS=86400 STELLAR_NETWORK=TESTNET STELLAR_HORIZON_URL=https://horizon-testnet.stellar.org SOROBAN_RPC_URL=https://soroban-testnet.stellar.org diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 8748e91..e2e6c35 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -7,6 +7,7 @@ import { StellarModule } from './stellar/stellar.module'; import { SentryModule } from './sentry/sentry.module'; import { RedisModule } from './common/redis/redis.module'; import { RateLimitModule } from './common/rate-limit/rate-limit.module'; +import { IdempotencyModule } from './common/idempotency/idempotency.module'; import { UserProfileModule } from './user-profile/user-profile.module'; import { EventIngestionModule } from './event-ingestion/event-ingestion.module'; import { DisputeModule } from './dispute/dispute.module'; @@ -21,6 +22,7 @@ import { ReputationModule } from './reputation/reputation.module'; SentryModule, RedisModule, RateLimitModule, + IdempotencyModule, AuthModule, UserProfileModule, EscrowModule, diff --git a/backend/src/common/idempotency/idempotency-key.decorator.ts b/backend/src/common/idempotency/idempotency-key.decorator.ts new file mode 100644 index 0000000..c32b8ba --- /dev/null +++ b/backend/src/common/idempotency/idempotency-key.decorator.ts @@ -0,0 +1,11 @@ +import { SetMetadata } from '@nestjs/common'; + +export const IDEMPOTENT_KEY = 'idempotent'; + +/** + * Marks a controller method as idempotent. When present, the + * IdempotencyKeyInterceptor will cache the handler's response keyed by + * (endpoint, Idempotency-Key header) and replay it on retries with + * the same key. + */ +export const Idempotent = () => SetMetadata(IDEMPOTENT_KEY, true); diff --git a/backend/src/common/idempotency/idempotency-key.interceptor.spec.ts b/backend/src/common/idempotency/idempotency-key.interceptor.spec.ts new file mode 100644 index 0000000..641c99e --- /dev/null +++ b/backend/src/common/idempotency/idempotency-key.interceptor.spec.ts @@ -0,0 +1,379 @@ +import { Controller, INestApplication, Post } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Reflector, APP_INTERCEPTOR } from '@nestjs/core'; +import request from 'supertest'; +import { IdempotencyKeyInterceptor } from './idempotency-key.interceptor'; +import { IdempotencyKeyService } from './idempotency-key.service'; +import { REDIS_CLIENT } from '../redis/redis.module'; +import { Idempotent } from './idempotency-key.decorator'; + +/** In-memory Redis stand-in that honours `SET key value EX ttl NX` semantics. */ +function createRedisMock() { + const store = new Map(); + return { + get: jest.fn(async (key: string) => store.get(key) ?? null), + set: jest.fn(async (key: string, value: string, ...args: unknown[]) => { + if (args.includes('NX') && store.has(key)) return null; + store.set(key, value); + return 'OK'; + }), + del: jest.fn(async (key: string) => { + const existed = store.has(key); + store.delete(key); + return existed ? 1 : 0; + }), + }; +} + +@Controller('idempotency-test') +class IdempotencyTestController { + @Post('idempotent') + @Idempotent() + create() { + return { id: 'record-1', createdAt: new Date().toISOString() }; + } + + @Post('not-idempotent') + createWithoutDecorator() { + return { ok: true }; + } +} + +describe('IdempotencyKeyInterceptor', () => { + let interceptor: IdempotencyKeyInterceptor; + + function createInterceptor(redisOverride?: ReturnType | null) { + const service = new IdempotencyKeyService((redisOverride ?? null) as any); + const reflector = new Reflector(); + return new IdempotencyKeyInterceptor(reflector, service); + } + + describe('basic behaviour', () => { + it('should be defined', () => { + interceptor = createInterceptor(); + expect(interceptor).toBeDefined(); + }); + }); + + describe('Supertest integration', () => { + let app: INestApplication; + let mockRedisFull: ReturnType; + + beforeEach(async () => { + mockRedisFull = createRedisMock(); + + const module: TestingModule = await Test.createTestingModule({ + controllers: [IdempotencyTestController], + providers: [ + IdempotencyKeyService, + { provide: REDIS_CLIENT, useValue: mockRedisFull }, + { + provide: APP_INTERCEPTOR, + useClass: IdempotencyKeyInterceptor, + }, + ], + }).compile(); + + app = module.createNestApplication(); + await app.init(); + }); + + afterEach(async () => { + await app.close(); + }); + + it('passes through when no Idempotency-Key header is present', async () => { + const res = await request(app.getHttpServer()) + .post('/idempotency-test/idempotent') + .send({ foo: 'bar' }) + .expect(201); + + expect(res.body).toHaveProperty('id', 'record-1'); + expect(mockRedisFull.get).not.toHaveBeenCalled(); + expect(mockRedisFull.set).not.toHaveBeenCalled(); + }); + + it('claims, executes, and finalizes on first use, then replays the cached response on retry', async () => { + const body = { foo: 'bar' }; + const idempotencyKey = 'test-uuid-123'; + + const res1 = await request(app.getHttpServer()) + .post('/idempotency-test/idempotent') + .set('Idempotency-Key', idempotencyKey) + .send(body) + .expect(201); + + expect(res1.body).toHaveProperty('id', 'record-1'); + // One SET to claim (pending, NX) and one SET to finalize (completed). + expect(mockRedisFull.set).toHaveBeenCalledTimes(2); + + const claimCall = mockRedisFull.set.mock.calls[0]; + expect(claimCall[2]).toBe('EX'); + expect(claimCall[4]).toBe('NX'); + const pendingRecord = JSON.parse(claimCall[1] as string); + expect(pendingRecord).toMatchObject({ status: 'pending' }); + + const finalizeCall = mockRedisFull.set.mock.calls[1]; + const completedRecord = JSON.parse(finalizeCall[1] as string); + expect(completedRecord).toMatchObject({ + status: 'completed', + statusCode: 201, + body: res1.body, + }); + + const res2 = await request(app.getHttpServer()) + .post('/idempotency-test/idempotent') + .set('Idempotency-Key', idempotencyKey) + .send(body) + .expect(201); + + expect(res2.body).toEqual(res1.body); + // The retry attempts an SET NX claim (which fails, since the key is + // already taken) but never overwrites the stored completed record. + expect(mockRedisFull.set).toHaveBeenCalledTimes(3); + const retryClaimAttempt = mockRedisFull.set.mock.results[2]; + await expect(retryClaimAttempt.value).resolves.toBeNull(); + }); + + it('returns 422 when the same key is reused with a different body', async () => { + const idempotencyKey = 'test-uuid-456'; + + await request(app.getHttpServer()) + .post('/idempotency-test/idempotent') + .set('Idempotency-Key', idempotencyKey) + .send({ foo: 'bar' }) + .expect(201); + + const res2 = await request(app.getHttpServer()) + .post('/idempotency-test/idempotent') + .set('Idempotency-Key', idempotencyKey) + .send({ foo: 'DIFFERENT' }) + .expect(422); + + expect(res2.body).toMatchObject({ + statusCode: 422, + message: 'Idempotency key has already been used with a different request payload', + }); + }); + + it('returns 409 when a duplicate request arrives while the original is still pending', async () => { + const idempotencyKey = 'test-uuid-pending'; + + // Simulate a claim made by a request that hasn't finished yet: write + // only the pending record, without ever finalizing it. + await mockRedisFull.set( + 'idempotency:POST:/idempotency-test/idempotent:test-uuid-pending', + JSON.stringify({ + version: 1, + requestHash: IdempotencyKeyService.hashBody({ foo: 'bar' }), + status: 'pending', + }), + 'EX', + 3600, + 'NX', + ); + + const res = await request(app.getHttpServer()) + .post('/idempotency-test/idempotent') + .set('Idempotency-Key', idempotencyKey) + .send({ foo: 'bar' }) + .expect(409); + + expect(res.body).toMatchObject({ + statusCode: 409, + message: 'A request with this idempotency key is already being processed', + }); + }); + + it('ignores the header on endpoints without @Idempotent()', async () => { + const res = await request(app.getHttpServer()) + .post('/idempotency-test/not-idempotent') + .set('Idempotency-Key', 'any-key') + .send({ x: 1 }) + .expect(201); + + expect(res.body).toHaveProperty('ok', true); + expect(mockRedisFull.get).not.toHaveBeenCalled(); + }); + + it('passes through gracefully when Redis is unavailable (null client)', async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [IdempotencyTestController], + providers: [ + IdempotencyKeyService, + { provide: REDIS_CLIENT, useValue: null }, + { + provide: APP_INTERCEPTOR, + useClass: IdempotencyKeyInterceptor, + }, + ], + }).compile(); + + const nullApp = module.createNestApplication(); + await nullApp.init(); + + const res = await request(nullApp.getHttpServer()) + .post('/idempotency-test/idempotent') + .set('Idempotency-Key', 'some-key') + .send({ foo: 'bar' }) + .expect(201); + + expect(res.body).toHaveProperty('id', 'record-1'); + await nullApp.close(); + }); + + it('passes through gracefully when Redis throws connection errors', async () => { + const flakyRedis = { + get: jest.fn().mockRejectedValue(new Error('ECONNREFUSED')), + set: jest.fn().mockRejectedValue(new Error('ECONNREFUSED')), + del: jest.fn().mockRejectedValue(new Error('ECONNREFUSED')), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [IdempotencyTestController], + providers: [ + IdempotencyKeyService, + { provide: REDIS_CLIENT, useValue: flakyRedis }, + { + provide: APP_INTERCEPTOR, + useClass: IdempotencyKeyInterceptor, + }, + ], + }).compile(); + + const flakyApp = module.createNestApplication(); + await flakyApp.init(); + + const res = await request(flakyApp.getHttpServer()) + .post('/idempotency-test/idempotent') + .set('Idempotency-Key', 'some-key') + .send({ foo: 'bar' }) + .expect(201); + + expect(res.body).toHaveProperty('id', 'record-1'); + expect(flakyRedis.set).toHaveBeenCalled(); + await flakyApp.close(); + }); + }); +}); + +describe('IdempotencyKeyService', () => { + describe('hashBody', () => { + it('produces a consistent hash for the same input', () => { + const a = IdempotencyKeyService.hashBody({ x: 1, y: 2 }); + const b = IdempotencyKeyService.hashBody({ x: 1, y: 2 }); + expect(a).toBe(b); + expect(a).toHaveLength(64); + }); + + it('produces different hashes for different inputs', () => { + const a = IdempotencyKeyService.hashBody({ x: 1 }); + const b = IdempotencyKeyService.hashBody({ x: 2 }); + expect(a).not.toBe(b); + }); + + it('handles null/undefined bodies', () => { + const a = IdempotencyKeyService.hashBody(null); + const b = IdempotencyKeyService.hashBody(undefined); + expect(a).toBe(b); + expect(a).toHaveLength(64); + }); + }); + + describe('claim', () => { + it('claims immediately when Redis is unavailable (graceful degradation)', async () => { + const service = new IdempotencyKeyService(null); + await expect(service.claim('endpoint', 'key', 'hash')).resolves.toEqual({ claimed: true }); + }); + + it('claims when the key does not exist yet, using SET NX', async () => { + const mockRedis = createRedisMock(); + const service = new IdempotencyKeyService(mockRedis as any); + await expect(service.claim('POST:/gigs', 'key-1', 'hash-1')).resolves.toEqual({ + claimed: true, + }); + expect(mockRedis.set).toHaveBeenCalledWith( + 'idempotency:POST:/gigs:key-1', + expect.any(String), + 'EX', + expect.any(Number), + 'NX', + ); + }); + + it('returns the existing record and claimed: false when the key is already taken', async () => { + const mockRedis = createRedisMock(); + const service = new IdempotencyKeyService(mockRedis as any); + await service.claim('POST:/gigs', 'key-1', 'hash-1'); + + const second = await service.claim('POST:/gigs', 'key-1', 'hash-1'); + expect(second.claimed).toBe(false); + if (!second.claimed) { + expect(second.record).toMatchObject({ status: 'pending', requestHash: 'hash-1' }); + } + }); + + it('degrades to claimed: true on Redis errors', async () => { + const mockRedis = { + get: jest.fn(), + set: jest.fn().mockRejectedValue(new Error('conn refused')), + }; + const service = new IdempotencyKeyService(mockRedis as any); + await expect(service.claim('endpoint', 'key', 'hash')).resolves.toEqual({ claimed: true }); + }); + }); + + describe('finalize', () => { + it('no-ops when Redis is unavailable', async () => { + const service = new IdempotencyKeyService(null); + await expect(service.finalize('endpoint', 'key', 'hash', 201, {})).resolves.toBeUndefined(); + }); + + it('stores the completed record with a TTL', async () => { + const mockRedis = createRedisMock(); + const service = new IdempotencyKeyService(mockRedis as any); + await service.finalize('POST:/gigs', 'key-1', 'hash-1', 201, { id: 'gig-1' }, {}, 3600); + + const [key, value, exFlag, ttl] = mockRedis.set.mock.calls[0]; + expect(key).toBe('idempotency:POST:/gigs:key-1'); + expect(ttl).toBe(3600); + expect(exFlag).toBe('EX'); + const parsed = JSON.parse(value); + expect(parsed).toMatchObject({ + requestHash: 'hash-1', + status: 'completed', + statusCode: 201, + body: { id: 'gig-1' }, + }); + }); + + it('does not throw on Redis errors (graceful degradation)', async () => { + const mockRedis = { get: jest.fn(), set: jest.fn().mockRejectedValue(new Error('fail')) }; + const service = new IdempotencyKeyService(mockRedis as any); + await expect(service.finalize('endpoint', 'key', 'hash', 201, {})).resolves.toBeUndefined(); + }); + }); + + describe('release', () => { + it('no-ops when Redis is unavailable', async () => { + const service = new IdempotencyKeyService(null); + await expect(service.release('endpoint', 'key')).resolves.toBeUndefined(); + }); + + it('deletes the claimed key', async () => { + const mockRedis = createRedisMock(); + const service = new IdempotencyKeyService(mockRedis as any); + await service.claim('POST:/gigs', 'key-1', 'hash-1'); + await service.release('POST:/gigs', 'key-1'); + + const second = await service.claim('POST:/gigs', 'key-1', 'hash-1'); + expect(second.claimed).toBe(true); + }); + + it('does not throw on Redis errors (graceful degradation)', async () => { + const mockRedis = { get: jest.fn(), del: jest.fn().mockRejectedValue(new Error('fail')) }; + const service = new IdempotencyKeyService(mockRedis as any); + await expect(service.release('endpoint', 'key')).resolves.toBeUndefined(); + }); + }); +}); diff --git a/backend/src/common/idempotency/idempotency-key.interceptor.ts b/backend/src/common/idempotency/idempotency-key.interceptor.ts new file mode 100644 index 0000000..173006e --- /dev/null +++ b/backend/src/common/idempotency/idempotency-key.interceptor.ts @@ -0,0 +1,145 @@ +import { + CallHandler, + ExecutionContext, + HttpException, + HttpStatus, + Injectable, + NestInterceptor, + Optional, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Observable, of } from 'rxjs'; +import { catchError, tap } from 'rxjs/operators'; +import { IDEMPOTENT_KEY } from './idempotency-key.decorator'; +import { IdempotencyKeyService } from './idempotency-key.service'; +import { MetricsService } from '../../monitoring/metrics.service'; + +/** + * Headers Express/Nest manage automatically. Replaying them from a cached + * response would fight with values the framework recomputes for the replay + * itself (e.g. Content-Length for the re-serialized body). + */ +const NON_REPLAYABLE_HEADERS = new Set([ + 'content-length', + 'connection', + 'date', + 'keep-alive', + 'transfer-encoding', + 'x-powered-by', + 'etag', +]); + +/** + * Global interceptor that enforces idempotency on endpoints decorated with + * {@link Idempotent}. Outcomes per request: + * + * 1. **No Idempotency-Key header** — pass through unchanged (backward compatible). + * 2. **Key not seen before** — atomically claims the key (Redis `SET NX`) and + * runs the handler. On success the response (status, body, and a small + * set of headers) is cached; on failure the claim is released so retries + * are not stuck as `pending` until the TTL expires. + * 3. **Key seen before, same body, completed** — replay the cached response. + * 4. **Key seen before, different body** — 422 Unprocessable Entity. + * 5. **Key seen before, still pending (concurrent duplicate request)** — + * 409 Conflict; the original request is still in flight. + * + * The claim is stored under `(endpoint, key)`, where `endpoint` includes the + * HTTP method and route path, so the same Idempotency-Key value used against + * two different endpoints (e.g. POST /gigs and POST /escrows) never collides. + */ +@Injectable() +export class IdempotencyKeyInterceptor implements NestInterceptor { + constructor( + private readonly reflector: Reflector, + private readonly idempotencyService: IdempotencyKeyService, + @Optional() private readonly metrics?: MetricsService, + ) {} + + async intercept(context: ExecutionContext, next: CallHandler): Promise> { + const isIdempotent = this.reflector.getAllAndOverride(IDEMPOTENT_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (!isIdempotent) return next.handle(); + + const request = context.switchToHttp().getRequest(); + const idempotencyKey = request.headers['idempotency-key'] as string | undefined; + + if (!idempotencyKey) return next.handle(); + + const endpoint = `${request.method}:${request.route?.path ?? request.url}`; + const requestHash = IdempotencyKeyService.hashBody(request.body); + + const claimResult = await this.idempotencyService.claim(endpoint, idempotencyKey, requestHash); + + if (!claimResult.claimed) { + const { record } = claimResult; + + if (!record) { + // Lost the claim race but the record vanished (e.g. TTL race) — + // degrade to pass-through rather than block the request. + return next.handle(); + } + + if (record.requestHash !== requestHash) { + this.metrics?.increment('idempotency_key_mismatch_total', { endpoint }); + throw new HttpException( + { + statusCode: HttpStatus.UNPROCESSABLE_ENTITY, + message: 'Idempotency key has already been used with a different request payload', + }, + HttpStatus.UNPROCESSABLE_ENTITY, + ); + } + + if (record.status === 'pending') { + this.metrics?.increment('idempotency_conflict_total', { endpoint }); + throw new HttpException( + { + statusCode: HttpStatus.CONFLICT, + message: 'A request with this idempotency key is already being processed', + }, + HttpStatus.CONFLICT, + ); + } + + const response = context.switchToHttp().getResponse(); + response.status(record.statusCode ?? HttpStatus.OK); + for (const [name, value] of Object.entries(record.headers ?? {})) { + response.setHeader(name, value); + } + return of(record.body); + } + + return next.handle().pipe( + tap(async (responseBody: unknown) => { + const response = context.switchToHttp().getResponse(); + await this.idempotencyService.finalize( + endpoint, + idempotencyKey, + requestHash, + response.statusCode, + responseBody, + this.capturedHeaders(response), + ); + }), + catchError(async err => { + await this.idempotencyService.release(endpoint, idempotencyKey); + throw err; + }), + ); + } + + private capturedHeaders(response: { + getHeaders?: () => Record; + }): Record { + const headers = response.getHeaders?.() ?? {}; + const result: Record = {}; + for (const [name, value] of Object.entries(headers)) { + if (value === undefined || NON_REPLAYABLE_HEADERS.has(name.toLowerCase())) continue; + result[name] = String(value); + } + return result; + } +} diff --git a/backend/src/common/idempotency/idempotency-key.service.ts b/backend/src/common/idempotency/idempotency-key.service.ts new file mode 100644 index 0000000..dd814d0 --- /dev/null +++ b/backend/src/common/idempotency/idempotency-key.service.ts @@ -0,0 +1,157 @@ +import { Inject, Injectable, Logger, Optional } from '@nestjs/common'; +import { Redis } from 'ioredis'; +import { REDIS_CLIENT } from '../redis/redis.module'; +import { MetricsService } from '../../monitoring/metrics.service'; +import { createHash } from 'crypto'; + +const KEY_PREFIX = 'idempotency:'; +const RECORD_VERSION = 1; +const DEFAULT_TTL_SECONDS = 24 * 60 * 60; // 24 hours + +function defaultTtlSeconds(): number { + const raw = process.env.IDEMPOTENCY_KEY_TTL_SECONDS; + if (!raw) return DEFAULT_TTL_SECONDS; + const parsed = Number(raw); + return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_TTL_SECONDS; +} + +export interface IdempotencyRecord { + /** Schema version of this record, so future format changes can be handled gracefully. */ + version: number; + /** SHA-256 of the request body, used to detect key reuse with different payloads. */ + requestHash: string; + /** `pending` while the original request is still executing, `completed` once cached. */ + status: 'pending' | 'completed'; + /** Cached HTTP status code. Present once `status` is `completed`. */ + statusCode?: number; + /** Cached response body. Present once `status` is `completed`. */ + body?: unknown; + /** Cached response headers worth replaying (e.g. Location). */ + headers?: Record; +} + +export type ClaimResult = { claimed: true } | { claimed: false; record: IdempotencyRecord | null }; + +@Injectable() +export class IdempotencyKeyService { + private readonly logger = new Logger(IdempotencyKeyService.name); + + constructor( + @Inject(REDIS_CLIENT) private readonly redis: Redis | null, + @Optional() private readonly metrics?: MetricsService, + ) {} + + /** + * Hashes a request body to a stable fingerprint. We hash rather than store + * the raw body so the Redis key stays compact. + */ + static hashBody(body: unknown): string { + return createHash('sha256') + .update(JSON.stringify(body ?? {})) + .digest('hex'); + } + + /** + * Atomically claims (endpoint, key) for the current request via `SET NX` so + * that concurrent requests carrying the same idempotency key cannot both + * proceed to execute the handler. The caller that wins the race gets + * `claimed: true` and should run the handler, then call {@link finalize} + * (or {@link release} on failure). Callers that lose the race get the + * existing record (which may still be `pending`) so they can replay it or + * reject a mismatched payload. + * + * When Redis is unavailable, we degrade to `claimed: true` (no dedup) + * rather than block the request. + */ + async claim( + endpoint: string, + key: string, + requestHash: string, + ttlSeconds: number = defaultTtlSeconds(), + ): Promise { + if (!this.redis) return { claimed: true }; + + const pending: IdempotencyRecord = { + version: RECORD_VERSION, + requestHash, + status: 'pending', + }; + + try { + const result = await this.redis.set( + this.buildKey(endpoint, key), + JSON.stringify(pending), + 'EX', + ttlSeconds, + 'NX', + ); + if (result === 'OK') { + this.metrics?.increment('idempotency_cache_miss_total', { endpoint }); + return { claimed: true }; + } + } catch (err) { + this.logger.warn('Idempotency claim failed, proceeding without cache', err); + this.metrics?.increment('idempotency_redis_error_total', { endpoint, op: 'claim' }); + return { claimed: true }; + } + + try { + const raw = await this.redis.get(this.buildKey(endpoint, key)); + this.metrics?.increment('idempotency_cache_hit_total', { endpoint }); + return { claimed: false, record: raw ? (JSON.parse(raw) as IdempotencyRecord) : null }; + } catch (err) { + this.logger.warn('Idempotency lookup failed, proceeding without cache', err); + this.metrics?.increment('idempotency_redis_error_total', { endpoint, op: 'lookup' }); + return { claimed: true }; + } + } + + /** + * Finalizes a previously claimed key with the handler's response. + * No-op when Redis is unavailable. + */ + async finalize( + endpoint: string, + key: string, + requestHash: string, + statusCode: number, + body: unknown, + headers: Record = {}, + ttlSeconds: number = defaultTtlSeconds(), + ): Promise { + if (!this.redis) return; + const record: IdempotencyRecord = { + version: RECORD_VERSION, + requestHash, + status: 'completed', + statusCode, + body, + headers, + }; + try { + await this.redis.set(this.buildKey(endpoint, key), JSON.stringify(record), 'EX', ttlSeconds); + this.metrics?.increment('idempotency_store_success_total', { endpoint }); + } catch (err) { + this.logger.warn('Idempotency store failed, response will not be cached', err); + this.metrics?.increment('idempotency_redis_error_total', { endpoint, op: 'finalize' }); + } + } + + /** + * Releases a claimed key, e.g. after the handler throws, so the key does + * not stay stuck as `pending` (blocking retries) until its TTL expires. + */ + async release(endpoint: string, key: string): Promise { + if (!this.redis) return; + try { + await this.redis.del(this.buildKey(endpoint, key)); + } catch (err) { + this.logger.warn('Idempotency release failed', err); + this.metrics?.increment('idempotency_redis_error_total', { endpoint, op: 'release' }); + } + } + + private buildKey(endpoint: string, key: string): string { + return `${KEY_PREFIX}${endpoint}:${key}`; + } +} diff --git a/backend/src/common/idempotency/idempotency.integration.spec.ts b/backend/src/common/idempotency/idempotency.integration.spec.ts new file mode 100644 index 0000000..f84998d --- /dev/null +++ b/backend/src/common/idempotency/idempotency.integration.spec.ts @@ -0,0 +1,339 @@ +import { Body, Controller, INestApplication, Post } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { APP_INTERCEPTOR } from '@nestjs/core'; +import request from 'supertest'; +import { IdempotencyKeyInterceptor } from './idempotency-key.interceptor'; +import { IdempotencyKeyService } from './idempotency-key.service'; +import { REDIS_CLIENT } from '../redis/redis.module'; +import { Idempotent } from './idempotency-key.decorator'; + +/** In-memory Redis stand-in that honours `SET key value EX ttl NX` semantics. */ +function createRedisMock() { + const store = new Map(); + return { + get: jest.fn(async (key: string) => store.get(key) ?? null), + set: jest.fn(async (key: string, value: string, ...args: unknown[]) => { + if (args.includes('NX') && store.has(key)) return null; + store.set(key, value); + return 'OK'; + }), + del: jest.fn(async (key: string) => { + const existed = store.has(key); + store.delete(key); + return existed ? 1 : 0; + }), + }; +} + +/** + * Simulates the POST /gigs and POST /escrows controllers with idempotency + * wired end-to-end through the global interceptor. + */ +@Controller('test-gigs') +class FakeGigController { + private counter = 0; + + @Post() + @Idempotent() + create(@Body() dto: { creator: string; title: string; budgetXLM: string }) { + return { + id: `gig-${++this.counter}`, + ...dto, + status: 'open', + createdAt: new Date().toISOString(), + }; + } +} + +@Controller('test-escrows') +class FakeEscrowController { + private counter = 0; + + @Post() + @Idempotent() + create(@Body() dto: { depositor: string; beneficiary: string; amountXLM: string }) { + return { + id: `esc-${++this.counter}`, + ...dto, + status: 'pending', + createdAt: new Date().toISOString(), + }; + } +} + +/** Slow controller used to exercise the concurrent-request race. */ +@Controller('test-slow') +class FakeSlowCreateController { + private counter = 0; + + @Post() + @Idempotent() + async create(@Body() dto: { title: string }) { + await new Promise(resolve => setTimeout(resolve, 25)); + return { id: `slow-${++this.counter}`, ...dto }; + } +} + +describe('Idempotency integration — gig-like endpoint', () => { + let app: INestApplication; + + beforeEach(async () => { + const mockRedis = createRedisMock(); + + const module: TestingModule = await Test.createTestingModule({ + controllers: [FakeGigController], + providers: [ + IdempotencyKeyService, + { provide: REDIS_CLIENT, useValue: mockRedis }, + { provide: APP_INTERCEPTOR, useClass: IdempotencyKeyInterceptor }, + ], + }).compile(); + + app = module.createNestApplication(); + await app.init(); + }); + + afterEach(async () => { + await app.close(); + }); + + const gigBody = { + creator: 'GABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF01', + title: 'Build an audit report', + budgetXLM: '250', + }; + + it('creates only one record when the same request is sent twice with the same Idempotency-Key', async () => { + const key = 'gig-idem-uuid-001'; + + const res1 = await request(app.getHttpServer()) + .post('/test-gigs') + .set('Idempotency-Key', key) + .send(gigBody) + .expect(201); + + const res2 = await request(app.getHttpServer()) + .post('/test-gigs') + .set('Idempotency-Key', key) + .send(gigBody) + .expect(201); + + expect(res1.body.id).toBe('gig-1'); + expect(res2.body).toEqual(res1.body); + }); + + it('returns 422 when the same key is used with a different body', async () => { + const key = 'gig-idem-uuid-002'; + + await request(app.getHttpServer()) + .post('/test-gigs') + .set('Idempotency-Key', key) + .send(gigBody) + .expect(201); + + const res = await request(app.getHttpServer()) + .post('/test-gigs') + .set('Idempotency-Key', key) + .send({ ...gigBody, title: 'DIFFERENT TITLE' }) + .expect(422); + + expect(res.body.message).toContain('different request payload'); + }); + + it('creates separate records when no Idempotency-Key is provided', async () => { + const res1 = await request(app.getHttpServer()).post('/test-gigs').send(gigBody).expect(201); + + const res2 = await request(app.getHttpServer()).post('/test-gigs').send(gigBody).expect(201); + + expect(res1.body.id).toBe('gig-1'); + expect(res2.body.id).toBe('gig-2'); + }); +}); + +describe('Idempotency integration — escrow-like endpoint', () => { + let app: INestApplication; + + beforeEach(async () => { + const mockRedis = createRedisMock(); + + const module: TestingModule = await Test.createTestingModule({ + controllers: [FakeEscrowController], + providers: [ + IdempotencyKeyService, + { provide: REDIS_CLIENT, useValue: mockRedis }, + { provide: APP_INTERCEPTOR, useClass: IdempotencyKeyInterceptor }, + ], + }).compile(); + + app = module.createNestApplication(); + await app.init(); + }); + + afterEach(async () => { + await app.close(); + }); + + const escrowBody = { + depositor: 'GDEP0001234567890DEP0001234567890DEP0001234567890DEP0001', + beneficiary: 'GBEN0001234567890BEN0001234567890BEN0001234567890BEN0001', + amountXLM: '100', + }; + + it('creates only one record when the same request is sent twice with the same Idempotency-Key', async () => { + const key = 'esc-idem-uuid-001'; + + const res1 = await request(app.getHttpServer()) + .post('/test-escrows') + .set('Idempotency-Key', key) + .send(escrowBody) + .expect(201); + + const res2 = await request(app.getHttpServer()) + .post('/test-escrows') + .set('Idempotency-Key', key) + .send(escrowBody) + .expect(201); + + expect(res1.body.id).toBe('esc-1'); + expect(res2.body).toEqual(res1.body); + }); + + it('returns 422 when the same key is used with a different body', async () => { + const key = 'esc-idem-uuid-002'; + + await request(app.getHttpServer()) + .post('/test-escrows') + .set('Idempotency-Key', key) + .send(escrowBody) + .expect(201); + + await request(app.getHttpServer()) + .post('/test-escrows') + .set('Idempotency-Key', key) + .send({ ...escrowBody, amountXLM: '999' }) + .expect(422); + }); + + it('creates separate records when no Idempotency-Key is provided', async () => { + const res1 = await request(app.getHttpServer()) + .post('/test-escrows') + .send(escrowBody) + .expect(201); + + const res2 = await request(app.getHttpServer()) + .post('/test-escrows') + .send(escrowBody) + .expect(201); + + expect(res1.body.id).toBe('esc-1'); + expect(res2.body.id).toBe('esc-2'); + }); +}); + +describe('Idempotency integration — cross-endpoint key namespacing', () => { + let app: INestApplication; + + beforeEach(async () => { + const mockRedis = createRedisMock(); + + const module: TestingModule = await Test.createTestingModule({ + controllers: [FakeGigController, FakeEscrowController], + providers: [ + IdempotencyKeyService, + { provide: REDIS_CLIENT, useValue: mockRedis }, + { provide: APP_INTERCEPTOR, useClass: IdempotencyKeyInterceptor }, + ], + }).compile(); + + app = module.createNestApplication(); + await app.init(); + }); + + afterEach(async () => { + await app.close(); + }); + + it('does not let the same Idempotency-Key collide across different endpoints', async () => { + const sharedKey = 'shared-key-across-endpoints'; + + const gigRes = await request(app.getHttpServer()) + .post('/test-gigs') + .set('Idempotency-Key', sharedKey) + .send({ + creator: 'GABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF01', + title: 'Gig using shared key', + budgetXLM: '250', + }) + .expect(201); + + const escrowRes = await request(app.getHttpServer()) + .post('/test-escrows') + .set('Idempotency-Key', sharedKey) + .send({ + depositor: 'GDEP0001234567890DEP0001234567890DEP0001234567890DEP0001', + beneficiary: 'GBEN0001234567890BEN0001234567890BEN0001234567890BEN0001', + amountXLM: '100', + }) + .expect(201); + + // Neither request was rejected as a "different payload" reuse, and each + // hit its own handler (distinct id prefixes), proving the cache key is + // scoped per (method, route), not just per Idempotency-Key value. + expect(gigRes.body.id).toBe('gig-1'); + expect(escrowRes.body.id).toBe('esc-1'); + }); +}); + +describe('Idempotency integration — concurrent duplicate requests', () => { + let app: INestApplication; + + beforeEach(async () => { + const mockRedis = createRedisMock(); + + const module: TestingModule = await Test.createTestingModule({ + controllers: [FakeSlowCreateController], + providers: [ + IdempotencyKeyService, + { provide: REDIS_CLIENT, useValue: mockRedis }, + { provide: APP_INTERCEPTOR, useClass: IdempotencyKeyInterceptor }, + ], + }).compile(); + + app = module.createNestApplication(); + await app.init(); + }); + + afterEach(async () => { + await app.close(); + }); + + it('creates exactly one resource when N identical requests race on the same key', async () => { + const key = 'race-key-001'; + const body = { title: 'racey request' }; + const concurrency = 8; + + const responses = await Promise.all( + Array.from({ length: concurrency }, () => + request(app.getHttpServer()).post('/test-slow').set('Idempotency-Key', key).send(body), + ), + ); + + const created = responses.filter(r => r.status === 201); + const conflicted = responses.filter(r => r.status === 409); + + // Exactly one request wins the atomic claim and actually runs the handler. + expect(created).toHaveLength(1); + expect(conflicted).toHaveLength(concurrency - 1); + expect(created[0].body.id).toBe('slow-1'); + + // Once the winner finishes, a later retry replays the cached result + // instead of creating a second resource. + const retry = await request(app.getHttpServer()) + .post('/test-slow') + .set('Idempotency-Key', key) + .send(body) + .expect(201); + + expect(retry.body).toEqual(created[0].body); + }); +}); diff --git a/backend/src/common/idempotency/idempotency.module.ts b/backend/src/common/idempotency/idempotency.module.ts new file mode 100644 index 0000000..42f5a77 --- /dev/null +++ b/backend/src/common/idempotency/idempotency.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { APP_INTERCEPTOR } from '@nestjs/core'; +import { IdempotencyKeyService } from './idempotency-key.service'; +import { IdempotencyKeyInterceptor } from './idempotency-key.interceptor'; +import { RedisModule } from '../redis/redis.module'; +import { MonitoringModule } from '../../monitoring/monitoring.module'; + +@Module({ + imports: [RedisModule, MonitoringModule], + providers: [ + IdempotencyKeyService, + { + provide: APP_INTERCEPTOR, + useClass: IdempotencyKeyInterceptor, + }, + ], + exports: [IdempotencyKeyService], +}) +export class IdempotencyModule {} diff --git a/backend/src/common/idempotency/index.ts b/backend/src/common/idempotency/index.ts new file mode 100644 index 0000000..5e7af66 --- /dev/null +++ b/backend/src/common/idempotency/index.ts @@ -0,0 +1,4 @@ +export * from './idempotency-key.decorator'; +export * from './idempotency-key.service'; +export * from './idempotency-key.interceptor'; +export * from './idempotency.module'; diff --git a/backend/src/escrow/escrow.controller.ts b/backend/src/escrow/escrow.controller.ts index 5d433cc..3748a06 100644 --- a/backend/src/escrow/escrow.controller.ts +++ b/backend/src/escrow/escrow.controller.ts @@ -1,5 +1,13 @@ import { Controller, Get, NotFoundException, Post, Body, Param, Query } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery, ApiBody } from '@nestjs/swagger'; +import { + ApiBody, + ApiHeader, + ApiOperation, + ApiParam, + ApiQuery, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; import { EscrowService } from './escrow.service'; import { WebhookService } from '../webhook/webhook.service'; import { DiscordService } from '../webhook/discord.service'; @@ -7,6 +15,7 @@ import { WebhookEvent } from '../webhook/webhook.dto'; import { ReputationService } from '../reputation/reputation.service'; import { EscrowReleaseTransactionBuilderService } from '../escrow-write/escrow-release-transaction-builder.service'; import { BuildReleaseTransactionQueryDto } from '../escrow-write/escrow-write.dto'; +import { Idempotent } from '../common/idempotency'; interface CreateEscrowDto { depositor: string; @@ -30,10 +39,20 @@ export class EscrowController { ) {} @Post() + @Idempotent() @ApiOperation({ summary: 'Create new escrow', description: 'Creates a new escrow vault with depositor, beneficiary, and amount.', }) + @ApiHeader({ + name: 'Idempotency-Key', + required: false, + description: + 'A unique key (e.g. UUID) to ensure this request is idempotent. ' + + 'Retries with the same key and body return the original response without creating a duplicate. ' + + 'Reusing the key with a different body returns 422.', + schema: { type: 'string' }, + }) @ApiBody({ description: 'Escrow creation details', schema: { diff --git a/backend/src/gig/gig.controller.ts b/backend/src/gig/gig.controller.ts index 2f3f774..b87fbf6 100644 --- a/backend/src/gig/gig.controller.ts +++ b/backend/src/gig/gig.controller.ts @@ -11,6 +11,7 @@ import { import { ApiBearerAuth, ApiBody, + ApiHeader, ApiOperation, ApiParam, ApiResponse, @@ -21,6 +22,7 @@ import { WebhookService } from '../webhook/webhook.service'; import { AcceptGigDto, AcceptGigSchema, CreateGigDto, CreateGigSchema } from './gig.dto'; import { GIG_EVENTS } from './gig.entity'; import { JwtAuthGuard } from '../auth/auth.guard'; +import { Idempotent } from '../common/idempotency'; @ApiTags('Gigs') @Controller('gigs') @@ -34,12 +36,22 @@ export class GigController { @UseGuards(JwtAuthGuard) @ApiBearerAuth('JWT-auth') @HttpCode(HttpStatus.CREATED) + @Idempotent() @ApiOperation({ summary: 'Post a gig solicitation', description: 'Creates an open gig solicitation. If nobody accepts it within the response window ' + '(default 72h), the background expiry sweep automatically marks it as expired.', }) + @ApiHeader({ + name: 'Idempotency-Key', + required: false, + description: + 'A unique key (e.g. UUID) to ensure this request is idempotent. ' + + 'Retries with the same key and body return the original response without creating a duplicate. ' + + 'Reusing the key with a different body returns 422.', + schema: { type: 'string' }, + }) @ApiBody({ description: 'Gig solicitation details', schema: {