feat(restore): self-heal restore_allow_force default ON at boot

Fresh installs of picpeak had `restore_allow_force` defaulting to
false (or missing entirely). Combined with the "1 active admin
user" pre-restore warning that the fresh-install admin auto-creates,
this meant the very first restore on every new install hit:

  Force restore is not allowed by system settings

Admins then had to hand-craft SQL to flip the setting before they
could recover their data — at the worst possible moment, when they
were already mid-disaster.

This isn't security: the admin who can SQL the setting on can also
flip it via the UI. It's just a sharp edge that bites every new
install once.

Cure: boot-time self-heal that seeds restore_allow_force=true only
when the row doesn't exist. Existing installs that explicitly set
the row (true OR false) are NOT touched — admin policy wins.
Pattern mirrors _backupPathsBoot.js and _emailTemplateBoot.js.

Default-ON rationale matches Stage A's principle: the cost of
forgetting (= can't recover from a disaster) outweighs the friction
saved (= adversarial admins can't run forced restores). Audit
logging keeps the accountability story intact.
This commit is contained in:
Luca
2026-05-30 21:48:05 +02:00
parent 7f7c8eee61
commit dbcecfe2aa
2 changed files with 121 additions and 0 deletions
+12
View File
@@ -812,6 +812,18 @@ async function startServer() {
logger.warn('backup_paths self-heal failed at boot:', err.message);
}
// Self-heal restore-meta settings — currently just
// `restore_allow_force` defaulting to ON so fresh installs can
// recover from disaster without a SQL incantation. Only seeds on
// FRESH installs (existing rows, true or false, are preserved).
// See _restoreSettingsBoot.js for the full rationale.
try {
const { seedRestoreSettingsAtBoot } = require('./src/services/_restoreSettingsBoot');
await seedRestoreSettingsAtBoot(db, logger);
} catch (err) {
logger.warn('restore-settings self-heal failed at boot:', err.message);
}
// Start backup service
await startBackupService();
@@ -0,0 +1,109 @@
/**
* Boot-time self-heal for restore-meta settings.
*
* **Why this exists**
*
* `restore_allow_force` gates whether the Restore wizard accepts a
* `force: true` payload. The flag exists to add admin friction
* before letting a restore override safety warnings (e.g. "1 active
* admin user — restoring would clobber the current install").
*
* In practice the friction lands at the worst possible moment: a
* fresh install (no app_settings row yet OR `restore_allow_force =
* false` by default) hits the wall on its very FIRST restore. The
* admin is mid disaster-recovery, panicked, and gets:
*
* "Force restore is not allowed by system settings"
*
* They then have to hand-craft SQL like
*
* INSERT INTO app_settings (setting_key, setting_value, ...)
* VALUES ('restore_allow_force', 'true', 'restore', NOW())
* ON CONFLICT ... SET setting_value = 'true';
*
* before they can recover their data. This isn't security — the
* admin who could run that SQL could also flip the setting via the
* UI. It's just a sharp edge that bites every new install once.
*
* Cure: seed the default ON at boot via `INSERT ... ON CONFLICT
* DO NOTHING`. New installs get force-allowed out of the box.
* Existing installs that have explicitly set the row (true OR
* false) are NOT overwritten — admin policy wins. Same pattern
* `_backupPathsBoot.js` uses for the canonical backup_paths rows.
*
* **Default-ON rationale (matches Stage A's principle)**
*
* Stage A defaulted inline DB dumps to ON because the cost of
* forgetting was data loss. By the same logic, `restore_allow_force`
* defaults ON because the cost of forgetting is being unable to
* recover from a disaster. Audit logging captures every forced
* restore so the accountability story stays intact.
*
* If/when the broader "exclude restore-meta settings from being
* overwritten by restore" follow-up lands (the second half of this
* chicken-and-egg), this self-heal becomes the safety net for
* fresh installs only — existing installs by that point have the
* row preserved across restores.
*/
const SEEDS = [
{
setting_key: 'restore_allow_force',
setting_value: 'true',
setting_type: 'restore',
rationale: 'Default ON so fresh installs can recover from disaster '
+ 'without a SQL incantation. Admins who want to require manual '
+ 'intervention can disable via the admin UI.',
},
];
let booted = false;
/**
* Seed the canonical restore-meta settings on fresh installs.
*
* @param {object} db knex instance
* @param {object} logger app logger (must expose .info / .warn)
* @returns {Promise<{ seeded: string[] }>}
*/
async function seedRestoreSettingsAtBoot(db, logger) {
const log = logger || { info: () => {}, warn: () => {} };
if (booted) return { seeded: [] };
if (!(await db.schema.hasTable('app_settings'))) {
log.warn('app_settings table missing at boot — restore-settings self-heal skipped');
return { seeded: [] };
}
const seeded = [];
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
await db('app_settings').insert({
setting_key: seed.setting_key,
setting_value: seed.setting_value,
setting_type: seed.setting_type,
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)}...)`);
} catch (err) {
log.warn(`Failed to seed restore-meta setting ${seed.setting_key}: ${err.message}`);
}
}
booted = true;
return { seeded };
}
// Test-only: reset the module-level boot flag so jest can re-exercise
// the seeder against a fresh test DB inside a single worker.
function _resetBootForTests() {
booted = false;
}
module.exports = { seedRestoreSettingsAtBoot, _resetBootForTests, SEEDS };