Spaces:
Sleeping
Sleeping
File size: 3,793 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 82 83 84 85 86 87 88 89 90 91 | import { CallHandler, ExecutionContext, HttpException, Injectable, NestInterceptor } from '@nestjs/common';
import type { Request, Response } from 'express';
import { Observable, of } from 'rxjs';
import { DatabaseService } from '../database/database.service';
/**
* Nest counterpart of the legacy `applyIdempotency` middleware
* (server/src/middleware/idempotency.ts), which the Express `authenticate`
* middleware runs on every authenticated request.
*
* The TREK client attaches an `X-Idempotency-Key` to ALL write operations (see
* client/src/api/client.ts) and the offline sync queue replays mutations with
* that key, so a migrated mutating route MUST honour it — otherwise a replayed
* POST would create a duplicate instead of returning the cached response. This
* reproduces the legacy behaviour exactly, against the same `idempotency_keys`
* table:
* - non-mutating method, or no key, or no authenticated user -> pass through
* - key longer than the cap -> 400 with the exact legacy message
* - (key, user, method, path) already stored -> replay the cached response
* - otherwise -> capture a successful JSON response under the key
*
* Capturing wraps `res.json`, so 204 / `res.end()` responses are not cached —
* matching the Express wrapper, which only fires on `res.json`.
*/
const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
const MAX_KEY_LENGTH = 128;
const MAX_CACHED_BODY_BYTES = 256 * 1024;
interface IdempotencyRow {
status_code: number;
response_body: string;
}
@Injectable()
export class IdempotencyInterceptor implements NestInterceptor {
constructor(private readonly database: DatabaseService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const req = context.switchToHttp().getRequest<Request & { user?: { id: number } }>();
const res = context.switchToHttp().getResponse<Response>();
if (!MUTATING_METHODS.has(req.method)) return next.handle();
const key = req.headers['x-idempotency-key'] as string | undefined;
if (!key) return next.handle();
// Idempotency only applies to authenticated requests — the legacy code runs
// inside `authenticate`, after req.user is set.
const userId = req.user?.id;
if (userId == null) return next.handle();
if (key.length > MAX_KEY_LENGTH) {
throw new HttpException({ error: 'X-Idempotency-Key exceeds maximum length of 128 characters' }, 400);
}
// Scope the lookup by method + path as well as user, so the same key replayed
// against a different endpoint can't return an unrelated cached body.
const existing = this.database.get<IdempotencyRow>(
'SELECT status_code, response_body FROM idempotency_keys WHERE key = ? AND user_id = ? AND method = ? AND path = ?',
key, userId, req.method, req.path,
);
if (existing) {
res.status(existing.status_code);
return of(JSON.parse(existing.response_body));
}
const originalJson = res.json.bind(res);
const database = this.database;
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) {
database.run(
`INSERT OR IGNORE INTO idempotency_keys (key, user_id, method, path, status_code, response_body, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
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);
};
return next.handle();
}
}
|