openvibers/openvibe.live Commits

Restore drills: LIVE_DRILL=1 makes Live a read-only instance on a restored copy, so `ovhost drill live` can boot one next to production. It refuses to start unless DB_PATH and DATA_DIR are set and outside the checkout and /opt/openvibe.live, HOST is loopback, PORT is not 3000 and no socket was handed over by systemd, before any file is opened. It then starts only its HTTP server (no jobs, WebSocket servers, RTMP, SFU, JSMPEG, WHIP, TURN credentials, restream or relay resume, AI jobs, Media reconcilers, Events outbox, chat bridge, identity sync, deploy notice, star job or registry refresh), answers 403 to every method but GET/HEAD/OPTIONS and to every WebSocket upgrade, and guards net/fetch/child_process/dgram/listen so nothing leaves the process (other services look down; only git runs). Every file location now comes from server/paths.js (DATA_DIR, default ./data), analytics.db included; module-level timers stay off in a drill. test/drill-mode.test.js boots the real server that way and checks each point

OP OpenVibers committed 23 Sept 2026 · 8a58aea2bf · 31 files, +784 −70


Changes

ddiff --git a/.env.example b/.env.example
iindex 130ee3fa..1f89c70b 100644
---- a/.env.example
++++ b/.env.example
@@@ -36,6 +36,15 @@ JWT_SECRET=change-me-to-a-random-secret-at-least-32-chars
# Database
DB_PATH=./data/live.db
++# Every file Live writes lives under one data directory (default ./data, relative to the working
++# directory). The *_PATH variables below still move single locations (server/paths.js).
++# DATA_DIR=./data
++
++# Restore-drill mode, set only by `ovhost drill live` (OpenVibe.Host docs/restore-drills.md): reads
++# only from a restored copy; refuses to start unless DB_PATH and DATA_DIR are outside the checkout,
++# HOST is loopback and PORT is not 3000. Starts no jobs, sockets or listeners but its HTTP port and
++# makes no outbound connection (server/drill.js).
++# LIVE_DRILL=1
# ── OpenVibe.Network (SSO provider + OpenCoins wallet + URL registry) ─────────
# Public base of the identity provider (issuer for RS256 JWTs)
ddiff --git a/AGENTS.md b/AGENTS.md
iindex 2fefea59..69a722c7 100644
---- a/AGENTS.md
++++ b/AGENTS.md
@@@ -46,6 +46,7 @@ Each feature lives in its own `server/<feature>/` directory with `routes.js` + s
- **Public responses:** serialize through [server/web/serializers.js](server/web/serializers.js) — never return raw `managed_streams`/`users` rows.
- **TURN:** ICE lists come from [server/net/turn.js](server/net/turn.js); set `TURN_AUTH_SECRET` (coturn `use-auth-secret`) for short-lived credentials.
- **Outbound fetches of user-chosen URLs:** [server/net/egress.js](server/net/egress.js) only. Background loops: `server/utils/jobs.js`.
++- **Files and restore drills:** every file location comes from [server/paths.js](server/paths.js) (`DATA_DIR`, `DB_PATH`), never a literal `./data`. Anything started at boot or at module load (timers, listeners, sockets, jobs, outbound calls) must stay off under `LIVE_DRILL` ([server/drill.js](server/drill.js)); `test/drill-mode.test.js` fails on any new one.
- **WebSocket servers:** Each has `init(server)` and `handleUpgrade(req, socket, head)` methods.
- **Frontend globals:** `currentUser`, `api()`, `navigate()`, `handleLinkClick()`. Cross-component sync via `CustomEvent` (e.g., `openvibe-auth-changed`).
- **ChatServer:** Singleton — `chat-server.js` exports `new ChatServer()`, not the class.
ddiff --git a/docs/deploy.md b/docs/deploy.md
iindex 65ad04d2..6a91b584 100644
---- a/docs/deploy.md
++++ b/docs/deploy.md
@@@ -80,6 +80,27 @@ checks each of these behaviours.
static cache, gzip and log format for Live, enforced by that repo's `test/nginx-generator-live.test.js`.
Apply nginx changes by hand: copy the file, `nginx -t`, `systemctl reload nginx`.
++## Restore drills
++
++`ovhost drill live` (OpenVibe.Host, `docs/restore-drills.md`) restores `live.db` from the latest
++backup into a directory of its own and starts a second Live from this checkout on 127.0.0.1:13000
++with `LIVE_DRILL=1`, `DB_PATH` on the copy and `DATA_DIR` in that directory. In that mode
++(`server/drill.js`) Live:
++
++- refuses to start unless `DB_PATH` and `DATA_DIR` are set and outside the checkout and
++ `/opt/openvibe.live`, `HOST` is loopback, `PORT` is not 3000 and no socket was handed over by systemd;
++- writes nothing outside `DATA_DIR` and the copy's directory (`server/paths.js`: the per-location
++ `*_PATH` variables from the env file are ignored);
++- starts only its HTTP server: no job, WebSocket server, RTMP, SFU, JSMPEG, WHIP, TURN credential,
++ restream or relay resume, AI job, Media reconciler, Events outbox, chat bridge, identity sync,
++ deploy notice or registry refresh;
++- connects to nothing and runs no program but `git`: other services look down to it, so a route that
++ asks Media or Community answers as it does when they are down;
++- answers 403 to every method but GET, HEAD and OPTIONS, and to every WebSocket upgrade;
++- reports `"mode": "drill"` in `/api/ready`.
++
++`test/drill-mode.test.js` boots the real server that way and checks each point.
++
## Checks
```bash
ddiff --git a/server/admin/routes.js b/server/admin/routes.js
iindex 13ff0d19..f2be55d9 100644
---- a/server/admin/routes.js
++++ b/server/admin/routes.js
@@@ -968,16 +968,17 @@ function diskUsage(targetPath) {
// Full disk overview + per-directory breakdown
router.get('/storage', (req, res) => {
try {
-- const dataRoot = path.resolve('./data');
++ const paths = require('../paths');
++ const dataRoot = paths.dataDir();
const disk = diskUsage(dataRoot);
// Per-directory breakdown
// VODs/clips/pastes/thumbnails moved to OpenVibe.Media — only Live-local dirs remain.
const directories = [
-- { name: 'Live thumbs', path: './data/live-thumbs', icon: 'fa-image' },
-- { name: 'Avatars', path: './data/avatars', icon: 'fa-user-circle' },
-- { name: 'Emotes', path: './data/emotes', icon: 'fa-face-smile' },
-- { name: 'Offline screens', path: './data/offline', icon: 'fa-tv' },
++ { name: 'Live thumbs', path: paths.data('live-thumbs'), icon: 'fa-image' },
++ { name: 'Avatars', path: paths.data('avatars'), icon: 'fa-user-circle' },
++ { name: 'Emotes', path: paths.data('emotes'), icon: 'fa-face-smile' },
++ { name: 'Offline screens', path: paths.data('offline'), icon: 'fa-tv' },
];
const breakdown = directories.map(d => {
@@@ -987,7 +988,7 @@ router.get('/storage', (req, res) => {
// Database file size
let dbBytes = 0;
-- try { dbBytes = fs.statSync(path.resolve('./data/live.db')).size; } catch {}
++ try { dbBytes = fs.statSync(paths.dbPath()).size; } catch {}
// Total data directory
const dataTotal = dirStats(dataRoot);
ddiff --git a/server/ai/audio-events.js b/server/ai/audio-events.js
iindex e66013af..553eac9a 100644
---- a/server/ai/audio-events.js
++++ b/server/ai/audio-events.js
@@@ -26,9 +26,9 @@ const fs = require('fs');
const path = require('path');
const MODEL_PATH = process.env.YAMNET_MODEL
-- || path.join(__dirname, '../../data/models/yamnet.onnx');
++ || require('../paths').data('models', 'yamnet.onnx');
const CLASSMAP_PATH = process.env.YAMNET_CLASSMAP
-- || path.join(__dirname, '../../data/models/yamnet_class_map.csv');
++ || require('../paths').data('models', 'yamnet_class_map.csv');
// YAMNet emits one score vector per 0.96s window advanced by 0.48s.
const FRAME_HOP_SEC = 0.48;
ddiff --git a/server/ai/stream-audio.js b/server/ai/stream-audio.js
iindex ad22628c..4869bc69 100644
---- a/server/ai/stream-audio.js
++++ b/server/ai/stream-audio.js
@@@ -227,7 +227,7 @@ async function captureAudioChunk(stream, seconds = 12) {
// 10s is the balance point; most boundaries land in silence anyway, since only ~13% of
// this audio is speech. Tune with AI_HEAR_SEGMENT_SEC.
const SEGMENT_SEC = Math.max(5, Math.min(120, parseInt(process.env.AI_HEAR_SEGMENT_SEC, 10) || 10));
--const SPOOL_ROOT = path.join(__dirname, '../../data/asr');
++const SPOOL_ROOT = require('../paths').data('asr');
// Hard cap on spooled audio per stream. The box has ~34GB free on a filesystem that
// openvibe.media is actively growing into, so an unbounded spool is not an option.
const MAX_SPOOL_FILES = Math.max(4, parseInt(process.env.AI_HEAR_MAX_SPOOL, 10) || 40);
ddiff --git a/server/ai/stream-memory-job.js b/server/ai/stream-memory-job.js
iindex f537c965..edf9e39c 100644
---- a/server/ai/stream-memory-job.js
++++ b/server/ai/stream-memory-job.js
@@@ -42,7 +42,7 @@ function captureMemoryNow(stream, { allowFfmpeg = true, reason = 'manual' } = {}
// within the hour, so every moment in the timeline ended up showing the placeholder
// pixel (a white box). Keep a small JPEG of the analysed frame per moment instead,
// under data/ai-moments/<streamId>/<offset>.jpg (served at /data/ai-moments/…, ~15 KB).
--const MOMENTS_DIR = require('path').resolve(process.env.AI_MOMENTS_PATH || './data/ai-moments');
++const MOMENTS_DIR = require('../paths').dir('AI_MOMENTS_PATH', 'ai-moments');
async function _persistMomentFrame(image, streamId, offset) {
if (!image) return null;
try {
ddiff --git a/server/arena/arena-service.js b/server/arena/arena-service.js
iindex 58d82648..3da4dea5 100644
---- a/server/arena/arena-service.js
++++ b/server/arena/arena-service.js
@@@ -27,7 +27,8 @@ const crypto = require('crypto');
const db = require('../db/database');
const llm = require('../ai/llm');
--const ARENA_DIR = path.resolve(process.env.ARENA_IMAGE_PATH || './data/arena');
++const paths = require('../paths');
++const ARENA_DIR = paths.dir('ARENA_IMAGE_PATH', 'arena');
const ACTIVE_DAYS = 45;
const STATS_WINDOW_DAYS = 90;
const PERSONA_TTL_MS = 24 * 60 * 60 * 1000;
@@@ -415,11 +416,11 @@ async function referenceImagesFor(userId) {
if (live) {
const thumbs = require('../media-proxy/live-thumbs');
const url = thumbs.getCurrentLiveThumbnailUrl(live.id);
-- if (url) { const local = path.resolve('./data/live-thumbs', path.basename(url)); if (fs.existsSync(local)) push(local); else if (url.startsWith('http')) push(url); }
++ if (url) { const local = paths.data('live-thumbs', path.basename(url)); if (fs.existsSync(local)) push(local); else if (url.startsWith('http')) push(url); }
}
} catch { /* */ }
try {
-- const momentsDir = path.resolve(process.env.AI_MOMENTS_PATH || './data/ai-moments');
++ const momentsDir = paths.dir('AI_MOMENTS_PATH', 'ai-moments');
const streams = db.all(`SELECT id FROM streams WHERE user_id = ? AND duration_seconds > 0 ORDER BY started_at DESC LIMIT 6`, [userId]);
for (const s of streams) {
const dir = path.join(momentsDir, String(s.id));
ddiff --git a/server/arena/voice.js b/server/arena/voice.js
iindex 76af7cea..60f168f8 100644
---- a/server/arena/voice.js
++++ b/server/arena/voice.js
@@@ -15,7 +15,7 @@ const path = require('path');
const crypto = require('crypto');
const db = require('../db/database');
--const CACHE_DIR = path.resolve(process.env.TTS_CACHE_PATH || './data/tts-cache');
++const CACHE_DIR = require('../paths').dir('TTS_CACHE_PATH', 'tts-cache');
const MAX_TEXT = 240;
const MAX_CACHE_MB = 300;
const MAX_AGE_MS = 60 * 24 * 3600_000;
ddiff --git a/server/auth/auth.js b/server/auth/auth.js
iindex b822aeec..87e02ff6 100644
---- a/server/auth/auth.js
++++ b/server/auth/auth.js
@@@ -24,7 +24,7 @@ function getNetworkIssuer() {
function loadNetworkPublicKey() {
const keyPaths = [
process.env.OV_NETWORK_PUBLIC_KEY,
-- path.resolve('./data/keys/openvibe-tools-public.pem'),
++ require('../paths').data('keys', 'openvibe-tools-public.pem'),
'/opt/openvibe/openvibe-tools/data/keys/public.pem',
].filter(Boolean);
ddiff --git a/server/auth/routes.js b/server/auth/routes.js
iindex 6e522173..cb0bdbf8 100644
---- a/server/auth/routes.js
++++ b/server/auth/routes.js
@@@ -68,7 +68,7 @@ function isAllowedAvatarUrl(value) {
}
// ── Avatar Upload Config ─────────────────────────────────────
--const avatarDir = path.resolve('./data/avatars');
++const avatarDir = require('../paths').data('avatars');
if (!fs.existsSync(avatarDir)) fs.mkdirSync(avatarDir, { recursive: true });
const MIME_TO_EXT = { 'image/png': '.png', 'image/jpeg': '.jpg', 'image/gif': '.gif', 'image/webp': '.webp', 'image/avif': '.avif' };
ddiff --git a/server/chat/dm-routes.js b/server/chat/dm-routes.js
iindex 79f9c7aa..dc748028 100644
---- a/server/chat/dm-routes.js
++++ b/server/chat/dm-routes.js
@@@ -36,7 +36,8 @@ function _checkRate(userId, bucket, max, windowMs) {
u[bucket].push(now);
return true;
}
--setInterval(() => {
++// Not in a restore drill (LIVE_DRILL): it runs no timers, and it takes no DMs.
++if (!require('../drill').enabled) setInterval(() => {
const cutoff = Date.now() - 3_600_000;
for (const [uid, b] of _rateBuckets) {
b.messages = b.messages.filter(t => t > cutoff);
ddiff --git a/server/chat/soundboard-service.js b/server/chat/soundboard-service.js
iindex 5357ac85..06a9a066 100644
---- a/server/chat/soundboard-service.js
++++ b/server/chat/soundboard-service.js
@@@ -5,7 +5,7 @@ const path = require('path');
const fs = require('fs');
const db = require('../db/database');
--const CACHE_DIR = path.join(__dirname, '..', '..', 'data', 'soundboard-cache');
++const CACHE_DIR = require('../paths').data('soundboard-cache');
const CACHE_TTL_MS = 15 * 60 * 1000;
const URL_REGEX = /(?:https?:\/\/)?(?:www\.)?101soundboards\.com\/sounds\/(\d+)(?:[-\w/?#=&.]*)/i;
const CMD_REGEX = /^!sb\s+(.+)$/i;
ddiff --git a/server/config.js b/server/config.js
iindex df812687..72cbf2fa 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -7,6 +7,8 @@
require('dotenv').config();
const { resolveRegistryValues, URL_DEFINITIONS } = require('openvibe-shared/url-resolver');
++// File locations: all under the data directory (DATA_DIR, else ./data); see paths.js.
++const paths = require('./paths');
const DEFAULTS = {
BASE_URL: 'http://localhost:3000',
@@@ -225,7 +227,7 @@ function buildConfig(registryValues) {
expiresIn: '7d',
},
db: {
-- path: process.env.DB_PATH || './data/live.db',
++ path: paths.dbPath(),
},
jsmpeg: {
publicUrl: jsmpegPublicUrl,
@@@ -248,16 +250,16 @@ function buildConfig(registryValues) {
escrowDays: parseInt(process.env.ESCROW_HOLD_DAYS || '14', 10),
},
vod: {
-- path: process.env.VOD_PATH || './data/vods',
++ path: paths.dir('VOD_PATH', 'vods'),
coldPath: process.env.COLD_STORAGE_PATH || '',
-- clipsPath: process.env.CLIPS_PATH || './data/clips',
++ clipsPath: paths.dir('CLIPS_PATH', 'clips'),
maxSizeMb: parseInt(process.env.MAX_VOD_SIZE_MB || '2048', 10),
},
thumbnails: {
-- path: process.env.THUMBNAILS_PATH || './data/thumbnails',
++ path: paths.dir('THUMBNAILS_PATH', 'thumbnails'),
},
emotes: {
-- path: process.env.EMOTES_PATH || './data/emotes',
++ path: paths.dir('EMOTES_PATH', 'emotes'),
maxSizeKb: parseInt(process.env.MAX_EMOTE_SIZE_KB || '2048', 10),
maxPerUser: parseInt(process.env.MAX_EMOTES_PER_USER || '30', 10),
// A channel holds up to 30 emotes total (streamer + viewer uploads), first-come until
@@@ -269,7 +271,7 @@ function buildConfig(registryValues) {
sevenTvCacheTtl: parseInt(process.env.SEVENTV_CACHE_TTL || '3600', 10),
},
sounds: {
-- path: process.env.SOUNDS_PATH || './data/sounds',
++ path: paths.dir('SOUNDS_PATH', 'sounds'),
maxSizeKb: parseInt(process.env.MAX_SOUND_SIZE_KB || '2048', 10), // 2MB per clip
defaultMaxSeconds: parseInt(process.env.SOUND_DEFAULT_MAX_SECONDS || '10', 10),
maxPerChannel: parseInt(process.env.MAX_SOUNDS_PER_CHANNEL || '150', 10),
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 10572259..86ad6d0c 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -83,7 +83,8 @@ const crypto = require('crypto');
// BILLING_AUTHORITY tripwire: Live's money columns/tables are only written in `live` mode.
const { assertLiveLedger } = require('../monetization/money-authority');
--const DB_PATH = process.env.DB_PATH || './data/live.db';
++// DB_PATH, else <data dir>/live.db (server/paths.js).
++const DB_PATH = require('../paths').dbPath();
const dbDir = path.dirname(path.resolve(DB_PATH));
// Ensure data directory exists
@@@ -1224,7 +1225,8 @@ function initDb() {
_dedupeStreamMemories(database);
// Deferred: this repairs a historical backlog and no request depends on it, so it must
// not sit between process start and the first served request. Runs shortly after boot.
-- setTimeout(() => { try { _adoptOrphanedTimelineRows(database); } catch { /* */ } }, 4000).unref?.();
++ // A restore drill (LIVE_DRILL) runs no timers and repairs nothing in its copy.
++ if (!require('../drill').enabled) setTimeout(() => { try { _adoptOrphanedTimelineRows(database); } catch { /* */ } }, 4000).unref?.();
// ── Unified audio timeline ───────────────────────────────────────────────
// One time-indexed row per thing heard on a stream: a phrase that was spoken
@@@ -2678,8 +2680,8 @@ function initDb() {
// half-converted balances.
require('./migrations').run(database);
// Deferred migrations wait for tables that feature jobs create after boot; try them again once
-- // those jobs have started.
-- setTimeout(() => { try { require('./migrations').run(database); } catch (e) { console.error('[DB] deferred migrations:', e.message); } }, 120000).unref?.();
++ // those jobs have started. A restore drill starts no jobs and runs no timers.
++ if (!require('../drill').enabled) setTimeout(() => { try { require('./migrations').run(database); } catch (e) { console.error('[DB] deferred migrations:', e.message); } }, 120000).unref?.();
console.log('[DB] Schema initialized');
return database;
ddiff --git a/server/diagnostics.js b/server/diagnostics.js
iindex 1c19dfdf..254ec53e 100644
---- a/server/diagnostics.js
++++ b/server/diagnostics.js
@@@ -15,7 +15,8 @@ const WARN_P99_MS = 200;
const histogram = monitorEventLoopDelay({ resolution: 20 });
histogram.enable();
let lastWindow = null;
--const windowTimer = setInterval(() => {
++// A restore drill (LIVE_DRILL) runs no timers at all; its delay window simply stays empty.
++const windowTimer = require('./drill').enabled ? null : setInterval(() => {
const ms = (ns) => Math.round(ns / 1e6);
lastWindow = {
endedAt: new Date().toISOString(),
@@@ -29,7 +30,7 @@ const windowTimer = setInterval(() => {
}
histogram.reset();
}, WINDOW_MS);
--if (windowTimer.unref) windowTimer.unref();
++if (windowTimer && windowTimer.unref) windowTimer.unref();
const safe = (fn, fallback = null) => { try { return fn(); } catch { return fallback; } };
ddiff --git a/server/drill.js b/server/drill.js
nnew file mode 100644
iindex 00000000..951ce595
---- /dev/null
++++ b/server/drill.js
@@@ -0,0 +1,246 @@
++'use strict';
++/**
++ * Restore-drill mode: LIVE_DRILL=1 (`ovhost drill live`, see OpenVibe.Host docs/restore-drills.md).
++ *
++ * A drill starts a second Live from the production checkout, with the production env file, against
++ * a restored copy of the database on a spare loopback port, and compares its public reads with
++ * production's. That instance must serve reads from the copy and do nothing else. With LIVE_DRILL:
++ *
++ * - assertSafe(): Live refuses to start unless DB_PATH and DATA_DIR are set and lie outside the
++ * checkout and outside /opt/openvibe.live (so neither can be production's), PORT is set and is not
++ * production's 3000, HOST is loopback, and no socket was handed over by systemd. It runs before
++ * anything opens a file. All of Live's files go under DATA_DIR (server/paths.js).
++ * - installGuards(): no outbound connection (net.Socket#connect, fetch), no program other than git
++ * (child_process; git only reads the checkout, for /release.json and the updates list), no UDP
++ * socket, and nothing listens except the drill's own HTTP port. To Live's code every other service
++ * looks down, so a route that asks Media or Community answers the way it does when they are down.
++ * - readOnly: 403 for every request that is not GET, HEAD or OPTIONS, on every path (the /api
++ * writes, and also /internal webhooks, WHIP, the ban page's Continue).
++ * - refuseUpgrade(): every WebSocket upgrade gets a 403.
++ * - server/index.js start() opens the database and serves HTTP, and starts nothing else: no job
++ * loop, no chat/broadcast/call/control socket server, no RTMP, SFU, JSMPEG or WHIP, no restream or
++ * relay resume, no AI job, no Media reconciler, no Events outbox, no chat bridge, no identity sync,
++ * no deploy notice, no star job, no registry refresh. Timers that modules start when they are
++ * loaded check `enabled` as well. TURN credentials are never minted (server/net/turn.js).
++ *
++ * The guards are there so that anything this list missed fails closed instead of reaching production.
++ */
++const path = require('path');
++const fs = require('fs');
++
++const TRUE = new Set(['1', 'true', 'on', 'yes']);
++
++/** LIVE_DRILL as a switch (1/true/on/yes). */
++function parse(value) {
++ return TRUE.has(String(value == null ? '' : value).trim().toLowerCase());
++}
++
++const enabled = parse(process.env.LIVE_DRILL);
++
++const REPO_ROOT = path.resolve(__dirname, '..');
++/** Where production runs (OpenVibe.Host inventory: repo /opt/openvibe.live, database data/live.db). */
++const PRODUCTION_ROOT = '/opt/openvibe.live';
++const PRODUCTION_PORT = 3000;
++const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost', '::ffff:127.0.0.1']);
++
++function isLoopbackHost(host) {
++ const h = String(host || '').trim().toLowerCase().replace(/^\[|\]$/g, '');
++ return LOOPBACK_HOSTS.has(h) || /^127(\.\d{1,3}){3}$/.test(h);
++}
++
++/** The real location of p, following symlinks on the part of it that exists. */
++function realish(p) {
++ let head = path.resolve(p);
++ const tail = [];
++ for (;;) {
++ try { return path.join(fs.realpathSync(head), ...tail.reverse()); } catch { /* not there (yet) */ }
++ const parent = path.dirname(head);
++ if (parent === head) return path.resolve(p);
++ tail.push(path.basename(head));
++ head = parent;
++ }
++}
++
++function inside(p, root) {
++ const rel = path.relative(root, p);
++ return rel === '' || (!!rel && !rel.startsWith('..') && !path.isAbsolute(rel));
++}
++
++/**
++ * Why this environment is not safe for a drill ([] = safe). Pure, for tests.
++ * `repoRoot` is the checkout the process runs from; `cwd` resolves relative paths.
++ */
++function problems(env = process.env, { repoRoot = REPO_ROOT, cwd = process.cwd(), pid = process.pid } = {}) {
++ const out = [];
++ const roots = [...new Set([repoRoot, realish(repoRoot), PRODUCTION_ROOT])];
++ const productionDb = new Set([path.join(PRODUCTION_ROOT, 'data', 'live.db'), path.join(repoRoot, 'data', 'live.db'), path.join(cwd, 'data', 'live.db')]);
++ const checkPath = (name, value) => {
++ const abs = path.resolve(cwd, value);
++ const real = realish(abs);
++ if (name === 'DB_PATH' && (productionDb.has(abs) || productionDb.has(real))) {
++ out.push(`DB_PATH (${abs}) is production's database; point it at the restored copy`);
++ return;
++ }
++ const root = roots.find((r) => inside(abs, r) || inside(real, r));
++ if (root) out.push(`${name} (${abs}${real !== abs ? ` → ${real}` : ''}) is inside ${root}; a drill writes nothing in the checkout or in production's data`);
++ };
++ if (!env.DB_PATH) out.push('DB_PATH is not set; it must name the restored copy of the database');
++ else checkPath('DB_PATH', env.DB_PATH);
++ if (!env.DATA_DIR) out.push('DATA_DIR is not set; it must name the drill\'s own data directory (Live writes all its files there)');
++ else checkPath('DATA_DIR', env.DATA_DIR);
++ if (!isLoopbackHost(env.HOST)) out.push(`HOST (${env.HOST || 'unset, i.e. 0.0.0.0'}) is not loopback; a drill binds 127.0.0.1 only`);
++ const port = Number(env.PORT);
++ if (!env.PORT || !Number.isInteger(port) || port < 1 || port > 65535) out.push('PORT is not set; it must be the drill\'s own port');
++ else if (port === PRODUCTION_PORT) out.push(`PORT ${port} is production's port`);
++ if (env.LISTEN_FDS && Number(env.LISTEN_PID) === pid) out.push('systemd handed this process a listening socket (LISTEN_FDS): a drill never serves on production\'s socket');
++ return out;
++}
++
++class DrillRefused extends Error {
++ constructor(list) {
++ super(`LIVE_DRILL: refusing to start a restore-drill instance: ${list.join('; ')}`);
++ this.code = 'LIVE_DRILL_UNSAFE';
++ this.problems = list;
++ }
++}
++
++/** Throws DrillRefused unless the environment is safe for a drill. */
++function assertSafe(env = process.env, opts) {
++ const list = problems(env, opts);
++ if (list.length) throw new DrillRefused(list);
++}
++
++// ── Guards ───────────────────────────────────────────────────
++
++const blocked = []; // what the guards refused (newest last, bounded): { at, kind, target }
++function note(kind, target) {
++ blocked.push({ at: new Date().toISOString(), kind, target: String(target).slice(0, 200) });
++ if (blocked.length > 50) blocked.shift();
++ console.warn(`[Drill] refused ${kind}: ${target}`);
++}
++
++function drillError(message, code) {
++ const err = new Error(`${message} (LIVE_DRILL: a restore-drill instance does not do this)`);
++ err.code = code;
++ return err;
++}
++
++/** Where a net.Socket#connect call is going: { host, port } or { path }. */
++function connectTarget(args) {
++ let a = args[0];
++ if (Array.isArray(a)) a = a[0]; // net.connect() hands Socket#connect its normalized [options, cb]
++ if (a && typeof a === 'object') return a.path != null ? { path: String(a.path) } : { host: a.host, port: Number(a.port) };
++ if (typeof a === 'string' && !/^\d+$/.test(a)) return { path: a };
++ return { host: typeof args[1] === 'string' ? args[1] : undefined, port: Number(a) };
++}
++
++const ALLOWED_PROGRAMS = new Set(['git']);
++
++function programOf(fn, args) {
++ const first = args[0];
++ if (fn === 'exec' || fn === 'execSync') return String(first || '').trim().split(/\s+/)[0] || '';
++ if (fn === 'fork') return 'node';
++ return String(first || '');
++}
++
++let installed = false;
++
++/**
++ * Replace the process's ways out with ones that refuse (drill only). `port` is the drill's HTTP
++ * port: the one address the process may connect to (itself, on loopback) and the one it may listen on.
++ */
++function installGuards({ port = Number(process.env.PORT) } = {}) {
++ if (installed) return;
++ installed = true;
++ const net = require('net');
++ const util = require('util');
++
++ const isSelf = (t) => t && t.path == null && t.port === port && (t.host == null || isLoopbackHost(t.host));
++
++ const connect = net.Socket.prototype.connect;
++ net.Socket.prototype.connect = function drillConnect(...args) {
++ const t = connectTarget(args);
++ if (isSelf(t)) return connect.apply(this, args);
++ const where = t.path != null ? t.path : `${t.host || 'localhost'}:${t.port}`;
++ note('connection', where);
++ const err = drillError(`connect ECONNREFUSED ${where}`, 'ECONNREFUSED');
++ err.syscall = 'connect';
++ process.nextTick(() => this.destroy(err));
++ return this;
++ };
++
++ if (typeof globalThis.fetch === 'function') {
++ const fetch = globalThis.fetch;
++ globalThis.fetch = function drillFetch(input, init) {
++ let url = null;
++ try { url = new URL(typeof input === 'string' || input instanceof URL ? String(input) : input.url); } catch { /* */ }
++ if (url && isSelf({ host: url.hostname, port: Number(url.port || (url.protocol === 'https:' ? 443 : 80)) })) return fetch(input, init);
++ note('fetch', url ? url.origin + url.pathname : String(input));
++ return Promise.reject(new TypeError('fetch failed', { cause: drillError(`connect ECONNREFUSED ${url ? url.host : ''}`, 'ECONNREFUSED') }));
++ };
++ }
++
++ const cp = require('child_process');
++ for (const fn of ['spawn', 'spawnSync', 'exec', 'execSync', 'execFile', 'execFileSync', 'fork']) {
++ const orig = cp[fn];
++ if (typeof orig !== 'function') continue;
++ const check = (args) => {
++ const program = programOf(fn, args);
++ if (ALLOWED_PROGRAMS.has(path.basename(program))) return;
++ note('program', program);
++ throw drillError(`${fn}(${program}) refused`, 'EDRILL');
++ };
++ const guarded = function (...args) { check(args); return orig.apply(this, args); };
++ const custom = orig[util.promisify.custom];
++ if (custom) guarded[util.promisify.custom] = (...args) => { check(args); return custom(...args); };
++ cp[fn] = guarded;
++ }
++
++ const dgram = require('dgram');
++ dgram.createSocket = function drillUdp() {
++ note('udp socket', 'dgram.createSocket');
++ throw drillError('UDP socket refused', 'EDRILL');
++ };
++
++ const listen = net.Server.prototype.listen;
++ net.Server.prototype.listen = function drillListen(...args) {
++ const a = args[0];
++ const p = a && typeof a === 'object' ? Number(a.port) : Number(a);
++ if (p === port && !(a && typeof a === 'object' && (a.fd != null || a.path != null))) return listen.apply(this, args);
++ note('listener', a && typeof a === 'object' ? JSON.stringify(a) : String(a));
++ throw drillError(`listen ${a && typeof a === 'object' ? JSON.stringify(a) : a} refused`, 'EDRILL');
++ };
++}
++
++// ── HTTP ─────────────────────────────────────────────────────
++
++/** Express middleware: reads only. */
++function readOnly(req, res, next) {
++ if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') return next();
++ res.status(403).json({ error: 'This is a restore-drill instance (LIVE_DRILL): it serves reads only', code: 'live.drill_read_only' });
++}
++
++/** Answer a WebSocket upgrade with 403 and close it. */
++function refuseUpgrade(socket) {
++ const body = 'restore-drill instance (LIVE_DRILL): no WebSockets\n';
++ try {
++ socket.write(`HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Type: text/plain\r\nContent-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`, () => socket.destroy());
++ } catch {
++ try { socket.destroy(); } catch { /* */ }
++ }
++}
++
++module.exports = {
++ enabled,
++ parse,
++ problems,
++ assertSafe,
++ DrillRefused,
++ installGuards,
++ readOnly,
++ refuseUpgrade,
++ blocked,
++ isLoopbackHost,
++ PRODUCTION_ROOT,
++ REPO_ROOT,
++};
ddiff --git a/server/home/routes.js b/server/home/routes.js
iindex 14441e14..a5327183 100644
---- a/server/home/routes.js
++++ b/server/home/routes.js
@@@ -426,8 +426,9 @@ function _latestUpdate() {
if (Date.now() - _updateCache.at >= 5 * 60_000) _refreshLatestUpdate(); // never awaited
return _updateCache.data;
}
--// Resolve it once at startup so the very first home load already has it.
--setTimeout(_refreshLatestUpdate, 1500).unref?.();
++// Resolve it once at startup so the very first home load already has it (a restore drill runs no
++// timers; there the first /pulse asks git).
++if (!require('../drill').enabled) setTimeout(_refreshLatestUpdate, 1500).unref?.();
// ── "While you were away" / "Lately on OpenVibe" digest — for everyone ────────
// ?since=<ISO> (the client remembers its own last visit; anonymous / first visit → last 48h).
@@@ -573,4 +574,5 @@ module.exports.FALLBACK_QUIPS = FALLBACK_QUIPS;
// Warm the hero caches once boot has finished (~6s on production). Every deploy restarts the process with empty caches, and
// the first home page load after it used to wait on every upstream call cold (~2.3s measured right
// after a deploy). The timer is unref'd so it never holds the process open.
--setTimeout(() => { buildHero(null).catch(() => {}); }, 15000).unref?.();
++// A restore drill runs no timers (and its hero would only find Media and Network down).
++if (!require('../drill').enabled) setTimeout(() => { buildHero(null).catch(() => {}); }, 15000).unref?.();
ddiff --git a/server/index.js b/server/index.js
iindex 91fe7ac7..70678cd2 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -44,12 +44,29 @@ const fs = require('fs');
// Load env before anything else
require('dotenv').config({ path: path.resolve(__dirname, '../.env') });
++// Restore-drill mode (LIVE_DRILL=1, `ovhost drill live`): refuse an unsafe environment before
++// anything opens a file, then cut every way out of the process but its own HTTP port. The instance
++// serves reads from a restored copy of the database and starts nothing else (server/drill.js).
++const drill = require('./drill');
++if (drill.enabled) {
++ try {
++ drill.assertSafe();
++ } catch (err) {
++ console.error(`[Drill] ${err.message}`);
++ process.exit(1);
++ }
++ drill.installGuards();
++ console.log(`[Drill] LIVE_DRILL: restore-drill instance on ${process.env.HOST}:${process.env.PORT}, database ${path.resolve(process.env.DB_PATH)}, data ${path.resolve(process.env.DATA_DIR)}. Reads only; no background work, sockets or outbound connections.`);
++}
++const paths = require('./paths');
++
const express = require('express');
const http = require('http');
const cors = require('cors');
const helmet = require('helmet');
const cookieParser = require('cookie-parser');
--const rateLimit = require('express-rate-limit');
++// A restore drill has one loopback client and answers reads only: no limiter (and so no limiter timers).
++const rateLimit = drill.enabled ? () => (req, res, next) => next() : require('express-rate-limit');
const config = require('./config');
const assets = require('./web/assets');
// Starts the event-loop delay histogram at boot so the first diagnostics window is complete.
@@@ -239,6 +256,9 @@ function hasActiveLiveFeed(stream) {
// ── Middleware ────────────────────────────────────────────────
app.set('trust proxy', 2); // Two hops: Cloudflare → nginx → Node
++// A restore-drill instance answers reads only: 403 for every other method, on every path.
++if (drill.enabled) app.use(drill.readOnly);
++
// RTMP FLV is now proxied same-origin via /api/streams/rtmp-proxy/:id.flv — no external CSP entry needed
// The hosted browser WHIP publisher (/whip-publisher.html) POSTs its SDP offer to the
@@@ -358,12 +378,15 @@ const uploadLimiter = rateLimit({
});
// ── Analytics Tracking ────────────────────────────────────────
const BetterSqlite3 = require('better-sqlite3');
--const analyticsDbPath = path.join(__dirname, '..', 'data', 'analytics.db');
++// <data dir>/analytics.db (ANALYTICS_DB_PATH overrides it outside a drill; server/paths.js).
++const analyticsDbPath = paths.analyticsDbPath();
++fs.mkdirSync(path.dirname(analyticsDbPath), { recursive: true });
const analyticsDb = new BetterSqlite3(analyticsDbPath);
analyticsDb.pragma('journal_mode = WAL');
--const analytics = new analyticsModule.AnalyticsTracker(analyticsDb, 'live', { retention: false }); // prune: job 8b2 below
++// A drill records no page views and runs no flush/aggregate timers (its analytics.db is its own, empty).
++const analytics = new analyticsModule.AnalyticsTracker(analyticsDb, 'live', { retention: false, timers: !drill.enabled }); // prune: job 8b2 below
app.locals.analytics = analytics;
--app.use(analytics.middleware());
++if (!drill.enabled) app.use(analytics.middleware());
app.use('/api/', apiLimiter);
app.use('/api/auth/login', authLimiter);
@@@ -515,11 +538,10 @@ app.use((req, res, next) => {
// unfilled route markers and unversioned asset URLs (a request without Accept: text/html skips SEO).
app.use(express.static(assets.PUBLIC_DIR, { index: false, setHeaders: (res, filePath) => { if (filePath.endsWith('.html')) noCacheHeaders(res); } }));
--// Ensure data directories exist. VOD/clip/paste/thumbnail files live in
--// OpenVibe.Media now; what remains is Live-local state (live thumbs, emotes,
++// Ensure data directories exist (all under the data directory, server/paths.js). VOD/clip/paste/
++// thumbnail files live in OpenVibe.Media now; what remains is Live-local state (live thumbs, emotes,
// avatars, offline screens) + the local song-request media cache.
--['./data', './data/live-thumbs', './data/emotes', './data/avatars', './data/offline', './data/media', './data/media/cache'].forEach(dir => {
-- const fullPath = path.resolve(dir);
++[paths.dataDir(), paths.data('live-thumbs'), paths.data('emotes'), paths.data('avatars'), paths.data('offline'), paths.data('media'), paths.data('media', 'cache')].forEach(fullPath => {
if (!fs.existsSync(fullPath)) fs.mkdirSync(fullPath, { recursive: true });
});
@@@ -527,19 +549,19 @@ app.use(express.static(assets.PUBLIC_DIR, { index: false, setHeaders: (res, file
// Locally cached song-request media. Only the cache subtree is public: this directory has held
// operational files before (the yt-dlp cookie jar, which was therefore downloadable by anyone),
// and a static mount over a directory that other code writes into is a standing invitation.
--app.use('/media', express.static(path.resolve('./data/media'), {
++app.use('/media', express.static(paths.data('media'), {
dotfiles: 'deny',
index: false,
setHeaders: (res) => res.setHeader('X-Content-Type-Options', 'nosniff'),
}));
// AI moment frames (one small JPEG per stream memory, see server/ai/stream-memory-job.js)
--app.use('/data/ai-moments', express.static(path.resolve(process.env.AI_MOMENTS_PATH || './data/ai-moments'), {
++app.use('/data/ai-moments', express.static(paths.dir('AI_MOMENTS_PATH', 'ai-moments'), {
maxAge: '30d', immutable: true, index: false, dotfiles: 'deny',
}));
// Arena portraits (AI-generated fighting-game character art, see server/arena)
--app.use('/data/arena', express.static(path.resolve(process.env.ARENA_IMAGE_PATH || './data/arena'), {
++app.use('/data/arena', express.static(paths.dir('ARENA_IMAGE_PATH', 'arena'), {
maxAge: '7d', immutable: true, index: false, dotfiles: 'deny',
setHeaders: (res) => res.setHeader('Content-Type', 'image/png'),
}));
@@@ -588,7 +610,7 @@ app.use('/internal', require('./internal/routes'));
const IMAGE_EXT_MIME = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.avif': 'image/avif', '.svg': 'image/svg+xml' };
// Serve avatar files (force image Content-Type, prevent XSS via spoofed extensions)
--app.use('/data/avatars', express.static(path.resolve('./data/avatars'), {
++app.use('/data/avatars', express.static(paths.data('avatars'), {
setHeaders: (res, filePath) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Content-Disposition', 'inline');
@@@ -633,7 +655,7 @@ app.get('/p/:slug/raw', (req, res) => {
});
// Offline-screen assets (channel offline background: webp images + transcoded webm)
--app.use('/data/offline', express.static(path.resolve('./data/offline'), {
++app.use('/data/offline', express.static(paths.data('offline'), {
maxAge: '1h',
setHeaders: (res, filePath) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
@@@ -761,8 +783,10 @@ const readiness = observability.createLiveReadiness({
bootComplete: () => _bootComplete,
dbQuery: () => db.get('SELECT 1 AS ok'),
sfuReady: () => webrtcSFU.ready === true && !!webrtcSFU.worker,
-- mediaUrl: mediaClient.MEDIA_URL,
++ // A drill asks no other service anything, Media's health included.
++ mediaUrl: drill.enabled ? null : mediaClient.MEDIA_URL,
networkKey: () => require('./auth/auth').getNetworkPublicKey(),
++ drill: drill.enabled,
});
app.get('/api/ready', readiness.handler);
@@@ -1020,6 +1044,9 @@ app.use((err, req, res, _next) => {
// ── WebSocket Upgrade Handler ────────────────────────────────
server.on('upgrade', (req, socket, head) => {
++ // A restore-drill instance has no WebSocket servers: chat, broadcast, calls and controls are live
++ // traffic, and a drill only answers reads.
++ if (drill.enabled) return drill.refuseUpgrade(socket);
const url = req.url || '';
const origin = normalizeOrigin(req.headers.origin);
@@@ -1068,6 +1095,8 @@ async function start() {
console.log(' ╚══════════════════════════════════════════╝');
console.log('');
++ if (drill.enabled) return startDrill();
++
await config.refreshRegistry();
allowedOrigins = getAllowedOrigins();
console.log('[Server] Effective BASE_URL:', config.baseUrl);
@@@ -1530,8 +1559,39 @@ async function start() {
}, { jitterMs: 30 * 1000 });
}
++/**
++ * Boot a restore-drill instance (LIVE_DRILL): open the restored database, bring its schema up to
++ * this release the way any boot does, and serve HTTP on HOST:PORT. Nothing else from start() runs:
++ * no registry refresh (config comes from the env), no seeding, no chat drain, no socket servers, no
++ * RTMP/SFU/JSMPEG, no restream or relay resume, no heartbeat refresh, no jobs of any kind.
++ */
++function startDrill() {
++ db.initDb();
++ cosmeticsModule.ensureTables();
++ require('./game/tags').ensureTagTables();
++ require('./chat/dm').ensureTables();
++ console.log(`[Drill] Database ready: ${paths.dbPath()}`);
++ // Its port taken: stop (the process-wide handler would log EADDRINUSE and keep running unready).
++ server.once('error', (err) => { console.error(`[Drill] HTTP server: ${err.message}`); process.exit(1); });
++ // Never fd 3: a drill does not serve on a socket systemd handed over (assertSafe refuses that too).
++ server.listen({ port: config.port, host: config.host }, () => {
++ _bootComplete = true;
++ console.log(`[Drill] Ready: http://${config.host}:${config.port} (reads only)`);
++ });
++}
++
// ── Graceful Shutdown ────────────────────────────────────────
function shutdown() {
++ if (drill.enabled) {
++ // Nothing was started but the HTTP server and the two databases.
++ console.log('[Drill] Shutting down');
++ _bootComplete = false;
++ try { analytics.destroy(); analyticsDb.close(); } catch { /* */ }
++ server.close(() => { try { db.close(); } catch { /* */ } process.exit(0); });
++ try { server.closeAllConnections(); } catch { /* */ }
++ setTimeout(() => process.exit(0), 3000).unref();
++ return;
++ }
console.log('\n[Server] Shutting down...');
// Notify all chat clients before closing connections
ddiff --git a/server/integrations/robotstreamer-service.js b/server/integrations/robotstreamer-service.js
iindex 51456961..fb8abccc 100644
---- a/server/integrations/robotstreamer-service.js
++++ b/server/integrations/robotstreamer-service.js
@@@ -41,7 +41,8 @@ class RobotStreamerService {
this._rsViewerCounts = new Map();
this.publishProxy = new WebSocket.Server({ noServer: true, maxPayload: 512 * 1024, perMessageDeflate: false });
this.publishProxy.on('connection', (ws, req, ctx) => this._handlePublishConnection(ws, req, ctx));
-- this._startRsViewerPolling();
++ // A restore drill (LIVE_DRILL) polls nothing: it has no bridges and makes no outbound calls.
++ if (!require('../drill').enabled) this._startRsViewerPolling();
}
/** Extract a robot's live viewer count from a robot_page_load response. */
ddiff --git a/server/media-proxy/asset-sync.js b/server/media-proxy/asset-sync.js
iindex 3df706fe..39e29527 100644
---- a/server/media-proxy/asset-sync.js
++++ b/server/media-proxy/asset-sync.js
@@@ -16,8 +16,9 @@ const path = require('path');
const db = require('../db/database');
const media = require('../media-client');
--const EMOTE_DIR = path.resolve(process.env.EMOTES_PATH || './data/emotes');
--const SOUND_DIR = path.resolve(process.env.SOUNDS_PATH || './data/sounds');
++const paths = require('../paths');
++const EMOTE_DIR = paths.dir('EMOTES_PATH', 'emotes');
++const SOUND_DIR = paths.dir('SOUNDS_PATH', 'sounds');
const MIME_BY_EXT = {
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',
'.webp': 'image/webp', '.avif': 'image/avif',
ddiff --git a/server/media-proxy/live-thumbs.js b/server/media-proxy/live-thumbs.js
iindex c9c70dc2..9e6cae64 100644
---- a/server/media-proxy/live-thumbs.js
++++ b/server/media-proxy/live-thumbs.js
@@@ -18,7 +18,7 @@ const WebSocket = require('ws');
const config = require('../config');
const db = require('../db/database');
--const THUMB_DIR = path.resolve(process.env.LIVE_THUMBS_PATH || './data/live-thumbs');
++const THUMB_DIR = require('../paths').dir('LIVE_THUMBS_PATH', 'live-thumbs');
const THUMB_WIDTH = 640;
const THUMB_QUALITY = 6;
const LIVE_THUMB_MIN_INTERVAL_MS = 120000;
ddiff --git a/server/media-proxy/pastes.js b/server/media-proxy/pastes.js
iindex 411c54bf..cca94e83 100644
---- a/server/media-proxy/pastes.js
++++ b/server/media-proxy/pastes.js
@@@ -38,7 +38,8 @@ const router = express.Router();
const shotUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 15 * 1024 * 1024 } });
// Anonymous pastes and comments are allowed, but Media does not rate-limit app-key calls, so the
// limit for anonymous writes lives here.
--const anonWriteLimiter = require('express-rate-limit')({
++// (A restore drill refuses every write, so it has no limiter and no limiter timer.)
++const anonWriteLimiter = require('../drill').enabled ? (req, res, next) => next() : require('express-rate-limit')({
windowMs: 10 * 60 * 1000,
max: 20,
standardHeaders: true,
ddiff --git a/server/media/media-downloader.js b/server/media/media-downloader.js
iindex 5492fabc..a1f2b312 100644
---- a/server/media/media-downloader.js
++++ b/server/media/media-downloader.js
@@@ -23,7 +23,8 @@ function getDb() {
// ── Config ──────────────────────────────────────────────────
const YTDLP_PATH = process.env.YTDLP_PATH || '/usr/local/bin/yt-dlp';
--const CACHE_DIR = path.resolve('./data/media/cache');
++const paths = require('../paths');
++const CACHE_DIR = paths.data('media', 'cache');
/**
* The yt-dlp cookie jar.
*
@@@ -35,9 +36,10 @@ const CACHE_DIR = path.resolve('./data/media/cache');
* first load so an upgrade does not silently lose a working configuration — and deletes the old
* copy, which is the part that actually closes the hole.
*/
--const COOKIES_PATH = path.resolve('./data/ytdlp/cookies.txt');
--const LEGACY_COOKIES_PATH = path.resolve('./data/media/cookies.txt');
++const COOKIES_PATH = paths.data('ytdlp', 'cookies.txt');
++const LEGACY_COOKIES_PATH = paths.data('media', 'cookies.txt');
(function migrateCookieJar() {
++ if (require('../drill').enabled) return; // a restore drill touches no files at load
try {
fs.mkdirSync(path.dirname(COOKIES_PATH), { recursive: true });
if (fs.existsSync(LEGACY_COOKIES_PATH)) {
@@@ -156,7 +158,8 @@ function commonArgs() {
// (a remote proxy cannot reach this machine's internal network either).
const egress = require('../net/egress');
let _egressUrl = null;
--const _egressReady = egress.proxy().then((url) => { _egressUrl = url; return url; })
++// A restore drill (LIVE_DRILL) runs no downloads and opens no listener besides its HTTP port.
++const _egressReady = require('../drill').enabled ? Promise.resolve(null) : egress.proxy().then((url) => { _egressUrl = url; return url; })
.catch((e) => { console.warn('[MediaDownloader] egress proxy unavailable:', e.message); return null; });
function ready() { return _egressReady; }
@@@ -545,17 +548,19 @@ function purgeCache() {
} catch {}
}
--// Purge on startup and periodically
--purgeCache();
--setInterval(purgeCache, 60 * 60 * 1000); // Every hour
++// Purge on startup and periodically (never in a restore drill: no timers, no file work)
++if (!require('../drill').enabled) {
++ purgeCache();
++ setInterval(purgeCache, 60 * 60 * 1000); // Every hour
--// Clean up expired URL cache entries periodically
--setInterval(() => {
-- const now = Date.now();
-- for (const [key, val] of urlCache) {
-- if (now >= val.expiresAt) urlCache.delete(key);
-- }
--}, 30 * 60 * 1000);
++ // Clean up expired URL cache entries periodically
++ setInterval(() => {
++ const now = Date.now();
++ for (const [key, val] of urlCache) {
++ if (now >= val.expiresAt) urlCache.delete(key);
++ }
++ }, 30 * 60 * 1000);
++}
/**
* Run yt-dlp with given args. Returns stdout.
ddiff --git a/server/monetization/routes.js b/server/monetization/routes.js
iindex 4809e17f..f59c8b36 100644
---- a/server/monetization/routes.js
++++ b/server/monetization/routes.js
@@@ -278,7 +278,7 @@ router.delete('/goals/:id', requireAuth, (req, res) => {
if (g && g.image_url && /^\/data\/offline\//.test(g.image_url)) {
try {
const fs = require('fs'); const path = require('path');
-- const p = path.join(process.env.OFFLINE_SCREEN_PATH || './data/offline', path.basename(g.image_url));
++ const p = path.join(require('../paths').dir('OFFLINE_SCREEN_PATH', 'offline'), path.basename(g.image_url));
if (fs.existsSync(p)) fs.unlinkSync(p);
} catch { /* orphan is harmless */ }
}
ddiff --git a/server/net/turn.js b/server/net/turn.js
iindex 39141420..5001b463 100644
---- a/server/net/turn.js
++++ b/server/net/turn.js
@@@ -14,6 +14,8 @@ const config = require('../config');
const TTL_SECONDS = 3600;
function turnCredentials(tag = 'anon') {
++ // A restore-drill instance (LIVE_DRILL) hands out no credential for production's TURN server.
++ if (require('../drill').enabled) return null;
const secret = String(process.env.TURN_AUTH_SECRET || '').trim();
if (secret) {
const username = `${Math.floor(Date.now() / 1000) + TTL_SECONDS}:${String(tag).replace(/[^A-Za-z0-9_-]/g, '')}`;
ddiff --git a/server/paths.js b/server/paths.js
nnew file mode 100644
iindex 00000000..5b7f1a15
---- /dev/null
++++ b/server/paths.js
@@@ -0,0 +1,46 @@
++'use strict';
++/**
++ * Where Live keeps its files.
++ *
++ * Everything Live writes lives under one data directory: DATA_DIR, or ./data relative to the
++ * working directory (the checkout in production, where the unit's WorkingDirectory is
++ * /opt/openvibe.live). That is what lets a second instance (a restore drill, a test) run next to
++ * production without touching production's files: point DATA_DIR and DB_PATH somewhere else.
++ *
++ * A few locations also have their own variable (EMOTES_PATH, LIVE_THUMBS_PATH, …). In a restore drill
++ * (LIVE_DRILL, see ./drill.js) those are ignored: the drill loads the production env file, whose
++ * values point at production's directories. Only DATA_DIR and DB_PATH count there.
++ */
++const path = require('path');
++const drill = require('./drill');
++
++/** The data directory (absolute). */
++function dataDir() {
++ return path.resolve(process.env.DATA_DIR || './data');
++}
++
++/** A path under the data directory. */
++function data(...parts) {
++ return path.join(dataDir(), ...parts);
++}
++
++/**
++ * A location with its own override variable (e.g. dir('EMOTES_PATH', 'emotes')): the variable when
++ * set (never in a drill), else <data dir>/<parts>.
++ */
++function dir(envName, ...parts) {
++ const v = !drill.enabled && envName ? process.env[envName] : '';
++ return v ? path.resolve(v) : data(...parts);
++}
++
++/** The main SQLite database: DB_PATH, else <data dir>/live.db. */
++function dbPath() {
++ return process.env.DB_PATH ? path.resolve(process.env.DB_PATH) : data('live.db');
++}
++
++/** Page-view analytics (openvibe-shared/analytics): ANALYTICS_DB_PATH, else <data dir>/analytics.db. */
++function analyticsDbPath() {
++ return dir('ANALYTICS_DB_PATH', 'analytics.db');
++}
++
++module.exports = { dataDir, data, dir, dbPath, analyticsDbPath };
ddiff --git a/server/streaming/routes.js b/server/streaming/routes.js
iindex f8e783f2..3bb9bfab 100644
---- a/server/streaming/routes.js
++++ b/server/streaming/routes.js
@@@ -42,7 +42,7 @@ const { spawn } = require('child_process');
let sharp; try { sharp = require('sharp'); } catch { /* optional */ }
// Offline-screen asset storage + upload/transcode
--const OFFLINE_DIR = process.env.OFFLINE_SCREEN_PATH || './data/offline';
++const OFFLINE_DIR = require('../paths').dir('OFFLINE_SCREEN_PATH', 'offline');
try { fs.mkdirSync(OFFLINE_DIR, { recursive: true }); } catch { /* exists */ }
const offlineUpload = multer({
storage: multer.diskStorage({
ddiff --git a/server/streaming/rtmp-server.js b/server/streaming/rtmp-server.js
iindex c857f5ba..623e2734 100644
---- a/server/streaming/rtmp-server.js
++++ b/server/streaming/rtmp-server.js
@@@ -60,7 +60,7 @@ class RTMPServer extends EventEmitter {
// edge drops it), but that is somebody else's firewall, not ours, so the origin
// is narrowed here as the part we control.
allow_origin: config.baseUrl || 'http://127.0.0.1',
-- mediaroot: './data/media',
++ mediaroot: require('../paths').data('media'),
},
// NOTE: NMS trans server crashes on v2.7.4 with 'version is not defined'
// in node_trans_server.js. HTTP-FLV playback works without HLS transcoding.
ddiff --git a/server/web/observability.js b/server/web/observability.js
iindex f1170c4a..716c79eb 100644
---- a/server/web/observability.js
++++ b/server/web/observability.js
@@@ -76,7 +76,7 @@ function registerDomainGauges(registry, { liveStreams, wsServers = {}, outboxSta
* GET <mediaUrl>/healthz, cached 30 s
* networkKey() -> PEM or null optional (sign-in: RS256 key from OpenVibe.Network)
*/
--function createLiveReadiness({ release, bootComplete, dbQuery, sfuReady, mediaUrl, networkKey, fetchImpl = globalThis.fetch }) {
++function createLiveReadiness({ release, bootComplete, dbQuery, sfuReady, mediaUrl, networkKey, drill = false, fetchImpl = globalThis.fetch }) {
return createReadiness({
service: 'live',
release: release && release.release,
@@@ -87,6 +87,8 @@ function createLiveReadiness({ release, bootComplete, dbQuery, sfuReady, mediaUr
{
name: 'media', required: false, cacheMs: 30000, timeoutMs: 2000, description: 'OpenVibe.Media (VODs, clips, thumbnails)',
check: async () => {
++ // No URL: not asked (a restore drill talks to no other service).
++ if (!mediaUrl) return 'not checked (restore drill)';
const res = await fetchImpl(`${mediaUrl}/healthz`, { signal: AbortSignal.timeout(2000) });
return res.ok ? true : `Media answered ${res.status}`;
},
@@@ -95,6 +97,8 @@ function createLiveReadiness({ release, bootComplete, dbQuery, sfuReady, mediaUr
],
// Fields the previous /api/ready served, so nothing that read them breaks.
details: (body) => ({
++ // A restore-drill instance (LIVE_DRILL) says so: it serves reads from a restored copy only.
++ ...(drill ? { mode: 'drill' } : {}),
uptime: Math.round(process.uptime()),
optional: {
sfu: body.checks.sfu.status === 'ok',
ddiff --git a/test/drill-mode.test.js b/test/drill-mode.test.js
nnew file mode 100644
iindex 00000000..f89da2a2
---- /dev/null
++++ b/test/drill-mode.test.js
@@@ -0,0 +1,307 @@
++'use strict';
++/**
++ * LIVE_DRILL=1 (server/drill.js): a restore-drill instance (`ovhost drill live`, OpenVibe.Host
++ * docs/restore-drills.md) serves reads from a restored copy of the database and does nothing else.
++ *
++ * - It refuses to start when DB_PATH is production's database, when DB_PATH or DATA_DIR are missing
++ * or inside the checkout, when HOST is not loopback or PORT is production's, and it refuses
++ * before any file is opened (a real `node server/index.js`, in a child process).
++ * - Booted for real (server/index.js, in this process) against a temp copy with production-like
++ * settings (EVENTS_URL, MEDIA_URL, a client secret, a TURN secret): one listener, its HTTP port on
++ * 127.0.0.1; no setInterval, no long timer from Live's code, an empty jobs registry; no program
++ * but git; no outbound connection.
++ * - The endpoints the Host inventory compares answer from the copy and touch nothing but it; a
++ * route that asks Media answers as if Media were down (the call is refused, never sent).
++ * - Writes answer 403 on every path (the copy's rows stay as they were), WebSocket upgrades 403, and
++ * no TURN credential is minted.
++ *
++ * Run: node test/drill-mode.test.js
++ */
++const assert = require('assert');
++const fs = require('fs');
++const http = require('http');
++const net = require('net');
++const os = require('os');
++const path = require('path');
++const { spawnSync, execFileSync } = require('child_process');
++
++const REPO = path.resolve(__dirname, '..');
++const SERVER = path.join(REPO, 'server');
++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'live-drill-test-'));
++const DB_PATH = path.join(tmp, 'db', 'live.db');
++const DATA_DIR = path.join(tmp, 'data');
++fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
++
++let failures = 0;
++async function check(name, fn) {
++ try { await fn(); console.log(` ✓ ${name}`); } catch (err) { failures++; console.error(` ✗ ${name}\n ${err.stack || err.message}`); }
++}
++
++function freePort() {
++ return new Promise((resolve) => { const s = net.createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => resolve(p)); }); });
++}
++
++// What the production env file sets: with these, a normal boot would publish events, call Media,
++// mint service tokens and TURN credentials. A drill must ignore all of it.
++const PROD_LIKE = {
++ NODE_ENV: 'production',
++ EVENTS_URL: 'http://127.0.0.1:9',
++ MEDIA_URL: 'http://127.0.0.1:9',
++ OV_OAUTH_CLIENT_SECRET: 's'.repeat(40),
++ INTERNAL_API_KEY: 'k'.repeat(40),
++ TURN_AUTH_SECRET: 't'.repeat(40),
++ OV_NETWORK_INTERNAL_URL: 'http://127.0.0.1:9',
++ EMOTES_PATH: path.join(REPO, 'data', 'emotes'), // per-path overrides are ignored in a drill
++};
++
++// ── 1. The environment guard (pure) ──────────────────────────
++const drillMod = require('../server/drill'); // LIVE_DRILL is unset here: `enabled` is false in this process until the boot below
++const safe = { DB_PATH, DATA_DIR, HOST: '127.0.0.1', PORT: '13000' };
++
++(async () => {
++ console.log('drill-mode: environment guard');
++ await check('LIVE_DRILL parses like a switch and is off by default', () => {
++ for (const v of ['1', 'true', 'on', 'yes', 'TRUE']) assert.strictEqual(drillMod.parse(v), true, v);
++ for (const v of ['', '0', 'false', 'off', undefined]) assert.strictEqual(drillMod.parse(v), false, String(v));
++ assert.strictEqual(drillMod.enabled, false);
++ });
++ await check('a safe environment passes', () => {
++ assert.deepStrictEqual(drillMod.problems(safe), []);
++ });
++ await check('refuses production\'s database, by the checkout\'s path and by /opt/openvibe.live', () => {
++ for (const p of [path.join(REPO, 'data', 'live.db'), '/opt/openvibe.live/data/live.db']) {
++ const list = drillMod.problems({ ...safe, DB_PATH: p });
++ assert.ok(list.some((m) => /production's database/.test(m)), `${p}: ${list.join('; ')}`);
++ }
++ const rel = drillMod.problems({ ...safe, DB_PATH: './data/live.db' }, { cwd: REPO });
++ assert.ok(rel.some((m) => /production's database/.test(m)), rel.join('; '));
++ });
++ await check('refuses missing DB_PATH / DATA_DIR and anything inside the checkout or /opt/openvibe.live', () => {
++ assert.ok(drillMod.problems({ ...safe, DB_PATH: '' }).some((m) => /DB_PATH is not set/.test(m)));
++ assert.ok(drillMod.problems({ ...safe, DATA_DIR: '' }).some((m) => /DATA_DIR is not set/.test(m)));
++ assert.ok(drillMod.problems({ ...safe, DATA_DIR: path.join(REPO, 'data') }).some((m) => /DATA_DIR .* is inside/.test(m)));
++ assert.ok(drillMod.problems({ ...safe, DB_PATH: path.join(REPO, 'drill.db') }).some((m) => /DB_PATH .* is inside/.test(m)));
++ assert.ok(drillMod.problems({ ...safe, DATA_DIR: '/opt/openvibe.live/data-drill' }).some((m) => /is inside \/opt\/openvibe\.live/.test(m)));
++ });
++ await check('refuses a symlink that leads into the checkout', () => {
++ const link = path.join(tmp, 'sneaky');
++ fs.symlinkSync(path.join(REPO, 'data'), link);
++ const list = drillMod.problems({ ...safe, DATA_DIR: link });
++ assert.ok(list.some((m) => /DATA_DIR .* is inside/.test(m)), list.join('; '));
++ fs.unlinkSync(link);
++ });
++ await check('refuses a non-loopback HOST, production\'s PORT and a socket handed over by systemd', () => {
++ for (const h of ['', '0.0.0.0', '::', '192.168.1.5']) assert.ok(drillMod.problems({ ...safe, HOST: h }).some((m) => /not loopback/.test(m)), h);
++ for (const h of ['127.0.0.1', '::1', 'localhost', '127.0.0.2']) assert.deepStrictEqual(drillMod.problems({ ...safe, HOST: h }), [], h);
++ assert.ok(drillMod.problems({ ...safe, PORT: '3000' }).some((m) => /production's port/.test(m)));
++ assert.ok(drillMod.problems({ ...safe, PORT: '' }).some((m) => /PORT is not set/.test(m)));
++ assert.ok(drillMod.problems({ ...safe, LISTEN_FDS: '1', LISTEN_PID: '42' }, { pid: 42 }).some((m) => /LISTEN_FDS/.test(m)));
++ });
++
++ // ── 2. A real `node server/index.js` refuses before touching anything ──
++ console.log('drill-mode: refusal at boot');
++ const refusalPort = await freePort();
++ const boot = (env) => spawnSync(process.execPath, [path.join(SERVER, 'index.js')], {
++ cwd: REPO, encoding: 'utf8', timeout: 30000,
++ env: { ...process.env, ...PROD_LIKE, LIVE_DRILL: '1', DATA_DIR, HOST: '127.0.0.1', PORT: String(refusalPort), ...env },
++ });
++ const stamp = (p) => { try { const s = fs.statSync(p); return `${s.size}:${s.mtimeMs}`; } catch { return 'absent'; } };
++ await check('DB_PATH = production\'s database: exit 1, the file untouched', () => {
++ const prod = path.join(REPO, 'data', 'live.db');
++ const before = [prod, `${prod}-wal`, `${prod}-shm`].map(stamp);
++ const r = boot({ DB_PATH: prod });
++ assert.strictEqual(r.status, 1, r.stdout + r.stderr);
++ assert.match(r.stderr, /refusing to start a restore-drill instance: DB_PATH .* is production's database/);
++ assert.deepStrictEqual([prod, `${prod}-wal`, `${prod}-shm`].map(stamp), before, 'production\'s database files are unchanged');
++ });
++ await check('HOST 0.0.0.0: exit 1 before the database is created', () => {
++ const other = path.join(tmp, 'never', 'live.db');
++ const r = boot({ DB_PATH: other, HOST: '0.0.0.0' });
++ assert.strictEqual(r.status, 1, r.stdout + r.stderr);
++ assert.match(r.stderr, /HOST \(0\.0\.0\.0\) is not loopback/);
++ assert.ok(!fs.existsSync(path.dirname(other)), 'nothing created');
++ });
++
++ // ── 3. The restored copy (a normal-mode process fills it, as production would have) ──
++ const seed = spawnSync(process.execPath, ['-e', `
++ const db = require('./server/db/database');
++ db.initDb();
++ require('./server/themes/theme-service').seedBuiltinThemes();
++ const star = db.createUser({ username: 'drillstar', password_hash: 'x', display_name: 'Drill Star', stream_key: 'secretkey123' }).lastInsertRowid;
++ const fan = db.createUser({ username: 'drillfan', password_hash: 'x', display_name: 'Drill Fan', stream_key: 'fankey456' }).lastInsertRowid;
++ db.ensureChannel(star); db.ensureChannel(fan);
++ db.run("INSERT INTO streams (user_id, title, protocol, is_live, viewer_count, started_at, last_heartbeat) VALUES (?, 'Restored stream', 'webrtc', 1, 3, datetime('now'), datetime('now'))", [star]);
++ db.run("INSERT INTO streams (user_id, title, protocol, is_live, started_at, ended_at) VALUES (?, 'Old stream', 'webrtc', 0, datetime('now', '-2 days'), datetime('now', '-2 days', '+1 hour'))", [star]);
++ db.run("INSERT INTO emotes (user_id, code, url, is_global) VALUES (?, 'drillWave', '/api/emotes/file/wave.png', 1)", [star]);
++ db.run('INSERT INTO follows (follower_id, streamer_id) VALUES (?, ?)', [fan, star]);
++ db.run("INSERT INTO chat_messages (stream_id, user_id, username, message) VALUES (1, ?, 'drillfan', 'hello from the backup')", [fan]);
++ db.initDb(); // production booted since these rows were written (its backfills already ran on them)
++ db.close();
++ `], { cwd: REPO, encoding: 'utf8', env: { ...process.env, DB_PATH, DATA_DIR, NODE_ENV: 'test' } });
++ assert.strictEqual(seed.status, 0, `seeding the copy failed:\n${seed.stdout}\n${seed.stderr}`);
++ const Database = require('better-sqlite3');
++ const COUNTED = ['users', 'channels', 'managed_streams', 'streams', 'follows', 'chat_messages'];
++ const counts = () => { const d = new Database(DB_PATH, { readonly: true }); try { return Object.fromEntries(COUNTED.map((t) => [t, d.prepare(`SELECT count(*) AS n FROM ${t}`).get().n])); } finally { d.close(); } };
++ const countsBefore = counts();
++
++ // ── 4. Boot the real server in drill mode, in this process, with spies ──
++ const port = await freePort();
++ Object.assign(process.env, PROD_LIKE, { LIVE_DRILL: '1', DB_PATH, DATA_DIR, HOST: '127.0.0.1', PORT: String(port) });
++ const inRepoServer = (stack) => {
++ const frame = stack.split('\n').slice(2).find((l) => l.includes(REPO) && !l.includes(`${path.sep}node_modules${path.sep}`) && !l.includes(__filename));
++ return frame && frame.includes(`${SERVER}${path.sep}`) ? frame.trim() : null;
++ };
++ const spied = { intervals: [], longTimeouts: [], listens: [], programs: [], udp: 0 };
++ const realSetInterval = global.setInterval;
++ global.setInterval = function (fn, ms, ...a) { spied.intervals.push({ ms, at: (new Error().stack.split('\n')[2] || '').trim() }); return realSetInterval(fn, ms, ...a); };
++ const realSetTimeout = global.setTimeout;
++ global.setTimeout = function (fn, ms, ...a) { if (ms >= 1000) { const at = inRepoServer(new Error().stack); if (at) spied.longTimeouts.push({ ms, at }); } return realSetTimeout(fn, ms, ...a); };
++ const realListen = net.Server.prototype.listen;
++ net.Server.prototype.listen = function (...a) { spied.listens.push(a[0]); return realListen.apply(this, a); };
++ const cp = require('child_process');
++ for (const fn of ['spawn', 'spawnSync', 'exec', 'execSync', 'execFile', 'execFileSync', 'fork']) {
++ const orig = cp[fn];
++ cp[fn] = function (...a) { spied.programs.push(fn === 'exec' || fn === 'execSync' ? String(a[0]).split(/\s+/)[0] : String(a[0])); return orig.apply(this, a); };
++ }
++ const analyticsInRepo = stamp(path.join(REPO, 'data', 'analytics.db'));
++
++ // drill.js read LIVE_DRILL (unset) when section 1 loaded it; the server must load it afresh.
++ delete require.cache[require.resolve('../server/drill')];
++ require('../server/index.js');
++ const drill = require('../server/drill');
++ const base = `http://127.0.0.1:${port}`;
++ const request = (method, p, { headers = {}, body = null } = {}) => new Promise((resolve, reject) => {
++ const req = http.request(base + p, { method, headers: { ...(body ? { 'Content-Type': 'application/json' } : {}), ...headers } }, (res) => {
++ let b = ''; res.on('data', (c) => { b += c; }); res.on('end', () => resolve({ status: res.statusCode, text: b, json: (() => { try { return JSON.parse(b); } catch { return null; } })() }));
++ });
++ req.on('upgrade', (res, socket) => { socket.destroy(); resolve({ status: 101, upgraded: true }); });
++ req.on('error', reject);
++ if (body) req.write(JSON.stringify(body));
++ req.end();
++ });
++ let ready = null;
++ for (let i = 0; i < 150 && !(ready && ready.status === 200); i++) {
++ try { ready = await request('GET', '/api/ready'); } catch { /* not listening yet */ }
++ if (!ready || ready.status !== 200) await new Promise((r) => realSetTimeout(r, 100));
++ }
++ // Let anything deferred by a few seconds show itself.
++ await new Promise((r) => realSetTimeout(r, 1500));
++
++ console.log('drill-mode: a booted drill instance');
++ await check('/api/ready is 200 and says mode: drill (Media not asked)', () => {
++ assert.strictEqual(ready && ready.status, 200, ready && ready.text);
++ assert.strictEqual(ready.json.ready, true);
++ assert.strictEqual(ready.json.mode, 'drill');
++ assert.strictEqual(ready.json.checks.media.error, 'not checked (restore drill)');
++ });
++ await check('one listener: its HTTP port on 127.0.0.1 (no RTMP, JSMPEG, SFU, WHIP, egress proxy)', () => {
++ assert.deepStrictEqual(spied.listens, [{ port, host: '127.0.0.1' }]);
++ const servers = process._getActiveHandles().filter((h) => h instanceof net.Server && h.listening);
++ assert.deepStrictEqual(servers.map((s) => s.address()), [{ address: '127.0.0.1', family: 'IPv4', port }]);
++ assert.strictEqual(process._getActiveHandles().filter((h) => h && h.constructor && h.constructor.name === 'Socket' && typeof h.send === 'function' && h.type).length, 0, 'no UDP socket');
++ });
++ await check('no timers: no setInterval at all, no timer of a second or more from Live\'s code, an empty jobs registry', () => {
++ assert.deepStrictEqual(spied.intervals, [], JSON.stringify(spied.intervals, null, 1));
++ assert.deepStrictEqual(spied.longTimeouts, [], JSON.stringify(spied.longTimeouts, null, 1));
++ assert.deepStrictEqual(require('../server/utils/jobs').snapshot(), []);
++ });
++ await check('no program but git ran, and nothing was refused (nothing tried to leave)', () => {
++ assert.ok(spied.programs.every((p) => path.basename(p) === 'git'), spied.programs.join(', '));
++ assert.deepStrictEqual(drill.blocked, []);
++ });
++ await check('files go to DATA_DIR: analytics.db there, the checkout\'s untouched', () => {
++ assert.ok(fs.existsSync(path.join(DATA_DIR, 'analytics.db')));
++ assert.strictEqual(stamp(path.join(REPO, 'data', 'analytics.db')), analyticsInRepo);
++ assert.strictEqual(require('../server/paths').dir('EMOTES_PATH', 'emotes'), path.join(DATA_DIR, 'emotes'), 'EMOTES_PATH from the env file is ignored');
++ });
++
++ console.log('drill-mode: reads');
++ const head = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], { cwd: REPO, encoding: 'utf8' }).trim();
++ await check('the endpoints the Host inventory compares answer from the copy and ask nobody', async () => {
++ const rel = await request('GET', '/release.json');
++ assert.strictEqual(rel.status, 200);
++ assert.strictEqual(rel.json.release, head);
++ const streams = await request('GET', '/api/streams');
++ assert.strictEqual(streams.status, 200);
++ assert.deepStrictEqual(streams.json.streams.map((s) => [s.title, s.user_id, s.username]), [['Restored stream', 1, 'drillstar']]);
++ assert.ok(!/secretkey123/.test(streams.text), 'no ingest key');
++ const themes = await request('GET', '/api/themes');
++ assert.strictEqual(themes.status, 200);
++ assert.ok(themes.json.themes.length > 0);
++ const emotes = await request('GET', '/api/emotes/global');
++ assert.deepStrictEqual(emotes.json.emotes.map((e) => e.code), ['drillWave']);
++ const live = await request('GET', '/api/streams/channel/drillstar/live');
++ assert.strictEqual(live.status, 200);
++ assert.strictEqual(live.json.streams.length, 1);
++ const chEmotes = await request('GET', '/api/emotes/channel/1');
++ assert.strictEqual(chEmotes.status, 200);
++ assert.deepStrictEqual(drill.blocked, [], 'none of these asked another service');
++ assert.deepStrictEqual(counts(), countsBefore, 'and none of them wrote');
++ });
++ await check('/api/streams/recently-online asks Media for thumbnails only (the inventory ignores vod_thumbnail)', async () => {
++ const online = await request('GET', '/api/streams/recently-online?limit=20');
++ assert.strictEqual(online.status, 200);
++ assert.deepStrictEqual(online.json.streamers.map((x) => [x.username, x.managed_streams.length, x.managed_streams[0].vod_thumbnail]), [['drillstar', 1, null]]);
++ assert.deepStrictEqual(drill.blocked.map((b) => b.target), ['http://127.0.0.1:9/api/v1/live/vods/latest-thumbs'], 'the thumbnail lookup, refused');
++ drill.blocked.length = 0;
++ });
++ await check('other reads from Live\'s own DB work (home, channel poll, SPA shell)', async () => {
++ for (const p of ['/api/home/stats-live', '/api/home/digest', '/api/home/featured', '/api/streams/channel/drillstar?pollOnly=1', '/api/health', '/']) {
++ const r = await request('GET', p, { headers: p === '/' ? { Accept: 'text/html' } : {} });
++ assert.strictEqual(r.status, 200, `${p}: ${r.status} ${r.text.slice(0, 200)}`);
++ }
++ const stats = await request('GET', '/api/home/stats-live');
++ assert.strictEqual(stats.json.liveNow === undefined ? 1 : stats.json.liveNow, 1);
++ assert.strictEqual(stats.json.chatMessages, 1);
++ });
++ await check('a route that asks Media answers as if Media were down: the call is refused, not sent', async () => {
++ const r = await request('GET', '/api/streams/channel/drillstar');
++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
++ assert.deepStrictEqual(r.json.vods, []);
++ assert.ok(drill.blocked.length > 0, 'the Media call was refused by the drill guard');
++ assert.ok(drill.blocked.every((b) => b.kind === 'fetch' || b.kind === 'connection'), JSON.stringify(drill.blocked));
++ });
++
++ console.log('drill-mode: writes, sockets, credentials');
++ await check('writes answer 403 on every path, and change nothing', async () => {
++ const before = counts();
++ const writes = [
++ ['POST', '/api/streams', { title: 'x' }], ['PUT', '/api/streams/1', { title: 'y' }], ['PATCH', '/api/streams/1', { title: 'y' }],
++ ['DELETE', '/api/streams/1', null], ['POST', '/api/chat/send', { message: 'hi' }], ['POST', '/api/auth/logout', null],
++ ['POST', '/internal/media-events', { type: 'media.vod.ready' }], ['POST', '/internal/media-webhook', {}], ['POST', '/whip/1', null],
++ ['POST', '/banned/continue', null], ['POST', '/api/csp-report', {}],
++ ];
++ for (const [m, p, body] of writes) {
++ const r = await request(m, p, { body });
++ assert.strictEqual(r.status, 403, `${m} ${p}: ${r.status}`);
++ assert.strictEqual(r.json && r.json.code, 'live.drill_read_only', `${m} ${p}`);
++ }
++ assert.deepStrictEqual(counts(), before);
++ });
++ await check('WebSocket upgrades are refused with 403', async () => {
++ for (const p of ['/ws/chat', '/ws/broadcast', '/ws/call', '/ws/control', '/ws/robotstreamer-publish']) {
++ const r = await request('GET', p, { headers: { Connection: 'Upgrade', Upgrade: 'websocket', 'Sec-WebSocket-Version': '13', 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==' } });
++ assert.strictEqual(r.status, 403, `${p} answered ${r.status}`);
++ assert.ok(!r.upgraded);
++ }
++ });
++ await check('no TURN credential is minted, even with TURN_AUTH_SECRET set', () => {
++ const turn = require('../server/net/turn');
++ assert.strictEqual(turn.turnCredentials('x'), null);
++ assert.deepStrictEqual(turn.turnEntries('turn:turn.example:3478', 'x'), []);
++ });
++ await check('the guards refuse a connection, a program and a listener that code might still try', async () => {
++ await assert.rejects(fetch('https://openvibe.media/healthz'), /fetch failed/);
++ const sock = net.connect({ host: '127.0.0.1', port: 9 });
++ const err = await new Promise((resolve) => sock.on('error', resolve));
++ assert.strictEqual(err.code, 'ECONNREFUSED');
++ assert.throws(() => cp.spawn('ffmpeg', ['-version']), (e) => e.code === 'EDRILL');
++ assert.throws(() => net.createServer().listen(1935), (e) => e.code === 'EDRILL');
++ assert.throws(() => require('dgram').createSocket('udp4'), (e) => e.code === 'EDRILL');
++ });
++
++ try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* */ }
++ if (failures) { console.error(`\n${failures} drill-mode check(s) failed`); process.exit(1); }
++ console.log('\ndrill-mode: all checks passed');
++ process.exit(0);
++})().catch((err) => { console.error(err); process.exit(1); });