openvibers/openvibe.network Commits

Footer copy comes from OpenVibe.AI only: drop the fallback to Live's /internal/ai/site-copy. When AI is unreachable or answers badly the last good copy stays (chrome_cache, across restarts), or the hand-written copy when there has never been one; test/chrome.test.js covers AI down, AI ok, AI down again, restart, and that Live is never called

OP OpenVibers committed 23 Sept 2026 · 31b234a6b1 · 3 files, +59 −17


Changes

ddiff --git a/docs/shared-contracts.md b/docs/shared-contracts.md
iindex e65f64d3..15b4e258 100644
---- a/docs/shared-contracts.md
++++ b/docs/shared-contracts.md
@@@ -132,10 +132,11 @@ Public, CORS `*`, `max-age=600`, ETag. What the navbar and footer of every site
- Ranking = each service's 7-day page views and visitors plus signed-in cross-site history,
refreshed every 30 minutes and stored in `chrome_cache`, so an unreachable service keeps its last
score. The Network itself counts page views only, at half weight.
--- Copy = once a day the AI configured in admin (called through Live's internal
-- `POST /internal/ai/site-copy`, budget-gated) may write a blurb per site and pick links **by id**
-- from a list we supply. Ids resolve to our URLs server-side; text is length-capped and screened
-- (no markup, no URLs, no cost claims). Anything that fails keeps the hand-written copy.
++- Copy = once a day OpenVibe.AI's `network.site_copy` workflow (self-signed `svc:network` service
++ token, `ns network.*`) may write a blurb per site and pick links **by id** from a list we supply.
++ Ids resolve to our URLs server-side; text is length-capped and screened (no markup, no URLs, no
++ cost claims). There is no fallback source: when AI fails, the last good copy (kept in
++ `chrome_cache`) stays, or the hand-written copy if there has never been one.
- Clients: `navbar.js` and `footer.js` share `window.OpenVibeChrome` — a per-host localStorage cache
(30 min, refreshed in the background). Navbar options: `networkLinks` (`false` | count, default 4).
ddiff --git a/server/chrome/service.js b/server/chrome/service.js
iindex 5467b0c2..abc481d8 100644
---- a/server/chrome/service.js
++++ b/server/chrome/service.js
@@@ -10,9 +10,10 @@
// Ranking: page views and visitors from each service's own analytics (last 7 days) plus signed-in
// cross-site history, refreshed every 30 minutes and kept in the database so a restart or an
// unreachable service never reorders the network at random. Only totals cross the wire.
--// Copy: once a day the configured AI (through OpenVibe.Live's internal API) may write a blurb per
--// site and pick "discover" links BY ID from a list we supply; ids resolve to our own URLs here, text
--// is stripped, length-capped and screened. No AI, or a bad answer → the hand-written copy stays.
++// Copy: once a day OpenVibe.AI (network.site_copy) may write a blurb per site and pick "discover"
++// links BY ID from a list we supply; ids resolve to our own URLs here, text is stripped, length-capped
++// and screened. AI unreachable, failing or answering badly → the last good copy stays (kept in
++// chrome_cache across restarts), or the hand-written copy when there has never been one.
// ═══════════════════════════════════════════════════════════════
const express = require('express');
const crypto = require('crypto');
@@@ -26,7 +27,7 @@ const BASE_WEIGHT = { live: 6, tools: 5, community: 4, games: 3, media: 2, netwo
/**
* Footer copy comes from OpenVibe.AI's network.site_copy workflow (roadmap Wave 13, ADR-015), called with a
* self-signed Network service token (sub svc:network, aud openvibe.ai, cap ai.run.create, ns network.*).
-- * Live's /internal/ai/site-copy stays only as a fallback while AI is unreachable.
++ * There is no other source: Live's /internal/ai/site-copy is no longer called.
*/
function aiSiteCopy({ privateKey, issuer, aiUrl }) {
const { serviceAuth } = require('openvibe-contracts');
@@@ -126,20 +127,14 @@ function createChromeService(db, config, analytics, { privateKey = null, issuer
}
async function refreshCopy() {
-- if (!fromAi && (!config.internalKey || config.internalKey === 'change-me-in-production')) return;
++ if (!fromAi) return;
const pool = linkPool();
const popular = new Set(popularTools(12).map(t => t.url));
const links = [...pool.entries()].filter(([id, l]) => !id.startsWith('tool:') || popular.has(l.url)).map(([id, l]) => ({ id, name: l.name, about: l.about }));
const body = { sites: SITES.filter(s => s.status === 'open').map(s => ({ id: s.id, name: 'OpenVibe.' + s.name, what: s.what, popular: s.id === 'tools' ? popularTools(6).map(t => t.name) : [] })), links };
let j = null;
-- if (fromAi) { try { j = await fromAi(body); } catch { j = null; } }
-- if (!j && config.internalKey && config.internalKey !== 'change-me-in-production') {
-- try {
-- const r = await fetch(`${svcUrl('live', 'http://127.0.0.1:3000')}/internal/ai/site-copy`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Internal-Key': config.internalKey }, body: JSON.stringify(body), signal: AbortSignal.timeout(90_000) });
-- j = r.ok ? await r.json() : null;
-- } catch { j = null; }
-- }
-- if (!j || !Array.isArray(j.sites)) return;
++ try { j = await fromAi(body); } catch { j = null; }
++ if (!j || !Array.isArray(j.sites)) return; // keep the last good copy
const next = {};
for (const row of j.sites) {
const site = SITES.find(s => s.id === row.id); if (!site) continue;
ddiff --git a/test/chrome.test.js b/test/chrome.test.js
iindex 93a4459f..7283460d 100644
---- a/test/chrome.test.js
++++ b/test/chrome.test.js
@@@ -1,5 +1,7 @@
'use strict';
const assert = require('assert');
++const crypto = require('crypto');
++const http = require('http');
const Database = require('better-sqlite3');
const { createChromeService, BANNED } = require('../server/chrome/service');
const { siteForHost } = require('../server/chrome/sites');
@@@ -29,5 +31,49 @@ for (let i = 0; i < 6; i++) ins.run(1, 'tools', 'dns');
assert.ok(p.nav.findIndex(n => n.id === 'network') > 0, 'the Network is never first on shared-service traffic alone');
assert.ok(BANNED.test('Totally free tools') && BANNED.test('see https://x.y') && BANNED.test('<b>hi</b>') && !BANNED.test('Go live from a browser in seconds.'), 'AI copy screen');
assert.equal(p.footer.ai, false);
++
++ // Footer copy comes from OpenVibe.AI only. Live's /internal/ai/site-copy is never called, and an
++ // AI failure keeps the last good copy (or the hand-written one when there has never been one).
++ let liveHits = 0; let aiMode = 'ok'; let aiAuth = null;
++ const live = http.createServer((req, res) => { liveHits++; res.statusCode = 200; res.setHeader('content-type', 'application/json'); res.end(JSON.stringify({ ok: true, sites: [{ id: 'games', blurb: 'Copy from Live that must never be used here.', picks: [] }] })); });
++ const ai = http.createServer((req, res) => {
++ aiAuth = req.headers.authorization;
++ if (aiMode === 'down') { res.statusCode = 503; return res.end('{}'); }
++ res.setHeader('content-type', 'application/json');
++ res.end(JSON.stringify({ run: { status: 'succeeded', model: { key: 'test-model' }, output: { sites: [{ id: 'games', blurb: 'Play Scraplandia and the shared pixel canvas in a tab.', picks: ['site:live'] }] } } }));
++ });
++ await Promise.all([live, ai].map(s => new Promise(r => s.listen(0, '127.0.0.1', r))));
++ const { privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });
++ const cfg = { internalKey: 'a-real-internal-key', services: { live: { internalUrl: `http://127.0.0.1:${live.address().port}` } } };
++ const aiUrl = `http://127.0.0.1:${ai.address().port}`;
++ const db2 = new Database(':memory:');
++ const copySvc = (d) => createChromeService(d, cfg, null, { privateKey, issuer: 'https://openvibe.network', aiUrl });
++
++ let s2 = copySvc(db2);
++ aiMode = 'down';
++ await s2.refreshCopy();
++ p = s2.payloadFor('openvibe.games');
++ assert.equal(p.footer.ai, false, 'AI down and no earlier copy: the hand-written blurb');
++ assert.equal(p.footer.blurb, siteForHost('openvibe.games').what);
++ assert.equal(liveHits, 0, 'no fallback to Live when AI fails');
++
++ aiMode = 'ok';
++ await s2.refreshCopy();
++ assert.match(aiAuth, /^Bearer ey/, 'AI is called with the self-signed service token');
++ p = s2.payloadFor('openvibe.games');
++ assert.equal(p.footer.ai, true);
++ assert.equal(p.footer.blurb, 'Play Scraplandia and the shared pixel canvas in a tab.');
++
++ aiMode = 'down';
++ await s2.refreshCopy();
++ p = s2.payloadFor('openvibe.games');
++ assert.equal(p.footer.blurb, 'Play Scraplandia and the shared pixel canvas in a tab.', 'AI failure keeps the last good copy');
++ s2 = copySvc(db2);
++ assert.equal(s2.payloadFor('openvibe.games').footer.blurb, 'Play Scraplandia and the shared pixel canvas in a tab.', 'and it survives a restart');
++
++ // Without a signing key there is no AI client, and still no Live call.
++ await createChromeService(new Database(':memory:'), cfg, null).refreshCopy();
++ assert.equal(liveHits, 0, 'Live is never asked for copy');
++ live.close(); ai.close();
console.log('chrome service: all checks passed');
})().catch(e => { console.error(e); process.exit(1); });