From f2e59a242662715d4ec5dc49266ca4c0649b4a98 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 25 Aug 2026 16:54:38 -0500 Subject: [PATCH 1/2] =?UTF-8?q?feat(delivery):=20phase=2012=20=E2=80=94=20?= =?UTF-8?q?the=20container,=20and=20the=20defect=20only=20a=20proxy=20coul?= =?UTF-8?q?d=20find?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PLAN.md §13 phase 12, the last one. Four decisions of record, D54–D57, taking the count to fifty-seven; recorded in §6, "How phase 12 delivered it". A two-stage Dockerfile, a pull-only docker-compose.yml carrying both bind mounts, .env.example, the workflow that publishes and deploys, CONTRIBUTING.md, the community-health files this was the only repository of the ten to lack, and DEPLOY.md. D54 — a merge deploys, amending D6. build-image.yml pushes runicgateway-site:latest and :sha-<7>, then rolls the container over on the `rgcom` runner out of /opt/runicgateway.com, and waits for the container's own healthcheck rather than for `up -d` to return. D55 — the site runs on its own host behind a generic reverse proxy, so DEPLOY.md states the four requirements rather than one worked example, and the container binds 127.0.0.1 so the safe configuration is the default. D56 — @astrojs/node derives the request protocol from req.socket.encrypted and never reads x-forwarded-proto, so behind a TLS-terminating proxy the browser sends Origin: https://… while the container computes http://… and Astro's CSRF check compares them for equality. Every beta signup, from every visitor, was answered 403. serve.mjs now normalises both forwarded headers, unconditionally — the image should deploy and work. Two assertions in test/headers.test.mjs hold both halves. D57 — DEPLOY.md rather than a README section; SECURITY.md and CODE_OF_CONDUCT.md are pointers to the org's copies rather than copies, because a copy would hard-code the contact address D13 confines to brand.json. Verified: npm run verify green (eleven checks, 36 unit tests, 7 served tests, astro check 0 errors). The image was built and run with both mounts — a mounted brand reached 51 files and all 50 search pages, /brand/* fell back per file, a proxy-shaped signup reached the store, and the export CLI wrote both Play files to the host mount. docker compose config caught a YAML trap in the healthcheck: a block sequence reads the `: ` in `r.ok ? 0 : 1` as a mapping. Co-Authored-By: Claude --- .dockerignore | 42 +++ .env.example | 61 ++++ .gitea/ISSUE_TEMPLATE/bug_report.md | 48 +++ .gitea/ISSUE_TEMPLATE/config.yaml | 11 + .gitea/ISSUE_TEMPLATE/feature_request.md | 38 +++ .gitea/PULL_REQUEST_TEMPLATE.md | 42 +++ .gitea/workflows/build-image.yml | 150 +++++++++ CODE_OF_CONDUCT.md | 17 + CONTRIBUTING.md | 141 +++++++++ DEPLOY.md | 380 +++++++++++++++++++++++ Dockerfile | 102 ++++++ PLAN.md | 135 +++++++- README.md | 39 ++- SECURITY.md | 43 +++ docker-compose.yml | 94 ++++++ scripts/serve.mjs | 65 +++- test/headers.test.mjs | 57 ++++ 17 files changed, 1451 insertions(+), 14 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitea/ISSUE_TEMPLATE/bug_report.md create mode 100644 .gitea/ISSUE_TEMPLATE/config.yaml create mode 100644 .gitea/ISSUE_TEMPLATE/feature_request.md create mode 100644 .gitea/PULL_REQUEST_TEMPLATE.md create mode 100644 .gitea/workflows/build-image.yml create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 DEPLOY.md create mode 100644 Dockerfile create mode 100644 SECURITY.md create mode 100644 docker-compose.yml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3bff607 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,42 @@ +# What must never reach the build context. +# +# Two entries here are load-bearing rather than housekeeping, and both are about +# the bind mounts (PLAN.md §6, §7). +# +# brand/ is the OPERATOR's override. If a developer's local mount were copied +# in, the image would ship somebody's test logo as if it were stock — +# and, worse, it would win over brand-default/ on every deployment that +# does not mount its own. The mount is the only way brand/ is allowed +# to exist inside a container. +# +# data/ holds beta.sqlite: real addresses, given under a consent notice that +# says where they are stored. A published image is world-readable to +# anyone who can pull it. This line is the reason that cannot happen by +# accident. +# +# brand-default/ is deliberately NOT here. It is baked in and must always be +# complete; §7's whole per-file fallback rests on it. + +node_modules +dist +.astro +.output + +brand +data + +.git +.gitea +.gitignore +.dockerignore + +.env +.env.* +!.env.example + +# Authoring inputs and working notes, none of which the running site reads. +PLAN.md +*.log +npm-debug.log* +.DS_Store +Thumbs.db diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..aee3962 --- /dev/null +++ b/.env.example @@ -0,0 +1,61 @@ +# runicgateway.com — production environment. +# +# Copy to `.env` beside docker-compose.yml on the host and fill in the two +# secrets. Everything else has a working default; this file exists so the +# defaults are visible rather than discovered. +# +# cp .env.example .env +# +# Nothing here is a credential for another service. The site talks to no API, +# sends no mail (D7) and has no database server — the only state it keeps is a +# SQLite file on the ./data mount. + +# --------------------------------------------------------------------------------------- +# Deployment +# --------------------------------------------------------------------------------------- + +# Which published build runs. `latest` follows main; pin `sha-<7>` for a +# reproducible deploy or to roll back — every merge publishes both tags. +IMAGE_TAG=latest + +# Host port the container is published on, bound to 127.0.0.1 (see the note in +# docker-compose.yml if your reverse proxy cannot reach the host's loopback). +SITE_HOST_PORT=4321 + +# --------------------------------------------------------------------------------------- +# The closed-beta signup (PLAN.md §8) +# --------------------------------------------------------------------------------------- +# +# THE TWO BELOW ARE THE ONLY VALUES THAT REALLY WANT SETTING. Both default to a +# random value generated per process, which is safe but forgetful: every restart +# invalidates every rate-limit window and every rendered form. That is the right +# default — a hard-coded salt shipped in a public repository would make every +# deployment's ip_hash values identical and therefore reversible by anyone who +# can read it — but it is not what you want on a host that restarts. +# +# Generate both once, keep them, and do not rotate them casually: changing the +# salt orphans the rate-limit history of everyone already counted. +# +# openssl rand -hex 32 + +# Salts the ip_hash column. The raw IP address is never stored — /privacy says +# so, and this is the mechanism that makes it true while still allowing a +# per-connection limit. +BETA_IP_SALT= + +# Signs the hidden form token, so a script has to fetch the page before it can +# post. Rotating this only invalidates forms currently open in a browser. +BETA_FORM_KEY= + +# Rows, across all time, above which the form closes and says so on the page. +BETA_TOTAL_CAP=500 + +# What one connection may do, in a rolling hour and a rolling day. +BETA_PER_HOUR=3 +BETA_PER_DAY=24 + +# Seconds between the page rendering and the form posting. Below the minimum is +# treated as a script; above the maximum the form is stale and re-rendered. +# Twelve hours is the default maximum. +BETA_MIN_SECONDS=2 +BETA_MAX_SECONDS=43200 diff --git a/.gitea/ISSUE_TEMPLATE/bug_report.md b/.gitea/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..dee3ea9 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,48 @@ +--- +name: Bug report +about: Something on the site is broken, wrong, or behaving unexpectedly +title: "[bug] " +labels: + - bug +--- + +## Summary + + + +## Where + + + +- Page: +- Viewport width, if it is a layout problem: +- Browser and version: + +## What happened, and what you expected + + + +## Is it a factual error? + + + +## Additional context + + + + diff --git a/.gitea/ISSUE_TEMPLATE/config.yaml b/.gitea/ISSUE_TEMPLATE/config.yaml new file mode 100644 index 0000000..f98acfc --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/config.yaml @@ -0,0 +1,11 @@ +blank_issues_enabled: true +contact_links: + - name: Security vulnerability + url: https://gitea.whitlocktech.com/RunicGateway/runicgateway.com/src/branch/main/SECURITY.md + about: Please do not open a public issue for security problems — report them privately instead (see SECURITY.md). + - name: Questions, help and the Android beta + url: https://discord.gg/t2Jav8yT4g + about: Discord is the front door — instant, and it needs no account here. Bug reports are welcome there too. + - name: A problem with the platform, not the site + url: https://gitea.whitlocktech.com/RunicGateway + about: The website, the sidecar, the shard plugin, the installer and the Android app each have their own tracker. This repository only describes them. diff --git a/.gitea/ISSUE_TEMPLATE/feature_request.md b/.gitea/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..fa1c65b --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,38 @@ +--- +name: Feature request +about: Suggest a page, a section, or a change to how the site explains something +title: "[feature] " +labels: + - enhancement +--- + +## Problem / motivation + + + +## Proposed solution + + + +## Does it belong here? + + + +## Additional context + + diff --git a/.gitea/PULL_REQUEST_TEMPLATE.md b/.gitea/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..d7203ca --- /dev/null +++ b/.gitea/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,42 @@ + + +## What & why + + + +## How it was tested + + + +## Checklist + +- [ ] I have read [CONTRIBUTING.md](CONTRIBUTING.md). +- [ ] `npm run verify` passes locally (all eleven checks and both test suites). +- [ ] No fact is stated in prose — versions and platform facts come from `src/data/platform.json`. +- [ ] `PLAN.md` still describes what this repository does; a decision it records is either + unchanged or amended here, with the reasoning. +- [ ] My commits are reasonably scoped, with Conventional Commit messages. + +## AI-assisted contributions (required) + +This project **requires disclosure of AI tool usage**. Please pick one: + +- [ ] No AI tools were used to produce this contribution. +- [ ] AI tools were used. Tool(s): `___________`. I have reviewed and understand + every change, and take responsibility for it. AI-authored commits are + marked with a `Co-Authored-By` / `Assisted-By` trailer. + +## License + +- [ ] I agree that my contribution is licensed under this project's license + (**GNU GPL v3.0 or later**), and I have the right to contribute it. diff --git a/.gitea/workflows/build-image.yml b/.gitea/workflows/build-image.yml new file mode 100644 index 0000000..7f09e2e --- /dev/null +++ b/.gitea/workflows/build-image.yml @@ -0,0 +1,150 @@ +# Build the container image, publish it to Gitea's container registry, then roll +# the site onto it — on every merge to main. +# +# Gitea Actions caution, learned elsewhere in this org and repeated from +# pr-checks.yml because it costs one comment and has already cost months: never +# leave an empty template expression anywhere in a `run:` script, not even inside +# a comment. The runner silently SKIPS the whole step without failing the job, +# and the problem is invisible in the workflow list. +# +# Two jobs, in sequence: +# +# build — builds and pushes the image (on `ubuntu-latest`) +# deploy — `needs: build`, so it starts only after a clean build and push, and +# pulls + recreates the stack on the host (on `rgcom`) +# +# Prerequisites, one-time: +# +# • A runner labelled `ubuntu-latest` whose jobs have the host Docker socket +# mounted (/var/run/docker.sock), so `docker build` talks to the host daemon. +# This also gives free layer caching between runs. The org already runs one. +# +# • A runner labelled `rgcom` ON the host that serves the site, able to reach +# the Docker daemon and /opt/runicgateway.com — the directory holding the +# production docker-compose.yml and .env. DEPLOY.md has the registration +# command and the directory layout. +# +# • Two repository secrets (Settings → Actions → Secrets), both of which +# already exist for pr-checks.yml's cross-repository checks: +# REGISTRY_USER — the Gitea username owning the token below +# REGISTRY_TOKEN — a token with write:package (and read:package) +# +# Produces, in gitea.whitlocktech.com/runicgateway/ : +# runicgateway-site:latest + runicgateway-site:sha-<7> +# +# and deploys `:latest`, which is what docker-compose.yml defaults IMAGE_TAG to. +# +# WHY THIS REPOSITORY DEPLOYS AND D6 SAID IT WOULD NOT: D6 was written before +# there was a host to deploy to, and read "ship the image, the org lead deploys". +# The org lead amended it on 2026-08-25 (D54): a marketing site whose content is +# its whole purpose is a bad fit for a manual step between merging a fix and the +# fix being visible. What D6 was protecting — that a bad build cannot reach +# production — is held by `needs: build` instead, plus every check in +# pr-checks.yml having already run on the pull request. + +name: Build and publish the image + +on: + push: + branches: [main] + workflow_dispatch: {} + +concurrency: + group: image-${{ github.ref }} + cancel-in-progress: true + +env: + REGISTRY: gitea.whitlocktech.com + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Check out the merged commit + uses: actions/checkout@v4 + + - name: Derive the image ref + # The registry path must be lowercase for Docker; the org is `RunicGateway`. + run: | + set -euo pipefail + OWNER="$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')" + SHORT_SHA="${GITHUB_SHA:0:7}" + echo "IMAGE=${REGISTRY}/${OWNER}/runicgateway-site" >> "$GITHUB_ENV" + echo "TAG=sha-${SHORT_SHA}" >> "$GITHUB_ENV" + + - name: Verify the Docker daemon is reachable + # Fails fast with a clear message if the host socket is not mounted into + # the job container — the one hard runner prerequisite. + run: | + set -euo pipefail + if ! docker info >/dev/null 2>&1; then + echo "::error::Docker daemon not reachable. Mount /var/run/docker.sock into the runner's job containers." + exit 1 + fi + echo "Docker daemon OK" + + - name: Log in to the Gitea container registry + run: | + set -euo pipefail + echo "${{ secrets.REGISTRY_TOKEN }}" \ + | docker login "${REGISTRY}" -u "${{ secrets.REGISTRY_USER }}" --password-stdin + + - name: Build and push + # Two tags from one build: `latest` for the compose default, `sha-<7>` so + # a deploy can be pinned or rolled back to an exact commit. + run: | + set -euo pipefail + docker build -f Dockerfile \ + -t "${IMAGE}:latest" \ + -t "${IMAGE}:${TAG}" \ + . + docker push "${IMAGE}:latest" + docker push "${IMAGE}:${TAG}" + + - name: Log out + if: always() + run: docker logout "${REGISTRY}" || true + + deploy: + # Roll the site onto the image `build` just pushed. `needs: build` makes this + # wait for a clean build and push — if the build fails, deploy never fires and + # the running container is left alone rather than torn down for nothing. + needs: build + runs-on: rgcom + # Guard against a workflow_dispatch fired from a branch: only main is deployed. + if: github.ref == 'refs/heads/main' + + steps: + - name: Pull the fresh image and recreate the container + # No `down` first, deliberately. There is one service and no database to + # keep still, so `up -d` recreates it in place when the pulled digest + # differs — a couple of seconds of connection refused behind the proxy + # rather than the whole stack stopped while an image is fetched. + run: | + set -euo pipefail + cd /opt/runicgateway.com + docker compose pull + docker compose up -d --remove-orphans + docker compose ps + + - name: Wait for the container to report healthy + # The image's healthcheck watches an actual response, and `npm start` runs + # the brand rewrite before the server starts — so "running" arrives well + # before "serving". Without this the job would go green on a container + # that is about to crash-loop on, say, an unwritable ./data. + run: | + set -euo pipefail + cd /opt/runicgateway.com + for attempt in $(seq 1 30); do + STATUS="$(docker compose ps --format '{{.Health}}' site | head -n 1)" + echo "attempt ${attempt}: ${STATUS:-unknown}" + if [ "$STATUS" = "healthy" ]; then + echo "Site is healthy." + exit 0 + fi + sleep 5 + done + echo "::error::The site did not become healthy within 150s." + docker compose logs --tail 100 site + exit 1 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..0277801 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,17 @@ +# Code of Conduct + +This repository is covered by the Runic Gateway organisation's Code of Conduct — the Contributor +Covenant, v2.1 — which applies identically across all ten repositories: + +**[RunicGateway/docs → CODE_OF_CONDUCT.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/CODE_OF_CONDUCT.md)** + +It covers the standards expected of everyone taking part, the scope (project spaces and public +spaces where somebody represents the project), the enforcement guidelines, and **how to report +unacceptable behaviour privately**. + +Reporting goes to the organisation maintainer. That document carries the address; this file +deliberately does not, for the same reason [SECURITY.md](SECURITY.md) does not — **D13** (`PLAN.md` +§5) keeps the published contact address in one bind-mounted file so that changing it costs a file +copy rather than a commit in ten repositories. + +Reports are handled privately, and the reporter's identity is not shared with the person reported. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..4fb1f09 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,141 @@ +# Contributing to runicgateway.com + +Thanks for your interest. This repository is the **public marketing and documentation site** for +[Runic Gateway][org] — the platform that puts a private game server's live state on a public website +without ever exposing the game to the internet. + +It is a site, not a component. Nothing else in the organisation depends on it, and it depends on +everything: almost every sentence here describes something that lives in another repository. + +By participating you agree to abide by our [Code of Conduct](CODE_OF_CONDUCT.md). + +## Read the plan first + +**[`PLAN.md`](PLAN.md) is the design of record.** It is not a sketch — it carries the verified +platform state, the org lead's fifty-odd decisions, the information architecture, the accuracy +machinery and the build phases. A change that contradicts a decision recorded there needs the +decision changed first, in the same pull request, with the reasoning written down. + +Two things it records are worth knowing before you write a line: + +- **§1 — the site never re-specifies a contract.** `docs/` is normative for the protocol, the module + API, the backend design and the installer. This site teaches, links out, and quotes versions from + data rather than prose. A page that restates a contract is a page that will be wrong later, and + nothing will notice. +- **§12 — the checks are the mechanism, and their failure is the feature.** When the platform moves, + this repository goes red so that somebody updates the site. Do not route around a check; if one is + wrong, fix the check and say why in the pull request. + +## Ways to contribute + +- **Report a bug** or **request a feature** through the [issue tracker][issues] — templates are + provided. +- **Improve a page, a check or the build** by opening a pull request. +- **Never** report a security vulnerability in a public issue. See [SECURITY.md](SECURITY.md). + +If you spot a claim on the site that is *wrong about the platform* — a version, a capability, a +command that no longer exists — that is the most valuable report this repository can receive. + +## Development setup + +**Prerequisites:** Node 22 LTS or newer. Nothing else — no database, no game server, no container +runtime for ordinary work. + +```bash +npm install +npm run dev # http://localhost:4321 +``` + +```bash +npm run build # → dist/ (prerendered pages + the Node server entry) +npm start # serve the built site, exactly as the container does +``` + +Two directories are **bind mounts at runtime and not in the repository**: `brand/` overrides the +stock branding per file, and `data/` holds the beta signup store. Both are optional locally; an +absent `brand/` produces exactly the stock site, and `data/` is created on first write. + +## The checks + +There are eleven, plus two test suites. `npm run verify` runs all of them in dependency order, and +that is what CI does on every pull request. + +```bash +npm run verify +``` + +Three of them read other repositories over the Gitea API and need a token with access to the +organisation, not just this repository: + +```bash +GITEA_TOKEN= npm run check:facts # every version agrees with its authority +GITEA_TOKEN= npm run check:quickstart # the install page still matches website's own files +GITEA_TOKEN= npm run check:reference # every name the Reference lists still exists +``` + +Without a token they fail rather than skip, deliberately: a check that silently passes when it could +not do its job is worse than no check. CI maps the org-level `REGISTRY_TOKEN` secret into +`GITEA_TOKEN` for those three steps. + +The README's "The checks, and why they are not optional" section explains what each one guards. +Read it before adding a page — several of them constrain how a page may be written, particularly +`check:tokens` (no colour literal outside `src/styles/tokens.css`) and `check:links` (no commit +permalinks into org repositories). + +## Writing for this site + +- **Understated honesty** (D8). The site reads as finished. Where something is not built, the + absence is stated as data in `src/data/notBuilt.mjs` and rendered — never implied by silence and + never dressed up as a roadmap. +- **No version in prose.** Every externally-sourced fact lives in `src/data/platform.json` and is + re-read from its authority by `check:facts`. If you find yourself typing a version number into a + sentence, put it in the data file instead. +- **No email address in `src/` or `scripts/`** (D13). The published contact is a `brand.json` field + so that changing it stays a file copy and a restart. `check:facts` enforces this. +- **British spelling**, in common with the rest of the organisation's prose. + +## Branch and pull-request workflow + +1. Branch from `main` with a descriptive name (`feature/…`, `fix/…`, `docs/…`, `chore/…`). +2. Keep changes focused; small pull requests are easier to review. +3. Run `npm run verify` before opening the pull request. +4. Push and open a pull request against `main`. Fill out the template, including the **AI-assisted + contributions** disclosure. +5. A maintainer will review; address feedback with follow-up commits. + +### Commit messages + +[Conventional Commits](https://www.conventionalcommits.org/) — `type(scope): summary`, in common +with every repository in the organisation. For example `fix(docs): correct the installer flag on the +quickstart`. + +### What merging does + +Merging to `main` builds a container image, publishes it to the Gitea registry and **deploys it** +(`.gitea/workflows/build-image.yml`). There is no separate release step and no manual promotion, so +a merge is a publication. [`DEPLOY.md`](DEPLOY.md) describes the whole path, including how to roll +back to a previous build. + +## AI-assisted contributions (disclosure required) + +This project is developed openly with AI assistance, and we ask the same transparency of everyone. +**If you used an AI tool** (Claude, Copilot, ChatGPT, Cursor, etc.) to help produce a contribution, +you must disclose it: + +- Tick the AI-usage box in the pull-request template and name the tool(s). +- Mark AI-authored commits with a trailer, e.g. `Co-Authored-By: Claude ` or + `Assisted-By: `. +- You remain responsible for every line you submit: review it, understand it, and make sure it is + correct and that you have the right to contribute it. + +Disclosed AI assistance is welcome. Undisclosed AI-generated contributions are not, and may be +closed. + +## Licence + +Runic Gateway is licensed under the **GNU General Public License v3.0 or later** (see +[LICENSE](LICENSE)). By submitting a contribution you agree that it is licensed under the same terms +(inbound = outbound) and that you have the right to contribute it. + +[org]: https://gitea.whitlocktech.com/RunicGateway +[issues]: https://gitea.whitlocktech.com/RunicGateway/runicgateway.com/issues diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..82ccbe8 --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,380 @@ +# Deploying runicgateway.com + +The operator's guide. `PLAN.md` is the design of record and explains *why* the site is shaped this +way; this file is what you follow on the host. + +**What ships:** one container image, published to the Gitea registry, and the +[`docker-compose.yml`](docker-compose.yml) in this repository. There is no installer and no +`curl | bash`. The image is built and pushed by `.gitea/workflows/build-image.yml` on every merge to +`main`, tagged `latest` and `sha-<7>`. + +``` +gitea.whitlocktech.com/runicgateway/runicgateway-site:latest +``` + +**What you provide:** a host with Docker, a reverse proxy that terminates TLS, and a DNS record. + +--- + +## Contents + +1. [What the site actually needs](#1-what-the-site-actually-needs) +2. [First deploy](#2-first-deploy) +3. [Putting a proxy in front of it](#3-putting-a-proxy-in-front-of-it) +4. [DNS and TLS](#4-dns-and-tls) +5. [Branding, without a rebuild](#5-branding-without-a-rebuild) +6. [The closed-beta tester list](#6-the-closed-beta-tester-list) +7. [Updating, and the automatic deploy](#7-updating-and-the-automatic-deploy) +8. [Rolling back](#8-rolling-back) +9. [Backups](#9-backups) +10. [When something is wrong](#10-when-something-is-wrong) + +--- + +## 1. What the site actually needs + +Very little, and that is deliberate (`PLAN.md` §6). + +| | | +|---|---| +| **Runtime** | Docker, with Compose v2 (`docker compose`, not `docker-compose`) | +| **CPU / RAM** | One core and 512 MB is comfortable. Every page but two is prerendered HTML | +| **Disk** | ~600 MB for the image, plus a SQLite file that will not reach a megabyte | +| **Network out** | Only to pull the image. The running site makes no outbound request of any kind | +| **Network in** | One HTTP port, reached by your reverse proxy | +| **Database** | None. No MariaDB, no Redis, no second service | +| **Mail** | None. The site sends no email at all (D7) — there is nothing to configure | + +It does **not** need the platform: no website, no sidecar, no shard. The site describes Runic +Gateway; it does not talk to it. + +## 2. First deploy + +### 2.1 Create the directory + +The compose file, the `.env` and both bind mounts live together. The automatic deploy +([§7](#7-updating-and-the-automatic-deploy)) expects **`/opt/runicgateway.com`**; if you put it +somewhere else, change the `cd` in `.gitea/workflows/build-image.yml`. + +```bash +sudo mkdir -p /opt/runicgateway.com +sudo chown "$USER" /opt/runicgateway.com +cd /opt/runicgateway.com +``` + +Fetch the two files from this repository — the compose file, and the environment template: + +```bash +curl -fsSLO https://gitea.whitlocktech.com/RunicGateway/runicgateway.com/raw/branch/main/docker-compose.yml +curl -fsSLO https://gitea.whitlocktech.com/RunicGateway/runicgateway.com/raw/branch/main/.env.example +mv .env.example .env +``` + +### 2.2 Create the two mounts + +```bash +mkdir -p brand data +``` + +**`data/` must be writable by uid 1000**, which is what the container runs as. If you created it as +another user: + +```bash +sudo chown -R 1000:1000 data +``` + +Two failure modes this avoids, both of which look like a broken site rather than a permission +problem: + +- **A missing directory.** Docker creates a bind-mount source that does not exist, as `root:root`. + The container then cannot open the store, and `/beta` renders with the form replaced by "the + signup is temporarily unavailable" — correct behaviour, and a confusing thing to debug. +- **`brand/` deleted later.** Same mechanism. An empty `brand/` is fine and produces exactly the + stock site; a *missing* one gets recreated as root, and since the container only ever reads it, + nothing breaks until the day you want to change the logo. + +### 2.3 Fill in the two secrets + +Open `.env`. Everything has a working default except `BETA_IP_SALT` and `BETA_FORM_KEY`, which +default to a random value **per process** — safe, but forgotten on every restart, which means every +rate-limit window resets and every open form goes stale. + +```bash +printf 'BETA_IP_SALT=%s\n' "$(openssl rand -hex 32)" >> .env +printf 'BETA_FORM_KEY=%s\n' "$(openssl rand -hex 32)" >> .env +``` + +(Then delete the two empty declarations the template shipped with, so the file has one of each.) + +Do not rotate the salt casually: it is what makes the stored `ip_hash` values meaningful, so +changing it orphans the rate-limit history of everyone already counted. The raw IP address is never +stored — `/privacy` says so, and the salt is the mechanism that makes it true. + +### 2.4 Log in to the registry and start + +The image is published to the organisation's Gitea registry. If the package is not public-read, log +in once with a token that has `read:package`: + +```bash +docker login gitea.whitlocktech.com +``` + +```bash +docker compose pull +docker compose up -d +docker compose ps +``` + +`ps` should show `site` as `running (healthy)` within about a minute. Health is a real HTTP request +rather than a process check, and the delay is expected: `npm start` runs the brand rewrite before +the server starts. + +### 2.5 Confirm it from the host + +```bash +curl -sI http://127.0.0.1:4321/ | head -n 1 +curl -sI http://127.0.0.1:4321/ | grep -i content-security-policy | cut -c1-120 +``` + +The second command matters more than the first. The site sends its **own** Content-Security-Policy, +per page, built from the hashes of that page's inline scripts and styles. If it is missing, do not +add one at the proxy — see below. + +## 3. Putting a proxy in front of it + +The container publishes on **`127.0.0.1:4321`** by default and speaks plain HTTP. Any reverse proxy +will do; the site has no opinion about which. What it does have is four requirements, and the third +is the one that is easy to get wrong and quiet when you do. + +### 3.1 Forward to the published port + +Whatever your proxy calls it: forward `runicgateway.com` (and `www.` if you want it) to +`http://127.0.0.1:4321`. There are no WebSockets, no long-polling, no streaming responses and no +upload larger than a form field, so no timeout or buffering setting needs changing. + +**If your proxy is itself in a container, or on another machine,** it cannot reach the host's +loopback. Either change the port line in `docker-compose.yml` to publish on all interfaces — + +```yaml + ports: + - "${SITE_HOST_PORT:-4321}:4321" +``` + +— and firewall the port so only the proxy reaches it, or put the proxy on a shared Docker network +and address the service as `site:4321`, publishing no host port at all. + +### 3.2 Set `X-Forwarded-For` + +**This one is load-bearing.** The beta signup rate-limits per client, and it reads the first entry of +`X-Forwarded-For`, falling back to the connection's peer address. Behind a proxy that does not set +the header, that peer address is *the proxy* — so every visitor on earth shares one bucket, and the +third signup of any hour closes the form for everybody. + +It fails toward refusing signups rather than toward accepting abuse, which is the right direction, +but it is still a broken page. Most proxies set the header by default; confirm yours does. + +`X-Forwarded-Proto` and `Host` are worth passing through as well, in common with any site behind a +proxy. + +### 3.3 Do not add security headers at the proxy + +The container already sends `Content-Security-Policy`, `X-Content-Type-Options`, `Referrer-Policy`, +`X-Frame-Options` and a `Permissions-Policy` (`PLAN.md` D48). That is deliberate: the image should be +correct on its own, and a proxy somebody else configures is a promise this repository cannot check. + +If your proxy adds its own, you get **two** of each. Browsers resolve a duplicate CSP by enforcing +the intersection — that is, the *strictest* combination of both — and since this site's policy is a +list of per-page hashes, a second generic policy from a proxy will forbid the page's own stylesheet +and inline scripts. The site renders unstyled, the documentation theme switcher stops working, and +the only symptom is a console message. + +So: strip a global CSP for this host if your proxy adds one. The one header worth adding at the +proxy is HSTS, which the container cannot sensibly set because it does not know whether it is behind +TLS. + +### 3.4 Give it a real hostname + +Two absolute URLs are generated at build time — the sitemap and the OpenGraph `og:url` — so the site +expects to be served at its own name rather than under a path. Serving it at `example.com/site/` will +work visually and produce wrong metadata. + +## 4. DNS and TLS + +The domain is registered through **Cloudflare**, with DNS on Cloudflare (`PLAN.md` §14, N1). + +1. In the Cloudflare dashboard, add an `A` record for `runicgateway.com` pointing at the host's + public IP (and `AAAA` if it has a v6 address). Add `www` as a `CNAME` to the apex if you want it. +2. Let your proxy obtain the certificate — Let's Encrypt over HTTP-01 works once the record + resolves. + +**If you leave Cloudflare's proxy on (the orange cloud)**, three of its features rewrite HTML and +will break the hash-based CSP. Check them before assuming the site is at fault: + +- **Rocket Loader** — injects a script into every page. Not covered by any hash. Turn it off. +- **Auto Minify / HTML minification** — changes the bytes of inline `