config.ts

8.1 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);5859/** A bare hostname with an optional `:port`: pages, feeds and install.sh put it in URLs as is. */60export function isDomain(domain: string): boolean {61	return HOSTNAME.test(domain);62}6364export function parseConfig(text: string): Config {65	let raw: unknown;66	try {67		raw = Bun.TOML.parse(text);68	} catch (cause) {69		throw new ConfigError(`kepr.toml is not valid TOML: ${(cause as Error).message}`);70	}71	const top = asTable(raw, "kepr.toml");72	const bucket = asTable(top.bucket, "[bucket]");73	const adminPasswordHash = requireString(top, "admin_password_hash");74	if (!adminPasswordHash.startsWith("$argon2id$")) {75		throw new ConfigError("admin_password_hash must be an Argon2id hash ($argon2id$...)");76	}77	const domain = requireString(top, "domain");78	// Pages, feeds and install.sh put it in URLs and a shell script as is.79	if (!HOSTNAME.test(domain))80		throw new ConfigError("domain must be a bare hostname, with an optional :port");81	return {82		domain,83		dataDir: requireString(top, "data_dir"),84		adminPasswordHash,85		bucket: parseBucket(bucket),86		...(top.smtp === undefined ? {} : { smtp: parseSmtp(asTable(top.smtp, "[smtp]")) }),87		...(top.backup === undefined ? {} : { backup: parseBackup(asTable(top.backup, "[backup]")) }),88	};89}9091function parseBackup(backup: Record<string, unknown>): BackupBucket {92	const endpoint = requireString(backup, "endpoint", "[backup] ");93	if (!URL.canParse(endpoint)) throw new ConfigError("[backup] endpoint must be a URL");94	return {95		endpoint,96		name: requireString(backup, "name", "[backup] "),97		region: requireString(backup, "region", "[backup] "),98		accessKeyId: requireString(backup, "access_key_id", "[backup] "),99		secretAccessKey: requireString(backup, "secret_access_key", "[backup] "),100	};101}102103function parseSmtp(smtp: Record<string, unknown>): SmtpSettings {104	const { port, security } = smtp;105	if (typeof port !== "number" || !Number.isInteger(port) || port < 1 || port > 65535) {106		throw new ConfigError("[smtp] port must be a whole number from 1 to 65535");107	}108	const known = SMTP_SECURITY.find((value) => value === security);109	if (!known) throw new ConfigError(`[smtp] security must be one of ${SMTP_SECURITY.join(", ")}`);110	const optional = (key: string) => (typeof smtp[key] === "string" ? (smtp[key] as string) : "");111	return {112		host: requireString(smtp, "host", "[smtp] "),113		port,114		security: known,115		username: optional("username"),116		password: optional("password"),117		from: requireString(smtp, "from", "[smtp] "),118		to: requireString(smtp, "to", "[smtp] "),119	};120}121122/** Where `main` reads the config, and where the console's Bucket section writes it back (§19). */123export const CONFIG_FILE = "kepr.toml";124125/**126 * Replaces the config file with `config` in one rename, owner-only since it holds keys (§19).127 * Comments in the old file are not kept. What would not parse back is never written.128 */129export async function saveConfig(path: string, config: Config): Promise<void> {130	// A JSON string is a valid TOML basic string for everything the parse-back below accepts.131	const str = JSON.stringify;132	const { bucket } = config;133	const lines = [134		`domain = ${str(config.domain)}`,135		`data_dir = ${str(config.dataDir)}`,136		`admin_password_hash = ${str(config.adminPasswordHash)}`,137		"",138		"[bucket]",139	];140	if (bucket.kind === "folder") lines.push(`folder = ${str(bucket.path)}`);141	else {142		lines.push(143			`endpoint = ${str(bucket.endpoint)}`,144			`name = ${str(bucket.name)}`,145			`region = ${str(bucket.region)}`,146			`access_key_id = ${str(bucket.accessKeyId)}`,147			`secret_access_key = ${str(bucket.secretAccessKey)}`,148		);149		if (bucket.write) {150			lines.push(151				"",152				"[bucket.write]",153				`access_key_id = ${str(bucket.write.accessKeyId)}`,154				`secret_access_key = ${str(bucket.write.secretAccessKey)}`,155			);156		}157	}158	const { smtp } = config;159	if (smtp) {160		lines.push(161			"",162			"[smtp]",163			`host = ${str(smtp.host)}`,164			`port = ${smtp.port}`,165			`security = ${str(smtp.security)}`,166			`username = ${str(smtp.username)}`,167			`password = ${str(smtp.password)}`,168			`from = ${str(smtp.from)}`,169			`to = ${str(smtp.to)}`,170		);171	}172	const { backup } = config;173	if (backup) {174		lines.push(175			"",176			"[backup]",177			`endpoint = ${str(backup.endpoint)}`,178			`name = ${str(backup.name)}`,179			`region = ${str(backup.region)}`,180			`access_key_id = ${str(backup.accessKeyId)}`,181			`secret_access_key = ${str(backup.secretAccessKey)}`,182		);183	}184	const text = `${lines.join("\n")}\n`;185	parseConfig(text);186	const next = `${path}.new`;187	await writeFile(next, text, { mode: 0o600 });188	// `mode` applies only to a new file, and a crash may have left an older one behind.189	await chmod(next, 0o600);190	await rename(next, path);191}192193export async function loadConfig(path: string): Promise<Config> {194	const file = Bun.file(path);195	if (!(await file.exists())) throw new ConfigError(`${path} not found`);196	return parseConfig(await file.text());197}198199function parseBucket(bucket: Record<string, unknown>): BucketSource {200	if (bucket.folder !== undefined) {201		if (bucket.write !== undefined)202			throw new ConfigError("[bucket] write cannot be combined with folder");203		const s3Keys = ["endpoint", "name", "region", "access_key_id", "secret_access_key"];204		const mixed = s3Keys.filter((key) => bucket[key] !== undefined);205		if (mixed.length > 0) {206			throw new ConfigError(`[bucket] folder cannot be combined with ${mixed.join(", ")}`);207		}208		return { kind: "folder", path: requireString(bucket, "folder", "[bucket] ") };209	}210	const endpoint = requireString(bucket, "endpoint", "[bucket] ");211	if (!URL.canParse(endpoint)) throw new ConfigError("[bucket] endpoint must be a URL");212	const write = bucket.write === undefined ? undefined : asTable(bucket.write, "[bucket.write]");213	return {214		kind: "s3",215		endpoint,216		name: requireString(bucket, "name", "[bucket] "),217		region: requireString(bucket, "region", "[bucket] "),218		accessKeyId: requireString(bucket, "access_key_id", "[bucket] "),219		secretAccessKey: requireString(bucket, "secret_access_key", "[bucket] "),220		...(write221			? {222					write: {223						accessKeyId: requireString(write, "access_key_id", "[bucket.write] "),224						secretAccessKey: requireString(write, "secret_access_key", "[bucket.write] "),225					},226				}227			: {}),228	};229}230231function asTable(value: unknown, name: string): Record<string, unknown> {232	if (typeof value !== "object" || value === null || Array.isArray(value)) {233		throw new ConfigError(`${name} is missing or not a table`);234	}235	return value as Record<string, unknown>;236}237238function requireString(table: Record<string, unknown>, key: string, section = ""): string {239	const value = table[key];240	if (typeof value !== "string" || value.trim() === "") {241		throw new ConfigError(`${section}${key} is required and must be a non-empty string`);242	}243	return value;244}