Skip to content

Commit e7bc114

Browse files
authored
feat: validate environment variables at startup with a typed config (#121)
1 parent d1d23b1 commit e7bc114

7 files changed

Lines changed: 121 additions & 24 deletions

File tree

package-lock.json

Lines changed: 12 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@
5656
"@privacybydesign/yivi-web": "^1.0.1",
5757
"pino": "^10.3.1",
5858
"sass": "^1.100.0",
59-
"svelte-i18n": "^4.0.1"
59+
"svelte-i18n": "^4.0.1",
60+
"zod": "^4.4.3"
6061
},
6162
"overrides": {
6263
"@sveltejs/kit": {

src/lib/feature-flags.ts

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { dev } from '$app/environment';
2-
import { env } from '$env/dynamic/private';
2+
import { config } from '$lib/server/config';
33

44
export type FeatureFlag =
55
| 'pricingPage'
@@ -30,17 +30,17 @@ export const FLAG_LABELS: Record<FeatureFlag, string> = {
3030

3131
/** Flags from environment variables (immutable, set at startup). */
3232
const envFlags: Record<FeatureFlag, boolean> = {
33-
pricingPage: env.FF_PRICING_PAGE === 'true',
34-
registration: env.FF_REGISTRATION === 'true',
35-
portalApiKeys: env.FF_PORTAL_API_KEYS === 'true',
36-
portalOrgInfo: env.FF_PORTAL_ORG_INFO === 'true',
37-
portalEmailLog: env.FF_PORTAL_EMAIL_LOG === 'true',
38-
portalDns: env.FF_PORTAL_DNS === 'true',
39-
portalMembers: env.FF_PORTAL_MEMBERS === 'true',
40-
adminPanel: env.FF_ADMIN_PANEL === 'true',
41-
adminOrgStatus: env.FF_ADMIN_ORG_STATUS === 'true',
42-
adminAuditLog: env.FF_ADMIN_AUDIT_LOG === 'true',
43-
adminImpersonation: env.FF_ADMIN_IMPERSONATION === 'true'
33+
pricingPage: config.FF_PRICING_PAGE,
34+
registration: config.FF_REGISTRATION,
35+
portalApiKeys: config.FF_PORTAL_API_KEYS,
36+
portalOrgInfo: config.FF_PORTAL_ORG_INFO,
37+
portalEmailLog: config.FF_PORTAL_EMAIL_LOG,
38+
portalDns: config.FF_PORTAL_DNS,
39+
portalMembers: config.FF_PORTAL_MEMBERS,
40+
adminPanel: config.FF_ADMIN_PANEL,
41+
adminOrgStatus: config.FF_ADMIN_ORG_STATUS,
42+
adminAuditLog: config.FF_ADMIN_AUDIT_LOG,
43+
adminImpersonation: config.FF_ADMIN_IMPERSONATION
4444
};
4545

4646
/** Runtime overrides — only used in dev mode. */

src/lib/server/auth/yivi.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { env } from '$env/dynamic/private';
1+
import { config } from '$lib/server/config';
22

3-
export const YIVI_SERVER_URL = env.YIVI_SERVER_URL ?? 'http://localhost:8088';
4-
export const YIVI_SERVER_TOKEN = env.YIVI_SERVER_TOKEN ?? '';
5-
const USE_DEMO_ATTRS = env.YIVI_DEMO_ATTRIBUTES === 'true';
3+
export const YIVI_SERVER_URL = config.YIVI_SERVER_URL;
4+
export const YIVI_SERVER_TOKEN = config.YIVI_SERVER_TOKEN;
5+
const USE_DEMO_ATTRS = config.YIVI_DEMO_ATTRIBUTES;
66

77
// Production attributes (pbdf scheme)
88
const PROD_ATTR = {

src/lib/server/config.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { z } from 'zod';
2+
import { env } from '$env/dynamic/private';
3+
4+
// Env booleans are strings; match the app's existing `=== 'true'` semantics
5+
// exactly (only the literal "true" is truthy; unset → false).
6+
const boolFromEnv = z.preprocess((v) => v === 'true', z.boolean());
7+
8+
// Single source of truth for the server's environment configuration. Validated
9+
// once at startup so a missing/mistyped var fails fast with a clear message,
10+
// rather than surfacing at the first use of each value.
11+
export const configSchema = z.object({
12+
DATABASE_URL: z.string().min(1),
13+
14+
// Yivi / IRMA. Injected via docker-compose / k8s in real environments.
15+
YIVI_SERVER_URL: z.string().min(1).default('http://localhost:8088'),
16+
YIVI_SERVER_TOKEN: z.string().default(''),
17+
YIVI_DEMO_ATTRIBUTES: boolFromEnv,
18+
19+
// Feature flags (FF_*).
20+
FF_PRICING_PAGE: boolFromEnv,
21+
FF_REGISTRATION: boolFromEnv,
22+
FF_PORTAL_API_KEYS: boolFromEnv,
23+
FF_PORTAL_ORG_INFO: boolFromEnv,
24+
FF_PORTAL_EMAIL_LOG: boolFromEnv,
25+
FF_PORTAL_DNS: boolFromEnv,
26+
FF_PORTAL_MEMBERS: boolFromEnv,
27+
FF_ADMIN_PANEL: boolFromEnv,
28+
FF_ADMIN_ORG_STATUS: boolFromEnv,
29+
FF_ADMIN_AUDIT_LOG: boolFromEnv,
30+
FF_ADMIN_IMPERSONATION: boolFromEnv
31+
});
32+
33+
export type Config = z.infer<typeof configSchema>;
34+
35+
function loadConfig(): Config {
36+
const parsed = configSchema.safeParse(env);
37+
if (!parsed.success) {
38+
const issues = parsed.error.issues
39+
.map((i) => ` - ${i.path.join('.') || '(root)'}: ${i.message}`)
40+
.join('\n');
41+
throw new Error(`Invalid environment configuration:\n${issues}`);
42+
}
43+
return parsed.data;
44+
}
45+
46+
export const config = loadConfig();

src/lib/server/db/index.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,19 @@ import { drizzle } from 'drizzle-orm/postgres-js';
22
import postgres from 'postgres';
33
import * as schema from './schema';
44
import { env } from '$env/dynamic/private';
5-
6-
if (!env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
5+
import { config } from '$lib/server/config';
76

87
// Parse a positive-integer env var, falling back to a default when unset/invalid.
98
export function intFromEnv(value: string | undefined, fallback: number): number {
109
const parsed = Number(value);
1110
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
1211
}
1312

14-
// Explicit pool sizing + timeouts. postgres.js otherwise leaves connect/idle
15-
// timeouts effectively unbounded, and `max` should be sized against Postgres
16-
// `max_connections` across replicas — so make it tunable via env.
17-
const client = postgres(env.DATABASE_URL, {
13+
// DATABASE_URL is validated in config (fails fast at startup if unset). Pool
14+
// sizing/timeouts stay tunable here via env. postgres.js otherwise leaves
15+
// connect/idle timeouts effectively unbounded, and `max` should be sized
16+
// against Postgres `max_connections` across replicas.
17+
const client = postgres(config.DATABASE_URL, {
1818
max: intFromEnv(env.DB_POOL_MAX, 10),
1919
idle_timeout: intFromEnv(env.DB_IDLE_TIMEOUT, 20), // seconds
2020
connect_timeout: intFromEnv(env.DB_CONNECT_TIMEOUT, 10), // seconds

tests/unit/config.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
3+
// Importing the config module runs validation against the real env at load time;
4+
// give it a valid DATABASE_URL so the import doesn't throw, then exercise the
5+
// schema directly with explicit inputs.
6+
vi.mock('$env/dynamic/private', () => ({
7+
env: { DATABASE_URL: 'postgres://user:pass@localhost:5432/db' }
8+
}));
9+
10+
import { configSchema } from '$lib/server/config';
11+
12+
describe('configSchema', () => {
13+
it('applies sensible defaults for a minimal valid env', () => {
14+
const c = configSchema.parse({ DATABASE_URL: 'postgres://x' });
15+
expect(c.YIVI_SERVER_URL).toBe('http://localhost:8088');
16+
expect(c.YIVI_SERVER_TOKEN).toBe('');
17+
expect(c.YIVI_DEMO_ATTRIBUTES).toBe(false);
18+
expect(c.FF_ADMIN_PANEL).toBe(false);
19+
});
20+
21+
it('coerces only the exact string "true" to boolean true', () => {
22+
const c = configSchema.parse({
23+
DATABASE_URL: 'postgres://x',
24+
YIVI_DEMO_ATTRIBUTES: 'true',
25+
FF_ADMIN_PANEL: 'true',
26+
FF_REGISTRATION: 'false',
27+
FF_PORTAL_DNS: 'TRUE'
28+
});
29+
expect(c.YIVI_DEMO_ATTRIBUTES).toBe(true);
30+
expect(c.FF_ADMIN_PANEL).toBe(true);
31+
expect(c.FF_REGISTRATION).toBe(false);
32+
expect(c.FF_PORTAL_DNS).toBe(false); // case-sensitive, matches legacy behaviour
33+
});
34+
35+
it('rejects a missing or empty DATABASE_URL', () => {
36+
expect(configSchema.safeParse({}).success).toBe(false);
37+
expect(configSchema.safeParse({ DATABASE_URL: '' }).success).toBe(false);
38+
});
39+
});

0 commit comments

Comments
 (0)