1
21
22import { existsSync, readFileSync } from "node:fs";
23import { z } from "zod";
24
25import { ApiError } from "./errors.js";
26
27
28export type Env = Record<string, string | undefined>;
29
30
35export function loadEnv<T>(env: Env, schema: z.ZodType<T>): T {
36 const result = schema.safeParse(env);
37 if (!result.success) {
38 throw new ApiError("VALIDATION_ERROR", {
39 message: "Invalid environment configuration",
40 details: result.error.issues.map((issue) => ({
41 key: issue.path.join(".") || "(root)",
42 message: issue.message,
43 })),
44 });
45 }
46 return result.data;
47}
48
49
55export function parseDotEnv(content: string): Env {
56 const out: Env = {};
57 for (const rawLine of content.split(/\r?\n/)) {
58 const line = rawLine.trim();
59 if (line === "" || line.startsWith("#")) continue;
60 const eq = line.indexOf("=");
61 if (eq <= 0) continue;
62 const key = line.slice(0, eq).trim();
63 if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
64 let value = line.slice(eq + 1).trim();
65 if (
66 (value.startsWith('"') && value.endsWith('"') && value.length >= 2) ||
67 (value.startsWith("'") && value.endsWith("'") && value.length >= 2)
68 ) {
69 value = value.slice(1, -1);
70 } else {
71 const hash = value.indexOf(" #");
72 if (hash >= 0) value = value.slice(0, hash).trim();
73 }
74 out[key] = value;
75 }
76 return out;
77}
78
79
86export function withDotEnv(env: Env = process.env, path = ".env"): Env {
87 let fileEnv: Env = {};
88 try {
89 if (existsSync(path)) {
90 fileEnv = parseDotEnv(readFileSync(path, "utf8"));
91 }
92 } catch {
93 fileEnv = {};
94 }
95 return { ...fileEnv, ...env };
96}
97
98
99
100
101
102const nonEmpty = z.string().min(1);
103
104
109export const appEnvSchema = z.object({
110 NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
111 PORT: z.coerce.number().int().positive().default(4000),
112 WEB_URL: z.string().url().default("http://localhost:3000"),
113 API_URL: z.string().url().default("http://localhost:4000"),
114 REALTIME_URL: z.string().default("ws://localhost:4001"),
115 PUBLIC_BASE_URL: z.string().url().default("http://localhost:3000"),
116
117 DATABASE_URL: nonEmpty.default(
118 "postgresql://factory:factory@localhost:5432/factory?schema=public",
119 ),
120 REDIS_URL: nonEmpty.default("redis://localhost:6379"),
121
122 JWT_SECRET: nonEmpty.default("change-me-to-a-long-random-string"),
123 JWT_EXPIRES_IN: z.string().default("15m"),
124 REFRESH_TOKEN_SECRET: nonEmpty.default("change-me-to-another-long-random-string"),
125 REFRESH_TOKEN_TTL_DAYS: z.coerce.number().int().positive().default(30),
126 SESSION_SECRET: nonEmpty.default("change-me-session-secret"),
127 BCRYPT_ROUNDS: z.coerce.number().int().min(4).max(15).default(12),
128
129 S3_ENDPOINT: nonEmpty.default("http://localhost:9000"),
130 S3_ACCESS_KEY: nonEmpty.default("minioadmin"),
131 S3_SECRET_KEY: nonEmpty.default("minioadmin"),
132 S3_BUCKET: nonEmpty.default("factory-uploads"),
133 S3_REGION: nonEmpty.default("us-east-1"),
134 S3_FORCE_PATH_STYLE: z
135 .string()
136 .default("true")
137 .transform((v) => v === "true" || v === "1"),
138
139 SEARCH_URL: nonEmpty.default("http://localhost:9200"),
140 SEARCH_USERNAME: z.string().optional(),
141 SEARCH_PASSWORD: z.string().optional(),
142 SEARCH_INDEX_PREFIX: nonEmpty.default("factory"),
143
144 AI_PROVIDER: z.string().default("openai"),
145 AI_BASE_URL: z.string().default("https://api.openai.com/v1"),
146 AI_API_KEY: z.string().default(""),
147 AI_MODEL: z.string().default("gpt-4o"),
148 AI_EMBEDDING_MODEL: z.string().default("text-embedding-3-small"),
149
150 SMTP_HOST: z.string().default("localhost"),
151 SMTP_PORT: z.coerce.number().int().positive().default(587),
152 SMTP_USER: z.string().default(""),
153 SMTP_PASSWORD: z.string().default(""),
154 SMTP_FROM: z.string().default("AI Software Factory <no-reply@example.com>"),
155
156 REALTIME_PORT: z.coerce.number().int().positive().default(4001),
157 REALTIME_HEARTBEAT_MS: z.coerce.number().int().positive().default(30_000),
158 YJS_STORAGE_PREFIX: nonEmpty.default("yjs"),
159
160 WEBHOOK_RETRY_MAX: z.coerce.number().int().min(0).default(8),
161 WEBHOOK_TIMEOUT_MS: z.coerce.number().int().positive().default(10_000),
162
163 RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(60_000),
164 RATE_LIMIT_MAX: z.coerce.number().int().positive().default(300),
165
166 OTEL_ENABLED: z
167 .string()
168 .default("false")
169 .transform((v) => v === "true" || v === "1"),
170 OTEL_EXPORTER_OTLP_ENDPOINT: z.string().default("http://localhost:4318"),
171
172 MASTER_ENCRYPTION_KEY: z.string().default(""),
173});
174
175export type AppEnv = z.infer<typeof appEnvSchema>;
176