import { chmod, rename, writeFile } from "node:fs/promises"; export type BucketSource = | { kind: "s3"; endpoint: string; name: string; region: string; accessKeyId: string; secretAccessKey: string; write?: { accessKeyId: string; secretAccessKey: string }; } // A directory laid out exactly like the bucket (Koh 3's folder remote), for development and tests. | { kind: "folder"; path: string }; export const SMTP_SECURITY = ["tls", "starttls", "none"] as const; /** Where the operator's email goes out (§24); the password lives here and nowhere else (§25). */ export type SmtpSettings = { host: string; port: number; /** Implicit TLS, STARTTLS, or none for a relay on the same machine. */ security: (typeof SMTP_SECURITY)[number]; username: string; password: string; from: string; to: string; }; /** * A second S3 bucket that takes a copy of every backup (June, 2026-09-30). One key reads, writes * and deletes there; like every key it lives here and nowhere else (§25). */ export type BackupBucket = { endpoint: string; name: string; region: string; accessKeyId: string; secretAccessKey: string; }; export type Config = { domain: string; dataDir: string; adminPasswordHash: string; bucket: BucketSource; smtp?: SmtpSettings; backup?: BackupBucket; }; export class ConfigError extends Error {} const HOSTNAME = new RegExp( String.raw`^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*` + String.raw`(:([1-9]\d{0,3}|[1-5]\d{4}|6[0-4]\d{3}|65[0-4]\d{2}|655[0-2]\d|6553[0-5]))?$`, "i", ); /** A bare hostname with an optional `:port`: pages, feeds and install.sh put it in URLs as is. */ export function isDomain(domain: string): boolean { return HOSTNAME.test(domain); } export function parseConfig(text: string): Config { let raw: unknown; try { raw = Bun.TOML.parse(text); } catch (cause) { throw new ConfigError(`kepr.toml is not valid TOML: ${(cause as Error).message}`); } const top = asTable(raw, "kepr.toml"); const bucket = asTable(top.bucket, "[bucket]"); const adminPasswordHash = requireString(top, "admin_password_hash"); if (!adminPasswordHash.startsWith("$argon2id$")) { throw new ConfigError("admin_password_hash must be an Argon2id hash ($argon2id$...)"); } const domain = requireString(top, "domain"); // Pages, feeds and install.sh put it in URLs and a shell script as is. if (!HOSTNAME.test(domain)) throw new ConfigError("domain must be a bare hostname, with an optional :port"); return { domain, dataDir: requireString(top, "data_dir"), adminPasswordHash, bucket: parseBucket(bucket), ...(top.smtp === undefined ? {} : { smtp: parseSmtp(asTable(top.smtp, "[smtp]")) }), ...(top.backup === undefined ? {} : { backup: parseBackup(asTable(top.backup, "[backup]")) }), }; } function parseBackup(backup: Record): BackupBucket { const endpoint = requireString(backup, "endpoint", "[backup] "); if (!URL.canParse(endpoint)) throw new ConfigError("[backup] endpoint must be a URL"); return { endpoint, name: requireString(backup, "name", "[backup] "), region: requireString(backup, "region", "[backup] "), accessKeyId: requireString(backup, "access_key_id", "[backup] "), secretAccessKey: requireString(backup, "secret_access_key", "[backup] "), }; } function parseSmtp(smtp: Record): SmtpSettings { const { port, security } = smtp; if (typeof port !== "number" || !Number.isInteger(port) || port < 1 || port > 65535) { throw new ConfigError("[smtp] port must be a whole number from 1 to 65535"); } const known = SMTP_SECURITY.find((value) => value === security); if (!known) throw new ConfigError(`[smtp] security must be one of ${SMTP_SECURITY.join(", ")}`); const optional = (key: string) => (typeof smtp[key] === "string" ? (smtp[key] as string) : ""); return { host: requireString(smtp, "host", "[smtp] "), port, security: known, username: optional("username"), password: optional("password"), from: requireString(smtp, "from", "[smtp] "), to: requireString(smtp, "to", "[smtp] "), }; } /** Where `main` reads the config, and where the console's Bucket section writes it back (§19). */ export const CONFIG_FILE = "kepr.toml"; /** * Replaces the config file with `config` in one rename, owner-only since it holds keys (§19). * Comments in the old file are not kept. What would not parse back is never written. */ export async function saveConfig(path: string, config: Config): Promise { // A JSON string is a valid TOML basic string for everything the parse-back below accepts. const str = JSON.stringify; const { bucket } = config; const lines = [ `domain = ${str(config.domain)}`, `data_dir = ${str(config.dataDir)}`, `admin_password_hash = ${str(config.adminPasswordHash)}`, "", "[bucket]", ]; if (bucket.kind === "folder") lines.push(`folder = ${str(bucket.path)}`); else { lines.push( `endpoint = ${str(bucket.endpoint)}`, `name = ${str(bucket.name)}`, `region = ${str(bucket.region)}`, `access_key_id = ${str(bucket.accessKeyId)}`, `secret_access_key = ${str(bucket.secretAccessKey)}`, ); if (bucket.write) { lines.push( "", "[bucket.write]", `access_key_id = ${str(bucket.write.accessKeyId)}`, `secret_access_key = ${str(bucket.write.secretAccessKey)}`, ); } } const { smtp } = config; if (smtp) { lines.push( "", "[smtp]", `host = ${str(smtp.host)}`, `port = ${smtp.port}`, `security = ${str(smtp.security)}`, `username = ${str(smtp.username)}`, `password = ${str(smtp.password)}`, `from = ${str(smtp.from)}`, `to = ${str(smtp.to)}`, ); } const { backup } = config; if (backup) { lines.push( "", "[backup]", `endpoint = ${str(backup.endpoint)}`, `name = ${str(backup.name)}`, `region = ${str(backup.region)}`, `access_key_id = ${str(backup.accessKeyId)}`, `secret_access_key = ${str(backup.secretAccessKey)}`, ); } const text = `${lines.join("\n")}\n`; parseConfig(text); const next = `${path}.new`; await writeFile(next, text, { mode: 0o600 }); // `mode` applies only to a new file, and a crash may have left an older one behind. await chmod(next, 0o600); await rename(next, path); } export async function loadConfig(path: string): Promise { const file = Bun.file(path); if (!(await file.exists())) throw new ConfigError(`${path} not found`); return parseConfig(await file.text()); } function parseBucket(bucket: Record): BucketSource { if (bucket.folder !== undefined) { if (bucket.write !== undefined) throw new ConfigError("[bucket] write cannot be combined with folder"); const s3Keys = ["endpoint", "name", "region", "access_key_id", "secret_access_key"]; const mixed = s3Keys.filter((key) => bucket[key] !== undefined); if (mixed.length > 0) { throw new ConfigError(`[bucket] folder cannot be combined with ${mixed.join(", ")}`); } return { kind: "folder", path: requireString(bucket, "folder", "[bucket] ") }; } const endpoint = requireString(bucket, "endpoint", "[bucket] "); if (!URL.canParse(endpoint)) throw new ConfigError("[bucket] endpoint must be a URL"); const write = bucket.write === undefined ? undefined : asTable(bucket.write, "[bucket.write]"); return { kind: "s3", endpoint, name: requireString(bucket, "name", "[bucket] "), region: requireString(bucket, "region", "[bucket] "), accessKeyId: requireString(bucket, "access_key_id", "[bucket] "), secretAccessKey: requireString(bucket, "secret_access_key", "[bucket] "), ...(write ? { write: { accessKeyId: requireString(write, "access_key_id", "[bucket.write] "), secretAccessKey: requireString(write, "secret_access_key", "[bucket.write] "), }, } : {}), }; } function asTable(value: unknown, name: string): Record { if (typeof value !== "object" || value === null || Array.isArray(value)) { throw new ConfigError(`${name} is missing or not a table`); } return value as Record; } function requireString(table: Record, key: string, section = ""): string { const value = table[key]; if (typeof value !== "string" || value.trim() === "") { throw new ConfigError(`${section}${key} is required and must be a non-empty string`); } return value; }