Spec 031 — Backend caching mechanism & browser caching (Surface A/B headers)
Status: implemented Branch: 031-backend-edge-caching
Status is set by the human, never by the agent. It moves draft → approved → implemented.
Epic context. Wave 1 of the gw2.app alignment epic (
docs/epics/gw2app-alignment.md). This spec owns the caching mechanism — the per-route header policy that Specs 032 (recipe-graph-cacheable) and 033 (frontend-perf) annotate their endpoints against. They consume it; they do not invent their own header scheme. The invariant this spec makes enforceable: anything cacheable is user-agnostic and keyless; anything per-user is stateless and never cached.Scope note (post-research + human decisions). Staying on Render's free tier, two adjacent goals from the epic's "Spec 1" are deferred out of this spec: the shared edge CDN (research V1 — free needs a custom domain, Render-native is paid-only) and cold-start removal (the keep-warm approach was dropped this session — see Out of scope and research V2). What this spec delivers on free: the mechanism, which makes Surface-A responses browser-cacheable now and leaves the headers correct for a later CDN.
Problem
GW2Priory's backend attaches no HTTP caching metadata to any response (0 hits for cache-control / etag / setHeader in apps/api/src). So every repeat of an identical, user-agnostic response — the legendary catalogue — is a full origin round-trip, even though the data is the same for every user and changes only on a game patch or a deploy. The backend also has no shared vocabulary for which responses are safe to cache: nothing on an endpoint declares whether it is user-agnostic (shareable) or per-user (never shareable), so caching cannot be turned on safely one endpoint at a time.
This spec adds caching metadata (headers) only. It changes no response body — moving prices or account data off the origin is Specs 032/033, not this one.
HTTP endpoints (contract-first)
This spec adds no endpoint and changes no response body. It adds one response header (Cache-Control) to existing responses, driven by a per-route surface declaration. Below is the inventory the epic asks for: every current endpoint labelled A (public / keyless / user-agnostic — cacheable) or B (per-user — never cached), with the policy it receives. Surface-A responses become browser-cacheable immediately; a shared edge CDN is deferred (see the scope note), but the headers are already correct for one.
Method & path (under /api) | Key? | User-agnostic? | Surface | Cache-Control this spec stamps |
|---|---|---|---|---|
GET /legendaries | no | yes | A | public, max-age=<long> (the only cacheable endpoint today) |
GET /commerce/prices | no | yes (volatile) | A-candidate, declared B for now | private, no-store — keyless but volatile, and prices move browser-direct in Wave 2, so not cached (C1 → b) |
GET /recipe-graph/:itemId | no | structure yes, but prices folded in | A-candidate, declared B for now | private, no-store — not cacheable yet; Spec 032 removes the price fold and flips it to Surface A |
GET /health | no | yes (liveness) | infra → B policy | private, no-store (never cached, so the platform health probe always reaches the origin) |
GET /account, GET /account/materials, GET /account/wallet | yes | no | B | private, no-store |
GET /legendaries/ranking | yes | no | B | private, no-store |
POST /assistant/ask | yes | no | B | private, no-store |
POST /mcp (+ GET/DELETE → 405) | yes (forwarded) | no | B | private, no-store via the fail-safe default — the @Res() handler is not decorated (an interceptor cannot stamp it); POST/405 are uncacheable regardless and it is excluded from OpenAPI |
OpenAPI impact. The committed apps/api/openapi.json is unchanged (C2 resolved). Cache-Control is transport/caching metadata, not part of the response schema the orval client consumes; documenting it would churn the committed contract and generated hooks for no client benefit, so the surface inventory above is the record instead. No request schema, response schema, path, or status code changes.
The caching mechanism (the contract Specs 032/033 consume)
The mechanism is a per-route surface declaration read by a single response-header stamper. Ponytail: the vocabulary is exactly the invariant's two surfaces — no third knob is introduced.
- Two policies, one argument.
- Surface A —
public, max-age=<seconds>. Requires a max-age chosen for the data's volatility (long for committed-static data, short for volatile). Browser-cacheable now, and shared-cacheable by any future CDN. - Surface B —
private, no-store. Never stored by a shared or private cache.
- Surface A —
- Declared per route, next to the handler, so the surface label lives with the endpoint and a reviewer can reject on it alone (epic cross-cutting rule).
- Fail-safe default. A route with no declaration is stamped Surface B (
private, no-store). Forgetting the annotation can only under-cache (a perf regression, caught in review), never leak a per-user or volatile response into a shared cache. This is load-bearing, not merely tidy: research F4 found that a shared CDN (Render-native, if ever adopted) would default-cache any 200 without aCache-Controlfor 120 min — so an unstamped Surface-B response would be cached for hours. The fail-safe closes that hole in advance. - Success-only public caching. The Surface-A header is stamped only on a successful (2xx) response. Any error path (4xx/5xx), and any response before the handler resolves, carries the fail-safe
private, no-store— so a404/502is never cached as if it were the data. - A closed set, not free text. Endpoints pick a policy from this vocabulary; they do not write raw
Cache-Controlstrings. That is what keeps the invariant enforceable and lets 032/033 annotate their endpoints without re-deriving header semantics.
The exact header-stamping technology (a Nest interceptor keyed by route metadata is the expected shape, given the Fastify adapter) is a plan.md decision, not spec content; the spec fixes the contract (two surfaces, the fail-safe default, success-only public stamping), not the class that emits it.
User stories
Ordered by priority. Each story is independently testable and shippable.
P1 — Public data is browser-cached, not re-fetched every time
As a player / seller, I want repeat views of public, user-agnostic data (the legendary catalogue) to be served from my browser's cache instead of a fresh origin round-trip, so that the app feels fast on repeat visits within the cache window.
Independent test: a Surface-A response (GET /api/legendaries) carries Cache-Control: public, max-age=…; a second identical request from the same browser within the window is served from the browser's HTTP cache (no network request to the origin). A Surface-B response (GET /api/account) carries private, no-store and is never reused from cache.
Acceptance scenarios
- Given the mechanism is deployed, when I
GET /api/legendaries, then the response carriesCache-Control: public, max-age=…, and a repeat request from the same browser within the window is satisfied from the browser cache (observable as a from-cache response, no origin hit). - Given a request with a valid key, when I
GET /api/account, then the response carriesCache-Control: private, no-storeand no cache stores it. - Given an endpoint that resolves to an error, when it returns
404/502, then the response carries the fail-safeprivate, no-store, not apublic, max-age, so the error is never cached. - Given
GET /api/recipe-graph/:itemIdstill folds volatile prices into its body (until Spec 032), when I request it, then it carriesprivate, no-store— the structure is not yet cached, and the mechanism is ready to flip it to Surface A when Spec 032 lands.
P2 — Every endpoint declares its surface, through one shared mechanism
As a maintainer (and as Specs 032/033), I want a single caching-policy mechanism that every endpoint declares its surface against, so that caching is applied consistently, the invariant is enforceable in review and in a test, and later specs annotate their endpoints without inventing a second header scheme.
Independent test: each current route resolves to exactly one declared surface (A or B) per the inventory above; a route with no declaration is treated as Surface B; a unit/e2e test asserts the stamped Cache-Control for a representative Surface-A route, a Surface-B route, the fail-safe default, and an error path.
Acceptance scenarios
- Given the mechanism, when a handler is declared Surface A with a max-age, then its successful response carries exactly
public, max-age=<that value>. - Given a handler declared Surface B (or undeclared), when it responds, then the response carries
private, no-store. - Given Spec 032/033 add or change an endpoint, when they annotate it, then they use this mechanism's two-policy vocabulary — no new
Cache-Controlstring is written by hand.
Requirements
- R1 — A reusable per-route caching-policy mechanism exposes exactly two policies — Surface A (
public, max-age=<seconds>, a required max-age) and Surface B (private, no-store) — and stamps the correspondingCache-Controlon the response. It changes no response body. - R2 — An undeclared route is stamped Surface B (
private, no-store) by default — fail-safe toward privacy, and load-bearing against a CDN's default-cache behaviour (research F4). - R3 — The Surface-A header is stamped only on a successful (2xx) response. Error responses and any pre-handler state carry the fail-safe
private, no-store, so a non-2xx is never cached as data. - R4 — Every existing endpoint is declared per the inventory table:
GET /legendaries→ Surface A (the only currently cacheable endpoint);GET /account*,GET /legendaries/ranking,POST /assistant/ask,POST /mcp,GET /health→ Surface B (private, no-store);GET /recipe-graph/:itemId→ Surface B for now (prices still folded in — Spec 032 flips it);GET /commerce/prices→ Surface B (keyless but volatile, moving browser-direct in Wave 2 — C1 resolved to b). - R5 — Neither
GET /recipe-graph/:itemIdnorGET /commerce/pricesis marked cacheable by this spec (both still expose volatile prices). The mechanism is ready so a later spec flipsrecipe-graphto Surface A with a single declaration change once Spec 032 removes the price fold. - R6 —
GET /healthuses the not-cacheable Surface-B policy (private, no-store) and must always reach the origin — it is the platform health probe; no cache may answer it. - R7 — Surface-A responses carry
public, max-age=…, making them browser-cacheable now. A shared edge CDN is deferred (out of scope): research V1/F3 established it needs a custom domain (Cloudflare cannot proxy a*.onrender.comhost) or the paid tier, neither adopted under "no paid for now". The mechanism stamps headers so that when a CDN is later added, Surface A is edge-cached and Surface B is never cached, with no further code change. - R8 — CORS reflects
Access-Control-Allow-Originper the existing allow-list, so Surface-A responses may carryVary: Origin. This is inert for browser caching; it is documented for the deferred shared CDN, which must honourVary(research V1: Cloudflare Cache Rules supportVaryon all plans). CORS itself is unchanged (out of scope). - R9 — The committed
openapi.jsonis byte-unchanged by this spec (C2):Cache-Controlis transport metadata, not documented in the contract. No path/schema/status change. - R10 — The mechanism and the per-route declarations are covered by tests asserting the stamped
Cache-Controlfor: a Surface-A route (with its max-age), a Surface-B route, an undeclared route (fail-safe), and an error path (fail-safe). Browser-cache reuse (SC2) is observational, verified on the real deploy and logged likedeploy.md's SC log. - R11 —
pnpm lint,pnpm test,pnpm build, andpnpm docs:buildare green; noany, no unexplained escape hatch; no files written underdocs/superpowers/.
Resolved clarifications (human, this session). No [NEEDS CLARIFICATION] remains open.
- C1 → (b).
GET /commerce/pricesis Surface B (uncached) for now: it is keyless and user-agnostic but serves volatile prices, and prices move browser-direct in Wave 2, so this spec does not cache it. - C2 → leave contract unchanged. The committed
openapi.jsonis not touched;Cache-Controlis transport metadata, not response schema.
Resolved verifications (research.md, 2026-08-22). No [NEEDS VERIFICATION] remains open.
- V1 → CDN deferred. A shared edge CDN is achievable for free only via Cloudflare-in-front, which needs a custom domain (F3); Render-native edge caching is paid-only. Under "no paid for now" the shared CDN is deferred; the mechanism ships browser caching now and is CDN-ready (R7).
- V2 → cold-start removal dropped from this spec. Research found the free keep-warm option viable but fragile (GitHub-cron jitter; ~720–744 h against the 750 h cap). The human elected to drop cold-start removal from this spec (see Out of scope); the evidence stays in
research.mdfor a future paid-tier decision.
Success criteria
Measurable and technology-agnostic — outcomes, not implementation.
- SC1 — A Surface-A response (
GET /api/legendaries) carriesCache-Control: public, max-age=…; a Surface-B response (GET /api/account) carriesprivate, no-store; an undeclared route carriesprivate, no-store; an error response carriesprivate, no-store(neverpublic, max-age). Assertable in a test without a live network. - SC2 — A repeated
GET /api/legendariesfrom the same browser within the max-age window is served from the browser cache with no origin round-trip; a Surface-B or error response is never reused from cache. Observed in a browser on the real deploy. - SC3 —
GET /api/recipe-graph/:itemIdcarriesprivate, no-store(not yet cacheable), and flipping it to Surface A is a single declaration change once Spec 032 removes the price fold. - SC4 — No response body changes anywhere, and the committed
openapi.jsonis unchanged. Assertable against the committed contract. - SC5 — Specs 032 and 033 can declare a new/changed endpoint's surface using only this mechanism's two-policy vocabulary (demonstrated by
recipe-graphbeing flippable to Surface A via one declaration — SC3). - SC6 —
pnpm lint,pnpm test,pnpm build,pnpm docs:buildare green; noany; no files underdocs/superpowers/.
Out of scope
- Cold-start removal / keep-warm. Dropped this session: the free-tier ~1-minute cold start is accepted for now. Removing it later is a one-line paid-tier flip or a keep-warm ping (research V2); neither is built here.
- A shared edge CDN (Cloudflare-in-front or Render-native). Deferred: research V1/F3 — free needs a custom domain, paid needs the Starter tier; neither adopted now. The mechanism is CDN-ready (R7).
- The paid Render tier — deferred ("no paid for now"); moving off free is a one-line
render.yamlchange when wanted. - Changing any response body — moving prices off
recipe-graph(Spec 032) or account/prices to the browser (Spec 033). This spec is headers only. - Making
recipe-graphorcommerce/pricescacheable — deferred (R5); they stay Surface B here. - Frontend cache configuration (TanStack
staleTime, IndexedDB persistence, code-splitting) — Spec 033. - A cache-busting scheme keyed to deploys / game builds,
ETag/conditional requests, orstale-while-revalidatetuning. The initial policy is a plainmax-ageper surface; richer revalidation is a later refinement, not required to satisfy the invariant (YAGNI). s-maxagediverging frommax-age(separate edge vs browser TTLs). The mechanism emits a singlemax-age; a split TTL is a later refinement, most relevant once a CDN lands.- Changing CORS, the allow-list, or key transport. Only noted where it affects
Vary(R8). - A custom domain for the API (deferred; and a prerequisite of the deferred CDN — F3).
- Reconciling the Postgres-in-docs / no-DB-in-code divergence — this spec adds no persistence; parked (
docs/gaps/), not fixed here.
Assumptions
- The API is a single Frankfurt Render Docker web service on the free plan, with
/api/healthas its probe, and the static SPA already on Render's global CDN (render.yaml,docs/architecture/deploy.md). - The API uses the Fastify adapter with a global
/apiprefix (apps/api/src/main.ts); response headers are settable per request on the Fastify reply. - Current endpoint surfaces are exactly the inventory table above (verified against the seven controllers in
apps/api/src);POST /mcpis already excluded from OpenAPI (@ApiExcludeEndpoint). GET /legendariesis served from committed static data and is the only Surface-A endpoint today; max-age is nonetheless per-route, not global, because volatility differs per endpoint — a future priceless recipe tree (Spec 032) warrants a different TTL — so the mechanism keeps that knob ready.- Browser-cache reuse is only fully provable on the real Render deploy; that criterion is observed and logged there, as spec 014 did.
Traceability
Each acceptance scenario and success criterion maps to a named test (or a dated deploy observation for the observational ones). Filled in during implementation.
| Criterion | Test / Observation |
|---|---|
| P1 #1 | cache-control.interceptor.test.ts "Surface A → public, max-age" (+ browser-cache reuse observed on deploy — SC2) |
| P1 #2 | cache-control.interceptor.test.ts "Surface B → private, no-store" |
| P1 #3 | cache-control.interceptor.test.ts "error → private, no-store (never public)" + "Surface A that throws → private, no-store" |
| P1 #4 | cache-policy.declarations.test.ts "recipe-graph is Surface B for now (Spec 032 flips it to A)" |
| P2 #1 | cache-control.interceptor.test.ts "Surface A → public, max-age" + cache-policy.declarations.test.ts "GET /legendaries is Surface A with the legendaries max-age" |
| P2 #2 | cache-control.interceptor.test.ts "Surface B …" + "undeclared → private, no-store (fail-safe)" |
| P2 #3 | cache-policy.declarations.test.ts "recipe-graph is Surface B for now (Spec 032 flips it to A)" |
| SC1 | cache-control.interceptor.test.ts (Surface A / method-over-class / Surface B / undeclared / error / Surface-A-that-throws) + caching.module.test.ts (global wiring) |
| SC2 | deploy observation — browser-cache reuse on repeated /api/legendaries (pending real deploy; logged like deploy.md) |
| SC3 | cache-policy.declarations.test.ts "recipe-graph is Surface B for now" |
| SC4 | committed apps/api/openapi.json unchanged under pnpm verify:contract; no response-body change |
| SC5 | cache-policy.declarations.test.ts (recipe-graph declared B; flip to A is one decorator change — realised by Spec 032) |
| SC6 | pnpm lint && typecheck && test && build && verify:contract && docs:build green; no any; zero files under docs/superpowers/ |