fix(restore): default restore_allow_force=true + auto-upgrade existing installs

Root cause of the persistent "Force restore is not allowed by system
settings" error even on fresh installs after `docker compose down -v`:

  migrations/core/032_add_restore_runs_table.js seeded the row with
  `JSON.stringify(false)` = the literal string 'false'.

So every install (fresh OR upgraded) wrote restore_allow_force=false
at migration time. The boot self-heal added earlier today saw the row
and respected "admin policy" per its safety design — never noticing
that the row was the deprecated migration default, not an explicit
admin choice.

Cure follows [[feedback_migration_no_compensation]] +
[[feedback_self_heal_pattern]]:

  1. Edit migration 032 IN PLACE — flip seed value from false to
     true. Fresh installs forward get the correct default at install
     time, no boot helper needed.

  2. One-time auto-upgrade in _restoreSettingsBoot.js for installs
     that already ran the OLD migration. Bumps restore_allow_force
     to 'true' iff the current value is the deprecated literal
     'false' AND the new tracking key
     `restore_allow_force_auto_upgraded` doesn't yet exist. The
     tracking flag is always written after the first boot pass, so
     subsequent admin choices (e.g. deliberately disabling force)
     are preserved on every boot after.

  3. Defensive: adminRestore.js getRestoreSettings() now normalizes
     'true'/'false'/'"true"'/'"false"' string shapes to JS booleans,
     not just '1'/'0'. Belt-and-suspenders so any future seeder that
     uses a different boolean serialization doesn't silently break
     the !settings.restore_allow_force gate.

Net effect: any picpeak install pulling this image — fresh or
existing — gets restore_allow_force=true on first boot after the
upgrade. The catch-22 that forced every disaster-recovery admin to
hand-write SQL before their FIRST restore is closed.
This commit is contained in:
Luca
2026-05-31 21:35:48 +02:00
parent dbcecfe2aa
commit c435263744
3 changed files with 93 additions and 13 deletions
+12 -6
View File
@@ -718,17 +718,23 @@ async function getRestoreSettings() {
const settings = await db('app_settings')
.where('setting_type', 'restore')
.select('setting_key', 'setting_value');
const result = {};
settings.forEach(setting => {
// Convert boolean strings to actual booleans
if (setting.setting_value === '1' || setting.setting_value === '0') {
result[setting.setting_key] = setting.setting_value === '1';
// Boolean-string normalization. Historically only handled '1'/'0',
// but other code paths (boot self-heal, admin UI, direct SQL) write
// 'true' / 'false' or JSON-encoded "true" / "false". Accept all four
// shapes so `!settings.<key>` evaluates correctly downstream.
const raw = setting.setting_value;
if (raw === '1' || raw === 'true' || raw === '"true"') {
result[setting.setting_key] = true;
} else if (raw === '0' || raw === 'false' || raw === '"false"') {
result[setting.setting_key] = false;
} else {
result[setting.setting_key] = setting.setting_value;
result[setting.setting_key] = raw;
}
});
return result;
}
+70 -6
View File
@@ -57,6 +57,27 @@ const SEEDS = [
},
];
/**
* Installs that ran migration 032 BEFORE the 2026-05-30 in-place edit
* have a `restore_allow_force` row with the deprecated `false` default
* (literal string `'false'` from `JSON.stringify(false)`). Per
* [[feedback_self_heal_pattern]] knex won't re-run the corrected
* migration on those installs, so we have to bump the row to `true`
* here ONCE at boot.
*
* The bump is guarded by a tracking row `restore_allow_force_auto_upgraded`
* so we don't fight an admin who explicitly disables force later:
* - Tracking row absent → bump if the value is the deprecated `'false'`
* - Tracking row present → never touch `restore_allow_force` again
*
* The bump applies ONLY when the existing value EXACTLY equals the old
* migration default. Any other value (`'true'`, admin-set anything,
* empty, null) is left alone — those reflect either the fixed
* migration's output or a deliberate admin choice.
*/
const DEPRECATED_DEFAULT_VALUE = 'false';
const AUTO_UPGRADE_FLAG_KEY = 'restore_allow_force_auto_upgraded';
let booted = false;
/**
@@ -64,24 +85,27 @@ let booted = false;
*
* @param {object} db knex instance
* @param {object} logger app logger (must expose .info / .warn)
* @returns {Promise<{ seeded: string[] }>}
* @returns {Promise<{ seeded: string[], upgraded: string[] }>}
*/
async function seedRestoreSettingsAtBoot(db, logger) {
const log = logger || { info: () => {}, warn: () => {} };
if (booted) return { seeded: [] };
if (booted) return { seeded: [], upgraded: [] };
if (!(await db.schema.hasTable('app_settings'))) {
log.warn('app_settings table missing at boot — restore-settings self-heal skipped');
return { seeded: [] };
return { seeded: [], upgraded: [] };
}
const seeded = [];
const upgraded = [];
// Step 1: fresh-install seeding. Insert rows that don't exist at all.
for (const seed of SEEDS) {
try {
const existing = await db('app_settings')
.where('setting_key', seed.setting_key)
.first();
if (existing) continue; // admin policy already in effect
if (existing) continue;
await db('app_settings').insert({
setting_key: seed.setting_key,
@@ -90,14 +114,54 @@ async function seedRestoreSettingsAtBoot(db, logger) {
updated_at: new Date(),
});
seeded.push(seed.setting_key);
log.info(`Seeded restore-meta setting ${seed.setting_key}=${seed.setting_value} (${seed.rationale.slice(0, 80)}...)`);
log.info(`Seeded restore-meta setting ${seed.setting_key}=${seed.setting_value}`);
} catch (err) {
log.warn(`Failed to seed restore-meta setting ${seed.setting_key}: ${err.message}`);
}
}
// Step 2: one-time auto-upgrade for installs that ran the OLD
// migration 032 (which seeded restore_allow_force='false'). Bump
// to 'true' iff the value is still the deprecated default AND the
// auto-upgrade tracking flag hasn't already been set.
try {
const guard = await db('app_settings')
.where('setting_key', AUTO_UPGRADE_FLAG_KEY)
.first();
if (!guard) {
const row = await db('app_settings')
.where('setting_key', 'restore_allow_force')
.first();
if (row && row.setting_value === DEPRECATED_DEFAULT_VALUE) {
await db('app_settings')
.where('setting_key', 'restore_allow_force')
.update({
setting_value: 'true',
updated_at: new Date(),
});
upgraded.push('restore_allow_force');
log.info('Auto-upgraded restore_allow_force from deprecated migration-032 default \'false\' to \'true\' '
+ '(fresh-install disaster recovery now works without SQL incantation)');
}
// Always set the guard, even if no upgrade happened — prevents
// the bump from firing later if an admin sets the value to
// false on purpose.
await db('app_settings').insert({
setting_key: AUTO_UPGRADE_FLAG_KEY,
setting_value: 'true',
setting_type: 'restore',
updated_at: new Date(),
});
}
} catch (err) {
log.warn(`restore_allow_force auto-upgrade failed: ${err.message}`);
}
booted = true;
return { seeded };
return { seeded, upgraded };
}
// Test-only: reset the module-level boot flag so jest can re-exercise