[object Object]

← back to Lifestyle Asset Intel

yolo tick #33: /api/audit/recent ?status= filter (exact 3-digit code OR 1-digit class prefix 2/4/5; composes with route+method+asset_slug; invalid/empty silently ignored); OpenAPI documents the new param

674dc237085960c61d12e89822fdcb83a7869cf9 · 2026-05-14 09:17:01 -0700 · Steve Abrams

Files touched

Diff

commit 674dc237085960c61d12e89822fdcb83a7869cf9
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu May 14 09:17:01 2026 -0700

    yolo tick #33: /api/audit/recent ?status= filter (exact 3-digit code OR 1-digit class prefix 2/4/5; composes with route+method+asset_slug; invalid/empty silently ignored); OpenAPI documents the new param
---
 lib/audit.js        | 20 +++++++++++++++++++-
 lib/openapi.js      |  5 +++--
 routes/api.js       | 11 ++++++++++-
 tests/audit.test.js | 48 ++++++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 80 insertions(+), 4 deletions(-)

diff --git a/lib/audit.js b/lib/audit.js
index 058a858..bfa5173 100644
--- a/lib/audit.js
+++ b/lib/audit.js
@@ -89,7 +89,9 @@ function auditMiddleware(req, res, next) {
   next();
 }
 
-async function recentCalls({ limit = 100, asset_slug = null, route = null, method = null } = {}) {
+async function recentCalls({
+  limit = 100, asset_slug = null, route = null, method = null, status = null
+} = {}) {
   const lim = Math.max(1, Math.min(1000, parseInt(limit, 10) || 100));
   const where = [];
   const params = [];
@@ -105,6 +107,22 @@ async function recentCalls({ limit = 100, asset_slug = null, route = null, metho
     params.push(method.trim().toUpperCase());
     where.push(`UPPER(method) = $${params.length}`);
   }
+  // status: accept either an integer code (200, 404, …) or a 1-digit
+  // class prefix ("2", "4", "5"). Out-of-range / non-numeric input is
+  // silently ignored (mirrors how empty-string route/method are treated).
+  if (status != null && status !== '') {
+    const s = String(status).trim();
+    if (/^[1-5]$/.test(s)) {
+      params.push(parseInt(s, 10) * 100);
+      const lo = `$${params.length}`;
+      params.push(parseInt(s, 10) * 100 + 99);
+      const hi = `$${params.length}`;
+      where.push(`response_status BETWEEN ${lo} AND ${hi}`);
+    } else if (/^[1-5][0-9]{2}$/.test(s)) {
+      params.push(parseInt(s, 10));
+      where.push(`response_status = $${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 3614fb2..ec0b23a 100644
--- a/lib/openapi.js
+++ b/lib/openapi.js
@@ -557,13 +557,14 @@ 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).',
+          '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).',
         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: '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' } }
         ],
         responses: {
           200: { description: 'OK', content: { 'application/json': { schema: {
diff --git a/routes/api.js b/routes/api.js
index a3b0d1f..eae9b8b 100644
--- a/routes/api.js
+++ b/routes/api.js
@@ -243,17 +243,26 @@ router.get('/audit/recent', setCache(NO_STORE), async (req, res, next) => {
     const asset_slug = req.query.asset_slug || null;
     const route = typeof req.query.route === 'string' ? req.query.route.trim() : '';
     const method = typeof req.query.method === 'string' ? req.query.method.trim() : '';
+    const statusRaw = typeof req.query.status === 'string' ? req.query.status.trim() : '';
+    // Echo back the normalized status: full 3-digit codes as integers,
+    // 1-digit class prefixes as the string "2xx"/"4xx"/etc. Anything
+    // that doesn't validate echoes back as null (server ignored it).
+    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 rows = await recentCalls({
       limit,
       asset_slug,
       route: route || null,
-      method: method || null
+      method: method || null,
+      status: statusRaw || null
     });
     res.json({
       count: rows.length,
       asset_slug: asset_slug || null,
       route: route || null,
       method: method ? method.toUpperCase() : null,
+      status: statusEcho,
       calls: rows
     });
   } catch (e) { next(e); }
diff --git a/tests/audit.test.js b/tests/audit.test.js
index db25a8a..976bd85 100644
--- a/tests/audit.test.js
+++ b/tests/audit.test.js
@@ -200,6 +200,54 @@ test('GET /api/audit/recent?method filters by HTTP method (case-insensitive) and
   assert.equal(r4.json.method, null);
 });
 
+test('GET /api/audit/recent?status filters by exact code and class-prefix, composes', async () => {
+  // Prime the table with a known 200 row and a known 404 row.
+  await getJson('/api/valuation/birkin-30-togo-gold-ghw-us');               // 200
+  await getJson('/api/valuation/this-slug-definitely-does-not-exist-xyz');  // 404
+  await new Promise((res) => setTimeout(res, 250));
+
+  // Exact 3-digit code.
+  const r200 = await getJson('/api/audit/recent?status=200&limit=50');
+  assert.equal(r200.status, 200);
+  assert.equal(r200.json.status, 200);
+  assert.ok(r200.json.calls.length >= 1, 'at least one 200 row');
+  for (const c of r200.json.calls) assert.equal(c.response_status, 200);
+
+  // Exact 404.
+  const r404 = await getJson('/api/audit/recent?status=404&limit=50');
+  assert.equal(r404.status, 200);
+  assert.equal(r404.json.status, 404);
+  for (const c of r404.json.calls) assert.equal(c.response_status, 404);
+
+  // 1-digit class prefix → 4xx (matches the 404 row).
+  const r4xx = await getJson('/api/audit/recent?status=4&limit=50');
+  assert.equal(r4xx.status, 200);
+  assert.equal(r4xx.json.status, '4xx');
+  assert.ok(r4xx.json.calls.length >= 1, 'at least one 4xx row');
+  for (const c of r4xx.json.calls) {
+    assert.ok(c.response_status >= 400 && c.response_status <= 499,
+      `expected 4xx, got ${c.response_status}`);
+  }
+
+  // Composes cleanly with route filter.
+  const rCombo = await getJson('/api/audit/recent?status=200&route=valuation&limit=20');
+  assert.equal(rCombo.status, 200);
+  for (const c of rCombo.json.calls) {
+    assert.equal(c.response_status, 200);
+    assert.match(c.route, /\/api\/valuation/);
+  }
+
+  // Invalid status (non-numeric) is silently ignored → behaves like no filter.
+  const rJunk = await getJson('/api/audit/recent?status=banana&limit=3');
+  assert.equal(rJunk.status, 200);
+  assert.equal(rJunk.json.status, null);
+
+  // Empty status is silently ignored too.
+  const rEmpty = await getJson('/api/audit/recent?status=&limit=3');
+  assert.equal(rEmpty.status, 200);
+  assert.equal(rEmpty.json.status, 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

← 369b470 yolo tick #32: /api/audit/recent ?method= filter (HTTP verb,  ·  back to Lifestyle Asset Intel  ·  yolo tick #34: /api/audit/recent ?since= ISO-8601 lower boun 9b2ec0e →