openvibers/openvibe.tools Commits

Favourites for everyone (WS-L task 1): without an account a browser keeps its starred tools in the ov_tool_favs cookie on the tools zone (24, newest first) and they join the account's favourites once at sign-in; stars on search results as well as Your tools; favourites rank first in results; Ctrl+K (or /) on an empty search opens Your favourites, Escape goes back

OP OpenVibers committed 25 Sept 2026 · db18e70da7 · 5 files, +154 −34


Changes

ddiff --git a/apps/_shared/test/usage.test.js b/apps/_shared/test/usage.test.js
iindex ffbabc7..ad1d69c 100644
---- a/apps/_shared/test/usage.test.js
++++ b/apps/_shared/test/usage.test.js
@@@ -110,6 +110,32 @@ const fetchImpl = async (url, opts) => {
assert.deepStrictEqual(mergeGuestTools({ headers: {} }, resOf(), USR, [], [], fake), [], 'nothing to merge');
assert.deepStrictEqual(mergeGuestTools({ headers: {} }, resOf(), USR, [], ['png'], { enabled: false }), [], 'recorder off');
++ // Anonymous favourites (WS-L task 1): a browser's stars live in ov_tool_favs on the tools zone; at
++ // sign-in they join the account's after its own, once, and the cookie is cleared.
++ {
++ const { favoritesFromCookie, setCookieFavorite, mergeGuestFavorites, FAV_COOKIE } = require('../usage');
++ assert.deepStrictEqual(favoritesFromCookie({ headers: { cookie: `${FAV_COOKIE}=png.x..<b>.png.yaml` } }), ['png', 'x', 'yaml'], 'only tool ids, deduplicated');
++ const out = [];
++ const res2 = { append: (k, v) => out.push(v) };
++ assert.deepStrictEqual(setCookieFavorite({ headers: { cookie: `${FAV_COOKIE}=yaml` } }, res2, 'png', true, 'openvibe.tools'), ['png', 'yaml'], 'newest first');
++ assert.match(out[0], /^ov_tool_favs=png\.yaml; Domain=\.openvibe\.tools; Path=\/; Max-Age=31536000; SameSite=Lax; Secure$/);
++ assert.deepStrictEqual(setCookieFavorite({ headers: { cookie: `${FAV_COOKIE}=png.yaml` } }, res2, 'png', false, '127.0.0.1'), ['yaml']);
++ assert.doesNotMatch(out[1], /Domain=/, 'a host-only cookie off the tools zone');
++ assert.match(setCookieFavorite({ headers: { cookie: `${FAV_COOKIE}=png` } }, res2, 'png', false, 'png.openvibe.tools') && out[2], /^ov_tool_favs=; Domain=\.openvibe\.tools; Path=\/; Max-Age=0/, 'the last unstar clears it');
++ const many = Array.from({ length: 30 }, (_, i) => `t${i}`);
++ assert.strictEqual(setCookieFavorite({ headers: { cookie: `${FAV_COOKIE}=${many.join('.')}` } }, res2, 'new', true, 'openvibe.tools').length, 24, 'at most 24');
++
++ await r.setFavorite(USR, 'uuid', true);
++ out.length = 0;
++ const merged = await mergeGuestFavorites({ headers: { cookie: `${FAV_COOKIE}=png.uuid.yaml` } }, res2, USR, 'openvibe.tools', r);
++ assert.deepStrictEqual(merged, ['uuid', 'png', 'yaml'], "the guest's stars join after the account's own, without duplicates");
++ assert.deepStrictEqual(modules.get(USR).data.favorites, ['uuid', 'png', 'yaml']);
++ assert.match(out[0], /^ov_tool_favs=; .*Max-Age=0/, 'and the cookie is cleared');
++ assert.strictEqual(await mergeGuestFavorites({ headers: {} }, res2, USR, 'openvibe.tools', r), null, 'nothing to merge');
++ assert.strictEqual(await mergeGuestFavorites({ headers: { cookie: `${FAV_COOKIE}=png` } }, res2, USR, 'openvibe.tools', { enabled: false }), null, 'recorder off: the cookie stays');
++ for (const id of ['uuid', 'png', 'yaml']) await r.setFavorite(USR, id, false);
++ }
++
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 76418c4..8ad3868 100644
---- a/apps/_shared/usage.js
++++ b/apps/_shared/usage.js
@@@ -125,14 +125,25 @@ function createRecorder({ env = process.env, fetchImpl = globalThis.fetch, now =
/** Star (on) or unstar a tool: a read-modify-write naming the revision read; a 412 reads again. → favorites */
async function setFavorite(subject, tool, on) {
++ if (!TOOL_RE.test(String(tool || ''))) throw Object.assign(new Error('bad subject or tool'), { status: 400 });
++ return changeFavorites(subject, (had) => (on ? [tool, ...had.filter((id) => id !== tool)] : had.filter((id) => id !== tool)));
++ }
++
++ /** Add tools a browser starred as a guest after the account's own favourites (sign-in). → favorites */
++ async function addFavorites(subject, tools) {
++ const add = (tools || []).filter((id) => TOOL_RE.test(String(id)));
++ return changeFavorites(subject, (had) => [...had, ...add.filter((id) => !had.includes(id))]);
++ }
++
++ async function changeFavorites(subject, change) {
if (!enabled) throw Object.assign(new Error('favourites are off on this server'), { status: 503 });
-- if (!USR_RE.test(String(subject || '')) || !TOOL_RE.test(String(tool || ''))) throw Object.assign(new Error('bad subject or tool'), { status: 400 });
++ if (!USR_RE.test(String(subject || ''))) throw Object.assign(new Error('bad subject or tool'), { status: 400 });
for (let attempt = 0; attempt < 3; attempt++) {
const cur = await call('GET', subject);
if (cur.status !== 200 && cur.status !== 404) throw Object.assign(new Error(`read: ${cur.status}`), { status: 503 });
const data = cur.status === 200 && cur.body && cur.body.data && typeof cur.body.data === 'object' ? cur.body.data : {};
const had = Array.isArray(data.favorites) ? data.favorites : [];
-- const next = on ? [tool, ...had.filter((id) => id !== tool)].slice(0, FAV_MAX) : had.filter((id) => id !== tool);
++ const next = change(had).slice(0, FAV_MAX);
if (JSON.stringify(next) === JSON.stringify(had)) return had;
const w = await call('PUT', subject, { body: { data: { ...data, favorites: next } }, revision: cur.status === 200 ? cur.body.revision : 0 });
if (w.status === 412) { stats.conflicts++; continue; }
@@@ -142,7 +153,7 @@ function createRecorder({ env = process.env, fetchImpl = globalThis.fetch, now =
throw Object.assign(new Error('your favourites kept changing; try again'), { status: 409 });
}
-- return { enabled, record, recent, favorites, setFavorite, flush, stop, stats: () => ({ enabled, pending: pending.size, ...stats }) };
++ return { enabled, record, recent, favorites, setFavorite, addFavorites, 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
@@@ -161,6 +172,38 @@ function rememberInCookie(req, res, tool, host) {
res.append('Set-Cookie', `${COOKIE}=${list.join('.')}; Domain=.openvibe.tools; Path=/; Max-Age=${180 * 24 * 3600}; SameSite=Lax; Secure`);
}
++// Anonymous favourites (roadmap WS-L task 1): the tools a browser starred without an account, in a
++// first-party cookie on the tools zone like its history (24 at most, newest first). When the person
++// signs in they join the account's favourites once, and the cookie is cleared.
++const FAV_COOKIE = 'ov_tool_favs';
++function favoritesFromCookie(req) {
++ const m = new RegExp(`(?:^|;\\s*)${FAV_COOKIE}=([^;]*)`).exec(String((req.headers && req.headers.cookie) || ''));
++ 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, FAV_MAX);
++}
++function writeFavoritesCookie(res, list, host) {
++ const domain = /(^|\.)openvibe\.tools$/.test(String(host || '')) ? '; Domain=.openvibe.tools' : '';
++ res.append('Set-Cookie', list.length
++ ? `${FAV_COOKIE}=${list.slice(0, FAV_MAX).join('.')}${domain}; Path=/; Max-Age=${365 * 24 * 3600}; SameSite=Lax; Secure`
++ : `${FAV_COOKIE}=${domain}; Path=/; Max-Age=0; SameSite=Lax; Secure`);
++}
++/** Star or unstar a tool for this browser. → favorites (newest first) */
++function setCookieFavorite(req, res, tool, on, host) {
++ const had = favoritesFromCookie(req);
++ const next = on ? [tool, ...had.filter((id) => id !== tool)].slice(0, FAV_MAX) : had.filter((id) => id !== tool);
++ writeFavoritesCookie(res, next, host);
++ return next;
++}
++/** Signed in with guest favourites in the cookie: add them to the account's, then clear the cookie. → favorites | null */
++async function mergeGuestFavorites(req, res, subject, host, rec = recorder()) {
++ const guest = favoritesFromCookie(req);
++ if (!guest.length || !subject || !rec.enabled) return null;
++ const merged = await rec.addFavorites(subject, guest);
++ writeFavoritesCookie(res, [], host);
++ return merged;
++}
++
let _recorder = null;
function recorder() { if (!_recorder) _recorder = createRecorder(); return _recorder; }
@@@ -219,4 +262,4 @@ function usagePages({ snapshot, rec = null }) {
};
}
--module.exports = { createRecorder, recorder, usagePages, recentFromCookie, mergeGuestTools, NS, COOKIE, MERGED_COOKIE };
++module.exports = { createRecorder, recorder, usagePages, recentFromCookie, mergeGuestTools, favoritesFromCookie, setCookieFavorite, mergeGuestFavorites, NS, COOKIE, MERGED_COOKIE, FAV_COOKIE };
ddiff --git a/apps/gateway/public/js/site.js b/apps/gateway/public/js/site.js
iindex 9db36eb..b0985e0 100644
---- a/apps/gateway/public/js/site.js
++++ b/apps/gateway/public/js/site.js
@@@ -46,16 +46,58 @@
var b = document.createElement('b'); b.textContent = t.name; var s = document.createElement('small'); s.textContent = t.tagline; var i = document.createElement('i'); i.textContent = t.hosts.short || t.hosts.canonical;
box.appendChild(b); box.appendChild(s); box.appendChild(i); a.appendChild(ic); a.appendChild(box); return a;
}
++ // Favourites (signed in: the account's, on every device and my.openvibe.network; otherwise this
++ // browser's). Each card can be starred wherever it is shown; every star for a tool stays in step.
++ var favs = [], favReady = null, heading = results.querySelector('h2');
++ function isFav(id) { return favs.indexOf(id) >= 0; }
++ function starred(t) {
++ var w = document.createElement('div'); w.className = 'tool-wrap';
++ var b = document.createElement('button'); b.type = 'button'; b.className = 'fav'; b.setAttribute('data-tool', t.id);
++ paint(b, t.name, isFav(t.id));
++ b.addEventListener('click', function () {
++ var on = b.getAttribute('aria-pressed') !== 'true'; b.disabled = true;
++ fetch('/api/v1/me/favorites/' + encodeURIComponent(t.id), { method: on ? 'PUT' : 'DELETE', credentials: 'same-origin' })
++ .then(function (r) { return r.ok ? r.json() : null; })
++ .then(function (res) { if (res && res.favorites) { favs = res.favorites; syncStars(); } })
++ .catch(function () { /* unchanged */ })
++ .then(function () { b.disabled = false; });
++ });
++ w.appendChild(card(t)); w.appendChild(b); return w;
++ }
++ function paint(b, name, on) { b.setAttribute('aria-pressed', on ? 'true' : 'false'); b.textContent = on ? '\u2605' : '\u2606'; b.setAttribute('aria-label', (on ? 'Remove ' : 'Add ') + name + (on ? ' from' : ' to') + ' favourites'); }
++ function syncStars() {
++ [].forEach.call(document.querySelectorAll('button.fav[data-tool]'), function (b) {
++ var id = b.getAttribute('data-tool'), c = catalog && catalog.filter(function (x) { return x.t.id === id; })[0];
++ paint(b, c ? c.t.name : id, isFav(id));
++ });
++ }
++ // Ctrl+K (or /) on an empty search: your favourites, ready to open with the keyboard.
++ function showFavorites() {
++ if (input.value.trim()) return;
++ (favReady || Promise.resolve()).then(load).then(function () {
++ if (input.value.trim() || !favs.length) return;
++ var byId = {}; catalog.forEach(function (c) { byId[c.t.id] = c.t; });
++ var list = favs.map(function (id) { return byId[id]; }).filter(Boolean);
++ if (!list.length) return;
++ heading.textContent = 'Your favourites'; empty.hidden = true;
++ grid.replaceChildren.apply(grid, list.map(starred));
++ results.hidden = false; others.forEach(function (x) { x.hidden = true; }); if (recentSec) recentSec.hidden = true;
++ mountIcons(grid);
++ });
++ }
function show(q, push) {
q = q.trim();
++ heading.textContent = 'Results';
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; }); 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;
++ var hits = find(q);
++ hits = hits.filter(function (t) { return isFav(t.id); }).concat(hits.filter(function (t) { return !isFav(t.id); }));
++ grid.replaceChildren.apply(grid, hits.map(starred)); empty.hidden = hits.length > 0;
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); }
++ mountIcons(grid);
});
}
var timer = null;
@@@ -65,14 +107,16 @@
window.addEventListener('popstate', function () { var q = new URL(location.href).searchParams.get('q') || ''; input.value = q; show(q, false); });
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(); }
++ 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(); showFavorites(); }
++ else if (e.key === 'Escape' && !input.value.trim() && !results.hidden) show('', false);
});
// Your tools: favourites first (signed in: starred here or on my.openvibe.network), then 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) {
-- var favs = (d && d.favorites) || [];
++ favReady = fetch('/api/v1/me/recent-tools', { credentials: 'same-origin' }).then(function (r) { return r.ok ? r.json() : null; })
++ .then(function (d) { favs = (d && d.favorites) || []; if (catalog) syncStars(); return d; }).catch(function () { return null; });
++ if (recentSec) favReady.then(function (d) {
if (!d || ((!d.recent || !d.recent.length) && !favs.length)) return null;
return load().then(function () {
var byId = {}; catalog.forEach(function (c) { byId[c.t.id] = c.t; });
@@@ -80,23 +124,6 @@
var list = ids.map(function (id) { return byId[id]; }).filter(Boolean).slice(0, Math.max(8, favs.length));
if (!list.length) return;
var g = document.getElementById('recent-grid');
-- // Signed in: a star on each card (favourites live in the account's tools.usage module).
-- var starred = function (t) {
-- if (d.mode !== 'account') return card(t);
-- var w = document.createElement('div'); w.className = 'tool-wrap';
-- var b = document.createElement('button'); b.type = 'button'; b.className = 'fav';
-- var set = function (on) { b.setAttribute('aria-pressed', on ? 'true' : 'false'); b.textContent = on ? '\u2605' : '\u2606'; b.setAttribute('aria-label', (on ? 'Remove ' : 'Add ') + t.name + (on ? ' from' : ' to') + ' favourites'); };
-- set(favs.indexOf(t.id) >= 0);
-- b.addEventListener('click', function () {
-- var on = b.getAttribute('aria-pressed') !== 'true'; b.disabled = true;
-- fetch('/api/v1/me/favorites/' + encodeURIComponent(t.id), { method: on ? 'PUT' : 'DELETE', credentials: 'same-origin' })
-- .then(function (r) { return r.ok ? r.json() : null; })
-- .then(function (res) { if (res) { favs = res.favorites; set(favs.indexOf(t.id) >= 0); } })
-- .catch(function () { /* unchanged */ })
-- .then(function () { b.disabled = false; });
-- });
-- w.appendChild(card(t)); w.appendChild(b); return w;
-- };
g.replaceChildren.apply(g, list.map(starred));
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;
ddiff --git a/apps/gateway/server/index.js b/apps/gateway/server/index.js
iindex 29cc741..8baaa66 100644
---- a/apps/gateway/server/index.js
++++ b/apps/gateway/server/index.js
@@@ -197,7 +197,8 @@ 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. Guest conversion
// (WS-B task 8): the first time an account asks from a browser, the tools this browser used as a guest
--// join the account's list (once per account and browser: ov_recent_merged).
++// join the account's list (once per account and browser: ov_recent_merged). Favourites likewise: the
++// account's, or this browser's ov_tool_favs cookie; a guest's starred tools join the account at sign-in.
app.get('/api/v1/me/recent-tools', async (req, res) => {
const usage = require('../../_shared/usage');
res.set('Cache-Control', 'private, no-store');
@@@ -212,22 +213,26 @@ app.get('/api/v1/me/recent-tools', async (req, res) => {
if (merged.length) recent = [...merged.map((tool) => ({ tool })), ...recent];
} catch { /* the cookie list stands in */ }
}
-- let favorites = [];
-- if (mode === 'account') { try { favorites = (await usage.recorder().favorites(sid)).filter((id) => known.has(id)); } catch { /* optional */ } }
-- res.json({ mode, recent: recent.filter((e) => e && known.has(e.tool)).slice(0, 12), favorites });
++ let favorites = mode === 'account' ? [] : usage.favoritesFromCookie(req);
++ if (mode === 'account') {
++ try { favorites = (await usage.mergeGuestFavorites(req, res, sid, req.hostname)) || await usage.recorder().favorites(sid); } catch { /* optional */ }
++ }
++ res.json({ mode, recent: recent.filter((e) => e && known.has(e.tool)).slice(0, 12), favorites: favorites.filter((id) => known.has(id)) });
});
--// Favourite tools (tools.usage v2 `favorites`): PUT stars a tool, DELETE unstars it, for the signed-in
--// person. Cross-site pages cannot send these (PUT/DELETE need a CORS preflight the gateway refuses).
++// Favourite tools: PUT stars a tool, DELETE unstars it. Signed in: the account's tools.usage v2
++// `favorites` (every device, my.openvibe.network too); otherwise this browser's ov_tool_favs cookie.
++// Cross-site pages cannot send these (PUT/DELETE need a CORS preflight the gateway refuses).
for (const method of ['put', 'delete']) {
app[method]('/api/v1/me/favorites/:tool', async (req, res) => {
res.set('Cache-Control', 'private, no-store');
++ const usage = require('../../_shared/usage');
const sid = req.user && req.user.subject_id;
-- if (!sid) return res.status(401).json({ error: 'Sign in to keep favourite tools', code: 'auth.required' });
const tool = String(req.params.tool || '');
if (!(toolRegistry.snapshot().tools || []).some((t) => t.id === tool)) return res.status(404).json({ error: 'No such tool', code: 'tools.not_found' });
++ if (!sid || !usage.recorder().enabled) return res.json({ mode: 'device', favorites: usage.setCookieFavorite(req, res, tool, method === 'put', req.hostname) });
try {
-- res.json({ favorites: await require('../../_shared/usage').recorder().setFavorite(sid, tool, method === 'put') });
++ res.json({ mode: 'account', favorites: await usage.recorder().setFavorite(sid, tool, method === 'put') });
} catch (err) {
res.status(err.status || 500).json({ error: err.status ? err.message : 'Could not save your favourites', code: 'tools.favorites.unavailable' });
}
ddiff --git a/apps/gateway/test/tools-api.test.js b/apps/gateway/test/tools-api.test.js
iindex dfa720a..77fbc10 100644
---- a/apps/gateway/test/tools-api.test.js
++++ b/apps/gateway/test/tools-api.test.js
@@@ -197,6 +197,25 @@ const get = async (url, headers = {}, method = 'GET') => {
assert.strictEqual(ready.checks.tool_registry.status, 'ok', JSON.stringify(ready.checks.tool_registry));
assert.strictEqual(ready.checks.tool_registry.required, false);
++ // ── Favourites without an account (WS-L task 1): this browser's ov_tool_favs cookie ──
++ {
++ const fav = (method, tool, cookie = '') => fetch(`${G}/api/v1/me/favorites/${tool}`, { method, headers: cookie ? { Cookie: cookie } : {} });
++ let r = await fav('PUT', 'png');
++ assert.strictEqual(r.status, 200);
++ assert.deepStrictEqual(await r.json(), { mode: 'device', favorites: ['png'] });
++ const cookie = (r.headers.get('set-cookie') || '').split(';')[0];
++ assert.strictEqual(cookie, 'ov_tool_favs=png');
++ r = await fav('PUT', 'yaml', cookie);
++ const both = (r.headers.get('set-cookie') || '').split(';')[0];
++ assert.deepStrictEqual((await r.json()).favorites, ['yaml', 'png'], 'newest first');
++ const me = await (await fetch(`${G}/api/v1/me/recent-tools`, { headers: { Cookie: `${both}; ov_recent_tools=uuid` } })).json();
++ assert.deepStrictEqual(me, { mode: 'device', recent: [{ tool: 'uuid' }], favorites: ['yaml', 'png'] }, 'the launcher reads them back');
++ assert.strictEqual((await fav('PUT', 'no-such-tool', cookie)).status, 404);
++ r = await fav('DELETE', 'png', cookie);
++ assert.deepStrictEqual((await r.json()).favorites, []);
++ assert.match(r.headers.get('set-cookie') || '', /^ov_tool_favs=; Path=\/; Max-Age=0/, 'the last unstar clears the cookie');
++ }
++
// ── The gateway's /api/ limiter counts registry reads (120 a minute per address) ──
let limited = null;
for (let i = 0; i < 130 && !limited; i++) { const x = await fetch(`${G}/api/v1/tools/png`); if (x.status === 429) limited = x; }