publish(task 2): config-loader mit env-validation

loadConfig() liest 3 pflicht-keys (BUNKER_URL, AUTHOR_PUBKEY_HEX,
BOOTSTRAP_RELAY) und 3 optionale mit defaults (CONTENT_ROOT,
CLIENT_TAG=leer, MIN_RELAY_ACKS=2). pubkey-hex-format validiert
(64 lowercase hex), MIN_RELAY_ACKS als positive integer. 6 tests, alle
grün.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jörg Lohrer 2026-04-18 05:21:39 +02:00
parent 6b6502a22c
commit 1e4359aab6
2 changed files with 104 additions and 0 deletions

View File

@ -0,0 +1,47 @@
export interface Config {
bunkerUrl: string
authorPubkeyHex: string
bootstrapRelay: string
contentRoot: string
clientTag: string
minRelayAcks: number
}
type EnvReader = (key: string) => string | undefined
const REQUIRED = ['BUNKER_URL', 'AUTHOR_PUBKEY_HEX', 'BOOTSTRAP_RELAY'] as const
const DEFAULTS = {
CONTENT_ROOT: '../content/posts',
CLIENT_TAG: '',
MIN_RELAY_ACKS: '2',
}
export function loadConfig(read: EnvReader = (k) => Deno.env.get(k)): Config {
const missing: string[] = []
const values: Record<string, string> = {}
for (const key of REQUIRED) {
const v = read(key)
if (!v) missing.push(key)
else values[key] = v
}
if (missing.length) {
throw new Error(`Missing env: ${missing.join(', ')}`)
}
if (!/^[0-9a-f]{64}$/.test(values.AUTHOR_PUBKEY_HEX)) {
throw new Error('AUTHOR_PUBKEY_HEX must be 64 lowercase hex characters')
}
const minAcksRaw = read('MIN_RELAY_ACKS') ?? DEFAULTS.MIN_RELAY_ACKS
const minAcks = Number(minAcksRaw)
if (!Number.isInteger(minAcks) || minAcks < 1) {
throw new Error(`MIN_RELAY_ACKS must be a positive integer, got "${minAcksRaw}"`)
}
return {
bunkerUrl: values.BUNKER_URL,
authorPubkeyHex: values.AUTHOR_PUBKEY_HEX,
bootstrapRelay: values.BOOTSTRAP_RELAY,
contentRoot: read('CONTENT_ROOT') ?? DEFAULTS.CONTENT_ROOT,
clientTag: read('CLIENT_TAG') ?? DEFAULTS.CLIENT_TAG,
minRelayAcks: minAcks,
}
}

View File

@ -0,0 +1,57 @@
import { assertEquals, assertThrows } from '@std/assert'
import { loadConfig } from '../src/core/config.ts'
const REQUIRED = {
BUNKER_URL: 'bunker://abc?relay=wss://r.example&secret=s',
AUTHOR_PUBKEY_HEX: '4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41',
BOOTSTRAP_RELAY: 'wss://relay.damus.io',
}
Deno.test('loadConfig: liest alle pflicht-keys aus env', () => {
const cfg = loadConfig((k) => REQUIRED[k as keyof typeof REQUIRED])
assertEquals(cfg.bunkerUrl, REQUIRED.BUNKER_URL)
assertEquals(cfg.authorPubkeyHex, REQUIRED.AUTHOR_PUBKEY_HEX)
assertEquals(cfg.bootstrapRelay, REQUIRED.BOOTSTRAP_RELAY)
})
Deno.test('loadConfig: liefert defaults für optionale keys', () => {
const cfg = loadConfig((k) => REQUIRED[k as keyof typeof REQUIRED])
assertEquals(cfg.contentRoot, '../content/posts')
assertEquals(cfg.clientTag, '')
assertEquals(cfg.minRelayAcks, 2)
})
Deno.test('loadConfig: optionale keys können überschrieben werden', () => {
const env = {
...REQUIRED,
CONTENT_ROOT: '../blog',
CLIENT_TAG: 'my-site',
MIN_RELAY_ACKS: '3',
}
const cfg = loadConfig((k) => env[k as keyof typeof env])
assertEquals(cfg.contentRoot, '../blog')
assertEquals(cfg.clientTag, 'my-site')
assertEquals(cfg.minRelayAcks, 3)
})
Deno.test('loadConfig: wirft bei fehlender pflicht-variable', () => {
assertThrows(() => loadConfig(() => undefined), Error, 'BUNKER_URL')
})
Deno.test('loadConfig: validiert pubkey-format (64 hex)', () => {
const env = { ...REQUIRED, AUTHOR_PUBKEY_HEX: 'zzz' }
assertThrows(
() => loadConfig((k) => env[k as keyof typeof env]),
Error,
'AUTHOR_PUBKEY_HEX',
)
})
Deno.test('loadConfig: MIN_RELAY_ACKS muss positiv sein', () => {
const env = { ...REQUIRED, MIN_RELAY_ACKS: '0' }
assertThrows(
() => loadConfig((k) => env[k as keyof typeof env]),
Error,
'MIN_RELAY_ACKS',
)
})