import { z } from 'zod'; import path from 'node:path'; /** * Zentrale, typsichere Konfiguration der Management-Plattform. * Alle Werte stammen aus Umgebungsvariablen und werden beim Start * einmalig validiert (fail-fast bei fehlerhafter Konfiguration). */ export type NodeEnvironment = 'development' | 'test' | 'production'; export interface DatabaseConfig { readonly url: string; } export interface SecurityConfig { readonly sessionTtlMinutes: number; readonly cookieSecure: boolean; readonly behindProxy: boolean; /** @deprecated Account lockout was removed to prevent attacker-triggered account denial. */ readonly loginMaxAttempts: number; /** @deprecated Account lockout was removed to prevent attacker-triggered account denial. */ readonly loginLockoutMinutes: number; readonly loginRateLimitAttempts: number; readonly loginRateLimitWindowMinutes: number; } export interface AdminSeedConfig { readonly username: string; readonly email: string; readonly password: string; } export interface RuntimeConfig { readonly modulesDir: string; readonly logsDir: string; readonly moduleUidBase?: number; readonly moduleConfigurationEncryptionKey: string; } export interface MarketplaceProviderConfig { readonly clientId: string; readonly clientSecret: string; readonly baseUrl: string; } export interface MarketplaceConfig { readonly publicUrl: string; readonly tokenEncryptionKey: string; readonly providers: { readonly github?: MarketplaceProviderConfig; readonly gitea?: MarketplaceProviderConfig; readonly forgejo?: MarketplaceProviderConfig; }; } export interface AppConfig { readonly nodeEnv: NodeEnvironment; readonly port: number; readonly database: DatabaseConfig; readonly security: SecurityConfig; readonly adminSeed: AdminSeedConfig; readonly runtime: RuntimeConfig; readonly marketplace: MarketplaceConfig; } const booleanFromString = z .enum(['true', 'false']) .default('false') .transform((value) => value === 'true'); const environmentSchema = z.object({ NODE_ENV: z.enum(['development', 'test', 'production']).default('production'), PORT: z.coerce.number().int().positive().default(3000), DATABASE_URL: z.string().min(1, 'DATABASE_URL ist erforderlich'), SESSION_TTL_MINUTES: z.coerce.number().int().positive().default(120), COOKIE_SECURE: booleanFromString, BEHIND_PROXY: booleanFromString, LOGIN_MAX_ATTEMPTS: z.coerce.number().int().positive().default(5), LOGIN_LOCKOUT_MINUTES: z.coerce.number().int().positive().default(15), LOGIN_RATE_LIMIT_ATTEMPTS: z.coerce.number().int().positive().default(10), LOGIN_RATE_LIMIT_WINDOW_MINUTES: z.coerce.number().int().positive().default(5), ADMIN_USERNAME: z.string().trim().min(3).max(100), ADMIN_EMAIL: z.string().trim().email(), ADMIN_PASSWORD: z.string().min(10, 'ADMIN_PASSWORD muss mindestens 10 Zeichen lang sein').max(200), MODULES_DIR: z.string().min(1).default('./data/modules'), MODULE_DATA_DIR: z.string().min(1).default('./data/module-data'), LOGS_DIR: z.string().min(1).default('./data/logs'), MODULE_CONFIG_ENCRYPTION_KEY: z.string().default(''), MODULE_UID_BASE: z.coerce.number().int().min(10_000).max(64_535).optional(), MARKETPLACE_PUBLIC_URL: z.string().url().default('http://127.0.0.1:8081'), MARKETPLACE_TOKEN_ENCRYPTION_KEY: z.string().default(''), GITHUB_OAUTH_CLIENT_ID: z.string().default(''), GITHUB_OAUTH_CLIENT_SECRET: z.string().default(''), GITEA_BASE_URL: z.string().default(''), GITEA_OAUTH_CLIENT_ID: z.string().default(''), GITEA_OAUTH_CLIENT_SECRET: z.string().default(''), FORGEJO_BASE_URL: z.string().default(''), FORGEJO_OAUTH_CLIENT_ID: z.string().default(''), FORGEJO_OAUTH_CLIENT_SECRET: z.string().default(''), }).superRefine((environment, context) => { if (environment.NODE_ENV === 'production' && !environment.COOKIE_SECURE) { context.addIssue({ code: z.ZodIssueCode.custom, path: ['COOKIE_SECURE'], message: 'COOKIE_SECURE muss in production auf true gesetzt sein', }); } if (environment.NODE_ENV === 'production' && environment.MODULE_UID_BASE === undefined) { context.addIssue({ code: z.ZodIssueCode.custom, path: ['MODULE_UID_BASE'], message: 'MODULE_UID_BASE ist in production erforderlich, damit Module getrennte UIDs erhalten', }); } const oauthFields = [ environment.GITHUB_OAUTH_CLIENT_ID, environment.GITHUB_OAUTH_CLIENT_SECRET, environment.GITEA_BASE_URL, environment.GITEA_OAUTH_CLIENT_ID, environment.GITEA_OAUTH_CLIENT_SECRET, environment.FORGEJO_BASE_URL, environment.FORGEJO_OAUTH_CLIENT_ID, environment.FORGEJO_OAUTH_CLIENT_SECRET, ]; if (oauthFields.some(Boolean) && environment.MARKETPLACE_TOKEN_ENCRYPTION_KEY.length < 32) { context.addIssue({ code: z.ZodIssueCode.custom, path: ['MARKETPLACE_TOKEN_ENCRYPTION_KEY'], message: 'Bei aktivierten OAuth-Anbietern ist ein Schlüssel mit mindestens 32 Zeichen erforderlich', }); } for (const [provider, fields] of [ ['GITHUB', [environment.GITHUB_OAUTH_CLIENT_ID, environment.GITHUB_OAUTH_CLIENT_SECRET]], ['GITEA', [environment.GITEA_BASE_URL, environment.GITEA_OAUTH_CLIENT_ID, environment.GITEA_OAUTH_CLIENT_SECRET]], ['FORGEJO', [environment.FORGEJO_BASE_URL, environment.FORGEJO_OAUTH_CLIENT_ID, environment.FORGEJO_OAUTH_CLIENT_SECRET]], ] as const) { if (fields.some(Boolean) && fields.some((field) => !field)) { context.addIssue({ code: z.ZodIssueCode.custom, path: [`${provider}_OAUTH_CLIENT_ID`], message: `OAuth-Konfiguration für ${provider} ist unvollständig`, }); } } for (const [field, value] of [['GITEA_BASE_URL', environment.GITEA_BASE_URL], ['FORGEJO_BASE_URL', environment.FORGEJO_BASE_URL]] as const) { if (!value) continue; try { const url = new URL(value); if (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))) { throw new Error('protocol'); } if (url.username || url.password || url.search || url.hash) throw new Error('url'); } catch { context.addIssue({ code: z.ZodIssueCode.custom, path: [field], message: 'Forge-URL muss HTTPS verwenden (HTTP ist nur lokal zulässig) und darf keine Zugangsdaten enthalten', }); } } }); /** Lädt und validiert die Konfiguration aus den Umgebungsvariablen. */ export function loadConfiguration(): AppConfig { const environment = environmentSchema.parse(process.env); return { nodeEnv: environment.NODE_ENV, port: environment.PORT, database: { url: environment.DATABASE_URL }, security: { sessionTtlMinutes: environment.SESSION_TTL_MINUTES, cookieSecure: environment.COOKIE_SECURE, behindProxy: environment.BEHIND_PROXY, loginMaxAttempts: environment.LOGIN_MAX_ATTEMPTS, loginLockoutMinutes: environment.LOGIN_LOCKOUT_MINUTES, loginRateLimitAttempts: environment.LOGIN_RATE_LIMIT_ATTEMPTS, loginRateLimitWindowMinutes: environment.LOGIN_RATE_LIMIT_WINDOW_MINUTES, }, adminSeed: { username: environment.ADMIN_USERNAME, email: environment.ADMIN_EMAIL, password: environment.ADMIN_PASSWORD, }, runtime: { modulesDir: path.resolve(environment.MODULES_DIR), logsDir: path.resolve(environment.LOGS_DIR), moduleConfigurationEncryptionKey: environment.MODULE_CONFIG_ENCRYPTION_KEY, ...(environment.MODULE_UID_BASE !== undefined ? { moduleUidBase: environment.MODULE_UID_BASE } : {}), }, marketplace: { publicUrl: environment.MARKETPLACE_PUBLIC_URL.replace(/\/$/, ''), tokenEncryptionKey: environment.MARKETPLACE_TOKEN_ENCRYPTION_KEY, providers: { ...(environment.GITHUB_OAUTH_CLIENT_ID && environment.GITHUB_OAUTH_CLIENT_SECRET ? { github: { clientId: environment.GITHUB_OAUTH_CLIENT_ID, clientSecret: environment.GITHUB_OAUTH_CLIENT_SECRET, baseUrl: 'https://github.com' } } : {}), ...(environment.GITEA_BASE_URL && environment.GITEA_OAUTH_CLIENT_ID && environment.GITEA_OAUTH_CLIENT_SECRET ? { gitea: { clientId: environment.GITEA_OAUTH_CLIENT_ID, clientSecret: environment.GITEA_OAUTH_CLIENT_SECRET, baseUrl: environment.GITEA_BASE_URL.replace(/\/$/, '') } } : {}), ...(environment.FORGEJO_BASE_URL && environment.FORGEJO_OAUTH_CLIENT_ID && environment.FORGEJO_OAUTH_CLIENT_SECRET ? { forgejo: { clientId: environment.FORGEJO_OAUTH_CLIENT_ID, clientSecret: environment.FORGEJO_OAUTH_CLIENT_SECRET, baseUrl: environment.FORGEJO_BASE_URL.replace(/\/$/, '') } } : {}), }, }, }; }