Plan 031 — Backend caching mechanism & browser caching
Status: approved Written in plan mode from spec.md and research.md. Approved by the human before any code is written. Status is set by the human, never by the agent: proposed → approved.
Produced alone — tasks.md stays untouched until this plan is approved in turn.
Goal
Every HTTP response from apps/api carries a Cache-Control header that states its surface — public, max-age=… for the one user-agnostic endpoint (GET /legendaries) and private, no-store for everything else — stamped by a single reusable mechanism that Specs 032/033 annotate against rather than reinvent, with an undeclared route failing safe to private, no-store. Legendary-catalogue responses become browser-cacheable immediately. No response body and no committed OpenAPI byte changes.
Approach
A NestJS interceptor registered globally reads a per-route policy declared by a decorator and stamps Cache-Control on the Fastify reply. The vocabulary is exactly the invariant's two surfaces: @SurfaceA(maxAgeSeconds) → public, max-age=<n>, and @SurfaceB() → private, no-store.
The interceptor is fail-safe and success-only. It sets private, no-store eagerly (before the handler runs), then — only when the handler emits a value successfully — overrides it with the declared policy. So an error path (a thrown HttpException), a route that forgot to declare, and a handler that manages its own response all keep private, no-store; only a successful Surface-A response gets public, max-age. This is what closes research F4: a shared CDN (Render-native, if ever enabled) default-caches any 200 lacking Cache-Control for 120 min, so an unstamped per-user response would leak into a shared cache — the fail-safe forecloses that in advance.
Declarations are applied at controller-class level where a whole controller is one surface (all of account, commerce, recipe-graph, assistant, health are Surface B), with a method-level@SurfaceA on LegendariesController.list overriding its class-level @SurfaceB (the sibling ranking route stays B). The interceptor resolves method-over-class with Reflector.getAllAndOverride.
The shared edge CDN and cold-start removal are not built here (research V1/V2 + the "no paid for now" and "drop keep-warm" decisions). The mechanism leaves headers correct so a future CDN caches Surface A and never Surface B with no code change; the free-tier cold start is accepted for now.
Architecture
A new self-contained caching feature module owns the mechanism; the controllers consume it via a decorator; AppModule wires it globally.
AppModule (imports: … , CachingModule)
└── CachingModule
└── provides { APP_INTERCEPTOR → CacheControlInterceptor } (global, DI-resolved)
│ injects Reflector
▼
CacheControlInterceptor.intercept(ctx, next)
1. non-HTTP context → passthrough
2. reply.header('cache-control', 'private, no-store') ← fail-safe (also covers errors)
3. policy = reflector.getAllAndOverride(CACHE_POLICY_KEY, [handler, class])
4. next.handle().pipe(tap(onSuccess → set header from policy))
@SurfaceA(maxAge) / @SurfaceB() = SetMetadata(CACHE_POLICY_KEY, …) ← applied on controllersDependency direction: controllers depend only on the decorator (a SetMetadata wrapper — no runtime coupling); the interceptor depends on Reflector (from @nestjs/core); AppModule depends on CachingModule. Nothing depends back on the controllers, so caching/ has no feature imports.
Tech stack
NestJS 11 on Fastify, SWC-compiled (unchanged). The mechanism uses only what is already present: @nestjs/common (NestInterceptor, ExecutionContext, CallHandler, SetMetadata), @nestjs/core (Reflector, APP_INTERCEPTOR), rxjs (tap), and fastify (FastifyReply) types. No new dependency.
Global Constraints
Copied verbatim from the architecture docs. Every task inherits these; do not summarise or reword them — a test asserts they appear here unchanged.
From docs/architecture/typescript.md:
- No
any. Not in app code, not in tests. Useunknownplus narrowing, or model the type properly. If a third-party type forces it, isolate it behind one typed adapter and comment why. - No non-null assertions (
!) to silence the compiler. - No
@ts-expect-errorwithout a comment explaining what is expected and when it can be removed. - Validate everything crossing a boundary (GW2 API responses, HTTP input) at runtime, not just at the type level.
- Prefer pure functions for domain logic. The optimizer must be testable without a network or a database.
- Match the style of surrounding code. No new dependency without justification in the spec or plan.
moduleResolution: "node"andbaseUrlare removed in TS 7. Use"nodenext"(or"bundler"for Vite/bundler-resolved code) pluspaths: { "*": ["./*"] }in place ofbaseUrl.- TypeScript's default
libincludes DOM. A base tsconfig shared by non-DOM and DOM profiles must pin a non-DOM floor explicitly ("lib": ["ES2023"]), ordocument/windowresolve everywhere, silently defeating profile isolation. A profile that needs DOM adds"DOM","DOM.Iterable"on top of that floor. experimentalDecoratorsandemitDecoratorMetadataare supported for typecheck only —tsc --noEmitaccepts decorator syntax and resolves metadata types, but--noEmitnever emitsdesign:paramtypesat runtime regardless of these options. A decorator-consuming runtime (e.g. NestJS DI) needs a separate emitting compiler for that metadata; seestack.md's api build model.
From docs/architecture/stack.md:
- Monorepo, pnpm workspaces.
apps/api— NestJS (TypeScript).apps/web— React (TypeScript).packages/*— shared code (domain types, the curated Mystic Forge dataset) when sharing is real, not speculative.- Postgres for persistence. In-memory cache for the MVP — no Redis until the caching story earns it.
- Vitest everywhere, both apps.
- Deploy: managed PaaS (Fly.io / Railway). CI: GitHub Actions — lint + typecheck + test + build.
- Static data (items, station recipes) is immutable: cache hard.
- Prices are volatile: short TTL, recomputed live.
- GW2 API rate limit: per-IP token bucket, 300 burst, refill 5/sec,
429on overflow. Batch up to 200 ids per?ids=call. - All GW2 API access goes through the client that budgets this. Never call the GW2 API directly from a service.
- API keys are user secrets: never logged, never persisted server-side, never returned to the client. In the MVP the key is held client-side — the browser's
localStorage— and sent per request asAuthorization: Bearer; the api forwards it to GW2 and stores nothing at rest. Encryption at rest applies only if/when server-side key storage is introduced; no such storage exists today. Client-sidelocalStorageis plaintext and readable by any script on the origin (XSS) — a deliberate MVP limitation, established by spec 016 (client-custody, revisit before adding write-scoped or higher-value keys).
From CLAUDE.md: typecheck clean, tests pass, every acceptance scenario and success criterion covered by a test whose name traces to it, no unexplained escape hatches, the human reviews the diff.
File Structure
Exact paths, and what each file is responsible for. A path here is a commitment; a task that touches a file not listed is a signal the plan missed something.
| Path | Change | Responsibility |
|---|---|---|
apps/api/src/caching/cache-policy.decorator.ts | new | CACHE_POLICY_KEY, the CachePolicy type, the SurfaceA(maxAge)/SurfaceB() decorators, and the PRIVATE_NO_STORE constant. Pure metadata — no Nest runtime deps. |
apps/api/src/caching/cache-control.interceptor.ts | new | CacheControlInterceptor (@Injectable, injects Reflector): fail-safe + success-only stamping of Cache-Control. |
apps/api/src/caching/caching.module.ts | new | CachingModule — provides { provide: APP_INTERCEPTOR, useClass: CacheControlInterceptor } (global). |
apps/api/src/caching/cache-control.interceptor.test.ts | new | Mechanism behaviour via synthetic controllers + Fastify app.inject: Surface A → public, max-age; Surface B, undeclared, and error paths → private, no-store. (SC1, R2, R3) |
apps/api/src/caching/cache-policy.declarations.test.ts | new | Asserts each real controller's declared policy metadata via Reflector (legendaries A + ranking B, account/commerce/recipe-graph/assistant/health B). (R4, R5, R6, SC3) |
apps/api/src/caching/caching.module.test.ts | new | Boots CachingModule in a test app and asserts the interceptor is registered globally (a Surface-A route gets public, max-age). (module convention; SC1) |
apps/api/src/app.module.ts | modify | Add CachingModule to imports. |
apps/api/src/legendaries/legendaries.controller.ts | modify | @SurfaceB() on the class + @SurfaceA(LEGENDARIES_MAX_AGE) on list; LEGENDARIES_MAX_AGE named constant. |
apps/api/src/account/account.controller.ts | modify | @SurfaceB() on the class. |
apps/api/src/commerce/commerce.controller.ts | modify | @SurfaceB() on the class. |
apps/api/src/recipe-graph/recipe-graph.controller.ts | modify | @SurfaceB() on the class (still B — Spec 032 flips it). |
apps/api/src/assistant/assistant.controller.ts | modify | @SurfaceB() on the class. |
apps/api/src/health/health.controller.ts | modify | @SurfaceB() on the class. |
McpController is not modified: it handles its own response via @Res(), so a post-handler interceptor cannot stamp it, and its verbs (POST, and GET/DELETE → 405) are not cache-eligible. It stays Surface B by documentation (spec inventory) — see Risks.
Data & contracts
One internal type, no HTTP-contract change:
export type CachePolicy =
| { readonly kind: 'public'; readonly maxAge: number }
| { readonly kind: 'private' };CACHE_POLICY_KEYis a module-private metadata key;SurfaceA/SurfaceBwrapSetMetadata.- These are custom metadata decorators, not
@nestjs/swaggerdecorators, so they do not alter the generated document: the committedapps/api/openapi.jsonis byte-unchanged andpnpm verify:contractstays green (R9, SC4). No request/response schema, path, or status code changes. - No new Zod schema, no
createZodDto, no response-body change anywhere.
Test strategy
How the spec's criteria become tests (the bite-sized TDD steps land in tasks.md):
- SC1 / R1 / R2 / R3 — the mechanism.
cache-control.interceptor.test.tsbuilds a Nest Fastify test app (Test.createTestingModule+FastifyAdapter, the pattern inlegendaries.controller.test.ts) withCacheControlInterceptoras a global interceptor and four synthetic controllers/routes: a@SurfaceA(600)route (assertcache-control: public, max-age=600); a@SurfaceB()route and an undeclared route (both assertprivate, no-store); a route thatthrows (assert the error response isprivate, no-store, neverpublic); and a@SurfaceAroute that throws (assertprivate, no-store). Assertions readres.headers['cache-control']fromapp.inject. - R4 / R5 / R6 / SC3 — the declarations.
cache-policy.declarations.test.tsreads the metadata off the real controllers with aReflector(getAllAndOverride([handler, class])):LegendariesController .list→{ kind: 'public', maxAge: LEGENDARIES_MAX_AGE };LegendariesController.ranking,AccountController,CommerceController,RecipeGraphController,AssistantController,HealthController→{ kind: 'private' }. This pins every endpoint's surface (including recipe-graph/commerce staying B) without booting each feature's dependency graph. - Module wiring.
caching.module.test.tsboots a small app importingCachingModuleand asserts a@SurfaceAroute actually receives the header end-to-end (proves the global registration, not just the class). - SC4 — no contract change.
pnpm verify:contract(already in CI) provesopenapi.jsonis unchanged; a colocated assertion is unnecessary. - SC2 (browser-cache reuse) is observational — only provable on the real Render deploy, like
deploy.md's SC log. It is verified by a browser/curl check after deploy and recorded there, not by a unit test. The plan does not fake a proxy test for it. - SC5 — 032/033 reuse. Demonstrated structurally:
recipe-graphflips to Surface A by changing its one class decorator, asserted by the declarations test today (B) and by Spec 032 later (A).
Alternatives considered
- Set headers in each controller by hand — rejected: not DRY, no fail-safe, no single enforcement point; the reusable mechanism is the spec's P2 deliverable.
app.useGlobalInterceptors(new CacheControlInterceptor(...))inmain.ts— rejected: it side-steps DI and the "AppModule is a manifest" convention (nestjs.mdG6).APP_INTERCEPTORin a module is the DI-friendly, testable registration.- Middleware instead of an interceptor — rejected: middleware runs before route resolution, so it cannot read per-route
Reflectormetadata cleanly; an interceptor has theExecutionContext. - Explicit
@SurfaceB()on every method — rejected in favour of class-level declaration (DRY); method-level@SurfaceAoverrides where one route in a class differs. - A third
no-storepolicy for/healthand the recipe-graph bridge — rejected:private, no-store(Surface B) already means "never cached" and matches Render's documented prevent-caching directive; two policies keep the vocabulary equal to the invariant's two surfaces. - Building the shared CDN now (Cloudflare-in-front) / paid tier / keep-warm for the cold start — rejected/deferred per research V1/V2 and the "no paid for now" + "drop keep-warm" decisions. The free-tier cold start is accepted for now.
Risks
McpController(@Res()) is not stamped. A post-handler interceptor can't set headers once the handler writes toreply.raw. Mitigation: MCP isPOST(andGET/DELETE→ 405), none cache-eligible (Render caches onlyGET/HEAD); it is excluded from OpenAPI. Documented as Surface B in the inventory; no code needed. If defence-in-depth is later wanted, the handler can set the header onreply.rawitself — out of scope here.- Interceptor vs. nestjs-zod serialization ordering. The interceptor only sets a header (never touches the body), and does so in
tapbefore Nest sends; a test asserting the correct body and header together on a Surface-A route confirms no conflict. - Setting the header eagerly then overriding. Fastify buffers reply headers until send, so an eager
reply.header(...)followed by an override intapis well-defined; the error-path test pins the fail-safe survives whentapnever runs. LEGENDARIES_MAX_AGEstaleness. With no cache-busting, a browser can hold the catalogue up to the TTL after a deploy. Legendary data changes rarely;3600(1 h, matching ArenaNet's item TTL) is the proposed value and is trivially tunable — a named constant with a sourced comment.
Open questions
- None blocking. Research resolved V1/V2; C1/C2 are resolved in the spec; cold-start removal is dropped from scope.
LEGENDARIES_MAX_AGE = 3600is a proposed default (not a spec-fixed number), adjustable on review without touching the mechanism.