← 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
M public/css/app.cssM routes/console.jsM tests/smoke.test.jsA views/admin_audit.ejs
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="/">← 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=<asset-slug></code> or
+ <code>?limit=<1..1000></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 →