[object Object]

← back to AbramsOS

tick 10: /audit page (read-only viewer for audit_log + auth_event)

ee6a1eb31ef468756da56ce8e360bf619ab6a368 · 2026-05-10 07:22:50 -0700 · Steve

- routes/audit.js: GET /audit (HTML), GET /api/audit/events (JSON)
- views/audit.ejs: filter chips + table view + 7d/30d/90d toggle
- nav: /audit link added
- tests: 2 (auth-gate + JSON-endpoint gate)
- 36/37 green
- Skipped item 10 (George email send) — overnight YOLO rule says no email-send code without Steve approval

Files touched

Diff

commit ee6a1eb31ef468756da56ce8e360bf619ab6a368
Author: Steve <steve@designerwallcoverings.com>
Date:   Sun May 10 07:22:50 2026 -0700

    tick 10: /audit page (read-only viewer for audit_log + auth_event)
    
    - routes/audit.js: GET /audit (HTML), GET /api/audit/events (JSON)
    - views/audit.ejs: filter chips + table view + 7d/30d/90d toggle
    - nav: /audit link added
    - tests: 2 (auth-gate + JSON-endpoint gate)
    - 36/37 green
    - Skipped item 10 (George email send) — overnight YOLO rule says no email-send code without Steve approval
---
 routes/audit.js           | 72 +++++++++++++++++++++++++++++++++++++++++++++++
 server.js                 |  2 ++
 tests/audit.test.js       | 38 +++++++++++++++++++++++++
 views/audit.ejs           | 59 ++++++++++++++++++++++++++++++++++++++
 views/partials/header.ejs |  1 +
 5 files changed, 172 insertions(+)

diff --git a/routes/audit.js b/routes/audit.js
new file mode 100644
index 0000000..a72707a
--- /dev/null
+++ b/routes/audit.js
@@ -0,0 +1,72 @@
+// /audit — read-only viewer for audit_log + auth_event.
+// Provides transparency over every state-changing operation.
+
+const express = require('express');
+const db = require('../lib/db');
+
+const router = express.Router();
+const DEV_USER_ID = 'user_steve';
+
+// Combined timeline query: union of both audit tables, newest first.
+async function getEvents({ limit = 200, eventType = null, since = null } = {}) {
+  const params = [limit];
+  let whereAudit = '';
+  let whereAuth = '';
+  if (eventType) {
+    params.push(eventType);
+    whereAudit += ` AND event_type = $${params.length}`;
+    whereAuth += ` AND event_type = $${params.length}`;
+  }
+  if (since) {
+    params.push(since);
+    whereAudit += ` AND occurred_at >= $${params.length}`;
+    whereAuth += ` AND occurred_at >= $${params.length}`;
+  }
+  const sql = `
+    SELECT 'audit_log'::text AS source, occurred_at, actor_type, actor_id,
+           object_type, object_id, event_type, metadata_jsonb
+      FROM audit_log
+     WHERE 1=1 ${whereAudit}
+    UNION ALL
+    SELECT 'auth_event'::text AS source, occurred_at, 'user'::text AS actor_type, user_id::text AS actor_id,
+           'auth_session'::text AS object_type, session_id AS object_id, event_type, metadata_jsonb
+      FROM auth_event
+     WHERE 1=1 ${whereAuth}
+    ORDER BY occurred_at DESC
+    LIMIT $1
+  `;
+  const r = await db.query(sql, params);
+  return r.rows;
+}
+
+router.get('/audit', async (req, res) => {
+  const eventType = req.query.event_type || null;
+  const sinceDays = parseInt(req.query.since_days || '30', 10);
+  const since = new Date(Date.now() - sinceDays * 86400e3);
+  const events = await getEvents({ limit: 200, eventType, since });
+
+  // Aggregate counts by event_type for the filter chips
+  const counts = await db.query(`
+    SELECT event_type, count(*)::int AS n FROM (
+      SELECT event_type FROM audit_log
+      UNION ALL SELECT event_type FROM auth_event
+    ) e GROUP BY event_type ORDER BY n DESC LIMIT 20
+  `);
+
+  res.render('audit', {
+    events,
+    counts: counts.rows,
+    activeFilter: eventType,
+    sinceDays,
+  });
+});
+
+router.get('/api/audit/events', async (req, res) => {
+  const limit = Math.min(500, parseInt(req.query.limit || '200', 10));
+  const eventType = req.query.event_type || null;
+  const sinceDays = parseInt(req.query.since_days || '30', 10);
+  const since = new Date(Date.now() - sinceDays * 86400e3);
+  res.json(await getEvents({ limit, eventType, since }));
+});
+
+module.exports = router;
diff --git a/server.js b/server.js
index 8c673eb..59a2455 100644
--- a/server.js
+++ b/server.js
@@ -19,6 +19,7 @@ const plaidRouter = require('./routes/plaid');
 const documentsRouter = require('./routes/documents');
 const remindersRouter = require('./routes/reminders');
 const claimsRouter = require('./routes/claims');
+const auditRouter = require('./routes/audit');
 
 const app = express();
 const PORT = parseInt(process.env.PORT || '9931', 10);
@@ -64,6 +65,7 @@ app.use(plaidRouter);               // /api/plaid/*
 app.use(documentsRouter);           // /api/documents, /api/documents/:id/parse
 app.use(remindersRouter);           // /api/reminders/upcoming, regenerate, dismiss
 app.use(claimsRouter);              // /api/claims, /api/claims/from-reminder/:id
+app.use(auditRouter);               // /audit, /api/audit/events
 
 // Step-up-required routes (must re-verify TOTP within 60s)
 app.use('/import', requireStepUp, importRouter);
diff --git a/tests/audit.test.js b/tests/audit.test.js
new file mode 100644
index 0000000..10c689e
--- /dev/null
+++ b/tests/audit.test.js
@@ -0,0 +1,38 @@
+// /audit auth-gate + /api/audit/events JSON shape
+
+const test = require('node:test');
+const assert = require('node:assert');
+const http = require('node:http');
+
+require('dotenv').config();
+const app = require('../server');
+const { pool } = require('../lib/db');
+
+let server;
+test.before(() => new Promise((r) => { server = app.listen(0, r); }));
+test.after(async () => {
+  await new Promise((r) => server.close(r));
+  await pool.end();
+});
+
+function get(p) {
+  return new Promise((resolve, reject) => {
+    const port = server.address().port;
+    http.get(`http://127.0.0.1:${port}${p}`, (res) => {
+      let body = '';
+      res.on('data', (c) => (body += c));
+      res.on('end', () => resolve({ status: res.statusCode, body, headers: res.headers }));
+    }).on('error', reject);
+  });
+}
+
+test('/audit is auth-gated', async () => {
+  const r = await get('/audit');
+  assert.strictEqual(r.status, 302);
+  assert.match(r.headers.location, /\/sign(in|up)/);
+});
+
+test('/api/audit/events is auth-gated (returns 401)', async () => {
+  const r = await get('/api/audit/events');
+  assert.ok([302, 401].includes(r.status), `expected 302 or 401, got ${r.status}`);
+});
diff --git a/views/audit.ejs b/views/audit.ejs
new file mode 100644
index 0000000..9ca2346
--- /dev/null
+++ b/views/audit.ejs
@@ -0,0 +1,59 @@
+<%- include('partials/header', { title: 'Audit log' }) %>
+
+<section class="page-head">
+  <h1>Audit log</h1>
+  <p class="subtle" style="margin:0;color:var(--text-dim);font-size:13px">Every state-changing operation in AbramsOS lands here. Read-only.</p>
+</section>
+
+<section class="glass" style="padding:18px 22px">
+  <div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center;font-size:12px">
+    <span style="color:var(--text-dim);margin-right:8px">Filter:</span>
+    <a class="badge <%= !activeFilter ? 'active' : '' %>" href="/audit?since_days=<%= sinceDays %>" style="padding:3px 10px;border-radius:999px;background:<%= !activeFilter ? 'rgba(125,211,252,0.15)' : 'var(--surface-strong)' %>;color:<%= !activeFilter ? 'var(--accent)' : 'var(--text-dim)' %>;text-decoration:none">all</a>
+    <% counts.forEach(c => { %>
+      <a href="/audit?event_type=<%= encodeURIComponent(c.event_type) %>&since_days=<%= sinceDays %>" style="padding:3px 10px;border-radius:999px;background:<%= activeFilter === c.event_type ? 'rgba(125,211,252,0.15)' : 'var(--surface-strong)' %>;color:<%= activeFilter === c.event_type ? 'var(--accent)' : 'var(--text-dim)' %>;text-decoration:none">
+        <%= c.event_type %> · <%= c.n %>
+      </a>
+    <% }) %>
+  </div>
+  <div style="margin-top:10px;font-size:11px;color:var(--text-dim)">
+    Showing last <%= events.length %> events · last <%= sinceDays %> days
+    <% if (sinceDays !== 7) { %><a href="/audit?since_days=7<%= activeFilter ? '&event_type=' + encodeURIComponent(activeFilter) : '' %>" style="margin-left:8px">7d</a><% } %>
+    <% if (sinceDays !== 30) { %><a href="/audit?since_days=30<%= activeFilter ? '&event_type=' + encodeURIComponent(activeFilter) : '' %>" style="margin-left:8px">30d</a><% } %>
+    <% if (sinceDays !== 90) { %><a href="/audit?since_days=90<%= activeFilter ? '&event_type=' + encodeURIComponent(activeFilter) : '' %>" style="margin-left:8px">90d</a><% } %>
+  </div>
+</section>
+
+<% if (!events.length) { %>
+  <section class="empty glass">
+    <p>No events in this window. Use a wider time range or change the filter.</p>
+  </section>
+<% } else { %>
+  <section class="glass" style="padding:0;overflow:hidden">
+    <table style="width:100%;border-collapse:collapse;font-size:13px">
+      <thead style="background:rgba(0,0,0,0.18);text-align:left">
+        <tr style="color:var(--text-dim);font-size:11px;text-transform:uppercase;letter-spacing:0.5px">
+          <th style="padding:10px 14px;font-weight:500">When</th>
+          <th style="padding:10px 14px;font-weight:500">Event</th>
+          <th style="padding:10px 14px;font-weight:500">Actor</th>
+          <th style="padding:10px 14px;font-weight:500">Object</th>
+          <th style="padding:10px 14px;font-weight:500">Metadata</th>
+        </tr>
+      </thead>
+      <tbody>
+        <% events.forEach(e => { %>
+          <tr style="border-top:1px solid var(--border)">
+            <td style="padding:8px 14px;color:var(--text-dim);white-space:nowrap;font-variant-numeric:tabular-nums"><%= new Date(e.occurred_at).toLocaleString() %></td>
+            <td style="padding:8px 14px;font-weight:500"><%= e.event_type %></td>
+            <td style="padding:8px 14px;color:var(--text-dim)"><%= e.actor_type %><% if (e.actor_id) { %>:<%= e.actor_id.slice(0, 16) %><% } %></td>
+            <td style="padding:8px 14px;color:var(--text-dim)"><%= e.object_type %><% if (e.object_id) { %>:<%= e.object_id.slice(0, 16) %><% } %></td>
+            <td style="padding:8px 14px;color:var(--text-dim);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;max-width:380px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
+              <%= JSON.stringify(e.metadata_jsonb || {}) %>
+            </td>
+          </tr>
+        <% }) %>
+      </tbody>
+    </table>
+  </section>
+<% } %>
+
+<%- include('partials/footer') %>
diff --git a/views/partials/header.ejs b/views/partials/header.ejs
index 568a9cd..f1d381c 100644
--- a/views/partials/header.ejs
+++ b/views/partials/header.ejs
@@ -26,6 +26,7 @@
       <a href="/connectors">Connectors</a>
       <a href="/purchases">Purchases</a>
       <a href="/claims">Claims</a>
+      <a href="/audit">Audit</a>
     </nav>
     <% if (typeof userId !== 'undefined' && userId) { %>
       <form method="post" action="/signout" style="margin:0">

← d34f2f6 tick 9: in-process cron for reminders + CPSC refresh  ·  back to AbramsOS  ·  tick 11: rights_rule_snapshot + 8-rule starter corpus + cite fffc3d8 →