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

OP OpenVibers committed 25 Sept 2026 · c3c95dba44 · 6 files, +321 −23


Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 7bccf88d..27578620 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex eb81a495..0ba7f2fd 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/public/js/chat.js b/public/js/chat.js
iindex eb1da690..82543a62 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -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() {
ddiff --git a/server/auth/module-summaries.js b/server/auth/module-summaries.js
nnew file mode 100644
iindex 00000000..fa8db8c0
---- /dev/null
++++ b/server/auth/module-summaries.js
@@@ -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 };
ddiff --git a/server/index.js b/server/index.js
iindex b7615a29..ef537231 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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.
ddiff --git a/test/module-summaries.test.js b/test/module-summaries.test.js
nnew file mode 100644
iindex 00000000..32d6bd26
---- /dev/null
++++ b/test/module-summaries.test.js
@@@ -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); });