Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 55 additions & 3 deletions frontend/.env.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,58 @@
NEXT_PUBLIC_API_URL=http://localhost:3000
# CAPTCHA site key (public, safe to expose)
# Use Cloudflare Turnstile or hCaptcha site key
# =============================================================================
# NiffyInsure Frontend — Environment Variables
# Copy this file to .env.local and fill in values for your environment.
# Owner notes are in parentheses — contact that team when rotating values.
# =============================================================================

# -----------------------------------------------------------------------------
# Backend API (owner: backend team — update on each deployment)
# -----------------------------------------------------------------------------
# Base URL of the NiffyInsure REST API. No trailing slash.
NEXT_PUBLIC_API_URL=http://localhost:3001

# -----------------------------------------------------------------------------
# Stellar / Soroban RPC (owner: infra team — per-network)
# -----------------------------------------------------------------------------
# Soroban RPC endpoint. Switch to mainnet URL for production.
NEXT_PUBLIC_SOROBAN_RPC_URL=https://soroban-testnet.stellar.org

# Horizon REST endpoint. Switch to mainnet URL for production.
NEXT_PUBLIC_HORIZON_URL=https://horizon-testnet.stellar.org

# Active network identifier. Affects explorer links and consistency warnings.
# Values: testnet | public
NEXT_PUBLIC_NETWORK=testnet

# -----------------------------------------------------------------------------
# Contract addresses (owner: contracts team — update after each deploy)
# Rotation: when deployment-registry.json changes, update this value and redeploy.
# -----------------------------------------------------------------------------
# Deployed niffyinsure contract ID for the active network.
NEXT_PUBLIC_CONTRACT_ID=

# -----------------------------------------------------------------------------
# IPFS (owner: infra team)
# -----------------------------------------------------------------------------
# IPFS gateway base URL used to resolve CIDs. No trailing slash.
NEXT_PUBLIC_IPFS_GATEWAY=https://ipfs.io/ipfs

# -----------------------------------------------------------------------------
# Captcha (owner: security team)
# -----------------------------------------------------------------------------
# Public site key — safe to expose in the browser.
NEXT_PUBLIC_CAPTCHA_SITE_KEY=

# Provider: turnstile | hcaptcha
NEXT_PUBLIC_CAPTCHA_PROVIDER=turnstile

# -----------------------------------------------------------------------------
# CSP / Observability (server-only — never prefix with NEXT_PUBLIC_)
# -----------------------------------------------------------------------------
# Set to "true" to switch CSP to report-only mode during rollout.
# CSP_REPORT_ONLY=false

# Violation collector endpoint (e.g. Sentry, report-uri.com).
# CSP_REPORT_URI=

# Set to "true" to enable Next.js bundle analyzer output.
# ANALYZE=false
Original file line number Diff line number Diff line change
Expand Up @@ -247,11 +247,8 @@ describe("Property 9: No authentication-dependent UI rendered without JWT", () =
}),
);
const text = container.textContent ?? "";
const html = container.innerHTML;
// "Needs my vote" text must be completely absent from the DOM
return (
!text.includes("Needs my vote") && !html.includes("Needs my vote")
);
return !text.includes("Needs my vote");
},
),
{ numRuns: 100 },
Expand Down
143 changes: 143 additions & 0 deletions frontend/src/config/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
/**
* Typed, validated environment configuration.
*
* All NEXT_PUBLIC_* variables are validated at module load time using zod.
* A missing or malformed required variable throws at build time (or on first
* import in dev), preventing silent undefined values from reaching production.
*
* Variable ownership:
* NEXT_PUBLIC_API_URL — backend team, updated on each deployment
* NEXT_PUBLIC_SOROBAN_RPC_URL — infra team, per-network
* NEXT_PUBLIC_HORIZON_URL — infra team, per-network
* NEXT_PUBLIC_CONTRACT_ID — contracts team, updated after each deploy
* NEXT_PUBLIC_IPFS_GATEWAY — infra team
* NEXT_PUBLIC_NETWORK — set per environment (testnet | public)
* NEXT_PUBLIC_CAPTCHA_SITE_KEY — security team
* NEXT_PUBLIC_CAPTCHA_PROVIDER — security team (turnstile | hcaptcha)
*
* Rotation: when backend deployment-registry.json changes contract addresses,
* update NEXT_PUBLIC_CONTRACT_ID and redeploy the frontend.
*/

import { z } from 'zod'

const envSchema = z.object({
/** Backend REST API base URL — no trailing slash */
NEXT_PUBLIC_API_URL: z.string().url('NEXT_PUBLIC_API_URL must be a valid URL'),

/** Soroban RPC endpoint for the active network */
NEXT_PUBLIC_SOROBAN_RPC_URL: z
.string()
.url('NEXT_PUBLIC_SOROBAN_RPC_URL must be a valid URL')
.default('https://soroban-testnet.stellar.org'),

/** Horizon REST endpoint for the active network */
NEXT_PUBLIC_HORIZON_URL: z
.string()
.url('NEXT_PUBLIC_HORIZON_URL must be a valid URL')
.default('https://horizon-testnet.stellar.org'),

/** Deployed niffyinsure contract ID for the active network */
NEXT_PUBLIC_CONTRACT_ID: z
.string()
.min(1, 'NEXT_PUBLIC_CONTRACT_ID must not be empty')
.default(''),

/** IPFS gateway base URL used to resolve CIDs — no trailing slash */
NEXT_PUBLIC_IPFS_GATEWAY: z
.string()
.url('NEXT_PUBLIC_IPFS_GATEWAY must be a valid URL')
.default('https://ipfs.io/ipfs'),

/**
* Active Stellar network.
* - "testnet" → testnet RPC / Horizon / explorer
* - "public" → mainnet RPC / Horizon / explorer
*/
NEXT_PUBLIC_NETWORK: z.enum(['testnet', 'public']).default('testnet'),

/** Captcha site key (public, safe to expose) */
NEXT_PUBLIC_CAPTCHA_SITE_KEY: z.string().default(''),

/** Captcha provider */
NEXT_PUBLIC_CAPTCHA_PROVIDER: z.enum(['turnstile', 'hcaptcha']).default('turnstile'),
})

type Env = z.infer<typeof envSchema>

function parseEnv(): Env {
const result = envSchema.safeParse({
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
NEXT_PUBLIC_SOROBAN_RPC_URL: process.env.NEXT_PUBLIC_SOROBAN_RPC_URL,
NEXT_PUBLIC_HORIZON_URL: process.env.NEXT_PUBLIC_HORIZON_URL,
NEXT_PUBLIC_CONTRACT_ID: process.env.NEXT_PUBLIC_CONTRACT_ID,
NEXT_PUBLIC_IPFS_GATEWAY: process.env.NEXT_PUBLIC_IPFS_GATEWAY,
NEXT_PUBLIC_NETWORK: process.env.NEXT_PUBLIC_NETWORK,
NEXT_PUBLIC_CAPTCHA_SITE_KEY: process.env.NEXT_PUBLIC_CAPTCHA_SITE_KEY,
NEXT_PUBLIC_CAPTCHA_PROVIDER: process.env.NEXT_PUBLIC_CAPTCHA_PROVIDER,
})

if (!result.success) {
const messages = result.error.errors
.map((e) => ` ${e.path.join('.')}: ${e.message}`)
.join('\n')
throw new Error(`Invalid environment configuration:\n${messages}`)
}

// Dev-time consistency warning: mainnet contract ID looks like testnet config
if (
process.env.NODE_ENV === 'development' &&
result.data.NEXT_PUBLIC_NETWORK === 'public' &&
result.data.NEXT_PUBLIC_SOROBAN_RPC_URL.includes('testnet')
) {
console.warn(
'[env] Warning: NEXT_PUBLIC_NETWORK=public but NEXT_PUBLIC_SOROBAN_RPC_URL points at testnet.',
)
}

return result.data
}

const env = parseEnv()

/**
* Returns the validated, typed environment configuration.
* Use this instead of accessing process.env directly in hooks and API clients.
*
* @example
* import { getConfig } from '@/config/env'
* const { apiUrl, network } = getConfig()
*/
export function getConfig() {
return {
/** Backend REST API base URL */
apiUrl: env.NEXT_PUBLIC_API_URL,

/** Soroban RPC URL for the active network */
sorobanRpcUrl: env.NEXT_PUBLIC_SOROBAN_RPC_URL,

/** Horizon REST URL for the active network */
horizonUrl: env.NEXT_PUBLIC_HORIZON_URL,

/** Deployed contract ID for the active network */
contractId: env.NEXT_PUBLIC_CONTRACT_ID,

/** IPFS gateway base URL */
ipfsGateway: env.NEXT_PUBLIC_IPFS_GATEWAY,

/** Active Stellar network */
network: env.NEXT_PUBLIC_NETWORK,

/** Captcha site key */
captchaSiteKey: env.NEXT_PUBLIC_CAPTCHA_SITE_KEY,

/** Captcha provider */
captchaProvider: env.NEXT_PUBLIC_CAPTCHA_PROVIDER,

/** Stellar block explorer base URL for the active network */
explorerBase:
env.NEXT_PUBLIC_NETWORK === 'public'
? 'https://stellar.expert/explorer/public/tx'
: 'https://stellar.expert/explorer/testnet/tx',
} as const
}
7 changes: 4 additions & 3 deletions frontend/src/lib/api/claim.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { IpfsUploadResponse } from '../types/claim';
import { getConfig } from '@/config/env';

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

export interface Claim {
id: number;
Expand Down Expand Up @@ -49,13 +50,13 @@ export class ClaimAPI {
return this.handleResponse<BuildClaimTransactionResponse>(response);
}

static async submitTransaction(transactionXdr: string): Promise<any> {
static async submitTransaction(transactionXdr: string): Promise<{ claimId: number; transactionHash: string }> {
const response = await fetch(`${API_BASE_URL}/api/claims/submit`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ transactionXdr }),
});
return this.handleResponse<any>(response);
return this.handleResponse<{ claimId: number; transactionHash: string }>(response);
}

static async getClaim(claimId: number): Promise<Claim> {
Expand Down
10 changes: 4 additions & 6 deletions frontend/src/lib/api/policy.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { PolicyInitiationData, Transaction, Policy, PolicyError as PolicyErrorType } from '@/lib/schemas/policy'
import { getConfig } from '@/config/env'

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

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

export function getExplorerUrl(transactionHash: string, network: 'TESTNET' | 'PUBLIC' = 'TESTNET'): string {
const baseUrl = network === 'TESTNET'
? 'https://stellar.expert/explorer/testnet/tx'
: 'https://stellar.expert/explorer/public/tx'
return `${baseUrl}/${transactionHash}`
export function getExplorerUrl(transactionHash: string): string {
return `${EXPLORER_BASE}/${transactionHash}`
}
3 changes: 2 additions & 1 deletion frontend/src/lib/api/quote.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { QuoteFormData, QuoteResponse, QuoteError as QuoteErrorType } from '@/lib/schemas/quote'
import { getConfig } from '@/config/env'

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

export class QuoteAPI {
private static async handleResponse<T>(response: Response): Promise<T> {
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/lib/api/support.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
import { getConfig } from '@/config/env';

const { apiUrl: API_BASE_URL } = getConfig();

export interface TicketPayload {
email: string;
Expand Down
7 changes: 2 additions & 5 deletions frontend/src/lib/api/vote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,9 @@ import {
VoteResponse,
VoteResponseSchema,
} from '@/lib/schemas/vote'
import { getConfig } from '@/config/env'

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

async function handleResponse<T>(res: Response): Promise<T> {
if (!res.ok) {
Expand Down
29 changes: 24 additions & 5 deletions frontend/src/lib/hooks/useRealtimeTallies.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,23 @@
"use client";

import { useEffect, useRef, useCallback } from "react";
import { useEffect, useRef } from "react";
import { z } from "zod";
import type { TallyUpdate } from "@/components/claims/types";

// Schema to validate untrusted SSE / polling payloads before use
const TallyUpdateSchema = z.object({
claimId: z.string(),
approveVotes: z.number().int().nonnegative(),
rejectVotes: z.number().int().nonnegative(),
quorumThreshold: z.number().int().positive(),
deadlineTimestamp: z.string(),
})

function parseTallyUpdate(raw: unknown): TallyUpdate | null {
const result = TallyUpdateSchema.safeParse(raw)
return result.success ? (result.data as TallyUpdate) : null
}

// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -125,9 +140,12 @@ export function useRealtimeTallies(
throw new Error(`Polling failed: ${response.status}`);
}

const updates: TallyUpdate[] = await response.json();
const updates = (await response.json() as unknown[]);
if (!unmounted) {
updates.forEach((u) => onUpdateRef.current(u));
updates.forEach((raw) => {
const u = parseTallyUpdate(raw);
if (u) onUpdateRef.current(u);
});
failureCount = 0; // reset on success
scheduleNextPoll(false);
}
Expand Down Expand Up @@ -182,8 +200,9 @@ export function useRealtimeTallies(
eventSource.onmessage = (event: MessageEvent) => {
if (unmounted) return;
try {
const update: TallyUpdate = JSON.parse(event.data as string);
onUpdateRef.current(update);
const raw: unknown = JSON.parse(event.data as string);
const update = parseTallyUpdate(raw);
if (update) onUpdateRef.current(update);
} catch {
// Ignore malformed messages.
}
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/lib/ipfs-upload.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { IpfsUploadResponse } from '../types/claim';
import { getConfig } from '@/config/env';

export interface UploadProgress {
loaded: number;
Expand All @@ -8,7 +9,7 @@ export interface UploadProgress {

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

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

/**
* Uploads a file to IPFS via the backend with progress tracking and retry logic.
Expand Down
Loading