export type BucketSource = | { kind: "s3"; endpoint: string; name: string; region: string; 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 type Config = { domain: string; dataDir: string; adminPasswordHash: string; bucket: BucketSource; }; export class ConfigError extends Error {} 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$...)"); } return { domain: requireString(top, "domain"), dataDir: requireString(top, "data_dir"), adminPasswordHash, bucket: parseBucket(bucket), }; } 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) { 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"); 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] "), }; } 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; }