This commit is contained in:
2025-12-26 20:54:20 +01:00
parent 904feb41b8
commit 6389be4f0c
26 changed files with 745 additions and 195 deletions

View File

@@ -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
View 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
View 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);
}

View File

@@ -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}`);

View File

@@ -50,6 +50,7 @@
],
"extends": "../../tsconfig.base.json",
"include": [
"src/**/*"
"src/**/*",
"src/**/*.d.ts"
]
}

25
apps/website/env.d.ts vendored
View File

@@ -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;
}
}
}

View 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');
}
}

View File

@@ -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}`;

View File

@@ -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,

View File

@@ -73,6 +73,7 @@
"hooks/",
"lib/",
"next-env.d.ts",
"env.d.ts",
"types/",
"utilities/",
".next/types/**/*.ts"