[object Object]

← back to Lifestyle Asset Intel

yolo tick #18: /admin/audit HTML view over recent valuation_calls

ced9794ed88bbafcac0a4672fccd5439adf20d6d · 2026-05-13 12:46:03 -0700 · Steve Abrams

Read-only inspector for the immutable audit log shipped in tick #7.
Supports ?slug=<asset-slug> and ?limit=<1..1000> filters, no-store
cache header, 4xx/5xx status-class coloring. Two new smoke tests
(render + slug filter).

Files touched

Diff

commit ced9794ed88bbafcac0a4672fccd5439adf20d6d
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed May 13 12:46:03 2026 -0700

    yolo tick #18: /admin/audit HTML view over recent valuation_calls
    
    Read-only inspector for the immutable audit log shipped in tick #7.
    Supports ?slug=<asset-slug> and ?limit=<1..1000> filters, no-store
    cache header, 4xx/5xx status-class coloring. Two new smoke tests
    (render + slug filter).
---
 public/css/app.css    | 20 +++++++++++++
 routes/console.js     | 24 +++++++++++++++
 tests/smoke.test.js   | 28 ++++++++++++++++++
 views/admin_audit.ejs | 81 +++++++++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 153 insertions(+)

diff --git a/public/css/app.css b/public/css/app.css
index 80b12b6..7a14d54 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -211,3 +211,23 @@ tfoot tr.totals th, tfoot tr.totals td {
 .methodology table { font-size: 0.92em; }
 .methodology blockquote { border-left: 3px solid var(--accent); margin: 0.8rem 0; padding: 0.4rem 0.9rem; color: var(--muted); }
 .methodology a { text-decoration: underline; text-underline-offset: 2px; }
+
+/* admin audit view (v0.2) */
+.audit-filter { display: flex; gap: 0.9rem; align-items: end; margin: 1rem 0 1.2rem; flex-wrap: wrap; }
+.audit-filter label { display: flex; flex-direction: column; gap: 0.2rem; }
+.audit-filter .lbl { font-size: 0.78em; color: var(--muted); letter-spacing: 0.04em; text-transform: uppercase; }
+.audit-filter input { padding: 0.4rem 0.6rem; border: 1px solid var(--line); border-radius: 6px; background: var(--card); color: var(--fg); font-family: var(--mono); font-size: 0.9em; }
+.audit-filter button { padding: 0.45rem 0.9rem; border: 1px solid var(--line); border-radius: 6px; background: var(--accent); color: #fff; cursor: pointer; font-weight: 600; }
+.audit-filter .reset { padding: 0.45rem 0.9rem; color: var(--muted); text-decoration: none; }
+.audit-filter .reset:hover { text-decoration: underline; }
+
+.audit-table { width: 100%; border-collapse: collapse; font-size: 0.86em; }
+.audit-table th, .audit-table td { padding: 0.4rem 0.5rem; border-bottom: 1px solid var(--line); text-align: left; }
+.audit-table th { font-weight: 600; color: var(--muted); letter-spacing: 0.03em; text-transform: uppercase; font-size: 0.78em; background: var(--card); position: sticky; top: 0; }
+.audit-table .num { text-align: right; font-variant-numeric: tabular-nums; }
+.audit-table .mono { font-family: var(--mono); }
+.audit-table .small { font-size: 0.85em; }
+.audit-table .status-2xx { color: var(--good, #20a55b); }
+.audit-table .status-3xx { color: var(--muted); }
+.audit-table .status-4xx { color: var(--warn, #c98014); font-weight: 600; }
+.audit-table .status-5xx { color: var(--bad, #c63831); font-weight: 600; }
diff --git a/routes/console.js b/routes/console.js
index 442578b..50689cf 100644
--- a/routes/console.js
+++ b/routes/console.js
@@ -9,6 +9,7 @@ const {
   insertHolding,
   deleteHolding
 } = require('../lib/portfolio');
+const { recentCalls } = require('../lib/audit');
 
 const router = express.Router();
 
@@ -145,6 +146,29 @@ router.post('/portfolio/add', async (req, res, next) => {
   } catch (e) { next(e); }
 });
 
+// /admin/audit — HTML view over /api/audit/recent. Read-only inspector of
+// the immutable valuation_calls log. No auth gate in v0 — runs on
+// localhost-only pm2; once the brand domain goes live, gate this behind
+// the next-auth-gate skill before exposing externally.
+router.get('/admin/audit', async (req, res, next) => {
+  try {
+    const limitRaw = parseInt(req.query.limit, 10);
+    const limit = Number.isFinite(limitRaw) && limitRaw > 0
+      ? Math.min(limitRaw, 1000)
+      : 100;
+    const slug = typeof req.query.slug === 'string' ? req.query.slug.trim() : '';
+    const calls = await recentCalls({ limit, asset_slug: slug || null });
+    res.set('Cache-Control', 'no-store, must-revalidate');
+    res.render('admin_audit', {
+      title: 'Audit — recent API calls',
+      calls,
+      filterSlug: slug,
+      limit,
+      path: req.path
+    });
+  } catch (e) { next(e); }
+});
+
 router.get('/methodology', (req, res) => {
   res.render('methodology', {
     title: 'Methodology — lifestyle-asset-intel',
diff --git a/tests/smoke.test.js b/tests/smoke.test.js
index 668d688..ed3dedb 100644
--- a/tests/smoke.test.js
+++ b/tests/smoke.test.js
@@ -270,6 +270,34 @@ test('GET / renders the console with sort and density controls', async () => {
   assert.ok(/application\/ld\+json/.test(r.text), 'schema.org JSON-LD present');
 });
 
+test('GET /admin/audit renders the audit HTML view', async () => {
+  // Make one call we know will land in the audit log, so the page
+  // isn't proving the empty-state.
+  await get('/api/assets?q=birkin');
+  // Give the fire-and-forget audit writer a tick to flush.
+  await new Promise((r) => setTimeout(r, 50));
+  const r = await get('/admin/audit');
+  assert.equal(r.status, 200);
+  assert.match(r.text, /Audit — recent API calls/);
+  assert.match(r.text, /class="audit-table"/);
+  // Filter form widgets present
+  assert.match(r.text, /name="slug"/);
+  assert.match(r.text, /name="limit"/);
+  // Must NEVER cache the audit view — it reflects an immutable log
+  // that other API calls keep appending to.
+  const head = await fetch(baseUrl + '/admin/audit');
+  assert.match(head.headers.get('cache-control') || '', /no-store/);
+});
+
+test('GET /admin/audit?slug=<slug> filters to a single asset', async () => {
+  await get('/api/valuation/birkin-30-togo-gold-ghw-us');
+  await new Promise((r) => setTimeout(r, 50));
+  const r = await get('/admin/audit?slug=birkin-30-togo-gold-ghw-us&limit=20');
+  assert.equal(r.status, 200);
+  // Should NOT contain the kelly slug (we filtered to birkin)
+  assert.ok(!/asset\/kelly-/.test(r.text), 'kelly slug should not leak into birkin-filtered view');
+});
+
 test('GET /asset/birkin-30-togo-gold-ghw-us renders the detail page', async () => {
   const r = await get('/asset/birkin-30-togo-gold-ghw-us');
   assert.equal(r.status, 200);
diff --git a/views/admin_audit.ejs b/views/admin_audit.ejs
new file mode 100644
index 0000000..97b87ff
--- /dev/null
+++ b/views/admin_audit.ejs
@@ -0,0 +1,81 @@
+<%- include('partials/head') %>
+<main class="container">
+  <p class="crumbs"><a href="/">&larr; Console</a></p>
+  <h1>Audit — recent API calls</h1>
+  <p class="muted">
+    Reading the immutable <code>valuation_calls</code> log. Showing the last
+    <%= calls.length %> rows. Filter by <code>?slug=&lt;asset-slug&gt;</code> or
+    <code>?limit=&lt;1..1000&gt;</code>. JSON: <a href="/api/audit/recent">/api/audit/recent</a>.
+  </p>
+
+  <form method="get" action="/admin/audit" class="audit-filter">
+    <label>
+      <span class="lbl">Asset slug</span>
+      <input type="text" name="slug" value="<%= filterSlug || '' %>" placeholder="birkin-30-togo-gold-ghw-us">
+    </label>
+    <label>
+      <span class="lbl">Limit</span>
+      <input type="number" name="limit" value="<%= limit %>" min="1" max="1000" step="1">
+    </label>
+    <button type="submit">Apply</button>
+    <% if (filterSlug || limit !== 100) { %>
+      <a class="reset" href="/admin/audit">Reset</a>
+    <% } %>
+  </form>
+
+  <% if (!calls.length) { %>
+    <p class="muted">No audit rows match the current filter.</p>
+  <% } else { %>
+    <table class="audit-table">
+      <thead>
+        <tr>
+          <th>Called at</th>
+          <th>Method</th>
+          <th>Route</th>
+          <th>Slug</th>
+          <th class="num">Status</th>
+          <th class="num">Latency</th>
+          <th>Caller</th>
+          <th>Request ID</th>
+        </tr>
+      </thead>
+      <tbody>
+        <% calls.forEach(function (c) { %>
+          <tr>
+            <td class="mono"><%= fmtTs(c.called_at) %></td>
+            <td class="mono"><%= c.method %></td>
+            <td class="mono"><%= c.route %></td>
+            <td>
+              <% if (c.asset_slug) { %>
+                <a href="/asset/<%= c.asset_slug %>"><%= c.asset_slug %></a>
+              <% } else { %>
+                <span class="muted">—</span>
+              <% } %>
+            </td>
+            <td class="num <%= statusClass(c.response_status) %>"><%= c.response_status %></td>
+            <td class="num"><%= c.latency_ms != null ? c.latency_ms + ' ms' : '—' %></td>
+            <td class="mono"><%= c.caller_ip || '—' %></td>
+            <td class="mono small"><%= (c.request_id || '').slice(0,8) %></td>
+          </tr>
+        <% }); %>
+      </tbody>
+    </table>
+  <% } %>
+</main>
+<%
+function fmtTs(v){
+  if(!v) return '—';
+  try { return new Date(v).toISOString().replace('T',' ').slice(0,19) + 'Z'; }
+  catch(e){ return String(v); }
+}
+function statusClass(s){
+  var n = parseInt(s,10);
+  if(!isFinite(n)) return '';
+  if(n >= 500) return 'status-5xx';
+  if(n >= 400) return 'status-4xx';
+  if(n >= 300) return 'status-3xx';
+  if(n >= 200) return 'status-2xx';
+  return '';
+}
+%>
+<%- include('partials/foot') %>

← 1df0cc4 yolo tick #17: /api/assets sort param (value/liquidity/comps  ·  back to Lifestyle Asset Intel  ·  yolo tick #19: condition_facets chips on asset detail comps 9cba52d →