openvibers/openvibe.network Commits
Add private cross-site history timeline (#760)
Changes
nnew file mode 100644@@@ -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.@@@ -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()`. |nnew file mode 100644@@@ -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));@@@ -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'); }@@@ -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>@@@ -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' });@@@ -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')@@@ -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) => {@@@ -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.@@@ -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 recordedr = 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);@@@ -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;@@@ -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.
new file mode 100644
new file mode 100644
@@ -0,0 +1,22 @@
@@ -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.
@@ -44,7 +44,7 @@ helpers.
@@ -44,7 +44,7 @@ helpers.
| **Footer** | `footer.js` — network column, legal, socials, signed-in row, `full`/`compact` | `OpenVibeFooter.init({ service, variant, links })`. |
| **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. |
| **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">` |
| **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`. |
| **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`. |
| **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()`. |
| **Tooltips, user cards, account switcher** | `tooltip.js`, `user-card.js`, `account-switcher.js` | markup-driven / `init()`. |
new file mode 100644
new file mode 100644
@@ -0,0 +1,8 @@
@@ -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));
@@ -190,7 +190,10 @@ async function loadHistory(more) {
@@ -190,7 +190,10 @@ async function loadHistory(more) {
if (_histService) q.set('service', _histService);
if (_histService) q.set('service', _histService);
const term = document.getElementById('hist-q').value.trim(); if (term) q.set('q', term);
const term = document.getElementById('hist-q').value.trim(); if (term) q.set('q', term);
const data = await apiFetch('/api/history?' + q);
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) {
if (!_histLoaded || !more) {
const chips = document.getElementById('hist-chips');
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('');
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) {
@@ -214,9 +217,26 @@ async function loadHistory(more) {
} catch (err) { listEl.innerHTML = `<div class="hist-empty">Could not load history: ${esc(err.message)}</div>`; }
} catch (err) { listEl.innerHTML = `<div class="hist-empty">Could not load history: ${esc(err.message)}</div>`; }
}
}
async function toggleHistoryPause(cb) {
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'); }
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() {
async function clearHistory() {
if (!confirm('Delete your whole OpenVibe history? This cannot be undone.')) return;
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'); }
try { await apiFetch('/api/history', { method: 'DELETE' }); _histLoaded = false; loadHistory(false); toast('History cleared.', 'success'); } catch (e) { toast(e.message, 'error'); }
@@ -374,13 +374,19 @@
@@ -374,13 +374,19 @@
<div class="section" id="sec-history">
<div class="section" id="sec-history">
<div class="card">
<div class="card">
<h3><span class="icon"><i class="fa-solid fa-clock-rotate-left"></i></span> History across OpenVibe</h3>
<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-toolbar">
<div class="hist-chips" id="hist-chips"></div>
<div class="hist-chips" id="hist-chips"></div>
<input type="search" id="hist-q" class="hist-search" placeholder="Search history…" oninput="histDebounce()">
<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>
<button class="btn btn-small btn-danger" onclick="clearHistory()" title="Delete the whole timeline"><i class="fa-solid fa-trash"></i> Clear</button>
</div>
</div>
+ <p id="hist-summary" role="status" hidden></p>
<div id="hist-list"><div class="loading-spinner">Loading history…</div></div>
<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 class="btn-row" id="hist-more-row" style="display:none;margin-top:10px"><button class="btn" onclick="loadHistory(true)">Load more</button></div>
</div>
</div>
@@ -12,9 +12,9 @@
@@ -12,9 +12,9 @@
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
const express = require('express');
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 = {
const SERVICE_LABELS = {
live: 'Live', tools: 'Tools', games: 'Games', media: 'Media', network: 'Network', community: 'Community', space: 'Space',
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',
chat: 'Chat', codes: 'Codes', blog: 'Blog', wiki: 'Wiki', news: 'News', reviews: 'Reviews', tips: 'Tips',
@@ -48,7 +48,7 @@ function rowOut(r) {
@@ -48,7 +48,7 @@ function rowOut(r) {
let meta = null;
let meta = null;
try { meta = r.meta ? JSON.parse(r.meta) : null; } catch { meta = null; }
try { meta = r.meta ? JSON.parse(r.meta) : null; } catch { meta = null; }
return {
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),
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,
meta, hits: r.hits, created_at: r.created_at, updated_at: r.updated_at,
};
};
@@ -68,17 +68,25 @@ async function createHistoryRoutes(db, requireAuth) {
@@ -68,17 +68,25 @@ async function createHistoryRoutes(db, requireAuth) {
router.get('/', async (req, res) => {
router.get('/', async (req, res) => {
const limit = Math.min(200, Math.max(1, parseInt(req.query.limit, 10) || 50));
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 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;
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))
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 }));
.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) => {
router.post('/', async (req, res) => {
@@ -87,9 +95,14 @@ async function createHistoryRoutes(db, requireAuth) {
@@ -87,9 +95,14 @@ async function createHistoryRoutes(db, requireAuth) {
const url = allowedUrl(req.body?.url);
const url = allowedUrl(req.body?.url);
if (!url) return res.status(400).json({ error: 'A URL on the OpenVibe network is required' });
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';
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 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 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 inf = serviceFromUrl(url);
const service = String(req.body?.service || inf.service || '').toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 32) || null;
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;
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) {
@@ -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);
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) {
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 });
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.
// 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 });
res.status(201).json({ ok: true, id: r.lastInsertRowid });
});
});
router.put('/settings', async (req, res) => {
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) => {
router.delete('/', async (req, res) => {
@@ -121,6 +138,37 @@ async function createHistoryRoutes(db, requireAuth) {
@@ -121,6 +138,37 @@ async function createHistoryRoutes(db, requireAuth) {
res.json({ ok: true, deleted: r.changes });
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) => {
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);
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' });
if (!r.changes) return res.status(404).json({ error: 'Not found' });
@@ -166,7 +166,7 @@ async function networkPart(db, userId) {
@@ -166,7 +166,7 @@ async function networkPart(db, userId) {
const truncated = [];
const truncated = [];
const add = (name, content, cut) => { files.push({ name, content }); if (cut) truncated.push(name); };
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',
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 = {};
const account = {};
for (const k of keep) if (k in u) account[k] = u[k];
for (const k of keep) if (k in u) account[k] = u[k];
account.merged_in = await tableExists(db, 'subject_aliases')
account.merged_in = await tableExists(db, 'subject_aliases')
@@ -919,6 +919,7 @@ app.use('/api/push', requireAuth, require('./push/routes'));
@@ -919,6 +919,7 @@ app.use('/api/push', requireAuth, require('./push/routes'));
// Cross-site history (what the account touched anywhere on the network)
// 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/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)
// "Sign in everywhere" chain targets (public: the fanout page reads them before hopping)
app.get('/api/sso/targets', (req, res) => {
app.get('/api/sso/targets', (req, res) => {
@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// The real directory: 0016 applies on top of 0015, once.
// The real directory: 0016 applies on top of 0015, once.
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
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.ok(await hasAgents());
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
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.
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
@@ -37,6 +37,7 @@ const requireAuth = (req, res, next) => { const u = users[req.headers['x-user']]
@@ -37,6 +37,7 @@ const requireAuth = (req, res, next) => { const u = users[req.headers['x-user']]
const app = express();
const app = express();
app.use(express.json());
app.use(express.json());
app.use('/api/history', await createHistoryRoutes(db, requireAuth));
app.use('/api/history', await createHistoryRoutes(db, requireAuth));
+app.use('/api/v1/history', await createHistoryRoutes(db, requireAuth));
const server = http.createServer(app);
const server = http.createServer(app);
(async () => {
(async () => {
@@ -54,6 +55,8 @@ const server = http.createServer(app);
@@ -54,6 +55,8 @@ const server = http.createServer(app);
assert.strictEqual(r.status, 400, 'off-network URL rejected');
assert.strictEqual(r.status, 400, 'off-network URL rejected');
r = await call('POST', '/api/history', 'anon', { type: 'page', title: 'x', url: 'https://openvibe.live/' });
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');
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/' });
r = await call('POST', '/api/history', null, { type: 'page', title: 'x', url: 'https://openvibe.live/' });
assert.strictEqual(r.status, 401);
assert.strictEqual(r.status, 401);
@@ -70,6 +73,27 @@ const server = http.createServer(app);
@@ -70,6 +73,27 @@ const server = http.createServer(app);
assert.strictEqual(r.body.items.length, 1);
assert.strictEqual(r.body.items.length, 1);
r = await call('GET', '/api/history', 'b');
r = await call('GET', '/api/history', 'b');
assert.strictEqual(r.body.total, 0, 'other accounts see nothing');
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
// pause → nothing recorded
r = await call('PUT', '/api/history/settings', 'a', { paused: true });
r = await call('PUT', '/api/history/settings', 'a', { paused: true });
@@ -77,12 +101,14 @@ const server = http.createServer(app);
@@ -77,12 +101,14 @@ const server = http.createServer(app);
r = await call('POST', '/api/history', 'a', { type: 'page', title: 'paused', url: 'https://openvibe.games/' });
r = await call('POST', '/api/history', 'a', { type: 'page', title: 'paused', url: 'https://openvibe.games/' });
assert.strictEqual(r.body.paused, true);
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);
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 });
await call('PUT', '/api/history/settings', 'a', { paused: false });
// delete one / all, ownership enforced
// 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}`, '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('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('DELETE', '/api/history', 'a'); assert.strictEqual(r.body.deleted, 1);
r = await call('GET', '/api/history', 'a'); assert.strictEqual(r.body.total, 0);
r = await call('GET', '/api/history', 'a'); assert.strictEqual(r.body.total, 0);
@@ -175,7 +175,7 @@ async function buildWorld({ label = 'sec', env: extraEnv = {} } = {}) {
@@ -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' }));
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;
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({ 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' } };
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 });
await call('alice', 'POST', '/api/push/subscribe', { subscription: pushSub });
secrets['alice push endpoint'] = pushSub.endpoint;
secrets['alice push endpoint'] = pushSub.endpoint;
@@ -56,7 +56,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -56,7 +56,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// ── 0020 applies on top of 0019, once, and rewrites nothing.
// ── 0020 applies on top of 0019, once, and rewrites nothing.
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
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');
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
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.
// 0020–0061 keep every row; 0062 moves the principal ids to lnk_ in place and references follow.