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
## 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
- 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
- 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 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.

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.
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.
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
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.
## Runtime
Package: lumi-bot
Version: 0.2.2
Version: 0.2.3
## Routes
- GET /api/events
- POST /api/destructive-confirmations

4
package-lock.json generated
View File

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

View File

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

View File

@ -2,6 +2,36 @@
"schema_version": 1,
"channel": "stable",
"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",
"ref": "refs/tags/v0.2.2",

View File

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

View File

@ -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.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");
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.2");
assert.equal(manifest.to_version, "0.2.3");
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$/;
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.2");
assert.equal(JSON.parse(zip.readAsText("package.json")).version, "0.2.3");
const simulation = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-repair-simulation-"));
try {
@ -47,11 +47,11 @@ try {
fs.writeFileSync(target, `preserve:${sentinel}`);
}
zip.extractAllTo(simulation, true);
assert.equal(verifyPatchPackage(simulation).to_version, "0.2.2");
assert.equal(verifyPatchPackage(simulation).to_version, "0.2.3");
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.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", "production-diagnostics.js")), 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");
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?.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("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");

View File

@ -4,8 +4,8 @@ const path = require("path");
const { findSafeTarget } = require("../src/services/versioning");
const root = path.join(__dirname, "..");
const releaseVersion = "0.2.2";
const previousCoreVersion = "0.2.1";
const releaseVersion = "0.2.3";
const previousCoreVersion = "0.2.2";
const earliestCompatibleCoreVersion = "0.1.9";
const changedPlugins = {
"auto-vc": { from: "0.1.5", to: "0.1.6", knowledge: "auto-vc" },
@ -87,4 +87,4 @@ assert.equal(webSearch.minimum_lumi_version, "0.2.0");
assert.equal(webSearch.minimum_lumi_ai_version, changedPlugins.lumi_ai.to);
assert.equal(hasVersionHeading(readText("plugins/lumi_ai_web_search/CHANGELOG.md"), webSearch.version), true);
console.log("Release metadata verification passed: core 0.2.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 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");
for (const release of releaseIndex.releases) {
assert.equal(normalizeRepositoryRef(release.ref), release.ref);
@ -35,8 +35,9 @@ for (const [toolId, version] of Object.entries(currentRelease.tools)) {
}
const baseTarget = {
current_version: "0.2.0",
current_version: "0.2.2",
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.1", ref: "refs/tags/v0.2.1", 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"
});
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.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 settings = fs.readFileSync(path.join(root, "src", "web", "views", "admin-settings.ejs"), "utf8");
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('form.matches("[data-update-check-form]")'));
assert(dashboard.includes('action="/admin/check-update" class="inline-form" data-update-action'));

View File

@ -1146,9 +1146,18 @@
let activeDestructive = 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) => {
try {
const action = submitter?.formAction || form.action;
const action = effectiveFormAction(form, submitter);
return new URL(action, window.location.origin).pathname;
} catch {
return "";
@ -1517,7 +1526,7 @@
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-check-form]")) return;
const action = submitter?.formAction || form.action;
const action = effectiveFormAction(form, submitter);
const actionPath = normalizeDestructiveAction(action);
if (updateLog && actionPath.startsWith("/admin/updates/") && actionPath.endsWith("/check")) return;
event.preventDefault();
@ -1548,7 +1557,7 @@
appendUpdateLog(`Started ${submitter?.textContent?.trim() || "update action"}.`, "info", form);
try {
const requestOptions = {
method: submitter?.formMethod || form.method || "POST",
method: effectiveFormMethod(form, submitter) || "POST",
body: new FormData(form),
headers: { Accept: "application/json" },
credentials: "same-origin"

View File

@ -1,6 +1,6 @@
{
"name": "Lumi Core",
"version": "0.2.2",
"version": "0.2.3",
"channel": "stable",
"released_at": "2026-07-18",
"compatible_from": "0.1.9",
@ -8,7 +8,7 @@
"replaces_versions": [
"1.2.0"
],
"migration_notes": "Includes the 1.2.0 version correction, production plugin-update diagnostics, 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,
"requirements": [
"Node.js 18 or newer"
@ -49,6 +49,18 @@
],
"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."
},
{
"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."
}
]
}