Files
mpm/apps/platform-backend/src/config/configuration.ts
2026-10-08 21:13:57 +02:00

211 lines
8.6 KiB
TypeScript

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(/\/$/, '') } }
: {}),
},
},
};
}