Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
-- Migration: add_idempotency_record
-- Adds the IdempotencyRecord table used by the IdempotencyMiddleware to
-- deduplicate retries on POST /credits/mint, POST /marketplace/purchase,
-- and POST /retirements.
--
-- Records are keyed by (idempotencyKey, endpoint) and retained for 24 hours.
-- Cleanup is done opportunistically by the middleware on each request.

CREATE TABLE "IdempotencyRecord" (
"id" TEXT NOT NULL,
"idempotencyKey" TEXT NOT NULL,
"endpoint" TEXT NOT NULL,
"requestHash" TEXT NOT NULL,
"responseStatus" INTEGER NOT NULL,
"responseBody" TEXT NOT NULL,
"txHash" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "IdempotencyRecord_pkey" PRIMARY KEY ("id")
);

-- Composite unique constraint: one record per (key, endpoint) pair
CREATE UNIQUE INDEX "IdempotencyRecord_idempotencyKey_endpoint_key"
ON "IdempotencyRecord"("idempotencyKey", "endpoint");

-- Index to support efficient TTL-based cleanup queries
CREATE INDEX "IdempotencyRecord_createdAt_idx"
ON "IdempotencyRecord"("createdAt");
25 changes: 25 additions & 0 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,31 @@ model AdminConfig {
updatedAt DateTime @updatedAt
}

/// Stores idempotency records for critical mutating endpoints.
/// A record is keyed by (idempotencyKey, endpoint) to allow the same key
/// to be reused across different endpoints without collision.
/// Cached responses are kept for 24 hours (86400 seconds) after creation.
model IdempotencyRecord {
id String @id @default(cuid())
/// Client-supplied Idempotency-Key header value (UUID v4 recommended)
idempotencyKey String
/// Normalised endpoint path, e.g. "POST:/api/v1/credits/mint"
endpoint String
/// SHA-256 hash of the serialised request body to detect body mismatch
requestHash String
/// HTTP status code of the first successful response
responseStatus Int
/// Serialised response body (JSON string)
responseBody String @db.Text
/// Stellar transaction hash if the operation produced one
txHash String?
/// When this record was first created (used to enforce 24-hour TTL)
createdAt DateTime @default(now())

@@unique([idempotencyKey, endpoint])
@@index([createdAt])
}

model ApiKey {
id String @id @default(cuid())
key String @unique
Expand Down
16 changes: 15 additions & 1 deletion backend/src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { AdminModule } from "./admin/admin.module";
import { PublicApiModule } from "./public-api/public-api.module";
import { Module, Controller, Get, MiddlewareConsumer, NestModule } from "@nestjs/common";
import { Module, Controller, Get, MiddlewareConsumer, NestModule, RequestMethod } from "@nestjs/common";
import { APP_INTERCEPTOR, APP_GUARD, APP_FILTER } from "@nestjs/core";
import { BullModule } from "@nestjs/bullmq";
import { ThrottlerModule } from "@nestjs/throttler";
Expand Down Expand Up @@ -28,6 +28,9 @@ import { CorrelationIdMiddleware } from "./logger/correlation-id.middleware";
import { LoggingInterceptor } from "./logger/logging.interceptor";
// Role-based quota throttling (issue #540)
import { ThrottleModule, RoleLimitGuard } from "./throttle";
// Idempotency support for critical POST endpoints (issue #539)
import { IdempotencyModule } from "./idempotency/idempotency.module";
import { IdempotencyMiddleware } from "./idempotency/idempotency.middleware";

import { Res, HttpStatus } from "@nestjs/common";
import { Response } from "express";
Expand Down Expand Up @@ -144,6 +147,7 @@ class HealthController {
AdminModule,
PublicApiModule,
RedisModule,
IdempotencyModule,
],
controllers: [HealthController],
providers: [
Expand Down Expand Up @@ -188,5 +192,15 @@ class HealthController {
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(CorrelationIdMiddleware).forRoutes('*');

// Apply idempotency enforcement to the three critical mutating endpoints.
// The Idempotency-Key header is optional; omitting it simply bypasses the check.
consumer
.apply(IdempotencyMiddleware)
.forRoutes(
{ path: 'credits/mint', method: RequestMethod.POST },
{ path: 'marketplace/purchase', method: RequestMethod.POST },
{ path: 'retirements', method: RequestMethod.POST },
);
}
}
Loading
Loading