openvibers/openvibe.live Commits

GET /internal/followers for Network's live.stream.started consumer (service token live.follower.read, loopback, paged); chat shows the VIP member badge from OpenVibe.Chat (on the message, in history metadata, or a later chat_vip_badge frame), as text, replacing Live's own subscriber star

OP OpenVibers committed 23 Sept 2026 · 456eccd942 · 5 files, +156 −0


Changes

ddiff --git a/public/css/style.css b/public/css/style.css
iindex 62fb2ff9..62e97fcc 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -3679,6 +3679,8 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }
.chat-badge-cmod { color: #6ea8ff; background: color-mix(in srgb, #6ea8ff 18%, transparent); border-color: color-mix(in srgb, #6ea8ff 40%, transparent); }
.chat-badge-streamer { color: var(--live-red); background: color-mix(in srgb, var(--live-red) 18%, transparent); border-color: color-mix(in srgb, var(--live-red) 40%, transparent); }
.chat-badge-sub { color: var(--accent-light, var(--accent)); background: color-mix(in srgb, var(--accent) 18%, transparent); border-color: color-mix(in srgb, var(--accent) 40%, transparent); }
++/* VIP member badge (OpenVibe.VIP): a short text label, so it is a pill rather than an icon square. */
++.chat-vip-badge { width: auto; padding: 0 5px; font-size: 0.62rem; font-weight: 700; letter-spacing: 0.02em; white-space: nowrap; color: var(--accent-light, var(--accent)); background: color-mix(in srgb, var(--accent) 22%, transparent); border-color: color-mix(in srgb, var(--accent) 50%, transparent); }
/* Relay platform badge — colour driven by --relay-color (per platform). */
.chat-relay-badge { color: var(--relay-color, #888); background: color-mix(in srgb, var(--relay-color, #888) 20%, transparent); border-color: color-mix(in srgb, var(--relay-color, #888) 45%, transparent); }
.chat-relay-badge i { font-size: 0.6rem; }
ddiff --git a/public/js/chat.js b/public/js/chat.js
iindex 2ba7247a..1fcedb32 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -2965,6 +2965,39 @@ function _updateTranslateHint(lang) {
}
/* ── Message handling ─────────────────────────────────────────── */
++/* ── VIP member badge ──────────────────────────────────────────
++ OpenVibe.Chat marks a member's messages with the creator's VIP badge ({ creator, perk, name,
++ badge, label }): on the message itself, as metadata.vip_badge on history, or a beat later as
++ { type:'chat_vip_badge', id, vip_badge }. It is plain data, rendered as text only. */
++function _msgVipBadge(msg) {
++ if (!msg) return null;
++ if (msg.vip_badge && typeof msg.vip_badge === 'object') return msg.vip_badge;
++ if (msg.metadata) {
++ try {
++ const m = typeof msg.metadata === 'string' ? JSON.parse(msg.metadata) : msg.metadata;
++ if (m && m.vip_badge && typeof m.vip_badge === 'object') return m.vip_badge;
++ } catch { /* not JSON */ }
++ }
++ return null;
++}
++function _vipBadgeEl(b) {
++ const el = document.createElement('span');
++ el.className = 'chat-badge chat-vip-badge';
++ el.textContent = String(b.label || b.name || 'Member').slice(0, 24);
++ el.dataset.tip = `${String(b.name || 'VIP member').slice(0, 60)}`;
++ return el;
++}
++function _placeVipBadge(el, b) {
++ if (!el || !b || el.querySelector('.chat-vip-badge')) return;
++ el.querySelectorAll('.chat-badge-sub').forEach((s) => s.remove()); // VIP supersedes Live's own subscriber star
++ const user = el.querySelector('.chat-user');
++ if (user) user.parentNode.insertBefore(_vipBadgeEl(b), user);
++}
++function _applyChatVipBadge(evt) {
++ if (!evt || evt.id == null || !evt.vip_badge) return;
++ document.querySelectorAll(`.chat-msg[data-msg-id="${CSS.escape(String(evt.id))}"]`).forEach((el) => _placeVipBadge(el, evt.vip_badge));
++}
++
function handleChatMessage(msg) {
switch (msg.type) {
case 'vibe-coding':
@@@ -2973,6 +3006,9 @@ function handleChatMessage(msg) {
case 'chat_translation':
_applyChatTranslation(msg);
break;
++ case 'chat_vip_badge':
++ _applyChatVipBadge(msg);
++ break;
case 'chat':
addChatMessage(msg);
// Self-mode TTS: speak every incoming chat message via browser synthesis
@@@ -3588,6 +3624,7 @@ function buildChatMessageEl(msg, opts = {}) {
// metadata.translation on history). See server/i18n/translate.js for the direction rules.
const _tr = _msgTranslation(msg);
if (_tr) el.appendChild(_translationEl(_tr));
++ if (chatSettings.showBadges) _placeVipBadge(el, _msgVipBadge(msg));
// Reply / translate actions. No per-message listeners: one delegated handler (bottom of this
// file) reads what it needs from these expandos, so 500 rows cost 0 listeners, not 1,000.
ddiff --git a/server/index.js b/server/index.js
iindex 053b3236..e867f588 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -576,6 +576,9 @@ app.use('/internal/chat-context', chatLiveContext.contextRouter);
app.use('/internal/chat-effects', chatLiveContext.effectsRouter);
// OpenVibe.Tips announces settled tips in the creator's chat here (service token, live.tips_delivery.write).
app.use('/internal/tips', require('./tips/delivery-routes'));
++// OpenVibe.Network sends go-live notifications from live.stream.started and asks here who follows
++// the channel (service token, live.follower.read).
++app.use('/internal/followers', require('./streaming/followers-internal'));
app.use('/internal', require('./internal/routes'));
ddiff --git a/server/streaming/followers-internal.js b/server/streaming/followers-internal.js
nnew file mode 100644
iindex 00000000..f0fd421f
---- /dev/null
++++ b/server/streaming/followers-internal.js
@@@ -0,0 +1,36 @@
++'use strict';
++// GET /internal/followers?stream_id=<id>&limit=<n>[&after=<cursor>] — the followers of the channel that
++// streams <id>, for OpenVibe.Network's live.stream.started consumer (go-live notifications are
++// Network's job). Service token only (aud openvibe.live, cap live.follower.read), loopback only.
++// A follower is named by their Network subject when Live knows it, else their Network user id.
++const express = require('express');
++const db = require('../db/database');
++const { guard } = require('../net/service-guard');
++const { http } = require('openvibe-contracts');
++
++const router = express.Router();
++router.get('/', guard('live.follower.read'), (req, res) => {
++ const streamId = Number(req.query.stream_id);
++ if (!Number.isSafeInteger(streamId) || streamId <= 0) return http.sendProblem(res, 400, 'live.bad_request', { detail: 'stream_id is required' });
++ const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 1000, 1), 5000);
++ const after = Math.max(parseInt(req.query.after, 10) || 0, 0);
++ const d = db.getDb();
++ const stream = d.prepare('SELECT user_id FROM streams WHERE id = ?').get(streamId);
++ if (!stream) return http.sendProblem(res, 404, 'live.unknown_stream', { detail: `no stream ${streamId}` });
++ const channel = d.prepare("SELECT subject_id FROM linked_accounts WHERE service = 'network' AND user_id = ?").get(stream.user_id);
++ const rows = d.prepare(`SELECT f.id, la.subject_id, la.service_user_id FROM follows f
++ LEFT JOIN linked_accounts la ON la.service = 'network' AND la.user_id = f.follower_id
++ WHERE f.streamer_id = ? AND f.id > ? ORDER BY f.id LIMIT ?`).all(stream.user_id, after, limit);
++ const followers = rows.map(r => ({
++ subject: r.subject_id || null,
++ network_user_id: /^\d+$/.test(String(r.service_user_id || '')) ? Number(r.service_user_id) : null,
++ }));
++ res.set('Cache-Control', 'no-store').json({
++ stream_id: streamId,
++ channel: { subject: (channel && channel.subject_id) || null },
++ followers,
++ next: rows.length === limit ? rows[rows.length - 1].id : null,
++ });
++});
++
++module.exports = router;
ddiff --git a/test/followers-internal.test.js b/test/followers-internal.test.js
nnew file mode 100644
iindex 00000000..21955c94
---- /dev/null
++++ b/test/followers-internal.test.js
@@@ -0,0 +1,78 @@
++'use strict';
++
++// GET /internal/followers — OpenVibe.Network asks who follows the channel behind a stream when it
++// consumes live.stream.started. Service token with live.follower.read, loopback only, paged.
++
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++const crypto = require('crypto');
++const express = require('express');
++const { serviceAuth } = require('openvibe-contracts');
++
++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-followers-'));
++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
++fs.writeFileSync(path.join(tmp, 'network.pem'), keys.publicKey);
++process.env.DB_PATH = path.join(tmp, 'live.db');
++process.env.OV_NETWORK_PUBLIC_KEY = path.join(tmp, 'network.pem');
++process.env.OV_NETWORK_URL = 'https://openvibe.network';
++console.log = () => {};
++console.warn = () => {};
++
++const ISS = 'https://openvibe.network';
++const now = () => Math.floor(Date.now() / 1000);
++let jti = 0;
++const token = (cap) => serviceAuth.signServiceToken({ iss: ISS, sub: 'svc:network', actor_type: 'service', aud: ['openvibe.live'], cap, iat: now(), exp: now() + 300, jti: `tok_net_${++jti}` }, keys.privateKey);
++
++(async () => {
++ const db = require('../server/db/database');
++ db.initDb();
++ const d = db.getDb();
++ for (const [id, name] of [[601, 'streamer'], [602, 'fan_a'], [603, 'fan_b'], [604, 'fan_c']]) {
++ d.prepare("INSERT INTO users (id, username, display_name, password_hash) VALUES (?, ?, ?, 'x')").run(id, name, name);
++ }
++ d.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (601, 'network', '70', 'usr_01J0000000000000000000000S')").run();
++ d.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (602, 'network', '71', 'usr_01J0000000000000000000000A')").run();
++ d.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id) VALUES (603, 'network', '72')").run();
++ for (const f of [602, 603, 604]) d.prepare('INSERT INTO follows (follower_id, streamer_id) VALUES (?, 601)').run(f);
++ const streamId = d.prepare("INSERT INTO streams (user_id, title, is_live) VALUES (601, 'hello', 1)").run().lastInsertRowid;
++
++ const app = express();
++ app.use('/internal/followers', require('../server/streaming/followers-internal'));
++ const server = await new Promise((r) => { const s = http.createServer(app); s.listen(0, '127.0.0.1', () => r(s)); });
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const get = (q, { cap = ['live.follower.read'], headers = {} } = {}) => fetch(`${base}/internal/followers?${q}`, {
++ headers: { Authorization: `Bearer ${token(cap)}`, ...headers },
++ }).then(async (r) => ({ status: r.status, json: await r.json().catch(() => null) }));
++
++ assert.strictEqual((await get(`stream_id=${streamId}`, { cap: ['live.chat_effects.write'] })).status, 403, 'needs live.follower.read');
++ assert.strictEqual((await get(`stream_id=${streamId}`, { headers: { 'X-Forwarded-For': '1.2.3.4' } })).status, 403, 'loopback only');
++ assert.strictEqual((await fetch(`${base}/internal/followers?stream_id=${streamId}`)).status, 401, 'no token');
++ assert.strictEqual((await get('stream_id=abc')).status, 400);
++ assert.strictEqual((await get('stream_id=999999')).status, 404);
++
++ const all = await get(`stream_id=${streamId}`);
++ assert.strictEqual(all.status, 200);
++ assert.strictEqual(all.json.channel.subject, 'usr_01J0000000000000000000000S');
++ assert.deepStrictEqual(all.json.followers, [
++ { subject: 'usr_01J0000000000000000000000A', network_user_id: 71 },
++ { subject: null, network_user_id: 72 },
++ { subject: null, network_user_id: null },
++ ]);
++ assert.strictEqual(all.json.next, null);
++
++ // Paging: two per page, then the rest.
++ const p1 = await get(`stream_id=${streamId}&limit=2`);
++ assert.strictEqual(p1.json.followers.length, 2);
++ assert.ok(p1.json.next);
++ const p2 = await get(`stream_id=${streamId}&limit=2&after=${p1.json.next}`);
++ assert.strictEqual(p2.json.followers.length, 1);
++ assert.strictEqual(p2.json.next, null);
++
++ server.close();
++ fs.rmSync(tmp, { recursive: true, force: true });
++ process.stdout.write('followers-internal: all passed\n');
++ process.exit(0);
++})().catch((e) => { process.stderr.write(`${e.stack}\n`); process.exit(1); });