fix: resolve inherited form actions

This commit is contained in:
Franz Rolfsvaag 2026-07-18 16:53:12 +02:00
parent c1bf74c3f0
commit ccb8cb70be
15 changed files with 91 additions and 26 deletions

View File

@ -1,5 +1,10 @@
# Lumi changelog # Lumi changelog
## 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 the same browser behavior in asynchronous core/plugin update forms, which could submit to the Updates page itself and report a context-free failure without running the update.
## 0.2.2 ## 0.2.2
- Added stage-specific plugin update failures beside the affected plugin and persisted the production failure stage in update state. - Added stage-specific plugin update failures beside the affected plugin and persisted the production failure stage in update state.

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: 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.
- 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 modules versions. - 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 modules versions.

View File

@ -160,6 +160,10 @@ Admin update actions publish Server-Sent Events through
Core update success returns a five-second in-page notice before refresh/restart. 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 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. whole-page refresh, then restarts Lumi so the selected plugin code is loaded.
Submit buttons inherit the endpoint and method from their parent form unless
they explicitly declare `formaction` or `formmethod`; this prevents browsers'
current-page fallback values from redirecting an update away from its apply
route.
Plugin failures are shown directly beside the affected plugin instead of only Plugin failures are shown directly beside the affected plugin instead of only
changing the action button label. The error identifies the failed stage: changing the action button label. The error identifies the failed stage:

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.2 Version: 0.2.3
## 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.2", "version": "0.2.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "lumi-bot", "name": "lumi-bot",
"version": "0.2.2", "version": "0.2.3",
"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.2", "version": "0.2.3",
"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.3",
"ref": "refs/tags/v0.2.3",
"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": "Fixes shared form endpoint resolution so timed confirmations and asynchronous repository updates reach their intended routes.",
"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.2", "version": "0.2.2",
"ref": "refs/tags/v0.2.2", "ref": "refs/tags/v0.2.2",

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.2-repair.zip"); const destination = path.join(root, "dist", "lumi-core-1.2.0-to-0.2.3-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.2 repair", name: "Lumi core 0.2.3 repair",
target: "core", target: "core",
from_versions: ["0.1.9", "1.2.0", "0.2.0", "0.2.1", "0.2.2"], from_versions: ["0.1.9", "1.2.0", "0.2.0", "0.2.1", "0.2.2", "0.2.3"],
to_version: "0.2.2", to_version: "0.2.3",
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.2-repair.zip"); const archivePath = path.join(root, "dist", "lumi-core-1.2.0-to-0.2.3-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.2"); assert.equal(manifest.to_version, "0.2.3");
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"]); assert.deepEqual(manifest.from_versions, ["0.1.9", "1.2.0", "0.2.0", "0.2.1", "0.2.2", "0.2.3"]);
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.2"); assert.equal(JSON.parse(zip.readAsText("package.json")).version, "0.2.3");
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.2"); assert.equal(verifyPatchPackage(simulation).to_version, "0.2.3");
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.2"); assert.equal(JSON.parse(fs.readFileSync(path.join(simulation, "package.json"), "utf8")).version, "0.2.3");
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

@ -77,6 +77,7 @@ function verifySharedInfrastructure() {
const appScript = fs.readFileSync(path.join(root, "src", "web", "public", "app.js"), "utf8"); const appScript = fs.readFileSync(path.join(root, "src", "web", "public", "app.js"), "utf8");
assert(appScript.includes("submitter?.dataset?.confirmMode"), "Submit-button confirmation metadata is ignored"); assert(appScript.includes("submitter?.dataset?.confirmMode"), "Submit-button confirmation metadata is ignored");
assert(appScript.includes("submitter?.dataset?.confirmLabel || form.dataset.confirmLabel"), "The clicked submit button cannot override generic confirmation copy"); assert(appScript.includes("submitter?.dataset?.confirmLabel || form.dataset.confirmLabel"), "The clicked submit button cannot override generic confirmation copy");
assert(appScript.includes('submitter?.hasAttribute?.("formaction") ? submitter.formAction : form.action'), "Submit buttons without formaction incorrectly override their parent form action");
assert(appScript.includes('form.querySelector(\'input[name="confirmation_token"]\')'), "Timed form confirmation tokens are not attached to submitted forms"); assert(appScript.includes('form.querySelector(\'input[name="confirmation_token"]\')'), "Timed form confirmation tokens are not attached to submitted forms");
assert(appScript.includes("const response = await fetch(action, requestOptions)"), "Async update requests do not submit through the shared confirmed form payload"); assert(appScript.includes("const response = await fetch(action, requestOptions)"), "Async update requests do not submit through the shared confirmed form payload");
assert(!appScript.includes("window.LumiConfirm.destructiveFetch(form.action"), "Timed update forms request a second confirmation instead of reusing the form token"); assert(!appScript.includes("window.LumiConfirm.destructiveFetch(form.action"), "Timed update forms request a second confirmation instead of reusing the form token");

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.2"; const releaseVersion = "0.2.3";
const previousCoreVersion = "0.2.1"; const previousCoreVersion = "0.2.2";
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.2 and 11 changed plugin/tool packages."); console.log("Release metadata verification passed: core 0.2.3 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.2", "0.2.1", "0.2.0", "0.1.9"]); assert.deepEqual(releaseVersions, ["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.0", current_version: "0.2.2",
available_versions: [ available_versions: [
{ 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 },
{ version: "0.2.0", ref: "refs/tags/v0.2.0", rollback_safe: true }, { version: "0.2.0", ref: "refs/tags/v0.2.0", rollback_safe: true },
@ -62,7 +63,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.2"); assert.equal(corrected.safe_target_version, "0.2.3");
assert.equal(corrected.update_available, true); assert.equal(corrected.update_available, true);
assert.equal(corrected.blocked, false); assert.equal(corrected.blocked, false);

View File

@ -195,7 +195,9 @@ function verifySharedUpdateActions() {
const dashboard = fs.readFileSync(path.join(root, "src", "web", "views", "admin-dashboard.ejs"), "utf8"); const dashboard = fs.readFileSync(path.join(root, "src", "web", "views", "admin-dashboard.ejs"), "utf8");
const settings = fs.readFileSync(path.join(root, "src", "web", "views", "admin-settings.ejs"), "utf8"); const settings = fs.readFileSync(path.join(root, "src", "web", "views", "admin-settings.ejs"), "utf8");
assert(appSource.includes('button[data-update-action]')); assert(appSource.includes('button[data-update-action]'));
assert(appSource.includes("submitter?.formAction || form.action")); assert(appSource.includes('submitter?.hasAttribute?.("formaction") ? submitter.formAction : form.action'));
assert(appSource.includes('submitter?.hasAttribute?.("formmethod") ? submitter.formMethod : form.method'));
assert(!appSource.includes("submitter?.formAction || form.action"));
assert(appSource.includes('actionPath.endsWith("/check")')); assert(appSource.includes('actionPath.endsWith("/check")'));
assert(appSource.includes('form.matches("[data-update-check-form]")')); assert(appSource.includes('form.matches("[data-update-check-form]")'));
assert(dashboard.includes('action="/admin/check-update" class="inline-form" data-update-action')); assert(dashboard.includes('action="/admin/check-update" class="inline-form" data-update-action'));

View File

@ -1146,9 +1146,18 @@
let activeDestructive = null; let activeDestructive = null;
let activeCallbackConfirm = null; let activeCallbackConfirm = null;
// A button without a formaction can report the current document URL through
// its formAction property. Only prefer the button value when the attribute
// was explicitly authored; otherwise the parent form owns the endpoint.
const effectiveFormAction = (form, submitter = null) =>
submitter?.hasAttribute?.("formaction") ? submitter.formAction : form.action;
const effectiveFormMethod = (form, submitter = null) =>
submitter?.hasAttribute?.("formmethod") ? submitter.formMethod : form.method;
const destructiveAction = (form, submitter = null) => { const destructiveAction = (form, submitter = null) => {
try { try {
const action = submitter?.formAction || form.action; const action = effectiveFormAction(form, submitter);
return new URL(action, window.location.origin).pathname; return new URL(action, window.location.origin).pathname;
} catch { } catch {
return ""; return "";
@ -1517,7 +1526,7 @@
const submitter = event.submitter || form.querySelector("button[type='submit']"); const submitter = event.submitter || form.querySelector("button[type='submit']");
if (!form.matches("[data-update-action]") && !submitter?.matches?.("[data-update-action]")) return; if (!form.matches("[data-update-action]") && !submitter?.matches?.("[data-update-action]")) return;
if (form.matches("[data-update-check-form]")) return; if (form.matches("[data-update-check-form]")) return;
const action = submitter?.formAction || form.action; const action = effectiveFormAction(form, submitter);
const actionPath = normalizeDestructiveAction(action); const actionPath = normalizeDestructiveAction(action);
if (updateLog && actionPath.startsWith("/admin/updates/") && actionPath.endsWith("/check")) return; if (updateLog && actionPath.startsWith("/admin/updates/") && actionPath.endsWith("/check")) return;
event.preventDefault(); event.preventDefault();
@ -1548,7 +1557,7 @@
appendUpdateLog(`Started ${submitter?.textContent?.trim() || "update action"}.`, "info", form); appendUpdateLog(`Started ${submitter?.textContent?.trim() || "update action"}.`, "info", form);
try { try {
const requestOptions = { const requestOptions = {
method: submitter?.formMethod || form.method || "POST", method: effectiveFormMethod(form, submitter) || "POST",
body: new FormData(form), body: new FormData(form),
headers: { Accept: "application/json" }, headers: { Accept: "application/json" },
credentials: "same-origin" credentials: "same-origin"

View File

@ -1,6 +1,6 @@
{ {
"name": "Lumi Core", "name": "Lumi Core",
"version": "0.2.2", "version": "0.2.3",
"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, and an optional secured read-only production diagnostics endpoint. 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, 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"
@ -49,6 +49,18 @@
], ],
"rollback_safe": true, "rollback_safe": true,
"migration_notes": "Adds stage-specific plugin update preflight/error reporting and an optional secured read-only production diagnostics endpoint; preserved local data is not replaced." "migration_notes": "Adds stage-specific plugin update preflight/error reporting and an optional secured read-only production diagnostics endpoint; preserved local data is not replaced."
},
{
"version": "0.2.3",
"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": "Fixes shared browser form endpoint resolution for timed confirmations and repository updates; preserved local data is not replaced."
} }
] ]
} }