1export type BucketSource =2 | {3 kind: "s3";4 endpoint: string;5 name: string;6 region: string;7 accessKeyId: string;8 secretAccessKey: string;9 write?: { accessKeyId: string; secretAccessKey: string };10 }11 12 | { kind: "folder"; path: string };1314export type Config = {15 domain: string;16 dataDir: string;17 adminPasswordHash: string;18 bucket: BucketSource;19};2021export class ConfigError extends Error {}2223const HOSTNAME = new RegExp(24 String.raw`^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*` +25 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]))?$`,26 "i",27);2829export function parseConfig(text: string): Config {30 let raw: unknown;31 try {32 raw = Bun.TOML.parse(text);33 } catch (cause) {34 throw new ConfigError(`kepr.toml is not valid TOML: ${(cause as Error).message}`);35 }36 const top = asTable(raw, "kepr.toml");37 const bucket = asTable(top.bucket, "[bucket]");38 const adminPasswordHash = requireString(top, "admin_password_hash");39 if (!adminPasswordHash.startsWith("$argon2id$")) {40 throw new ConfigError("admin_password_hash must be an Argon2id hash ($argon2id$...)");41 }42 const domain = requireString(top, "domain");43 44 if (!HOSTNAME.test(domain))45 throw new ConfigError("domain must be a bare hostname, with an optional :port");46 return {47 domain,48 dataDir: requireString(top, "data_dir"),49 adminPasswordHash,50 bucket: parseBucket(bucket),51 };52}5354export async function loadConfig(path: string): Promise<Config> {55 const file = Bun.file(path);56 if (!(await file.exists())) throw new ConfigError(`${path} not found`);57 return parseConfig(await file.text());58}5960function parseBucket(bucket: Record<string, unknown>): BucketSource {61 if (bucket.folder !== undefined) {62 if (bucket.write !== undefined)63 throw new ConfigError("[bucket] write cannot be combined with folder");64 const s3Keys = ["endpoint", "name", "region", "access_key_id", "secret_access_key"];65 const mixed = s3Keys.filter((key) => bucket[key] !== undefined);66 if (mixed.length > 0) {67 throw new ConfigError(`[bucket] folder cannot be combined with ${mixed.join(", ")}`);68 }69 return { kind: "folder", path: requireString(bucket, "folder", "[bucket] ") };70 }71 const endpoint = requireString(bucket, "endpoint", "[bucket] ");72 if (!URL.canParse(endpoint)) throw new ConfigError("[bucket] endpoint must be a URL");73 const write = bucket.write === undefined ? undefined : asTable(bucket.write, "[bucket.write]");74 return {75 kind: "s3",76 endpoint,77 name: requireString(bucket, "name", "[bucket] "),78 region: requireString(bucket, "region", "[bucket] "),79 accessKeyId: requireString(bucket, "access_key_id", "[bucket] "),80 secretAccessKey: requireString(bucket, "secret_access_key", "[bucket] "),81 ...(write82 ? {83 write: {84 accessKeyId: requireString(write, "access_key_id", "[bucket.write] "),85 secretAccessKey: requireString(write, "secret_access_key", "[bucket.write] "),86 },87 }88 : {}),89 };90}9192function asTable(value: unknown, name: string): Record<string, unknown> {93 if (typeof value !== "object" || value === null || Array.isArray(value)) {94 throw new ConfigError(`${name} is missing or not a table`);95 }96 return value as Record<string, unknown>;97}9899function requireString(table: Record<string, unknown>, key: string, section = ""): string {100 const value = table[key];101 if (typeof value !== "string" || value.trim() === "") {102 throw new ConfigError(`${section}${key} is required and must be a non-empty string`);103 }104 return value;105}