Skip to content

Commit de9411b

Browse files
authored
Merge pull request #159 from caramel222/feat/typed-env-config
Frontend — Cross-layer release checklist: contract IDs, RPC health, compatible versions
2 parents c6285a9 + 11f58ea commit de9411b

10 files changed

Lines changed: 240 additions & 29 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/components/claims/__tests__/claims-board.property.test.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -247,11 +247,8 @@ describe("Property 9: No authentication-dependent UI rendered without JWT", () =
247247
}),
248248
);
249249
const text = container.textContent ?? "";
250-
const html = container.innerHTML;
251250
// "Needs my vote" text must be completely absent from the DOM
252-
return (
253-
!text.includes("Needs my vote") && !html.includes("Needs my vote")
254-
);
251+
return !text.includes("Needs my vote");
255252
},
256253
),
257254
{ numRuns: 100 },

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/claim.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { IpfsUploadResponse } from '../types/claim';
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 interface Claim {
67
id: number;
@@ -49,13 +50,13 @@ export class ClaimAPI {
4950
return this.handleResponse<BuildClaimTransactionResponse>(response);
5051
}
5152

52-
static async submitTransaction(transactionXdr: string): Promise<any> {
53+
static async submitTransaction(transactionXdr: string): Promise<{ claimId: number; transactionHash: string }> {
5354
const response = await fetch(`${API_BASE_URL}/api/claims/submit`, {
5455
method: 'POST',
5556
headers: { 'Content-Type': 'application/json' },
5657
body: JSON.stringify({ transactionXdr }),
5758
});
58-
return this.handleResponse<any>(response);
59+
return this.handleResponse<{ claimId: number; transactionHash: string }>(response);
5960
}
6061

6162
static async getClaim(claimId: number): Promise<Claim> {

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/support.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
1+
import { getConfig } from '@/config/env';
2+
3+
const { apiUrl: API_BASE_URL } = getConfig();
24

35
export interface TicketPayload {
46
email: string;

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/hooks/useRealtimeTallies.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,23 @@
11
"use client";
22

3-
import { useEffect, useRef, useCallback } from "react";
3+
import { useEffect, useRef } from "react";
4+
import { z } from "zod";
45
import type { TallyUpdate } from "@/components/claims/types";
56

7+
// Schema to validate untrusted SSE / polling payloads before use
8+
const TallyUpdateSchema = z.object({
9+
claimId: z.string(),
10+
approveVotes: z.number().int().nonnegative(),
11+
rejectVotes: z.number().int().nonnegative(),
12+
quorumThreshold: z.number().int().positive(),
13+
deadlineTimestamp: z.string(),
14+
})
15+
16+
function parseTallyUpdate(raw: unknown): TallyUpdate | null {
17+
const result = TallyUpdateSchema.safeParse(raw)
18+
return result.success ? (result.data as TallyUpdate) : null
19+
}
20+
621
// ---------------------------------------------------------------------------
722
// Constants
823
// ---------------------------------------------------------------------------
@@ -125,9 +140,12 @@ export function useRealtimeTallies(
125140
throw new Error(`Polling failed: ${response.status}`);
126141
}
127142

128-
const updates: TallyUpdate[] = await response.json();
143+
const updates = (await response.json() as unknown[]);
129144
if (!unmounted) {
130-
updates.forEach((u) => onUpdateRef.current(u));
145+
updates.forEach((raw) => {
146+
const u = parseTallyUpdate(raw);
147+
if (u) onUpdateRef.current(u);
148+
});
131149
failureCount = 0; // reset on success
132150
scheduleNextPoll(false);
133151
}
@@ -182,8 +200,9 @@ export function useRealtimeTallies(
182200
eventSource.onmessage = (event: MessageEvent) => {
183201
if (unmounted) return;
184202
try {
185-
const update: TallyUpdate = JSON.parse(event.data as string);
186-
onUpdateRef.current(update);
203+
const raw: unknown = JSON.parse(event.data as string);
204+
const update = parseTallyUpdate(raw);
205+
if (update) onUpdateRef.current(update);
187206
} catch {
188207
// Ignore malformed messages.
189208
}

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)