File size: 3,093 Bytes
57a889c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
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();
}