From 328d06662f6248607495df4ab03efa32f447b02d Mon Sep 17 00:00:00 2001 From: Franz Rolfsvaag Date: Sat, 18 Jul 2026 17:04:35 +0200 Subject: [PATCH] fix: trust private diagnostics proxies --- CHANGELOG.md | 5 +++ TODO.md | 1 + docs/production-diagnostics.md | 2 +- knowledge/core/lumi-core.md | 2 +- package-lock.json | 4 +-- package.json | 2 +- release-index.json | 30 +++++++++++++++++ scripts/build-core-repair-patch.js | 8 ++--- scripts/production-diagnostics-client.js | 42 +++++++++++++++++++++--- scripts/verify-core-repair-patch.js | 12 +++---- scripts/verify-production-diagnostics.js | 20 +++++++++-- scripts/verify-release-metadata.js | 6 ++-- scripts/verify-update-system.js | 7 ++-- src/services/production-diagnostics.js | 37 +++++++++++++++++++-- src/web/server.js | 3 +- update-manifest.json | 16 +++++++-- 16 files changed, 164 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4ce1fd..7a0dfff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Lumi changelog +## 0.2.5 + +- Fixed diagnostics behind local/private reverse proxies by accepting forwarded HTTPS only from loopback, RFC1918/link-local IPv4, or private/link-local IPv6 proxy addresses. +- Updated the local diagnostics client to read either JSON or env-style `.secrets` files, including `LUMI_PROD_URL` and `LUMI_DIAG_KEY` aliases. + ## 0.2.4 - Changed the production diagnostics endpoint, cURL example, and local-client configuration example to use the actual URL from which the administrator opened Lumi, including trusted reverse-proxy HTTPS handling. diff --git a/TODO.md b/TODO.md index c798c61..e8b375a 100644 --- a/TODO.md +++ b/TODO.md @@ -677,6 +677,7 @@ This section is for Lumi chat answer feedback and AI Improvement Center work, no ## Done +- 2026-07-18: Fixed private reverse-proxy HTTPS recognition for production diagnostics in core 0.2.5 without globally trusting client forwarding headers, and taught the local client to consume the repository's env-style `.secrets` file and short diagnostic variable names directly. - 2026-07-18: Updated production diagnostics examples in core 0.2.4 to derive the full endpoint and client base URL from the administrator's current Lumi request instead of showing a placeholder hostname. - 2026-07-18: Fixed the shared submit-action resolver in core 0.2.3: ordinary buttons now inherit their parent form endpoint unless they explicitly declare `formaction`, restoring timed diagnostics-key creation and preventing async update actions from posting back to the Updates page. - 2026-07-18: Added production-stage plugin update diagnostics in core 0.2.2: selected plugin source is verified before snapshotting, failures record their exact stage and target in update state, and the affected plugin row displays the server error directly. diff --git a/docs/production-diagnostics.md b/docs/production-diagnostics.md index de5625a..389cc89 100644 --- a/docs/production-diagnostics.md +++ b/docs/production-diagnostics.md @@ -43,7 +43,7 @@ The bundled client then runs an allowlisted check and prints its redacted JSON r npm run diagnostics:production -- update_state ``` -`LUMI_DIAGNOSTICS_CONFIG` can point to a key file outside the repository. `LUMI_DIAGNOSTICS_URL` and `LUMI_DIAGNOSTICS_KEY` are also supported for ephemeral environments. +`LUMI_DIAGNOSTICS_CONFIG` can point to a key file outside the repository. The client also reads an env-style `.secrets` file and accepts the shorter `LUMI_PROD_URL` / `LUMI_DIAG_KEY` names in addition to `LUMI_DIAGNOSTICS_URL` / `LUMI_DIAGNOSTICS_KEY`. The supported checks are: diff --git a/knowledge/core/lumi-core.md b/knowledge/core/lumi-core.md index 84f9c7e..5138ed8 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.2.4 +Version: 0.2.5 ## Routes - GET /api/events - POST /api/destructive-confirmations diff --git a/package-lock.json b/package-lock.json index 52a62cf..2dbc095 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lumi-bot", - "version": "0.2.4", + "version": "0.2.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lumi-bot", - "version": "0.2.4", + "version": "0.2.5", "dependencies": { "adm-zip": "^0.5.12", "better-sqlite3": "^11.5.0", diff --git a/package.json b/package.json index 1464940..9caa8a5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lumi-bot", - "version": "0.2.4", + "version": "0.2.5", "private": true, "type": "commonjs", "scripts": { diff --git a/release-index.json b/release-index.json index b838ddd..473ea50 100644 --- a/release-index.json +++ b/release-index.json @@ -2,6 +2,36 @@ "schema_version": 1, "channel": "stable", "releases": [ + { + "version": "0.2.5", + "ref": "refs/tags/v0.2.5", + "released_at": "2026-07-18", + "installable": true, + "rollback_safe": true, + "replaces_versions": [ + "1.2.0" + ], + "data_policy": "preserve", + "dependency_policy": "sync_on_restart", + "migration_notes": "Recognizes secured diagnostics forwarded through local/private reverse proxies and supports env-style local diagnostic secrets.", + "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.2", + "moderation": "0.1.5", + "okf": "0.1.1", + "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.2.4", "ref": "refs/tags/v0.2.4", diff --git a/scripts/build-core-repair-patch.js b/scripts/build-core-repair-patch.js index 1136fce..7d239bd 100644 --- a/scripts/build-core-repair-patch.js +++ b/scripts/build-core-repair-patch.js @@ -4,7 +4,7 @@ const path = require("path"); const AdmZip = require("adm-zip"); const root = path.join(__dirname, ".."); -const destination = path.join(root, "dist", "lumi-core-1.2.0-to-0.2.4-repair.zip"); +const destination = path.join(root, "dist", "lumi-core-1.2.0-to-0.2.5-repair.zip"); const files = [ "CHANGELOG.md", "README.md", @@ -53,10 +53,10 @@ for (const relativePath of files) { const manifest = { schema_version: 1, - name: "Lumi core 0.2.4 repair", + name: "Lumi core 0.2.5 repair", target: "core", - from_versions: ["0.1.9", "1.2.0", "0.2.0", "0.2.1", "0.2.2", "0.2.3", "0.2.4"], - to_version: "0.2.4", + from_versions: ["0.1.9", "1.2.0", "0.2.0", "0.2.1", "0.2.2", "0.2.3", "0.2.4", "0.2.5"], + to_version: "0.2.5", data_policy: "preserve", dependency_policy: "sync_on_restart", created_at: new Date().toISOString(), diff --git a/scripts/production-diagnostics-client.js b/scripts/production-diagnostics-client.js index 2d969af..9aef65c 100644 --- a/scripts/production-diagnostics-client.js +++ b/scripts/production-diagnostics-client.js @@ -9,19 +9,25 @@ if (!allowedChecks.has(check)) { process.exit(2); } -const configPath = path.resolve(process.env.LUMI_DIAGNOSTICS_CONFIG || path.join(root, ".secrets", "production-diagnostics.json")); +const defaultSecretPath = path.join(root, ".secrets"); +const configPath = path.resolve(process.env.LUMI_DIAGNOSTICS_CONFIG || ( + fs.statSync(defaultSecretPath, { throwIfNoEntry: false })?.isFile() + ? defaultSecretPath + : path.join(defaultSecretPath, "production-diagnostics.json") +)); let fileConfig = {}; try { - fileConfig = JSON.parse(fs.readFileSync(configPath, "utf8")); + fileConfig = parseConfig(fs.readFileSync(configPath, "utf8")); } catch (error) { - if (!process.env.LUMI_DIAGNOSTICS_URL || !process.env.LUMI_DIAGNOSTICS_KEY) { + if (!(process.env.LUMI_DIAGNOSTICS_URL || process.env.LUMI_PROD_URL) || + !(process.env.LUMI_DIAGNOSTICS_KEY || process.env.LUMI_DIAG_KEY)) { console.error(`Diagnostics configuration was not found or is invalid: ${configPath}`); process.exit(2); } } -const baseUrl = String(process.env.LUMI_DIAGNOSTICS_URL || fileConfig.base_url || "").trim(); -const key = String(process.env.LUMI_DIAGNOSTICS_KEY || fileConfig.key || "").trim(); +const baseUrl = String(process.env.LUMI_DIAGNOSTICS_URL || process.env.LUMI_PROD_URL || fileConfig.base_url || "").trim(); +const key = String(process.env.LUMI_DIAGNOSTICS_KEY || process.env.LUMI_DIAG_KEY || fileConfig.key || "").trim(); let endpoint; try { const parsed = new URL(baseUrl); @@ -64,3 +70,29 @@ fetch(endpoint, { process.exitCode = 1; }) .finally(() => clearTimeout(timeout)); + +function parseConfig(raw) { + const source = String(raw || "").trim(); + try { + const parsed = JSON.parse(source); + return { + base_url: parsed.base_url || parsed.url || "", + key: parsed.key || "" + }; + } catch { + const values = {}; + for (const line of source.split(/\r?\n/)) { + const match = line.trim().match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/); + if (!match) continue; + let value = match[2].trim(); + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + values[match[1]] = value; + } + return { + base_url: values.LUMI_DIAGNOSTICS_URL || values.LUMI_PROD_URL || "", + key: values.LUMI_DIAGNOSTICS_KEY || values.LUMI_DIAG_KEY || "" + }; + } +} diff --git a/scripts/verify-core-repair-patch.js b/scripts/verify-core-repair-patch.js index b12f348..bdb73e4 100644 --- a/scripts/verify-core-repair-patch.js +++ b/scripts/verify-core-repair-patch.js @@ -7,7 +7,7 @@ const AdmZip = require("adm-zip"); const { verifyPatchPackage } = require("../src/services/update-manager"); const root = path.join(__dirname, ".."); -const archivePath = path.join(root, "dist", "lumi-core-1.2.0-to-0.2.4-repair.zip"); +const archivePath = path.join(root, "dist", "lumi-core-1.2.0-to-0.2.5-repair.zip"); assert.equal(fs.existsSync(archivePath), true, "build the repair patch first"); const zip = new AdmZip(archivePath); const entries = zip.getEntries().filter((entry) => !entry.isDirectory); @@ -15,9 +15,9 @@ const names = new Set(entries.map((entry) => entry.entryName.replace(/\\/g, "/") assert.equal(names.has("patch-manifest.json"), true); const manifest = JSON.parse(zip.readAsText("patch-manifest.json")); assert.equal(manifest.target, "core"); -assert.equal(manifest.to_version, "0.2.4"); +assert.equal(manifest.to_version, "0.2.5"); assert.equal(manifest.data_policy, "preserve"); -assert.deepEqual(manifest.from_versions, ["0.1.9", "1.2.0", "0.2.0", "0.2.1", "0.2.2", "0.2.3", "0.2.4"]); +assert.deepEqual(manifest.from_versions, ["0.1.9", "1.2.0", "0.2.0", "0.2.1", "0.2.2", "0.2.3", "0.2.4", "0.2.5"]); const forbidden = /^(?:data|plugins|node_modules|config|storage|uploads|logs|database|databases|knowledge\/(?:community|corrections))(?:\/|$)|^\.env(?:\.|$)|^\.secrets$/; for (const entry of entries) { @@ -29,7 +29,7 @@ for (const [relativePath, expected] of Object.entries(manifest.files)) { assert.equal(actual, expected, `${relativePath} checksum`); } assert.equal(Object.keys(manifest.files).length + 1, entries.length, "every repair file must be checksummed"); -assert.equal(JSON.parse(zip.readAsText("package.json")).version, "0.2.4"); +assert.equal(JSON.parse(zip.readAsText("package.json")).version, "0.2.5"); const simulation = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-repair-simulation-")); try { @@ -47,11 +47,11 @@ try { fs.writeFileSync(target, `preserve:${sentinel}`); } zip.extractAllTo(simulation, true); - assert.equal(verifyPatchPackage(simulation).to_version, "0.2.4"); + assert.equal(verifyPatchPackage(simulation).to_version, "0.2.5"); for (const sentinel of sentinels) { assert.equal(fs.readFileSync(path.join(simulation, sentinel), "utf8"), `preserve:${sentinel}`); } - assert.equal(JSON.parse(fs.readFileSync(path.join(simulation, "package.json"), "utf8")).version, "0.2.4"); + assert.equal(JSON.parse(fs.readFileSync(path.join(simulation, "package.json"), "utf8")).version, "0.2.5"); assert.equal(fs.existsSync(path.join(simulation, "src", "services", "dependency-manager.js")), true); assert.equal(fs.existsSync(path.join(simulation, "src", "services", "production-diagnostics.js")), true); assert.equal(fs.existsSync(path.join(simulation, "src", "web", "views", "admin-diagnostics.ejs")), true); diff --git a/scripts/verify-production-diagnostics.js b/scripts/verify-production-diagnostics.js index 7a03ba0..3198744 100644 --- a/scripts/verify-production-diagnostics.js +++ b/scripts/verify-production-diagnostics.js @@ -23,6 +23,18 @@ assert.equal(diagnostics.verifyAccessKey("wrong-prefix", hash), false); assert.equal(diagnostics.isSecureDiagnosticRequest({ secure: true, ip: "203.0.113.10" }), true); assert.equal(diagnostics.isSecureDiagnosticRequest({ secure: false, ip: "127.0.0.1" }), true); assert.equal(diagnostics.isSecureDiagnosticRequest({ secure: false, ip: "203.0.113.10" }), false); +const proxiedRequest = { + secure: false, + protocol: "http", + socket: { remoteAddress: "::ffff:172.19.0.4" }, + get(name) { return name === "x-forwarded-proto" ? "https" : ""; } +}; +assert.equal(diagnostics.isTrustedPrivateProxyAddress("172.19.0.4"), true); +assert.equal(diagnostics.isTrustedPrivateProxyAddress("192.168.1.10"), true); +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 redacted = diagnostics.redactDiagnosticValue({ token: "top-secret", @@ -82,12 +94,16 @@ assert.match(viewSource, /\.secrets/); assert.match(viewSource, /diagnosticsEndpointUrl/); assert.match(viewSource, /diagnosticsBaseUrl/); assert.doesNotMatch(viewSource, /https:\/\/your-lumi-host/); -assert.match(serverSource, /const diagnosticsBaseUrl = `\$\{req\.protocol\}:\/\/\$\{req\.get\("host"\)\}`/); +assert.match(serverSource, /const diagnosticsBaseUrl = `\$\{diagnosticsRequestProtocol\(req\)\}:\/\/\$\{req\.get\("host"\)\}`/); const clientSource = fs.readFileSync(path.join(root, "scripts", "production-diagnostics-client.js"), "utf8"); -assert.match(clientSource, /\.secrets["'], "production-diagnostics\.json"/); +assert.match(clientSource, /path\.join\(root, "\.secrets"\)/); +assert.match(clientSource, /path\.join\(defaultSecretPath, "production-diagnostics\.json"\)/); assert.match(clientSource, /method: "POST"/); assert.match(clientSource, /new URL\("\/api\/diagnostics\/v1\/run"/); +assert.match(clientSource, /LUMI_PROD_URL/); +assert.match(clientSource, /LUMI_DIAG_KEY/); +assert.match(clientSource, /function parseConfig\(raw\)/); assert.doesNotMatch(clientSource, /console\.(?:log|error)\(\s*key\s*\)/, "client must not print its access key"); console.log("Production diagnostics verification passed: fixed read-only checks, redaction, key validation, rate limiting, HTTPS proxy trust, and admin-only controls."); diff --git a/scripts/verify-release-metadata.js b/scripts/verify-release-metadata.js index f62cb72..d6c4312 100644 --- a/scripts/verify-release-metadata.js +++ b/scripts/verify-release-metadata.js @@ -4,8 +4,8 @@ const path = require("path"); const { findSafeTarget } = require("../src/services/versioning"); const root = path.join(__dirname, ".."); -const releaseVersion = "0.2.4"; -const previousCoreVersion = "0.2.3"; +const releaseVersion = "0.2.5"; +const previousCoreVersion = "0.2.4"; const earliestCompatibleCoreVersion = "0.1.9"; const changedPlugins = { "auto-vc": { from: "0.1.5", to: "0.1.6", knowledge: "auto-vc" }, @@ -87,4 +87,4 @@ assert.equal(webSearch.minimum_lumi_version, "0.2.0"); assert.equal(webSearch.minimum_lumi_ai_version, changedPlugins.lumi_ai.to); assert.equal(hasVersionHeading(readText("plugins/lumi_ai_web_search/CHANGELOG.md"), webSearch.version), true); -console.log("Release metadata verification passed: core 0.2.4 and 11 changed plugin/tool packages."); +console.log("Release metadata verification passed: core 0.2.5 and 11 changed plugin/tool packages."); diff --git a/scripts/verify-update-system.js b/scripts/verify-update-system.js index 92cc601..fe9071f 100644 --- a/scripts/verify-update-system.js +++ b/scripts/verify-update-system.js @@ -16,7 +16,7 @@ function readJson(relativePath) { const releaseIndex = readJson("release-index.json"); const releaseVersions = releaseIndex.releases.map((release) => release.version); -assert.deepEqual(releaseVersions, ["0.2.4", "0.2.3", "0.2.2", "0.2.1", "0.2.0", "0.1.9"]); +assert.deepEqual(releaseVersions, ["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); @@ -35,8 +35,9 @@ for (const [toolId, version] of Object.entries(currentRelease.tools)) { } const baseTarget = { - current_version: "0.2.3", + current_version: "0.2.4", available_versions: [ + { version: "0.2.5", ref: "refs/tags/v0.2.5", rollback_safe: true }, { version: "0.2.4", ref: "refs/tags/v0.2.4", rollback_safe: true }, { version: "0.2.3", ref: "refs/tags/v0.2.3", rollback_safe: true }, { version: "0.2.2", ref: "refs/tags/v0.2.2", rollback_safe: true }, @@ -64,7 +65,7 @@ const corrected = buildStatus({ channel: "stable" }); assert.equal(corrected.version_correction, true); -assert.equal(corrected.safe_target_version, "0.2.4"); +assert.equal(corrected.safe_target_version, "0.2.5"); assert.equal(corrected.update_available, true); assert.equal(corrected.blocked, false); diff --git a/src/services/production-diagnostics.js b/src/services/production-diagnostics.js index ec573aa..f5827dc 100644 --- a/src/services/production-diagnostics.js +++ b/src/services/production-diagnostics.js @@ -272,8 +272,39 @@ function consumeRateLimit(fingerprint, now = Date.now()) { function isSecureDiagnosticRequest(req) { if (req.secure === true) return true; - const address = String(req.ip || req.socket?.remoteAddress || ""); - return ["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(address); + const address = String(req.socket?.remoteAddress || req.ip || ""); + if (isLoopbackAddress(address)) return true; + return diagnosticsRequestProtocol(req) === "https"; +} + +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"; +} + +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"; } function summarizeRecovery(marker) { @@ -305,9 +336,11 @@ module.exports = { authenticateDiagnosticsRequest, consumeRateLimit, diagnosticsAccessStatus, + diagnosticsRequestProtocol, hashAccessKey, issueDiagnosticsAccessKey, isSecureDiagnosticRequest, + isTrustedPrivateProxyAddress, recentDiagnosticAudit, redactDiagnosticValue, revokeDiagnosticsAccess, diff --git a/src/web/server.js b/src/web/server.js index 7ade243..86162a1 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -110,6 +110,7 @@ const { auditDiagnosticRequest, authenticateDiagnosticsRequest, diagnosticsAccessStatus, + diagnosticsRequestProtocol, issueDiagnosticsAccessKey, recentDiagnosticAudit, revokeDiagnosticsAccess, @@ -5770,7 +5771,7 @@ function createWebServer({ loadPlugins, discordClient }) { }); const renderDiagnosticsAdmin = (req, res, values = {}) => { - const diagnosticsBaseUrl = `${req.protocol}://${req.get("host")}`; + const diagnosticsBaseUrl = `${diagnosticsRequestProtocol(req)}://${req.get("host")}`; res.set("Cache-Control", "no-store"); res.render("admin-diagnostics", { title: "Production diagnostics", diff --git a/update-manifest.json b/update-manifest.json index 6e73bc9..96e4581 100644 --- a/update-manifest.json +++ b/update-manifest.json @@ -1,6 +1,6 @@ { "name": "Lumi Core", - "version": "0.2.4", + "version": "0.2.5", "channel": "stable", "released_at": "2026-07-18", "compatible_from": "0.1.9", @@ -8,7 +8,7 @@ "replaces_versions": [ "1.2.0" ], - "migration_notes": "Includes the 1.2.0 version correction, production plugin-update diagnostics, secured read-only production diagnostics with request-aware examples, and the shared form-action fix required by timed confirmations and repository updates. Lumi synchronizes runtime dependencies on restart and preserves settings, databases, plugin data, community knowledge, AI models, runtimes, uploads, logs, and secrets.", + "migration_notes": "Includes the 1.2.0 version correction, production plugin-update diagnostics, secured read-only production diagnostics with private-proxy HTTPS support, and the shared form-action fix required by timed confirmations and repository updates. Lumi synchronizes runtime dependencies on restart and preserves settings, databases, plugin data, community knowledge, AI models, runtimes, uploads, logs, and secrets.", "rollback_safe": true, "requirements": [ "Node.js 18 or newer" @@ -73,6 +73,18 @@ ], "rollback_safe": true, "migration_notes": "Uses the administrator's current Lumi origin in production diagnostics examples; preserved local data is not replaced." + }, + { + "version": "0.2.5", + "channel": "stable", + "released_at": "2026-07-18", + "compatible_from": "0.1.9", + "migration_kind": "patch", + "replaces_versions": [ + "1.2.0" + ], + "rollback_safe": true, + "migration_notes": "Recognizes forwarded HTTPS from local/private reverse proxies for secured diagnostics; preserved local data is not replaced." } ] }