config.ts

8.0 KB · raw

1import { chmod, rename, writeFile } from "node:fs/promises";23export type BucketSource =4	| {5			kind: "s3";6			endpoint: string;7			name: string;8			region: string;9			accessKeyId: string;10			secretAccessKey: string;11			write?: { accessKeyId: string; secretAccessKey: string };12	  }13	// A directory laid out exactly like the bucket (Koh 3's folder remote), for development and tests.14	| { kind: "folder"; path: string };1516export const SMTP_SECURITY = ["tls", "starttls", "none"] as const;1718/** Where the operator's email goes out (§24); the password lives here and nowhere else (§25). */19export type SmtpSettings = {20	host: string;21	port: number;22	/** Implicit TLS, STARTTLS, or none for a relay on the same machine. */23	security: (typeof SMTP_SECURITY)[number];24	username: string;25	password: string;26	from: string;27	to: string;28};2930/**31 * A second S3 bucket that takes a copy of every backup (June, 2026-09-30). One key reads, writes32 * and deletes there; like every key it lives here and nowhere else (§25).33 */34export type BackupBucket = {35	endpoint: string;36	name: string;37	region: string;38	accessKeyId: string;39	secretAccessKey: string;40};4142export type Config = {43	domain: string;44	dataDir: string;45	adminPasswordHash: string;46	bucket: BucketSource;47	smtp?: SmtpSettings;48	backup?: BackupBucket;49};5051export class ConfigError extends Error {}5253const HOSTNAME = new RegExp(54	String.raw`^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*` +55		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]))?$`,56	"i",57);5859export function parseConfig(text: string): Config {60	let raw: unknown;61	try {62		raw = Bun.TOML.parse(text);63	} catch (cause) {64		throw new ConfigError(`kepr.toml is not valid TOML: ${(cause as Error).message}`);65	}66	const top = asTable(raw, "kepr.toml");67	const bucket = asTable(top.bucket, "[bucket]");68	const adminPasswordHash = requireString(top, "admin_password_hash");69	if (!adminPasswordHash.startsWith("$argon2id$")) {70		throw new ConfigError("admin_password_hash must be an Argon2id hash ($argon2id$...)");71	}72	const domain = requireString(top, "domain");73	// Pages, feeds and install.sh put it in URLs and a shell script as is.74	if (!HOSTNAME.test(domain))75		throw new ConfigError("domain must be a bare hostname, with an optional :port");76	return {77		domain,78		dataDir: requireString(top, "data_dir"),79		adminPasswordHash,80		bucket: parseBucket(bucket),81		...(top.smtp === undefined ? {} : { smtp: parseSmtp(asTable(top.smtp, "[smtp]")) }),82		...(top.backup === undefined ? {} : { backup: parseBackup(asTable(top.backup, "[backup]")) }),83	};84}8586function parseBackup(backup: Record<string, unknown>): BackupBucket {87	const endpoint = requireString(backup, "endpoint", "[backup] ");88	if (!URL.canParse(endpoint)) throw new ConfigError("[backup] endpoint must be a URL");89	return {90		endpoint,91		name: requireString(backup, "name", "[backup] "),92		region: requireString(backup, "region", "[backup] "),93		accessKeyId: requireString(backup, "access_key_id", "[backup] "),94		secretAccessKey: requireString(backup, "secret_access_key", "[backup] "),95	};96}9798function parseSmtp(smtp: Record<string, unknown>): SmtpSettings {99	const { port, security } = smtp;100	if (typeof port !== "number" || !Number.isInteger(port) || port < 1 || port > 65535) {101		throw new ConfigError("[smtp] port must be a whole number from 1 to 65535");102	}103	const known = SMTP_SECURITY.find((value) => value === security);104	if (!known) throw new ConfigError(`[smtp] security must be one of ${SMTP_SECURITY.join(", ")}`);105	const optional = (key: string) => (typeof smtp[key] === "string" ? (smtp[key] as string) : "");106	return {107		host: requireString(smtp, "host", "[smtp] "),108		port,109		security: known,110		username: optional("username"),111		password: optional("password"),112		from: requireString(smtp, "from", "[smtp] "),113		to: requireString(smtp, "to", "[smtp] "),114	};115}116117/** Where `main` reads the config, and where the console's Bucket section writes it back (§19). */118export const CONFIG_FILE = "kepr.toml";119120/**121 * Replaces the config file with `config` in one rename, owner-only since it holds keys (§19).122 * Comments in the old file are not kept. What would not parse back is never written.123 */124export async function saveConfig(path: string, config: Config): Promise<void> {125	// A JSON string is a valid TOML basic string for everything the parse-back below accepts.126	const str = JSON.stringify;127	const { bucket } = config;128	const lines = [129		`domain = ${str(config.domain)}`,130		`data_dir = ${str(config.dataDir)}`,131		`admin_password_hash = ${str(config.adminPasswordHash)}`,132		"",133		"[bucket]",134	];135	if (bucket.kind === "folder") lines.push(`folder = ${str(bucket.path)}`);136	else {137		lines.push(138			`endpoint = ${str(bucket.endpoint)}`,139			`name = ${str(bucket.name)}`,140			`region = ${str(bucket.region)}`,141			`access_key_id = ${str(bucket.accessKeyId)}`,142			`secret_access_key = ${str(bucket.secretAccessKey)}`,143		);144		if (bucket.write) {145			lines.push(146				"",147				"[bucket.write]",148				`access_key_id = ${str(bucket.write.accessKeyId)}`,149				`secret_access_key = ${str(bucket.write.secretAccessKey)}`,150			);151		}152	}153	const { smtp } = config;154	if (smtp) {155		lines.push(156			"",157			"[smtp]",158			`host = ${str(smtp.host)}`,159			`port = ${smtp.port}`,160			`security = ${str(smtp.security)}`,161			`username = ${str(smtp.username)}`,162			`password = ${str(smtp.password)}`,163			`from = ${str(smtp.from)}`,164			`to = ${str(smtp.to)}`,165		);166	}167	const { backup } = config;168	if (backup) {169		lines.push(170			"",171			"[backup]",172			`endpoint = ${str(backup.endpoint)}`,173			`name = ${str(backup.name)}`,174			`region = ${str(backup.region)}`,175			`access_key_id = ${str(backup.accessKeyId)}`,176			`secret_access_key = ${str(backup.secretAccessKey)}`,177		);178	}179	const text = `${lines.join("\n")}\n`;180	parseConfig(text);181	const next = `${path}.new`;182	await writeFile(next, text, { mode: 0o600 });183	// `mode` applies only to a new file, and a crash may have left an older one behind.184	await chmod(next, 0o600);185	await rename(next, path);186}187188export async function loadConfig(path: string): Promise<Config> {189	const file = Bun.file(path);190	if (!(await file.exists())) throw new ConfigError(`${path} not found`);191	return parseConfig(await file.text());192}193194function parseBucket(bucket: Record<string, unknown>): BucketSource {195	if (bucket.folder !== undefined) {196		if (bucket.write !== undefined)197			throw new ConfigError("[bucket] write cannot be combined with folder");198		const s3Keys = ["endpoint", "name", "region", "access_key_id", "secret_access_key"];199		const mixed = s3Keys.filter((key) => bucket[key] !== undefined);200		if (mixed.length > 0) {201			throw new ConfigError(`[bucket] folder cannot be combined with ${mixed.join(", ")}`);202		}203		return { kind: "folder", path: requireString(bucket, "folder", "[bucket] ") };204	}205	const endpoint = requireString(bucket, "endpoint", "[bucket] ");206	if (!URL.canParse(endpoint)) throw new ConfigError("[bucket] endpoint must be a URL");207	const write = bucket.write === undefined ? undefined : asTable(bucket.write, "[bucket.write]");208	return {209		kind: "s3",210		endpoint,211		name: requireString(bucket, "name", "[bucket] "),212		region: requireString(bucket, "region", "[bucket] "),213		accessKeyId: requireString(bucket, "access_key_id", "[bucket] "),214		secretAccessKey: requireString(bucket, "secret_access_key", "[bucket] "),215		...(write216			? {217					write: {218						accessKeyId: requireString(write, "access_key_id", "[bucket.write] "),219						secretAccessKey: requireString(write, "secret_access_key", "[bucket.write] "),220					},221				}222			: {}),223	};224}225226function asTable(value: unknown, name: string): Record<string, unknown> {227	if (typeof value !== "object" || value === null || Array.isArray(value)) {228		throw new ConfigError(`${name} is missing or not a table`);229	}230	return value as Record<string, unknown>;231}232233function requireString(table: Record<string, unknown>, key: string, section = ""): string {234	const value = table[key];235	if (typeof value !== "string" || value.trim() === "") {236		throw new ConfigError(`${section}${key} is required and must be a non-empty string`);237	}238	return value;239}