From e61d1b65b418185688d51127183aa81635f7e88b Mon Sep 17 00:00:00 2001 From: floraispretty Date: Sat, 26 Sep 2026 20:12:40 +0100 Subject: [PATCH 1/2] fix(env): add resolveEnv() that throws in production when unset (#37) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit api.ts's NEXT_PUBLIC_API_URL falls back to http://localhost:3000 with a bare `?? fallback`. If that var is forgotten in a production build, every visitor's browser silently tries to call *its own* localhost instead of the real backend — the failure then looks like a backend outage, not a config mistake, because nothing ever throws or logs anything pointing at the actual cause. Adds src/lib/env.ts: resolveEnv(name, value, devFallback) returns the dev fallback in dev/test as before, but throws a clear "Missing required environment variable" error referencing the exact var name when NODE_ENV === 'production' and the value is empty/unset. NODE_ENV=production is Next.js's own signal for a real `next build`, not something this app sets itself, so no extra build-time flag is needed to distinguish "real production build" from "someone running `next dev` locally without a .env". Exported as a plain function (not env.ts computing the values itself at module load) so the throwing behavior is directly unit-testable without needing to re-evaluate module-level `process.env` reads. Tests (src/lib/env.test.ts, using vi.stubEnv per this repo's vitest 4.x convention): value present short-circuits regardless of NODE_ENV; dev/test fall back; production with no value throws referencing the var name; empty string is treated the same as missing. --- src/lib/env.test.ts | 41 +++++++++++++++++++++++++++++++++++++++++ src/lib/env.ts | 21 +++++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 src/lib/env.test.ts create mode 100644 src/lib/env.ts diff --git a/src/lib/env.test.ts b/src/lib/env.test.ts new file mode 100644 index 0000000..4244df7 --- /dev/null +++ b/src/lib/env.test.ts @@ -0,0 +1,41 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { resolveEnv } from './env'; + +const originalNodeEnv = process.env.NODE_ENV; + +afterEach(() => { + vi.stubEnv('NODE_ENV', originalNodeEnv ?? 'test'); +}); + +describe('resolveEnv', () => { + it('returns the value when it is set, regardless of NODE_ENV', () => { + vi.stubEnv('NODE_ENV', 'production'); + expect(resolveEnv('X', 'https://api.example.com', 'http://localhost:3000')).toBe( + 'https://api.example.com', + ); + }); + + it('falls back to the dev default outside production', () => { + vi.stubEnv('NODE_ENV', 'development'); + expect(resolveEnv('X', undefined, 'http://localhost:3000')).toBe('http://localhost:3000'); + }); + + it('falls back to the dev default in test', () => { + vi.stubEnv('NODE_ENV', 'test'); + expect(resolveEnv('X', undefined, 'http://localhost:3000')).toBe('http://localhost:3000'); + }); + + it('throws instead of silently falling back when missing in production', () => { + vi.stubEnv('NODE_ENV', 'production'); + expect(() => resolveEnv('NEXT_PUBLIC_API_URL', undefined, 'http://localhost:3000')).toThrow( + /NEXT_PUBLIC_API_URL/, + ); + }); + + it('treats an empty string the same as missing', () => { + vi.stubEnv('NODE_ENV', 'production'); + expect(() => resolveEnv('NEXT_PUBLIC_API_URL', '', 'http://localhost:3000')).toThrow( + /NEXT_PUBLIC_API_URL/, + ); + }); +}); diff --git a/src/lib/env.ts b/src/lib/env.ts new file mode 100644 index 0000000..8c05618 --- /dev/null +++ b/src/lib/env.ts @@ -0,0 +1,21 @@ +/** + * Guards against a public env var silently falling back to a dev-only + * default in a production build (#37). `api.ts`'s `NEXT_PUBLIC_API_URL` + * fallback (`http://localhost:3000`) is the case that prompted this: if the + * var is forgotten when building for production, every visitor's browser + * tries to call *its own* localhost instead of the real backend, and the + * failure looks like a backend outage rather than a config mistake. + * + * `NODE_ENV === 'production'` is Next.js's own signal for "this is a real + * build", set automatically by `next build` — not something this app sets + * itself — so this doesn't need a separate build-time flag. + */ +export function resolveEnv(name: string, value: string | undefined, devFallback: string): string { + if (value) return value; + if (process.env.NODE_ENV === 'production') { + throw new Error( + `Missing required environment variable: ${name}. This must be set for production builds.`, + ); + } + return devFallback; +} From df1537914b9fbcc3c23e8af75165b370724cc494 Mon Sep 17 00:00:00 2001 From: floraispretty Date: Sat, 26 Sep 2026 20:12:40 +0100 Subject: [PATCH 2/2] fix(env): use resolveEnv() for API_URL and NETWORK_PASSPHRASE (#37) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the new resolveEnv() guard into the two places already flagged by #37: - api.ts's API_URL (NEXT_PUBLIC_API_URL) — the case described in the issue. - wallet.ts's NETWORK_PASSPHRASE (NEXT_PUBLIC_STELLAR_NETWORK_PASSPHRASE) — same bug class the issue asked to also cover ("and wallet.ts"), and arguably higher-stakes: this is the Stellar network identity used to sign transactions. If forgotten in a production build, this would silently sign against the *testnet* passphrase instead of loudly failing at build/startup, which would then surface later as confusing signature/network-mismatch errors during a real wallet transaction rather than an obvious missing-config error up front. Both keep their existing dev/test fallback value unchanged — only the production behavior changes (throw instead of silently substituting). Scope note: while checking for the same pattern elsewhere, I also found NEXT_PUBLIC_APP_URL falling back to http://localhost:3001 in app/sitemap.ts and app/layout.tsx (same silent-fallback shape, lower stakes — wrong canonical/OG URLs and sitemap entries rather than a broken API or wallet). Left those out of this PR to keep it scoped to what #37 asked for (api.ts + wallet.ts); flagging here in case a follow-up is wanted for the sitemap/layout case too. Verified: `npx vitest run` — 78/78 passing (full suite, no regressions), including the 5 new env.test.ts cases and the existing api.ts/wallet.ts test files. `npx tsc --noEmit` reports pre-existing, unrelated errors confined entirely to app/my-tickets/page.tsx (broken JSX already on main, not touched by this change) — confirmed via `git status` that this PR's changes are limited to src/lib/env.ts, src/lib/env.test.ts, src/lib/api.ts, and src/lib/wallet.ts. --- src/lib/api.ts | 4 +++- src/lib/wallet.ts | 8 ++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/lib/api.ts b/src/lib/api.ts index 0a4607b..d65a8e8 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,4 +1,6 @@ -const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000'; +import { resolveEnv } from './env'; + +const API_URL = resolveEnv('NEXT_PUBLIC_API_URL', process.env.NEXT_PUBLIC_API_URL, 'http://localhost:3000'); const TOKEN_KEY = 'stellartickets.token'; export class ApiError extends Error { diff --git a/src/lib/wallet.ts b/src/lib/wallet.ts index 09e608e..111963f 100644 --- a/src/lib/wallet.ts +++ b/src/lib/wallet.ts @@ -4,9 +4,13 @@ import { getAddress, signTransaction as freighterSignTransaction, } from '@stellar/freighter-api'; +import { resolveEnv } from './env'; -const NETWORK_PASSPHRASE = - process.env.NEXT_PUBLIC_STELLAR_NETWORK_PASSPHRASE ?? 'Test SDF Network ; September 2015'; +const NETWORK_PASSPHRASE = resolveEnv( + 'NEXT_PUBLIC_STELLAR_NETWORK_PASSPHRASE', + process.env.NEXT_PUBLIC_STELLAR_NETWORK_PASSPHRASE, + 'Test SDF Network ; September 2015', +); export class WalletError extends Error {}