import type { GroupFileMapping } from "@tactical-media-broadcast-hub/core"; /** Explicit, immutable room → library mapping. Only rooms listed here are ever read. */ export interface RoomMapping { roomId: string; workspaceId: string; groupId: string; } /** * Phase-B encrypted export bundles. All fields optional at boot: exports are * disabled unless every required variable is present, and an explicit * `ARCHIVE_EXPORT_ENABLED=false` wins over anything else. */ export interface ExportConfig { enabled: boolean; /** bech32 age1… recipients; the file key is wrapped for each. Private keys never exist here. */ ageRecipients: string[]; /** Rolling auto-export after this many newly archived attachments per room (0 disables). */ every: number; r2: { endpoint: string; region: string; accessKeyId: string; secretAccessKey: string; bucket: string; /** Bounded spill dir for the ciphertext before the R2 PUT (tmpfs-friendly). */ tmpDir: string; }; } export interface BotConfig { databaseUrl: string; homeserverUrl: URL; matrixUser: string; matrixPassword: string; /** Stable device so Matrix transaction-ID idempotency survives restarts. */ matrixDeviceId: string; /** Extra hosts (e.g. the R2 media endpoint) media redirects may target; auth is always dropped. */ mediaRedirectHosts: string[]; mappings: RoomMapping[]; webdav: { baseUrl: URL; user: string; password: string }; healthPort: number; /** Null when phase-B exports are not configured (bot behaves exactly as before). */ export: ExportConfig | null; } const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; /** Room IDs only: `!opaque:server`. Aliases (`#…`) are rejected — mappings must be immutable. */ const ROOM_ID = /^![^:\s]+:[^\s]+$/; export class ConfigError extends Error {} function required(env: NodeJS.ProcessEnv, name: string): string { const value = env[name]; if (value === undefined || value.trim() === "") throw new ConfigError(`${name} is required`); return value.trim(); } function httpUrl(name: string, raw: string): URL { let url: URL; try { url = new URL(raw); } catch { throw new ConfigError(`${name} must be an absolute URL`); } if (url.protocol !== "https:" && url.protocol !== "http:") throw new ConfigError(`${name} must be http(s)`); return url; } export function parseRoomMappings(raw: string): RoomMapping[] { let parsed: unknown; try { parsed = JSON.parse(raw); } catch { throw new ConfigError("ROOM_MAPPINGS must be JSON"); } if (!Array.isArray(parsed) || parsed.length === 0) throw new ConfigError("ROOM_MAPPINGS must be a non-empty array"); const seen = new Set(); return parsed.map((entry: unknown, index) => { if (typeof entry !== "object" || entry === null) throw new ConfigError(`ROOM_MAPPINGS[${index}] must be an object`); const { roomId, workspaceId, groupId } = entry as Record; if (typeof roomId !== "string" || !ROOM_ID.test(roomId)) { throw new ConfigError(`ROOM_MAPPINGS[${index}].roomId must be a room ID (!id:server), not an alias`); } if (typeof workspaceId !== "string" || !UUID.test(workspaceId)) { throw new ConfigError(`ROOM_MAPPINGS[${index}].workspaceId must be a UUID`); } if (typeof groupId !== "string" || !UUID.test(groupId)) { throw new ConfigError(`ROOM_MAPPINGS[${index}].groupId must be a UUID`); } if (seen.has(roomId)) throw new ConfigError(`ROOM_MAPPINGS has duplicate roomId ${roomId}`); seen.add(roomId); return { roomId, workspaceId: workspaceId.toLowerCase(), groupId: groupId.toLowerCase() }; }); } /** The env mapping only exists for rooms explicitly enabled as unencrypted archive rooms. */ export function toGroupFileMapping(mapping: RoomMapping): GroupFileMapping { return { workspaceId: mapping.workspaceId, groupId: mapping.groupId, roomId: mapping.roomId, spaceId: mapping.workspaceId, folderId: `${mapping.workspaceId}/${mapping.groupId}`, archivePolicy: "unencrypted-archive", retention: { legalHold: false, mode: "keep" }, }; } const AGE_RECIPIENT = /^age1[02-9ac-hj-np-z]{58,}$/; /** * Parse the phase-B export block. Enabled only when the operator supplied * recipients AND R2 credentials; a malformed value fails the boot loudly * rather than silently disabling exports. */ export function parseExportConfig(env: NodeJS.ProcessEnv): ExportConfig | null { const rawRecipients = env.ARCHIVE_EXPORT_AGE_RECIPIENTS?.trim() ?? ""; const accessKey = env.ARCHIVE_EXPORT_R2_ACCESS_KEY?.trim() ?? ""; const secretKey = env.ARCHIVE_EXPORT_R2_SECRET_KEY?.trim() ?? ""; if (env.ARCHIVE_EXPORT_ENABLED?.trim().toLowerCase() === "false") return null; if (rawRecipients === "" && accessKey === "" && secretKey === "") return null; const recipients = rawRecipients.split(",").map((r) => r.trim()).filter((r) => r !== ""); if (recipients.length === 0) { throw new ConfigError("ARCHIVE_EXPORT_AGE_RECIPIENTS: at least one bech32 age1… recipient is required"); } recipients.forEach((recipient, index) => { if (!AGE_RECIPIENT.test(recipient)) { throw new ConfigError(`ARCHIVE_EXPORT_AGE_RECIPIENTS[${index}]: expected a bech32 age1… public key`); } }); if (accessKey === "") throw new ConfigError("ARCHIVE_EXPORT_R2_ACCESS_KEY is required when export recipients are set"); if (secretKey === "") throw new ConfigError("ARCHIVE_EXPORT_R2_SECRET_KEY is required when export recipients are set"); const every = env.ARCHIVE_EXPORT_EVERY === undefined || env.ARCHIVE_EXPORT_EVERY.trim() === "" ? 500 : Number(env.ARCHIVE_EXPORT_EVERY); if (!Number.isInteger(every) || every < 0 || every > 1_000_000) { throw new ConfigError("ARCHIVE_EXPORT_EVERY must be a non-negative integer"); } const endpoint = env.ARCHIVE_EXPORT_R2_ENDPOINT?.trim(); if (endpoint !== undefined && endpoint !== "" && !/^https:\/\/.+/.test(endpoint)) { throw new ConfigError("ARCHIVE_EXPORT_R2_ENDPOINT must be an https URL"); } return { enabled: true, ageRecipients: [...new Set(recipients)], every, r2: { endpoint: endpoint ?? "", region: env.ARCHIVE_EXPORT_R2_REGION?.trim() || "auto", accessKeyId: accessKey, secretAccessKey: secretKey, bucket: env.ARCHIVE_EXPORT_R2_BUCKET?.trim() || "hub-matrix-media", tmpDir: env.ARCHIVE_EXPORT_TMP_DIR?.trim() || DEFAULT_EXPORT_TMP_DIR, }, }; } export const DEFAULT_EXPORT_TMP_DIR = `${process.env.TMPDIR ?? "/tmp"}/archive-bot-export`; export function loadConfig(env: NodeJS.ProcessEnv = process.env): BotConfig { const healthPort = Number(env.ARCHIVE_BOT_HEALTH_PORT ?? 8085); if (!Number.isInteger(healthPort) || healthPort <= 0 || healthPort > 65535) { throw new ConfigError("ARCHIVE_BOT_HEALTH_PORT must be a TCP port"); } return { databaseUrl: required(env, "DATABASE_URL"), homeserverUrl: httpUrl("MATRIX_HOMESERVER_URL", required(env, "MATRIX_HOMESERVER_URL")), matrixUser: required(env, "MATRIX_USER"), matrixPassword: required(env, "MATRIX_PASSWORD"), matrixDeviceId: env.MATRIX_DEVICE_ID?.trim() || "HUB_ARCHIVE_BOT", mediaRedirectHosts: (env.MATRIX_MEDIA_REDIRECT_HOSTS ?? "") .split(",") .map((h) => h.trim().toLowerCase()) .filter((h) => h !== ""), mappings: parseRoomMappings(required(env, "ROOM_MAPPINGS")), webdav: { baseUrl: httpUrl("FILES_OCID_WEBDAV_URL", required(env, "FILES_OCID_WEBDAV_URL")), user: required(env, "FILES_OCID_USER"), password: required(env, "FILES_OCID_PASSWORD"), }, healthPort, export: parseExportConfig(env), }; }