diff --git a/docs/api.md b/docs/api.md index c888fe6..7e58f8a 100644 --- a/docs/api.md +++ b/docs/api.md @@ -72,6 +72,57 @@ removes the enum value. `settlementCurrency` stays in responses (always `"USDC"`) so the frontend doesn't break; the `currency` filter on `GET /markets` accepts only `USDC`. +## Idempotency + +State-changing endpoints accept an `Idempotency-Key` header +([draft-ietf-httpapi-idempotency-key-header](https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/), +the same model as Stripe). Generate one key per **logical operation** and send +the same key on every retry of it. + +| Route | Header | +|---|---| +| `POST /markets/generate` | **required** | +| `POST /trade/copy` | **required** | +| `PATCH /trade/copy/:id/confirm` | optional | +| `PUT /traces/access/allowance` | optional | +| `POST /traces/verify` | optional | +| `POST /traces/:id/unlock` | optional | +| `POST /auth/challenge`, `POST /auth/verify` | not supported (the handshake is single-use, and `/auth/verify` returns a JWT that is never stored) | + +- **Format:** 1–255 printable ASCII characters. A UUID (`crypto.randomUUID()`) is recommended. +- **Scope:** `(user, method, route template, key)`. The same key from another user or on another route never collides. +- **TTL:** 24 h. Expired keys are purged hourly. +- **Fingerprint:** sha256 of the canonical `{ method, route, params, query, body }` (keys sorted, so JSON key order and query-parameter order don't matter). + +**Responses** + +| Situation | Result | +|---|---| +| First request | Runs normally. Its 2xx/4xx response is stored. | +| Exact retry after it finished | The stored status and body are replayed with `Idempotent-Replayed: true`. The handler does **not** run again (e.g. `POST /markets/generate` returns the original `jobId` and starts no new cycle). | +| Retry while the first is still running | `409 IDEMPOTENCY_IN_PROGRESS` with `Retry-After: 1` | +| Same key, different request | `422 IDEMPOTENCY_KEY_MISMATCH` | +| Missing key on a required route | `400 IDEMPOTENCY_KEY_REQUIRED` | +| Malformed key | `400 IDEMPOTENCY_KEY_INVALID` | +| Original response was > 64 KB | `409 IDEMPOTENCY_RESPONSE_UNAVAILABLE` (use a new key) | + +- **5xx responses are not stored.** The key is released so a retry runs again. If a handler commits a side effect and *then* fails with a 5xx, a retry re-executes it, so such handlers must make the effect itself idempotent (as `recordPayment` does for a duplicate `txHash`). +- A stored **4xx is replayed as-is**, even if state has changed since (e.g. a `403 TRACE_LOCKED` replays after the user buys a pass). After changing state, use a **new** key. +- A request that crashed mid-flight holds its key for 60 s, after which a retry takes it over. +- Only `Content-Type`, `Location` and `API-Version` are replayed. `Set-Cookie`/`Authorization` are never stored, and body fields named like `token`/`secret`/`authorization` are redacted before storage. + +**Frontend:** create the key when the user starts the action, and reuse it across React Query retries: + +```ts +const key = crypto.randomUUID(); // once per logical action, e.g. per click +useMutation({ + mutationKey: ['copy-trade', key], + mutationFn: (vars) => + api.post('/trade/copy', vars, { headers: { 'Idempotency-Key': key } }), + retry: 3, // retries reuse the same key +}); +``` + ## Endpoints ### Health diff --git a/prisma/migrations/20261017000008_idempotency_keys/migration.sql b/prisma/migrations/20261017000008_idempotency_keys/migration.sql new file mode 100644 index 0000000..537e886 --- /dev/null +++ b/prisma/migrations/20261017000008_idempotency_keys/migration.sql @@ -0,0 +1,28 @@ +-- Migration: 20261017000008_idempotency_keys +-- Idempotency-Key storage for state-changing endpoints (#123). + +-- CreateTable +CREATE TABLE "idempotency_keys" ( + "id" TEXT NOT NULL, + "scope" TEXT NOT NULL, + "method" TEXT NOT NULL, + "route" TEXT NOT NULL, + "key" TEXT NOT NULL, + "fingerprint" TEXT NOT NULL, + "status" TEXT NOT NULL, + "lockedUntil" TIMESTAMP(3) NOT NULL, + "responseCode" INTEGER, + "responseBody" JSONB, + "responseHeaders" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expiresAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "idempotency_keys_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "idempotency_keys_expiresAt_idx" ON "idempotency_keys"("expiresAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "idempotency_keys_scope_method_route_key_key" ON "idempotency_keys"("scope", "method", "route", "key"); + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6b5c264..2e3c80d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -548,3 +548,29 @@ model ConstraintQuarantine { @@index([tableName, checkName]) @@map("constraint_quarantine") } + +/// Idempotency-Key records for state-changing endpoints (#123). One row per +/// (scope, method, route, key); see src/middlewares/idempotency.middleware.ts. +model IdempotencyKey { + id String @id @default(uuid()) + /// userId, or "anon" for unauthenticated routes + scope String + method String + /// Route template, e.g. "/api/v1/trade/copy" + route String + key String + /// sha256 of the canonical request (method, route, params, query, body) + fingerprint String + /// IN_PROGRESS | COMPLETED + status String + lockedUntil DateTime + responseCode Int? + responseBody Json? + responseHeaders Json? + createdAt DateTime @default(now()) + expiresAt DateTime + + @@unique([scope, method, route, key]) + @@index([expiresAt]) + @@map("idempotency_keys") +} diff --git a/src/app.ts b/src/app.ts index 26401e1..2eb45ea 100644 --- a/src/app.ts +++ b/src/app.ts @@ -32,6 +32,9 @@ app.use(cors({ callback(null, false); }, credentials: true, + // #123: let browsers send Idempotency-Key and read Idempotent-Replayed. + allowedHeaders: ['Content-Type', 'Authorization', 'Idempotency-Key'], + exposedHeaders: ['Idempotent-Replayed', 'Retry-After'], })); // ─── Rate Limiting ─── diff --git a/src/cron/monitor.cron.ts b/src/cron/monitor.cron.ts index 4056bd6..9a2bb49 100644 --- a/src/cron/monitor.cron.ts +++ b/src/cron/monitor.cron.ts @@ -2,6 +2,7 @@ import cron from 'node-cron'; import { runTraderCycle } from '../agents/trader.agent'; import { expireStaleSubscriptions } from '../services/subscription.service'; import { logger } from '../lib/logger'; +import { purgeExpiredIdempotencyKeys } from '../middlewares/idempotency.middleware'; let traderRunning = false; @@ -41,6 +42,16 @@ export function startMonitorCron(): void { } }); + // Expired Idempotency-Key rows (24h TTL, #123) — hourly at :10, in batches of 5 000 + cron.schedule('10 * * * *', async () => { + try { + const deleted = await purgeExpiredIdempotencyKeys(); + if (deleted > 0) logger.info({ deleted }, 'Monitor cron: purged expired idempotency keys'); + } catch (err) { + logger.error({ err }, 'Monitor cron: idempotency key purge failed'); + } + }); + // Copy trade sweep — every 5 minutes cron.schedule('*/5 * * * *', async () => { await sweepCopyTrades(); diff --git a/src/middlewares/idempotency.middleware.ts b/src/middlewares/idempotency.middleware.ts new file mode 100644 index 0000000..4b26b86 --- /dev/null +++ b/src/middlewares/idempotency.middleware.ts @@ -0,0 +1,295 @@ +import type { NextFunction, Request, RequestHandler, Response } from 'express'; +import { v4 as uuidv4 } from 'uuid'; +import { prisma } from '../lib/prisma'; +import { logger } from '../lib/logger'; +import { canonicalJson, sha256Hex } from '../utils/hash.util'; +import { sendError } from '../utils/response.util'; + +/** + * Idempotency-Key support for state-changing endpoints (#123), following + * draft-ietf-httpapi-idempotency-key-header (the same model Stripe uses). + * + * The client sends a unique `Idempotency-Key` per logical operation and reuses + * it on retries. The first request with a key owns it; its final response is + * stored and replayed for exact repeats (`Idempotent-Replayed: true`). Reusing + * a key with a different request is 422 `IDEMPOTENCY_KEY_MISMATCH`. A repeat + * while the first is still running is 409 `IDEMPOTENCY_IN_PROGRESS` with + * `Retry-After`. A request that crashed mid-flight leaves a lock that expires + * after `lockSeconds`, after which a retry takes the key over. + * + * Keys are scoped by (userId or "anon", method, route template, key), so the + * same key on another route or from another user never collides. + * + * Must run after auth (it scopes by `req.user`) and after body parsing. + */ + +export const IDEMPOTENCY_HEADER = 'Idempotency-Key'; +export const REPLAYED_HEADER = 'Idempotent-Replayed'; + +const DEFAULT_TTL_HOURS = 24; +/** Must exceed the slowest guarded handler (POST /traces/verify fetches IPFS with a 20s timeout). */ +const DEFAULT_LOCK_SECONDS = 60; +const MAX_STORED_BODY_BYTES = 64 * 1024; +/** Printable ASCII, 1–255 chars (the draft recommends a UUID). */ +const KEY_PATTERN = /^[\x20-\x7E]{1,255}$/; +/** Only these response headers are stored and replayed. Never Set-Cookie / Authorization. */ +const REPLAY_HEADER_ALLOWLIST = ['content-type', 'location', 'api-version']; +/** Same spirit as the pino redact list in src/lib/logger.ts. */ +const REDACT_KEY_PATTERN = /token|secret|authorization|password|private.?key/i; +const TOO_LARGE_MARKER = { __idempotencyResponseTooLarge: true } as const; + +export type IdempotencyStatus = 'IN_PROGRESS' | 'COMPLETED'; + +export interface IdempotencyRecord { + id: string; + fingerprint: string; + status: IdempotencyStatus; + lockedUntil: Date; + expiresAt: Date; + responseCode: number | null; + responseBody: unknown; + responseHeaders: Record | null; +} + +export interface IdempotencyScope { + scope: string; + method: string; + route: string; + key: string; +} + +/** Storage behind the middleware. Postgres in production; swappable in tests. */ +export interface IdempotencyStore { + /** Insert an IN_PROGRESS row. Returns its id, or null if the key already exists. */ + claim(s: IdempotencyScope, row: { id: string; fingerprint: string; lockedUntil: Date; expiresAt: Date }): Promise; + find(s: IdempotencyScope): Promise; + /** + * Take over a row whose owner is gone: an IN_PROGRESS row with an expired + * lock, or any row past `expiresAt`. Conditional, so only one retry wins. + */ + takeOver(id: string, row: { fingerprint: string; lockedUntil: Date; expiresAt: Date }, now: Date): Promise; + complete(id: string, code: number, body: unknown, headers: Record): Promise; + release(id: string): Promise; + /** Delete up to `batchSize` expired rows; returns how many were deleted. */ + purgeExpired(now: Date, batchSize: number): Promise; +} + +/** + * Postgres store. Everything goes through raw SQL: the `ON CONFLICT DO NOTHING` + * insert is the only synchronization point between instances (no in-memory + * map), and the conditional UPDATE makes stale-lock takeover race-free. + */ +export const postgresIdempotencyStore: IdempotencyStore = { + async claim(s, row) { + const rows = await prisma.$queryRaw<{ id: string }[]>` + INSERT INTO "idempotency_keys" + ("id", "scope", "method", "route", "key", "fingerprint", "status", "lockedUntil", "expiresAt") + VALUES + (${row.id}, ${s.scope}, ${s.method}, ${s.route}, ${s.key}, ${row.fingerprint}, 'IN_PROGRESS', ${row.lockedUntil}, ${row.expiresAt}) + ON CONFLICT ("scope", "method", "route", "key") DO NOTHING + RETURNING "id"`; + return rows[0]?.id ?? null; + }, + async find(s) { + const rows = await prisma.$queryRaw` + SELECT "id", "fingerprint", "status", "lockedUntil", "expiresAt", + "responseCode", "responseBody", "responseHeaders" + FROM "idempotency_keys" + WHERE "scope" = ${s.scope} AND "method" = ${s.method} AND "route" = ${s.route} AND "key" = ${s.key}`; + return rows[0] ?? null; + }, + async takeOver(id, row, now) { + const rows = await prisma.$queryRaw<{ id: string }[]>` + UPDATE "idempotency_keys" + SET "fingerprint" = ${row.fingerprint}, "status" = 'IN_PROGRESS', + "lockedUntil" = ${row.lockedUntil}, "expiresAt" = ${row.expiresAt}, + "responseCode" = NULL, "responseBody" = NULL, "responseHeaders" = NULL + WHERE "id" = ${id} + AND (("status" = 'IN_PROGRESS' AND "lockedUntil" < ${now}) OR "expiresAt" < ${now}) + RETURNING "id"`; + return rows.length > 0; + }, + async complete(id, code, body, headers) { + await prisma.$executeRaw` + UPDATE "idempotency_keys" + SET "status" = 'COMPLETED', "responseCode" = ${code}, + "responseBody" = ${JSON.stringify(body ?? null)}::jsonb, + "responseHeaders" = ${JSON.stringify(headers)}::jsonb + WHERE "id" = ${id}`; + }, + async release(id) { + await prisma.$executeRaw`DELETE FROM "idempotency_keys" WHERE "id" = ${id}`; + }, + async purgeExpired(now, batchSize) { + return prisma.$executeRaw` + DELETE FROM "idempotency_keys" + WHERE "id" IN ( + SELECT "id" FROM "idempotency_keys" WHERE "expiresAt" < ${now} LIMIT ${batchSize} + )`; + }, +}; + +let store: IdempotencyStore = postgresIdempotencyStore; + +/** Test seam: swap the store and get the previous one back. */ +export function setIdempotencyStore(next: IdempotencyStore): IdempotencyStore { + const prev = store; + store = next; + return prev; +} + +/** Recursively replace values under sensitive-looking keys. Defence in depth. */ +export function redactSensitive(value: unknown): unknown { + if (Array.isArray(value)) return value.map(redactSensitive); + if (value !== null && typeof value === 'object') { + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + out[k] = REDACT_KEY_PATTERN.test(k) ? '[redacted]' : redactSensitive(v); + } + return out; + } + return value; +} + +function routeTemplate(req: Request): string { + // Route-level middleware runs after matching, so req.route is the template + // ("/copy/:id/confirm"), not the concrete path. + const path = (req.route as { path?: string } | undefined)?.path ?? req.path; + return `${req.baseUrl}${path}`; +} + +export function requestFingerprint(req: Request, route: string): string { + return sha256Hex( + canonicalJson({ + method: req.method, + route, + params: req.params ?? {}, + // Parsed object, so parameter order doesn't change the fingerprint. + query: req.query ?? {}, + body: req.body ?? null, + }), + ); +} + +function replay(res: Response, record: IdempotencyRecord): void { + if ( + record.responseBody !== null && + typeof record.responseBody === 'object' && + (record.responseBody as Record).__idempotencyResponseTooLarge + ) { + sendError(res, 409, 'IDEMPOTENCY_RESPONSE_UNAVAILABLE', 'The original response was too large to store; retry with a new Idempotency-Key'); + return; + } + for (const [name, value] of Object.entries(record.responseHeaders ?? {})) { + if (REPLAY_HEADER_ALLOWLIST.includes(name.toLowerCase())) res.setHeader(name, value); + } + res.setHeader(REPLAYED_HEADER, 'true'); + res.status(record.responseCode ?? 200).json(record.responseBody); +} + +/** Capture the final response, then store it (2xx/4xx) or release the key (5xx). */ +function captureAndStore(res: Response, id: string): void { + let body: unknown; + const originalJson = res.json.bind(res); + res.json = ((payload: unknown) => { + body = payload; + return originalJson(payload); + }) as Response['json']; + + res.on('finish', () => { + const code = res.statusCode; + // Transient server errors stay retryable: drop the key instead of caching. + if (code >= 500) { + store.release(id).catch((err) => logger.error({ err, id }, 'Idempotency: failed to release key')); + return; + } + const headers: Record = {}; + for (const name of REPLAY_HEADER_ALLOWLIST) { + const value = res.getHeader(name); + if (value !== undefined) headers[name] = String(value); + } + const redacted = redactSensitive(body ?? null); + const size = Buffer.byteLength(JSON.stringify(redacted) ?? 'null', 'utf8'); + const stored = size > MAX_STORED_BODY_BYTES ? TOO_LARGE_MARKER : redacted; + store.complete(id, code, stored, headers).catch((err) => logger.error({ err, id }, 'Idempotency: failed to store response')); + }); +} + +export function idempotent(opts: { required?: boolean; ttlHours?: number; lockSeconds?: number } = {}): RequestHandler { + const ttlMs = (opts.ttlHours ?? DEFAULT_TTL_HOURS) * 3_600_000; + const lockMs = (opts.lockSeconds ?? DEFAULT_LOCK_SECONDS) * 1_000; + + return async (req: Request, res: Response, next: NextFunction): Promise => { + const header = req.get(IDEMPOTENCY_HEADER); + if (header === undefined) { + if (opts.required) { + sendError(res, 400, 'IDEMPOTENCY_KEY_REQUIRED', `This endpoint requires an ${IDEMPOTENCY_HEADER} header`); + return; + } + next(); + return; + } + const key = header.trim(); + if (!KEY_PATTERN.test(key)) { + sendError(res, 400, 'IDEMPOTENCY_KEY_INVALID', `${IDEMPOTENCY_HEADER} must be 1–255 printable ASCII characters`); + return; + } + + const route = routeTemplate(req); + const scope: IdempotencyScope = { scope: req.user?.userId ?? 'anon', method: req.method, route, key }; + const fingerprint = requestFingerprint(req, route); + const now = new Date(); + const row = { fingerprint, lockedUntil: new Date(now.getTime() + lockMs), expiresAt: new Date(now.getTime() + ttlMs) }; + + try { + const claimedId = await store.claim(scope, { id: uuidv4(), ...row }); + if (claimedId) { + captureAndStore(res, claimedId); + next(); + return; + } + + const existing = await store.find(scope); + if (!existing) { + // Purged between our insert and read; treat as a transient conflict. + res.setHeader('Retry-After', '1'); + sendError(res, 409, 'IDEMPOTENCY_IN_PROGRESS', 'A request with this Idempotency-Key is in progress'); + return; + } + + const expired = existing.expiresAt.getTime() < now.getTime(); + if (!expired && existing.fingerprint !== fingerprint) { + sendError(res, 422, 'IDEMPOTENCY_KEY_MISMATCH', 'This Idempotency-Key was already used with a different request'); + return; + } + + if (!expired && existing.status === 'COMPLETED') { + replay(res, existing); + return; + } + + const stale = expired || existing.lockedUntil.getTime() < now.getTime(); + if (stale && (await store.takeOver(existing.id, row, now))) { + captureAndStore(res, existing.id); + next(); + return; + } + + res.setHeader('Retry-After', '1'); + sendError(res, 409, 'IDEMPOTENCY_IN_PROGRESS', 'A request with this Idempotency-Key is in progress'); + } catch (err) { + next(err); + } + }; +} + +/** Delete expired keys in batches (scheduled from monitor.cron.ts). */ +export async function purgeExpiredIdempotencyKeys(batchSize = 5_000, maxBatches = 100): Promise { + let total = 0; + for (let i = 0; i < maxBatches; i++) { + const deleted = await store.purgeExpired(new Date(), batchSize); + total += deleted; + if (deleted < batchSize) break; + } + return total; +} diff --git a/src/routes/auth.routes.ts b/src/routes/auth.routes.ts index 92c5044..89ba4ce 100644 --- a/src/routes/auth.routes.ts +++ b/src/routes/auth.routes.ts @@ -4,6 +4,9 @@ import { authLimiter } from '../middlewares/rate-limit.middleware'; const router = Router(); +// No Idempotency-Key here (#123): /challenge is harmless to repeat, and +// /verify returns a JWT that must never be stored for replay (its GETDEL +// challenge already makes a duplicate fail with CHALLENGE_NOT_FOUND). router.post('/challenge', authLimiter, postChallenge); router.post('/verify', authLimiter, postVerify); diff --git a/src/routes/market.routes.ts b/src/routes/market.routes.ts index 89e6f76..fe09035 100644 --- a/src/routes/market.routes.ts +++ b/src/routes/market.routes.ts @@ -9,6 +9,7 @@ import { } from '../controllers/market.controller'; import { requireAdmin, requireAuth } from '../middlewares/auth.middleware'; import { generateLimiter } from '../middlewares/rate-limit.middleware'; +import { idempotent } from '../middlewares/idempotency.middleware'; const router = Router(); @@ -18,7 +19,9 @@ router.get('/on-chain/:onChainMarketId', getMarketByOnChainId); router.get('/on-chain/:onChainMarketId/state', getOnChainMarketState); // Admin only: each run calls paid LLM APIs and the treasury. -router.post('/generate', generateLimiter, requireAuth, requireAdmin, triggerMarketGeneration); +// Idempotency-Key required (#123): a retry replays the stored 202 and its +// jobId instead of starting a second paid market-maker cycle. +router.post('/generate', generateLimiter, requireAuth, requireAdmin, idempotent({ required: true }), triggerMarketGeneration); router.get('/generation-status/:jobId', requireAuth, getMarketGenerationStatus); router.get('/:id', getMarket); diff --git a/src/routes/trace.routes.ts b/src/routes/trace.routes.ts index 2ac6efc..14d6093 100644 --- a/src/routes/trace.routes.ts +++ b/src/routes/trace.routes.ts @@ -9,18 +9,20 @@ import { verifyTrace, } from '../controllers/trace.controller'; import { optionalAuth, requireAuth } from '../middlewares/auth.middleware'; +import { idempotent } from '../middlewares/idempotency.middleware'; const router = Router(); // Public router.get('/', listTraces); router.get('/access/allowance', requireAuth, getMySpendingAllowance); -router.put('/access/allowance', requireAuth, setSpendingAllowance); +router.put('/access/allowance', requireAuth, idempotent(), setSpendingAllowance); router.get('/payments', requireAuth, getMyPaymentEvents); router.get('/:id', optionalAuth, getTrace); // Preview without a token, full trace with a daily pass // Protected -router.post('/verify', requireAuth, verifyTrace); -router.post('/:id/unlock', requireAuth, unlockTrace); +// Idempotency-Key optional (#123). Lock is 60s by default, above verify's 20s IPFS timeout. +router.post('/verify', requireAuth, idempotent(), verifyTrace); +router.post('/:id/unlock', requireAuth, idempotent(), unlockTrace); export default router; diff --git a/src/routes/trade.routes.ts b/src/routes/trade.routes.ts index e99dc09..ace255a 100644 --- a/src/routes/trade.routes.ts +++ b/src/routes/trade.routes.ts @@ -1,10 +1,12 @@ import { Router } from 'express'; import { initiateCopyTrade, confirmCopyTrade } from '../controllers/trade.controller'; import { requireAuth } from '../middlewares/auth.middleware'; +import { idempotent } from '../middlewares/idempotency.middleware'; const router = Router(); -router.post('/copy', requireAuth, initiateCopyTrade); -router.patch('/copy/:id/confirm', requireAuth, confirmCopyTrade); +// #123: required on /copy (each duplicate creates another PENDING CopyTrade). +router.post('/copy', requireAuth, idempotent({ required: true }), initiateCopyTrade); +router.patch('/copy/:id/confirm', requireAuth, idempotent(), confirmCopyTrade); export default router; diff --git a/src/services/subscription.service.ts b/src/services/subscription.service.ts index c629144..1b8bcdb 100644 --- a/src/services/subscription.service.ts +++ b/src/services/subscription.service.ts @@ -72,6 +72,10 @@ export async function checkAccess( * (payment-verification.service.ts). Each transaction hash is used once. * Per-trace unlocks go through the x402 service instead. */ +function isUniqueViolation(err: unknown): boolean { + return (err as { code?: unknown } | null)?.code === 'P2002'; +} + export async function recordPayment(params: { userId: string; walletAddress: string; @@ -110,44 +114,60 @@ export async function recordPayment(params: { const expiresAt = dayjs().add(24, 'hour').toDate(); - const subscription = await prisma.$transaction(async (tx) => { - const created = await tx.subscription.create({ - data: { - userId, - traceId: traceId ?? null, - type: type as SubscriptionType, - status: SubscriptionStatus.ACTIVE, - amountPaid: amount, - currency: SettlementCurrency.USDC, - txHash, - expiresAt, - }, + let subscription; + try { + subscription = await prisma.$transaction(async (tx) => { + const created = await tx.subscription.create({ + data: { + userId, + traceId: traceId ?? null, + type: type as SubscriptionType, + status: SubscriptionStatus.ACTIVE, + amountPaid: amount, + currency: SettlementCurrency.USDC, + txHash, + expiresAt, + }, + }); + + // Unique on txHash: a concurrent request with the same hash fails here. + await tx.paymentEvent.create({ + data: { + userId, + traceId: traceId ?? null, + txHash, + type: type as PaymentEventType, + amount, + amountRaw: transfer.amount.toString(), + fromAddress: transfer.from, + currency: SettlementCurrency.USDC, + status: PaymentEventStatus.CONFIRMED, + confirmedAt: new Date(), + metadata: { subscriptionId: created.id, ledger: transfer.ledger } as any, + }, + }); + + await tx.spendingAllowance.updateMany({ + where: { userId, isActive: true }, + data: { spentToday: { increment: amount } }, + }); + + return created; }); - - // Unique on txHash: a concurrent request with the same hash fails here. - await tx.paymentEvent.create({ - data: { - userId, - traceId: traceId ?? null, - txHash, - type: type as PaymentEventType, - amount, - amountRaw: transfer.amount.toString(), - fromAddress: transfer.from, - currency: SettlementCurrency.USDC, - status: PaymentEventStatus.CONFIRMED, - confirmedAt: new Date(), - metadata: { subscriptionId: created.id, ledger: transfer.ledger } as any, - }, - }); - - await tx.spendingAllowance.updateMany({ - where: { userId, isActive: true }, - data: { spentToday: { increment: amount } }, - }); - - return created; - }); + } catch (err) { + // #123: a concurrent duplicate with the same txHash loses the unique race + // on PaymentEvent.txHash (P2002). Resolve it like the pre-check above + // instead of surfacing a 500, with or without an Idempotency-Key. + if (isUniqueViolation(err)) { + const winner = await prisma.paymentEvent.findUnique({ where: { txHash } }); + if (winner?.userId === userId) { + const sub = await prisma.subscription.findFirst({ where: { txHash } }); + if (sub) return sub; + } + throw new AppError(409, 'PAYMENT_ALREADY_USED', 'This payment has already been used'); + } + throw err; + } logger.info({ subscriptionId: subscription.id, type, userId, amountRaw: transfer.amount.toString() }, 'Subscription created'); return subscription; diff --git a/tests/idempotency.test.ts b/tests/idempotency.test.ts new file mode 100644 index 0000000..9db83cd --- /dev/null +++ b/tests/idempotency.test.ts @@ -0,0 +1,379 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import type { AddressInfo } from 'node:net'; +import { Account, Address, Keypair, Operation, TransactionBuilder, nativeToScVal, rpc } from '@stellar/stellar-sdk'; +import { setupTestEnv } from './helpers/env'; + +setupTestEnv(); +// Admin for the POST /markets/generate replay test; read once when config loads. +const ADMIN = Keypair.random().publicKey(); +process.env.ADMIN_ADDRESSES = ADMIN; + +type Mw = typeof import('../src/middlewares/idempotency.middleware'); +type Rec = import('../src/middlewares/idempotency.middleware').IdempotencyRecord; + +/** In-memory IdempotencyStore with the same semantics as the Postgres one. */ +function memoryStore() { + const rows = new Map(); + const k = (s: { scope: string; method: string; route: string; key: string }) => `${s.scope}|${s.method}|${s.route}|${s.key}`; + const store: import('../src/middlewares/idempotency.middleware').IdempotencyStore = { + async claim(s, row) { + if ([...rows.values()].some((r) => r.k === k(s))) return null; + rows.set(row.id, { k: k(s), id: row.id, fingerprint: row.fingerprint, status: 'IN_PROGRESS', lockedUntil: row.lockedUntil, expiresAt: row.expiresAt, responseCode: null, responseBody: null, responseHeaders: null }); + return row.id; + }, + async find(s) { + return [...rows.values()].find((r) => r.k === k(s)) ?? null; + }, + async takeOver(id, row, now) { + const r = rows.get(id); + if (!r) return false; + const stale = (r.status === 'IN_PROGRESS' && r.lockedUntil < now) || r.expiresAt < now; + if (!stale) return false; + Object.assign(r, { fingerprint: row.fingerprint, status: 'IN_PROGRESS', lockedUntil: row.lockedUntil, expiresAt: row.expiresAt, responseCode: null, responseBody: null, responseHeaders: null }); + return true; + }, + async complete(id, code, body, headers) { + const r = rows.get(id); + if (r) Object.assign(r, { status: 'COMPLETED', responseCode: code, responseBody: JSON.parse(JSON.stringify(body)), responseHeaders: headers }); + }, + async release(id) { + rows.delete(id); + }, + async purgeExpired(now, batch) { + let n = 0; + for (const [id, r] of rows) if (r.expiresAt < now && n < batch) { rows.delete(id); n++; } + return n; + }, + }; + return { store, rows }; +} + +/** Express app with the middleware on a counting handler; `respond` decides the reply. */ +async function testApp(mw: Mw, opts: Parameters[0], respond: (req: any, res: any, n: number) => void | Promise) { + const { default: express } = await import('express'); + const app = express(); + app.use(express.json()); + let calls = 0; + const fakeAuth = (req: any, _res: any, next: any) => { if (req.get('x-user')) req.user = { userId: req.get('x-user') }; next(); }; + const handler = async (req: any, res: any) => { calls++; await respond(req, res, calls); }; + app.post('/things/:id', fakeAuth, mw.idempotent(opts), handler); + app.post('/other/:id', fakeAuth, mw.idempotent(opts), handler); + return { app, calls: () => calls }; +} + +async function serve(app: any, fn: (base: string) => Promise) { + const server = app.listen(0); + try { await fn(`http://127.0.0.1:${(server.address() as AddressInfo).port}`); } finally { server.close(); } +} + +const post = (base: string, path: string, body: unknown, headers: Record = {}, signal?: AbortSignal) => + fetch(`${base}${path}`, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body: JSON.stringify(body), signal }); + +/** `finish` fires after the response is sent; give the async store write a tick. */ +const settle = () => new Promise((r) => setTimeout(r, 20)); + +test('first request stores response; replay returns identical body and Idempotent-Replayed', async () => { + const mw = await import('../src/middlewares/idempotency.middleware'); + const { store } = memoryStore(); + const prev = mw.setIdempotencyStore(store); + try { + const { app, calls } = await testApp(mw, {}, (_req, res, n) => res.status(201).json({ ok: true, data: { n } })); + await serve(app, async (base) => { + const first = await post(base, '/things/1', { a: 1, b: 2 }, { 'idempotency-key': 'k1', 'x-user': 'u1' }); + assert.equal(first.status, 201); + const firstBody = await first.json(); + assert.equal(first.headers.get('idempotent-replayed'), null); + await settle(); + // Same body with keys reordered: same canonical fingerprint. + const again = await post(base, '/things/1', { b: 2, a: 1 }, { 'idempotency-key': 'k1', 'x-user': 'u1' }); + assert.equal(again.status, 201); + assert.deepEqual(await again.json(), firstBody); + assert.equal(again.headers.get('idempotent-replayed'), 'true'); + assert.equal(calls(), 1); + }); + } finally { mw.setIdempotencyStore(prev); } +}); + +test('same key different body -> 422 IDEMPOTENCY_KEY_MISMATCH', async () => { + const mw = await import('../src/middlewares/idempotency.middleware'); + const prev = mw.setIdempotencyStore(memoryStore().store); + try { + const { app, calls } = await testApp(mw, {}, (_req, res) => res.json({ ok: true })); + await serve(app, async (base) => { + await post(base, '/things/1', { a: 1 }, { 'idempotency-key': 'k1', 'x-user': 'u1' }); + await settle(); + const res = await post(base, '/things/1', { a: 2 }, { 'idempotency-key': 'k1', 'x-user': 'u1' }); + assert.equal(res.status, 422); + assert.equal((await res.json()).error.code, 'IDEMPOTENCY_KEY_MISMATCH'); + assert.equal(calls(), 1); + }); + } finally { mw.setIdempotencyStore(prev); } +}); + +test('concurrent duplicate while in flight -> 409 IDEMPOTENCY_IN_PROGRESS with Retry-After', async () => { + const mw = await import('../src/middlewares/idempotency.middleware'); + const prev = mw.setIdempotencyStore(memoryStore().store); + let release!: () => void; + const gate = new Promise((r) => { release = r; }); + try { + const { app, calls } = await testApp(mw, {}, async (_req, res) => { await gate; res.json({ ok: true }); }); + await serve(app, async (base) => { + const first = post(base, '/things/1', { a: 1 }, { 'idempotency-key': 'k1', 'x-user': 'u1' }); + await settle(); + const dup = await post(base, '/things/1', { a: 1 }, { 'idempotency-key': 'k1', 'x-user': 'u1' }); + assert.equal(dup.status, 409); + assert.equal(dup.headers.get('retry-after'), '1'); + assert.equal((await dup.json()).error.code, 'IDEMPOTENCY_IN_PROGRESS'); + release(); + assert.equal((await first).status, 200); + assert.equal(calls(), 1); + }); + } finally { mw.setIdempotencyStore(prev); } +}); + +test('crashed request lock expires and can be taken over', async () => { + const mw = await import('../src/middlewares/idempotency.middleware'); + const prev = mw.setIdempotencyStore(memoryStore().store); + const abort = new AbortController(); + try { + // First call never responds (simulated crash); lock is 50 ms. + const { app, calls } = await testApp(mw, { lockSeconds: 0.05 }, (_req, res, n) => { if (n > 1) res.json({ ok: true, data: { n } }); }); + await serve(app, async (base) => { + post(base, '/things/1', { a: 1 }, { 'idempotency-key': 'k1', 'x-user': 'u1' }, abort.signal).catch(() => undefined); + await settle(); + const blocked = await post(base, '/things/1', { a: 1 }, { 'idempotency-key': 'k1', 'x-user': 'u1' }); + assert.equal(blocked.status, 409); + await new Promise((r) => setTimeout(r, 80)); + const takeover = await post(base, '/things/1', { a: 1 }, { 'idempotency-key': 'k1', 'x-user': 'u1' }); + assert.equal(takeover.status, 200); + assert.equal((await takeover.json()).data.n, 2); + assert.equal(calls(), 2); + }); + } finally { abort.abort(); mw.setIdempotencyStore(prev); } +}); + +test('5xx responses are not stored; retry re-executes', async () => { + const mw = await import('../src/middlewares/idempotency.middleware'); + const { store, rows } = memoryStore(); + const prev = mw.setIdempotencyStore(store); + try { + const { app, calls } = await testApp(mw, {}, (_req, res, n) => (n === 1 ? res.status(503).json({ ok: false }) : res.json({ ok: true }))); + await serve(app, async (base) => { + assert.equal((await post(base, '/things/1', { a: 1 }, { 'idempotency-key': 'k1', 'x-user': 'u1' })).status, 503); + await settle(); + assert.equal(rows.size, 0); + const retry = await post(base, '/things/1', { a: 1 }, { 'idempotency-key': 'k1', 'x-user': 'u1' }); + assert.equal(retry.status, 200); + assert.equal(retry.headers.get('idempotent-replayed'), null); + assert.equal(calls(), 2); + }); + } finally { mw.setIdempotencyStore(prev); } +}); + +test('4xx responses are stored and replayed', async () => { + const mw = await import('../src/middlewares/idempotency.middleware'); + const prev = mw.setIdempotencyStore(memoryStore().store); + try { + const { app, calls } = await testApp(mw, {}, (_req, res) => res.status(403).json({ ok: false, error: { code: 'TRACE_LOCKED' } })); + await serve(app, async (base) => { + await post(base, '/things/1', {}, { 'idempotency-key': 'k1', 'x-user': 'u1' }); + await settle(); + const again = await post(base, '/things/1', {}, { 'idempotency-key': 'k1', 'x-user': 'u1' }); + assert.equal(again.status, 403); + assert.equal(again.headers.get('idempotent-replayed'), 'true'); + assert.equal(calls(), 1); + }); + } finally { mw.setIdempotencyStore(prev); } +}); + +test('keys are scoped per user and per route', async () => { + const mw = await import('../src/middlewares/idempotency.middleware'); + const prev = mw.setIdempotencyStore(memoryStore().store); + try { + const { app, calls } = await testApp(mw, {}, (_req, res) => res.json({ ok: true })); + await serve(app, async (base) => { + const h = { 'idempotency-key': 'shared' }; + assert.equal((await post(base, '/things/1', { a: 1 }, { ...h, 'x-user': 'u1' })).headers.get('idempotent-replayed'), null); + await settle(); + // Different user, same key and body: runs. + assert.equal((await post(base, '/things/1', { a: 1 }, { ...h, 'x-user': 'u2' })).headers.get('idempotent-replayed'), null); + await settle(); + // Different route template, same user and key: runs. + assert.equal((await post(base, '/other/1', { a: 1 }, { ...h, 'x-user': 'u1' })).headers.get('idempotent-replayed'), null); + await settle(); + // Different :id on the same template is a different request: mismatch, not a replay. + assert.equal((await post(base, '/things/2', { a: 1 }, { ...h, 'x-user': 'u1' })).status, 422); + assert.equal(calls(), 3); + }); + } finally { mw.setIdempotencyStore(prev); } +}); + +test('stored response never contains authorization headers or secrets; oversized bodies are not replayed', async () => { + const mw = await import('../src/middlewares/idempotency.middleware'); + const { store, rows } = memoryStore(); + const prev = mw.setIdempotencyStore(store); + try { + const { app } = await testApp(mw, {}, (req, res) => { + res.setHeader('Set-Cookie', 'sid=abc'); + res.setHeader('Authorization', 'Bearer leak'); + res.setHeader('Location', '/things/1'); + if (req.body.big) res.json({ blob: 'x'.repeat(70 * 1024) }); + else res.json({ ok: true, data: { accessToken: 'jwt', nested: { clientSecret: 's' }, keep: 1 } }); + }); + await serve(app, async (base) => { + await post(base, '/things/1', {}, { 'idempotency-key': 'k1', 'x-user': 'u1' }); + await settle(); + const [row] = [...rows.values()]; + assert.deepEqual(Object.keys(row.responseHeaders ?? {}).sort(), ['content-type', 'location']); + const stored = JSON.stringify(row.responseBody); + assert.ok(!stored.includes('jwt') && !stored.includes('"s"')); + assert.match(stored, /"keep":1/); + const replay = await post(base, '/things/1', {}, { 'idempotency-key': 'k1', 'x-user': 'u1' }); + assert.equal(replay.headers.get('set-cookie'), null); + assert.equal(replay.headers.get('location'), '/things/1'); + + await post(base, '/things/1', { big: true }, { 'idempotency-key': 'k2', 'x-user': 'u1' }); + await settle(); + const big = await post(base, '/things/1', { big: true }, { 'idempotency-key': 'k2', 'x-user': 'u1' }); + assert.equal(big.status, 409); + assert.equal((await big.json()).error.code, 'IDEMPOTENCY_RESPONSE_UNAVAILABLE'); + }); + } finally { mw.setIdempotencyStore(prev); } +}); + +test('missing key is fine when optional; malformed key is rejected', async () => { + const mw = await import('../src/middlewares/idempotency.middleware'); + const prev = mw.setIdempotencyStore(memoryStore().store); + try { + const { app, calls } = await testApp(mw, {}, (_req, res) => res.json({ ok: true })); + await serve(app, async (base) => { + assert.equal((await post(base, '/things/1', {})).status, 200); + assert.equal((await post(base, '/things/1', {})).status, 200); + assert.equal(calls(), 2); + const bad = await post(base, '/things/1', {}, { 'idempotency-key': 'x'.repeat(256) }); + assert.equal(bad.status, 400); + assert.equal((await bad.json()).error.code, 'IDEMPOTENCY_KEY_INVALID'); + }); + } finally { mw.setIdempotencyStore(prev); } +}); + +test('purgeExpiredIdempotencyKeys deletes only expired keys', async () => { + const mw = await import('../src/middlewares/idempotency.middleware'); + const { store, rows } = memoryStore(); + const prev = mw.setIdempotencyStore(store); + try { + const past = new Date(Date.now() - 1000); + const future = new Date(Date.now() + 3_600_000); + await store.claim({ scope: 'u', method: 'POST', route: '/a', key: 'old' }, { id: 'old', fingerprint: 'f', lockedUntil: past, expiresAt: past }); + await store.claim({ scope: 'u', method: 'POST', route: '/a', key: 'new' }, { id: 'new', fingerprint: 'f', lockedUntil: future, expiresAt: future }); + assert.equal(await mw.purgeExpiredIdempotencyKeys(5_000), 1); + assert.deepEqual([...rows.keys()], ['new']); + } finally { mw.setIdempotencyStore(prev); } +}); + +async function withAppServer(fn: (base: string) => Promise) { + const { default: app } = await import('../src/app'); + await serve(app, fn); +} + +test('POST /trade/copy without key -> 400 IDEMPOTENCY_KEY_REQUIRED', async () => { + const { signToken } = await import('../src/middlewares/auth.middleware'); + await withAppServer(async (base) => { + const token = signToken('u1', Keypair.random().publicKey()); + const res = await post(base, '/api/v1/trade/copy', {}, { authorization: `Bearer ${token}` }); + assert.equal(res.status, 400); + assert.equal((await res.json()).error.code, 'IDEMPOTENCY_KEY_REQUIRED'); + }); +}); + +test('POST /markets/generate replay returns same jobId and starts one cycle', async () => { + const mw = await import('../src/middlewares/idempotency.middleware'); + const agent = await import('../src/agents/market-maker.agent'); + const { prisma } = await import('../src/lib/prisma'); + const { signToken } = await import('../src/middlewares/auth.middleware'); + const prev = mw.setIdempotencyStore(memoryStore().store); + let cycles = 0; + const origCycle = agent.runMarketMakerCycle; + const origLog = prisma.agentLog.create; + (agent as any).runMarketMakerCycle = async () => { cycles++; return {}; }; + (prisma.agentLog.create as any) = async () => ({}); + try { + await withAppServer(async (base) => { + const auth = { authorization: `Bearer ${signToken('admin-1', ADMIN)}` }; + const missing = await post(base, '/api/v1/markets/generate', {}, auth); + assert.equal(missing.status, 400); + assert.equal((await missing.json()).error.code, 'IDEMPOTENCY_KEY_REQUIRED'); + + const first = await post(base, '/api/v1/markets/generate', {}, { ...auth, 'idempotency-key': 'gen-1' }); + assert.equal(first.status, 202); + const firstJob = (await first.json()).data.jobId; + await settle(); + const again = await post(base, '/api/v1/markets/generate', {}, { ...auth, 'idempotency-key': 'gen-1' }); + assert.equal(again.status, 202); + assert.equal(again.headers.get('idempotent-replayed'), 'true'); + assert.equal((await again.json()).data.jobId, firstJob); + assert.equal(cycles, 1); + }); + } finally { + (agent as any).runMarketMakerCycle = origCycle; + (prisma.agentLog.create as any) = origLog; + mw.setIdempotencyStore(prev); + } +}); + +test('CORS allows Idempotency-Key and exposes Idempotent-Replayed', async () => { + await withAppServer(async (base) => { + const pre = await fetch(`${base}/api/v1/trade/copy`, { + method: 'OPTIONS', + headers: { origin: 'http://localhost:3000', 'access-control-request-method': 'POST', 'access-control-request-headers': 'idempotency-key,content-type' }, + }); + assert.match(pre.headers.get('access-control-allow-headers') ?? '', /Idempotency-Key/i); + const get = await fetch(`${base}/health`, { headers: { origin: 'http://localhost:3000' } }); + assert.match(get.headers.get('access-control-expose-headers') ?? '', /Idempotent-Replayed/i); + }); +}); + +test('recordPayment concurrent duplicate returns existing subscription instead of 500', async () => { + const verification = await import('../src/services/payment-verification.service'); + const { CONTRACTS, PAYMENTS_RECIPIENT } = await import('../src/config/contracts'); + const { config } = await import('../src/config'); + const { prisma } = await import('../src/lib/prisma'); + const { recordPayment } = await import('../src/services/subscription.service'); + const HASH = 'cd'.repeat(32); + const user = Keypair.random().publicKey(); + const tx = new TransactionBuilder(new Account(user, '1'), { fee: '100', networkPassphrase: config.STELLAR_NETWORK_PASSPHRASE }) + .addOperation(Operation.invokeContractFunction({ + contract: CONTRACTS.usdc, + function: 'transfer', + args: [new Address(user).toScVal(), new Address(PAYMENTS_RECIPIENT).toScVal(), nativeToScVal(5_000_000n, { type: 'i128' })], + })) + .setTimeout(30) + .build(); + const prevReader = verification.setTransactionReader({ + async getTransaction(hash: string) { + return { status: rpc.Api.GetTransactionStatus.SUCCESS, txHash: hash, ledger: 42, envelopeXdr: tx.toEnvelope() } as any; + }, + }); + const winnerSub = { id: 'sub-winner', txHash: HASH, status: 'ACTIVE' }; + let winnerUser = 'u1'; + // Pre-check sees nothing (the race); the transaction then loses on the unique index. + let lookups = 0; + (prisma.paymentEvent.findUnique as any) = async () => (lookups++ === 0 ? null : { userId: winnerUser }); + (prisma.subscription.findFirst as any) = async () => winnerSub; + (prisma.spendingAllowance.findUnique as any) = async () => null; + (prisma.$transaction as any) = async () => { throw Object.assign(new Error('Unique constraint failed'), { code: 'P2002' }); }; + try { + const sub = await recordPayment({ userId: 'u1', walletAddress: user, type: 'DAILY_PASS', txHash: HASH }); + assert.equal(sub.id, 'sub-winner'); + + lookups = 0; + winnerUser = 'someone-else'; + await assert.rejects( + recordPayment({ userId: 'u1', walletAddress: user, type: 'DAILY_PASS', txHash: HASH }), + (e: any) => e.statusCode === 409 && e.code === 'PAYMENT_ALREADY_USED', + ); + } finally { + verification.setTransactionReader(prevReader); + } +});