openvibers/openvibe.tools Commits

Recent tools in the launcher (D31: anonymous and account history): every tool page view puts the tool on top of this browser's ov_recent_tools cookie (first-party, .openvibe.tools, 12 ids, no page script needed across tool hosts); GET /api/v1/me/recent-tools answers a signed-in person's tools.usage module (plus what is not yet flushed) or the cookie list, registry-filtered and never cached; the home page shows 'Your recent tools' (every device, or this browser) and Ctrl/Cmd+K focuses search

OP OpenVibers committed 24 Sept 2026 · 557de61d3e · 5 files, +95 −14


Changes

ddiff --git a/apps/_shared/test/usage.test.js b/apps/_shared/test/usage.test.js
iindex 444afe3..79d0de8 100644
---- a/apps/_shared/test/usage.test.js
++++ b/apps/_shared/test/usage.test.js
@@@ -53,16 +53,30 @@ const fetchImpl = async (url, opts) => {
// The page middleware: a signed-in document request for a tool host counts; assets, APIs and unknown hosts do not.
const got = [];
const mw = usagePages({ snapshot: () => ({ tools: [{ id: 'jsonminify', hosts: ['json-minifier.openvibe.tools', 'jsonminify.openvibe.tools'] }] }), rec: { enabled: true, record: (s, id) => got.push(id) } });
-- const req = (host, p, headers = {}, user = { subject_id: USR }) => ({ method: 'GET', hostname: host, path: p, user, get: (h) => headers[h.toLowerCase()] });
-- const run = (q) => mw(q, {}, () => {});
++ const req = (host, p, headers = {}, user = { subject_id: USR }) => ({ method: 'GET', hostname: host, path: p, user, headers, get: (h) => headers[h.toLowerCase()] });
++ const cookies = [];
++ const run = (q) => mw(q, { append: (k, v) => { assert.strictEqual(k, 'Set-Cookie'); cookies.push(v); } }, () => {});
run(req('json-minifier.openvibe.tools', '/', { 'sec-fetch-dest': 'document' }));
run(req('jsonminify.openvibe.tools', '/', { accept: 'text/html' }));
run(req('json-minifier.openvibe.tools', '/app.js', { 'sec-fetch-dest': 'script' }));
run(req('json-minifier.openvibe.tools', '/api/x', { accept: 'text/html' }));
run(req('openvibe.tools', '/', { 'sec-fetch-dest': 'document', 'x-ov-tool': 'evil' }));
-- run(req('json-minifier.openvibe.tools', '/', { 'sec-fetch-dest': 'document' }, null));
assert.deepStrictEqual(got, ['jsonminify', 'jsonminify']);
++ // Anonymous history: no account write, but the browser's cookie list gets the tool on top.
++ cookies.length = 0;
++ run(req('json-minifier.openvibe.tools', '/', { 'sec-fetch-dest': 'document', cookie: 'a=1; ov_recent_tools=png.jsonminify.yaml; b=2' }, null));
++ assert.deepStrictEqual(got, ['jsonminify', 'jsonminify'], 'nobody signed in: no module write');
++ assert.strictEqual(cookies.length, 1);
++ assert.ok(cookies[0].startsWith('ov_recent_tools=jsonminify.png.yaml;'), cookies[0]);
++ assert.ok(/Domain=\.openvibe\.tools/.test(cookies[0]) && /Secure/.test(cookies[0]) && /SameSite=Lax/.test(cookies[0]));
++ const { recentFromCookie } = require('../usage');
++ assert.deepStrictEqual(recentFromCookie({ headers: { cookie: 'ov_recent_tools=a-b.x..<script>.a-b' } }), ['a-b', 'x'], 'only tool ids, deduplicated');
++ cookies.length = 0;
++ const mirror = usagePages({ snapshot: () => ({ tools: [{ id: 'yaml', hosts: ['yaml.example.org'] }] }), rec: { enabled: false } });
++ mirror(req('yaml.example.org', '/', { 'sec-fetch-dest': 'document' }, null), { append: (k, v) => cookies.push(v) }, () => {});
++ assert.strictEqual(cookies.length, 0, 'no zone cookie from a custom domain');
++
const off = createRecorder({ env: {}, fetchImpl });
assert.strictEqual(off.enabled, false);
assert.strictEqual(off.record(USR, 'yaml'), false, 'off without a service secret');
ddiff --git a/apps/_shared/usage.js b/apps/_shared/usage.js
iindex 415a98b..ff93a81 100644
---- a/apps/_shared/usage.js
++++ b/apps/_shared/usage.js
@@@ -7,6 +7,10 @@
// app.use(usagePages({ snapshot: toolRegistry.snapshot })) after guard.identify: a page view of
// a tool by a signed-in person counts
// recorder().record(subjectId, toolId) e.g. a run API call by a person
++// recorder().recent(subjectId), recentFromCookie(req) what the launcher shows
++//
++// Everyone's page views (signed in or not) also go into the ov_recent_tools cookie on .openvibe.tools:
++// the launcher's anonymous history.
//
// Cheap by design: a person counts once per tool per 10 minutes, and a process flushes what it
// gathered every minute: per person one read and one conditional write (If-Match), merged with what
@@@ -96,7 +100,33 @@ function createRecorder({ env = process.env, fetchImpl = globalThis.fetch, now =
function start() { if (timer || !enabled) return; timer = setInterval(() => { flush().catch(() => {}); }, flushMs); if (timer.unref) timer.unref(); }
function stop() { if (timer) clearInterval(timer); timer = null; }
-- return { enabled, record, flush, stop, stats: () => ({ enabled, pending: pending.size, ...stats }) };
++ /** The person's recent tools (the module, with anything not yet flushed on top): [{ tool, at }]. */
++ async function recent(subject) {
++ if (!enabled || !USR_RE.test(String(subject || ''))) return [];
++ const cur = await call('GET', subject);
++ const had = cur.status === 200 && cur.body && cur.body.data && Array.isArray(cur.body.data.recent) ? cur.body.data.recent : [];
++ const fresh = pending.get(subject);
++ const top = fresh ? [...fresh].reverse().map(([tool, at]) => ({ tool, at })) : [];
++ return [...top, ...had.filter((e) => e && (!fresh || !fresh.has(e.tool)))].slice(0, MAX);
++ }
++
++ return { enabled, record, recent, flush, stop, stats: () => ({ enabled, pending: pending.size, ...stats }) };
++}
++
++// Anonymous history: the same list, per browser, in a first-party cookie on the tools zone, so every
++// tool host (each its own origin) adds to one list without any page script.
++const COOKIE = 'ov_recent_tools';
++const COOKIE_MAX = 12;
++function recentFromCookie(req) {
++ const m = String((req.headers && req.headers.cookie) || '').match(/(?:^|;\s*)ov_recent_tools=([^;]*)/);
++ if (!m) return [];
++ let v = ''; try { v = decodeURIComponent(m[1]); } catch { return []; }
++ return [...new Set(v.split('.').filter((id) => TOOL_RE.test(id)))].slice(0, COOKIE_MAX);
++}
++function rememberInCookie(req, res, tool, host) {
++ if (!res || typeof res.append !== 'function' || !/(^|\.)openvibe\.tools$/.test(host)) return;
++ const list = [tool, ...recentFromCookie(req).filter((id) => id !== tool)].slice(0, COOKIE_MAX);
++ res.append('Set-Cookie', `${COOKIE}=${list.join('.')}; Domain=.openvibe.tools; Path=/; Max-Age=${180 * 24 * 3600}; SameSite=Lax; Secure`);
}
let _recorder = null;
@@@ -117,18 +147,21 @@ function usagePages({ snapshot, rec = null }) {
mapFor = s;
return map;
};
-- return function toolUsage(req, _res, next) {
++ return function toolUsage(req, res, next) {
try {
-- const sid = req.user && req.user.subject_id;
-- const r = rec || recorder();
-- if (sid && r.enabled && req.method === 'GET' && !req.path.startsWith('/api/') && !/\.[a-z0-9]{1,8}$/i.test(req.path)) {
++ if (req.method === 'GET' && !req.path.startsWith('/api/') && !/\.[a-z0-9]{1,8}$/i.test(req.path)) {
const dest = req.get('sec-fetch-dest');
if (dest ? dest === 'document' : /text\/html/.test(req.get('accept') || '')) {
const host = String(req.hostname || '').toLowerCase();
const m = hostMap();
const named = String(req.get('x-ov-tool') || '');
const tool = m.get(host) || (m.ids.has(named) ? named : null);
-- if (tool) r.record(sid, tool);
++ if (tool) {
++ rememberInCookie(req, res, tool, host);
++ const sid = req.user && req.user.subject_id;
++ const r = rec || recorder();
++ if (sid && r.enabled) r.record(sid, tool);
++ }
}
}
} catch { /* usage is best-effort */ }
@@@ -136,4 +169,4 @@ function usagePages({ snapshot, rec = null }) {
};
}
--module.exports = { createRecorder, recorder, usagePages, NS };
++module.exports = { createRecorder, recorder, usagePages, recentFromCookie, NS, COOKIE };
ddiff --git a/apps/gateway/public/js/site.js b/apps/gateway/public/js/site.js
iindex d381659..67b1df2 100644
---- a/apps/gateway/public/js/site.js
++++ b/apps/gateway/public/js/site.js
@@@ -19,7 +19,8 @@
var input = document.getElementById('q'), results = document.getElementById('results');
if (!input || !results) return;
var grid = document.getElementById('results-grid'), empty = document.getElementById('results-empty');
-- var catalog = null, loading = null, others = [].slice.call(document.querySelectorAll('main > section:not(#results)'));
++ var catalog = null, loading = null, others = [].slice.call(document.querySelectorAll('main > section:not(#results):not(#recent)'));
++ var recentSec = document.getElementById('recent'), recentReady = false;
function load() {
if (catalog) return Promise.resolve(catalog);
@@@ -49,11 +50,11 @@
q = q.trim();
var url = new URL(location.href); if (q) url.searchParams.set('q', q); else url.searchParams.delete('q');
if (url.href !== location.href) history[push ? 'pushState' : 'replaceState']({ q: q }, '', url);
-- if (!q) { results.hidden = true; others.forEach(function (s) { s.hidden = false; }); return; }
++ if (!q) { results.hidden = true; others.forEach(function (s) { s.hidden = false; }); if (recentSec) recentSec.hidden = !recentReady; return; }
load().then(function () {
if (input.value.trim() !== q) return;
var hits = find(q); grid.replaceChildren.apply(grid, hits.map(card)); empty.hidden = hits.length > 0;
-- results.hidden = false; others.forEach(function (s) { s.hidden = true; });
++ results.hidden = false; others.forEach(function (s) { s.hidden = true; }); if (recentSec) recentSec.hidden = true;
if (window.OpenVibeIcons) OpenVibeIcons.mount(grid); else if (!document.getElementById('ov-icons-loader')) { var sc = document.createElement('script'); sc.id = 'ov-icons-loader'; sc.src = 'https://openvibe.network/shared/ov-icons.js'; document.head.appendChild(sc); }
});
}
@@@ -62,6 +63,24 @@
input.addEventListener('input', function () { clearTimeout(timer); timer = setTimeout(function () { show(input.value, false); }, 90); });
input.form.addEventListener('submit', function (e) { e.preventDefault(); show(input.value, true); var first = grid.querySelector('a'); if (first && input.value.trim()) first.focus(); });
window.addEventListener('popstate', function () { var q = new URL(location.href).searchParams.get('q') || ''; input.value = q; show(q, false); });
-- document.addEventListener('keydown', function (e) { if (e.key === '/' && document.activeElement !== input && !/input|textarea|select/i.test(document.activeElement.tagName)) { e.preventDefault(); input.focus(); } });
++ document.addEventListener('keydown', function (e) {
++ var k = (e.key || '').toLowerCase();
++ if ((k === 'k' && (e.ctrlKey || e.metaKey) && !e.altKey) || (e.key === '/' && document.activeElement !== input && !/input|textarea|select/i.test(document.activeElement.tagName))) { e.preventDefault(); input.focus(); input.select(); }
++ });
++
++ // Recent tools: this person's (every device they sign in to) or this browser's. Filled in after load.
++ function mountIcons(el) { if (window.OpenVibeIcons) OpenVibeIcons.mount(el); else if (!document.getElementById('ov-icons-loader')) { var sc = document.createElement('script'); sc.id = 'ov-icons-loader'; sc.src = 'https://openvibe.network/shared/ov-icons.js'; document.head.appendChild(sc); } }
++ if (recentSec) fetch('/api/v1/me/recent-tools', { credentials: 'same-origin' }).then(function (r) { return r.ok ? r.json() : null; }).then(function (d) {
++ if (!d || !d.recent || !d.recent.length) return null;
++ return load().then(function () {
++ var byId = {}; catalog.forEach(function (c) { byId[c.t.id] = c.t; });
++ var list = d.recent.map(function (e) { return byId[e.tool]; }).filter(Boolean).slice(0, 8);
++ if (!list.length) return;
++ var g = document.getElementById('recent-grid'); g.replaceChildren.apply(g, list.map(card));
++ document.getElementById('recent-note').textContent = d.mode === 'account' ? 'On every device you sign in to' : 'On this browser';
++ recentReady = true; if (!input.value.trim()) recentSec.hidden = false;
++ mountIcons(g);
++ });
++ }).catch(function () { /* optional */ });
var q0 = new URL(location.href).searchParams.get('q'); if (q0) { input.value = q0; show(q0, false); }
})();
ddiff --git a/apps/gateway/server/index.js b/apps/gateway/server/index.js
iindex e7044da..88e5ccb 100644
---- a/apps/gateway/server/index.js
++++ b/apps/gateway/server/index.js
@@@ -192,6 +192,20 @@ app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, max: 60, keyGenerator: (req
// the /api/ limiter above. /api/catalog.json stays as it is (Network reads it).
app.use(createToolsApi({ snapshot: toolRegistry.snapshot }));
++// The launcher's recent tools: a signed-in person's tools.usage module (every device), otherwise this
++// browser's ov_recent_tools cookie. Only tools the registry still lists; never cached.
++app.get('/api/v1/me/recent-tools', async (req, res) => {
++ const usage = require('../../_shared/usage');
++ res.set('Cache-Control', 'private, no-store');
++ const known = new Set((toolRegistry.snapshot().tools || []).filter((t) => t.status !== 'unavailable').map((t) => t.id));
++ const sid = req.user && req.user.subject_id;
++ let mode = 'device', recent = usage.recentFromCookie(req).map((tool) => ({ tool }));
++ if (sid && usage.recorder().enabled) {
++ try { recent = await usage.recorder().recent(sid); mode = 'account'; } catch { /* the cookie list stands in */ }
++ }
++ res.json({ mode, recent: recent.filter((e) => e && known.has(e.tool)).slice(0, 12) });
++});
++
// ── Net.OpenVibe and Dev.OpenVibe routes (the pages' API; the run API calls the same handlers) ──
const netRouter = createNetRoutes(null, requireAuth, { guard });
const devRouter = createDevRoutes(null, requireAuth, { guard });
ddiff --git a/apps/gateway/server/pages/site.js b/apps/gateway/server/pages/site.js
iindex cac90fc..5f5c99f 100644
---- a/apps/gateway/server/pages/site.js
++++ b/apps/gateway/server/pages/site.js
@@@ -160,6 +160,7 @@ ${searchForm('')}
<ul class="stats"><li><b>${tools.length}</b> tools</li><li><b>${fams.length}</b> families</li><li><b>0</b> installs</li><li><b>1</b> account for all of OpenVibe</li></ul></div>
<nav class="jump" aria-label="Tool families">${fams.map(f => `<a href="#f-${f.id}">${icon(f.icon, 22)}${esc(f.name)}<i>${tools.filter(t => t.family === f.id).length}</i></a>`).join('')}</nav>
<section id="results" hidden aria-live="polite"><div class="sec-h"><h2>Results</h2></div><div class="grid" id="results-grid"></div><p class="empty" id="results-empty" hidden>No tool matches that yet. <a href="/all-tools">Browse every tool</a>.</p></section>
++<section id="recent" hidden aria-labelledby="recent-h"><div class="sec-h"><h2 id="recent-h">Your recent tools</h2><span class="more" id="recent-note"></span></div><div class="grid" id="recent-grid"></div></section>
<section aria-labelledby="pop-h"><div class="sec-h"><h2 id="pop-h">What people reach for</h2><a class="more" href="/all-tools">A to Z list</a></div><div class="grid">${popular.slice(0, 8).map(toolCard).join('')}</div></section>
${fams.map(f => { const list = tools.filter(t => t.family === f.id); const next = planned.filter(t => t.family === f.id); return `<section class="fam" aria-labelledby="f-${f.id}"><div class="sec-h">${icon(f.icon, 40)}<div><h2 id="f-${f.id}"><a href="${esc(f.path)}">${esc(f.name)}</a></h2><p class="fam-tag">${esc(f.tagline)}</p></div><a class="more" href="${esc(f.path)}">About these ${list.length}</a></div><div class="tiles">${list.map(tile).join('')}${next.map(soon).join('')}</div></section>`; }).join('')}
<section aria-labelledby="acct-h"><div class="sec-h"><h2 id="acct-h">Better with an account, fine without</h2></div><div class="perks">