diff --git a/.env.example b/.env.example index d1ecb8f..223d05f 100644 --- a/.env.example +++ b/.env.example @@ -15,9 +15,5 @@ POSTGRES_USERS_USER=users POSTGRES_USERS_PASSWORD=CHANGE_ME POSTGRES_USERS_DB=star_wars_users -# ── Admin ──────────────────────────────────────────────────────────────────── -# Password to unlock the team-switching debug widget in the UI -ADMIN_PASSWORD=CHANGE_ME - # ── CORS ───────────────────────────────────────────────────────────────────── CORS_ORIGIN=* \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 9fffce0..21d4680 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,29 +1,39 @@ -FROM node:20-alpine - -# Create non-root user/group before switching context -RUN addgroup -S appgroup && adduser -S appuser -G appgroup +# ── Stage 1: install production dependencies ────────────────────────────────── +# node:20-alpine is used only for npm ci; it never ships to production. +FROM node:20-alpine AS deps WORKDIR /app -# Install dependencies first for better layer caching -COPY package.json package-lock.json ./ -RUN npm ci --omit=dev +# package-lock.json* — the wildcard makes the COPY succeed even if the lock +# file is absent, so the image can be built from a clean checkout without any +# local Node installation. +COPY package.json package-lock.json* ./ +RUN if [ -f package-lock.json ]; then npm ci --omit=dev; else npm install --omit=dev; fi + +# ── Stage 2: hardened, minimal runtime ──────────────────────────────────────── +# gcr.io/distroless/nodejs20-debian12:nonroot contains only the Node runtime. +# No shell, no package manager, no OS utilities → drastically reduced attack +# surface and near-zero CVEs from OS packages. Runs as uid 65532 (nonroot). +FROM gcr.io/distroless/nodejs20-debian12:nonroot + +WORKDIR /app + +COPY --from=deps /app/node_modules ./node_modules # Copy application source COPY server ./server COPY public ./public COPY config ./config -# Drop to non-root user -USER appuser - ENV NODE_ENV=production ENV PORT=8080 EXPOSE 8080 -# Health-check: lightweight wget is available in node:alpine +# Health-check: no wget/curl in distroless — use the bundled Node binary +# directly via exec form (no shell needed). HEALTHCHECK --interval=15s --timeout=5s --start-period=30s --retries=3 \ - CMD wget -qO- http://localhost:8080/api/config > /dev/null || exit 1 + CMD ["/nodejs/bin/node", "server/healthcheck.js"] -CMD ["node", "server/index.js"] \ No newline at end of file +# The distroless ENTRYPOINT is already /nodejs/bin/node; CMD is the argument. +CMD ["server/index.js"] \ No newline at end of file diff --git a/README.md b/README.md index 65bd755..3693d02 100644 --- a/README.md +++ b/README.md @@ -4,119 +4,301 @@ Exploitable ring only (outer Ø100, inner Ø60). Planet positions are determinis > **Desktop only** — the galaxy map is not playable on phones or narrow screens (< 768 px wide). -## Layout +--- -The UI is split into two panes: +## Table of contents + +1. [Architecture](#architecture) +2. [Prerequisites](#prerequisites) +3. [Deploy on a new server](#deploy-on-a-new-server) +4. [Environment variables](#environment-variables) +5. [Runtime config (`game.settings.json`)](#runtime-config-gamesettingsjson) +6. [Local development (without Docker)](#local-development-without-docker) +7. [UI layout](#ui-layout) +8. [Game mechanics](#game-mechanics) +9. [API reference](#api-reference) +10. [Docker image notes](#docker-image-notes) +11. [Database persistence](#database-persistence) + +--- + +## Architecture + +| Component | Technology | +|-----------|-----------| +| Web server / API | Node.js 20 + Express | +| Game database | PostgreSQL 16 (`star_wars_grid`) | +| Users database | PostgreSQL 16 (`star_wars_users`) | +| Auth | JWT (7-day tokens, signed with `JWT_SECRET`) | +| Config hot-reload | File mtime polling (`game.settings.json`) | +| Economy engine | Server-side tick loop (every 5 s) | +| Container runtime | Docker / Docker Compose | + +The app runs as a single Node process that serves both the static frontend (`public/`) and the REST API (`/api/*`). Two separate PostgreSQL instances hold game state and user accounts respectively. + +--- + +## Prerequisites + +| Requirement | Minimum version | +|-------------|----------------| +| Docker Engine | 24+ | +| Docker Compose plugin | v2 (`docker compose`) | +| Git | any recent | + +No Node.js or PostgreSQL installation is needed on the host — everything runs inside Docker. + +--- + +## Deploy on a new server + +### 1 — Clone the repository + +```bash +git clone star-wars-wild-space +cd star-wars-wild-space +``` + +### 2 — Create the environment file + +```bash +cp .env.example .env +``` + +Open `.env` and set every value — see [Environment variables](#environment-variables) for details. At a minimum you **must** change: + +- `JWT_SECRET` — generate a strong random string: + ```bash + node -e "console.log(require('crypto').randomBytes(48).toString('hex'))" + ``` +- `POSTGRES_PASSWORD` +- `POSTGRES_USERS_PASSWORD` +- `ADMIN_PASSWORD` + +### 3 — Review the game config + +`config/game.settings.json` is mounted into the container and hot-reloaded at runtime. Adjust values before first boot if needed — see [Runtime config](#runtime-config-gamesettingsjson). + +### 4 — Create the data directories + +Docker bind-mounts the PostgreSQL data directories from the host. Create them before the first run so that Docker does not create them as root: + +```bash +mkdir -p data/postgres data/postgres_users +``` + +### 5 — Build and start + +```bash +docker compose up --build -d +``` + +The `app` service waits for both databases to pass their health checks before starting (pg_isready polling every 3 s, up to 15 retries). On first boot the server automatically creates all required tables. + +### 6 — Verify the deployment + +```bash +# Check all three containers are running +docker compose ps + +# Tail logs +docker compose logs -f app + +# Health check (should return JSON) +curl http://localhost:8080/api/config +``` + +Open `http://:8080` in a browser. + +### 7 — (Optional) Reverse proxy + +To expose the app on port 80/443, put Nginx or Caddy in front and proxy to `http://localhost:8080`. Set `CORS_ORIGIN` in `.env` to your public domain. + +### Stopping / restarting + +```bash +docker compose down # stop (data preserved) +docker compose down -v # stop and remove named volumes (data lost) +docker compose restart app # restart only the app container +``` + +--- + +## Environment variables + +**Never commit secrets.** All credentials live in **`.env`** (git-ignored). Copy from `.env.example` before the first run. + +| Variable | Default | Description | +|----------|---------|-------------| +| `PORT` | `8080` | HTTP port exposed by the app | +| `JWT_SECRET` | *(required)* | Secret used to sign JWT tokens — use a long random string in production | +| `POSTGRES_USER` | `game` | Game-DB username | +| `POSTGRES_PASSWORD` | *(required)* | Game-DB password | +| `POSTGRES_DB` | `star_wars_grid` | Game-DB name | +| `POSTGRES_USERS_USER` | `users` | Users-DB username | +| `POSTGRES_USERS_PASSWORD` | *(required)* | Users-DB password | +| `POSTGRES_USERS_DB` | `star_wars_users` | Users-DB name | +| `CORS_ORIGIN` | `*` | Value of the `Access-Control-Allow-Origin` header — set to your public domain in production | + +--- + +## Runtime config (`game.settings.json`) + +Edit **`config/game.settings.json`** on the host (bind-mounted into the container at `/app/config/game.settings.json`). The server checks the file's **mtime** on a schedule; when it changes the new values are applied without a restart, within at most `configReloadIntervalSeconds` seconds (minimum 5 s). + +### Core fields + +| Field | Default | Meaning | +|-------|---------|---------| +| `clickCooldownSeconds` | `10` | Per-user cooldown in seconds between new tile reveals. `0` disables it. | +| `databaseWipeoutIntervalSeconds` | `21600` | World period length in seconds (6 h). The world seed is `swg-` where `slot = floor(UTC unix seconds / this value)`. When the slot advances, `grid_cells` is truncated and a new era begins. Minimum `60`. | +| `configReloadIntervalSeconds` | `30` | How often the server polls the config file; also used by the client to poll `/api/config`. Minimum `5`. | + +### Economy fields + +| Field | Meaning | +|-------|---------| +| `elementWorth` | Per-element multipliers (`common`, `food`, `petrol`, `goods`, `industry`, `medic`, `money`, `science`). | +| `resourceWorth.common` / `resourceWorth.rare` | Base income per resource type for common and rare planet tiers. Drives the server-side economy tick (every 5 s). | + +`GET /api/config` returns the core fields plus **`worldSeed`**, **`seedPeriodStartsAtUtc`**, **`seedPeriodEndsAtUtc`**, and economy values. + +--- + +## Local development (without Docker) + +Requires two running PostgreSQL 16 instances, then: + +```bash +cp .env.example .env # fill in all values + +npm install + +# Set connection strings (PowerShell example) +$env:DATABASE_URL = "postgres://game:game@localhost:5432/star_wars_grid" +$env:USERS_DATABASE_URL = "postgres://users:users@localhost:5433/star_wars_users" +$env:JWT_SECRET = "dev_secret_change_me" + +npm start +``` + +The server creates tables automatically on first run. Ensure `config/game.settings.json` exists (the file is included in the repository). + +### Ports (local) + +| Service | Port | +|---------|------| +| App | `8080` (or `PORT` env var) | +| Game Postgres | `5432` | +| Users Postgres | `5433` | + +--- + +## UI layout | Pane | Content | |------|---------| | **Left column** | Title, team scores, player info, cooldown timer, world seed & period countdown, Refresh button, selection details | | **Right side** | Galaxy map — a perfect square anchored to the right edge of the viewport | -## Secrets & environment variables +--- -**Never commit secrets.** All credentials live in **`.env`** (git-ignored). -Copy the template and fill in your values before the first run: +## Game mechanics -```powershell -cp .env.example .env -# then edit .env +### Teams (Résistance / Premier Ordre) + +- Users register with a team (`Résistance` or `Premier Ordre`) that is locked to their account and embedded in their JWT. +- **Your** discovered tiles use your team colour. Unclaimed ring tiles use the neutral idle tint. +- Tiles discovered by the **other** team appear grey, show no planet, and are not clickable. The first reveal owns the tile (`discovered_by`). + +### Cooldown + +After revealing a **new** tile, a per-user cooldown starts. During cooldown you cannot reveal additional unseen tiles, but you can still click tiles your team has already discovered to view their stats. The effective cooldown is reduced by the team's element bonus: + +``` +effectiveCooldown = clickCooldownSeconds / (1 + elementBonus / 100) ``` -| Variable | Description | -|----------|-------------| -| `JWT_SECRET` | Secret used to sign JWT tokens — use a long random string in production | -| `POSTGRES_USER` | Game-DB username (default `game`) | -| `POSTGRES_PASSWORD` | Game-DB password — **change in production** | -| `POSTGRES_DB` | Game-DB name (default `star_wars_grid`) | -| `POSTGRES_USERS_USER` | Users-DB username (default `users`) | -| `POSTGRES_USERS_PASSWORD` | Users-DB password — **change in production** | -| `POSTGRES_USERS_DB` | Users-DB name (default `star_wars_users`) | -| `PORT` | HTTP port exposed by the app (default `8080`) | +### World seed & wipe -## Runtime config (file + volume) +The world seed changes automatically when the UTC period slot advances. On a seed change the server truncates `grid_cells` and starts a fresh era. Data in `users`, cooldown tables, econ scores, and victory points is kept across wipes. -Edit **`config/game.settings.json`** on the host (mounted into the container at `/app/config/game.settings.json`). The server reloads it when the file's **mtime** changes, on a schedule controlled by **`configReloadIntervalSeconds`** (minimum 5 s), so frequent polling is avoided when nothing changed. +### Economy -| Field | Meaning | -|-------|---------| -| `clickCooldownSeconds` | Cooldown between **new** reveals (same as before). | -| `databaseWipeoutIntervalSeconds` | World period length in **seconds** (default `21600` = 6 h). The **world seed** is `swg-` with `slot = floor(UTC unix seconds / this value)`. When the slot changes, **`grid_cells` is truncated** (full wipe). | -| `debugModeForTeams` | If `true`, the **Blue / Red** segmented control is shown; if `false`, it is hidden. | -| `configReloadIntervalSeconds` | How often the server **checks** the config file (mtime); also used by the client to poll `/api/config`. | +A server-side tick runs every 5 seconds. It reads the current grid from the database, computes income and element bonus for each team from their discovered planets, and persists the deltas. No browser needs to be connected for the economy to progress. -`GET /api/config` returns these values plus **`worldSeed`**, **`seedPeriodEndsAtUtc`**, **`seedPeriodStartsAtUtc`**. +--- -## Teams (blue / red) +## API reference -- With **`debugModeForTeams`**: use the **Team** control (top-left) to switch perspective. -- **Your** discovered tiles use your team colour. Unclaimed ring tiles use the classic idle tint for both teams. -- Tiles discovered by the **other** team appear **grey**, show **no planet**, and are **not clickable**. First reveal **owns** the tile (`discovered_by`). +All endpoints are relative to the app root. -## Cooldown +### Public -After revealing a **new** tile, a **cooldown** runs (left column). During cooldown you **cannot reveal additional unseen tiles**, but you can still **click tiles your team already discovered** to view their stats. +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/api/config` | Game config plus current world seed and period timestamps. Accepts optional `?team=blue|red` and Bearer token to compute per-user cooldown remaining. | +| `GET` | `/api/grid/:seed` | All revealed cells for `seed`. Returns `410` if `seed` is not the current world seed. | +| `GET` | `/api/scores` | `{ blue: N, red: N }` — tile counts for the current period. | +| `GET` | `/api/econ-scores` | Economic score totals for the current period. | +| `GET` | `/api/element-bonus` | Current element bonus percentages per team. | +| `GET` | `/api/victory-points` | All awarded victory points across eras. | +| `GET` | `/api/db-info` | Database creation timestamp. | -## Run with Docker Compose (Node + PostgreSQL) +### Auth (no token required) -### Prerequisites +| Method | Path | Body | Response | +|--------|------|------|----------| +| `POST` | `/api/auth/register` | `{ username, email, password, team }` | `{ token, user }` | +| `POST` | `/api/auth/login` | `{ username, password }` | `{ token, user }` | -1. Copy the environment template and set your secrets: - ```powershell - cp star_wars_grid_game/.env.example star_wars_grid_game/.env - # Edit .env — at minimum change JWT_SECRET, POSTGRES_PASSWORD, POSTGRES_USERS_PASSWORD - ``` +### Auth (Bearer token required) -2. From `star_wars_grid_game/`: - ```powershell - docker compose up --build - ``` +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/api/auth/me` | Returns refreshed `{ token, user }`. | +| `POST` | `/api/cell/reveal` | Body `{ seed, x, y }`. Team is taken from the JWT. First reveal wins; **`409`** if the other team already owns the tile; **`410`** if seed is stale; **`429`** if on cooldown. | -3. Open `http://localhost:8080`. +### Auth field rules -### Ports +| Field | Rules | +|-------|-------| +| `username` | 2–32 characters | +| `password` | Minimum 6 characters | +| `team` | `"Résistance"` or `"Premier Ordre"` | +| `email` | Must be unique | -| Service | Port | Notes | -|---------|------|-------| -| App | `8080` | configurable via `PORT` in `.env` | -| Game Postgres | `5432` | user/pass from `.env` | -| Users Postgres | `5433` | user/pass from `.env` | +Tokens are valid for **7 days**. Call `GET /api/auth/me` to silently renew. -### Database persistence - -PostgreSQL data is under **`./data/postgres`** and **`./data/postgres_users`** (bind mounts). The **world wipe** only clears `grid_cells` when the UTC period slot changes; it does not delete the Postgres data directory. - -## Local dev (without Docker) - -Requires PostgreSQL, then: - -```powershell -cd star_wars_grid_game -cp .env.example .env # fill in DATABASE_URL etc. -npm install -# export vars or use a tool like dotenv-cli -$env:DATABASE_URL="postgres://game:game@localhost:5432/star_wars_grid" -$env:USERS_DATABASE_URL="postgres://users:users@localhost:5433/star_wars_users" -$env:JWT_SECRET="dev_secret" -npm start -``` - -Ensure `config/game.settings.json` exists (or copy from the repo). +--- ## Docker image notes -- Base image: `node:20-alpine` -- Runs as a **non-root user** (`appuser`) for security -- `NODE_ENV=production` is set inside the image -- Built-in **health check**: polls `GET /api/config` every 15 s (`wget`) -- Secrets (`JWT_SECRET`, DB passwords) are **never baked into the image** — pass them via `.env` / Compose environment +- Build uses a **two-stage** Dockerfile: + - Stage 1 (`node:20-alpine`): installs production dependencies via `npm ci`. + - Stage 2 (`gcr.io/distroless/nodejs20-debian12:nonroot`): minimal runtime with no shell or package manager; runs as UID 65532. +- `NODE_ENV=production` is set inside the image. +- Built-in health check: calls `server/healthcheck.js` via the Node binary every 15 s (no `wget`/`curl` — distroless has none). +- Secrets are **never baked into the image** — always passed via `.env` / Compose environment. -## API +--- -- `GET /api/config` — cooldown, wipe interval, debug flag, poll interval, `worldSeed`, period timestamps. -- `GET /api/grid/:seed` — cells for that seed; **`410`** if `seed` is not the current world seed. -- `POST /api/cell/reveal` — body `{ seed, x, y, team: "blue" | "red" }` — first reveal wins; **`409`** if the other team owns the tile; **`410`** if seed is stale. -- `POST /api/auth/register` — `{ username, email, password, team }` → `{ token, user }`. -- `POST /api/auth/login` — `{ username, password }` → `{ token, user }`. -- `GET /api/auth/me` — Bearer token required → refreshed `{ token, user }`. -- `GET /api/scores` — `{ blue: N, red: N }` tile counts for the current period. \ No newline at end of file +## Database persistence + +PostgreSQL data lives in bind-mount directories on the host: + +| Directory | Database | +|-----------|----------| +| `./data/postgres` | Game DB (`star_wars_grid`) | +| `./data/postgres_users` | Users DB (`star_wars_users`) | + +A **world wipe** only truncates the `grid_cells` table when the UTC period slot changes; it does not touch the host data directories. Users, cooldowns, econ scores, and victory points survive wipes. + +To fully reset the game state (including all user accounts), stop the stack and delete both data directories, then start again: + +```bash +docker compose down +rm -rf data/postgres data/postgres_users +mkdir -p data/postgres data/postgres_users +docker compose up -d +``` \ No newline at end of file diff --git a/config/game.settings.json b/config/game.settings.json index dfe0bd0..718e915 100644 --- a/config/game.settings.json +++ b/config/game.settings.json @@ -1,17 +1,16 @@ { "clickCooldownSeconds": 10, "databaseWipeoutIntervalSeconds": 21600, - "debugModeForTeams": true, "configReloadIntervalSeconds": 30, "elementWorth": { - "common": 1, - "food": 2, - "petrol": 3, - "goods": 4, - "industry": 5, - "medic": 6, - "money": 7, - "science": 8 + "common": 0.1, + "food": 0.2, + "petrol": 0.3, + "goods": 0.4, + "industry": 0.5, + "medic": 0.6, + "money": 0.7, + "science": 0.8 }, "resourceWorth": { "common": { diff --git a/docker-compose.yml b/docker-compose.yml index e99ed74..b835261 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -46,6 +46,7 @@ services: ADMIN_PASSWORD: ${ADMIN_PASSWORD} PORT: "${PORT:-8080}" CONFIG_FILE_PATH: /app/config/game.settings.json + CORS_ORIGIN: ${CORS_ORIGIN:-*} volumes: - ./config:/app/config depends_on: diff --git a/node_modules/.package-lock.json b/node_modules/.package-lock.json index d94a5c4..ed1082d 100644 --- a/node_modules/.package-lock.json +++ b/node_modules/.package-lock.json @@ -132,6 +132,23 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -641,6 +658,15 @@ "node": ">= 0.6" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", diff --git a/node_modules/cors/LICENSE b/node_modules/cors/LICENSE new file mode 100644 index 0000000..fd10c84 --- /dev/null +++ b/node_modules/cors/LICENSE @@ -0,0 +1,22 @@ +(The MIT License) + +Copyright (c) 2013 Troy Goode + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/node_modules/cors/README.md b/node_modules/cors/README.md new file mode 100644 index 0000000..3d206e5 --- /dev/null +++ b/node_modules/cors/README.md @@ -0,0 +1,277 @@ +# cors + +[![NPM Version][npm-image]][npm-url] +[![NPM Downloads][downloads-image]][downloads-url] +[![Build Status][github-actions-ci-image]][github-actions-ci-url] +[![Test Coverage][coveralls-image]][coveralls-url] + +CORS is a [Node.js](https://nodejs.org/en/) middleware for [Express](https://expressjs.com/)/[Connect](https://github.com/senchalabs/connect) that sets [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS) response headers. These headers tell browsers which origins can read responses from your server. + +> [!IMPORTANT] +> **How CORS Works:** This package sets response headers—it doesn't block requests. CORS is enforced by browsers: they check the headers and decide if JavaScript can read the response. Non-browser clients (curl, Postman, other servers) ignore CORS entirely. See the [MDN CORS guide](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS) for details. + +* [Installation](#installation) +* [Usage](#usage) + * [Simple Usage](#simple-usage-enable-all-cors-requests) + * [Enable CORS for a Single Route](#enable-cors-for-a-single-route) + * [Configuring CORS](#configuring-cors) + * [Configuring CORS w/ Dynamic Origin](#configuring-cors-w-dynamic-origin) + * [Enabling CORS Pre-Flight](#enabling-cors-pre-flight) + * [Customizing CORS Settings Dynamically per Request](#customizing-cors-settings-dynamically-per-request) +* [Configuration Options](#configuration-options) +* [Common Misconceptions](#common-misconceptions) +* [License](#license) +* [Original Author](#original-author) + +## Installation + +This is a [Node.js](https://nodejs.org/en/) module available through the +[npm registry](https://www.npmjs.com/). Installation is done using the +[`npm install` command](https://docs.npmjs.com/downloading-and-installing-packages-locally): + +```sh +$ npm install cors +``` + +## Usage + +### Simple Usage (Enable *All* CORS Requests) + +```javascript +var express = require('express') +var cors = require('cors') +var app = express() + +// Adds headers: Access-Control-Allow-Origin: * +app.use(cors()) + +app.get('/products/:id', function (req, res, next) { + res.json({msg: 'Hello'}) +}) + +app.listen(80, function () { + console.log('web server listening on port 80') +}) +``` + +### Enable CORS for a Single Route + +```javascript +var express = require('express') +var cors = require('cors') +var app = express() + +// Adds headers: Access-Control-Allow-Origin: * +app.get('/products/:id', cors(), function (req, res, next) { + res.json({msg: 'Hello'}) +}) + +app.listen(80, function () { + console.log('web server listening on port 80') +}) +``` + +### Configuring CORS + +See the [configuration options](#configuration-options) for details. + +```javascript +var express = require('express') +var cors = require('cors') +var app = express() + +var corsOptions = { + origin: 'http://example.com', + optionsSuccessStatus: 200 // some legacy browsers (IE11, various SmartTVs) choke on 204 +} + +// Adds headers: Access-Control-Allow-Origin: http://example.com, Vary: Origin +app.get('/products/:id', cors(corsOptions), function (req, res, next) { + res.json({msg: 'Hello'}) +}) + +app.listen(80, function () { + console.log('web server listening on port 80') +}) +``` + +### Configuring CORS w/ Dynamic Origin + +This module supports validating the origin dynamically using a function provided +to the `origin` option. This function will be passed a string that is the origin +(or `undefined` if the request has no origin), and a `callback` with the signature +`callback(error, origin)`. + +The `origin` argument to the callback can be any value allowed for the `origin` +option of the middleware, except a function. See the +[configuration options](#configuration-options) section for more information on all +the possible value types. + +This function is designed to allow the dynamic loading of allowed origin(s) from +a backing datasource, like a database. + +```javascript +var express = require('express') +var cors = require('cors') +var app = express() + +var corsOptions = { + origin: function (origin, callback) { + // db.loadOrigins is an example call to load + // a list of origins from a backing database + db.loadOrigins(function (error, origins) { + callback(error, origins) + }) + } +} + +// Adds headers: Access-Control-Allow-Origin: , Vary: Origin +app.get('/products/:id', cors(corsOptions), function (req, res, next) { + res.json({msg: 'Hello'}) +}) + +app.listen(80, function () { + console.log('web server listening on port 80') +}) +``` + +### Enabling CORS Pre-Flight + +Certain CORS requests are considered 'complex' and require an initial +`OPTIONS` request (called the "pre-flight request"). An example of a +'complex' CORS request is one that uses an HTTP verb other than +GET/HEAD/POST (such as DELETE) or that uses custom headers. To enable +pre-flighting, you must add a new OPTIONS handler for the route you want +to support: + +```javascript +var express = require('express') +var cors = require('cors') +var app = express() + +app.options('/products/:id', cors()) // preflight for DELETE +app.del('/products/:id', cors(), function (req, res, next) { + res.json({msg: 'Hello'}) +}) + +app.listen(80, function () { + console.log('web server listening on port 80') +}) +``` + +You can also enable pre-flight across-the-board like so: + +```javascript +app.options('*', cors()) // include before other routes +``` + +NOTE: When using this middleware as an application level middleware (for +example, `app.use(cors())`), pre-flight requests are already handled for all +routes. + +### Customizing CORS Settings Dynamically per Request + +For APIs that require different CORS configurations for specific routes or requests, you can dynamically generate CORS options based on the incoming request. The `cors` middleware allows you to achieve this by passing a function instead of static options. This function is called for each incoming request and must use the callback pattern to return the appropriate CORS options. + +The function accepts: +1. **`req`**: + - The incoming request object. + +2. **`callback(error, corsOptions)`**: + - A function used to return the computed CORS options. + - **Arguments**: + - **`error`**: Pass `null` if there’s no error, or an error object to indicate a failure. + - **`corsOptions`**: An object specifying the CORS policy for the current request. + +Here’s an example that handles both public routes and restricted, credential-sensitive routes: + +```javascript +var dynamicCorsOptions = function(req, callback) { + var corsOptions; + if (req.path.startsWith('/auth/connect/')) { + // Access-Control-Allow-Origin: http://mydomain.com, Access-Control-Allow-Credentials: true, Vary: Origin + corsOptions = { + origin: 'http://mydomain.com', + credentials: true + }; + } else { + // Access-Control-Allow-Origin: * + corsOptions = { origin: '*' }; + } + callback(null, corsOptions); +}; + +app.use(cors(dynamicCorsOptions)); + +app.get('/auth/connect/twitter', function (req, res) { + res.send('Hello'); +}); + +app.get('/public', function (req, res) { + res.send('Hello'); +}); + +app.listen(80, function () { + console.log('web server listening on port 80') +}) +``` + +## Configuration Options + +* `origin`: Configures the **Access-Control-Allow-Origin** CORS header. Possible values: + - `Boolean` - set `origin` to `true` to reflect the [request origin](https://datatracker.ietf.org/doc/html/draft-abarth-origin-09), as defined by `req.header('Origin')`, or set it to `false` to disable CORS. + - `String` - set `origin` to a specific origin. For example, if you set it to + - `"http://example.com"` only requests from "http://example.com" will be allowed. + - `"*"` for all domains to be allowed. + - `RegExp` - set `origin` to a regular expression pattern which will be used to test the request origin. If it's a match, the request origin will be reflected. For example the pattern `/example\.com$/` will reflect any request that is coming from an origin ending with "example.com". + - `Array` - set `origin` to an array of valid origins. Each origin can be a `String` or a `RegExp`. For example `["http://example1.com", /\.example2\.com$/]` will accept any request from "http://example1.com" or from a subdomain of "example2.com". + - `Function` - set `origin` to a function implementing some custom logic. The function takes the request origin as the first parameter and a callback (called as `callback(err, origin)`, where `origin` is a non-function value of the `origin` option) as the second. +* `methods`: Configures the **Access-Control-Allow-Methods** CORS header. Expects a comma-delimited string (ex: 'GET,PUT,POST') or an array (ex: `['GET', 'PUT', 'POST']`). +* `allowedHeaders`: Configures the **Access-Control-Allow-Headers** CORS header. Expects a comma-delimited string (ex: 'Content-Type,Authorization') or an array (ex: `['Content-Type', 'Authorization']`). If not specified, defaults to reflecting the headers specified in the request's **Access-Control-Request-Headers** header. +* `exposedHeaders`: Configures the **Access-Control-Expose-Headers** CORS header. Expects a comma-delimited string (ex: 'Content-Range,X-Content-Range') or an array (ex: `['Content-Range', 'X-Content-Range']`). If not specified, no custom headers are exposed. +* `credentials`: Configures the **Access-Control-Allow-Credentials** CORS header. Set to `true` to pass the header, otherwise it is omitted. +* `maxAge`: Configures the **Access-Control-Max-Age** CORS header. Set to an integer to pass the header, otherwise it is omitted. +* `preflightContinue`: Pass the CORS preflight response to the next handler. +* `optionsSuccessStatus`: Provides a status code to use for successful `OPTIONS` requests, since some legacy browsers (IE11, various SmartTVs) choke on `204`. + +The default configuration is the equivalent of: + +```json +{ + "origin": "*", + "methods": "GET,HEAD,PUT,PATCH,POST,DELETE", + "preflightContinue": false, + "optionsSuccessStatus": 204 +} +``` + +## Common Misconceptions + +### "CORS blocks requests from disallowed origins" + +**No.** Your server receives and processes every request. CORS headers tell the browser whether JavaScript can read the response—not whether the request is allowed. + +### "CORS protects my API from unauthorized access" + +**No.** CORS is not access control. Any HTTP client (curl, Postman, another server) can call your API regardless of CORS settings. Use authentication and authorization to protect your API. + +### "Setting `origin: 'http://example.com'` means only that domain can access my server" + +**No.** It means browsers will only let JavaScript from that origin read responses. The server still responds to all requests. + +## License + +[MIT License](http://www.opensource.org/licenses/mit-license.php) + +## Original Author + +[Troy Goode](https://github.com/TroyGoode) ([troygoode@gmail.com](mailto:troygoode@gmail.com)) + +[coveralls-image]: https://img.shields.io/coveralls/expressjs/cors/master.svg +[coveralls-url]: https://coveralls.io/r/expressjs/cors?branch=master +[downloads-image]: https://img.shields.io/npm/dm/cors.svg +[downloads-url]: https://npmjs.com/package/cors +[github-actions-ci-image]: https://img.shields.io/github/actions/workflow/status/expressjs/cors/ci.yml?branch=master&label=ci +[github-actions-ci-url]: https://github.com/expressjs/cors?query=workflow%3Aci +[npm-image]: https://img.shields.io/npm/v/cors.svg +[npm-url]: https://npmjs.com/package/cors diff --git a/node_modules/cors/lib/index.js b/node_modules/cors/lib/index.js new file mode 100644 index 0000000..ad899ca --- /dev/null +++ b/node_modules/cors/lib/index.js @@ -0,0 +1,238 @@ +(function () { + + 'use strict'; + + var assign = require('object-assign'); + var vary = require('vary'); + + var defaults = { + origin: '*', + methods: 'GET,HEAD,PUT,PATCH,POST,DELETE', + preflightContinue: false, + optionsSuccessStatus: 204 + }; + + function isString(s) { + return typeof s === 'string' || s instanceof String; + } + + function isOriginAllowed(origin, allowedOrigin) { + if (Array.isArray(allowedOrigin)) { + for (var i = 0; i < allowedOrigin.length; ++i) { + if (isOriginAllowed(origin, allowedOrigin[i])) { + return true; + } + } + return false; + } else if (isString(allowedOrigin)) { + return origin === allowedOrigin; + } else if (allowedOrigin instanceof RegExp) { + return allowedOrigin.test(origin); + } else { + return !!allowedOrigin; + } + } + + function configureOrigin(options, req) { + var requestOrigin = req.headers.origin, + headers = [], + isAllowed; + + if (!options.origin || options.origin === '*') { + // allow any origin + headers.push([{ + key: 'Access-Control-Allow-Origin', + value: '*' + }]); + } else if (isString(options.origin)) { + // fixed origin + headers.push([{ + key: 'Access-Control-Allow-Origin', + value: options.origin + }]); + headers.push([{ + key: 'Vary', + value: 'Origin' + }]); + } else { + isAllowed = isOriginAllowed(requestOrigin, options.origin); + // reflect origin + headers.push([{ + key: 'Access-Control-Allow-Origin', + value: isAllowed ? requestOrigin : false + }]); + headers.push([{ + key: 'Vary', + value: 'Origin' + }]); + } + + return headers; + } + + function configureMethods(options) { + var methods = options.methods; + if (methods.join) { + methods = options.methods.join(','); // .methods is an array, so turn it into a string + } + return { + key: 'Access-Control-Allow-Methods', + value: methods + }; + } + + function configureCredentials(options) { + if (options.credentials === true) { + return { + key: 'Access-Control-Allow-Credentials', + value: 'true' + }; + } + return null; + } + + function configureAllowedHeaders(options, req) { + var allowedHeaders = options.allowedHeaders || options.headers; + var headers = []; + + if (!allowedHeaders) { + allowedHeaders = req.headers['access-control-request-headers']; // .headers wasn't specified, so reflect the request headers + headers.push([{ + key: 'Vary', + value: 'Access-Control-Request-Headers' + }]); + } else if (allowedHeaders.join) { + allowedHeaders = allowedHeaders.join(','); // .headers is an array, so turn it into a string + } + if (allowedHeaders && allowedHeaders.length) { + headers.push([{ + key: 'Access-Control-Allow-Headers', + value: allowedHeaders + }]); + } + + return headers; + } + + function configureExposedHeaders(options) { + var headers = options.exposedHeaders; + if (!headers) { + return null; + } else if (headers.join) { + headers = headers.join(','); // .headers is an array, so turn it into a string + } + if (headers && headers.length) { + return { + key: 'Access-Control-Expose-Headers', + value: headers + }; + } + return null; + } + + function configureMaxAge(options) { + var maxAge = (typeof options.maxAge === 'number' || options.maxAge) && options.maxAge.toString() + if (maxAge && maxAge.length) { + return { + key: 'Access-Control-Max-Age', + value: maxAge + }; + } + return null; + } + + function applyHeaders(headers, res) { + for (var i = 0, n = headers.length; i < n; i++) { + var header = headers[i]; + if (header) { + if (Array.isArray(header)) { + applyHeaders(header, res); + } else if (header.key === 'Vary' && header.value) { + vary(res, header.value); + } else if (header.value) { + res.setHeader(header.key, header.value); + } + } + } + } + + function cors(options, req, res, next) { + var headers = [], + method = req.method && req.method.toUpperCase && req.method.toUpperCase(); + + if (method === 'OPTIONS') { + // preflight + headers.push(configureOrigin(options, req)); + headers.push(configureCredentials(options)) + headers.push(configureMethods(options)) + headers.push(configureAllowedHeaders(options, req)); + headers.push(configureMaxAge(options)) + headers.push(configureExposedHeaders(options)) + applyHeaders(headers, res); + + if (options.preflightContinue) { + next(); + } else { + // Safari (and potentially other browsers) need content-length 0, + // for 204 or they just hang waiting for a body + res.statusCode = options.optionsSuccessStatus; + res.setHeader('Content-Length', '0'); + res.end(); + } + } else { + // actual response + headers.push(configureOrigin(options, req)); + headers.push(configureCredentials(options)) + headers.push(configureExposedHeaders(options)) + applyHeaders(headers, res); + next(); + } + } + + function middlewareWrapper(o) { + // if options are static (either via defaults or custom options passed in), wrap in a function + var optionsCallback = null; + if (typeof o === 'function') { + optionsCallback = o; + } else { + optionsCallback = function (req, cb) { + cb(null, o); + }; + } + + return function corsMiddleware(req, res, next) { + optionsCallback(req, function (err, options) { + if (err) { + next(err); + } else { + var corsOptions = assign({}, defaults, options); + var originCallback = null; + if (corsOptions.origin && typeof corsOptions.origin === 'function') { + originCallback = corsOptions.origin; + } else if (corsOptions.origin) { + originCallback = function (origin, cb) { + cb(null, corsOptions.origin); + }; + } + + if (originCallback) { + originCallback(req.headers.origin, function (err2, origin) { + if (err2 || !origin) { + next(err2); + } else { + corsOptions.origin = origin; + cors(corsOptions, req, res, next); + } + }); + } else { + next(); + } + } + }); + }; + } + + // can pass either an options hash, an options delegate, or nothing + module.exports = middlewareWrapper; + +}()); diff --git a/node_modules/cors/package.json b/node_modules/cors/package.json new file mode 100644 index 0000000..e90bac8 --- /dev/null +++ b/node_modules/cors/package.json @@ -0,0 +1,42 @@ +{ + "name": "cors", + "description": "Node.js CORS middleware", + "version": "2.8.6", + "author": "Troy Goode (https://github.com/troygoode/)", + "license": "MIT", + "keywords": [ + "cors", + "express", + "connect", + "middleware" + ], + "repository": "expressjs/cors", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + }, + "main": "./lib/index.js", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "devDependencies": { + "after": "0.8.2", + "eslint": "7.30.0", + "express": "4.21.2", + "mocha": "9.2.2", + "nyc": "15.1.0", + "supertest": "6.1.3" + }, + "files": [ + "lib/index.js" + ], + "engines": { + "node": ">= 0.10" + }, + "scripts": { + "test": "npm run lint && npm run test-ci", + "test-ci": "nyc --reporter=lcov --reporter=text mocha --require test/support/env", + "lint": "eslint lib test" + } +} diff --git a/node_modules/object-assign/index.js b/node_modules/object-assign/index.js new file mode 100644 index 0000000..0930cf8 --- /dev/null +++ b/node_modules/object-assign/index.js @@ -0,0 +1,90 @@ +/* +object-assign +(c) Sindre Sorhus +@license MIT +*/ + +'use strict'; +/* eslint-disable no-unused-vars */ +var getOwnPropertySymbols = Object.getOwnPropertySymbols; +var hasOwnProperty = Object.prototype.hasOwnProperty; +var propIsEnumerable = Object.prototype.propertyIsEnumerable; + +function toObject(val) { + if (val === null || val === undefined) { + throw new TypeError('Object.assign cannot be called with null or undefined'); + } + + return Object(val); +} + +function shouldUseNative() { + try { + if (!Object.assign) { + return false; + } + + // Detect buggy property enumeration order in older V8 versions. + + // https://bugs.chromium.org/p/v8/issues/detail?id=4118 + var test1 = new String('abc'); // eslint-disable-line no-new-wrappers + test1[5] = 'de'; + if (Object.getOwnPropertyNames(test1)[0] === '5') { + return false; + } + + // https://bugs.chromium.org/p/v8/issues/detail?id=3056 + var test2 = {}; + for (var i = 0; i < 10; i++) { + test2['_' + String.fromCharCode(i)] = i; + } + var order2 = Object.getOwnPropertyNames(test2).map(function (n) { + return test2[n]; + }); + if (order2.join('') !== '0123456789') { + return false; + } + + // https://bugs.chromium.org/p/v8/issues/detail?id=3056 + var test3 = {}; + 'abcdefghijklmnopqrst'.split('').forEach(function (letter) { + test3[letter] = letter; + }); + if (Object.keys(Object.assign({}, test3)).join('') !== + 'abcdefghijklmnopqrst') { + return false; + } + + return true; + } catch (err) { + // We don't expect any of the above to throw, but better to be safe. + return false; + } +} + +module.exports = shouldUseNative() ? Object.assign : function (target, source) { + var from; + var to = toObject(target); + var symbols; + + for (var s = 1; s < arguments.length; s++) { + from = Object(arguments[s]); + + for (var key in from) { + if (hasOwnProperty.call(from, key)) { + to[key] = from[key]; + } + } + + if (getOwnPropertySymbols) { + symbols = getOwnPropertySymbols(from); + for (var i = 0; i < symbols.length; i++) { + if (propIsEnumerable.call(from, symbols[i])) { + to[symbols[i]] = from[symbols[i]]; + } + } + } + } + + return to; +}; diff --git a/node_modules/object-assign/license b/node_modules/object-assign/license new file mode 100644 index 0000000..654d0bf --- /dev/null +++ b/node_modules/object-assign/license @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) Sindre Sorhus (sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/node_modules/object-assign/package.json b/node_modules/object-assign/package.json new file mode 100644 index 0000000..503eb1e --- /dev/null +++ b/node_modules/object-assign/package.json @@ -0,0 +1,42 @@ +{ + "name": "object-assign", + "version": "4.1.1", + "description": "ES2015 `Object.assign()` ponyfill", + "license": "MIT", + "repository": "sindresorhus/object-assign", + "author": { + "name": "Sindre Sorhus", + "email": "sindresorhus@gmail.com", + "url": "sindresorhus.com" + }, + "engines": { + "node": ">=0.10.0" + }, + "scripts": { + "test": "xo && ava", + "bench": "matcha bench.js" + }, + "files": [ + "index.js" + ], + "keywords": [ + "object", + "assign", + "extend", + "properties", + "es2015", + "ecmascript", + "harmony", + "ponyfill", + "prollyfill", + "polyfill", + "shim", + "browser" + ], + "devDependencies": { + "ava": "^0.16.0", + "lodash": "^4.16.4", + "matcha": "^0.7.0", + "xo": "^0.16.0" + } +} diff --git a/node_modules/object-assign/readme.md b/node_modules/object-assign/readme.md new file mode 100644 index 0000000..1be09d3 --- /dev/null +++ b/node_modules/object-assign/readme.md @@ -0,0 +1,61 @@ +# object-assign [![Build Status](https://travis-ci.org/sindresorhus/object-assign.svg?branch=master)](https://travis-ci.org/sindresorhus/object-assign) + +> ES2015 [`Object.assign()`](http://www.2ality.com/2014/01/object-assign.html) [ponyfill](https://ponyfill.com) + + +## Use the built-in + +Node.js 4 and up, as well as every evergreen browser (Chrome, Edge, Firefox, Opera, Safari), +support `Object.assign()` :tada:. If you target only those environments, then by all +means, use `Object.assign()` instead of this package. + + +## Install + +``` +$ npm install --save object-assign +``` + + +## Usage + +```js +const objectAssign = require('object-assign'); + +objectAssign({foo: 0}, {bar: 1}); +//=> {foo: 0, bar: 1} + +// multiple sources +objectAssign({foo: 0}, {bar: 1}, {baz: 2}); +//=> {foo: 0, bar: 1, baz: 2} + +// overwrites equal keys +objectAssign({foo: 0}, {foo: 1}, {foo: 2}); +//=> {foo: 2} + +// ignores null and undefined sources +objectAssign({foo: 0}, null, {bar: 1}, undefined); +//=> {foo: 0, bar: 1} +``` + + +## API + +### objectAssign(target, [source, ...]) + +Assigns enumerable own properties of `source` objects to the `target` object and returns the `target` object. Additional `source` objects will overwrite previous ones. + + +## Resources + +- [ES2015 spec - Object.assign](https://people.mozilla.org/~jorendorff/es6-draft.html#sec-object.assign) + + +## Related + +- [deep-assign](https://github.com/sindresorhus/deep-assign) - Recursive `Object.assign()` + + +## License + +MIT © [Sindre Sorhus](https://sindresorhus.com) diff --git a/package-lock.json b/package-lock.json index ed8ef64..bee1118 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "name": "star-wars-wild-space", "dependencies": { "bcryptjs": "^2.4.3", + "cors": "^2.8.6", "dotenv": "^17.3.1", "express": "^4.21.2", "jsonwebtoken": "^9.0.2", @@ -142,6 +143,23 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -651,6 +669,15 @@ "node": ">= 0.6" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", diff --git a/package.json b/package.json index 5a50778..fc6eeb0 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ }, "dependencies": { "bcryptjs": "^2.4.3", + "cors": "^2.8.6", "dotenv": "^17.3.1", "express": "^4.21.2", "jsonwebtoken": "^9.0.2", diff --git a/server/app.js b/server/app.js index bfd6d09..4820877 100644 --- a/server/app.js +++ b/server/app.js @@ -1,4 +1,5 @@ import express from "express"; +import cors from "cors"; import path from "path"; import { fileURLToPath } from "url"; import authRouter from "./routes/auth.js"; @@ -8,6 +9,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const publicDir = path.join(__dirname, "..", "public"); const app = express(); +app.use(cors({ origin: process.env.CORS_ORIGIN ?? "*" })); app.use(express.json()); app.use(express.static(publicDir)); diff --git a/server/db/pools.js b/server/db/pools.js index 7558feb..9ec421e 100644 --- a/server/db/pools.js +++ b/server/db/pools.js @@ -1,10 +1,11 @@ import pg from "pg"; -const DATABASE_URL = - process.env.DATABASE_URL ?? "postgres://game:game@localhost:5432/star_wars_grid"; +if (!process.env.DATABASE_URL) { + throw new Error("[startup] DATABASE_URL environment variable is required but not set."); +} +if (!process.env.USERS_DATABASE_URL) { + throw new Error("[startup] USERS_DATABASE_URL environment variable is required but not set."); +} -const USERS_DATABASE_URL = - process.env.USERS_DATABASE_URL ?? "postgres://users:users@localhost:5433/star_wars_users"; - -export const pool = new pg.Pool({ connectionString: DATABASE_URL }); -export const usersPool = new pg.Pool({ connectionString: USERS_DATABASE_URL }); \ No newline at end of file +export const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL }); +export const usersPool = new pg.Pool({ connectionString: process.env.USERS_DATABASE_URL }); \ No newline at end of file diff --git a/server/healthcheck.js b/server/healthcheck.js new file mode 100644 index 0000000..e71bad1 --- /dev/null +++ b/server/healthcheck.js @@ -0,0 +1,8 @@ +import http from "http"; + +const port = Number(process.env.PORT ?? 8080); +http + .get(`http://localhost:${port}/api/config`, (res) => { + process.exit(res.statusCode === 200 ? 0 : 1); + }) + .on("error", () => process.exit(1)); diff --git a/server/middleware/auth.js b/server/middleware/auth.js index 992fc17..2328dcf 100644 --- a/server/middleware/auth.js +++ b/server/middleware/auth.js @@ -1,6 +1,10 @@ import jwt from "jsonwebtoken"; -export const JWT_SECRET = process.env.JWT_SECRET ?? "dev_secret_change_me"; +if (!process.env.JWT_SECRET) { + throw new Error("[startup] JWT_SECRET environment variable is required but not set."); +} + +export const JWT_SECRET = process.env.JWT_SECRET; export function authMiddleware(req, res, next) { const authHeader = req.headers["authorization"];