Skip to content

Commit b66a912

Browse files
committed
Defensively coerce legacy config values during migration, and pass in error to fastify.log correctly
1 parent b7ab4b2 commit b66a912

File tree

2 files changed

+75
-17
lines changed

2 files changed

+75
-17
lines changed

apps/backend/src/modules/config/migration.ts

Lines changed: 72 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,56 @@
11
// This module is responsible for migrating the JSON config from the previous app (umbel-config.json) to this app's equivalent settings.json
2+
// It will run on first boot after the update and coerce the values to the new schema if needed.
23
import path from 'node:path'
34
import fse from 'fs-extra'
5+
import {ZodError} from 'zod'
46

57
import {writeWithBackup} from './fs-helpers.js'
68
import {APP_STATE_DIR} from '../../lib/paths.js'
7-
import {settingsSchema, defaultValues, type SettingsSchema} from '#settings'
9+
import {settingsSchema, defaultValues, settingsMetadata, type SettingsSchema, type Option} from '#settings'
810

911
const LEGACY_CONFIG_PATH = path.join(APP_STATE_DIR, 'bitcoin-config.json')
1012
const NEW_CONFIG_PATH = path.join(APP_STATE_DIR, 'settings.json')
1113

14+
// Normalise anything “boolean‑like” that may appear in the legacy file.
15+
const toBool = (v: unknown): boolean => {
16+
if (typeof v === 'boolean') return v
17+
if (typeof v === 'number') return v === 1
18+
if (typeof v === 'string') {
19+
const s = v.trim().toLowerCase()
20+
if (s === 'true') return true
21+
if (s === 'false') return false
22+
if (s === '1') return true
23+
if (s === '0') return false
24+
}
25+
return false
26+
}
27+
28+
// Coerce legacy string / number / boolean mixes into the correct runtime types expected by the new schema, using `settingsMetadata` as the single source of truth.
29+
// Legacy values should already be stored as the correct types; however, this is a defensive measure in case users have manually edited the file or there are edge cases we aren't aware of in the old app.
30+
function coerceFromMetadata(raw: Record<string, unknown>) {
31+
const out: Record<string, unknown> = {}
32+
33+
for (const [key, value] of Object.entries(raw)) {
34+
// ignore any unknown keys
35+
if (!(key in settingsMetadata)) continue
36+
37+
const meta = settingsMetadata[key as keyof typeof settingsMetadata] as Option
38+
39+
switch (meta.kind) {
40+
case 'number':
41+
out[key] = typeof value === 'string' ? Number(value) : value
42+
break
43+
case 'toggle':
44+
out[key] = toBool(value)
45+
break
46+
default:
47+
// 'multi' & 'select' need no coercion here
48+
out[key] = value
49+
}
50+
}
51+
return out
52+
}
53+
1254
// Mapping of legacy config options to modern config options.
1355
// Options with values that are 1-to-1 with the modern config options are included. Options that require logic to translate are handled below.
1456
// These are listed in the same order as the old DEFAULT_ADVANCED_SETTINGS from the legacy app for ease of comparison
@@ -50,35 +92,51 @@ export async function migrateLegacyConfig(): Promise<SettingsSchema | undefined>
5092
// If the old bitcoin-config.json doesn't exist, we don't need to migrate
5193
if (!(await fse.pathExists(LEGACY_CONFIG_PATH))) return
5294

53-
const legacyConfig = await fse.readJson(LEGACY_CONFIG_PATH)
95+
const legacyConfig = await fse.readJson(LEGACY_CONFIG_PATH).catch((err) => {
96+
// throw a nicely formatted error message that is logged by the Fastify server logger
97+
const msg =
98+
err instanceof SyntaxError
99+
? '[migration] Invalid JSON in legacy bitcoin-config.json'
100+
: '[migration] Unable to read legacy bitcoin-config.json'
101+
throw new Error(`${msg}: ${err.message}`)
102+
})
54103

55104
// First we translate the config options that are not 1-to-1
56105
const translated: Partial<SettingsSchema> = {
57106
// the legacy config split up the outgoing connections into separate keys for clearnet, tor, and i2p
58107
onlynet: [
59-
...(legacyConfig.clearnet ? ['clearnet'] : []),
60-
...(legacyConfig.tor ? ['tor'] : []),
61-
...(legacyConfig.i2p ? ['i2p'] : []),
108+
...(toBool(legacyConfig.clearnet) ? ['clearnet'] : []),
109+
...(toBool(legacyConfig.tor) ? ['tor'] : []),
110+
...(toBool(legacyConfig.i2p) ? ['i2p'] : []),
62111
],
63112
// the legacy config had a single incomingConnections key that was a boolean for whether to allow incoming connections on ALL networks
64-
listen: legacyConfig.incomingConnections ? ['clearnet', 'tor', 'i2p'] : [],
113+
listen: toBool(legacyConfig.incomingConnections) ? ['clearnet', 'tor', 'i2p'] : [],
65114
// only use the pruneSizeGB value if prune is enabled because pruning could be disabled in the old app even with a pruneSizeGB value set
66-
prune: legacyConfig.prune?.enabled ? legacyConfig.prune.pruneSizeGB : 0,
115+
prune: toBool(legacyConfig.prune?.enabled) ? Number(legacyConfig.prune.pruneSizeGB) : 0,
67116
}
68117

69118
// Then we translate the config options that are 1-to-1
70119
for (const [legacyKey, modernKey] of Object.entries(LEGACY_TO_MODERN_MAP)) {
71120
if (legacyKey in legacyConfig) translated[modernKey] = legacyConfig[legacyKey]
72121
}
73122

74-
// merge with current defaults and validate to fail fast here if there are any issues
75-
// This is done again in ensureConfig
76-
const validated = settingsSchema.parse({...defaultValues, ...translated})
123+
// defensively coerce the values to the new schema
124+
const coerced = coerceFromMetadata(translated)
77125

78-
await writeWithBackup(NEW_CONFIG_PATH, JSON.stringify(validated, null, 2) + '\n')
79-
await fse.move(LEGACY_CONFIG_PATH, `${LEGACY_CONFIG_PATH}.bak`, {overwrite: false})
80-
81-
return validated
126+
try {
127+
const validated = settingsSchema.parse({...defaultValues, ...coerced})
128+
await writeWithBackup(NEW_CONFIG_PATH, JSON.stringify(validated, null, 2) + '\n')
129+
await fse.move(LEGACY_CONFIG_PATH, `${LEGACY_CONFIG_PATH}.bak`, {overwrite: false})
130+
return validated
131+
} catch (error) {
132+
if (error instanceof ZodError) {
133+
// This should be extremely rare because we defensively coerce the values to the new schema
134+
// but could still happen if, for example, the user manually edits the chain to an invalid value like "mainnet"
135+
const summary = error.errors.map((e) => `${e.path.join('.')}: ${e.message}`).join('; ')
136+
throw new Error(`[migration] Validation of legacy config failed – ${summary}`)
137+
}
138+
throw new Error('[migration] Unexpected error during migration of legacy config', {cause: error as Error})
139+
}
82140
}
83141

84142
// Previous app's bitcoin-config.json for reference:

apps/backend/src/server.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url))
1515
await ensureDirs()
1616

1717
// Start bitcoind without blocking server start
18-
bootBitcoind().catch((err) => {
19-
bitcoind.setLastError(err as Error) // record for /status
20-
app.log.error('Bitcoind bootstrap failed:', err)
18+
bootBitcoind().catch((error) => {
19+
bitcoind.setLastError(error as Error) // record for /status
20+
app.log.error(error, 'Bitcoind bootstrap failed.')
2121
})
2222

2323
// Create the HTTP server and register the routes

0 commit comments

Comments
 (0)