openvibers/openvibe.network Commits
The frame's legal links point where the documents are (10 sites' footers 404'd) (#530)
Changes
@@@ -160,8 +160,9 @@ async function createFrameService(db, config, analytics, { privateKey = null, isconst open = orderedOpen(); const pool = linkPool();const ai = copy.sites[site.id];const discover = (ai ? ai.picks.map(id => pool.get(id)) : open.filter(s => s.id !== site.id).slice(0, 4).map(s => pool.get('site:' + s.id))).filter(Boolean).map(l => ({ name: l.name, url: l.url }));-- // Legal documents live on the site's own apex, so each domain answers for itself.-- const legalBase = `https://${site.host}`;++ // Legal documents live on the site's own apex, so each domain answers for itself, once it serves them: a site++ // whose /privacy does not answer gets the network-wide documents here instead of three links that 404.++ const legalBase = legalOwn.get(site.host) === false ? 'https://openvibe.network' : `https://${site.host}`;return {updated: new Date(version).toISOString(), site: { id: site.id, name: site.name, host: site.host, status: site.status, state: site.state },// Every open site, most used first, each followed by the products it serves on their own domains (`of`).@@@ -300,6 +301,27 @@ async function createFrameService(db, config, analytics, { privateKey = null, isres.status(204).end();});++ // ── Which sites serve their own legal documents ──────────────────────────────────────────────────────────────++ // A site's footer links to its own /terms, /privacy and /dmca (openvibe-shared/legal, wording by its manifest's++ // legalProfile). On 11-02 ten open sites did not serve them, so their footers' legal links answered 404. Every open++ // site's /privacy is asked once a day (GET, no redirects, 5 s); a site that does not answer 200 gets openvibe.network's++ // documents until it does. Before the first answer a site keeps its own links, as before.++ const legalOwn = new Map(); // host → true (serves them) | false (does not)++ async function probeLegal({ fetchImpl = fetch } = {}) {++ let changed = false;++ await Promise.all(orderedOpen().map(async (s) => {++ let ok;++ try {++ const r = await fetchImpl(`https://${s.host}/privacy`, { redirect: 'manual', signal: AbortSignal.timeout(5000), headers: { 'user-agent': 'OpenVibe.Network frame (legal links)', accept: 'text/html' } });++ ok = r.status === 200;++ if (r.body && typeof r.body.cancel === 'function') r.body.cancel().catch(() => {});++ } catch { return; } // no answer: keep what was known++ if (legalOwn.get(s.host) !== ok) { legalOwn.set(s.host, ok); changed = true; }++ }));++ if (changed) version = Date.now();++ return Object.fromEntries(legalOwn);++ }++let timers = [];async function start() {const rankAge = Date.now() - ((await load('rank') || {}).at || 0), copyAge = Date.now() - ((await load('copy') || {}).at || 0);@@@ -307,7 +329,9 @@ async function createFrameService(db, config, analytics, { privateKey = null, isconst i1 = setInterval(() => refreshRank().catch(() => {}), RANK_MS); i1.unref();const t2 = setTimeout(() => refreshCopy().catch(() => {}), copyAge > COPY_MS ? 60_000 : COPY_MS - copyAge); t2.unref();const i2 = setInterval(() => refreshCopy().catch(() => {}), COPY_MS); i2.unref();-- timers = [t1, i1, t2, i2];++ const t3 = setTimeout(() => probeLegal().catch(() => {}), 45_000); t3.unref();++ const i3 = setInterval(() => probeLegal().catch(() => {}), 24 * 3600 * 1000); i3.unref();++ timers = [t1, i1, t2, i2, t3, i3];}/** Graceful stop: no further rank or copy refreshes. */function stop() { for (const t of timers) { clearTimeout(t); clearInterval(t); } timers = []; }@@@ -317,7 +341,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, isreturn { at: rankedAt, order: orderedOpen().map(s => s.id), everyMs: RANK_MS };}-- return { router, start, stop, refreshRank, refreshCopy, payloadFor, ranking, _state: () => ({ rank, copy }) };++ return { router, start, stop, refreshRank, refreshCopy, probeLegal, payloadFor, ranking, _state: () => ({ rank, copy }) };}module.exports = { createFrameService, BANNED };@@@ -13,6 +13,25 @@ const svc = await createFrameService(db, { services: {} }, null);assert.equal(siteForHost('yt.openvibe.tools').id, 'tools');assert.equal(siteForHost('evil.example'), null);assert.strictEqual(svc.ranking().at, null, 'cold start: no use counted yet (the registry\'s featured list says so)');++++// Legal links (11-02: ten open sites did not serve /privacy, so their footers' Terms, Privacy and DMCA answered 404):++// a site whose /privacy does not answer 200 gets openvibe.network's documents until it does.++{++ assert.equal(svc.payloadFor('openvibe.events').footer.legal.privacy, 'https://openvibe.events/privacy', 'not asked yet: its own, as before');++ const seen = await svc.probeLegal({ fetchImpl: async (url) => ({ status: /openvibe\.(events|report)\//.test(url) ? 404 : 200 }) });++ assert.strictEqual(seen['openvibe.events'], false);++ assert.strictEqual(seen['openvibe.live'], true);++ assert.deepStrictEqual(svc.payloadFor('openvibe.events').footer.legal, { terms: 'https://openvibe.network/terms', privacy: 'https://openvibe.network/privacy', dmca: 'https://openvibe.network/dmca' });++ assert.equal(svc.payloadFor('openvibe.live').footer.legal.privacy, 'https://openvibe.live/privacy', 'a site that serves them keeps its own');++ // A site that gains its pages gets its own links back; a site that does not answer keeps what was known.++ await svc.probeLegal({ fetchImpl: async (url) => { if (/openvibe\.report\//.test(url)) throw new Error('down'); return { status: 200 }; } });++ assert.equal(svc.payloadFor('openvibe.events').footer.legal.privacy, 'https://openvibe.events/privacy');++ assert.equal(svc.payloadFor('openvibe.report').footer.legal.privacy, 'https://openvibe.network/privacy', 'no answer: kept');++ // A redirect is not the page.++ await svc.probeLegal({ fetchImpl: async (url) => ({ status: /openvibe\.events\//.test(url) ? 302 : 200 }) });++ assert.equal(svc.payloadFor('openvibe.events').footer.legal.privacy, 'https://openvibe.network/privacy');++ await svc.probeLegal({ fetchImpl: async () => ({ status: 200 }) });++}let p = svc.payloadFor('openvibe.media');assert.deepEqual(p.nav.filter(n => !n.of).map(n => n.id), ['live', 'tools', 'space', 'community', 'games', 'media', 'network', 'chat', 'services', 'blog', 'wiki', 'news', 'reviews', 'tips', 'vip', 'trade', 'host', 'deals', 'coupons', 'stream', 'media-hub', 'bot', 'codes', 'actor', 'food', 'help', 'work', 'quest', 'rent', 'watch', 'inventory', 'events', 'ai', 'search', 'run', 'play', 'zone', 'domains', 'report', 'design', 'today', 'surf', 'meme', 'vision', 'exchange', 'fun', 'cam', 'bench', 'casino', 'tours', 'shoes', 'connect'], 'cold start follows the base order');// The launcher: every open site in one of the groups, and the products other services serve on their own domains@@@ -121,7 +121,7 @@ const REVIEWED = {'server/domains/catalog.js fetch': [3, 'the tools catalog URL from operator config'],'server/today/live-follows.js fetch': [1, 'Live\'s internal URL from config (OV_LIVE_INTERNAL_URL): its public list of live streams, fixed path, 2.5 s timeout, held 10 s; never a request value'],'server/coins/routes.js fetch': [1, 'Billing internal URL from config (the person\'s Vibes with their own token, 2 s timeout)'],-- 'server/frame/service.js fetch': [2, 'Live and AI internal URLs from config (navbar ranking copy)'],++ 'server/frame/service.js fetch': [3, 'Live and AI internal URLs from config (navbar ranking copy); https://<open site host>/privacy, hosts from the site registry only, no redirects, 5 s (legal links)'],'server/stats/open.js fetch': [1, 'open stats (/stats): fixed first-party loopback URLs only — Live (OV_LIVE_INTERNAL_URL, its public home numbers and service-token analytics totals) and Prometheus (PROMETHEUS_URL, loopback by default) with fixed queries; the request only picks 7, 30 or 90 days'],'server/admin/service-proxies.js fetch': [1, 'the admin panel proxies: Live\'s and Chat\'s internal URLs from config, fixed path prefixes, staff only'],'server/integrations/github.js fetch': [1, 'fixed host api.github.com (asserted below)'],
@@ -160,8 +160,9 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
@@ -160,8 +160,9 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
const open = orderedOpen(); const pool = linkPool();
const open = orderedOpen(); const pool = linkPool();
const ai = copy.sites[site.id];
const ai = copy.sites[site.id];
const discover = (ai ? ai.picks.map(id => pool.get(id)) : open.filter(s => s.id !== site.id).slice(0, 4).map(s => pool.get('site:' + s.id))).filter(Boolean).map(l => ({ name: l.name, url: l.url }));
const discover = (ai ? ai.picks.map(id => pool.get(id)) : open.filter(s => s.id !== site.id).slice(0, 4).map(s => pool.get('site:' + s.id))).filter(Boolean).map(l => ({ name: l.name, url: l.url }));
- // Legal documents live on the site's own apex, so each domain answers for itself.
- const legalBase = `https://${site.host}`;
+ // Legal documents live on the site's own apex, so each domain answers for itself, once it serves them: a site
+ // whose /privacy does not answer gets the network-wide documents here instead of three links that 404.
+ const legalBase = legalOwn.get(site.host) === false ? 'https://openvibe.network' : `https://${site.host}`;
return {
return {
updated: new Date(version).toISOString(), site: { id: site.id, name: site.name, host: site.host, status: site.status, state: site.state },
updated: new Date(version).toISOString(), site: { id: site.id, name: site.name, host: site.host, status: site.status, state: site.state },
// Every open site, most used first, each followed by the products it serves on their own domains (`of`).
// Every open site, most used first, each followed by the products it serves on their own domains (`of`).
@@ -300,6 +301,27 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
@@ -300,6 +301,27 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
res.status(204).end();
res.status(204).end();
});
});
+ // ── Which sites serve their own legal documents ──────────────────────────────────────────────────────────────
+ // A site's footer links to its own /terms, /privacy and /dmca (openvibe-shared/legal, wording by its manifest's
+ // legalProfile). On 11-02 ten open sites did not serve them, so their footers' legal links answered 404. Every open
+ // site's /privacy is asked once a day (GET, no redirects, 5 s); a site that does not answer 200 gets openvibe.network's
+ // documents until it does. Before the first answer a site keeps its own links, as before.
+ const legalOwn = new Map(); // host → true (serves them) | false (does not)
+ async function probeLegal({ fetchImpl = fetch } = {}) {
+ let changed = false;
+ await Promise.all(orderedOpen().map(async (s) => {
+ let ok;
+ try {
+ const r = await fetchImpl(`https://${s.host}/privacy`, { redirect: 'manual', signal: AbortSignal.timeout(5000), headers: { 'user-agent': 'OpenVibe.Network frame (legal links)', accept: 'text/html' } });
+ ok = r.status === 200;
+ if (r.body && typeof r.body.cancel === 'function') r.body.cancel().catch(() => {});
+ } catch { return; } // no answer: keep what was known
+ if (legalOwn.get(s.host) !== ok) { legalOwn.set(s.host, ok); changed = true; }
+ }));
+ if (changed) version = Date.now();
+ return Object.fromEntries(legalOwn);
+ }
+
let timers = [];
let timers = [];
async function start() {
async function start() {
const rankAge = Date.now() - ((await load('rank') || {}).at || 0), copyAge = Date.now() - ((await load('copy') || {}).at || 0);
const rankAge = Date.now() - ((await load('rank') || {}).at || 0), copyAge = Date.now() - ((await load('copy') || {}).at || 0);
@@ -307,7 +329,9 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
@@ -307,7 +329,9 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
const i1 = setInterval(() => refreshRank().catch(() => {}), RANK_MS); i1.unref();
const i1 = setInterval(() => refreshRank().catch(() => {}), RANK_MS); i1.unref();
const t2 = setTimeout(() => refreshCopy().catch(() => {}), copyAge > COPY_MS ? 60_000 : COPY_MS - copyAge); t2.unref();
const t2 = setTimeout(() => refreshCopy().catch(() => {}), copyAge > COPY_MS ? 60_000 : COPY_MS - copyAge); t2.unref();
const i2 = setInterval(() => refreshCopy().catch(() => {}), COPY_MS); i2.unref();
const i2 = setInterval(() => refreshCopy().catch(() => {}), COPY_MS); i2.unref();
- timers = [t1, i1, t2, i2];
+ const t3 = setTimeout(() => probeLegal().catch(() => {}), 45_000); t3.unref();
+ const i3 = setInterval(() => probeLegal().catch(() => {}), 24 * 3600 * 1000); i3.unref();
+ timers = [t1, i1, t2, i2, t3, i3];
}
}
/** Graceful stop: no further rank or copy refreshes. */
/** Graceful stop: no further rank or copy refreshes. */
function stop() { for (const t of timers) { clearTimeout(t); clearInterval(t); } timers = []; }
function stop() { for (const t of timers) { clearTimeout(t); clearInterval(t); } timers = []; }
@@ -317,7 +341,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
@@ -317,7 +341,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
return { at: rankedAt, order: orderedOpen().map(s => s.id), everyMs: RANK_MS };
return { at: rankedAt, order: orderedOpen().map(s => s.id), everyMs: RANK_MS };
}
}
- return { router, start, stop, refreshRank, refreshCopy, payloadFor, ranking, _state: () => ({ rank, copy }) };
+ return { router, start, stop, refreshRank, refreshCopy, probeLegal, payloadFor, ranking, _state: () => ({ rank, copy }) };
}
}
module.exports = { createFrameService, BANNED };
module.exports = { createFrameService, BANNED };
@@ -13,6 +13,25 @@ const svc = await createFrameService(db, { services: {} }, null);
@@ -13,6 +13,25 @@ const svc = await createFrameService(db, { services: {} }, null);
assert.equal(siteForHost('yt.openvibe.tools').id, 'tools');
assert.equal(siteForHost('yt.openvibe.tools').id, 'tools');
assert.equal(siteForHost('evil.example'), null);
assert.equal(siteForHost('evil.example'), null);
assert.strictEqual(svc.ranking().at, null, 'cold start: no use counted yet (the registry\'s featured list says so)');
assert.strictEqual(svc.ranking().at, null, 'cold start: no use counted yet (the registry\'s featured list says so)');
+
+// Legal links (11-02: ten open sites did not serve /privacy, so their footers' Terms, Privacy and DMCA answered 404):
+// a site whose /privacy does not answer 200 gets openvibe.network's documents until it does.
+{
+ assert.equal(svc.payloadFor('openvibe.events').footer.legal.privacy, 'https://openvibe.events/privacy', 'not asked yet: its own, as before');
+ const seen = await svc.probeLegal({ fetchImpl: async (url) => ({ status: /openvibe\.(events|report)\//.test(url) ? 404 : 200 }) });
+ assert.strictEqual(seen['openvibe.events'], false);
+ assert.strictEqual(seen['openvibe.live'], true);
+ assert.deepStrictEqual(svc.payloadFor('openvibe.events').footer.legal, { terms: 'https://openvibe.network/terms', privacy: 'https://openvibe.network/privacy', dmca: 'https://openvibe.network/dmca' });
+ assert.equal(svc.payloadFor('openvibe.live').footer.legal.privacy, 'https://openvibe.live/privacy', 'a site that serves them keeps its own');
+ // A site that gains its pages gets its own links back; a site that does not answer keeps what was known.
+ await svc.probeLegal({ fetchImpl: async (url) => { if (/openvibe\.report\//.test(url)) throw new Error('down'); return { status: 200 }; } });
+ assert.equal(svc.payloadFor('openvibe.events').footer.legal.privacy, 'https://openvibe.events/privacy');
+ assert.equal(svc.payloadFor('openvibe.report').footer.legal.privacy, 'https://openvibe.network/privacy', 'no answer: kept');
+ // A redirect is not the page.
+ await svc.probeLegal({ fetchImpl: async (url) => ({ status: /openvibe\.events\//.test(url) ? 302 : 200 }) });
+ assert.equal(svc.payloadFor('openvibe.events').footer.legal.privacy, 'https://openvibe.network/privacy');
+ await svc.probeLegal({ fetchImpl: async () => ({ status: 200 }) });
+}
let p = svc.payloadFor('openvibe.media');
let p = svc.payloadFor('openvibe.media');
assert.deepEqual(p.nav.filter(n => !n.of).map(n => n.id), ['live', 'tools', 'space', 'community', 'games', 'media', 'network', 'chat', 'services', 'blog', 'wiki', 'news', 'reviews', 'tips', 'vip', 'trade', 'host', 'deals', 'coupons', 'stream', 'media-hub', 'bot', 'codes', 'actor', 'food', 'help', 'work', 'quest', 'rent', 'watch', 'inventory', 'events', 'ai', 'search', 'run', 'play', 'zone', 'domains', 'report', 'design', 'today', 'surf', 'meme', 'vision', 'exchange', 'fun', 'cam', 'bench', 'casino', 'tours', 'shoes', 'connect'], 'cold start follows the base order');
assert.deepEqual(p.nav.filter(n => !n.of).map(n => n.id), ['live', 'tools', 'space', 'community', 'games', 'media', 'network', 'chat', 'services', 'blog', 'wiki', 'news', 'reviews', 'tips', 'vip', 'trade', 'host', 'deals', 'coupons', 'stream', 'media-hub', 'bot', 'codes', 'actor', 'food', 'help', 'work', 'quest', 'rent', 'watch', 'inventory', 'events', 'ai', 'search', 'run', 'play', 'zone', 'domains', 'report', 'design', 'today', 'surf', 'meme', 'vision', 'exchange', 'fun', 'cam', 'bench', 'casino', 'tours', 'shoes', 'connect'], 'cold start follows the base order');
// The launcher: every open site in one of the groups, and the products other services serve on their own domains
// The launcher: every open site in one of the groups, and the products other services serve on their own domains
@@ -121,7 +121,7 @@ const REVIEWED = {
@@ -121,7 +121,7 @@ const REVIEWED = {
'server/domains/catalog.js fetch': [3, 'the tools catalog URL from operator config'],
'server/domains/catalog.js fetch': [3, 'the tools catalog URL from operator config'],
'server/today/live-follows.js fetch': [1, 'Live\'s internal URL from config (OV_LIVE_INTERNAL_URL): its public list of live streams, fixed path, 2.5 s timeout, held 10 s; never a request value'],
'server/today/live-follows.js fetch': [1, 'Live\'s internal URL from config (OV_LIVE_INTERNAL_URL): its public list of live streams, fixed path, 2.5 s timeout, held 10 s; never a request value'],
'server/coins/routes.js fetch': [1, 'Billing internal URL from config (the person\'s Vibes with their own token, 2 s timeout)'],
'server/coins/routes.js fetch': [1, 'Billing internal URL from config (the person\'s Vibes with their own token, 2 s timeout)'],
- 'server/frame/service.js fetch': [2, 'Live and AI internal URLs from config (navbar ranking copy)'],
+ 'server/frame/service.js fetch': [3, 'Live and AI internal URLs from config (navbar ranking copy); https://<open site host>/privacy, hosts from the site registry only, no redirects, 5 s (legal links)'],
'server/stats/open.js fetch': [1, 'open stats (/stats): fixed first-party loopback URLs only — Live (OV_LIVE_INTERNAL_URL, its public home numbers and service-token analytics totals) and Prometheus (PROMETHEUS_URL, loopback by default) with fixed queries; the request only picks 7, 30 or 90 days'],
'server/stats/open.js fetch': [1, 'open stats (/stats): fixed first-party loopback URLs only — Live (OV_LIVE_INTERNAL_URL, its public home numbers and service-token analytics totals) and Prometheus (PROMETHEUS_URL, loopback by default) with fixed queries; the request only picks 7, 30 or 90 days'],
'server/admin/service-proxies.js fetch': [1, 'the admin panel proxies: Live\'s and Chat\'s internal URLs from config, fixed path prefixes, staff only'],
'server/admin/service-proxies.js fetch': [1, 'the admin panel proxies: Live\'s and Chat\'s internal URLs from config, fixed path prefixes, staff only'],
'server/integrations/github.js fetch': [1, 'fixed host api.github.com (asserted below)'],
'server/integrations/github.js fetch': [1, 'fixed host api.github.com (asserted below)'],