From 54c29d182daef9afa18a9bdc6bf90a6eea433d7c Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Mon, 27 Jul 2026 06:14:16 -0700 Subject: [PATCH] fix: actually pass the new optional env vars into the container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OIDC_ISSUER_MCP and OIDC_AUDIENCE_SCOPE were added to .env and read by the app, but never reached it: the compose `environment:` block is an explicit allow-list, not env_file, so anything not named there is silently dropped. The aud scope only worked because its computed default happened to be right. Also make optional vars tolerate the empty string. compose renders `${VAR:-}` as "" rather than omitting the key, so an unset optional var would arrive as "" and fail .url()/.min(1) validation — taking the app down at boot rather than falling back to its default. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/lib/env.ts | 14 ++++++++++++-- docker-compose.yml | 4 ++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/web/lib/env.ts b/apps/web/lib/env.ts index 617bc15..9609460 100644 --- a/apps/web/lib/env.ts +++ b/apps/web/lib/env.ts @@ -4,6 +4,16 @@ const Bool = z .union([z.boolean(), z.enum(["true", "false", "1", "0"])]) .transform((v) => v === true || v === "true" || v === "1"); +/** + * Treat an empty string as "not set". + * + * docker-compose renders `${VAR:-}` as an empty string rather than omitting + * the key, so an unset optional var arrives as "" and would otherwise fail + * `.url()` / `.min(1)` validation and take the whole app down at boot. + */ +const optional = (schema: T) => + z.preprocess((v) => (v === "" ? undefined : v), schema.optional()); + const envSchema = z.object({ NODE_ENV: z.enum(["development", "test", "production"]).default("development"), LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"), @@ -25,7 +35,7 @@ const envSchema = z.object({ // // Set this to the MCP application's issuer. Defaults to OIDC_ISSUER for // single-application setups. - OIDC_ISSUER_MCP: z.string().url().optional(), + OIDC_ISSUER_MCP: optional(z.string().url()), OIDC_CLIENT_ID_WEB: z.string().min(1), OIDC_CLIENT_SECRET_WEB: z.string().min(1), OIDC_CLIENT_ID_MCP: z.string().min(1), @@ -40,7 +50,7 @@ const envSchema = z.object({ // every token arrives without an `aud` claim (-> 401 "claim invalid: aud"). // // Defaults to the `aud-` convention used in the README setup. - OIDC_AUDIENCE_SCOPE: z.string().min(1).optional(), + OIDC_AUDIENCE_SCOPE: optional(z.string().min(1)), // Database DATABASE_URL: z.string().url(), diff --git a/docker-compose.yml b/docker-compose.yml index 0b4307b..02f6a2a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -113,6 +113,10 @@ services: OIDC_CLIENT_SECRET_WEB: ${OIDC_CLIENT_SECRET_WEB:?required} OIDC_CLIENT_ID_MCP: ${OIDC_CLIENT_ID_MCP:?required} OIDC_AUDIENCE: ${OIDC_AUDIENCE:?required} + # Optional. This block is an explicit allow-list, not env_file — a var + # added to .env but not listed here never reaches the container. + OIDC_ISSUER_MCP: ${OIDC_ISSUER_MCP:-} + OIDC_AUDIENCE_SCOPE: ${OIDC_AUDIENCE_SCOPE:-} DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}