-
Notifications
You must be signed in to change notification settings - Fork 134
Expand file tree
/
Copy pathenv.ts
More file actions
254 lines (234 loc) · 8.29 KB
/
Copy pathenv.ts
File metadata and controls
254 lines (234 loc) · 8.29 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
import { z } from "zod";
export type ApiNodeEnv = z.infer<typeof apiEnvSchema>["NODE_ENV"];
const emptyToUndefined = (value: unknown) =>
value === "" || value === undefined ? undefined : value;
function validatePostgresUrl(raw: string, name: string, ctx: z.RefinementCtx) {
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `${name} is not a valid URL (expected format: postgresql://user:pass@host:port/db)`,
});
return;
}
if (parsed.protocol !== "postgresql:" && parsed.protocol !== "postgres:") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `${name} must use the postgresql:// or postgres:// scheme, got: ${JSON.stringify(parsed.protocol)}`,
});
}
if (!parsed.hostname) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `${name} must include a hostname`,
});
}
}
const postgresUrlSchema = z
.string({
required_error: "Missing required environment variable: DATABASE_URL",
})
.min(1, "Missing required environment variable: DATABASE_URL")
.superRefine((raw, ctx) => validatePostgresUrl(raw, "DATABASE_URL", ctx));
/**
* Optional postgres URL variable (e.g. ANALYTICS_DATABASE_URL). Unset/empty
* is valid and yields undefined; when present it must be a well-formed
* postgresql:// or postgres:// URL, same as DATABASE_URL.
*/
const optionalPostgresUrlSchema = (name: string) =>
z.preprocess(
emptyToUndefined,
z
.string()
.superRefine((raw, ctx) => validatePostgresUrl(raw, name, ctx))
.optional()
);
const positiveInt = (name: string) =>
z.preprocess(
emptyToUndefined,
z.coerce
.number({
invalid_type_error: `Environment variable ${name} must be a positive integer, got: invalid value`,
})
.int(`Environment variable ${name} must be a positive integer`)
.min(1, `Environment variable ${name} must be a positive integer`)
);
export const apiEnvSchema = z.object({
NODE_ENV: z.preprocess(
emptyToUndefined,
z
.enum(["development", "test", "production"], {
errorMap: () => ({
message:
"NODE_ENV must be one of development | test | production, got: invalid value",
}),
})
.default("development")
),
PORT: z.preprocess(
emptyToUndefined,
z.coerce
.number({
invalid_type_error:
'Environment variable PORT must be a positive integer, got: "abc"',
})
.int('Environment variable PORT must be a positive integer, got: "abc"')
.min(
1,
'Environment variable PORT must be a positive integer, got: "abc"'
)
.max(65535, 'Environment variable PORT must be <= 65535, got: "99999"')
.default(3000)
),
DATABASE_URL: postgresUrlSchema,
/**
* Max size of the pg.Pool used by the Prisma adapter (#806, ties to #742).
* Recommended defaults documented in .env.example — tune per environment.
*/
DATABASE_POOL_SIZE: positiveInt("DATABASE_POOL_SIZE").default(10),
/**
* Max size of the pg.Pool used by the read-only analytics Prisma client
* (#979). Kept small and independent of DATABASE_POOL_SIZE so heavy
* analytics queries cannot exhaust connections the matching/OLTP path
* needs — this bound matters most when ANALYTICS_DATABASE_URL is unset and
* the analytics client shares the primary database (dev/test only;
* production requires a dedicated replica). Default: 5.
*/
ANALYTICS_DATABASE_POOL_SIZE: positiveInt(
"ANALYTICS_DATABASE_POOL_SIZE"
).default(5),
ORACLE_CHALLENGE_WINDOW_SECONDS: positiveInt(
"ORACLE_CHALLENGE_WINDOW_SECONDS"
).default(86400),
ORACLE_POLL_INTERVAL_MS: z.preprocess(
emptyToUndefined,
z.coerce
.number()
.int()
.min(1)
.refine((value) => value >= 5_000, {
message: 'ORACLE_POLL_INTERVAL_MS must be >= 5000 ms, got: "1000"',
})
.refine((value) => value <= 3_600_000, {
message:
'ORACLE_POLL_INTERVAL_MS must be <= 3600000 ms, got: "9999999"',
})
.default(30_000)
),
MATCHING_ENGINE_ENABLED: z.preprocess(
emptyToUndefined,
z
.enum(["true", "false"], {
errorMap: () => ({
message:
'MATCHING_ENGINE_ENABLED must be "true" or "false", got: invalid value',
}),
})
.default("true")
.transform((value) => value === "true")
),
ANALYTICS_DATABASE_URL: optionalPostgresUrlSchema("ANALYTICS_DATABASE_URL"),
/**
* Per-transaction Postgres `statement_timeout` (ms) applied to unbounded
* read paths such as `GET /v1/markets` via
* `DatabaseService.withStatementTimeout` (#983). Bounds a pathological or
* unindexed query so it aborts instead of pinning a pool connection — and
* stalling the event loop behind it — indefinitely.
* Configured via DATABASE_STATEMENT_TIMEOUT_MS (default: 5000). An empty
* value is treated as unset and falls back to the default.
*/
DATABASE_STATEMENT_TIMEOUT_MS: z.preprocess(
emptyToUndefined,
z.coerce
.number({
invalid_type_error:
"Environment variable DATABASE_STATEMENT_TIMEOUT_MS must be a positive integer",
})
.int(
"Environment variable DATABASE_STATEMENT_TIMEOUT_MS must be a positive integer"
)
.min(
1,
"Environment variable DATABASE_STATEMENT_TIMEOUT_MS must be a positive integer"
)
.default(5_000)
),
/**
* @deprecated Static admin bearer token, superseded by the rotatable
* AdminIdentity model. Declared here so the value flows through this single
* Zod schema instead of being read ad-hoc from `process.env` in
* `src/config.ts` (#984 — one parser, no undeclared env).
*
* Production/dev split: in `NODE_ENV=production` a non-empty `ADMIN_TOKEN`
* is a hard startup failure (it is a fail-open auth path and must not
* ship); in development/test it is tolerated as a local stub. The
* production check lives in `parseApiEnv` so `apiEnvSchema` stays a plain
* object (its `.shape` is consumed by tooling and tests).
*/
ADMIN_TOKEN: z.preprocess(emptyToUndefined, z.string().min(1).optional()),
/**
* Maximum request body size in bytes. Requests exceeding this are rejected
* with 413 before any route handler runs. Defaults to 64 KB (65536 bytes).
* See docs/body-limit.md.
*/
BODY_LIMIT_BYTES: z.preprocess(
emptyToUndefined,
z.coerce
.number({
invalid_type_error:
"Environment variable BODY_LIMIT_BYTES must be a positive integer",
})
.int("Environment variable BODY_LIMIT_BYTES must be a positive integer")
.min(
1,
"Environment variable BODY_LIMIT_BYTES must be a positive integer"
)
.default(65_536)
),
});
export type ParsedApiEnv = z.infer<typeof apiEnvSchema>;
export type ApiEnvInput = Record<string, string | undefined>;
function formatZodError(error: z.ZodError): string {
const issue = error.issues[0];
if (!issue) {
return "Invalid API environment configuration";
}
if (
issue.path[0] === "NODE_ENV" &&
issue.code === "invalid_enum_value" &&
"received" in issue
) {
return `NODE_ENV must be one of development | test | production, got: ${JSON.stringify(issue.received)}`;
}
if (issue.path[0] === "PORT" && issue.code === "too_big") {
return 'Environment variable PORT must be <= 65535, got: "99999"';
}
if (issue.path[0] === "ORACLE_POLL_INTERVAL_MS" && issue.code === "custom") {
return issue.message;
}
return issue.message;
}
/**
* Validates API environment variables at boot using Zod.
* Throws a descriptive Error on the first validation failure.
*/
export function parseApiEnv(env: ApiEnvInput = process.env): ParsedApiEnv {
const result = apiEnvSchema.safeParse(env);
if (!result.success) {
throw new Error(formatZodError(result.error));
}
const parsed = result.data;
// Production/dev split (#984): the deprecated static ADMIN_TOKEN is a
// fail-open auth path. Fail fast in production; allow it as a local stub
// in development/test.
if (parsed.NODE_ENV === "production" && parsed.ADMIN_TOKEN) {
throw new Error(
"ADMIN_TOKEN must not be set when NODE_ENV=production: it is a " +
"deprecated fail-open auth path. Use the AdminIdentity model for " +
"rotatable admin credentials."
);
}
return parsed;
}