211 lines
8.6 KiB
TypeScript
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(/\/$/, '') } }
|
|
: {}),
|
|
},
|
|
},
|
|
};
|
|
}
|