feat: integrate platform backend and application interfaces
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user