import { readFileSync, existsSync } from "node:fs"; import { randomBytes } from "node:crypto"; import { resolve } from "node:path"; import type { PolicySet } from "./policy/types.js"; export interface Config { /** Social account DID (optional — omit for replication-only node). */ DID?: string; /** Social account handle (optional). */ HANDLE?: string; PDS_HOSTNAME?: string; AUTH_TOKEN: string; /** Social account signing key hex (optional). */ SIGNING_KEY?: string; /** Social account signing key public multibase (optional). */ SIGNING_KEY_PUBLIC?: string; JWT_SECRET?: string; PASSWORD_HASH?: string; EMAIL?: string; DATA_DIR: string; PORT: number; IPFS_ENABLED: boolean; IPFS_NETWORKING: boolean; REPLICATE_DIDS: string[]; POLICY_FILE?: string; FIREHOSE_URL: string; FIREHOSE_ENABLED: boolean; /** Whether rate limiting is enabled (default true). */ RATE_LIMIT_ENABLED: boolean; /** Per-pool rate limit overrides (requests per minute). */ RATE_LIMIT_READ_PER_MIN: number; RATE_LIMIT_SYNC_PER_MIN: number; RATE_LIMIT_SESSION_PER_MIN: number; RATE_LIMIT_WRITE_PER_MIN: number; RATE_LIMIT_CHALLENGE_PER_MIN: number; RATE_LIMIT_MAX_CONNECTIONS: number; RATE_LIMIT_FIREHOSE_PER_IP: number; /** Whether OAuth login is enabled for remote PDS publishing (default true). */ OAUTH_ENABLED: boolean; /** Public URL of this p2pds instance, used for push notifications between nodes. */ PUBLIC_URL: string; } /** Required when OAuth is disabled (legacy mode). With OAuth, identity comes from login. */ const LEGACY_REQUIRED_KEYS = [ "PDS_HOSTNAME", "AUTH_TOKEN", "JWT_SECRET", "PASSWORD_HASH", ] as const; /** * Load a .env file into process.env (simple key=value parser). * Skips comments and empty lines. */ function loadDotEnv(path: string): void { let content: string; try { content = readFileSync(path, "utf-8"); } catch { return; // .env file is optional } for (const line of content.split("\n")) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("#")) continue; const eqIdx = trimmed.indexOf("="); if (eqIdx === -1) continue; const key = trimmed.slice(0, eqIdx).trim(); let value = trimmed.slice(eqIdx + 1).trim(); // Strip surrounding quotes if ( (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")) ) { value = value.slice(1, -1); } if (!process.env[key]) { process.env[key] = value; } } } /** * Load and validate configuration from environment variables. * Optionally loads a .env file first. * * Social account fields (DID, HANDLE, SIGNING_KEY, SIGNING_KEY_PUBLIC) are optional. * When omitted, the node runs as replication-only. */ export function loadConfig(envPath?: string): Config { // Load .env file if it exists const dotenvPath = envPath ?? process.env.DOTENV_PATH ?? resolve(process.cwd(), ".env"); loadDotEnv(dotenvPath); // Validate required variables (legacy auth fields only required without OAuth) const oauthEnabled = process.env.OAUTH_ENABLED !== "false"; if (!oauthEnabled) { const missing: string[] = []; for (const key of LEGACY_REQUIRED_KEYS) { if (!process.env[key]) { missing.push(key); } } if (missing.length > 0) { throw new Error( `Missing required environment variables: ${missing.join(", ")}`, ); } } const pdsHostname = process.env.PDS_HOSTNAME || undefined; return { DID: process.env.DID || undefined, HANDLE: process.env.HANDLE || undefined, PDS_HOSTNAME: pdsHostname, AUTH_TOKEN: process.env.AUTH_TOKEN || randomBytes(32).toString("hex"), SIGNING_KEY: process.env.SIGNING_KEY || undefined, SIGNING_KEY_PUBLIC: process.env.SIGNING_KEY_PUBLIC || undefined, JWT_SECRET: process.env.JWT_SECRET || undefined, PASSWORD_HASH: process.env.PASSWORD_HASH || undefined, EMAIL: process.env.EMAIL, DATA_DIR: process.env.DATA_DIR ?? "./data", PORT: parseInt(process.env.PORT ?? "3000", 10), IPFS_ENABLED: process.env.IPFS_ENABLED !== "false", IPFS_NETWORKING: process.env.IPFS_NETWORKING !== "false", REPLICATE_DIDS: (process.env.REPLICATE_DIDS ?? "").split(",").map(s => s.trim()).filter(Boolean), POLICY_FILE: process.env.POLICY_FILE || undefined, FIREHOSE_URL: process.env.FIREHOSE_URL ?? "wss://bsky.network/xrpc/com.atproto.sync.subscribeRepos", FIREHOSE_ENABLED: process.env.FIREHOSE_ENABLED !== "false", RATE_LIMIT_ENABLED: process.env.RATE_LIMIT_ENABLED !== "false", RATE_LIMIT_READ_PER_MIN: parseInt(process.env.RATE_LIMIT_READ_PER_MIN ?? "300", 10), RATE_LIMIT_SYNC_PER_MIN: parseInt(process.env.RATE_LIMIT_SYNC_PER_MIN ?? "30", 10), RATE_LIMIT_SESSION_PER_MIN: parseInt(process.env.RATE_LIMIT_SESSION_PER_MIN ?? "10", 10), RATE_LIMIT_WRITE_PER_MIN: parseInt(process.env.RATE_LIMIT_WRITE_PER_MIN ?? "200", 10), RATE_LIMIT_CHALLENGE_PER_MIN: parseInt(process.env.RATE_LIMIT_CHALLENGE_PER_MIN ?? "20", 10), RATE_LIMIT_MAX_CONNECTIONS: parseInt(process.env.RATE_LIMIT_MAX_CONNECTIONS ?? "100", 10), RATE_LIMIT_FIREHOSE_PER_IP: parseInt(process.env.RATE_LIMIT_FIREHOSE_PER_IP ?? "3", 10), OAUTH_ENABLED: process.env.OAUTH_ENABLED !== "false", PUBLIC_URL: process.env.PUBLIC_URL || `http://localhost:${parseInt(process.env.PORT ?? "3000", 10)}`, }; } /** * Load policies from a JSON file. Returns null if no file is configured or found. * The file should contain a PolicySet JSON object. */ export function loadPolicies(config: Config): PolicySet | null { const policyPath = config.POLICY_FILE; if (!policyPath) return null; const resolved = resolve(policyPath); if (!existsSync(resolved)) { console.warn(`Policy file not found: ${resolved}`); return null; } try { const content = readFileSync(resolved, "utf-8"); const parsed = JSON.parse(content) as PolicySet; if (parsed.version !== 1) { throw new Error(`Unsupported policy version: ${parsed.version}`); } if (!Array.isArray(parsed.policies)) { throw new Error("Policy file must contain a 'policies' array"); } return parsed; } catch (err) { const message = err instanceof Error ? err.message : String(err); throw new Error(`Failed to load policy file ${resolved}: ${message}`); } }