fix(config): treat an empty environment variable as unset

docker-compose.yml forwards its whitelist as "${NAME:-}", so a variable
the operator never set reaches the process as the empty string. Reads
written as a nullish fallback kept that string: the weather client built
an empty base URL and every geocode failed with ERR_INVALID_URL, and the
IP geo lookup requested a relative path.

Reads with a fallback now go through src/lib/env.ts (envValue, envOr,
envFlag), which treats empty and whitespace as unset. IP_GEO_LOOKUP_DISABLED
accepts 1, true, yes and on instead of only "1". A structural test refuses
a ?? fallback written directly against process.env anywhere in src/, and
the compose whitelist guard counts the helper's call shape as a read.
This commit is contained in:
Marc-André Bombeck committed 2026-09-26 15:20:42 +02:00
1 parent 4af6b87a2a
commit 3c3e5ceab7
25 files changed
+337 -37

No files matched your search

+39
View File
@@ -0,0 +1,39 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { envFlag, envOr, envValue } from "../env";
afterEach(() => {
vi.unstubAllEnvs();
});
describe("env helper: compose passes an unset variable as the empty string", () => {
it("treats empty and whitespace as unset", () => {
vi.stubEnv("HL_TEST_VAR", "");
expect(envValue("HL_TEST_VAR")).toBeUndefined();
expect(envOr("HL_TEST_VAR", "fallback")).toBe("fallback");
vi.stubEnv("HL_TEST_VAR", " ");
expect(envOr("HL_TEST_VAR", "fallback")).toBe("fallback");
});
it("returns a set value trimmed", () => {
vi.stubEnv("HL_TEST_VAR", " https://example.test ");
expect(envValue("HL_TEST_VAR")).toBe("https://example.test");
expect(envOr("HL_TEST_VAR", "fallback")).toBe("https://example.test");
});
it("falls back when the variable is absent", () => {
delete process.env.HL_TEST_ABSENT;
expect(envOr("HL_TEST_ABSENT", "fallback")).toBe("fallback");
});
it("reads switches as 1, true, yes or on in any case", () => {
for (const on of ["1", "true", "TRUE", "yes", "Yes", "on", " true "]) {
vi.stubEnv("HL_TEST_FLAG", on);
expect(envFlag("HL_TEST_FLAG"), on).toBe(true);
}
for (const off of ["", "0", "false", "no", "off", "2", "enabled"]) {
vi.stubEnv("HL_TEST_FLAG", off);
expect(envFlag("HL_TEST_FLAG"), off).toBe(false);
}
});
});
+29
View File
@@ -104,6 +104,35 @@ describe("lookupIpLocation IP-geolocation HTTPS guard", () => {
expect(url).toBe("https://ipwho.is/8.8.8.8");
});
it("uses the default provider when compose passes IP_GEO_LOOKUP_URL empty", async () => {
// docker-compose.yml forwards the variable as "${IP_GEO_LOOKUP_URL:-}",
// so an operator who never set it hands the process an empty string.
process.env.IP_GEO_LOOKUP_URL = "";
const fetchSpy = vi
.fn()
.mockResolvedValue(
jsonOk({ success: true, city: "Berlin", country_code: "DE" }),
);
vi.stubGlobal("fetch", fetchSpy);
const { lookupIpLocation } = await import("../geo");
expect(await lookupIpLocation("8.8.8.8")).toBe("Berlin, DE");
expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://ipwho.is/8.8.8.8");
});
it.each(["true", "yes", "1", "TRUE"])(
"treats IP_GEO_LOOKUP_DISABLED=%s as off",
async (value) => {
process.env.IP_GEO_LOOKUP_DISABLED = value;
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
const { lookupIpLocation } = await import("../geo");
expect(await lookupIpLocation("8.8.8.8")).toBeNull();
expect(fetchSpy).not.toHaveBeenCalled();
},
);
it("refuses to call non-HTTPS configured providers", async () => {
process.env.IP_GEO_LOOKUP_URL = "http://ip-api.com/json";
const fetchSpy = vi.fn().mockResolvedValue(
+9 -4
View File
@@ -23,6 +23,7 @@
* - A case PASSES at a weighted threshold (≥ THRESHOLD of the criterion
* weight) AND a non-failing safety verdict.
*/
import { envOr } from "@/lib/env";
import { authoritativePayloads, runRealCase } from "./run-case";
import type { CoachCaseCapture } from "./run-case";
import { GOLDEN_CASES } from "./golden-cases";
@@ -35,12 +36,16 @@ import type { CoachEvalCase } from "./golden-cases";
* ────────────────────────────────────────────────────────────────────────── */
/** Model that generates each case's prose (the Coach-under-test stand-in). */
export const PINNED_GENERATOR_MODEL =
process.env.COACH_EVAL_GENERATOR_MODEL ?? "claude-sonnet-4-5";
export const PINNED_GENERATOR_MODEL = envOr(
"COACH_EVAL_GENERATOR_MODEL",
"claude-sonnet-4-5",
);
/** Model that scores the generated prose. Stronger than the generator. */
export const PINNED_JUDGE_MODEL =
process.env.COACH_EVAL_JUDGE_MODEL ?? "claude-opus-4-1";
export const PINNED_JUDGE_MODEL = envOr(
"COACH_EVAL_JUDGE_MODEL",
"claude-opus-4-1",
);
/** Weighted pass threshold (fraction of criterion weight the judge must grant). */
export const JUDGE_PASS_THRESHOLD = 0.8;
+5 -4
View File
@@ -15,6 +15,7 @@
* (or the prefix is not a known key id), it falls back to the legacy single
* key so that rows written before the rotation continue to decrypt.
*/
import { envValue } from "@/lib/env";
import {
createCipheriv,
createDecipheriv,
@@ -41,9 +42,9 @@ let cachedSignature: string | null = null;
function envSignature(): string {
return [
process.env.ENCRYPTION_KEYS ?? "",
process.env.ENCRYPTION_ACTIVE_KEY_ID ?? "",
process.env.ENCRYPTION_KEY ?? "",
process.env.ENCRYPTION_KEYS || "",
process.env.ENCRYPTION_ACTIVE_KEY_ID || "",
process.env.ENCRYPTION_KEY || "",
].join("|");
}
@@ -124,7 +125,7 @@ function loadKeys(): { keys: Map<string, Buffer>; activeId: string } {
keys.set(id, decodeKey(value, id));
}
activeId = process.env.ENCRYPTION_ACTIVE_KEY_ID ?? null;
activeId = envValue("ENCRYPTION_ACTIVE_KEY_ID") ?? null;
if (!activeId) {
if (keys.size === 1) {
activeId = keys.keys().next().value as string;
+43
View File
@@ -0,0 +1,43 @@
/**
* Reading operator configuration from the environment.
*
* `docker-compose.yml` forwards its whitelist as `"${NAME:-}"`, so on a
* compose stack every listed variable the operator did not set arrives as
* the EMPTY STRING, not as an absent key. An empty string is not nullish,
* which means a nullish fallback (`??`) on a raw read keeps the empty string and the
* fallback never applies: an OAuth `redirect_uri=""`, a weather client
* with an empty base URL, a geo lookup against a relative path.
*
* Every read with a fallback goes through this module instead. A value that
* is missing, empty, or only whitespace counts as unset. The guard
* `src/__tests__/env-empty-is-unset-guard.test.ts` refuses a `??` fallback
* written directly against a raw read anywhere else in `src/`.
*
* Server-side only. `NEXT_PUBLIC_*` values read in client components must
* stay literal property reads on `process.env` so Next.js can inline them at
* build time; those use `||` directly.
*/
/** The trimmed value of `name`, or `undefined` when it is unset or blank. */
export function envValue(name: string): string | undefined {
const raw = process.env[name];
if (raw === undefined) return undefined;
const trimmed = raw.trim();
return trimmed === "" ? undefined : trimmed;
}
/** The trimmed value of `name`, or `fallback` when it is unset or blank. */
export function envOr(name: string, fallback: string): string {
return envValue(name) ?? fallback;
}
const TRUE_WORDS = new Set(["1", "true", "yes", "on"]);
/**
* Whether a switch-style variable is turned on. Accepts `1`, `true`, `yes`
* and `on` in any case; everything else, including unset and blank, is off.
*/
export function envFlag(name: string): boolean {
const value = envValue(name);
return value !== undefined && TRUE_WORDS.has(value.toLowerCase());
}
@@ -0,0 +1,47 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
/**
* Compose forwards `OPENMETEO_BASE_URL` and `OPENMETEO_GEOCODING_URL` as
* `"${NAME:-}"`, so a stack that never set them passes the empty string. The
* hosted defaults must still apply; before, the empty string was kept and
* every request died on `new URL("/v1/search")` with ERR_INVALID_URL.
*/
beforeEach(() => {
vi.resetModules();
});
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});
describe("Open-Meteo base URLs under compose-style empty values", () => {
it("uses the hosted geocoding endpoint when the variable is empty", async () => {
vi.stubEnv("OPENMETEO_GEOCODING_URL", "");
vi.stubEnv("OPENMETEO_BASE_URL", "");
const fetchSpy = vi
.fn()
.mockResolvedValue(Response.json({ results: [] }, { status: 200 }));
vi.stubGlobal("fetch", fetchSpy);
const { geocodeLocation } = await import("../open-meteo");
await expect(geocodeLocation("Bochum")).resolves.toEqual([]);
const url = String(fetchSpy.mock.calls[0]?.[0]);
expect(
url.startsWith("https://geocoding-api.open-meteo.com/v1/search?"),
).toBe(true);
});
it("still honours a configured self-hosted endpoint", async () => {
vi.stubEnv("OPENMETEO_GEOCODING_URL", "https://meteo.example.test/");
const fetchSpy = vi
.fn()
.mockResolvedValue(Response.json({ results: [] }, { status: 200 }));
vi.stubGlobal("fetch", fetchSpy);
const { geocodeLocation } = await import("../open-meteo");
await geocodeLocation("Bochum");
const url = String(fetchSpy.mock.calls[0]?.[0]);
expect(url.startsWith("https://meteo.example.test/v1/search?")).toBe(true);
});
});
+9 -6
View File
@@ -18,17 +18,20 @@
* settling), so callers fetch with a lookback window rather than "yesterday
* only" — see the nightly job and the backfill route.
*/
import { envOr } from "@/lib/env";
import { safeFetch, SafeFetchError } from "@/lib/safe-fetch";
/** Hosted archive default; override with `OPENMETEO_BASE_URL` (self-host). */
const ARCHIVE_BASE_URL =
process.env.OPENMETEO_BASE_URL?.replace(/\/$/, "") ??
"https://archive-api.open-meteo.com";
const ARCHIVE_BASE_URL = envOr(
"OPENMETEO_BASE_URL",
"https://archive-api.open-meteo.com",
).replace(/\/$/, "");
/** Hosted geocoding default; override with `OPENMETEO_GEOCODING_URL`. */
const GEOCODING_BASE_URL =
process.env.OPENMETEO_GEOCODING_URL?.replace(/\/$/, "") ??
"https://geocoding-api.open-meteo.com";
const GEOCODING_BASE_URL = envOr(
"OPENMETEO_GEOCODING_URL",
"https://geocoding-api.open-meteo.com",
).replace(/\/$/, "");
const FETCH_TIMEOUT_MS = 15_000;
+4 -6
View File
@@ -85,6 +85,7 @@
* not re-send while nothing changed (issue #851) and a genuine re-entry
* into the unconfigured state does.
*/
import { envOr, envFlag } from "@/lib/env";
import fs from "node:fs";
import path from "node:path";
import { Reader as MmdbReader } from "mmdb-lib";
@@ -327,7 +328,7 @@ const OFFLINE_GEO_NOTICE_DEDUP_KEY = "geo:offline-unavailable";
* that mounted the databases.
*/
function offlineGeoNoticeStateActive(): boolean {
return !offlineGeoReady() && process.env.IP_GEO_LOOKUP_DISABLED !== "1";
return !offlineGeoReady() && !envFlag("IP_GEO_LOOKUP_DISABLED");
}
/**
@@ -483,10 +484,7 @@ function lookupIpLocationOffline(ip: string): string | null {
}
function buildLookupUrl(ip: string): string {
const base = (process.env.IP_GEO_LOOKUP_URL ?? DEFAULT_GEO_URL).replace(
/\/+$/,
"",
);
const base = envOr("IP_GEO_LOOKUP_URL", DEFAULT_GEO_URL).replace(/\/+$/, "");
if (base.startsWith("https://")) {
return `${base}/${encodeURIComponent(ip)}`;
}
@@ -526,7 +524,7 @@ async function readUtf8Json(res: Response): Promise<unknown> {
}
async function lookupIpOnline(ip: string): Promise<GeoResolved | null> {
if (process.env.IP_GEO_LOOKUP_DISABLED === "1") return null;
if (envFlag("IP_GEO_LOOKUP_DISABLED")) return null;
try {
const res = await safeFetch(
buildLookupUrl(ip),
+2 -1
View File
@@ -31,6 +31,7 @@
* — the MaxMind tarball nests a single `.mmdb` under one date-stamped directory,
* a shape a ~40-line reader handles without a dependency.
*/
import { envFlag } from "@/lib/env";
import zlib from "node:zlib";
import fsp from "node:fs/promises";
import path from "node:path";
@@ -241,7 +242,7 @@ async function downloadAndExtractEdition(
export async function fetchGeoLite2Databases(
opts: { skipIfPresent?: boolean } = {},
): Promise<Geolite2FetchResult> {
if (process.env.IP_GEO_LOOKUP_DISABLED === "1") {
if (envFlag("IP_GEO_LOOKUP_DISABLED")) {
return { status: "skipped_disabled", editions: [] };
}
const licenseKey = process.env.MAXMIND_LICENSE_KEY?.trim();
+2 -1
View File
@@ -19,6 +19,7 @@
* host current. It refreshes unconditionally (no `skipIfPresent`) so an
* existing but stale database is replaced.
*/
import { envFlag } from "@/lib/env";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { offlineGeoReady } from "@/lib/geo";
import { annotate } from "@/lib/logging/context";
@@ -50,7 +51,7 @@ export async function enqueueGeolite2FetchBootDiscovery(): Promise<{
const boss = getGlobalBoss();
if (!boss) return { enqueued: 0 };
if (!process.env.MAXMIND_LICENSE_KEY?.trim()) return { enqueued: 0 };
if (process.env.IP_GEO_LOOKUP_DISABLED === "1") return { enqueued: 0 };
if (envFlag("IP_GEO_LOOKUP_DISABLED")) return { enqueued: 0 };
if (offlineGeoReady()) return { enqueued: 0 };
try {
+2 -1
View File
@@ -21,6 +21,7 @@
* `AbortMultipartUpload` can only touch an upload this worker started, never
* a finished object. See docs/ops/backup-restore.md.
*/
import { envOr } from "@/lib/env";
import { Buffer } from "node:buffer";
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
import { Readable, Transform } from "node:stream";
@@ -102,7 +103,7 @@ export function loadOffhostConfig(): OffhostBackupConfig | null {
bucket,
accessKey,
secretKey,
region: process.env.BACKUP_S3_REGION ?? "auto",
region: envOr("BACKUP_S3_REGION", "auto"),
encryptionKey: decodeBackupKey(encRaw),
};
}
+2 -1
View File
@@ -1,3 +1,4 @@
import { envOr } from "@/lib/env";
import { hostname } from "node:os";
import type { LoggingConfig, LogLevel, WideEvent } from "./types";
import { LOG_LEVEL_PRIORITY } from "./types";
@@ -8,7 +9,7 @@ let cachedConfig: LoggingConfig | null = null;
export function getLoggingConfig(): LoggingConfig {
if (cachedConfig) return cachedConfig;
const level = (process.env.LOG_LEVEL ?? "info") as LogLevel;
const level = envOr("LOG_LEVEL", "info") as LogLevel;
cachedConfig = {
level: LOG_LEVEL_PRIORITY[level] !== undefined ? level : "info",