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
Changes
@@@ -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');@@@ -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 };@@@ -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;@@@ -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' });}@@@ -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; }
@@ -110,6 +110,32 @@ const fetchImpl = async (url, opts) => {
@@ -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, [], [], fake), [], 'nothing to merge');
assert.deepStrictEqual(mergeGuestTools({ headers: {} }, resOf(), USR, [], ['png'], { enabled: false }), [], 'recorder off');
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 });
const off = createRecorder({ env: {}, fetchImpl });
assert.strictEqual(off.enabled, false);
assert.strictEqual(off.enabled, false);
assert.strictEqual(off.record(USR, 'yaml'), false, 'off without a service secret');
assert.strictEqual(off.record(USR, 'yaml'), false, 'off without a service secret');
@@ -125,14 +125,25 @@ function createRecorder({ env = process.env, fetchImpl = globalThis.fetch, now =
@@ -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 */
/** 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) {
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 (!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++) {
for (let attempt = 0; attempt < 3; attempt++) {
const cur = await call('GET', subject);
const cur = await call('GET', subject);
if (cur.status !== 200 && cur.status !== 404) throw Object.assign(new Error(`read: ${cur.status}`), { status: 503 });
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 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 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;
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 });
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; }
if (w.status === 412) { stats.conflicts++; continue; }
@@ -142,7 +153,7 @@ function createRecorder({ env = process.env, fetchImpl = globalThis.fetch, now =
@@ -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 });
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
// 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) {
@@ -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`);
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;
let _recorder = null;
function recorder() { if (!_recorder) _recorder = createRecorder(); return _recorder; }
function recorder() { if (!_recorder) _recorder = createRecorder(); return _recorder; }
@@ -219,4 +262,4 @@ function usagePages({ snapshot, rec = null }) {
@@ -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 };
@@ -46,16 +46,58 @@
@@ -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;
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;
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) {
function show(q, push) {
q = q.trim();
q = q.trim();
+ heading.textContent = 'Results';
var url = new URL(location.href); if (q) url.searchParams.set('q', q); else url.searchParams.delete('q');
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 (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; }
if (!q) { results.hidden = true; others.forEach(function (s) { s.hidden = false; }); if (recentSec) recentSec.hidden = !recentReady; return; }
load().then(function () {
load().then(function () {
if (input.value.trim() !== q) return;
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;
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;
var timer = null;
@@ -65,14 +107,16 @@
@@ -65,14 +107,16 @@
window.addEventListener('popstate', function () { var q = new URL(location.href).searchParams.get('q') || ''; input.value = q; show(q, false); });
window.addEventListener('popstate', function () { var q = new URL(location.href).searchParams.get('q') || ''; input.value = q; show(q, false); });
document.addEventListener('keydown', function (e) {
document.addEventListener('keydown', function (e) {
var k = (e.key || '').toLowerCase();
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,
// 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.
// 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); } }
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;
if (!d || ((!d.recent || !d.recent.length) && !favs.length)) return null;
return load().then(function () {
return load().then(function () {
var byId = {}; catalog.forEach(function (c) { byId[c.t.id] = c.t; });
var byId = {}; catalog.forEach(function (c) { byId[c.t.id] = c.t; });
@@ -80,23 +124,6 @@
@@ -80,23 +124,6 @@
var list = ids.map(function (id) { return byId[id]; }).filter(Boolean).slice(0, Math.max(8, favs.length));
var list = ids.map(function (id) { return byId[id]; }).filter(Boolean).slice(0, Math.max(8, favs.length));
if (!list.length) return;
if (!list.length) return;
var g = document.getElementById('recent-grid');
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));
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';
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;
recentReady = true; if (!input.value.trim()) recentSec.hidden = false;
@@ -197,7 +197,8 @@ app.use(createToolsApi({ snapshot: toolRegistry.snapshot }));
@@ -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
// 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
// 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
// (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) => {
app.get('/api/v1/me/recent-tools', async (req, res) => {
const usage = require('../../_shared/usage');
const usage = require('../../_shared/usage');
res.set('Cache-Control', 'private, no-store');
res.set('Cache-Control', 'private, no-store');
@@ -212,22 +213,26 @@ app.get('/api/v1/me/recent-tools', async (req, res) => {
@@ -212,22 +213,26 @@ app.get('/api/v1/me/recent-tools', async (req, res) => {
if (merged.length) recent = [...merged.map((tool) => ({ tool })), ...recent];
if (merged.length) recent = [...merged.map((tool) => ({ tool })), ...recent];
} catch { /* the cookie list stands in */ }
} 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']) {
for (const method of ['put', 'delete']) {
app[method]('/api/v1/me/favorites/:tool', async (req, res) => {
app[method]('/api/v1/me/favorites/:tool', async (req, res) => {
res.set('Cache-Control', 'private, no-store');
res.set('Cache-Control', 'private, no-store');
+ const usage = require('../../_shared/usage');
const sid = req.user && req.user.subject_id;
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 || '');
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 (!(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 {
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) {
} catch (err) {
res.status(err.status || 500).json({ error: err.status ? err.message : 'Could not save your favourites', code: 'tools.favorites.unavailable' });
res.status(err.status || 500).json({ error: err.status ? err.message : 'Could not save your favourites', code: 'tools.favorites.unavailable' });
}
}
@@ -197,6 +197,25 @@ const get = async (url, headers = {}, method = 'GET') => {
@@ -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.status, 'ok', JSON.stringify(ready.checks.tool_registry));
assert.strictEqual(ready.checks.tool_registry.required, false);
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) ──
// ── The gateway's /api/ limiter counts registry reads (120 a minute per address) ──
let limited = null;
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; }
for (let i = 0; i < 130 && !limited; i++) { const x = await fetch(`${G}/api/v1/tools/png`); if (x.status === 429) limited = x; }