[object Object]

← back to Lifestyle Asset Intel

yolo tick #11: per-route Cache-Control discipline

25a60821921c8c3ea97da537b2e5056b4e376a81 · 2026-05-10 00:21:49 -0700 · Steve Abrams

CDN + client cache hygiene matters for any enterprise API. Without
explicit headers, intermediaries either over-cache (serving stale
valuations) or under-cache (defeating CDN economics on the
list endpoints).

Discipline (lib/audit must record every valuation hit, so caching
those is non-negotiable):

  no-store    /api/valuation/{slug}, /api/audit/recent, /api/portfolio*
              — auditable + per-user; must hit live every time
  no-cache    /api/health, /api/version
              — conditional GETs OK, never serve stale to monitors
  public 60s  /api/assets
              — list + filter results; refresh quickly
  public 300s /api/sources, /api/index/{slug}
              — slow-moving reference data
  public 1h   /api/openapi.json
              — only changes on deploy

Plus stale-while-revalidate on the public ones so a brief origin
hiccup doesn't propagate to clients.

Helper: setCache(value) mini-middleware factory inside routes/api.js
so every route declares its own cache policy on the same line as the
handler — easy to audit at code-review time.

Smoke test asserts five representative paths (one per cache class).
41/41 tests green; live curl probe confirms all 8 documented routes
emit the expected Cache-Control header.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 25a60821921c8c3ea97da537b2e5056b4e376a81
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun May 10 00:21:49 2026 -0700

    yolo tick #11: per-route Cache-Control discipline
    
    CDN + client cache hygiene matters for any enterprise API. Without
    explicit headers, intermediaries either over-cache (serving stale
    valuations) or under-cache (defeating CDN economics on the
    list endpoints).
    
    Discipline (lib/audit must record every valuation hit, so caching
    those is non-negotiable):
    
      no-store    /api/valuation/{slug}, /api/audit/recent, /api/portfolio*
                  — auditable + per-user; must hit live every time
      no-cache    /api/health, /api/version
                  — conditional GETs OK, never serve stale to monitors
      public 60s  /api/assets
                  — list + filter results; refresh quickly
      public 300s /api/sources, /api/index/{slug}
                  — slow-moving reference data
      public 1h   /api/openapi.json
                  — only changes on deploy
    
    Plus stale-while-revalidate on the public ones so a brief origin
    hiccup doesn't propagate to clients.
    
    Helper: setCache(value) mini-middleware factory inside routes/api.js
    so every route declares its own cache policy on the same line as the
    handler — easy to audit at code-review time.
    
    Smoke test asserts five representative paths (one per cache class).
    41/41 tests green; live curl probe confirms all 8 documented routes
    emit the expected Cache-Control header.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 routes/api.js       | 39 +++++++++++++++++++++++++++++++--------
 tests/smoke.test.js | 22 ++++++++++++++++++++++
 2 files changed, 53 insertions(+), 8 deletions(-)

diff --git a/routes/api.js b/routes/api.js
index 53efb42..d7967cf 100644
--- a/routes/api.js
+++ b/routes/api.js
@@ -21,6 +21,23 @@ const router = express.Router();
 // table. See lib/audit.js for the full schema + redaction rules.
 router.use(auditMiddleware);
 
+// Per-route Cache-Control discipline. Why each rule:
+//
+//   no-store    : valuations + portfolio + audit (auditable + per-user;
+//                 must hit live so audit row writes; never sit in CDN)
+//   short cache : list endpoints (sources/assets/indices/openapi) —
+//                 minutes-fresh is fine for the catalog
+//   no-cache    : health/version (allow conditional GETs but never
+//                 serve stale to monitoring)
+const NO_STORE  = 'no-store, must-revalidate';
+const NO_CACHE  = 'no-cache, must-revalidate';
+const PUB_60    = 'public, max-age=60, stale-while-revalidate=300';
+const PUB_300   = 'public, max-age=300, stale-while-revalidate=86400';
+const PUB_3600  = 'public, max-age=3600, stale-while-revalidate=86400';
+function setCache(value) {
+  return (req, res, next) => { res.set('Cache-Control', value); next(); };
+}
+
 // Resolve build metadata at boot (cheap; doesn't touch the DB).
 const PKG_VERSION = (() => {
   try { return require('../package.json').version; } catch { return '0.0.0'; }
@@ -48,7 +65,7 @@ const BUILD_TIME = new Date().toISOString();
 // regenerating per-request would just burn CPU.
 const OPENAPI_SPEC = buildSpec({ version: PKG_VERSION, gitSha: GIT_SHA });
 
-router.get('/health', async (req, res) => {
+router.get('/health', setCache(NO_CACHE), async (req, res) => {
   let dbOk = false;
   try {
     await pool.query('SELECT 1');
@@ -62,7 +79,7 @@ router.get('/health', async (req, res) => {
 // /api/version — full build + data introspection. Used by deploy
 // scripts, monitoring dashboards, and "is the prod the same as my
 // local?" pre-flight checks.
-router.get('/version', async (req, res, next) => {
+router.get('/version', setCache(NO_CACHE), async (req, res, next) => {
   try {
     const [migs, counts] = await Promise.all([
       pool.query(`SELECT filename, applied_at FROM schema_migrations ORDER BY filename`),
@@ -101,7 +118,7 @@ router.get('/version', async (req, res, next) => {
   } catch (e) { next(e); }
 });
 
-router.get('/sources', async (req, res, next) => {
+router.get('/sources', setCache(PUB_300), async (req, res, next) => {
   try {
     const r = await pool.query(`SELECT slug, name, tier, kind, weight, enabled, url, notes
                                   FROM sources
@@ -110,7 +127,7 @@ router.get('/sources', async (req, res, next) => {
   } catch (e) { next(e); }
 });
 
-router.get('/assets', async (req, res, next) => {
+router.get('/assets', setCache(PUB_60), async (req, res, next) => {
   try {
     const q = typeof req.query.q === 'string' ? req.query.q : '';
     const rows = await listAssets({ q });
@@ -118,7 +135,9 @@ router.get('/assets', async (req, res, next) => {
   } catch (e) { next(e); }
 });
 
-router.get('/valuation/:slug', async (req, res, next) => {
+// Valuations are auditable + must always hit live (audit middleware
+// must record every request). NEVER cache.
+router.get('/valuation/:slug', setCache(NO_STORE), async (req, res, next) => {
   try {
     const out = await valueAsset(req.params.slug);
     if (!out) return res.status(404).json({ error: 'asset_not_found' });
@@ -126,7 +145,7 @@ router.get('/valuation/:slug', async (req, res, next) => {
   } catch (e) { next(e); }
 });
 
-router.get('/index/:slug', async (req, res, next) => {
+router.get('/index/:slug', setCache(PUB_300), async (req, res, next) => {
   try {
     const out = await getIndex(req.params.slug);
     if (!out) return res.status(404).json({ error: 'index_not_found' });
@@ -134,11 +153,12 @@ router.get('/index/:slug', async (req, res, next) => {
   } catch (e) { next(e); }
 });
 
-router.get('/openapi.json', (req, res) => {
+router.get('/openapi.json', setCache(PUB_3600), (req, res) => {
   res.type('application/json').send(JSON.stringify(OPENAPI_SPEC));
 });
 
-router.get('/audit/recent', async (req, res, next) => {
+// Audit must reflect the live table.
+router.get('/audit/recent', setCache(NO_STORE), async (req, res, next) => {
   try {
     const limit = req.query.limit;
     const asset_slug = req.query.asset_slug || null;
@@ -147,6 +167,9 @@ router.get('/audit/recent', async (req, res, next) => {
   } catch (e) { next(e); }
 });
 
+// Portfolio is per-user state — never cache, never put behind a CDN.
+router.use('/portfolio', setCache(NO_STORE));
+
 router.get('/portfolio', async (req, res, next) => {
   try {
     const owner = req.query.owner;
diff --git a/tests/smoke.test.js b/tests/smoke.test.js
index 75105fb..e171c52 100644
--- a/tests/smoke.test.js
+++ b/tests/smoke.test.js
@@ -46,6 +46,28 @@ test('boots and returns 200 on /api/health with db_ok true', async () => {
   assert.equal(r.json.version, '0.0.1');
 });
 
+test('Cache-Control headers match the per-route discipline', async () => {
+  // Health: no-cache (must-validate)
+  let r = await fetch(baseUrl + '/api/health');
+  assert.match(r.headers.get('cache-control') || '', /no-cache/);
+
+  // Assets list: short public cache
+  r = await fetch(baseUrl + '/api/assets');
+  assert.match(r.headers.get('cache-control') || '', /public.*max-age=60/);
+
+  // Valuation: NEVER cached (audit requirement)
+  r = await fetch(baseUrl + '/api/valuation/birkin-30-togo-gold-ghw-us');
+  assert.match(r.headers.get('cache-control') || '', /no-store/);
+
+  // OpenAPI spec: longer cache (changes only on deploy)
+  r = await fetch(baseUrl + '/api/openapi.json');
+  assert.match(r.headers.get('cache-control') || '', /public.*max-age=3600/);
+
+  // Portfolio: per-user state, never cached
+  r = await fetch(baseUrl + '/api/portfolio?owner=cache-probe%40example.com');
+  assert.match(r.headers.get('cache-control') || '', /no-store/);
+});
+
 test('GET /api/assets?q=birkin returns only Birkin family rows', async () => {
   const r = await get('/api/assets?q=birkin');
   assert.equal(r.status, 200);

← 6d5a46c yolo tick #10: server-side /api/assets?q= filter  ·  back to Lifestyle Asset Intel  ·  yolo tick #12: family taxonomy pages (/family/:slug) 41d8213 →