1export type BucketSource =2 | {3 kind: "s3";4 endpoint: string;5 name: string;6 region: string;7 accessKeyId: string;8 secretAccessKey: string;9 }10 11 | { kind: "folder"; path: string };1213export type Config = {14 domain: string;15 dataDir: string;16 adminPasswordHash: string;17 bucket: BucketSource;18};1920export class ConfigError extends Error {}2122const HOSTNAME =23 /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*(:([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]))?$/i;2425export function parseConfig(text: string): Config {26 let raw: unknown;27 try {28 raw = Bun.TOML.parse(text);29 } catch (cause) {30 throw new ConfigError(`kepr.toml is not valid TOML: ${(cause as Error).message}`);31 }32 const top = asTable(raw, "kepr.toml");33 const bucket = asTable(top.bucket, "[bucket]");34 const adminPasswordHash = requireString(top, "admin_password_hash");35 if (!adminPasswordHash.startsWith("$argon2id$")) {36 throw new ConfigError("admin_password_hash must be an Argon2id hash ($argon2id$...)");37 }38 const domain = requireString(top, "domain");39 40 if (!HOSTNAME.test(domain))41 throw new ConfigError("domain must be a bare hostname, with an optional :port");42 return {43 domain,44 dataDir: requireString(top, "data_dir"),45 adminPasswordHash,46 bucket: parseBucket(bucket),47 };48}4950export async function loadConfig(path: string): Promise<Config> {51 const file = Bun.file(path);52 if (!(await file.exists())) throw new ConfigError(`${path} not found`);53 return parseConfig(await file.text());54}5556function parseBucket(bucket: Record<string, unknown>): BucketSource {57 if (bucket.folder !== undefined) {58 const s3Keys = ["endpoint", "name", "region", "access_key_id", "secret_access_key"];59 const mixed = s3Keys.filter((key) => bucket[key] !== undefined);60 if (mixed.length > 0) {61 throw new ConfigError(`[bucket] folder cannot be combined with ${mixed.join(", ")}`);62 }63 return { kind: "folder", path: requireString(bucket, "folder", "[bucket] ") };64 }65 const endpoint = requireString(bucket, "endpoint", "[bucket] ");66 if (!URL.canParse(endpoint)) throw new ConfigError("[bucket] endpoint must be a URL");67 return {68 kind: "s3",69 endpoint,70 name: requireString(bucket, "name", "[bucket] "),71 region: requireString(bucket, "region", "[bucket] "),72 accessKeyId: requireString(bucket, "access_key_id", "[bucket] "),73 secretAccessKey: requireString(bucket, "secret_access_key", "[bucket] "),74 };75}7677function asTable(value: unknown, name: string): Record<string, unknown> {78 if (typeof value !== "object" || value === null || Array.isArray(value)) {79 throw new ConfigError(`${name} is missing or not a table`);80 }81 return value as Record<string, unknown>;82}8384function requireString(table: Record<string, unknown>, key: string, section = ""): string {85 const value = table[key];86 if (typeof value !== "string" || value.trim() === "") {87 throw new ConfigError(`${section}${key} is required and must be a non-empty string`);88 }89 return value;90}