openvibers/openvibe.network Commits

Add private cross-site history timeline (#760)

OP OpenVibers committed 3 days ago · a91ab9f130 · 12 files, +161 −30

* Build the private cross-site history timeline

* Filter non-entity and sensitive history visits

* Include history migration in schema fixture

* Keep history cursor totals stable

* Update history migration fixtures

Seed a typed visit in security fixtures and include the history migration in the inventory expectation.

Changes

ddiff --git a/docs/history-api.md b/docs/history-api.md
nnew file mode 100644
iindex 00000000..43260389
---- /dev/null
++++ b/docs/history-api.md
@@@ -0,0 +1,22 @@
++# Private visit history API
++
++Every route is authenticated and scoped to the signed-in account. Responses are `no-store`; history is never public.
++Anonymous accounts cannot read or record history. Titles are indexed with PostgreSQL full text search; URLs and page
++contents are not search inputs. The shared browser relay sends only a page's type, title, URL, site, optional picture,
++and private/mature flags. It never sends page contents.
++
++| Method and path | Purpose |
++| --- | --- |
++| `GET /api/v1/history?cursor&limit&day&site&kind&q` | Newest first; filters by day/site/kind and title text. Returns `next_cursor`. |
++| `POST /api/v1/history` | Record one thing with `{ type, title, url, image_url, private, mature }`. Private and mature rows are skipped. |
++| `DELETE /api/v1/history/:id` | Remove one of your rows. |
++| `DELETE /api/v1/history/day/:day` | Remove one UTC day. |
++| `DELETE /api/v1/history/site/:site` | Remove one site's rows. |
++| `DELETE /api/v1/history` | Clear your rows. |
++| `PUT /api/v1/history/settings` | Set `{ keep_history, agents_allowed, keep_private, keep_mature }`; personal history is on by default, other choices are off. |
++| `GET /api/v1/history/find?q=...` | Find rows by natural language terms using title full text. |
++| `GET /api/v1/history/summary?period=day\|week` | A title-and-kind-only recent activity paragraph; no model call or page content. |
++| `GET /api/v1/history/agents/recent` | Recent rows for the person's agent; returns `403 not_allowed` until the person opts in. |
++
++The service retains up to one year and 20,000 rows per account, merging visits to the same URL for 60 minutes. The
++account export includes the history and settings; account deletion erases the rows.
ddiff --git a/docs/platform-plan.md b/docs/platform-plan.md
iindex 71868ce4..b802e6de 100644
---- a/docs/platform-plan.md
++++ b/docs/platform-plan.md
@@@ -44,7 +44,7 @@ helpers.
| **Footer** | `footer.js` — network column, legal, socials, signed-in row, `full`/`compact` | `OpenVibeFooter.init({ service, variant, links })`. |
| **Theme** | `theme-loader.js` + `builtin-themes.js` + `theme-sync.js` (generated loader map, derived tokens `--on-accent`, `--accent-rgb`, `--accent-glow`) | Load the loader first in `<head>`; style with the tokens. The user's theme follows them to every room. |
| **Brand mark** | `ov-mark.js` — one animated mark, `data-variant` per room (live pulses red, tools spins, games bounces, community counter-orbits…) | `<span class="ov-mark" data-variant="chat">` |
--| **History** | `history.js` + `/api/history` (record, list, search, pause, delete) | `history: { type, title }` on `init`, or `OpenVibeHistory.record()`. Shows in the navbar and on `/my#history`. |
++| **History** | `history.js` + `/api/history` (record, list, search, pause, delete) | `history: { type, title }` on `init`, or `OpenVibeHistory.record()`. Full private timeline and controls: `/api/v1/history`; one year, 20,000 rows per account; title search uses PostgreSQL full text. |
| **Linked services** | recorded by the Network on every OAuth exchange (`linked_accounts.last_used_at`) | Free: any room that signs people in appears on `/my#linked`. |
| **Notifications** | `notification-ui.js`, web push via `openvibe-sw.js` | Bell in the navbar; server-to-server `/internal/notify`. |
| **Tooltips, user cards, account switcher** | `tooltip.js`, `user-card.js`, `account-switcher.js` | markup-driven / `init()`. |
ddiff --git a/migrations/0064_history_timeline.sql b/migrations/0064_history_timeline.sql
nnew file mode 100644
iindex 00000000..8a8460db
---- /dev/null
++++ b/migrations/0064_history_timeline.sql
@@@ -0,0 +1,8 @@
++-- phase: expand
++-- Full private history controls and richer page metadata. Existing history remains exportable and deletable.
++ALTER TABLE user_history ADD COLUMN IF NOT EXISTS image_url text COLLATE "C";
++ALTER TABLE users ADD COLUMN IF NOT EXISTS history_agents_allowed bigint NOT NULL DEFAULT 0;
++ALTER TABLE users ADD COLUMN IF NOT EXISTS history_keep_private bigint NOT NULL DEFAULT 0;
++ALTER TABLE users ADD COLUMN IF NOT EXISTS history_keep_mature bigint NOT NULL DEFAULT 0;
++CREATE INDEX IF NOT EXISTS idx_user_history_user_updated_id ON user_history (user_id, updated_at DESC, id DESC);
++CREATE INDEX IF NOT EXISTS idx_user_history_title_fts ON user_history USING gin (to_tsvector('simple', title));
ddiff --git a/public/js/my/security.js b/public/js/my/security.js
iindex 12fc8535..447378dc 100644
---- a/public/js/my/security.js
++++ b/public/js/my/security.js
@@@ -190,7 +190,10 @@ async function loadHistory(more) {
if (_histService) q.set('service', _histService);
const term = document.getElementById('hist-q').value.trim(); if (term) q.set('q', term);
const data = await apiFetch('/api/history?' + q);
-- document.getElementById('hist-pause').checked = !!data.paused;
++ document.getElementById('hist-pause').checked = !data.paused;
++ document.getElementById('hist-agents').checked = !!data.agents_allowed;
++ document.getElementById('hist-private').checked = !!data.keep_private;
++ document.getElementById('hist-mature').checked = !!data.keep_mature;
if (!_histLoaded || !more) {
const chips = document.getElementById('hist-chips');
chips.innerHTML = `<button class="${_histService ? '' : 'active'}" onclick="histFilter('', this)">All</button>` + (data.services || []).map(s => `<button class="${_histService === s.service ? 'active' : ''}" onclick="histFilter('${esc(s.service)}', this)">${esc(s.label || s.service)} <span style="opacity:.6">${s.count}</span></button>`).join('');
@@@ -214,9 +217,26 @@ async function loadHistory(more) {
} catch (err) { listEl.innerHTML = `<div class="hist-empty">Could not load history: ${esc(err.message)}</div>`; }
}
async function toggleHistoryPause(cb) {
-- try { await apiFetch('/api/history/settings', { method: 'PUT', body: JSON.stringify({ paused: cb.checked }) }); toast(cb.checked ? 'History paused — nothing new is recorded.' : 'History recording resumed.'); }
++ try { await apiFetch('/api/history/settings', { method: 'PUT', body: JSON.stringify({ keep_history: cb.checked }) }); toast(cb.checked ? 'History recording resumed.' : 'History paused — nothing new is recorded.'); }
catch (e) { cb.checked = !cb.checked; toast(e.message, 'error'); }
}
++async function toggleHistoryAgents(cb) {
++ try { await apiFetch('/api/history/settings', { method: 'PUT', body: JSON.stringify({ agents_allowed: cb.checked }) }); }
++ catch (e) { cb.checked = !cb.checked; toast(e.message, 'error'); }
++}
++async function toggleHistoryPrivacy(cb, key) {
++ try { await apiFetch('/api/history/settings', { method: 'PUT', body: JSON.stringify({ [key]: cb.checked }) }); }
++ catch (e) { cb.checked = !cb.checked; toast(e.message, 'error'); }
++}
++async function historySummary() {
++ const box = document.getElementById('hist-summary');
++ try {
++ const period = document.getElementById('hist-summary-period').value === 'week' ? 'week' : 'day';
++ const result = await apiFetch('/api/v1/history/summary?period=' + period);
++ box.textContent = result.text || 'No recent history.';
++ box.hidden = false;
++ } catch (e) { toast(e.message, 'error'); }
++}
async function clearHistory() {
if (!confirm('Delete your whole OpenVibe history? This cannot be undone.')) return;
try { await apiFetch('/api/history', { method: 'DELETE' }); _histLoaded = false; loadHistory(false); toast('History cleared.', 'success'); } catch (e) { toast(e.message, 'error'); }
ddiff --git a/public/my.html b/public/my.html
iindex c4cb804c..4083b729 100644
---- a/public/my.html
++++ b/public/my.html
@@@ -374,13 +374,19 @@
<div class="section" id="sec-history">
<div class="card">
<h3><span class="icon"><i class="fa-solid fa-clock-rotate-left"></i></span> History across OpenVibe</h3>
-- <p style="margin-bottom:12px;color:var(--text-secondary);font-size:13px">Everything this account opened on any OpenVibe site — tools, streams, pastes, games — in one timeline. Only you can see it; pause or clear it any time.</p>
++ <p style="margin-bottom:12px;color:var(--text-secondary);font-size:13px">One year of the things you visit across OpenVibe, up to 20,000 rows. Only you can see it; pause or clear it any time.</p>
<div class="hist-toolbar">
<div class="hist-chips" id="hist-chips"></div>
<input type="search" id="hist-q" class="hist-search" placeholder="Search history…" oninput="histDebounce()">
-- <label class="hist-pause" title="While paused nothing new is recorded"><input type="checkbox" id="hist-pause" onchange="toggleHistoryPause(this)"> Pause</label>
++ <label for="hist-summary-period">What was I doing?</label><select id="hist-summary-period"><option value="day">Last day</option><option value="week">Last week</option></select>
++ <button class="btn btn-small" onclick="historySummary()">Summarize</button>
++ <label class="hist-pause"><input type="checkbox" id="hist-pause" onchange="toggleHistoryPause(this)"> Keep my history</label>
++ <label class="hist-pause"><input type="checkbox" id="hist-agents" onchange="toggleHistoryAgents(this)"> My agents may read my history</label>
++ <label class="hist-pause"><input type="checkbox" id="hist-private" onchange="toggleHistoryPrivacy(this, 'keep_private')"> Keep private pages</label>
++ <label class="hist-pause"><input type="checkbox" id="hist-mature" onchange="toggleHistoryPrivacy(this, 'keep_mature')"> Keep mature pages</label>
<button class="btn btn-small btn-danger" onclick="clearHistory()" title="Delete the whole timeline"><i class="fa-solid fa-trash"></i> Clear</button>
</div>
++ <p id="hist-summary" role="status" hidden></p>
<div id="hist-list"><div class="loading-spinner">Loading history…</div></div>
<div class="btn-row" id="hist-more-row" style="display:none;margin-top:10px"><button class="btn" onclick="loadHistory(true)">Load more</button></div>
</div>
ddiff --git a/server/history/routes.js b/server/history/routes.js
iindex 5a931441..4996085f 100644
---- a/server/history/routes.js
++++ b/server/history/routes.js
@@@ -12,9 +12,9 @@
// ═══════════════════════════════════════════════════════════════
const express = require('express');
--const MAX_PER_USER = 2000; // oldest rows beyond this are trimmed on write
--const DEDUPE_WINDOW_MIN = 10; // same URL within this window bumps the entry instead of adding one
--const TYPES = new Set(['tool', 'stream', 'vod', 'clip', 'paste', 'game', 'page', 'post', 'account', 'theme', 'download', 'chat']);
++const MAX_PER_USER = 20000; // oldest rows beyond this are trimmed on write
++const DEDUPE_WINDOW_MIN = 60; // same URL within this window bumps the entry instead of adding one
++const TYPES = new Set(['tool', 'stream', 'vod', 'clip', 'paste', 'game', 'page', 'post', 'account', 'theme', 'download', 'chat', 'thread', 'video', 'document', 'conversation']);
const SERVICE_LABELS = {
live: 'Live', tools: 'Tools', games: 'Games', media: 'Media', network: 'Network', community: 'Community', space: 'Space',
chat: 'Chat', codes: 'Codes', blog: 'Blog', wiki: 'Wiki', news: 'News', reviews: 'Reviews', tips: 'Tips',
@@@ -48,7 +48,7 @@ function rowOut(r) {
let meta = null;
try { meta = r.meta ? JSON.parse(r.meta) : null; } catch { meta = null; }
return {
-- id: r.id, type: r.type, title: r.title, url: r.url, icon: r.icon, service: r.service, sub: r.sub,
++ id: r.id, type: r.type, title: r.title, url: r.url, icon: r.icon, image_url: r.image_url || null, service: r.service, sub: r.sub,
service_label: r.sub ? `${r.sub}.${SERVICE_LABELS[r.service] || r.service || ''}` : (SERVICE_LABELS[r.service] || r.service || null),
meta, hits: r.hits, created_at: r.created_at, updated_at: r.updated_at,
};
@@@ -68,17 +68,25 @@ async function createHistoryRoutes(db, requireAuth) {
router.get('/', async (req, res) => {
const limit = Math.min(200, Math.max(1, parseInt(req.query.limit, 10) || 50));
const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
-- const where = ['user_id = ?']; const params = [req.user.id];
-- if (req.query.service) { where.push('service = ?'); params.push(String(req.query.service).slice(0, 32)); }
-- if (req.query.type) { where.push('type = ?'); params.push(String(req.query.type).slice(0, 32)); }
-- if (req.query.q) { where.push('(title LIKE ? OR url LIKE ?)'); const q = `%${String(req.query.q).slice(0, 80)}%`; params.push(q, q); }
-- const rows = await db.prepare(`SELECT * FROM user_history WHERE ${where.join(' AND ')} ORDER BY updated_at DESC, id DESC LIMIT ? OFFSET ?`).all(...params, limit, offset);
-- const total = (await db.prepare(`SELECT COUNT(*) AS c FROM user_history WHERE ${where.join(' AND ')}`).get(...params))?.c || 0;
++ const cursor = String(req.query.cursor || '');
++ const where = ['user_id = ?', "updated_at >= (now() - interval '1 year')::text"]; const params = [req.user.id];
++ if (req.query.type || req.query.kind) { where.push('type = ?'); params.push(String(req.query.type || req.query.kind).slice(0, 32)); }
++ if (req.query.site || req.query.service) { where.push('service = ?'); params.push(String(req.query.site || req.query.service).slice(0, 32)); }
++ if (req.query.day) { where.push(`updated_at >= ? AND updated_at < ?`); const d = String(req.query.day).slice(0, 10); params.push(`${d} 00:00:00`, `${d} 23:59:59`); }
++ if (req.query.q) { where.push(`to_tsvector('simple', title) @@ plainto_tsquery('simple', ?)`); params.push(String(req.query.q).slice(0, 120)); }
++ const countWhere = where.join(' AND '), countParams = params.slice();
++ if (cursor) { try { const c = JSON.parse(Buffer.from(cursor, 'base64url').toString()); where.push('(updated_at, id) < (?, ?)'); params.push(c.t, c.i); } catch { return res.status(400).json({ error: 'Invalid cursor' }); } }
++ const rows = await db.prepare(`SELECT * FROM user_history WHERE ${where.join(' AND ')} ORDER BY updated_at DESC, id DESC LIMIT ? OFFSET ?`).all(...params, limit + 1, cursor ? 0 : offset);
++ const hasMore = rows.length > limit; if (hasMore) rows.pop();
++ const last = rows[rows.length - 1];
++ const next_cursor = hasMore && last ? Buffer.from(JSON.stringify({ t: last.updated_at, i: last.id })).toString('base64url') : null;
++ const total = (await db.prepare(`SELECT COUNT(*) AS c FROM user_history WHERE ${countWhere}`).get(...countParams))?.c || 0;
const paused = !!(await db.prepare('SELECT history_paused FROM users WHERE id = ?').get(req.user.id))?.history_paused;
++ res.set('Cache-Control', 'no-store');
++ const prefs = await db.prepare('SELECT history_agents_allowed, history_keep_private, history_keep_mature FROM users WHERE id = ?').get(req.user.id);
const services = (await db.prepare('SELECT service, COUNT(*) AS c FROM user_history WHERE user_id = ? GROUP BY service ORDER BY c DESC').all(req.user.id))
.map(r => ({ service: r.service, label: SERVICE_LABELS[r.service] || r.service, count: r.c }));
-- res.set('Cache-Control', 'no-store');
-- res.json({ items: rows.map(rowOut), total, paused, services });
++ res.json({ items: rows.map(rowOut), total, paused, keep_history: !paused, agents_allowed: !!prefs?.history_agents_allowed, keep_private: !!prefs?.history_keep_private, keep_mature: !!prefs?.history_keep_mature, next_cursor, services });
});
router.post('/', async (req, res) => {
@@@ -87,9 +95,14 @@ async function createHistoryRoutes(db, requireAuth) {
const url = allowedUrl(req.body?.url);
if (!url) return res.status(400).json({ error: 'A URL on the OpenVibe network is required' });
const type = TYPES.has(String(req.body?.type)) ? String(req.body.type) : 'page';
++ if (type === 'page') return res.json({ ok: true, skipped: 'not_a_thing' });
++ const privacy = await db.prepare('SELECT history_keep_private, history_keep_mature FROM users WHERE id = ?').get(req.user.id);
++ if ((req.body?.private && !privacy?.history_keep_private) || (req.body?.mature && !privacy?.history_keep_mature)) return res.json({ ok: true, skipped: 'private_or_mature' });
++ await db.prepare("DELETE FROM user_history WHERE user_id = ? AND updated_at < (now() - interval '1 year')::text").run(req.user.id);
const title = String(req.body?.title || '').trim().slice(0, 200) || url;
-- const TYPE_ICONS = { tool: 'fa-screwdriver-wrench', stream: 'fa-tower-broadcast', vod: 'fa-clapperboard', clip: 'fa-scissors', paste: 'fa-paste', game: 'fa-gamepad', page: 'fa-file-lines', post: 'fa-comments', account: 'fa-user', theme: 'fa-palette', download: 'fa-download', chat: 'fa-comment' };
++ const TYPE_ICONS = { tool: 'fa-screwdriver-wrench', stream: 'fa-tower-broadcast', vod: 'fa-clapperboard', video: 'fa-clapperboard', clip: 'fa-scissors', paste: 'fa-paste', game: 'fa-gamepad', page: 'fa-file-lines', document: 'fa-file-lines', thread: 'fa-comments', post: 'fa-comments', account: 'fa-user', theme: 'fa-palette', download: 'fa-download', chat: 'fa-comment', conversation: 'fa-comment' };
const icon = /^fa-[a-z0-9-]{1,40}$/.test(String(req.body?.icon || '')) ? String(req.body.icon) : (TYPE_ICONS[type] || null);
++ const imageUrl = allowedUrl(req.body?.image_url || req.body?.picture) || null;
const inf = serviceFromUrl(url);
const service = String(req.body?.service || inf.service || '').toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 32) || null;
const sub = String(req.body?.sub || inf.sub || '').toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 48) || null;
@@@ -100,20 +113,24 @@ async function createHistoryRoutes(db, requireAuth) {
}
const recent = await db.prepare(`SELECT id FROM user_history WHERE user_id = ? AND url = ? AND updated_at > datetime('now', '-${DEDUPE_WINDOW_MIN} minutes')`).get(req.user.id, url);
if (recent) {
-- await db.prepare('UPDATE user_history SET hits = hits + 1, title = ?, updated_at = ov_now() WHERE id = ?').run(title, recent.id);
++ await db.prepare('UPDATE user_history SET hits = hits + 1, title = ?, icon = ?, image_url = ?, updated_at = ov_now() WHERE id = ?').run(title, icon, imageUrl, recent.id);
return res.json({ ok: true, id: recent.id, merged: true });
}
-- const r = await db.prepare('INSERT INTO user_history (user_id, service, sub, type, title, url, icon, meta) VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING id')
-- .run(req.user.id, service, sub, type, title, url, icon, meta);
++ const r = await db.prepare('INSERT INTO user_history (user_id, service, sub, type, title, url, icon, meta, image_url) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id')
++ .run(req.user.id, service, sub, type, title, url, icon, meta, imageUrl);
// Trim: keep the newest MAX_PER_USER rows.
-- await db.prepare(`DELETE FROM user_history WHERE user_id = ? AND id NOT IN (SELECT id FROM user_history WHERE user_id = ? ORDER BY updated_at DESC LIMIT ${MAX_PER_USER})`).run(req.user.id, req.user.id);
++ await db.prepare(`DELETE FROM user_history WHERE user_id = ? AND (updated_at < (now() - interval '1 year')::text OR id NOT IN (SELECT id FROM user_history WHERE user_id = ? ORDER BY updated_at DESC LIMIT ${MAX_PER_USER}))`).run(req.user.id, req.user.id);
res.status(201).json({ ok: true, id: r.lastInsertRowid });
});
router.put('/settings', async (req, res) => {
-- const paused = req.body?.paused ? 1 : 0;
-- await db.prepare('UPDATE users SET history_paused = ? WHERE id = ?').run(paused, req.user.id);
-- res.json({ ok: true, paused: !!paused });
++ const paused = req.body?.keep_history === undefined && req.body?.paused === undefined ? null : (req.body?.keep_history === false || req.body?.paused ? 1 : 0);
++ const agents = req.body?.agents_allowed === undefined ? null : (req.body.agents_allowed ? 1 : 0);
++ const keepPrivate = req.body?.keep_private === undefined ? null : (req.body.keep_private ? 1 : 0);
++ const keepMature = req.body?.keep_mature === undefined ? null : (req.body.keep_mature ? 1 : 0);
++ await db.prepare('UPDATE users SET history_paused = COALESCE(?, history_paused), history_agents_allowed = COALESCE(?, history_agents_allowed), history_keep_private = COALESCE(?, history_keep_private), history_keep_mature = COALESCE(?, history_keep_mature) WHERE id = ?').run(paused, agents, keepPrivate, keepMature, req.user.id);
++ const settings = await db.prepare('SELECT history_paused, history_agents_allowed, history_keep_private, history_keep_mature FROM users WHERE id = ?').get(req.user.id);
++ res.json({ ok: true, paused: !!settings?.history_paused, keep_history: !settings?.history_paused, agents_allowed: !!settings?.history_agents_allowed, keep_private: !!settings?.history_keep_private, keep_mature: !!settings?.history_keep_mature });
});
router.delete('/', async (req, res) => {
@@@ -121,6 +138,37 @@ async function createHistoryRoutes(db, requireAuth) {
res.json({ ok: true, deleted: r.changes });
});
++ router.delete('/day/:day', async (req, res) => {
++ const d = String(req.params.day).slice(0, 10);
++ const r = await db.prepare('DELETE FROM user_history WHERE user_id = ? AND updated_at >= ? AND updated_at < ?').run(req.user.id, `${d} 00:00:00`, `${d} 23:59:59`);
++ res.json({ ok: true, deleted: r.changes });
++ });
++ router.delete('/site/:site', async (req, res) => {
++ const r = await db.prepare('DELETE FROM user_history WHERE user_id = ? AND service = ?').run(req.user.id, String(req.params.site).slice(0, 32));
++ res.json({ ok: true, deleted: r.changes });
++ });
++ router.get('/agents/recent', async (req, res) => {
++ const allowed = await db.prepare('SELECT history_agents_allowed FROM users WHERE id = ?').get(req.user.id);
++ if (!allowed?.history_agents_allowed) return res.status(403).json({ error: 'not_allowed' });
++ const limit = Math.min(50, Math.max(1, parseInt(req.query.limit, 10) || 20));
++ const rows = await db.prepare('SELECT * FROM user_history WHERE user_id = ? ORDER BY updated_at DESC, id DESC LIMIT ?').all(req.user.id, limit);
++ res.set('Cache-Control', 'no-store').json({ items: rows.map(rowOut) });
++ });
++ router.get('/find', async (req, res) => {
++ const query = String(req.query.q || '').trim().slice(0, 120);
++ if (!query) return res.status(400).json({ error: 'q is required' });
++ const limit = Math.min(50, Math.max(1, parseInt(req.query.limit, 10) || 20));
++ const rows = await db.prepare("SELECT * FROM user_history WHERE user_id = ? AND updated_at >= (now() - interval '1 year')::text AND to_tsvector('simple', title) @@ plainto_tsquery('simple', ?) ORDER BY updated_at DESC, id DESC LIMIT ?").all(req.user.id, query, limit);
++ res.set('Cache-Control', 'no-store').json({ query, items: rows.map(rowOut) });
++ });
++ router.get('/summary', async (req, res) => {
++ const days = req.query.period === 'week' ? 7 : 1;
++ const rows = await db.prepare(`SELECT type, title FROM user_history WHERE user_id = ? AND updated_at >= (now() - (? * interval '1 day'))::text ORDER BY updated_at DESC LIMIT 50`).all(req.user.id, days);
++ const moments = rows.slice(0, 8).map(r => `${r.type} “${r.title}”`);
++ const text = moments.length ? `Over the last ${days === 7 ? 'week' : 'day'}, you visited ${moments.join('; ')}.` : 'No recent history.';
++ res.set('Cache-Control', 'no-store').json({ period: days === 7 ? 'week' : 'day', items: rows.map(r => ({ kind: r.type, title: r.title })), text });
++ });
++
router.delete('/:id', async (req, res) => {
const r = await db.prepare('DELETE FROM user_history WHERE id = ? AND user_id = ?').run(parseInt(req.params.id, 10) || 0, req.user.id);
if (!r.changes) return res.status(404).json({ error: 'Not found' });
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex 1883c2e7..2eb31325 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -166,7 +166,7 @@ async function networkPart(db, userId) {
const truncated = [];
const add = (name, content, cut) => { files.push({ name, content }); if (cut) truncated.push(name); };
const keep = ['id', 'username', 'email', 'display_name', 'avatar_url', 'bio', 'role', 'profile_color', 'created_at', 'updated_at', 'last_seen',
-- 'email_verified', 'email_verified_at', 'name_effect', 'particle_effect', 'history_paused', 'subject_id'];
++ 'email_verified', 'email_verified_at', 'name_effect', 'particle_effect', 'history_paused', 'history_agents_allowed', 'history_keep_private', 'history_keep_mature', 'subject_id'];
const account = {};
for (const k of keep) if (k in u) account[k] = u[k];
account.merged_in = await tableExists(db, 'subject_aliases')
ddiff --git a/server/index.js b/server/index.js
iindex cb84c154..000be855 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -919,6 +919,7 @@ app.use('/api/push', requireAuth, require('./push/routes'));
// Cross-site history (what the account touched anywhere on the network)
app.use('/api/history', rateLimit({ windowMs: 60_000, max: 60 }), await require('./history/routes').createHistoryRoutes(db, requireAuth));
++app.use('/api/v1/history', rateLimit({ windowMs: 60_000, max: 60 }), await require('./history/routes').createHistoryRoutes(db, requireAuth));
// "Sign in everywhere" chain targets (public: the fanout page reads them before hopping)
app.get('/api/sso/targets', (req, res) => {
ddiff --git a/test/agent-schema.test.js b/test/agent-schema.test.js
iindex 6edd3844..af3762c3 100644
---- a/test/agent-schema.test.js
++++ b/test/agent-schema.test.js
@@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// The real directory: 0016 applies on top of 0015, once.
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate']]);
++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand']]);
assert.ok(await hasAgents());
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
ddiff --git a/test/history-sso.test.js b/test/history-sso.test.js
iindex 0b45ba7f..2a7dc84e 100644
---- a/test/history-sso.test.js
++++ b/test/history-sso.test.js
@@@ -37,6 +37,7 @@ const requireAuth = (req, res, next) => { const u = users[req.headers['x-user']]
const app = express();
app.use(express.json());
app.use('/api/history', await createHistoryRoutes(db, requireAuth));
++app.use('/api/v1/history', await createHistoryRoutes(db, requireAuth));
const server = http.createServer(app);
(async () => {
@@@ -54,6 +55,8 @@ const server = http.createServer(app);
assert.strictEqual(r.status, 400, 'off-network URL rejected');
r = await call('POST', '/api/history', 'anon', { type: 'page', title: 'x', url: 'https://openvibe.live/' });
assert.strictEqual(r.status, 403, 'anonymous sessions keep no history');
++ r = await call('POST', '/api/v1/history', 'a', { type: 'video', title: 'private title', url: 'https://openvibe.media/v/private', private: true });
++ assert.strictEqual(r.body.skipped, 'private_or_mature', 'private pages never enter history');
r = await call('POST', '/api/history', null, { type: 'page', title: 'x', url: 'https://openvibe.live/' });
assert.strictEqual(r.status, 401);
@@@ -70,6 +73,27 @@ const server = http.createServer(app);
assert.strictEqual(r.body.items.length, 1);
r = await call('GET', '/api/history', 'b');
assert.strictEqual(r.body.total, 0, 'other accounts see nothing');
++ r = await call('GET', '/api/v1/history/agents/recent', 'a');
++ assert.strictEqual(r.status, 403, 'agents are denied until the person opts in');
++ r = await call('PUT', '/api/v1/history/settings', 'a', { agents_allowed: true });
++ assert.strictEqual(r.body.agents_allowed, true);
++ assert.strictEqual(r.body.keep_history, true, 'changing agent access leaves personal history on');
++ r = await call('GET', '/api/v1/history/agents/recent', 'a');
++ assert.strictEqual(r.body.items.length, 2, 'the opt-in agent read returns only the owner history');
++ r = await call('GET', '/api/v1/history?q=formatter', 'a');
++ assert.strictEqual(r.body.items.length, 1, 'search matches titles');
++ assert.ok(r.body.next_cursor === null || typeof r.body.next_cursor === 'string');
++ r = await call('GET', '/api/v1/history?limit=1', 'a');
++ assert.ok(r.body.next_cursor, 'a full page supplies a cursor');
++ const firstId = r.body.items[0].id;
++ r = await call('GET', `/api/v1/history?limit=1&cursor=${encodeURIComponent(r.body.next_cursor)}`, 'a');
++ assert.notStrictEqual(r.body.items[0].id, firstId, 'cursor continues the newest-first timeline');
++ assert.strictEqual(r.body.total, 2, 'the total is independent of the cursor position');
++ r = await call('GET', '/api/v1/history/summary?period=week', 'a');
++ assert.strictEqual(r.body.period, 'week');
++ assert.ok(r.body.text.includes('goosely live') && !r.body.text.includes('openvibe.live'), 'summary uses titles and kinds, never page addresses');
++ r = await call('GET', '/api/v1/history', null);
++ assert.strictEqual(r.status, 401, 'signed-out reads are unauthorized');
// pause → nothing recorded
r = await call('PUT', '/api/history/settings', 'a', { paused: true });
@@@ -77,12 +101,14 @@ const server = http.createServer(app);
r = await call('POST', '/api/history', 'a', { type: 'page', title: 'paused', url: 'https://openvibe.games/' });
assert.strictEqual(r.body.paused, true);
r = await call('GET', '/api/history', 'a'); assert.strictEqual(r.body.total, 2); assert.strictEqual(r.body.paused, true);
++ const id = r.body.items[0].id;
++ r = await call('GET', '/api/v1/history/agents/recent', 'b'); assert.strictEqual(r.status, 403, 'a different person without opt-in receives no rows');
await call('PUT', '/api/history/settings', 'a', { paused: false });
// delete one / all, ownership enforced
-- const id = r.body.items[0].id;
r = await call('DELETE', `/api/history/${id}`, 'b'); assert.strictEqual(r.status, 404, 'cannot delete another account\'s entry');
r = await call('DELETE', `/api/history/${id}`, 'a'); assert.strictEqual(r.status, 200);
++ r = await call('GET', '/api/v1/history?q=goosely', 'a'); assert.strictEqual(r.body.items.length, 0, 'a deleted row is gone from search');
r = await call('DELETE', '/api/history', 'a'); assert.strictEqual(r.body.deleted, 1);
r = await call('GET', '/api/history', 'a'); assert.strictEqual(r.body.total, 0);
ddiff --git a/test/security-world.js b/test/security-world.js
iindex 78fdb567..c0dca28c 100644
---- a/test/security-world.js
++++ b/test/security-world.js
@@@ -175,7 +175,7 @@ async function buildWorld({ label = 'sec', env: extraEnv = {} } = {}) {
const sess = once('session creation', await call('alice', 'POST', '/api/auth/sessions', { device_name: 'alice-laptop' }));
if (sess.body && sess.body.session_token) secrets['alice session token'] = sess.body.session_token;
await call({ authorization: `Bearer ${gamesToken}` }, 'POST', '/internal/link-account', { user_id: users.alice.id, service: 'games', service_user_id: 'alice-games-771' });
-- await call('alice', 'POST', '/api/history', { service: 'live', url: 'https://openvibe.live/@alice-private-history', title: 'alice-private-history' });
++ await call('alice', 'POST', '/api/history', { service: 'live', type: 'stream', url: 'https://openvibe.live/@alice-private-history', title: 'alice-private-history' });
const pushSub = { endpoint: 'https://push.services.mozilla.com/wpush/v2/alice-endpoint-capability-0001', keys: { p256dh: vapid.publicKey, auth: 'alice-push-auth-secret' } };
await call('alice', 'POST', '/api/push/subscribe', { subscription: pushSub });
secrets['alice push endpoint'] = pushSub.endpoint;
ddiff --git a/test/user-owned-trust.test.js b/test/user-owned-trust.test.js
iindex 6fb9700c..768fde33 100644
---- a/test/user-owned-trust.test.js
++++ b/test/user-owned-trust.test.js
@@@ -56,7 +56,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// ── 0020 applies on top of 0019, once, and rewrites nothing.
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate']]);
++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand']]);
assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twice
// 0020–0061 keep every row; 0062 moves the principal ids to lnk_ in place and references follow.