330 行
15 KiB
JavaScript
330 行
15 KiB
JavaScript
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 reimbursementMaxAgeDays = parseBoundedInteger(
|
|
"REIMBURSEMENT_MAX_AGE_DAYS",
|
|
process.env.REIMBURSEMENT_MAX_AGE_DAYS ?? 365,
|
|
{ min: 180, max: 365 },
|
|
);
|
|
if (![180, 365].includes(reimbursementMaxAgeDays)) {
|
|
throw new Error("Invalid REIMBURSEMENT_MAX_AGE_DAYS");
|
|
}
|
|
|
|
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",
|
|
reimbursementMaxAgeDays,
|
|
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",
|
|
reimbursementInvoiceGuardEnabled: process.env.REIMBURSEMENT_INVOICE_GUARD_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",
|
|
wecomPcWebLoginEnabled: process.env.WECOM_PC_WEB_LOGIN_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 ?? "[email protected]",
|
|
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 };
|