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/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; +} 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 {}