openvibers/openvibe.live Commits
User modules (Contracts 0.41.0, WS-B task 9): Live writes live.profile and live.stats to Network (server/auth/module-summaries.js: a 5-minute scan of ended streams and new followers, a daily refresh of the 30-day window, written only when changed); the chat panel's text-to-speech and sound settings follow the person through chat.tts_defaults (/api/chat/tts-settings) like the display ones
Changes
@@@ -25,7 +25,7 @@"multer": "^1.4.5-lts.1","node-media-server": "^2.7.1","onnxruntime-node": "^1.20.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.39.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.41.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.7.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0","sanitize-html": "^2.17.7",@@@ -2814,9 +2814,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.39.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.39.0",-- "integrity": "sha512-ek3tryU3pPwmFSg621gzr4ss1OE/gpufBnxk9XFDLSfUe2RuZpYVZCJNdQ0F/9GfA83Xop+xJW8+IjRyjFg6Yg==",++ "version": "0.41.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.41.0",++ "integrity": "sha512-uT+Prd6NOO7iT2zn2cstxpPa6qmybc/O5KH0AQ8R5alzN/+WyUufd+h/yE0YxAw1+H/mJ4I2Q4xT+Go+CiPW2w==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -43,7 +43,7 @@"multer": "^1.4.5-lts.1","node-media-server": "^2.7.1","onnxruntime-node": "^1.20.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.39.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.41.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.7.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0","sanitize-html": "^2.17.7",@@@ -277,9 +277,9 @@ function saveChatSettings() {_debounceSyncSettingsToServer();}--// Portable chat preferences live in OpenVibe.Network (user module chat.preferences), read and written--// through OpenVibe.Chat at /api/chat/preferences. Only these four settings have fields there (schema v1);--// the rest of chatSettings stays in Live's /api/auth/preferences. A patch value of null removes the++// Portable chat preferences live in OpenVibe.Network (user modules chat.preferences and chat.tts_defaults),++// read and written through OpenVibe.Chat at /api/chat/preferences and /api/chat/tts-settings. Only the++// settings mapped below have fields there; the rest of chatSettings stays in Live's /api/auth/preferences. A patch value of null removes the// field, i.e. "Live's default"; Chat writes nothing when nothing changed.const CHAT_PREFS_FONT_SCALE = { small: 0.88, large: 1.18 };function _chatPrefsFromSettings(s) {@@@ -300,26 +300,65 @@ function _chatSettingsFromPrefs(p) {if (typeof p.font_scale === 'number') out.fontSize = p.font_scale < 0.95 ? 'small' : p.font_scale > 1.05 ? 'large' : 'default';return out;}++// Text-to-speech and sounds: the user module chat.tts_defaults (v2), through /api/chat/tts-settings.++// A field at Live's default is sent as null (removed), so the record only holds what the person changed.++const TTS_MODULE_SOURCES = { native: 'ttsSrcNative', robotstreamer: 'ttsSrcRS', kick: 'ttsSrcKick', youtube: 'ttsSrcYoutube', twitch: 'ttsSrcTwitch' };++function _ttsModuleFromSettings(s) {++ const sources = {};++ for (const [k, key] of Object.entries(TTS_MODULE_SOURCES)) if (s[key] === false) sources[k] = false;++ const pct = (v) => (Number.isInteger(v) && v >= 0 && v <= 100 && v !== 80 ? v : null);++ return {++ send: s.ttsEnabled === false ? false : null,++ send_while_live: s.streamingTtsEnabled === false ? false : null,++ volume: pct(s.ttsVolume),++ sounds: s.soundsEnabled === false ? false : null,++ sound_volume: pct(s.soundVolume),++ sources: Object.keys(sources).length ? sources : null,++ };++}++function _settingsFromTtsModule(t) {++ const out = {++ ttsEnabled: t.send !== false,++ streamingTtsEnabled: t.send_while_live !== false,++ ttsVolume: Number.isInteger(t.volume) ? t.volume : 80,++ soundsEnabled: t.sounds !== false,++ soundVolume: Number.isInteger(t.sound_volume) ? t.sound_volume : 80,++ };++ const src = t.sources && typeof t.sources === 'object' ? t.sources : {};++ for (const [k, key] of Object.entries(TTS_MODULE_SOURCES)) out[key] = src[k] !== false;++ return out;++}++const CHAT_MODULES = [++ { url: '/api/chat/preferences', key: 'preferences', from: _chatPrefsFromSettings, to: _chatSettingsFromPrefs },++ { url: '/api/chat/tts-settings', key: 'settings', from: _ttsModuleFromSettings, to: _settingsFromTtsModule },++];function _pushChatPrefs(token) {-- return fetch('/api/chat/preferences', {++ return Promise.all(CHAT_MODULES.map((m) => fetch(m.url, {method: 'PUT',headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },-- body: JSON.stringify({ preferences: _chatPrefsFromSettings(chatSettings) }),-- }).catch(() => { /* Chat or Network unavailable: Live's copy still has them */ });++ body: JSON.stringify({ [m.key]: m.from(chatSettings) }),++ }).catch(() => { /* Chat or Network unavailable: Live's copy still has them */ })));}--/** After Live's copy: the Network record wins for its fields; none yet → seed it from this browser. */++/** After Live's copy: each Network record wins for its fields; none yet → seed it from this browser. */function _syncChatPrefsFromChat(token) {-- fetch('/api/chat/preferences', { headers: { 'Authorization': `Bearer ${token}` } })-- .then(r => r.ok ? r.json() : null)-- .then(data => {-- if (!data || !data.preferences || typeof data.preferences !== 'object') return;-- if (!data.revision) { _pushChatPrefs(token); return; }-- chatSettings = { ...chatSettings, ..._chatSettingsFromPrefs(data.preferences) };-- try { localStorage.setItem(CHAT_SETTINGS_KEY, JSON.stringify(chatSettings)); } catch { }-- applyChatSettings();-- syncSettingsPanelUI();-- })-- .catch(() => { /* offline — use local */ });++ for (const m of CHAT_MODULES) {++ fetch(m.url, { headers: { 'Authorization': `Bearer ${token}` } })++ .then(r => r.ok ? r.json() : null)++ .then(data => {++ const rec = data && data[m.key];++ if (!rec || typeof rec !== 'object') return;++ if (!data.revision) {++ fetch(m.url, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ [m.key]: m.from(chatSettings) }) }).catch(() => {});++ return;++ }++ chatSettings = { ...chatSettings, ...m.to(rec) };++ try { localStorage.setItem(CHAT_SETTINGS_KEY, JSON.stringify(chatSettings)); } catch { }++ applyChatSettings();++ syncSettingsPanelUI();++ if (typeof syncTTSToggleButtons === 'function') syncTTSToggleButtons();++ })++ .catch(() => { /* offline — use local */ });++ }}function _syncSettingsFromServer() {nnew file mode 100644@@@ -0,0 +1,161 @@++'use strict';++/**++ * Live's user modules on OpenVibe.Network (openvibe-contracts 0.41.0, roadmap WS-B task 9):++ *++ * live.profile channel_url, followers, is_streamer, last_live_at (public), stream_minutes_30d++ * live.stats streams_30d, stream_minutes_30d, peak_viewers_30d (public), avg_viewers_30d,++ * new_followers_30d, computed_at++ *++ * Summaries only: streams and follows stay Live's truth. Written with Live's service token (grants++ * network.modules.read/write on live.profile and live.stats), unconditionally, as the owner.++ *++ * scan() every 5 minutes: people whose stream ended or who gained a follower since the last scan++ * refresh() daily: everyone who streamed in the last 30 days (the window moves) or has followers++ *++ * A record is written only when it changed since Live last wrote it (module_summary_pushes keeps a hash),++ * so an idle day writes nothing. Only accounts with a Network subject (usr_). Off without++ * OV_OAUTH_CLIENT_SECRET, and never started under LIVE_DRILL (server/index.js).++ */++const crypto = require('crypto');++const db = require('../db/database');++const identity = require('./identity-sync');++++const NETWORK_INTERNAL_URL = (process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000').replace(/\/+$/, '');++const CLIENT_ID = process.env.OV_OAUTH_CLIENT_ID || 'live';++const PUBLIC_BASE = (process.env.LIVE_PUBLIC_ORIGIN || 'https://openvibe.live').replace(/\/+$/, '');++const DAY_MS = 86400000;++++let modulesClient = null;++const stats = { written: 0, unchanged: 0, failed: 0, lastError: null, lastScanAt: null };++++function ensureSchema() {++ db.getDb().exec(`CREATE TABLE IF NOT EXISTS module_summary_pushes (++ user_id INTEGER NOT NULL,++ namespace TEXT NOT NULL,++ hash TEXT NOT NULL,++ pushed_at DATETIME DEFAULT CURRENT_TIMESTAMP,++ PRIMARY KEY (user_id, namespace)++ )`);++}++++// SQLite CURRENT_TIMESTAMP is UTC without a zone ('2026-09-23 01:30:00').++function toIso(v) {++ if (!v) return null;++ const d = new Date(String(v).includes('T') ? v : `${String(v).replace(' ', 'T')}Z`);++ return Number.isNaN(d.getTime()) ? null : d.toISOString();++}++++/** { profile, stats } for one account, or null when it has neither streams nor followers. */++function summarize(userId, { now = Date.now() } = {}) {++ const d = db.getDb();++ const user = d.prepare('SELECT id, username FROM users WHERE id = ?').get(userId);++ if (!user) return null;++ const since = new Date(now - 30 * DAY_MS).toISOString().replace('T', ' ').slice(0, 19);++ const followers = d.prepare('SELECT COUNT(*) AS n FROM follows WHERE streamer_id = ?').get(userId).n;++ const ever = d.prepare('SELECT COUNT(*) AS n, MAX(started_at) AS last FROM streams WHERE user_id = ?').get(userId);++ if (!ever.n && !followers) return null;++ const recent = d.prepare(`SELECT COUNT(*) AS n, COALESCE(SUM(s.duration_seconds), 0) AS secs, COALESCE(MAX(s.peak_viewers), 0) AS peak,++ AVG(a.avg_viewers) AS avg++ FROM streams s LEFT JOIN stream_analytics a ON a.stream_id = s.id++ WHERE s.user_id = ? AND s.started_at >= ? AND s.is_live = 0`).get(userId, since);++ const newFollowers = d.prepare('SELECT COUNT(*) AS n FROM follows WHERE streamer_id = ? AND created_at >= ?').get(userId, since).n;++ const minutes = Math.round(Number(recent.secs) / 60);++ return {++ profile: {++ channel_url: `${PUBLIC_BASE}/@${encodeURIComponent(user.username)}`,++ followers,++ is_streamer: ever.n > 0,++ last_live_at: toIso(ever.last),++ stream_minutes_30d: minutes,++ },++ stats: {++ streams_30d: recent.n,++ stream_minutes_30d: minutes,++ peak_viewers_30d: Number(recent.peak) || 0,++ avg_viewers_30d: recent.avg == null ? 0 : Math.round(Number(recent.avg) * 10) / 10,++ new_followers_30d: newFollowers,++ },++ };++}++++function client() {++ if (modulesClient) return modulesClient;++ const clientSecret = process.env.OV_OAUTH_CLIENT_SECRET || '';++ if (!clientSecret) return null;++ const { createClient } = require('openvibe-sdk/core');++ const { createServiceTokenClient } = require('openvibe-sdk/auth');++ const { createModulesClient } = require('openvibe-sdk/modules');++ const tokens = createServiceTokenClient({ tokenUrl: `${NETWORK_INTERNAL_URL}/oauth/token`, clientId: CLIENT_ID, clientSecret });++ const core = createClient({ baseUrls: { network: NETWORK_INTERNAL_URL }, tokenProvider: tokens, retries: 1 });++ modulesClient = createModulesClient(core, { baseUrl: NETWORK_INTERNAL_URL }).forSubject;++ return modulesClient;++}++++const hashOf = (data) => crypto.createHash('sha256').update(JSON.stringify(data)).digest('hex').slice(0, 32);++++/** Write one person's records where they changed. Returns the namespaces written. */++async function push(userId, { modules = client(), now = Date.now() } = {}) {++ if (!modules) return [];++ const subject = identity.subjectOf(userId);++ if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(subject || '')) return [];++ const sum = summarize(userId, { now });++ if (!sum) return [];++ const written = [];++ const pushes = [['live.profile', sum.profile], ['live.stats', sum.stats]];++ for (const [ns, data] of pushes) {++ const hash = hashOf(data);++ const last = db.getDb().prepare('SELECT hash FROM module_summary_pushes WHERE user_id = ? AND namespace = ?').get(userId, ns);++ if (last && last.hash === hash) { stats.unchanged++; continue; }++ const body = ns === 'live.stats' ? { ...data, computed_at: new Date(now).toISOString() } : data;++ try {++ await modules.put(ns, subject, body);++ } catch (err) {++ stats.failed++; stats.lastError = `${ns}: ${err.message}`;++ continue;++ }++ db.getDb().prepare(`INSERT INTO module_summary_pushes (user_id, namespace, hash, pushed_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)++ ON CONFLICT(user_id, namespace) DO UPDATE SET hash = excluded.hash, pushed_at = excluded.pushed_at`).run(userId, ns, hash);++ stats.written++;++ written.push(ns);++ }++ return written;++}++++let lastScan = null;++/** People whose stream ended, or who gained a follower, since the previous scan. */++async function scan({ modules = client(), now = Date.now() } = {}) {++ if (!modules) return 0;++ const from = lastScan || new Date(now - 10 * 60 * 1000).toISOString().replace('T', ' ').slice(0, 19);++ const to = new Date(now).toISOString().replace('T', ' ').slice(0, 19);++ const ids = db.getDb().prepare(`SELECT user_id AS id FROM streams WHERE ended_at >= ? AND ended_at < ?++ UNION SELECT streamer_id AS id FROM follows WHERE created_at >= ? AND created_at < ?++ UNION SELECT user_id AS id FROM streams WHERE started_at >= ? AND started_at < ?`).all(from, to, from, to, from, to).map((r) => r.id);++ for (const id of ids) await push(id, { modules, now });++ lastScan = to;++ stats.lastScanAt = new Date(now).toISOString();++ return ids.length;++}++++/** Everyone who streamed in the last 30 days (their window moves daily) or has followers. */++async function refresh({ modules = client(), now = Date.now() } = {}) {++ if (!modules) return 0;++ const since = new Date(now - 31 * DAY_MS).toISOString().replace('T', ' ').slice(0, 19);++ const ids = db.getDb().prepare(`SELECT DISTINCT user_id AS id FROM streams WHERE started_at >= ?++ UNION SELECT DISTINCT streamer_id AS id FROM follows`).all(since).map((r) => r.id);++ for (const id of ids) await push(id, { modules, now });++ return ids.length;++}++++function init() {++ if (!process.env.OV_OAUTH_CLIENT_SECRET || process.env.LIVE_MODULE_SUMMARIES === 'off') return false;++ ensureSchema();++ const jobs = require('../utils/jobs');++ jobs.every('module-summaries-scan', 5 * 60 * 1000, () => scan(), { initialDelayMs: 90 * 1000, jitterMs: 15 * 1000 });++ jobs.every('module-summaries-refresh', DAY_MS, () => refresh(), { initialDelayMs: 10 * 60 * 1000, jitterMs: 5 * 60 * 1000 });++ return true;++}++++function status() { return { ...stats }; }++function _reset() { modulesClient = null; lastScan = null; Object.assign(stats, { written: 0, unchanged: 0, failed: 0, lastError: null, lastScanAt: null }); }++++module.exports = { init, ensureSchema, summarize, push, scan, refresh, status, _reset };@@@ -1557,6 +1557,10 @@ async function start() {// transactional outbox (server/events/stream-events.js). Off unless EVENTS_URL is set.try { require('./events/stream-events').init(); } catch (err) { console.warn('[Events] not started:', err.message); }++ // 8d. User modules on Network (Contracts 0.41.0): live.profile and live.stats, written when they++ // change (server/auth/module-summaries.js). Off without OV_OAUTH_CLIENT_SECRET.++ try { require('./auth/module-summaries').init(); } catch (err) { console.warn('[Modules] summaries not started:', err.message); }++// 9. Periodic registry refresh — re-syncs config with openvibe.network every 5 minutes.// This is a safety net: if the startup refresh failed (openvibe.network was temporarily// unreachable), subsequent refreshes will fix CORS, issuer, and other URL config.nnew file mode 100644@@@ -0,0 +1,94 @@++'use strict';++// Live's user modules on Network (server/auth/module-summaries.js, Contracts 0.41.0, WS-B task 9):++// live.profile and live.stats computed from streams and follows, written as the owner with Live's++// service token through openvibe-sdk/modules, only when they changed; accounts without a Network++// subject and people with neither streams nor followers are skipped; the scan picks up ended streams++// and new followers; the job is never started under LIVE_DRILL.++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-live-modsum-'));++process.env.DB_PATH = path.join(tmp, 'live.db');++process.env.NODE_ENV = 'test';++const quiet = console.log;++console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };++console.warn = () => {};++++const db = require('../server/db/database');++db.initDb();++const sums = require('../server/auth/module-summaries');++sums.ensureSchema();++++const ANN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA';++const d = db.getDb();++const now = Date.parse('2026-09-25T12:00:00Z');++const at = (msAgo) => new Date(now - msAgo).toISOString().replace('T', ' ').slice(0, 19);++const H = 3600000, DAY = 24 * H;++d.prepare("INSERT INTO users (id, username, email, password_hash) VALUES (1, 'ann', 'a@x', 'x'), (2, 'bob', 'b@x', 'x'), (3, 'cat', 'c@x', 'x')").run();++d.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (1, 'network', '11', ?), (3, 'network', '33', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPC')").run(ANN);++const stream = (user, startedAgo, secs, peak, avg) => {++ const id = d.prepare('INSERT INTO streams (user_id, is_live, peak_viewers, started_at, ended_at, duration_seconds) VALUES (?, 0, ?, ?, ?, ?)')++ .run(user, peak, at(startedAgo), at(startedAgo - secs * 1000), secs).lastInsertRowid;++ if (avg != null) d.prepare('INSERT INTO stream_analytics (stream_id, avg_viewers) VALUES (?, ?)').run(id, avg);++};++stream(1, 2 * DAY, 3600, 12, 8);++stream(1, 5 * DAY, 1800, 30, 11);++stream(1, 45 * DAY, 7200, 99, 50); // outside the 30-day window++d.prepare('INSERT INTO follows (follower_id, streamer_id, created_at) VALUES (2, 1, ?), (3, 1, ?)').run(at(3 * DAY), at(60 * DAY));++++(async () => {++ // ── The summary ──++ const s = sums.summarize(1, { now });++ assert.deepStrictEqual(s.profile, { channel_url: 'https://openvibe.live/@ann', followers: 2, is_streamer: true, last_live_at: new Date(now - 2 * DAY).toISOString(), stream_minutes_30d: 90 });++ assert.deepStrictEqual(s.stats, { streams_30d: 2, stream_minutes_30d: 90, peak_viewers_30d: 30, avg_viewers_30d: 9.5, new_followers_30d: 1 });++ assert.strictEqual(sums.summarize(3, { now }), null, 'no streams and no followers: nothing to say');++++ // ── Writes through a stub Network (the real SDK client, Live's service token) ──++ const puts = [];++ const server = http.createServer((req, res) => {++ let body = '';++ req.on('data', (c) => { body += c; });++ req.on('end', () => {++ if (req.url === '/oauth/token') { res.setHeader('content-type', 'application/json'); return res.end(JSON.stringify({ access_token: 'svc-token', token_type: 'Bearer', expires_in: 300 })); }++ const m = req.url.match(/^\/internal\/modules\/([^/]+)\/([^/]+)$/);++ if (req.method === 'PUT' && m) {++ puts.push({ ns: m[1], subject: m[2], auth: req.headers.authorization, data: JSON.parse(body).data });++ res.statusCode = 201; res.setHeader('content-type', 'application/json');++ return res.end(JSON.stringify({ namespace: m[1], revision: puts.length, data: JSON.parse(body).data }));++ }++ res.statusCode = 404; res.end('{}');++ });++ });++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ process.env.OV_NETWORK_INTERNAL_URL = `http://127.0.0.1:${server.address().port}`;++ process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';++ delete require.cache[require.resolve('../server/auth/module-summaries')];++ const live = require('../server/auth/module-summaries');++++ assert.deepStrictEqual(await live.push(1, { now }), ['live.profile', 'live.stats']);++ assert.deepStrictEqual(puts.map((p) => [p.ns, p.subject, p.auth]), [['live.profile', ANN, 'Bearer svc-token'], ['live.stats', ANN, 'Bearer svc-token']]);++ assert.strictEqual(puts[1].data.computed_at, new Date(now).toISOString());++ const { modules } = require('openvibe-contracts');++ for (const p of puts) assert.ok(modules.validateData(p.ns, p.data).valid, `${p.ns} matches its schema`);++ assert.deepStrictEqual(await live.push(1, { now: now + 60000 }), [], 'unchanged: nothing written');++ assert.deepStrictEqual(await live.push(2, { now }), [], 'no Network subject: skipped');++++ // ── The scan: a stream that just ended ──++ d.prepare('INSERT INTO streams (user_id, is_live, peak_viewers, started_at, ended_at, duration_seconds) VALUES (1, 0, 40, ?, ?, 600)').run(at(20 * 60000), at(8 * 60000));++ assert.strictEqual(await live.scan({ now }), 1);++ assert.strictEqual(puts.length, 4, 'both records changed');++ assert.strictEqual(puts[3].data.peak_viewers_30d, 40);++ assert.strictEqual(await live.scan({ now: now + 5 * 60000 }), 0, 'the next scan starts where this one ended');++ assert.strictEqual(await live.refresh({ now: now + DAY }), 1, 'the daily refresh visits everyone with recent streams or followers');++++ // ── Never under LIVE_DRILL: index.js starts it inside the non-drill boot, like the other jobs ──++ const idx = fs.readFileSync(path.join(__dirname, '../server/index.js'), 'utf8');++ assert.ok(/require\('\.\/auth\/module-summaries'\)\.init\(\)/.test(idx), 'index.js starts it');++++ server.close();++ fs.rmSync(tmp, { recursive: true, force: true });++ quiet('module summaries: all checks passed');++})().catch((err) => { console.error(err); process.exit(1); });
@@ -25,7 +25,7 @@
@@ -25,7 +25,7 @@
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"node-media-server": "^2.7.1",
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
"onnxruntime-node": "^1.20.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.39.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.41.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.7.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.7.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0",
"sanitize-html": "^2.17.7",
"sanitize-html": "^2.17.7",
@@ -2814,9 +2814,9 @@
@@ -2814,9 +2814,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.39.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.39.0",
- "integrity": "sha512-ek3tryU3pPwmFSg621gzr4ss1OE/gpufBnxk9XFDLSfUe2RuZpYVZCJNdQ0F/9GfA83Xop+xJW8+IjRyjFg6Yg==",
+ "version": "0.41.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.41.0",
+ "integrity": "sha512-uT+Prd6NOO7iT2zn2cstxpPa6qmybc/O5KH0AQ8R5alzN/+WyUufd+h/yE0YxAw1+H/mJ4I2Q4xT+Go+CiPW2w==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -43,7 +43,7 @@
@@ -43,7 +43,7 @@
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"node-media-server": "^2.7.1",
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
"onnxruntime-node": "^1.20.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.39.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.41.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.7.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.7.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0",
"sanitize-html": "^2.17.7",
"sanitize-html": "^2.17.7",
@@ -277,9 +277,9 @@ function saveChatSettings() {
@@ -277,9 +277,9 @@ function saveChatSettings() {
_debounceSyncSettingsToServer();
_debounceSyncSettingsToServer();
}
}
-// Portable chat preferences live in OpenVibe.Network (user module chat.preferences), read and written
-// through OpenVibe.Chat at /api/chat/preferences. Only these four settings have fields there (schema v1);
-// the rest of chatSettings stays in Live's /api/auth/preferences. A patch value of null removes the
+// Portable chat preferences live in OpenVibe.Network (user modules chat.preferences and chat.tts_defaults),
+// read and written through OpenVibe.Chat at /api/chat/preferences and /api/chat/tts-settings. Only the
+// settings mapped below have fields there; the rest of chatSettings stays in Live's /api/auth/preferences. A patch value of null removes the
// field, i.e. "Live's default"; Chat writes nothing when nothing changed.
// field, i.e. "Live's default"; Chat writes nothing when nothing changed.
const CHAT_PREFS_FONT_SCALE = { small: 0.88, large: 1.18 };
const CHAT_PREFS_FONT_SCALE = { small: 0.88, large: 1.18 };
function _chatPrefsFromSettings(s) {
function _chatPrefsFromSettings(s) {
@@ -300,26 +300,65 @@ function _chatSettingsFromPrefs(p) {
@@ -300,26 +300,65 @@ function _chatSettingsFromPrefs(p) {
if (typeof p.font_scale === 'number') out.fontSize = p.font_scale < 0.95 ? 'small' : p.font_scale > 1.05 ? 'large' : 'default';
if (typeof p.font_scale === 'number') out.fontSize = p.font_scale < 0.95 ? 'small' : p.font_scale > 1.05 ? 'large' : 'default';
return out;
return out;
}
}
+// Text-to-speech and sounds: the user module chat.tts_defaults (v2), through /api/chat/tts-settings.
+// A field at Live's default is sent as null (removed), so the record only holds what the person changed.
+const TTS_MODULE_SOURCES = { native: 'ttsSrcNative', robotstreamer: 'ttsSrcRS', kick: 'ttsSrcKick', youtube: 'ttsSrcYoutube', twitch: 'ttsSrcTwitch' };
+function _ttsModuleFromSettings(s) {
+ const sources = {};
+ for (const [k, key] of Object.entries(TTS_MODULE_SOURCES)) if (s[key] === false) sources[k] = false;
+ const pct = (v) => (Number.isInteger(v) && v >= 0 && v <= 100 && v !== 80 ? v : null);
+ return {
+ send: s.ttsEnabled === false ? false : null,
+ send_while_live: s.streamingTtsEnabled === false ? false : null,
+ volume: pct(s.ttsVolume),
+ sounds: s.soundsEnabled === false ? false : null,
+ sound_volume: pct(s.soundVolume),
+ sources: Object.keys(sources).length ? sources : null,
+ };
+}
+function _settingsFromTtsModule(t) {
+ const out = {
+ ttsEnabled: t.send !== false,
+ streamingTtsEnabled: t.send_while_live !== false,
+ ttsVolume: Number.isInteger(t.volume) ? t.volume : 80,
+ soundsEnabled: t.sounds !== false,
+ soundVolume: Number.isInteger(t.sound_volume) ? t.sound_volume : 80,
+ };
+ const src = t.sources && typeof t.sources === 'object' ? t.sources : {};
+ for (const [k, key] of Object.entries(TTS_MODULE_SOURCES)) out[key] = src[k] !== false;
+ return out;
+}
+const CHAT_MODULES = [
+ { url: '/api/chat/preferences', key: 'preferences', from: _chatPrefsFromSettings, to: _chatSettingsFromPrefs },
+ { url: '/api/chat/tts-settings', key: 'settings', from: _ttsModuleFromSettings, to: _settingsFromTtsModule },
+];
function _pushChatPrefs(token) {
function _pushChatPrefs(token) {
- return fetch('/api/chat/preferences', {
+ return Promise.all(CHAT_MODULES.map((m) => fetch(m.url, {
method: 'PUT',
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
- body: JSON.stringify({ preferences: _chatPrefsFromSettings(chatSettings) }),
- }).catch(() => { /* Chat or Network unavailable: Live's copy still has them */ });
+ body: JSON.stringify({ [m.key]: m.from(chatSettings) }),
+ }).catch(() => { /* Chat or Network unavailable: Live's copy still has them */ })));
}
}
-/** After Live's copy: the Network record wins for its fields; none yet → seed it from this browser. */
+/** After Live's copy: each Network record wins for its fields; none yet → seed it from this browser. */
function _syncChatPrefsFromChat(token) {
function _syncChatPrefsFromChat(token) {
- fetch('/api/chat/preferences', { headers: { 'Authorization': `Bearer ${token}` } })
- .then(r => r.ok ? r.json() : null)
- .then(data => {
- if (!data || !data.preferences || typeof data.preferences !== 'object') return;
- if (!data.revision) { _pushChatPrefs(token); return; }
- chatSettings = { ...chatSettings, ..._chatSettingsFromPrefs(data.preferences) };
- try { localStorage.setItem(CHAT_SETTINGS_KEY, JSON.stringify(chatSettings)); } catch { }
- applyChatSettings();
- syncSettingsPanelUI();
- })
- .catch(() => { /* offline — use local */ });
+ for (const m of CHAT_MODULES) {
+ fetch(m.url, { headers: { 'Authorization': `Bearer ${token}` } })
+ .then(r => r.ok ? r.json() : null)
+ .then(data => {
+ const rec = data && data[m.key];
+ if (!rec || typeof rec !== 'object') return;
+ if (!data.revision) {
+ fetch(m.url, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ [m.key]: m.from(chatSettings) }) }).catch(() => {});
+ return;
+ }
+ chatSettings = { ...chatSettings, ...m.to(rec) };
+ try { localStorage.setItem(CHAT_SETTINGS_KEY, JSON.stringify(chatSettings)); } catch { }
+ applyChatSettings();
+ syncSettingsPanelUI();
+ if (typeof syncTTSToggleButtons === 'function') syncTTSToggleButtons();
+ })
+ .catch(() => { /* offline — use local */ });
+ }
}
}
function _syncSettingsFromServer() {
function _syncSettingsFromServer() {
new file mode 100644
new file mode 100644
@@ -0,0 +1,161 @@
@@ -0,0 +1,161 @@
+'use strict';
+/**
+ * Live's user modules on OpenVibe.Network (openvibe-contracts 0.41.0, roadmap WS-B task 9):
+ *
+ * live.profile channel_url, followers, is_streamer, last_live_at (public), stream_minutes_30d
+ * live.stats streams_30d, stream_minutes_30d, peak_viewers_30d (public), avg_viewers_30d,
+ * new_followers_30d, computed_at
+ *
+ * Summaries only: streams and follows stay Live's truth. Written with Live's service token (grants
+ * network.modules.read/write on live.profile and live.stats), unconditionally, as the owner.
+ *
+ * scan() every 5 minutes: people whose stream ended or who gained a follower since the last scan
+ * refresh() daily: everyone who streamed in the last 30 days (the window moves) or has followers
+ *
+ * A record is written only when it changed since Live last wrote it (module_summary_pushes keeps a hash),
+ * so an idle day writes nothing. Only accounts with a Network subject (usr_). Off without
+ * OV_OAUTH_CLIENT_SECRET, and never started under LIVE_DRILL (server/index.js).
+ */
+const crypto = require('crypto');
+const db = require('../db/database');
+const identity = require('./identity-sync');
+
+const NETWORK_INTERNAL_URL = (process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000').replace(/\/+$/, '');
+const CLIENT_ID = process.env.OV_OAUTH_CLIENT_ID || 'live';
+const PUBLIC_BASE = (process.env.LIVE_PUBLIC_ORIGIN || 'https://openvibe.live').replace(/\/+$/, '');
+const DAY_MS = 86400000;
+
+let modulesClient = null;
+const stats = { written: 0, unchanged: 0, failed: 0, lastError: null, lastScanAt: null };
+
+function ensureSchema() {
+ db.getDb().exec(`CREATE TABLE IF NOT EXISTS module_summary_pushes (
+ user_id INTEGER NOT NULL,
+ namespace TEXT NOT NULL,
+ hash TEXT NOT NULL,
+ pushed_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (user_id, namespace)
+ )`);
+}
+
+// SQLite CURRENT_TIMESTAMP is UTC without a zone ('2026-09-23 01:30:00').
+function toIso(v) {
+ if (!v) return null;
+ const d = new Date(String(v).includes('T') ? v : `${String(v).replace(' ', 'T')}Z`);
+ return Number.isNaN(d.getTime()) ? null : d.toISOString();
+}
+
+/** { profile, stats } for one account, or null when it has neither streams nor followers. */
+function summarize(userId, { now = Date.now() } = {}) {
+ const d = db.getDb();
+ const user = d.prepare('SELECT id, username FROM users WHERE id = ?').get(userId);
+ if (!user) return null;
+ const since = new Date(now - 30 * DAY_MS).toISOString().replace('T', ' ').slice(0, 19);
+ const followers = d.prepare('SELECT COUNT(*) AS n FROM follows WHERE streamer_id = ?').get(userId).n;
+ const ever = d.prepare('SELECT COUNT(*) AS n, MAX(started_at) AS last FROM streams WHERE user_id = ?').get(userId);
+ if (!ever.n && !followers) return null;
+ const recent = d.prepare(`SELECT COUNT(*) AS n, COALESCE(SUM(s.duration_seconds), 0) AS secs, COALESCE(MAX(s.peak_viewers), 0) AS peak,
+ AVG(a.avg_viewers) AS avg
+ FROM streams s LEFT JOIN stream_analytics a ON a.stream_id = s.id
+ WHERE s.user_id = ? AND s.started_at >= ? AND s.is_live = 0`).get(userId, since);
+ const newFollowers = d.prepare('SELECT COUNT(*) AS n FROM follows WHERE streamer_id = ? AND created_at >= ?').get(userId, since).n;
+ const minutes = Math.round(Number(recent.secs) / 60);
+ return {
+ profile: {
+ channel_url: `${PUBLIC_BASE}/@${encodeURIComponent(user.username)}`,
+ followers,
+ is_streamer: ever.n > 0,
+ last_live_at: toIso(ever.last),
+ stream_minutes_30d: minutes,
+ },
+ stats: {
+ streams_30d: recent.n,
+ stream_minutes_30d: minutes,
+ peak_viewers_30d: Number(recent.peak) || 0,
+ avg_viewers_30d: recent.avg == null ? 0 : Math.round(Number(recent.avg) * 10) / 10,
+ new_followers_30d: newFollowers,
+ },
+ };
+}
+
+function client() {
+ if (modulesClient) return modulesClient;
+ const clientSecret = process.env.OV_OAUTH_CLIENT_SECRET || '';
+ if (!clientSecret) return null;
+ const { createClient } = require('openvibe-sdk/core');
+ const { createServiceTokenClient } = require('openvibe-sdk/auth');
+ const { createModulesClient } = require('openvibe-sdk/modules');
+ const tokens = createServiceTokenClient({ tokenUrl: `${NETWORK_INTERNAL_URL}/oauth/token`, clientId: CLIENT_ID, clientSecret });
+ const core = createClient({ baseUrls: { network: NETWORK_INTERNAL_URL }, tokenProvider: tokens, retries: 1 });
+ modulesClient = createModulesClient(core, { baseUrl: NETWORK_INTERNAL_URL }).forSubject;
+ return modulesClient;
+}
+
+const hashOf = (data) => crypto.createHash('sha256').update(JSON.stringify(data)).digest('hex').slice(0, 32);
+
+/** Write one person's records where they changed. Returns the namespaces written. */
+async function push(userId, { modules = client(), now = Date.now() } = {}) {
+ if (!modules) return [];
+ const subject = identity.subjectOf(userId);
+ if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(subject || '')) return [];
+ const sum = summarize(userId, { now });
+ if (!sum) return [];
+ const written = [];
+ const pushes = [['live.profile', sum.profile], ['live.stats', sum.stats]];
+ for (const [ns, data] of pushes) {
+ const hash = hashOf(data);
+ const last = db.getDb().prepare('SELECT hash FROM module_summary_pushes WHERE user_id = ? AND namespace = ?').get(userId, ns);
+ if (last && last.hash === hash) { stats.unchanged++; continue; }
+ const body = ns === 'live.stats' ? { ...data, computed_at: new Date(now).toISOString() } : data;
+ try {
+ await modules.put(ns, subject, body);
+ } catch (err) {
+ stats.failed++; stats.lastError = `${ns}: ${err.message}`;
+ continue;
+ }
+ db.getDb().prepare(`INSERT INTO module_summary_pushes (user_id, namespace, hash, pushed_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)
+ ON CONFLICT(user_id, namespace) DO UPDATE SET hash = excluded.hash, pushed_at = excluded.pushed_at`).run(userId, ns, hash);
+ stats.written++;
+ written.push(ns);
+ }
+ return written;
+}
+
+let lastScan = null;
+/** People whose stream ended, or who gained a follower, since the previous scan. */
+async function scan({ modules = client(), now = Date.now() } = {}) {
+ if (!modules) return 0;
+ const from = lastScan || new Date(now - 10 * 60 * 1000).toISOString().replace('T', ' ').slice(0, 19);
+ const to = new Date(now).toISOString().replace('T', ' ').slice(0, 19);
+ const ids = db.getDb().prepare(`SELECT user_id AS id FROM streams WHERE ended_at >= ? AND ended_at < ?
+ UNION SELECT streamer_id AS id FROM follows WHERE created_at >= ? AND created_at < ?
+ UNION SELECT user_id AS id FROM streams WHERE started_at >= ? AND started_at < ?`).all(from, to, from, to, from, to).map((r) => r.id);
+ for (const id of ids) await push(id, { modules, now });
+ lastScan = to;
+ stats.lastScanAt = new Date(now).toISOString();
+ return ids.length;
+}
+
+/** Everyone who streamed in the last 30 days (their window moves daily) or has followers. */
+async function refresh({ modules = client(), now = Date.now() } = {}) {
+ if (!modules) return 0;
+ const since = new Date(now - 31 * DAY_MS).toISOString().replace('T', ' ').slice(0, 19);
+ const ids = db.getDb().prepare(`SELECT DISTINCT user_id AS id FROM streams WHERE started_at >= ?
+ UNION SELECT DISTINCT streamer_id AS id FROM follows`).all(since).map((r) => r.id);
+ for (const id of ids) await push(id, { modules, now });
+ return ids.length;
+}
+
+function init() {
+ if (!process.env.OV_OAUTH_CLIENT_SECRET || process.env.LIVE_MODULE_SUMMARIES === 'off') return false;
+ ensureSchema();
+ const jobs = require('../utils/jobs');
+ jobs.every('module-summaries-scan', 5 * 60 * 1000, () => scan(), { initialDelayMs: 90 * 1000, jitterMs: 15 * 1000 });
+ jobs.every('module-summaries-refresh', DAY_MS, () => refresh(), { initialDelayMs: 10 * 60 * 1000, jitterMs: 5 * 60 * 1000 });
+ return true;
+}
+
+function status() { return { ...stats }; }
+function _reset() { modulesClient = null; lastScan = null; Object.assign(stats, { written: 0, unchanged: 0, failed: 0, lastError: null, lastScanAt: null }); }
+
+module.exports = { init, ensureSchema, summarize, push, scan, refresh, status, _reset };
@@ -1557,6 +1557,10 @@ async function start() {
@@ -1557,6 +1557,10 @@ async function start() {
// transactional outbox (server/events/stream-events.js). Off unless EVENTS_URL is set.
// transactional outbox (server/events/stream-events.js). Off unless EVENTS_URL is set.
try { require('./events/stream-events').init(); } catch (err) { console.warn('[Events] not started:', err.message); }
try { require('./events/stream-events').init(); } catch (err) { console.warn('[Events] not started:', err.message); }
+ // 8d. User modules on Network (Contracts 0.41.0): live.profile and live.stats, written when they
+ // change (server/auth/module-summaries.js). Off without OV_OAUTH_CLIENT_SECRET.
+ try { require('./auth/module-summaries').init(); } catch (err) { console.warn('[Modules] summaries not started:', err.message); }
+
// 9. Periodic registry refresh — re-syncs config with openvibe.network every 5 minutes.
// 9. Periodic registry refresh — re-syncs config with openvibe.network every 5 minutes.
// This is a safety net: if the startup refresh failed (openvibe.network was temporarily
// This is a safety net: if the startup refresh failed (openvibe.network was temporarily
// unreachable), subsequent refreshes will fix CORS, issuer, and other URL config.
// unreachable), subsequent refreshes will fix CORS, issuer, and other URL config.
new file mode 100644
new file mode 100644
@@ -0,0 +1,94 @@
@@ -0,0 +1,94 @@
+'use strict';
+// Live's user modules on Network (server/auth/module-summaries.js, Contracts 0.41.0, WS-B task 9):
+// live.profile and live.stats computed from streams and follows, written as the owner with Live's
+// service token through openvibe-sdk/modules, only when they changed; accounts without a Network
+// subject and people with neither streams nor followers are skipped; the scan picks up ended streams
+// and new followers; the job is never started under LIVE_DRILL.
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+
+const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-live-modsum-'));
+process.env.DB_PATH = path.join(tmp, 'live.db');
+process.env.NODE_ENV = 'test';
+const quiet = console.log;
+console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
+console.warn = () => {};
+
+const db = require('../server/db/database');
+db.initDb();
+const sums = require('../server/auth/module-summaries');
+sums.ensureSchema();
+
+const ANN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA';
+const d = db.getDb();
+const now = Date.parse('2026-09-25T12:00:00Z');
+const at = (msAgo) => new Date(now - msAgo).toISOString().replace('T', ' ').slice(0, 19);
+const H = 3600000, DAY = 24 * H;
+d.prepare("INSERT INTO users (id, username, email, password_hash) VALUES (1, 'ann', 'a@x', 'x'), (2, 'bob', 'b@x', 'x'), (3, 'cat', 'c@x', 'x')").run();
+d.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (1, 'network', '11', ?), (3, 'network', '33', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPC')").run(ANN);
+const stream = (user, startedAgo, secs, peak, avg) => {
+ const id = d.prepare('INSERT INTO streams (user_id, is_live, peak_viewers, started_at, ended_at, duration_seconds) VALUES (?, 0, ?, ?, ?, ?)')
+ .run(user, peak, at(startedAgo), at(startedAgo - secs * 1000), secs).lastInsertRowid;
+ if (avg != null) d.prepare('INSERT INTO stream_analytics (stream_id, avg_viewers) VALUES (?, ?)').run(id, avg);
+};
+stream(1, 2 * DAY, 3600, 12, 8);
+stream(1, 5 * DAY, 1800, 30, 11);
+stream(1, 45 * DAY, 7200, 99, 50); // outside the 30-day window
+d.prepare('INSERT INTO follows (follower_id, streamer_id, created_at) VALUES (2, 1, ?), (3, 1, ?)').run(at(3 * DAY), at(60 * DAY));
+
+(async () => {
+ // ── The summary ──
+ const s = sums.summarize(1, { now });
+ assert.deepStrictEqual(s.profile, { channel_url: 'https://openvibe.live/@ann', followers: 2, is_streamer: true, last_live_at: new Date(now - 2 * DAY).toISOString(), stream_minutes_30d: 90 });
+ assert.deepStrictEqual(s.stats, { streams_30d: 2, stream_minutes_30d: 90, peak_viewers_30d: 30, avg_viewers_30d: 9.5, new_followers_30d: 1 });
+ assert.strictEqual(sums.summarize(3, { now }), null, 'no streams and no followers: nothing to say');
+
+ // ── Writes through a stub Network (the real SDK client, Live's service token) ──
+ const puts = [];
+ const server = http.createServer((req, res) => {
+ let body = '';
+ req.on('data', (c) => { body += c; });
+ req.on('end', () => {
+ if (req.url === '/oauth/token') { res.setHeader('content-type', 'application/json'); return res.end(JSON.stringify({ access_token: 'svc-token', token_type: 'Bearer', expires_in: 300 })); }
+ const m = req.url.match(/^\/internal\/modules\/([^/]+)\/([^/]+)$/);
+ if (req.method === 'PUT' && m) {
+ puts.push({ ns: m[1], subject: m[2], auth: req.headers.authorization, data: JSON.parse(body).data });
+ res.statusCode = 201; res.setHeader('content-type', 'application/json');
+ return res.end(JSON.stringify({ namespace: m[1], revision: puts.length, data: JSON.parse(body).data }));
+ }
+ res.statusCode = 404; res.end('{}');
+ });
+ });
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ process.env.OV_NETWORK_INTERNAL_URL = `http://127.0.0.1:${server.address().port}`;
+ process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';
+ delete require.cache[require.resolve('../server/auth/module-summaries')];
+ const live = require('../server/auth/module-summaries');
+
+ assert.deepStrictEqual(await live.push(1, { now }), ['live.profile', 'live.stats']);
+ assert.deepStrictEqual(puts.map((p) => [p.ns, p.subject, p.auth]), [['live.profile', ANN, 'Bearer svc-token'], ['live.stats', ANN, 'Bearer svc-token']]);
+ assert.strictEqual(puts[1].data.computed_at, new Date(now).toISOString());
+ const { modules } = require('openvibe-contracts');
+ for (const p of puts) assert.ok(modules.validateData(p.ns, p.data).valid, `${p.ns} matches its schema`);
+ assert.deepStrictEqual(await live.push(1, { now: now + 60000 }), [], 'unchanged: nothing written');
+ assert.deepStrictEqual(await live.push(2, { now }), [], 'no Network subject: skipped');
+
+ // ── The scan: a stream that just ended ──
+ d.prepare('INSERT INTO streams (user_id, is_live, peak_viewers, started_at, ended_at, duration_seconds) VALUES (1, 0, 40, ?, ?, 600)').run(at(20 * 60000), at(8 * 60000));
+ assert.strictEqual(await live.scan({ now }), 1);
+ assert.strictEqual(puts.length, 4, 'both records changed');
+ assert.strictEqual(puts[3].data.peak_viewers_30d, 40);
+ assert.strictEqual(await live.scan({ now: now + 5 * 60000 }), 0, 'the next scan starts where this one ended');
+ assert.strictEqual(await live.refresh({ now: now + DAY }), 1, 'the daily refresh visits everyone with recent streams or followers');
+
+ // ── Never under LIVE_DRILL: index.js starts it inside the non-drill boot, like the other jobs ──
+ const idx = fs.readFileSync(path.join(__dirname, '../server/index.js'), 'utf8');
+ assert.ok(/require\('\.\/auth\/module-summaries'\)\.init\(\)/.test(idx), 'index.js starts it');
+
+ server.close();
+ fs.rmSync(tmp, { recursive: true, force: true });
+ quiet('module summaries: all checks passed');
+})().catch((err) => { console.error(err); process.exit(1); });