From a9cdbf46142039613e022f3ce08f799a402d2ed6 Mon Sep 17 00:00:00 2001 From: clawedassistant26 <307253840+clawedassistant26@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:20:59 +0000 Subject: [PATCH] fix(config): stop a trailing space in .env becoming part of the value The .env loader's value pattern is `(.*)\s*$`. The greedy `.*` consumes the trailing whitespace before `\s*` ever runs, so the trim the regex was written to do never happens. `PUBLIC_ORIGIN=https://app.moshcode.sh ` (one stray space, easy to leave behind when editing a .env) exports the space too, and every device verification link becomes `https://app.moshcode.sh /device`. A padded `RESEND_API_KEY` or `TELEGRAM_BOT_TOKEN` goes out to the provider with the space still on it and just fails to authenticate. It also breaks the quote stripping: `KEY="value" ` no longer ends with a quote, so the value keeps its literal quotes. Making the group lazy lets the trailing `\s*` do its job. loadEnv now takes an optional path (defaulting to the same apps/pwa/.env) so it can be tested against a throwaway file instead of the repo's own .env. Verified on unmodified main: importing src/config.mjs with a .env holding `PUBLIC_ORIGIN=https://app.moshcode.sh ` yields config.origin with the trailing space. --- apps/pwa/src/config.mjs | 8 +-- apps/pwa/test/config-env.test.mjs | 89 +++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 apps/pwa/test/config-env.test.mjs diff --git a/apps/pwa/src/config.mjs b/apps/pwa/src/config.mjs index 81391bb..74fcb40 100644 --- a/apps/pwa/src/config.mjs +++ b/apps/pwa/src/config.mjs @@ -6,11 +6,13 @@ import { fileURLToPath } from "node:url"; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); // Tiny .env loader — does not override anything already in the environment. -function loadEnv() { - const file = path.join(ROOT, ".env"); +// The value group is lazy on purpose: a greedy `(.*)` eats the whitespace the +// trailing `\s*` is there to drop, so `KEY=secret ` exports the trailing space +// as part of the secret, and `KEY="secret" ` never gets unquoted at all. +export function loadEnv(file = path.join(ROOT, ".env")) { if (!fs.existsSync(file)) return; for (const line of fs.readFileSync(file, "utf8").split("\n")) { - const m = /^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/i.exec(line); + const m = /^\s*([A-Z0-9_]+)\s*=\s*(.*?)\s*$/i.exec(line); if (!m) continue; const key = m[1]; if (process.env[key] !== undefined) continue; diff --git a/apps/pwa/test/config-env.test.mjs b/apps/pwa/test/config-env.test.mjs new file mode 100644 index 0000000..e3dc1da --- /dev/null +++ b/apps/pwa/test/config-env.test.mjs @@ -0,0 +1,89 @@ +// Unit tests for the tiny .env loader in src/config.mjs. +// +// No PWA dependencies are needed — config.mjs only uses node builtins — so +// these run on a bare repo clone. Each test uses its own key names and clears +// them out of process.env afterwards, because the loader writes there. +import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +const { loadEnv } = await import("../src/config.mjs"); + +const workdir = mkdtempSync(join(tmpdir(), "moshcode-env-")); +let n = 0; + +/** Write a throwaway .env and hand back its path. */ +function envFile(contents) { + const file = join(workdir, `env-${n++}`); + writeFileSync(file, contents); + return file; +} + +/** Load a throwaway .env and hand back the keys it set, then clear them. */ +function load(contents, keys) { + const file = envFile(contents); + for (const k of keys) delete process.env[k]; + try { + loadEnv(file); + return Object.fromEntries(keys.map((k) => [k, process.env[k]])); + } finally { + for (const k of keys) delete process.env[k]; + } +} + +test("a trailing space is not part of the value", () => { + const env = load("MC_TEST_ORIGIN=https://app.moshcode.sh \n", ["MC_TEST_ORIGIN"]); + assert.equal(env.MC_TEST_ORIGIN, "https://app.moshcode.sh"); +}); + +test("a trailing tab is not part of the value", () => { + const env = load("MC_TEST_TOKEN=123:AAbb\t\n", ["MC_TEST_TOKEN"]); + assert.equal(env.MC_TEST_TOKEN, "123:AAbb"); +}); + +test("a CRLF file parses without a carriage return on the value", () => { + const env = load("MC_TEST_DB=file:./data/local.db\r\nMC_TEST_PORT=8080\r\n", ["MC_TEST_DB", "MC_TEST_PORT"]); + assert.equal(env.MC_TEST_DB, "file:./data/local.db"); + assert.equal(env.MC_TEST_PORT, "8080"); +}); + +test("a quoted value is still unquoted when the line has trailing whitespace", () => { + const env = load('MC_TEST_KEY="re_live_key" \n', ["MC_TEST_KEY"]); + assert.equal(env.MC_TEST_KEY, "re_live_key"); +}); + +test("spaces inside a value are kept", () => { + const env = load("MC_TEST_FROM=moshcode \n", ["MC_TEST_FROM"]); + assert.equal(env.MC_TEST_FROM, "moshcode "); +}); + +test("plain, quoted, empty and padded lines still parse", () => { + const env = load( + "MC_TEST_PLAIN=plain\nMC_TEST_SQ='single'\n\n MC_TEST_PAD = padded \nnot a pair\n", + ["MC_TEST_PLAIN", "MC_TEST_SQ", "MC_TEST_PAD"], + ); + assert.equal(env.MC_TEST_PLAIN, "plain"); + assert.equal(env.MC_TEST_SQ, "single"); + assert.equal(env.MC_TEST_PAD, "padded"); +}); + +test("an empty value stays an empty string", () => { + const env = load("MC_TEST_EMPTY=\n", ["MC_TEST_EMPTY"]); + assert.equal(env.MC_TEST_EMPTY, ""); +}); + +test("the environment still wins over the file", () => { + process.env.MC_TEST_WINS = "from-environment"; + try { + loadEnv(envFile("MC_TEST_WINS=from-file\n")); + assert.equal(process.env.MC_TEST_WINS, "from-environment"); + } finally { + delete process.env.MC_TEST_WINS; + } +}); + +test("a missing file is a no-op", () => { + assert.doesNotThrow(() => loadEnv(join(workdir, "does-not-exist"))); +});