diff --git a/CHANGELOG.md b/CHANGELOG.md
index a8e7ef5..35dcd51 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,11 @@
# Lumi changelog
+## 0.2.2
+
+- Added stage-specific plugin update failures beside the affected plugin and persisted the production failure stage in update state.
+- Validated the selected repository plugin before creating a rollback snapshot and removed the generic, context-free failed result.
+- Added an optional admin-controlled production diagnostics endpoint with fixed read-only checks, one-time hashed access keys, HTTPS enforcement, redaction, rate limiting, and audited access.
+
## 0.2.1
- Fixed repository update checks being handled twice and incorrectly ending in a failed button state after a successful result.
diff --git a/TODO.md b/TODO.md
index 8401122..e4040d3 100644
--- a/TODO.md
+++ b/TODO.md
@@ -677,6 +677,8 @@ This section is for Lumi chat answer feedback and AI Improvement Center work, no
## Done
+- 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.
+- 2026-07-18: Added opt-in production diagnostics in core 0.2.2: administrators can issue or revoke a one-time access key for an HTTPS-only, rate-limited endpoint with five fixed read-only checks, recursive secret/path redaction, and audited access; no arbitrary command, SQL, file, URL, or write capability is exposed.
- 2026-07-18: Released the follow-up core 0.2.1 update-page fix: update checks now have one client-side handler, successful results use clear module-specific wording, and exact core/plugin version controls are collapsed and list only the selected module’s versions.
- 2026-07-18: Audited the 0.1.9-to-0.2.0 update boundary, removed core's direct dependency on OKF plugin files, made OBS WebSocket optional, added locked runtime dependency repair, corrected 1.2.0 to 0.2.0, added immutable release metadata and exact-version core/plugin installs, and produced a checksummed data-preserving core repair patch.
diff --git a/docs/production-diagnostics.md b/docs/production-diagnostics.md
new file mode 100644
index 0000000..de5625a
--- /dev/null
+++ b/docs/production-diagnostics.md
@@ -0,0 +1,60 @@
+# Production diagnostics
+
+Lumi includes an optional, disabled-by-default diagnostics endpoint for investigating problems that only occur on a production installation. It is intended for trusted maintainers who need runtime evidence without being given shell, database, file, or administrative access.
+
+## Security model
+
+- Only an administrator can enable access, replace the key, or revoke it.
+- The generated bearer key is shown once. Lumi stores its SHA-256 hash, a short display prefix, and its creation time—not the usable key.
+- Remote requests require HTTPS. Requests over plain HTTP are accepted only from loopback. Lumi trusts forwarded HTTPS headers only from a reverse proxy on the same machine.
+- Disabled endpoints and invalid keys return `404`, and valid keys are limited to 20 requests per minute.
+- The request can select only one fixed, allowlisted check. It cannot provide commands, SQL, paths, URLs, module names, or code.
+- Results recursively redact credential-like fields, authorization values, secret query parameters, local paths, and email addresses. Output size and nesting are bounded.
+- Successful and failed authorized requests are recorded in Lumi's normal audit logs. The endpoint does not change application or plugin data; the audit record is its only routine write.
+
+This is intentionally visible to Lumi administrators under **Admin → Diagnostics**. It is not a hidden support account or backdoor.
+
+## Enable and connect
+
+1. Open **Admin → Diagnostics**.
+2. Choose **Create access key** and complete the timed confirmation.
+3. Copy the one-time key to the trusted diagnostic computer. Store it outside the repository or in Lumi's ignored `.secrets` directory.
+4. Send a JSON `POST` to `/api/diagnostics/v1/run` using the production HTTPS address:
+
+```sh
+curl -X POST "https://your-lumi-host/api/diagnostics/v1/run" \
+ -H "Authorization: Bearer $LUMI_DIAGNOSTICS_KEY" \
+ -H "Content-Type: application/json" \
+ --data '{"check":"update_state"}'
+```
+
+For repository maintainers, save the key and address in the ignored `.secrets/production-diagnostics.json` file:
+
+```json
+{
+ "base_url": "https://your-lumi-host",
+ "key": "lumi_diag_replace-with-the-one-time-key"
+}
+```
+
+The bundled client then runs an allowlisted check and prints its redacted JSON result:
+
+```sh
+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.
+
+The supported checks are:
+
+- `system_health`: runtime, database, dependency, disk-space, and recovery status.
+- `update_state`: the last update status and stage, recovery state, and snapshot summary.
+- `plugins`: plugin manifest and registry versions without plugin data.
+- `recent_errors`: recent warnings and errors after redaction.
+- `benchmark`: a bounded read-only database, plugin scan, and JSON timing workload.
+
+Replace or revoke the key from the same page. Replacement immediately invalidates the previous key.
+
+## Scope and limitations
+
+The endpoint deliberately does not expose arbitrary benchmarks or generic diagnostic commands. New checks must be implemented and reviewed in core before they can run. For incident response, start with `update_state`, `system_health`, and `recent_errors`; their request IDs can be correlated with the audit list on the admin page.
diff --git a/docs/updates.md b/docs/updates.md
index 7791d94..c98116e 100644
--- a/docs/updates.md
+++ b/docs/updates.md
@@ -160,3 +160,15 @@ Admin update actions publish Server-Sent Events through
Core update success returns a five-second in-page notice before refresh/restart.
Plugin update success updates progress for the affected plugin action without a
whole-page refresh, then restarts Lumi so the selected plugin code is loaded.
+
+Plugin failures are shown directly beside the affected plugin instead of only
+changing the action button label. The error identifies the failed stage:
+repository metadata, selected repository version, recovery preparation,
+rollback snapshot, file replacement, or installed-plugin verification. The
+same stage and error are persisted in `data/update-state.json`; a later success
+clears the stale failure.
+
+When a problem occurs only on production, the optional **Admin > Diagnostics**
+page can expose the redacted `update_state`, `system_health`, and
+`recent_errors` checks to a trusted maintainer without shell or write access.
+See [Production diagnostics](production-diagnostics.md).
diff --git a/knowledge/core/lumi-core.md b/knowledge/core/lumi-core.md
index ffbf964..8a0d341 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.1
+Version: 0.2.2
## Routes
- GET /api/events
- POST /api/destructive-confirmations
diff --git a/package-lock.json b/package-lock.json
index fdb5856..e9dbccd 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "lumi-bot",
- "version": "0.2.1",
+ "version": "0.2.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lumi-bot",
- "version": "0.2.1",
+ "version": "0.2.2",
"dependencies": {
"adm-zip": "^0.5.12",
"better-sqlite3": "^11.5.0",
diff --git a/package.json b/package.json
index 3f5e90f..3f59d3e 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "lumi-bot",
- "version": "0.2.1",
+ "version": "0.2.2",
"private": true,
"type": "commonjs",
"scripts": {
@@ -11,6 +11,8 @@
"verify:safe-files": "node scripts/verify-safe-files.js",
"verify:uploads": "node scripts/verify-upload-security.js",
"verify:updates": "node scripts/verify-release-metadata.js && node scripts/verify-update-system.js",
+ "verify:diagnostics": "node scripts/verify-production-diagnostics.js",
+ "diagnostics:production": "node scripts/production-diagnostics-client.js",
"build:repair-patch": "node scripts/build-core-repair-patch.js && node scripts/verify-core-repair-patch.js",
"verify:web-auth": "node scripts/verify-web-auth.js",
"verify:destructive-actions": "node scripts/verify-destructive-actions.js",
diff --git a/release-index.json b/release-index.json
index ca94a84..494fbd0 100644
--- a/release-index.json
+++ b/release-index.json
@@ -2,6 +2,36 @@
"schema_version": 1,
"channel": "stable",
"releases": [
+ {
+ "version": "0.2.2",
+ "ref": "refs/tags/v0.2.2",
+ "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": "Adds production-stage plugin update diagnostics, validates repository plugin files before changing live files, and provides optional secured read-only production diagnostics.",
+ "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.1",
"ref": "refs/tags/v0.2.1",
diff --git a/scripts/build-core-repair-patch.js b/scripts/build-core-repair-patch.js
index edd0a92..10a93e5 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.1-repair.zip");
+const destination = path.join(root, "dist", "lumi-core-1.2.0-to-0.2.2-repair.zip");
const files = [
"CHANGELOG.md",
"README.md",
@@ -14,6 +14,7 @@ const files = [
"run.js",
"update-manifest.json",
"docs/updates.md",
+ "docs/production-diagnostics.md",
"docs/update-audit-0.2.0.md",
"knowledge/core/lumi-core.md",
"scripts/verify-release-metadata.js",
@@ -21,16 +22,21 @@ const files = [
"scripts/build-core-repair-patch.js",
"scripts/verify-core-repair-patch.js",
"scripts/verify-update-system.js",
+ "scripts/verify-production-diagnostics.js",
+ "scripts/production-diagnostics-client.js",
"scripts/verify-webui.js",
"src/services/dependency-manager.js",
"src/services/overlay-connectors.js",
"src/services/repo-update.js",
+ "src/services/production-diagnostics.js",
+ "src/services/settings.js",
"src/services/update-index.js",
"src/services/update-manager.js",
"src/services/update-repository.js",
"src/web/server.js",
"src/web/public/app.js",
- "src/web/views/admin-updates.ejs"
+ "src/web/views/admin-updates.ejs",
+ "src/web/views/admin-diagnostics.ejs"
];
const hashes = {};
@@ -47,10 +53,10 @@ for (const relativePath of files) {
const manifest = {
schema_version: 1,
- name: "Lumi core 0.2.1 repair",
+ name: "Lumi core 0.2.2 repair",
target: "core",
- from_versions: ["0.1.9", "1.2.0", "0.2.0", "0.2.1"],
- to_version: "0.2.1",
+ from_versions: ["0.1.9", "1.2.0", "0.2.0", "0.2.1", "0.2.2"],
+ to_version: "0.2.2",
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
new file mode 100644
index 0000000..2d969af
--- /dev/null
+++ b/scripts/production-diagnostics-client.js
@@ -0,0 +1,66 @@
+const fs = require("fs");
+const path = require("path");
+
+const root = path.join(__dirname, "..");
+const allowedChecks = new Set(["system_health", "update_state", "plugins", "recent_errors", "benchmark"]);
+const check = String(process.argv[2] || "system_health").trim();
+if (!allowedChecks.has(check)) {
+ console.error(`Choose one of: ${Array.from(allowedChecks).join(", ")}`);
+ process.exit(2);
+}
+
+const configPath = path.resolve(process.env.LUMI_DIAGNOSTICS_CONFIG || path.join(root, ".secrets", "production-diagnostics.json"));
+let fileConfig = {};
+try {
+ fileConfig = JSON.parse(fs.readFileSync(configPath, "utf8"));
+} catch (error) {
+ if (!process.env.LUMI_DIAGNOSTICS_URL || !process.env.LUMI_DIAGNOSTICS_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();
+let endpoint;
+try {
+ const parsed = new URL(baseUrl);
+ const isLoopback = ["localhost", "127.0.0.1", "::1", "[::1]"].includes(parsed.hostname);
+ if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && isLoopback)) {
+ throw new Error("Production diagnostics require HTTPS (HTTP is allowed only for loopback).");
+ }
+ if (parsed.username || parsed.password) throw new Error("Do not put credentials in the diagnostics URL.");
+ endpoint = new URL("/api/diagnostics/v1/run", parsed);
+} catch (error) {
+ console.error(error.message || "Configure a valid production diagnostics URL.");
+ process.exit(2);
+}
+if (!key.startsWith("lumi_diag_")) {
+ console.error("The production diagnostics key is missing or invalid.");
+ process.exit(2);
+}
+
+const controller = new AbortController();
+const timeout = setTimeout(() => controller.abort(), 15000);
+fetch(endpoint, {
+ method: "POST",
+ headers: {
+ authorization: `Bearer ${key}`,
+ "content-type": "application/json",
+ accept: "application/json"
+ },
+ body: JSON.stringify({ check }),
+ signal: controller.signal
+})
+ .then(async (response) => {
+ const body = await response.json().catch(() => null);
+ if (!response.ok || body?.ok !== true) {
+ throw new Error(body?.error || `Diagnostics request failed with HTTP ${response.status}.`);
+ }
+ console.log(JSON.stringify(body, null, 2));
+ })
+ .catch((error) => {
+ console.error(error.name === "AbortError" ? "Diagnostics request timed out." : error.message);
+ process.exitCode = 1;
+ })
+ .finally(() => clearTimeout(timeout));
diff --git a/scripts/verify-all.js b/scripts/verify-all.js
index 07d0b59..a3923a9 100644
--- a/scripts/verify-all.js
+++ b/scripts/verify-all.js
@@ -11,6 +11,7 @@ const checks = [
"scripts/verify-placeholders.js",
"scripts/verify-release-metadata.js",
"scripts/verify-update-system.js",
+ "scripts/verify-production-diagnostics.js",
"scripts/verify-plugin-update-preserves-data.js",
"scripts/verify-safe-files.js",
"scripts/verify-upload-security.js",
diff --git a/scripts/verify-core-repair-patch.js b/scripts/verify-core-repair-patch.js
index 8b4f335..0fc1522 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.1-repair.zip");
+const archivePath = path.join(root, "dist", "lumi-core-1.2.0-to-0.2.2-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.1");
+assert.equal(manifest.to_version, "0.2.2");
assert.equal(manifest.data_policy, "preserve");
-assert.deepEqual(manifest.from_versions, ["0.1.9", "1.2.0", "0.2.0", "0.2.1"]);
+assert.deepEqual(manifest.from_versions, ["0.1.9", "1.2.0", "0.2.0", "0.2.1", "0.2.2"]);
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.1");
+assert.equal(JSON.parse(zip.readAsText("package.json")).version, "0.2.2");
const simulation = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-repair-simulation-"));
try {
@@ -47,12 +47,14 @@ try {
fs.writeFileSync(target, `preserve:${sentinel}`);
}
zip.extractAllTo(simulation, true);
- assert.equal(verifyPatchPackage(simulation).to_version, "0.2.1");
+ assert.equal(verifyPatchPackage(simulation).to_version, "0.2.2");
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.1");
+ assert.equal(JSON.parse(fs.readFileSync(path.join(simulation, "package.json"), "utf8")).version, "0.2.2");
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);
} finally {
fs.rmSync(simulation, { recursive: true, force: true });
}
diff --git a/scripts/verify-production-diagnostics.js b/scripts/verify-production-diagnostics.js
new file mode 100644
index 0000000..5da9b4a
--- /dev/null
+++ b/scripts/verify-production-diagnostics.js
@@ -0,0 +1,90 @@
+const assert = require("assert");
+const fs = require("fs");
+const path = require("path");
+
+const root = path.join(__dirname, "..");
+require("../src/services/db").migrate();
+const diagnostics = require("../src/services/production-diagnostics");
+
+assert.deepEqual(Object.keys(diagnostics.CHECKS), [
+ "system_health",
+ "update_state",
+ "plugins",
+ "recent_errors",
+ "benchmark"
+]);
+
+const key = "lumi_diag_verification-key_123";
+const hash = diagnostics.hashAccessKey(key);
+assert.match(hash, /^[a-f0-9]{64}$/);
+assert.equal(diagnostics.verifyAccessKey(key, hash), true);
+assert.equal(diagnostics.verifyAccessKey(`${key}x`, hash), false);
+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 redacted = diagnostics.redactDiagnosticValue({
+ token: "top-secret",
+ message: `authorization=Bearer-value ${key} /mnt/c/private/lumi/file.js user@example.com ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefgh`,
+ nested: { password: "also-secret", url: "https://example.test/?api_key=secret-value&ok=1" }
+});
+const redactedJson = JSON.stringify(redacted);
+for (const forbidden of ["top-secret", "also-secret", key, "/mnt/c/private", "user@example.com", "secret-value", "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefgh"]) {
+ assert.equal(redactedJson.includes(forbidden), false, `diagnostic output leaked ${forbidden}`);
+}
+assert.match(redactedJson, /redacted|diagnostics key|local path|email/i);
+
+const fingerprint = `verify-${Date.now()}`;
+for (let index = 0; index < diagnostics.MAX_REQUESTS_PER_MINUTE; index += 1) {
+ const rate = diagnostics.consumeRateLimit(fingerprint, 120000);
+ assert.equal(rate.allowed, true);
+}
+assert.equal(diagnostics.consumeRateLimit(fingerprint, 120000).allowed, false);
+assert.equal(diagnostics.consumeRateLimit(fingerprint, 180000).allowed, true);
+
+for (const check of Object.keys(diagnostics.CHECKS)) {
+ const result = diagnostics.runDiagnosticCheck(check);
+ assert.equal(result.schema_version, 1);
+ assert.equal(result.check, check);
+ assert.equal(typeof result.duration_ms, "number");
+ assert.doesNotMatch(JSON.stringify(result), /\blumi_diag_[A-Za-z0-9_-]+\b/);
+}
+assert.throws(() => diagnostics.runDiagnosticCheck("shell"), /Unknown diagnostic check/);
+
+const serviceSource = fs.readFileSync(path.join(root, "src", "services", "production-diagnostics.js"), "utf8");
+for (const forbidden of [
+ /child_process/,
+ /\bexec(?:Sync)?\s*\(/,
+ /\bspawn(?:Sync)?\s*\(/,
+ /\beval\s*\(/,
+ /new\s+Function\s*\(/,
+ /\bfetch\s*\(/,
+ /require\(["'](?:https?|net|tls|dgram)["']\)/
+]) {
+ assert.doesNotMatch(serviceSource, forbidden, "diagnostics must not expose execution or network primitives");
+}
+
+const serverSource = fs.readFileSync(path.join(root, "src", "web", "server.js"), "utf8");
+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\.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"\)/);
+
+const viewSource = fs.readFileSync(path.join(root, "src", "web", "views", "admin-diagnostics.ejs"), "utf8");
+assert.match(viewSource, /no remote control/i);
+assert.match(viewSource, /data-confirm-mode="modal"/);
+assert.match(viewSource, /\.secrets/);
+assert.match(viewSource, /\/api\/diagnostics\/v1\/run/);
+
+const clientSource = fs.readFileSync(path.join(root, "scripts", "production-diagnostics-client.js"), "utf8");
+assert.match(clientSource, /\.secrets["'], "production-diagnostics\.json"/);
+assert.match(clientSource, /method: "POST"/);
+assert.match(clientSource, /new URL\("\/api\/diagnostics\/v1\/run"/);
+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 df99d51..5cc5225 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.1";
-const previousCoreVersion = "0.2.0";
+const releaseVersion = "0.2.2";
+const previousCoreVersion = "0.2.1";
const earliestCompatibleCoreVersion = "0.1.9";
const changedPlugins = {
"auto-vc": { from: "0.1.5", to: "0.1.6", knowledge: "auto-vc" },
@@ -52,7 +52,7 @@ assert.equal(releaseIndex.releases[0].version, releaseVersion);
assert.equal(releaseIndex.releases[0].ref, `refs/tags/v${releaseVersion}`);
assert.equal(releaseIndex.releases[1].version, previousCoreVersion);
assert.equal(releaseIndex.releases[1].ref, `refs/tags/v${previousCoreVersion}`);
-assert.equal(releaseIndex.releases[2].version, earliestCompatibleCoreVersion);
+assert.equal(releaseIndex.releases.at(-1).version, earliestCompatibleCoreVersion);
assert.equal(hasVersionHeading(readText("CHANGELOG.md"), releaseVersion), true);
assert.match(readText("knowledge/core/lumi-core.md"), new RegExp(`^Version: ${escapeRegex(releaseVersion)}$`, "m"));
@@ -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.1 and 11 changed plugin/tool packages.");
+console.log("Release metadata verification passed: core 0.2.2 and 11 changed plugin/tool packages.");
diff --git a/scripts/verify-update-system.js b/scripts/verify-update-system.js
index 7628df3..4253f80 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.1", "0.2.0", "0.1.9"]);
+assert.deepEqual(releaseVersions, ["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,6 +37,7 @@ for (const [toolId, version] of Object.entries(currentRelease.tools)) {
const baseTarget = {
current_version: "0.2.0",
available_versions: [
+ { version: "0.2.2", ref: "refs/tags/v0.2.2", rollback_safe: true },
{ version: "0.2.1", ref: "refs/tags/v0.2.1", rollback_safe: true },
{ version: "0.2.0", ref: "refs/tags/v0.2.0", rollback_safe: true },
{ version: "0.1.9", ref: "refs/tags/v0.1.9", rollback_safe: true }
@@ -61,7 +62,7 @@ const corrected = buildStatus({
channel: "stable"
});
assert.equal(corrected.version_correction, true);
-assert.equal(corrected.safe_target_version, "0.2.1");
+assert.equal(corrected.safe_target_version, "0.2.2");
assert.equal(corrected.update_available, true);
assert.equal(corrected.blocked, false);
@@ -73,6 +74,12 @@ for (const required of ["data", "plugins", "node_modules", "knowledge/community"
const serverSource = fs.readFileSync(path.join(root, "src", "web", "server.js"), "utf8");
assert.doesNotMatch(serverSource, /require\([^\n]*plugins\/okf\/backend/);
assert.match(serverSource, /global\.lumiFrameworks\?\.okf\?\.saveCorrection/);
+const repoUpdateSource = fs.readFileSync(path.join(root, "src", "services", "repo-update.js"), "utf8");
+assert.match(repoUpdateSource, /Plugin update failed while \$\{stage\}/);
+assert.match(repoUpdateSource, /last_update_stage: stage/);
+assert.match(repoUpdateSource, /stage = "preparing the selected repository version"[\s\S]*verifyPluginFiles\(pluginId, managed\.path, target\.safe_target_version\)[\s\S]*stage = "creating the rollback snapshot"/);
+assert.match(repoUpdateSource, /stage = "replacing plugin files"[\s\S]*applyPluginFiles\(path\.join\(managed\.path, "plugins", pluginId\)/);
+assert.match(repoUpdateSource, /last_update_stage: "complete",\s*last_error: null/);
const connectorSource = fs.readFileSync(path.join(root, "src", "services", "overlay-connectors.js"), "utf8");
assert.match(connectorSource, /try\s*{[\s\S]*require\("obs-websocket-js"\)/);
diff --git a/scripts/verify-webui.js b/scripts/verify-webui.js
index 61b7038..243ad4e 100644
--- a/scripts/verify-webui.js
+++ b/scripts/verify-webui.js
@@ -205,7 +205,9 @@ function verifySharedUpdateActions() {
assert(serverSource.includes('`Core version ${status.core.safe_target_version} is available.`'));
assert(serverSource.includes('`${plugin.name} version ${plugin.safe_target_version} is available.`'));
assert(updates.includes("data-update-check-form"));
+ assert(updates.includes("data-update-inline-result"));
assert(updates.includes('class="lumi-expandable-settings update-version-picker"'));
+ assert(appSource.includes('submitter.textContent = "Update failed"'));
assert(!updates.includes("reinstall current"));
assert(!updates.includes(" · core <%= release.core_version %>"));
}
diff --git a/src/services/production-diagnostics.js b/src/services/production-diagnostics.js
new file mode 100644
index 0000000..ec573aa
--- /dev/null
+++ b/src/services/production-diagnostics.js
@@ -0,0 +1,316 @@
+const crypto = require("crypto");
+const fs = require("fs");
+const path = require("path");
+const { performance } = require("perf_hooks");
+const { db } = require("./db");
+const { dependencyIssues } = require("./dependency-manager");
+const { listLogs, log } = require("./logger");
+const { getPlugins, scanPluginDirectories } = require("./plugins");
+const { readRecoveryMarker } = require("./recovery-mode");
+const { getSetting, setSetting } = require("./settings");
+const { readUpdateState } = require("./update-repository");
+
+const repoRoot = path.join(__dirname, "..", "..");
+const packageJson = require(path.join(repoRoot, "package.json"));
+const TOKEN_PREFIX = "lumi_diag_";
+const MAX_REQUESTS_PER_MINUTE = 20;
+const requestWindows = new Map();
+const CHECKS = Object.freeze({
+ system_health: "Runtime, database, dependency, disk-space, and recovery health.",
+ update_state: "Latest local update state, recovery marker, and snapshot summary.",
+ plugins: "Installed plugin manifests and registry versions without plugin data.",
+ recent_errors: "Recent warning/error records with secrets and local paths redacted.",
+ benchmark: "Bounded read-only database, plugin-scan, and serialization timing."
+});
+
+function diagnosticsAccessStatus() {
+ return {
+ enabled: getSetting("production_diagnostics_enabled", false) === true,
+ configured: Boolean(getSetting("production_diagnostics_key_hash", "")),
+ key_prefix: String(getSetting("production_diagnostics_key_prefix", "") || ""),
+ created_at: getSetting("production_diagnostics_key_created_at", null),
+ checks: Object.entries(CHECKS).map(([id, description]) => ({ id, description }))
+ };
+}
+
+function issueDiagnosticsAccessKey() {
+ const key = `${TOKEN_PREFIX}${crypto.randomBytes(32).toString("base64url")}`;
+ setSetting("production_diagnostics_key_hash", hashAccessKey(key));
+ setSetting("production_diagnostics_key_prefix", `${key.slice(0, TOKEN_PREFIX.length + 8)}…`);
+ setSetting("production_diagnostics_key_created_at", new Date().toISOString());
+ setSetting("production_diagnostics_enabled", true);
+ log("warn", "Production diagnostics access key rotated", { key_prefix: `${key.slice(0, TOKEN_PREFIX.length + 8)}…` });
+ return key;
+}
+
+function revokeDiagnosticsAccess() {
+ setSetting("production_diagnostics_enabled", false);
+ setSetting("production_diagnostics_key_hash", "");
+ setSetting("production_diagnostics_key_prefix", "");
+ setSetting("production_diagnostics_key_created_at", null);
+ requestWindows.clear();
+ log("warn", "Production diagnostics access revoked");
+}
+
+function authenticateDiagnosticsRequest(req, now = Date.now()) {
+ if (!diagnosticsAccessStatus().enabled) return { allowed: false, status: 404, reason: "disabled" };
+ if (!isSecureDiagnosticRequest(req)) return { allowed: false, status: 404, reason: "secure_transport_required" };
+ const authorization = String(req.get?.("authorization") || "");
+ const match = authorization.match(/^Bearer\s+([^\s]+)$/i);
+ const candidate = match?.[1] || "";
+ const storedHash = String(getSetting("production_diagnostics_key_hash", "") || "");
+ if (!verifyAccessKey(candidate, storedHash)) return { allowed: false, status: 404, reason: "invalid_key" };
+ const fingerprint = hashAccessKey(candidate).slice(0, 20);
+ const rate = consumeRateLimit(fingerprint, now);
+ if (!rate.allowed) return { allowed: false, status: 429, reason: "rate_limited", retry_after_seconds: rate.retry_after_seconds };
+ return { allowed: true, fingerprint, remaining: rate.remaining };
+}
+
+function runDiagnosticCheck(checkId) {
+ const check = String(checkId || "").trim();
+ if (!Object.hasOwn(CHECKS, check)) throw new Error("Unknown diagnostic check.");
+ const started = performance.now();
+ let result;
+ if (check === "system_health") result = systemHealth();
+ else if (check === "update_state") result = updateState();
+ else if (check === "plugins") result = pluginInventory();
+ else if (check === "recent_errors") result = recentErrors();
+ else result = boundedBenchmark();
+ return redactDiagnosticValue({
+ schema_version: 1,
+ check,
+ generated_at: new Date().toISOString(),
+ duration_ms: round(performance.now() - started),
+ result
+ });
+}
+
+function auditDiagnosticRequest(values = {}) {
+ log(values.ok === false ? "warn" : "info", "Production diagnostics request", {
+ request_id: values.request_id,
+ check: values.check,
+ ok: values.ok !== false,
+ key_fingerprint: values.fingerprint || null,
+ reason: values.reason || null,
+ duration_ms: values.duration_ms || null
+ });
+}
+
+function recentDiagnosticAudit(limit = 30) {
+ return listLogs({ limit: 250 })
+ .filter((entry) => ["Production diagnostics request", "Production diagnostics access key rotated", "Production diagnostics access revoked"].includes(entry.message))
+ .slice(0, Math.max(1, Math.min(100, Number(limit) || 30)))
+ .map((entry) => redactDiagnosticValue(entry));
+}
+
+function systemHealth() {
+ let database = { ok: false };
+ try {
+ database = { ok: db.prepare("SELECT 1 AS ok").get()?.ok === 1 };
+ } catch (error) {
+ database = { ok: false, error: error.message };
+ }
+ let disk = null;
+ try {
+ if (typeof fs.statfsSync !== "function") throw new Error("Disk statistics are unavailable on this Node.js version.");
+ const stats = fs.statfsSync(repoRoot);
+ disk = {
+ available_bytes: Number(stats.bavail) * Number(stats.bsize),
+ total_bytes: Number(stats.blocks) * Number(stats.bsize)
+ };
+ } catch {
+ disk = { available: false };
+ }
+ const dependencies = dependencyIssues(repoRoot);
+ return {
+ core_version: packageJson.version,
+ node_version: process.version,
+ platform: process.platform,
+ architecture: process.arch,
+ uptime_seconds: Math.floor(process.uptime()),
+ memory: process.memoryUsage(),
+ database,
+ disk,
+ dependencies: {
+ ready: dependencies.filter((item) => !item.optional).length === 0,
+ issues: dependencies
+ },
+ recovery: summarizeRecovery(readRecoveryMarker())
+ };
+}
+
+function updateState() {
+ const state = readUpdateState();
+ const index = readJson(path.join(repoRoot, "data", "snapshots", "index.json"), []);
+ const snapshots = Array.isArray(index) ? index : [];
+ return {
+ core_version: packageJson.version,
+ last_update: {
+ status: state.last_update_status || null,
+ target_kind: state.last_target_kind || null,
+ target_id: state.last_target_id || null,
+ target_version: state.last_target_version || null,
+ stage: state.last_update_stage || null,
+ error: state.last_error || null,
+ updated_at: state.last_update_at || state.updated_at || null,
+ source_ref: state.branch || null,
+ commit: state.commit || null
+ },
+ recovery: summarizeRecovery(readRecoveryMarker()),
+ snapshots: {
+ available: snapshots.filter((entry) => entry.status === "available").length,
+ latest: snapshots
+ .filter((entry) => entry.status === "available")
+ .sort((left, right) => Number(right.createdAt) - Number(left.createdAt))
+ .slice(0, 10)
+ .map((entry) => ({
+ type: entry.type,
+ plugin_id: entry.pluginId || null,
+ from_version: entry.from_version || null,
+ to_version: entry.to_version || null,
+ created_at: entry.createdAt || null,
+ storage_bytes: entry.storage_bytes || null
+ }))
+ }
+ };
+}
+
+function pluginInventory() {
+ const registry = new Map(getPlugins().map((plugin) => [plugin.id, plugin]));
+ return scanPluginDirectories().map((plugin) => ({
+ id: plugin.id,
+ name: plugin.name,
+ manifest_version: plugin.version,
+ registry_version: registry.get(plugin.id)?.version || null,
+ enabled: Boolean(registry.get(plugin.id)?.enabled),
+ version_matches_registry: !registry.has(plugin.id) || registry.get(plugin.id)?.version === plugin.version
+ })).sort((left, right) => left.id.localeCompare(right.id));
+}
+
+function recentErrors() {
+ return listLogs({ limit: 50, levels: ["warn", "error"] }).map((entry) => ({
+ level: entry.level,
+ message: entry.message,
+ details: entry.details,
+ created_at: entry.created_at
+ }));
+}
+
+function boundedBenchmark() {
+ const databaseStarted = performance.now();
+ for (let index = 0; index < 100; index += 1) db.prepare("SELECT 1 AS ok").get();
+ const databaseMs = performance.now() - databaseStarted;
+ const pluginStarted = performance.now();
+ let pluginCount = 0;
+ for (let index = 0; index < 5; index += 1) pluginCount = scanPluginDirectories().length;
+ const pluginMs = performance.now() - pluginStarted;
+ const serializationStarted = performance.now();
+ const sample = Array.from({ length: 250 }, (_, index) => ({ index, status: "ok", version: packageJson.version }));
+ for (let index = 0; index < 20; index += 1) JSON.stringify(sample);
+ const serializationMs = performance.now() - serializationStarted;
+ return {
+ fixed_workload: true,
+ database_select_100_ms: round(databaseMs),
+ plugin_scan_5_ms: round(pluginMs),
+ plugin_count: pluginCount,
+ json_serialize_20_ms: round(serializationMs)
+ };
+}
+
+function redactDiagnosticValue(value, key = "", depth = 0) {
+ if (depth > 8) return "[truncated]";
+ if (/(?:token|secret|password|passwd|authorization|cookie|session|credential|private.?key|api.?key)/i.test(key)) return "[redacted]";
+ if (typeof value === "string") return redactString(value).slice(0, 4000);
+ if (typeof value === "bigint") return Number(value);
+ if (Array.isArray(value)) return value.slice(0, 100).map((item) => redactDiagnosticValue(item, key, depth + 1));
+ if (value && typeof value === "object") {
+ return Object.fromEntries(Object.entries(value).slice(0, 100).map(([childKey, child]) => [childKey, redactDiagnosticValue(child, childKey, depth + 1)]));
+ }
+ return value;
+}
+
+function redactString(value) {
+ return String(value)
+ .replace(/\blumi_diag_[A-Za-z0-9_-]+\b/g, "[diagnostics key]")
+ .replace(/(https?:\/\/)[^/@\s:]+:[^/@\s]+@/gi, "$1[credentials]@")
+ .replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, "[signed token]")
+ .replace(/\b([a-z0-9_-]*(?:token|secret|session|password|passwd|api[_-]?key|auth)[a-z0-9_-]*)\s*[:=]\s*[^,\s;]+/gi, "$1=[redacted]")
+ .replace(/\b(?:bearer|basic)\s+[a-z0-9._~+/=-]+/gi, "[auth header]")
+ .replace(/([?&](?:token|secret|session|password|api_key|apikey|auth|code)=)[^\s]+/gi, "$1[redacted]")
+ .replace(/(?:[A-Za-z]:\\|\\\\)[^\s<>\"|?*]+/g, "[local path]")
+ .replace(/\/(?:home|Users|mnt|var|tmp|opt|root)\/[^\s)\]}>]+/g, "[local path]")
+ .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, "[email]")
+ .replace(/[A-Za-z0-9+_=-]{40,}/g, (candidate) => /[A-Za-z]/.test(candidate) && /\d/.test(candidate) ? "[high-entropy value]" : candidate);
+}
+
+function hashAccessKey(value) {
+ return crypto.createHash("sha256").update(String(value || "")).digest("hex");
+}
+
+function verifyAccessKey(candidate, expectedHash) {
+ if (!String(candidate || "").startsWith(TOKEN_PREFIX) || !/^[a-f0-9]{64}$/i.test(String(expectedHash || ""))) return false;
+ const actual = Buffer.from(hashAccessKey(candidate), "hex");
+ const expected = Buffer.from(expectedHash, "hex");
+ return actual.length === expected.length && crypto.timingSafeEqual(actual, expected);
+}
+
+function consumeRateLimit(fingerprint, now = Date.now()) {
+ const windowStart = now - (now % 60000);
+ const current = requestWindows.get(fingerprint);
+ const state = !current || current.window_start !== windowStart ? { window_start: windowStart, count: 0 } : current;
+ state.count += 1;
+ requestWindows.set(fingerprint, state);
+ if (requestWindows.size > 1000) {
+ for (const [key, value] of requestWindows.entries()) if (value.window_start < windowStart) requestWindows.delete(key);
+ }
+ return {
+ allowed: state.count <= MAX_REQUESTS_PER_MINUTE,
+ remaining: Math.max(0, MAX_REQUESTS_PER_MINUTE - state.count),
+ retry_after_seconds: Math.max(1, Math.ceil((windowStart + 60000 - now) / 1000))
+ };
+}
+
+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);
+}
+
+function summarizeRecovery(marker) {
+ if (!marker) return { active: false };
+ return {
+ active: ["pending", "applying", "verifying", "failed", "stale"].includes(marker.status),
+ status: marker.status || null,
+ target_kind: marker.target_kind || null,
+ target_id: marker.target_id || null,
+ from_version: marker.from_version || null,
+ to_version: marker.to_version || null,
+ error: marker.error || null,
+ updated_at: marker.updated_at || null
+ };
+}
+
+function readJson(filePath, fallback) {
+ try { return JSON.parse(fs.readFileSync(filePath, "utf8")); } catch { return fallback; }
+}
+
+function round(value) {
+ return Math.round(Number(value) * 100) / 100;
+}
+
+module.exports = {
+ CHECKS,
+ MAX_REQUESTS_PER_MINUTE,
+ auditDiagnosticRequest,
+ authenticateDiagnosticsRequest,
+ consumeRateLimit,
+ diagnosticsAccessStatus,
+ hashAccessKey,
+ issueDiagnosticsAccessKey,
+ isSecureDiagnosticRequest,
+ recentDiagnosticAudit,
+ redactDiagnosticValue,
+ revokeDiagnosticsAccess,
+ runDiagnosticCheck,
+ verifyAccessKey
+};
diff --git a/src/services/repo-update.js b/src/services/repo-update.js
index cbd1a9b..a200b34 100644
--- a/src/services/repo-update.js
+++ b/src/services/repo-update.js
@@ -65,17 +65,6 @@ function verifyPluginFiles(pluginId, rootPath = repoRoot, expectedVersion = null
return metadata;
}
-function applyPluginFromRepositorySnapshot(remote, repositoryRef, pluginId, expectedVersion) {
- const managed = ensureManagedRepo(remote, repositoryRef);
- const pluginRoot = path.join(managed.path, "plugins", pluginId);
- if (!fs.existsSync(path.join(pluginRoot, "plugin.json"))) {
- throw new Error(`Plugin ${pluginId} was not found in ${repositoryRef}.`);
- }
- verifyPluginFiles(pluginId, managed.path, expectedVersion);
- applyPluginFiles(pluginRoot, pluginId, { preserveData: true });
- return managed;
-}
-
function targetForRequestedVersion(baseTarget, requestedVersion, label) {
if (!requestedVersion) return baseTarget;
const version = parseSemver(requestedVersion)?.raw;
@@ -166,6 +155,8 @@ async function applyCoreUpdate({ source = "stable", remote = null, version = nul
branch: managed.branch,
last_update_at: new Date().toISOString(),
last_update_status: "complete",
+ last_update_stage: "complete",
+ last_error: null,
last_snapshot_id: record.id,
last_target_kind: "core",
last_target_version: target.safe_target_version
@@ -206,28 +197,41 @@ async function applyCoreUpdate({ source = "stable", remote = null, version = nul
async function applyPluginUpdateFromRepo(pluginId, { source = "stable", remote = null, version = null, publish } = {}) {
return withOperation(`plugin:${pluginId}`, async () => {
- const status = getUpdateStatus({ source, remote });
- const statusTarget = status.plugins.find((plugin) => plugin.id === pluginId);
- if (!statusTarget) throw new Error("Plugin was not found in the local or repository catalog.");
- const target = targetForRequestedVersion(statusTarget, version, "Plugin version");
- if (target.blocked) throw new Error(target.blocked_reason || "Plugin update is blocked.");
- if (!target.update_available) throw new Error("No safe plugin update target is available.");
- const marker = createRecoveryMarker({
- target_kind: "plugin",
- target_id: pluginId,
- from_version: target.current_version,
- to_version: target.safe_target_version,
- source_branch: target.source_branch,
- update_method: target.installed === false ? "git_install" : "git",
- rollback_safe: target.rollback_safe,
- major_crossing: target.major_crossing
- });
+ let status = null;
+ let target = null;
+ let marker = null;
+ let managed = null;
let snapshot = null;
let snapshotRecord = null;
+ let stage = "checking repository metadata";
try {
+ status = getUpdateStatus({ source, remote });
+ const statusTarget = status.plugins.find((plugin) => plugin.id === pluginId);
+ if (!statusTarget) throw new Error("Plugin was not found in the local or repository catalog.");
+ target = targetForRequestedVersion(statusTarget, version, "Plugin version");
+ if (target.blocked) throw new Error(target.blocked_reason || "Plugin update is blocked.");
+ if (!target.update_available) throw new Error("No plugin update target is available.");
emitProgress(publish, "update:queued", { target: "plugin", plugin_id: pluginId });
emitProgress(publish, "update:metadata", target);
+
+ stage = "preparing the selected repository version";
+ managed = ensureManagedRepo(status.remote, target.source_branch);
+ verifyPluginFiles(pluginId, managed.path, target.safe_target_version);
+
+ stage = "preparing recovery";
+ marker = createRecoveryMarker({
+ target_kind: "plugin",
+ target_id: pluginId,
+ from_version: target.current_version,
+ to_version: target.safe_target_version,
+ source_branch: target.source_branch,
+ update_method: target.installed === false ? "git_install" : "git",
+ rollback_safe: target.rollback_safe,
+ major_crossing: target.major_crossing
+ });
updateRecoveryMarker({ status: "applying" });
+
+ stage = "creating the rollback snapshot";
snapshot = await createSnapshot({
type: "plugin",
pluginId,
@@ -249,14 +253,11 @@ async function applyPluginUpdateFromRepo(pluginId, { source = "stable", remote =
emitProgress(publish, "update:snapshot", { target: "plugin", plugin_id: pluginId, snapshot_id: snapshotRecord.id });
emitProgress(publish, "update:download", { target: "plugin", plugin_id: pluginId, branch: target.source_branch });
emitProgress(publish, "update:apply", { target: "plugin", plugin_id: pluginId });
- const managed = applyPluginFromRepositorySnapshot(
- status.remote,
- target.source_branch,
- pluginId,
- target.safe_target_version
- );
+ stage = "replacing plugin files";
+ applyPluginFiles(path.join(managed.path, "plugins", pluginId), pluginId, { preserveData: true });
updateRecoveryMarker({ status: "verifying" });
emitProgress(publish, "update:verify", { target: "plugin", plugin_id: pluginId });
+ stage = "verifying the installed plugin";
verifyPluginFiles(pluginId, repoRoot, target.safe_target_version);
syncPluginRegistry();
const record = snapshotRecord;
@@ -266,6 +267,8 @@ async function applyPluginUpdateFromRepo(pluginId, { source = "stable", remote =
branch: managed.branch,
last_update_at: new Date().toISOString(),
last_update_status: "complete",
+ last_update_stage: "complete",
+ last_error: null,
last_snapshot_id: record.id,
last_target_kind: "plugin",
last_target_id: pluginId,
@@ -275,6 +278,9 @@ async function applyPluginUpdateFromRepo(pluginId, { source = "stable", remote =
emitProgress(publish, "update:complete", { target: "plugin", plugin_id: pluginId, snapshot_id: record.id });
return { status: "complete", restart_required: true, snapshot: record, target };
} catch (error) {
+ const originalMessage = error?.message || String(error || "Unknown update error.");
+ error.message = `Plugin update failed while ${stage}: ${originalMessage}`;
+ const recoveryDiagnostics = [];
if (snapshotRecord) {
try {
restoreSnapshot(snapshotRecord.id, {
@@ -284,23 +290,36 @@ async function applyPluginUpdateFromRepo(pluginId, { source = "stable", remote =
allowUnsafeMajorRollback: true
});
} catch (restoreError) {
- markRecoveryMarkerFailed(restoreError);
+ recoveryDiagnostics.push(`automatic restore failed: ${restoreError.message}`);
}
} else if (snapshot) {
try {
discardSnapshot(snapshot);
- } catch {
- // Ignore cleanup failures.
+ } catch (discardError) {
+ recoveryDiagnostics.push(`incomplete snapshot cleanup failed: ${discardError.message}`);
}
}
- markRecoveryMarkerFailed(error);
- writeUpdateState({
- last_update_at: new Date().toISOString(),
- last_update_status: "failed",
- last_target_kind: "plugin",
- last_target_id: pluginId,
- last_error: error.message
- });
+ if (marker) {
+ try {
+ markRecoveryMarkerFailed(error);
+ } catch (markerError) {
+ recoveryDiagnostics.push(`recovery marker update failed: ${markerError.message}`);
+ }
+ }
+ if (recoveryDiagnostics.length) error.message += ` Recovery diagnostics: ${recoveryDiagnostics.join("; ")}`;
+ try {
+ writeUpdateState({
+ last_update_at: new Date().toISOString(),
+ last_update_status: "failed",
+ last_target_kind: "plugin",
+ last_target_id: pluginId,
+ last_target_version: target?.safe_target_version || null,
+ last_update_stage: stage,
+ last_error: error.message
+ });
+ } catch (stateError) {
+ error.message += ` Update-state diagnostics could not be saved: ${stateError.message}`;
+ }
emitProgress(publish, "update:failed", { target: "plugin", plugin_id: pluginId, error: error.message });
throw error;
}
diff --git a/src/services/settings.js b/src/services/settings.js
index 9259e78..80a49b8 100644
--- a/src/services/settings.js
+++ b/src/services/settings.js
@@ -68,6 +68,7 @@ function ensureDefaults() {
discord_redirect_uri: envString("DISCORD_REDIRECT_URI", ""),
auto_update_enabled: envBoolean("AUTO_UPDATE_ENABLED", false),
auto_update_interval_minutes: envNumber("AUTO_UPDATE_INTERVAL_MINUTES", 60),
+ production_diagnostics_enabled: false,
git_remote: envString("GIT_REMOTE", "origin"),
git_branch: envString("GIT_BRANCH", "main"),
bot_avatar_url: null,
diff --git a/src/web/public/app.js b/src/web/public/app.js
index 7802af7..ff55646 100644
--- a/src/web/public/app.js
+++ b/src/web/public/app.js
@@ -1470,7 +1470,8 @@
row.textContent = message;
updateLog.prepend(row);
}
- const inlineResult = form?.closest(".card")?.querySelector("[data-update-inline-result]");
+ const resultScope = form?.closest(".plugin-update-row, [data-update-target='core'], .card");
+ const inlineResult = resultScope?.querySelector("[data-update-inline-result]");
if (inlineResult) {
inlineResult.className = `hint ${level === "danger" ? "status-danger" : level === "success" ? "status-success" : ""}`.trim();
inlineResult.textContent = message;
@@ -1564,7 +1565,7 @@
}
} catch (error) {
if (isStateButton) window.LumiStateButton?.error?.(submitter);
- else if (submitter) submitter.textContent = "Failed";
+ else if (submitter) submitter.textContent = "Update failed";
appendUpdateLog(error.message, "danger", form);
} finally {
if (!isStateButton && submitter) {
diff --git a/src/web/server.js b/src/web/server.js
index 7affb7c..ec2a4c0 100644
--- a/src/web/server.js
+++ b/src/web/server.js
@@ -106,6 +106,15 @@ const {
clearRecoveryMarker,
updateRecoveryMarker
} = require("../services/recovery-mode");
+const {
+ auditDiagnosticRequest,
+ authenticateDiagnosticsRequest,
+ diagnosticsAccessStatus,
+ issueDiagnosticsAccessKey,
+ recentDiagnosticAudit,
+ revokeDiagnosticsAccess,
+ runDiagnosticCheck
+} = require("../services/production-diagnostics");
const {
generateCommandPreview,
previewParts
@@ -2876,6 +2885,10 @@ async function verifyYouTubeSettings(settings) {
function createWebServer({ loadPlugins, discordClient }) {
const app = express();
+ // 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");
const webhooks = createWebhookService();
placeholders.registerCorePlaceholders();
placeholders.registerPlatformPlaceholders({
@@ -3182,6 +3195,43 @@ function createWebServer({ loadPlugins, discordClient }) {
};
registerPublicOverlayRoutes(app);
+ app.post("/api/diagnostics/v1/run", (req, res) => {
+ res.set("Cache-Control", "no-store");
+ res.set("Pragma", "no-cache");
+ const authentication = authenticateDiagnosticsRequest(req);
+ if (!authentication.allowed) {
+ if (authentication.status === 429) {
+ res.set("Retry-After", String(authentication.retry_after_seconds));
+ return res.status(429).json({ ok: false, error: "Too many diagnostic requests. Try again shortly." });
+ }
+ return res.status(404).json({ error: "Not found." });
+ }
+ const requestId = crypto.randomUUID();
+ const startedAt = Date.now();
+ const check = String(req.body?.check || "").trim();
+ res.set("X-RateLimit-Remaining", String(authentication.remaining));
+ try {
+ const diagnostic = runDiagnosticCheck(check);
+ auditDiagnosticRequest({
+ request_id: requestId,
+ check,
+ ok: true,
+ fingerprint: authentication.fingerprint,
+ duration_ms: Date.now() - startedAt
+ });
+ return res.json({ ok: true, request_id: requestId, diagnostic });
+ } catch (error) {
+ auditDiagnosticRequest({
+ request_id: requestId,
+ check,
+ ok: false,
+ fingerprint: authentication.fingerprint,
+ reason: error.message,
+ duration_ms: Date.now() - startedAt
+ });
+ return res.status(400).json({ ok: false, request_id: requestId, error: error.message });
+ }
+ });
app.use(requireConfigured);
app.get("/api/events", requireAuth, subscribeWebEvents);
app.post("/api/destructive-confirmations", requireAuth, (req, res) => {
@@ -5719,6 +5769,62 @@ function createWebServer({ loadPlugins, discordClient }) {
res.redirect("/admin/theming");
});
+ const renderDiagnosticsAdmin = (res, values = {}) => {
+ res.set("Cache-Control", "no-store");
+ res.render("admin-diagnostics", {
+ title: "Production diagnostics",
+ diagnosticsAccess: diagnosticsAccessStatus(),
+ diagnosticAudit: recentDiagnosticAudit(),
+ issuedKey: null,
+ diagnosticResult: null,
+ diagnosticError: null,
+ selectedCheck: "system_health",
+ ...values
+ });
+ };
+
+ app.get("/admin/diagnostics", requireRole("admin"), (_req, res) => {
+ renderDiagnosticsAdmin(res);
+ });
+
+ app.post("/admin/diagnostics/run", requireRole("admin"), (req, res) => {
+ const requestId = crypto.randomUUID();
+ const check = String(req.body.check || "").trim();
+ const startedAt = Date.now();
+ try {
+ const diagnosticResult = runDiagnosticCheck(check);
+ auditDiagnosticRequest({
+ request_id: requestId,
+ check,
+ ok: true,
+ fingerprint: `admin:${req.session.user.id}`,
+ duration_ms: Date.now() - startedAt
+ });
+ renderDiagnosticsAdmin(res, { diagnosticResult, selectedCheck: check });
+ } catch (error) {
+ auditDiagnosticRequest({
+ request_id: requestId,
+ check,
+ ok: false,
+ fingerprint: `admin:${req.session.user.id}`,
+ reason: error.message,
+ duration_ms: Date.now() - startedAt
+ });
+ renderDiagnosticsAdmin(res, { diagnosticError: error.message, selectedCheck: check });
+ }
+ });
+
+ app.post("/admin/diagnostics/access/renew", requireRole("admin"), (_req, res) => {
+ const issuedKey = issueDiagnosticsAccessKey();
+ renderDiagnosticsAdmin(res, { issuedKey });
+ });
+
+ app.post("/admin/diagnostics/access/revoke", requireRole("admin"), (req, res) => {
+ revokeDiagnosticsAccess();
+ setFlash(req, "success", "Production diagnostics access revoked.");
+ res.redirect("/admin/diagnostics");
+ });
+
app.get("/admin/logs", requireRole("admin"), (req, res) => {
const range = parseLogRange(req.query.range);
const limit = parseLogLimit(req.query.limit);
@@ -6992,6 +7098,7 @@ function collectNavItems(user, pluginNav, currentPath) {
section: "admin"
},
{ label: "Feedback review", path: "/admin/feedback", role: "admin", section: "admin" },
+ { label: "Diagnostics", path: "/admin/diagnostics", role: "admin", section: "admin" },
{ label: "Logs", path: "/admin/logs", role: "admin", section: "admin" },
{ label: "Updates", path: "/admin/updates", role: "admin", section: "admin" },
{
@@ -7252,6 +7359,7 @@ function getDefaultNavIcon(item) {
if (pathName === "/admin/navigation") return "settings";
if (pathName === "/admin/theming") return "theming";
if (pathName === "/admin/privileges") return "privileges";
+ if (pathName === "/admin/diagnostics") return "admin";
if (pathName === "/admin/logs") return "logs";
if (pathName === "/admin/updates") return "updates";
if (pathName === "/admin/commands") return "commands";
diff --git a/src/web/views/admin-diagnostics.ejs b/src/web/views/admin-diagnostics.ejs
new file mode 100644
index 0000000..9241dce
--- /dev/null
+++ b/src/web/views/admin-diagnostics.ejs
@@ -0,0 +1,111 @@
+<%- include("partials/layout-top", { title }) %>
+
+ This service has no shell, file browser, SQL input, URL fetching, or write actions. It only runs the fixed checks listed below, redacts secrets and local paths, rate-limits requests, and records access in Lumi's logs. It is shown once. Store it on the computer that will run diagnostics, preferably in the ignored <%= diagnosticError %>.secrets directory. Lumi stores only its hash.