Skip to content

Commit a9cdbf4

Browse files
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.
1 parent dfcb8c1 commit a9cdbf4

2 files changed

Lines changed: 94 additions & 3 deletions

File tree

apps/pwa/src/config.mjs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@ import { fileURLToPath } from "node:url";
66
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
77

88
// Tiny .env loader — does not override anything already in the environment.
9-
function loadEnv() {
10-
const file = path.join(ROOT, ".env");
9+
// The value group is lazy on purpose: a greedy `(.*)` eats the whitespace the
10+
// trailing `\s*` is there to drop, so `KEY=secret ` exports the trailing space
11+
// as part of the secret, and `KEY="secret" ` never gets unquoted at all.
12+
export function loadEnv(file = path.join(ROOT, ".env")) {
1113
if (!fs.existsSync(file)) return;
1214
for (const line of fs.readFileSync(file, "utf8").split("\n")) {
13-
const m = /^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/i.exec(line);
15+
const m = /^\s*([A-Z0-9_]+)\s*=\s*(.*?)\s*$/i.exec(line);
1416
if (!m) continue;
1517
const key = m[1];
1618
if (process.env[key] !== undefined) continue;

apps/pwa/test/config-env.test.mjs

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// Unit tests for the tiny .env loader in src/config.mjs.
2+
//
3+
// No PWA dependencies are needed — config.mjs only uses node builtins — so
4+
// these run on a bare repo clone. Each test uses its own key names and clears
5+
// them out of process.env afterwards, because the loader writes there.
6+
import assert from "node:assert/strict";
7+
import { mkdtempSync, writeFileSync } from "node:fs";
8+
import { tmpdir } from "node:os";
9+
import { join } from "node:path";
10+
import test from "node:test";
11+
12+
const { loadEnv } = await import("../src/config.mjs");
13+
14+
const workdir = mkdtempSync(join(tmpdir(), "moshcode-env-"));
15+
let n = 0;
16+
17+
/** Write a throwaway .env and hand back its path. */
18+
function envFile(contents) {
19+
const file = join(workdir, `env-${n++}`);
20+
writeFileSync(file, contents);
21+
return file;
22+
}
23+
24+
/** Load a throwaway .env and hand back the keys it set, then clear them. */
25+
function load(contents, keys) {
26+
const file = envFile(contents);
27+
for (const k of keys) delete process.env[k];
28+
try {
29+
loadEnv(file);
30+
return Object.fromEntries(keys.map((k) => [k, process.env[k]]));
31+
} finally {
32+
for (const k of keys) delete process.env[k];
33+
}
34+
}
35+
36+
test("a trailing space is not part of the value", () => {
37+
const env = load("MC_TEST_ORIGIN=https://app.moshcode.sh \n", ["MC_TEST_ORIGIN"]);
38+
assert.equal(env.MC_TEST_ORIGIN, "https://app.moshcode.sh");
39+
});
40+
41+
test("a trailing tab is not part of the value", () => {
42+
const env = load("MC_TEST_TOKEN=123:AAbb\t\n", ["MC_TEST_TOKEN"]);
43+
assert.equal(env.MC_TEST_TOKEN, "123:AAbb");
44+
});
45+
46+
test("a CRLF file parses without a carriage return on the value", () => {
47+
const env = load("MC_TEST_DB=file:./data/local.db\r\nMC_TEST_PORT=8080\r\n", ["MC_TEST_DB", "MC_TEST_PORT"]);
48+
assert.equal(env.MC_TEST_DB, "file:./data/local.db");
49+
assert.equal(env.MC_TEST_PORT, "8080");
50+
});
51+
52+
test("a quoted value is still unquoted when the line has trailing whitespace", () => {
53+
const env = load('MC_TEST_KEY="re_live_key" \n', ["MC_TEST_KEY"]);
54+
assert.equal(env.MC_TEST_KEY, "re_live_key");
55+
});
56+
57+
test("spaces inside a value are kept", () => {
58+
const env = load("MC_TEST_FROM=moshcode <notify@moshcoding.com>\n", ["MC_TEST_FROM"]);
59+
assert.equal(env.MC_TEST_FROM, "moshcode <notify@moshcoding.com>");
60+
});
61+
62+
test("plain, quoted, empty and padded lines still parse", () => {
63+
const env = load(
64+
"MC_TEST_PLAIN=plain\nMC_TEST_SQ='single'\n\n MC_TEST_PAD = padded \nnot a pair\n",
65+
["MC_TEST_PLAIN", "MC_TEST_SQ", "MC_TEST_PAD"],
66+
);
67+
assert.equal(env.MC_TEST_PLAIN, "plain");
68+
assert.equal(env.MC_TEST_SQ, "single");
69+
assert.equal(env.MC_TEST_PAD, "padded");
70+
});
71+
72+
test("an empty value stays an empty string", () => {
73+
const env = load("MC_TEST_EMPTY=\n", ["MC_TEST_EMPTY"]);
74+
assert.equal(env.MC_TEST_EMPTY, "");
75+
});
76+
77+
test("the environment still wins over the file", () => {
78+
process.env.MC_TEST_WINS = "from-environment";
79+
try {
80+
loadEnv(envFile("MC_TEST_WINS=from-file\n"));
81+
assert.equal(process.env.MC_TEST_WINS, "from-environment");
82+
} finally {
83+
delete process.env.MC_TEST_WINS;
84+
}
85+
});
86+
87+
test("a missing file is a no-op", () => {
88+
assert.doesNotThrow(() => loadEnv(join(workdir, "does-not-exist")));
89+
});

0 commit comments

Comments
 (0)