config.ts

8.2 KB · raw

1import { chmod, rename, writeFile } from "node:fs/promises";2import { isSecret } from "./totp.ts";34export type BucketSource =5	| {6			kind: "s3";7			endpoint: string;8			name: string;9			region: string;10			accessKeyId: string;11			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	/** The two-step sign-in secret (§24), base32; absent leaves sign-in to the password alone. */47	totpSecret?: string;48	bucket: BucketSource;49	smtp?: SmtpSettings;50	backup?: BackupBucket;51};5253export class ConfigError extends Error {}5455const HOSTNAME = new RegExp(56	String.raw`^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*` +57		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]))?$`,58	"i",59);6061/** A bare hostname with an optional `:port`: pages, feeds and install.sh put it in URLs as is. */62export function isDomain(domain: string): boolean {63	return HOSTNAME.test(domain);64}6566export function parseConfig(text: string): Config {67	let raw: unknown;68	try {69		raw = Bun.TOML.parse(text);70	} catch (cause) {71		throw new ConfigError(`kepr.toml is not valid TOML: ${(cause as Error).message}`);72	}73	const top = asTable(raw, "kepr.toml");74	const bucket = asTable(top.bucket, "[bucket]");75	const adminPasswordHash = requireString(top, "admin_password_hash");76	if (!adminPasswordHash.startsWith("$argon2id$")) {77		throw new ConfigError("admin_password_hash must be an Argon2id hash ($argon2id$...)");78	}79	const domain = requireString(top, "domain");80	// Pages, feeds and install.sh put it in URLs and a shell script as is.81	if (!HOSTNAME.test(domain))82		throw new ConfigError("domain must be a bare hostname, with an optional :port");83	// A secret that cannot be read is a lockout, so it is refused at startup, not at sign-in.84	const totpSecret = typeof top.totp_secret === "string" ? top.totp_secret.trim() : "";85	if (totpSecret !== "" && !isSecret(totpSecret)) {86		throw new ConfigError("totp_secret must be a base32 secret of at least 16 bytes");87	}88	return {89		domain,90		dataDir: requireString(top, "data_dir"),91		adminPasswordHash,92		...(totpSecret === "" ? {} : { totpSecret }),93		bucket: parseBucket(bucket),94		...(top.smtp === undefined ? {} : { smtp: parseSmtp(asTable(top.smtp, "[smtp]")) }),95		...(top.backup === undefined ? {} : { backup: parseBackup(asTable(top.backup, "[backup]")) }),96	};97}9899function parseBackup(backup: Record<string, unknown>): BackupBucket {100	const endpoint = requireString(backup, "endpoint", "[backup] ");101	if (!URL.canParse(endpoint)) throw new ConfigError("[backup] endpoint must be a URL");102	return {103		endpoint,104		name: requireString(backup, "name", "[backup] "),105		region: requireString(backup, "region", "[backup] "),106		accessKeyId: requireString(backup, "access_key_id", "[backup] "),107		secretAccessKey: requireString(backup, "secret_access_key", "[backup] "),108	};109}110111function parseSmtp(smtp: Record<string, unknown>): SmtpSettings {112	const { port, security } = smtp;113	if (typeof port !== "number" || !Number.isInteger(port) || port < 1 || port > 65535) {114		throw new ConfigError("[smtp] port must be a whole number from 1 to 65535");115	}116	const known = SMTP_SECURITY.find((value) => value === security);117	if (!known) throw new ConfigError(`[smtp] security must be one of ${SMTP_SECURITY.join(", ")}`);118	const optional = (key: string) => (typeof smtp[key] === "string" ? (smtp[key] as string) : "");119	return {120		host: requireString(smtp, "host", "[smtp] "),121		port,122		security: known,123		username: optional("username"),124		password: optional("password"),125		from: requireString(smtp, "from", "[smtp] "),126		to: requireString(smtp, "to", "[smtp] "),127	};128}129130/** Where `main` reads the config, and where the console's Bucket section writes it back (§19). */131export const CONFIG_FILE = "kepr.toml";132133/**134 * Replaces the config file with `config` in one rename, owner-only since it holds keys (§19).135 * Comments in the old file are not kept. What would not parse back is never written.136 */137export async function saveConfig(path: string, config: Config): Promise<void> {138	// A JSON string is a valid TOML basic string for everything the parse-back below accepts.139	const str = JSON.stringify;140	const { bucket } = config;141	const lines = [142		`domain = ${str(config.domain)}`,143		`data_dir = ${str(config.dataDir)}`,144		`admin_password_hash = ${str(config.adminPasswordHash)}`,145		...(config.totpSecret ? [`totp_secret = ${str(config.totpSecret)}`] : []),146		"",147		"[bucket]",148	];149	if (bucket.kind === "folder") lines.push(`folder = ${str(bucket.path)}`);150	else {151		lines.push(152			`endpoint = ${str(bucket.endpoint)}`,153			`name = ${str(bucket.name)}`,154			`region = ${str(bucket.region)}`,155			`access_key_id = ${str(bucket.accessKeyId)}`,156			`secret_access_key = ${str(bucket.secretAccessKey)}`,157		);158	}159	const { smtp } = config;160	if (smtp) {161		lines.push(162			"",163			"[smtp]",164			`host = ${str(smtp.host)}`,165			`port = ${smtp.port}`,166			`security = ${str(smtp.security)}`,167			`username = ${str(smtp.username)}`,168			`password = ${str(smtp.password)}`,169			`from = ${str(smtp.from)}`,170			`to = ${str(smtp.to)}`,171		);172	}173	const { backup } = config;174	if (backup) {175		lines.push(176			"",177			"[backup]",178			`endpoint = ${str(backup.endpoint)}`,179			`name = ${str(backup.name)}`,180			`region = ${str(backup.region)}`,181			`access_key_id = ${str(backup.accessKeyId)}`,182			`secret_access_key = ${str(backup.secretAccessKey)}`,183		);184	}185	const text = `${lines.join("\n")}\n`;186	parseConfig(text);187	const next = `${path}.new`;188	await writeFile(next, text, { mode: 0o600 });189	// `mode` applies only to a new file, and a crash may have left an older one behind.190	await chmod(next, 0o600);191	await rename(next, path);192}193194export async function loadConfig(path: string): Promise<Config> {195	const file = Bun.file(path);196	if (!(await file.exists())) throw new ConfigError(`${path} not found`);197	return parseConfig(await file.text());198}199200function parseBucket(bucket: Record<string, unknown>): BucketSource {201	if (bucket.write !== undefined) {202		throw new ConfigError(203			"[bucket.write] is no longer used: Kepr reads and writes with the [bucket] key alone",204		);205	}206	if (bucket.folder !== undefined) {207		const s3Keys = ["endpoint", "name", "region", "access_key_id", "secret_access_key"];208		const mixed = s3Keys.filter((key) => bucket[key] !== undefined);209		if (mixed.length > 0) {210			throw new ConfigError(`[bucket] folder cannot be combined with ${mixed.join(", ")}`);211		}212		return { kind: "folder", path: requireString(bucket, "folder", "[bucket] ") };213	}214	const endpoint = requireString(bucket, "endpoint", "[bucket] ");215	if (!URL.canParse(endpoint)) throw new ConfigError("[bucket] endpoint must be a URL");216	return {217		kind: "s3",218		endpoint,219		name: requireString(bucket, "name", "[bucket] "),220		region: requireString(bucket, "region", "[bucket] "),221		accessKeyId: requireString(bucket, "access_key_id", "[bucket] "),222		secretAccessKey: requireString(bucket, "secret_access_key", "[bucket] "),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}