env vars
This commit is contained in:
@@ -17,12 +17,20 @@ import { RaceModule } from './domain/race/RaceModule';
|
||||
import { SponsorModule } from './domain/sponsor/SponsorModule';
|
||||
import { TeamModule } from './domain/team/TeamModule';
|
||||
|
||||
import { getApiPersistence, getEnableBootstrap } from './env';
|
||||
|
||||
const API_PERSISTENCE = getApiPersistence();
|
||||
const USE_DATABASE = API_PERSISTENCE === 'postgres';
|
||||
|
||||
// Keep bootstrap on by default; tests can disable explicitly.
|
||||
const ENABLE_BOOTSTRAP = getEnableBootstrap();
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
HelloModule,
|
||||
DatabaseModule,
|
||||
...(USE_DATABASE ? [DatabaseModule] : []),
|
||||
LoggingModule,
|
||||
BootstrapModule,
|
||||
...(ENABLE_BOOTSTRAP ? [BootstrapModule] : []),
|
||||
AnalyticsModule,
|
||||
AuthModule,
|
||||
DashboardModule,
|
||||
|
||||
34
apps/api/src/env.d.ts
vendored
Normal file
34
apps/api/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
declare global {
|
||||
namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
NODE_ENV?: 'development' | 'production' | 'test';
|
||||
|
||||
// API runtime toggles
|
||||
GRIDPILOT_API_PERSISTENCE?: 'postgres' | 'inmemory';
|
||||
GRIDPILOT_API_BOOTSTRAP?: string;
|
||||
GENERATE_OPENAPI?: string;
|
||||
|
||||
// Database (TypeORM)
|
||||
DATABASE_URL?: string;
|
||||
DATABASE_HOST?: string;
|
||||
DATABASE_PORT?: string;
|
||||
DATABASE_USER?: string;
|
||||
DATABASE_PASSWORD?: string;
|
||||
DATABASE_NAME?: string;
|
||||
|
||||
// Policy / operational mode
|
||||
GRIDPILOT_POLICY_CACHE_MS?: string;
|
||||
GRIDPILOT_POLICY_PATH?: string;
|
||||
GRIDPILOT_OPERATIONAL_MODE?: string;
|
||||
GRIDPILOT_FEATURES_JSON?: string;
|
||||
GRIDPILOT_MAINTENANCE_ALLOW_VIEW?: string;
|
||||
GRIDPILOT_MAINTENANCE_ALLOW_MUTATE?: string;
|
||||
|
||||
// Authorization
|
||||
GRIDPILOT_AUTHZ_CACHE_MS?: string;
|
||||
GRIDPILOT_USER_ROLES_JSON?: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
62
apps/api/src/env.ts
Normal file
62
apps/api/src/env.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
export type ApiPersistence = 'postgres' | 'inmemory';
|
||||
|
||||
function isTruthyEnv(value: string | undefined): boolean {
|
||||
if (!value) return false;
|
||||
return value !== '0' && value.toLowerCase() !== 'false';
|
||||
}
|
||||
|
||||
function isSet(value: string | undefined): boolean {
|
||||
return value !== undefined;
|
||||
}
|
||||
|
||||
function readLower(name: string): string | undefined {
|
||||
const raw = process.env[name];
|
||||
if (raw === undefined) return undefined;
|
||||
return raw.toLowerCase();
|
||||
}
|
||||
|
||||
function requireOneOf<T extends string>(name: string, value: string, allowed: readonly T[]): T {
|
||||
if ((allowed as readonly string[]).includes(value)) {
|
||||
return value as T;
|
||||
}
|
||||
|
||||
const valid = allowed.join(', ');
|
||||
throw new Error(`Invalid ${name}: "${value}". Must be one of: ${valid}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Controls whether the API uses Postgres or runs in-memory.
|
||||
*
|
||||
* If `GRIDPILOT_API_PERSISTENCE` is set, it must be `postgres|inmemory`.
|
||||
* Otherwise, it falls back to: `postgres` when `DATABASE_URL` exists, else `inmemory`.
|
||||
*/
|
||||
export function getApiPersistence(): ApiPersistence {
|
||||
const configured = readLower('GRIDPILOT_API_PERSISTENCE');
|
||||
if (configured) {
|
||||
return requireOneOf('GRIDPILOT_API_PERSISTENCE', configured, ['postgres', 'inmemory'] as const);
|
||||
}
|
||||
|
||||
return process.env.DATABASE_URL ? 'postgres' : 'inmemory';
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep bootstrap on by default; tests can disable explicitly.
|
||||
*
|
||||
* `GRIDPILOT_API_BOOTSTRAP` uses "truthy" parsing:
|
||||
* - false when unset / "0" / "false"
|
||||
* - true otherwise
|
||||
*/
|
||||
export function getEnableBootstrap(): boolean {
|
||||
const raw = process.env.GRIDPILOT_API_BOOTSTRAP;
|
||||
if (raw === undefined) return true;
|
||||
return isTruthyEnv(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* When set, the API will generate `openapi.json` and optionally reduce logging noise.
|
||||
*
|
||||
* Matches previous behavior: any value (even "0") counts as enabled if the var is present.
|
||||
*/
|
||||
export function getGenerateOpenapi(): boolean {
|
||||
return isSet(process.env.GENERATE_OPENAPI);
|
||||
}
|
||||
@@ -11,8 +11,11 @@ import { AuthenticationGuard } from './domain/auth/AuthenticationGuard';
|
||||
import { AuthorizationGuard } from './domain/auth/AuthorizationGuard';
|
||||
import { FeatureAvailabilityGuard } from './domain/policy/FeatureAvailabilityGuard';
|
||||
|
||||
import { getGenerateOpenapi } from './env';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule, process.env.GENERATE_OPENAPI ? { logger: false } : undefined);
|
||||
const generateOpenapi = getGenerateOpenapi();
|
||||
const app = await NestFactory.create(AppModule, generateOpenapi ? { logger: false } : undefined);
|
||||
|
||||
// Website runs on a different origin in dev/docker (e.g. http://localhost:3000 -> http://localhost:3001),
|
||||
// and our website HTTP client uses `credentials: 'include'`, so we must support CORS with credentials.
|
||||
@@ -64,7 +67,7 @@ async function bootstrap() {
|
||||
SwaggerModule.setup('api/docs', app as any, document);
|
||||
|
||||
// Export OpenAPI spec as JSON file when GENERATE_OPENAPI env var is set
|
||||
if (process.env.GENERATE_OPENAPI) {
|
||||
if (generateOpenapi) {
|
||||
const outputPath = join(__dirname, '../openapi.json');
|
||||
writeFileSync(outputPath, JSON.stringify(document, null, 2));
|
||||
console.log(`✅ OpenAPI spec generated at: ${outputPath}`);
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
],
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": [
|
||||
"src/**/*"
|
||||
"src/**/*",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
25
apps/website/env.d.ts
vendored
25
apps/website/env.d.ts
vendored
@@ -47,8 +47,33 @@ declare module 'react/compiler-runtime' {
|
||||
declare global {
|
||||
namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
NODE_ENV?: 'development' | 'production' | 'test';
|
||||
|
||||
// Website (public, exposed to browser)
|
||||
NEXT_PUBLIC_GRIDPILOT_MODE?: 'pre-launch' | 'alpha';
|
||||
NEXT_PUBLIC_SITE_URL?: string;
|
||||
NEXT_PUBLIC_API_BASE_URL?: string;
|
||||
NEXT_PUBLIC_SITE_NAME?: string;
|
||||
NEXT_PUBLIC_SUPPORT_EMAIL?: string;
|
||||
NEXT_PUBLIC_SPONSOR_EMAIL?: string;
|
||||
NEXT_PUBLIC_LEGAL_COMPANY_NAME?: string;
|
||||
NEXT_PUBLIC_LEGAL_VAT_ID?: string;
|
||||
NEXT_PUBLIC_LEGAL_REGISTERED_COUNTRY?: string;
|
||||
NEXT_PUBLIC_LEGAL_REGISTERED_ADDRESS?: string;
|
||||
NEXT_PUBLIC_DISCORD_URL?: string;
|
||||
NEXT_PUBLIC_X_URL?: string;
|
||||
NEXT_TELEMETRY_DISABLED?: string;
|
||||
|
||||
// Website (server-only)
|
||||
API_BASE_URL?: string;
|
||||
|
||||
// Vercel KV (server-only)
|
||||
KV_REST_API_URL?: string;
|
||||
KV_REST_API_TOKEN?: string;
|
||||
|
||||
// Build/CI toggles (server-only)
|
||||
CI?: string;
|
||||
DOCKER?: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
96
apps/website/lib/config/env.ts
Normal file
96
apps/website/lib/config/env.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const urlOptional = z.string().url().optional();
|
||||
const stringOptional = z.string().optional();
|
||||
|
||||
const publicEnvSchema = z.object({
|
||||
NEXT_PUBLIC_GRIDPILOT_MODE: z.enum(['pre-launch', 'alpha']).optional(),
|
||||
NEXT_PUBLIC_SITE_URL: urlOptional,
|
||||
NEXT_PUBLIC_API_BASE_URL: urlOptional,
|
||||
|
||||
NEXT_PUBLIC_SITE_NAME: stringOptional,
|
||||
NEXT_PUBLIC_SUPPORT_EMAIL: stringOptional,
|
||||
NEXT_PUBLIC_SPONSOR_EMAIL: stringOptional,
|
||||
|
||||
NEXT_PUBLIC_LEGAL_COMPANY_NAME: stringOptional,
|
||||
NEXT_PUBLIC_LEGAL_VAT_ID: stringOptional,
|
||||
NEXT_PUBLIC_LEGAL_REGISTERED_COUNTRY: stringOptional,
|
||||
NEXT_PUBLIC_LEGAL_REGISTERED_ADDRESS: stringOptional,
|
||||
|
||||
NEXT_PUBLIC_DISCORD_URL: stringOptional,
|
||||
NEXT_PUBLIC_X_URL: stringOptional,
|
||||
});
|
||||
|
||||
const serverEnvSchema = z.object({
|
||||
API_BASE_URL: urlOptional,
|
||||
KV_REST_API_URL: urlOptional,
|
||||
KV_REST_API_TOKEN: stringOptional,
|
||||
|
||||
CI: stringOptional,
|
||||
DOCKER: stringOptional,
|
||||
NODE_ENV: z.enum(['development', 'production', 'test']).optional(),
|
||||
});
|
||||
|
||||
export type WebsitePublicEnv = z.infer<typeof publicEnvSchema>;
|
||||
export type WebsiteServerEnv = z.infer<typeof serverEnvSchema>;
|
||||
|
||||
function formatZodIssues(issues: z.ZodIssue[]): string {
|
||||
return issues
|
||||
.map((issue) => {
|
||||
const path = issue.path.join('.') || '(root)';
|
||||
return `${path}: ${issue.message}`;
|
||||
})
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses Website env vars (server-side safe).
|
||||
* Only validates the variables we explicitly support.
|
||||
*/
|
||||
export function getWebsiteServerEnv(): WebsiteServerEnv {
|
||||
const result = serverEnvSchema.safeParse(process.env);
|
||||
if (!result.success) {
|
||||
throw new Error(`Invalid website server env: ${formatZodIssues(result.error.issues)}`);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses Website public env vars (safe on both server + client).
|
||||
* Note: on the client, only `NEXT_PUBLIC_*` vars are actually present.
|
||||
*/
|
||||
export function getWebsitePublicEnv(): WebsitePublicEnv {
|
||||
const result = publicEnvSchema.safeParse(process.env);
|
||||
if (!result.success) {
|
||||
throw new Error(`Invalid website public env: ${formatZodIssues(result.error.issues)}`);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
export function isTruthyEnv(value: string | undefined): boolean {
|
||||
if (!value) return false;
|
||||
return value !== '0' && value.toLowerCase() !== 'false';
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches the semantics used in [`getWebsiteApiBaseUrl()`](apps/website/lib/config/apiBaseUrl.ts:6).
|
||||
*/
|
||||
export function isTestLikeEnvironment(): boolean {
|
||||
const { NODE_ENV, CI, DOCKER } = getWebsiteServerEnv();
|
||||
return NODE_ENV === 'test' || CI === 'true' || DOCKER === 'true';
|
||||
}
|
||||
|
||||
export function isProductionEnvironment(): boolean {
|
||||
return getWebsiteServerEnv().NODE_ENV === 'production';
|
||||
}
|
||||
|
||||
export function isKvConfigured(): boolean {
|
||||
const { KV_REST_API_URL, KV_REST_API_TOKEN } = getWebsiteServerEnv();
|
||||
return Boolean(KV_REST_API_URL && KV_REST_API_TOKEN);
|
||||
}
|
||||
|
||||
export function assertKvConfiguredInProduction(): void {
|
||||
if (isProductionEnvironment() && !isKvConfigured()) {
|
||||
throw new Error('Missing KV_REST_API_URL/KV_REST_API_TOKEN in production environment');
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import { assertKvConfiguredInProduction, isKvConfigured, isProductionEnvironment } from './config/env';
|
||||
|
||||
const RATE_LIMIT_WINDOW = 60 * 60 * 1000; // 1 hour in milliseconds
|
||||
const MAX_REQUESTS_PER_WINDOW = 5;
|
||||
const RATE_LIMIT_PREFIX = 'ratelimit:signup:';
|
||||
|
||||
const isDev = !process.env.KV_REST_API_URL;
|
||||
// Dev fallback: only allowed outside production.
|
||||
const isDev = !isProductionEnvironment() && !isKvConfigured();
|
||||
|
||||
// In-memory fallback for development
|
||||
const devRateLimits = new Map<string, { count: number; resetAt: number }>();
|
||||
@@ -49,6 +52,8 @@ export async function checkRateLimit(identifier: string): Promise<{
|
||||
}
|
||||
|
||||
// Production: Use Vercel KV
|
||||
assertKvConfiguredInProduction();
|
||||
|
||||
const { kv } = await import('@vercel/kv');
|
||||
const key = `${RATE_LIMIT_PREFIX}${identifier}`;
|
||||
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import { apiClient } from '@/lib/apiClient';
|
||||
import { ApiClient } from '@/lib/api';
|
||||
import type { LeaguesApiClient } from '@/lib/api/leagues/LeaguesApiClient';
|
||||
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
|
||||
import { LeagueMemberViewModel } from '@/lib/view-models/LeagueMemberViewModel';
|
||||
import type { LeagueMemberDTO } from '@/lib/types/generated/LeagueMemberDTO';
|
||||
import type { LeagueMembership } from '@/lib/types/LeagueMembership';
|
||||
|
||||
let cachedLeaguesApiClient: LeaguesApiClient | undefined;
|
||||
|
||||
function getDefaultLeaguesApiClient(): LeaguesApiClient {
|
||||
if (cachedLeaguesApiClient) return cachedLeaguesApiClient;
|
||||
|
||||
const api = new ApiClient(getWebsiteApiBaseUrl());
|
||||
cachedLeaguesApiClient = (api as any).leagues as LeaguesApiClient;
|
||||
return cachedLeaguesApiClient;
|
||||
}
|
||||
|
||||
export class LeagueMembershipService {
|
||||
// In-memory cache for memberships (populated via API calls)
|
||||
private static leagueMemberships = new Map<string, LeagueMembership[]>();
|
||||
@@ -11,7 +22,7 @@ export class LeagueMembershipService {
|
||||
constructor(private readonly leaguesApiClient?: LeaguesApiClient) {}
|
||||
|
||||
private getClient(): LeaguesApiClient {
|
||||
return (this.leaguesApiClient ?? (apiClient as any).leagues) as LeaguesApiClient;
|
||||
return this.leaguesApiClient ?? getDefaultLeaguesApiClient();
|
||||
}
|
||||
|
||||
async getLeagueMemberships(leagueId: string, currentUserId: string): Promise<LeagueMemberViewModel[]> {
|
||||
@@ -45,7 +56,7 @@ export class LeagueMembershipService {
|
||||
*/
|
||||
static async fetchLeagueMemberships(leagueId: string): Promise<LeagueMembership[]> {
|
||||
try {
|
||||
const result = await apiClient.leagues.getMemberships(leagueId);
|
||||
const result = await getDefaultLeaguesApiClient().getMemberships(leagueId);
|
||||
const memberships: LeagueMembership[] = ((result as any)?.members ?? []).map((member: any) => ({
|
||||
id: `${member.driverId}-${leagueId}`, // Generate ID since API doesn't provide it
|
||||
leagueId,
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"hooks/",
|
||||
"lib/",
|
||||
"next-env.d.ts",
|
||||
"env.d.ts",
|
||||
"types/",
|
||||
"utilities/",
|
||||
".next/types/**/*.ts"
|
||||
|
||||
Reference in New Issue
Block a user