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
Changes
@@@ -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; }@@@ -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.@@@ -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'));nnew file mode 100644@@@ -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;nnew file mode 100644@@@ -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); });
@@ -3679,6 +3679,8 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }
@@ -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-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-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); }
.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). */
/* 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 { 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; }
.chat-relay-badge i { font-size: 0.6rem; }
@@ -2965,6 +2965,39 @@ function _updateTranslateHint(lang) {
@@ -2965,6 +2965,39 @@ function _updateTranslateHint(lang) {
}
}
/* ── Message handling ─────────────────────────────────────────── */
/* ── 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) {
function handleChatMessage(msg) {
switch (msg.type) {
switch (msg.type) {
case 'vibe-coding':
case 'vibe-coding':
@@ -2973,6 +3006,9 @@ function handleChatMessage(msg) {
@@ -2973,6 +3006,9 @@ function handleChatMessage(msg) {
case 'chat_translation':
case 'chat_translation':
_applyChatTranslation(msg);
_applyChatTranslation(msg);
break;
break;
+ case 'chat_vip_badge':
+ _applyChatVipBadge(msg);
+ break;
case 'chat':
case 'chat':
addChatMessage(msg);
addChatMessage(msg);
// Self-mode TTS: speak every incoming chat message via browser synthesis
// Self-mode TTS: speak every incoming chat message via browser synthesis
@@ -3588,6 +3624,7 @@ function buildChatMessageEl(msg, opts = {}) {
@@ -3588,6 +3624,7 @@ function buildChatMessageEl(msg, opts = {}) {
// metadata.translation on history). See server/i18n/translate.js for the direction rules.
// metadata.translation on history). See server/i18n/translate.js for the direction rules.
const _tr = _msgTranslation(msg);
const _tr = _msgTranslation(msg);
if (_tr) el.appendChild(_translationEl(_tr));
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
// 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.
// file) reads what it needs from these expandos, so 500 rows cost 0 listeners, not 1,000.
@@ -576,6 +576,9 @@ app.use('/internal/chat-context', chatLiveContext.contextRouter);
@@ -576,6 +576,9 @@ app.use('/internal/chat-context', chatLiveContext.contextRouter);
app.use('/internal/chat-effects', chatLiveContext.effectsRouter);
app.use('/internal/chat-effects', chatLiveContext.effectsRouter);
// OpenVibe.Tips announces settled tips in the creator's chat here (service token, live.tips_delivery.write).
// 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'));
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'));
app.use('/internal', require('./internal/routes'));
new file mode 100644
new file mode 100644
@@ -0,0 +1,36 @@
@@ -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;
new file mode 100644
new file mode 100644
@@ -0,0 +1,78 @@
@@ -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); });