Skip to content

Commit b35ca4a

Browse files
committed
feat(frontend): typed env config with zod validation — closes #70
1 parent 25a245d commit b35ca4a

6 files changed

Lines changed: 208 additions & 16 deletions

File tree

frontend/.env.example

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,58 @@
1-
NEXT_PUBLIC_API_URL=http://localhost:3000
2-
# CAPTCHA site key (public, safe to expose)
3-
# Use Cloudflare Turnstile or hCaptcha site key
1+
# =============================================================================
2+
# NiffyInsure Frontend — Environment Variables
3+
# Copy this file to .env.local and fill in values for your environment.
4+
# Owner notes are in parentheses — contact that team when rotating values.
5+
# =============================================================================
6+
7+
# -----------------------------------------------------------------------------
8+
# Backend API (owner: backend team — update on each deployment)
9+
# -----------------------------------------------------------------------------
10+
# Base URL of the NiffyInsure REST API. No trailing slash.
11+
NEXT_PUBLIC_API_URL=http://localhost:3001
12+
13+
# -----------------------------------------------------------------------------
14+
# Stellar / Soroban RPC (owner: infra team — per-network)
15+
# -----------------------------------------------------------------------------
16+
# Soroban RPC endpoint. Switch to mainnet URL for production.
17+
NEXT_PUBLIC_SOROBAN_RPC_URL=https://soroban-testnet.stellar.org
18+
19+
# Horizon REST endpoint. Switch to mainnet URL for production.
20+
NEXT_PUBLIC_HORIZON_URL=https://horizon-testnet.stellar.org
21+
22+
# Active network identifier. Affects explorer links and consistency warnings.
23+
# Values: testnet | public
24+
NEXT_PUBLIC_NETWORK=testnet
25+
26+
# -----------------------------------------------------------------------------
27+
# Contract addresses (owner: contracts team — update after each deploy)
28+
# Rotation: when deployment-registry.json changes, update this value and redeploy.
29+
# -----------------------------------------------------------------------------
30+
# Deployed niffyinsure contract ID for the active network.
31+
NEXT_PUBLIC_CONTRACT_ID=
32+
33+
# -----------------------------------------------------------------------------
34+
# IPFS (owner: infra team)
35+
# -----------------------------------------------------------------------------
36+
# IPFS gateway base URL used to resolve CIDs. No trailing slash.
37+
NEXT_PUBLIC_IPFS_GATEWAY=https://ipfs.io/ipfs
38+
39+
# -----------------------------------------------------------------------------
40+
# Captcha (owner: security team)
41+
# -----------------------------------------------------------------------------
42+
# Public site key — safe to expose in the browser.
443
NEXT_PUBLIC_CAPTCHA_SITE_KEY=
44+
545
# Provider: turnstile | hcaptcha
646
NEXT_PUBLIC_CAPTCHA_PROVIDER=turnstile
47+
48+
# -----------------------------------------------------------------------------
49+
# CSP / Observability (server-only — never prefix with NEXT_PUBLIC_)
50+
# -----------------------------------------------------------------------------
51+
# Set to "true" to switch CSP to report-only mode during rollout.
52+
# CSP_REPORT_ONLY=false
53+
54+
# Violation collector endpoint (e.g. Sentry, report-uri.com).
55+
# CSP_REPORT_URI=
56+
57+
# Set to "true" to enable Next.js bundle analyzer output.
58+
# ANALYZE=false

frontend/src/config/env.ts

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
/**
2+
* Typed, validated environment configuration.
3+
*
4+
* All NEXT_PUBLIC_* variables are validated at module load time using zod.
5+
* A missing or malformed required variable throws at build time (or on first
6+
* import in dev), preventing silent undefined values from reaching production.
7+
*
8+
* Variable ownership:
9+
* NEXT_PUBLIC_API_URL — backend team, updated on each deployment
10+
* NEXT_PUBLIC_SOROBAN_RPC_URL — infra team, per-network
11+
* NEXT_PUBLIC_HORIZON_URL — infra team, per-network
12+
* NEXT_PUBLIC_CONTRACT_ID — contracts team, updated after each deploy
13+
* NEXT_PUBLIC_IPFS_GATEWAY — infra team
14+
* NEXT_PUBLIC_NETWORK — set per environment (testnet | public)
15+
* NEXT_PUBLIC_CAPTCHA_SITE_KEY — security team
16+
* NEXT_PUBLIC_CAPTCHA_PROVIDER — security team (turnstile | hcaptcha)
17+
*
18+
* Rotation: when backend deployment-registry.json changes contract addresses,
19+
* update NEXT_PUBLIC_CONTRACT_ID and redeploy the frontend.
20+
*/
21+
22+
import { z } from 'zod'
23+
24+
const envSchema = z.object({
25+
/** Backend REST API base URL — no trailing slash */
26+
NEXT_PUBLIC_API_URL: z.string().url('NEXT_PUBLIC_API_URL must be a valid URL'),
27+
28+
/** Soroban RPC endpoint for the active network */
29+
NEXT_PUBLIC_SOROBAN_RPC_URL: z
30+
.string()
31+
.url('NEXT_PUBLIC_SOROBAN_RPC_URL must be a valid URL')
32+
.default('https://soroban-testnet.stellar.org'),
33+
34+
/** Horizon REST endpoint for the active network */
35+
NEXT_PUBLIC_HORIZON_URL: z
36+
.string()
37+
.url('NEXT_PUBLIC_HORIZON_URL must be a valid URL')
38+
.default('https://horizon-testnet.stellar.org'),
39+
40+
/** Deployed niffyinsure contract ID for the active network */
41+
NEXT_PUBLIC_CONTRACT_ID: z
42+
.string()
43+
.min(1, 'NEXT_PUBLIC_CONTRACT_ID must not be empty')
44+
.default(''),
45+
46+
/** IPFS gateway base URL used to resolve CIDs — no trailing slash */
47+
NEXT_PUBLIC_IPFS_GATEWAY: z
48+
.string()
49+
.url('NEXT_PUBLIC_IPFS_GATEWAY must be a valid URL')
50+
.default('https://ipfs.io/ipfs'),
51+
52+
/**
53+
* Active Stellar network.
54+
* - "testnet" → testnet RPC / Horizon / explorer
55+
* - "public" → mainnet RPC / Horizon / explorer
56+
*/
57+
NEXT_PUBLIC_NETWORK: z.enum(['testnet', 'public']).default('testnet'),
58+
59+
/** Captcha site key (public, safe to expose) */
60+
NEXT_PUBLIC_CAPTCHA_SITE_KEY: z.string().default(''),
61+
62+
/** Captcha provider */
63+
NEXT_PUBLIC_CAPTCHA_PROVIDER: z.enum(['turnstile', 'hcaptcha']).default('turnstile'),
64+
})
65+
66+
type Env = z.infer<typeof envSchema>
67+
68+
function parseEnv(): Env {
69+
const result = envSchema.safeParse({
70+
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
71+
NEXT_PUBLIC_SOROBAN_RPC_URL: process.env.NEXT_PUBLIC_SOROBAN_RPC_URL,
72+
NEXT_PUBLIC_HORIZON_URL: process.env.NEXT_PUBLIC_HORIZON_URL,
73+
NEXT_PUBLIC_CONTRACT_ID: process.env.NEXT_PUBLIC_CONTRACT_ID,
74+
NEXT_PUBLIC_IPFS_GATEWAY: process.env.NEXT_PUBLIC_IPFS_GATEWAY,
75+
NEXT_PUBLIC_NETWORK: process.env.NEXT_PUBLIC_NETWORK,
76+
NEXT_PUBLIC_CAPTCHA_SITE_KEY: process.env.NEXT_PUBLIC_CAPTCHA_SITE_KEY,
77+
NEXT_PUBLIC_CAPTCHA_PROVIDER: process.env.NEXT_PUBLIC_CAPTCHA_PROVIDER,
78+
})
79+
80+
if (!result.success) {
81+
const messages = result.error.errors
82+
.map((e) => ` ${e.path.join('.')}: ${e.message}`)
83+
.join('\n')
84+
throw new Error(`Invalid environment configuration:\n${messages}`)
85+
}
86+
87+
// Dev-time consistency warning: mainnet contract ID looks like testnet config
88+
if (
89+
process.env.NODE_ENV === 'development' &&
90+
result.data.NEXT_PUBLIC_NETWORK === 'public' &&
91+
result.data.NEXT_PUBLIC_SOROBAN_RPC_URL.includes('testnet')
92+
) {
93+
console.warn(
94+
'[env] Warning: NEXT_PUBLIC_NETWORK=public but NEXT_PUBLIC_SOROBAN_RPC_URL points at testnet.',
95+
)
96+
}
97+
98+
return result.data
99+
}
100+
101+
const env = parseEnv()
102+
103+
/**
104+
* Returns the validated, typed environment configuration.
105+
* Use this instead of accessing process.env directly in hooks and API clients.
106+
*
107+
* @example
108+
* import { getConfig } from '@/config/env'
109+
* const { apiUrl, network } = getConfig()
110+
*/
111+
export function getConfig() {
112+
return {
113+
/** Backend REST API base URL */
114+
apiUrl: env.NEXT_PUBLIC_API_URL,
115+
116+
/** Soroban RPC URL for the active network */
117+
sorobanRpcUrl: env.NEXT_PUBLIC_SOROBAN_RPC_URL,
118+
119+
/** Horizon REST URL for the active network */
120+
horizonUrl: env.NEXT_PUBLIC_HORIZON_URL,
121+
122+
/** Deployed contract ID for the active network */
123+
contractId: env.NEXT_PUBLIC_CONTRACT_ID,
124+
125+
/** IPFS gateway base URL */
126+
ipfsGateway: env.NEXT_PUBLIC_IPFS_GATEWAY,
127+
128+
/** Active Stellar network */
129+
network: env.NEXT_PUBLIC_NETWORK,
130+
131+
/** Captcha site key */
132+
captchaSiteKey: env.NEXT_PUBLIC_CAPTCHA_SITE_KEY,
133+
134+
/** Captcha provider */
135+
captchaProvider: env.NEXT_PUBLIC_CAPTCHA_PROVIDER,
136+
137+
/** Stellar block explorer base URL for the active network */
138+
explorerBase:
139+
env.NEXT_PUBLIC_NETWORK === 'public'
140+
? 'https://stellar.expert/explorer/public/tx'
141+
: 'https://stellar.expert/explorer/testnet/tx',
142+
} as const
143+
}

frontend/src/lib/api/policy.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { PolicyInitiationData, Transaction, Policy, PolicyError as PolicyErrorType } from '@/lib/schemas/policy'
2+
import { getConfig } from '@/config/env'
23

3-
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'
4+
const { apiUrl: API_BASE_URL, explorerBase: EXPLORER_BASE } = getConfig()
45

56
export class PolicyAPI {
67
private static async handleResponse<T>(response: Response): Promise<T> {
@@ -100,9 +101,6 @@ export function getPolicyErrorMessage(error: PolicyError): string {
100101
return POLICY_ERROR_MESSAGES[error.code] || error.message || POLICY_ERROR_MESSAGES.UNKNOWN_ERROR
101102
}
102103

103-
export function getExplorerUrl(transactionHash: string, network: 'TESTNET' | 'PUBLIC' = 'TESTNET'): string {
104-
const baseUrl = network === 'TESTNET'
105-
? 'https://stellar.expert/explorer/testnet/tx'
106-
: 'https://stellar.expert/explorer/public/tx'
107-
return `${baseUrl}/${transactionHash}`
104+
export function getExplorerUrl(transactionHash: string): string {
105+
return `${EXPLORER_BASE}/${transactionHash}`
108106
}

frontend/src/lib/api/quote.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { QuoteFormData, QuoteResponse, QuoteError as QuoteErrorType } from '@/lib/schemas/quote'
2+
import { getConfig } from '@/config/env'
23

3-
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'
4+
const { apiUrl: API_BASE_URL } = getConfig()
45

56
export class QuoteAPI {
67
private static async handleResponse<T>(response: Response): Promise<T> {

frontend/src/lib/api/vote.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,9 @@ import {
77
VoteResponse,
88
VoteResponseSchema,
99
} from '@/lib/schemas/vote'
10+
import { getConfig } from '@/config/env'
1011

11-
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'
12-
const EXPLORER_BASE =
13-
process.env.NEXT_PUBLIC_NETWORK === 'PUBLIC'
14-
? 'https://stellar.expert/explorer/public/tx'
15-
: 'https://stellar.expert/explorer/testnet/tx'
12+
const { apiUrl: API_BASE, explorerBase: EXPLORER_BASE } = getConfig()
1613

1714
async function handleResponse<T>(res: Response): Promise<T> {
1815
if (!res.ok) {

frontend/src/lib/ipfs-upload.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { IpfsUploadResponse } from '../types/claim';
2+
import { getConfig } from '@/config/env';
23

34
export interface UploadProgress {
45
loaded: number;
@@ -8,7 +9,7 @@ export interface UploadProgress {
89

910
export type ProgressCallback = (progress: UploadProgress) => void;
1011

11-
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
12+
const { apiUrl: API_BASE_URL } = getConfig();
1213

1314
/**
1415
* Uploads a file to IPFS via the backend with progress tracking and retry logic.

0 commit comments

Comments
 (0)