Spaces:
Sleeping
Sleeping
| import { Request, Response, NextFunction } from 'express'; | |
| import { db } from '../db/database'; | |
| const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); | |
| // Reject pathological client-supplied keys outright instead of hashing | |
| // everything — 128 chars is plenty for any realistic UUID / ULID / nonce. | |
| const MAX_KEY_LENGTH = 128; | |
| // Responses larger than this are not worth caching — a backup-restore | |
| // endpoint could otherwise store a megabyte-sized JSON body per request | |
| // key and, with mass key creation, blow up idempotency_keys. | |
| const MAX_CACHED_BODY_BYTES = 256 * 1024; | |
| interface IdempotencyRow { | |
| status_code: number; | |
| response_body: string; | |
| } | |
| /** | |
| * Called from within `authenticate` after req.user is set. | |
| * | |
| * For mutating requests carrying X-Idempotency-Key: | |
| * - If (key, userId, method, path) already stored: replays the cached response. | |
| * - Otherwise: wraps res.json to capture and store a successful response. | |
| * | |
| * The lookup is scoped by method + path as well as user so the same key | |
| * replayed against a different endpoint doesn't return the cached body | |
| * of an unrelated request. Key length is capped and the cached body is | |
| * skipped when it exceeds `MAX_CACHED_BODY_BYTES`. | |
| * | |
| * Storing happens in idempotency_keys (24h TTL, cleaned by scheduler). | |
| */ | |
| export function applyIdempotency(req: Request, res: Response, next: NextFunction, userId: number): void { | |
| if (!MUTATING_METHODS.has(req.method)) { | |
| next(); | |
| return; | |
| } | |
| const key = req.headers['x-idempotency-key'] as string | undefined; | |
| if (!key) { | |
| next(); | |
| return; | |
| } | |
| if (key.length > MAX_KEY_LENGTH) { | |
| res.status(400).json({ error: 'X-Idempotency-Key exceeds maximum length of 128 characters' }); | |
| return; | |
| } | |
| // Return cached response only if the same key was seen for the same | |
| // user AND the same method+path — avoids a POST's cached body leaking | |
| // into an unrelated PATCH that reused the idempotency-key string. | |
| const existing = db.prepare( | |
| 'SELECT status_code, response_body FROM idempotency_keys WHERE key = ? AND user_id = ? AND method = ? AND path = ?' | |
| ).get(key, userId, req.method, req.path) as IdempotencyRow | undefined; | |
| if (existing) { | |
| res.status(existing.status_code).json(JSON.parse(existing.response_body)); | |
| return; | |
| } | |
| // Wrap res.json to capture the response on first successful execution | |
| const originalJson = res.json.bind(res); | |
| res.json = function (body: unknown): Response { | |
| if (res.statusCode >= 200 && res.statusCode < 300) { | |
| try { | |
| const serialized = JSON.stringify(body); | |
| if (serialized.length <= MAX_CACHED_BODY_BYTES) { | |
| db.prepare( | |
| `INSERT OR IGNORE INTO idempotency_keys (key, user_id, method, path, status_code, response_body, created_at) | |
| VALUES (?, ?, ?, ?, ?, ?, ?)` | |
| ).run(key, userId, req.method, req.path, res.statusCode, serialized, Math.floor(Date.now() / 1000)); | |
| } | |
| } catch { | |
| // Non-fatal: if storage fails, the request still succeeds | |
| } | |
| } | |
| return originalJson(body); | |
| }; | |
| next(); | |
| } | |