diff --git a/app/backend/src/app.module.ts b/app/backend/src/app.module.ts index 63637956..3935b65e 100644 --- a/app/backend/src/app.module.ts +++ b/app/backend/src/app.module.ts @@ -43,6 +43,7 @@ import { DeploymentMetadataModule } from './deployment-metadata/deployment-metad import { RedisModule } from '@liaoliaots/nestjs-redis'; import { AdaptiveRateLimitGuard } from './common/guards/adaptive-rate-limit.guard'; import { DeprecationInterceptor } from './common/interceptors/deprecation.interceptor'; +import { HttpCacheInterceptor } from './common/interceptors/http-cache.interceptor'; import { SandboxModule } from './sandbox/sandbox.module'; @Module({ @@ -158,6 +159,12 @@ import { SandboxModule } from './sandbox/sandbox.module'; provide: APP_INTERCEPTOR, useClass: DeprecationInterceptor, }, + { + provide: APP_INTERCEPTOR, + // Registered last so it runs closest to the route handler and + // observes the raw response body for ETag computation. + useClass: HttpCacheInterceptor, + }, ], }) export class AppModule implements NestModule { diff --git a/app/backend/src/common/decorators/http-cache.decorator.ts b/app/backend/src/common/decorators/http-cache.decorator.ts new file mode 100644 index 00000000..f2a2ba34 --- /dev/null +++ b/app/backend/src/common/decorators/http-cache.decorator.ts @@ -0,0 +1,76 @@ +import { SetMetadata } from '@nestjs/common'; + +/** + * HTTP cache metadata. + * + * Controllers and route handlers can opt in or out of caching and tweak + * the Cache-Control directives emitted by `HttpCacheInterceptor`. + */ +export interface HttpCacheOptions { + /** + * When true, the response is allowed to be cached by shared caches. + * Use sparingly: only for endpoints that do not depend on the + * authenticated principal (Authorization). Defaults to `false` + * ("private, must-revalidate") which is safe for NGO-scoped data. + */ + public?: boolean; + + /** + * Optional explicit TTL in seconds. When set, expands the directive + * with `max-age=`. A value of `0` downgrades to + * `no-cache` (effective revalidation via ETag) while keeping the + * `ETag` header. + */ + ttl?: number; +} + +export const HTTP_CACHE_METADATA = 'http_cache:options'; +export const HTTP_CACHE_SKIP = 'http_cache:skip'; + +/** + * Skip HTTP caching entirely for the decorated handler or controller. + * Use for endpoints that: + * - return sensitive or per-user content that must never be cached, + * - already set their own cache headers, + * - stream binary content (e.g., file downloads). + * + * @example + * @Get('secret') + * @SkipHttpCache() + * getSecret() { ... } + */ +export const SkipHttpCache = (): MethodDecorator & ClassDecorator => + SetMetadata(HTTP_CACHE_SKIP, true); + +/** + * Override the default Cache-Control TTL (max-age) for the decorated + * handler. The default visibility (`private`) is preserved unless + * paired with `@HttpCache({ public: true })`. + * + * @example + * @Get('campaigns') + * @HttpCacheTtl(60) + * list() { ... } // → Cache-Control: private, max-age=60, must-revalidate + */ +export const HttpCacheTtl = (ttl: number): MethodDecorator & ClassDecorator => + SetMetadata(HTTP_CACHE_METADATA, { ttl }); + +/** + * Configure HTTP cache directives explicitly for the decorated handler. + * + * - `public: true` switches the directive to `public` so CDN / shared + * caches may store the response. Only use for endpoints where the + * payload is identical for every principal. + * - `ttl` sets `max-age=`; omit it to delegate to the global + * default (currently `must-revalidate` / no `max-age`). + * + * @example + * @Get('public/stats') + * @Public() + * @HttpCache({ public: true, ttl: 300 }) + * stats() { ... } // → Cache-Control: public, max-age=300 + */ +export const HttpCache = ( + options: HttpCacheOptions, +): MethodDecorator & ClassDecorator => + SetMetadata(HTTP_CACHE_METADATA, { ...options }); diff --git a/app/backend/src/common/interceptors/__tests__/http-cache.interceptor.spec.ts b/app/backend/src/common/interceptors/__tests__/http-cache.interceptor.spec.ts new file mode 100644 index 00000000..0486a073 --- /dev/null +++ b/app/backend/src/common/interceptors/__tests__/http-cache.interceptor.spec.ts @@ -0,0 +1,418 @@ +import 'reflect-metadata'; +import { ExecutionContext, CallHandler } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { ConfigService } from '@nestjs/config'; +import { firstValueFrom, of } from 'rxjs'; +import { HttpCacheInterceptor } from '../http-cache.interceptor'; +import { + HTTP_CACHE_METADATA, + HTTP_CACHE_SKIP, +} from '../../decorators/http-cache.decorator'; + +interface FakeResponse { + headers: Record; + statusCode: number; + status(code: number): FakeResponse; + setHeader(name: string, value: string | number): void; + getHeader(name: string): string | number | undefined; + removeHeader(name: string): void; +} + +const createResponse = (): FakeResponse => { + const headers: Record = {}; + return { + headers, + statusCode: 200, + status(code: number) { + this.statusCode = code; + return this; + }, + setHeader(name: string, value: string | number) { + headers[name.toLowerCase()] = String(value); + }, + getHeader(name: string) { + return headers[name.toLowerCase()]; + }, + removeHeader(name: string) { + delete headers[name.toLowerCase()]; + }, + }; +}; + +const decorated = (key: string, value: unknown) => { + const fn: (...args: unknown[]) => unknown = (..._args: unknown[]) => + undefined; + Reflect.defineMetadata(key, value, fn); + return fn; +}; + +const createContext = ({ + method = 'GET', + path = '/api/v1/campaigns', + handler = decorated(HTTP_CACHE_SKIP, false), + controller = class {}, + ifNoneMatch, +}: { + method?: string; + path?: string; + handler?: object; + controller?: object; + ifNoneMatch?: string; +}): { context: ExecutionContext; response: FakeResponse } => { + const headers: Record = {}; + if (ifNoneMatch !== undefined) headers['if-none-match'] = ifNoneMatch; + + const request = { + method, + path, + baseUrl: '', + url: path, + originalUrl: path, + headers, + }; + const response = createResponse(); + + const context: ExecutionContext = { + getHandler: () => handler, + getClass: () => controller, + switchToHttp: () => ({ + getRequest: () => request, + getResponse: () => response, + }), + } as unknown as ExecutionContext; + + return { context, response }; +}; + +describe('HttpCacheInterceptor', () => { + const configGet = jest.fn(); + const reflector = new Reflector(); + + const buildInterceptor = () => + new HttpCacheInterceptor(reflector, { + get: configGet, + } as unknown as ConfigService); + + beforeEach(() => { + configGet.mockReset(); + }); + + describe('global enable / disable', () => { + it('passes through when HTTP_CACHE_ENABLED=false', async () => { + configGet.mockImplementation((key: string) => + key === 'HTTP_CACHE_ENABLED' ? 'false' : undefined, + ); + const { context, response } = createContext({}); + const next: CallHandler = { handle: () => of({ a: 1 }) }; + const result = await firstValueFrom( + buildInterceptor().intercept(context, next), + ); + + expect(result).toEqual({ a: 1 }); + expect(response.statusCode).toBe(200); + expect(response.getHeader('ETag')).toBeUndefined(); + expect(response.getHeader('Cache-Control')).toBeUndefined(); + }); + }); + + describe('mutating methods', () => { + it('sets Cache-Control: no-store on POST/PUT/PATCH/DELETE', async () => { + configGet.mockReturnValue(undefined); + const interceptor = buildInterceptor(); + + for (const method of ['POST', 'PUT', 'PATCH', 'DELETE']) { + const { context, response } = createContext({ method }); + const next: CallHandler = { handle: () => of({}) }; + await firstValueFrom(interceptor.intercept(context, next)); + expect(response.getHeader('Cache-Control')).toBe('no-store'); + } + }); + + it('skips the debug header in production', async () => { + configGet.mockImplementation((key: string) => + key === 'NODE_ENV' ? 'production' : undefined, + ); + const interceptor = buildInterceptor(); + const { context, response } = createContext({ method: 'POST' }); + await firstValueFrom( + interceptor.intercept(context, { handle: () => of({}) }), + ); + expect(response.getHeader('X-Http-Cache')).toBeUndefined(); + }); + + it('still applies no-store on a path that is otherwise always-skipped', async () => { + configGet.mockReturnValue(undefined); + const interceptor = buildInterceptor(); + const { context, response } = createContext({ + method: 'POST', + path: '/api/docs', + }); + await firstValueFrom( + interceptor.intercept(context, { handle: () => of({}) }), + ); + expect(response.getHeader('Cache-Control')).toBe('no-store'); + }); + + it('does not interfere with HEAD / OPTIONS', async () => { + configGet.mockReturnValue(undefined); + const interceptor = buildInterceptor(); + for (const method of ['HEAD', 'OPTIONS']) { + const { context, response } = createContext({ method }); + const next: CallHandler = { handle: () => of({}) }; + await firstValueFrom(interceptor.intercept(context, next)); + expect(response.getHeader('Cache-Control')).toBeUndefined(); + expect(response.getHeader('ETag')).toBeUndefined(); + } + }); + + it('HEAD also receives Cache-Control + ETag', async () => { + configGet.mockReturnValue(undefined); + const interceptor = buildInterceptor(); + const { context, response } = createContext({ method: 'HEAD' }); + await firstValueFrom( + interceptor.intercept(context, { handle: () => of({ id: 1 }) }), + ); + expect(response.getHeader('Cache-Control')).toBe( + 'private, must-revalidate', + ); + expect(response.getHeader('Vary')).toBe( + 'Authorization, Accept-Encoding', + ); + expect(response.getHeader('ETag')).toMatch(/^"[a-f0-9]{64}"$/); + }); + + it('HEAD with matching If-None-Match returns 304', async () => { + configGet.mockReturnValue(undefined); + const interceptor = buildInterceptor(); + + const first = createContext({ method: 'HEAD' }); + await firstValueFrom( + interceptor.intercept(first.context, { handle: () => of({ id: 1 }) }), + ); + const tag = first.response.getHeader('ETag') as string; + + const second = createContext({ + method: 'HEAD', + ifNoneMatch: tag, + }); + const body = await firstValueFrom( + interceptor.intercept(second.context, { handle: () => of({ id: 1 }) }), + ); + expect(second.response.statusCode).toBe(304); + expect(body).toBeUndefined(); + }); + }); + + describe('GET request headers', () => { + it('sets private, must-revalidate by default', async () => { + configGet.mockReturnValue(undefined); + const interceptor = buildInterceptor(); + const { context, response } = createContext({}); + await firstValueFrom( + interceptor.intercept(context, { handle: () => of({ id: 1 }) }), + ); + expect(response.getHeader('Cache-Control')).toBe( + 'private, must-revalidate', + ); + expect(response.getHeader('Vary')).toBe( + 'Authorization, Accept-Encoding', + ); + expect(response.getHeader('ETag')).toMatch(/^"[a-f0-9]{64}"$/); + expect(response.getHeader('X-Http-Cache')).toBe('miss'); + }); + + it('emits deterministic ETags across key reorderings', async () => { + configGet.mockReturnValue(undefined); + const interceptor = buildInterceptor(); + + const firstCtx = createContext({}); + const first = await firstValueFrom( + interceptor.intercept(firstCtx.context, { + handle: () => of({ z: 1, a: 2, m: { y: 1, x: 2 } }), + }), + ); + const firstTag = firstCtx.response.getHeader('ETag') as string; + + const secondCtx = createContext({ path: '/api/v1/y' }); + const second = await firstValueFrom( + interceptor.intercept(secondCtx.context, { + handle: () => of({ a: 2, m: { x: 2, y: 1 }, z: 1 }), + }), + ); + const secondTag = secondCtx.response.getHeader('ETag') as string; + + expect(first).toEqual(second); + expect(firstTag).toBeTruthy(); + expect(firstTag).toBe(secondTag); + }); + + it('handles responses containing BigInt without throwing', async () => { + configGet.mockReturnValue(undefined); + const interceptor = buildInterceptor(); + const { context, response } = createContext({}); + await firstValueFrom( + interceptor.intercept(context, { + handle: () => of({ big: 1125899906842621n }), + }), + ); + expect(response.getHeader('ETag')).toMatch(/^"[a-f0-9]{64}"$/); + }); + + describe('If-None-Match parsing', () => { + const expect304 = async ( + ifNoneMatch: string, + payload: unknown = { id: 1 }, + ): Promise<{ status: number; etag: string | undefined }> => { + configGet.mockReturnValue(undefined); + + // First call: get the ETag. + const seeded = createContext({}); + const seedInterceptor = buildInterceptor(); + await firstValueFrom( + seedInterceptor.intercept(seeded.context, { + handle: () => of(payload), + }), + ); + const tag = seeded.response.getHeader('ETag') as string; + + const next = createContext({ + ifNoneMatch: ifNoneMatch.replace('__TAG__', tag), + }); + const result = await firstValueFrom( + buildInterceptor().intercept(next.context, { + handle: () => of(payload), + }), + ); + expect(result).toBeUndefined(); + return { status: next.response.statusCode, etag: next.response.getHeader('ETag') }; + }; + + it('returns 304 on exact strong ETag match', async () => { + const { status, etag } = await expect304('__TAG__'); + expect(status).toBe(304); + expect(etag).toBeDefined(); + }); + + it('returns 304 for If-None-Match: *', async () => { + const { status } = await expect304('*'); + expect(status).toBe(304); + }); + + it('returns 304 for W/-prefixed weak tag', async () => { + const { status } = await expect304('W/__TAG__'); + expect(status).toBe(304); + }); + + it('returns 304 for comma-separated multi-value list', async () => { + const { status } = await expect304('"old", __TAG__, "another"'); + expect(status).toBe(304); + }); + + it('does not 304 when If-None-Match does not match', async () => { + configGet.mockReturnValue(undefined); + const seeded = createContext({}); + await firstValueFrom( + buildInterceptor().intercept(seeded.context, { + handle: () => of({ id: 1 }), + }), + ); + + const next = createContext({ ifNoneMatch: '"does-not-exist"' }); + const result = await firstValueFrom( + buildInterceptor().intercept(next.context, { + handle: () => of({ id: 1 }), + }), + ); + expect(next.response.statusCode).toBe(200); + expect(result).toEqual({ id: 1 }); + }); + }); + + it('honours HttpCacheTtl decorator', async () => { + configGet.mockReturnValue(undefined); + const interceptor = buildInterceptor(); + const handler = decorated(HTTP_CACHE_METADATA, { ttl: 120 }); + const { context, response } = createContext({ handler }); + await firstValueFrom( + interceptor.intercept(context, { handle: () => of({ id: 1 }) }), + ); + expect(response.getHeader('Cache-Control')).toBe( + 'private, max-age=120, must-revalidate', + ); + }); + + it('honours HttpCache({ public: true })', async () => { + configGet.mockReturnValue(undefined); + const interceptor = buildInterceptor(); + const handler = decorated(HTTP_CACHE_METADATA, { public: true }); + const { context, response } = createContext({ handler }); + await firstValueFrom( + interceptor.intercept(context, { handle: () => of({ ok: 1 }) }), + ); + expect(response.getHeader('Cache-Control')).toBe('public'); + }); + + it('honours @SkipHttpCache', async () => { + configGet.mockReturnValue(undefined); + const interceptor = buildInterceptor(); + const handler = decorated(HTTP_CACHE_SKIP, true); + const { context, response } = createContext({ handler }); + const result = await firstValueFrom( + interceptor.intercept(context, { handle: () => of({ a: 1 }) }), + ); + expect(result).toEqual({ a: 1 }); + expect(response.getHeader('Cache-Control')).toBeUndefined(); + expect(response.getHeader('ETag')).toBeUndefined(); + }); + + it('skips Swagger / docs / deprecated-test paths on GET', async () => { + configGet.mockReturnValue(undefined); + const interceptor = buildInterceptor(); + for (const path of [ + '/api/docs', + '/api/v1/docs', + '/api/v1/deprecated-test', + '/api/v2/docs/anything', + ]) { + const { context, response } = createContext({ path }); + await firstValueFrom( + interceptor.intercept(context, { handle: () => of({}) }), + ); + expect(response.getHeader('Cache-Control')).toBeUndefined(); + expect(response.getHeader('ETag')).toBeUndefined(); + } + }); + + it('passes streams / buffers / StreamableFile-shaped values through', async () => { + configGet.mockReturnValue(undefined); + const interceptor = buildInterceptor(); + + const cases = [ + { kind: 'node stream', value: { pipe: jest.fn() } }, + { + kind: 'web stream', + value: { + pipeTo: jest.fn(), + [Symbol.asyncIterator]: jest.fn(), + } as unknown, + }, + { kind: 'buffer', value: Buffer.from('hi') }, + { kind: 'uint8array', value: new Uint8Array([1, 2, 3]) }, + ]; + + for (const { value } of cases) { + const { context, response } = createContext({ + path: '/api/v1/download', + }); + const result = await firstValueFrom( + interceptor.intercept(context, { handle: () => of(value) }), + ); + expect(result).toBe(value); + expect(response.getHeader('ETag')).toBeUndefined(); + expect(response.getHeader('Cache-Control')).toBe( + 'private, must-revalidate', + ); + } + }); + }); +}); diff --git a/app/backend/src/common/interceptors/http-cache.interceptor.ts b/app/backend/src/common/interceptors/http-cache.interceptor.ts new file mode 100644 index 00000000..1da4451e --- /dev/null +++ b/app/backend/src/common/interceptors/http-cache.interceptor.ts @@ -0,0 +1,346 @@ +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, + Logger, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { ConfigService } from '@nestjs/config'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { createHash } from 'node:crypto'; +import type { Request, Response } from 'express'; +import { canonicalStringify } from '../utils/json-canonicalize.util'; +import { + HTTP_CACHE_METADATA, + HTTP_CACHE_SKIP, + HttpCacheOptions, +} from '../decorators/http-cache.decorator'; + +/** + * Maximum size (bytes) for which we compute an ETag. Larger payloads + * skip ETag generation but still get Cache-Control. + */ +const MAX_ETAG_PAYLOAD_BYTES = 256 * 1024; // 256 KB + +/** + * Path prefixes that always skip HTTP caching regardless of decorators. + * These endpoints serve documentation, test fixtures, or live signals + * that must never be stored by an intermediary. + */ +const SKIP_PATH_PREFIXES: readonly string[] = [ + '/api/docs', + '/api/v1/docs', + '/api/v2/docs', + '/api/v1/deprecated-test', +]; + +/** + * HTTP methods considered safe to cache responses from. Per RFC 7231 + * GET and HEAD are the canonical safe methods; we treat them equally + * so that CDN revalidation HEAD probes benefit from the same + * Cache-Control / ETag surface as the original GET. + */ +const SAFE_METHODS = new Set(['GET', 'HEAD']); + +/** + * HTTP methods whose responses must never be persisted by any cache + * because they describe server-side state changes (idempotent or not). + */ +const MUTATION_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); + +/** + * HttpCacheInterceptor + * + * Adds RFC 7232-compliant caching headers to GET / HEAD responses and + * `Cache-Control: no-store` to mutation responses. The interceptor is + * intentionally conservative: + * + * - Successful GET / HEAD responses receive: + * * `ETag` — strong validator derived from the + * canonical JSON body so cosmetic + * reordering does not invalidate the + * cache. + * * `Cache-Control` — `private, must-revalidate` by default + * to prevent shared caches from + * accidentally serving auth-scoped + * data; tunable per handler. + * * `Vary` — `Authorization, Accept-Encoding` so + * caches partition by identity and + * content encoding. + * - Mutation responses (POST/PUT/PATCH/DELETE) receive + * `Cache-Control: no-store` so intermediaries do not persist + * them between requests. + * - When the client supplies `If-None-Match` matching the freshly + * computed ETag, the handler payload short-circuits and is + * replaced with an empty `304 Not Modified` response, leaving the + * ETag / Cache-Control headers in place. + * + * The interceptor does NOT mutate anything when: + * - The global `HTTP_CACHE_ENABLED` flag is `false`. + * - The decorated handler/controller uses `@SkipHttpCache()`. + * - The matched path begins with a skippable prefix (e.g. Swagger + * docs, the deprecation test endpoint). + * - The body is a stream (Node Readable / Web ReadableStream / + * NestJS `StreamableFile` / `Buffer`) so binary downloads remain + * untouched. + * - The response Content-Type is set to a non-JSON value already. + */ +@Injectable() +export class HttpCacheInterceptor implements NestInterceptor { + private readonly logger = new Logger(HttpCacheInterceptor.name); + private readonly enabled: boolean; + private readonly defaultTtl: number; + private readonly maxPayloadBytes: number; + private readonly debugHeaders: boolean; + + constructor( + private readonly reflector: Reflector, + configService: ConfigService, + ) { + this.enabled = (configService.get('HTTP_CACHE_ENABLED') ?? 'true') + .toLowerCase() + !== 'false'; + + const ttlRaw = configService.get('HTTP_CACHE_DEFAULT_TTL'); + const parsedTtl = ttlRaw !== undefined ? Number.parseInt(ttlRaw, 10) : NaN; + this.defaultTtl = + Number.isFinite(parsedTtl) && parsedTtl >= 0 ? parsedTtl : 0; + + const maxRaw = configService.get('HTTP_CACHE_MAX_ETAG_BYTES'); + const parsedMax = maxRaw !== undefined ? Number.parseInt(maxRaw, 10) : NaN; + this.maxPayloadBytes = + Number.isFinite(parsedMax) && parsedMax > 0 + ? parsedMax + : MAX_ETAG_PAYLOAD_BYTES; + + this.debugHeaders = + (configService.get('NODE_ENV') ?? 'development') !== + 'production'; + } + + intercept(context: ExecutionContext, next: CallHandler): Observable { + if (!this.enabled) { + return next.handle(); + } + + const httpContext = context.switchToHttp(); + const request = httpContext.getRequest(); + const response = httpContext.getResponse(); + const requestMethod = (request.method ?? '').toUpperCase(); + + // Mutations ALWAYS get `Cache-Control: no-store` first — this fires + // before any skip-path or `@SkipHttpCache` decorator check because + // mutating endpoints must never end up in a shared cache, even if + // mounted on a path we normally skip (e.g. a docs handler that + // accepts test mutations). + if (MUTATION_METHODS.has(requestMethod)) { + response.setHeader('Cache-Control', 'no-store'); + return next.handle(); + } + + if (this.shouldAlwaysSkip(request)) { + return next.handle(); + } + + const skip = this.reflector.getAllAndOverride(HTTP_CACHE_SKIP, [ + context.getHandler(), + context.getClass(), + ]); + if (skip) { + return next.handle(); + } + + if (!SAFE_METHODS.has(requestMethod)) { + // Non-safe, non-mutation methods (e.g. OPTIONS) are passed through. + return next.handle(); + } + + const options = + this.reflector.getAllAndOverride(HTTP_CACHE_METADATA, [ + context.getHandler(), + context.getClass(), + ]) ?? {}; + + response.setHeader('Cache-Control', this.buildCacheControl(options)); + response.setHeader('Vary', 'Authorization, Accept-Encoding'); + + return next.handle().pipe( + map((data) => this.applyGetHeaders(request, response, data)), + ); + } + + private shouldAlwaysSkip(request: Request): boolean { + const path = (request.baseUrl ?? '') + (request.path ?? request.url ?? ''); + if (!path) return false; + for (const prefix of SKIP_PATH_PREFIXES) { + if (path === prefix || path.startsWith(`${prefix}/`)) { + return true; + } + } + return false; + } + + private applyGetHeaders( + request: Request, + response: Response, + data: unknown, + ): unknown { + if (!this.isCacheableBody(data, response)) { + this.setDebugHeader(response, 'bypass'); + return data; + } + + // Don't bother hashing huge payloads; ETag is a "free" win only on + // small responses that we expect to be re-served often. + const serialized = canonicalStringify(data); + if ( + serialized.length === 0 || + serialized.length > this.maxPayloadBytes + ) { + this.setDebugHeader(response, 'bypass'); + return data; + } + + const etagHash = createHash('sha256').update(serialized).digest('hex'); + const etag = `"${etagHash}"`; + + response.setHeader('ETag', etag); + + if (this.ifNoneMatchMatches(request, etag)) { + // Short-circuit 304 responses: no body, no Content-Length, no + // Content-Type. Returning undefined tells NestJS to emit an empty + // body while keeping the ETag / Cache-Control headers above — + // which satisfies RFC 7232 §4.1. + response.removeHeader('Content-Length'); + response.removeHeader('Content-Type'); + response.status(304); + this.setDebugHeader(response, 'hit'); + + this.logger.debug( + `304 Not Modified for ${request.method} ${request.originalUrl ?? request.url}`, + ); + return undefined; + } + + this.setDebugHeader(response, 'miss'); + return data; + } + + private buildCacheControl(options: HttpCacheOptions): string { + const ttl = options.ttl !== undefined ? options.ttl : this.defaultTtl; + const visibility = options.public === true ? 'public' : 'private'; + + const directives: string[] = [visibility]; + + if (typeof ttl === 'number' && Number.isFinite(ttl)) { + if (ttl <= 0) { + // `no-cache` still permits storage but forces revalidation; + // combine with `must-revalidate` for private caches. + directives.push('no-cache'); + } else { + directives.push(`max-age=${Math.floor(ttl)}`); + } + } + + if (visibility === 'private') { + directives.push('must-revalidate'); + } + + return directives.join(', '); + } + + private isCacheableBody(data: unknown, response: Response): boolean { + if (data === null || data === undefined) { + return true; + } + + if (typeof data !== 'object') { + return true; + } + + // Node Readables expose `.pipe`. + const candidate = data as { pipe?: unknown; pipeTo?: unknown }; + if (typeof candidate.pipe === 'function' || typeof candidate.pipeTo === 'function') { + return false; + } + + // Web Readables expose an async iterator (ReadableStream / Readable.fromWeb). + if (typeof (data as { [Symbol.asyncIterator]?: unknown })[Symbol.asyncIterator] === 'function') { + return false; + } + + // Buffers and NestJS StreamableFile wrappers are pass-through. + if (Buffer.isBuffer(data)) { + return false; + } + + // Bare typed-array / ArrayBufferView returns would otherwise be + // canonicalized as numeric-keyed objects, producing a misleading + // ETag on byte-equal downloads. Skip them. + if ( + typeof ArrayBuffer !== 'undefined' && + typeof ArrayBuffer.isView === 'function' && + ArrayBuffer.isView(data) + ) { + return false; + } + + const contentType = response.getHeader?.('Content-Type'); + if (typeof contentType === 'string' && !contentType.includes('json')) { + return false; + } + + return true; + } + + private ifNoneMatchMatches( + request: Request, + etag: string, + ): boolean { + const raw = request.headers['if-none-match']; + if (raw === undefined) return false; + const values = normalizeIfNoneMatch(raw); + + // Wildcard: matches as long as a representation exists. + if (values.includes('*')) return true; + // Strip optional W/ weak prefix and surrounding whitespace, then + // compare opaque-tag portion (RFC 7232 §2.3.2 weak comparison). + const target = stripWeakPrefix(etag); + + return values.some(value => stripWeakPrefix(value) === target); + } + + private setDebugHeader(response: Response, value: string): void { + if (this.debugHeaders) { + response.setHeader('X-Http-Cache', value); + } + } +} + +/** + * Parse an `If-None-Match` header value into its constituent tag list, + * handling the (rare) case where Express flattens multiple headers + * into an array. + */ +function normalizeIfNoneMatch(raw: string | string[]): string[] { + const values = Array.isArray(raw) ? raw : [raw]; + const out: string[] = []; + for (const v of values) { + for (const piece of v.split(',')) { + const trimmed = piece.trim(); + if (trimmed.length > 0) out.push(trimmed); + } + } + return out; +} + +/** + * Strip the optional weak comparison prefix `W/` from an ETag value. + * Per RFC 7232 we always run weak comparison for `If-None-Match` + * semantics; the opaque-tag substring is what matters. + */ +function stripWeakPrefix(value: string): string { + return value.startsWith('W/') ? value.slice(2) : value; +} diff --git a/app/backend/src/common/utils/__tests__/json-canonicalize.util.spec.ts b/app/backend/src/common/utils/__tests__/json-canonicalize.util.spec.ts new file mode 100644 index 00000000..a6db9760 --- /dev/null +++ b/app/backend/src/common/utils/__tests__/json-canonicalize.util.spec.ts @@ -0,0 +1,49 @@ +import { canonicalStringify } from '../json-canonicalize.util'; + +describe('canonicalStringify', () => { + it('serializes primitives', () => { + expect(canonicalStringify(null)).toBe('null'); + expect(canonicalStringify(undefined)).toBe('null'); + expect(canonicalStringify('hello')).toBe('"hello"'); + expect(canonicalStringify(42)).toBe('42'); + expect(canonicalStringify(true)).toBe('true'); + expect(canonicalStringify(false)).toBe('false'); + }); + + it('replaces non-finite numbers with null', () => { + expect(canonicalStringify(Number.NaN)).toBe('null'); + expect(canonicalStringify(Number.POSITIVE_INFINITY)).toBe('null'); + expect(canonicalStringify(Number.NEGATIVE_INFINITY)).toBe('null'); + }); + + it('serializes Date to ISO string', () => { + const iso = '2025-01-02T03:04:05.000Z'; + expect(canonicalStringify(new Date(iso))).toBe(JSON.stringify(iso)); + }); + + it('sorts object keys for determinism', () => { + const a = { b: 1, a: 2, c: { y: 1, x: 2 } }; + const b = { c: { x: 2, y: 1 }, a: 2, b: 1 }; + expect(canonicalStringify(a)).toBe(canonicalStringify(b)); + }); + + it('preserves array order', () => { + const a = canonicalStringify([1, 2, 3]); + const b = canonicalStringify([3, 2, 1]); + expect(a).not.toBe(b); + }); + + it('handles circular references safely', () => { + const obj: Record = { name: 'root' }; + obj.self = obj; + const result = canonicalStringify(obj); + expect(result).toContain('"[Circular]"'); + }); + + it('serializes nested structures', () => { + const value = { list: [{ id: 1 }, { id: 2 }], count: 2 }; + expect(canonicalStringify(value)).toBe( + '{"count":2,"list":[{"id":1},{"id":2}]}', + ); + }); +}); diff --git a/app/backend/src/common/utils/json-canonicalize.util.ts b/app/backend/src/common/utils/json-canonicalize.util.ts new file mode 100644 index 00000000..1ffeae96 --- /dev/null +++ b/app/backend/src/common/utils/json-canonicalize.util.ts @@ -0,0 +1,70 @@ +/** + * Canonical JSON stringification. + * + * Produces a deterministic string representation of a JSON-like value so + * that two structurally equivalent values with different key order yield + * the same output. Used by the HTTP cache interceptor to compute strong + * ETags that don't churn on cosmetic reordering by upstream serializer + * passes. + * + * Semantics: + * - Primitives use the built-in JSON.stringify rules. + * - `Date` instances are serialized to ISO 8601 strings. + * - `null` is serialized as `null`. + * - Object keys are sorted alphabetically at every depth. + * - Array order is preserved (arrays are not sorted — they have meaning). + * - Circular references fall back to a sentinel string so we never throw. + */ +export function canonicalStringify(value: unknown): string { + const seen = new WeakSet(); + + const visit = (input: unknown): string => { + if (input === null || input === undefined) return 'null'; + + const type = typeof input; + + if (type === 'string') return JSON.stringify(input); + if (type === 'number') { + // JSON.stringify emits NaN/Infinity as null; replicate to be explicit. + const n = input as number; + if (!Number.isFinite(n)) return 'null'; + return JSON.stringify(n); + } + if (type === 'boolean') return JSON.stringify(input); + + if (input instanceof Date) { + return JSON.stringify(input.toISOString()); + } + + if (typeof input === 'bigint') { + // JSON.stringify throws on bigint by default; emit as a string + // so ETag computation doesn't crash on Stellar amounts / NGO IDs. + return JSON.stringify(input.toString()); + } + + if (Array.isArray(input)) { + return `[${input.map(visit).join(',')}]`; + } + + if (type === 'object') { + const obj = input as Record; + if (seen.has(obj)) return '"[Circular]"'; + seen.add(obj); + + const keys = Object.keys(obj).sort(); + const parts: string[] = []; + for (const key of keys) { + parts.push(`${JSON.stringify(key)}:${visit(obj[key])}`); + } + return `{${parts.join(',')}}`; + } + + // Functions, symbols, undefined nested in objects, etc. — fall back + // to JSON.stringify behavior. JSON.stringify never returns `null` + // for valid input, so we keep the `?? 'null'` for safety with + // possible `JSON.stringify(undefined) === undefined`. + return JSON.stringify(input as string | number | boolean | null) ?? 'null'; + }; + + return visit(value); +}