Skip to content
Merged
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
51 changes: 51 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions prisma/migrations/20261017000008_idempotency_keys/migration.sql
Original file line number Diff line number Diff line change
@@ -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");

26 changes: 26 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
3 changes: 3 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───
Expand Down
11 changes: 11 additions & 0 deletions src/cron/monitor.cron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading