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

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"