Skip to content
Open
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
2 changes: 1 addition & 1 deletion app/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@
"eslint-plugin-prettier": "^5.5.5",
"globals": "^16.0.0",
"ioredis-mock": "^8.13.1",
"jest": "^30.0.0",
"jest": "^30.4.2",
"jest-mock-extended": "^4.0.0",
"prettier": "^3.4.2",
"prisma": "^6.19.2",
Expand Down
7 changes: 7 additions & 0 deletions app/backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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 {
Expand Down
76 changes: 76 additions & 0 deletions app/backend/src/common/decorators/http-cache.decorator.ts
Original file line number Diff line number Diff line change
@@ -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=<ttl>`. 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=<ttl>`; 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 });
Loading
Loading