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
@@ -100,8 +100,18 @@ exports.up = async function(knex) {
// Add restore-related settings to app_settings // Add restore-related settings to app_settings
const restoreSettings = [ const restoreSettings = [
{ {
// Default ON so fresh installs can recover from disaster
// without the catch-22 documented in _restoreSettingsBoot.js
// (fresh-install admin user trips the "1 active admin" warning,
// which can only be overridden with force=true, which the wizard
// refused if this setting was false — exactly the moment an
// admin can least afford a SQL incantation). Flipped from false
// to true 2026-05-30. Existing installs that ran this migration
// with the OLD value will get auto-upgraded once by the boot
// self-heal in _restoreSettingsBoot.js — see the
// `restore_allow_force_auto_upgraded` guard there.
setting_key: 'restore_allow_force', setting_key: 'restore_allow_force',
setting_value: JSON.stringify(false), setting_value: JSON.stringify(true),
setting_type: 'restore' setting_type: 'restore'
}, },
{ {
+12 -6
View File
@@ -718,17 +718,23 @@ async function getRestoreSettings() {
const settings = await db('app_settings') const settings = await db('app_settings')
.where('setting_type', 'restore') .where('setting_type', 'restore')
.select('setting_key', 'setting_value'); .select('setting_key', 'setting_value');
const result = {}; const result = {};
settings.forEach(setting => { settings.forEach(setting => {
// Convert boolean strings to actual booleans // Boolean-string normalization. Historically only handled '1'/'0',
if (setting.setting_value === '1' || setting.setting_value === '0') { // but other code paths (boot self-heal, admin UI, direct SQL) write
result[setting.setting_key] = setting.setting_value === '1'; // '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 { } else {
result[setting.setting_key] = setting.setting_value; result[setting.setting_key] = raw;
} }
}); });
return result; 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; let booted = false;
/** /**
@@ -64,24 +85,27 @@ let booted = false;
* *
* @param {object} db knex instance * @param {object} db knex instance
* @param {object} logger app logger (must expose .info / .warn) * @param {object} logger app logger (must expose .info / .warn)
* @returns {Promise<{ seeded: string[] }>} * @returns {Promise<{ seeded: string[], upgraded: string[] }>}
*/ */
async function seedRestoreSettingsAtBoot(db, logger) { async function seedRestoreSettingsAtBoot(db, logger) {
const log = logger || { info: () => {}, warn: () => {} }; const log = logger || { info: () => {}, warn: () => {} };
if (booted) return { seeded: [] }; if (booted) return { seeded: [], upgraded: [] };
if (!(await db.schema.hasTable('app_settings'))) { if (!(await db.schema.hasTable('app_settings'))) {
log.warn('app_settings table missing at boot — restore-settings self-heal skipped'); log.warn('app_settings table missing at boot — restore-settings self-heal skipped');
return { seeded: [] }; return { seeded: [], upgraded: [] };
} }
const seeded = []; const seeded = [];
const upgraded = [];
// Step 1: fresh-install seeding. Insert rows that don't exist at all.
for (const seed of SEEDS) { for (const seed of SEEDS) {
try { try {
const existing = await db('app_settings') const existing = await db('app_settings')
.where('setting_key', seed.setting_key) .where('setting_key', seed.setting_key)
.first(); .first();
if (existing) continue; // admin policy already in effect if (existing) continue;
await db('app_settings').insert({ await db('app_settings').insert({
setting_key: seed.setting_key, setting_key: seed.setting_key,
@@ -90,14 +114,54 @@ async function seedRestoreSettingsAtBoot(db, logger) {
updated_at: new Date(), updated_at: new Date(),
}); });
seeded.push(seed.setting_key); 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) { } catch (err) {
log.warn(`Failed to seed restore-meta setting ${seed.setting_key}: ${err.message}`); 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; booted = true;
return { seeded }; return { seeded, upgraded };
} }
// Test-only: reset the module-level boot flag so jest can re-exercise // Test-only: reset the module-level boot flag so jest can re-exercise