From 224ae3e6dbb88bd065b9a6795f8f89dad75bb782 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Sat, 11 Jul 2026 15:20:24 +0200 Subject: [PATCH] Fix boot crash on compose-passed empty setup variables main.ts parsed process.env raw, so the compose passthroughs (SETUP_DEFAULT_LOCALE: "") failed the enum parse and crash-looped the Test api. Route main.ts through the same loadApiEnv as AppConfig, which overlays the secret store and drops empty strings. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1 --- apps/api/src/config/app-config.service.ts | 19 +++++++--- apps/api/src/config/app-config.test.ts | 44 +++++++++++++++++++++++ apps/api/src/main.ts | 5 ++- 3 files changed, 60 insertions(+), 8 deletions(-) create mode 100644 apps/api/src/config/app-config.test.ts diff --git a/apps/api/src/config/app-config.service.ts b/apps/api/src/config/app-config.service.ts index 0e88a2f..d0fb8f8 100644 --- a/apps/api/src/config/app-config.service.ts +++ b/apps/api/src/config/app-config.service.ts @@ -3,15 +3,24 @@ import { ApiEnv, apiEnvSchema, parseEnv } from '@dorfteich/shared'; import { overlayEnv, readSecretsFile } from './secret-store'; +/** + * The one way to resolve the api environment: secrets the setup wizard + * persisted (SMTP credentials) extend the process env, explicit env always + * wins, and empty strings count as unset (secret-store.ts). main.ts and + * AppConfig MUST both use this — compose passes optional variables as "" + * and a raw `parseEnv(schema, process.env)` would fail on enum fields. + */ +export function loadApiEnv(): ApiEnv { + const secretsFile = apiEnvSchema.shape.SECRETS_FILE.parse(process.env.SECRETS_FILE || undefined); + const secrets = readSecretsFile(secretsFile); + return parseEnv(apiEnvSchema, overlayEnv(process.env, secrets)); +} + @Injectable() export class AppConfig { readonly env: ApiEnv; constructor() { - // Secrets the setup wizard persisted (SMTP credentials) extend the - // environment; explicit process env always wins (secret-store.ts). - const secretsFile = apiEnvSchema.shape.SECRETS_FILE.parse(process.env.SECRETS_FILE); - const secrets = readSecretsFile(secretsFile); - this.env = parseEnv(apiEnvSchema, overlayEnv(process.env, secrets)); + this.env = loadApiEnv(); } } diff --git a/apps/api/src/config/app-config.test.ts b/apps/api/src/config/app-config.test.ts new file mode 100644 index 0000000..c541cfb --- /dev/null +++ b/apps/api/src/config/app-config.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { loadApiEnv } from './app-config.service'; + +/** + * Regression for the Test-stage boot crash after #80: compose passes + * optional variables as empty strings (`SETUP_DEFAULT_LOCALE: ${…:-}`), + * and a raw parse of process.env fails on enum fields. loadApiEnv must + * treat them as unset — in main.ts exactly like in AppConfig. + */ +describe('loadApiEnv', () => { + const touched = [ + 'SETUP_DEFAULT_LOCALE', + 'SETUP_REGISTRATION_MODE', + 'SMTP_HOST', + 'SMTP_PORT', + 'SECRETS_FILE', + 'DATABASE_URL', + ] as const; + const saved = Object.fromEntries(touched.map((key) => [key, process.env[key]])); + + afterEach(() => { + for (const key of touched) { + if (saved[key] === undefined) delete process.env[key]; + else process.env[key] = saved[key]; + } + }); + + it('treats compose-passed empty strings as unset', () => { + process.env.DATABASE_URL = 'postgresql://user:pass@localhost:5432/db'; + process.env.SETUP_DEFAULT_LOCALE = ''; + process.env.SETUP_REGISTRATION_MODE = ''; + process.env.SMTP_HOST = ''; + process.env.SMTP_PORT = ''; + process.env.SECRETS_FILE = ''; + + const env = loadApiEnv(); + expect(env.SETUP_DEFAULT_LOCALE).toBeUndefined(); + expect(env.SETUP_REGISTRATION_MODE).toBeUndefined(); + expect(env.SMTP_HOST).toBe('localhost'); // schema default, not "" + expect(env.SMTP_PORT).toBe(1025); + expect(env.SECRETS_FILE).toBe('./data/secrets.env'); + }); +}); diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 2e482c2..552f6cc 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -3,11 +3,10 @@ import { execFileSync } from 'node:child_process'; import cookieParser from 'cookie-parser'; import { NestFactory } from '@nestjs/core'; import type { NestExpressApplication } from '@nestjs/platform-express'; -import { apiEnvSchema, parseEnv } from '@dorfteich/shared'; import { Logger } from 'nestjs-pino'; import { AppModule } from './app.module'; -import { AppConfig } from './config/app-config.service'; +import { AppConfig, loadApiEnv } from './config/app-config.service'; /** * Apply pending migrations before the application accepts traffic, so @@ -22,7 +21,7 @@ function runMigrations(): void { async function bootstrap(): Promise { // Validate the environment before doing anything with it; this throws a // readable list of problems and prevents a half-started process. - const env = parseEnv(apiEnvSchema, process.env); + const env = loadApiEnv(); if (env.MIGRATE_ON_START) { runMigrations(); }