diff --git a/README.md b/README.md index e0b7e9b9..3fcb44ca 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,9 @@ LLAMACLOUD_API_KEY= NEXT_PUBLIC_APP_URL=http://localhost:3000 ``` -**Note**: To use the env file for the app AND for docker the env var values cannot be in quotes. +**Important Notes:** +- Environment variable values should NOT be wrapped in quotes for Docker compatibility +- See the [Environment Variables](#-environment-variables) section below for critical information about `NEXT_PUBLIC_*` variables ### 4. Database Setup @@ -260,7 +262,28 @@ pnpm docker-run **Note:** The Docker container uses Next.js standalone output mode for optimized production deployment. Make sure your `.env.local` includes a database connection string that's accessible from within the Docker container. -**IMPORTANT NOTE**: When you build/run a docker container all of the `NEXT_*` env vars are resolved at `build-time` (when the container is built; because the vars also need to be available in the frontend and are generated into the frontend code). The other vars are resolved at `run-time` (when the container is started). Means, if you (for instance) need to set `NEXT_PUBLIC_APP_URL` to your own site (e.g. `https://rfp.mydomain.com`) then you need to make this change to the env file, before you run docker-build. +**CRITICAL: Build-Time vs Runtime Variables** + +Environment variables with the `NEXT_PUBLIC_` prefix are resolved at **build-time**, while others are resolved at **runtime**: + +- **Build-Time (`NEXT_PUBLIC_*`)**: Embedded into the generated JavaScript during build + - `NEXT_PUBLIC_SUPABASE_URL` + - `NEXT_PUBLIC_SUPABASE_ANON_KEY` + - `NEXT_PUBLIC_APP_URL` + - **Require Docker rebuild** when changed + +- **Runtime (others)**: Read from environment when server starts + - `DATABASE_URL` + - `OPENAI_API_KEY` + - `LLAMACLOUD_API_KEY` + - Can be changed by restarting the container + +**Example**: To change `NEXT_PUBLIC_APP_URL` from `http://localhost:3000` to `https://rfp.mydomain.com`: +1. Update the `.env` file +2. Run `pnpm docker-build` (rebuild required!) +3. Run `pnpm docker-run` + +Simply changing the environment variable and restarting will NOT work for `NEXT_PUBLIC_*` variables. See the [Environment Variables](#-environment-variables) section for more details. ## 🔌 API Endpoints @@ -286,6 +309,147 @@ Try the platform with our sample RFP document: - **Sample File**: [RFP - Launch Services for Medium-Lift Payloads][rfp-sample-file] - **Use Case**: Download and upload to test question extraction and response generation +## 🔐 Environment Variables + +### Understanding Build-Time vs Runtime Variables + +AutoRFP uses a centralized environment variable management system through `lib/env.ts`. Understanding how different variables are resolved is critical for proper deployment, especially with Docker. + +### Variable Types + +#### Runtime Variables (Server-Side Only) + +Variables **without** the `NEXT_PUBLIC_` prefix are resolved at **runtime**: + +```bash +DATABASE_URL=postgresql://... +DIRECT_URL=postgresql://... +OPENAI_API_KEY=sk-... +LLAMACLOUD_API_KEY=llx-... +LLAMACLOUD_API_KEY_INTERNAL=llx-... +LLAMACLOUD_API_URL=https://api.cloud.llamaindex.ai +INTERNAL_EMAIL_DOMAIN=@runllama.ai +NODE_ENV=production +``` + +**Characteristics:** +- ✅ Only available on the server (API routes, server components) +- ✅ Read from environment when server starts +- ✅ Can be changed without rebuilding +- ✅ NOT bundled into client JavaScript +- ✅ Safe for secrets and credentials + +**Docker behavior:** +- Can be changed by updating `.env` and restarting the container +- No rebuild required + +#### Build-Time Variables (Public/Client-Side) + +Variables **with** the `NEXT_PUBLIC_` prefix are resolved at **build-time**: + +```bash +NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co +NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ... +NEXT_PUBLIC_APP_URL=https://rfp.mydomain.com +``` + +**Characteristics:** +- ⚠️ Available on both server AND client +- ⚠️ Embedded into generated JavaScript during build +- ⚠️ Require rebuild to reflect changes +- ⚠️ Visible in browser DevTools (never use for secrets!) + +**Docker behavior:** +- **MUST rebuild Docker image** when changed +- Restarting the container alone will NOT pick up changes +- The built JavaScript contains hardcoded values + +### Why This Matters for Docker + +When Next.js builds your application, it performs a step called "static optimization" where it replaces all `process.env.NEXT_PUBLIC_*` references with their literal string values. This means: + +```javascript +// Your code: +const apiUrl = process.env.NEXT_PUBLIC_APP_URL; + +// After build (in the generated JavaScript): +const apiUrl = "http://localhost:3000"; +``` + +The Docker image contains these pre-built files with hardcoded values. Changing environment variables at runtime won't affect code that was already compiled. + +### Docker Deployment Workflow + +**Wrong approach** (will not work): +```bash +# Build with localhost +NEXT_PUBLIC_APP_URL=http://localhost:3000 +pnpm docker-build + +# Try to change to production (THIS WON'T WORK!) +NEXT_PUBLIC_APP_URL=https://rfp.mydomain.com +pnpm docker-run +# ❌ App will still use http://localhost:3000 +``` + +**Correct approach**: +```bash +# Set production values BEFORE building +NEXT_PUBLIC_APP_URL=https://rfp.mydomain.com +NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co + +# Build with production values +pnpm docker-build + +# Run the container +pnpm docker-run +# ✅ App will use https://rfp.mydomain.com +``` + +### Best Practices + +**For Development:** +- Use `.env.local` for local development variables +- Keep `NEXT_PUBLIC_APP_URL=http://localhost:3000` + +**For Docker/Production:** +- Create environment-specific `.env` files (`.env.production`, `.env.staging`) +- Set all `NEXT_PUBLIC_*` variables correctly before building +- Build separate Docker images for each environment +- Never expose secrets in `NEXT_PUBLIC_*` variables + +**For Multi-Environment Deployments:** +```bash +# Development build +cp .env.development .env +pnpm docker-build -t auto_rfp:dev + +# Production build +cp .env.production .env +pnpm docker-build -t auto_rfp:prod +``` + +### Accessing Variables in Code + +All environment variable access goes through the centralized `lib/env.ts` module: + +```typescript +// ✅ Correct +import { env } from '@/lib/env'; +const apiKey = env.get('OPENAI_API_KEY')!; +const appUrl = env.get('NEXT_PUBLIC_APP_URL')!; + +// ❌ Incorrect +const apiKey = process.env.OPENAI_API_KEY; +``` + +### Startup Validation + +Environment variables are validated once at server startup via `instrumentation.ts`: +- Application will not start if required variables are missing +- Validation errors are logged clearly +- Eliminates per-request validation overhead + ## 🐛 Troubleshooting ### Common Issues diff --git a/app/api/extract-questions/route.ts b/app/api/extract-questions/route.ts index 5f462898..3f8840bb 100644 --- a/app/api/extract-questions/route.ts +++ b/app/api/extract-questions/route.ts @@ -2,13 +2,19 @@ import { NextRequest } from 'next/server'; import { apiHandler } from '@/lib/middleware/api-handler'; import { ExtractQuestionsRequestSchema } from '@/lib/validators/extract-questions'; import { questionExtractionService } from '@/lib/services/question-extraction-service'; -import { ValidationError } from '@/lib/errors/api-errors'; +import { AuthorizationError } from '@/lib/errors/api-errors'; +import { organizationService } from '@/lib/organization-service'; export async function POST(request: NextRequest) { return apiHandler(async () => { - // Parse and validate request body + // SECURITY: Verify authentication first + const currentUser = await organizationService.getCurrentUser(); + if (!currentUser) { + throw new AuthorizationError('Authentication required'); + } - console.log("request", request); + // Parse and validate request body + console.log("request", request); const body = await request.json(); const validatedRequest = ExtractQuestionsRequestSchema.parse(body); diff --git a/app/api/generate-response-multistep/route.ts b/app/api/generate-response-multistep/route.ts index acee43d9..4754e8ef 100644 --- a/app/api/generate-response-multistep/route.ts +++ b/app/api/generate-response-multistep/route.ts @@ -4,19 +4,13 @@ import { z } from 'zod'; import { NextRequest } from 'next/server'; import { organizationService } from '@/lib/organization-service'; import { db } from '@/lib/db'; -import { LlamaIndexService } from '@/lib/llama-index-service'; +import { LlamaIndexService } from '@/lib/llamaindex-service'; import { getLlamaCloudApiKey } from '@/lib/env'; export async function POST(request: NextRequest) { console.log('🎯 Multi-step API route called'); - + try { - // Check OpenAI API key - if (!process.env.OPENAI_API_KEY) { - console.log('❌ OPENAI_API_KEY not configured'); - return new Response('OpenAI API key not configured', { status: 500 }); - } - const body = await request.json(); console.log('📝 Request body:', JSON.stringify(body, null, 2)); diff --git a/app/api/health/route.ts b/app/api/health/route.ts index 181f5c55..8f3f39dd 100644 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -16,7 +16,7 @@ export async function GET(request: NextRequest) { status: "healthy", timestamp: new Date().toISOString(), uptime: process.uptime(), - environment: process.env.NODE_ENV, + environment: env.get('NODE_ENV')!, }; }); } diff --git a/app/api/llamacloud/projects/route.ts b/app/api/llamacloud/projects/route.ts index 6c8b9eba..51c9e933 100644 --- a/app/api/llamacloud/projects/route.ts +++ b/app/api/llamacloud/projects/route.ts @@ -1,17 +1,10 @@ import { NextRequest } from 'next/server'; import { apiHandler } from '@/lib/middleware/api-handler'; -import { env, validateEnv, getLlamaCloudApiKey } from '@/lib/env'; +import { env, getLlamaCloudApiKey } from '@/lib/env'; import { organizationService } from '@/lib/organization-service'; export async function GET(request: NextRequest) { return apiHandler(async () => { - // Validate environment variables - if (!validateEnv()) { - return new Response( - JSON.stringify({ error: 'LlamaCloud API key not configured in environment variables' }), - { status: 500, headers: { 'Content-Type': 'application/json' } } - ); - } try { // Get current user to determine which API key to use @@ -20,14 +13,14 @@ export async function GET(request: NextRequest) { // Fetch projects and organizations from LlamaCloud const [projectsResponse, organizationsResponse] = await Promise.all([ - fetch(`${env.LLAMACLOUD_API_URL}/api/v1/projects`, { + fetch(`${env.get('LLAMACLOUD_API_URL')!}/api/v1/projects`, { method: 'GET', headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, }), - fetch(`${env.LLAMACLOUD_API_URL}/api/v1/organizations`, { + fetch(`${env.get('LLAMACLOUD_API_URL')!}/api/v1/organizations`, { method: 'GET', headers: { 'Authorization': `Bearer ${apiKey}`, diff --git a/app/api/llamaparse/route.ts b/app/api/llamaparse/route.ts index 0f620a83..d1dfbcee 100644 --- a/app/api/llamaparse/route.ts +++ b/app/api/llamaparse/route.ts @@ -3,13 +3,21 @@ import { apiHandler } from '@/lib/middleware/api-handler'; import { LlamaParseRequestSchema } from '@/lib/validators/llamaparse'; import { llamaParseProcessingService } from '@/lib/services/llamaparse-processing-service'; import { parseFormDataToLlamaParseRequest } from '@/lib/utils/form-data-parser'; +import { AuthorizationError } from '@/lib/errors/api-errors'; +import { organizationService } from '@/lib/organization-service'; export async function POST(request: NextRequest) { return apiHandler(async () => { + // SECURITY: Verify authentication first + const currentUser = await organizationService.getCurrentUser(); + if (!currentUser) { + throw new AuthorizationError('Authentication required'); + } + // Parse FormData into structured request const formData = await request.formData(); const parsedRequest = parseFormDataToLlamaParseRequest(formData); - + // Validate the parsed request const validatedRequest = LlamaParseRequestSchema.parse(parsedRequest); diff --git a/app/api/organizations/route.ts b/app/api/organizations/route.ts index 160b1d23..7970b6c8 100644 --- a/app/api/organizations/route.ts +++ b/app/api/organizations/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { organizationService } from '@/lib/organization-service'; import { llamaCloudConnectionService } from '@/lib/services/llamacloud-connection-service'; -import { env, validateEnv, getLlamaCloudApiKey } from '@/lib/env'; +import { env, getLlamaCloudApiKey } from '@/lib/env'; export async function GET() { @@ -78,22 +78,19 @@ export async function GET() { // Helper function to fetch available LlamaCloud projects async function fetchLlamaCloudProjects(userEmail?: string) { try { - if (!validateEnv()) { - return []; - } // Get the appropriate API key based on user's email const apiKey = getLlamaCloudApiKey(userEmail); const [projectsResponse, organizationsResponse] = await Promise.all([ - fetch(`${env.LLAMACLOUD_API_URL}/api/v1/projects`, { + fetch(`${env.get('LLAMACLOUD_API_URL')!}/api/v1/projects`, { method: 'GET', headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, }), - fetch(`${env.LLAMACLOUD_API_URL}/api/v1/organizations`, { + fetch(`${env.get('LLAMACLOUD_API_URL')!}/api/v1/organizations`, { method: 'GET', headers: { 'Authorization': `Bearer ${apiKey}`, diff --git a/app/api/projects/[projectId]/indexes/route.ts b/app/api/projects/[projectId]/indexes/route.ts index a2240959..083e821d 100644 --- a/app/api/projects/[projectId]/indexes/route.ts +++ b/app/api/projects/[projectId]/indexes/route.ts @@ -74,7 +74,7 @@ export async function GET( // Get the appropriate API key based on user's email const apiKey = getLlamaCloudApiKey(currentUser.email); - const pipelinesResponse = await fetch(`${env.LLAMACLOUD_API_URL}/api/v1/pipelines`, { + const pipelinesResponse = await fetch(`${env.get('LLAMACLOUD_API_URL')!}/api/v1/pipelines`, { method: 'GET', headers: { 'Authorization': `Bearer ${apiKey}`, @@ -245,7 +245,7 @@ export async function POST( // Get the appropriate API key based on user's email const apiKey = getLlamaCloudApiKey(currentUser.email); - const pipelinesResponse = await fetch(`${env.LLAMACLOUD_API_URL}/api/v1/pipelines`, { + const pipelinesResponse = await fetch(`${env.get('LLAMACLOUD_API_URL')!}/api/v1/pipelines`, { method: 'GET', headers: { 'Authorization': `Bearer ${apiKey}`, diff --git a/app/login/actions.ts b/app/login/actions.ts index 4f6eb8ad..bb052422 100644 --- a/app/login/actions.ts +++ b/app/login/actions.ts @@ -4,6 +4,7 @@ import { revalidatePath } from 'next/cache' import { redirect } from 'next/navigation' import { createClient } from '@/lib/utils/supabase/server' +import { env } from '@/lib/env' export async function signInWithMagicLink(formData: FormData) { const supabase = await createClient() @@ -27,7 +28,7 @@ export async function signInWithMagicLink(formData: FormData) { return `${protocol}://${process.env.VERCEL_URL}` } // For local development or custom deployments - return process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000' + return env.get('NEXT_PUBLIC_APP_URL')! } const origin = getOrigin() diff --git a/app/organizations/[orgId]/page.tsx b/app/organizations/[orgId]/page.tsx index 7b3e1394..a5c4d908 100644 --- a/app/organizations/[orgId]/page.tsx +++ b/app/organizations/[orgId]/page.tsx @@ -103,7 +103,7 @@ export default function OrganizationPage({ params }: OrganizationPageProps) {
diff --git a/components/projects/ProjectDocuments.tsx b/components/projects/ProjectDocuments.tsx index 59b140e1..44a83e4c 100644 --- a/components/projects/ProjectDocuments.tsx +++ b/components/projects/ProjectDocuments.tsx @@ -230,7 +230,7 @@ export function ProjectDocuments({ projectId, refreshKey }: ProjectDocumentsProp useEffect(() => { fetchProjectDocuments(); - }, [projectId, refreshKey]); + }, [projectId, refreshKey, fetchProjectDocuments]); const handleRefresh = () => { fetchProjectDocuments(); diff --git a/components/projects/ProjectIndexSelector.tsx b/components/projects/ProjectIndexSelector.tsx index 79b6a4e2..cf4ce696 100644 --- a/components/projects/ProjectIndexSelector.tsx +++ b/components/projects/ProjectIndexSelector.tsx @@ -85,7 +85,12 @@ export function ProjectIndexSelector({ projectId, onSaveSuccess }: ProjectIndexS setSelectedIndexId(prev => prev === indexId ? null : indexId); }; - const handleSave = async () => { + const hasChanges = useCallback(() => { + const currentId = currentIndexes.length > 0 ? currentIndexes[0].id : null; + return selectedIndexId !== currentId; + }, [currentIndexes, selectedIndexId]); + + const handleSave = useCallback(async () => { try { setIsSaving(true); @@ -121,12 +126,7 @@ export function ProjectIndexSelector({ projectId, onSaveSuccess }: ProjectIndexS } finally { setIsSaving(false); } - }; - - const hasChanges = () => { - const currentId = currentIndexes.length > 0 ? currentIndexes[0].id : null; - return selectedIndexId !== currentId; - }; + }, [projectId, selectedIndexId, onSaveSuccess, toast]); // Debounced auto-save effect useEffect(() => { @@ -141,7 +141,7 @@ export function ProjectIndexSelector({ projectId, onSaveSuccess }: ProjectIndexS }, 800); return () => clearTimeout(timer); - }, [selectedIndexId]); + }, [selectedIndexId, isInitialized, isLoading, hasChanges, handleSave]); if (isLoading) { return ( diff --git a/context/organization-context.tsx b/context/organization-context.tsx index 2d6eb36b..bd8675c6 100644 --- a/context/organization-context.tsx +++ b/context/organization-context.tsx @@ -62,6 +62,14 @@ export function OrganizationProvider({ children }: OrganizationProviderProps) { const fetchOrganizations = async () => { try { const response = await fetch("/api/organizations"); + + // Check if response is JSON before parsing + const contentType = response.headers.get("content-type"); + if (!contentType || !contentType.includes("application/json")) { + console.error("Expected JSON response but got:", contentType); + return; + } + const data = await response.json(); if (data.success) { setOrganizations(data.data); @@ -75,8 +83,16 @@ export function OrganizationProvider({ children }: OrganizationProviderProps) { try { // Track which organization we're fetching for fetchingProjectsForOrgRef.current = organizationId; - + const response = await fetch(`/api/projects?organizationId=${organizationId}`); + + // Check if response is JSON before parsing + const contentType = response.headers.get("content-type"); + if (!contentType || !contentType.includes("application/json")) { + console.error("Expected JSON response but got:", contentType); + return; + } + const data = await response.json(); if (data.success) { // Only set projects if we're still fetching for the same organization @@ -92,6 +108,14 @@ export function OrganizationProvider({ children }: OrganizationProviderProps) { const fetchProjectById = async (projectId: string) => { try { const response = await fetch(`/api/projects/${projectId}`); + + // Check if response is JSON before parsing + const contentType = response.headers.get("content-type"); + if (!contentType || !contentType.includes("application/json")) { + console.error("Expected JSON response but got:", contentType); + throw new Error(`Expected JSON response but got: ${contentType}`); + } + if (!response.ok) { throw new Error(`Failed to fetch project: ${response.statusText}`); } diff --git a/instrumentation.ts b/instrumentation.ts new file mode 100644 index 00000000..7e5be0b6 --- /dev/null +++ b/instrumentation.ts @@ -0,0 +1,18 @@ +import { validateEnv } from './lib/env'; + +/** + * Next.js instrumentation hook - runs once at server startup + * This is the ideal place to validate environment configuration + */ +export async function register() { + if (process.env.NEXT_RUNTIME === 'nodejs') { + console.log('Validating environment configuration at startup...'); + + if (!validateEnv()) { + console.error('Environment validation failed. Server will not start.'); + process.exit(1); + } + + console.log('Environment validation passed. Server starting...'); + } +} diff --git a/lib/db.ts b/lib/db.ts index ff53316b..007b1acb 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -1,4 +1,5 @@ import { PrismaClient } from '@prisma/client'; +import { isDevelopment, isProduction } from '@/lib/env'; // PrismaClient is attached to the `global` object in development to prevent // exhausting your database connection limit. @@ -7,7 +8,7 @@ const globalForPrisma = global as unknown as { prisma: PrismaClient }; export const db = globalForPrisma.prisma || new PrismaClient({ - log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'], + log: isDevelopment() ? ['query', 'error', 'warn'] : ['error'], }); -if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db; \ No newline at end of file +if (!isProduction()) globalForPrisma.prisma = db; \ No newline at end of file diff --git a/lib/env.ts b/lib/env.ts index d9f0af2b..36f810e0 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -1,39 +1,182 @@ -// Environment variables configuration -export const env = { - LLAMACLOUD_API_KEY: process.env.LLAMACLOUD_API_KEY || '', - LLAMACLOUD_API_KEY_INTERNAL: process.env.LLAMACLOUD_API_KEY_INTERNAL || '', - LLAMACLOUD_API_URL: process.env.LLAMACLOUD_API_URL || 'https://api.cloud.llamaindex.ai', - INTERNAL_EMAIL_DOMAIN: process.env.INTERNAL_EMAIL_DOMAIN || '@runllama.ai', -}; +/** + * Centralized environment variables configuration. + * All environment variable access should go through this module. + * + * Env vars can be: + * + * - Mandatory: Must be set (use without test) + * - Optional (with default): Can be set (use without test) + * - Optional (without default): Can be set (use with test) + */ -// Function to validate required environment variables -export function validateEnv() { - const requiredVars = [ - { key: 'LLAMACLOUD_API_KEY', value: env.LLAMACLOUD_API_KEY } +/** + * Define all valid environment variable keys + * This provides compile-time type safety for env.get() calls + */ +const ENV_KEYS = { + // Database + DATABASE_URL: 'DATABASE_URL', + DIRECT_URL: 'DIRECT_URL', + + // Supabase + NEXT_PUBLIC_SUPABASE_URL: 'NEXT_PUBLIC_SUPABASE_URL', + NEXT_PUBLIC_SUPABASE_ANON_KEY: 'NEXT_PUBLIC_SUPABASE_ANON_KEY', + + // OpenAI + OPENAI_API_KEY: 'OPENAI_API_KEY', + + // LlamaCloud + LLAMACLOUD_API_KEY: 'LLAMACLOUD_API_KEY', + LLAMACLOUD_API_KEY_INTERNAL: 'LLAMACLOUD_API_KEY_INTERNAL', + LLAMACLOUD_API_URL: 'LLAMACLOUD_API_URL', + INTERNAL_EMAIL_DOMAIN: 'INTERNAL_EMAIL_DOMAIN', + + // App configuration + NEXT_PUBLIC_APP_URL: 'NEXT_PUBLIC_APP_URL', + NODE_ENV: 'NODE_ENV', +} as const; + +/** + * Type representing valid environment variable keys + * Derived from ENV_KEYS to ensure single source of truth + */ +type EnvKey = keyof typeof ENV_KEYS; + +/** + * Type-safe wrapper around Map for environment variables + * Prevents typos by only accepting valid EnvKey values + */ +class EnvStore { + private store: Map; + + constructor(initialValues: Map) { + this.store = initialValues; + } + + /** + * Get environment variable value by key + * Only accepts valid EnvKey types - typos will cause compile-time errors + */ + get(key: EnvKey): string | undefined { + return this.store.get(key); + } + + /** + * Iterate over all environment variables + * Used by validation logic + */ + forEach(callback: (value: string | undefined, key: string) => void): void { + this.store.forEach(callback); + } + + /** + * Get iterator over environment variable entries + * Used by validation logic + */ + entries(): IterableIterator<[string, string | undefined]> { + return this.store.entries(); + } +} + +const envMap = new Map([ + // Database + ['DATABASE_URL', process.env.DATABASE_URL || 'mandatory-env-not-set'], + ['DIRECT_URL', process.env.DIRECT_URL || 'mandatory-env-not-set'], + + // Supabase + ['NEXT_PUBLIC_SUPABASE_URL', process.env.NEXT_PUBLIC_SUPABASE_URL || 'mandatory-env-not-set'], + ['NEXT_PUBLIC_SUPABASE_ANON_KEY', process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || 'mandatory-env-not-set'], + + // OpenAI + ['OPENAI_API_KEY', process.env.OPENAI_API_KEY || 'mandatory-env-not-set'], + + // LlamaCloud + ['LLAMACLOUD_API_KEY', process.env.LLAMACLOUD_API_KEY || 'mandatory-env-not-set'], + ['LLAMACLOUD_API_KEY_INTERNAL', process.env.LLAMACLOUD_API_KEY_INTERNAL], + ['LLAMACLOUD_API_URL', process.env.LLAMACLOUD_API_URL || 'https://api.cloud.llamaindex.ai'], + ['INTERNAL_EMAIL_DOMAIN', process.env.INTERNAL_EMAIL_DOMAIN || '@runllama.ai'], + + // App configuration + ['NEXT_PUBLIC_APP_URL', process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'], + ['NODE_ENV', process.env.NODE_ENV || 'development'], +]); + +/** + * Type-safe environment variable store + * Use env.get(key) to access variables - only valid keys are accepted + */ +export const env = new EnvStore(envMap); + +function logEnv() { + const sensitiveVars = [ + 'DATABASE_URL', + 'DIRECT_URL', + 'NEXT_PUBLIC_SUPABASE_URL', + 'NEXT_PUBLIC_SUPABASE_ANON_KEY', + 'OPENAI_API_KEY', + 'LLAMACLOUD_API_KEY', + 'LLAMACLOUD_API_KEY_INTERNAL', ]; - const missingVars = requiredVars.filter(v => !v.value); - + console.log('=== Environment Variables ==='); + env.forEach((value, key) => { + const displayValue = sensitiveVars.includes(key) && value !== undefined && value !== 'mandatory-env-not-set' + ? `${value.substring(0, 8)}...` + : value; + console.log(`${key}: ${displayValue}`); + }); + console.log('============================='); +} + +/** + * Validate required environment variables. + * + * Returns true if all required vars are set, false otherwise + */ +export function validateEnv(): boolean { + logEnv(); + + const missingVars: string[] = []; + + for (const [key, value] of env.entries()) { + if (value === 'mandatory-env-not-set') { + missingVars.push(key); + } + } + if (missingVars.length > 0) { - console.error(` - Missing required environment variables: - ${missingVars.map(v => `- ${v.key}`).join('\n ')} - - Please set these in your .env file - `); + console.error(`Missing required environment variables: ${missingVars.join(', ')}`); return false; } - + return true; } -// Helper function to get the appropriate LlamaCloud API key based on user email +/** + * Get the appropriate LlamaCloud API key based on user email + * Returns internal key for internal users, regular key otherwise + */ export function getLlamaCloudApiKey(userEmail?: string | null): string { + const internalDomain = env.get('INTERNAL_EMAIL_DOMAIN')!; + const internalKey = env.get('LLAMACLOUD_API_KEY_INTERNAL'); + const regularKey = env.get('LLAMACLOUD_API_KEY')!; + // If user has internal email domain and internal key is configured, use internal key - if (userEmail?.endsWith(env.INTERNAL_EMAIL_DOMAIN) && env.LLAMACLOUD_API_KEY_INTERNAL) { - return env.LLAMACLOUD_API_KEY_INTERNAL; - } - - // Otherwise, use the regular API key - return env.LLAMACLOUD_API_KEY; -} \ No newline at end of file + const key = userEmail?.endsWith(internalDomain) && internalKey ? internalKey : regularKey; + + return key; +} + +/** + * Check if running in production environment + */ +export function isProduction(): boolean { + return env.get('NODE_ENV') === 'production'; +} + +/** + * Check if running in development environment + */ +export function isDevelopment(): boolean { + return env.get('NODE_ENV') === 'development'; +} diff --git a/lib/llama-index-service.ts b/lib/llamaindex-service.ts similarity index 95% rename from lib/llama-index-service.ts rename to lib/llamaindex-service.ts index 9bace5b4..0b129a50 100644 --- a/lib/llama-index-service.ts +++ b/lib/llamaindex-service.ts @@ -1,4 +1,4 @@ -import { env, validateEnv } from "./env"; +import { env } from "./env"; import { LlamaCloudIndex, ContextChatEngine } from "llamaindex"; import { ILlamaIndexService, @@ -31,14 +31,11 @@ export class LlamaIndexService implements ILlamaIndexService { }; } else { // Fallback to environment variables (for default responses) - if (!validateEnv()) { - throw new Error('Required environment variables are missing'); - } this.config = { - apiKey: env.LLAMACLOUD_API_KEY, + apiKey: env.get('LLAMACLOUD_API_KEY')!, projectName: 'Default', }; - } + } this.initializeIndexes(); } @@ -48,7 +45,7 @@ export class LlamaIndexService implements ILlamaIndexService { try { // Extract hostname from LLAMACLOUD_API_URL for LlamaCloudIndex // The SDK expects just the hostname (e.g., 'api.cloud.eu.llamaindex.ai') - const baseUrlHostname = new URL(env.LLAMACLOUD_API_URL).hostname; + const baseUrlHostname = new URL(env.get('LLAMACLOUD_API_URL')!).hostname; console.log('Initializing LlamaCloud indexes with config:', this.config); if (this.config.indexNames && this.config.indexNames.length > 0) { diff --git a/lib/llamaparse-service.ts b/lib/llamaparse-service.ts index 99a1825c..bcaf541d 100644 --- a/lib/llamaparse-service.ts +++ b/lib/llamaparse-service.ts @@ -1,4 +1,4 @@ -import { env, validateEnv } from "./env"; +import { env } from "./env"; import fs from 'fs'; import path from 'path'; import os from 'os'; @@ -18,11 +18,7 @@ export class LlamaParseService { private apiKey: string; constructor() { - if (!validateEnv()) { - throw new Error('Required environment variables are missing'); - } - - this.apiKey = env.LLAMACLOUD_API_KEY; + this.apiKey = env.get('LLAMACLOUD_API_KEY')!; } /** @@ -58,12 +54,12 @@ export class LlamaParseService { const useAgentic = options.agenticMode !== false; // LlamaParseReader uses protocol + hostname format (no /api/v1) - // env.LLAMACLOUD_API_URL is already in this format + // env.get('LLAMACLOUD_API_URL') is already in this format let readerOptions: Record = { apiKey: this.apiKey, resultType: "markdown", useAgenticParse: useAgentic, - baseUrl: env.LLAMACLOUD_API_URL, + baseUrl: env.get('LLAMACLOUD_API_URL')!, }; // Add mode-specific options diff --git a/lib/services/llamacloud-client.ts b/lib/services/llamacloud-client.ts index be0f1a14..76c71b48 100644 --- a/lib/services/llamacloud-client.ts +++ b/lib/services/llamacloud-client.ts @@ -19,7 +19,7 @@ export class LlamaCloudClient implements ILlamaCloudClient { constructor(config: Partial = {}) { this.config = { - baseUrl: `${env.LLAMACLOUD_API_URL}/api/v1`, + baseUrl: `${env.get('LLAMACLOUD_API_URL')!}/api/v1`, timeout: 30000, retryAttempts: 3, ...config, diff --git a/lib/services/llamacloud-connection-service.ts b/lib/services/llamacloud-connection-service.ts index 7c77b7bf..4ed015db 100644 --- a/lib/services/llamacloud-connection-service.ts +++ b/lib/services/llamacloud-connection-service.ts @@ -9,7 +9,7 @@ import { llamaCloudClient } from './llamacloud-client'; import { organizationAuth } from './organization-auth'; import { db } from '@/lib/db'; import { DatabaseError, LlamaCloudConnectionError } from '@/lib/errors/api-errors'; -import { env, validateEnv, getLlamaCloudApiKey } from '@/lib/env'; +import { env, getLlamaCloudApiKey } from '@/lib/env'; /** * Main LlamaCloud connection management service @@ -23,12 +23,7 @@ export class LlamaCloudConnectionService implements ILlamaCloudConnectionService // Step 1: Verify user has admin access await organizationAuth.requireAdminAccess(userId, request.organizationId); - // Step 2: Validate environment variables - if (!validateEnv()) { - throw new LlamaCloudConnectionError('LlamaCloud API key not configured in environment variables'); - } - - // Get user's email to determine which API key to use + // Step 2: Get user's email to determine which API key to use const user = await db.user.findUnique({ where: { id: userId }, select: { email: true } @@ -167,8 +162,8 @@ export class LlamaCloudConnectionService implements ILlamaCloudConnectionService }, }); - // Connection is considered active if there's a project ID and the environment API key is available - const isConnected = !!(organization?.llamaCloudProjectId && validateEnv()); + // Connection is considered active if there's a project ID + const isConnected = !!organization?.llamaCloudProjectId; return { isConnected, diff --git a/lib/services/llamacloud-documents-service.ts b/lib/services/llamacloud-documents-service.ts index 24ac5006..4a22d513 100644 --- a/lib/services/llamacloud-documents-service.ts +++ b/lib/services/llamacloud-documents-service.ts @@ -8,7 +8,7 @@ import { llamaCloudClient } from './llamacloud-client'; import { organizationAuth } from './organization-auth'; import { db } from '@/lib/db'; import { DatabaseError, LlamaCloudConnectionError, NotFoundError } from '@/lib/errors/api-errors'; -import { env, validateEnv, getLlamaCloudApiKey } from '@/lib/env'; +import { env, getLlamaCloudApiKey } from '@/lib/env'; /** * LlamaCloud documents management service @@ -22,12 +22,7 @@ export class LlamaCloudDocumentsService implements ILlamaCloudDocumentsService { // Step 1: Verify user has organization access await organizationAuth.requireMembership(userId, request.organizationId); - // Step 2: Validate environment variables - if (!validateEnv()) { - throw new LlamaCloudConnectionError('LlamaCloud API key not configured in environment variables'); - } - - // Get user's email to determine which API key to use + // Step 2: Get user's email to determine which API key to use const user = await db.user.findUnique({ where: { id: userId }, select: { email: true } @@ -125,16 +120,11 @@ export class LlamaCloudDocumentsService implements ILlamaCloudDocumentsService { */ async fetchDocumentsForAllPipelines(organizationId: string): Promise { try { - // Validate environment variables - if (!validateEnv()) { - throw new LlamaCloudConnectionError('LlamaCloud API key not configured in environment variables'); - } - const organization = await this.getConnectedOrganization(organizationId); // Get all pipelines for the project const pipelines = await llamaCloudClient.fetchPipelinesForProject( - env.LLAMACLOUD_API_KEY, + env.get('LLAMACLOUD_API_KEY')!, organization.llamaCloudProjectId! ); @@ -143,7 +133,7 @@ export class LlamaCloudDocumentsService implements ILlamaCloudDocumentsService { for (const pipeline of pipelines) { try { const documents = await llamaCloudClient.fetchFilesForPipeline( - env.LLAMACLOUD_API_KEY, + env.get('LLAMACLOUD_API_KEY')!, pipeline.id ); diff --git a/lib/services/multi-step-response-service.ts b/lib/services/multi-step-response-service.ts index 4a783be7..e91de16a 100644 --- a/lib/services/multi-step-response-service.ts +++ b/lib/services/multi-step-response-service.ts @@ -1,8 +1,8 @@ import { IMultiStepResponseService } from '@/lib/interfaces/multi-step-response'; -import { - MultiStepGenerateRequest, - MultiStepResponse, - StepUpdate, +import { + MultiStepGenerateRequest, + MultiStepResponse, + StepUpdate, StepResult, QuestionAnalysis, DocumentSearchResult, @@ -10,11 +10,12 @@ import { ResponseSynthesis, MultiStepConfig } from '@/lib/validators/multi-step-response'; -import { LlamaIndexService } from '@/lib/llama-index-service'; +import { LlamaIndexService } from '@/lib/llamaindex-service'; import { generateId } from 'ai'; import { db } from '@/lib/db'; import { organizationService } from '@/lib/organization-service'; import OpenAI from 'openai'; +import { env } from '@/lib/env'; /** * Multi-step response generation service implementation with AI-powered reasoning @@ -39,7 +40,7 @@ export class MultiStepResponseService implements IMultiStepResponseService { // Initialize OpenAI for AI-powered reasoning this.openai = new OpenAI({ - apiKey: process.env.OPENAI_API_KEY, + apiKey: env.get('OPENAI_API_KEY')!, }); } @@ -65,7 +66,7 @@ export class MultiStepResponseService implements IMultiStepResponseService { console.log(`DEBUG Multi-step: selected index names:`, selectedIndexNames); this.llamaIndexService = new LlamaIndexService({ - apiKey: process.env.LLAMACLOUD_API_KEY!, + apiKey: env.get('LLAMACLOUD_API_KEY')!, projectName: projectConfig.organization.llamaCloudProjectName || 'Default', indexNames: selectedIndexNames.length > 0 ? selectedIndexNames : undefined, }); diff --git a/lib/services/openai-question-extractor.ts b/lib/services/openai-question-extractor.ts index 6206d341..ff8fa6a8 100644 --- a/lib/services/openai-question-extractor.ts +++ b/lib/services/openai-question-extractor.ts @@ -3,6 +3,7 @@ import { IAIQuestionExtractor, AIServiceConfig } from '@/lib/interfaces/ai-servi import { ExtractedQuestions, ExtractedQuestionsSchema } from '@/lib/validators/extract-questions'; import { DEFAULT_LANGUAGE_MODEL } from '@/lib/constants'; import { AIServiceError } from '@/lib/errors/api-errors'; +import { env } from '@/lib/env'; /** * OpenAI-powered question extraction service @@ -13,7 +14,7 @@ export class OpenAIQuestionExtractor implements IAIQuestionExtractor { constructor(config: Partial = {}) { this.client = new OpenAI({ - apiKey: process.env.OPENAI_API_KEY, + apiKey: env.get('OPENAI_API_KEY')!, }); this.config = { @@ -23,10 +24,6 @@ export class OpenAIQuestionExtractor implements IAIQuestionExtractor { timeout: 60000, ...config, }; - - if (!process.env.OPENAI_API_KEY) { - throw new AIServiceError('OpenAI API key is not configured'); - } } /** diff --git a/lib/services/response-generation-service.ts b/lib/services/response-generation-service.ts index 3586db2e..bf418558 100644 --- a/lib/services/response-generation-service.ts +++ b/lib/services/response-generation-service.ts @@ -1,6 +1,6 @@ import { db } from '@/lib/db'; import { organizationService } from '@/lib/organization-service'; -import { LlamaIndexService } from '@/lib/llama-index-service'; +import { LlamaIndexService } from '@/lib/llamaindex-service'; import { getLlamaCloudApiKey } from '@/lib/env'; import { GenerateResponseRequest, diff --git a/lib/utils/supabase/client.ts b/lib/utils/supabase/client.ts index 78ff395d..4377d733 100644 --- a/lib/utils/supabase/client.ts +++ b/lib/utils/supabase/client.ts @@ -1,8 +1,9 @@ import { createBrowserClient } from '@supabase/ssr' +import { env } from '@/lib/env' export function createClient() { return createBrowserClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! + env.get('NEXT_PUBLIC_SUPABASE_URL')!, + env.get('NEXT_PUBLIC_SUPABASE_ANON_KEY')! ) } \ No newline at end of file diff --git a/lib/utils/supabase/middleware.ts b/lib/utils/supabase/middleware.ts index edb0e079..d4d0f65d 100644 --- a/lib/utils/supabase/middleware.ts +++ b/lib/utils/supabase/middleware.ts @@ -1,5 +1,6 @@ import { createServerClient } from '@supabase/ssr' import { NextResponse, type NextRequest } from 'next/server' +import { env } from '@/lib/env' export async function updateSession(request: NextRequest) { let supabaseResponse = NextResponse.next({ @@ -7,8 +8,8 @@ export async function updateSession(request: NextRequest) { }) const supabase = createServerClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + env.get('NEXT_PUBLIC_SUPABASE_URL')!, + env.get('NEXT_PUBLIC_SUPABASE_ANON_KEY')!, { cookies: { getAll() { diff --git a/lib/utils/supabase/server.ts b/lib/utils/supabase/server.ts index 87a6ba7f..b80ea6aa 100644 --- a/lib/utils/supabase/server.ts +++ b/lib/utils/supabase/server.ts @@ -1,12 +1,13 @@ import { createServerClient } from '@supabase/ssr' import { cookies } from 'next/headers' +import { env } from '@/lib/env' export async function createClient() { const cookieStore = await cookies() return createServerClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + env.get('NEXT_PUBLIC_SUPABASE_URL')!, + env.get('NEXT_PUBLIC_SUPABASE_ANON_KEY')!, { cookies: { getAll() { diff --git a/next.config.ts b/next.config.ts index 904c8b35..e3c64db5 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,20 +1,8 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - // Configure environment variables that will be available on both server and client side - env: { - // You will need to set LLAMACLOUD_API_KEY in your deployment environment - // or in a .env.local file that's not committed to version control - LLAMACLOUD_API_KEY: process.env.LLAMACLOUD_API_KEY, - // Internal API key for internal users - LLAMACLOUD_API_KEY_INTERNAL: process.env.LLAMACLOUD_API_KEY_INTERNAL, - // Internal email domain (defaults to @runllama.ai) - INTERNAL_EMAIL_DOMAIN: process.env.INTERNAL_EMAIL_DOMAIN, - }, - // Other Next.js config options - reactStrictMode: true, - // Enable standalone output for Docker deployment - output: 'standalone', + reactStrictMode: true, + output: 'standalone', }; export default nextConfig; diff --git a/utils/supabase/client.ts b/utils/supabase/client.ts index 78ff395d..4377d733 100644 --- a/utils/supabase/client.ts +++ b/utils/supabase/client.ts @@ -1,8 +1,9 @@ import { createBrowserClient } from '@supabase/ssr' +import { env } from '@/lib/env' export function createClient() { return createBrowserClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! + env.get('NEXT_PUBLIC_SUPABASE_URL')!, + env.get('NEXT_PUBLIC_SUPABASE_ANON_KEY')! ) } \ No newline at end of file