From f18defe4a5cbf339e25d33a307bf0f498b033b95 Mon Sep 17 00:00:00 2001 From: Rajkumar Date: Sun, 13 Sep 2026 02:56:53 +0530 Subject: [PATCH 1/2] (fix and update) github actions implementation and also deployment script ready --- .env.example | 2 +- .env.prod.example | 85 ++ .github/workflows/ci.yml | 125 +++ .github/workflows/deploy.yml | 342 +++++++ DEPLOY-WALKTHROUGH.md | 921 ++++++++++++++++++ DEPLOYMENT.md | 739 ++++++++++++++ README.md | 91 +- SETUP.md | 17 +- apps/admin/app/globals.css | 4 +- apps/admin/app/layout.tsx | 4 +- apps/admin/components/AdminArtwork.tsx | 2 +- apps/admin/components/AdminLogin.tsx | 2 +- apps/admin/components/AdminShell.tsx | 4 +- apps/admin/components/badges/crests.ts | 2 +- apps/admin/lib/session.ts | 2 +- .../http-api/src/modules/auth/auth.service.ts | 2 +- .../src/modules/leagues/standings.service.ts | 1 - apps/web/app/globals.css | 2 +- apps/web/app/layout.tsx | 2 +- apps/web/app/page.tsx | 3 + apps/web/app/rankings/page.tsx | 324 +++++- apps/web/components/AppShell.tsx | 143 ++- apps/web/components/Logo.tsx | 4 +- apps/web/components/PageViewTracker.tsx | 2 +- apps/web/components/leagues/FixturesPanel.tsx | 1 - apps/web/components/leagues/LeagueFlow.tsx | 1 - apps/web/components/ranking/Badges.tsx | 18 +- apps/web/components/ranking/crests.ts | 2 +- apps/web/components/results/Results.tsx | 17 +- apps/web/lib/api.ts | 2 +- apps/web/lib/session.ts | 2 +- apps/web/next-env.d.ts | 2 +- docker-compose.dev.yml | 12 +- docker-compose.prod.yml | 256 +++++ docker-compose.yml | 16 +- docker/piston-init/provision.sh | 2 +- docs-src/01-technical-approach.md | 4 +- docs-src/02-why-what-how.md | 4 +- docs-src/03-user-guide.md | 8 +- docs-src/README.md | 2 +- docs/01-technical-approach.pdf | Bin 184601 -> 184302 bytes docs/02-why-what-how.pdf | Bin 128280 -> 127953 bytes docs/03-user-guide.pdf | Bin 131422 -> 130907 bytes ...entation.pdf => spidder-documentation.pdf} | Bin 325952 -> 325368 bytes packages/auth/src/index.ts | 6 +- packages/db/.env.example | 2 +- packages/db/prisma/schema.prisma | 2 +- scripts/make-docs-pdf.py | 2 +- 48 files changed, 3027 insertions(+), 159 deletions(-) create mode 100644 .env.prod.example create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/deploy.yml create mode 100644 DEPLOY-WALKTHROUGH.md create mode 100644 DEPLOYMENT.md create mode 100644 docker-compose.prod.yml rename docs/{combatx-documentation.pdf => spidder-documentation.pdf} (72%) diff --git a/.env.example b/.env.example index 3583d58..b702ba7 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ # Shared local dev environment for all apps. Not committed (see .gitignore). -DATABASE_URL="postgresql://postgres:postgres@localhost:5432/combateone?schema=public" +DATABASE_URL="postgresql://postgres:postgres@localhost:5432/spidder?schema=public" REDIS_URL="redis://localhost:6379" JWT_SECRET="replace-with-a-long-random-string" CORS_ORIGINS="http://localhost:3001,http://localhost:3002" diff --git a/.env.prod.example b/.env.prod.example new file mode 100644 index 0000000..de79364 --- /dev/null +++ b/.env.prod.example @@ -0,0 +1,85 @@ +# Spidder — production environment for docker-compose.prod.yml. +# +# cp .env.prod.example .env # on the VPS, next to the compose file +# chmod 600 .env +# +# Compose reads `.env` automatically. This file holds real credentials once +# filled in, so it is NEVER committed — .gitignore already excludes `.env`. +# +# Every value without a default below is marked required in the compose file: +# leave one out and `docker compose up` refuses to start rather than silently +# booting against the wrong database. + +# --------------------------------------------------------------------------- +# Host services +# --------------------------------------------------------------------------- +# Postgres and Redis run on the VPS host, not in Docker. Containers reach them +# through `host.docker.internal`, which docker-compose.prod.yml maps to the +# host gateway. Do NOT use `localhost` here — inside a container that is the +# container itself, and the connection will be refused. +DATABASE_URL="postgresql://spidder:CHANGE_ME@host.docker.internal:5432/spidder?schema=public" + +# Include the password you set with `requirepass` in redis.conf. +REDIS_URL="redis://:CHANGE_ME@host.docker.internal:6379" + +# --------------------------------------------------------------------------- +# Secrets +# --------------------------------------------------------------------------- +# Signs session and guest JWTs. Generate with: openssl rand -hex 32 +# Changing it invalidates every existing session, which is the correct +# response to a suspected leak. +JWT_SECRET="CHANGE_ME" + +# Comma-separated origins the API and ws-server accept cross-origin requests +# from. This is the public site origin, NOT the container port. +CORS_ORIGINS="https://spidder.example.com" + +# --------------------------------------------------------------------------- +# Public URLs — baked into the browser bundle at IMAGE BUILD time +# --------------------------------------------------------------------------- +# These are what the visitor's browser calls, so they must be the public +# origins served by your reverse proxy. +# +# Because they are compiled into the client bundle, changing them here and +# restarting does NOTHING — the web image has to be rebuilt. In the CI/CD flow +# they come from repository secrets of the same name and are passed as Docker +# build args; these entries matter only if you build on the server by hand. +NEXT_PUBLIC_API_URL="https://api.spidder.example.com" +NEXT_PUBLIC_WS_URL="wss://api.spidder.example.com/ws" + +# --------------------------------------------------------------------------- +# Images (Docker Hub) +# --------------------------------------------------------------------------- +# The Docker Hub ACCOUNT name the five spidder-* images live under. Defaults +# to `codeheist` in the compose file, so leaving this commented out is fine for +# this project — set it only if you publish under a different account. +# +# Write it lowercase if you do change it. Docker Hub stores account names +# lowercase, and while `docker tag` accepts the capitals you typed, the pull +# then targets a namespace that does not exist. +# REGISTRY="codeheist" + +# Which build to run. The deploy workflow sets this to the commit SHA, which +# is what makes a rollback a one-line change. Unset means `latest`, which is +# what you want when starting the stack by hand. +# IMAGE_TAG="latest" + +# --------------------------------------------------------------------------- +# Optional +# --------------------------------------------------------------------------- +# Host-side ports the reverse proxy forwards to. Only change these if +# something else on the VPS already owns the port. +# WEB_PORT=3001 +# HTTP_API_PORT=4001 +# WS_SERVER_PORT=4002 + +# How many submissions the judge runs in parallel. Raise it only if the VPS +# has the cores to spare — each one is a sandboxed process. +# JUDGE_CONCURRENCY=4 + +# Creates the first admin account on boot when it does not exist. Set these +# for the initial deploy, then remove them and restart so the credentials stop +# living in a file on disk. +# SUPER_ADMIN_EMAIL="you@example.com" +# SUPER_ADMIN_PASSWORD="a-strong-password" +# SUPER_ADMIN_USERNAME="admin" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6dca938 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,125 @@ +############################################################################### +# CI — runs on every push and pull request. +# +# Three jobs in parallel (quality, test, build) rather than one long chain, so +# a type error and a failing test surface together instead of one hiding the +# other behind a 6-minute queue. +# +# Docker images are NOT built here. Building four images on every branch push +# would dominate the run time and produce artifacts nobody installs. The deploy +# workflow builds them, once, when something is actually shipping. +############################################################################### + +name: CI + +on: + push: + branches: ["**"] + pull_request: + branches: [main] + +# A second push to the same branch cancels the first. Nobody is waiting on the +# results of a commit that has already been replaced. +# +# `main` is deliberately excluded from cancellation: its runs gate the deploy, +# and a cancelled run there would leave a commit with no verdict at all. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +env: + # Keep in lockstep with the root package.json "packageManager" field and the + # PNPM_VERSION arg in every Dockerfile.prod. + PNPM_VERSION: 10.19.0 + NODE_VERSION: 22 + +jobs: + quality: + name: Lint & types + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + + # --frozen-lockfile fails if pnpm-lock.yaml does not match the manifests. + # That is the point: a lockfile drifting from package.json is how a build + # that passes CI installs different versions in production. + - run: pnpm install --frozen-lockfile + + # @repo/db's build runs `prisma generate`. Without it the generated client + # does not exist and every downstream typecheck fails on missing imports, + # which looks like a hundred unrelated errors rather than one missing step. + - name: Generate Prisma client + run: pnpm --filter @repo/db build + + - name: Typecheck + run: pnpm check-types + + - name: Lint + run: pnpm lint + + test: + name: Unit tests + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + + - run: pnpm install --frozen-lockfile + + # @repo/game is the only package with tests, and it is deliberately + # database-free so it needs no services here. Called by filter rather than + # `turbo run test` because turbo.json defines no `test` task — adding one + # just to reach a single package would be indirection for its own sake. + - name: Game logic tests + run: pnpm --filter @repo/game test + + build: + name: Build + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + + - run: pnpm install --frozen-lockfile + + # Compiles every app and package exactly as the Dockerfiles do. Catches + # the class of failure that only appears in a real build — a bad import + # path, a Next.js page that throws while being prerendered — before the + # deploy workflow spends ten minutes discovering it inside Docker. + # + # NEXT_PUBLIC_* are inlined into the client bundle at build time. The + # placeholders here are never shipped: the deploy workflow rebuilds the + # web image with the real public origins as build args. + - name: Build all packages + env: + NEXT_PUBLIC_API_URL: http://localhost:4001 + NEXT_PUBLIC_WS_URL: ws://localhost:4002/ws + run: pnpm build diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..abecb5f --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,342 @@ +############################################################################### +# Deploy — build images, push to Docker Hub, roll them out on the VPS. +# +# WHY IMAGES ARE BUILT HERE AND NOT ON THE SERVER +# ----------------------------------------------- +# A `docker compose build` on the VPS competes with the running stack for CPU +# and memory, and a small droplet will OOM partway through a Next.js build and +# leave the site down. Building on a runner and shipping finished images keeps +# the deploy step to a pull and a restart — seconds, not minutes. +# +# It also makes rollback trivial: every deploy is tagged with its commit SHA, +# so going back is re-running this workflow against an older tag rather than +# reverting code and rebuilding. +# +# TRIGGERS +# push to main build and deploy automatically +# manual dispatch deploy any ref, or build without deploying +# +# REQUIRED REPOSITORY SECRETS (Settings -> Secrets -> Actions) +# DOCKERHUB_USERNAME your Docker Hub account — `codeheist` +# DOCKERHUB_TOKEN an ACCESS TOKEN, not your password. Create one at +# hub.docker.com -> Account Settings -> Personal access +# tokens, scope "Read & Write". A token can be revoked +# on its own; your password cannot without locking you +# out of the account. +# VPS_HOST server hostname or IP +# VPS_USER ssh user (needs docker permission) +# VPS_SSH_KEY private key, full PEM including header/footer lines +# VPS_APP_DIR absolute path to the checkout on the server +# NEXT_PUBLIC_API_URL public API origin, e.g. https://api.spidder.example.com +# NEXT_PUBLIC_WS_URL public WS origin, e.g. wss://api.spidder.example.com/ws +# +# Optional: +# VPS_SSH_PORT defaults to 22 +# +# The five images are published as: +# codeheist/spidder-{http-api,ws-server,judge-worker,web,db-init} +# +# DOCKERHUB_USERNAME supplies that namespace when PUSHING here; the compose +# file on the server writes `codeheist` literally when PULLING. Change one and +# you must change the other, or CI pushes somewhere the server never looks. +# +# Docker Hub's free tier gives ONE private repository. Five private images +# means a paid plan; otherwise create the five repos as public. They contain +# only compiled application code — no secrets, since every credential is +# injected at runtime from the server's .env. The one exception is the `web` +# image: NEXT_PUBLIC_* are compiled into its browser bundle, and those are +# public URLs by definition, visible in any visitor's devtools anyway. +############################################################################### + +name: Deploy + +on: + push: + branches: [main] + workflow_dispatch: + inputs: + skip_deploy: + description: "Build and push images, but do not touch the server" + type: boolean + default: false + +# One deploy at a time. Without this, two merges a minute apart race each other +# onto the same server and the one that finishes second wins — which may be the +# older commit. +# +# `cancel-in-progress: false` matters here: cancelling mid-rollout could leave +# the stack half-restarted. Better to queue and let the first finish. +concurrency: + group: deploy-production + cancel-in-progress: false + +env: + PNPM_VERSION: 10.19.0 + NODE_VERSION: 22 +# No REGISTRY here. Docker Hub is the implicit default for both `docker login` +# and an unprefixed `user/name` tag, so naming `docker.io` explicitly would be +# an unused variable that looks load-bearing. The namespace — the part that +# actually varies — comes from the DOCKERHUB_USERNAME secret. + +jobs: + # --------------------------------------------------------------------------- + # Re-verify before shipping. + # + # CI already ran on this commit, but that is not the same guarantee: a merge + # queue, a force-push, or a manual dispatch of an arbitrary ref can all reach + # this workflow with a commit CI never saw. This is cheap; a broken deploy is + # not. + # --------------------------------------------------------------------------- + verify: + name: Verify + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + + - run: pnpm install --frozen-lockfile + + # Generates the Prisma client, which the typecheck depends on. + - name: Generate Prisma client + run: pnpm --filter @repo/db build + + - name: Typecheck + run: pnpm check-types + + - name: Lint + run: pnpm lint + + - name: Game logic tests + run: pnpm --filter @repo/game test + + # --------------------------------------------------------------------------- + # Build the five images in parallel and push them to Docker Hub. + # + # A matrix rather than five copies of the same block: the only thing that + # differs is the Dockerfile path and the image name. + # --------------------------------------------------------------------------- + build: + name: Build ${{ matrix.name }} + needs: verify + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + # Only the checkout needs a token. Docker Hub authenticates with its own + # access token secret, so `packages: write` — which exists solely to let + # GITHUB_TOKEN push to GHCR — would be an unused grant. + contents: read + strategy: + # One image failing should not cancel the other four — seeing every + # failure in one run beats fixing them one deploy at a time. + fail-fast: false + matrix: + include: + - name: http-api + dockerfile: apps/http-api/Dockerfile.prod + - name: ws-server + dockerfile: apps/ws-server/Dockerfile.prod + - name: judge-worker + dockerfile: apps/judge-worker/Dockerfile.prod + - name: web + dockerfile: apps/web/Dockerfile.prod + - name: db-init + dockerfile: docker/db-init/Dockerfile + steps: + - uses: actions/checkout@v4 + + # Docker Hub namespaces images under the ACCOUNT name, which has nothing + # to do with the GitHub org — so it comes from the secret rather than + # being derived from `github.repository_owner`. + # + # Lowercased because Docker Hub stores account names lowercase while + # people type them capitalised. The local `docker tag` would accept the + # capitals happily; the push then targets a namespace that does not + # exist, and the error arrives as an access-denied deep into the run. + - name: Compute image namespace + id: img + run: | + ns=$(echo '${{ secrets.DOCKERHUB_USERNAME }}' | tr '[:upper:]' '[:lower:]') + if [ -z "$ns" ]; then + echo "DOCKERHUB_USERNAME is not set — add it under Settings → Secrets → Actions." >&2 + exit 1 + fi + echo "ns=$ns" >> "$GITHUB_OUTPUT" + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + # No `registry:` key — docker/login-action defaults to Docker Hub, + # and naming it explicitly is the documented way to get this wrong. + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + # The monorepo ROOT is the build context — every Dockerfile.prod runs + # `turbo prune` and needs the whole workspace to do it. + context: . + file: ${{ matrix.dockerfile }} + push: true + # Docker Hub images carry no registry host: `user/name` IS a Hub + # reference. Prefixing `docker.io/` would work but would make the + # server's `docker images` output disagree with the compose file. + tags: | + ${{ steps.img.outputs.ns }}/spidder-${{ matrix.name }}:${{ github.sha }} + ${{ steps.img.outputs.ns }}/spidder-${{ matrix.name }}:latest + # Layer cache in the registry, so a deploy that only changed one app + # reuses the pnpm install and build layers of the previous one. + cache-from: type=gha,scope=${{ matrix.name }} + cache-to: type=gha,mode=max,scope=${{ matrix.name }} + # NEXT_PUBLIC_* are inlined into the CLIENT bundle at build time, so + # the real public origins have to be present HERE. Passing them at + # runtime would leave the browser calling localhost in production. + # Harmless for the other four images, which ignore them. + build-args: | + NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }} + NEXT_PUBLIC_WS_URL=${{ secrets.NEXT_PUBLIC_WS_URL }} + + # --------------------------------------------------------------------------- + # Roll out on the VPS: pull the new images, recreate the containers. + # --------------------------------------------------------------------------- + deploy: + name: Deploy to VPS + needs: build + if: ${{ github.event_name == 'push' || !inputs.skip_deploy }} + runs-on: ubuntu-latest + timeout-minutes: 15 + # No `environment:` here. GitHub would create one on first use, but an + # environment earns its keep only once it carries something — a required + # reviewer, or its own secrets. To gate deploys behind manual approval, + # create an Environment named `production` (Settings → Environments), add + # yourself as a required reviewer, then add `environment: production` back + # on this job. + steps: + - name: Open an SSH session + uses: webfactory/ssh-agent@v0.9.0 + with: + ssh-private-key: ${{ secrets.VPS_SSH_KEY }} + + - name: Trust the host key + env: + VPS_HOST: ${{ secrets.VPS_HOST }} + VPS_SSH_PORT: ${{ secrets.VPS_SSH_PORT || '22' }} + run: | + mkdir -p ~/.ssh && chmod 700 ~/.ssh + ssh-keyscan -p "$VPS_SSH_PORT" -H "$VPS_HOST" >> ~/.ssh/known_hosts 2>/dev/null + chmod 600 ~/.ssh/known_hosts + + # The rollout itself. + # + # `set -euo pipefail` inside the remote shell is what makes this safe: a + # failed pull must abort the script, not fall through to a `compose up` + # that silently restarts the OLD images and reports success. + - name: Pull and restart + env: + VPS_HOST: ${{ secrets.VPS_HOST }} + VPS_USER: ${{ secrets.VPS_USER }} + VPS_SSH_PORT: ${{ secrets.VPS_SSH_PORT || '22' }} + VPS_APP_DIR: ${{ secrets.VPS_APP_DIR }} + IMAGE_TAG: ${{ github.sha }} + HUB_USER: ${{ secrets.DOCKERHUB_USERNAME }} + HUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + run: | + ssh -p "$VPS_SSH_PORT" "$VPS_USER@$VPS_HOST" \ + IMAGE_TAG="$IMAGE_TAG" \ + APP_DIR="$VPS_APP_DIR" \ + HUB_USER="$HUB_USER" \ + HUB_TOKEN="$HUB_TOKEN" \ + 'bash -euo pipefail -s' <<'REMOTE' + cd "$APP_DIR" + + # Logging in even for public images is deliberate. Anonymous pulls + # from Docker Hub are rate-limited per IP, and a VPS sharing an + # address range with other pullers can hit that ceiling mid-deploy — + # the failure is a 429 on `compose pull`, which reads like an outage. + # An authenticated pull gets the account's own, far higher limit. + echo "::group::Authenticating to Docker Hub" + echo "$HUB_TOKEN" | docker login -u "$HUB_USER" --password-stdin + echo "::endgroup::" + + # The compose file itself is read from the server's checkout, so keep + # it current. --ff-only refuses to deploy from a diverged working + # tree rather than silently merging on a production box. + echo "::group::Syncing deployment files" + git fetch --depth=1 origin main + git checkout main + git merge --ff-only origin/main + echo "::endgroup::" + + # Substituted into docker-compose.prod.yml, pinning this rollout to + # the exact images built from this commit. The Docker Hub account is + # written literally in that file, so only the tag travels. + export IMAGE_TAG + + echo "::group::Pulling $IMAGE_TAG" + docker compose -f docker-compose.prod.yml pull + echo "::endgroup::" + + echo "::group::Applying schema" + # Runs to completion before any app starts. Idempotent, so a deploy + # with no schema change is a no-op rather than a risk. + docker compose -f docker-compose.prod.yml up --no-deps --exit-code-from db-init db-init + echo "::endgroup::" + + echo "::group::Restarting services" + docker compose -f docker-compose.prod.yml up -d --remove-orphans + echo "::endgroup::" + + # Reclaim disk from superseded images. A VPS with a small disk will + # otherwise fill up after a few dozen deploys and fail the next pull + # with a confusing "no space left on device". + docker image prune -f --filter "until=168h" + REMOTE + + # Proves the rollout actually worked. Without this the job goes green as + # long as `compose up` returned 0 — which it does even when a container + # starts, crashes on a bad env var, and enters a restart loop. + - name: Verify the stack is healthy + env: + VPS_HOST: ${{ secrets.VPS_HOST }} + VPS_USER: ${{ secrets.VPS_USER }} + VPS_SSH_PORT: ${{ secrets.VPS_SSH_PORT || '22' }} + VPS_APP_DIR: ${{ secrets.VPS_APP_DIR }} + run: | + ssh -p "$VPS_SSH_PORT" "$VPS_USER@$VPS_HOST" \ + APP_DIR="$VPS_APP_DIR" 'bash -euo pipefail -s' <<'REMOTE' + cd "$APP_DIR" + + # Give the containers their start-period before judging them. + for i in $(seq 1 30); do + unhealthy=$(docker compose -f docker-compose.prod.yml ps \ + --format '{{.Service}} {{.State}} {{.Health}}' \ + | awk '$2 == "running" && $3 != "" && $3 != "healthy"' | wc -l) + [ "$unhealthy" -eq 0 ] && break + sleep 5 + done + + echo "--- final state ---" + docker compose -f docker-compose.prod.yml ps + + # Anything running but not healthy fails the deploy loudly. + bad=$(docker compose -f docker-compose.prod.yml ps \ + --format '{{.Service}} {{.State}} {{.Health}}' \ + | awk '$2 == "running" && $3 != "" && $3 != "healthy"' || true) + if [ -n "$bad" ]; then + echo "UNHEALTHY after rollout:" + echo "$bad" + docker compose -f docker-compose.prod.yml logs --tail=60 + exit 1 + fi + echo "All services healthy." + REMOTE diff --git a/DEPLOY-WALKTHROUGH.md b/DEPLOY-WALKTHROUGH.md new file mode 100644 index 0000000..6932fd5 --- /dev/null +++ b/DEPLOY-WALKTHROUGH.md @@ -0,0 +1,921 @@ +# Spidder — deployment walkthrough (user: `spidder`) + +A start-to-finish run for a VPS that **already has Docker, Postgres and Redis +installed**. You will not reinstall any of them. What follows creates the +`spidder` user, gives it the access it needs, points the app at the databases you +already have, and puts nginx in front. + +[DEPLOYMENT.md](DEPLOYMENT.md) is the reference version of all of this — read +it when you want the reasoning. This file is the sequence. + +Throughout, replace: + +| Placeholder | With | +| --- | --- | +| `spidder.example.com` | the site hostname | +| `api.spidder.example.com` | the API hostname | +| `your.vps.ip` | the server's address | + +--- + +## Stage 0 — What you already have + +Run these **as your current user** before changing anything. They tell you what +work is genuinely left. + +```bash +# Is Postgres reachable from a container? This is the one that usually fails. +sudo -u postgres psql -c 'SHOW listen_addresses;' + +# Redis: bound where, and does it need a password? +sudo grep -E '^\s*(bind|requirepass|protected-mode)' /etc/redis/redis.conf + +# Does the app's database already exist? +sudo -u postgres psql -c '\l' | grep -i spidder || echo "spidder: NOT created yet" + +# What is the docker bridge address? Every config below refers to it. +ip -4 addr show docker0 | awk '/inet /{print "docker bridge:", $2}' +``` + +Keep the bridge address (usually `172.17.0.1`) — you need it in stages 2 and 3. + +If `listen_addresses` says only `localhost`, **stage 2 is required**. Postgres +being installed is not the same as Postgres being reachable from a container, +and this is the single most common reason a first deploy fails. + +--- + +## Stage 1 — Create the `spidder` user + +As root, or any user with sudo: + +```bash +sudo adduser --disabled-password --gecos "" spidder +``` + +No password: you will reach this account by SSH key only, which is what an +account CI drives should be. + +### Groups + +```bash +sudo usermod -aG sudo spidder # for setup; not needed for deploys +sudo usermod -aG docker spidder # this is the one that matters +``` + +You are **not installing Docker again**. The daemon is already running as root; +the `docker` group is simply the key to its socket at `/var/run/docker.sock`. +Adding `spidder` to that group is the whole of "giving `spidder` Docker access". + +> `docker` group membership is effectively root — a member can mount `/` into a +> container and edit anything. That is normal for a deploy account and it is +> what CI needs, but treat the `spidder` SSH key as a root-equivalent credential. + +### SSH key + +On **your laptop**: + +```bash +ssh-keygen -t ed25519 -C "spidder-deploy" -f ~/.ssh/spidder_deploy -N "" +ssh-copy-id -i ~/.ssh/spidder_deploy.pub spidder@your.vps.ip +``` + +Then verify — this must pass before you go further: + +```bash +ssh -i ~/.ssh/spidder_deploy spidder@your.vps.ip 'id' +``` + +You should see `docker` in the group list. If you don't, the group was added +after the session started; reconnect and check again. + +Finally, confirm Docker works **without sudo** as `spidder`: + +```bash +ssh -i ~/.ssh/spidder_deploy spidder@your.vps.ip 'docker ps' +``` + +A "permission denied on /var/run/docker.sock" here means the group has not +taken effect in that session. Log out fully and back in. + +--- + +## Stage 2 — Let containers reach your Postgres + +Your containers talk to the host database through `host.docker.internal`, which +resolves to the docker bridge. Postgres must be listening there, and must +accept connections from the bridge subnet. + +### Create the role and database + +Skip whichever of these you already did. + +```bash +sudo -u postgres psql +``` + +```sql +CREATE ROLE spidder WITH LOGIN PASSWORD 'PUT_A_LONG_RANDOM_PASSWORD_HERE'; +CREATE DATABASE spidder OWNER spidder; +\q +``` + +Generate that password rather than inventing one: `openssl rand -hex 24`. + +### Listen on the bridge + +```bash +sudo -u postgres psql -c 'SHOW config_file;' # find the exact path +sudo nano /etc/postgresql/16/main/postgresql.conf +``` + +```conf +listen_addresses = 'localhost,172.17.0.1' +``` + +### Allow the bridge subnet + +```bash +sudo nano /etc/postgresql/16/main/pg_hba.conf +``` + +Add this line. It is `172.16.0.0/12`, not the single gateway address, because a +container's own IP is assigned per-network and will not be `172.17.0.1`: + +```conf +host spidder spidder 172.16.0.0/12 scram-sha-256 +``` + +```bash +sudo systemctl restart postgresql +``` + +### Prove it works + +This is the only test that means anything — it runs from inside a container, +exactly like the app will: + +```bash +docker run --rm --add-host=host.docker.internal:host-gateway alpine:3.20 \ + sh -c 'nc -z -w3 host.docker.internal 5432 && echo CONNECTED || echo REFUSED' +``` + +`REFUSED` means `listen_addresses` has not taken effect. Do not continue until +this prints `CONNECTED`. + +--- + +## Stage 3 — Same for Redis + +```bash +sudo nano /etc/redis/redis.conf +``` + +```conf +bind 127.0.0.1 172.17.0.1 +requirepass PUT_ANOTHER_LONG_RANDOM_PASSWORD_HERE +protected-mode yes +appendonly yes +``` + +```bash +sudo systemctl restart redis-server +``` + +> **Do set the password.** A Redis on the bridge with no password is reachable +> by anything that gets a shell in any container — including the sandbox whose +> entire job is running strangers' code. + +Prove it: + +```bash +docker run --rm --add-host=host.docker.internal:host-gateway alpine:3.20 \ + sh -c 'nc -z -w3 host.docker.internal 6379 && echo CONNECTED || echo REFUSED' +``` + +--- + +## Stage 4 — Firewall + +Only SSH and web need to be open. The app ports are published to `127.0.0.1` +and reached by nginx locally; the database ports are reached over the docker +bridge, which `ufw` does not filter. + +```bash +sudo ufw default deny incoming +sudo ufw default allow outgoing +sudo ufw allow OpenSSH +sudo ufw allow 'Nginx Full' +sudo ufw enable +sudo ufw status verbose +``` + +From your **laptop**, these must all hang and time out: + +```bash +nc -zv your.vps.ip 5432 +nc -zv your.vps.ip 6379 +nc -zv your.vps.ip 4001 +``` + +If any connects, stop and fix it before the app is live. + +--- + +## Stage 5 — Get the code, as `spidder` + +```bash +ssh -i ~/.ssh/spidder_deploy spidder@your.vps.ip +``` + +Clone into the `spidder` user's own home directory. No `sudo`, no `chown` — the +user already owns it, which is the whole point of running deploys as `spidder`: + +```bash +cd ~ +git clone https://github.com/TheCodeHeist-Coder/Spidder.git app +cd ~/app +pwd # /home/spidder/app — note this, you need it in stage 11 +``` + +Docker does not care where the compose file lives. The one path-sensitive thing +in it is a bind mount for the Piston provisioning script, and that path is +relative to the compose file, so it travels with the directory. + +Tighten the home directory while you are here. Some distros create it `755`, +which would let any other account on the box read your `.env` — and that file +holds the database password and the JWT secret: + +```bash +chmod 750 /home/spidder +``` + +--- + +## Stage 6 — Write `.env` + +```bash +cp .env.prod.example .env +chmod 600 .env +nano .env +``` + +`chmod 600` matters: this file holds your database password and JWT secret. + +Generate the JWT secret now: + +```bash +openssl rand -hex 32 +``` + +Six values are **required** — compose refuses to start without them: + +```bash +# Note host.docker.internal, NOT localhost. Inside a container, localhost is +# the container itself, and the connection is refused. +DATABASE_URL="postgresql://spidder:YOUR_PG_PASSWORD@host.docker.internal:5432/spidder?schema=public" +REDIS_URL="redis://:YOUR_REDIS_PASSWORD@host.docker.internal:6379" + +JWT_SECRET="paste-the-openssl-output-here" + +# The SITE origin — the origin a browser sends. Not the API's own hostname. +CORS_ORIGINS="https://spidder.example.com" + +# What the BROWSER calls. Compiled into the JS bundle at image build time. +NEXT_PUBLIC_API_URL="https://api.spidder.example.com" +NEXT_PUBLIC_WS_URL="wss://api.spidder.example.com/ws" +``` + +Note the Redis URL shape: `redis://:password@host` — the colon before the +password is not a typo. Redis has no username, so the field is left empty. + +Optional, worth setting on the first deploy so you have an admin account: + +```bash +SUPER_ADMIN_EMAIL="you@example.com" +SUPER_ADMIN_PASSWORD="a-strong-password" +SUPER_ADMIN_USERNAME="admin" +``` + +Remove those three and restart once the account exists, so the credentials stop +sitting in a file on disk. + +### Check it before starting anything + +```bash +docker compose -f docker-compose.prod.yml config >/dev/null && echo "env OK" +``` + +A missing required value names itself here, in a second, instead of halfway +through a rollout. + +--- + +## Stage 7 — First start + +```bash +cd ~/app +docker compose -f docker-compose.prod.yml up -d +``` + +The first run pulls five images, installs Python into the Piston sandbox, then +applies the database schema and seeds the problems. Give it a few minutes. + +Watch it happen: + +```bash +docker compose -f docker-compose.prod.yml logs -f +``` + +Then confirm the shape is right: + +```bash +docker compose -f docker-compose.prod.yml ps +``` + +You are looking for: + +| Service | Expected | +| --- | --- | +| `http-api` | Up (healthy) | +| `ws-server` | Up (healthy) | +| `web` | Up (healthy) | +| `judge-worker` | Up (healthy) | +| `piston` | Up (healthy) | +| `db-init` | **Exited (0)** | +| `piston-init` | **Exited (0)** | + +The two `Exited (0)` are correct, not failures — they are one-shot setup jobs +that run and finish. Any other exit code is a real problem; read its log: + +```bash +docker compose -f docker-compose.prod.yml logs db-init +``` + +### Prove the stack works before adding nginx + +```bash +curl -s localhost:4001/health # {"ok":true} +curl -s localhost:4002/health # {"ok":true} +curl -sI localhost:3001 | head -1 # HTTP/1.1 200 OK +``` + +All three must pass. If they do, the application is fine and anything that +breaks next is nginx or DNS — which is a much smaller haystack. + +--- + +## Stage 8 — DNS + +Point both names at the server, and wait for them to resolve before requesting +certificates. Certbot proves control by answering a challenge on these names. + +``` +A spidder.example.com -> your.vps.ip +A api.spidder.example.com -> your.vps.ip +``` + +```bash +dig +short spidder.example.com +dig +short api.spidder.example.com +``` + +Both must print your server's IP before stage 10. + +--- + +## Stage 9 — nginx, in depth + +nginx is the only way in. The containers publish to `127.0.0.1` only, so +without this nothing is reachable from outside the machine at all. + +Needs sudo — nginx is a host service, not a container. + +### How nginx decides where a request goes + +Two rules, and almost every routing surprise comes from misunderstanding one of +them. + +**First it picks a `server` block, by `Host` header.** Two hostnames point at +this one IP, so nginx reads the `Host:` header the browser sent and matches it +against `server_name`. That is why the site and the API can share port 443 with +no conflict. + +**Then, inside that block, it picks a `location`** — and *not* in file order. +The rules, in priority: + +| Syntax | Meaning | Priority | +| --- | --- | --- | +| `location = /path` | exact match | highest — wins immediately | +| `location ^~ /path` | prefix, stop searching regexes | second | +| `location ~ /re` | regex, first match in file order | third | +| `location /path` | prefix — **longest match wins** | lowest | + +The last row is the one that catches people. `location /ws` and `location /` +are both prefix matches; `/ws` wins for a request to `/ws` because it is +*longer*, not because it appears first. Writing it first is still worth doing — +it makes the intent obvious to the next reader — but the ordering is not what +makes it work. + +### The upgrade map + +Must live at `http` level, not inside a `server`, so it goes in its own file: + +```bash +sudo nano /etc/nginx/conf.d/upgrade.conf +``` + +```nginx +# Maps the request's Upgrade header to the right Connection response header. +# +# Why not just write `proxy_set_header Connection "upgrade"` in the /ws block? +# Because that block also serves the plain HTTP requests that share the same +# connection, and telling nginx every one of them is an upgrade breaks +# keep-alive. This sends "upgrade" only when the client actually asked for one. +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} +``` + +### Shared proxy headers + +Both blocks set the same four headers. Put them in one file and `include` it, +so they cannot drift apart: + +```bash +sudo nano /etc/nginx/conf.d/proxy-common.conf +``` + +```nginx +# Included by every location that proxies to a container. +# +# Without these the app sees every request as coming from 127.0.0.1 (nginx +# itself) on http — so rate limiting by IP would lump all users together, and +# any absolute URL the app generates would come out as http://. +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 X-Forwarded-Host $host; +``` + +> `conf.d/*.conf` is included at `http` level, so this file is parsed once and +> the directives are available to every server block. `include` inside a +> `location` then pulls them in where needed. + +### Rate limiting + +Define the zones at `http` level. They are *defined* here and *applied* per +location, which lets one zone protect several endpoints: + +```bash +sudo nano /etc/nginx/conf.d/limits.conf +``` + +```nginx +# 10 MB of shared memory tracks roughly 160,000 addresses — far more than a +# single VPS will ever see at once. +# +# Two zones because the traffic shapes differ. Normal API browsing is bursty +# and harmless; auth endpoints are where credential stuffing lands, and there a +# human never needs more than a few attempts a minute. +limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s; +limit_req_zone $binary_remote_addr zone=auth:10m rate=5r/m; + +# Cap concurrent connections per address. A WebSocket holds one open for the +# length of a battle, so this must be generous enough for several tabs. +limit_conn_zone $binary_remote_addr zone=conn:10m; + +# Return 429 rather than nginx's default 503 — "too many requests" is the +# honest status, and clients back off correctly on it. +limit_req_status 429; +limit_conn_status 429; +``` + +### The site block + +```bash +sudo nano /etc/nginx/sites-available/spidder +``` + +```nginx +server { + listen 80; + listen [::]:80; + server_name spidder.example.com; + + # Hide the nginx version from error pages and the Server header. + server_tokens off; + + # Security headers. These apply to the HTML the site serves. + # + # No Content-Security-Policy here: Next.js injects inline scripts for + # hydration, and a CSP tight enough to be worth having needs nonces wired + # through the app. A half-CSP with 'unsafe-inline' would be theatre. + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # Uploads are avatars at most. + client_max_body_size 2m; + + # Next's immutable build assets: hashed filenames, safe to cache forever. + # `^~` stops nginx evaluating regex locations for these, which is a small + # win on the highest-volume path on the site. + location ^~ /_next/static/ { + proxy_pass http://127.0.0.1:3001; + include /etc/nginx/conf.d/proxy-common.conf; + + add_header Cache-Control "public, max-age=31536000, immutable"; + access_log off; + } + + # Everything else: the Next.js server. + location / { + proxy_pass http://127.0.0.1:3001; + proxy_http_version 1.1; + include /etc/nginx/conf.d/proxy-common.conf; + + # Next streams server components. Buffering holds the whole response + # until it completes, which delays first paint for no benefit. + proxy_buffering off; + + # If the container is restarting mid-deploy, wait rather than 502. + proxy_connect_timeout 5s; + proxy_read_timeout 60s; + } +} +``` + +### The API block + +This one carries the WebSocket, the auth rate limit, and the deny rule for the +unauthenticated internal endpoint. + +```bash +sudo nano /etc/nginx/sites-available/spidder-api +``` + +```nginx +server { + listen 80; + listen [::]:80; + server_name api.spidder.example.com; + + server_tokens off; + client_max_body_size 2m; + + # Concurrent connections per IP. Generous because each open battle holds a + # WebSocket, and someone may legitimately have two or three tabs. + limit_conn conn 20; + + # --- realtime: ws-server ------------------------------------------------- + location /ws { + proxy_pass http://127.0.0.1:4002; + proxy_http_version 1.1; + + # The two headers that make an upgrade happen. $connection_upgrade + # comes from the map in conf.d/upgrade.conf. + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + include /etc/nginx/conf.d/proxy-common.conf; + + # A battle runs for minutes and the socket is idle between moves. + # nginx's 60s default would cut it mid-match, and the client would + # reconnect into a battle already in progress. + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + + # Realtime frames must not wait for a buffer to fill. + proxy_buffering off; + + # NOT rate limited. A WebSocket is one request that lives for the whole + # battle; limiting it by request rate would do nothing useful, and + # limit_conn above already caps how many a single address can hold. + } + + # --- ws-server health, for uptime monitoring ----------------------------- + # `=` is an exact match, so this cannot accidentally shadow anything. + location = /ws-health { + proxy_pass http://127.0.0.1:4002/health; + include /etc/nginx/conf.d/proxy-common.conf; + access_log off; + } + + # --- BLOCKED: unauthenticated internal endpoint -------------------------- + # ws-server exposes /internal/stats with NO auth — by design, because it is + # meant to be reached only by http-api over the Docker network. See + # apps/ws-server/src/transport/httpApp.ts. + # + # `^~` matters here: it stops nginx searching regex locations, so no regex + # added later can ever take precedence and re-expose this. + location ^~ /internal/ { + return 404; + } + + # --- auth: the tight rate limit ------------------------------------------ + # 5 requests/minute per IP, burst 10. Credential stuffing is the attack + # this blunts; a real person signing in never comes close to the limit. + # + # `nodelay` lets the burst through immediately rather than queuing it — + # a legitimate retry should feel instant, only sustained abuse should stall. + location /auth/ { + limit_req zone=auth burst=10 nodelay; + + proxy_pass http://127.0.0.1:4001; + proxy_http_version 1.1; + include /etc/nginx/conf.d/proxy-common.conf; + proxy_read_timeout 60s; + } + + # --- everything else: http-api ------------------------------------------- + location / { + limit_req zone=api burst=60 nodelay; + + proxy_pass http://127.0.0.1:4001; + proxy_http_version 1.1; + include /etc/nginx/conf.d/proxy-common.conf; + + proxy_connect_timeout 5s; + proxy_read_timeout 60s; + } +} +``` + +> **On CORS.** Do not add `add_header Access-Control-Allow-Origin` here. The +> API already handles CORS itself from `CORS_ORIGINS`, and a second set of +> headers from nginx produces *duplicate* headers, which browsers reject +> outright — the symptom is every cross-origin request failing with a CORS +> error that looks like the app is misconfigured. + +### Enable and test + +```bash +sudo ln -s /etc/nginx/sites-available/spidder /etc/nginx/sites-enabled/ +sudo ln -s /etc/nginx/sites-available/spidder-api /etc/nginx/sites-enabled/ + +# The default block answers for any Host that matches nothing else. Left in +# place it will serve the nginx welcome page to anyone hitting your bare IP. +sudo rm -f /etc/nginx/sites-enabled/default + +sudo nginx -t +``` + +`nginx -t` is not optional. A reload with a broken config leaves the old one +running — but a *restart* would fail and take the site down. + +```bash +sudo systemctl reload nginx +``` + +### Check the routing before adding TLS + +Over plain HTTP first, so a certificate problem cannot be confused with a +routing problem. `--resolve` forces the hostname at your server without +needing DNS to have propagated yet: + +```bash +IP=your.vps.ip + +curl -s --resolve spidder.example.com:80:$IP \ + -o /dev/null -w 'site: %{http_code}\n' http://spidder.example.com/ + +curl -s --resolve api.spidder.example.com:80:$IP \ + http://api.spidder.example.com/health && echo + +curl -s --resolve api.spidder.example.com:80:$IP \ + http://api.spidder.example.com/ws-health && echo + +# MUST be 404. +curl -s --resolve api.spidder.example.com:80:$IP \ + -o /dev/null -w 'internal: %{http_code} (must be 404)\n' \ + http://api.spidder.example.com/internal/stats +``` + +### Useful while debugging + +```bash +# The whole effective config, every include expanded. Answers "is my map +# actually loaded" definitively. +sudo nginx -T | less + +# Confirm the upgrade map is present. +sudo nginx -T | grep -A3 'map $http_upgrade' + +# Which server block answered, and what it did. +sudo tail -f /var/log/nginx/access.log +sudo tail -f /var/log/nginx/error.log +``` + +An `upstream connect failed` in the error log means nginx is fine and the +container is not — check `docker compose ps`. + +--- + +## Stage 10 — HTTPS + +```bash +sudo certbot --nginx \ + -d spidder.example.com \ + -d api.spidder.example.com \ + --agree-tos -m you@example.com --redirect +``` + +Certbot edits both server blocks in place, adding `listen 443 ssl`, the +certificate paths, and an HTTP→HTTPS redirect. Renewal is a systemd timer: + +```bash +sudo certbot renew --dry-run +systemctl list-timers | grep certbot +``` + +### Verify the whole path + +```bash +curl -sI https://spidder.example.com | head -1 # 200 +curl -s https://api.spidder.example.com/health # {"ok":true} +curl -s https://api.spidder.example.com/ws-health # {"ok":true} + +# MUST be 404. A 200 means the deny block is missing or mis-ordered. +curl -s -o /dev/null -w '%{http_code}\n' \ + https://api.spidder.example.com/internal/stats + +# The WebSocket. 101 Switching Protocols means the handshake works. +curl -i -s -N \ + -H "Connection: Upgrade" -H "Upgrade: websocket" \ + -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \ + -H "Sec-WebSocket-Version: 13" \ + https://api.spidder.example.com/ws | head -1 +``` + +That `101` is the single most important check here. A `200` instead means the +request fell through to http-api — the `/ws` block is missing or ordered after +`location /`. + +Then open the site in a browser, sign in, and start a battle. If the timer +runs and a submission gets judged, the whole path works. + +--- + +## Stage 11 — CI/CD + +Add these under **Settings → Secrets and variables → Actions**: + +| Secret | Value | +| --- | --- | +| `DOCKERHUB_USERNAME` | `codeheist` | +| `DOCKERHUB_TOKEN` | access token from hub.docker.com → Account Settings → Personal access tokens (Read & Write) | +| `VPS_HOST` | `your.vps.ip` | +| `VPS_USER` | `spidder` | +| `VPS_SSH_KEY` | the **whole** contents of `~/.ssh/spidder_deploy` (private key) | +| `VPS_APP_DIR` | `/home/spidder/app` — **absolute**, not `~/app` | +| `NEXT_PUBLIC_API_URL` | `https://api.spidder.example.com` | +| `NEXT_PUBLIC_WS_URL` | `wss://api.spidder.example.com/ws` | + +```bash +cat ~/.ssh/spidder_deploy # copy everything, BEGIN and END lines included +``` + +Note `VPS_USER` is `spidder`, and `NEXT_PUBLIC_*` must match what you put in +`.env` — they are compiled into the browser bundle at image build time, so a +mismatch means the site calls the wrong host and only a rebuild fixes it. + +### First run + +Push to `main`, or use **Actions → Deploy → Run workflow**. It will: + +1. re-run lint, typecheck and tests +2. build five images and push them to Docker Hub +3. SSH in as `spidder`, pull, apply the schema, restart + +On Docker Hub, the free tier allows **one private repository**. Five private +images needs a paid plan — otherwise make the five repos public. They hold only +compiled application code; every credential is injected at runtime from `.env`. + +### Prove the pipeline, not just the app + +Change something visible, push, and confirm the running image actually moved: + +```bash +docker compose -f docker-compose.prod.yml images | grep web +``` + +The tag should be the new commit SHA. + +--- + +## Day-to-day + +As `spidder`, no sudo: + +```bash +cd ~/app +alias dc='docker compose -f docker-compose.prod.yml' + +dc ps # status and health +dc logs -f --tail=100 # everything +dc logs -f judge-worker # one service +dc restart http-api +``` + +Host services need sudo: + +```bash +sudo systemctl status nginx postgresql redis-server +sudo nginx -t && sudo systemctl reload nginx +sudo tail -f /var/log/nginx/error.log +``` + +### Backups + +The database is on the host, so no Docker volume contains it: + +```bash +sudo tee /etc/cron.daily/spidder-backup >/dev/null <<'EOF' +#!/bin/sh +set -eu +mkdir -p /var/backups/spidder +sudo -u postgres pg_dump spidder | gzip \ + > /var/backups/spidder/spidder-$(date +%F).sql.gz +find /var/backups/spidder -name '*.sql.gz' -mtime +14 -delete +EOF +sudo chmod +x /etc/cron.daily/spidder-backup +sudo /etc/cron.daily/spidder-backup && ls -lh /var/backups/spidder +``` + +Run it once by hand as above — a backup script nobody has tested is not a +backup. + +### Rollback + +Re-run **Deploy** on the last good commit from the Actions tab, or pin by hand: + +```bash +cd ~/app +IMAGE_TAG= docker compose -f docker-compose.prod.yml pull +IMAGE_TAG= docker compose -f docker-compose.prod.yml up -d +``` + +This rolls back **code only**. `prisma db push` converges forward, so undoing a +destructive schema change needs the backup above. + +--- + +## When something breaks + +**`docker ps` says permission denied.** The `docker` group has not applied to +this session. Log out completely and back in. + +**Containers restart in a loop.** Read the log — it names the missing thing: + +```bash +docker compose -f docker-compose.prod.yml logs --tail=50 http-api +``` + +**`ECONNREFUSED` to Postgres or Redis.** Almost always `localhost` in `.env` +where it should be `host.docker.internal`, or the host service still listening +on loopback only. Test from inside a container: + +```bash +docker compose -f docker-compose.prod.yml exec http-api \ + node -e "require('net').connect(5432,'host.docker.internal').on('connect',()=>{console.log('ok');process.exit(0)}).on('error',e=>{console.log(e.code);process.exit(1)})" +``` + +**Site loads, but the browser console shows calls to `localhost:4001`.** +`NEXT_PUBLIC_API_URL` was wrong when the image was built. It is compiled into +the bundle — re-run the deploy workflow with the corrected secret. A restart +will not fix it. + +**Battles never start, everything else is fine.** The WebSocket is not +upgrading. Check the `101` from stage 10, then: + +```bash +sudo nginx -T | grep -A3 'map $http_upgrade' +``` + +**Submissions queue but are never judged.** `judge-worker` or `piston` is down, +or Piston has no Python runtime: + +```bash +docker compose -f docker-compose.prod.yml logs judge-worker piston +docker compose -f docker-compose.prod.yml logs piston-init | tail -5 +``` + +**`no space left on device`.** Old images: + +```bash +docker system df +docker image prune -a -f --filter "until=168h" +``` + +--- + +## One gap to know about + +`apps/admin` has **no Dockerfile**, so the separate admin UI is not deployable +yet and nothing above proxies to it. The admin *API* routes live inside +`http-api` and are already served from `api.spidder.example.com`; only the +Next.js dashboard is missing. When it is containerised it needs its own +hostname, its own server block, and ideally an IP allowlist or basic auth at +the nginx layer on top of its login. diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..41bcf41 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,739 @@ +# Deploying Spidder to a VPS + +Postgres, Redis and nginx run **on the host**, managed by systemd. Everything +else — the four app services and the Piston sandbox — runs in Docker, owned by +an unprivileged `deploy` user. Nothing here needs root after setup. + +Two things shape the rest of this guide. Containers cannot reach a host service +by `localhost`, so Postgres and Redis must listen on the Docker bridge and the +firewall must then keep that bridge off the public internet. And the app +containers publish only to `127.0.0.1`, so nginx is the single way in. + +``` + ┌──────────────── VPS ──────────────────┐ + │ │ + browser ──── :443 ──▶ │ nginx (host, systemd) │ + │ │ │ + spidder.example.com ─┼─▶ 127.0.0.1:3001 web │ + │ │ │ + api.spidder.example.com ─┼─▶ 127.0.0.1:4001 http-api │ + /ws │ └─▶ 127.0.0.1:4002 ws-server │ + │ │ │ + │ ┌──────────┴────────────┐ │ + │ ▼ ▼ │ + │ postgres :5432 redis :6379 │ + │ (host, systemd) (host, systemd) │ + │ ▲ ▲ │ + │ └─ host.docker.internal ┘ │ + │ (docker bridge, 172.17.0.1) │ + │ │ + │ judge-worker ──▶ piston (container) │ + │ │ + │ ── docker compose, run as `deploy` ──│ + └───────────────────────────────────────┘ +``` + +--- + +## 1. Create the deploy user + +Everything below runs as a dedicated `deploy` user, never as root. Two reasons +that matter in practice: a mistake in a deploy script cannot touch the rest of +the system, and the SSH key CI holds is scoped to one account you can revoke +without locking yourself out. + +SSH in as root **one last time**: + +```bash +adduser --disabled-password --gecos "" deploy +``` + +`--disabled-password` means the account has no password to guess. You will +reach it by key only, which is what you want for an account CI can drive. + +### Give it sudo, but keep it honest + +```bash +usermod -aG sudo deploy +``` + +The deploy user needs sudo for the *setup* in sections 2–5 — installing +packages, editing Postgres config, managing nginx. It does **not** need sudo +for deploys: once Docker is set up, `docker compose` runs as the user itself. +If you want to lock it down further after setup, remove it from `sudo` and the +CI deploy will still work. + +### Let it use Docker without sudo + +```bash +usermod -aG docker deploy +``` + +This is what makes the CI deploy possible: the workflow SSHes in and runs +`docker compose` directly, with no password prompt to get stuck on. + +> **Be clear-eyed about this.** Membership of the `docker` group is effectively +> root: anyone in it can start a container that mounts `/` and edit anything on +> the host. It is the standard way to run deploys and it is what the CI +> workflow needs, but it means the `deploy` SSH key is a root-equivalent +> credential. Treat it that way — dedicated key, no passphrase reuse, revoke it +> if a laptop goes missing. + +### Set up SSH access + +On **your laptop**, generate a key used for nothing else: + +```bash +ssh-keygen -t ed25519 -C "spidder-deploy" -f ~/.ssh/spidder_deploy -N "" +ssh-copy-id -i ~/.ssh/spidder_deploy.pub deploy@your.vps.ip +``` + +Confirm it works **before** you lock root out: + +```bash +ssh -i ~/.ssh/spidder_deploy deploy@your.vps.ip 'id && sudo -n true && echo "sudo ok"' +``` + +You should see `deploy` in the groups, including `docker`. + +### Close the root door + +Only once the line above succeeds. In `/etc/ssh/sshd_config`: + +```conf +PermitRootLogin no +PasswordAuthentication no +PubkeyAuthentication yes +``` + +```bash +sudo sshd -t # syntax check FIRST — a typo here locks you out +sudo systemctl reload ssh +``` + +Keep your existing session open while you test a *new* one in another +terminal. If the new session fails you still have the old one to fix it with. + +--- + +## 2. Host prerequisites + +Ubuntu 22.04+ or Debian 12+, 2 vCPU / 4 GB RAM minimum. The Next.js build does +not run here — CI builds the images — but Piston plus four Node processes want +headroom. + +As `deploy`: + +```bash +sudo apt update && sudo apt install -y \ + postgresql postgresql-contrib redis-server nginx git ufw \ + certbot python3-certbot-nginx + +# Docker engine + compose plugin +curl -fsSL https://get.docker.com | sudo sh +``` + +You added `deploy` to the `docker` group in section 1, but **group membership +only applies to new sessions**. Log out and back in, then confirm: + +```bash +docker ps # must work with no sudo +``` + +If that still says "permission denied", the session is stale — reconnect. + +--- + +## 3. Postgres + +Create the database and a role that is **not** the superuser: + +```bash +sudo -u postgres psql <<'SQL' +CREATE ROLE spidder WITH LOGIN PASSWORD 'use-a-long-random-password'; +CREATE DATABASE spidder OWNER spidder; +SQL +``` + +Now make it reachable from the containers. Find the bridge address first — +`172.17.0.1` is the common default but not guaranteed: + +```bash +ip -4 addr show docker0 | awk '/inet /{print $2}' # e.g. 172.17.0.1/16 +``` + +Find your config directory (the version number varies): + +```bash +sudo -u postgres psql -c 'SHOW config_file;' +``` + +In `postgresql.conf`: + +```conf +listen_addresses = 'localhost,172.17.0.1' +``` + +In `pg_hba.conf` — the whole private range, because a container's own address +is assigned per-network and will not be the gateway: + +```conf +host spidder spidder 172.16.0.0/12 scram-sha-256 +``` + +```bash +sudo systemctl restart postgresql +``` + +Verify from inside a container, which is the only test that proves the path: + +```bash +docker run --rm --add-host=host.docker.internal:host-gateway alpine:3.20 \ + sh -c 'nc -z -w3 host.docker.internal 5432 && echo CONNECTED || echo REFUSED' +``` + +`REFUSED` means Postgres is still loopback-only — re-check `listen_addresses`. + +--- + +## 4. Redis + +Find the config with `sudo systemctl cat redis-server | grep ExecStart`, then +in `/etc/redis/redis.conf`: + +```conf +bind 127.0.0.1 172.17.0.1 +requirepass use-another-long-random-password +protected-mode yes +appendonly yes +``` + +```bash +sudo systemctl restart redis-server +``` + +> **Set the password.** Leaving Redis open on the bridge means anything that +> gets a shell in any container — including the sandbox that runs submitted +> code — has full read/write access to your job queue. + +--- + +## 5. Firewall + +The bridge is now listening. Close the public door before going further: + +```bash +sudo ufw default deny incoming +sudo ufw default allow outgoing +sudo ufw allow OpenSSH +sudo ufw allow 'Nginx Full' +sudo ufw enable +``` + +Ports 5432 and 6379 are deliberately absent, and so are 3001/4001/4002. The +containers publish those to `127.0.0.1` only and nginx reaches them there; +nothing from outside should. + +Verify from your laptop — all of these should hang and time out: + +```bash +nc -zv your.vps.ip 5432 +nc -zv your.vps.ip 6379 +nc -zv your.vps.ip 4001 +``` + +--- + +## 6. The application + +As `deploy`, in a directory the user owns: + +Clone into the deploy user's own home directory — no `sudo`, no `chown`, since +the user already owns it: + +```bash +cd ~ +git clone https://github.com/TheCodeHeist-Coder/Spidder.git spidder +cd ~/spidder + +cp .env.prod.example .env +chmod 600 .env +$EDITOR .env # fill in every CHANGE_ME +``` + +`chmod 600` matters: that file holds your database password and JWT secret. +Tighten the home directory too — some distros create it `755`, which lets any +other account on the box read it: + +```bash +chmod 750 ~ +``` + +`/opt/spidder` or `/srv/spidder` work equally well if you prefer a conventional +location; they just need `sudo mkdir` and `sudo chown deploy:deploy` first. +Whatever you choose, `VPS_APP_DIR` must match it. + +Generate the secrets rather than inventing them: + +```bash +openssl rand -hex 32 # JWT_SECRET +openssl rand -hex 24 # database / redis passwords +``` + +Then bring it up: + +```bash +docker compose -f docker-compose.prod.yml up -d +docker compose -f docker-compose.prod.yml ps +``` + +`db-init` applies the schema and seeds the problem bank, then exits 0. The app +services wait for it, so the first request never hits an empty database. + +--- + +## 7. nginx and TLS + +The containers publish to `127.0.0.1` only, so nginx is not optional — it is +the only way in. + +Two server blocks: one for the site, one for the API. They are separate +hostnames because the browser bundle is compiled with absolute URLs +(`NEXT_PUBLIC_API_URL`), and keeping the API on its own name means you can move +or scale it later without rebuilding the frontend. + +### DNS first + +Point both names at the VPS before requesting certificates — certbot proves +control by answering an HTTP challenge on them: + +``` +A spidder.example.com -> your.vps.ip +A api.spidder.example.com -> your.vps.ip +``` + +Check it has propagated: `dig +short spidder.example.com`. + +### The site block + +`/etc/nginx/sites-available/spidder`: + +```nginx +server { + listen 80; + listen [::]:80; + server_name spidder.example.com; + + # certbot fills in the TLS config and the redirect below. + location / { + proxy_pass http://127.0.0.1:3001; + 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 X-Forwarded-Host $host; + + # Next.js streams server components; buffering them defeats the point + # and makes the first paint wait for the whole response. + proxy_buffering off; + } + + # Next's immutable build assets. Hashed filenames, so a long cache is safe + # and saves the Node process from serving them at all on repeat visits. + location /_next/static/ { + proxy_pass http://127.0.0.1:3001; + proxy_cache_valid 200 60m; + add_header Cache-Control "public, max-age=31536000, immutable"; + } + + client_max_body_size 2m; +} +``` + +### The API block + +This one carries the WebSocket, and the upgrade headers are the part people +miss — without them battles never start while everything else looks fine. + +`/etc/nginx/sites-available/spidder-api`: + +```nginx +server { + listen 80; + listen [::]:80; + server_name api.spidder.example.com; + + # --- realtime: ws-server ------------------------------------------------- + # Must come before the catch-all `location /`, or the API block below + # swallows it and the upgrade never happens. + location /ws { + proxy_pass http://127.0.0.1:4002; + proxy_http_version 1.1; + + # The three lines that make a WebSocket work. $connection_upgrade is + # defined in the map block below — using a literal "upgrade" here + # breaks ordinary requests that share the connection. + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + 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; + + # A battle outlives nginx's 60s default, and the socket is idle + # whenever nobody is typing. Without this the connection is cut + # mid-match and the client reconnects into a battle already moving. + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + + # Realtime frames must not be held back waiting for a buffer to fill. + proxy_buffering off; + } + + # --- ws-server's health probe ------------------------------------------- + # Useful for external uptime monitoring. Cheap, and leaks nothing. + location = /ws-health { + proxy_pass http://127.0.0.1:4002/health; + proxy_set_header Host $host; + access_log off; + } + + # --- BLOCKED: ws-server's internal stats --------------------------------- + # /internal/* is server-to-server only and has NO authentication — see + # apps/ws-server/src/transport/httpApp.ts. http-api reaches it over the + # Docker network; it must never be routable from outside. This block is + # belt-and-braces (the proxy_pass above targets 4002 only under /ws), but + # an explicit deny survives someone later adding a broader location. + location /internal/ { + return 404; + } + + # --- everything else: http-api ------------------------------------------- + location / { + proxy_pass http://127.0.0.1:4001; + 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_read_timeout 60s; + } + + # Code submissions are the largest thing a client sends, and they are + # text. 2 MB is generous; the app caps source at 100 KB anyway. + client_max_body_size 2m; +} +``` + +### The upgrade map + +`$connection_upgrade` has to be defined at the `http` level, not inside a +server block. Create `/etc/nginx/conf.d/upgrade.conf`: + +```nginx +# Maps the Upgrade request header to the right Connection response header. +# +# A plain `Connection: upgrade` on every request breaks keep-alive for normal +# HTTP; this sends "upgrade" only when the client actually asked for one, and +# "close" otherwise. +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} +``` + +### Enable and test + +```bash +sudo ln -s /etc/nginx/sites-available/spidder /etc/nginx/sites-enabled/ +sudo ln -s /etc/nginx/sites-available/spidder-api /etc/nginx/sites-enabled/ +sudo rm -f /etc/nginx/sites-enabled/default + +sudo nginx -t # ALWAYS before reload +sudo systemctl reload nginx +``` + +### Certificates + +```bash +sudo certbot --nginx \ + -d spidder.example.com \ + -d api.spidder.example.com \ + --agree-tos -m you@example.com --redirect +``` + +`--redirect` adds the HTTP→HTTPS redirect. certbot rewrites both server blocks +in place, adding `listen 443 ssl`, the certificate paths, and a port-80 block +that redirects. Renewal is installed as a systemd timer; check it with: + +```bash +systemctl list-timers | grep certbot +sudo certbot renew --dry-run +``` + +### Verify the whole path + +```bash +curl -I https://spidder.example.com +curl -s https://api.spidder.example.com/health # {"ok":true} +curl -s https://api.spidder.example.com/ws-health # {"ok":true} +curl -s -o /dev/null -w '%{http_code}\n' \ + https://api.spidder.example.com/internal/stats # 404 — must NOT be 200 + +# The WebSocket upgrade. 101 means the handshake worked. +curl -i -N \ + -H "Connection: Upgrade" -H "Upgrade: websocket" \ + -H "Sec-WebSocket-Key: $(openssl rand -base64 16)" \ + -H "Sec-WebSocket-Version: 13" \ + https://api.spidder.example.com/ws 2>&1 | head -1 +``` + +A `200` on `/internal/stats` means the deny block is missing or ordered wrong — +fix it before going live. + +### What about the admin dashboard? + +`apps/admin` has **no `Dockerfile.prod`** and appears in no compose file, so +there is nothing to proxy to yet — that is why no third server block is given +here. The admin API routes live inside `http-api` and are already reachable +through `api.spidder.example.com`; only the separate Next.js UI is missing. + +When it is containerised, it needs its own hostname and a block identical to +the site one, pointing at its port — and it should sit behind something more +than a login form: an IP allowlist (`allow`/`deny`) or basic auth at the nginx +layer, because it is the one surface where a stolen session is worth the most. + +### Point the app at these names + +Two places, and they must agree: + +1. **Repository secrets** — `NEXT_PUBLIC_API_URL=https://api.spidder.example.com` + and `NEXT_PUBLIC_WS_URL=wss://api.spidder.example.com/ws`. Note `wss://`, + not `ws://`: a plain-ws connection from an https page is blocked by the + browser as mixed content. +2. **The server's `.env`** — `CORS_ORIGINS=https://spidder.example.com`. This + is the *site* origin, not the API's; it is the origin the browser sends. + +The `NEXT_PUBLIC_*` pair is compiled into the browser bundle at image build +time, so changing them means re-running the deploy workflow. Changing +`CORS_ORIGINS` only needs a restart. + +--- + +## 8. CI/CD + +### What runs when + +| Workflow | Trigger | What it does | +|---|---|---| +| `ci.yml` | every push, every PR | lint, typecheck, tests, full build | +| `deploy.yml` | push to `main`, or manual | re-verify → build 5 images → push to Docker Hub → roll out | + +Images are built on the runner, never on the VPS. Building a Next.js app on a +small droplet competes with the running site for memory and can OOM halfway, +taking production down to ship a change. A runner builds it, the server pulls +the result. + +Every deploy is tagged with its commit SHA, so a rollback is re-running the +workflow against an older commit rather than reverting and rebuilding. + +### Repository secrets + +`Settings → Secrets and variables → Actions`: + +| Secret | Example | Notes | +|---|---|---| +| `DOCKERHUB_USERNAME` | `codeheist` | your Docker Hub account, lowercase | +| `DOCKERHUB_TOKEN` | `dckr_pat_…` | an **access token**, not your password | +| `VPS_HOST` | `203.0.113.10` | hostname or IP | +| `VPS_USER` | `deploy` | must be in the `docker` group | +| `VPS_SSH_KEY` | `-----BEGIN OPENSSH…` | the **private** key, whole file | +| `VPS_APP_DIR` | `/home/deploy/spidder` | absolute path, not `~` | +| `NEXT_PUBLIC_API_URL` | `https://api.spidder.example.com` | baked into the browser bundle | +| `NEXT_PUBLIC_WS_URL` | `wss://api.spidder.example.com/ws` | note `wss://`, not `ws://` | +| `VPS_SSH_PORT` | `22` | optional | + +### Docker Hub + +Create the access token at **hub.docker.com → Account Settings → Personal +access tokens**, scope **Read & Write**. Use a token rather than your password: +a token can be revoked on its own, and it cannot be used to sign in to the +account itself. + +The workflow pushes five images: + +``` +/spidder-http-api +/spidder-ws-server +/spidder-judge-worker +/spidder-web +/spidder-db-init +``` + +They are created on first push. **The free tier includes one private +repository**, so five private images needs a paid plan — otherwise create them +as public. They hold only compiled application code; every credential is +injected at runtime from the server's `.env`, so nothing secret ships inside +them. The one thing compiled *in* is `NEXT_PUBLIC_*` in the web image, and +those are public URLs visible in any visitor's devtools regardless. + +The compose file already defaults to `codeheist`, so a hand-run +`docker compose` on the VPS resolves the same images the workflow pushes. If +you publish under a different Docker Hub account, set `REGISTRY` in the +server's `.env` to match `DOCKERHUB_USERNAME` — otherwise the server keeps +pulling the original account's images while CI pushes to yours, and the deploy +appears to succeed while changing nothing. + +### The deploy key + +Generate a keypair used by nothing else: + +```bash +ssh-keygen -t ed25519 -C "github-actions" -f ~/.ssh/spidder_deploy -N "" +ssh-copy-id -i ~/.ssh/spidder_deploy.pub deploy@your.vps.ip +cat ~/.ssh/spidder_deploy # paste ALL of this into VPS_SSH_KEY +``` + +### First run + +The workflow logs the server in to Docker Hub before pulling, even when the +images are public. That is on purpose: anonymous pulls are rate-limited per IP +address, and a VPS that shares an address range with other pullers can hit the +ceiling partway through a deploy. The failure is a `429 Too Many Requests` on +`docker compose pull`, which reads like an outage rather than a quota. An +authenticated pull uses the account's own, much higher limit. + +A first deploy failing with `pull access denied` or `repository does not exist` +means the namespace is wrong — check `DOCKERHUB_USERNAME` is your Hub account +(not your GitHub org) and is lowercase. + +--- + +## Operations + +Everything here runs as `deploy`, with no sudo — that is the point of section 1. + +```bash +cd ~/spidder + +# A short alias saves repeating the -f on every command. +alias dc='docker compose -f docker-compose.prod.yml' + +dc ps # what is running, and is it healthy +dc logs -f --tail=100 # everything +dc logs -f http-api # one service +dc restart http-api +dc pull && dc up -d # roll to the latest images by hand +``` + +nginx, Postgres and Redis are host services, so they use systemd and do need +sudo: + +```bash +sudo systemctl status nginx postgresql redis-server +sudo nginx -t && sudo systemctl reload nginx # ALWAYS test before reload +sudo tail -f /var/log/nginx/error.log +``` + +### Backups + +The database is on the host, so it is not in any Docker volume: + +```bash +# /etc/cron.daily/spidder-backup +sudo -u postgres pg_dump spidder | gzip > /var/backups/spidder-$(date +%F).sql.gz +find /var/backups -name 'spidder-*.sql.gz' -mtime +14 -delete +``` + +Restore: + +```bash +gunzip -c /var/backups/spidder-2026-09-13.sql.gz | sudo -u postgres psql spidder +``` + +### Rollback + +The fastest route is the Actions tab: re-run **Deploy** on the last good +commit. To pin by hand on the server instead — `IMAGE_TAG` is the only knob, +since the Docker Hub account is written literally in the compose file: + +```bash +cd ~/spidder +IMAGE_TAG= docker compose -f docker-compose.prod.yml pull +IMAGE_TAG= docker compose -f docker-compose.prod.yml up -d +``` + +Find a SHA from `git log --oneline` or the Actions run list. Note this does +**not** roll the database back: `db push` is forward-converging, so a rollback +across a destructive schema change needs the backup below. + +--- + +## Troubleshooting + +**`ECONNREFUSED` reaching Postgres or Redis.** The host service is not +listening on the bridge. Check `listen_addresses` / `bind`, confirm the bridge +address is what you assumed (`ip -4 addr show docker0`), and check from inside +a container: + +```bash +docker compose -f docker-compose.prod.yml exec http-api \ + node -e "require('net').connect(5432,'host.docker.internal').on('connect',()=>{console.log('ok');process.exit(0)}).on('error',e=>{console.log(e.code);process.exit(1)})" +``` + +**The site loads but nothing works; the browser console shows calls to +`localhost:4001`.** `NEXT_PUBLIC_API_URL` was wrong when the web image was +built. These values are compiled into the client bundle, so fixing the secret +requires a **rebuild**, not a restart — re-run the deploy workflow. + +**Battles never start; everything else is fine.** The WebSocket is not being +upgraded. Check in this order: + +```bash +# 1. Does the handshake reach ws-server at all? 101 = yes. +curl -i -s -N -H "Connection: Upgrade" -H "Upgrade: websocket" \ + -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" -H "Sec-WebSocket-Version: 13" \ + https://api.spidder.example.com/ws | head -1 + +# 2. Is the map defined? Missing $connection_upgrade is the usual cause. +sudo nginx -T | grep -A3 'map $http_upgrade' +``` + +A `200` instead of `101` means the request fell through to `location /` and hit +http-api — the `/ws` block is missing or ordered after the catch-all. A `502` +means it routed correctly but ws-server is down. + +**`403` or `404` from the API on requests the browser makes.** Usually CORS: +`CORS_ORIGINS` on the server must be the **site** origin +(`https://spidder.example.com`), not the API's own hostname. It is the origin +the browser sends, not the one it is talking to. + +**The admin dashboard's live connection count is always zero.** +`WS_SERVER_INTERNAL_URL` must be `http://ws-server:4002` — the code defaults to +`localhost:4002`, which inside a container is the container itself. It is set +in `docker-compose.prod.yml`; confirm it survived any local edit: + +```bash +docker compose -f docker-compose.prod.yml exec http-api printenv WS_SERVER_INTERNAL_URL +``` + +**Submissions queue but never judge.** `judge-worker` or `piston` is down: + +```bash +docker compose -f docker-compose.prod.yml logs judge-worker piston +``` + +`piston-init` must have exited 0 at least once, or no language runtime is +installed and every submission fails to execute. + +**`no space left on device` during a pull.** Old images: + +```bash +docker system df +docker image prune -a -f --filter "until=168h" +``` diff --git a/README.md b/README.md index 37389ce..dc08b3e 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Loading.. .. .. +# Spidder Real-time competitive coding battles. @@ -16,8 +16,8 @@ only a passed-count — and the hidden tests never reach a client. You need **Docker** and nothing else. ```bash -git clone combatX -cd combatX +git clone https://github.com/TheCodeHeist-Coder/Spidder.git spidder +cd spidder docker compose up --build ``` @@ -44,12 +44,12 @@ Full instructions, including running without Docker, are in **[SETUP.md](SETUP.m | **[Why, What & How](docs/02-why-what-how.pdf)** (PDF) | The problem, the audience, and why the design follows | | **[User Guide](docs/03-user-guide.pdf)** (PDF) | Running it, hosting a battle, playing, troubleshooting | -Or read **[all three combined](docs/combatx-documentation.pdf)**. Sources live in +Or read **[all three combined](docs/spidder-documentation.pdf)**. Sources live in [`docs-src/`](docs-src/); regenerate with `python3 scripts/make-docs-pdf.py --combined`. --- -## Why CombatX exists +## Why Spidder exists ### The problem @@ -80,8 +80,10 @@ a bracket, a warm-up round, or a quick grudge match without scheduling around someone else's contest calendar. That audience drives the whole design. A club can't ask forty people to -register accounts before a session starts, so play is **guest-only** — pick a -name, get a room code, you're in. And an organizer shouldn't need a DevOps +register accounts before a session starts, so a room-code battle needs **no +account at all** — pick a name, get a code, you're in. Signing up is optional, +and only buys the things that genuinely require an identity that persists: +a rating, a league team, badges. And an organizer shouldn't need a DevOps afternoon to host it, so the entire stack is **one command**. ### Why it's built this way @@ -122,12 +124,23 @@ socket frame) and `packages/game` are compiled into both the frontend and the backend. A protocol change that breaks a client is a type error at build time, not a mystery bug in the middle of someone's tournament. -### Non-goals +### What it deliberately isn't -No ratings, no ladders, no matchmaking against strangers. CombatX assumes you -already know who you're playing — you're in a room together, physically or on a -call. It's a tool for a group that has gathered, not a platform trying to keep -you online. +**Not a scheduled-contest platform.** Nothing here runs on a calendar. A match +starts when two people want one, not at 20:00 UTC on a Saturday. + +**Not a place to grind alone.** Every mode needs an opponent. There is no +single-player practice track, because the thing being trained is performing +against someone, not correctness in isolation. + +**Not built to keep you online.** No streaks to maintain, no daily quests, no +notifications pulling you back. Ranked exists so a match can be fair between +strangers — not to give anyone a number to defend. + +The ladder came later than the room-code battles it sits beside, and the two +answer different needs: a club wants a bracket on a Thursday, a solo player +wants a fair opponent right now. Both stay guest-friendly, and rating only +moves in server-paired matches so a private room can never feed it. --- @@ -144,8 +157,10 @@ A battle moves through three phases, all driven over a single WebSocket: 3. **Results** — first side to pass every test wins immediately (`ALL_PASSED`). If the timer expires first, the highest passed-count wins (`TIMEOUT`). -Play is guest-only: pick a display name, get a JWT, share a room code. No -accounts, no passwords. +Room-code battles need no account: pick a display name, get a guest JWT, share +the code. Registering is optional and unlocks what needs a lasting identity — +ranked matchmaking, leagues, and progression. Guests are barred from ranked on +purpose: a rating that vanishes with a token is one nobody can be held to. Modes run from 1v1 up to 4v4. **1v1 ships today**; the larger team modes are modelled end-to-end and gated in the UI. @@ -270,9 +285,10 @@ because it arrived over a channel that was trusted a moment ago. ## Project structure ``` -combatX/ +spidder/ ├── apps/ │ ├── web/ Next.js frontend +│ ├── admin/ Next.js operations console (not yet containerised) │ ├── http-api/ Express REST API │ ├── ws-server/ authoritative WebSocket game server │ └── judge-worker/ BullMQ consumer + Piston client @@ -285,10 +301,20 @@ combatX/ ├── docker/ │ ├── db-init/ migrate + seed init container │ └── piston-init/ runtime provisioner -├── docker-compose.yml production stack -└── docker-compose.dev.yml hot-reload dev stack +├── .github/workflows/ +│ ├── ci.yml lint · typecheck · test · build +│ └── deploy.yml build images → Docker Hub → roll out on the VPS +├── docker-compose.yml full stack in containers (local) +├── docker-compose.dev.yml hot-reload dev stack +└── docker-compose.prod.yml VPS deploy — host Postgres/Redis ``` +### Deploying + +See [DEPLOYMENT.md](DEPLOYMENT.md). Production runs Postgres and Redis on the +host and only the app services in Docker, so the compose file and the host +setup have to agree — that guide covers both. + ### Data model `User` · `Problem` · `TestCase` · `Battle` · `Team` · `TeamMember` · @@ -301,6 +327,7 @@ combatX/ | Service | URL | | --------- | --------------------------- | | Web | | +| Admin | | | HTTP API | | | WebSocket | `ws://localhost:4002/ws` | | Piston | | @@ -315,7 +342,7 @@ remap — e.g. `REDIS_PORT=6380`. Details in [SETUP.md](SETUP.md). ## Commands ```bash -pnpm dev # run all four apps with hot reload +pnpm dev # run all five apps with hot reload pnpm build # production build, all workspaces pnpm check-types # TypeScript pnpm lint # ESLint @@ -338,8 +365,8 @@ reload) and `Dockerfile.prod` (multi-stage, pruned, non-root). The production images use `turbo prune` so an unrelated app's change doesn't bust the dependency-install cache, then install production-only dependencies -into a slim runtime layer. The difference is substantial — `web` is **331MB** -in prod versus 1.19GB in dev. +into a slim runtime layer. The difference is substantial — the `web` prod image +is roughly a quarter the size of its dev counterpart. Two init containers make the one-command start possible: **`db-init`** applies the schema and seeds problems, **`piston-init`** installs the language runtime. @@ -349,8 +376,22 @@ Both are idempotent and gated behind healthchecks. ## Status -1v1 battles work end to end — guest auth, lobby, live scoring, sandboxed -judging, instant-win and timeout outcomes, persisted results. - -Team modes 2v2 through 4v4 are modelled across the schema, protocol, and rules, -but gated in the UI pending a lobby flow for larger rosters. +**Working end to end:** + +- **1v1 battles** — guest auth, lobby, live scoring, sandboxed judging, + instant-win and timeout outcomes, persisted results +- **Ranked matchmaking** — Glicko-2 rating, server-paired opponents only, so + the ladder cannot be farmed from a private room +- **Leagues** — create, join by code, team formation, host-drawn fixtures, + qualification rules, brackets and a flow preview +- **Progression** — XP, ranks, and 29 badges across milestone, skill and + contribution categories +- **Community problems** — players submit problems, admins approve or reject + with a reason, approved ones enter rotation + +**Not yet:** + +- Team modes 2v2–4v4 are modelled across the schema, protocol and rules, but + gated in the UI pending a lobby flow for larger rosters +- `apps/admin` has no production Dockerfile, so the operations console runs + only in development. Its API lives in `http-api` and is deployed diff --git a/SETUP.md b/SETUP.md index 03262ea..e240ae2 100644 --- a/SETUP.md +++ b/SETUP.md @@ -1,6 +1,6 @@ # Setup -Two ways to run CombatX: +Two ways to run Spidder: - **[Docker](#option-a--docker-recommended)** — one command, nothing to install. Best for just running it. - **[Local](#option-b--local-no-docker)** — run the apps on your host with hot reload. Best for day-to-day development. @@ -17,8 +17,8 @@ no pnpm, no Postgres. ### Run it ```bash -git clone combatX -cd combatX +git clone https://github.com/TheCodeHeist-Coder/Spidder.git spidder +cd spidder docker compose up --build ``` @@ -89,8 +89,8 @@ applies to `POSTGRES_PORT` and `PISTON_PORT`. ### 1. Install ```bash -git clone combatX -cd combatX +git clone https://github.com/TheCodeHeist-Coder/Spidder.git spidder +cd spidder corepack enable pnpm install ``` @@ -112,7 +112,7 @@ cp packages/db/.env.example packages/db/.env | `packages/db/.env` | Prisma CLI (migrate, seed, studio) | The defaults assume Postgres on `localhost:5432` with user/password -`postgres`/`postgres` and a database named `combateone`. Adjust `DATABASE_URL` +`postgres`/`postgres` and a database named `spidder`. Adjust `DATABASE_URL` in **both** `.env` and `packages/db/.env` if yours differs — they must match. Set `JWT_SECRET` to any long random string for local work. @@ -122,7 +122,7 @@ Set `JWT_SECRET` to any long random string for local work. Create the database, then apply the schema and seed the problems: ```bash -createdb combateone +createdb spidder pnpm --filter @repo/db db:push # apply the schema pnpm --filter @repo/db db:seed # insert the problems @@ -168,7 +168,8 @@ installed by default. pnpm dev ``` -This runs all four apps together. Open ****. +This runs all five apps together. Open **** for the +site, or **** for the admin console. To run just one: diff --git a/apps/admin/app/globals.css b/apps/admin/app/globals.css index 366a719..e39287f 100644 --- a/apps/admin/app/globals.css +++ b/apps/admin/app/globals.css @@ -1,7 +1,7 @@ @import "tailwindcss"; /* --------------------------------------------------------------------------- - * CombatX admin console. + * Spidder admin console. * * Black and grey, deliberately. This is a tool someone stares at for an hour, * so the surface stays out of the way: no glow, no coloured washes, no @@ -396,7 +396,7 @@ * * The console proper is cyan so an operator can never mistake it for the * public site. The SIGN-IN screen is the one place that should feel like - * CombatX — it is the door to the product, not the machinery behind it — so + * Spidder — it is the door to the product, not the machinery behind it — so * it borrows the player app's orange, its display faces, and its wordmark * treatment. Everything here is scoped under .auth-theme; nothing leaks into * the dashboard. diff --git a/apps/admin/app/layout.tsx b/apps/admin/app/layout.tsx index 090d0ed..f57adba 100644 --- a/apps/admin/app/layout.tsx +++ b/apps/admin/app/layout.tsx @@ -33,8 +33,8 @@ const fingerPaint = localFont({ }); export const metadata: Metadata = { - title: "CombatX Admin", - description: "Operations console for CombatX.", + title: "Spidder Admin", + description: "Operations console for Spidder.", // The console must never be indexed, even if it is ever exposed publicly. robots: { index: false, follow: false, nocache: true }, }; diff --git a/apps/admin/components/AdminArtwork.tsx b/apps/admin/components/AdminArtwork.tsx index 61b9c1e..b3c6b0b 100644 --- a/apps/admin/components/AdminArtwork.tsx +++ b/apps/admin/components/AdminArtwork.tsx @@ -31,7 +31,7 @@ export function AdminArtwork() { return (
{/* The player app's two-tone wash — blue left, orange right — so the - console's door looks like the rest of CombatX. */} + console's door looks like the rest of Spidder. */}
- COMBATX + SPIDDER = PODIUM_MIN ? entries.slice(0, 3) : []; + const rest = entries.length >= PODIUM_MIN ? entries.slice(3) : entries; + const outsidePage = data?.me && !data.entries.some((e) => e.userId === data.me?.userId) ? data.me @@ -104,32 +122,64 @@ export default function RankingsPage() { : "No ranked operatives yet — finish a battle to appear here."}

) : ( -
- - - - - - - - - - - - - {data.entries.map((e) => ( - + {/* + The podium: second, FIRST, third — the winner in the middle. + + The cards are laid out on a shared bottom edge and given + different top padding, so first place stands taller than the + other two exactly as a real podium does. + + Source order stays 1, 2, 3 and CSS `order` does the arranging. + That matters twice over: a screen reader and the keyboard tab + sequence still meet the winner first, and when the row collapses + to a single column on a phone the ordering is dropped, so the + stack reads 1, 2, 3 top to bottom rather than stranding the + winner in the middle with no height cue to explain why. + */} + {podium.length > 0 && ( +
+ {podium.map((e) => ( + ))} -
-
#Operative{board === "rating" ? "Tier" : "Rank"} - {board === "rating" ? "Rating" : "XP"} - W/LStreak
-
+
+ )} + + {rest.length > 0 && ( +
+ + + + + + + + + + + + + + {rest.map((e) => ( + + ))} + +
#OperativeBadges{board === "rating" ? "Tier" : "Rank"} + {board === "rating" ? "Rating" : "XP"} + W/LStreak
+
+ )} + )} {/* @@ -150,7 +200,18 @@ export default function RankingsPage() { <>

Your standing

+ {/* Same column widths as the board above, so the two line up + rather than reading as an unrelated stray row. */} + + + + + + + + + @@ -193,14 +254,17 @@ function BoardTab({ function Th({ children, align = "left", + width, }: { children: React.ReactNode; align?: "left" | "right"; + /** Fixed column width, so the badge and rank columns stop shifting. */ + width?: string; }) { return ( @@ -231,22 +295,26 @@ function Row({ + {/* + Badges get their own column rather than sitting under the name. + Stacked, they made every row a different height and pushed the + username off the row's centre line; beside it, the medals line up + down the page and the name column stays a clean left edge. + */} + ); } + +/** + * A tight horizontal run of medals for a table row. + * + * Not `BadgeRow`: that renders each medal as a `
` padded to + * `width: size + 16`, which is right on a profile shelf but leaves 16px of + * dead space per badge in a table — enough that two rows with different badge + * counts no longer line up. Here the medals butt together at a fixed size and + * the column keeps a straight left edge. + * + * Capped at three. The API already sends only the rarest few, but a cap means + * the column can never widen enough to squeeze the name beside it. + */ +const MEDALS_SHOWN = 3; + +function MedalStrip({ + badges, + size = 26, +}: { + badges: BadgeView[]; + size?: number; +}) { + if (badges.length === 0) { + return ( + + — + + ); + } + + const shown = badges.slice(0, MEDALS_SHOWN); + const extra = badges.length - shown.length; + + return ( + + {shown.map((b) => ( + + ))} + {extra > 0 && ( + + +{extra} + + )} + + ); +} + +/** One medal at an exact pixel size, with its meaning on hover. */ +function MedalDot({ badge, size }: { badge: BadgeView; size: number }) { + return ( + + + + ); +} + +/** + * One of the top three, as a card. + * + * ORDER AND HEIGHT + * ---------------- + * A real podium: second, FIRST, third, with the winner raised above the other + * two. The parent aligns all three on a shared bottom edge, and the extra + * height comes from top padding here rather than a fixed height, so a long + * username wrapping to two lines grows the card instead of overflowing it. + * + * `order` only applies once the row is a grid (sm and up). Stacked on a phone + * the cards fall back to source order — 1, 2, 3 — because the height cue that + * justifies a centred winner does not survive stacking. + * + * EVERY CARD IS THE SAME SHAPE + * ---------------------------- + * Avatar, name, headline number, then a fixed-height medal strip. The strip + * keeps its height whether or not the holder has badges, so three cards with + * different badge counts still have their numbers on one line — the exact + * misalignment that made the old rows look untidy. + */ +const PLACE_TONE: Record = { + 1: { rim: "#e0b341", chip: "#e0b341", ink: "#1a1205" }, + 2: { rim: "#b9c2cc", chip: "#b9c2cc", ink: "#12161a" }, + 3: { rim: "#c98b5e", chip: "#c98b5e", ink: "#1a1008" }, +}; + +function PodiumCard({ + entry, + board, + isMe, +}: { + entry: LeaderboardEntry; + board: LeaderboardBoard; + isMe: boolean; +}) { + const tone = PLACE_TONE[entry.rank] ?? PLACE_TONE[3]!; + const first = entry.rank === 1; + const xpRank = rankFor(entry.xp); + const value = board === "rating" ? entry.rating.rating : entry.xp; + const sub = + board === "rating" ? entry.rating.tierLabel ?? "Unranked" : xpRank.label; + + // 2 · 1 · 3 across the row, first place raised above the other two. + const order = first ? "sm:order-2" : entry.rank === 2 ? "sm:order-1" : "sm:order-3"; + const lift = first ? "sm:pt-10 sm:pb-6" : "sm:pt-7 sm:pb-4"; + + return ( +
+ {/* Rank chip, straddling the top edge so it reads as a seal. */} + + {entry.rank} + + + + + + + {entry.username} + + {entry.name && ( + + {entry.name} + + )} + + + + {isMe && you} + + + {value} + + + {board === "rating" ? "rating" : "xp"} · {sub} + + + {/* Fixed height whether or not there are medals, so the three cards + keep a common baseline. */} + + + + + + {entry.wins}W · {entry.losses}L · {entry.bestStreak} streak + +
+ ); +} diff --git a/apps/web/components/AppShell.tsx b/apps/web/components/AppShell.tsx index 4bbafe2..c626968 100644 --- a/apps/web/components/AppShell.tsx +++ b/apps/web/components/AppShell.tsx @@ -9,7 +9,6 @@ import { UserAvatar } from "./identity/UserIdentity"; import { NotificationBell } from "./NotificationBell"; import type { Session } from "../lib/session"; import { - IconMapPin, IconGitHub, IconX, IconLinkedIn, @@ -25,6 +24,9 @@ import { * beneath it. Every destination is reachable from that one bar. */ +/** The public source. Named once so the bar and the footer cannot drift. */ +const REPO_URL = "https://github.com/TheCodeHeist-Coder/Spidder"; + interface NavItem { label: string; href: string; @@ -44,18 +46,32 @@ export function AppShell({ profile, children, right, + footer = false, }: { session?: Session | null; /** Live progression, when the caller has fetched it. */ profile?: ProfileResponse | null; children: ReactNode; right?: ReactNode; + /** + * Whether to show the marketing footer. Off by default. + * + * The footer is site-level furniture — company links, socials, contact — and + * it belongs on the landing page, where a visitor is still deciding. Every + * other screen is a working surface: an auth form, an arena, a league + * dashboard. There a wall of links is noise between the user and the task, + * and on the short ones it is most of the page. + * + * Defaulting to off means a new screen inherits the quiet version and has to + * ask for the footer, rather than acquiring it silently. + */ + footer?: boolean; }) { return (
{children}
- + {footer && }
); } @@ -98,6 +114,35 @@ function CommandBar({
{right} + {/* + The source, for anyone who wants to contribute. + + It sits in the bar rather than only in the footer because the + footer now appears on the landing page alone, and a contributor is + most likely to be deep in the product — mid-match or browsing + problems — when the thought strikes. + + Icon-only: it is an invitation, not a primary action, and it must + not compete with Log in / Sign up. The accessible name and the + tooltip carry the meaning that the glyph cannot. + */} + { + e.currentTarget.style.color = "var(--color-ink)"; + }} + onMouseLeave={(e) => { + e.currentTarget.style.color = "var(--color-ink-faint)"; + }} + > + + {/* Guests have nothing to be notified about — no league, no team. */} {session && !session.isGuest && ( @@ -165,6 +210,21 @@ function CommandBar({ ))} + {/* Same reasoning as the auth buttons below: the bar's GitHub icon + is sm-and-up, so without this a phone has no route to the source + now that the footer is landing-page only. */} + setOpen(false)} + className="nav-link flex items-center gap-2.5 rounded-[6px] px-3 py-2.5 text-[0.9rem]" + style={{ color: "var(--color-ink-dim)" }} + > + + Contribute on GitHub + + {/* The header's own auth buttons are sm-and-up only, so repeat them here or a signed-out phone visitor has no way to an account. */} {!session && ( @@ -202,16 +262,48 @@ function NavLink({ item, active }: { item: NavItem; active: boolean }) { style={{ color: active ? "var(--color-accent)" : "var(--color-ink-dim)" }} > {item.label} - {active && ( - - )} + {active && } ); } +/** + * The hand-drawn underline beneath the active nav item. + * + * An SVG rather than `text-decoration: wavy`: that keeps the wave glued to the + * text baseline at a size the browser picks, differs between engines, and + * cannot be given the slight irregularity that makes a mark read as drawn + * rather than generated. Here the curve is explicit. + * + * `preserveAspectRatio="none"` lets one path stretch across labels as short as + * "Intel" and as long as "Rankings" while keeping the stroke an even weight, + * because the stroke is scaled by `vector-effect` and not by the viewBox. + * + * Eight short humps rather than four long ones: at nav size a slow wave reads + * as a wobbly line, while a tight one reads as deliberate. `Q` sets the first + * hump and the `T` chain mirrors it, so the period stays even all the way + * across however far the path is stretched. + */ +function Squiggle() { + return ( + + ); +} + /** Avatar + callsign in the command bar. Links to settings to change either. */ function IdentityChip({ session, @@ -287,7 +379,7 @@ function SiteFooter() { className="font-mono text-[1.25rem] font-black uppercase tracking-[0.12em]" style={{ color: "#f5f7fb" }} > - COMBATX + SPIDDER
@@ -307,28 +399,13 @@ function SiteFooter() { Built for players, driven by community.

- {/* small orange line */} - @@ -607,9 +684,9 @@ function SiteFooter() { >
- © 2024{" "} + © 2026{" "} - COMBATX + SPIDDER . All rights reserved.
diff --git a/apps/web/components/Logo.tsx b/apps/web/components/Logo.tsx index 2ceffbc..5fccd29 100644 --- a/apps/web/components/Logo.tsx +++ b/apps/web/components/Logo.tsx @@ -1,5 +1,5 @@ /** - * The CombatX wordmark: chunky arcade type with the brand gradient, matching + * The Spidder wordmark: chunky arcade type with the brand gradient, matching * the "CODE BATTLE" hero treatment at a smaller size. */ export function Logo({ size = "md" }: { size?: "sm" | "md" | "lg" }) { @@ -9,7 +9,7 @@ export function Logo({ size = "md" }: { size?: "sm" | "md" | "lg" }) { className="display grad-text inline-flex select-none items-baseline" style={{ fontSize: `${scale * 1.1}rem` }} > - CombatX + Spidder ); } diff --git a/apps/web/components/PageViewTracker.tsx b/apps/web/components/PageViewTracker.tsx index 3c15ea2..bed85c5 100644 --- a/apps/web/components/PageViewTracker.tsx +++ b/apps/web/components/PageViewTracker.tsx @@ -4,7 +4,7 @@ import { useEffect } from "react"; import { usePathname } from "next/navigation"; import { API_URL } from "../lib/config"; -const VISITOR_KEY = "combatx.visitor"; +const VISITOR_KEY = "spidder.visitor"; /** * A stable per-browser id, generated on first visit. diff --git a/apps/web/components/leagues/FixturesPanel.tsx b/apps/web/components/leagues/FixturesPanel.tsx index ea94acc..850227d 100644 --- a/apps/web/components/leagues/FixturesPanel.tsx +++ b/apps/web/components/leagues/FixturesPanel.tsx @@ -1,6 +1,5 @@ "use client"; -import { useState } from "react"; import Link from "next/link"; import type { LeagueDetailResponse, diff --git a/apps/web/components/leagues/LeagueFlow.tsx b/apps/web/components/leagues/LeagueFlow.tsx index b3a9549..9cb1794 100644 --- a/apps/web/components/leagues/LeagueFlow.tsx +++ b/apps/web/components/leagues/LeagueFlow.tsx @@ -1,7 +1,6 @@ "use client"; import { useMemo } from "react"; -import Link from "next/link"; import { leagueProgress, teamPath, type BracketFixture } from "@repo/game"; import type { LeagueDetailResponse, diff --git a/apps/web/components/ranking/Badges.tsx b/apps/web/components/ranking/Badges.tsx index 13da91d..5e3e811 100644 --- a/apps/web/components/ranking/Badges.tsx +++ b/apps/web/components/ranking/Badges.tsx @@ -109,12 +109,22 @@ export function Badge({ locked = false, size = "md", tier, + bare, }: { badge: BadgeView; locked?: boolean; size?: "sm" | "md" | "lg"; /** Optional multiplier bubble for a repeatable achievement. */ tier?: number; + /** + * Exact pixel size with no figure padding, for dense rows. + * + * The named sizes wrap each medal in a figure padded to `size + 16`, which + * is right on a profile shelf but leaves dead space either side in a table + * column — enough that rows with different badge counts stop lining up. + * Passing `bare` renders the medal alone at exactly that width. + */ + bare?: number; }) { const honour = isHonour(badge.category); const r = rarity(badge.rarity); @@ -122,7 +132,9 @@ export function Badge({ const fg = honour ? HONOUR_FG : r.fg; // A wreathed medal needs a little more room to read, and the ornate viewBox // scales its contents down to make space for the laurel. - const px = (size === "lg" ? 80 : size === "sm" ? 34 : 64) * (honour ? 1.18 : 1); + const px = + bare ?? + (size === "lg" ? 80 : size === "sm" ? 34 : 64) * (honour ? 1.18 : 1); const description = locked ? `${badge.label} — ${badge.description} (not yet earned)` @@ -134,7 +146,7 @@ export function Badge({
{CRESTS[badge.key] ? ( )} - {size !== "sm" && ( + {size !== "sm" && !bare && (
s.side === mySide) ?? null; - const slowest = Math.max(...standings.map((s) => s.bestPassed), 1); return ( @@ -442,9 +442,22 @@ export function Results({ mySide={mySide} /> + {/* + Post-match actions. + + The rematch offer needs a LIVE room: the negotiation is held in + the battle room and evicted once everyone disconnects. `snap` is + the honest test for that — it is null when this screen was + rebuilt from REST after a reload or on a shared link, and there + a rematch button could never resolve. Offering one that silently + does nothing is worse than not offering it. + */}
+ {snap && ( + + )}
{children} - - - - - - - {isMe && you} - - {/* The rarest few, so a row shows what distinguishes someone - rather than the First Blood everybody has. */} - + + + + + + {isMe && you} + + {board === "rating" @@ -283,3 +351,187 @@ function Td({