/** * @factory/shared — environment configuration (zod-validated, dependency-free). * * Usage (API/worker/realtime all share this): * * ```ts * import { appEnvSchema, loadEnv, withDotEnv } from "@factory/shared/env"; * * const env = loadEnv(withDotEnv(process.env), appEnvSchema); * ``` * * - {@link loadEnv} validates a plain env object against a zod schema. It * never reads the filesystem itself. * - {@link withDotEnv} optionally merges a local `.env` file (parsed with a * small built-in parser — no `dotenv` dependency). Values already present in * the real environment always win over `.env` values. * - {@link appEnvSchema} mirrors `.env.example` and is the reference schema * each app validates its process env against (apps may extend it with * app-specific keys). */ import { existsSync, readFileSync } from "node:fs"; import { z } from "zod"; import { ApiError } from "./errors.js"; /** A plain environment mapping (e.g. `process.env`). */ export type Env = Record; /** * Parse + validate an environment object against a schema. * * @throws ApiError VALIDATION_ERROR with per-key details when invalid. */ export function loadEnv(env: Env, schema: z.ZodType): T { const result = schema.safeParse(env); if (!result.success) { throw new ApiError("VALIDATION_ERROR", { message: "Invalid environment configuration", details: result.error.issues.map((issue) => ({ key: issue.path.join(".") || "(root)", message: issue.message, })), }); } return result.data; } /** * Minimal `.env` parser: `KEY=VALUE` lines, `#` comments, optional single or * double quotes around values, blank lines ignored. Inline comments after an * unquoted value are stripped. Returns an empty object for missing/empty * content. Never throws on malformed lines (skips them). */ export function parseDotEnv(content: string): Env { const out: Env = {}; for (const rawLine of content.split(/\r?\n/)) { const line = rawLine.trim(); if (line === "" || line.startsWith("#")) continue; const eq = line.indexOf("="); if (eq <= 0) continue; const key = line.slice(0, eq).trim(); if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue; let value = line.slice(eq + 1).trim(); if ( (value.startsWith('"') && value.endsWith('"') && value.length >= 2) || (value.startsWith("'") && value.endsWith("'") && value.length >= 2) ) { value = value.slice(1, -1); } else { const hash = value.indexOf(" #"); if (hash >= 0) value = value.slice(0, hash).trim(); } out[key] = value; } return out; } /** * Load a `.env` file if it exists and merge it *underneath* the given env * (real environment variables always take precedence). Returns the merged * object; `env` defaults to `process.env`, `path` to `.env` in the CWD. * Missing or unreadable files are silently ignored (local dev convenience — * in containers the environment comes from the orchestrator). */ export function withDotEnv(env: Env = process.env, path = ".env"): Env { let fileEnv: Env = {}; try { if (existsSync(path)) { fileEnv = parseDotEnv(readFileSync(path, "utf8")); } } catch { fileEnv = {}; } return { ...fileEnv, ...env }; } // --------------------------------------------------------------------------- // Reference app schema (mirrors ROOT/.env.example) // --------------------------------------------------------------------------- const nonEmpty = z.string().min(1); /** * Reference schema for the whole platform (mirrors `.env.example`). All * fields have safe local-dev defaults so `loadEnv({}, appEnvSchema)` works * out of the box; production should override the secret-bearing keys. */ export const appEnvSchema = z.object({ NODE_ENV: z.enum(["development", "production", "test"]).default("development"), PORT: z.coerce.number().int().positive().default(4000), WEB_URL: z.string().url().default("http://localhost:3000"), API_URL: z.string().url().default("http://localhost:4000"), REALTIME_URL: z.string().default("ws://localhost:4001"), PUBLIC_BASE_URL: z.string().url().default("http://localhost:3000"), DATABASE_URL: nonEmpty.default( "postgresql://factory:factory@localhost:5432/factory?schema=public", ), REDIS_URL: nonEmpty.default("redis://localhost:6379"), JWT_SECRET: nonEmpty.default("change-me-to-a-long-random-string"), JWT_EXPIRES_IN: z.string().default("15m"), REFRESH_TOKEN_SECRET: nonEmpty.default("change-me-to-another-long-random-string"), REFRESH_TOKEN_TTL_DAYS: z.coerce.number().int().positive().default(30), SESSION_SECRET: nonEmpty.default("change-me-session-secret"), BCRYPT_ROUNDS: z.coerce.number().int().min(4).max(15).default(12), S3_ENDPOINT: nonEmpty.default("http://localhost:9000"), S3_ACCESS_KEY: nonEmpty.default("minioadmin"), S3_SECRET_KEY: nonEmpty.default("minioadmin"), S3_BUCKET: nonEmpty.default("factory-uploads"), S3_REGION: nonEmpty.default("us-east-1"), S3_FORCE_PATH_STYLE: z .string() .default("true") .transform((v) => v === "true" || v === "1"), SEARCH_URL: nonEmpty.default("http://localhost:9200"), SEARCH_USERNAME: z.string().optional(), SEARCH_PASSWORD: z.string().optional(), SEARCH_INDEX_PREFIX: nonEmpty.default("factory"), AI_PROVIDER: z.string().default("openai"), AI_BASE_URL: z.string().default("https://api.openai.com/v1"), AI_API_KEY: z.string().default(""), AI_MODEL: z.string().default("gpt-4o"), AI_EMBEDDING_MODEL: z.string().default("text-embedding-3-small"), SMTP_HOST: z.string().default("localhost"), SMTP_PORT: z.coerce.number().int().positive().default(587), SMTP_USER: z.string().default(""), SMTP_PASSWORD: z.string().default(""), SMTP_FROM: z.string().default("AI Software Factory "), REALTIME_PORT: z.coerce.number().int().positive().default(4001), REALTIME_HEARTBEAT_MS: z.coerce.number().int().positive().default(30_000), YJS_STORAGE_PREFIX: nonEmpty.default("yjs"), WEBHOOK_RETRY_MAX: z.coerce.number().int().min(0).default(8), WEBHOOK_TIMEOUT_MS: z.coerce.number().int().positive().default(10_000), RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(60_000), RATE_LIMIT_MAX: z.coerce.number().int().positive().default(300), OTEL_ENABLED: z .string() .default("false") .transform((v) => v === "true" || v === "1"), OTEL_EXPORTER_OTLP_ENDPOINT: z.string().default("http://localhost:4318"), MASTER_ENCRYPTION_KEY: z.string().default(""), }); export type AppEnv = z.infer;