Refactor environment variable management with centralized validation (#45)

* Refactor environment variable management with centralized validation

- Replace plain object with Map-based env structure
- Add all environment variables (not just LlamaCloud ones)
- Use sentinel value 'mandatory-env-not-set' for required vars
- Add helper functions: isDevelopment(), isProduction(), logEnv()
- Create instrumentation.ts for one-time startup validation
- Validate environment once at server startup using Next.js hook
- Exit with error code 1 if validation fails

This ensures the app doesn't start with missing required env vars and
provides better error messages during startup.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* Update services to use centralized env.get() pattern

Replace direct process.env access with env.get() in:
- OpenAI services (openai-question-extractor, multi-step-response)
- Supabase utilities (4 files)
- Database client (db.ts)
- Login actions and API routes

Also use helper functions:
- isDevelopment() and isProduction() instead of NODE_ENV checks
- Keep Vercel platform vars (VERCEL_URL, VERCEL_ENV) as process.env

Remove constructor-level env validation checks since validation now
happens once at startup.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* Remove redundant validateEnv() calls from services

Remove 9 validateEnv() calls across:
- lib/llama-index-service.ts
- lib/llamaparse-service.ts
- lib/services/llamacloud-documents-service.ts
- lib/services/llamacloud-connection-service.ts
- app/api/llamacloud/projects/route.ts
- app/api/organizations/route.ts

Environment validation now happens once at startup via instrumentation.ts,
so these per-request checks are no longer needed.

Also updated env access to use env.get() pattern consistently.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* Simplify next.config.ts by removing explicit env var exposure

Remove explicit env var configuration from next.config.ts.
Next.js automatically makes NEXT_PUBLIC_* variables available to the
client, so explicit exposure is unnecessary.

Keeps only essential config:
- reactStrictMode
- output: 'standalone' (for Docker deployment)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* Fix remaining env.PROPERTY to env.get() conversions

Updated remaining files that were still using env.PROPERTY pattern:
- lib/llama-index-service.ts
- lib/llamaparse-service.ts
- lib/services/llamacloud-client.ts
- lib/services/llamacloud-documents-service.ts
- app/api/llamacloud/projects/route.ts
- app/api/organizations/route.ts
- app/api/projects/[projectId]/indexes/route.ts

All files now consistently use env.get('VARIABLE_NAME')! pattern.
TypeScript compilation now passes successfully.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* Refactor LlamaIndexService import paths and consolidate service files

* Fix ESLint warnings in React components

Fix React Hook dependency warnings:
- ProjectDocuments: Add fetchProjectDocuments to useEffect deps
- ProjectIndexSelector: Wrap hasChanges and handleSave in useCallback
  and add all dependencies to the debounced auto-save useEffect

All ESLint checks now pass with no warnings or errors.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* Add comprehensive environment variable documentation

Add detailed documentation explaining build-time vs runtime variables,
especially for NEXT_PUBLIC_* variables and Docker deployments.

Key additions:
- New "Environment Variables" section in README with comprehensive guide
- Explanation of build-time (NEXT_PUBLIC_*) vs runtime variables
- Docker deployment workflow (correct vs incorrect approaches)
- Why NEXT_PUBLIC_* variables require Docker rebuild
- Best practices for multi-environment deployments
- Code examples showing proper env.get() usage
- Startup validation explanation

This documentation is critical for users deploying with Docker, as
changing NEXT_PUBLIC_* variables without rebuilding will not work.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* Implement auth for all api routes

* NODE_ENV has a default

* Make the env lookup type-safe.

* Restrict API health check path in session update logic

* Improve UI consistency

---------

Co-authored-by: Roland Tritsch <roland@tritsch.email>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
roland-the-engineer
2026-01-09 21:41:46 +00:00
committed by GitHub
parent 86e0a09873
commit a9431b3ba0
29 changed files with 466 additions and 149 deletions
+166 -2
View File
@@ -93,7 +93,9 @@ LLAMACLOUD_API_KEY=<your-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
+9 -3
View File
@@ -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);
+2 -8
View File
@@ -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));
+1 -1
View File
@@ -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')!,
};
});
}
+3 -10
View File
@@ -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}`,
+9 -1
View File
@@ -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);
+3 -6
View File
@@ -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}`,
@@ -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}`,
+2 -1
View File
@@ -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()
+1 -1
View File
@@ -103,7 +103,7 @@ export default function OrganizationPage({ params }: OrganizationPageProps) {
<div className="flex gap-2">
<Button onClick={() => setIsCreateProjectOpen(true)}>
<PlusCircle className="mr-2 h-4 w-4" />
New Project
Create Project
</Button>
</div>
</div>
+1 -1
View File
@@ -230,7 +230,7 @@ export function ProjectDocuments({ projectId, refreshKey }: ProjectDocumentsProp
useEffect(() => {
fetchProjectDocuments();
}, [projectId, refreshKey]);
}, [projectId, refreshKey, fetchProjectDocuments]);
const handleRefresh = () => {
fetchProjectDocuments();
+8 -8
View File
@@ -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 (
+25 -1
View File
@@ -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}`);
}
+18
View File
@@ -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...');
}
}
+3 -2
View File
@@ -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;
if (!isProduction()) globalForPrisma.prisma = db;
+171 -28
View File
@@ -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<string, string | undefined>;
constructor(initialValues: Map<string, string | undefined>) {
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<string, string | undefined>([
// 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;
}
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';
}
@@ -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) {
+4 -8
View File
@@ -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<string, any> = {
apiKey: this.apiKey,
resultType: "markdown",
useAgenticParse: useAgentic,
baseUrl: env.LLAMACLOUD_API_URL,
baseUrl: env.get('LLAMACLOUD_API_URL')!,
};
// Add mode-specific options
+1 -1
View File
@@ -19,7 +19,7 @@ export class LlamaCloudClient implements ILlamaCloudClient {
constructor(config: Partial<LlamaCloudClientConfig> = {}) {
this.config = {
baseUrl: `${env.LLAMACLOUD_API_URL}/api/v1`,
baseUrl: `${env.get('LLAMACLOUD_API_URL')!}/api/v1`,
timeout: 30000,
retryAttempts: 3,
...config,
@@ -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,
+4 -14
View File
@@ -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<LlamaCloudFile[]> {
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
);
+8 -7
View File
@@ -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,
});
+2 -5
View File
@@ -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<AIServiceConfig> = {}) {
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');
}
}
/**
+1 -1
View File
@@ -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,
+3 -2
View File
@@ -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')!
)
}
+3 -2
View File
@@ -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() {
+3 -2
View File
@@ -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() {
+2 -14
View File
@@ -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;
+3 -2
View File
@@ -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')!
)
}