diff --git a/.gitignore b/.gitignore index 13c5c25..a36d2c7 100644 --- a/.gitignore +++ b/.gitignore @@ -32,4 +32,6 @@ twitch-credentials-lumi.png .secrets .secrets-* DEVNOTES.md -.QWEN.md \ No newline at end of file +.QWEN.md +_*_changes.md +lumi_current.zip diff --git a/CHANGELOG.md b/CHANGELOG.md index 358c37c..0101f09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Lumi changelog +## 0.3.4 + +- Fixed production Companion pairing and download generation behind Lumi's private HTTPS reverse proxy, while continuing to reject forged forwarding headers from public clients. +- Reused the same guarded request-origin policy for Companion HTTP, pairing, settings, and WebSocket traffic, with explicit regression coverage for private proxy HTTPS, public spoofing, and malformed hosts. +- Standardized operational failures on Lumi's durable redacting logger, removed a placeholder metrics API that silently discarded events, and documented the actual logging and feature-owned metrics boundaries. +- Included the existing Lumi favicon and Qwen workspace housekeeping from the branch without changing preserved server or plugin data. + ## 0.3.3 - Isolated private tests in a dedicated libobs output, bypassed account OAuth stream-key mutation, retained crash-safe exact service restoration, and corrected OBS encoder ownership so failed outputs cannot corrupt a later retry. diff --git a/QWEN.md b/QWEN.md deleted file mode 100644 index e69de29..0000000 diff --git a/docs/LOGGING_STANDARD.md b/docs/LOGGING_STANDARD.md new file mode 100644 index 0000000..efecbee --- /dev/null +++ b/docs/LOGGING_STANDARD.md @@ -0,0 +1,68 @@ +# Logging standard + +Lumi uses the structured logger in `src/services/logger.js` for operational +events. It stores searchable entries for the admin UI, attaches request context, +redacts common credential fields, and publishes live admin updates. + +## Operational logging + +Core services create a named logger: + +```js +const { createLogger } = require("./logger"); +const logger = createLogger("updater"); + +logger.info("Update check completed", { version }, { + category: "updates", + event: "update_check_completed" +}); +``` + +Plugins receive a plugin-scoped `logger` in their `init()` dependencies. Use +that injected logger when practical. A plugin module that must log outside +`init()` may create a logger named `plugin:`. + +Use: + +- `debug` for detailed, low-value troubleshooting information. +- `info` for meaningful lifecycle and administrative actions. +- `warn` for degraded behavior that Lumi can continue through. +- `error` for a failed operation that may require attention. + +Pass an `Error` as the details argument when one is available. The logger +preserves its stack trace while applying credential redaction: + +```js +logger.error("Plugin refresh failed", error, { + event: "plugin_refresh_failed" +}); +``` + +Do not include passwords, tokens, cookies, pairing secrets, authorization +headers, or full request bodies in messages or metadata. Redaction is a safety +net, not a reason to collect secrets. + +## Metrics and high-frequency events + +Do not write high-frequency counters or per-frame events to the operational log. +There is no global metrics API. + +A feature may retain its own bounded metrics only when it also owns the storage, +retention, and inspection UI. Lumi AI is one example: +`plugins/lumi_ai/backend/metrics.js` aggregates AI timings and retains bounded +work history. Operational failures in such a feature still belong in the core +logger. + +## Console output + +Direct `console.*` calls are captured after `hookConsole()` starts, but they lose +the useful source and event metadata of a named logger. Prefer a named logger in +runtime code. Console output remains reasonable in standalone verification and +build scripts where the terminal is the intended consumer. + +## Retention and context + +Operational logs default to 30 days and at most 100,000 entries. Request +correlation is inherited through `withLogContext()` and logger `run()` scopes. +The logger redacts common sensitive keys and credential-looking text before an +entry is stored or published. diff --git a/docs/lumi-companion-transcription.md b/docs/lumi-companion-transcription.md index eafc0b5..47e6ee9 100644 --- a/docs/lumi-companion-transcription.md +++ b/docs/lumi-companion-transcription.md @@ -36,6 +36,10 @@ Production device HTTP and WebSocket traffic requires HTTPS/WSS. Plain HTTP/WebSocket is allowed only for a device paired from the exact matching loopback Lumi origin and only while both sides remain on loopback. +Lumi recognizes HTTPS terminated by a reverse proxy on loopback or an isolated +private proxy network. Forwarded protocol headers received directly from public +addresses are ignored, so only a trusted proxy can mark a request as HTTPS. + ## Models and workers The Admin page installs checksum-pinned model and worker artifacts only after diff --git a/knowledge/core/lumi-core.md b/knowledge/core/lumi-core.md index 00b8ebf..a194727 100644 --- a/knowledge/core/lumi-core.md +++ b/knowledge/core/lumi-core.md @@ -14,7 +14,7 @@ editable: false Lumi is the core web UI and bot runtime. ## Runtime Package: lumi-bot -Version: 0.3.3 +Version: 0.3.4 ## Routes - POST /api/diagnostics/v1/run - GET /api/events diff --git a/package-lock.json b/package-lock.json index 5ecda12..a06f782 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lumi-bot", - "version": "0.3.3", + "version": "0.3.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lumi-bot", - "version": "0.3.3", + "version": "0.3.4", "dependencies": { "adm-zip": "^0.6.0", "better-sqlite3": "^11.5.0", diff --git a/package.json b/package.json index b0883dc..1ea16db 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lumi-bot", - "version": "0.3.3", + "version": "0.3.4", "private": true, "type": "commonjs", "scripts": { diff --git a/plugins/auto-vc/index.js b/plugins/auto-vc/index.js index 0cea484..bfc3bd8 100644 --- a/plugins/auto-vc/index.js +++ b/plugins/auto-vc/index.js @@ -3,6 +3,9 @@ const fs = require("fs"); const ejs = require("ejs"); const { Permissions } = require("discord.js"); const { ensureUserForIdentity } = require("../../src/services/users"); +const { createLogger } = require("../../src/services/logger"); + +const logger = createLogger("auto-vc"); const PLUGIN_ID = "auto-vc"; const DEFAULT_TEMPLATE = "[username]'s room"; @@ -127,7 +130,7 @@ module.exports = { const attach = () => { bootstrapRooms(discordClient, db, state, settings).catch((error) => { - console.error("Auto VC bootstrap failed", error); + logger.error("Auto VC bootstrap failed", error); }); discordClient.on("voiceStateUpdate", (oldState, newState) => { handleVoiceStateUpdate(oldState, newState, db, settings, state); @@ -580,7 +583,7 @@ function handleVoiceStateUpdate(oldState, newState, db, settings, state) { if (lobby && newState.channelId !== oldState.channelId) { createRoomFromLobby(newState, lobby, db, settings, state, config).catch((error) => { - console.error("Auto VC creation failed", error); + logger.error("Auto VC creation failed", error); }); } @@ -923,7 +926,7 @@ async function deleteChannel(channel) { try { await channel.delete("Auto VC cleanup"); } catch (error) { - console.error("Failed to delete Auto VC channel", error); + logger.error("Failed to delete Auto VC channel", error); } } @@ -1361,12 +1364,12 @@ async function moveMemberToChannel(member, channel) { await member.voice.setChannel(channel); return true; } catch (error) { - console.error("Failed to move member", error); + logger.error("Failed to move member", error); try { await member.edit({ channel: channel.id }); return true; } catch (fallbackError) { - console.error("Fallback move failed", fallbackError); + logger.error("Fallback move failed", fallbackError); return false; } } @@ -1378,7 +1381,7 @@ function startNameRefreshTimer(discordClient, db, settings, state) { } const runSweep = () => { refreshRoomNames(discordClient, db, settings, state).catch((error) => { - console.error("Auto VC name refresh failed", error); + logger.error("Auto VC name refresh failed", error); }); }; runSweep(); @@ -1408,7 +1411,7 @@ async function refreshRoomNames(discordClient, db, settings, state) { const desiredName = buildRoomName(room.name_template, member, room.room_number, gameName); if (desiredName && desiredName !== channel.name) { await channel.setName(desiredName).catch((error) => { - console.error("Failed to update Auto VC name", error); + logger.error("Failed to update Auto VC name", error); }); } } @@ -1420,7 +1423,7 @@ function startSweepTimer(discordClient, db, settings, state) { } const runSweep = () => { sweepRooms(discordClient, db, state).catch((error) => { - console.error("Auto VC sweep failed", error); + logger.error("Auto VC sweep failed", error); }); }; runSweep(); diff --git a/plugins/birthday/index.js b/plugins/birthday/index.js index e55a656..f1d25ad 100644 --- a/plugins/birthday/index.js +++ b/plugins/birthday/index.js @@ -1,5 +1,8 @@ const crypto = require("crypto"); const path = require("path"); +const { createLogger } = require("../../src/services/logger"); + +const logger = createLogger("birthday"); const PLUGIN_ID = "birthday"; const TIMER_KEY = Symbol.for("lumi.birthday.interval"); @@ -539,9 +542,9 @@ function restartScheduler({ db, discordClient }) { if (!config.enabled) { return; } - checkBirthdays({ db, discordClient }).catch((error) => console.error("Birthday check failed", error)); + checkBirthdays({ db, discordClient }).catch((error) => logger.error("Birthday check failed", error)); global[TIMER_KEY] = setInterval(() => { - checkBirthdays({ db, discordClient }).catch((error) => console.error("Birthday check failed", error)); + checkBirthdays({ db, discordClient }).catch((error) => logger.error("Birthday check failed", error)); }, config.birthday_check_interval_minutes * 60 * 1000); } diff --git a/plugins/economy-framework/index.js b/plugins/economy-framework/index.js index a49d45b..82cca87 100644 --- a/plugins/economy-framework/index.js +++ b/plugins/economy-framework/index.js @@ -6,11 +6,14 @@ const express = require("express"); const multer = require("multer"); const EventEmitter = require("events"); const { ensureUserForIdentity } = require("../../src/services/users"); +const { createLogger } = require("../../src/services/logger"); const { cleanupUploadedFiles, validateUploadedFile } = require("../../src/services/upload-security"); +const logger = createLogger("economy-framework"); + const PLUGIN_ID = "economy-framework"; const LEGACY_STEM = ["echo", "nomy"].join(""); const LEGACY_PLUGIN_ID = `${LEGACY_STEM}-framework`; @@ -1423,7 +1426,7 @@ function startActivityRewardFlusher(db) { try { flushActivityRewards(db); } catch (error) { - console.error("Activity reward flush failed", error); + logger.error("Activity reward flush failed", error); } }, 60 * 1000); } @@ -1489,7 +1492,7 @@ function flushActivityRewards(db) { "DELETE FROM economy_activity_reward_hourly WHERE user_id = ? AND hour_start = ?" ).run(group.userId, group.hourStart); } catch (error) { - console.error("Failed to apply queued activity reward", error); + logger.error("Failed to apply queued activity reward", error); } } } diff --git a/plugins/expression-interaction/index.js b/plugins/expression-interaction/index.js index 1773528..654a1c0 100644 --- a/plugins/expression-interaction/index.js +++ b/plugins/expression-interaction/index.js @@ -1,5 +1,8 @@ const fs = require("fs"); const path = require("path"); +const { createLogger } = require("../../src/services/logger"); + +const logger = createLogger("expression-interaction"); const DEFAULT_ACTIONS = [ { id: "hug", verb: "hugs", past: "hugged" }, @@ -672,7 +675,7 @@ function writeCommandsManifest(config) { const target = path.join(pluginMeta.dir, "cmds.json"); fs.writeFileSync(target, JSON.stringify(manifest, null, 2), "utf8"); } catch (error) { - console.error("Failed to write expression command manifest", error); + logger.error("Failed to write expression command manifest", error); } } diff --git a/plugins/lumi_ai/index.js b/plugins/lumi_ai/index.js index d1bfd97..165d9c6 100644 --- a/plugins/lumi_ai/index.js +++ b/plugins/lumi_ai/index.js @@ -2,6 +2,7 @@ const fs = require("fs"); const path = require("path"); const express = require("express"); const { writeJsonAtomicSync } = require("../../src/services/safe-files"); +const { createLogger } = require("../../src/services/logger"); const { ensureDataDirs, resolveData } = require("./backend/paths"); const { getConfig, saveConfig, getRuntimeState } = require("./backend/config_manager"); const { detectHardware, estimateAllocation, performanceTuningHints } = require("./backend/hardware"); @@ -42,6 +43,7 @@ const { formatBytes, bytesFromMb, sanityCheckSize } = require("./backend/size_ut const { SOURCE_DEFAULTS } = require("./backend/controller"); const PLUGIN_ID = "lumi_ai"; +const logger = createLogger(`plugin:${PLUGIN_ID}`, { category: "plugin" }); const TOKEN_PRESETS = Object.freeze([ { label: "Tiny (256)", value: 256, description: "Small helper replies and minimal context." }, { label: "Very small (512)", value: 512, description: "Short replies and low memory usage." }, @@ -63,7 +65,7 @@ module.exports = { ensureDataDirs(); if (!repoIndexer.loadIndex()) { try { repoIndexer.refreshIndex(); } - catch (error) { console.warn("Lumi AI repository index initialization failed", error.message); } + catch (error) { logger.warn("Lumi AI repository index initialization failed", error); } } let config = getConfig(); metrics.configureRetention(config.work_history_retention); @@ -374,7 +376,7 @@ module.exports = { sanityCheckSize("Installed model", modelFileSize, 100 * 1024 ** 3), sanityCheckSize("Estimated GPU memory", bytesFromMb(gpuAllocation.estimated_gpu_memory_mb), 100 * 1024 ** 3) ].filter((check) => !check.valid); - for (const diagnostic of sizeDiagnostics) console.warn(`Lumi AI size diagnostic: ${diagnostic.message}`); + for (const diagnostic of sizeDiagnostics) logger.warn(`Lumi AI size diagnostic: ${diagnostic.message}`); const models = modelManifest.models.map((model) => ({ ...model, downloaded: fs.existsSync(resolveData("models", model.filename)), @@ -1651,7 +1653,7 @@ module.exports = { locals: { endpoint: `/plugins/${PLUGIN_ID}` } }); } else { - console.warn("Lumi AI assistant panel hook is unavailable; settings remain accessible."); + logger.warn("Lumi AI assistant panel hook is unavailable; settings remain accessible."); } ensureSidebarNavItem(settings); registerAssistantCommands({ @@ -1666,17 +1668,17 @@ module.exports = { }); writeCommandsManifest(plugin?.dir || __dirname, config); setImmediate(() => toolManager.loadEnabled().catch((error) => - console.error("Lumi AI tool loader failed", error) + logger.error("Lumi AI tool loader failed", error) )); if (config.enabled) { setImmediate(() => ensureGateRuntime().catch((error) => - console.error("Lumi AI gate runtime start failed", error) + logger.error("Lumi AI gate runtime start failed", error) )); } const state = getRuntimeState(); if (shouldAutoResume(config, state)) { - setImmediate(() => startRuntimes({ resume: true }).catch((error) => console.error("Lumi AI runtime resume failed", error))); + setImmediate(() => startRuntimes({ resume: true }).catch((error) => logger.error("Lumi AI runtime resume failed", error))); } return async () => { @@ -1799,7 +1801,7 @@ function deniedImprovement(req, res) { return sendImprovementError(req, res, 403, "Improvement Center access is not enabled for this account."); } function logImprovementActionError(req, error) { - console.error("Lumi AI feedback action failed", { + logger.error("Lumi AI feedback action failed", { review_id: cleanText(req.params?.id, 100), action: cleanText(req.body?.action, 30), user_id: cleanText(req.session?.user?.id, 100), diff --git a/plugins/lumi_transcription/backend/companion/gateway.js b/plugins/lumi_transcription/backend/companion/gateway.js index 3827f04..2ee96d8 100644 --- a/plugins/lumi_transcription/backend/companion/gateway.js +++ b/plugins/lumi_transcription/backend/companion/gateway.js @@ -1,6 +1,10 @@ const { WebSocketServer, WebSocket } = require("ws"); const { MAX_JSON_BYTES, MAX_AUDIO_PAYLOAD_BYTES, AUDIO_HEADER_BYTES, parseEnvelope, validateHello, parseAudioFrame, envelope } = require("./protocol"); const { insecureDeviceAllowed } = require("./device_store"); +const { + isSecureRequest, + requestOrigin +} = require("../../../../src/services/proxy-security"); const MAX_CONTROL_MESSAGES_PER_SECOND = 120; const MAX_AUDIO_MESSAGES_PER_SECOND = 200; @@ -15,15 +19,18 @@ class CompanionGateway { this.wss.on("connection", (socket, request, device) => this.connection(socket, request, device)); } upgrade(request, socket, head) { - const forwardedProto = String(request.headers["x-forwarded-proto"] || "").split(",")[0].trim(); - const proxyIsLocal = ["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(request.socket.remoteAddress); - const secure = Boolean(request.socket.encrypted) || (proxyIsLocal && forwardedProto === "https"); + const secure = isSecureRequest(request); const auth = this.devices.authenticate(request.headers.authorization, "transcription.capture.v1"); if (!auth.allowed) return reject(socket, auth.reason === "capability_revoked" ? 403 : 401, auth.reason); - const requestOrigin = `http://${request.headers.host || "invalid"}`; - if (!secure && !insecureDeviceAllowed(auth.device, requestOrigin, request.socket.remoteAddress)) return reject(socket, 426, "tls_required"); - const origin = request.headers.origin; - if (origin && !sameHostOrigin(origin, request.headers.host)) return reject(socket, 403, "origin_rejected"); + let requestOriginValue; + try { + requestOriginValue = requestOrigin(request); + } catch { + return reject(socket, 400, "invalid_host"); + } + if (!secure && !insecureDeviceAllowed(auth.device, requestOriginValue, request.socket.remoteAddress)) return reject(socket, 426, "tls_required"); + const browserOrigin = request.headers.origin; + if (browserOrigin && !sameHostOrigin(browserOrigin, request.headers.host)) return reject(socket, 403, "origin_rejected"); this.wss.handleUpgrade(request, socket, head, (ws) => this.wss.emit("connection", ws, request, auth.device)); } connection(socket, _request, device) { diff --git a/plugins/lumi_transcription/index.js b/plugins/lumi_transcription/index.js index ba4fd69..548294a 100644 --- a/plugins/lumi_transcription/index.js +++ b/plugins/lumi_transcription/index.js @@ -2,6 +2,10 @@ const express = require("express"); const ejs = require("ejs"); const fs = require("fs"); const path = require("path"); +const { + isSecureRequest, + requestOrigin +} = require("../../src/services/proxy-security"); const { DeviceStore } = require("./backend/companion/device_store"); const { insecureDeviceAllowed, sameLocalhostOrigin } = require("./backend/companion/device_store"); const { CompanionGateway } = require("./backend/companion/gateway"); @@ -98,25 +102,62 @@ module.exports = { runtime: runtimeManifest })); router.post("/api/pairing-package", requireAdmin, (req, res) => { + let host = ""; try { - const host = requestHost(req); + host = requestHost(req); + diagnosticLog.append?.({ kind: "companion_pairing", action: "request_start", scheme: host.startsWith("https") ? "https" : "http", host }); + if (!host.startsWith("https")) { + logger.warn?.( + "Companion pairing package requested over HTTP — check X-Forwarded-Proto proxy header", + { plugin: PLUGIN_ID, host }, + { event: "companion_http_pairing_request" } + ); + } const pairing = devices.issuePairing({ userId: req.session.user.id, host }); + diagnosticLog.append?.({ kind: "companion_pairing", action: "pairing_issued", pairing_id: pairing.pairing_id, scheme: host.startsWith("https") ? "https" : "http" }); const bootstrap = { format: "lumi-companion-bootstrap-v1", ...pairing, exchange_url: `${host}/plugins/${PLUGIN_ID}/api/pair`, ...companionOperatorMetadata(host) }; res.set("Cache-Control", "no-store"); res.attachment(`lumi-companion-${pairing.pairing_id}.lumi-pairing.json`); res.send(`${JSON.stringify(bootstrap, null, 2)}\n`); - } catch (error) { res.status(400).json({ ok: false, error: error.message }); } + } catch (error) { + diagnosticLog.append?.({ kind: "companion_pairing", action: "pairing_failed", error: error.message, scheme: host ? (host.startsWith("https") ? "https" : "http") : "invalid" }); + logger.error?.( + "Failed to generate companion pairing package", + { plugin: PLUGIN_ID, error: error.message }, + { event: "companion_pairing_failed" } + ); + res.status(400).json({ ok: false, error: error.message }); + } }); router.post("/api/companion/download", requireAdmin, async (req, res) => { + let host = ""; try { - const host = requestHost(req); + host = requestHost(req); + diagnosticLog.append?.({ kind: "companion_download", action: "request_start", scheme: host.startsWith("https") ? "https" : "http", host }); + if (!host.startsWith("https")) { + logger.warn?.( + "Companion download requested over HTTP — check X-Forwarded-Proto proxy header", + { plugin: PLUGIN_ID, host }, + { event: "companion_http_download_request" } + ); + } const pairing = devices.issuePairing({ userId: req.session.user.id, host }); + diagnosticLog.append?.({ kind: "companion_download", action: "pairing_issued_for_download", pairing_id: pairing.pairing_id }); const bootstrap = { format: "lumi-companion-bootstrap-v1", ...pairing, exchange_url: `${host}/plugins/${PLUGIN_ID}/api/pair`, ...companionOperatorMetadata(host) }; const bundle = await companionPackages.build({ ...pairing, bootstrap }); + diagnosticLog.append?.({ kind: "companion_download", action: "bundle_built", filename: bundle.filename, bytes: bundle.buffer?.length || 0 }); res.set("Cache-Control", "no-store"); res.attachment(bundle.filename); res.send(bundle.buffer); - } catch (error) { res.status(503).json({ ok: false, error: error.message }); } + } catch (error) { + diagnosticLog.append?.({ kind: "companion_download", action: "download_failed", error: error.message }); + logger.error?.( + "Failed to build Companion download bundle", + { plugin: PLUGIN_ID, error: error.message }, + { event: "companion_download_failed" } + ); + res.status(503).json({ ok: false, error: error.message }); + } }); const companionUpdateHandler = async (req, res) => { try { @@ -342,7 +383,7 @@ function companionOperatorMetadata(host) { }; } -function requireDeviceAccess(devices) { return (req, res, next) => { const auth = devices.authenticate(req.headers.authorization); if (!auth.allowed) return res.status(401).json({ error: "A paired device credential is required." }); if (!req.secure && !insecureDeviceAllowed(auth.device, requestHost(req), req.socket.remoteAddress)) return res.status(426).json({ error: "Companion requests require HTTPS unless this device was paired from the same localhost origin." }); req.lumiDevice = auth.device; next(); }; } +function requireDeviceAccess(devices) { return (req, res, next) => { const auth = devices.authenticate(req.headers.authorization); if (!auth.allowed) return res.status(401).json({ error: "A paired device credential is required." }); if (!isSecureRequest(req) && !insecureDeviceAllowed(auth.device, requestHost(req), req.socket.remoteAddress)) return res.status(426).json({ error: "Companion requests require HTTPS unless this device was paired from the same localhost origin." }); req.lumiDevice = auth.device; next(); }; } function compareVersions(left, right) { const parse = (value) => { const [version] = String(value || "0").split("+", 1); @@ -405,6 +446,6 @@ async function renderLumiPage(locals) { ]); return `${top}${body}${bottom}`; } -function requireSettingsAccess(devices) { return (req, res, next) => { if (req.session?.user?.isAdmin) return next(); const auth = devices.authenticate(req.headers.authorization, "transcription.settings.v1"); if (!auth.allowed) return res.status(401).json({ error: "A paired device credential is required." }); if (!req.secure && !insecureDeviceAllowed(auth.device, requestHost(req), req.socket.remoteAddress)) return res.status(426).json({ error: "Device settings synchronization requires HTTPS unless this device was paired from the same localhost origin." }); req.lumiDevice = auth.device; next(); }; } -function requestHost(req) { return `${req.protocol === "https" ? "https" : "http"}://${req.get("host")}`; } -function requirePairingTransport(devices) { return (req, res, next) => { if (req.secure) return next(); const local = ["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(req.socket.remoteAddress); if (local && devices.pairingAllowsHttp(req.body?.token, requestHost(req))) return next(); return res.status(426).json({ error: "Lumi Companion pairing requires HTTPS unless the package was generated from this exact localhost URL." }); }; } +function requireSettingsAccess(devices) { return (req, res, next) => { if (req.session?.user?.isAdmin) return next(); const auth = devices.authenticate(req.headers.authorization, "transcription.settings.v1"); if (!auth.allowed) return res.status(401).json({ error: "A paired device credential is required." }); if (!isSecureRequest(req) && !insecureDeviceAllowed(auth.device, requestHost(req), req.socket.remoteAddress)) return res.status(426).json({ error: "Device settings synchronization requires HTTPS unless this device was paired from the same localhost origin." }); req.lumiDevice = auth.device; next(); }; } +function requestHost(req) { return requestOrigin(req); } +function requirePairingTransport(devices) { return (req, res, next) => { if (isSecureRequest(req)) return next(); const local = ["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(req.socket.remoteAddress); if (local && devices.pairingAllowsHttp(req.body?.token, requestHost(req))) return next(); return res.status(426).json({ error: "Lumi Companion pairing requires HTTPS unless the package was generated from this exact localhost URL." }); }; } diff --git a/plugins/lumi_transcription/tests/verify.js b/plugins/lumi_transcription/tests/verify.js index 13a81cb..b46569c 100644 --- a/plugins/lumi_transcription/tests/verify.js +++ b/plugins/lumi_transcription/tests/verify.js @@ -24,6 +24,10 @@ const { CaptionStabilizer, LatestCaptionGate } = require("../backend/transcripti const { WhisperWorkerSupervisor, WhisperCppServerProvider } = require("../backend/transcription/provider"); const { BenchmarkStore, metricStats } = require("../backend/tests/benchmark_store"); const plugin = require("../index"); +const { + isSecureRequest, + requestOrigin +} = require("../../../src/services/proxy-security"); const { createWebUpgradeRegistry } = require("../../../src/services/web-upgrades"); async function run() { @@ -32,6 +36,7 @@ async function run() { verifyProtocol(); verifyPairingAndRevocation(); verifyLocalhostTransportPolicy(); + verifyProxiedHttpsTransport(); verifyCompanionVersionOrdering(); verifyWorkerResolution(temp); verifyRevisions(); @@ -53,6 +58,43 @@ async function run() { } finally { fs.rmSync(temp, { recursive: true, force: true }); } } +function verifyProxiedHttpsTransport() { + const request = { + protocol: "http", + secure: false, + socket: { remoteAddress: "::ffff:172.20.0.3" }, + get(name) { + if (name === "x-forwarded-proto") return "https"; + if (name === "host") return "lumi.example.test"; + return ""; + } + }; + const origin = requestOrigin(request); + assert.equal(origin, "https://lumi.example.test"); + assert.equal(isSecureRequest(request), true); + assert.doesNotThrow(() => normalizeHost(origin), "a Companion package generated behind the production HTTPS proxy must retain its HTTPS origin"); + const devices = new DeviceStore(new Database(":memory:")); + const pairing = devices.issuePairing({ userId: "admin", host: origin }); + assert.equal(pairing.host, "https://lumi.example.test", "the issued package must contain the public HTTPS origin"); + assert.throws( + () => normalizeHost(requestOrigin({ ...request, socket: { remoteAddress: "203.0.113.20" } })), + /HTTPS/, + "an untrusted client cannot turn an HTTP request into an HTTPS pairing package with forwarding headers" + ); + assert.throws( + () => requestOrigin({ + ...request, + get(name) { + if (name === "x-forwarded-proto") return "https"; + if (name === "host") return "lumi.example.test/forged"; + return ""; + } + }), + /host is invalid/, + "pairing origins must reject malformed Host headers instead of silently normalizing them" + ); +} + function verifyAdminDeviceRevocationUx() { const view = fs.readFileSync(path.join(__dirname, "../views/settings.ejs"), "utf8"); const client = fs.readFileSync(path.join(__dirname, "../public/transcription.js"), "utf8"); diff --git a/release-index.json b/release-index.json index 7142f84..aee0a6a 100644 --- a/release-index.json +++ b/release-index.json @@ -2,6 +2,38 @@ "schema_version": 1, "channel": "stable", "releases": [ + { + "version": "0.3.4", + "ref": "refs/tags/v0.3.4", + "released_at": "2026-07-26", + "installable": true, + "rollback_safe": true, + "replaces_versions": [ + "1.2.0" + ], + "data_policy": "preserve", + "dependency_policy": "sync_on_restart", + "migration_notes": "Fixes Companion package generation behind Lumi's private HTTPS reverse proxy, applies one guarded transport policy to HTTP and WebSocket device traffic, and standardizes durable redacted operational logging. Existing local and plugin data remains preserved.", + "plugins": { + "auto-vc": "0.1.6", + "birthday": "0.1.3", + "economy-framework": "0.2.10", + "economy-games": "0.1.7", + "expression-interaction": "0.2.1", + "lumi_ai": "0.8.5", + "lumi_transcription": "0.2.3", + "moderation": "0.1.5", + "now_playing": "0.1.2", + "okf": "0.1.2", + "quotes": "0.1.2", + "sample-plugin": "0.1.0", + "throne_wishlist": "0.1.2", + "welcome_messages": "0.1.1" + }, + "tools": { + "lumi_ai_web_search": "0.1.1" + } + }, { "version": "0.3.3", "ref": "refs/tags/v0.3.3", diff --git a/scripts/verify-production-diagnostics.js b/scripts/verify-production-diagnostics.js index 3198744..14b1e7b 100644 --- a/scripts/verify-production-diagnostics.js +++ b/scripts/verify-production-diagnostics.js @@ -5,6 +5,7 @@ const path = require("path"); const root = path.join(__dirname, ".."); require("../src/services/db").migrate(); const diagnostics = require("../src/services/production-diagnostics"); +const proxySecurity = require("../src/services/proxy-security"); assert.deepEqual(Object.keys(diagnostics.CHECKS), [ "system_health", @@ -35,6 +36,28 @@ assert.equal(diagnostics.isTrustedPrivateProxyAddress("203.0.113.10"), false); assert.equal(diagnostics.diagnosticsRequestProtocol(proxiedRequest), "https"); assert.equal(diagnostics.isSecureDiagnosticRequest(proxiedRequest), true); assert.equal(diagnostics.diagnosticsRequestProtocol({ ...proxiedRequest, socket: { remoteAddress: "203.0.113.10" } }), "http"); +const companionDownloadRequest = { + protocol: "http", + secure: false, + socket: { remoteAddress: "::ffff:172.19.0.4" }, + get(name) { + if (name === "x-forwarded-proto") return "https"; + if (name === "host") return "lumi.example.test"; + return ""; + } +}; +assert.equal(proxySecurity.requestOrigin(companionDownloadRequest), "https://lumi.example.test"); +assert.equal(proxySecurity.isSecureRequest(companionDownloadRequest), true); +assert.equal(proxySecurity.isTrustedProxyAddress("172.19.0.4"), true); +assert.equal(proxySecurity.isTrustedProxyAddress("203.0.113.10"), false); +assert.equal(proxySecurity.requestOrigin({ + ...companionDownloadRequest, + socket: { remoteAddress: "203.0.113.10" } +}), "http://lumi.example.test", "a public client must not be able to forge HTTPS"); +assert.equal(proxySecurity.requestProtocol({ + ...companionDownloadRequest, + get(name) { return name === "x-forwarded-proto" ? "javascript" : "lumi.example.test"; } +}), "http", "unsupported forwarding schemes must be rejected"); const redacted = diagnostics.redactDiagnosticValue({ token: "top-secret", @@ -82,7 +105,8 @@ const endpointIndex = serverSource.indexOf('app.post("/api/diagnostics/v1/run"') const configuredIndex = serverSource.indexOf("app.use(requireConfigured)"); assert(endpointIndex > 0 && endpointIndex < configuredIndex, "diagnostics endpoint must remain available for production recovery"); assert.match(serverSource, /authenticateDiagnosticsRequest\(req\)/); -assert.match(serverSource, /app\.set\("trust proxy", "loopback"\)/); +assert.match(serverSource, /app\.set\("trust proxy", isTrustedProxyAddress\)/); +assert.doesNotMatch(serverSource, /LUMI_LOCALHOST/, "proxy trust must not depend on an unrelated undocumented environment flag"); assert.match(serverSource, /app\.get\("\/admin\/diagnostics", requireRole\("admin"\)/); assert.match(serverSource, /app\.post\("\/admin\/diagnostics\/access\/renew", requireRole\("admin"\)/); assert.match(serverSource, /app\.post\("\/admin\/diagnostics\/access\/revoke", requireRole\("admin"\)/); diff --git a/scripts/verify-release-metadata.js b/scripts/verify-release-metadata.js index bb67ba7..970f04c 100644 --- a/scripts/verify-release-metadata.js +++ b/scripts/verify-release-metadata.js @@ -4,9 +4,9 @@ const path = require("path"); const { findSafeTarget } = require("../src/services/versioning"); const root = path.join(__dirname, ".."); -const releaseVersion = "0.3.3"; -const previousStableVersion = "0.3.2"; -const priorStableVersion = "0.3.1"; +const releaseVersion = "0.3.4"; +const previousStableVersion = "0.3.3"; +const priorStableVersion = "0.3.2"; const earliestCompatibleCoreVersion = "0.1.9"; const introducedPlugins = { lumi_transcription: { version: "0.2.3", knowledge: "lumi-transcription" }, @@ -82,4 +82,4 @@ assert.equal(webSearch.minimum_lumi_version, "0.2.0"); assert.equal(webSearch.minimum_lumi_ai_version, "0.8.2"); assert.equal(hasVersionHeading(readText("plugins/lumi_ai_web_search/CHANGELOG.md"), webSearch.version), true); -console.log("Release metadata verification passed: stable core 0.3.3 after 0.3.2 with synchronized Companion plugin metadata."); +console.log("Release metadata verification passed: stable core 0.3.4 after 0.3.3 with synchronized Companion plugin metadata."); diff --git a/scripts/verify-update-system.js b/scripts/verify-update-system.js index 9a4ced8..6cb5dc1 100644 --- a/scripts/verify-update-system.js +++ b/scripts/verify-update-system.js @@ -24,7 +24,7 @@ function readJson(relativePath) { const releaseIndex = readJson("release-index.json"); const releaseVersions = releaseIndex.releases.map((release) => release.version); -assert.deepEqual(releaseVersions, ["0.3.3", "0.3.2", "0.3.1", "0.3.0", "0.2.27", "0.2.26", "0.2.25", "0.2.24", "0.2.23", "0.2.22", "0.2.21", "0.2.20", "0.2.19", "0.2.18", "0.2.17", "0.2.16", "0.2.15", "0.2.14", "0.2.13", "0.2.12", "0.2.11", "0.2.10", "0.2.9", "0.2.8", "0.2.7", "0.2.6", "0.2.5", "0.2.4", "0.2.3", "0.2.2", "0.2.1", "0.2.0", "0.1.9"]); +assert.deepEqual(releaseVersions, ["0.3.4", "0.3.3", "0.3.2", "0.3.1", "0.3.0", "0.2.27", "0.2.26", "0.2.25", "0.2.24", "0.2.23", "0.2.22", "0.2.21", "0.2.20", "0.2.19", "0.2.18", "0.2.17", "0.2.16", "0.2.15", "0.2.14", "0.2.13", "0.2.12", "0.2.11", "0.2.10", "0.2.9", "0.2.8", "0.2.7", "0.2.6", "0.2.5", "0.2.4", "0.2.3", "0.2.2", "0.2.1", "0.2.0", "0.1.9"]); assert.equal(new Set(releaseVersions).size, releaseVersions.length, "release versions must be unique"); for (const release of releaseIndex.releases) { assert.equal(normalizeRepositoryRef(release.ref), release.ref); @@ -37,8 +37,8 @@ const packageVersion = readJson("package.json").version; const coreManifest = readJson("update-manifest.json"); assert.equal(packageVersion, coreManifest.version); assert.equal(coreManifest.channel, "stable"); -assert.equal(packageVersion, "0.3.3"); -assert.equal(currentRelease.version, "0.3.3"); +assert.equal(packageVersion, "0.3.4"); +assert.equal(currentRelease.version, "0.3.4"); assert.deepEqual(currentRelease.replaces_versions, ["1.2.0"]); for (const [pluginId, version] of Object.entries(currentRelease.plugins)) { assert.equal(readJson(`plugins/${pluginId}/plugin.json`).version, version, `${pluginId} release catalog version`); @@ -50,6 +50,7 @@ for (const [toolId, version] of Object.entries(currentRelease.tools)) { const baseTarget = { current_version: "0.2.4", available_versions: [ + { version: "0.3.4", ref: "refs/tags/v0.3.4", rollback_safe: true }, { version: "0.3.3", ref: "refs/tags/v0.3.3", rollback_safe: true }, { version: "0.3.2", ref: "refs/tags/v0.3.2", rollback_safe: true }, { version: "0.3.1", ref: "refs/tags/v0.3.1", rollback_safe: true }, @@ -152,7 +153,7 @@ const corrected = buildStatus({ channel: "stable" }); assert.equal(corrected.version_correction, true); -assert.equal(corrected.safe_target_version, "0.3.3"); +assert.equal(corrected.safe_target_version, "0.3.4"); assert.equal(corrected.update_available, true); assert.equal(corrected.blocked, false); diff --git a/src/services/plugin-stats.js b/src/services/plugin-stats.js index 57b3006..ae3fc38 100644 --- a/src/services/plugin-stats.js +++ b/src/services/plugin-stats.js @@ -2,6 +2,9 @@ const fs = require("fs"); const path = require("path"); const { db } = require("./db"); const { getPlugins } = require("./plugins"); +const { createLogger } = require("./logger"); + +const logger = createLogger("plugin-stats"); function readJsonSafe(filePath) { try { @@ -35,7 +38,7 @@ function loadStatProviders() { try { provider = require(providerPath); } catch (error) { - console.error("Failed to load plugin stats provider", error); + logger.error("Failed to load plugin stats provider", error); continue; } providers.push({ plugin, manifest, provider }); @@ -51,7 +54,7 @@ function buildProfileSection({ plugin, manifest, provider, userId }) { try { result = provider.getProfileStats({ db, userId, plugin, manifest }); } catch (error) { - console.error("Failed to load plugin profile stats", error); + logger.error("Failed to load plugin profile stats", error); return null; } const stats = Array.isArray(result?.stats) ? result.stats : []; @@ -72,7 +75,7 @@ function buildLeaderboardSection({ plugin, manifest, provider, limit }) { try { result = provider.getLeaderboards({ db, limit, plugin, manifest }); } catch (error) { - console.error("Failed to load plugin leaderboards", error); + logger.error("Failed to load plugin leaderboards", error); return null; } const boards = Array.isArray(result?.boards) ? result.boards : []; @@ -112,7 +115,7 @@ async function getAdminDashboardSections() { actions: normalizeDashboardActions(result.actions) }; } catch (error) { - console.error(`Failed to load ${plugin.id} admin dashboard stats`, error); + logger.error(`Failed to load ${plugin.id} admin dashboard stats`, error); return { id: plugin.id, eyebrow: "Companion service", diff --git a/src/services/production-diagnostics.js b/src/services/production-diagnostics.js index a800273..6ee5d83 100644 --- a/src/services/production-diagnostics.js +++ b/src/services/production-diagnostics.js @@ -8,6 +8,11 @@ const { createLogger, listLogs } = require("./logger"); const { getPlugins, scanPluginDirectories } = require("./plugins"); const { readRecoveryMarker } = require("./recovery-mode"); const { getSetting, setSetting } = require("./settings"); +const { + isLoopbackAddress, + isTrustedProxyAddress, + requestProtocol +} = require("./proxy-security"); const { readUpdateState } = require("./update-repository"); const repoRoot = path.join(__dirname, "..", ".."); @@ -279,33 +284,11 @@ function isSecureDiagnosticRequest(req) { } function diagnosticsRequestProtocol(req) { - if (req.secure === true || String(req.protocol || "").toLowerCase() === "https") return "https"; - const forwarded = String(req.get?.("x-forwarded-proto") || "") - .split(",")[0] - .trim() - .toLowerCase(); - const proxyAddress = String(req.socket?.remoteAddress || ""); - if (forwarded === "https" && isTrustedPrivateProxyAddress(proxyAddress)) return "https"; - return "http"; + return requestProtocol(req); } function isTrustedPrivateProxyAddress(value) { - const address = String(value || "").trim().toLowerCase().replace(/^::ffff:/, ""); - if (isLoopbackAddress(address)) return true; - if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(address)) { - const parts = address.split(".").map(Number); - if (parts.some((part) => part < 0 || part > 255)) return false; - return parts[0] === 10 || - (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) || - (parts[0] === 192 && parts[1] === 168) || - (parts[0] === 169 && parts[1] === 254); - } - return address.startsWith("fc") || address.startsWith("fd") || /^fe[89ab]/.test(address); -} - -function isLoopbackAddress(value) { - const address = String(value || "").trim().toLowerCase(); - return address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1"; + return isTrustedProxyAddress(value); } function summarizeRecovery(marker) { diff --git a/src/services/proxy-security.js b/src/services/proxy-security.js new file mode 100644 index 0000000..006c7d4 --- /dev/null +++ b/src/services/proxy-security.js @@ -0,0 +1,83 @@ +const net = require("net"); + +function normalizeAddress(value) { + return String(value || "").trim().toLowerCase().replace(/^::ffff:/, ""); +} + +function isLoopbackAddress(value) { + const address = normalizeAddress(value); + if (address === "::1") return true; + if (net.isIPv4(address)) return address.startsWith("127."); + return false; +} + +function isTrustedProxyAddress(value) { + const address = normalizeAddress(value); + if (isLoopbackAddress(address)) return true; + if (net.isIPv4(address)) { + const parts = address.split(".").map(Number); + return parts[0] === 10 || + (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) || + (parts[0] === 192 && parts[1] === 168) || + (parts[0] === 169 && parts[1] === 254); + } + return address.startsWith("fc") || + address.startsWith("fd") || + /^fe[89ab]/.test(address); +} + +function header(req, name) { + if (typeof req?.get === "function") return req.get(name); + return req?.headers?.[String(name).toLowerCase()]; +} + +function forwardedProtocol(req) { + if (!isTrustedProxyAddress(req?.socket?.remoteAddress)) return null; + const value = String(header(req, "x-forwarded-proto") || "") + .split(",")[0] + .trim() + .toLowerCase(); + return value === "https" || value === "http" ? value : null; +} + +function requestProtocol(req) { + if ( + req?.secure === true || + req?.socket?.encrypted === true || + String(req?.protocol || "").toLowerCase() === "https" + ) { + return "https"; + } + return forwardedProtocol(req) || "http"; +} + +function isSecureRequest(req) { + return requestProtocol(req) === "https"; +} + +function requestOrigin(req) { + const host = String(header(req, "host") || "").trim(); + if (!host || /[\r\n\0]/.test(host)) throw new Error("The request host is invalid."); + const url = new URL(`${requestProtocol(req)}://${host}`); + if ( + url.username || + url.password || + url.pathname !== "/" || + url.search || + url.hash || + url.host.toLowerCase() !== host.toLowerCase() + ) { + throw new Error("The request host is invalid."); + } + return url.origin; +} + +module.exports = { + forwardedProtocol, + isLoopbackAddress, + isSecureRequest, + isTrustedProxyAddress, + normalizeAddress, + requestOrigin, + requestProtocol +}; diff --git a/src/services/updater.js b/src/services/updater.js index c132989..31f2bbd 100644 --- a/src/services/updater.js +++ b/src/services/updater.js @@ -9,6 +9,9 @@ const { readUpdateState, resolveSourceBranch } = require("./update-repository"); +const { createLogger } = require("./logger"); + +const logger = createLogger("updater"); let restartHandler = null; @@ -102,7 +105,7 @@ function requestRestart(options = {}) { return; } Promise.resolve(restartHandler(10)).catch((error) => { - console.error("Graceful restart failed; forcing wrapper restart.", error); + logger.error("Graceful restart failed; forcing wrapper restart.", error); process.exit(10); }); }, delayMs); diff --git a/src/web/server.js b/src/web/server.js index 8af39ee..e79e117 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -156,6 +156,7 @@ const { revokeDiagnosticsAccess, runDiagnosticCheck } = require("../services/production-diagnostics"); +const { isTrustedProxyAddress } = require("../services/proxy-security"); const { generateCommandPreview, previewParts @@ -3105,10 +3106,9 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) { const app = express(); const upgradeRegistry = createWebUpgradeRegistry(); app.locals.lumiUpgradeRegistry = upgradeRegistry; - // Only trust forwarding headers from a reverse proxy on this machine. This - // lets the diagnostics endpoint recognize HTTPS without trusting arbitrary - // client-supplied X-Forwarded-Proto headers. - app.set("trust proxy", "loopback"); + // Lumi's TLS terminator can run on the host or an isolated private proxy + // network. Public clients remain unable to forge forwarding headers. + app.set("trust proxy", isTrustedProxyAddress); const webhooks = createWebhookService(); placeholders.registerCorePlaceholders(); placeholders.registerPlatformPlaceholders({ @@ -3811,7 +3811,7 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) { : true }; } catch (error) { - console.error(`Assistant panel ${panel.id} availability check failed`, error); + webLog.error(`Assistant panel ${panel.id} availability check failed`, error); panels.push({ ...unavailableAssistantPanel(panel, "availability_check_failed"), debug: panelDebug }); continue; } @@ -3855,7 +3855,7 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) { } }); } catch (error) { - console.error(`Assistant panel ${panel.id} render failed`, error); + webLog.error(`Assistant panel ${panel.id} render failed`, error); panel.onRenderDiagnostic?.({ panel_endpoint_status: 500, panel_html_length: 0, @@ -4285,7 +4285,7 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) { setFlash(req, "success", "Logged in."); res.redirect("/"); } catch (error) { - console.error(error); + webLog.error(error); res.status(500).render("error", { title: "Login failed", message: "Discord authentication failed." @@ -4507,7 +4507,7 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) { res.redirect("/profile"); } } catch (error) { - console.error(error); + webLog.error(error); res.status(500).render("error", { title: isLogin ? "Login failed" : isEvent ? "Event connection failed" : "Link failed", message: isLogin @@ -4670,7 +4670,7 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) { setFlash(req, "success", "YouTube account linked."); res.redirect("/profile"); } catch (error) { - console.error(error); + webLog.error(error); res.status(500).render("error", { title: isBot ? "Bot connect failed" : isLogin ? "Login failed" : "Link failed", message: isBot @@ -7223,7 +7223,7 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) { try { fs.rmSync(plugin.path, { recursive: true, force: true }); } catch (error) { - console.error(error); + webLog.error(error); } } removePlugin(req.params.id); diff --git a/update-manifest.json b/update-manifest.json index 2ad4616..767319b 100644 --- a/update-manifest.json +++ b/update-manifest.json @@ -1,14 +1,14 @@ { "name": "Lumi Core", - "version": "0.3.3", + "version": "0.3.4", "channel": "stable", - "released_at": "2026-07-25", + "released_at": "2026-07-26", "compatible_from": "0.1.9", "migration_kind": "patch", "replaces_versions": [ "1.2.0" ], - "migration_notes": "Replaces the FFmpeg Stream Testing receiver and transcoder with Lumi-managed checksum-pinned MediaMTX source remuxing, authenticated same-origin playback, and factual receiver diagnostics. Existing settings, databases, pairing records, OBS settings, credentials, overlays, uploads, models, secrets, plugin data, and local-only plugins are preserved.", + "migration_notes": "Fixes Companion package generation behind Lumi's private HTTPS reverse proxy, applies the same transport policy to HTTP and WebSocket device traffic, and standardizes durable redacted operational logging. Existing settings, databases, pairing records, OBS settings, credentials, overlays, uploads, models, secrets, plugin data, and local-only plugins are preserved.", "rollback_safe": true, "requirements": [ "Node.js 18 or newer" @@ -385,6 +385,18 @@ ], "rollback_safe": true, "migration_notes": "Fixes the managed OBS Bridge streaming-service ownership crash and requires the corrected bridge before Stream Testing. Existing settings, databases, pairing records, OBS settings, credentials, overlays, uploads, models, secrets, plugin data, and local-only plugins are preserved." + }, + { + "version": "0.3.3", + "channel": "stable", + "released_at": "2026-07-25", + "compatible_from": "0.1.9", + "migration_kind": "patch", + "replaces_versions": [ + "1.2.0" + ], + "rollback_safe": true, + "migration_notes": "Replaces the FFmpeg Stream Testing receiver and transcoder with Lumi-managed checksum-pinned MediaMTX source remuxing, authenticated same-origin playback, and factual receiver diagnostics. Existing settings, databases, pairing records, OBS settings, credentials, overlays, uploads, models, secrets, plugin data, and local-only plugins are preserved." } ] }