From dbcecfe2aa6629a0665150e56c6306287c6fd225 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Sat, 30 May 2026 21:48:05 +0200 Subject: [PATCH] feat(restore): self-heal restore_allow_force default ON at boot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/server.js | 12 ++ backend/src/services/_restoreSettingsBoot.js | 109 +++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 backend/src/services/_restoreSettingsBoot.js diff --git a/backend/server.js b/backend/server.js index 527efc4e..d0a1bf7b 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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(); diff --git a/backend/src/services/_restoreSettingsBoot.js b/backend/src/services/_restoreSettingsBoot.js new file mode 100644 index 00000000..592d258e --- /dev/null +++ b/backend/src/services/_restoreSettingsBoot.js @@ -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 };