/* * Copyright (C) 2026 Fluxer Contributors * * This file is part of Fluxer. * * Fluxer is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Fluxer is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Fluxer. If not, see . */ import process from 'node:process'; import {z} from '~/Schema'; function required(key: string): string { const value = process.env[key]; if (!value) { throw new Error(`Missing required environment variable: ${key}`); } return value; } function optional(key: string): string | undefined { return process.env[key] || undefined; } function optionalInt(key: string, defaultValue: number): number { const value = process.env[key]; if (!value) return defaultValue; const parsed = Number.parseInt(value, 10); return Number.isNaN(parsed) ? defaultValue : parsed; } function optionalBool(key: string, defaultValue = false): boolean { const value = process.env[key]; if (!value) return defaultValue; return value.toLowerCase() === 'true' || value === '1'; } function extractHostname(url: string): string { try { return new URL(url).hostname; } catch { throw new Error(`Invalid URL: ${url}`); } } function trimTrailingSlash(value: string): string { if (value.length > 1 && value.endsWith('/')) { return trimTrailingSlash(value.slice(0, -1)); } return value; } function normalizePath(path: string): string { const trimmed = path.trim(); if (trimmed === '' || trimmed === '/') return ''; const withLeadingSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`; return trimTrailingSlash(withLeadingSlash); } function appendPath(endpoint: string, path: string): string { const cleanEndpoint = trimTrailingSlash(endpoint); const normalizedPath = normalizePath(path); return normalizedPath ? `${cleanEndpoint}${normalizedPath}` : cleanEndpoint; } function parseCommaSeparated(value: string): Array { return value .split(',') .map((item) => item.trim()) .filter((item) => item.length > 0); } const ConfigSchema = z.object({ nodeEnv: z.enum(['development', 'production']), port: z.number(), postgres: z.object({ url: z.string(), }), cassandra: z.object({ hosts: z.string(), keyspace: z.string(), localDc: z.string(), username: z.string(), password: z.string(), }), redis: z.object({ url: z.string(), }), gateway: z.object({ rpcHost: z.string(), rpcPort: z.number(), rpcSecret: z.string(), }), mediaProxy: z.object({ host: z.string(), port: z.number(), secretKey: z.string(), }), geoip: z.object({ maxmindDbPath: z.string().optional(), }), endpoints: z.object({ apiPublic: z.string(), apiClient: z.string(), webApp: z.string(), gateway: z.string(), media: z.string(), cdn: z.string(), marketing: z.string(), admin: z.string(), invite: z.string(), gift: z.string(), }), hosts: z.object({ invite: z.string(), gift: z.string(), marketing: z.string(), unfurlIgnored: z.array(z.string()), }), s3: z.object({ endpoint: z.string(), accessKeyId: z.string(), secretAccessKey: z.string(), buckets: z.object({ cdn: z.string(), uploads: z.string(), reports: z.string(), harvests: z.string(), downloads: z.string(), }), }), email: z.object({ enabled: z.boolean(), apiKey: z.string().optional(), webhookPublicKey: z.string().optional(), fromEmail: z.string(), fromName: z.string(), }), sms: z.object({ enabled: z.boolean(), accountSid: z.string().optional(), authToken: z.string().optional(), verifyServiceSid: z.string().optional(), }), captcha: z.object({ enabled: z.boolean(), provider: z.enum(['hcaptcha', 'turnstile', 'none']), hcaptcha: z .object({ siteKey: z.string(), secretKey: z.string(), }) .optional(), turnstile: z .object({ siteKey: z.string(), secretKey: z.string(), }) .optional(), }), voice: z.object({ enabled: z.boolean(), apiKey: z.string().optional(), apiSecret: z.string().optional(), webhookUrl: z.string().optional(), url: z.string().optional(), autoCreateDummyData: z.boolean(), }), search: z.object({ enabled: z.boolean(), url: z.string().optional(), apiKey: z.string().optional(), }), stripe: z.object({ enabled: z.boolean(), secretKey: z.string().optional(), webhookSecret: z.string().optional(), prices: z .object({ monthlyUsd: z.string().optional(), monthlyEur: z.string().optional(), yearlyUsd: z.string().optional(), yearlyEur: z.string().optional(), visionaryUsd: z.string().optional(), visionaryEur: z.string().optional(), giftVisionaryUsd: z.string().optional(), giftVisionaryEur: z.string().optional(), gift1MonthUsd: z.string().optional(), gift1MonthEur: z.string().optional(), gift1YearUsd: z.string().optional(), gift1YearEur: z.string().optional(), }) .optional(), }), cloudflare: z.object({ purgeEnabled: z.boolean(), zoneId: z.string().optional(), apiToken: z.string().optional(), }), alerts: z.object({ webhookUrl: z.string().url().optional(), }), clamav: z.object({ enabled: z.boolean(), host: z.string(), port: z.number(), failOpen: z.boolean(), }), adminOauth2: z.object({ clientId: z.string().optional(), clientSecret: z.string().optional(), redirectUri: z.string(), autoCreate: z.boolean(), }), auth: z.object({ sudoModeSecret: z.string(), passkeys: z.object({ rpName: z.string(), rpId: z.string(), allowedOrigins: z.array(z.string()), }), }), cookie: z.object({ domain: z.string(), secure: z.boolean(), }), tenor: z.object({ apiKey: z.string().optional(), }), youtube: z.object({ apiKey: z.string().optional(), }), instance: z.object({ selfHosted: z.boolean(), autoJoinInviteCode: z.string().optional(), visionariesGuildId: z.string().optional(), operatorsGuildId: z.string().optional(), }), dev: z.object({ relaxRegistrationRateLimits: z.boolean(), disableRateLimits: z.boolean(), testModeEnabled: z.boolean(), testHarnessToken: z.string().optional(), }), attachmentDecayEnabled: z.boolean(), deletionGracePeriodHours: z.number(), inactivityDeletionThresholdDays: z.number().optional(), push: z.object({ publicVapidKey: z.string().optional(), }), metrics: z.object({ host: z.string().optional(), }), }); function loadConfig() { const apiPublicEndpoint = required('FLUXER_API_PUBLIC_ENDPOINT'); const apiClientEndpoint = optional('FLUXER_API_CLIENT_ENDPOINT') || apiPublicEndpoint; const webAppEndpoint = required('FLUXER_APP_ENDPOINT'); const gatewayEndpoint = required('FLUXER_GATEWAY_ENDPOINT'); const mediaEndpoint = required('FLUXER_MEDIA_ENDPOINT'); const cdnEndpoint = required('FLUXER_CDN_ENDPOINT'); const marketingEndpoint = appendPath(required('FLUXER_MARKETING_ENDPOINT'), required('FLUXER_PATH_MARKETING')); const adminEndpoint = appendPath(required('FLUXER_ADMIN_ENDPOINT'), required('FLUXER_PATH_ADMIN')); const inviteEndpoint = required('FLUXER_INVITE_ENDPOINT'); const giftEndpoint = required('FLUXER_GIFT_ENDPOINT'); const passkeyOriginsEnv = optional('PASSKEY_ALLOWED_ORIGINS'); const passkeyAllowedOrigins = passkeyOriginsEnv ? parseCommaSeparated(passkeyOriginsEnv) : Array.from(new Set([apiPublicEndpoint, webAppEndpoint, apiClientEndpoint])); const testModeEnabled = optionalBool('FLUXER_TEST_MODE'); const maxmindDbPath = optional('MAXMIND_DB_PATH'); return ConfigSchema.parse({ nodeEnv: optional('NODE_ENV') || 'development', port: optionalInt('FLUXER_API_PORT', 8080), postgres: { url: required('DATABASE_URL'), }, cassandra: { hosts: required('CASSANDRA_HOSTS'), keyspace: required('CASSANDRA_KEYSPACE'), localDc: optional('CASSANDRA_LOCAL_DC'), username: required('CASSANDRA_USERNAME'), password: required('CASSANDRA_PASSWORD'), }, redis: { url: required('REDIS_URL'), }, gateway: { rpcHost: optional('FLUXER_GATEWAY_RPC_HOST') || 'gateway', rpcPort: optionalInt('FLUXER_GATEWAY_RPC_PORT', 8081), rpcSecret: required('GATEWAY_RPC_SECRET'), }, mediaProxy: { host: optional('FLUXER_MEDIA_PROXY_HOST') || 'media', port: optionalInt('FLUXER_MEDIA_PROXY_PORT', 8080), secretKey: required('MEDIA_PROXY_SECRET_KEY'), }, geoip: { maxmindDbPath, }, endpoints: { apiPublic: apiPublicEndpoint, apiClient: apiClientEndpoint, webApp: webAppEndpoint, gateway: gatewayEndpoint, media: mediaEndpoint, cdn: cdnEndpoint, marketing: marketingEndpoint, admin: adminEndpoint, invite: inviteEndpoint, gift: giftEndpoint, }, hosts: { invite: extractHostname(inviteEndpoint), gift: extractHostname(giftEndpoint), marketing: extractHostname(marketingEndpoint), unfurlIgnored: parseCommaSeparated(optional('FLUXER_UNFURL_IGNORED_HOSTS') || '').concat([ 'web.fluxer.app', 'web.canary.fluxer.app', ]), }, s3: { endpoint: required('AWS_S3_ENDPOINT'), accessKeyId: required('AWS_ACCESS_KEY_ID'), secretAccessKey: required('AWS_SECRET_ACCESS_KEY'), buckets: { cdn: required('AWS_S3_BUCKET_CDN'), uploads: required('AWS_S3_BUCKET_UPLOADS'), reports: required('AWS_S3_BUCKET_REPORTS'), harvests: required('AWS_S3_BUCKET_HARVESTS'), downloads: required('AWS_S3_BUCKET_DOWNLOADS'), }, }, email: { enabled: optionalBool('EMAIL_ENABLED'), apiKey: optional('SENDGRID_API_KEY'), webhookPublicKey: optional('SENDGRID_WEBHOOK_PUBLIC_KEY'), fromEmail: optional('SENDGRID_FROM_EMAIL') || 'noreply@fluxer.app', fromName: optional('SENDGRID_FROM_NAME') || 'Fluxer', }, sms: { enabled: optionalBool('SMS_ENABLED'), accountSid: optional('TWILIO_ACCOUNT_SID'), authToken: optional('TWILIO_AUTH_TOKEN'), verifyServiceSid: optional('TWILIO_VERIFY_SERVICE_SID'), }, captcha: { enabled: optionalBool('CAPTCHA_ENABLED'), provider: (optional('CAPTCHA_PRIMARY_PROVIDER') as 'hcaptcha' | 'turnstile' | 'none') || 'none', hcaptcha: optional('HCAPTCHA_SITE_KEY') && optional('HCAPTCHA_SECRET_KEY') ? { siteKey: required('HCAPTCHA_SITE_KEY'), secretKey: required('HCAPTCHA_SECRET_KEY'), } : undefined, turnstile: optional('TURNSTILE_SITE_KEY') && optional('TURNSTILE_SECRET_KEY') ? { siteKey: required('TURNSTILE_SITE_KEY'), secretKey: required('TURNSTILE_SECRET_KEY'), } : undefined, }, voice: { enabled: optionalBool('VOICE_ENABLED'), apiKey: optional('LIVEKIT_API_KEY'), apiSecret: optional('LIVEKIT_API_SECRET'), webhookUrl: optional('LIVEKIT_WEBHOOK_URL'), url: optional('LIVEKIT_URL'), autoCreateDummyData: optionalBool('LIVEKIT_AUTO_CREATE_DUMMY_DATA'), }, search: { enabled: optionalBool('SEARCH_ENABLED'), url: optional('MEILISEARCH_URL'), apiKey: optional('MEILISEARCH_API_KEY'), }, stripe: { enabled: optionalBool('STRIPE_ENABLED'), secretKey: optional('STRIPE_SECRET_KEY'), webhookSecret: optional('STRIPE_WEBHOOK_SECRET'), prices: optionalBool('STRIPE_ENABLED') ? { monthlyUsd: optional('STRIPE_PRICE_ID_MONTHLY_USD'), monthlyEur: optional('STRIPE_PRICE_ID_MONTHLY_EUR'), yearlyUsd: optional('STRIPE_PRICE_ID_YEARLY_USD'), yearlyEur: optional('STRIPE_PRICE_ID_YEARLY_EUR'), visionaryUsd: optional('STRIPE_PRICE_ID_VISIONARY_USD'), visionaryEur: optional('STRIPE_PRICE_ID_VISIONARY_EUR'), giftVisionaryUsd: optional('STRIPE_PRICE_ID_GIFT_VISIONARY_USD'), giftVisionaryEur: optional('STRIPE_PRICE_ID_GIFT_VISIONARY_EUR'), gift1MonthUsd: optional('STRIPE_PRICE_ID_GIFT_1_MONTH_USD'), gift1MonthEur: optional('STRIPE_PRICE_ID_GIFT_1_MONTH_EUR'), gift1YearUsd: optional('STRIPE_PRICE_ID_GIFT_1_YEAR_USD'), gift1YearEur: optional('STRIPE_PRICE_ID_GIFT_1_YEAR_EUR'), } : undefined, }, cloudflare: { purgeEnabled: optionalBool('CLOUDFLARE_PURGE_ENABLED'), zoneId: optional('CLOUDFLARE_ZONE_ID'), apiToken: optional('CLOUDFLARE_API_TOKEN'), }, alerts: { webhookUrl: optional('ALERT_WEBHOOK_URL'), }, clamav: { enabled: optionalBool('CLAMAV_ENABLED'), host: optional('CLAMAV_HOST') || 'clamav', port: optionalInt('CLAMAV_PORT', 3310), failOpen: optionalBool('CLAMAV_FAIL_OPEN', true), }, adminOauth2: { clientId: optional('ADMIN_OAUTH2_CLIENT_ID'), clientSecret: optional('ADMIN_OAUTH2_CLIENT_SECRET'), redirectUri: `${adminEndpoint}/oauth2_callback`, autoCreate: optionalBool('ADMIN_OAUTH2_AUTO_CREATE'), }, auth: { sudoModeSecret: required('SUDO_MODE_SECRET'), passkeys: { rpName: optional('PASSKEY_RP_NAME') || 'Fluxer', rpId: optional('PASSKEY_RP_ID') || extractHostname(webAppEndpoint), allowedOrigins: passkeyAllowedOrigins, }, }, cookie: { domain: optional('FLUXER_COOKIE_DOMAIN') || '', secure: optionalBool('FLUXER_COOKIE_SECURE', true), }, tenor: { apiKey: optional('TENOR_API_KEY'), }, youtube: { apiKey: optional('YOUTUBE_API_KEY'), }, instance: { selfHosted: optionalBool('SELF_HOSTED'), autoJoinInviteCode: optional('AUTO_JOIN_INVITE_CODE'), visionariesGuildId: optional('FLUXER_VISIONARIES_GUILD_ID'), operatorsGuildId: optional('FLUXER_OPERATORS_GUILD_ID'), }, dev: { relaxRegistrationRateLimits: optionalBool('RELAX_REGISTRATION_RATE_LIMITS'), disableRateLimits: optionalBool('DISABLE_RATE_LIMITS'), testModeEnabled, testHarnessToken: optional('FLUXER_TEST_TOKEN'), }, attachmentDecayEnabled: optionalBool('ATTACHMENT_DECAY_ENABLED', true), deletionGracePeriodHours: testModeEnabled ? 0.01 : 336, inactivityDeletionThresholdDays: optionalInt('INACTIVITY_DELETION_THRESHOLD_DAYS', 365 * 2), push: { publicVapidKey: optional('VAPID_PUBLIC_KEY'), }, metrics: { host: optional('FLUXER_METRICS_HOST'), }, }); } export const Config = loadConfig(); export type Config = z.infer;