Changelog
[1.3.2] 2026-08-17 Security (1)
Security
-
dirandenvPathare now rejected when they contain..segments (src/index.ts). Both are fed straight toreadFileSync, so a relative path carrying..read a file outside the directory the loader was pointed at — and every value it parsed was then written intoprocess.env, where the rest of the process trusts it. That matters wherever either option is derived rather than hard-coded: a per-tenant config directory, a CLI flag, a build variable. Any path with a..segment now throws with the offending value named, instead of silently loading whatever it landed on.Absolute paths are deliberately still allowed: passing one is the caller explicitly naming the exact file, which is the documented way to point this loader anywhere on disk. The guard closes the case where a relative path was expected and traversal changed the answer.
[1.3.1] 2026-08-10 Fixed (3) · Tests (2)
Follow-up to the 1.3.0 review by @Ion. Narrows numeric coercion so it can no longer corrupt identifier-shaped values. Every value this stops converting was being corrupted, not served.
Fixed
-
Numeric coercion now requires an exact round trip (
src/index.ts).isNumericchanged from!isNaN(value)toNumber.isFinite(Number(value)) && String(Number(value)) === value: a value becomes a number only when converting it back yields the original characters. Applied to both call sites —parseValue(.envfiles) andcoerceProcessValue(process.env) — so the two sources cannot drift apart.Value Was Now 0123456789123456789— leading zero destroyed"0123456789"12345678901234567891234567890123456800— pastMAX_SAFE_INTEGER"1234567890123456789"0x1F31"0x1F"1e5100000"1e5"+1555123456715551234567—+stripped"+15551234567"1.501.5— significant trailing zero dropped"1.50"InfinityInfinity— no longer a string"Infinity"NaN"NaN"(unchanged, but see below)"NaN"Genuine numbers (
3000,1.5,-2,0) round-trip and still coerce.Why this became urgent in 1.3.0 specifically. The coercion rule itself is old, but until 1.3.0 it only applied to
.envfiles, where an author can opt out by quoting the value (parseValuereturns early on quotes, before the numeric branch). 1.3.0 applied the same rule toprocess.env, andcoerceProcessValuedeliberately never strips quotes — correctly, since a quote in a real environment variable belongs to the secret. The net effect was that a value injected by Docker or Kubernetes had no opt-out at all, and injected secrets, account IDs and tokens are exactly the values that look numeric. Round-tripping removes the need for an escape hatch.Two refinements beyond the reviewed one-liner, both verified empirically rather than reasoned about:
Number.isFinite, not a plain NaN check. The bare round tripString(Number(v)) === vaccepts the string"NaN"(String(Number("NaN"))is"NaN"), which would have started coercing a value that is a string today — a new corruption introduced by the fix, andNaNis especially bad to leak since it compares unequal to itself.Infinityis closed too. It round-trips, so the reviewed one-liner would have left it coercing even though the review’s own table lists it as a corruption to fix.Number.isFinitecloses that row and-Infinitywith it.
Tests
- New
src/__tests__/numeric-coercion.test.ts(26 assertions). Every row above is asserted from a.envfile and fromprocess.env; a final test compares the two result sets directly so the sources can never diverge.0123456789is pinned in its own regression test — it is the case an operator cannot defend against. - New direct coverage for the
loadedKeyshalf ofisProcessProvidedinsrc/__tests__/process-env.test.ts. The two halves of the test leaveprocess.env.APP_MODEin an identical state and differ only in who wrote it; the outcomes are opposite. Removing theloadedKeyscheck makes them agree and the test fails — previously this invariant was only covered indirectly through the precedence tests.
104 passed | 0 skipped[1.3.0] 2026-08-10 Added (5) · Fixed (3) · Docs (2)
Four defects reported on 2026-08-10, all at the boundary between a .env file and the real process environment. Every change here only affects cases that were previously wrong; no currently-working configuration changes behaviour.
Added
precedenceoption — who wins when a key is in both the file andprocess.env(src/index.ts). New exported typeEnvPrecedence = "file-wins" | "process-wins", available asEnvLoaderOptions.precedenceand as an optional third argument toloadEnvFile(envPath, override, precedence?)."file-wins"(the default, unchanged) — the.envfile value replaces whatever was already inprocess.env, in the store and inprocess.envitself. This is the historical behaviour, and it is the deployment hazard: a.envfile baked into a container image silently overwrites theDATABASE_URL, port, or secret that Docker / Kubernetes / CI injected. It stays the default for the whole v1.x line so a minor bump can never change what a running deployment reads."process-wins"— an already-setprocess.envvalue is authoritative: the file neither replaces it in the store nor writes over it. The file becomes a fallback layer underneath the real environment, matchingdotenv,dotenv-flow, Vite and Next.- Keys the loader itself wrote earlier in the same run (
.env.shared, or a previousloadEnvFile) are tracked via the existingloadedKeysset and are not mistaken for platform-injected values, so file layering keeps working under"process-wins". - v2.0 will flip the default to
"process-wins". That is a breaking change to which value a running production application reads, so it gets its own release and does not ride along with anything else.
Fixed
env()now falls back toprocess.envbefore the default (src/index.ts). Lookup order is now loaded store →process.env→defaultValue. PreviouslyenvDatawas populated only by parsing.envfiles andprocess.envwas never consulted, so a variable that existed in the real environment but in no file was invisible toenv()forever — and before anyloadEnv()call the store was empty, so every lookup returned its default, includingenv("NODE_ENV")whileprocess.env.NODE_ENVwas set. Values taken fromprocess.envare coerced to primitives the same way file values are ("8080"→8080,"true"→true), so a key’s type does not depend on where it came from; that coercion is deliberately narrower thanparseValue— no quote stripping, no${VAR}interpolation, and values with significant leading/trailing whitespace pass through untouched, because a real environment variable is a literal value.env.all()still returns the loaded store only and is not merged withprocess.env.${VAR}interpolation resolvesprocess.envand throws instead of emitting the literal string"undefined"(src/index.ts). Resolution order is the internal store, thenprocess.env, then a thrown error naming the key. Previously the replacement callback returnedundefinedfor an unresolved key, whichString.prototype.replacecoerced into the four-character string"undefined"and baked into the value —DB_URL=postgres://${DB_HOST}/appproducedpostgres://undefined/appwith no warning. That is value corruption rather than a missing value: the app starts, then fails much later with a connection error pointing at a host calledundefined, far from the cause. Forward references (a key declared later in the same file) remain unsupported and now throw with a message that says so.loadEnv()no longer throws when the directory has no.envfile at all (src/index.ts). The derived path —.env.${NODE_ENV}falling back to.env— is now checked beforeloadEnvFileis called, matching the guard that already existed for.env.shared. A project that gets everything from injected variables is a normal, supported state and no longer needs a caller-side guard. A path passed explicitly asenvPathstill throws when missing: the caller named it, so a typo must be loud.loadEnvFileitself is unchanged — it is the primitive and never guesses.
Docs
- README gains a
precedencesection (with the deployment warning and the v2.0 note), the updatedenv()lookup order, the throwing-interpolation contract, the optional-derived-path rule, and a “Let the platform’s injected values win” recipe replacing the old read-only-mode-as-orchestrator-fallback advice, which did not actually do what it claimed. skills/overview,skills/loader,skills/parser,skills/recipes,llms.txt, andllms-full.txtupdated to match.llms-full.txt’s “Known limitations” section was also stale since 1.2.4 — it still listed the four bugs that release fixed — and has been rewritten.
Tests
New src/__tests__/process-env.test.ts (20 assertions) covering precedence in both directions (including that process.env is left intact under "process-wins", that injected values are coerced, and that the loader’s own writes are not mistaken for injected ones), the env() fallback and its coercion, interpolation from process.env, the throw-on-miss with a regression pin that no value is returned at all, forward-reference failure, and loadEnv() on a directory with no .env. The parseValue test that asserted the old "prefix:undefined:suffix" output now asserts the throw.
76 passed | 0 skipped[1.2.4] 2026-05-26 Added (7) · Fixed (4)
Added
- Marketing-style README. API reference table, 30-second tour,
EnvLoaderOptionsdocumentation, file-resolution chain, coercion table, and aCaveatssection that calls out the known sharp edges (env()collapsingnull,resetEnvnot deleting later-added keys, the quoted-value-with-trailing-comment parse bug). llms.txt/llms-full.txt. AI-discoverable index and concatenated reference, matching the@mongez/atomshape so the docs aggregator can pick them up.skills/folder. Reference cards for tool-assisted development —README,overview,loader(file resolution + options),parser(line/value coercion + interpolation),recipes.- Vitest test suite. 56 passing assertions across
parse-line,parse-value,load-env-file,load-env, andknown-bugs, covering type coercion, quoted values,#-inside-quotes,${VAR}interpolation, file resolution underNODE_ENV, shared-env layering, override semantics, default-value fallbacks,env.all(), andresetEnv. - CI workflow. GitHub Actions matrix matching the rest of the
@mongez/*family: Node 18 / 20 / 22 on Ubuntu, plus Node 20 on Windows. - Vitest config. Self-detecting sibling-alias pattern (no-op today since
@mongez/dotenvhas no@mongez/*runtime deps, but the same hook point as the other packages). package.jsonpolish. Expandeddescription, expandedkeywords,sideEffects: false,test/test:watchscripts wired to vitest.
Fixed
env(key)now preserves a deliberately-loadednull(src/index.ts:174). The implementation switched fromenvData[key] ?? defaultValuetokey in envData ? envData[key] : defaultValue, soenv("EST_TIME")returnsnullwhen.envcontainsEST_TIME=nullinstead of falling through to the default.parseValuecorrectly handles quoted values that contain#and a trailing comment (src/index.ts:55-111). The two-branch slice-then-split logic was replaced with a single quote-aware pass: detect the wrapping quote (one of",',`), find the matching closing quote vialastIndexOf, take the substring between, then unescape\<quote>sequences. Anything after the closing quote (whitespace +# comment) is discarded.loadEnvFileno longer callsparseValuetwice per line (src/index.ts:150-172).parseLinealready runsparseValueon the right-hand side, so the loop body now assigns the result directly — removing wasted work and the footgun for any future non-idempotentparseValuebranch.resetEnvnow deletes process.env keys added since module load (src/index.ts:13,src/index.ts:26-44,src/index.ts:166-170). AloadedKeys: Set<string>tracks every key written toprocess.envbyloadEnvFile. On reset, those keys are deleted fromprocess.envbefore the initial-snapshot restore step runs, so reset truly returns the process environment to t0 with respect to anything the loader added. Keys that callers set directly onprocess.env(without going throughloadEnv) are not tracked and continue to survive reset — the caller owns their own additions.
Tests
56 passed | 0 skipped