feat(delivery): phase 12 — the container, and the defect only a proxy could find
All checks were successful
PR checks / checks (pull_request) Successful in 9m46s
All checks were successful
PR checks / checks (pull_request) Successful in 9m46s
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 <noreply@anthropic.com>
This commit is contained in:
42
.dockerignore
Normal file
42
.dockerignore
Normal file
@@ -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
|
||||
61
.env.example
Normal file
61
.env.example
Normal file
@@ -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
|
||||
48
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal file
48
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal file
@@ -0,0 +1,48 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Something on the site is broken, wrong, or behaving unexpectedly
|
||||
title: "[bug] "
|
||||
labels:
|
||||
- bug
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
<!-- A clear, concise description of the problem. -->
|
||||
|
||||
## Where
|
||||
|
||||
<!-- The URL, or the page and the section. If it is a documentation page, the heading. -->
|
||||
|
||||
- Page:
|
||||
- Viewport width, if it is a layout problem:
|
||||
- Browser and version:
|
||||
|
||||
## What happened, and what you expected
|
||||
|
||||
<!--
|
||||
Include exact wording for a factual error, and the console message if there is
|
||||
one. A screenshot helps for anything visual.
|
||||
-->
|
||||
|
||||
## Is it a factual error?
|
||||
|
||||
<!--
|
||||
The most valuable reports this repository gets are claims that are WRONG about
|
||||
the platform — a version, a command, a flag, a capability that no longer works
|
||||
that way. If so, say where the correct answer lives (which repository, which
|
||||
file), because the fix is usually to a data file or a check rather than to the
|
||||
sentence.
|
||||
-->
|
||||
|
||||
## Additional context
|
||||
|
||||
<!-- Anything else that helps. -->
|
||||
|
||||
<!--
|
||||
Security issue? Do NOT file it here — see SECURITY.md for the private route.
|
||||
|
||||
A problem with the PLATFORM rather than with this site (the website, the
|
||||
sidecar, the shard plugin, the installer, the Android app) belongs in that
|
||||
repository's tracker. This one only describes them.
|
||||
-->
|
||||
11
.gitea/ISSUE_TEMPLATE/config.yaml
Normal file
11
.gitea/ISSUE_TEMPLATE/config.yaml
Normal file
@@ -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.
|
||||
38
.gitea/ISSUE_TEMPLATE/feature_request.md
Normal file
38
.gitea/ISSUE_TEMPLATE/feature_request.md
Normal file
@@ -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
|
||||
|
||||
<!--
|
||||
What were you trying to find out, or do, when the site let you down? A missing
|
||||
page is easier to judge from the question that went unanswered than from the
|
||||
page title.
|
||||
-->
|
||||
|
||||
## Proposed solution
|
||||
|
||||
<!-- What you would like to see. -->
|
||||
|
||||
## Does it belong here?
|
||||
|
||||
<!--
|
||||
Two boundaries this repository holds deliberately (PLAN.md §1):
|
||||
|
||||
- The site TEACHES; `docs/` SPECIFIES. Protocol, module API, backend design and
|
||||
installer behaviour are normative in the docs repository — a page here links
|
||||
out rather than restating them, so that it cannot drift.
|
||||
- Absences are stated as data, not implied. If the request is for something the
|
||||
platform does not do yet, it may belong in src/data/notBuilt.mjs rather than
|
||||
as a page.
|
||||
|
||||
Say which side you think it falls on; being wrong about it is fine.
|
||||
-->
|
||||
|
||||
## Additional context
|
||||
|
||||
<!-- Mockups, links, related issues, the repository the change would describe. -->
|
||||
42
.gitea/PULL_REQUEST_TEMPLATE.md
Normal file
42
.gitea/PULL_REQUEST_TEMPLATE.md
Normal file
@@ -0,0 +1,42 @@
|
||||
<!--
|
||||
Thanks for contributing to Runic Gateway!
|
||||
Please fill out the sections below and check every box before requesting review.
|
||||
|
||||
Merging to main publishes: it builds the image, pushes it to the registry and
|
||||
deploys the site. There is no separate release step. See DEPLOY.md.
|
||||
-->
|
||||
|
||||
## What & why
|
||||
|
||||
<!-- What does this PR change, and why? Link any related issue: "Closes #123". -->
|
||||
|
||||
## How it was tested
|
||||
|
||||
<!--
|
||||
`npm run verify` output is the baseline. If the change touches a page, say what
|
||||
you looked at and at what width; if it touches the container, say whether you
|
||||
built and ran the image.
|
||||
-->
|
||||
|
||||
## 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.
|
||||
150
.gitea/workflows/build-image.yml
Normal file
150
.gitea/workflows/build-image.yml
Normal file
@@ -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
|
||||
17
CODE_OF_CONDUCT.md
Normal file
17
CODE_OF_CONDUCT.md
Normal file
@@ -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.
|
||||
141
CONTRIBUTING.md
Normal file
141
CONTRIBUTING.md
Normal file
@@ -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=<token> npm run check:facts # every version agrees with its authority
|
||||
GITEA_TOKEN=<token> npm run check:quickstart # the install page still matches website's own files
|
||||
GITEA_TOKEN=<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 <noreply@anthropic.com>` or
|
||||
`Assisted-By: <tool>`.
|
||||
- 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
|
||||
380
DEPLOY.md
Normal file
380
DEPLOY.md
Normal file
@@ -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 `<style>` and `<script>`
|
||||
elements, so their hashes no longer match what the header declares. Turn it off.
|
||||
- **Email address obfuscation** — injects a script *and* rewrites the contact address into an
|
||||
obfuscated span. Turn it off; the address on this site is published deliberately (D13).
|
||||
|
||||
Everything else — caching, Brotli, HTTP/3, Always Use HTTPS — is fine. If you would rather not think
|
||||
about it, DNS-only (the grey cloud) works and the site loses nothing, since it uses no third-party
|
||||
resource at all.
|
||||
|
||||
## 5. Branding, without a rebuild
|
||||
|
||||
`brand/` is the override; the image's `brand-default/` is the stock. **Every file resolves against
|
||||
the mount first and the defaults second, per file**, so a directory holding only `theme.css`
|
||||
recolours the site and leaves every logo alone. `PLAN.md` §7 is the full account.
|
||||
|
||||
```
|
||||
brand/
|
||||
brand.json Site name, tagline, contact address, Discord invite, Gitea org,
|
||||
demo URL, Play opt-in URL
|
||||
logo.png ONE raster. Every size the site asks for — header at three pixel
|
||||
ratios, install icons, apple-touch, favicons, a real .ico — is
|
||||
derived from it on demand
|
||||
theme.css Redefines the custom properties in tokens.css. It wins by cascade
|
||||
layer, so it does not need to be loaded last
|
||||
```
|
||||
|
||||
```bash
|
||||
# edit or drop in a file, then:
|
||||
docker compose restart site
|
||||
```
|
||||
|
||||
The restart is what rewrites the brand text into the prerendered HTML and re-indexes search — a
|
||||
mounted site name has to reach forty-nine pages that were rendered before the file existed.
|
||||
|
||||
**One field needs no restart:** `betaOptInUrl`. `/beta` renders per request and reads it live, so the
|
||||
day the Play closed test opens, pasting the URL into `brand/brand.json` puts a working link on the
|
||||
confirmation screen on the very next request.
|
||||
|
||||
To see which source answered a given asset:
|
||||
|
||||
```bash
|
||||
curl -sI http://127.0.0.1:4321/brand/logo.png | grep -i x-brand-source
|
||||
```
|
||||
|
||||
`mount`, `default`, or `derived:mount` / `derived:default` when the size was generated on demand
|
||||
from whichever `logo.png` is in force.
|
||||
|
||||
## 6. The closed-beta tester list
|
||||
|
||||
There is no admin page, by design (`PLAN.md` §8) — the site has no authenticated surface at all. The
|
||||
list is managed from a shell against the mount.
|
||||
|
||||
```bash
|
||||
cd /opt/runicgateway.com
|
||||
|
||||
docker compose exec site node scripts/beta.mjs stats
|
||||
docker compose exec site node scripts/beta.mjs export # marks the rows exported
|
||||
docker compose exec site node scripts/beta.mjs export --all # everything, again
|
||||
docker compose exec site node scripts/beta.mjs remove someone@example.com
|
||||
```
|
||||
|
||||
`export` writes two files into `data/exports/` — a CSV record, and a `.txt` of one address per line,
|
||||
which is the format Google Play's tester list accepts. They are on the bind mount, so they are on the
|
||||
host at `./data/exports/` and can be copied off with `scp` like any other file.
|
||||
|
||||
`remove` is a deletion request, and it **overwrites** the address, the IP hash and the user agent
|
||||
rather than flagging the row. That is what `/privacy` promises; the row survives only as an anonymous
|
||||
record that a signup happened.
|
||||
|
||||
## 7. Updating, and the automatic deploy
|
||||
|
||||
Merging to `main` builds the image, pushes it, and **deploys it** (D54). No manual step, no release
|
||||
tag. What protects production is that every one of the eleven checks and both test suites have
|
||||
already run on the pull request, and the deploy job is `needs: build`, so a failed build never
|
||||
reaches the host.
|
||||
|
||||
That requires a Gitea Actions runner **on this host**, labelled `rgcom`, running jobs directly on
|
||||
the host rather than inside a container — it needs the host's Docker daemon and
|
||||
`/opt/runicgateway.com`.
|
||||
|
||||
```bash
|
||||
# On the host, once. Get the registration token from
|
||||
# Gitea → the repository → Settings → Actions → Runners → Create new runner
|
||||
act_runner register \
|
||||
--no-interactive \
|
||||
--instance https://gitea.whitlocktech.com \
|
||||
--token <REGISTRATION_TOKEN> \
|
||||
--name runicgateway-com-host \
|
||||
--labels rgcom:host
|
||||
```
|
||||
|
||||
`rgcom:host` — the `:host` suffix is what makes jobs run on the machine rather than in a job
|
||||
container. Without it the job starts in a container with no Docker socket and no
|
||||
`/opt/runicgateway.com`, and fails on the `cd`.
|
||||
|
||||
Make sure the user the runner runs as can talk to Docker (`docker ps` succeeds) and can read and
|
||||
write `/opt/runicgateway.com`.
|
||||
|
||||
**To update by hand instead** — always available, and what you do if the runner is down:
|
||||
|
||||
```bash
|
||||
cd /opt/runicgateway.com
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
The compose file has no `build:` at all, so a production host can only ever pull.
|
||||
|
||||
## 8. Rolling back
|
||||
|
||||
Every merge publishes two tags: `latest` and `sha-<7>` of the commit. To pin:
|
||||
|
||||
```bash
|
||||
cd /opt/runicgateway.com
|
||||
sed -i 's/^IMAGE_TAG=.*/IMAGE_TAG=sha-1806406/' .env
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
Set it back to `latest` to resume following `main`. Note that while it is pinned, the automatic
|
||||
deploy still runs and still pulls — but Compose recreates the container on the *pinned* tag, so the
|
||||
site stays where you put it. That is the intended behaviour: a pin is a decision, and a merge should
|
||||
not quietly undo it.
|
||||
|
||||
The tags are listed under **Packages** on the organisation's Gitea page.
|
||||
|
||||
## 9. Backups
|
||||
|
||||
One file matters: `data/beta.sqlite`. The rest of the site is in the image and in git.
|
||||
|
||||
It is a live SQLite database in WAL mode, so **do not just `cp` it** — a copy taken mid-write can
|
||||
miss committed rows sitting in the `-wal` file. Either use SQLite's own backup, which is safe against
|
||||
a running writer:
|
||||
|
||||
```bash
|
||||
cd /opt/runicgateway.com
|
||||
docker compose exec site node -e "const db=require('better-sqlite3')(process.env.DATA_DIR+'/beta.sqlite');db.exec(\"VACUUM INTO '/app/data/beta-backup.sqlite'\");db.close()"
|
||||
mv data/beta-backup.sqlite /somewhere/safe/beta-$(date +%F).sqlite
|
||||
```
|
||||
|
||||
or stop the container first and copy all three files (`beta.sqlite`, `-wal`, `-shm`) together.
|
||||
|
||||
`brand/` is worth keeping too, if you have customised it — it is the one part of a running
|
||||
deployment that exists nowhere else.
|
||||
|
||||
## 10. When something is wrong
|
||||
|
||||
```bash
|
||||
cd /opt/runicgateway.com
|
||||
docker compose logs --tail 200 site
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
| Symptom | Cause worth checking first |
|
||||
|---|---|
|
||||
| Container restarts, or never becomes healthy | `data/` not writable by uid 1000 — `sudo chown -R 1000:1000 data` |
|
||||
| `/beta` says the signup is unavailable | Same. The rest of the site is unaffected, which is by design |
|
||||
| Every visitor hits the rate limit | The proxy is not setting `X-Forwarded-For` — [§3.2](#32-set-x-forwarded-for) |
|
||||
| Rate limits reset on every restart | `BETA_IP_SALT` is unset in `.env` — [§2.3](#23-fill-in-the-two-secrets) |
|
||||
| Pages render unstyled; console says `Refused to apply inline style` | A second CSP from the proxy or from Cloudflare Rocket Loader / minification — [§3.3](#33-do-not-add-security-headers-at-the-proxy), [§4](#4-dns-and-tls) |
|
||||
| A new logo or site name has not appeared | The mount needs a `docker compose restart site`, not just a file edit — [§5](#5-branding-without-a-rebuild) |
|
||||
| Search finds the old site name | Same restart; the boot rewrite re-indexes |
|
||||
| The site is stock despite files in `brand/` | Check the mount actually landed: `docker compose exec site ls /app/brand` |
|
||||
|
||||
Everything the container writes goes to stdout, so `docker compose logs` is the whole log. The
|
||||
reverse proxy's access log is the only traffic data that exists — there are no analytics anywhere on
|
||||
the site (D9).
|
||||
102
Dockerfile
Normal file
102
Dockerfile
Normal file
@@ -0,0 +1,102 @@
|
||||
# runicgateway.com — the one container (PLAN.md §6).
|
||||
#
|
||||
# Two stages. The first has the whole toolchain and produces `dist/`; the second
|
||||
# carries the built site, the pruned runtime dependencies and nothing else.
|
||||
#
|
||||
# Debian slim rather than Alpine, deliberately. Two of the runtime dependencies
|
||||
# are native — `better-sqlite3` (the beta store, §8) and `sharp` (the brand
|
||||
# derivations, §7) — and both publish prebuilt binaries for glibc. On musl they
|
||||
# are compiled from source instead, which means a C++ toolchain, libvips headers
|
||||
# and several minutes in the image build, to save about sixty megabytes on a
|
||||
# thing that is pulled a few times a year. A third, `pagefind`, ships a platform
|
||||
# binary and is needed at RUN time, not just build time: the boot rewrite
|
||||
# re-indexes the site after the brand strings change.
|
||||
|
||||
# ---------------------------------------------------------------------------------------
|
||||
# Stage 1 — build
|
||||
# ---------------------------------------------------------------------------------------
|
||||
FROM node:22-bookworm-slim AS build
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Dependencies first, so an edit to a page does not re-resolve the tree.
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Then the source. .dockerignore keeps node_modules, dist and BOTH bind mounts out.
|
||||
COPY . .
|
||||
|
||||
# Prerenders every marketing, legal and documentation page, builds the Node
|
||||
# server entry for the two routes that run per request, and writes the per-route
|
||||
# Content-Security-Policy into dist/_headers.json (D48).
|
||||
#
|
||||
# No token and no network: everything the build reads is in this context. The
|
||||
# checks that DO need the Gitea API — facts, quickstart, reference — run in CI
|
||||
# against the pull request, which is the right place for them. An image build
|
||||
# that could fail because another repository's server was slow would be an image
|
||||
# build people learn to retry rather than read.
|
||||
RUN npm run build
|
||||
|
||||
# Drop the devDependencies from the tree the runtime stage inherits. Pruning
|
||||
# here rather than running a second `npm ci --omit=dev` below keeps the native
|
||||
# modules exactly as they were resolved and built once.
|
||||
RUN npm prune --omit=dev
|
||||
|
||||
# ---------------------------------------------------------------------------------------
|
||||
# Stage 2 — runtime
|
||||
# ---------------------------------------------------------------------------------------
|
||||
FROM node:22-bookworm-slim AS runtime
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
HOST=0.0.0.0 \
|
||||
PORT=4321
|
||||
|
||||
# Both bind mounts, named here so the code's own `process.cwd()` defaults are
|
||||
# never what a container relies on. See docker-compose.yml.
|
||||
ENV BRAND_DIR=/app/brand \
|
||||
DATA_DIR=/app/data
|
||||
|
||||
# The stock brand, baked in and always complete (§7). Every /brand/* URL resolves
|
||||
# against the mount first and this second, per file.
|
||||
ENV BRAND_DEFAULT_DIR=/app/brand-default
|
||||
|
||||
# `dist/` is owned by `node` because the boot rewrite WRITES to it: applyBrand.mjs
|
||||
# rewrites the prerendered HTML from what it last applied to what the mount now
|
||||
# says, records that in dist/.brand-applied.json, and re-indexes dist/client/pagefind
|
||||
# so search finds the mounted site name. A read-only dist would make §7's promise
|
||||
# — recolour and rename by copying a file — fail at boot with a permission error.
|
||||
COPY --from=build --chown=node:node /build/node_modules ./node_modules
|
||||
COPY --from=build --chown=node:node /build/dist ./dist
|
||||
COPY --from=build --chown=node:node /build/brand-default ./brand-default
|
||||
COPY --from=build --chown=node:node /build/scripts ./scripts
|
||||
COPY --from=build --chown=node:node /build/package.json ./package.json
|
||||
|
||||
# `src/` is here for one reason: the tester-list CLI. §8 has no admin page by
|
||||
# design, so managing the closed beta is `docker compose exec site node
|
||||
# scripts/beta.mjs …`, and that reaches into src/lib/betaStore.mjs. Nothing
|
||||
# serving a request reads it — the pages were prerendered in stage 1.
|
||||
COPY --from=build --chown=node:node /build/src ./src
|
||||
|
||||
# Both mount points exist in the image, owned by the runtime user. An operator
|
||||
# who forgets a mount then gets a working stock site and an empty store rather
|
||||
# than a container that will not start; and `data/` being writable by uid 1000
|
||||
# BEFORE Docker creates it is what stops the store failing to open. If the host
|
||||
# directory is owned by someone else, `chown 1000:1000 ./data` on the host.
|
||||
RUN mkdir -p /app/brand /app/data && chown -R node:node /app/brand /app/data
|
||||
|
||||
USER node
|
||||
|
||||
EXPOSE 4321
|
||||
|
||||
# Cheap, and it tests the thing that actually breaks: `npm start` runs the brand
|
||||
# rewrite BEFORE the server, so a container can sit alive for a long moment with
|
||||
# nothing listening. A healthcheck that only watched the process would call that
|
||||
# healthy.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
|
||||
CMD node -e "fetch('http://127.0.0.1:' + (process.env.PORT || 4321) + '/').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
|
||||
|
||||
# applyBrand.mjs, then serve.mjs. Not dist/server/entry.mjs directly — see the
|
||||
# header comment in scripts/serve.mjs for the adapter bug that wrapper exists for.
|
||||
CMD ["npm", "start"]
|
||||
135
PLAN.md
135
PLAN.md
@@ -225,7 +225,7 @@ Taken by the org lead (Colby Whitlock) on 2026-08-19. Recorded so they are not r
|
||||
| **D3** | **All ten documentation conflicts are fixed**, not just the operator-facing five. | §4. |
|
||||
| **D4** | **Real web screenshots**, captured from the local review stack, not placeholders. | §13 phase 6. Needs seeded, presentable demo content. |
|
||||
| **D5** | **Claude drafts `/privacy` and `/terms`** from what the code actually collects; the org lead reviews before ship. | §9. |
|
||||
| **D6** | **Ship the image and compose file; the org lead deploys.** DNS and TLS terminate at their existing reverse proxy. | §13 phase 9. This repo never touches the production host. |
|
||||
| **D6** | **Ship the image and compose file; the org lead deploys.** DNS and TLS terminate at their existing reverse proxy. | §13 phase 9. **Amended by D54 (2026-08-25):** a merge to `main` now builds, publishes and deploys. What D6 protected is held by `needs: build` and by every check having run on the pull request. |
|
||||
| **D7** | **The site sends no email at all.** No SMTP, no notifications, no mailbox behind the domain yet. | §8 designs the signup so it works anyway — see "The opt-in link removes the need for email". A contact address is still required; D13 supplies it. |
|
||||
| **D8** | **Understated honesty.** The site reads as finished; factual badges appear only where they save a reader wasted effort. **The Integration Kit stays marked draft until a second module is successfully built against it.** | §11, §10. A status with an exit criterion, not a mood. |
|
||||
| **D9** | **No analytics.** No tracking scripts, no third-party requests, no cookie banner. | Reverse-proxy access logs are the only traffic data. |
|
||||
@@ -236,7 +236,7 @@ Taken by the org lead (Colby Whitlock) on 2026-08-19. Recorded so they are not r
|
||||
|
||||
**Decisions after D13 are recorded where they were taken**, in the section describing the phase that
|
||||
raised them, rather than appended here — a decision is only re-litigated when its reasoning is
|
||||
somewhere other than the thing it decided. The count of record is **fifty-three**:
|
||||
somewhere other than the thing it decided. The count of record is **fifty-seven**:
|
||||
|
||||
| # | Where | What it settled |
|
||||
|---|---|---|
|
||||
@@ -250,6 +250,7 @@ somewhere other than the thing it decided. The count of record is **fifty-three*
|
||||
| D42–D46 | §10, "How phase 9 took the screenshots" | The full rig behind the imagery, a neutral demo brand, the captures beside the claims, a committed and checked capture pipeline, the world dressed in the plugin repo's scaffolding |
|
||||
| D47–D50 | §6, "How phase 10 polished it" | Search reaches the marketing pages, the CSP is a real response header from the container, `robots.txt` allows everything and names the sitemap, two blocks of structured data and no more |
|
||||
| D51–D53 | §6, "How phase 11 validated it" | The chrome and the head follow the brand mount while the consent sentence does not, the documentation half gets phase 10's skip-link fix, and no twelfth check |
|
||||
| D54–D57 | §6, "How phase 12 delivered it" | A merge deploys (amending D6), the proxy is documented by its requirements rather than by an example, the container trusts the forwarded headers with nothing to configure, and the operator note is its own file while the two policy files are pointers |
|
||||
|
||||
---
|
||||
|
||||
@@ -514,6 +515,125 @@ per-capability deep links pointed into the mounted demo. And an opt-in URL paste
|
||||
file reached the confirmation screen on the next request, with no restart — which is the one part of
|
||||
§7 that has to be true on the day the closed test opens.
|
||||
|
||||
### How phase 12 delivered it
|
||||
|
||||
The last phase, and the one that turns a repository into a deployment: a two-stage `Dockerfile`, a
|
||||
pull-only `docker-compose.yml` carrying both bind mounts, `.env.example`, the publishing workflow,
|
||||
`CONTRIBUTING.md` with the AI-disclosure requirement, the community-health files this repository was
|
||||
the only one in the organisation to lack, and `DEPLOY.md`. Four decisions, **D54–D57**, taking the
|
||||
count of record to **fifty-seven**.
|
||||
|
||||
It also found a defect that would have made the closed beta impossible, and it is the only phase
|
||||
that could have found it. Everything before this ran the site the way a developer runs it: one
|
||||
process, plain HTTP, an origin the browser and the server agree about by construction. The site does
|
||||
not run that way. Standing a real container behind a real proxy is a different question, and it had
|
||||
a different answer.
|
||||
|
||||
#### D54 — a merge deploys, which amends D6
|
||||
|
||||
D6 said "ship the image and the compose file; the org lead deploys", and it was written before there
|
||||
was a host to deploy to. The org lead amended it on 2026-08-25: `.gitea/workflows/build-image.yml`
|
||||
builds, pushes `runicgateway-site:latest` and `:sha-<7>`, and then rolls the container over on a
|
||||
runner labelled **`rgcom`** on the site's own host, out of **`/opt/runicgateway.com`**. There is no
|
||||
release step and no promotion, so **a merge is a publication**.
|
||||
|
||||
What D6 was protecting is held by something else now. Every one of the eleven checks and both test
|
||||
suites have already run on the pull request; the deploy job is `needs: build`, so a failed build
|
||||
never reaches the host at all; and the job then waits for the container's own healthcheck rather
|
||||
than for `up -d` to return, because `npm start` runs the brand rewrite *before* the server starts and
|
||||
"running" therefore arrives well before "serving". A pinned `IMAGE_TAG` survives an automatic
|
||||
deploy — Compose recreates on the pinned tag — because a pin is a decision and a merge should not
|
||||
quietly undo it.
|
||||
|
||||
The runner registers with the label `rgcom:host`. The `:host` suffix is what makes jobs run on the
|
||||
machine rather than inside a job container; without it the deploy fails on the `cd`, having no
|
||||
Docker socket and no compose directory.
|
||||
|
||||
#### D55 — a generic proxy, documented by its requirements rather than by an example
|
||||
|
||||
The site runs on its own host, behind whatever reverse proxy the org lead puts there. So
|
||||
`DEPLOY.md` does not carry a worked Caddyfile or nginx block that would be wrong for three readers
|
||||
out of four; it states the four things the proxy must do, and the container binds to **`127.0.0.1`**
|
||||
by default so that the safe configuration is the default one.
|
||||
|
||||
Two of the four are worth repeating here because they are silent when wrong:
|
||||
|
||||
- **`X-Forwarded-For` is load-bearing.** The signup rate-limits per client from the first entry of
|
||||
that header, falling back to the connection's peer address. Behind a proxy that does not set it,
|
||||
the 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 and still a broken page.
|
||||
- **A second Content-Security-Policy from the proxy breaks every page.** Browsers enforce the
|
||||
intersection of duplicate policies, and this site's is a list of per-page hashes, so a generic
|
||||
policy added at the proxy forbids the page's own stylesheet. The container already sends CSP,
|
||||
`X-Content-Type-Options`, `Referrer-Policy`, `X-Frame-Options` and `Permissions-Policy` (D48), so
|
||||
the proxy's job is to add none of them. The same paragraph names the three Cloudflare features
|
||||
that rewrite HTML — Rocket Loader, HTML minification and email obfuscation — because each of them
|
||||
breaks the hashes in exactly the same way, from a dashboard rather than from a config file.
|
||||
|
||||
#### D56 — the container trusts the forwarded headers, with nothing to configure
|
||||
|
||||
**The defect.** `@astrojs/node` builds the URL of every request from the connection and the `Host`
|
||||
header alone. In `astro/app/node`'s `createRequestFromNodeRequest`:
|
||||
|
||||
```js
|
||||
const isEncrypted = "encrypted" in req.socket && req.socket.encrypted;
|
||||
const protocol = isEncrypted ? "https" : "http";
|
||||
```
|
||||
|
||||
`x-forwarded-proto` is never consulted on that path — and `security.allowedDomains`, which sounds
|
||||
like the answer, is not: on this code path it gates only whether `Astro.clientAddress` may come from
|
||||
`x-forwarded-for`. Behind a proxy that terminates TLS, the browser sends `Origin:
|
||||
https://runicgateway.com` and the container computes `http://runicgateway.com`, because its own
|
||||
socket is plaintext. Astro's CSRF middleware then compares the two for equality:
|
||||
|
||||
```js
|
||||
const isSameOrigin = request.headers.get("origin") === url.origin;
|
||||
```
|
||||
|
||||
So **every beta signup, from every visitor, is answered `403 Cross-site POST form submissions are
|
||||
forbidden`** — on the one route that accepts a POST, on the site whose nearest real deadline is a
|
||||
closed test that cannot start without it. No proxy configuration fixes it; a proxy cannot make the
|
||||
container's socket encrypted. Fifty pages look perfectly healthy while the form silently refuses
|
||||
everyone.
|
||||
|
||||
`scripts/serve.mjs` already exists to wrap this adapter's mistakes (D48), so the fix went there:
|
||||
`x-forwarded-proto: https` marks the socket encrypted, and `x-forwarded-host` replaces `Host` for
|
||||
the proxies that rewrite it to the upstream address instead of passing it through.
|
||||
|
||||
**Both are trusted unconditionally, with no flag to set** — the org lead's call, and the right one.
|
||||
The image is meant to be deployed and work; an operator who has to discover a `TRUST_PROXY` variable
|
||||
to make the signup work is an operator who ships a dead form, and the broken configuration would be
|
||||
the default. It costs nothing: a cross-site form submission cannot make a victim's browser send
|
||||
`x-forwarded-proto`, so the CSRF check is exactly as strong as it was, and the site has no cookie,
|
||||
session or credential to protect in the first place. Two assertions in `test/headers.test.mjs` hold
|
||||
both halves — a proxy-shaped POST is accepted, and a genuinely cross-origin one is still refused.
|
||||
|
||||
#### D57 — the operator note is its own file, and two policies are pointers
|
||||
|
||||
`DEPLOY.md` rather than a README section: the README is for someone working *on* the site and was
|
||||
already long, and deployment is a different task for a different sitting — it is also the file that
|
||||
gets opened on the host. It carries what the site needs, first deploy, the proxy, DNS and TLS,
|
||||
branding without a rebuild, the tester-list CLI, updating, rolling back, backups, and a symptoms
|
||||
table.
|
||||
|
||||
This repository was also the only one of the ten with no `CONTRIBUTING.md`, no
|
||||
`CODE_OF_CONDUCT.md`, no `SECURITY.md` and no issue or pull-request templates, so phase 12 added
|
||||
them. **The two policy files are pointers to the organisation's copies in `docs`, not copies** —
|
||||
because a copy would hard-code the contact address in a tenth place, and D13's whole promise is that
|
||||
the address lives only in `brand.json` and moves for the cost of a file copy. `checkFacts.mjs` scans
|
||||
`src/` and `scripts/`; these files honour the same rule voluntarily, and say so, so that the next
|
||||
person does not "fix" the missing address.
|
||||
|
||||
`SECURITY.md` is not only a pointer, though. It names what is actually worth reporting *here* —
|
||||
the signup's store, rate limit and signed form token; path traversal out of the branding mount; and
|
||||
a page served no policy or another page's — and states plainly that there is no authenticated
|
||||
surface to attack.
|
||||
|
||||
**No twelfth check.** D53 was re-tested against this phase's new drift risk — the environment
|
||||
variables the code reads, `.env.example` declares and the README tabulates — and the org lead held
|
||||
the line. Eleven checks stand.
|
||||
|
||||
---
|
||||
|
||||
## 7. Branding is bind-mounted data
|
||||
@@ -1491,11 +1611,16 @@ a mechanism rather than diligence:
|
||||
| **9** | Screenshots (D4): stand up the local review stack, seed presentable content, capture the admin panel, Teams, forums, marketplace, spawn atlas and shard console; build the screenshot components. **Plus an emulator pass against the same seeded stack** to fill `/app/`'s reserved slot (D26) |
|
||||
| **10** | Polish: responsive, accessibility, SEO/OpenGraph/sitemap/robots, full-text search, CSP headers. See D47–D50 — the CSP was the work, because `@astrojs/node` served every page another page's policy |
|
||||
| **11** | Validation: `astro check`, production build, **all eleven check scripts** (tokens, brand, links, facts, quickstart, data safety, reference, sidebar, screens, a11y, CSP) plus both test suites, mobile layout verified in a real browser, a signup walked end to end. See D51–D53 — the scripts were green before the phase started; the browser walk and a real brand mount are what found the three defects |
|
||||
| **12** | Delivery: Dockerfile, `docker-compose.yml` with both bind mounts documented, Gitea Actions workflow publishing to the registry, README, CONTRIBUTING with the AI-disclosure requirement, and an operator note covering DNS, TLS and the reverse proxy (D6) |
|
||||
| **12** | Delivery: two-stage Dockerfile, pull-only `docker-compose.yml` with both bind mounts, `.env.example`, the Gitea Actions workflow that publishes **and deploys** (D54), README, CONTRIBUTING, the community-health files this repo alone lacked, and `DEPLOY.md` (D55, D57). It found the one defect that only a container behind a proxy can find — see D56 |
|
||||
|
||||
Phases 5 and 6 are deliberately adjacent and early: the beta cannot start without `/privacy`, and
|
||||
the closed test is the nearest real deadline.
|
||||
|
||||
**All twelve are built, as of 2026-08-25.** What is left is not a phase: point the DNS record at the
|
||||
host (§14, N1), start the `rgcom` runner, and — when the demo VM exists (§15) and the Play track is
|
||||
open — put two URLs into the mounted `brand.json`. None of those is a code change, which was the
|
||||
point.
|
||||
|
||||
---
|
||||
|
||||
## 14. Still needed from the org lead
|
||||
@@ -1503,7 +1628,9 @@ the closed test is the nearest real deadline.
|
||||
None of these block starting Phase 0 or Phase 1.
|
||||
|
||||
**N1 — Resolved.** `runicgateway.com` is registered through **Cloudflare**, with DNS on Cloudflare.
|
||||
The domain does not resolve to anything yet; the record is pointed at the host in phase 12.
|
||||
The domain does not resolve to anything yet. Phase 12 shipped everything needed to point it: the
|
||||
`A` record, the proxy requirements and the three Cloudflare features that break a hash-based CSP are
|
||||
in `DEPLOY.md` §4. Creating the record is the org lead's, on the day the host is up.
|
||||
|
||||
**N2 — Settled by D13, and no longer blocking anything.** As of 2026-08-19 no mailbox exists at the
|
||||
domain and the org lead chose not to wait for one. **`whitlocktech@gmail.com` is the published
|
||||
|
||||
39
README.md
39
README.md
@@ -11,11 +11,12 @@ closed beta: **players**, who want the app.
|
||||
platform state, the org lead's decisions, the information architecture, and the build phases. Read
|
||||
it before changing anything here.
|
||||
|
||||
**Status: phase 5 of 12 — the app and the beta.** The foundation, the branding pipeline, the
|
||||
homepage and the five marketing pages are built, and `/app/` and `/beta/` now join them: a signed
|
||||
APK beside the closed-test signup, backed by a SQLite store on a bind mount and an export CLI. Next
|
||||
are the legal pages (phase 6) and then the documentation — the installation path, which is the
|
||||
priority of the whole project — in phases 7 and 8.
|
||||
**Status: phase 12 of 12 — delivery. The site is built.** Fifty pages: ten marketing, legal and
|
||||
app pages and forty of documentation, with real screenshots of the product, full-text search, a
|
||||
per-page Content-Security-Policy, eleven checks that fail the build when the platform moves out from
|
||||
under a claim, and a closed-beta signup backed by SQLite on a bind mount. This phase is the part
|
||||
that makes it a deployment rather than a repository — the container image, the compose file, the
|
||||
publishing workflow and [`DEPLOY.md`](DEPLOY.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -31,7 +32,22 @@ npm run build # → dist/ (prerendered pages + the Node server entry)
|
||||
npm start # serve the built site
|
||||
```
|
||||
|
||||
Node 22 LTS or newer.
|
||||
Node 22 LTS or newer. Nothing else — no database, no game server, no container runtime.
|
||||
|
||||
## Running it in production
|
||||
|
||||
One container, pulled from the Gitea registry, with two bind mounts and a reverse proxy in front.
|
||||
**[`DEPLOY.md`](DEPLOY.md) is the operator's guide**: first deploy, what the proxy must and must not
|
||||
do, DNS and TLS, branding without a rebuild, managing the tester list, rolling back, and the
|
||||
symptoms table.
|
||||
|
||||
```bash
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
Merging to `main` builds the image, publishes it as `runicgateway-site:latest` and `:sha-<7>`, and
|
||||
deploys it — `.gitea/workflows/build-image.yml`. There is no separate release step, so **a merge is
|
||||
a publication**.
|
||||
|
||||
## The checks, and why they are not optional
|
||||
|
||||
@@ -234,6 +250,10 @@ scripts/ The build-time checks, plus applyBrand and serve (boot)
|
||||
test/ node --test. The logic the other checks cannot see.
|
||||
PLAY_DATA_SAFETY.md GENERATED. The answers to Google Play's Data Safety form, from
|
||||
src/data/collection.mjs. Edit the data, run npm run play:datasafety.
|
||||
Dockerfile Two stages. Build with the toolchain, run with the pruned tree.
|
||||
docker-compose.yml Production. Pull-only, one service, both bind mounts.
|
||||
.env.example The two secrets worth setting, and every default made visible.
|
||||
DEPLOY.md The operator's guide: proxy, DNS, TLS, branding, backups.
|
||||
```
|
||||
|
||||
Two directories are bind mounts at runtime and are **not** in the repository: `brand/` overrides
|
||||
@@ -244,13 +264,18 @@ and §7.
|
||||
|
||||
Branch from `main` (`feature/…`, `fix/…`, `docs/…`, `chore/…`) and use
|
||||
[Conventional Commits](https://www.conventionalcommits.org/). Run `npm run verify` before opening a
|
||||
pull request.
|
||||
pull request. **[CONTRIBUTING.md](CONTRIBUTING.md)** has the rest, including the two rules from
|
||||
`PLAN.md` that constrain how a page may be written at all: the site never re-specifies a contract,
|
||||
and no fact is stated in prose.
|
||||
|
||||
**AI-assisted contributions must be disclosed**, per org policy: tick the box in the pull request
|
||||
template naming the tool, and mark AI-authored commits with a trailer such as
|
||||
`Co-Authored-By: Claude <noreply@anthropic.com>`. Undisclosed AI-generated contributions may be
|
||||
closed.
|
||||
|
||||
Security problems go to [SECURITY.md](SECURITY.md), never to a public issue. Everyone taking part is
|
||||
covered by the [Code of Conduct](CODE_OF_CONDUCT.md).
|
||||
|
||||
## Licence
|
||||
|
||||
GPL-3.0-or-later, in common with every repository in the organisation. See [LICENSE](LICENSE).
|
||||
|
||||
43
SECURITY.md
Normal file
43
SECURITY.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Security Policy
|
||||
|
||||
**Please do not report security vulnerabilities through public issues, pull requests or the wiki.**
|
||||
A public report tips off attackers before a fix is available.
|
||||
|
||||
Report privately, using the contact route in the organisation's security policy:
|
||||
|
||||
**[RunicGateway/docs → SECURITY.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/SECURITY.md)**
|
||||
|
||||
That document is the single copy for all ten repositories, and it carries the address, what to
|
||||
include in a report and what to expect back.
|
||||
|
||||
## Why this file is a pointer rather than a copy
|
||||
|
||||
Every other repository in the organisation states the reporting address inline. This one does not,
|
||||
and the reason is a decision of record rather than an oversight: **D13** (`PLAN.md` §5) confines the
|
||||
published contact address to `brand.json`, a bind-mounted file, so that changing it is a file copy
|
||||
and a container restart rather than a commit. `scripts/checkFacts.mjs` fails the build if an address
|
||||
appears anywhere in `src/` or `scripts/`, and this file honours the same rule voluntarily — a
|
||||
hard-coded address in the repository root would be one more place to forget when the address moves.
|
||||
|
||||
## What is worth reporting here
|
||||
|
||||
This site holds one thing of value and has one writing endpoint.
|
||||
|
||||
- **The closed-beta signup** (`/beta`, `PLAN.md` §8) is the only route that writes. It stores an
|
||||
email address, a consent record and a **salted hash of the IP address** — never the address
|
||||
itself. Anything that lets a caller read rows, bypass the rate limit or the total cap, forge the
|
||||
signed form token, or recover an IP from a hash is in scope and worth reporting.
|
||||
- **The branding mount** (`GET /brand/*`, §7) reads files from a directory an operator controls.
|
||||
Path traversal out of that directory, or reaching a file type outside the route's allowlist, is in
|
||||
scope.
|
||||
- **The Content-Security-Policy** is a real response header written by `scripts/serve.mjs`. A page
|
||||
that is served no policy, or another page's policy, is a defect worth reporting — that exact bug
|
||||
has happened here once already (`PLAN.md` D48).
|
||||
|
||||
The site has **no authenticated surface at all**, by design: the tester list is managed from a shell
|
||||
against the bind mount, not from an admin page. There is no session, no cookie and no login to
|
||||
attack.
|
||||
|
||||
Vulnerabilities in the **platform itself** — the website, the sidecar, the shard plugin, the
|
||||
installer or the Android app — belong in the organisation's policy linked above, not here. This
|
||||
repository only describes them.
|
||||
94
docker-compose.yml
Normal file
94
docker-compose.yml
Normal file
@@ -0,0 +1,94 @@
|
||||
# runicgateway.com — production.
|
||||
#
|
||||
# Pull-only, in common with the rest of the org: `image:` and no `build:`, so a
|
||||
# production host can never accidentally build. The image is published to the
|
||||
# Gitea registry by .gitea/workflows/build-image.yml on every merge to main.
|
||||
#
|
||||
# Full operator guide, including DNS, TLS and the reverse proxy: DEPLOY.md.
|
||||
#
|
||||
# docker compose pull && docker compose up -d
|
||||
#
|
||||
# One service. §6 records why there is no second one: the dynamic surface is two
|
||||
# routes, and one container is one thing to deploy, one thing to patch and one
|
||||
# log to read.
|
||||
|
||||
services:
|
||||
site:
|
||||
# IMAGE_TAG defaults to `latest`. Pin a build for a reproducible deploy or a
|
||||
# rollback — e.g. IMAGE_TAG=sha-1806406 in .env; every merge publishes both.
|
||||
image: gitea.whitlocktech.com/runicgateway/runicgateway-site:${IMAGE_TAG:-latest}
|
||||
restart: unless-stopped
|
||||
|
||||
# Secrets and tuning for the beta signup (§8). The file is optional in the
|
||||
# sense that the site starts without it — but read the note in .env.example
|
||||
# about BETA_IP_SALT and BETA_FORM_KEY before deciding to skip it: their
|
||||
# defaults are random PER PROCESS, so leaving them unset means every restart
|
||||
# forgets who has been rate-limited.
|
||||
env_file: .env
|
||||
|
||||
volumes:
|
||||
# ---------------------------------------------------------------------------
|
||||
# The branding mount (§7). Read-only: nothing in the container ever writes
|
||||
# here, and the whole point of the directory is that a human puts files in
|
||||
# it from the host.
|
||||
#
|
||||
# May be empty, partial or complete. Every file resolves against this mount
|
||||
# first and the image's brand-default/ second, PER FILE — so a directory
|
||||
# holding only theme.css recolours the site and leaves every logo stock,
|
||||
# and an empty directory produces exactly the stock site.
|
||||
#
|
||||
# Changing a file here takes a RESTART, not a rebuild: `docker compose
|
||||
# restart site` re-runs the boot rewrite, which is what puts a new site
|
||||
# name into forty-nine prerendered pages. The exception is brand.json's
|
||||
# betaOptInUrl, which /beta reads live on every request — so the closed
|
||||
# test can be opened by editing one file, with no restart at all.
|
||||
- ./brand:/app/brand:ro
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The beta signup store (§8): beta.sqlite and exports/. Read-WRITE, and the
|
||||
# only thing this site persists.
|
||||
#
|
||||
# A bind mount rather than a named volume because the tester list has to be
|
||||
# reachable from the host — `sqlite3 ./data/beta.sqlite`, a backup by `cp`,
|
||||
# and the CSV the export CLI writes into ./data/exports/ for pasting into
|
||||
# Play. A named volume would put all three behind `docker cp`.
|
||||
#
|
||||
# The container runs as uid 1000. Docker creates a MISSING bind-mount source
|
||||
# as root:root, and the store then fails to open — so create the directory
|
||||
# yourself and, if it is owned by someone else, `chown 1000:1000 ./data`.
|
||||
# DEPLOY.md has the two commands.
|
||||
- ./data:/app/data
|
||||
|
||||
# Bound to loopback, because TLS terminates at a reverse proxy on this host
|
||||
# and nothing else has any business reaching the container directly.
|
||||
#
|
||||
# CHANGE THIS if your proxy runs in its own container or on another machine:
|
||||
# it then cannot reach 127.0.0.1 of the host, and the binding must become
|
||||
# `"${SITE_HOST_PORT:-4321}:4321"` (all interfaces) with a firewall in front,
|
||||
# or the proxy must join a shared Docker network and address the service by
|
||||
# name instead of by port. DEPLOY.md, "Putting a proxy in front of it".
|
||||
ports:
|
||||
- "127.0.0.1:${SITE_HOST_PORT:-4321}:4321"
|
||||
|
||||
# Repeats the image's own HEALTHCHECK so `docker compose ps` reports it even
|
||||
# when the image is pinned to an older tag that predates it. It watches an
|
||||
# actual response rather than the process, because `npm start` runs the brand
|
||||
# rewrite before the server: there is a real window where the container is up
|
||||
# and nothing is listening.
|
||||
#
|
||||
# The command is a QUOTED flow sequence, which is not a style choice: written as a
|
||||
# block sequence, YAML reads the `: ` inside `r.ok ? 0 : 1` as a key/value separator
|
||||
# and `docker compose config` refuses the file with "healthcheck.test.3 must be a
|
||||
# string". Keep the quotes.
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:4321/').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
start_period: 40s
|
||||
retries: 3
|
||||
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
@@ -1,10 +1,10 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* serve.mjs — the production entry point (PLAN.md §6, D48).
|
||||
* serve.mjs — the production entry point (PLAN.md §6, D48, D56).
|
||||
*
|
||||
* `npm start` runs `applyBrand.mjs` and then this, instead of `dist/server/entry.mjs`
|
||||
* directly. It is a thin wrapper around the adapter's own handler and exists for two
|
||||
* reasons, one of them a bug in a dependency.
|
||||
* directly. It is a thin wrapper around the adapter's own handler and exists for three
|
||||
* reasons, two of them things `@astrojs/node` gets wrong.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* 1. THE ADAPTER SERVES THE WRONG PAGE'S CONTENT-SECURITY-POLICY
|
||||
@@ -119,7 +119,66 @@ process.env.ASTRO_NODE_AUTOSTART = 'disabled';
|
||||
// letter, and Node's ESM loader reads `c:` as an unsupported URL scheme.
|
||||
const { handler } = await import(pathToFileURL(path.join(root, 'dist', 'server', 'entry.mjs')).href);
|
||||
|
||||
/* ---------------------------------------------------------------------------------------
|
||||
3. THE FORWARDED HEADERS THE ADAPTER DOES NOT READ
|
||||
--------------------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Make the request look, to the adapter, like what the browser actually sent.
|
||||
*
|
||||
* `@astrojs/node` builds the URL of every request from the connection and the `Host`
|
||||
* header alone — `astro/app/node`'s `createRequestFromNodeRequest`:
|
||||
*
|
||||
* const isEncrypted = "encrypted" in req.socket && req.socket.encrypted;
|
||||
* const protocol = isEncrypted ? "https" : "http";
|
||||
*
|
||||
* `x-forwarded-proto` is never consulted on this path. (`security.allowedDomains` does not
|
||||
* help: on this code path it gates only whether `Astro.clientAddress` may come from
|
||||
* `x-forwarded-for`.)
|
||||
*
|
||||
* Behind a proxy that terminates TLS — which is how this site is deployed, and the only
|
||||
* way it is deployed — that is fatal to the one route that accepts a POST. The browser
|
||||
* sends `Origin: https://runicgateway.com`; the container computes `http://runicgateway.com`
|
||||
* because its own socket is plaintext; and Astro's CSRF middleware compares the two for
|
||||
* EQUALITY:
|
||||
*
|
||||
* const isSameOrigin = request.headers.get("origin") === url.origin;
|
||||
*
|
||||
* So every beta signup, from every visitor, is answered `403 Cross-site POST form
|
||||
* submissions are forbidden`. No proxy configuration can fix it — a proxy cannot make this
|
||||
* container's socket encrypted — and nothing else on the site changes, so the symptom is a
|
||||
* form that silently refuses everyone while fifty pages look perfectly healthy.
|
||||
*
|
||||
* Both headers are trusted unconditionally, with no flag to set. The image is meant to be
|
||||
* deployed and work: it publishes on loopback for a proxy to reach, and an operator who has
|
||||
* to discover a `TRUST_PROXY` variable to make the signup work is an operator who ships a
|
||||
* dead form. Trusting them costs nothing here — a cross-site form submission cannot make a
|
||||
* victim's browser send `x-forwarded-proto`, so the CSRF check is exactly as strong as it
|
||||
* was, and the site has no cookie, session or credential to protect in the first place.
|
||||
*
|
||||
* `x-forwarded-host` is handled for the same reason at one remove: most proxies pass `Host`
|
||||
* through untouched, but some rewrite it to the upstream address and put the real name here
|
||||
* instead, which produces the identical mismatch.
|
||||
*/
|
||||
const firstForwarded = (value) => value?.toString().split(',')[0].trim();
|
||||
|
||||
const applyForwardedHeaders = (req) => {
|
||||
const proto = firstForwarded(req.headers['x-forwarded-proto']);
|
||||
if (proto === 'https' && !req.socket.encrypted) {
|
||||
// What `"encrypted" in req.socket` reads. Defined on the socket rather than passed
|
||||
// along, because the adapter is given the raw request and looks there itself.
|
||||
Object.defineProperty(req.socket, 'encrypted', { value: true, configurable: true });
|
||||
}
|
||||
|
||||
const forwardedHost = firstForwarded(req.headers['x-forwarded-host']);
|
||||
if (forwardedHost && !/[/\\]/.test(forwardedHost)) {
|
||||
req.headers.host = forwardedHost;
|
||||
}
|
||||
};
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
applyForwardedHeaders(req);
|
||||
|
||||
const policy = policies.get(normalise(req.url ?? '/'));
|
||||
|
||||
for (const [key, value] of Object.entries(STATIC_HEADERS)) res.setHeader(key, value);
|
||||
|
||||
@@ -133,4 +133,61 @@ describe('the headers the server sends', { skip: built ? false : 'no build in di
|
||||
assert.ok(!scriptSrc.includes("'unsafe-inline'"), `${route} script-src allows unsafe-inline`);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* D56, phase 12. The signup POST behind a TLS-terminating proxy.
|
||||
*
|
||||
* `@astrojs/node` derives the request URL's protocol from `req.socket.encrypted` and
|
||||
* never reads `x-forwarded-proto`, so a container reached over plaintext by a proxy
|
||||
* computes `http://<host>` while the browser is sending `Origin: https://<host>`. Astro's
|
||||
* CSRF middleware compares those two for equality, so the answer is 403 — for every
|
||||
* visitor, on the only page that accepts a POST.
|
||||
*
|
||||
* This is the shape of a request as a proxy actually delivers it, and it belongs in this
|
||||
* file rather than in `npm test` for the same reason everything else here does: the build
|
||||
* was already correct, the store was already correct, and the failure existed only in the
|
||||
* bytes on the wire. `serve.mjs` normalises both forwarded headers; these assertions are
|
||||
* what stop that being deleted as unnecessary.
|
||||
*
|
||||
* It stops at the origin check deliberately — a signup that reached the store would write
|
||||
* a row into whatever `data/` the developer running the suite happens to have.
|
||||
*/
|
||||
it('accepts a form POST forwarded by a proxy that terminated TLS', async () => {
|
||||
const host = 'runicgateway.com';
|
||||
const res = await fetch(base + '/beta/', {
|
||||
method: 'POST',
|
||||
redirect: 'manual',
|
||||
headers: {
|
||||
host,
|
||||
origin: `https://${host}`,
|
||||
'x-forwarded-proto': 'https',
|
||||
'x-forwarded-host': host,
|
||||
'x-forwarded-for': '203.0.113.7',
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
// No form token, so the signup refuses it — but it must refuse it as a stale form,
|
||||
// rendering the page, rather than as a cross-site request.
|
||||
body: 'email=&consent=&ts=',
|
||||
});
|
||||
|
||||
assert.notEqual(res.status, 403, 'the proxy-shaped POST was refused as cross-site (D56)');
|
||||
assert.equal(res.status, 200);
|
||||
});
|
||||
|
||||
it('still refuses a genuinely cross-site POST', async () => {
|
||||
const res = await fetch(base + '/beta/', {
|
||||
method: 'POST',
|
||||
redirect: 'manual',
|
||||
headers: {
|
||||
host: 'runicgateway.com',
|
||||
origin: 'https://not-us.example.com',
|
||||
'x-forwarded-proto': 'https',
|
||||
'x-forwarded-host': 'runicgateway.com',
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: 'email=&consent=&ts=',
|
||||
});
|
||||
|
||||
assert.equal(res.status, 403, 'the forwarded-header fix weakened the CSRF check');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user