/** * apps/worker — environment configuration (zod-validated, dev-safe defaults). * * `loadEnv()` reads `process.env` once and returns a frozen, fully-typed * config object. Every processor takes this object (dependency injection) so * tests can override hosts/keys without touching the environment. */ import { z } from "zod"; const intFrom = (def: number) => z.coerce .number() .int() .positive() .default(def); const boolFrom = (def: boolean) => z .enum(["true", "false"]) .default(def ? "true" : "false") .transform((v) => v === "true"); /** * Environment contract. All values are strings by default (env is strings); * coercion happens here, nowhere else. */ export const envSchema = z.object({ NODE_ENV: z.string().default("development"), REDIS_URL: z.string().url().default("redis://localhost:6379"), DATABASE_URL: z .string() .default("postgresql://factory:factory@localhost:5432/factory"), S3_ENDPOINT: z.string().url().default("http://localhost:9000"), S3_REGION: z.string().default("us-east-1"), /** TASK-0101: the two private buckets (files + thumbs). `S3_BUCKET` is the * legacy alias for the files bucket (kept for older .env files). */ S3_BUCKET_FILES: z.string().default("factory-files"), S3_BUCKET_THUMBS: z.string().default("factory-thumbs"), S3_BUCKET: z.string().default("factory-files"), S3_ACCESS_KEY: z.string().default("minioadmin"), S3_SECRET_KEY: z.string().default("minioadmin"), S3_PUBLIC_BASE: z.string().default("http://localhost:9001"), SEARCH_URL: z.string().url().default("http://localhost:9200"), SEARCH_USERNAME: z.string().default("admin"), SEARCH_PASSWORD: z.string().default("changeme"), // The content index's dense_vector dimension (search §0: a parameter, not a // constant — `dims` is immutable per index; a change rolls a new index via // the alias, TASK-0099 `scripts/opensearch-reindex.ts`). 1536 = // text-embedding-3-small. HNSW construction tuning (search §3). SEARCH_VECTOR_DIMENSION: intFrom(1536), SEARCH_KNN_M: intFrom(16), SEARCH_KNN_EF_CONSTRUCTION: intFrom(100), MASTER_ENCRYPTION_KEY: z.string().default(""), /** TASK-0130: allow webhook delivery URLs targeting private/loopback/ * link-local addresses (dev/testing override; production must stay false). * When false, the shared SSRF guard (assertPublicUrl*) is enforced. */ WEBHOOK_ALLOW_PRIVATE: boolFrom(false), /** TASK-0130 (C-13.8d): webhook retry backoff — the task default is * 30 s base × 2 per attempt, capped at 15 min, with ±20 % jitter * (packages/shared `webhookBackoffMs`). Overridable per deployment; the * integration/E2E suites set short values so retries stay visible fast. */ WEBHOOK_BACKOFF_BASE_MS: intFrom(30_000), WEBHOOK_BACKOFF_MAX_MS: intFrom(900_000), AI_API_KEY: z.string().default(""), AI_PROVIDER: z.string().default("openai"), // Empty = default OpenAI endpoint (see ai.processor fallback). AI_BASE_URL: z.string().default(""), AI_MODEL: z.string().default("gpt-4o-mini"), // Embedding provider for the `embed:chunks` job (TASK-0099 §4, Phase-12 // stub). Empty (the default) ⇒ no real provider is wired: the job logs // "embedding disabled (Phase 12)" and skips (no AiEmbedding row, no index // write, the chunk docs' `vector` stays null). Set to a provider name to // record placeholder AiEmbedding cache rows (Phase 12 turns this into a // real provider call). AI_EMBEDDING_MODEL names the embedding model. AI_EMBEDDING_PROVIDER: z.string().default(""), AI_EMBEDDING_MODEL: z.string().default("text-embedding-3-small"), SMTP_HOST: z.string().default(""), SMTP_PORT: intFrom(587), SMTP_USER: z.string().default(""), SMTP_PASS: z.string().default(""), SMTP_FROM: z.string().default("noreply@factory.local"), SMTP_STARTTLS: boolFrom(true), CLAMAV_HOST: z.string().default(""), CLAMAV_PORT: intFrom(3310), // TASK-0102 §3: UNIX ClamAV socket (e.g. /var/run/clamav/clamd.ctl). Empty // = TCP (CLAMAV_HOST/CLAMAV_PORT) or, if both are empty, dev mode // (scan skipped + WARN). REQUIRED in production (boot fails — fail closed). CLAMD_SOCKET: z.string().default(""), // TASK-0102 §7: files pipeline tuning. FILES_WORKER_CONCURRENCY: intFrom(4), // ClamAV scan timeout (ms) — large files take a while; a hung scan is a // RETRYABLE failure (BullMQ backoff), never a silent pass. CLAMAV_TIMEOUT_MS: intFrom(120_000), FILES_TEMP_DIR: z.string().default(""), // empty = os.tmpdir()/factory-files FILES_TEMP_MAX_AGE_MS: intFrom(600_000), // cleanup: delete older than 10 min FILES_TEMP_FREE_MIN_BYTES: intFrom(512 * 1024 * 1024), // disk guard // TASK-0094 R6: base URL for ABSOLUTE links in notification/digest emails // (e.g. https://factory.example.com). The API calls the same value // `WEB_ORIGIN` (recorded naming divergence); both default to the local web // origin so a fresh setup needs no configuration. PUBLIC_URL: z.string().url().default("http://localhost:3000"), WORKER_CONCURRENCY: intFrom(10), // TASK-0113 §1: automation run pipeline concurrency (run jobs + schedule // fires share this pool; separate from the general WORKER_CONCURRENCY). AUTOMATIONS_WORKER_CONCURRENCY: intFrom(4), // TASK-0113 §2: run-level retry cap (retryable errors). Dev-only override // — production stays at 8. AUTO_RUN_MAX_RETRIES: intFrom(8), WORKER_ID: z.string().default("worker-1"), WORKER_RETENTION_EXECUTE: boolFrom(false), // HTTP health port (TASK-0009 item 4): /health + /health/ready on 4003. HEALTH_PORT: intFrom(4003) }); export type WorkerEnv = z.infer; let cached: WorkerEnv | null = null; /** * Parse the environment once (cached). Pass an explicit object in tests. * Throws on invalid config — a worker must never start half-configured. */ export function loadEnv( source: Record = process.env ): WorkerEnv { if (cached) return cached; const parsed = envSchema.safeParse(source); if (!parsed.success) { throw new Error( `Invalid worker environment: ${parsed.error.issues .map((i) => `${i.path.join(".")}: ${i.message}`) .join("; ")}` ); } cached = Object.freeze(parsed.data); return cached; } /** Test helper: reset the cached env (loadEnv re-parses next call). */ export function resetEnvCache(): void { cached = null; }