fix: show the active diagnostics URL

This commit is contained in:
Franz Rolfsvaag 2026-07-18 16:56:40 +02:00
parent ccb8cb70be
commit 5c17408221
14 changed files with 87 additions and 33 deletions

View File

@ -1,5 +1,9 @@
# Lumi changelog # Lumi changelog
## 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.
## 0.2.3 ## 0.2.3
- Fixed shared form action resolution so submit buttons without a `formaction` use their parent form's endpoint, restoring timed confirmations such as diagnostics-key creation. - Fixed shared form action resolution so submit buttons without a `formaction` use their parent form's endpoint, restoring timed confirmations such as diagnostics-key creation.

View File

@ -677,6 +677,7 @@ This section is for Lumi chat answer feedback and AI Improvement Center work, no
## Done ## Done
- 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: 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. - 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: 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.

View File

@ -14,7 +14,7 @@ editable: false
Lumi is the core web UI and bot runtime. Lumi is the core web UI and bot runtime.
## Runtime ## Runtime
Package: lumi-bot Package: lumi-bot
Version: 0.2.3 Version: 0.2.4
## Routes ## Routes
- GET /api/events - GET /api/events
- POST /api/destructive-confirmations - POST /api/destructive-confirmations

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "lumi-bot", "name": "lumi-bot",
"version": "0.2.3", "version": "0.2.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "lumi-bot", "name": "lumi-bot",
"version": "0.2.3", "version": "0.2.4",
"dependencies": { "dependencies": {
"adm-zip": "^0.5.12", "adm-zip": "^0.5.12",
"better-sqlite3": "^11.5.0", "better-sqlite3": "^11.5.0",

View File

@ -1,6 +1,6 @@
{ {
"name": "lumi-bot", "name": "lumi-bot",
"version": "0.2.3", "version": "0.2.4",
"private": true, "private": true,
"type": "commonjs", "type": "commonjs",
"scripts": { "scripts": {

View File

@ -2,6 +2,36 @@
"schema_version": 1, "schema_version": 1,
"channel": "stable", "channel": "stable",
"releases": [ "releases": [
{
"version": "0.2.4",
"ref": "refs/tags/v0.2.4",
"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": "Uses the administrator's current request origin for production diagnostics endpoint and local-client examples.",
"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.3", "version": "0.2.3",
"ref": "refs/tags/v0.2.3", "ref": "refs/tags/v0.2.3",

View File

@ -4,7 +4,7 @@ const path = require("path");
const AdmZip = require("adm-zip"); const AdmZip = require("adm-zip");
const root = path.join(__dirname, ".."); const root = path.join(__dirname, "..");
const destination = path.join(root, "dist", "lumi-core-1.2.0-to-0.2.3-repair.zip"); const destination = path.join(root, "dist", "lumi-core-1.2.0-to-0.2.4-repair.zip");
const files = [ const files = [
"CHANGELOG.md", "CHANGELOG.md",
"README.md", "README.md",
@ -53,10 +53,10 @@ for (const relativePath of files) {
const manifest = { const manifest = {
schema_version: 1, schema_version: 1,
name: "Lumi core 0.2.3 repair", name: "Lumi core 0.2.4 repair",
target: "core", target: "core",
from_versions: ["0.1.9", "1.2.0", "0.2.0", "0.2.1", "0.2.2", "0.2.3"], 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.3", to_version: "0.2.4",
data_policy: "preserve", data_policy: "preserve",
dependency_policy: "sync_on_restart", dependency_policy: "sync_on_restart",
created_at: new Date().toISOString(), created_at: new Date().toISOString(),

View File

@ -7,7 +7,7 @@ const AdmZip = require("adm-zip");
const { verifyPatchPackage } = require("../src/services/update-manager"); const { verifyPatchPackage } = require("../src/services/update-manager");
const root = path.join(__dirname, ".."); const root = path.join(__dirname, "..");
const archivePath = path.join(root, "dist", "lumi-core-1.2.0-to-0.2.3-repair.zip"); const archivePath = path.join(root, "dist", "lumi-core-1.2.0-to-0.2.4-repair.zip");
assert.equal(fs.existsSync(archivePath), true, "build the repair patch first"); assert.equal(fs.existsSync(archivePath), true, "build the repair patch first");
const zip = new AdmZip(archivePath); const zip = new AdmZip(archivePath);
const entries = zip.getEntries().filter((entry) => !entry.isDirectory); 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); assert.equal(names.has("patch-manifest.json"), true);
const manifest = JSON.parse(zip.readAsText("patch-manifest.json")); const manifest = JSON.parse(zip.readAsText("patch-manifest.json"));
assert.equal(manifest.target, "core"); assert.equal(manifest.target, "core");
assert.equal(manifest.to_version, "0.2.3"); assert.equal(manifest.to_version, "0.2.4");
assert.equal(manifest.data_policy, "preserve"); 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"]); 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"]);
const forbidden = /^(?:data|plugins|node_modules|config|storage|uploads|logs|database|databases|knowledge\/(?:community|corrections))(?:\/|$)|^\.env(?:\.|$)|^\.secrets$/; const forbidden = /^(?:data|plugins|node_modules|config|storage|uploads|logs|database|databases|knowledge\/(?:community|corrections))(?:\/|$)|^\.env(?:\.|$)|^\.secrets$/;
for (const entry of entries) { 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(actual, expected, `${relativePath} checksum`);
} }
assert.equal(Object.keys(manifest.files).length + 1, entries.length, "every repair file must be checksummed"); 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.3"); assert.equal(JSON.parse(zip.readAsText("package.json")).version, "0.2.4");
const simulation = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-repair-simulation-")); const simulation = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-repair-simulation-"));
try { try {
@ -47,11 +47,11 @@ try {
fs.writeFileSync(target, `preserve:${sentinel}`); fs.writeFileSync(target, `preserve:${sentinel}`);
} }
zip.extractAllTo(simulation, true); zip.extractAllTo(simulation, true);
assert.equal(verifyPatchPackage(simulation).to_version, "0.2.3"); assert.equal(verifyPatchPackage(simulation).to_version, "0.2.4");
for (const sentinel of sentinels) { for (const sentinel of sentinels) {
assert.equal(fs.readFileSync(path.join(simulation, sentinel), "utf8"), `preserve:${sentinel}`); 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.3"); assert.equal(JSON.parse(fs.readFileSync(path.join(simulation, "package.json"), "utf8")).version, "0.2.4");
assert.equal(fs.existsSync(path.join(simulation, "src", "services", "dependency-manager.js")), true); 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", "services", "production-diagnostics.js")), true);
assert.equal(fs.existsSync(path.join(simulation, "src", "web", "views", "admin-diagnostics.ejs")), true); assert.equal(fs.existsSync(path.join(simulation, "src", "web", "views", "admin-diagnostics.ejs")), true);

View File

@ -79,7 +79,10 @@ const viewSource = fs.readFileSync(path.join(root, "src", "web", "views", "admin
assert.match(viewSource, /no remote control/i); assert.match(viewSource, /no remote control/i);
assert.match(viewSource, /data-confirm-mode="modal"/); assert.match(viewSource, /data-confirm-mode="modal"/);
assert.match(viewSource, /\.secrets/); assert.match(viewSource, /\.secrets/);
assert.match(viewSource, /\/api\/diagnostics\/v1\/run/); 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"\)\}`/);
const clientSource = fs.readFileSync(path.join(root, "scripts", "production-diagnostics-client.js"), "utf8"); const clientSource = fs.readFileSync(path.join(root, "scripts", "production-diagnostics-client.js"), "utf8");
assert.match(clientSource, /\.secrets["'], "production-diagnostics\.json"/); assert.match(clientSource, /\.secrets["'], "production-diagnostics\.json"/);

View File

@ -4,8 +4,8 @@ const path = require("path");
const { findSafeTarget } = require("../src/services/versioning"); const { findSafeTarget } = require("../src/services/versioning");
const root = path.join(__dirname, ".."); const root = path.join(__dirname, "..");
const releaseVersion = "0.2.3"; const releaseVersion = "0.2.4";
const previousCoreVersion = "0.2.2"; const previousCoreVersion = "0.2.3";
const earliestCompatibleCoreVersion = "0.1.9"; const earliestCompatibleCoreVersion = "0.1.9";
const changedPlugins = { const changedPlugins = {
"auto-vc": { from: "0.1.5", to: "0.1.6", knowledge: "auto-vc" }, "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(webSearch.minimum_lumi_ai_version, changedPlugins.lumi_ai.to);
assert.equal(hasVersionHeading(readText("plugins/lumi_ai_web_search/CHANGELOG.md"), webSearch.version), true); assert.equal(hasVersionHeading(readText("plugins/lumi_ai_web_search/CHANGELOG.md"), webSearch.version), true);
console.log("Release metadata verification passed: core 0.2.3 and 11 changed plugin/tool packages."); console.log("Release metadata verification passed: core 0.2.4 and 11 changed plugin/tool packages.");

View File

@ -16,7 +16,7 @@ function readJson(relativePath) {
const releaseIndex = readJson("release-index.json"); const releaseIndex = readJson("release-index.json");
const releaseVersions = releaseIndex.releases.map((release) => release.version); const releaseVersions = releaseIndex.releases.map((release) => release.version);
assert.deepEqual(releaseVersions, ["0.2.3", "0.2.2", "0.2.1", "0.2.0", "0.1.9"]); assert.deepEqual(releaseVersions, ["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"); assert.equal(new Set(releaseVersions).size, releaseVersions.length, "release versions must be unique");
for (const release of releaseIndex.releases) { for (const release of releaseIndex.releases) {
assert.equal(normalizeRepositoryRef(release.ref), release.ref); assert.equal(normalizeRepositoryRef(release.ref), release.ref);
@ -35,8 +35,9 @@ for (const [toolId, version] of Object.entries(currentRelease.tools)) {
} }
const baseTarget = { const baseTarget = {
current_version: "0.2.2", current_version: "0.2.3",
available_versions: [ available_versions: [
{ 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.3", ref: "refs/tags/v0.2.3", rollback_safe: true },
{ version: "0.2.2", ref: "refs/tags/v0.2.2", rollback_safe: true }, { 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.1", ref: "refs/tags/v0.2.1", rollback_safe: true },
@ -63,7 +64,7 @@ const corrected = buildStatus({
channel: "stable" channel: "stable"
}); });
assert.equal(corrected.version_correction, true); assert.equal(corrected.version_correction, true);
assert.equal(corrected.safe_target_version, "0.2.3"); assert.equal(corrected.safe_target_version, "0.2.4");
assert.equal(corrected.update_available, true); assert.equal(corrected.update_available, true);
assert.equal(corrected.blocked, false); assert.equal(corrected.blocked, false);

View File

@ -5769,7 +5769,8 @@ function createWebServer({ loadPlugins, discordClient }) {
res.redirect("/admin/theming"); res.redirect("/admin/theming");
}); });
const renderDiagnosticsAdmin = (res, values = {}) => { const renderDiagnosticsAdmin = (req, res, values = {}) => {
const diagnosticsBaseUrl = `${req.protocol}://${req.get("host")}`;
res.set("Cache-Control", "no-store"); res.set("Cache-Control", "no-store");
res.render("admin-diagnostics", { res.render("admin-diagnostics", {
title: "Production diagnostics", title: "Production diagnostics",
@ -5779,12 +5780,14 @@ function createWebServer({ loadPlugins, discordClient }) {
diagnosticResult: null, diagnosticResult: null,
diagnosticError: null, diagnosticError: null,
selectedCheck: "system_health", selectedCheck: "system_health",
diagnosticsBaseUrl,
diagnosticsEndpointUrl: `${diagnosticsBaseUrl}/api/diagnostics/v1/run`,
...values ...values
}); });
}; };
app.get("/admin/diagnostics", requireRole("admin"), (_req, res) => { app.get("/admin/diagnostics", requireRole("admin"), (req, res) => {
renderDiagnosticsAdmin(res); renderDiagnosticsAdmin(req, res);
}); });
app.post("/admin/diagnostics/run", requireRole("admin"), (req, res) => { app.post("/admin/diagnostics/run", requireRole("admin"), (req, res) => {
@ -5800,7 +5803,7 @@ function createWebServer({ loadPlugins, discordClient }) {
fingerprint: `admin:${req.session.user.id}`, fingerprint: `admin:${req.session.user.id}`,
duration_ms: Date.now() - startedAt duration_ms: Date.now() - startedAt
}); });
renderDiagnosticsAdmin(res, { diagnosticResult, selectedCheck: check }); renderDiagnosticsAdmin(req, res, { diagnosticResult, selectedCheck: check });
} catch (error) { } catch (error) {
auditDiagnosticRequest({ auditDiagnosticRequest({
request_id: requestId, request_id: requestId,
@ -5810,13 +5813,13 @@ function createWebServer({ loadPlugins, discordClient }) {
reason: error.message, reason: error.message,
duration_ms: Date.now() - startedAt duration_ms: Date.now() - startedAt
}); });
renderDiagnosticsAdmin(res, { diagnosticError: error.message, selectedCheck: check }); renderDiagnosticsAdmin(req, res, { diagnosticError: error.message, selectedCheck: check });
} }
}); });
app.post("/admin/diagnostics/access/renew", requireRole("admin"), (_req, res) => { app.post("/admin/diagnostics/access/renew", requireRole("admin"), (req, res) => {
const issuedKey = issueDiagnosticsAccessKey(); const issuedKey = issueDiagnosticsAccessKey();
renderDiagnosticsAdmin(res, { issuedKey }); renderDiagnosticsAdmin(req, res, { issuedKey });
}); });
app.post("/admin/diagnostics/access/revoke", requireRole("admin"), (req, res) => { app.post("/admin/diagnostics/access/revoke", requireRole("admin"), (req, res) => {

View File

@ -16,7 +16,7 @@
<div><span>Remote access</span><strong><%= diagnosticsAccess.enabled ? "Enabled" : "Disabled" %></strong></div> <div><span>Remote access</span><strong><%= diagnosticsAccess.enabled ? "Enabled" : "Disabled" %></strong></div>
<div><span>Access key</span><strong><%= diagnosticsAccess.configured ? diagnosticsAccess.key_prefix : "Not created" %></strong></div> <div><span>Access key</span><strong><%= diagnosticsAccess.configured ? diagnosticsAccess.key_prefix : "Not created" %></strong></div>
<div><span>Created</span><strong><%= diagnosticsAccess.created_at ? new Date(diagnosticsAccess.created_at).toLocaleString() : "Never" %></strong></div> <div><span>Created</span><strong><%= diagnosticsAccess.created_at ? new Date(diagnosticsAccess.created_at).toLocaleString() : "Never" %></strong></div>
<div><span>Endpoint</span><strong><code>/api/diagnostics/v1/run</code></strong></div> <div><span>Endpoint</span><strong><code><%= diagnosticsEndpointUrl %></code></strong></div>
</div> </div>
<% if (issuedKey) { %> <% if (issuedKey) { %>
@ -76,12 +76,12 @@
<fieldset> <fieldset>
<legend>Connect from a trusted computer</legend> <legend>Connect from a trusted computer</legend>
<p>Use the public HTTPS address for this Lumi installation. Plain HTTP is accepted only from the same machine.</p> <p>Use the public HTTPS address for this Lumi installation. Plain HTTP is accepted only from the same machine.</p>
<pre class="log-details">curl -X POST "https://your-lumi-host/api/diagnostics/v1/run" \ <pre class="log-details">curl -X POST "<%= diagnosticsEndpointUrl %>" \
-H "Authorization: Bearer $LUMI_DIAGNOSTICS_KEY" \ -H "Authorization: Bearer $LUMI_DIAGNOSTICS_KEY" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
--data '{"check":"update_state"}'</pre> --data '{"check":"update_state"}'</pre>
<p class="hint">Keep the key out of shell history, tickets, chat, and logs. A local environment variable or an ignored <code>.secrets</code> file is safer than placing it directly in a command.</p> <p class="hint">Keep the key out of shell history, tickets, chat, and logs. A local environment variable or an ignored <code>.secrets</code> file is safer than placing it directly in a command.</p>
<p class="hint">Repository maintainers can save <code>{ "base_url": "https://your-lumi-host", "key": "…" }</code> in <code>.secrets/production-diagnostics.json</code>, then run <code>npm run diagnostics:production -- update_state</code>.</p> <p class="hint">Repository maintainers can save <code>{ "base_url": "<%= diagnosticsBaseUrl %>", "key": "…" }</code> in <code>.secrets/production-diagnostics.json</code>, then run <code>npm run diagnostics:production -- update_state</code>.</p>
</fieldset> </fieldset>
</section> </section>

View File

@ -1,6 +1,6 @@
{ {
"name": "Lumi Core", "name": "Lumi Core",
"version": "0.2.3", "version": "0.2.4",
"channel": "stable", "channel": "stable",
"released_at": "2026-07-18", "released_at": "2026-07-18",
"compatible_from": "0.1.9", "compatible_from": "0.1.9",
@ -8,7 +8,7 @@
"replaces_versions": [ "replaces_versions": [
"1.2.0" "1.2.0"
], ],
"migration_notes": "Includes the 1.2.0 version correction, production plugin-update diagnostics, secured read-only production diagnostics, 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 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.",
"rollback_safe": true, "rollback_safe": true,
"requirements": [ "requirements": [
"Node.js 18 or newer" "Node.js 18 or newer"
@ -61,6 +61,18 @@
], ],
"rollback_safe": true, "rollback_safe": true,
"migration_notes": "Fixes shared browser form endpoint resolution for timed confirmations and repository updates; preserved local data is not replaced." "migration_notes": "Fixes shared browser form endpoint resolution for timed confirmations and repository updates; preserved local data is not replaced."
},
{
"version": "0.2.4",
"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": "Uses the administrator's current Lumi origin in production diagnostics examples; preserved local data is not replaced."
} }
] ]
} }