From 550ad4a8f653ce8e4cf9826398269375b71cb020 Mon Sep 17 00:00:00 2001 From: olufunsoolutayo Date: Tue, 20 Oct 2026 09:40:15 +0000 Subject: [PATCH 1/2] Validate public URLs at startup --- lib/api/client.ts | 9 ++------ lib/env.ts | 55 +++++++++++++++++++++++++++++++++++++++++++++++ lib/seo/site.ts | 6 ++++-- next.config.ts | 1 + 4 files changed, 62 insertions(+), 9 deletions(-) create mode 100644 lib/env.ts diff --git a/lib/api/client.ts b/lib/api/client.ts index 301a9e46..adf6fdf2 100644 --- a/lib/api/client.ts +++ b/lib/api/client.ts @@ -1,4 +1,5 @@ import type { ApiEnvelope, ApiErrorDetail, ApiErrorEnvelope } from './types'; +import { publicEnv } from '../env'; const API_PREFIX = '/api'; @@ -28,13 +29,7 @@ export class ApiError extends Error { } function resolveBaseUrl(): string { - const origin = process.env.NEXT_PUBLIC_API_URL; - if (!origin) { - throw new Error( - 'NEXT_PUBLIC_API_URL is not set. Copy .env.example to .env.local.' - ); - } - return `${origin.replace(/\/+$/, '')}${API_PREFIX}`; + return `${publicEnv.apiUrl.replace(/\/+$/, '')}${API_PREFIX}`; } async function readJson(response: Response): Promise { diff --git a/lib/env.ts b/lib/env.ts new file mode 100644 index 00000000..8dddd272 --- /dev/null +++ b/lib/env.ts @@ -0,0 +1,55 @@ +const invalidVariables: string[] = []; + +function readHttpUrl( + name: string, + value: string | undefined, + required = false +): string | undefined { + const normalized = value?.trim(); + if (!normalized) { + if (required) invalidVariables.push(name); + return undefined; + } + + try { + const url = new URL(normalized); + if ( + (url.protocol !== 'http:' && url.protocol !== 'https:') || + !url.hostname + ) { + invalidVariables.push(name); + return undefined; + } + } catch { + invalidVariables.push(name); + return undefined; + } + + return normalized; +} + +const apiUrl = readHttpUrl( + 'NEXT_PUBLIC_API_URL', + process.env.NEXT_PUBLIC_API_URL, + true +); +const siteUrl = readHttpUrl( + 'NEXT_PUBLIC_SITE_URL', + process.env.NEXT_PUBLIC_SITE_URL +); +const boundlessAppUrl = readHttpUrl( + 'NEXT_PUBLIC_BOUNDLESS_APP_URL', + process.env.NEXT_PUBLIC_BOUNDLESS_APP_URL +); + +if (invalidVariables.length > 0) { + throw new Error( + `Invalid or missing public environment variable(s): ${invalidVariables.join(', ')}. Copy .env.example to .env.local and set each URL to an absolute http(s) URL.` + ); +} + +export const publicEnv = { + apiUrl: apiUrl as string, + siteUrl, + boundlessAppUrl, +}; \ No newline at end of file diff --git a/lib/seo/site.ts b/lib/seo/site.ts index 2608ed03..f2a2e18c 100644 --- a/lib/seo/site.ts +++ b/lib/seo/site.ts @@ -3,6 +3,8 @@ * structured data both read from here, so the same strings are never * duplicated across routes. */ +import { publicEnv } from '../env'; + /** * Read an origin from the environment. Trims whitespace and trailing slashes * so callers can append a path without producing a double slash. @@ -17,7 +19,7 @@ export const siteConfig = { organizationName: 'Boundless', /** Canonical origin for this app, with no trailing slash. */ url: readOrigin( - process.env.NEXT_PUBLIC_SITE_URL, + publicEnv.siteUrl, 'https://builders.boundlessfi.xyz' ), description: @@ -26,7 +28,7 @@ export const siteConfig = { locale: 'en_US', /** The main Boundless app, where builders sign up and create. */ parentUrl: readOrigin( - process.env.NEXT_PUBLIC_BOUNDLESS_APP_URL, + publicEnv.boundlessAppUrl, 'https://boundlessfi.xyz' ), /** Public path to the white Boundless logo used by structured data. */ diff --git a/next.config.ts b/next.config.ts index 1be5f115..66a70e5c 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,4 +1,5 @@ import type { NextConfig } from 'next'; +import './lib/env'; const nextConfig: NextConfig = { images: { From 75fb94093822890f140628c7d70a42bd4058fbcd Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Thu, 22 Oct 2026 09:47:46 +0100 Subject: [PATCH 2/2] refactor(env): drop the apiUrl cast and use the @/ alias - Narrow apiUrl by checking it in the failure guard instead of casting it with `as string`. - Import the env module as @/lib/env like the rest of lib/ (next.config.ts keeps its relative import, since the alias is not available there). - Add the missing trailing newline to lib/env.ts. --- lib/api/client.ts | 3 ++- lib/env.ts | 8 +++++--- lib/seo/site.ts | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/lib/api/client.ts b/lib/api/client.ts index adf6fdf2..c52ad7c7 100644 --- a/lib/api/client.ts +++ b/lib/api/client.ts @@ -1,5 +1,6 @@ +import { publicEnv } from '@/lib/env'; + import type { ApiEnvelope, ApiErrorDetail, ApiErrorEnvelope } from './types'; -import { publicEnv } from '../env'; const API_PREFIX = '/api'; diff --git a/lib/env.ts b/lib/env.ts index 8dddd272..fd5378b3 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -42,14 +42,16 @@ const boundlessAppUrl = readHttpUrl( process.env.NEXT_PUBLIC_BOUNDLESS_APP_URL ); -if (invalidVariables.length > 0) { +// `apiUrl` is only undefined when it was already reported above; checking it +// here as well lets TypeScript narrow it to a string for `publicEnv`. +if (apiUrl === undefined || invalidVariables.length > 0) { throw new Error( `Invalid or missing public environment variable(s): ${invalidVariables.join(', ')}. Copy .env.example to .env.local and set each URL to an absolute http(s) URL.` ); } export const publicEnv = { - apiUrl: apiUrl as string, + apiUrl, siteUrl, boundlessAppUrl, -}; \ No newline at end of file +}; diff --git a/lib/seo/site.ts b/lib/seo/site.ts index f2a2e18c..4dd2677d 100644 --- a/lib/seo/site.ts +++ b/lib/seo/site.ts @@ -3,7 +3,7 @@ * structured data both read from here, so the same strings are never * duplicated across routes. */ -import { publicEnv } from '../env'; +import { publicEnv } from '@/lib/env'; /** * Read an origin from the environment. Trims whitespace and trailing slashes