openvibers/openvibe.network Commits

Both balances in one request (GET /api/coins/me?with=vibes asks Billing with the person's token), and OpenRestream is its own name (never OpenVibe.OpenRestream) (#373)

OP OpenVibers committed 16 days ago · 0068653eaf · 8 files, +108 −9

Changes

ddiff --git a/public/js/my/tools.js b/public/js/my/tools.js
iindex 7c281f0d..711bb8cf 100644
---- a/public/js/my/tools.js
++++ b/public/js/my/tools.js
@@@ -141,7 +141,7 @@ async function loadModules() {
const title = document.createElement('strong'); title.textContent = m.namespace;
const info = document.createElement('small');
const n = about.get(m.namespace);
-- const keeper = n && n.owner ? 'OpenVibe.' + n.owner.charAt(0).toUpperCase() + n.owner.slice(1) : 'OpenVibe';
++ const keeper = n && n.owner ? (n.owner === 'openre' ? 'OpenRestream' : 'OpenVibe.' + n.owner.charAt(0).toUpperCase() + n.owner.slice(1)) : 'OpenVibe';
info.textContent = `${n && n.description ? n.description + ' ' : ''}Kept by ${keeper}; updated ${m.updated_at ? new Date(String(m.updated_at).replace(' ', 'T') + (String(m.updated_at).includes('Z') ? '' : 'Z')).toLocaleString() : 'recently'}.`;
const code = document.createElement('code'); code.textContent = JSON.stringify(m.data);
main.append(title, info, code);
ddiff --git a/server/coins/routes.js b/server/coins/routes.js
iindex 7a537087..5ad08f75 100644
---- a/server/coins/routes.js
++++ b/server/coins/routes.js
@@@ -9,14 +9,35 @@
const express = require('express');
const wallet = require('./wallet');
++const config = require('../config');
--module.exports = function createCoinsRoutes(db, requireAuth) {
++const VIBES_TIMEOUT_MS = 2000;
++
++module.exports = function createCoinsRoutes(db, requireAuth, opts = {}) {
const router = express.Router();
++ const billingUrl = String(opts.billingUrl || (config.services && config.services.billing && config.services.billing.internalUrl) || '').replace(/\/+$/, '');
++
++ /** The person's Vibes (OpenVibe.Billing's spendable balance), asked with their own token; null when Billing can't say. */
++ async function vibesOf(req) {
++ const auth = req.headers.authorization;
++ if (!billingUrl || !auth || !/^Bearer /i.test(auth)) return null;
++ try {
++ const r = await fetch(`${billingUrl}/api/me/vibes`, { headers: { Authorization: auth }, signal: AbortSignal.timeout(VIBES_TIMEOUT_MS) });
++ if (!r.ok) return null;
++ const v = Number((await r.json()).credit);
++ return Number.isFinite(v) ? v : null;
++ } catch { return null; }
++ }
-- // ── GET /api/coins/me ────────────────────────────────────
-- // → { balance }
++ // ── GET /api/coins/me[?with=vibes] ───────────────────────
++ // → { balance }, or with=vibes { balance, vibes }: the navbar's two balances in one request on every site, since
++ // every site's connect-src allows openvibe.network and none allowed billing.openvibe.network (2026-10-28: Vibes
++ // never showed beside OpenCoins). vibes is null when Billing can't answer within two seconds.
router.get('/me', requireAuth, async (req, res) => {
-- res.json({ balance: await wallet.getBalance(db, req.user.id) });
++ const balance = await wallet.getBalance(db, req.user.id);
++ if (req.query.with !== 'vibes') return res.json({ balance });
++ res.set('Cache-Control', 'private, no-store');
++ res.json({ balance, vibes: await vibesOf(req) });
});
// ── GET /api/coins/me/history?limit=50&offset=0 ──────────
ddiff --git a/server/config.js b/server/config.js
iindex edcb978e..38d471e1 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -125,5 +125,9 @@ module.exports = {
search: {
internalUrl: process.env.OV_SEARCH_INTERNAL_URL || 'http://127.0.0.1:4710',
},
++ // OpenVibe.Billing: the person's Vibes beside their OpenCoins (GET /api/coins/me?with=vibes, server/coins/routes.js).
++ billing: {
++ internalUrl: process.env.OV_BILLING_INTERNAL_URL || 'http://127.0.0.1:4600',
++ },
},
};
ddiff --git a/server/frame/service.js b/server/frame/service.js
iindex 5975464d..addfbafa 100644
---- a/server/frame/service.js
++++ b/server/frame/service.js
@@@ -20,7 +20,7 @@
// ═══════════════════════════════════════════════════════════════
const express = require('express');
const crypto = require('crypto');
--const { SITES, siteForHost, GROUPS, groupOf, PRODUCTS, ICON_ALIAS, MENU_LINE, MENU_NAME } = require('./sites');
++const { SITES, siteForHost, GROUPS, groupOf, PRODUCTS, ICON_ALIAS, MENU_LINE, MENU_NAME, fullName } = require('./sites');
const toolsCatalog = require('../domains/catalog');
const RANK_MS = 30 * 60_000, COPY_MS = 24 * 60 * 60_000;
@@@ -123,7 +123,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
function linkPool() {
const { catalog } = toolsCatalog.peek();
const pool = new Map();
-- for (const s of SITES.filter(x => x.status === 'open')) pool.set('site:' + s.id, { name: 'OpenVibe.' + s.name, url: `https://${s.host}/`, about: s.tagline });
++ for (const s of SITES.filter(x => x.status === 'open')) pool.set('site:' + s.id, { name: fullName(s), url: `https://${s.host}/`, about: s.tagline });
for (const f of catalog.families) if (f.path) pool.set('family:' + f.id, { name: f.name, url: 'https://openvibe.tools' + f.path, about: f.tagline });
for (const t of catalog.tools) pool.set('tool:' + t.id, { name: t.name, url: t.url, about: t.tagline });
return pool;
@@@ -140,7 +140,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
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 };
++ const body = { sites: SITES.filter(s => s.status === 'open').map(s => ({ id: s.id, name: fullName(s), what: s.what, popular: s.id === 'tools' ? popularTools(6).map(t => t.name) : [] })), links };
let j = null;
try { j = await fromAi(body); } catch { j = null; }
if (!j || !Array.isArray(j.sites)) return; // keep the last good copy
ddiff --git a/server/frame/sites.js b/server/frame/sites.js
iindex a746428d..06637072 100644
---- a/server/frame/sites.js
++++ b/server/frame/sites.js
@@@ -29,6 +29,10 @@ const SITES = RAW_SITES.map((s) => {
});
const byHost = new Map(SITES.map(s => [s.host, s]));
++/** A site's full name: its own when it is a brand already (OpenRestream), otherwise OpenVibe.<name> (OpenVibe.Live).
++ * "OpenVibe.OpenRestream" read wrong on the home page's cards (the owner, 2026-10-28). */
++const fullName = (s) => (/^Open[A-Z]/.test(String(s && s.name)) ? s.name : `OpenVibe.${s && s.name}`);
++
// The launcher (openvibe-shared navbar) lists every open site under one of these groups, most used first within
// each. A site missing here lands in the last group, so a new manifest is never left out of the menu.
// Grouped by what a person came to do (the owner, 2026-10-25: "reorganize this whole menu and make it more clean and
@@@ -86,4 +90,4 @@ function siteForHost(hostname) {
return null;
}
--module.exports = { SITES, siteForHost, GROUPS, groupOf, PRODUCTS, ICON_ALIAS, MENU_LINE, MENU_NAME };
++module.exports = { SITES, siteForHost, GROUPS, groupOf, PRODUCTS, ICON_ALIAS, MENU_LINE, MENU_NAME, fullName };
ddiff --git a/test/coins-vibes.test.js b/test/coins-vibes.test.js
nnew file mode 100644
iindex 00000000..43666573
---- /dev/null
++++ b/test/coins-vibes.test.js
@@@ -0,0 +1,56 @@
++'use strict';
++// GET /api/coins/me?with=vibes: OpenCoins and Vibes in one request. Every site's connect-src allows openvibe.network and
++// none allowed billing.openvibe.network, so Vibes never showed beside OpenCoins in the navbar (2026-10-28).
++const assert = require('assert');
++const http = require('http');
++const express = require('express');
++const { getDb } = require('../server/db/database');
++const createCoinsRoutes = require('../server/coins/routes');
++
++(async () => {
++ const asked = [];
++ let mode = 'ok';
++ const billing = http.createServer((req, res) => {
++ asked.push({ url: req.url, auth: req.headers.authorization });
++ if (mode === 'down') { res.statusCode = 503; return res.end('{}'); }
++ if (mode === 'slow') return setTimeout(() => res.end('{"credit":1}'), 2600);
++ res.setHeader('content-type', 'application/json');
++ res.end(JSON.stringify({ credit: 1250, currency: 'vibes' }));
++ });
++ await new Promise((r) => billing.listen(0, '127.0.0.1', r));
++ const requireAuth = (req, res, next) => { req.user = { id: 987654 }; next(); };
++ const app = express();
++ app.use('/api/coins', createCoinsRoutes(getDb(), requireAuth, { billingUrl: `http://127.0.0.1:${billing.address().port}/` }));
++ const server = app.listen(0, '127.0.0.1');
++ await new Promise((r) => server.once('listening', r));
++ const base = `http://127.0.0.1:${server.address().port}/api/coins/me`;
++ const get = async (qs, headers = { Authorization: 'Bearer user-token' }) => { const r = await fetch(`${base}${qs}`, { headers }); return { r, d: await r.json() }; };
++
++ let { r, d } = await get('');
++ assert.deepStrictEqual(Object.keys(d), ['balance'], 'without with=vibes: the balance only, Billing not asked');
++ assert.strictEqual(asked.length, 0);
++
++ ({ r, d } = await get('?with=vibes'));
++ assert.strictEqual(typeof d.balance, 'number');
++ assert.strictEqual(d.vibes, 1250);
++ assert.deepStrictEqual(asked[0], { url: '/api/me/vibes', auth: 'Bearer user-token' }, "Billing is asked with the person's own token");
++ assert.match(r.headers.get('cache-control'), /private, no-store/);
++
++ mode = 'down';
++ ({ d } = await get('?with=vibes'));
++ assert.strictEqual(d.vibes, null, 'Billing down: no Vibes, the coins still answer');
++
++ ({ d } = await get('?with=vibes', {}));
++ assert.strictEqual(d.vibes, null, 'no bearer token (a cookie session): Billing is not asked');
++ assert.strictEqual(asked.length, 2);
++
++ mode = 'slow';
++ const t0 = Date.now();
++ ({ d } = await get('?with=vibes'));
++ assert.strictEqual(d.vibes, null);
++ assert.ok(Date.now() - t0 < 2500, 'a slow Billing is given two seconds');
++
++ server.close(); billing.close();
++ console.log('coins-vibes: both balances in one request; Billing down, slow or no token gives vibes null ok');
++ process.exit(0);
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 381f4182..7dead87d 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -119,6 +119,7 @@ const REVIEWED = {
'server/discord/discord-service.js fetch': [2, 'discord.js channels.fetch: Discord API only'],
'server/discord/discord-service.js discord.js': [1, 'discord.js login: Discord gateway only'],
'server/domains/catalog.js fetch': [3, 'the tools catalog URL from operator config'],
++ '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/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'],
ddiff --git a/test/site-names.test.js b/test/site-names.test.js
nnew file mode 100644
iindex 00000000..d45e9109
---- /dev/null
++++ b/test/site-names.test.js
@@@ -0,0 +1,13 @@
++'use strict';
++// A site's full name (server/frame/sites.js fullName): OpenRestream is a brand of its own, never "OpenVibe.OpenRestream"
++// (the owner's screenshot of the home page's cards, 2026-10-28); the others are OpenVibe.<name>.
++const assert = require('assert');
++const { SITES, fullName } = require('../server/frame/sites');
++
++assert.strictEqual(fullName({ name: 'OpenRestream' }), 'OpenRestream');
++assert.strictEqual(fullName({ name: 'Live' }), 'OpenVibe.Live');
++assert.strictEqual(fullName({ name: 'VIP' }), 'OpenVibe.VIP');
++const names = SITES.map(fullName);
++assert.ok(names.includes('OpenRestream'), 'the catalog has OpenRestream');
++assert.ok(!names.some((n) => /^OpenVibe\.Open/.test(n)), `no doubled brand: ${names.filter((n) => /^OpenVibe\.Open/.test(n))}`);
++console.log('site-names: OpenRestream is its own name, the rest OpenVibe.<name> ok');