env vars
This commit is contained in:
@@ -1,45 +1,84 @@
|
|||||||
# ==========================================
|
# ==========================================
|
||||||
# GridPilot Development Environment
|
# GridPilot Development Environment
|
||||||
# ==========================================
|
# ==========================================
|
||||||
|
# Used by `docker-compose.dev.yml` via `env_file: .env.development`.
|
||||||
|
|
||||||
# Node Environment
|
# ------------------------------------------
|
||||||
|
# Runtime
|
||||||
|
# ------------------------------------------
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
|
NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
|
# ------------------------------------------
|
||||||
|
# API (NestJS)
|
||||||
|
# ------------------------------------------
|
||||||
|
# API persistence is inferred from DATABASE_URL by default.
|
||||||
|
# GRIDPILOT_API_PERSISTENCE=postgres
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# Database (PostgreSQL)
|
|
||||||
# ==========================================
|
|
||||||
DATABASE_URL=postgres://gridpilot_user:gridpilot_dev_pass@db:5432/gridpilot_dev
|
DATABASE_URL=postgres://gridpilot_user:gridpilot_dev_pass@db:5432/gridpilot_dev
|
||||||
|
|
||||||
|
# Postgres container vars (used by `docker-compose.dev.yml` -> `db`)
|
||||||
POSTGRES_DB=gridpilot_dev
|
POSTGRES_DB=gridpilot_dev
|
||||||
POSTGRES_USER=gridpilot_user
|
POSTGRES_USER=gridpilot_user
|
||||||
POSTGRES_PASSWORD=gridpilot_dev_pass
|
POSTGRES_PASSWORD=gridpilot_dev_pass
|
||||||
|
|
||||||
# ==========================================
|
# ------------------------------------------
|
||||||
# API Configuration
|
# Website (Next.js) - public (exposed to browser)
|
||||||
# ==========================================
|
# ------------------------------------------
|
||||||
API_PORT=3000
|
|
||||||
API_HOST=0.0.0.0
|
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# Website Configuration
|
|
||||||
# ==========================================
|
|
||||||
NEXT_PUBLIC_GRIDPILOT_MODE=alpha
|
NEXT_PUBLIC_GRIDPILOT_MODE=alpha
|
||||||
NEXT_PUBLIC_SITE_URL=http://localhost:3000
|
NEXT_PUBLIC_SITE_URL=http://localhost:3000
|
||||||
NEXT_PUBLIC_API_URL=http://localhost:3001
|
|
||||||
NEXT_PUBLIC_API_BASE_URL=http://localhost:3001
|
|
||||||
NEXT_PUBLIC_DISCORD_URL=https://discord.gg/your-invite-code
|
|
||||||
NEXT_TELEMETRY_DISABLED=1
|
|
||||||
|
|
||||||
# ==========================================
|
# Browser → API base URL (host port 3001 -> container port 3000)
|
||||||
# Vercel KV (Optional in Development)
|
NEXT_PUBLIC_API_BASE_URL=http://localhost:3001
|
||||||
# ==========================================
|
|
||||||
|
NEXT_PUBLIC_DISCORD_URL=https://discord.gg/your-invite-code
|
||||||
|
NEXT_PUBLIC_X_URL=https://x.com/your-handle
|
||||||
|
|
||||||
|
# Optional site/legal metadata (defaults used when unset)
|
||||||
|
# NEXT_PUBLIC_SITE_NAME=GridPilot
|
||||||
|
# NEXT_PUBLIC_SUPPORT_EMAIL=support@example.com
|
||||||
|
# NEXT_PUBLIC_SPONSOR_EMAIL=sponsors@example.com
|
||||||
|
# NEXT_PUBLIC_LEGAL_COMPANY_NAME=
|
||||||
|
# NEXT_PUBLIC_LEGAL_VAT_ID=
|
||||||
|
# NEXT_PUBLIC_LEGAL_REGISTERED_COUNTRY=
|
||||||
|
# NEXT_PUBLIC_LEGAL_REGISTERED_ADDRESS=
|
||||||
|
|
||||||
|
# ------------------------------------------
|
||||||
|
# Vercel KV (optional in dev)
|
||||||
|
# ------------------------------------------
|
||||||
|
# If unset, the Website uses an in-memory fallback with a warning.
|
||||||
# KV_REST_API_URL=
|
# KV_REST_API_URL=
|
||||||
# KV_REST_API_TOKEN=
|
# KV_REST_API_TOKEN=
|
||||||
|
|
||||||
# ==========================================
|
# ------------------------------------------
|
||||||
# Automation Mode
|
# Automation / Companion (advanced)
|
||||||
# ==========================================
|
# ------------------------------------------
|
||||||
AUTOMATION_MODE=dev
|
# Prefer using NODE_ENV=development/test. `AUTOMATION_MODE` is legacy & deprecated.
|
||||||
|
# AUTOMATION_MODE=dev
|
||||||
CHROME_DEBUG_PORT=9222
|
CHROME_DEBUG_PORT=9222
|
||||||
|
# CHROME_WS_ENDPOINT=ws://127.0.0.1:9222/devtools/browser/<id>
|
||||||
|
|
||||||
|
# Native automation tuning (optional)
|
||||||
|
# IRACING_WINDOW_TITLE=iRacing
|
||||||
|
# TEMPLATE_PATH=./resources/templates/iracing
|
||||||
|
# OCR_CONFIDENCE=0.9
|
||||||
|
# NUTJS_MOUSE_SPEED=1000
|
||||||
|
# NUTJS_KEYBOARD_DELAY=50
|
||||||
|
# AUTOMATION_MAX_RETRIES=3
|
||||||
|
# AUTOMATION_BASE_DELAY_MS=500
|
||||||
|
# AUTOMATION_MAX_DELAY_MS=5000
|
||||||
|
# AUTOMATION_BACKOFF_MULTIPLIER=2
|
||||||
|
# AUTOMATION_PAGE_LOAD_WAIT_MS=5000
|
||||||
|
# AUTOMATION_INTER_ACTION_DELAY_MS=200
|
||||||
|
# AUTOMATION_POST_CLICK_DELAY_MS=300
|
||||||
|
# AUTOMATION_PRE_STEP_DELAY_MS=100
|
||||||
|
|
||||||
AUTOMATION_TIMEOUT=30000
|
AUTOMATION_TIMEOUT=30000
|
||||||
RETRY_ATTEMPTS=3
|
RETRY_ATTEMPTS=3
|
||||||
SCREENSHOT_ON_ERROR=true
|
SCREENSHOT_ON_ERROR=true
|
||||||
|
|
||||||
|
# Logging (automation/adapters)
|
||||||
|
# LOG_LEVEL=debug
|
||||||
|
# LOG_FILE_PATH=./logs/gridpilot
|
||||||
|
# LOG_MAX_FILES=7
|
||||||
|
# LOG_MAX_SIZE=10m
|
||||||
|
|||||||
@@ -1,18 +1,89 @@
|
|||||||
# Development Environment Configuration
|
# ==========================================
|
||||||
# Copy this file to .env.development and adjust values as needed
|
# GridPilot Development Environment Example
|
||||||
|
# ==========================================
|
||||||
|
# Copy to `.env.development` and adjust values as needed.
|
||||||
|
#
|
||||||
|
# This file is consumed by `docker-compose.dev.yml` (API, Website, Postgres).
|
||||||
|
|
||||||
# Automation mode: 'dev' | 'production' | 'mock'
|
# ------------------------------------------
|
||||||
AUTOMATION_MODE=dev
|
# Runtime
|
||||||
|
# ------------------------------------------
|
||||||
|
NODE_ENV=development
|
||||||
|
NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
# Chrome DevTools settings (for dev mode)
|
# ------------------------------------------
|
||||||
|
# API (NestJS)
|
||||||
|
# ------------------------------------------
|
||||||
|
# API persistence is inferred from DATABASE_URL by default.
|
||||||
|
# GRIDPILOT_API_PERSISTENCE=postgres
|
||||||
|
|
||||||
|
DATABASE_URL=postgres://gridpilot_user:gridpilot_dev_pass@db:5432/gridpilot_dev
|
||||||
|
|
||||||
|
# Postgres container vars (used by docker `db` service)
|
||||||
|
POSTGRES_DB=gridpilot_dev
|
||||||
|
POSTGRES_USER=gridpilot_user
|
||||||
|
POSTGRES_PASSWORD=gridpilot_dev_pass
|
||||||
|
|
||||||
|
# ------------------------------------------
|
||||||
|
# Website (Next.js) - public (exposed to browser)
|
||||||
|
# ------------------------------------------
|
||||||
|
NEXT_PUBLIC_GRIDPILOT_MODE=alpha
|
||||||
|
NEXT_PUBLIC_SITE_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# Browser → API base URL (host port 3001 -> container port 3000)
|
||||||
|
NEXT_PUBLIC_API_BASE_URL=http://localhost:3001
|
||||||
|
|
||||||
|
# Optional links / metadata
|
||||||
|
NEXT_PUBLIC_DISCORD_URL=https://discord.gg/your-invite-code
|
||||||
|
NEXT_PUBLIC_X_URL=https://x.com/your-handle
|
||||||
|
# NEXT_PUBLIC_SITE_NAME=GridPilot
|
||||||
|
# NEXT_PUBLIC_SUPPORT_EMAIL=support@example.com
|
||||||
|
# NEXT_PUBLIC_SPONSOR_EMAIL=sponsors@example.com
|
||||||
|
# NEXT_PUBLIC_LEGAL_COMPANY_NAME=
|
||||||
|
# NEXT_PUBLIC_LEGAL_VAT_ID=
|
||||||
|
# NEXT_PUBLIC_LEGAL_REGISTERED_COUNTRY=
|
||||||
|
# NEXT_PUBLIC_LEGAL_REGISTERED_ADDRESS=
|
||||||
|
|
||||||
|
# ------------------------------------------
|
||||||
|
# Vercel KV (optional in dev)
|
||||||
|
# ------------------------------------------
|
||||||
|
# If unset, the Website uses an in-memory fallback with a warning.
|
||||||
|
# KV_REST_API_URL=
|
||||||
|
# KV_REST_API_TOKEN=
|
||||||
|
|
||||||
|
# ------------------------------------------
|
||||||
|
# Automation / Companion (advanced)
|
||||||
|
# ------------------------------------------
|
||||||
|
# Prefer using NODE_ENV=development/test. `AUTOMATION_MODE` is legacy & deprecated.
|
||||||
|
# AUTOMATION_MODE=dev
|
||||||
CHROME_DEBUG_PORT=9222
|
CHROME_DEBUG_PORT=9222
|
||||||
# CHROME_WS_ENDPOINT=ws://127.0.0.1:9222/devtools/browser/<id>
|
# CHROME_WS_ENDPOINT=ws://127.0.0.1:9222/devtools/browser/<id>
|
||||||
|
|
||||||
# Shared automation settings
|
# Native automation tuning (optional)
|
||||||
|
# IRACING_WINDOW_TITLE=iRacing
|
||||||
|
# TEMPLATE_PATH=./resources/templates/iracing
|
||||||
|
# OCR_CONFIDENCE=0.9
|
||||||
|
# NUTJS_MOUSE_SPEED=1000
|
||||||
|
# NUTJS_KEYBOARD_DELAY=50
|
||||||
|
# AUTOMATION_MAX_RETRIES=3
|
||||||
|
# AUTOMATION_BASE_DELAY_MS=500
|
||||||
|
# AUTOMATION_MAX_DELAY_MS=5000
|
||||||
|
# AUTOMATION_BACKOFF_MULTIPLIER=2
|
||||||
|
# AUTOMATION_PAGE_LOAD_WAIT_MS=5000
|
||||||
|
# AUTOMATION_INTER_ACTION_DELAY_MS=200
|
||||||
|
# AUTOMATION_POST_CLICK_DELAY_MS=300
|
||||||
|
# AUTOMATION_PRE_STEP_DELAY_MS=100
|
||||||
|
|
||||||
AUTOMATION_TIMEOUT=30000
|
AUTOMATION_TIMEOUT=30000
|
||||||
RETRY_ATTEMPTS=3
|
RETRY_ATTEMPTS=3
|
||||||
SCREENSHOT_ON_ERROR=true
|
SCREENSHOT_ON_ERROR=true
|
||||||
|
|
||||||
|
# Logging (automation/adapters)
|
||||||
|
# LOG_LEVEL=debug
|
||||||
|
# LOG_FILE_PATH=./logs/gridpilot
|
||||||
|
# LOG_MAX_FILES=7
|
||||||
|
# LOG_MAX_SIZE=10m
|
||||||
|
|
||||||
# Start Chrome with debugging enabled:
|
# Start Chrome with debugging enabled:
|
||||||
# /Applications/Google Chrome.app/Contents/MacOS/Google Chrome --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-debug
|
# /Applications/Google Chrome.app/Contents/MacOS/Google Chrome --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-debug
|
||||||
# Or use: npm run chrome:debug
|
# Or use: npm run chrome:debug
|
||||||
@@ -1,55 +1,78 @@
|
|||||||
# ==========================================
|
# ==========================================
|
||||||
# GridPilot Production Environment
|
# GridPilot Production Environment
|
||||||
# ==========================================
|
# ==========================================
|
||||||
|
# Used by `docker-compose.prod.yml` via `env_file: .env.production`.
|
||||||
|
#
|
||||||
|
# Security:
|
||||||
|
# - Do NOT commit real secrets (use a secret manager / CI vars).
|
||||||
|
# - Replace all `CHANGE_ME_...` values before deploying.
|
||||||
|
|
||||||
# Node Environment
|
# ------------------------------------------
|
||||||
|
# Runtime
|
||||||
|
# ------------------------------------------
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
|
NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
# ==========================================
|
# ------------------------------------------
|
||||||
# Database (PostgreSQL)
|
# API (NestJS)
|
||||||
# ==========================================
|
# ------------------------------------------
|
||||||
# IMPORTANT: Change these credentials in production!
|
# Controls whether the API uses Postgres or runs in-memory.
|
||||||
|
# Values: `postgres` | `inmemory`
|
||||||
|
GRIDPILOT_API_PERSISTENCE=postgres
|
||||||
|
|
||||||
|
# Optional: bootstrap demo data (default: true)
|
||||||
|
# GRIDPILOT_API_BOOTSTRAP=true
|
||||||
|
|
||||||
|
# Database connection for the API (preferred: single URL)
|
||||||
DATABASE_URL=postgres://gridpilot_user:CHANGE_ME_IN_PRODUCTION@db:5432/gridpilot_prod
|
DATABASE_URL=postgres://gridpilot_user:CHANGE_ME_IN_PRODUCTION@db:5432/gridpilot_prod
|
||||||
|
|
||||||
|
# Postgres container vars (used by `docker-compose.prod.yml` -> `db` service)
|
||||||
POSTGRES_DB=gridpilot_prod
|
POSTGRES_DB=gridpilot_prod
|
||||||
POSTGRES_USER=gridpilot_user
|
POSTGRES_USER=gridpilot_user
|
||||||
POSTGRES_PASSWORD=CHANGE_ME_IN_PRODUCTION
|
POSTGRES_PASSWORD=CHANGE_ME_IN_PRODUCTION
|
||||||
|
|
||||||
# ==========================================
|
# ------------------------------------------
|
||||||
# Redis Cache
|
# Redis container vars (used by `docker-compose.prod.yml` -> `redis` service)
|
||||||
# ==========================================
|
# ------------------------------------------
|
||||||
# IMPORTANT: Change password in production!
|
|
||||||
REDIS_URL=redis://:CHANGE_ME_IN_PRODUCTION@redis:6379
|
|
||||||
REDIS_PASSWORD=CHANGE_ME_IN_PRODUCTION
|
REDIS_PASSWORD=CHANGE_ME_IN_PRODUCTION
|
||||||
REDIS_HOST=redis
|
|
||||||
REDIS_PORT=6379
|
|
||||||
|
|
||||||
# ==========================================
|
# ------------------------------------------
|
||||||
# API Configuration
|
# Website (Next.js) - public (exposed to browser)
|
||||||
# ==========================================
|
# ------------------------------------------
|
||||||
API_PORT=3000
|
|
||||||
API_HOST=0.0.0.0
|
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# Website Configuration
|
|
||||||
# ==========================================
|
|
||||||
NEXT_PUBLIC_GRIDPILOT_MODE=alpha
|
NEXT_PUBLIC_GRIDPILOT_MODE=alpha
|
||||||
NEXT_PUBLIC_SITE_URL=http://localhost:80
|
NEXT_PUBLIC_SITE_URL=https://your-domain.com
|
||||||
NEXT_PUBLIC_API_URL=http://localhost:80/api
|
|
||||||
|
# Browser → API base URL.
|
||||||
|
# If nginx proxies `/api` to the API service, this is typically:
|
||||||
|
# https://your-domain.com/api
|
||||||
|
NEXT_PUBLIC_API_BASE_URL=https://your-domain.com/api
|
||||||
|
|
||||||
|
# Optional links / metadata (defaults used when unset)
|
||||||
NEXT_PUBLIC_DISCORD_URL=https://discord.gg/your-invite-code
|
NEXT_PUBLIC_DISCORD_URL=https://discord.gg/your-invite-code
|
||||||
NEXT_TELEMETRY_DISABLED=1
|
NEXT_PUBLIC_X_URL=https://x.com/your-handle
|
||||||
|
# NEXT_PUBLIC_SITE_NAME=GridPilot
|
||||||
|
# NEXT_PUBLIC_SUPPORT_EMAIL=support@your-domain.com
|
||||||
|
# NEXT_PUBLIC_SPONSOR_EMAIL=sponsors@your-domain.com
|
||||||
|
# NEXT_PUBLIC_LEGAL_COMPANY_NAME=Your Company GmbH
|
||||||
|
# NEXT_PUBLIC_LEGAL_VAT_ID=DE123456789
|
||||||
|
# NEXT_PUBLIC_LEGAL_REGISTERED_COUNTRY=DE
|
||||||
|
# NEXT_PUBLIC_LEGAL_REGISTERED_ADDRESS=Street 1, 12345 City
|
||||||
|
|
||||||
# ==========================================
|
# ------------------------------------------
|
||||||
# Vercel KV (REQUIRED in Production)
|
# Website (Next.js) - server (NOT exposed to browser)
|
||||||
# ==========================================
|
# ------------------------------------------
|
||||||
# For local testing, these can be left as placeholders
|
# SSR/server-to-server base URL inside Docker network
|
||||||
# In production, get these from: https://vercel.com/dashboard -> Storage -> KV
|
API_BASE_URL=http://api:3000
|
||||||
KV_REST_API_URL=https://placeholder-kv.vercel-storage.com
|
|
||||||
KV_REST_API_TOKEN=placeholder_kv_token
|
|
||||||
|
|
||||||
# ==========================================
|
# Vercel KV (required for email signup storage/rate limit in production)
|
||||||
# Automation Mode
|
KV_REST_API_URL=https://your-kv-rest-api-url.vercel-storage.com
|
||||||
# ==========================================
|
KV_REST_API_TOKEN=CHANGE_ME_IN_PRODUCTION
|
||||||
AUTOMATION_MODE=production
|
|
||||||
|
# ------------------------------------------
|
||||||
|
# Automation / Companion (advanced)
|
||||||
|
# ------------------------------------------
|
||||||
|
# NOTE: `AUTOMATION_MODE` is deprecated (see `getAutomationMode()` in adapters).
|
||||||
|
# AUTOMATION_MODE=production
|
||||||
AUTOMATION_TIMEOUT=30000
|
AUTOMATION_TIMEOUT=30000
|
||||||
RETRY_ATTEMPTS=3
|
RETRY_ATTEMPTS=3
|
||||||
SCREENSHOT_ON_ERROR=false
|
SCREENSHOT_ON_ERROR=false
|
||||||
@@ -1,62 +1,70 @@
|
|||||||
# ==========================================
|
# ==========================================
|
||||||
# GridPilot Production Environment Example
|
# GridPilot Production Environment Example
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# Copy this file to .env.production and update with real credentials
|
# Copy to `.env.production` and replace placeholders with real values.
|
||||||
|
#
|
||||||
|
# Do NOT commit real secrets.
|
||||||
|
|
||||||
# Node Environment
|
# ------------------------------------------
|
||||||
|
# Runtime
|
||||||
|
# ------------------------------------------
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# Database (PostgreSQL)
|
|
||||||
# ==========================================
|
|
||||||
# Update these with your production database credentials
|
|
||||||
DATABASE_URL=postgres://gridpilot_user:YOUR_SECURE_PASSWORD@db:5432/gridpilot_prod
|
|
||||||
POSTGRES_DB=gridpilot_prod
|
|
||||||
POSTGRES_USER=gridpilot_user
|
|
||||||
POSTGRES_PASSWORD=YOUR_SECURE_PASSWORD
|
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# Redis Cache
|
|
||||||
# ==========================================
|
|
||||||
# Update with your production Redis password
|
|
||||||
REDIS_URL=redis://:YOUR_REDIS_PASSWORD@redis:6379
|
|
||||||
REDIS_PASSWORD=YOUR_REDIS_PASSWORD
|
|
||||||
REDIS_HOST=redis
|
|
||||||
REDIS_PORT=6379
|
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# API Configuration
|
|
||||||
# ==========================================
|
|
||||||
API_PORT=3000
|
|
||||||
API_HOST=0.0.0.0
|
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# Website Configuration
|
|
||||||
# ==========================================
|
|
||||||
# Update with your actual domain
|
|
||||||
NEXT_PUBLIC_GRIDPILOT_MODE=alpha
|
|
||||||
NEXT_PUBLIC_SITE_URL=https://your-domain.com
|
|
||||||
NEXT_PUBLIC_API_URL=https://api.your-domain.com
|
|
||||||
NEXT_PUBLIC_DISCORD_URL=https://discord.gg/your-invite-code
|
|
||||||
NEXT_TELEMETRY_DISABLED=1
|
NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
# ==========================================
|
# ------------------------------------------
|
||||||
# Vercel KV (REQUIRED in Production)
|
# API (NestJS)
|
||||||
# ==========================================
|
# ------------------------------------------
|
||||||
# Get these from: https://vercel.com/dashboard -> Storage -> KV
|
GRIDPILOT_API_PERSISTENCE=postgres
|
||||||
KV_REST_API_URL=https://your-kv-rest-api-url.vercel-storage.com
|
# GRIDPILOT_API_BOOTSTRAP=true
|
||||||
KV_REST_API_TOKEN=your_kv_rest_api_token_here
|
|
||||||
|
|
||||||
# ==========================================
|
# Prefer a single connection URL (Docker: host `db`)
|
||||||
# Automation Mode
|
DATABASE_URL=postgres://gridpilot_user:CHANGE_ME@db:5432/gridpilot_prod
|
||||||
# ==========================================
|
|
||||||
AUTOMATION_MODE=production
|
# Postgres container vars (used by `docker-compose.prod.yml` -> `db`)
|
||||||
|
POSTGRES_DB=gridpilot_prod
|
||||||
|
POSTGRES_USER=gridpilot_user
|
||||||
|
POSTGRES_PASSWORD=CHANGE_ME
|
||||||
|
|
||||||
|
# Redis container vars (used by `docker-compose.prod.yml` -> `redis`)
|
||||||
|
REDIS_PASSWORD=CHANGE_ME
|
||||||
|
|
||||||
|
# ------------------------------------------
|
||||||
|
# Website (Next.js) - public (exposed to browser)
|
||||||
|
# ------------------------------------------
|
||||||
|
NEXT_PUBLIC_GRIDPILOT_MODE=alpha
|
||||||
|
NEXT_PUBLIC_SITE_URL=https://your-domain.com
|
||||||
|
|
||||||
|
# Browser → API base URL.
|
||||||
|
# If nginx proxies `/api` to the API service, this is typically:
|
||||||
|
# https://your-domain.com/api
|
||||||
|
NEXT_PUBLIC_API_BASE_URL=https://your-domain.com/api
|
||||||
|
|
||||||
|
NEXT_PUBLIC_DISCORD_URL=https://discord.gg/your-invite-code
|
||||||
|
NEXT_PUBLIC_X_URL=https://x.com/your-handle
|
||||||
|
|
||||||
|
# Optional site/legal metadata (defaults used when unset)
|
||||||
|
# NEXT_PUBLIC_SITE_NAME=GridPilot
|
||||||
|
# NEXT_PUBLIC_SUPPORT_EMAIL=support@your-domain.com
|
||||||
|
# NEXT_PUBLIC_SPONSOR_EMAIL=sponsors@your-domain.com
|
||||||
|
# NEXT_PUBLIC_LEGAL_COMPANY_NAME=Your Company GmbH
|
||||||
|
# NEXT_PUBLIC_LEGAL_VAT_ID=DE123456789
|
||||||
|
# NEXT_PUBLIC_LEGAL_REGISTERED_COUNTRY=DE
|
||||||
|
# NEXT_PUBLIC_LEGAL_REGISTERED_ADDRESS=Street 1, 12345 City
|
||||||
|
|
||||||
|
# ------------------------------------------
|
||||||
|
# Website (Next.js) - server (NOT exposed to browser)
|
||||||
|
# ------------------------------------------
|
||||||
|
API_BASE_URL=http://api:3000
|
||||||
|
|
||||||
|
# Vercel KV (required in production)
|
||||||
|
KV_REST_API_URL=https://your-kv-rest-api-url.vercel-storage.com
|
||||||
|
KV_REST_API_TOKEN=CHANGE_ME
|
||||||
|
|
||||||
|
# ------------------------------------------
|
||||||
|
# Automation / Companion (advanced)
|
||||||
|
# ------------------------------------------
|
||||||
|
# NOTE: `AUTOMATION_MODE` is deprecated.
|
||||||
|
# AUTOMATION_MODE=production
|
||||||
AUTOMATION_TIMEOUT=30000
|
AUTOMATION_TIMEOUT=30000
|
||||||
RETRY_ATTEMPTS=3
|
RETRY_ATTEMPTS=3
|
||||||
SCREENSHOT_ON_ERROR=false
|
SCREENSHOT_ON_ERROR=false
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# Security & Performance
|
|
||||||
# ==========================================
|
|
||||||
# Add any additional production-specific variables here
|
|
||||||
# Example: API keys, webhook URLs, etc.
|
|
||||||
@@ -1,10 +1,28 @@
|
|||||||
# Test Environment Configuration
|
# Test Environment Configuration (automation stack)
|
||||||
# Copy this file to .env.test and adjust values as needed
|
# Copy this file to `.env.test` and adjust values as needed.
|
||||||
|
#
|
||||||
|
# Scope:
|
||||||
|
# - This file is for the hosted-session automation stack (companion/automation).
|
||||||
|
# - It is NOT used by the Website ↔ API Docker smoke stack (that stack is driven by env vars in
|
||||||
|
# [`docker-compose.test.yml`](docker-compose.test.yml:1) and scripts in [`package.json`](package.json:92)).
|
||||||
|
#
|
||||||
|
# Note on legacy config:
|
||||||
|
# - `AUTOMATION_MODE` is deprecated in favor of `NODE_ENV` mapping in [`getAutomationMode()`](adapters/automation/config/AutomationConfig.ts:104).
|
||||||
|
# - Keep `AUTOMATION_MODE` only if you need to force legacy behavior.
|
||||||
|
|
||||||
# Use mock adapter for testing (no real browser automation)
|
# Prefer this:
|
||||||
|
NODE_ENV=test
|
||||||
|
|
||||||
|
# Optional legacy override (deprecated): `dev` | `mock` | `production`
|
||||||
AUTOMATION_MODE=mock
|
AUTOMATION_MODE=mock
|
||||||
|
|
||||||
# Test timeouts (can be shorter for faster tests)
|
# Shared automation settings
|
||||||
AUTOMATION_TIMEOUT=5000
|
AUTOMATION_TIMEOUT=5000
|
||||||
RETRY_ATTEMPTS=1
|
RETRY_ATTEMPTS=1
|
||||||
SCREENSHOT_ON_ERROR=false
|
SCREENSHOT_ON_ERROR=false
|
||||||
|
|
||||||
|
# Logging (automation/adapters)
|
||||||
|
# LOG_LEVEL=warn
|
||||||
|
# LOG_FILE_PATH=./logs/gridpilot
|
||||||
|
# LOG_MAX_FILES=7
|
||||||
|
# LOG_MAX_SIZE=10m
|
||||||
@@ -115,8 +115,8 @@ Before deploying to production:
|
|||||||
- Update `DATABASE_URL` with production database
|
- Update `DATABASE_URL` with production database
|
||||||
|
|
||||||
4. **Redis Password**:
|
4. **Redis Password**:
|
||||||
- Update `REDIS_PASSWORD` with strong password
|
- Update `REDIS_PASSWORD` with a strong password
|
||||||
- Update `REDIS_URL` accordingly
|
- No `REDIS_URL` is required (the Redis container is configured via `REDIS_PASSWORD` in `docker-compose.prod.yml`)
|
||||||
|
|
||||||
5. **Vercel KV** (if using):
|
5. **Vercel KV** (if using):
|
||||||
- Get credentials from Vercel dashboard
|
- Get credentials from Vercel dashboard
|
||||||
@@ -124,7 +124,7 @@ Before deploying to production:
|
|||||||
|
|
||||||
6. **Domain Configuration**:
|
6. **Domain Configuration**:
|
||||||
- Update `NEXT_PUBLIC_SITE_URL` with your domain
|
- Update `NEXT_PUBLIC_SITE_URL` with your domain
|
||||||
- Update `NEXT_PUBLIC_API_URL` with your API domain
|
- Update `NEXT_PUBLIC_API_BASE_URL` with your public API base (often `https://your-domain.com/api` when nginx proxies `/api`)
|
||||||
|
|
||||||
7. **Build & Deploy**:
|
7. **Build & Deploy**:
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -13,13 +13,13 @@ npm run docker:dev:build
|
|||||||
|
|
||||||
This will:
|
This will:
|
||||||
- Start PostgreSQL database on port 5432
|
- Start PostgreSQL database on port 5432
|
||||||
- Start API on port 3000 (with debugger on 9229)
|
- Start API on port 3001 (container port 3000, debugger 9229)
|
||||||
- Start Website on port 3001
|
- Start Website on port 3000
|
||||||
- Enable hot-reloading for both apps
|
- Enable hot-reloading for both apps
|
||||||
|
|
||||||
Access:
|
Access:
|
||||||
- Website: http://localhost:3001
|
- Website: http://localhost:3000
|
||||||
- API: http://localhost:3000
|
- API: http://localhost:3001
|
||||||
- Database: localhost:5432
|
- Database: localhost:5432
|
||||||
|
|
||||||
### Production
|
### Production
|
||||||
@@ -54,16 +54,93 @@ Access:
|
|||||||
- `npm run docker:prod:logs` - View logs
|
- `npm run docker:prod:logs` - View logs
|
||||||
- `npm run docker:prod:clean` - Stop and remove volumes
|
- `npm run docker:prod:clean` - Stop and remove volumes
|
||||||
|
|
||||||
|
### Testing (Docker)
|
||||||
|
The goal of Docker-backed tests is to catch *wiring* issues between Website ↔ API (wrong hostnames/ports/env vars, missing CORS for credentialed requests, etc.) in a deterministic environment.
|
||||||
|
|
||||||
|
- `npm run test:docker:website` - Start a dedicated test stack (ports `3100/3101`) and run Playwright smoke tests against it.
|
||||||
|
- Uses [`docker-compose.test.yml`](docker-compose.test.yml:1).
|
||||||
|
- Runs the Website in Docker and talks to an **API mock** container.
|
||||||
|
- Validates that pages render and that core API fetches succeed (hostname + CORS + routing).
|
||||||
|
|
||||||
|
Supporting scripts:
|
||||||
|
- `npm run docker:test:deps` - Install monorepo deps inside a reusable Docker volume (one-shot).
|
||||||
|
- `npm run docker:test:up` - Bring up the test stack.
|
||||||
|
- `npm run docker:test:wait` - Wait for `http://localhost:3100` and `http://localhost:3101/health` to be ready.
|
||||||
|
- `npm run docker:test:down` - Tear the test stack down (including volumes).
|
||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
|
### “Mock vs Real” (Website & API)
|
||||||
|
|
||||||
|
There is **no** `AUTOMATION_MODE` equivalent for the Website/API runtime.
|
||||||
|
|
||||||
|
- **Website “mock vs real”** is controlled purely by *which API base URL you point it at* via [`getWebsiteApiBaseUrl()`](apps/website/lib/config/apiBaseUrl.ts:6):
|
||||||
|
- Browser calls use `NEXT_PUBLIC_API_BASE_URL`
|
||||||
|
- Server/Next.js calls use `API_BASE_URL ?? NEXT_PUBLIC_API_BASE_URL`
|
||||||
|
|
||||||
|
- **API “mock vs real”** is controlled by API runtime env:
|
||||||
|
- Persistence: `GRIDPILOT_API_PERSISTENCE=postgres|inmemory` in [`AppModule`](apps/api/src/app.module.ts:25)
|
||||||
|
- Optional bootstrapping: `GRIDPILOT_API_BOOTSTRAP=0|1` in [`AppModule`](apps/api/src/app.module.ts:35)
|
||||||
|
|
||||||
|
Practical presets:
|
||||||
|
- **Website + real API (Docker dev)**: `npm run docker:dev:build` (Website `3000`, API `3001`, Postgres required).
|
||||||
|
- Website browser → API: `NEXT_PUBLIC_API_BASE_URL=http://localhost:3001`
|
||||||
|
- Website container → API container: `API_BASE_URL=http://api:3000`
|
||||||
|
- **Website + mock API (Docker smoke)**: `npm run test:docker:website` (Website `3100`, API mock `3101`).
|
||||||
|
- API mock is defined inline in [`docker-compose.test.yml`](docker-compose.test.yml:24)
|
||||||
|
- Website browser → API mock: `NEXT_PUBLIC_API_BASE_URL=http://localhost:3101`
|
||||||
|
- Website container → API mock container: `API_BASE_URL=http://api:3000`
|
||||||
|
|
||||||
|
### Website ↔ API Connection
|
||||||
|
|
||||||
|
The website talks to the API via `fetch()` in [`BaseApiClient`](apps/website/lib/api/base/BaseApiClient.ts:11), and it always includes cookies (`credentials: 'include'`). That means:
|
||||||
|
|
||||||
|
- The **browser** must be pointed at a host-accessible API URL via `NEXT_PUBLIC_API_BASE_URL`
|
||||||
|
- The **server** (Next.js / Node) must be pointed at a container-network API URL via `API_BASE_URL` (when running in Docker)
|
||||||
|
|
||||||
|
The single source of truth for “what base URL should I use?” is [`getWebsiteApiBaseUrl()`](apps/website/lib/config/apiBaseUrl.ts:6):
|
||||||
|
- Browser: reads `NEXT_PUBLIC_API_BASE_URL`
|
||||||
|
- Server: reads `API_BASE_URL ?? NEXT_PUBLIC_API_BASE_URL`
|
||||||
|
- In Docker/CI/test: throws if missing (no silent localhost fallback)
|
||||||
|
|
||||||
|
#### Dev Docker defaults (docker-compose.dev.yml)
|
||||||
|
- Website: `http://localhost:3000`
|
||||||
|
- API: `http://localhost:3001` (maps to container `api:3000`)
|
||||||
|
- `NEXT_PUBLIC_API_BASE_URL=http://localhost:3001` (browser → host port)
|
||||||
|
- `API_BASE_URL=http://api:3000` (website container → api container)
|
||||||
|
|
||||||
|
#### Test Docker defaults (docker-compose.test.yml)
|
||||||
|
This stack is intended for deterministic smoke tests and uses different host ports to avoid colliding with `docker:dev`:
|
||||||
|
|
||||||
|
- Website: `http://localhost:3100`
|
||||||
|
- API mock: `http://localhost:3101` (maps to container `api:3000`)
|
||||||
|
- `NEXT_PUBLIC_API_BASE_URL=http://localhost:3101` (browser → host port)
|
||||||
|
- `API_BASE_URL=http://api:3000` (website container → api container)
|
||||||
|
|
||||||
|
Important: the test stack’s API is a mock server defined inline in [`docker-compose.test.yml`](docker-compose.test.yml:24). It exists to validate Website ↔ API wiring, not domain correctness.
|
||||||
|
|
||||||
|
#### Troubleshooting
|
||||||
|
- If `docker:dev` is running, use `npm run docker:dev:down` before `npm run test:docker:website` to avoid port conflicts.
|
||||||
|
- If Docker volumes get stuck, run `npm run docker:test:down` (it uses `--remove-orphans` + `rm -f`).
|
||||||
|
|
||||||
|
### API “Real vs In-Memory” Mode
|
||||||
|
|
||||||
|
The API can now be run either:
|
||||||
|
- **postgres**: loads [`DatabaseModule`](apps/api/src/domain/database/DatabaseModule.ts:1) (requires Postgres)
|
||||||
|
- **inmemory**: does not load `DatabaseModule` (no Postgres required)
|
||||||
|
|
||||||
|
Control it with:
|
||||||
|
- `GRIDPILOT_API_PERSISTENCE=postgres|inmemory` (defaults to `postgres` if `DATABASE_URL` is set, otherwise `inmemory`)
|
||||||
|
- Optional: `GRIDPILOT_API_BOOTSTRAP=0` to skip [`BootstrapModule`](apps/api/src/domain/bootstrap/BootstrapModule.ts:1)
|
||||||
|
|
||||||
### Development (.env.development)
|
### Development (.env.development)
|
||||||
Copy and customize as needed. Default values work out of the box.
|
Copy and customize as needed. Default values work out of the box.
|
||||||
|
|
||||||
### Production (.env.production)
|
### Production (.env.production)
|
||||||
**IMPORTANT**: Update these before deploying:
|
**IMPORTANT**: Update these before deploying:
|
||||||
- Database credentials (POSTGRES_PASSWORD, DATABASE_URL)
|
- Database credentials (`POSTGRES_PASSWORD`, `DATABASE_URL`)
|
||||||
- API URLs (NEXT_PUBLIC_API_URL, NEXT_PUBLIC_SITE_URL)
|
- Website/API URLs (`NEXT_PUBLIC_API_BASE_URL`, `NEXT_PUBLIC_SITE_URL`)
|
||||||
- Vercel KV credentials (required for production)
|
- Vercel KV credentials (`KV_REST_API_URL`, `KV_REST_API_TOKEN`) (required for production email signups/rate limit)
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
|
|||||||
44
adapters/env.d.ts
vendored
Normal file
44
adapters/env.d.ts
vendored
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
declare global {
|
||||||
|
namespace NodeJS {
|
||||||
|
interface ProcessEnv {
|
||||||
|
NODE_ENV?: 'development' | 'production' | 'test';
|
||||||
|
|
||||||
|
// Automation (legacy + current)
|
||||||
|
AUTOMATION_MODE?: 'dev' | 'production' | 'mock';
|
||||||
|
AUTOMATION_TIMEOUT?: string;
|
||||||
|
RETRY_ATTEMPTS?: string;
|
||||||
|
SCREENSHOT_ON_ERROR?: string;
|
||||||
|
|
||||||
|
// Chrome / DevTools
|
||||||
|
CHROME_DEBUG_PORT?: string;
|
||||||
|
CHROME_WS_ENDPOINT?: string;
|
||||||
|
|
||||||
|
// Native automation tuning
|
||||||
|
NUTJS_MOUSE_SPEED?: string;
|
||||||
|
NUTJS_KEYBOARD_DELAY?: string;
|
||||||
|
IRACING_WINDOW_TITLE?: string;
|
||||||
|
TEMPLATE_PATH?: string;
|
||||||
|
OCR_CONFIDENCE?: string;
|
||||||
|
|
||||||
|
// Retry tuning
|
||||||
|
AUTOMATION_MAX_RETRIES?: string;
|
||||||
|
AUTOMATION_BASE_DELAY_MS?: string;
|
||||||
|
AUTOMATION_MAX_DELAY_MS?: string;
|
||||||
|
AUTOMATION_BACKOFF_MULTIPLIER?: string;
|
||||||
|
|
||||||
|
// Timing tuning
|
||||||
|
AUTOMATION_PAGE_LOAD_WAIT_MS?: string;
|
||||||
|
AUTOMATION_INTER_ACTION_DELAY_MS?: string;
|
||||||
|
AUTOMATION_POST_CLICK_DELAY_MS?: string;
|
||||||
|
AUTOMATION_PRE_STEP_DELAY_MS?: string;
|
||||||
|
|
||||||
|
// Logging
|
||||||
|
LOG_LEVEL?: 'debug' | 'info' | 'warn' | 'error' | 'fatal';
|
||||||
|
LOG_FILE_PATH?: string;
|
||||||
|
LOG_MAX_FILES?: string;
|
||||||
|
LOG_MAX_SIZE?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
@@ -7,6 +7,6 @@
|
|||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"types": ["vitest/globals"]
|
"types": ["vitest/globals"]
|
||||||
},
|
},
|
||||||
"include": ["**/*.ts", "../core/**/*.ts"],
|
"include": ["**/*.ts", "**/*.d.ts", "../core/**/*.ts", "../core/**/*.d.ts"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist"]
|
||||||
}
|
}
|
||||||
@@ -17,12 +17,20 @@ import { RaceModule } from './domain/race/RaceModule';
|
|||||||
import { SponsorModule } from './domain/sponsor/SponsorModule';
|
import { SponsorModule } from './domain/sponsor/SponsorModule';
|
||||||
import { TeamModule } from './domain/team/TeamModule';
|
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({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
HelloModule,
|
HelloModule,
|
||||||
DatabaseModule,
|
...(USE_DATABASE ? [DatabaseModule] : []),
|
||||||
LoggingModule,
|
LoggingModule,
|
||||||
BootstrapModule,
|
...(ENABLE_BOOTSTRAP ? [BootstrapModule] : []),
|
||||||
AnalyticsModule,
|
AnalyticsModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
DashboardModule,
|
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 { AuthorizationGuard } from './domain/auth/AuthorizationGuard';
|
||||||
import { FeatureAvailabilityGuard } from './domain/policy/FeatureAvailabilityGuard';
|
import { FeatureAvailabilityGuard } from './domain/policy/FeatureAvailabilityGuard';
|
||||||
|
|
||||||
|
import { getGenerateOpenapi } from './env';
|
||||||
|
|
||||||
async function bootstrap() {
|
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),
|
// 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.
|
// 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);
|
SwaggerModule.setup('api/docs', app as any, document);
|
||||||
|
|
||||||
// Export OpenAPI spec as JSON file when GENERATE_OPENAPI env var is set
|
// 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');
|
const outputPath = join(__dirname, '../openapi.json');
|
||||||
writeFileSync(outputPath, JSON.stringify(document, null, 2));
|
writeFileSync(outputPath, JSON.stringify(document, null, 2));
|
||||||
console.log(`✅ OpenAPI spec generated at: ${outputPath}`);
|
console.log(`✅ OpenAPI spec generated at: ${outputPath}`);
|
||||||
|
|||||||
@@ -50,6 +50,7 @@
|
|||||||
],
|
],
|
||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.base.json",
|
||||||
"include": [
|
"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 {
|
declare global {
|
||||||
namespace NodeJS {
|
namespace NodeJS {
|
||||||
interface ProcessEnv {
|
interface ProcessEnv {
|
||||||
|
NODE_ENV?: 'development' | 'production' | 'test';
|
||||||
|
|
||||||
|
// Website (public, exposed to browser)
|
||||||
NEXT_PUBLIC_GRIDPILOT_MODE?: 'pre-launch' | 'alpha';
|
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_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 RATE_LIMIT_WINDOW = 60 * 60 * 1000; // 1 hour in milliseconds
|
||||||
const MAX_REQUESTS_PER_WINDOW = 5;
|
const MAX_REQUESTS_PER_WINDOW = 5;
|
||||||
const RATE_LIMIT_PREFIX = 'ratelimit:signup:';
|
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
|
// In-memory fallback for development
|
||||||
const devRateLimits = new Map<string, { count: number; resetAt: number }>();
|
const devRateLimits = new Map<string, { count: number; resetAt: number }>();
|
||||||
@@ -49,6 +52,8 @@ export async function checkRateLimit(identifier: string): Promise<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Production: Use Vercel KV
|
// Production: Use Vercel KV
|
||||||
|
assertKvConfiguredInProduction();
|
||||||
|
|
||||||
const { kv } = await import('@vercel/kv');
|
const { kv } = await import('@vercel/kv');
|
||||||
const key = `${RATE_LIMIT_PREFIX}${identifier}`;
|
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 type { LeaguesApiClient } from '@/lib/api/leagues/LeaguesApiClient';
|
||||||
|
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
|
||||||
import { LeagueMemberViewModel } from '@/lib/view-models/LeagueMemberViewModel';
|
import { LeagueMemberViewModel } from '@/lib/view-models/LeagueMemberViewModel';
|
||||||
import type { LeagueMemberDTO } from '@/lib/types/generated/LeagueMemberDTO';
|
import type { LeagueMemberDTO } from '@/lib/types/generated/LeagueMemberDTO';
|
||||||
import type { LeagueMembership } from '@/lib/types/LeagueMembership';
|
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 {
|
export class LeagueMembershipService {
|
||||||
// In-memory cache for memberships (populated via API calls)
|
// In-memory cache for memberships (populated via API calls)
|
||||||
private static leagueMemberships = new Map<string, LeagueMembership[]>();
|
private static leagueMemberships = new Map<string, LeagueMembership[]>();
|
||||||
@@ -11,7 +22,7 @@ export class LeagueMembershipService {
|
|||||||
constructor(private readonly leaguesApiClient?: LeaguesApiClient) {}
|
constructor(private readonly leaguesApiClient?: LeaguesApiClient) {}
|
||||||
|
|
||||||
private getClient(): 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[]> {
|
async getLeagueMemberships(leagueId: string, currentUserId: string): Promise<LeagueMemberViewModel[]> {
|
||||||
@@ -45,7 +56,7 @@ export class LeagueMembershipService {
|
|||||||
*/
|
*/
|
||||||
static async fetchLeagueMemberships(leagueId: string): Promise<LeagueMembership[]> {
|
static async fetchLeagueMemberships(leagueId: string): Promise<LeagueMembership[]> {
|
||||||
try {
|
try {
|
||||||
const result = await apiClient.leagues.getMemberships(leagueId);
|
const result = await getDefaultLeaguesApiClient().getMemberships(leagueId);
|
||||||
const memberships: LeagueMembership[] = ((result as any)?.members ?? []).map((member: any) => ({
|
const memberships: LeagueMembership[] = ((result as any)?.members ?? []).map((member: any) => ({
|
||||||
id: `${member.driverId}-${leagueId}`, // Generate ID since API doesn't provide it
|
id: `${member.driverId}-${leagueId}`, // Generate ID since API doesn't provide it
|
||||||
leagueId,
|
leagueId,
|
||||||
|
|||||||
@@ -73,6 +73,7 @@
|
|||||||
"hooks/",
|
"hooks/",
|
||||||
"lib/",
|
"lib/",
|
||||||
"next-env.d.ts",
|
"next-env.d.ts",
|
||||||
|
"env.d.ts",
|
||||||
"types/",
|
"types/",
|
||||||
"utilities/",
|
"utilities/",
|
||||||
".next/types/**/*.ts"
|
".next/types/**/*.ts"
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ services:
|
|||||||
[
|
[
|
||||||
"sh",
|
"sh",
|
||||||
"-lc",
|
"-lc",
|
||||||
"set -e; LOCK_HASH=\"$$(sha1sum package-lock.json | awk '{print $$1}')\"; MARKER=\"node_modules/.gridpilot_lock_hash_dev\"; if [ -f \"$$MARKER\" ] && [ \"$$(cat \"$$MARKER\")\" = \"$$LOCK_HASH\" ]; then echo \"[deps] node_modules up-to-date\"; else echo \"[deps] installing api+website deps (slow first time)\"; npm install --no-package-lock --workspace=./apps/api --workspace=./apps/website --include-workspace-root --no-audit --fund=false --prefer-offline; node -e \"require.resolve('ts-node-dev')\"; node -e \"require.resolve('next')\"; echo \"$$LOCK_HASH\" > \"$$MARKER\"; fi",
|
"set -e; LOCK_HASH=\"$$(sha1sum package-lock.json | awk '{print $$1}')\"; MARKER=\"node_modules/.gridpilot_lock_hash_dev\"; if [ -f \"$$MARKER\" ] && [ \"$$(cat \"$$MARKER\")\" = \"$$LOCK_HASH\" ]; then echo \"[deps] node_modules up-to-date\"; else echo \"[deps] installing workspace deps (slow first time)\"; npm install --no-package-lock --include-workspace-root --no-audit --fund=false --prefer-offline; node -e \"require.resolve('ts-node-dev')\"; node -e \"require.resolve('next')\"; echo \"$$LOCK_HASH\" > \"$$MARKER\"; fi",
|
||||||
]
|
]
|
||||||
networks:
|
networks:
|
||||||
- gridpilot-network
|
- gridpilot-network
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ services:
|
|||||||
[
|
[
|
||||||
"sh",
|
"sh",
|
||||||
"-lc",
|
"-lc",
|
||||||
"set -e; LOCK_HASH=\"$$(sha1sum package-lock.json | awk '{print $$1}')\"; MARKER=\"node_modules/.gridpilot_lock_hash_test\"; if [ -f \"$$MARKER\" ] && [ \"$$(cat \"$$MARKER\")\" = \"$$LOCK_HASH\" ]; then echo \"[deps] node_modules up-to-date\"; else echo \"[deps] installing api+website deps\"; npm install --no-package-lock --workspace=./apps/api --workspace=./apps/website --include-workspace-root --no-audit --fund=false --prefer-offline; echo \"$$LOCK_HASH\" > \"$$MARKER\"; fi",
|
"set -e; LOCK_HASH=\"$$(sha1sum package-lock.json | awk '{print $$1}')\"; MARKER=\"node_modules/.gridpilot_lock_hash_test\"; if [ -f \"$$MARKER\" ] && [ \"$$(cat \"$$MARKER\")\" = \"$$LOCK_HASH\" ]; then echo \"[deps] node_modules up-to-date\"; else echo \"[deps] installing workspace deps\"; rm -rf apps/api/node_modules apps/website/node_modules; npm install --no-package-lock --include-workspace-root --no-audit --fund=false --prefer-offline; echo \"$$LOCK_HASH\" > \"$$MARKER\"; fi",
|
||||||
]
|
]
|
||||||
networks:
|
networks:
|
||||||
- gridpilot-test-network
|
- gridpilot-test-network
|
||||||
@@ -26,12 +26,12 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- NODE_ENV=test
|
- NODE_ENV=test
|
||||||
ports:
|
ports:
|
||||||
- "3001:3000"
|
- "3101:3000"
|
||||||
command:
|
command:
|
||||||
[
|
[
|
||||||
"sh",
|
"sh",
|
||||||
"-lc",
|
"-lc",
|
||||||
"node -e \"const http=require('http'); const baseCors={ 'Access-Control-Allow-Credentials':'true','Access-Control-Allow-Headers':'Content-Type','Access-Control-Allow-Methods':'GET,POST,PUT,PATCH,DELETE,OPTIONS' }; const server=http.createServer((req,res)=>{ const origin=req.headers.origin||'http://localhost:3000'; res.setHeader('Access-Control-Allow-Origin', origin); res.setHeader('Vary','Origin'); for(const [k,v] of Object.entries(baseCors)){ res.setHeader(k,v); } if(req.method==='OPTIONS'){ res.statusCode=204; return res.end(); } const url=new URL(req.url,'http://localhost'); const send=(code,obj)=>{ res.statusCode=code; res.setHeader('content-type','application/json'); res.end(JSON.stringify(obj)); }; if(url.pathname==='/health'){ return send(200,{status:'ok'});} if(url.pathname==='/auth/session'){ res.statusCode=200; res.setHeader('content-type','application/json'); return res.end('null'); } if(url.pathname==='/races/page-data'){ return send(200,{races:[]}); } if(url.pathname==='/leagues/all-with-capacity'){ return send(200,{leagues:[], totalCount:0}); } if(url.pathname==='/teams/all'){ return send(200,{teams:[], totalCount:0}); } return send(404,{message:'Not Found', path:url.pathname}); }); server.listen(3000,()=>console.log('[api-mock] listening on 3000'));\"",
|
"node -e \"const http=require('http'); const baseCors={ 'Access-Control-Allow-Credentials':'true','Access-Control-Allow-Headers':'Content-Type','Access-Control-Allow-Methods':'GET,POST,PUT,PATCH,DELETE,OPTIONS' }; const server=http.createServer((req,res)=>{ const origin=req.headers.origin||'http://localhost:3100'; res.setHeader('Access-Control-Allow-Origin', origin); res.setHeader('Vary','Origin'); for(const [k,v] of Object.entries(baseCors)){ res.setHeader(k,v); } if(req.method==='OPTIONS'){ res.statusCode=204; return res.end(); } const url=new URL(req.url,'http://localhost'); const send=(code,obj)=>{ res.statusCode=code; res.setHeader('content-type','application/json'); res.end(JSON.stringify(obj)); }; if(url.pathname==='/health'){ return send(200,{status:'ok'});} if(url.pathname==='/auth/session'){ res.statusCode=200; res.setHeader('content-type','application/json'); return res.end('null'); } if(url.pathname==='/races/page-data'){ return send(200,{races:[]}); } if(url.pathname==='/leagues/all-with-capacity'){ return send(200,{leagues:[], totalCount:0}); } if(url.pathname==='/teams/all'){ return send(200,{teams:[], totalCount:0}); } return send(404,{message:'Not Found', path:url.pathname}); }); server.listen(3000,()=>console.log('[api-mock] listening on 3000'));\"",
|
||||||
]
|
]
|
||||||
networks:
|
networks:
|
||||||
- gridpilot-test-network
|
- gridpilot-test-network
|
||||||
@@ -59,9 +59,9 @@ services:
|
|||||||
- DOCKER=true
|
- DOCKER=true
|
||||||
- NEXT_PUBLIC_GRIDPILOT_MODE=alpha
|
- NEXT_PUBLIC_GRIDPILOT_MODE=alpha
|
||||||
- API_BASE_URL=http://api:3000
|
- API_BASE_URL=http://api:3000
|
||||||
- NEXT_PUBLIC_API_BASE_URL=http://localhost:3001
|
- NEXT_PUBLIC_API_BASE_URL=http://localhost:3101
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3100:3000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./:/app
|
- ./:/app
|
||||||
- test_node_modules:/app/node_modules
|
- test_node_modules:/app/node_modules
|
||||||
|
|||||||
@@ -273,60 +273,49 @@ This pattern applies equally to integration tests (with real database operations
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Docker E2E Setup
|
## Docker-Based Tests (Website ↔ API Wiring)
|
||||||
|
|
||||||
### Architecture
|
This repo uses Docker in two different ways:
|
||||||
|
|
||||||
E2E tests run against a full stack orchestrated by `docker-compose.test.yml`:
|
1) **Website ↔ API smoke (wiring validation)**
|
||||||
|
- Orchestrated by [`docker-compose.test.yml`](docker-compose.test.yml:1) at the repo root.
|
||||||
|
- Runs:
|
||||||
|
- Website in Docker (Next.js dev server)
|
||||||
|
- An API mock server in Docker (Node HTTP server)
|
||||||
|
- Goal: catch misconfigured hostnames/ports/env vars and CORS issues that only show up in Dockerized setups.
|
||||||
|
|
||||||
```yaml
|
2) **Hosted-session automation E2E (fixture-driven automation)**
|
||||||
services:
|
- Orchestrated by `docker/docker-compose.e2e.yml` (separate stack; documented later in this file).
|
||||||
postgres:
|
- Goal: validate Playwright-driven automation against HTML fixtures.
|
||||||
image: postgres:16
|
|
||||||
environment:
|
|
||||||
POSTGRES_DB: gridpilot_test
|
|
||||||
POSTGRES_USER: test
|
|
||||||
POSTGRES_PASSWORD: test
|
|
||||||
|
|
||||||
redis:
|
### Website ↔ API smoke: how to run
|
||||||
image: redis:7-alpine
|
|
||||||
|
|
||||||
web-api:
|
Run:
|
||||||
build: ./src/apps/web-api
|
- `npm run test:docker:website` (see [`package.json`](package.json:92))
|
||||||
depends_on:
|
|
||||||
- postgres
|
|
||||||
- redis
|
|
||||||
environment:
|
|
||||||
DATABASE_URL: postgres://test:test@postgres:5432/gridpilot_test
|
|
||||||
REDIS_URL: redis://redis:6379
|
|
||||||
ports:
|
|
||||||
- "3000:3000"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Execution Flow
|
What it does (in order):
|
||||||
|
- Installs deps in a dedicated Docker volume (`npm run docker:test:deps`)
|
||||||
|
- Starts the test stack (`npm run docker:test:up`)
|
||||||
|
- Waits for readiness (`npm run docker:test:wait`)
|
||||||
|
- Runs Playwright smoke tests (`npm run smoke:website:docker`)
|
||||||
|
|
||||||
1. **Start Services:** `docker compose -f docker-compose.test.yml up -d`
|
Ports used:
|
||||||
2. **Run Migrations:** `npm run migrate:test` (seeds database)
|
- Website: `http://localhost:3100`
|
||||||
3. **Execute Tests:** Playwright targets `http://localhost:3000`
|
- API mock: `http://localhost:3101`
|
||||||
4. **Teardown:** `docker compose -f docker-compose.test.yml down -v`
|
|
||||||
|
|
||||||
### Environment Setup
|
Key contract:
|
||||||
|
- Website must resolve the API base URL via [`getWebsiteApiBaseUrl()`](apps/website/lib/config/apiBaseUrl.ts:6).
|
||||||
|
- The website’s HTTP client uses `credentials: 'include'`, so the API must support CORS-with-credentials (implemented for the real API in [`bootstrap()`](apps/api/src/main.ts:14)).
|
||||||
|
|
||||||
```bash
|
### “Mock vs Real” (Website & API)
|
||||||
# tests/e2e/setup.ts
|
|
||||||
export async function globalSetup() {
|
|
||||||
// Wait for web-api to be ready
|
|
||||||
await waitForService('http://localhost:3000/health');
|
|
||||||
|
|
||||||
// Run database migrations
|
- The Website does **not** have a runtime flag like `AUTOMATION_MODE`.
|
||||||
await runMigrations();
|
- “Mock vs real” for the Website is **only** which API base URL it uses:
|
||||||
}
|
- Browser: `NEXT_PUBLIC_API_BASE_URL`
|
||||||
|
- Server: `API_BASE_URL` (preferred in Docker) or `NEXT_PUBLIC_API_BASE_URL` fallback
|
||||||
|
|
||||||
export async function globalTeardown() {
|
In the Docker smoke stack, “mock API” means the Node HTTP server in [`docker-compose.test.yml`](docker-compose.test.yml:24).
|
||||||
// Stop Docker Compose services
|
In Docker dev/prod, “real API” means the NestJS app started from [`bootstrap()`](apps/api/src/main.ts:14), and “real vs in-memory” persistence is controlled by `GRIDPILOT_API_PERSISTENCE` in [`AppModule`](apps/api/src/app.module.ts:25).
|
||||||
await exec('docker compose -f docker-compose.test.yml down -v');
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -91,8 +91,8 @@
|
|||||||
"docker:prod:logs": "docker-compose -p gridpilot-prod -f docker-compose.prod.yml logs -f",
|
"docker:prod:logs": "docker-compose -p gridpilot-prod -f docker-compose.prod.yml logs -f",
|
||||||
"docker:test:deps": "COMPOSE_PARALLEL_LIMIT=1 docker-compose -p gridpilot-test -f docker-compose.test.yml run --rm deps",
|
"docker:test:deps": "COMPOSE_PARALLEL_LIMIT=1 docker-compose -p gridpilot-test -f docker-compose.test.yml run --rm deps",
|
||||||
"docker:test:up": "COMPOSE_PARALLEL_LIMIT=1 docker-compose -p gridpilot-test -f docker-compose.test.yml up -d --build api website",
|
"docker:test:up": "COMPOSE_PARALLEL_LIMIT=1 docker-compose -p gridpilot-test -f docker-compose.test.yml up -d --build api website",
|
||||||
"docker:test:down": "docker-compose -p gridpilot-test -f docker-compose.test.yml down -v",
|
"docker:test:down": "sh -lc \"docker-compose -p gridpilot-test -f docker-compose.test.yml down -v --remove-orphans || true; docker-compose -p gridpilot-test -f docker-compose.test.yml rm -fsv || true\"",
|
||||||
"docker:test:wait": "node -e \"const sleep=(ms)=>new Promise(r=>setTimeout(r,ms)); const wait=async(url,label)=>{for(let i=0;i<90;i++){try{const r=await fetch(url); if(r.ok){console.log('[wait] '+label+' ready'); return;} }catch{} await sleep(1000);} console.error('[wait] '+label+' not ready: '+url); process.exit(1);}; (async()=>{await wait('http://localhost:3001/health','api'); await wait('http://localhost:3000','website');})();\"",
|
"docker:test:wait": "node -e \"const sleep=(ms)=>new Promise(r=>setTimeout(r,ms)); const wait=async(url,label)=>{for(let i=0;i<90;i++){try{const r=await fetch(url); if(r.ok){console.log('[wait] '+label+' ready'); return;} }catch{} await sleep(1000);} console.error('[wait] '+label+' not ready: '+url); process.exit(1);}; (async()=>{await wait('http://localhost:3101/health','api'); await wait('http://localhost:3100','website');})();\"",
|
||||||
"smoke:website:docker": "DOCKER_SMOKE=true npx playwright test -c playwright.website.config.ts",
|
"smoke:website:docker": "DOCKER_SMOKE=true npx playwright test -c playwright.website.config.ts",
|
||||||
"test:docker:website": "sh -lc \"set -e; trap 'npm run docker:test:down' EXIT; npm run docker:test:deps; npm run docker:test:up; npm run docker:test:wait; npm run smoke:website:docker\"",
|
"test:docker:website": "sh -lc \"set -e; trap 'npm run docker:test:down' EXIT; npm run docker:test:deps; npm run docker:test:up; npm run docker:test:wait; npm run smoke:website:docker\"",
|
||||||
"dom:process": "npx tsx scripts/dom-export/processWorkflows.ts",
|
"dom:process": "npx tsx scripts/dom-export/processWorkflows.ts",
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export default defineConfig({
|
|||||||
|
|
||||||
// Base URL for the website
|
// Base URL for the website
|
||||||
use: {
|
use: {
|
||||||
baseURL: 'http://localhost:3000',
|
baseURL: process.env.DOCKER_SMOKE ? 'http://localhost:3100' : 'http://localhost:3000',
|
||||||
screenshot: 'only-on-failure',
|
screenshot: 'only-on-failure',
|
||||||
video: 'retain-on-failure',
|
video: 'retain-on-failure',
|
||||||
trace: 'retain-on-failure',
|
trace: 'retain-on-failure',
|
||||||
|
|||||||
34
tests/env.d.ts
vendored
Normal file
34
tests/env.d.ts
vendored
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
declare global {
|
||||||
|
namespace NodeJS {
|
||||||
|
interface ProcessEnv {
|
||||||
|
NODE_ENV?: 'development' | 'production' | 'test';
|
||||||
|
|
||||||
|
// CI providers / generic CI
|
||||||
|
CI?: string;
|
||||||
|
CONTINUOUS_INTEGRATION?: string;
|
||||||
|
GITHUB_ACTIONS?: string;
|
||||||
|
GITLAB_CI?: string;
|
||||||
|
CIRCLECI?: string;
|
||||||
|
TRAVIS?: string;
|
||||||
|
JENKINS_URL?: string;
|
||||||
|
BUILDKITE?: string;
|
||||||
|
TF_BUILD?: string;
|
||||||
|
|
||||||
|
// Playwright / smoke
|
||||||
|
DOCKER_SMOKE?: string;
|
||||||
|
|
||||||
|
// E2E toggles
|
||||||
|
HOSTED_REAL_E2E?: '0' | '1';
|
||||||
|
COMPANION_FIXTURE_HOSTED?: string;
|
||||||
|
|
||||||
|
// Electron (smoke harness)
|
||||||
|
ELECTRON_EXECUTABLE_PATH?: string;
|
||||||
|
|
||||||
|
// Headless heuristics
|
||||||
|
HEADLESS?: string;
|
||||||
|
DISPLAY?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"extends": "./tsconfig.base.json",
|
"extends": "./tsconfig.base.json",
|
||||||
"include": [
|
"include": [
|
||||||
|
"tests/env.d.ts",
|
||||||
"tests/**/*.ts",
|
"tests/**/*.ts",
|
||||||
"tests/**/*.tsx"
|
"tests/**/*.tsx"
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user