diff --git a/README.md b/README.md
index efa3d4fc42e..4c1ffc26328 100644
--- a/README.md
+++ b/README.md
@@ -54,6 +54,7 @@ There are three projects which can be run individually.
### Web
```
+cp apps/web/.env.local.example apps/web/.env.local
yarn workspace @app/web dev
```
diff --git a/apps/web/app/(basenames)/api/basenames/avatar/ipfsUpload/route.ts b/apps/web/app/(basenames)/api/basenames/avatar/ipfsUpload/route.ts
index d807ec18541..64a1ea30401 100644
--- a/apps/web/app/(basenames)/api/basenames/avatar/ipfsUpload/route.ts
+++ b/apps/web/app/(basenames)/api/basenames/avatar/ipfsUpload/route.ts
@@ -1,4 +1,4 @@
-import { pinata } from 'apps/web/src/utils/pinata';
+import { getPinata } from 'apps/web/src/utils/pinata';
import { isDevelopment } from 'libs/base-ui/constants';
import { NextResponse, NextRequest } from 'next/server';
@@ -50,6 +50,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'File is too large' }, { status: 500 });
// Upload
+ const pinata = getPinata();
const uploadData = await pinata.upload.file(file, {
groupId: '765ab5e4-0bc3-47bb-9d6a-35b308291009',
metadata: {
@@ -58,6 +59,7 @@ export async function POST(request: NextRequest) {
});
return NextResponse.json(uploadData, { status: 200 });
} catch (e) {
- return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
+ const errorMessage = e instanceof Error ? e.message : 'Internal Server Error';
+ return NextResponse.json({ error: errorMessage }, { status: 500 });
}
}
diff --git a/apps/web/app/api/cloudinaryUrl/route.ts b/apps/web/app/api/cloudinaryUrl/route.ts
index 24cdc46f14d..564f1ab786d 100644
--- a/apps/web/app/api/cloudinaryUrl/route.ts
+++ b/apps/web/app/api/cloudinaryUrl/route.ts
@@ -2,13 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { v2 as cloudinary } from 'cloudinary';
import { createHash } from 'crypto';
import { logger } from 'apps/web/src/utils/logger';
-
-// Configure Cloudinary
-cloudinary.config({
- cloud_name: process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME,
- api_key: process.env.CLOUDINARY_API_KEY,
- api_secret: process.env.CLOUDINARY_API_SECRET,
-});
+import { requireEnv } from 'apps/web/src/utils/env';
const folderName = 'base-org-uploads';
@@ -86,6 +80,13 @@ async function getCloudinaryMediaUrl({
export async function POST(request: NextRequest) {
try {
+ // Only validate when this API route is actually invoked.
+ cloudinary.config({
+ cloud_name: requireEnv('NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME', { feature: 'Cloudinary' }),
+ api_key: requireEnv('CLOUDINARY_API_KEY', { feature: 'Cloudinary' }),
+ api_secret: requireEnv('CLOUDINARY_API_SECRET', { feature: 'Cloudinary' }),
+ });
+
const body = (await request.json()) as CloudinaryMediaUrlRequest;
const { media, width } = body;
@@ -105,6 +106,7 @@ export async function POST(request: NextRequest) {
}
} catch (error) {
logger.error('Error processing Cloudinary URL:', error);
- return NextResponse.json({ error: 'Failed to process Cloudinary URL' }, { status: 500 });
+ const errorMessage = error instanceof Error ? error.message : 'Failed to process Cloudinary URL';
+ return NextResponse.json({ error: errorMessage }, { status: 500 });
}
}
diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts
index 1b3be0840f3..830fb594ca2 100644
--- a/apps/web/next-env.d.ts
+++ b/apps/web/next-env.d.ts
@@ -1,5 +1,6 @@
///
///
+///
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/apps/web/package.json b/apps/web/package.json
index ba41491730b..4ac3a8c949c 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -3,17 +3,18 @@
"version": "0.0.0",
"private": true,
"scripts": {
+ "check-env": "node ./scripts/check-env.js",
"start": "node --require ./tracer/initialize.js ./node_modules/.bin/next start",
"build": "next build",
"test": "jest",
"analyze": "ANALYZE=true next build",
- "dev": "node --require ./tracer/initialize.js ./node_modules/.bin/next dev",
+ "dev": "yarn check-env && node --require ./tracer/initialize.js ./node_modules/.bin/next dev",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:debug": "playwright test --debug",
"test:e2e:headed": "playwright test --headed",
"e2e:metamask:prepare": "node ../../node_modules/@coinbase/onchaintestkit/src/cli/prepare-metamask.mjs",
- "dev:million": "cross-env MILLION_LINT=true node --require ./tracer/initialize.js ./node_modules/.bin/next dev",
+ "dev:million": "yarn check-env && cross-env MILLION_LINT=true node --require ./tracer/initialize.js ./node_modules/.bin/next dev",
"lint": "next lint",
"update-contributors": "node ./scripts/updateContributors.js && npx prettier ./src/components/CoreContributors/CoreContributors.json -w",
"fetch-mirror-blog": "node ./scripts/pullLatestBlogPosts.js"
diff --git a/apps/web/scripts/check-env.js b/apps/web/scripts/check-env.js
new file mode 100644
index 00000000000..224bbc5a5cb
--- /dev/null
+++ b/apps/web/scripts/check-env.js
@@ -0,0 +1,35 @@
+const fs = require('fs');
+const path = require('path');
+
+const colors = {
+ red: '\x1b[31m',
+ green: '\x1b[32m',
+ yellow: '\x1b[33m',
+ reset: '\x1b[0m',
+};
+
+const rootDir = path.join(__dirname, '..');
+const examplePath = path.join(rootDir, '.env.local.example');
+const localPath = path.join(rootDir, '.env.local');
+
+console.log('Checking environment variables...');
+
+if (!fs.existsSync(localPath)) {
+ console.warn(`${colors.yellow}Warning: .env.local file is missing.${colors.reset}`);
+
+ if (fs.existsSync(examplePath)) {
+ console.log(
+ `Please copy ${colors.yellow}.env.local.example${colors.reset} to ${colors.yellow}.env.local${colors.reset} and configure it.`
+ );
+ } else {
+ console.log(
+ `Please ensure you have a ${colors.yellow}.env.local${colors.reset} file configured.`
+ );
+ }
+ // Do not block local dev; features will validate their own env vars on-demand.
+ process.exit(0);
+}
+
+console.log(
+ `${colors.green}✅ Environment configuration file found.${colors.reset}\n`
+);
\ No newline at end of file
diff --git a/apps/web/src/utils/bugsnag.ts b/apps/web/src/utils/bugsnag.ts
index 768ca228af4..1f54941b269 100644
--- a/apps/web/src/utils/bugsnag.ts
+++ b/apps/web/src/utils/bugsnag.ts
@@ -1,6 +1,7 @@
// import React from 'react';
import type { BugsnagPluginReactResult } from '@bugsnag/plugin-react';
import type { OnErrorCallback } from '@bugsnag/core/types/common';
+import { formatMissingEnvMessage, getEnv } from 'apps/web/src/utils/env';
type BugsnagClientType = {
notify: (error: Error | string, onError?: OnErrorCallback) => void;
@@ -26,11 +27,31 @@ async function inititializeBugsnag() {
BugsnagPluginReactInstance = (await import('@bugsnag/plugin-react')).default;
try {
+ const apiKey = getEnv('NEXT_PUBLIC_BUGSNAG_API_KEY');
+ const notifyUrl = getEnv('NEXT_PUBLIC_BUGSNAG_NOTIFY_URL');
+ const sessionsUrl = getEnv('NEXT_PUBLIC_BUGSNAG_SESSIONS_URL');
+
+ // Bugsnag is optional; only initialize when it is configured.
+ if (!apiKey || !notifyUrl || !sessionsUrl) {
+ if (process.env.NODE_ENV !== 'production') {
+ const missing = [
+ !apiKey ? 'NEXT_PUBLIC_BUGSNAG_API_KEY' : null,
+ !notifyUrl ? 'NEXT_PUBLIC_BUGSNAG_NOTIFY_URL' : null,
+ !sessionsUrl ? 'NEXT_PUBLIC_BUGSNAG_SESSIONS_URL' : null,
+ ].filter(Boolean);
+ console.warn(
+ formatMissingEnvMessage(missing.join(', '), { feature: 'Bugsnag' }),
+ );
+ }
+ BugsnagClient = null;
+ return;
+ }
+
BugsnagClient.start({
- apiKey: process.env.NEXT_PUBLIC_BUGSNAG_API_KEY as string,
+ apiKey,
endpoints: {
- notify: process.env.NEXT_PUBLIC_BUGSNAG_NOTIFY_URL as string,
- sessions: process.env.NEXT_PUBLIC_BUGSNAG_SESSIONS_URL as string,
+ notify: notifyUrl,
+ sessions: sessionsUrl,
},
plugins: [new BugsnagPluginReactInstance()],
});
diff --git a/apps/web/src/utils/datastores/postgres/index.ts b/apps/web/src/utils/datastores/postgres/index.ts
index 02e69bd133c..37bdd3f0c0b 100644
--- a/apps/web/src/utils/datastores/postgres/index.ts
+++ b/apps/web/src/utils/datastores/postgres/index.ts
@@ -3,12 +3,23 @@ import { Pool } from 'pg';
import { isDevelopment } from 'apps/web/src/constants';
import { Database } from './types';
import { logger } from 'apps/web/src/utils/logger';
+import { requireEnv } from 'apps/web/src/utils/env';
function createDefaultPostgresManager() {
- const user = isDevelopment ? process.env.POSTGRES_USER_DEVELOPMENT : process.env.POSTGRES_USER;
- const password = isDevelopment ? process.env.POSTGRES_PASSWORD_DEVELOPMENT : process.env.POSTGRES_PASSWORD;
- const host = isDevelopment ? process.env.POSTGRES_HOST_DEVELOPMENT : process.env.POSTGRES_HOST;
- const dbName = isDevelopment ? process.env.POSTGRES_DB_NAME_DEVELOPMENT : process.env.POSTGRES_DB_NAME;
+ // Only validate when DB is actually used (getDb()).
+ const feature = 'Postgres';
+ const user = isDevelopment
+ ? requireEnv('POSTGRES_USER_DEVELOPMENT', { feature })
+ : requireEnv('POSTGRES_USER', { feature });
+ const password = isDevelopment
+ ? requireEnv('POSTGRES_PASSWORD_DEVELOPMENT', { feature })
+ : requireEnv('POSTGRES_PASSWORD', { feature });
+ const host = isDevelopment
+ ? requireEnv('POSTGRES_HOST_DEVELOPMENT', { feature })
+ : requireEnv('POSTGRES_HOST', { feature });
+ const dbName = isDevelopment
+ ? requireEnv('POSTGRES_DB_NAME_DEVELOPMENT', { feature })
+ : requireEnv('POSTGRES_DB_NAME', { feature });
const connectionString = `postgresql://${user}:${password}@${host}:5432/${dbName}`;
const poolConfig = isDevelopment
? {
diff --git a/apps/web/src/utils/env.ts b/apps/web/src/utils/env.ts
new file mode 100644
index 00000000000..ffde48c2a5d
--- /dev/null
+++ b/apps/web/src/utils/env.ts
@@ -0,0 +1,41 @@
+type EnvHelp = {
+ /**
+ * Human-friendly feature name, e.g. "Cloudinary upload" or "Bugsnag".
+ */
+ feature?: string;
+ /**
+ * Where to configure the variable (defaults to apps/web/.env.local).
+ */
+ configFile?: string;
+ /**
+ * Example file (defaults to apps/web/.env.local.example).
+ */
+ exampleFile?: string;
+};
+
+function isNonEmptyString(value: unknown): value is string {
+ return typeof value === 'string' && value.trim().length > 0;
+}
+
+export function getEnv(name: string): string | undefined {
+ const value = process.env[name];
+ return isNonEmptyString(value) ? value : undefined;
+}
+
+export function formatMissingEnvMessage(name: string, help: EnvHelp = {}): string {
+ const featurePrefix = help.feature ? `[${help.feature}] ` : '';
+ const configFile = help.configFile ?? 'apps/web/.env.local';
+ const exampleFile = help.exampleFile ?? 'apps/web/.env.local.example';
+
+ return (
+ `${featurePrefix}Missing required environment variable: ${name}\n` +
+ `Add it to ${configFile} (you can copy from ${exampleFile}).`
+ );
+}
+
+export function requireEnv(name: string, help: EnvHelp = {}): string {
+ const value = getEnv(name);
+ if (value) return value;
+ throw new Error(formatMissingEnvMessage(name, help));
+}
+
diff --git a/apps/web/src/utils/pinata.ts b/apps/web/src/utils/pinata.ts
index 403616bc630..9beb2dae7eb 100644
--- a/apps/web/src/utils/pinata.ts
+++ b/apps/web/src/utils/pinata.ts
@@ -1,6 +1,15 @@
import { PinataSDK } from 'pinata';
+import { requireEnv } from 'apps/web/src/utils/env';
-export const pinata = new PinataSDK({
- pinataJwt: `${process.env.PINATA_API_KEY}`,
- pinataGateway: `${process.env.PINATA_GATEWAY_URL}`,
-});
+let _pinata: PinataSDK | undefined;
+
+export function getPinata(): PinataSDK {
+ if (_pinata) return _pinata;
+
+ // Only validate when the feature is actually used (e.g. IPFS upload route).
+ const pinataJwt = requireEnv('PINATA_API_KEY', { feature: 'Pinata' });
+ const pinataGateway = requireEnv('PINATA_GATEWAY_URL', { feature: 'Pinata' });
+
+ _pinata = new PinataSDK({ pinataJwt, pinataGateway });
+ return _pinata;
+}