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)
Changes
@@@ -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);@@@ -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 ──────────@@@ -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',++ },},};@@@ -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, isfunction 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, isconst 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@@@ -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 };nnew file mode 100644@@@ -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); });@@@ -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'],nnew file mode 100644@@@ -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');
@@ -141,7 +141,7 @@ async function loadModules() {
@@ -141,7 +141,7 @@ async function loadModules() {
const title = document.createElement('strong'); title.textContent = m.namespace;
const title = document.createElement('strong'); title.textContent = m.namespace;
const info = document.createElement('small');
const info = document.createElement('small');
const n = about.get(m.namespace);
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'}.`;
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);
const code = document.createElement('code'); code.textContent = JSON.stringify(m.data);
main.append(title, info, code);
main.append(title, info, code);
@@ -9,14 +9,35 @@
@@ -9,14 +9,35 @@
const express = require('express');
const express = require('express');
const wallet = require('./wallet');
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 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) => {
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 ──────────
// ── GET /api/coins/me/history?limit=50&offset=0 ──────────
@@ -125,5 +125,9 @@ module.exports = {
@@ -125,5 +125,9 @@ module.exports = {
search: {
search: {
internalUrl: process.env.OV_SEARCH_INTERNAL_URL || 'http://127.0.0.1:4710',
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',
+ },
},
},
};
};
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
const express = require('express');
const express = require('express');
const crypto = require('crypto');
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 toolsCatalog = require('../domains/catalog');
const RANK_MS = 30 * 60_000, COPY_MS = 24 * 60 * 60_000;
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
@@ -123,7 +123,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
function linkPool() {
function linkPool() {
const { catalog } = toolsCatalog.peek();
const { catalog } = toolsCatalog.peek();
const pool = new Map();
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 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 });
for (const t of catalog.tools) pool.set('tool:' + t.id, { name: t.name, url: t.url, about: t.tagline });
return pool;
return pool;
@@ -140,7 +140,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
@@ -140,7 +140,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
const pool = linkPool();
const pool = linkPool();
const popular = new Set(popularTools(12).map(t => t.url));
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 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;
let j = null;
try { j = await fromAi(body); } catch { j = null; }
try { j = await fromAi(body); } catch { j = null; }
if (!j || !Array.isArray(j.sites)) return; // keep the last good copy
if (!j || !Array.isArray(j.sites)) return; // keep the last good copy
@@ -29,6 +29,10 @@ const SITES = RAW_SITES.map((s) => {
@@ -29,6 +29,10 @@ const SITES = RAW_SITES.map((s) => {
});
});
const byHost = new Map(SITES.map(s => [s.host, 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
// 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.
// 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
// 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) {
@@ -86,4 +90,4 @@ function siteForHost(hostname) {
return null;
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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,56 @@
@@ -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); });
@@ -119,6 +119,7 @@ const REVIEWED = {
@@ -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 fetch': [2, 'discord.js channels.fetch: Discord API only'],
'server/discord/discord-service.js discord.js': [1, 'discord.js login: Discord gateway 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/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/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/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'],
new file mode 100644
new file mode 100644
@@ -0,0 +1,13 @@
@@ -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');