Files

139 lines
9.1 KiB
TypeScript

import { createHmac } from "node:crypto";
export type ServerConfig = {
host: string;
port: number;
accessTokenSecret: string;
accessTokenTtlSeconds: number;
refreshCookieName: string;
corsOrigin?: string;
adminEmail: string;
adminPassword: string;
adminMfaRequired: boolean;
adminSelfApprovalAllowed?: boolean;
adminMfaSecret?: string;
adminCaptchaRequired?: boolean;
userMfaRequired?: boolean;
userVerificationRequired?: boolean;
emailRegistrationVerificationRequired?: boolean;
phoneRegistrationVerificationRequired?: boolean;
captchaRequired?: boolean;
cookieSecure?: boolean;
csrfCookieName?: string;
maxBodyBytes?: number;
stagingDir?: string;
storeFile?: string;
rateLimitWindowMs?: number;
rateLimitMax?: number;
adminRoles?: string[];
databaseUrl?: string;
redisUrl?: string;
persistenceAdapter?: "memory" | "file" | "postgres";
queueAdapter?: "memory" | "redis";
stagingTtlSeconds?: number;
channelEncryptionKey?: string;
adminRole?: string;
refreshAbsoluteTtlSeconds?: number;
paymentWebhookSecret?: string;
adminAccounts?: Array<{ id: string; email: string; password: string; role: string; mfaSecret?: string; mfaRequired: boolean; recoveryCodeHashes?: string[]; mfaLastTotpCounter?: number }>;
};
function positiveInt(value: string | undefined, fallback: number) {
const parsed = Number(value);
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
}
function localMfaSecret(secret: string) {
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
const bytes = createHmac("sha256", secret).update("miragenflow-admin-mfa").digest().subarray(0, 20);
let output = "";
let buffer = 0;
let bits = 0;
for (const byte of bytes) {
buffer = (buffer << 8) | byte;
bits += 8;
while (bits >= 5) {
output += alphabet[(buffer >>> (bits - 5)) & 31];
bits -= 5;
}
}
if (bits > 0) output += alphabet[(buffer << (5 - bits)) & 31];
return output;
}
function adminAccounts(env: NodeJS.ProcessEnv, fallback: { email: string; password: string; role: string; mfaSecret: string; mfaRequired: boolean }) {
if (!env.MIRAGENFLOW_ADMIN_ACCOUNTS_JSON?.trim()) return [{ id: `admin:${fallback.email.toLowerCase()}`, ...fallback }];
const parsed = JSON.parse(env.MIRAGENFLOW_ADMIN_ACCOUNTS_JSON) as Array<Record<string, unknown>>;
if (!Array.isArray(parsed) || !parsed.length) throw new Error("MIRAGENFLOW_ADMIN_ACCOUNTS_JSON must contain at least one administrator");
return parsed.map((item, index) => {
const email = typeof item.email === "string" ? item.email.trim().toLowerCase() : "";
const password = typeof item.password === "string" ? item.password : "";
if (!email || password.length < 12) throw new Error(`administrator account ${index + 1} is invalid`);
return { id: typeof item.id === "string" && item.id.trim() ? item.id.trim() : `admin:${email}`, email, password, role: typeof item.role === "string" ? item.role : "operator", mfaSecret: typeof item.mfaSecret === "string" ? item.mfaSecret.trim() : undefined, mfaRequired: item.mfaRequired !== false, recoveryCodeHashes: Array.isArray(item.recoveryCodeHashes) ? item.recoveryCodeHashes.filter((value): value is string => typeof value === "string") : undefined, mfaLastTotpCounter: Number.isInteger(item.mfaLastTotpCounter) ? Number(item.mfaLastTotpCounter) : undefined };
});
}
export function loadConfig(env = process.env): ServerConfig {
const isProduction = env.NODE_ENV === "production";
const secret = env.MIRAGENFLOW_ACCESS_TOKEN_SECRET?.trim();
const adminPassword = env.MIRAGENFLOW_ADMIN_PASSWORD;
if (isProduction && (!secret || secret.length < 32)) throw new Error("MIRAGENFLOW_ACCESS_TOKEN_SECRET must be at least 32 characters in production");
if (isProduction && (!adminPassword || adminPassword.length < 12)) throw new Error("MIRAGENFLOW_ADMIN_PASSWORD must be set in production");
if (isProduction && env.MIRAGENFLOW_ADMIN_MFA_REQUIRED !== "false" && !env.MIRAGENFLOW_ADMIN_MFA_SECRET?.trim()) throw new Error("MIRAGENFLOW_ADMIN_MFA_SECRET must be set when administrator MFA is enabled");
if (isProduction && !env.MIRAGENFLOW_CHANNEL_ENCRYPTION_KEY?.trim()) throw new Error("MIRAGENFLOW_CHANNEL_ENCRYPTION_KEY must be set in production");
if (isProduction && env.MIRAGENFLOW_PERSISTENCE_ADAPTER !== "postgres") throw new Error("MIRAGENFLOW_PERSISTENCE_ADAPTER=postgres is required in production");
if (isProduction && !(env.DATABASE_URL || env.MIRAGENFLOW_DATABASE_URL)) throw new Error("DATABASE_URL is required in production");
if (isProduction && env.MIRAGENFLOW_QUEUE_ADAPTER !== "redis") throw new Error("MIRAGENFLOW_QUEUE_ADAPTER=redis is required in production");
if (isProduction && !(env.REDIS_URL || env.MIRAGENFLOW_REDIS_URL)) throw new Error("REDIS_URL is required in production");
const effectiveSecret = secret || "local-development-secret-change-me";
// Administrator login stays lightweight by default. MFA/CAPTCHA remain
// available as explicit security switches for deployments that enable them.
const defaultAdminMfaRequired = env.MIRAGENFLOW_ADMIN_MFA_REQUIRED === "true";
const defaultAdminMfaSecret = env.MIRAGENFLOW_ADMIN_MFA_SECRET?.trim() || localMfaSecret(effectiveSecret);
const configuredAdminAccounts = adminAccounts(env, { email: env.MIRAGENFLOW_ADMIN_EMAIL?.trim().toLowerCase() || "admin@admin.com", password: adminPassword || "admin", role: env.MIRAGENFLOW_ADMIN_ROLE?.trim() || "super_admin", mfaSecret: defaultAdminMfaSecret, mfaRequired: defaultAdminMfaRequired }).map((account) => ({ ...account, mfaSecret: account.mfaSecret || localMfaSecret(`${effectiveSecret}:${account.email}`) }));
return {
host: env.MIRAGENFLOW_HOST?.trim() || "127.0.0.1",
port: positiveInt(env.MIRAGENFLOW_PORT, 3100),
accessTokenSecret: effectiveSecret,
accessTokenTtlSeconds: positiveInt(env.MIRAGENFLOW_ACCESS_TOKEN_TTL_SECONDS, 900),
refreshCookieName: env.MIRAGENFLOW_REFRESH_COOKIE?.trim() || "miragenflow_refresh",
corsOrigin: env.MIRAGENFLOW_CORS_ORIGIN?.trim() || undefined,
adminEmail: env.MIRAGENFLOW_ADMIN_EMAIL?.trim() || "admin@admin.com",
adminPassword: adminPassword || "admin",
adminMfaRequired: defaultAdminMfaRequired,
adminSelfApprovalAllowed: !isProduction && env.MIRAGENFLOW_ADMIN_SELF_APPROVAL === "true",
adminCaptchaRequired: env.MIRAGENFLOW_ADMIN_CAPTCHA_REQUIRED === "true",
adminMfaSecret: defaultAdminMfaSecret,
adminAccounts: configuredAdminAccounts,
// Ordinary-user verification is an optional enhancement. The default
// preview/production policy is email+password only; deployments may turn
// CAPTCHA, registration verification, or user MFA on independently.
userMfaRequired: env.MIRAGENFLOW_USER_MFA_REQUIRED === "true",
userVerificationRequired: env.MIRAGENFLOW_USER_VERIFICATION_REQUIRED === "true",
emailRegistrationVerificationRequired: env.MIRAGENFLOW_EMAIL_REGISTRATION_VERIFICATION_REQUIRED === undefined ? undefined : env.MIRAGENFLOW_EMAIL_REGISTRATION_VERIFICATION_REQUIRED === "true",
phoneRegistrationVerificationRequired: env.MIRAGENFLOW_PHONE_REGISTRATION_VERIFICATION_REQUIRED === undefined ? undefined : env.MIRAGENFLOW_PHONE_REGISTRATION_VERIFICATION_REQUIRED === "true",
captchaRequired: env.MIRAGENFLOW_CAPTCHA_REQUIRED === "true",
// Secure cookies require an HTTPS public origin. Production keeps the
// secure default, while explicit false is useful for the HTTP local
// compose profile (and must not be swallowed by an `|| isProduction`
// expression).
cookieSecure: env.MIRAGENFLOW_COOKIE_SECURE === undefined ? isProduction : env.MIRAGENFLOW_COOKIE_SECURE === "true",
csrfCookieName: env.MIRAGENFLOW_CSRF_COOKIE?.trim() || "miragenflow_csrf",
maxBodyBytes: positiveInt(env.MIRAGENFLOW_MAX_BODY_BYTES, 10 * 1024 * 1024),
stagingDir: env.MIRAGENFLOW_STAGING_DIR?.trim() || "/tmp/miragenflow-staging",
storeFile: env.MIRAGENFLOW_STORE_FILE?.trim() || undefined,
rateLimitWindowMs: positiveInt(env.MIRAGENFLOW_RATE_LIMIT_WINDOW_MS, 60_000),
rateLimitMax: positiveInt(env.MIRAGENFLOW_RATE_LIMIT_MAX, 12),
adminRoles: (env.MIRAGENFLOW_ADMIN_ROLES || "super_admin,operator,finance,support,auditor").split(",").map((role) => role.trim()).filter(Boolean),
databaseUrl: env.DATABASE_URL?.trim() || env.MIRAGENFLOW_DATABASE_URL?.trim() || undefined,
redisUrl: env.REDIS_URL?.trim() || env.MIRAGENFLOW_REDIS_URL?.trim() || undefined,
persistenceAdapter: env.MIRAGENFLOW_PERSISTENCE_ADAPTER === "postgres" && (env.DATABASE_URL || env.MIRAGENFLOW_DATABASE_URL) ? "postgres" : env.MIRAGENFLOW_STORE_FILE?.trim() ? "file" : "memory",
queueAdapter: env.MIRAGENFLOW_QUEUE_ADAPTER === "redis" && (env.REDIS_URL || env.MIRAGENFLOW_REDIS_URL) ? "redis" : "memory",
stagingTtlSeconds: positiveInt(env.MIRAGENFLOW_STAGING_TTL_SECONDS, 7 * 24 * 60 * 60),
channelEncryptionKey: env.MIRAGENFLOW_CHANNEL_ENCRYPTION_KEY?.trim() || effectiveSecret,
adminRole: env.MIRAGENFLOW_ADMIN_ROLE?.trim() || "super_admin",
refreshAbsoluteTtlSeconds: positiveInt(env.MIRAGENFLOW_REFRESH_ABSOLUTE_TTL_SECONDS, 90 * 24 * 60 * 60),
paymentWebhookSecret: env.MIRAGENFLOW_PAYMENT_WEBHOOK_SECRET?.trim() || effectiveSecret,
};
}