Repository navigation
Add ETag and conditional-request support with cache policies per endpoint #99
Description
Overview
The frontend polls the backend with React Query: market lists, market detail, trace previews, portfolio and resolution status are refetched on focus and on intervals. No endpoint tells clients or intermediaries how long a response is valid. No route sets Cache-Control, so browsers and CDNs apply heuristics or nothing, and every poll re-runs the full handler (Prisma queries, sometimes live RPC simulations), serializes the result, compresses it with compression(), and sends the whole body again even when nothing changed.
Express does attach a weak ETag to res.json() bodies by default and will answer a matching If-None-Match with 304, but only after the handler has done all its work and serialized the full response, so it saves bandwidth and none of the server cost. It is also unsafe in one specific place: GET /traces/:id uses optionalAuth and returns either a free preview or the full trace depending on the caller's daily pass. Without Cache-Control: private and Vary: Authorization, any shared cache that starts honouring these responses could serve a subscriber's full trace to an anonymous user.
This issue defines an explicit cache policy per route (public and long-lived for immutable data such as resolved markets and published trace previews, short max-age with stale-while-revalidate for lists, private, no-store for authenticated payment and allowance data), computes strong ETags cheaply from row versions (updatedAt, counts, indexed ledger) before doing the expensive work where possible, and answers If-None-Match with 304. It must work with the response envelope, the BigInt replacer and compression.
Background & current behavior
src/app.ts:helmet(),hpp(), CORS allowlist withcredentials: true,globalLimiter,compression(),express.json, morgan;app.set('json replacer', ...)turns bigint into decimal strings. Express'setagsetting is left at its default (weak ETags over the serialized body). Helmet does not setCache-Controlfor API responses.src/utils/response.util.ts:sendSuccess(res, data, statusCode = 200, meta?)builds{ ok: true, data, error: null, meta }and callsres.status(code).json(body);sendErrorsimilarly. Every controller uses these helpers.- Routes (
src/routes/*.routes.ts):- Markets:
GET /markets(list, filtersstatus,category,currency,onChain, pagination),GET /markets/on-chain/:onChainMarketId,GET /markets/on-chain/:onChainMarketId/state(liveget_marketsimulation),GET /markets/:id, adminPOST /markets/generate, authenticatedGET /markets/generation-status/:jobId. - Traces:
GET /traces(preview list),GET /traces/access/allowanceandPUT(auth),GET /traces/payments(auth),GET /traces/:id(optionalAuth;getTracWithAccessControl(traceId, userId)returns preview withlockedFields/unlockPriceRaw/dailyPassPriceRawor full trace withaccessLevel),POST /traces/verify,POST /traces/:id/unlock. - Portfolio:
GET /portfolio(DB summary + livetreasury.available_capital),GET /portfolio/positions,GET /portfolio/stats. - Oracle:
GET /oracle/markets/:marketId/resolution(live resolver + market-core reads).
- Markets:
- Effectively immutable:
RESOLVED/CANCELLEDmarkets and trace previews onceonChainTraceIdis set (apart fromverified). Market.updatedAtandReasoningTrace.updatedAtare@updatedAtcolumns;IndexerCursor.lastLedgergives the latest indexed ledger.
Goals
src/middlewares/cache.middleware.tswith declarative per-route policies.- Strong ETags derived from a stable validator (row versions, counts, ledger, policy version, auth class) or, when no cheap validator exists, from a canonical serialization of the response data.
304 Not ModifiedonIf-None-Matchmatch, computed before the expensive part of the handler when a cheap validator exists.- Correct
Vary(Authorization,Origin,Accept-Encoding) andprivate/no-storefor anything personalised. - Never cache errors or non-2xx responses as fresh.
- Tests verifying headers and 304 behaviour for every GET route.
Non-goals
- Server-side response caching or chain-read caching (separate Redis chain-cache issue).
- CDN configuration; the headers must be CDN-safe but no CDN is set up.
- Cursor pagination changes (separate issue).
Last-Modified/If-Modified-Since(ETags only, to avoid second-granularity bugs).
Proposed implementation
- Policy model in new
src/middlewares/cache.middleware.ts:export type CachePolicy = | { kind: 'immutable'; maxAge: number } // public, max-age=N, immutable | { kind: 'shared'; maxAge: number; swr: number } // public, max-age=N, stale-while-revalidate=M | { kind: 'personalised'; maxAge: number } // private, max-age=N, Vary: Authorization | { kind: 'no-store' }; // private, no-store export function cachePolicy(policy: CachePolicy | ((req: Request, res: Response) => CachePolicy)): RequestHandler; export function validator(compute: (req: Request) => Promise<string | null>): RequestHandler;
cachePolicystores the policy onres.locals.cachePolicyand installs a hook that, on 2xx responses only, setsCache-Control,Varyand (if not already set) the ETag; on non-2xx it setsCache-Control: no-store.validatorcomputes a cheap version string; if it returns non-null it setsETag: "<sha256(policyVersion|authClass|version)>"and, whenreq.headers['if-none-match']matches (parse the list, handle*andW/prefixes, compare strong), ends with304immediately, skipping the controller. - Disable the default weak ETag for the API (
app.set('etag', false)insrc/app.ts) so there is exactly one ETag source, and implement the fallback: when novalidatorran,sendSuccess()computes a strong ETag fromcanonicalJson(body)(reusecanonicalJson/sha256Hexfromsrc/utils/hash.util.ts, which already sorts keys and stringifies bigint) and checksIf-None-Matchitself. UpdatesendSuccessinsrc/utils/response.util.tsto do this only ifres.locals.cachePolicyexists and is notno-store. - Auth class in the key:
authClass(req)=anon|user:<userId>|pass(for traces: whethercheckAccessgrants full access). Including it in the ETag prevents a 304 for a preview being accepted by a client that now has a pass. Forpersonalisedroutes theVary: Authorizationheader plusprivatekeeps shared caches out. - Per-route policies and validators:
GET /markets→shared(max-age=5, stale-while-revalidate=30). Validator:SELECT max("updatedAt"), count(*) FROM markets WHERE <same filters>via a helper that reuses the zod-parsed filters fromlistMarketsSchema, pluspage/limit. Export thewherebuilder fromsrc/controllers/market.controller.tsso validator and handler share it.GET /markets/:id,GET /markets/on-chain/:onChainMarketId→ policy function:immutable(max-age=86400) if the market isRESOLVED/CANCELLED, elseshared(max-age=5, swr=30). Validator:market.updatedAt+ latest traceupdatedAt+_countvalues (the detail embeds 5 trace previews and counts).GET /markets/on-chain/:onChainMarketId/state→shared(max-age=5, swr=10); ETag from canonical body (it is chain data; if the chain-cache issue lands, use itsledger).GET /traces→shared(max-age=10, swr=60); validator frommax(updatedAt), count(*)with the same filters.GET /traces/:id→personalisedalways (private, max-age=0, must-revalidate,Vary: Authorization), ETag includingauthClassandtrace.updatedAt. Anonymous preview of a published trace (onChainTraceIdset) may besharedwithmax-age=300only when noAuthorizationheader is present; when a token is present the response is alwaysprivate.GET /portfolio,/portfolio/positions,/portfolio/stats→shared(max-age=15, swr=60); validator for positions/stats frommax(updatedAt)/counts;/portfoliouses the canonical-body fallback because it includes a live chain read.GET /oracle/markets/:marketId/resolution→immutablewhenresolverState === 'Finalized'and market resolved, elseshared(max-age=5); ETag from canonical body.GET /traces/access/allowance,GET /traces/payments,GET /markets/generation-status/:jobId→no-store.- All non-GET routes →
no-store(installed once on the router for non-GET methods insrc/routes/index.ts).
/health→no-store.- Compression: strong ETags describe the identity representation; when
compression()encodes the body the representation changes. Either append the encoding to the ETag ("<hash>-gzip") by checkingres.getHeader('Content-Encoding')in anon-headershook, or mark ETags weak when encoded. Choose the suffix approach and addVary: Accept-Encoding(compression adds it; verify it survives). - CORS: add
ETagtoexposedHeadersin the CORS options insrc/app.tsso browser code can read it; verify preflights allowIf-None-Match. - Docs: add a "Caching" section to
docs/api.mdwith the policy table, ETag semantics (opaque, changes with access level), and React Query guidance.
Edge cases & pitfalls
- A user buys a daily pass: their next
GET /traces/:idmust not get304for the preview; theauthClassin the ETag ensures a mismatch. optionalAuthwith an invalid token falls back to anonymous; such responses must still beprivatebecause the request carriedAuthorization.- Errors (
404 MARKET_NOT_FOUND,502 CHAIN_ERROR) must never get longmax-age; a transient RPC failure cached for a day would be a severe bug. If-None-Match: *on GET must not return 304 for a non-existent resource.- Pagination meta changes when rows are inserted on earlier pages; include
count(*)in list validators. updatedAtprecision is milliseconds; two updates in the same millisecond are possible. Include counts or ids where it matters, and document the residual risk.
Testing plan
New tests/http-cache.test.ts using withServer from tests/http.test.ts with monkeypatched prisma methods (as tests/indexer.test.ts does) and stubbed chain reads:
every GET route sets the documented Cache-Control and Vary headers(table-driven over the policy table)matching If-None-Match returns 304 with no body and skips the controller for validator routes(assert the handler stub was not called)changing updatedAt changes the ETagresolved market detail is immutable; active market detail is short-livedtrace detail ETag differs between anonymous, user without pass and user with passrequests with Authorization never get a public Cache-Controlerrors get no-storecompressed and uncompressed responses have different ETagsETag is exposed to CORS clientsauthenticated payment and allowance routes are no-store
Commands: npm test, npm run build.
Acceptance criteria
-
src/middlewares/cache.middleware.tsimplements policies, validators andIf-None-Matchhandling with strong ETag comparison. - Express's default weak ETag is disabled and
sendSuccess()provides the canonical-body fallback ETag. - Every GET route has an explicit policy matching the documented table; non-GET routes and errors are
no-store. - Validator-backed routes return
304without running the controller (tested). -
GET /traces/:idresponses areprivatewithVary: Authorizationwhenever a token is present, and ETags include the access class. - Compressed responses carry encoding-specific ETags and
Vary: Accept-Encoding. -
ETagis exposed via CORS. -
docs/api.mddocuments caching behaviour and client guidance. -
npm run build,npm testand CI pass.
Resources
src/app.ts,src/utils/response.util.ts,src/utils/hash.util.ts,src/routes/market.routes.ts,src/routes/trace.routes.ts,src/routes/portfolio.routes.tssrc/controllers/market.controller.ts,src/services/subscription.service.ts,src/middlewares/auth.middleware.ts,docs/api.md- RFC 9110 conditional requests (
ETag,If-None-Match): https://www.rfc-editor.org/rfc/rfc9110#name-conditional-requests - RFC 9111 HTTP caching (
Cache-Control,Vary): https://www.rfc-editor.org/rfc/rfc9111 - RFC 5861
stale-while-revalidate: https://www.rfc-editor.org/rfc/rfc5861 - Express
etagapplication setting: https://expressjs.com/en/5x/api.html#app.settings.table
Important
Raise your pull request against the develop branch, not main.
All contributor work for this issue must be submitted as a PR into develop. Pull requests opened against main will not be merged and are closed automatically. Create your branch from develop, and before opening the PR double-check that the base branch is set to develop.
Generated by Claude Code
Activity
rinolame commentedon Oct 17, 2026
@rinolame has applied to work on this issue as part of the Stellar Wave Program's 10th wave.
ℹ️ Repo Maintainers: To accept this application, review their application or assign @rinolame to this issue.
rubbathabrahband commentedon Oct 17, 2026
@rubbathabrahband has applied to work on this issue as part of the Stellar Wave Program's 10th wave.
ℹ️ Repo Maintainers: To accept this application, review their application or assign @rubbathabrahband to this issue.
drips-wave commentedon Oct 17, 2026
Congratulations, @rubbathabrahband! 🎉 Your application was accepted by the repo's maintainers, and the issue is due on October 22, 2026.
🧑💻 @rubbathabrahband: Please resolve the issue such that the repo's maintainers have enough time to review your contribution before the due date. You'll earn Points for completing the issue on-time, which will make you eligible for a share of the Stellar Wave Program's reward pool.
Warning
When opening a PR, please link it to this issue to ensure it gets tracked accurately. Points are awarded when this issue is marked as completed by the maintainer.
🤠 Repo maintainers: Please keep an eye on the contributor's progress and review their work before the due date. You can manage this issue, including adjusting its complexity and points, here.
🌊 Happy Wave 🌊
rubbathabrahband commentedon Oct 17, 2026
Resolved in PR #174: #174