feat: add visitor identity cookie and database reset job

Three connected features for the deployment-strategy demo:

1. Visitor identity cookie (v3). A new visitors table backs a first-party
   gb_visitor cookie: middleware on /api checks the cookie's UUID against
   the database on every request and regenerates + persists it when the id
   is missing, malformed, or unknown (fresh browser, pruned identity, or a
   reset database). The identity remembers the name a visitor signed with
   (pre-filled next time, used for unsigned posts), replaces the standalone
   reaction cookie for reaction de-dupe, and feeds a live "N visiting"
   counter (identities seen in the last 5 minutes) shown on the wall, in
   /api/info, the admin overview, and the presence/tick SSE events. The
   admin console gains Visitors/Visiting tiles and a visitors table, plus a
   GET /api/me endpoint and GET /api/admin/visitors.

2. guestbook reset command. Wipes entries (including soft-deleted rows),
   reactions and visitor identities under the schema advisory lock, with
   --dry-run, --hard (also reset wall settings) and --reseed flags; meant
   to run as a one-off Miabi Job. After a reset every outstanding cookie is
   invalidated, so browsers get a fresh identity on their next request.

3. cleanup learns --prune-visitors (default 30 days): the cron job now also
   forgets idle identities together with their reactions.

Docs (README, DEMO) updated: act 8 covers reset, the visitor-identity
talking point is in act 3, and the API tables list the new endpoints.

Akili-Task: tsk_ptdi5ufndrzwlljk4v5g
Akili-Agent: goldfish
This commit is contained in:
Akili
2026-10-07 19:06:07 +02:00
parent a12c27b6bc
commit 86215f1eb1
13 changed files with 775 additions and 74 deletions
+31
View File
@@ -108,6 +108,8 @@
td.mono { font-family: var(--mono); font-size: 12.5px; word-break: break-all; }
tr.me td { color: #fff; }
tr.me td:first-child::after { content: " ← you"; color: var(--accent-2); font-family: inherit; font-size: 11.5px; }
td.fresh { color: var(--ok); }
td.fresh::before { content: "● "; font-size: 10px; }
dl.kv { display: grid; grid-template-columns: auto 1fr; gap: 8px 16px; margin: 0; font-size: 13.5px; }
dl.kv dt { color: var(--muted); }
dl.kv dd { margin: 0; font-family: var(--mono); font-size: 12.5px; word-break: break-all; }
@@ -216,6 +218,8 @@
<div id="tab-overview" data-panel>
<div class="tiles">
<div class="card tile live"><div class="label">Online now</div><div class="value" id="tOnline">–</div></div>
<div class="card tile"><div class="label">Visiting</div><div class="value" id="tVisiting" title="Visitor identities seen in the last 5 minutes">–</div></div>
<div class="card tile"><div class="label">Visitors</div><div class="value" id="tVisitors" title="Identity cookies the app has issued (rows in the visitors table)">–</div></div>
<div class="card tile"><div class="label">Replicas</div><div class="value" id="tReplicas">–</div></div>
<div class="card tile"><div class="label">Signatures</div><div class="value" id="tTotal">–</div></div>
<div class="card tile"><div class="label">Today</div><div class="value" id="tToday">–</div></div>
@@ -242,6 +246,14 @@
<dl class="kv" id="runtime"></dl>
</div>
</div>
<div class="card pad">
<h2>Visitors</h2>
<p class="hint">Every browser gets an identity cookie (<code>gb_visitor</code>); the backend accepts it only while its id exists in the database — after a <code>guestbook reset</code> every cookie is regenerated on the next request. Idle identities are pruned by the <code>cleanup</code> job (<code>--prune-visitors</code>).</p>
<table>
<thead><tr><th>Visitor</th><th>Name</th><th>Signatures</th><th>First seen</th><th>Last seen</th></tr></thead>
<tbody id="visitorRows"></tbody>
</table>
</div>
</div>
</div>
@@ -445,6 +457,8 @@
$('appName').textContent = rt.app;
setVersion(rt.version);
$('tOnline').textContent = online;
$('tVisiting').textContent = overview.visitors ?? 0;
$('tVisitors').textContent = stats.visitors ?? 0;
$('tReplicas').textContent = replicas.length;
$('tTotal').textContent = stats.total;
$('tToday').textContent = stats.today;
@@ -452,6 +466,7 @@
$('tPinned').textContent = stats.pinned;
$('tHidden').textContent = stats.hidden;
renderChart(stats.daily || []);
loadVisitors();
$('replicaHint').innerHTML = rt.broker === 'redis'
? 'Presence is shared through Redis, so every replica is listed. The requests counter grows per replica and resets when a rollout replaces it.'
@@ -502,6 +517,22 @@
}).join('');
}
// ---- visitors ----------------------------------------------------------
async function loadVisitors() {
const r = await req('GET', '/api/admin/visitors?limit=25');
if (!r.ok || !r.data) return;
const activeSince = new Date(r.data.active_since || 0).getTime();
const rows = r.data.visitors || [];
$('visitorRows').innerHTML = rows.length ? rows.map((v) => `
<tr>
<td class="mono">${esc(String(v.id).slice(0, 8))}…</td>
<td>${esc(v.name || '–')}</td>
<td>${v.signatures}</td>
<td title="${esc(new Date(v.first_seen).toLocaleString())}">${ago(v.first_seen)}</td>
<td class="${new Date(v.last_seen).getTime() >= activeSince ? 'fresh' : ''}" title="${esc(new Date(v.last_seen).toLocaleString())}">${ago(v.last_seen)}</td>
</tr>`).join('') : '<tr><td colspan="5" class="empty">No visitor identities yet — they are created on the first request.</td></tr>';
}
// ---- moderation --------------------------------------------------------
const mod = { status: '', q: '', offset: 0 };
async function loadRows() {
+36 -5
View File
@@ -215,6 +215,10 @@
.paused b { color: var(--text); }
.replicas { font-size: 12.5px; color: var(--muted); padding: 5px 12px; border-radius: 999px;
background: var(--card); border: 1px solid var(--card-border); white-space: nowrap; }
.clock-meta .you { color: var(--text); font-weight: 600; }
.visitors { display: inline-flex; align-items: center; gap: 7px; font-size: 12.5px; color: var(--muted);
padding: 5px 12px; border-radius: 999px; background: var(--card); border: 1px solid var(--card-border);
white-space: nowrap; }
.empty, .loading { text-align: center; color: var(--muted); padding: 40px 20px; font-size: 15px; }
@@ -257,7 +261,8 @@
<div class="status">
<span class="ver" id="ver" title="Serving version">v…</span>
<span class="replicas" id="replicas" title="Running replicas" hidden></span>
<span class="online"><span class="pulse"></span> <span id="online">0</span> online</span>
<span class="visitors" id="visitors" title="Visitor identities active in the last 5 minutes (issued by the identity cookie)" hidden>👤 <span id="visitorCount">0</span></span>
<span class="online" title="Browsers connected to the live stream right now"><span class="pulse"></span> <span id="online">0</span> online</span>
<button class="icon-btn" id="themeBtn" title="Toggle light / dark theme" aria-label="Toggle light / dark theme">🌓</button>
</div>
</div>
@@ -280,6 +285,8 @@
<div class="clock-time" id="clockTime">—</div>
</div>
<div class="clock-meta" id="clockMeta">waiting for server…</div>
<!-- Visitor identity (new in v3): the server recognises this browser -->
<div class="clock-meta" id="visitorMeta" hidden></div>
</div>
<!-- Home view -->
@@ -347,6 +354,7 @@
const api = {
info: () => fetch('/api/info').then(r => r.json()),
me: () => fetch('/api/me').then(r => r.json()),
time: () => fetch('/api/time').then(r => r.json()),
settings: () => fetch('/api/settings').then(r => r.json()),
list: (limit, offset) => fetch(`/api/entries?limit=${limit}&offset=${offset}`).then(r => r.json()),
@@ -486,6 +494,28 @@
$('replicas').hidden = false;
$('replicas').textContent = n === 1 ? '1 replica' : n + ' replicas';
}
function setVisitors(n) {
if (!n) return;
$('visitors').hidden = false;
$('visitorCount').textContent = n;
}
// ---- visitor identity ---------------------------------------------------
// The server recognises this browser by the gb_visitor cookie: it checks
// the cookie's id against the database and hands out a new one when it is
// unknown (first visit, pruned identity, or a wiped database). /api/me
// reports who the server thinks we are; the remembered name pre-fills the
// form.
function showVisitor(v) {
if (!v || !v.id) return;
const el = $('visitorMeta');
el.hidden = false;
const bits = [`you are <span class="you">${esc(v.name || 'guest-' + String(v.id).slice(0, 8))}</span>`];
if (v.signatures > 0) bits.push(`${v.signatures} signature${v.signatures === 1 ? '' : 's'}`);
if (v.first_seen) bits.push(`since ${timeAgo(v.first_seen)}`);
el.innerHTML = bits.join(' · ');
if (v.name && !$('name').value) $('name').value = v.name;
}
function applySettings(st) {
const banner = (st && st.banner) || '';
$('banner').hidden = !banner;
@@ -585,8 +615,8 @@
let connectedOnce = false;
function connectSSE() {
const es = new EventSource('/api/stream');
es.addEventListener('welcome', (e) => { const d = JSON.parse(e.data); setVersion(d.version); setOnline(d.online); setReplicas(d.replicas); });
es.addEventListener('presence', (e) => { const d = JSON.parse(e.data); setOnline(d.online); setReplicas(d.replicas); });
es.addEventListener('welcome', (e) => { const d = JSON.parse(e.data); setVersion(d.version); setOnline(d.online); setReplicas(d.replicas); setVisitors(d.visitors); });
es.addEventListener('presence', (e) => { const d = JSON.parse(e.data); setOnline(d.online); setReplicas(d.replicas); setVisitors(d.visitors); });
es.addEventListener('created', (e) => { const d = JSON.parse(e.data); if (d.entry && !d.entry.hidden) onCreated(d.entry); });
es.addEventListener('updated', () => reloadView());
es.addEventListener('deleted', (e) => { const d = JSON.parse(e.data); onDeleted(d.id); });
@@ -594,7 +624,7 @@
es.addEventListener('settings', (e) => { const d = JSON.parse(e.data); applySettings(d.settings); });
// Resync after a reconnect: events sent while disconnected were missed.
es.addEventListener('open', () => { if (connectedOnce) { reloadView(); api.settings().then(applySettings).catch(() => {}); } connectedOnce = true; });
es.addEventListener('tick', (e) => { const d = JSON.parse(e.data); showTime(d); setVersion(d.version); });
es.addEventListener('tick', (e) => { const d = JSON.parse(e.data); showTime(d); setVersion(d.version); setVisitors(d.visitors); });
// EventSource auto-reconnects on error; nothing to do here.
}
@@ -620,10 +650,11 @@
// ---- boot ---------------------------------------------------------------
api.info().then((d) => {
setVersion(d.version); setOnline(d.online); setReplicas((d.replicas || []).length);
setVersion(d.version); setOnline(d.online); setReplicas((d.replicas || []).length); setVisitors(d.visitors);
$('adminLink').hidden = !d.admin;
if (d.broker) { $('brokerPill').hidden = false; $('brokerPill').textContent = d.broker === 'redis' ? 'Redis pub/sub' : 'In-process broker'; }
}).catch(() => {});
api.me().then(showVisitor).catch(() => {});
api.settings().then(applySettings).catch(() => {});
api.time().then(showTime).catch(() => {}); // immediate clock; SSE ticks keep it live
route();