139 lines
9.1 KiB
TypeScript
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,
|
|
};
|
|
}
|