← back to Lifestyle Asset Intel
yolo tick #34: /api/audit/recent ?since= ISO-8601 lower bound filter (called_at >= since; composes with route+method+status+asset_slug; unparseable/empty silently ignored; echoes normalized ISO); OpenAPI documents the new param
9b2ec0ed0537eb63ef1c59cc9def3e6cd3d665a2 · 2026-05-14 09:47:18 -0700 · Steve Abrams
Files touched
M lib/audit.jsM lib/openapi.jsM routes/api.jsM tests/audit.test.js
Diff
commit 9b2ec0ed0537eb63ef1c59cc9def3e6cd3d665a2
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu May 14 09:47:18 2026 -0700
yolo tick #34: /api/audit/recent ?since= ISO-8601 lower bound filter (called_at >= since; composes with route+method+status+asset_slug; unparseable/empty silently ignored; echoes normalized ISO); OpenAPI documents the new param
---
lib/audit.js | 12 +++++++++++-
lib/openapi.js | 5 +++--
routes/api.js | 11 ++++++++++-
tests/audit.test.js | 46 ++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 70 insertions(+), 4 deletions(-)
diff --git a/lib/audit.js b/lib/audit.js
index bfa5173..af42300 100644
--- a/lib/audit.js
+++ b/lib/audit.js
@@ -90,7 +90,8 @@ function auditMiddleware(req, res, next) {
}
async function recentCalls({
- limit = 100, asset_slug = null, route = null, method = null, status = null
+ limit = 100, asset_slug = null, route = null, method = null,
+ status = null, since = null
} = {}) {
const lim = Math.max(1, Math.min(1000, parseInt(limit, 10) || 100));
const where = [];
@@ -123,6 +124,15 @@ async function recentCalls({
where.push(`response_status = $${params.length}`);
}
}
+ // since: ISO-8601 timestamp lower bound (called_at >= since). Anything
+ // that doesn't parse to a valid Date is silently ignored.
+ if (since != null && since !== '') {
+ const d = new Date(String(since).trim());
+ if (!Number.isNaN(d.getTime())) {
+ params.push(d.toISOString());
+ where.push(`called_at >= $${params.length}`);
+ }
+ }
params.push(lim);
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
const r = await pool.query(
diff --git a/lib/openapi.js b/lib/openapi.js
index ec0b23a..e4fc47c 100644
--- a/lib/openapi.js
+++ b/lib/openapi.js
@@ -557,14 +557,15 @@ function buildSpec({ version, gitSha }) {
description:
'Immutable audit log per METHODOLOGY § 14. Returns most recent N (default 100) ' +
'calls in descending called_at order. Optional filters compose with AND: ' +
- 'asset_slug (exact match), route (case-insensitive substring), method (exact, case-insensitive), status (exact 3-digit code OR 1-digit class prefix "2"/"4"/"5" for status-class match).',
+ 'asset_slug (exact match), route (case-insensitive substring), method (exact, case-insensitive), status (exact 3-digit code OR 1-digit class prefix "2"/"4"/"5" for status-class match), since (ISO-8601 lower bound on called_at).',
tags: ['ops'],
parameters: [
{ name: 'limit', in: 'query', schema: { type: 'integer', minimum: 1, maximum: 1000, default: 100 } },
{ name: 'asset_slug', in: 'query', schema: { type: 'string' } },
{ name: 'route', in: 'query', description: 'Case-insensitive substring match against route template', schema: { type: 'string' } },
{ name: 'method', in: 'query', description: 'Exact HTTP method, case-insensitive', schema: { type: 'string', example: 'GET' } },
- { name: 'status', in: 'query', description: 'Exact 3-digit response status (200, 404, …) OR a 1-digit class prefix (2 → 2xx). Invalid input is silently ignored.', schema: { type: 'string', example: '404' } }
+ { name: 'status', in: 'query', description: 'Exact 3-digit response status (200, 404, …) OR a 1-digit class prefix (2 → 2xx). Invalid input is silently ignored.', schema: { type: 'string', example: '404' } },
+ { name: 'since', in: 'query', description: 'ISO-8601 timestamp; only rows with called_at >= since are returned. Unparseable input is silently ignored.', schema: { type: 'string', format: 'date-time' } }
],
responses: {
200: { description: 'OK', content: { 'application/json': { schema: {
diff --git a/routes/api.js b/routes/api.js
index eae9b8b..e03d99c 100644
--- a/routes/api.js
+++ b/routes/api.js
@@ -250,12 +250,20 @@ router.get('/audit/recent', setCache(NO_STORE), async (req, res, next) => {
let statusEcho = null;
if (/^[1-5][0-9]{2}$/.test(statusRaw)) statusEcho = parseInt(statusRaw, 10);
else if (/^[1-5]$/.test(statusRaw)) statusEcho = `${statusRaw}xx`;
+ const sinceRaw = typeof req.query.since === 'string' ? req.query.since.trim() : '';
+ // Echo back the normalized ISO string if parseable, else null.
+ let sinceEcho = null;
+ if (sinceRaw) {
+ const d = new Date(sinceRaw);
+ if (!Number.isNaN(d.getTime())) sinceEcho = d.toISOString();
+ }
const rows = await recentCalls({
limit,
asset_slug,
route: route || null,
method: method || null,
- status: statusRaw || null
+ status: statusRaw || null,
+ since: sinceRaw || null
});
res.json({
count: rows.length,
@@ -263,6 +271,7 @@ router.get('/audit/recent', setCache(NO_STORE), async (req, res, next) => {
route: route || null,
method: method ? method.toUpperCase() : null,
status: statusEcho,
+ since: sinceEcho,
calls: rows
});
} catch (e) { next(e); }
diff --git a/tests/audit.test.js b/tests/audit.test.js
index 976bd85..8ce8e54 100644
--- a/tests/audit.test.js
+++ b/tests/audit.test.js
@@ -248,6 +248,52 @@ test('GET /api/audit/recent?status filters by exact code and class-prefix, compo
assert.equal(rEmpty.json.status, null);
});
+test('GET /api/audit/recent?since filters by ISO timestamp lower bound and composes', async () => {
+ // Capture a cutoff BEFORE we make new calls.
+ await new Promise((res) => setTimeout(res, 50));
+ const cutoff = new Date().toISOString();
+ await new Promise((res) => setTimeout(res, 50));
+
+ // Generate at least one call AFTER the cutoff.
+ await getJson('/api/valuation/birkin-30-togo-gold-ghw-us');
+ await new Promise((res) => setTimeout(res, 250));
+
+ const r = await getJson(`/api/audit/recent?since=${encodeURIComponent(cutoff)}&limit=100`);
+ assert.equal(r.status, 200);
+ assert.equal(r.json.since, cutoff);
+ assert.ok(r.json.calls.length >= 1, 'at least one row after cutoff');
+ for (const c of r.json.calls) {
+ assert.ok(new Date(c.called_at).getTime() >= new Date(cutoff).getTime(),
+ `expected called_at >= cutoff, got ${c.called_at}`);
+ }
+
+ // Composes with route filter.
+ const r2 = await getJson(
+ `/api/audit/recent?since=${encodeURIComponent(cutoff)}&route=valuation&limit=20`
+ );
+ assert.equal(r2.status, 200);
+ for (const c of r2.json.calls) {
+ assert.match(c.route, /\/api\/valuation/);
+ assert.ok(new Date(c.called_at).getTime() >= new Date(cutoff).getTime());
+ }
+
+ // Far-future cutoff returns zero rows.
+ const future = new Date(Date.now() + 1000 * 60 * 60 * 24 * 365).toISOString();
+ const rEmpty = await getJson(`/api/audit/recent?since=${encodeURIComponent(future)}&limit=10`);
+ assert.equal(rEmpty.status, 200);
+ assert.equal(rEmpty.json.calls.length, 0);
+
+ // Unparseable since is silently ignored → echoes null and behaves like no filter.
+ const rJunk = await getJson('/api/audit/recent?since=not-a-date&limit=5');
+ assert.equal(rJunk.status, 200);
+ assert.equal(rJunk.json.since, null);
+
+ // Empty since is silently ignored.
+ const rBlank = await getJson('/api/audit/recent?since=&limit=5');
+ assert.equal(rBlank.status, 200);
+ assert.equal(rBlank.json.since, null);
+});
+
test('valuation_calls has no UPDATE or DELETE called by app code', async () => {
// Sanity: app should never mutate the table. A grep would verify
// this at code-review time, but the fastest check at runtime is that
← 674dc23 yolo tick #33: /api/audit/recent ?status= filter (exact 3-di
·
back to Lifestyle Asset Intel
·
snapshot — gitify backup 2026-05-19 3c385b4 →