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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { AppController } from './app.controller';
import { AppService } from './app.service';
import { authConfig } from './config/auth.config';
import { opensearchConfig } from './config/opensearch.config';
import { validateBackendEnv } from './config/env.validate';
import { OpenSearchModule } from './config/opensearch.module';
import { AgentsModule } from './agents/agents.module';
import { AuthModule } from './auth/auth.module';
Expand Down Expand Up @@ -76,6 +77,7 @@ function getEnvFilePaths(): string[] {
isGlobal: true,
envFilePath: getEnvFilePaths(),
load: [authConfig, opensearchConfig],
validate: validateBackendEnv,
}),
ThrottlerModule.forRootAsync({
useFactory: () => {
Expand Down
112 changes: 112 additions & 0 deletions backend/src/config/__tests__/env.validate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { describe, it, expect } from 'bun:test';
import { backendEnvSchema } from '../env.schema';

/** Minimal valid backend env config */
function validEnv(overrides: Record<string, string> = {}): Record<string, string> {
return {
DATABASE_URL: 'postgresql://user:pass@localhost:5432/db',
SECRET_STORE_MASTER_KEY: 'a'.repeat(32),
LOG_KAFKA_BROKERS: 'localhost:9092',
...overrides,
};
}

describe('backendEnvSchema', () => {
it('accepts a valid full config', () => {
const result = backendEnvSchema.safeParse(validEnv());
expect(result.success).toBe(true);
});

it('fails when DATABASE_URL is missing (normal mode)', () => {
const { DATABASE_URL, ...rest } = validEnv();
const result = backendEnvSchema.safeParse(rest);
expect(result.success).toBe(false);
if (!result.success) {
const paths = result.error.issues.map((i) => i.path.join('.'));
expect(paths).toContain('DATABASE_URL');
}
});

it('passes when SKIP_INGEST_SERVICES=true without DATABASE_URL and LOG_KAFKA_BROKERS', () => {
const result = backendEnvSchema.safeParse({
SECRET_STORE_MASTER_KEY: 'a'.repeat(32),
SKIP_INGEST_SERVICES: 'true',
});
expect(result.success).toBe(true);
});

it('passes when ENABLE_INGEST_SERVICES=false without DATABASE_URL and LOG_KAFKA_BROKERS', () => {
const result = backendEnvSchema.safeParse({
SECRET_STORE_MASTER_KEY: 'a'.repeat(32),
ENABLE_INGEST_SERVICES: 'false',
});
expect(result.success).toBe(true);
});

it('fails when AUTH_PROVIDER=clerk without CLERK keys', () => {
const result = backendEnvSchema.safeParse(validEnv({ AUTH_PROVIDER: 'clerk' }));
expect(result.success).toBe(false);
if (!result.success) {
const paths = result.error.issues.map((i) => i.path.join('.'));
expect(paths).toContain('CLERK_SECRET_KEY');
expect(paths).toContain('CLERK_PUBLISHABLE_KEY');
}
});

it('passes when AUTH_PROVIDER=clerk with both CLERK keys', () => {
const result = backendEnvSchema.safeParse(
validEnv({
AUTH_PROVIDER: 'clerk',
CLERK_SECRET_KEY: 'sk_test_xxx',
CLERK_PUBLISHABLE_KEY: 'pk_test_xxx',
}),
);
expect(result.success).toBe(true);
});

it('normalizes " LOCAL " to "local"', () => {
const result = backendEnvSchema.safeParse(validEnv({ AUTH_PROVIDER: ' LOCAL ' }));
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.AUTH_PROVIDER).toBe('local');
}
});

it('falls back unknown AUTH_PROVIDER to "local"', () => {
const result = backendEnvSchema.safeParse(validEnv({ AUTH_PROVIDER: 'weird' }));
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.AUTH_PROVIDER).toBe('local');
}
});

it('normalizes "Clerk" to "clerk"', () => {
const result = backendEnvSchema.safeParse(
validEnv({
AUTH_PROVIDER: 'Clerk',
CLERK_SECRET_KEY: 'sk_test_xxx',
CLERK_PUBLISHABLE_KEY: 'pk_test_xxx',
}),
);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.AUTH_PROVIDER).toBe('clerk');
}
});

it('coerces PORT string to number', () => {
const result = backendEnvSchema.safeParse(validEnv({ PORT: '3211' }));
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.PORT).toBe(3211);
}
});

it('defaults PORT to 3211', () => {
const result = backendEnvSchema.safeParse(validEnv());
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.PORT).toBe(3211);
}
});
});
132 changes: 132 additions & 0 deletions backend/src/config/env.schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { z } from 'zod';
import {
databaseUrlSchema,
temporalConfigSchema,
secretStoreKeySchema,
stringToBoolean,
} from '@shipsec/shared';

/**
* AUTH_PROVIDER: trims, lowercases, and defaults unknown values to 'local'.
* Preserves the tolerant normalization from auth.config.ts.
*/
const authProviderSchema = z
.string()
.optional()
.default('local')
.transform((v) => v.trim().toLowerCase())
.pipe(z.enum(['local', 'clerk']).catch('local'));

export const backendEnvSchema = z
.object({
// --- Conditionally required (depend on ingest-services flags) ---
DATABASE_URL: z.string().optional(),
LOG_KAFKA_BROKERS: z.string().optional(),

// --- Required ---
SECRET_STORE_MASTER_KEY: secretStoreKeySchema,

// --- With defaults ---
PORT: z.coerce.number().optional().default(3211),
HOST: z.string().optional().default('0.0.0.0'),
SKIP_INGEST_SERVICES: stringToBoolean(false),
ENABLE_INGEST_SERVICES: stringToBoolean(true),

// --- Auth ---
AUTH_PROVIDER: authProviderSchema,
CLERK_SECRET_KEY: z.string().optional(),
CLERK_PUBLISHABLE_KEY: z.string().optional(),
AUTH_LOCAL_ALLOW_UNAUTHENTICATED: stringToBoolean(true),
AUTH_LOCAL_API_KEY: z.string().optional().default(''),
ADMIN_USERNAME: z.string().optional().default('admin'),
ADMIN_PASSWORD: z.string().optional().default('admin'),

// --- Optional services ---
REDIS_URL: z.string().optional(),
SESSION_SECRET: z.string().optional().default(''),
WEBHOOK_BASE_URL: z.string().optional(),

// --- OpenSearch ---
OPENSEARCH_URL: z.string().optional(),
OPENSEARCH_USERNAME: z.string().optional(),
OPENSEARCH_PASSWORD: z.string().optional(),
OPENSEARCH_DASHBOARDS_URL: z.string().optional().default(''),

// --- Loki ---
LOKI_URL: z.string().optional(),
LOKI_TENANT_ID: z.string().optional().default(''),
LOKI_USERNAME: z.string().optional().default(''),
LOKI_PASSWORD: z.string().optional().default(''),

// --- MinIO ---
MINIO_ROOT_USER: z.string().optional(),
MINIO_ROOT_PASSWORD: z.string().optional(),

// --- GitHub OAuth ---
GITHUB_OAUTH_CLIENT_ID: z.string().optional(),
GITHUB_OAUTH_CLIENT_SECRET: z.string().optional(),

// --- Zoom OAuth ---
ZOOM_OAUTH_CLIENT_ID: z.string().optional(),
ZOOM_OAUTH_CLIENT_SECRET: z.string().optional(),

// --- Platform ---
PLATFORM_API_URL: z.string().optional().default(''),
PLATFORM_SERVICE_TOKEN: z.string().optional().default(''),
PLATFORM_API_TIMEOUT_MS: z.string().optional().default(''),

// --- Temporal ---
TEMPORAL_BOOTSTRAP_DEMO: stringToBoolean(false),
})
.merge(temporalConfigSchema)
.superRefine((data, ctx) => {
// Match the runtime guard in node-io.module.ts / trace.module.ts:
// ingestEnabled = ENABLE_INGEST_SERVICES !== false && SKIP_INGEST_SERVICES !== true
const ingestRequired = data.ENABLE_INGEST_SERVICES && !data.SKIP_INGEST_SERVICES;
if (ingestRequired) {
if (!data.DATABASE_URL) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['DATABASE_URL'],
message:
'DATABASE_URL is required (set SKIP_INGEST_SERVICES=true or ENABLE_INGEST_SERVICES=false to skip)',
});
} else {
const parsed = databaseUrlSchema.safeParse(data.DATABASE_URL);
if (!parsed.success) {
for (const issue of parsed.error.issues) {
ctx.addIssue({ ...issue, path: ['DATABASE_URL'] });
}
}
}

if (!data.LOG_KAFKA_BROKERS) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['LOG_KAFKA_BROKERS'],
message:
'LOG_KAFKA_BROKERS is required (set SKIP_INGEST_SERVICES=true or ENABLE_INGEST_SERVICES=false to skip)',
});
}
}

// Clerk keys required when AUTH_PROVIDER is clerk
if (data.AUTH_PROVIDER === 'clerk') {
if (!data.CLERK_SECRET_KEY) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['CLERK_SECRET_KEY'],
message: 'CLERK_SECRET_KEY is required when AUTH_PROVIDER=clerk',
});
}
if (!data.CLERK_PUBLISHABLE_KEY) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['CLERK_PUBLISHABLE_KEY'],
message: 'CLERK_PUBLISHABLE_KEY is required when AUTH_PROVIDER=clerk',
});
}
}
});

export type BackendEnvConfig = z.infer<typeof backendEnvSchema>;
13 changes: 13 additions & 0 deletions backend/src/config/env.validate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { formatEnvErrors } from '@shipsec/shared';
import { backendEnvSchema, type BackendEnvConfig } from './env.schema';

export function validateBackendEnv(config: Record<string, unknown>): BackendEnvConfig {
const result = backendEnvSchema.safeParse(config);
if (!result.success) {
console.error('\n❌ Backend environment validation failed:\n');
console.error(formatEnvErrors(result.error));
console.error('\nSee backend/.env.example for reference.\n');
throw new Error('Invalid backend environment configuration');
}
return result.data;
}
82 changes: 82 additions & 0 deletions frontend/src/config/__tests__/env.schema.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { describe, it, expect } from 'bun:test';
import { frontendEnvSchema } from '../env.schema';

describe('frontendEnvSchema', () => {
it('accepts a valid config', () => {
const result = frontendEnvSchema.safeParse({
VITE_API_URL: 'http://localhost:3211',
VITE_ENABLE_CONNECTIONS: 'true',
});
expect(result.success).toBe(true);
});

it('empty object passes and VITE_API_URL defaults to http://localhost:3211', () => {
const result = frontendEnvSchema.safeParse({});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.VITE_API_URL).toBe('http://localhost:3211');
}
});

it('VITE_API_URL=undefined defaults to http://localhost:3211', () => {
const result = frontendEnvSchema.safeParse({ VITE_API_URL: undefined });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.VITE_API_URL).toBe('http://localhost:3211');
}
});

it('VITE_ENABLE_CONNECTIONS="false" → false (not truthy)', () => {
const result = frontendEnvSchema.safeParse({ VITE_ENABLE_CONNECTIONS: 'false' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.VITE_ENABLE_CONNECTIONS).toBe(false);
}
});

it('VITE_ENABLE_CONNECTIONS=undefined → false', () => {
const result = frontendEnvSchema.safeParse({});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.VITE_ENABLE_CONNECTIONS).toBe(false);
}
});

it('VITE_DISABLE_ANALYTICS="true" → true', () => {
const result = frontendEnvSchema.safeParse({ VITE_DISABLE_ANALYTICS: 'true' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.VITE_DISABLE_ANALYTICS).toBe(true);
}
});

it('all optional vars get defaults', () => {
const result = frontendEnvSchema.safeParse({});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.VITE_FRONTEND_BRANCH).toBe('');
expect(result.data.VITE_BACKEND_BRANCH).toBe('');
expect(result.data.VITE_GIT_SHA).toBe('');
expect(result.data.VITE_LOGO_DEV_PUBLIC_KEY).toBe('');
expect(result.data.VITE_OPENSEARCH_DASHBOARDS_URL).toBe('');
expect(result.data.VITE_ENABLE_IT_OPS).toBe(false);
}
});

it('fails when VITE_AUTH_PROVIDER=clerk without VITE_CLERK_PUBLISHABLE_KEY', () => {
const result = frontendEnvSchema.safeParse({ VITE_AUTH_PROVIDER: 'clerk' });
expect(result.success).toBe(false);
if (!result.success) {
const paths = result.error.issues.map((i) => i.path.join('.'));
expect(paths).toContain('VITE_CLERK_PUBLISHABLE_KEY');
}
});

it('passes when VITE_AUTH_PROVIDER=clerk with VITE_CLERK_PUBLISHABLE_KEY', () => {
const result = frontendEnvSchema.safeParse({
VITE_AUTH_PROVIDER: 'clerk',
VITE_CLERK_PUBLISHABLE_KEY: 'pk_test_xxx',
});
expect(result.success).toBe(true);
});
});
Loading