import type { LogLevel } from "@atbb/logger"; export interface AppConfig { port: number; forumDid: string; pdsUrl: string; databaseUrl: string; jetstreamUrl: string; // Logging logLevel: LogLevel; // OAuth configuration oauthPublicUrl: string; sessionSecret: string; sessionTtlDays: number; redisUrl?: string; // Forum credentials (optional - for server-side PDS writes) forumHandle?: string; forumPassword?: string; // Backfill configuration backfillRateLimit: number; backfillConcurrency: number; backfillCursorMaxAgeHours: number; } export function loadConfig(): AppConfig { const config: AppConfig = { port: parseInt(process.env.PORT ?? "3000", 10), forumDid: process.env.FORUM_DID ?? "", pdsUrl: process.env.PDS_URL ?? "https://bsky.social", databaseUrl: process.env.DATABASE_URL ?? "", jetstreamUrl: process.env.JETSTREAM_URL ?? "wss://jetstream2.us-east.bsky.network/subscribe", // Logging logLevel: parseLogLevel(process.env.LOG_LEVEL), // OAuth configuration oauthPublicUrl: process.env.OAUTH_PUBLIC_URL ?? `http://localhost:${process.env.PORT ?? "3000"}`, sessionSecret: process.env.SESSION_SECRET ?? "", sessionTtlDays: parseInt(process.env.SESSION_TTL_DAYS ?? "7", 10), redisUrl: process.env.REDIS_URL, // Forum credentials (optional - for server-side PDS writes) forumHandle: process.env.FORUM_HANDLE, forumPassword: process.env.FORUM_PASSWORD, // Backfill configuration backfillRateLimit: parseInt(process.env.BACKFILL_RATE_LIMIT ?? "10", 10), backfillConcurrency: parseInt(process.env.BACKFILL_CONCURRENCY ?? "10", 10), backfillCursorMaxAgeHours: parseInt(process.env.BACKFILL_CURSOR_MAX_AGE_HOURS ?? "48", 10), }; validateOAuthConfig(config); return config; } const LOG_LEVELS: readonly LogLevel[] = ["debug", "info", "warn", "error", "fatal"]; function parseLogLevel(value: string | undefined): LogLevel { if (value !== undefined && !(LOG_LEVELS as readonly string[]).includes(value)) { console.warn( `Invalid LOG_LEVEL "${value}". Must be one of: ${LOG_LEVELS.join(", ")}. Defaulting to "info".` ); } return (LOG_LEVELS as readonly string[]).includes(value ?? "") ? (value as LogLevel) : "info"; } /** * Validate OAuth-related configuration at startup. * Fails fast if required config is missing or invalid. */ function validateOAuthConfig(config: AppConfig): void { // Check environment-specific requirements first if (!process.env.OAUTH_PUBLIC_URL && process.env.NODE_ENV === 'production') { throw new Error('OAUTH_PUBLIC_URL is required in production'); } // Validate OAuth public URL format const url = new URL(config.oauthPublicUrl); // AT Proto OAuth requires HTTPS in production (or proper domain in dev) if (process.env.NODE_ENV === 'production' && url.protocol !== 'https:') { throw new Error( 'OAUTH_PUBLIC_URL must use HTTPS in production. Your OAuth client_id must be publicly accessible over HTTPS.' ); } // Warn about localhost usage (OAuth client will reject this) if (url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname.endsWith('.local')) { console.warn( '\n⚠️ WARNING: AT Protocol OAuth requires a publicly accessible HTTPS URL.\n' + 'Local development URLs (localhost, 127.0.0.1) will not work for OAuth.\n\n' + 'Options for local development:\n' + ' 1. Use ngrok or similar tunneling service: OAUTH_PUBLIC_URL=https://abc123.ngrok.io\n' + ' 2. Use a local domain with mkcert for HTTPS: OAUTH_PUBLIC_URL=https://atbb.local\n' + ' 3. Deploy to a staging environment with proper HTTPS\n\n' + 'See https://atproto.com/specs/oauth for details.\n' ); } // Then check global requirements if (!config.sessionSecret || config.sessionSecret.length < 32) { throw new Error( 'SESSION_SECRET must be at least 32 characters. Generate one with: openssl rand -hex 32' ); } // Warn about in-memory sessions in production if (!config.redisUrl && process.env.NODE_ENV === 'production') { console.warn( '⚠️ Using in-memory session storage in production. Sessions will be lost on restart.' ); } }