← 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
M routes/api.jsM tests/smoke.test.js
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 →