config.ts

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