1
8import { z } from "zod";
9
10const intFrom = (def: number) =>
11 z.coerce
12 .number()
13 .int()
14 .positive()
15 .default(def);
16
17const boolFrom = (def: boolean) =>
18 z
19 .enum(["true", "false"])
20 .default(def ? "true" : "false")
21 .transform((v) => v === "true");
22
23
27export const envSchema = z.object({
28 NODE_ENV: z.string().default("development"),
29 REDIS_URL: z.string().url().default("redis://localhost:6379"),
30 DATABASE_URL: z
31 .string()
32 .default("postgresql://factory:factory@localhost:5432/factory"),
33
34 S3_ENDPOINT: z.string().url().default("http://localhost:9000"),
35 S3_REGION: z.string().default("us-east-1"),
36
38 S3_BUCKET_FILES: z.string().default("factory-files"),
39 S3_BUCKET_THUMBS: z.string().default("factory-thumbs"),
40 S3_BUCKET: z.string().default("factory-files"),
41 S3_ACCESS_KEY: z.string().default("minioadmin"),
42 S3_SECRET_KEY: z.string().default("minioadmin"),
43 S3_PUBLIC_BASE: z.string().default("http://localhost:9001"),
44
45 SEARCH_URL: z.string().url().default("http://localhost:9200"),
46 SEARCH_USERNAME: z.string().default("admin"),
47 SEARCH_PASSWORD: z.string().default("changeme"),
48
49
50
51
52 SEARCH_VECTOR_DIMENSION: intFrom(1536),
53 SEARCH_KNN_M: intFrom(16),
54 SEARCH_KNN_EF_CONSTRUCTION: intFrom(100),
55
56 MASTER_ENCRYPTION_KEY: z.string().default(""),
57
58
61 WEBHOOK_ALLOW_PRIVATE: boolFrom(false),
62
63
67 WEBHOOK_BACKOFF_BASE_MS: intFrom(30_000),
68 WEBHOOK_BACKOFF_MAX_MS: intFrom(900_000),
69
70 AI_API_KEY: z.string().default(""),
71 AI_PROVIDER: z.string().default("openai"),
72
73 AI_BASE_URL: z.string().default(""),
74 AI_MODEL: z.string().default("gpt-4o-mini"),
75
76
77
78
79
80
81 AI_EMBEDDING_PROVIDER: z.string().default(""),
82 AI_EMBEDDING_MODEL: z.string().default("text-embedding-3-small"),
83
84 SMTP_HOST: z.string().default(""),
85 SMTP_PORT: intFrom(587),
86 SMTP_USER: z.string().default(""),
87 SMTP_PASS: z.string().default(""),
88 SMTP_FROM: z.string().default("noreply@factory.local"),
89 SMTP_STARTTLS: boolFrom(true),
90
91 CLAMAV_HOST: z.string().default(""),
92 CLAMAV_PORT: intFrom(3310),
93
94
95
96 CLAMD_SOCKET: z.string().default(""),
97
98
99 FILES_WORKER_CONCURRENCY: intFrom(4),
100
101
102 CLAMAV_TIMEOUT_MS: intFrom(120_000),
103 FILES_TEMP_DIR: z.string().default(""),
104 FILES_TEMP_MAX_AGE_MS: intFrom(600_000),
105 FILES_TEMP_FREE_MIN_BYTES: intFrom(512 * 1024 * 1024),
106
107
108
109
110
111 PUBLIC_URL: z.string().url().default("http://localhost:3000"),
112
113 WORKER_CONCURRENCY: intFrom(10),
114
115
116 AUTOMATIONS_WORKER_CONCURRENCY: intFrom(4),
117
118
119 AUTO_RUN_MAX_RETRIES: intFrom(8),
120 WORKER_ID: z.string().default("worker-1"),
121 WORKER_RETENTION_EXECUTE: boolFrom(false),
122
123 HEALTH_PORT: intFrom(4003)
124});
125
126export type WorkerEnv = z.infer<typeof envSchema>;
127
128let cached: WorkerEnv | null = null;
129
130
134export function loadEnv(
135 source: Record<string, string | undefined> = process.env
136): WorkerEnv {
137 if (cached) return cached;
138 const parsed = envSchema.safeParse(source);
139 if (!parsed.success) {
140 throw new Error(
141 `Invalid worker environment: ${parsed.error.issues
142 .map((i) => `${i.path.join(".")}: ${i.message}`)
143 .join("; ")}`
144 );
145 }
146 cached = Object.freeze(parsed.data);
147 return cached;
148}
149
150
151export function resetEnvCache(): void {
152 cached = null;
153}
154