diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2a97e45 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,86 @@ +# Continuous integration — SPEC §12 +# Node 20. Steps: lint → typecheck → test + coverage → build → docker build. +# Runs on every PR and on pushes to main. + +name: CI + +on: + push: + branches: ["main"] + pull_request: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci + - run: npm run lint + + typecheck: + name: Typecheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci + - run: npm run typecheck + + test: + name: Test (coverage) + runs-on: ubuntu-latest + services: + mongo: + image: mongo:7 + ports: + - "27017:27017" + env: + # Backend integration tests run against the GitHub-hosted Mongo service + # (SPEC §11); falls back to mongodb-memory-server when unset. + MONGODB_URI: mongodb://127.0.0.1:27017/workorders + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci + - run: npm run test -w @workorders/shared + - run: npm run test:coverage -w backend + - run: npm run test:coverage -w frontend + + build: + name: Build + runs-on: ubuntu-latest + needs: [lint, typecheck, test] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci + - run: npm run build + + docker: + name: Docker build + runs-on: ubuntu-latest + needs: [build] + steps: + - uses: actions/checkout@v4 + - name: Build backend image + run: docker build -f backend/Dockerfile --target prod -t workorders-api:ci . + - name: Build frontend/nginx image + run: docker build -f frontend/Dockerfile --target prod -t workorders-nginx:ci . diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..0285e3a --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,77 @@ +# ============================================================================= +# Work Order Management — PRODUCTION compose stack +# +# cp .env.example .env # fill in REAL secrets first +# docker compose -f docker-compose.prod.yml up --build -d +# +# mongo + api live on an internal network only; nginx :80 is the only public +# entry point. The API reads secrets from .env (never baked into the image). +# nginx.conf is mounted read-only from ./nginx/nginx.conf. +# ============================================================================= + +name: workorders-prod + +services: + mongo: + image: mongo:7 + restart: unless-stopped + volumes: + - mongo-data:/data/db + healthcheck: + test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + networks: + - internal + + api: + build: + context: . + dockerfile: backend/Dockerfile + target: prod + image: workorders-api:prod + restart: unless-stopped + # Secrets come from .env — overrides below only fix container-local wiring. + env_file: + - .env + environment: + NODE_ENV: production + MONGODB_URI: mongodb://mongo:27017/workorders + # nginx is the only proxy in front of the API; one trusted hop. + TRUST_PROXY_HOPS: "1" + expose: + - "4000" + depends_on: + mongo: + condition: service_healthy + networks: + - internal + + nginx: + build: + context: . + dockerfile: frontend/Dockerfile + target: prod + args: + # Baked into the bundle at build time; empty => same-origin (relative /api). + VITE_APP_URL: ${VITE_APP_URL:-} + VITE_API_URL: ${VITE_API_URL:-} + image: workorders-nginx:prod + restart: unless-stopped + ports: + - "80:80" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + depends_on: + - api + networks: + - internal + +networks: + internal: + driver: bridge + +volumes: + mongo-data: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0569e30 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,102 @@ +# ============================================================================= +# Work Order Management — DEVELOPMENT compose stack +# +# docker compose up --build +# +# Runs Mongo 7, the Express API (tsx watch hot reload), and the Vite dev +# server (HMR) with bind-mounts so source edits hot-apply. nginx is NOT used +# in dev: Vite proxies /api, /health, /ready to the api service (SPEC §4). +# +# IMPORTANT (dev proxy): the Vite dev proxy target inside this compose network +# must be http://api:4000 (the api service name). `frontend/vite.config.ts` +# reads that from VITE_PROXY_TARGET (defaults to http://localhost:4000 on the +# host, set to http://api:4000 in this file). See README.md. +# +# Dockerfile targets: backend/Dockerfile:dev and frontend/Dockerfile:dev run +# the dev stages (full deps + source). Both containers also run a `tsc --watch` +# on packages/shared so shared schema/type edits rebuild dist/ live. +# ============================================================================= + +name: workorders-dev + +services: + mongo: + image: mongo:7 + restart: unless-stopped + # Named volume so data survives `docker compose down`. + volumes: + - mongo-data:/data/db + # Published so host-side tooling (`npm run seed` with + # MONGODB_URI=mongodb://localhost:27017/workorders) can reach Mongo. + ports: + - "27017:27017" + healthcheck: + test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + + api: + build: + context: . + dockerfile: backend/Dockerfile + target: dev + image: workorders-api:dev + restart: unless-stopped + init: true + working_dir: /app/backend + # Rebuild packages/shared dist/ on change, then run tsx watch (hot reload). + command: > + sh -c "npx tsc -p ../packages/shared/tsconfig.json --watch --preserveWatchOutput & + exec npx tsx watch src/server.ts" + environment: + NODE_ENV: development + PORT: "4000" + MONGODB_URI: mongodb://mongo:27017/workorders + # ── dev only: long random-looking values so backend validation passes. + # These are NOT secrets; production reads real values from .env. + JWT_SECRET: dev-only-e7c1a9f34d5b8e2c0a6f9d1b4c7e3a85 + COOKIE_SECRET: dev-only-b2f6a4d9c8e1b3f7a5c0d2e4f8b6a1c7 + CORS_ORIGIN: http://localhost:5173 + APP_URL: http://localhost:5173 + TRUST_PROXY_HOPS: "0" + DEBUG_ERRORS: "true" + LOG_LEVEL: debug + volumes: + - ./backend/src:/app/backend/src + - ./packages/shared/src:/app/packages/shared/src + ports: + - "4000:4000" + depends_on: + mongo: + condition: service_healthy + + vite: + build: + context: . + dockerfile: frontend/Dockerfile + target: dev + image: workorders-vite:dev + restart: unless-stopped + init: true + working_dir: /app/frontend + # Keep packages/shared dist/ fresh for Vite, then start the dev server. + command: > + sh -c "npx tsc -p ../packages/shared/tsconfig.json --watch --preserveWatchOutput & + exec npm run dev -- --host 0.0.0.0" + environment: + NODE_ENV: development + VITE_APP_URL: http://localhost:5173 + VITE_PROXY_TARGET: http://api:4000 + # Unset / empty => Vite dev proxy handles /api (SPEC §10). + volumes: + - ./frontend/src:/app/frontend/src + - ./packages/shared/src:/app/packages/shared/src + ports: + - "5173:5173" + depends_on: + - api + +volumes: + mongo-data: diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..9acd471 --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,109 @@ +# nginx reverse proxy — single-origin deployment (SPEC §4). +# Serves the built SPA and proxies /api, /health, /ready to the Express API +# so the browser talks to exactly one origin (cookies stay SameSite=Lax safe). +# Mounted read-only at /etc/nginx/nginx.conf by docker-compose.prod.yml. + +worker_processes auto; + +events { + worker_connections 1024; +} + +http { + # Hide nginx version from error pages and Server headers. + server_tokens off; + + include /etc/nginx/mime.types; + default_type application/octet-stream; + + sendfile on; + tcp_nopush on; + keepalive_timeout 65; + client_max_body_size 1m; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + access_log /var/log/nginx/access.log main; + error_log /var/log/nginx/error.log warn; + + # ---- Compression ---- + gzip on; + gzip_vary on; + gzip_comp_level 6; + gzip_min_length 1024; + gzip_types text/plain text/css text/xml text/javascript + application/javascript application/json + application/xml application/rss+xml + image/svg+xml; + + # ---- Security headers (inherited by every location unless a location sets + # its own add_header, in which case it is re-declared there) ---- + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + upstream api { + server api:4000; + } + + server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # ---- API: forward the original URI untouched (/api/... -> /api/...) ---- + # X-Forwarded-* headers let the API's TRUST_PROXY_HOPS resolve the real + # client IP for rate limiting (SPEC §8.4). + location /api/ { + proxy_pass http://api; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Connection ""; + proxy_read_timeout 30s; + } + + # ---- Public health / readiness (unversioned) ---- + location = /health { + proxy_pass http://api/health; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location = /ready { + proxy_pass http://api/ready; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # ---- Hashed build assets: cache aggressively (immutable), never HTML ---- + location ~* \.(?:js|css|map|svg|png|jpg|jpeg|gif|webp|ico|woff2?|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, max-age=31536000, immutable" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + try_files $uri =404; + } + + # ---- SPA fallback: everything else -> index.html (never cached) ---- + location / { + add_header Cache-Control "no-store" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + try_files $uri $uri/ /index.html; + } + } +}