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

@@ -46,6 +46,13 @@ const SRC = join(ROOT, "src");
const DIRECT_READ =
/process\.env(?:\.([A-Za-z_][A-Za-z0-9_]*)|\[\s*["'`]([A-Za-z_][A-Za-z0-9_]*)["'`]\s*\])/g;
/**
* `envValue("NAME")`, `envOr("NAME", …)` and `envFlag("NAME")` from
* `src/lib/env.ts` — the one helper every read with a fallback goes through,
* so its call shape is a read wherever it appears. Asserted non-empty in T2.
*/
const HELPER_READ = /\benv(?:Value|Or|Flag)\(\s*["'`]([A-Z][A-Z0-9_]*)["'`]/g;
/**
* Modules that index `process.env` through a variable rather than a literal.
* The direct matcher cannot see their reads at all, so each names the exact
@@ -154,6 +161,7 @@ function runtimeReads(): Set<string> {
for (const rel of sourceFiles()) {
const src = read(rel);
for (const m of src.matchAll(DIRECT_READ)) names.add(m[1] ?? m[2]);
for (const m of src.matchAll(HELPER_READ)) names.add(m[1]);
}
for (const reader of INDIRECT_READERS) {
for (const m of read(reader.file).matchAll(reader.pattern)) names.add(m[1]);
@@ -191,6 +199,13 @@ describe("compose env whitelist", () => {
});
it("T2 — every indirect reader still matches its call shape", () => {
const helperReads = sourceFiles().flatMap((rel) =>
[...read(rel).matchAll(HELPER_READ)].map((m) => m[1]),
);
// The env helper is the common path for reads with a fallback; a matcher
// that stopped seeing it would drop those names from the read set.
expect(helperReads.length).toBeGreaterThan(10);
expect(helperReads).toContain("IP_GEO_LOOKUP_URL");
for (const reader of INDIRECT_READERS) {
const found = [...read(reader.file).matchAll(reader.pattern)].map(
(m) => m[1],
@@ -0,0 +1,107 @@
/**
* Structural guard: an environment variable with a fallback is read through
* `src/lib/env.ts`, never as `process.env.NAME ?? fallback`.
*
* ## The failure this freezes
*
* `docker-compose.yml` forwards its whitelist as `"${NAME:-}"`, so every
* listed variable the operator left unset arrives as the empty string. `??`
* only replaces `null` and `undefined`, so the empty string wins and the
* fallback never applies. On a stock compose stack that sent Withings an
* OAuth `redirect_uri=""`, handed the weather client an empty base URL
* (`ERR_INVALID_URL` on every fetch) and pointed the IP geo lookup at a
* relative path. The same shape with a method in between,
* `process.env.NAME?.replace(...) ?? fallback`, fails the same way.
*
* ## What it proves and what it does not
*
* It proves no source file under `src/` writes a nullish fallback directly
* against a `process.env` read. It does not see a read stored in a local
* first and defaulted later (`const x = process.env.NAME; x ?? y`); those
* stay a review question. `||` is allowed: it already treats the empty
* string as unset, and client components need a literal
* `process.env.NEXT_PUBLIC_X` for build-time inlining.
*/
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { walkSourceFiles } from "./helpers/source-files";
const SRC = join(process.cwd(), "src");
/**
* `process.env.NAME` or `process.env["NAME"]`, optionally followed by
* optional-chained calls such as `?.trim()` or `?.replace(/x/, "")`, then
* `??` across any whitespace including a line break.
*/
const NULLISH_ENV_FALLBACK =
/process\.env(?:\.[A-Za-z_][A-Za-z0-9_]*|\[\s*["'`][A-Za-z_][A-Za-z0-9_]*["'`]\s*\])(?:\?\.[A-Za-z_]+\((?:[^()]|\([^()]*\))*\))*\s*\?\?/g;
/**
* Files that still carry the pattern because another change owns them.
* Each entry fails T3 once the file no longer matches, so the list can only
* shrink.
*/
const PENDING: Readonly<Record<string, string>> = {
"lib/withings/client.ts":
"WITHINGS_REDIRECT_URI ?? derived callback; the integration clients are being reworked in a parallel change that owns this file",
};
function sourceFiles(): string[] {
return walkSourceFiles(SRC, { floor: 3000 })
.filter((rel) => !rel.startsWith("generated/"))
.filter((rel) => !rel.includes("__tests__"))
.filter((rel) => !rel.endsWith(".test.ts") && !rel.endsWith(".test.tsx"))
.filter((rel) => rel !== "lib/env.ts");
}
function offenders(): string[] {
return sourceFiles().filter((rel) => {
const src = readFileSync(join(SRC, rel), "utf8");
return [...src.matchAll(NULLISH_ENV_FALLBACK)].length > 0;
});
}
describe("an empty environment variable counts as unset", () => {
it("T1 — the matcher catches every shape it claims to", () => {
const shapes = [
`const a = process.env.FOO ?? "x";`,
`const b = process.env.FOO\n ?? "x";`,
`const c = process.env["FOO"] ?? "x";`,
`const d = process.env.FOO?.replace(/\\/$/, "") ?? "x";`,
`const e = process.env.FOO?.trim()?.toLowerCase() ?? "x";`,
];
for (const shape of shapes) {
expect(
[...shape.matchAll(NULLISH_ENV_FALLBACK)].length,
shape,
).toBeGreaterThan(0);
}
for (const allowed of [
`const f = process.env.FOO || "x";`,
`const g = envOr("FOO", "x");`,
`const h = process.env.FOO === "1" ? a ?? b : c;`,
]) {
expect([...allowed.matchAll(NULLISH_ENV_FALLBACK)], allowed).toEqual([]);
}
});
it("T2 — no source file defaults a process.env read with ??", () => {
const found = offenders().filter((rel) => !(rel in PENDING));
expect(
found,
"Read these through envValue/envOr/envFlag from @/lib/env. Compose " +
"passes an unset variable as the empty string, and ?? keeps it.",
).toEqual([]);
});
it("T3 — every pending entry still matches, so the list only shrinks", () => {
const current = new Set(offenders());
const stale = Object.keys(PENDING).filter((rel) => !current.has(rel));
expect(
stale,
"No longer carries the pattern; drop the entry from PENDING.",
).toEqual([]);
});
});
+2 -1
View File
@@ -1,3 +1,4 @@
import { envOr } from "@/lib/env";
import { NextRequest } from "next/server";
import { apiHandler, requireAdmin, HttpError } from "@/lib/api-handler";
import { prisma } from "@/lib/db";
@@ -95,7 +96,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
"api.groq.com",
"openrouter.ai",
]);
for (const h of (process.env.ADMIN_AI_BASE_URL_ALLOWLIST ?? "")
for (const h of envOr("ADMIN_AI_BASE_URL_ALLOWLIST", "")
.split(",")
.map((h) => h.trim())
.filter(Boolean)) {
@@ -1,3 +1,4 @@
import { envValue } from "@/lib/env";
import { NextRequest } from "next/server";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
@@ -37,8 +38,8 @@ function resolveUmamiSendUrls(scriptUrl: string | null): string[] {
function resolveAppUrl(request: NextRequest): URL {
const configured =
process.env.APP_URL ??
process.env.NEXT_PUBLIC_APP_URL ??
envValue("APP_URL") ??
envValue("NEXT_PUBLIC_APP_URL") ??
`https://${request.headers.get("host") ?? "localhost:3000"}`;
try {
+3 -2
View File
@@ -1,3 +1,4 @@
import { envOr } from "@/lib/env";
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
@@ -14,7 +15,7 @@ const BUILD_TIME = process.env.BUILD_TIMESTAMP || new Date().toISOString();
const START_TIME = new Date().toISOString();
// Try to read git commit info
let gitCommit = process.env.GIT_COMMIT ?? "unknown";
let gitCommit = envOr("GIT_COMMIT", "unknown");
try {
const head = readFileSync(".git/HEAD", "utf-8").trim();
if (head.startsWith("ref:")) {
@@ -69,7 +70,7 @@ export const GET = apiHandler(async () => {
const failingJobs = await readFailingQueues();
return apiSuccess({
version: process.env.npm_package_version ?? "0.1.0",
version: process.env.npm_package_version || "0.1.0",
nodeVersion: process.version,
gitCommit,
buildTime: BUILD_TIME,
+2 -1
View File
@@ -1,3 +1,4 @@
import { envOr } from "@/lib/env";
import { timingSafeEqual } from "node:crypto";
import { NextRequest, NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
@@ -190,7 +191,7 @@ async function notifyAdminsOfFailure(event: NormalizedEvent): Promise<void> {
// Optional: operators can point this at their deployment
// dashboard (e.g. the Coolify UI). Falls back to an empty
// string so the body template still composes cleanly.
logsUrl: process.env.DEPLOY_LOGS_URL ?? "",
logsUrl: envOr("DEPLOY_LOGS_URL", ""),
},
metadata: {
source: "deploy-webhook",
+2 -1
View File
@@ -1,3 +1,4 @@
import { envValue } from "@/lib/env";
import { prisma } from "@/lib/db";
import {
apiError,
@@ -50,7 +51,7 @@ export const GET = apiHandler(async () => {
});
async function registerTelegramWebhook(botToken: string) {
const appUrl = process.env.APP_URL ?? process.env.NEXT_PUBLIC_APP_URL;
const appUrl = envValue("APP_URL") ?? envValue("NEXT_PUBLIC_APP_URL");
if (!appUrl) {
return apiError(
"Server configuration error: APP_URL (or NEXT_PUBLIC_APP_URL) is missing.",
+1 -1
View File
@@ -4,7 +4,7 @@ import { useEffect } from "react";
import { ErrorDetails } from "@/components/error-details";
const CHUNK_RELOAD_KEY = "healthlog:chunk-reload-attempted";
const SHELL_VERSION = process.env.NEXT_PUBLIC_APP_VERSION ?? "";
const SHELL_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "";
function isChunkLoadError(err: Error & { digest?: string }): boolean {
if (err.name === "ChunkLoadError") return true;
+1 -1
View File
@@ -127,7 +127,7 @@ export default async function RootLayout({
id="healthlog-i18n-boot"
strategy="beforeInteractive"
nonce={nonce}
src={`/i18n/${initialLocale}.js?v=${process.env.NEXT_PUBLIC_APP_VERSION ?? "dev"}`}
src={`/i18n/${initialLocale}.js?v=${process.env.NEXT_PUBLIC_APP_VERSION || "dev"}`}
/>
</head>
<body className={`${inter.variable} font-sans antialiased`}>
+1 -1
View File
@@ -48,7 +48,7 @@ import {
subscribeToMeaningfulVisibilityRefresh,
} from "@/lib/pwa/query-client-options";
const SHELL_VERSION = process.env.NEXT_PUBLIC_APP_VERSION ?? "";
const SHELL_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "";
// ── Theme Context ────────────────────────────────────
+1 -1
View File
@@ -48,7 +48,7 @@ import { useTranslations } from "@/lib/i18n/context";
const POLL_INTERVAL_MS = 60_000;
const SESSION_GUARD_KEY = "healthlog:version-reload-attempted";
const SHELL_VERSION = process.env.NEXT_PUBLIC_APP_VERSION ?? "";
const SHELL_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "";
export type VersionPollDecision = "up-to-date" | "reload" | "already-attempted";
+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",