openvibers/openvibe.tools Commits

The OpenVibe.Tools home shows the week's most opened tools (counted once per person a day, a number per tool and nothing else), how many tools have an API with a sample request, and a FAQ; every tool page shows the request that runs it from code (#84)

OP OpenVibers committed 22 days ago · 941b28668c · 6 files, +314 −13

Changes

ddiff --git a/apps/gateway/server/index.js b/apps/gateway/server/index.js
iindex 52de450..3045417 100644
---- a/apps/gateway/server/index.js
++++ b/apps/gateway/server/index.js
@@@ -135,7 +135,11 @@ app.use(helmet({
// ── Host roles: aliases redirect, unknown hosts leave, satellite hosts are proxied ──
const { hostRoles } = require('./registry/host-middleware');
const registryTimer = registry.start();
++// Tool opens (registry/opens.js): one count per tool per day for the home page's "Most opened this week".
++const toolOpens = require('./registry/opens').createOpens({ db: toolsDb.db });
++toolOpens.start();
app.use(hostRoles({
++ onOpen: (tool, req) => toolOpens.record(tool, req.ip),
hostOf: getRequestHost,
enforce: config.isProduction || process.env.OV_ENFORCE_HOSTS === '1',
// Gateway-served subdomains that predate the catalog (tool aliases, the pastes hand-off).
@@@ -427,7 +431,7 @@ function sendTool(req, res, file) {
// sitemap.xml, robots.txt, llms.txt and /api/catalog.json (server/pages/site.js).
// This site's own pinned copy of the OpenVibe Frame's browser files (/shared, openvibe-shared/serve) is
// mounted with the release routes: apps/_shared/release.js.
--app.use(require('./pages/site').createSiteRouter());
++app.use(require('./pages/site').createSiteRouter({ opens: toolOpens }));
// Net tool subdomains → net.html SPA
app.use((req, res, next) => {
@@@ -545,6 +549,7 @@ require('../../_shared/graceful').gracefulStop({
() => runApi.pool.close(),
() => guard.close(),
async () => { await telemetryRetention.stop(); await telemetry.stop(); },
++ () => toolOpens.stop(),
() => toolsDb.db.close(),
() => toolsValkey && toolsValkey.close(),
() => require('./revocation-events').close(),
ddiff --git a/apps/gateway/server/pages/site.js b/apps/gateway/server/pages/site.js
iindex cf3f5df..fb07405 100644
---- a/apps/gateway/server/pages/site.js
++++ b/apps/gateway/server/pages/site.js
@@@ -96,6 +96,11 @@ details{border-bottom:1px solid var(--border,rgba(255,255,255,.08));padding:12px
.perks{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px}.perks div{padding:16px;border-radius:14px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#111826)}.perks p{margin:6px 0 0;color:var(--text-secondary,#a8b3c4);font-size:14px}
@media (max-width:560px){.tiles{grid-template-columns:1fr 1fr}.tile small{display:none}.jump{top:52px}}
#ov-footer{margin-top:56px}
++.tool .opens{color:var(--text-secondary,#a8b3c4);font-weight:600}
++.api-box{max-width:820px}.api-box pre{background:var(--bg-secondary,#111826);border:1px solid var(--border,rgba(255,255,255,.08));border-radius:12px;padding:12px 14px;font-size:13px;line-height:1.5}
++.api-facts{list-style:none;padding:0;margin:0 0 12px;display:flex;flex-direction:column;gap:6px;color:var(--text-secondary,#a8b3c4)}.api-facts code{overflow-wrap:anywhere}
++.dev{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(320px,100%),1fr));gap:14px;align-items:start}.dev pre{margin:0;background:var(--bg-secondary,#111826);border:1px solid var(--border,rgba(255,255,255,.08));border-radius:12px;padding:12px 14px;font-size:13px;line-height:1.5}
++.dev ul{margin:0;padding-left:18px;color:var(--text-secondary,#a8b3c4)}.dev li{margin:4px 0}
@media (max-width:560px){.grid{grid-template-columns:1fr}.search button{padding:0 14px}}
@media (prefers-reduced-motion:reduce){.tool{transition:none}}
${icons.CSS}`;
@@@ -154,33 +159,85 @@ function search(q) {
return registry.get().tools.map(t => [score(t, terms), t]).filter(x => x[0] > 0).sort((a, b) => b[0] - a[0] || a[1].name.localeCompare(b[1].name)).slice(0, 60).map(x => x[1]);
}
++// ── The API, per tool (descriptors: run path, inputs, files, auth, an example) ──
++const descriptorOf = (id) => require('../registry/descriptors').snapshot().tools.find((d) => d.id === id) || null;
++const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
++const mb = (bytes) => `${Math.round(bytes / 1048576)} MB`;
++/** "Use it from code" on a tool's page: the one request that runs it, who may call it, what it takes. */
++function apiSection(t) {
++ const d = descriptorOf(t.id);
++ if (!d) return '';
++ if (!d.api) return `<section aria-labelledby="api-h"><div class="sec-h"><h2 id="api-h">Use it from code</h2></div><p class="sec-p">${esc(t.name)} runs on its page only and has no API. <a href="/developers">The Tools API</a> covers the tools you can call from code.</p></section>`;
++ const ex = (d.examples || [])[0] || {};
++ const token = !(d.auth && d.auth.anonymous);
++ const lines = [`curl -X POST ${SITE}${d.run.path}`];
++ if (token) lines.push(`-H 'Authorization: Bearer $OPENVIBE_TOKEN'`);
++ if (d.files) {
++ for (const f of ex.files || [{ name: 'file' }]) lines.push(`-F ${shq(`file=@${f.name}`)}`);
++ if (ex.input && Object.keys(ex.input).length) lines.push(`-F ${shq(`input=${JSON.stringify(ex.input)}`)}`);
++ } else {
++ lines.push(`-H 'Content-Type: application/json'`, `-d ${shq(JSON.stringify({ input: ex.input || {} }))}`);
++ }
++ const who = d.auth && d.auth.capability === 'tools.net.probe'
++ ? 'Needs a token with <code>tools.net.probe</code>, granted to partners only; people use the page.'
++ : token ? 'Needs a signed-in browser session or a token with <code>tools.tool.run</code>.'
++ : 'No account needed: anonymous calls run on the lowest quota tier; a token with <code>tools.tool.run</code> raises it.';
++ const files = d.files ? `Takes ${d.files.min === d.files.max ? d.files.min : `${d.files.min} to ${d.files.max}`} file${d.files.max === 1 ? '' : 's'}${d.files.maxBytes ? `, up to ${mb(d.files.maxBytes)} each` : ''}.` : '';
++ const how = d.execution === 'job' ? 'Long work answers 202 with a job to follow; short work answers with the result.' : 'It answers with the result.';
++ return `<section class="api-box" aria-labelledby="api-h"><div class="sec-h"><h2 id="api-h">Use it from code</h2></div>
++<ul class="api-facts"><li><code>${esc(d.run.method)} ${esc(SITE + d.run.path)}</code></li><li>${who}</li><li>${esc([how, files].filter(Boolean).join(' '))}</li></ul>
++<pre tabindex="0"><code>${esc(lines.join(' \\\n '))}</code></pre>
++<p class="sec-p">Inputs: <a href="/api/v1/tools/${esc(d.id)}/schema">JSON Schema</a> · <a href="/api/v1/openapi.json">OpenAPI</a> · from JavaScript: <code>tools.run(${esc(JSON.stringify(d.id))}, …)</code> in <a href="https://github.com/OpenVibers/OpenVibe.SDK">openvibe-sdk</a> · <a href="/developers">How the API works</a></p></section>`;
++}
++
++/** The home's questions: shown on the page and published as its FAQPage, from this one list. */
++const HOME_FAQ = [
++ ['Are the tools free?', 'Yes. Every tool on OpenVibe.Tools is free to use, with fair-use limits per address so the shared servers stay fast for everyone.'],
++ ['Do I need an account?', 'No. Every tool works without signing in, and anonymous results stay available for an hour. Signed in, results keep for 24 hours and your recent tools follow you across devices.'],
++ ['What happens to the files I upload?', 'Many tools run in your browser, so the file never leaves your device. The rest process files on OpenVibe\'s own servers and delete them with their results when they expire: after an hour without an account, after 24 hours with one.'],
++ ['Can I use the tools from my own code?', 'Yes. Most tools have an API: one POST request per tool, jobs for long work, an OpenAPI document and the openvibe-sdk client for JavaScript.'],
++ ['Is OpenVibe.Tools open source?', 'Yes. The code is on GitHub under OpenVibers and the site is community-run.'],
++];
++
// ── Pages ────────────────────────────────────────────────────
--function pageIndex() {
++function pageIndex(top = []) {
// Only built tools: a home never shows what is planned (plan §1 "no placeholders").
const { tools, families } = registry.get();
const byId = new Map(tools.map(t => [t.id, t]));
const popular = POPULAR.map(id => byId.get(id)).filter(Boolean);
const fams = families.filter(f => f.path);
++ const withApi = require('../registry/descriptors').snapshot().tools.filter((d) => d.api && d.status !== 'unavailable').length;
++ // Most opened this week (registry/opens.js): real counts, shown once at least four tools have some.
++ const opened = top.map((r) => [byId.get(r.tool), r.opens]).filter(([t, n]) => t && n > 0).slice(0, 8);
++ const openedCard = ([t, n]) => toolCard(t).replace('</small><i>', `</small><i><span class="opens">${n.toLocaleString('en-US')} ${n === 1 ? 'person' : 'people'} this week</span> · `);
const tile = (t) => `<a class="tile" href="${esc(t.url)}"${go(t)} title="${esc(t.tagline)}">${icon(t.icon, 28)}<span><b>${esc(t.name)}</b><small>${esc(t.tagline)}</small></span></a>`;
const body = `<div class="hero"><p class="kicker">Open source · community-run · no account needed to start</p><h1>The toolbox for <span>everything you do online</span></h1>
<p>Convert a video, squeeze an image, merge a PDF, debug DNS, format JSON. ${tools.length} tools that open instantly, each on an address you can remember, like <a href="https://yt.openvibe.tools/">yt.openvibe.tools</a>.</p>
${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>
++<ul class="stats"><li><b>${tools.length}</b> tools</li><li><b>${fams.length}</b> families</li><li><b>${withApi}</b> with an API</li><li><b>0</b> installs</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 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); 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('')}</div></section>`; }).join('')}
++${opened.length >= 4 ? `<section aria-labelledby="top-h"><div class="sec-h"><h2 id="top-h">Most opened this week</h2></div><p class="sec-p">How many people opened each tool in the last seven days, counted once per person a day.</p><div class="grid">${opened.map(openedCard).join('')}</div></section>` : ''}
++<section aria-labelledby="pop-h"><div class="sec-h"><h2 id="pop-h">Good places to start</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); 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)}">${list.length === 1 ? 'About this one' : `About these ${list.length}`}</a></div><div class="tiles">${list.map(tile).join('')}</div></section>`; }).join('')}
++<section aria-labelledby="dev-h"><div class="sec-h"><h2 id="dev-h">Use any tool from code</h2><a class="more" href="/developers">Developer guide</a></div><p class="sec-p">${withApi} of the ${tools.length} tools take one request each, the same engines the pages use. Each tool's page shows its own request.</p>
++<div class="dev"><pre tabindex="0"><code>curl -X POST ${SITE}/api/v1/tools/jsonminify/run \\
++ -H 'Content-Type: application/json' \\
++ -d '{"input":{"text":"{ \\"a\\": 1 }"}}'</code></pre>
++<ul><li><a href="/api/v1/openapi.json">OpenAPI 3.1</a>, generated from the tool descriptors</li><li><a href="/api/v1/tools">/api/v1/tools</a>: every tool's inputs, limits and access</li><li>Long work runs as a job you can follow over server-sent events</li><li><a href="https://github.com/OpenVibers/OpenVibe.SDK">openvibe-sdk</a>: <code>tools.run(id, input)</code> from JavaScript</li></ul></div></section>
${frame.shipped({ service: 'tools', title: 'Recently shipped on OpenVibe.Tools' })}
<section aria-labelledby="acct-h"><div class="sec-h"><h2 id="acct-h">Better with an account, fine without</h2></div><div class="perks">
<div><b>Without signing in</b><p>Every tool works. Results stay available for an hour, then they are deleted.</p></div>
<div><b>Signed in</b><p>Results keep for 24 hours, your recent tools follow you across devices, and your theme and notifications come along from the rest of OpenVibe.</p></div>
--<div><b>For developers</b><p>Run any tool from code: see <a href="/developers">the Tools API</a> (<a href="/api/v1/openapi.json">OpenAPI</a>). The catalog is public JSON at <a href="/api/catalog.json">/api/catalog.json</a>, with a plain-text map at <a href="/llms.txt">/llms.txt</a>.</p></div></div></section>`;
++<div><b>For developers</b><p>Run any tool from code: see <a href="/developers">the Tools API</a> (<a href="/api/v1/openapi.json">OpenAPI</a>). The catalog is public JSON at <a href="/api/catalog.json">/api/catalog.json</a>, with a plain-text map at <a href="/llms.txt">/llms.txt</a>.</p></div></div></section>
++<section aria-labelledby="faq-h"><div class="sec-h"><h2 id="faq-h">Questions</h2></div>${HOME_FAQ.map(([q, a]) => `<details><summary>${esc(q)}</summary><p>${esc(a)}</p></details>`).join('')}</section>`;
return shell({ families, body, head: head({
title: 'Online Tools for Files, Text, Code & Networks', description: `${tools.length} online tools in one place: video and audio converters, image and PDF tools, developer utilities and network diagnostics. Open source and community-run.`,
canonical: SITE + '/', keywords: 'online tools, converter, pdf tools, image converter, developer tools, network tools, youtube downloader',
jsonLd: [seo.jsonLd.organization(), seo.jsonLd.website({ name: NAME, url: SITE + '/', description: 'Online tools for files, text, code and networks.', searchUrl: SITE + '/search?q={search_term_string}' }),
-- seo.jsonLd.itemList('Tool families', fams.map(f => ({ name: f.name, url: SITE + f.path, description: f.tagline })))] }) });
++ seo.jsonLd.itemList('Tool families', fams.map(f => ({ name: f.name, url: SITE + f.path, description: f.tagline }))),
++ seo.jsonLd.faq(HOME_FAQ.map(([q, a]) => ({ q, a })))] }) });
}
function pageFamily(f) {
@@@ -209,6 +266,7 @@ ${t.status === 'unavailable' ? `<p class="lead"><strong>Not available yet.</stro
<section><div class="sec-h"><h2>Where to find it</h2></div><p class="sec-p">Every address below opens the same tool. The first address is the one to bookmark and share.</p>
<ul class="hosts">${hosts.map(([h, tag], i) => `<li${i === 0 ? ' class="primary"' : ''}><a href="https://${esc(h)}/"${i === 0 ? '' : ' rel="nofollow"'}>${esc(h)}</a><span class="tag">${tag}</span></li>`).join('')}</ul></section>
${t.keywords && t.keywords.length ? `<section><div class="sec-h"><h2>People also call this</h2></div><ul class="kw">${t.keywords.map(k => `<li>${esc(k)}</li>`).join('')}</ul></section>` : ''}
++${apiSection(t)}
${related.length ? `<section><div class="sec-h"><h2>Related tools</h2>${f && f.path ? `<a class="more" href="${esc(f.path)}">All ${esc(f.name)}</a>` : ''}</div><div class="grid">${related.map(toolCard).join('')}</div></section>` : ''}`;
return shell({ families, body, head: head({ title: `${t.name}: ${t.tagline}`, description: t.description, canonical: url, keywords: (t.keywords || []).join(', '),
jsonLd: [seo.jsonLd.breadcrumbs([{ name: 'Tools', url: SITE + '/' }, ...(f && f.path ? [{ name: f.name, url: SITE + f.path }] : []), { name: t.name, url }]),
@@@ -234,8 +292,8 @@ function pageSearch(q) {
// ── Cache + router ───────────────────────────────────────────
const cache = new Map();
--function cached(key, render) {
-- const ver = registry.get().updated;
++function cached(key, render, extra = '') {
++ const ver = `${registry.get().updated}|${extra}`;
let e = cache.get(key);
if (!e || e.ver !== ver) { const html = render(); e = { ver, html, etag: '"' + crypto.createHash('sha1').update(html).digest('base64url').slice(0, 20) + '"' }; cache.set(key, e); }
return e;
@@@ -282,8 +340,8 @@ const done = await job.wait(); // any tool, sync or job`)}
jsonLd: [seo.jsonLd.breadcrumbs([{ name: 'Tools', url: SITE + '/' }, { name: 'Developers', url: SITE + '/developers' }])] }) });
}
--function send(req, res, key, render, type) {
-- const e = cached(key, render);
++function send(req, res, key, render, type, extra) {
++ const e = cached(key, render, extra);
res.set('Content-Type', type || 'text/html; charset=utf-8');
res.set('Cache-Control', 'public, max-age=300, stale-while-revalidate=86400');
res.set('ETag', e.etag);
@@@ -330,7 +388,7 @@ function llmsFullBody() {
sections: llmsSections().map(s => ({ title: s.title, pages: s.links.map(l => ({ title: l.title, url: l.url, text: l.text })) })) });
}
--function createSiteRouter() {
++function createSiteRouter({ opens = null } = {}) {
const router = express.Router();
const apexOnly = (req, res, next) => (!req.ovHost || req.ovHost.kind === 'apex' || (req.ovHost.kind === 'unknown' && !req.ovHost.inZone) ? next() : next('router'));
router.use(apexOnly);
@@@ -339,7 +397,12 @@ function createSiteRouter() {
// What shipped on OpenVibe.Tools: the shared update log every OpenVibe site has.
router.get('/updates', (req, res) => send(req, res, 'updates', pageUpdates));
router.get('/api/catalog.json', (req, res) => { res.set('Access-Control-Allow-Origin', '*'); send(req, res, 'catalog', () => JSON.stringify(registry.catalog()), 'application/json; charset=utf-8'); });
-- router.get('/', (req, res) => send(req, res, '/', pageIndex));
++ // The home carries the week's most opened tools (registry/opens.js, read at most every 5 minutes); a read that
++ // fails leaves that section out, never the page.
++ router.get('/', async (req, res) => {
++ const top = opens ? await opens.top(7, 12).catch(() => []) : [];
++ send(req, res, '/', () => pageIndex(top), undefined, opens ? opens.version() : '');
++ });
router.get('/all-tools', (req, res) => send(req, res, '/all-tools', pageAll));
router.get('/search', (req, res) => { res.set('Cache-Control', 'no-store'); res.type('html').send(pageSearch(String(req.query.q || '').slice(0, 80))); });
router.get('/tool/:id', (req, res, next) => { const t = registry.get().tools.find(x => x.id === req.params.id); return t ? send(req, res, '/tool/' + t.id, () => pageTool(t)) : next(); });
ddiff --git a/apps/gateway/server/registry/host-middleware.js b/apps/gateway/server/registry/host-middleware.js
iindex e624e2d..aeb1b5b 100644
---- a/apps/gateway/server/registry/host-middleware.js
++++ b/apps/gateway/server/registry/host-middleware.js
@@@ -54,6 +54,7 @@ function proxyTo(port, info, req, res) {
* @param {(host: string) => string} opts.hostOf request → hostname
* @param {(sub: string) => boolean} opts.isLegacyHost gateway-served subdomains that are not catalog tools (aliases, pastes…)
* @param {boolean} opts.enforce false in development: unknown hosts fall through
++ * @param {(tool: string, req) => void} [opts.onOpen] a person opened a tool's page on its own host (registry/opens.js)
*/
function hostRoles(opts) {
return function ovHostRoles(req, res, next) {
@@@ -80,6 +81,10 @@ function hostRoles(opts) {
res.set('Cache-Control', 'private, no-store'); res.set('Vary', 'User-Agent, Accept');
return res.redirect(302, `https://${info.shortHost}${req.originalUrl || '/'}`);
}
++ // A person opening a tool's page (not an asset, an API call or a crawler): the home's "Most opened" count.
++ if (opts.onOpen && info.kind === 'tool' && isPersonNavigating(req)) {
++ try { opts.onOpen(info.primary || info.tool, req); } catch { /* a count never blocks the tool */ }
++ }
if (info.port) return proxyTo(info.port, info, req, res);
next();
};
ddiff --git a/apps/gateway/server/registry/opens.js b/apps/gateway/server/registry/opens.js
nnew file mode 100644
iindex 0000000..58a98f6
---- /dev/null
++++ b/apps/gateway/server/registry/opens.js
@@@ -0,0 +1,93 @@
++'use strict';
++// Tool opens: the count behind the home page's "Most opened this week".
++//
++// An open is a person's browser loading a tool's page on its own host (host-middleware's isPersonNavigating: a GET
++// for HTML from a browser that is not a crawler or a link preview). Each address counts once per tool per UTC day,
++// so a refresh, the canonical-to-short-host hop or a reload loop adds nothing. The dedupe set lives in this process
++// only, holds salted hashes (never an address) and is dropped when the day changes; past MAX_SEEN keys a day stops
++// counting new opens rather than counting them twice. What reaches the database (migration 0007) is one number per
++// tool per day, written every FLUSH_MS.
++const crypto = require('crypto');
++
++const FLUSH_MS = 60_000;
++const TOP_MS = 5 * 60_000;
++const MAX_SEEN = 200_000;
++
++const dayOf = (ms) => new Date(ms).toISOString().slice(0, 10);
++
++function createOpens({ db, now = Date.now, log = console } = {}) {
++ const salt = crypto.randomBytes(16);
++ let day = null;
++ let seen = new Set();
++ const pending = new Map(); // `${day}|${tool}` → opens not yet written
++ let topMemo = null;
++ let timer = null;
++
++ /** One open of `tool` from `address` (counted once per address, tool and day). */
++ function record(tool, address) {
++ if (!tool) return false;
++ const d = dayOf(now());
++ if (d !== day) { day = d; seen = new Set(); }
++ const key = crypto.createHmac('sha256', salt).update(`${d}\u0000${address || ''}\u0000${tool}`).digest('base64url').slice(0, 22);
++ if (seen.has(key) || seen.size >= MAX_SEEN) return false;
++ seen.add(key);
++ const k = `${d}|${tool}`;
++ pending.set(k, (pending.get(k) || 0) + 1);
++ return true;
++ }
++
++ /** Write what is pending (one upsert per tool and day); a failed write keeps the counts for the next flush. */
++ async function flush() {
++ if (!pending.size) return 0;
++ const batch = [...pending];
++ pending.clear();
++ let written = 0;
++ for (let i = 0; i < batch.length; i++) {
++ const [k, n] = batch[i];
++ try {
++ await db.exec(
++ `INSERT INTO tool_opens_daily (tool, day, opens) VALUES ($1, $2::date, $3)
++ ON CONFLICT (tool, day) DO UPDATE SET opens = tool_opens_daily.opens + EXCLUDED.opens`,
++ [k.slice(11), k.slice(0, 10), n]);
++ written += n;
++ } catch (err) {
++ // This one and every one after it wait for the next flush.
++ for (const [kk, nn] of batch.slice(i)) pending.set(kk, (pending.get(kk) || 0) + nn);
++ if (log.warn) log.warn('[tools] tool opens not written yet:', err.message);
++ break;
++ }
++ }
++ return written;
++ }
++
++ /** The most opened tools over the last `days` UTC days (today included): [{ tool, opens }], read at most every TOP_MS. */
++ async function top(days = 7, limit = 12) {
++ const at = now();
++ if (topMemo && at - topMemo.at < TOP_MS && topMemo.days === days && topMemo.limit === limit) return topMemo.rows;
++ const since = dayOf(at - (days - 1) * 86_400_000);
++ const rows = (await db.many(
++ `SELECT tool, sum(opens)::int AS opens FROM tool_opens_daily WHERE day >= $1::date
++ GROUP BY tool ORDER BY opens DESC, tool LIMIT $2`,
++ [since, limit])).map((r) => ({ tool: String(r.tool), opens: Number(r.opens) || 0 }));
++ topMemo = { at, days, limit, rows };
++ return rows;
++ }
++
++ /** The top list as a short string: a page cached on it is rebuilt when the list it shows changes, and only then. */
++ const version = () => (topMemo ? topMemo.rows.map((r) => `${r.tool}:${r.opens}`).join(',') : '');
++
++ function start() {
++ if (timer) return;
++ timer = setInterval(() => { flush().catch(() => {}); }, FLUSH_MS);
++ timer.unref();
++ }
++ async function stop() {
++ if (timer) clearInterval(timer);
++ timer = null;
++ await flush().catch(() => {});
++ }
++
++ return { record, flush, top, version, start, stop };
++}
++
++module.exports = { createOpens, dayOf, MAX_SEEN };
ddiff --git a/apps/gateway/test/tool-opens.test.js b/apps/gateway/test/tool-opens.test.js
nnew file mode 100644
iindex 0000000..f5ddb7b
---- /dev/null
++++ b/apps/gateway/test/tool-opens.test.js
@@@ -0,0 +1,124 @@
++'use strict';
++// Tool opens (server/registry/opens.js) and the home sections built on them and on the descriptors:
++// - a person's page load on a tool's host counts once per address, tool and day; crawlers and API calls never do;
++// - what is written is one number per tool and day (no address), and a failed write is kept for the next flush;
++// - the home shows "Most opened this week" only with real counts for at least four tools, plus the API section and
++// a FAQ that is also its FAQPage; a tool's page shows the one request that runs it.
++const assert = require('assert');
++const http = require('http');
++const express = require('express');
++const { createOpens } = require('../server/registry/opens');
++const { hostRoles } = require('../server/registry/host-middleware');
++const registry = require('../server/registry');
++const site = require('../server/pages/site');
++
++/** A db stand-in: tool_opens_daily as a Map, the two statements opens.js runs. */
++function fakeDb({ failFirst = false } = {}) {
++ const rows = new Map();
++ const writes = [];
++ let fail = failFirst;
++ return {
++ rows, writes,
++ async exec(sql, [tool, day, n]) {
++ if (fail) { fail = false; throw new Error('db down'); }
++ writes.push([tool, day, n]);
++ rows.set(`${tool}|${day}`, (rows.get(`${tool}|${day}`) || 0) + n);
++ },
++ async many(sql, [since, limit]) {
++ const sum = new Map();
++ for (const [k, n] of rows) { const [tool, day] = k.split('|'); if (day >= since) sum.set(tool, (sum.get(tool) || 0) + n); }
++ return [...sum].map(([tool, opens]) => ({ tool, opens })).sort((a, b) => b.opens - a.opens || a.tool.localeCompare(b.tool)).slice(0, limit);
++ },
++ };
++}
++
++(async () => {
++ const DAY = Date.parse('2026-10-22T12:00:00Z');
++ let now = DAY;
++
++ // ── counting ──
++ {
++ const db = fakeDb({ failFirst: true });
++ const opens = createOpens({ db, now: () => now, log: { warn() {} } });
++ assert.strictEqual(opens.record('dns', '10.0.0.1'), true);
++ assert.strictEqual(opens.record('dns', '10.0.0.1'), false, 'the same address again the same day adds nothing');
++ assert.strictEqual(opens.record('dns', '10.0.0.2'), true);
++ assert.strictEqual(opens.record('whois', '10.0.0.1'), true, 'another tool counts');
++ assert.strictEqual(opens.record('', '10.0.0.1'), false, 'no tool, no count');
++ assert.strictEqual(await opens.flush(), 0, 'a failed write keeps the counts');
++ assert.strictEqual(await opens.flush(), 3);
++ assert.deepStrictEqual(db.writes.sort(), [['dns', '2026-10-22', 2], ['whois', '2026-10-22', 1]]);
++ for (const w of db.writes) assert.ok(!w.join(' ').includes('10.0.0'), 'no address is written');
++ now = DAY + 86_400_000;
++ assert.strictEqual(opens.record('dns', '10.0.0.1'), true, 'a new day counts the address again');
++ await opens.flush();
++ assert.deepStrictEqual(await opens.top(7, 5), [{ tool: 'dns', opens: 3 }, { tool: 'whois', opens: 1 }]);
++ db.rows.clear();
++ now += 60_000;
++ assert.deepStrictEqual(await opens.top(7, 5), [{ tool: 'dns', opens: 3 }, { tool: 'whois', opens: 1 }], 'read at most every 5 minutes');
++ await db.exec('', ['dns', '2026-10-14', 9]);
++ now = DAY + 9 * 86_400_000;
++ assert.deepStrictEqual(await opens.top(7, 5), [], 'the last seven days only');
++ }
++
++ // ── the host middleware counts people opening a tool, nothing else ──
++ {
++ const seen = [];
++ const tool = registry.get().tools.find((t) => !t.port && t.hosts.short);
++ const mw = hostRoles({ hostOf: (req) => req.headers.host, isLegacyHost: () => false, enforce: false, onOpen: (id, req) => seen.push([id, req.ip]) });
++ const chrome = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/130 Safari/537.36';
++ const run = (headers, method = 'GET') => new Promise((resolve) => {
++ const req = { method, headers: { host: tool.hosts.short, ...headers }, ip: '10.1.1.1', originalUrl: '/' };
++ const res = { set() {}, redirect() { resolve('redirect'); }, status() { return this; }, type() { return this; }, send() { resolve('sent'); } };
++ mw(req, res, () => resolve('next'));
++ });
++ await run({ 'user-agent': chrome, accept: 'text/html', 'sec-fetch-dest': 'document' });
++ await run({ 'user-agent': 'Mozilla/5.0 (compatible; Googlebot/2.1)', accept: 'text/html' });
++ await run({ 'user-agent': chrome, accept: 'application/json' });
++ await run({ 'user-agent': chrome, accept: 'text/html' }, 'POST');
++ assert.deepStrictEqual(seen, [[tool.id, '10.1.1.1']], 'one person opening the page; no crawler, API call or POST');
++ }
++
++ // ── the home and a tool page ──
++ {
++ const db = fakeDb();
++ const opens = createOpens({ db, now: () => DAY });
++ const app = express();
++ app.use(site.createSiteRouter({ opens }));
++ const srv = http.createServer(app);
++ await new Promise((r) => srv.listen(0, '127.0.0.1', r));
++ const base = `http://127.0.0.1:${srv.address().port}`;
++ try {
++ let home = await (await fetch(`${base}/`)).text();
++ assert.ok(!home.includes('Most opened this week'), 'no counts yet: the section is not shown');
++ assert.ok(home.includes('Good places to start'), 'the hand-picked list says what it is');
++ assert.ok(home.includes('Use any tool from code') && home.includes('/api/v1/openapi.json'), 'the API section');
++ assert.ok(/<b>\d+<\/b> with an API/.test(home), 'how many tools have an API');
++ assert.ok(home.includes('"@type":"FAQPage"') && home.includes('<summary>Do I need an account?</summary>'), 'the FAQ and its JSON-LD');
++
++ const ids = registry.get().tools.slice(0, 5).map((t) => t.id);
++ ids.forEach((id, i) => { for (let k = 0; k <= i; k++) opens.record(id, `10.2.0.${k}`); });
++ await opens.flush();
++ const fresh = createOpens({ db, now: () => DAY }); // a new process reads the counts the first one wrote
++ const app2 = express();
++ app2.use(site.createSiteRouter({ opens: fresh }));
++ const srv2 = http.createServer(app2);
++ await new Promise((r) => srv2.listen(0, '127.0.0.1', r));
++ try {
++ home = await (await fetch(`http://127.0.0.1:${srv2.address().port}/`)).text();
++ assert.ok(home.includes('Most opened this week'), 'real counts for five tools: the section shows');
++ assert.ok(home.includes('5 people this week') && home.includes('1 person this week'), 'each with its count');
++ } finally { srv2.close(); }
++
++ const page = await (await fetch(`${base}/tool/dns`)).text();
++ assert.ok(page.includes('Use it from code') && page.includes('https://openvibe.tools/api/v1/tools/dns/run'), 'a tool page names its request');
++ assert.ok(page.includes('/api/v1/tools/dns/schema'), 'and its input schema');
++ const file = registry.get().tools.find((t) => t.id === 'mergepdf');
++ if (file) {
++ const pdf = await (await fetch(`${base}/tool/mergepdf`)).text();
++ assert.ok(pdf.includes('-F &#39;[email protected]&#39;') || pdf.includes("-F '[email protected]'"), 'a file tool shows a multipart upload');
++ }
++ } finally { srv.close(); }
++ }
++ console.log('tool opens: all checks passed');
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/migrations/0007_tool_opens.sql b/migrations/0007_tool_opens.sql
nnew file mode 100644
iindex 0000000..69f60a1
---- /dev/null
++++ b/migrations/0007_tool_opens.sql
@@@ -0,0 +1,11 @@
++-- phase: expand
++-- tool_opens_daily: how many people opened each tool's page on a UTC day (apps/gateway/server/registry/opens.js),
++-- for the home page's "Most opened this week". A count per tool per day and nothing else: no address, no person,
++-- no page. Additive: a new empty table.
++CREATE TABLE IF NOT EXISTS tool_opens_daily (
++ tool text NOT NULL,
++ day date NOT NULL,
++ opens integer NOT NULL DEFAULT 0,
++ PRIMARY KEY (tool, day)
++);
++CREATE INDEX IF NOT EXISTS tool_opens_daily_day ON tool_opens_daily (day);