openvibers/openvibe.tools Commits

Guest conversion for the launcher (WS-B task 8): the first time an account asks for its recent tools from a browser, the tools that browser used as a guest (ov_recent_tools) join the account's tools.usage list, once per account and browser (HttpOnly ov_recent_merged marker)

OP OpenVibers committed 24 Sept 2026 · bfa8c2e1c9 · 3 files, +45 −4


Changes

ddiff --git a/apps/_shared/test/usage.test.js b/apps/_shared/test/usage.test.js
iindex 79d0de8..7b56362 100644
---- a/apps/_shared/test/usage.test.js
++++ b/apps/_shared/test/usage.test.js
@@@ -77,6 +77,22 @@ const fetchImpl = async (url, opts) => {
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');
++ // Guest conversion (WS-B task 8): the guest's tools join the account once per account and browser.
++ const { mergeGuestTools, MERGED_COOKIE } = require('../usage');
++ const recorded = [];
++ const fake = { enabled: true, record: (s, tool) => { recorded.push([s, tool]); return true; } };
++ const set = [];
++ const resOf = () => ({ append: (k, v) => set.push(v) });
++ let added = mergeGuestTools({ headers: { cookie: 'ov_recent_tools=png.yaml.jsonminify' } }, resOf(), USR, [{ tool: 'yaml' }], ['png', 'yaml', 'jsonminify'], fake);
++ assert.deepStrictEqual(added, ['png', 'jsonminify'], 'only what the account lacks, newest first');
++ assert.deepStrictEqual(recorded, [[USR, 'jsonminify'], [USR, 'png']], 'recorded oldest first so the newest ends on top');
++ assert.ok(set[0].startsWith(`${MERGED_COOKIE}=${USR};`) && /HttpOnly/.test(set[0]) && /Domain=\.openvibe\.tools/.test(set[0]));
++ added = mergeGuestTools({ headers: { cookie: `ov_recent_tools=png; ${MERGED_COOKIE}=${USR}` } }, resOf(), USR, [], ['png'], fake);
++ assert.deepStrictEqual(added, [], 'merged once per account and browser');
++ assert.deepStrictEqual(mergeGuestTools({ headers: { cookie: `${MERGED_COOKIE}=${USR}` } }, resOf(), 'usr_01JAB2C3D4E5F6G7H8J9K0OTHR', [], ['png'], fake), ['png'], 'another account on the same browser gets them too');
++ assert.deepStrictEqual(mergeGuestTools({ headers: {} }, resOf(), USR, [], [], fake), [], 'nothing to merge');
++ assert.deepStrictEqual(mergeGuestTools({ headers: {} }, resOf(), USR, [], ['png'], { enabled: false }), [], 'recorder off');
++
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 ff93a81..4688aa7 100644
---- a/apps/_shared/usage.js
++++ b/apps/_shared/usage.js
@@@ -132,6 +132,24 @@ function rememberInCookie(req, res, tool, host) {
let _recorder = null;
function recorder() { if (!_recorder) _recorder = createRecorder(); return _recorder; }
++const MERGED_COOKIE = 'ov_recent_merged';
++/**
++ * Guest conversion (roadmap WS-B task 8): tools this browser used before the person signed in join
++ * their account's list, once per account and browser (a marker cookie names the subject merged).
++ * Returns the tools added (newest first); records them through the recorder.
++ */
++function mergeGuestTools(req, res, subject, accountRecent, fromCookie, rec = recorder()) {
++ if (!subject || !rec.enabled || !fromCookie.length) return [];
++ const raw = String(req.headers.cookie || '');
++ const m = new RegExp(`(?:^|;\\s*)${MERGED_COOKIE}=([^;]*)`).exec(raw);
++ if (m && decodeURIComponent(m[1]) === subject) return [];
++ const have = new Set((accountRecent || []).map((e) => e && e.tool));
++ const add = fromCookie.filter((t) => !have.has(t));
++ for (const tool of [...add].reverse()) rec.record(subject, tool);
++ res.append('Set-Cookie', `${MERGED_COOKIE}=${encodeURIComponent(subject)}; Domain=.openvibe.tools; Path=/; Max-Age=${365 * 24 * 3600}; SameSite=Lax; Secure; HttpOnly`);
++ return add;
++}
++
/**
* Middleware: a GET of a tool's page by a signed-in person (req.user.subject_id from guard.identify)
* records that tool. The host names the tool (the registry's hosts, or the X-OV-Tool the gateway sends).
@@@ -169,4 +187,4 @@ function usagePages({ snapshot, rec = null }) {
};
}
--module.exports = { createRecorder, recorder, usagePages, recentFromCookie, NS, COOKIE };
++module.exports = { createRecorder, recorder, usagePages, recentFromCookie, mergeGuestTools, NS, COOKIE, MERGED_COOKIE };
ddiff --git a/apps/gateway/server/index.js b/apps/gateway/server/index.js
iindex d284d12..5336c0d 100644
---- a/apps/gateway/server/index.js
++++ b/apps/gateway/server/index.js
@@@ -193,15 +193,22 @@ app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, max: 60, keyGenerator: (req
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.
++// 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).
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 }));
++ const fromCookie = usage.recentFromCookie(req);
++ let mode = 'device', recent = fromCookie.map((tool) => ({ tool }));
if (sid && usage.recorder().enabled) {
-- try { recent = await usage.recorder().recent(sid); mode = 'account'; } catch { /* the cookie list stands in */ }
++ try {
++ recent = await usage.recorder().recent(sid); mode = 'account';
++ const merged = usage.mergeGuestTools(req, res, sid, recent, fromCookie);
++ if (merged.length) recent = [...merged.map((tool) => ({ tool })), ...recent];
++ } catch { /* the cookie list stands in */ }
}
res.json({ mode, recent: recent.filter((e) => e && known.has(e.tool)).slice(0, 12) });
});