const path = require("node:path"); const ONE_DAY_SECONDS = 24 * 60 * 60; const ONE_MINUTE_SECONDS = 60; function requiredEnv(name) { const value = process.env[name]; if (!value) { throw new Error(`Missing required env: ${name}`); } return value; } function normalizeBuildSha(value) { const sha = String(value || "").trim().toLowerCase(); return /^[0-9a-f]{7,64}$/.test(sha) ? sha.slice(0, 12) : "unknown"; } function parseBoundedInteger(name, value, { min = 0, max = Number.MAX_SAFE_INTEGER } = {}) { const normalized = typeof value === "number" ? String(value) : String(value ?? "").trim(); if (!/^\d+$/.test(normalized)) { throw new Error(`Invalid ${name}`); } const parsed = Number(normalized); if (!Number.isInteger(parsed) || parsed < min || parsed > max) { throw new Error(`Invalid ${name}`); } return parsed; } function assertProductionAuthCodePepper(value) { const isPlaceholder = /^<[^>]+>$/.test(value); if (isPlaceholder || Buffer.byteLength(value, "utf8") < 32) { throw new Error("AUTH_CODE_PEPPER must be a non-placeholder secret of at least 32 bytes"); } } function getConfig() { const nodeEnv = process.env.NODE_ENV ?? "development"; const serverRoot = path.resolve(__dirname, ".."); const storageDir = process.env.STORAGE_DIR ?? "generated/storage"; const clientDistDir = process.env.CLIENT_DIST_DIR ?? "../client/dist"; const jwtSecret = requiredEnv("JWT_SECRET"); const devShowPasswordResetCode = process.env.DEV_SHOW_PASSWORD_RESET_CODE === "true"; if (nodeEnv === "production" && devShowPasswordResetCode) { throw new Error("DEV_SHOW_PASSWORD_RESET_CODE is forbidden in production"); } const authCodePepper = String(process.env.AUTH_CODE_PEPPER ?? "").trim(); if (nodeEnv === "production" && !authCodePepper) { throw new Error("Missing required env: AUTH_CODE_PEPPER"); } if (nodeEnv === "production") { assertProductionAuthCodePepper(authCodePepper); if (authCodePepper === jwtSecret) { throw new Error("AUTH_CODE_PEPPER must be independent from JWT_SECRET"); } } const trustProxyHopsValue = process.env.TRUST_PROXY_HOPS ?? (nodeEnv === "production" ? requiredEnv("TRUST_PROXY_HOPS") : "0"); const trustProxyHops = parseBoundedInteger("TRUST_PROXY_HOPS", trustProxyHopsValue, { min: nodeEnv === "production" ? 1 : 0, max: nodeEnv === "production" ? 1 : 8, }); const passwordResetCodeTtlSeconds = parseBoundedInteger( "PASSWORD_RESET_CODE_TTL_SECONDS", process.env.PASSWORD_RESET_CODE_TTL_SECONDS ?? 10 * ONE_MINUTE_SECONDS, { min: ONE_MINUTE_SECONDS, max: ONE_DAY_SECONDS }, ); const passwordResetFailWindowSeconds = parseBoundedInteger( "PASSWORD_RESET_FAIL_WINDOW_SECONDS", process.env.PASSWORD_RESET_FAIL_WINDOW_SECONDS ?? 30 * ONE_MINUTE_SECONDS, { min: ONE_MINUTE_SECONDS, max: 7 * ONE_DAY_SECONDS }, ); const passwordResetMaxAttempts = parseBoundedInteger( "PASSWORD_RESET_MAX_ATTEMPTS", process.env.PASSWORD_RESET_MAX_ATTEMPTS ?? 5, { min: 1, max: 20 }, ); const passwordResetLockSeconds = parseBoundedInteger( "PASSWORD_RESET_LOCK_SECONDS", process.env.PASSWORD_RESET_LOCK_SECONDS ?? 30 * ONE_MINUTE_SECONDS, { min: ONE_MINUTE_SECONDS, max: 7 * ONE_DAY_SECONDS }, ); const passwordResetRequestWindowSeconds = parseBoundedInteger( "PASSWORD_RESET_REQUEST_WINDOW_SECONDS", process.env.PASSWORD_RESET_REQUEST_WINDOW_SECONDS ?? 60 * ONE_MINUTE_SECONDS, { min: ONE_MINUTE_SECONDS, max: 7 * ONE_DAY_SECONDS }, ); const passwordResetRequestMax = parseBoundedInteger( "PASSWORD_RESET_REQUEST_MAX", process.env.PASSWORD_RESET_REQUEST_MAX ?? 3, { min: 1, max: 20 }, ); const authCodeRequestCooldownSeconds = parseBoundedInteger( "AUTH_CODE_REQUEST_COOLDOWN_SECONDS", process.env.AUTH_CODE_REQUEST_COOLDOWN_SECONDS ?? ONE_MINUTE_SECONDS, { min: 1, max: ONE_DAY_SECONDS }, ); const emailLoginCodeTtlSeconds = parseBoundedInteger( "EMAIL_LOGIN_CODE_TTL_SECONDS", process.env.EMAIL_LOGIN_CODE_TTL_SECONDS ?? 10 * ONE_MINUTE_SECONDS, { min: ONE_MINUTE_SECONDS, max: ONE_DAY_SECONDS }, ); const emailLoginCodeMaxAttempts = parseBoundedInteger( "EMAIL_LOGIN_CODE_MAX_ATTEMPTS", process.env.EMAIL_LOGIN_CODE_MAX_ATTEMPTS ?? 5, { min: 1, max: 20 }, ); const emailLoginCodeFailWindowSeconds = parseBoundedInteger( "EMAIL_LOGIN_CODE_FAIL_WINDOW_SECONDS", process.env.EMAIL_LOGIN_CODE_FAIL_WINDOW_SECONDS ?? 30 * ONE_MINUTE_SECONDS, { min: ONE_MINUTE_SECONDS, max: 7 * ONE_DAY_SECONDS }, ); const emailLoginCodeLockSeconds = parseBoundedInteger( "EMAIL_LOGIN_CODE_LOCK_SECONDS", process.env.EMAIL_LOGIN_CODE_LOCK_SECONDS ?? 30 * ONE_MINUTE_SECONDS, { min: ONE_MINUTE_SECONDS, max: 7 * ONE_DAY_SECONDS }, ); return { nodeEnv, port: Number(process.env.PORT ?? 3000), host: process.env.HOST ?? "0.0.0.0", buildSha: normalizeBuildSha(process.env.BUILD_SHA), metricsEnabled: process.env.METRICS_ENABLED !== "false", trustProxy: trustProxyHops > 0 ? trustProxyHops : false, appOrigin: process.env.APP_ORIGIN ?? "", jwtSecret, fieldEncryptionKey: process.env.FIELD_ENCRYPTION_KEY ?? "", authCodePepper: authCodePepper || jwtSecret, accessTokenTtl: "15m", refreshTtlSeconds: 7 * ONE_DAY_SECONDS, refreshCookieName: "refresh_token", cookieSecure: process.env.COOKIE_SECURE != null ? process.env.COOKIE_SECURE === "true" : nodeEnv === "production", passwordResetCodeTtlSeconds, passwordResetFailWindowSeconds, passwordResetMaxAttempts, passwordResetLockSeconds, passwordResetRequestWindowSeconds, passwordResetRequestMax, authCodeRequestCooldownSeconds, emailLoginCodeTtlSeconds, emailLoginCodeMaxAttempts, emailLoginCodeFailWindowSeconds, emailLoginCodeLockSeconds, devShowPasswordResetCode, loginFailWindowSeconds: Number( process.env.AUTH_LOGIN_FAIL_WINDOW_SECONDS ?? 15 * ONE_MINUTE_SECONDS, ), loginFailMaxAttempts: Number(process.env.AUTH_LOGIN_FAIL_MAX_ATTEMPTS ?? 5), loginLockSeconds: Number(process.env.AUTH_LOGIN_LOCK_SECONDS ?? 30 * ONE_MINUTE_SECONDS), oidcEnabled: process.env.OIDC_ENABLED === "true", oidcIssuerUrl: process.env.OIDC_ISSUER_URL ?? "", oidcClientId: process.env.OIDC_CLIENT_ID ?? "", oidcClientSecret: process.env.OIDC_CLIENT_SECRET ?? "", oidcRedirectUri: process.env.OIDC_REDIRECT_URI ?? "", oidcScopes: process.env.OIDC_SCOPES ?? "openid profile email", wecomEnabled: process.env.WECOM_ENABLED === "true", wecomCorpId: process.env.WECOM_CORP_ID ?? "", wecomAgentId: process.env.WECOM_AGENT_ID ?? "", wecomAgentSecret: process.env.WECOM_AGENT_SECRET ?? "", wecomCheckinSyncEnabled: process.env.WECOM_CHECKIN_SYNC_ENABLED === "true", wecomCheckinSecret: process.env.WECOM_CHECKIN_SECRET ?? "", wecomContactSyncEnabled: process.env.WECOM_CONTACT_SYNC_ENABLED === "true", wecomContactSecret: process.env.WECOM_CONTACT_SECRET ?? "", wecomContactDefaultDepartmentId: process.env.WECOM_CONTACT_DEFAULT_DEPARTMENT_ID ?? "", wecomHrMasterSyncEnabled: process.env.WECOM_HR_MASTER_SYNC_ENABLED === "true", wecomHrAssistantSecret: process.env.WECOM_HR_ASSISTANT_SECRET ?? "", wecomOauthEnabled: process.env.WECOM_OAUTH_ENABLED === "true", wecomOauthOnboardingEnabled: process.env.WECOM_OAUTH_ONBOARDING_ENABLED === "true", wecomRedirectUri: process.env.WECOM_REDIRECT_URI ?? "", wecomAllowedReturnHosts: process.env.WECOM_ALLOWED_RETURN_HOSTS ?? "", wecomApprovalCallbackUrl: process.env.WECOM_APPROVAL_CALLBACK_URL ?? "", wecomCallbackToken: process.env.WECOM_CALLBACK_TOKEN ?? "", wecomCallbackEncodingAesKey: process.env.WECOM_CALLBACK_ENCODING_AES_KEY ?? "", wecomApprovalCallbackEvidenceFile: process.env.WECOM_APPROVAL_CALLBACK_EVIDENCE_FILE ?? "", appTimezone: process.env.APP_TIMEZONE ?? "Asia/Shanghai", storageDir: path.resolve(serverRoot, storageDir), storageDriver: process.env.STORAGE_DRIVER ?? "local", s3Endpoint: process.env.S3_ENDPOINT ?? "", s3Region: process.env.S3_REGION ?? "us-east-1", s3Bucket: process.env.S3_BUCKET ?? "", s3AccessKeyId: process.env.S3_ACCESS_KEY_ID ?? "", s3SecretAccessKey: process.env.S3_SECRET_ACCESS_KEY ?? "", s3ForcePathStyle: process.env.S3_FORCE_PATH_STYLE === "true", serveClientDist: process.env.SERVE_CLIENT_DIST === "true", openapiDocsEnabled: process.env.OPENAPI_DOCS_ENABLED === "true", clientDistDir: path.resolve(serverRoot, clientDistDir), signedUrlTtlSeconds: Number(process.env.SIGNED_URL_TTL_SECONDS ?? 600), maxUploadSizeBytes: Number(process.env.MAX_UPLOAD_SIZE_BYTES ?? 10 * 1024 * 1024), rateLimitEnabled: process.env.RATE_LIMIT_ENABLED != null ? process.env.RATE_LIMIT_ENABLED === "true" : nodeEnv === "production", rateLimitLoginMax: Number(process.env.RATE_LIMIT_LOGIN_MAX ?? 10), rateLimitLoginWindow: process.env.RATE_LIMIT_LOGIN_WINDOW ?? "1 minute", delegationRevokeCron: process.env.DELEGATION_REVOKE_CRON ?? "0 * * * *", jobDriver: process.env.JOB_DRIVER ?? "cron", jobsEnabled: process.env.JOBS_ENABLED !== "false", pgBossSchema: process.env.PG_BOSS_SCHEMA ?? "pgboss", ocrProvider: process.env.OCR_PROVIDER ?? "off", ocrCronEnabled: process.env.OCR_CRON_ENABLED !== "false", ocrBatchSize: Number(process.env.OCR_BATCH_SIZE ?? 10), ocrLockTtlSeconds: Number(process.env.OCR_LOCK_TTL_SECONDS ?? 300), ocrPollCron: process.env.OCR_POLL_CRON ?? "* * * * *", ocrMaxAttempts: Number(process.env.OCR_MAX_ATTEMPTS ?? 3), ocrTimeoutMs: Number(process.env.OCR_TIMEOUT_MS ?? 5000), invoiceVerificationEnabled: process.env.INVOICE_VERIFICATION_ENABLED === "true", leaveHolidayCalendarEnabled: process.env.LEAVE_HOLIDAY_CALENDAR_ENABLED === "true", invoiceVerificationFlagEnv: process.env.INVOICE_VERIFICATION_ENABLED ?? "false", verificationProvider: process.env.VERIFICATION_PROVIDER ?? "mock", verificationPollCron: process.env.VERIFICATION_POLL_CRON ?? "* * * * *", verificationMaxAttempts: Number(process.env.VERIFICATION_MAX_ATTEMPTS ?? 3), verificationRetryCooldownSeconds: Number( process.env.VERIFICATION_RETRY_COOLDOWN_SECONDS ?? 60, ), verificationTimeoutMs: Number(process.env.VERIFICATION_TIMEOUT_MS ?? 5000), budgetControlMode: process.env.BUDGET_CONTROL_MODE ?? "warn", budgetPrecheckEnabled: process.env.BUDGET_PRECHECK_ENABLED === "true", paymentBatchEnabled: process.env.PAYMENT_BATCH_ENABLED === "true", portalHomeEnabled: process.env.PORTAL_HOME_ENABLED === "true", globalSearchEnabled: process.env.GLOBAL_SEARCH_ENABLED === "true", announcementsEnabled: process.env.ANNOUNCEMENTS_ENABLED === "true", workflowDefinitionAdminEnabled: process.env.WORKFLOW_DEFINITION_ADMIN_ENABLED === "true", cutoverConsoleEnabled: process.env.CUTOVER_CONSOLE_ENABLED === "true", presenceBoardEnabled: process.env.PRESENCE_BOARD_ENABLED === "true", attendanceEnabled: process.env.ATTENDANCE_ENABLED === "true", attendanceUnifiedEnabled: process.env.ATTENDANCE_UNIFIED_ENABLED !== "false", weeklyReportEnabled: process.env.WEEKLY_REPORT_ENABLED === "true", employeeLifecycleEnabled: process.env.EMPLOYEE_LIFECYCLE_ENABLED === "true", todoBoardEnabled: process.env.TODO_BOARD_ENABLED === "true", wecomEntryEnabled: process.env.WECOM_ENTRY_ENABLED === "true", projectDomainEnabled: process.env.PROJECT_DOMAIN_ENABLED === "true", projectLinkingEnabled: process.env.PROJECT_LINKING_ENABLED === "true", projectCommunicationLogEnabled: process.env.PROJECT_COMMUNICATION_LOG_ENABLED === "true", projectClosureChecklistEnabled: process.env.PROJECT_CLOSURE_CHECKLIST_ENABLED === "true", confidentialDocRefsEnabled: process.env.CONFIDENTIAL_DOC_REFS_ENABLED === "true", publicSecurityHardeningEnabled: process.env.PUBLIC_SECURITY_HARDENING_ENABLED === "true", aiAssistEnabled: process.env.AI_ASSIST_ENABLED === "true", aiProvider: process.env.AI_PROVIDER ?? "mock", aiBaseUrl: process.env.AI_BASE_URL ?? "", aiModel: process.env.AI_MODEL ?? "", aiTimeoutMs: Number(process.env.AI_TIMEOUT_MS ?? 10000), aiAuthToken: process.env.AI_AUTH_TOKEN ?? "", erpSyncEnabled: process.env.ERP_SYNC_ENABLED === "true", erpSyncFlagEnv: process.env.ERP_SYNC_ENABLED ?? "false", erpSyncProvider: process.env.ERP_SYNC_PROVIDER ?? "mock", erpSyncEndpoint: process.env.ERP_SYNC_ENDPOINT ?? "", erpJournalExportEndpoint: process.env.ERP_JOURNAL_EXPORT_ENDPOINT ?? "", erpProcurementExportEndpoint: process.env.ERP_PROCUREMENT_EXPORT_ENDPOINT ?? "", erpAssetExportEndpoint: process.env.ERP_ASSET_EXPORT_ENDPOINT ?? "", erpMasterDataEndpoint: process.env.ERP_MASTER_DATA_ENDPOINT ?? "", erpSyncTimeoutMs: Number(process.env.ERP_SYNC_TIMEOUT_MS ?? 5000), erpProcurementTimeoutMs: Number( process.env.ERP_PROCUREMENT_TIMEOUT_MS ?? process.env.ERP_SYNC_TIMEOUT_MS ?? 5000, ), erpAssetTimeoutMs: Number( process.env.ERP_ASSET_TIMEOUT_MS ?? process.env.ERP_SYNC_TIMEOUT_MS ?? 5000, ), erpMasterDataTimeoutMs: Number( process.env.ERP_MASTER_DATA_TIMEOUT_MS ?? process.env.ERP_SYNC_TIMEOUT_MS ?? 5000, ), erpMasterDataSyncCron: process.env.ERP_MASTER_DATA_SYNC_CRON ?? "", erpSyncAuthToken: process.env.ERP_SYNC_AUTH_TOKEN ?? "", travelSyncEnabled: process.env.TRAVEL_SYNC_ENABLED === "true", imNotifierEnabled: process.env.IM_NOTIFIER_ENABLED === "true", integrationOutboxCron: process.env.INTEGRATION_OUTBOX_CRON ?? "* * * * *", integrationOutboxBatchSize: Number(process.env.INTEGRATION_OUTBOX_BATCH_SIZE ?? 20), integrationOutboxMaxAttempts: Number(process.env.INTEGRATION_OUTBOX_MAX_ATTEMPTS ?? 5), integrationOutboxRetrySeconds: Number(process.env.INTEGRATION_OUTBOX_RETRY_SECONDS ?? 120), invoiceArchiveCron: process.env.INVOICE_ARCHIVE_CRON ?? "30 2 * * *", invoiceArchiveUnusedDays: Number(process.env.INVOICE_ARCHIVE_UNUSED_DAYS ?? 90), invoiceArchiveBatchSize: Number(process.env.INVOICE_ARCHIVE_BATCH_SIZE ?? 200), tencentSecretId: process.env.TENCENT_SECRET_ID ?? "", tencentSecretKey: process.env.TENCENT_SECRET_KEY ?? "", tencentOcrRegion: process.env.TENCENT_OCR_REGION ?? "ap-beijing", emailEnabled: process.env.EMAIL_ENABLED === "true", emailFrom: process.env.EMAIL_FROM ?? "no-reply@hyinda.com", emailBaseUrl: process.env.EMAIL_BASE_URL ?? "", smtpHost: process.env.SMTP_HOST ?? "", smtpPort: Number(process.env.SMTP_PORT ?? 587), smtpSecure: process.env.SMTP_SECURE === "true", smtpUser: process.env.SMTP_USER ?? "", smtpPass: process.env.SMTP_PASS ?? "", otelTracesEnabled: process.env.OTEL_TRACES_ENABLED === "true", }; } module.exports = { getConfig, parseBoundedInteger };