Compare commits
70 Commits
feat/phase
...
8701126d64
| Author | SHA1 | Date | |
|---|---|---|---|
| 8701126d64 | |||
| e7bbc250a5 | |||
| d59c993430 | |||
| 9b11c5cb99 | |||
| 06a8892895 | |||
| a918e9248e | |||
| abf7dceb06 | |||
| c47e9c1aa4 | |||
| 4f38e29ad7 | |||
| 56473d4d51 | |||
| ec65b2d5aa | |||
| 297cc83db5 | |||
| ecc556d7ac | |||
| fe86fc369d | |||
| 9b28c95305 | |||
| aad3a37c09 | |||
| 75c8ffc07a | |||
| a100195e7b | |||
| 4426d3e339 | |||
| 6046214d71 | |||
| 9938e71e07 | |||
| 2971b84d51 | |||
| b1584043a3 | |||
| c3c347d237 | |||
| 4e1ce2316a | |||
| 73f664c38e | |||
| e91e76bfa9 | |||
| 5f58a4cbf2 | |||
| 82b55e3084 | |||
| a0af01026e | |||
| 5c6735bd1d | |||
| 5e987518c6 | |||
| 3574bba4d5 | |||
| d01fd55f43 | |||
| 83c438885e | |||
| 391a4a131b | |||
| c8a293b8f6 | |||
| 916921551f | |||
| 7709b055a4 | |||
| 92a1a33121 | |||
| 283814dbf2 | |||
| 782e3df7e2 | |||
| e0d491d33b | |||
| ebd6ba8dbf | |||
| 289b6b3a8d | |||
| 5ba91717c0 | |||
| 2da014790e | |||
| 268e1c98fc | |||
| 12c416f5fc | |||
| ce13a44ed7 | |||
| b86f4cabf0 | |||
| e60812fb34 | |||
| 92f00ab20a | |||
| 3b4067fa37 | |||
| 1558111050 | |||
| f2e59a2426 | |||
| 18064062a9 | |||
| de9d25bbe7 | |||
| 58883951a4 | |||
| e71ff4acd4 | |||
| a34c2ce538 | |||
| 8b6efd5c0a | |||
| ebb3f71786 | |||
| c29ec94f46 | |||
| 31d914ba44 | |||
| b3cb6bf1eb | |||
| d89ce06bb8 | |||
| e8cb6061fe | |||
| a993b872ac | |||
| bcb633403f |
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
|
||||
70
.env.example
Normal file
@@ -0,0 +1,70 @@
|
||||
# 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.
|
||||
SITE_HOST_PORT=4321
|
||||
|
||||
# Which of the host's addresses that port is published on. The default is every
|
||||
# interface, so the site answers on the host's own address — http://<vm-ip>:4321
|
||||
# — which is what a proxy in another container, another machine, or a browser
|
||||
# elsewhere on the network needs.
|
||||
#
|
||||
# Narrow it if this host has a public address and you want only the proxy to
|
||||
# reach the container: 127.0.0.1 for a proxy on this same host, or one interface
|
||||
# address for the LAN but not a public NIC. Nothing else in the site changes.
|
||||
SITE_BIND_ADDR=0.0.0.0
|
||||
|
||||
# ---------------------------------------------------------------------------------------
|
||||
# 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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
209
.gitea/workflows/build-image.yml
Normal file
@@ -0,0 +1,209 @@
|
||||
# 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
|
||||
# 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}" \
|
||||
.
|
||||
|
||||
- name: No layer may exceed the registry's request limit
|
||||
# Gitea is behind Cloudflare, which refuses a request body over 100 MB on
|
||||
# every plan below Enterprise, and `docker push` uploads each layer as one
|
||||
# monolithic PUT. An oversized layer is therefore rejected at the EDGE:
|
||||
# Gitea never sees it, the log says only `413 Payload Too Large` against a
|
||||
# blob digest, and the image is not published at all. That is what happened
|
||||
# on the first merge after phase 12, and the Dockerfile's three-layer
|
||||
# node_modules split is what fixed it.
|
||||
#
|
||||
# A split is a margin, not a guarantee, so this counts the layers before
|
||||
# the push rather than letting the next fat dependency rediscover the 413.
|
||||
# 90 MB, not 100: the cap is on the whole request, and the blob is not the
|
||||
# only thing in it.
|
||||
#
|
||||
# Measured by re-compressing what `docker save` writes, because the daemon
|
||||
# exposes uncompressed sizes only and the limit applies to the compressed
|
||||
# blob. gzip is what the push uses, so the numbers agree to within a per
|
||||
# cent; both archive layouts are handled, since a layer is already gzipped
|
||||
# in one of them and plain in the other.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
LIMIT_MB=90
|
||||
|
||||
docker save "${IMAGE}:${TAG}" -o /tmp/image.tar
|
||||
mkdir -p /tmp/layers
|
||||
tar -xf /tmp/image.tar -C /tmp/layers
|
||||
|
||||
WORST_MB=0
|
||||
WORST_FILE=""
|
||||
# Only files big enough to matter; everything else is metadata.
|
||||
while IFS= read -r f; do
|
||||
if [ "$(head -c 2 "$f" | od -An -tx1 | tr -d ' \n')" = "1f8b" ]; then
|
||||
SIZE=$(stat -c %s "$f") # already compressed
|
||||
else
|
||||
SIZE=$(gzip -c "$f" | wc -c) # compress it the way the push will
|
||||
fi
|
||||
MB=$(( SIZE / 1048576 ))
|
||||
if [ "$MB" -gt "$WORST_MB" ]; then
|
||||
WORST_MB=$MB
|
||||
WORST_FILE=$f
|
||||
fi
|
||||
done < <(find /tmp/layers -type f -size +8M)
|
||||
|
||||
rm -rf /tmp/image.tar /tmp/layers
|
||||
|
||||
echo "Largest layer: ${WORST_MB} MB compressed (limit ${LIMIT_MB} MB)"
|
||||
if [ "$WORST_MB" -gt "$LIMIT_MB" ]; then
|
||||
echo "::error::A layer is ${WORST_MB} MB compressed (${WORST_FILE}). Cloudflare rejects a request body over 100 MB, so this push would fail with 413 Payload Too Large and publish nothing. Split the layer in the Dockerfile — see the COPY block that splits node_modules."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Push
|
||||
run: |
|
||||
set -euo pipefail
|
||||
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'
|
||||
# Until a runner with this label exists, this job simply QUEUES. That is the
|
||||
# intended behaviour and it breaks nothing: `build` has already published the
|
||||
# image, so `docker compose pull && up -d` by hand is available the whole time,
|
||||
# and the queued job runs the moment the runner registers.
|
||||
|
||||
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
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
node-version: '22.23.4'
|
||||
cache: npm
|
||||
|
||||
- name: Install
|
||||
@@ -65,6 +65,40 @@ jobs:
|
||||
# the shorter form would pass locally and break only here.
|
||||
run: npm test
|
||||
|
||||
- name: Sidebar
|
||||
# PLAN.md §12, phase 8. src/config/sidebar.mjs holds two trees — the one Starlight
|
||||
# renders and the one §10 planned — and they must agree on groups, labels and
|
||||
# ORDER. Order because the order of "Getting started" IS the installation path.
|
||||
#
|
||||
# While pages were being written the planned tree was a checklist; now that every
|
||||
# page exists it is a hand-maintained second copy, and it had already drifted
|
||||
# unnoticed (phase 7 added Content under D37 and never updated it). Nothing caught
|
||||
# that because nothing read it.
|
||||
#
|
||||
# No token, no network, no build — so it runs early and fails fast.
|
||||
run: npm run check:sidebar
|
||||
|
||||
- name: Game vocabulary
|
||||
# PLAN.md §16, D63/D64 — the twelfth check, phase 14. The site may NAME the games it
|
||||
# supports anywhere, but a game's vocabulary (shard, guild, clan, uo-link, Oxide…)
|
||||
# belongs in that game's own docs group, its sidebar group, or a <GameExample> block
|
||||
# that labels it (D68). Core pages use core's words: a Team, a game server, the
|
||||
# sidecar, the bridge plugin, the game module.
|
||||
#
|
||||
# No token, no network, no build — so it runs early and fails fast.
|
||||
run: npm run check:terms
|
||||
|
||||
- name: Screenshots
|
||||
# PLAN.md §12, phase 9 (D45). src/data/screens.mjs is the one list of what the site
|
||||
# shows of itself: every entry must have a file, at the size the markup declares, and
|
||||
# every file must have an entry. The size half is the one that repays the check —
|
||||
# a re-capture taken at the wrong viewport looks perfectly fine on its own and only
|
||||
# reveals itself as a page that reflows while it decodes.
|
||||
#
|
||||
# No browser and no game server: the capture tool is an authoring script whose output
|
||||
# is committed, exactly like the brand assets, so CI only reads what it produced.
|
||||
run: npm run check:screens
|
||||
|
||||
- name: Production build
|
||||
run: npm run build
|
||||
|
||||
@@ -81,6 +115,17 @@ jobs:
|
||||
# fails because some other host is slow is a check people learn to ignore.
|
||||
run: npm run check:links
|
||||
|
||||
- name: Game vocabulary in the built pages
|
||||
# PLAN.md §16, D79 — phase 17 widened the twelfth check to the whole site. The
|
||||
# marketing pages are templates fed from src/data/, so the words a reader sees are
|
||||
# mostly not in any source file: a capability label, a screenshot caption, a value
|
||||
# from platform.json. This pass reads every built page's visible text, alt, title and
|
||||
# aria-label, the way check:links reads its hrefs. A game's routes, and elements
|
||||
# marked data-game (<GameExample>, a game-tagged screenshot), allow that game's words.
|
||||
#
|
||||
# Still the twelfth check, not a thirteenth: the same script, the same word list.
|
||||
run: npm run check:terms:built
|
||||
|
||||
- name: Platform facts
|
||||
# PLAN.md §12 — every version, protocol number and bundle tag is re-read from
|
||||
# its authority over the Gitea API and must agree with src/data/platform.json.
|
||||
@@ -115,3 +160,64 @@ jobs:
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: npm run check:quickstart
|
||||
|
||||
- name: Reference enumerations against their sources
|
||||
# PLAN.md §12, phase 8. The Reference section names things — every environment
|
||||
# variable, config key, installer command, visibility rung and canonical document.
|
||||
# §1 forbids re-specifying a contract, and this is what makes writing the NAMES
|
||||
# down safe anyway: each list is a SET comparison against the repository that owns
|
||||
# it, in both directions.
|
||||
#
|
||||
# The second direction is the one that earns its keep. A reference page does not
|
||||
# usually rot by describing something that vanished — it rots by quietly not
|
||||
# mentioning the three things added since it was written.
|
||||
#
|
||||
# Descriptions are deliberately NOT checked; nothing here can know whether a
|
||||
# one-line summary is still true, so it does not pretend to.
|
||||
#
|
||||
# Same token, and for the same reason: it reads five other repositories in the org.
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: npm run check:reference
|
||||
|
||||
- name: The headers the server actually sends
|
||||
# PLAN.md §6 / D48, phase 10. Every other check reads dist/; this one starts
|
||||
# scripts/serve.mjs and reads the responses, because the defect it exists for
|
||||
# happened after the build was already correct. @astrojs/node matched a request to a
|
||||
# policy with a SUBSTRING test, so /modules/ was served the policy built for
|
||||
# /docs/modules/building-a-module — every file on disk right, the bytes on the wire
|
||||
# wrong, and the page rendered with its own stylesheet refused.
|
||||
#
|
||||
# It needs the build, so it cannot live in the "Unit tests" step above.
|
||||
run: npm run test:served
|
||||
|
||||
- name: Accessibility
|
||||
# PLAN.md §13, phase 10. Seven structural rules over every built page: one <h1> and
|
||||
# no skipped heading level, an alt attribute on every image, a label on every form
|
||||
# control, an accessible name on every link and button, <html lang>, one <main> with
|
||||
# a skip link that reaches it, and no positive tabindex.
|
||||
#
|
||||
# Structural on purpose. A static check cannot measure contrast on a rendered page
|
||||
# or find a focus trap, and a check that pretended to would be trusted for things it
|
||||
# cannot see. What it does catch is the class of defect that is invisible to a
|
||||
# sighted author and permanent once shipped — and it covers Starlight's forty pages
|
||||
# too, so a dependency upgrade that loses a label is a red build rather than a
|
||||
# discovery.
|
||||
#
|
||||
# After the build, because it reads dist/client. No token and no network.
|
||||
run: npm run check:a11y
|
||||
|
||||
- name: Content-Security-Policy
|
||||
# PLAN.md §6 / D48. The policy is a real response header — the Node adapter's
|
||||
# staticHeaders writes dist/_headers.json and the standalone server sends it — so
|
||||
# frame-ancestors applies and the operator's proxy needs no CSP config.
|
||||
#
|
||||
# The check that matters is the second one: every inline script and style must be
|
||||
# covered by a hash in ITS OWN page's policy. Astro does not hash <script is:inline>,
|
||||
# and Starlight ships six of them per documentation page, so the first build with CSP
|
||||
# enabled had a strict, correct header and a dead theme switcher — a failure with no
|
||||
# symptom except a console message. A Starlight upgrade can reintroduce it at any
|
||||
# time, which is why this runs on every PR rather than once.
|
||||
#
|
||||
# After the build, because it reads dist/. No token and no network.
|
||||
run: npm run check:csp
|
||||
|
||||
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
@@ -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 twelve, 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
|
||||
409
DEPLOY.md
Normal file
@@ -0,0 +1,409 @@
|
||||
# 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** | ~750 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 port is published on every interface by default ([§3.1](#31-forward-to-the-published-port)), so
|
||||
the same two commands work from any other machine on the network with the host's address in place of
|
||||
`127.0.0.1` — which is the quickest way to look at the site in a real browser before DNS or the
|
||||
proxy exists.
|
||||
|
||||
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 **port 4321 of every interface** by default and speaks plain HTTP, so it
|
||||
answers both on `http://127.0.0.1:4321` and on the host's own address — `http://<vm-ip>:4321`. 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://<host>:4321` — loopback if the proxy runs on this same machine, the host's address if it
|
||||
runs in another container or on another machine. 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.
|
||||
|
||||
A proxy on a shared Docker network can address the service as `site:4321` instead and skip the host
|
||||
port entirely.
|
||||
|
||||
**Narrowing the binding.** `SITE_BIND_ADDR` in `.env` decides which addresses the port answers on,
|
||||
and nothing else in the site changes with it:
|
||||
|
||||
```bash
|
||||
SITE_BIND_ADDR=0.0.0.0 # every interface — the default
|
||||
SITE_BIND_ADDR=192.168.1.10 # one interface: the LAN, but not a public NIC
|
||||
SITE_BIND_ADDR=127.0.0.1 # loopback only: a proxy on THIS host and nothing else
|
||||
```
|
||||
|
||||
**On a host with a public address, the default means port 4321 answers from the internet directly**,
|
||||
beside whatever the proxy serves on 443 — plain HTTP, no TLS, and no proxy in the path to set
|
||||
`X-Forwarded-For` ([§3.2](#32-set-x-forwarded-for)), so signups arriving that way share one
|
||||
rate-limit bucket. There is no login and nothing to steal, so this is untidy rather than dangerous —
|
||||
but on a public host, firewall the port or narrow the binding.
|
||||
|
||||
### 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`.
|
||||
|
||||
**Until that runner exists, the deploy job just queues**, and nothing is harmed: the image has
|
||||
already been built and pushed by the time it would run, so the manual update below works throughout,
|
||||
and the queued job goes as soon as the runner registers.
|
||||
|
||||
**One way the automatic deploy can fail before it starts.** The registry is behind Cloudflare, which
|
||||
refuses a request body over 100 MB, and `docker push` uploads each image layer as a single request —
|
||||
so a layer that grows past that is rejected at the edge with `413 Payload Too Large`, publishing
|
||||
nothing. `needs: build` then keeps the deploy from running at all, which means the container you are
|
||||
already serving is left alone; the site is simply not updated. The workflow checks layer sizes before
|
||||
it pushes and fails with a message naming the layer, so this should announce itself rather than
|
||||
arriving as a `413`. Either way it is fixed in the `Dockerfile` (see the `COPY` block that splits
|
||||
`node_modules`) and nothing needs doing on the host.
|
||||
|
||||
**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` |
|
||||
| A merge did not deploy, and the build job is red | If it failed on the layer check or on a `413`, a layer grew past Cloudflare's 100 MB request limit — [§7](#7-updating-and-the-automatic-deploy). The running container is untouched; the fix is in the Dockerfile, not on the host |
|
||||
|
||||
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).
|
||||
132
Dockerfile
Normal file
@@ -0,0 +1,132 @@
|
||||
# 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.23.4-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
|
||||
|
||||
# Set the two largest packages aside so the runtime stage can copy them as their
|
||||
# own layers. See the COPY block below for why a single node_modules layer could
|
||||
# not be pushed at all. Moving them rather than copying them twice is what keeps
|
||||
# the three layers disjoint: whatever is left in node_modules is exactly the
|
||||
# remainder, and a dependency added later lands in it automatically.
|
||||
RUN mkdir -p /split \
|
||||
&& mv node_modules/@pagefind /split/ \
|
||||
&& mv node_modules/@img /split/
|
||||
|
||||
# ---------------------------------------------------------------------------------------
|
||||
# Stage 2 — runtime
|
||||
# ---------------------------------------------------------------------------------------
|
||||
FROM node:22.23.4-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.
|
||||
# node_modules arrives in THREE layers, not one, and the reason is the registry
|
||||
# rather than anything about the site.
|
||||
#
|
||||
# Gitea sits behind Cloudflare, which refuses a request body over 100 MB on every
|
||||
# plan below Enterprise, and `docker push` uploads each layer as one monolithic
|
||||
# PUT. A single `COPY node_modules` measured **108.8 MB compressed** — nine over —
|
||||
# so the first merge to `main` after phase 12 failed with `413 Payload Too Large`
|
||||
# on that one blob, from the edge, with Gitea never seeing the request. Nothing
|
||||
# was published, and `needs: build` meant nothing was deployed either.
|
||||
#
|
||||
# `@pagefind` (the search index binaries) and `@img` (sharp's libvips) are the two
|
||||
# packages that make it fat and both are needed at RUN time — the boot rewrite
|
||||
# re-indexes the site and re-derives the brand images — so the fix is where they
|
||||
# land, not whether they ship. Split, they measure 54.7 + 50.6 + 12.1 MB, the
|
||||
# largest with about 45 MB of headroom.
|
||||
#
|
||||
# That headroom is why the workflow counts layers before it pushes: this is a
|
||||
# margin, not a guarantee, and a dependency that grows past it would otherwise
|
||||
# come back as the same unreadable 413. See `.gitea/workflows/build-image.yml`.
|
||||
COPY --from=build --chown=node:node /build/node_modules ./node_modules
|
||||
COPY --from=build --chown=node:node /split/@pagefind ./node_modules/@pagefind
|
||||
COPY --from=build --chown=node:node /split/@img ./node_modules/@img
|
||||
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"]
|
||||
@@ -32,6 +32,7 @@ Where the console offers free text about security practices, two things are wort
|
||||
| App info and performance | Other app data | No | No | Not collected by us. Stored on the device only. |
|
||||
| Messages | Other in-app messages | No | No | Not collected by us. Declare the relay hop in the console’s free-text security section if it asks. |
|
||||
| Messages | Other user-generated content | No | No | Not collected by us. |
|
||||
| Messages | Other in-app messages | No | No | Not collected by us. Stored on the device only. |
|
||||
| Device or other IDs | Device or other IDs | No | No | Not collected. |
|
||||
|
||||
## Each answer, and why it is the truthful one
|
||||
@@ -83,12 +84,23 @@ Push is off until you enable it. When you do, the app mints a random, unguessabl
|
||||
|
||||
**Messages → Other user-generated content.** Not collected by us.
|
||||
|
||||
Forum posts, Team activity, character and shard information, notification preferences: all of it is a live read or write against the deployment. Nothing is cached for offline use and nothing is duplicated anywhere else — the app with no signal is an app with no content, which is a limitation and also an accurate description of where the data lives.
|
||||
Forum posts, Team activity, what the game module shows you, notification preferences: all of it is a live read or write against the deployment. Apart from the notification snapshot described in the next entry, nothing is cached for offline use and nothing is duplicated anywhere else — the app with no signal is an app with almost no content, which is a limitation and also an accurate description of where the data lives.
|
||||
|
||||
- **Why that answer:** Content is written to the community’s own installation. We have no copy, no access and no way to obtain one.
|
||||
- **Retention:** Held by the deployment, under its operator’s policy
|
||||
- **Read from:** `PLAN.md §9 section 2`
|
||||
|
||||
### A snapshot of your notifications, so the inbox opens without a signal
|
||||
|
||||
**Messages → Other in-app messages.** Not collected by us. Stored on the device only.
|
||||
|
||||
The app keeps the most recent notifications it has already fetched — at most thirty, and only the first page — on the device, so opening the inbox shows you what you had rather than a spinner. It is a copy of what the deployment already sent you and it is refreshed from there; nothing is written here that was not read from your own account. It is scoped to the account that fetched it, so a second person signing in on the same phone is never shown the first one’s messages.
|
||||
|
||||
- **Why that answer:** The snapshot is written on the phone from data the deployment had already delivered. It is not uploaded anywhere, and no server we operate is on either end of it.
|
||||
- **Retention:** Until you sign out, or the thirty are pushed out by newer ones
|
||||
- **In detail:** Signing out deletes the snapshot outright. It lives in the app’s ordinary preference store rather than the encrypted one — sign-in tokens are the thing that store is for — which is worth stating plainly: on a device where someone has root, these are readable, and they are notification bodies rather than credentials.
|
||||
- **Read from:** `core/inbox/DataStoreInboxCache.kt, data/repository/AuthRepository.kt`
|
||||
|
||||
### No analytics, no crash reporting, no advertising
|
||||
|
||||
**Device or other IDs → Device or other IDs.** Not collected.
|
||||
@@ -116,4 +128,4 @@ Not part of the Data Safety form — that form is about the app — but a review
|
||||
- **Your browser’s user-agent string, truncated** — With the row; blanked on removal.
|
||||
- **The web server’s access log** — Short-term operational retention, then rotated away.
|
||||
|
||||
Last generated from data dated 2026-08-24. Regenerate with `npm run play:datasafety` after any change to what the app stores.
|
||||
Last generated from data dated 2026-09-01. Regenerate with `npm run play:datasafety` after any change to what the app stores.
|
||||
|
||||
113
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, twelve 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,21 +32,45 @@ npm run build # → dist/ (prerendered pages + the Node server entry)
|
||||
npm start # serve the built site
|
||||
```
|
||||
|
||||
Node 22 LTS or newer.
|
||||
Node 22.23.4+ (or 24.22.1+); older builds carry the Node2Shell Brotli bug, CVE-2026-111497. 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
|
||||
|
||||
Two of them, both from `PLAN.md` §12. Neither is a linter; each one enforces a promise the site
|
||||
makes that would otherwise decay quietly.
|
||||
Twelve of them, from `PLAN.md` §12. None is a linter; each one enforces a promise the site makes
|
||||
that would otherwise decay quietly.
|
||||
|
||||
```bash
|
||||
npm run check:sidebar # the rendered docs tree still matches the planned one
|
||||
npm run check:terms # no game's vocabulary outside its section: docs + sidebar
|
||||
npm run check:screens # every screenshot has an entry, at the size declared
|
||||
npm run check:tokens # no colour literal outside the token file
|
||||
npm run check:brand # the branding pipeline's two quiet failures
|
||||
npm run check:datasafety # the Play declaration still matches /privacy
|
||||
npm run check # astro check
|
||||
npm test # the beta signup's decision path, and the policy data
|
||||
npm run build && npm run check:links # every internal link resolves (reads the build)
|
||||
npm run build # everything below reads the build
|
||||
npm run check:links # every internal link resolves
|
||||
npm run check:terms:built # the same rule over every built page, as rendered
|
||||
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
|
||||
npm run check:a11y # seven structural accessibility rules, every page
|
||||
npm run check:csp # every inline script and style is hashed in its policy
|
||||
npm run verify # all of the above, in that order
|
||||
```
|
||||
|
||||
@@ -81,6 +106,22 @@ carry are assembled from data files and template literals and a source scan sees
|
||||
also refuses a commit permalink into any org repository — those stop tracking the document they name
|
||||
without ever 404ing, which is the failure a link checker would otherwise call healthy.
|
||||
|
||||
**`checkA11y.mjs`** applies seven structural rules to every built page — one `<h1>` and no skipped
|
||||
heading level, an `alt` on every image, a label on every form control, an accessible name on every
|
||||
link and button, `<html lang>`, one `<main>` with a skip link that reaches it, and no positive
|
||||
`tabindex`. Structural on purpose: a static check cannot measure contrast on a rendered page or find
|
||||
a focus trap, and one that pretended to would be trusted for things it cannot see. It covers
|
||||
Starlight's forty pages as well as our ten, so a dependency upgrade that loses a label turns the
|
||||
build red rather than becoming a discovery.
|
||||
|
||||
**`checkCsp.mjs`** verifies that every route has a policy and that **every inline script and style is
|
||||
covered by a hash in its own page's policy**. That second rule is the one that earns its keep: Astro
|
||||
does not hash `<script is:inline>`, and Starlight ships six of them per documentation page, so the
|
||||
first build with CSP enabled had a strict, correct header and a dead theme switcher — a failure whose
|
||||
only symptom is a console message. When Starlight is upgraded and a hash stops matching,
|
||||
`npm run csp:hashes` rebuilds, re-harvests `src/config/cspHashes.mjs` and rebuilds again; read the
|
||||
diff before committing it, because that file is a list of scripts allowed to run.
|
||||
|
||||
**`npm test`** is the one check that reads none of the above. Everything else inspects built output,
|
||||
and the beta signup's logic does not appear there: a honeypot can stop working entirely and produce
|
||||
a build identical to one where it works. It covers the honeypot, the signed form token, the timing
|
||||
@@ -147,6 +188,40 @@ Regenerating the stock assets is a separate, manual step — `npm run brand:asse
|
||||
the emblem and the Cinzel outlines from the sibling checkouts in the workspace. Its output is
|
||||
committed so that CI never needs either.
|
||||
|
||||
## Security headers, and the one workaround in the server
|
||||
|
||||
`npm start` runs `scripts/applyBrand.mjs` and then `scripts/serve.mjs` — not
|
||||
`dist/server/entry.mjs` directly. `serve.mjs` is a thin wrapper around the adapter's own handler,
|
||||
and it exists for two reasons.
|
||||
|
||||
The first is a bug in `@astrojs/node`. Its `staticHeaders` option writes one Content-Security-Policy
|
||||
per prerendered route into `dist/_headers.json`, then looks the right one up per request with
|
||||
`headersMap.find((h) => h.pathname.includes(baselessPathname))` — a **substring** test taking the
|
||||
first match. So `/modules/` was served the policy built for `/docs/modules/building-a-module`,
|
||||
`/architecture/` got a docs page's, and `/`, being a substring of every path in the file, got
|
||||
whichever record came first. Because each policy is a list of per-page hashes, that is not a
|
||||
cosmetic mismatch: the browser refused the page's own stylesheet, and `/modules/` and
|
||||
`/architecture/` rendered unstyled with `Refused to apply inline style` in a console. The wrapper
|
||||
keeps the same `_headers.json` and matches by **equality**. It is deliberately small so it can be
|
||||
deleted whole once the upstream `find` is fixed; the test for that is whether `/modules/` and
|
||||
`/docs/modules/building-a-module` are served different policies.
|
||||
|
||||
The second is the handful of headers that have nothing to do with Astro: `X-Content-Type-Options`,
|
||||
`Referrer-Policy`, `X-Frame-Options` and a `Permissions-Policy` that turns off hardware this site has
|
||||
no reason to ask for. They are set in the container rather than written into an operator's
|
||||
reverse-proxy configuration, because the image should be correct on its own and a proxy somebody
|
||||
else configures is a promise this repository cannot check. The two routes that render per request —
|
||||
`/beta` and `/brand/*` — have no prerendered policy, so they get `frame-ancestors 'none'` on its own:
|
||||
the one directive a `<meta>` CSP cannot express, and therefore the one thing Astro's per-page meta
|
||||
tag leaves them missing.
|
||||
|
||||
`style-src-attr 'unsafe-inline'` is the single relaxation in the policy, and it is scoped to that
|
||||
directive. Starlight and Expressive Code write around 3,700 inline `style` attributes into the
|
||||
documentation — icon sizes, the theme select's width, and every syntax colour — which cannot be
|
||||
hashed, because CSP hashes cover `<style>` elements and never attributes. A style attribute cannot
|
||||
execute script, so this leaves `script-src`, the directive CSP exists for, untouched. The marketing
|
||||
pages emit no inline style attributes at all.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
@@ -155,11 +230,15 @@ src/
|
||||
data/collection.mjs What is collected, in three scopes. /privacy renders it and the
|
||||
Play Data Safety notes are generated from it — one inventory.
|
||||
data/legal.mjs The values /terms, /privacy and the consent sentence must share.
|
||||
data/games/ One file per game, in that game's words, checked against its
|
||||
module's capabilities. Only that game's pages render it.
|
||||
styles/tokens.css THE token file — the only place a colour literal may appear.
|
||||
styles/global.css The layout shell, built entirely from tokens.
|
||||
styles/starlight.css Restates our tokens as Starlight's, so the docs cannot drift.
|
||||
layouts/, components/ The marketing chrome.
|
||||
pages/ Marketing routes.
|
||||
pages/modules/ One page per game module; with /runicnpc/, the only marketing
|
||||
routes that may use a game's own words (D63, checkTerms --built).
|
||||
pages/beta.astro The signup. Renders AND handles its own POST — runs per request.
|
||||
content/docs/docs/ Documentation. The extra level mounts Starlight at /docs.
|
||||
pages/brand/ GET /brand/* — the mount, resolved and derived. Runs per request.
|
||||
@@ -170,12 +249,17 @@ src/
|
||||
lib/betaSignup.mjs Everything between a POST body and a row. Never throws.
|
||||
lib/tokens.mjs Reads tokens.css at build time, for the few values that leave CSS.
|
||||
config/sidebar.mjs The documentation journey, and the planned tree behind it.
|
||||
config/cspHashes.mjs GENERATED. Starlight's inline scripts, which Astro does not hash.
|
||||
brand-default/ The stock brand, baked into the image and always complete.
|
||||
scripts/ The build-time checks, plus applyBrand (boot), brand:assets (manual)
|
||||
and beta.mjs (the tester-list CLI).
|
||||
scripts/ The build-time checks, plus applyBrand and serve (boot),
|
||||
brand:assets (manual) and beta.mjs (the tester-list CLI).
|
||||
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
|
||||
@@ -186,13 +270,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
@@ -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.
|
||||
@@ -3,6 +3,8 @@ import { defineConfig } from 'astro/config';
|
||||
import node from '@astrojs/node';
|
||||
import starlight from '@astrojs/starlight';
|
||||
|
||||
import { inlineScriptHashes, inlineStyleHashes } from './src/config/cspHashes.mjs';
|
||||
import { docsRedirects } from './src/config/redirects.mjs';
|
||||
import { docsSidebar } from './src/config/sidebar.mjs';
|
||||
|
||||
/**
|
||||
@@ -20,13 +22,76 @@ import { docsSidebar } from './src/config/sidebar.mjs';
|
||||
export default defineConfig({
|
||||
site: 'https://runicgateway.com',
|
||||
output: 'static',
|
||||
adapter: node({ mode: 'standalone' }),
|
||||
// `staticHeaders` is what turns §6's CSP from a promise into a response header (D48).
|
||||
// Without it the policy ships as a `<meta http-equiv>`, and a meta CSP silently ignores
|
||||
// `frame-ancestors` — the one directive that stops the site being framed. With it, the
|
||||
// build writes `_headers.json` next to the server entry and the standalone server sends
|
||||
// the policy as a real header on every prerendered route, so the operator's reverse proxy
|
||||
// needs no CSP configuration at all and cannot get it wrong.
|
||||
adapter: node({ mode: 'standalone', staticHeaders: true }),
|
||||
|
||||
build: {
|
||||
// Directory-style URLs, so every link in prose can end in a slash and mean it.
|
||||
format: 'directory',
|
||||
},
|
||||
|
||||
// Pages that moved into a game's own sidebar group (PLAN.md §16, D60). See the file.
|
||||
redirects: docsRedirects,
|
||||
|
||||
|
||||
security: {
|
||||
csp: {
|
||||
directives: [
|
||||
// The whole posture in one line: nothing loads from anywhere but this origin.
|
||||
// §6 could promise this without exceptions because the fonts are self-hosted and
|
||||
// D9 rules out analytics — there is no CDN to whitelist and no beacon to allow.
|
||||
"default-src 'self'",
|
||||
// Not covered by `default-src`, and each one closes a specific door: no injected
|
||||
// `<base>` can re-point every relative URL on the page, the signup form can only
|
||||
// post to us, no plugin content at all, and the site cannot be framed. The last
|
||||
// of those is the reason `staticHeaders` is on.
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
"object-src 'none'",
|
||||
"frame-ancestors 'none'",
|
||||
// One `url(data:image/svg+xml)` survives bundling into the stylesheet. Data URLs
|
||||
// are a real (if small) exfiltration-free risk surface, so this is the only
|
||||
// relaxation of `default-src` on the image directive and it is scoped to images.
|
||||
"img-src 'self' data:",
|
||||
],
|
||||
scriptDirective: {
|
||||
resources: [
|
||||
"'self'",
|
||||
// Pagefind (D47) compiles its index with `WebAssembly.instantiate`, which a
|
||||
// strict `script-src` blocks outright — search silently returns nothing. This
|
||||
// permits WASM compilation *only*; it does not restore `eval`.
|
||||
"'wasm-unsafe-eval'",
|
||||
],
|
||||
// Starlight's own `is:inline` scripts, which Astro does not hash because it never
|
||||
// parses them. Generated — see src/config/cspHashes.mjs and `npm run check:csp`.
|
||||
hashes: inlineScriptHashes,
|
||||
},
|
||||
styleDirective: {
|
||||
// No `'self'` here, though `style-src` needs it and gets it: Astro's default
|
||||
// already supplies it, and naming it alongside an `attribute`-kind resource makes
|
||||
// the build warn — browsers do not fall back from `style-src-attr` to `style-src`,
|
||||
// so a `'self'` written here would apply to neither scope the author meant.
|
||||
resources: [
|
||||
// Starlight and Expressive Code write ~3,700 inline `style` attributes into the
|
||||
// documentation — icon sizing, the theme select's width, and every syntax
|
||||
// colour, which Expressive Code emits as custom properties on the element. They
|
||||
// cannot be hashed (CSP hashes cover `<style>` elements, never attributes), and
|
||||
// Astro's own docs record Shiki as incompatible with CSP for exactly this
|
||||
// reason. Scoped to `style-src-attr` deliberately: a style attribute cannot
|
||||
// execute script, so this leaves the directive CSP exists for — `script-src` —
|
||||
// untouched. The marketing pages emit zero inline style attributes.
|
||||
{ resource: "'unsafe-inline'", kind: 'attribute' },
|
||||
],
|
||||
hashes: inlineStyleHashes,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
integrations: [
|
||||
starlight({
|
||||
title: 'Runic Gateway',
|
||||
@@ -49,6 +114,9 @@ export default defineConfig({
|
||||
// Starlight builds its own head, so the docs otherwise miss the brand stylesheet,
|
||||
// the manifest and the OG card entirely. See the component.
|
||||
Head: './src/components/DocsHead.astro',
|
||||
// One attribute, for one reason: Starlight's skip link targets the `<h1>`, and an
|
||||
// `<h1>` is not focusable. See the component (phase 11).
|
||||
PageTitle: './src/components/DocsPageTitle.astro',
|
||||
},
|
||||
credits: false,
|
||||
sidebar: docsSidebar,
|
||||
|
||||
104
docker-compose.yml
Normal file
@@ -0,0 +1,104 @@
|
||||
# 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
|
||||
|
||||
# Published on ALL interfaces by default, so the site answers on the host's
|
||||
# own address — `http://<vm-ip>:4321` — and not only on its loopback. That is
|
||||
# what makes it reachable from the rest of the network: a proxy in another
|
||||
# container or on another machine, a browser on the LAN, a phone on the same
|
||||
# wifi checking the mobile layout.
|
||||
#
|
||||
# It is deliberately a variable rather than a fixed address, because the safe
|
||||
# binding depends on where this host sits. Set SITE_BIND_ADDR in .env to
|
||||
# narrow it without touching this file:
|
||||
#
|
||||
# SITE_BIND_ADDR=127.0.0.1 loopback only — a proxy on THIS host, nothing else
|
||||
# SITE_BIND_ADDR=192.168.1.10 one interface — the LAN, but not a public NIC
|
||||
# SITE_BIND_ADDR=0.0.0.0 every interface (the default)
|
||||
#
|
||||
# On a host with a public address, `0.0.0.0` means port 4321 answers from the
|
||||
# internet directly, beside whatever the proxy serves on 443 — plain HTTP, no
|
||||
# TLS. Firewall the port, or narrow the binding. DEPLOY.md, "Putting a proxy
|
||||
# in front of it".
|
||||
ports:
|
||||
- "${SITE_BIND_ADDR:-0.0.0.0}:${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"
|
||||
839
package-lock.json
generated
22
package.json
@@ -12,7 +12,7 @@
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"start": "node scripts/applyBrand.mjs && node ./dist/server/entry.mjs",
|
||||
"start": "node scripts/applyBrand.mjs && node scripts/serve.mjs",
|
||||
"check": "astro check",
|
||||
"check:facts": "node scripts/checkFacts.mjs",
|
||||
"check:tokens": "node scripts/checkTokens.mjs",
|
||||
@@ -20,11 +20,21 @@
|
||||
"check:links": "node scripts/checkLinks.mjs",
|
||||
"check:datasafety": "node scripts/playDataSafety.mjs --check",
|
||||
"check:quickstart": "node scripts/checkQuickstart.mjs",
|
||||
"check:reference": "node scripts/checkReference.mjs",
|
||||
"check:sidebar": "node scripts/checkSidebar.mjs",
|
||||
"check:terms": "node scripts/checkTerms.mjs",
|
||||
"check:terms:built": "node scripts/checkTerms.mjs --built",
|
||||
"check:screens": "node scripts/checkScreens.mjs",
|
||||
"check:a11y": "node scripts/checkA11y.mjs",
|
||||
"check:csp": "node scripts/checkCsp.mjs",
|
||||
"play:datasafety": "node scripts/playDataSafety.mjs",
|
||||
"beta": "node scripts/beta.mjs",
|
||||
"test": "node --test test/beta.test.mjs test/legal.test.mjs",
|
||||
"test": "node --test test/beta.test.mjs test/legal.test.mjs test/footer.test.mjs",
|
||||
"brand:assets": "node scripts/buildBrandAssets.mjs",
|
||||
"verify": "npm run check:tokens && npm run check:brand && npm run check:datasafety && npm run check && npm test && npm run build && npm run check:links && npm run check:facts && npm run check:quickstart"
|
||||
"screens:capture": "node scripts/captureScreens.mjs",
|
||||
"csp:hashes": "node scripts/checkCsp.mjs --reset && astro build && node scripts/checkCsp.mjs --write && astro build && node scripts/checkCsp.mjs",
|
||||
"verify": "npm run check:sidebar && npm run check:terms && npm run check:screens && npm run check:tokens && npm run check:brand && npm run check:datasafety && npm run check && npm test && npm run build && npm run check:links && npm run check:terms:built && npm run check:facts && npm run check:quickstart && npm run check:reference && npm run test:served && npm run check:a11y && npm run check:csp",
|
||||
"test:served": "node --test test/headers.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/node": "^11.1.4",
|
||||
@@ -33,12 +43,14 @@
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
"astro": "^7.2.4",
|
||||
"better-sqlite3": "^12.11.1",
|
||||
"pagefind": "^1.5.2",
|
||||
"sharp": "^0.35.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "^0.9.10",
|
||||
"opentype.js": "^2.0.0",
|
||||
"yaml": "^2.8.1",
|
||||
"typescript": "^6.0.3"
|
||||
"puppeteer-core": "^23.11.1",
|
||||
"typescript": "^6.0.3",
|
||||
"yaml": "^2.8.1"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
public/screens/admin-appearance.webp
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
public/screens/admin-client-files.webp
Normal file
|
After Width: | Height: | Size: 114 KiB |
BIN
public/screens/admin-dashboard.webp
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
public/screens/admin-modules.webp
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
public/screens/admin-rust-npcs.webp
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
public/screens/admin-rust-permissions.webp
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
public/screens/admin-shard.webp
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
public/screens/admin-users.webp
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
public/screens/app-account.webp
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
public/screens/app-drawer.webp
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
public/screens/app-home.webp
Normal file
|
After Width: | Height: | Size: 136 KiB |
BIN
public/screens/app-market.webp
Normal file
|
After Width: | Height: | Size: 89 KiB |
BIN
public/screens/app-rust-account.webp
Normal file
|
After Width: | Height: | Size: 89 KiB |
BIN
public/screens/app-rust-map.webp
Normal file
|
After Width: | Height: | Size: 284 KiB |
BIN
public/screens/app-rust-server.webp
Normal file
|
After Width: | Height: | Size: 173 KiB |
BIN
public/screens/app-shard.webp
Normal file
|
After Width: | Height: | Size: 111 KiB |
BIN
public/screens/app-wiki.webp
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
public/screens/guilds.webp
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
public/screens/houses.webp
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
public/screens/marketplace.webp
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
public/screens/news.webp
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
public/screens/rust-feed.webp
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
public/screens/rust-leaderboard.webp
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
public/screens/rust-map.webp
Normal file
|
After Width: | Height: | Size: 111 KiB |
BIN
public/screens/rust-servers.webp
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
public/screens/shard-status.webp
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
public/screens/spawn-atlas.webp
Normal file
|
After Width: | Height: | Size: 67 KiB |
@@ -229,10 +229,71 @@ const counts = new Map(replacements.map((r) => [r.field, 0]));
|
||||
counts.set('demoDeep', 0);
|
||||
let filesTouched = 0;
|
||||
|
||||
/**
|
||||
* The CSP (§6, D48) hashes every inline `<script>` and `<style>` in the build. This script
|
||||
* runs after that hashing and rewrites the same files, so a brand value that happened to
|
||||
* sit inside an inline block would change its bytes, invalidate its hash and get the block
|
||||
* refused by the browser — with no error anywhere except a console nobody has open. The
|
||||
* page would render perfectly and the script simply would not run.
|
||||
*
|
||||
* Nothing puts brand text in an inline script today, and the replacements are guarded by
|
||||
* MIN_REWRITABLE_LENGTH so they are unlikely to collide by accident. "Unlikely" is not the
|
||||
* standard for a failure this quiet, so the collision is checked rather than reasoned
|
||||
* about: if a rewrite ever lands inside an inline block, this refuses to write that file
|
||||
* and says so, and the page keeps its stock text instead of losing its behaviour.
|
||||
*/
|
||||
const INLINE_BLOCK = /<(script|style)(?![^>]*\bsrc\s*=)([^>]*)>([\s\S]*?)<\/\1>/g;
|
||||
|
||||
/**
|
||||
* The structured-data block (D50) is a `<script>` that no browser executes and no CSP hash
|
||||
* covers, so it is not one of the blocks this guard protects — and it MUST NOT be, because
|
||||
* it contains the site's name. Treating it as a script would make the guard refuse to
|
||||
* rewrite the homepage, which is §7 failing on the one page that matters most.
|
||||
*/
|
||||
const DATA_BLOCK = /type\s*=\s*["']application\/(ld\+json|json)["']/i;
|
||||
|
||||
const inlineRanges = (html) => {
|
||||
const ranges = [];
|
||||
INLINE_BLOCK.lastIndex = 0;
|
||||
let match;
|
||||
while ((match = INLINE_BLOCK.exec(html))) {
|
||||
if (match[1] === 'script' && DATA_BLOCK.test(match[2])) continue;
|
||||
// Where the block's CONTENT starts — measured back from the end of the whole match, so
|
||||
// the opening tag's attributes cannot throw the offset off: `</script>` is the tag name
|
||||
// plus three characters.
|
||||
const closing = match[1].length + 3;
|
||||
const start = match.index + match[0].length - closing - match[3].length;
|
||||
ranges.push([start, start + match[3].length]);
|
||||
}
|
||||
return ranges;
|
||||
};
|
||||
const hitsInlineBlock = (html, needle) => {
|
||||
if (!needle || !html.includes(needle)) return false;
|
||||
const ranges = inlineRanges(html);
|
||||
if (ranges.length === 0) return false;
|
||||
for (let at = html.indexOf(needle); at !== -1; at = html.indexOf(needle, at + 1)) {
|
||||
const end = at + needle.length;
|
||||
if (ranges.some(([from, to]) => at < to && end > from)) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const inlineCollisions = [];
|
||||
|
||||
for (const file of walk(CLIENT)) {
|
||||
const before = readFileSync(file, 'utf8');
|
||||
let after = before;
|
||||
|
||||
if (path.extname(file) === '.html') {
|
||||
const colliding = replacements.filter(({ from }) => hitsInlineBlock(before, from));
|
||||
if (colliding.length) {
|
||||
inlineCollisions.push({
|
||||
file: path.relative(CLIENT, file),
|
||||
fields: [...new Set(colliding.map((c) => c.field))],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
for (const { field, from, to } of replacements) {
|
||||
if (!after.includes(from)) continue;
|
||||
counts.set(field, counts.get(field) + after.split(from).length - 1);
|
||||
@@ -270,6 +331,47 @@ if (counts.get('demoDeep')) {
|
||||
);
|
||||
}
|
||||
|
||||
// Pagefind builds its search index from the HTML at BUILD time (phase 10), so a rename
|
||||
// applied here reaches the pages but not the search results. Worth fixing when search
|
||||
// lands; recorded here rather than in a plan section nobody will re-read.
|
||||
if (inlineCollisions.length) {
|
||||
console.error(
|
||||
`\n[brand] ${inlineCollisions.length} file(s) were LEFT UNCHANGED: a brand value occurs ` +
|
||||
`inside an inline <script> or <style>, and rewriting it would break that block's CSP ` +
|
||||
`hash (§6, D48) — the page would render and the script would silently not run.\n`
|
||||
);
|
||||
for (const { file, fields } of inlineCollisions) {
|
||||
console.error(` ! ${file} (${fields.join(', ')})`);
|
||||
}
|
||||
console.error(
|
||||
`\n Those pages keep the stock text. Fix it by taking the brand value out of the inline\n` +
|
||||
` block — move it into markup the CSP does not hash, or into /brand/theme.css.\n`
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------------------
|
||||
Search
|
||||
--------------------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Pagefind builds its index from the built HTML at BUILD time, so everything above reaches
|
||||
* the pages and none of it reaches the search results: a site renamed through the mount
|
||||
* would answer a search for its own name with the stock one, and every result title would
|
||||
* still carry the old suffix. Phase 2 recorded that and left it for this phase, when
|
||||
* search became site-wide (D47).
|
||||
*
|
||||
* The fix is to re-index, which is cheap and needs nothing the container does not already
|
||||
* have — Pagefind is what Starlight ran at build. It only runs when a rewrite actually
|
||||
* happened, so the stock deployment, which is the common case, still pays nothing.
|
||||
*/
|
||||
if (filesTouched > 0) {
|
||||
const pagefind = await import('pagefind');
|
||||
try {
|
||||
const { index } = await pagefind.createIndex();
|
||||
const { page_count } = await index.addDirectory({ path: CLIENT });
|
||||
await index.writeFiles({ outputPath: path.join(CLIENT, 'pagefind') });
|
||||
console.log(`[brand] re-indexed ${page_count} page(s) for search so results agree with it.`);
|
||||
} catch (error) {
|
||||
// Search degrading to stale titles is not a reason to refuse to serve the site.
|
||||
console.error(`[brand] could not rebuild the search index; it keeps the built one: ${error.message}`);
|
||||
} finally {
|
||||
await pagefind.close();
|
||||
}
|
||||
}
|
||||
|
||||
206
scripts/captureScreens.mjs
Normal file
@@ -0,0 +1,206 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* captureScreens.mjs — retakes the screenshots in `src/data/screens.mjs`.
|
||||
*
|
||||
* PLAN.md §13 phase 9, D4 / D45.
|
||||
*
|
||||
* node scripts/captureScreens.mjs # every web screen
|
||||
* node scripts/captureScreens.mjs shard-status admin-users
|
||||
* RG_DEMO=http://localhost:3000 node scripts/captureScreens.mjs
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* AN AUTHORING TOOL, LIKE buildBrandAssets.mjs — NOT A CHECK
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* This never runs in CI and CI never needs it: its output is committed, because the site
|
||||
* must build from a clean checkout with no game server, no database and no browser. What
|
||||
* CI runs is `checkScreens.mjs`, which only reads the files this produced.
|
||||
*
|
||||
* It exists because D4 asks for real screenshots of a real deployment, and the way real
|
||||
* screenshots rot is that the recipe for taking them lives in somebody's memory. The rig
|
||||
* is written down in PLAN.md §13; the framing — route, viewport, scroll offset, whether to
|
||||
* sign in — is written down in `screens.mjs`; and this turns the two into files.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY puppeteer-core AND NOT puppeteer
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* `puppeteer` downloads its own Chromium — a hundred-odd megabytes fetched on every clean
|
||||
* install of a repository that needs a browser once per redesign. `puppeteer-core` drives
|
||||
* a Chrome that is already on the machine, which every machine that can look at this site
|
||||
* has. Point `RG_CHROME` at it if it is somewhere unusual.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY IT SIGNS IN THROUGH THE API RATHER THAN THE LOGIN FORM
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* The administration screens need a session, and typing into the login form is the part of
|
||||
* a browser script most likely to break on a redesign — a moved field, a renamed button, a
|
||||
* React input that ignores synthetic typing. The session cookie is the only thing actually
|
||||
* wanted, so this asks the API for one from inside the page and lets the browser store it.
|
||||
* If that call stops returning 200 the script says so and stops, rather than quietly
|
||||
* screenshotting a login screen twelve times.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readdirSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import sharp from 'sharp';
|
||||
|
||||
import { screens, screensOf, WEB } from '../src/data/screens.mjs';
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const OUT = path.join(HERE, '..', 'public', 'screens');
|
||||
|
||||
const BASE = (process.env.RG_DEMO || 'http://localhost:3000').replace(/\/+$/, '');
|
||||
const USER = process.env.RG_ADMIN_USER || 'demoadmin';
|
||||
const PASS = process.env.RG_ADMIN_PASS || 'DemoReview!2026';
|
||||
|
||||
/** Where Chrome usually is, per platform. First hit wins; `RG_CHROME` beats all of them. */
|
||||
const CHROME_CANDIDATES = [
|
||||
process.env.RG_CHROME,
|
||||
'C:/Program Files/Google/Chrome/Application/chrome.exe',
|
||||
'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/usr/bin/google-chrome',
|
||||
'/usr/bin/chromium',
|
||||
].filter(Boolean);
|
||||
|
||||
const wanted = process.argv.slice(2).filter((arg) => !arg.startsWith('-'));
|
||||
const todo = screensOf('web').filter((shot) => wanted.length === 0 || wanted.includes(shot.id));
|
||||
|
||||
if (todo.length === 0) {
|
||||
const known = screens.map((shot) => shot.id).join(', ');
|
||||
console.error(`Nothing to capture. Known ids: ${known}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const chrome = CHROME_CANDIDATES.find((candidate) => existsSync(candidate));
|
||||
|
||||
if (!chrome) {
|
||||
console.error(
|
||||
'No Chrome found. Set RG_CHROME to the browser executable — this script drives an\n' +
|
||||
'installed Chrome rather than downloading one (see the header).',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const puppeteer = (await import('puppeteer-core')).default;
|
||||
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
executablePath: chrome,
|
||||
headless: 'new',
|
||||
defaultViewport: { ...WEB.viewport, deviceScaleFactor: WEB.scale },
|
||||
// Scrollbars are the browser's furniture, not the product's, and a colour profile that
|
||||
// is not sRGB makes the palette in a screenshot disagree with the palette on the page.
|
||||
args: ['--hide-scrollbars', '--force-color-profile=srgb'],
|
||||
});
|
||||
|
||||
/**
|
||||
* One page per privilege level rather than signing in and out around each shot: signing
|
||||
* out is the step that gets forgotten, and a public page captured with an admin session
|
||||
* shows a navigation bar the public never sees.
|
||||
*/
|
||||
const anon = await browser.newPage();
|
||||
const admin = await browser.newPage();
|
||||
|
||||
await admin.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||
|
||||
const status = await admin.evaluate(
|
||||
async (username, password) => {
|
||||
const res = await fetch('/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
return res.status;
|
||||
},
|
||||
USER,
|
||||
PASS,
|
||||
);
|
||||
|
||||
if (status !== 200) {
|
||||
console.error(
|
||||
`Could not sign in as "${USER}" at ${BASE} (HTTP ${status}).\n` +
|
||||
'Seed the demo first — see PLAN.md §13 phase 9 and scripts/seedDemo.mjs.',
|
||||
);
|
||||
await browser.close();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let failures = 0;
|
||||
|
||||
for (const shot of todo) {
|
||||
const page = shot.admin ? admin : anon;
|
||||
const url = BASE + shot.route;
|
||||
|
||||
try {
|
||||
await page.goto(url, { waitUntil: 'networkidle2', timeout: 30_000 });
|
||||
|
||||
// A screen whose subject is behind a tab or a button the route cannot reach. Each step is
|
||||
// the visible text the element starts with; the first visible button, link or tab that
|
||||
// matches is clicked. A step that matches nothing fails the shot rather than capturing
|
||||
// the wrong screen.
|
||||
for (const label of shot.click ?? []) {
|
||||
const clicked = await page.evaluate((text) => {
|
||||
const candidates = [...document.querySelectorAll('button, a, [role="tab"], [role="button"], li')];
|
||||
const hit = candidates.find(
|
||||
(el) => el.offsetParent !== null && el.textContent.trim().startsWith(text),
|
||||
);
|
||||
if (hit) hit.click();
|
||||
return Boolean(hit);
|
||||
}, label);
|
||||
if (!clicked) throw new Error(`nothing to click starting "${label}"`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||
}
|
||||
|
||||
if (shot.scrollY) {
|
||||
await page.evaluate((y) => window.scrollTo(0, y), shot.scrollY);
|
||||
}
|
||||
|
||||
// Live pages settle after their first paint: a shard panel fills in from an event
|
||||
// stream, a list re-sorts once its data lands. A second is cheap and the difference
|
||||
// between a screenshot of the product and a screenshot of its loading state.
|
||||
await new Promise((resolve) => setTimeout(resolve, 1200));
|
||||
|
||||
const png = await page.screenshot({ type: 'png' });
|
||||
const file = path.join(OUT, `${shot.id}.webp`);
|
||||
|
||||
// Quality 82 is where UI text stops visibly softening; the files land near 150 KB,
|
||||
// which is what makes a page with five of them still a page and not a download.
|
||||
await sharp(png).webp({ quality: 82 }).toFile(file);
|
||||
|
||||
const meta = await sharp(file).metadata();
|
||||
|
||||
if (meta.width !== WEB.width || meta.height !== WEB.height) {
|
||||
console.error(
|
||||
` ! ${shot.id}: got ${meta.width}x${meta.height}, expected ${WEB.width}x${WEB.height}`,
|
||||
);
|
||||
failures++;
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(` + ${shot.id.padEnd(18)} ${shot.route.padEnd(20)} ${meta.width}x${meta.height}`);
|
||||
} catch (err) {
|
||||
console.error(` ! ${shot.id}: ${err.message}`);
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
|
||||
// A file left behind by a screen that has since been renamed or dropped is a file the
|
||||
// site still ships and nothing points at. Say so; do not delete somebody's work silently.
|
||||
if (wanted.length === 0) {
|
||||
const declared = new Set(screensOf('web').map((shot) => `${shot.id}.webp`));
|
||||
const phones = new Set(screensOf('phone').map((shot) => `${shot.id}.webp`));
|
||||
const orphans = readdirSync(OUT).filter((name) => !declared.has(name) && !phones.has(name));
|
||||
|
||||
if (orphans.length > 0) {
|
||||
console.log(`\nNot declared in screens.mjs, left alone: ${orphans.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n${todo.length - failures} captured, ${failures} failed.`);
|
||||
process.exit(failures > 0 ? 1 : 0);
|
||||
278
scripts/checkA11y.mjs
Normal file
@@ -0,0 +1,278 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* checkA11y.mjs — PLAN.md §13 phase 10.
|
||||
*
|
||||
* Every other rule this repository cares about is enforced by a script — the facts, the
|
||||
* links, the tokens, the sidebar, the screenshots, the CSP. Accessibility was the exception:
|
||||
* it was a thing someone checked once, by hand, on the pages they happened to open. This
|
||||
* makes it the eleventh check so a regression fails a build instead of waiting for a reader
|
||||
* who cannot use the page and will not file an issue.
|
||||
*
|
||||
* node scripts/checkA11y.mjs
|
||||
*
|
||||
* ── What it checks, and why each one ────────────────────────────────────────
|
||||
* A static check cannot measure contrast against a rendered page or find a focus trap, and
|
||||
* pretending otherwise would be worse than not checking. What it CAN do is catch the class
|
||||
* of defect that is invisible to a sighted author and permanent once shipped:
|
||||
*
|
||||
* 1. **One `<h1>` per page, and no skipped heading level.** The heading tree is the
|
||||
* document outline a screen-reader user navigates by. Two `<h1>`s or an `<h2>` under
|
||||
* nothing reads as a page with no structure at all.
|
||||
* 2. **Every `<img>` has an `alt`.** Not "a non-empty alt": `alt=""` is correct and
|
||||
* deliberate for the header mark, which sits inside a link that already says the
|
||||
* product's name. A MISSING attribute is what makes a screen reader read the filename.
|
||||
* 3. **Every form control has a label.** `<label for>`, a wrapping `<label>`,
|
||||
* `aria-label` or `aria-labelledby`. The signup form is the only place on this site
|
||||
* where a person is asked to type something, so it is the one place this must hold.
|
||||
* 4. **Every link and button has an accessible name.** An icon-only control with no text
|
||||
* and no `aria-label` is announced as "link", which is no name at all. The search
|
||||
* button is icon-only under 46rem, which is exactly this hazard.
|
||||
* 5. **`<html lang>` is set**, or a screen reader reads English prose with whatever voice
|
||||
* the reader last used.
|
||||
* 6. **One `<main>` per page and a skip link that points at it.** The site's header is a
|
||||
* lockup, four links and a search box in front of every page; without a working skip
|
||||
* link a keyboard user walks all six on every navigation.
|
||||
* 7. **No positive `tabindex`.** It reorders the tab sequence away from the visual one
|
||||
* and is almost never what the author meant.
|
||||
*
|
||||
* Both chromes are checked — the marketing pages and Starlight's forty. Starlight is
|
||||
* generally careful, so the docs half is a regression alarm on a dependency rather than a
|
||||
* review of our own markup, and it has already earned its place once: it is what would have
|
||||
* caught the `<h2>`-without-`<h1>` shape if a docs page had ever lost its title.
|
||||
*
|
||||
* No token and no network: everything read here is in `dist/`.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const clientDir = path.join(root, 'dist', 'client');
|
||||
|
||||
if (!fs.existsSync(clientDir)) {
|
||||
console.error('\ncheckA11y: dist/client does not exist. Run `npm run build` first.\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const failures = [];
|
||||
const fail = (page, what, detail) => failures.push({ page, what, detail });
|
||||
|
||||
const pages = [];
|
||||
const walk = (dir) => {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(full);
|
||||
else if (entry.name.endsWith('.html')) pages.push(full);
|
||||
}
|
||||
};
|
||||
walk(clientDir);
|
||||
|
||||
/* ---------------------------------------------------------------------------------------
|
||||
A very small amount of HTML reading
|
||||
|
||||
Not a parser. Everything below is a tag-level question — does this element carry this
|
||||
attribute, what text sits between these two tags — and a regex answers those on
|
||||
generated, well-formed output. A DOM parser would be a dependency, and this repository's
|
||||
checks are dependency-free on purpose (§12): the reader runs them the same way CI does.
|
||||
--------------------------------------------------------------------------------------- */
|
||||
|
||||
/** Takes the ATTRIBUTE STRING — what is between the tag name and the `>` — not the tag. */
|
||||
const attrs = (attrString) => {
|
||||
const found = new Map();
|
||||
const re = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g;
|
||||
let m;
|
||||
while ((m = re.exec(attrString))) {
|
||||
found.set(m[1].toLowerCase(), m[2] ?? m[3] ?? m[4] ?? '');
|
||||
}
|
||||
return found;
|
||||
};
|
||||
|
||||
/** Text a screen reader would announce: markup and comments stripped, entities loosened. */
|
||||
const textOf = (html) =>
|
||||
html
|
||||
.replace(/<!--[\s\S]*?-->/g, '')
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.replace(/&[a-zA-Z#0-9]+;/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
/** An element's own accessible name, near enough for "is there one at all". */
|
||||
const named = (tag, inner) => {
|
||||
const a = attrs(tag);
|
||||
if (a.get('aria-label')?.trim()) return true;
|
||||
if (a.get('aria-labelledby')?.trim()) return true;
|
||||
if (a.get('title')?.trim()) return true;
|
||||
if (textOf(inner)) return true;
|
||||
// An image child with alt text names the control.
|
||||
for (const img of inner.matchAll(/<img\b([^>]*)>/gi)) {
|
||||
if (attrs(img[1]).get('alt')?.trim()) return true;
|
||||
}
|
||||
// An SVG with a title element does too.
|
||||
if (/<svg\b[^>]*>[\s\S]*?<title\b[^>]*>[^<]+<\/title>/i.test(inner)) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
for (const file of pages) {
|
||||
const page = '/' + path.relative(clientDir, file).replace(/\\/g, '/');
|
||||
|
||||
/**
|
||||
* Comments are stripped before anything is counted, and that is not a nicety: this
|
||||
* repository comments its markup heavily, and several of those comments quote the tags
|
||||
* they are explaining. `Base.astro`'s note about `data-pagefind-body` contains the text
|
||||
* "<main>", and the first run of this check reported every marketing page as having two
|
||||
* `<main>` landmarks because of it. Stripping once, up front, also keeps every offset
|
||||
* below measured against the same string.
|
||||
*/
|
||||
const html = fs.readFileSync(file, 'utf8').replace(/<!--[\s\S]*?-->/g, '');
|
||||
|
||||
// ── 5. lang ───────────────────────────────────────────────────────────────
|
||||
const htmlTag = /<html\b([^>]*)>/i.exec(html);
|
||||
if (!htmlTag) fail(page, '<html>', 'has no <html> element');
|
||||
else if (!attrs(htmlTag[1]).get('lang')?.trim()) fail(page, '<html>', 'has no lang attribute');
|
||||
|
||||
// ── 1. headings ───────────────────────────────────────────────────────────
|
||||
const headings = [...html.matchAll(/<h([1-6])\b([^>]*)>([\s\S]*?)<\/h\1>/gi)]
|
||||
// `aria-hidden` headings are decorative and out of the outline by definition.
|
||||
.filter((m) => attrs(m[2]).get('aria-hidden') !== 'true')
|
||||
.map((m) => ({ level: Number(m[1]), text: textOf(m[3]) }));
|
||||
|
||||
const h1s = headings.filter((h) => h.level === 1);
|
||||
if (h1s.length === 0) fail(page, 'headings', 'has no <h1>');
|
||||
if (h1s.length > 1) {
|
||||
fail(page, 'headings', `has ${h1s.length} <h1>s: ${h1s.map((h) => JSON.stringify(h.text)).join(', ')}`);
|
||||
}
|
||||
|
||||
let previous = 0;
|
||||
for (const heading of headings) {
|
||||
if (previous && heading.level > previous + 1) {
|
||||
fail(
|
||||
page,
|
||||
'headings',
|
||||
`jumps from h${previous} to h${heading.level} at ${JSON.stringify(heading.text.slice(0, 50))}`,
|
||||
);
|
||||
}
|
||||
previous = heading.level;
|
||||
}
|
||||
|
||||
// ── 2. images ─────────────────────────────────────────────────────────────
|
||||
for (const img of html.matchAll(/<img\b([^>]*)>/gi)) {
|
||||
const a = attrs(img[1]);
|
||||
if (!a.has('alt')) {
|
||||
fail(page, '<img>', `has no alt attribute: src=${a.get('src') ?? '(none)'}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. form controls ──────────────────────────────────────────────────────
|
||||
const labelledIds = new Set(
|
||||
[...html.matchAll(/<label\b([^>]*)>/gi)]
|
||||
.map((m) => attrs(m[1]).get('for'))
|
||||
.filter(Boolean),
|
||||
);
|
||||
/**
|
||||
* A control wrapped in its own `<label>` needs no `for` and — this is the part that took
|
||||
* a wrong answer to find — needs no `id` either, so it cannot be recorded by id. Starlight
|
||||
* labels its theme and language selects exactly this way. What is recorded instead is the
|
||||
* character offset of each wrapped control, which identifies it uniquely without
|
||||
* requiring it to have any attributes at all.
|
||||
*/
|
||||
const wrappedAt = new Set();
|
||||
for (const label of html.matchAll(/<label\b[^>]*>([\s\S]*?)<\/label>/gi)) {
|
||||
const base = label.index + label[0].indexOf(label[1]);
|
||||
for (const control of label[1].matchAll(/<(input|select|textarea)\b[^>]*>/gi)) {
|
||||
wrappedAt.add(base + control.index);
|
||||
}
|
||||
}
|
||||
|
||||
for (const control of html.matchAll(/<(input|select|textarea)\b([^>]*)>/gi)) {
|
||||
const a = attrs(control[2]);
|
||||
const type = (a.get('type') ?? 'text').toLowerCase();
|
||||
// These are not things a person types into and are named by other means.
|
||||
if (['hidden', 'submit', 'button', 'reset', 'image'].includes(type)) continue;
|
||||
|
||||
const id = a.get('id');
|
||||
const hasLabel =
|
||||
wrappedAt.has(control.index) ||
|
||||
(id && labelledIds.has(id)) ||
|
||||
a.get('aria-label')?.trim() ||
|
||||
a.get('aria-labelledby')?.trim() ||
|
||||
a.get('title')?.trim();
|
||||
|
||||
if (!hasLabel) {
|
||||
fail(
|
||||
page,
|
||||
`<${control[1]}>`,
|
||||
`has no label: ${id ? `id="${id}"` : `name="${a.get('name') ?? '(none)'}"`} — ` +
|
||||
'a placeholder is not a label',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4. link and button names ──────────────────────────────────────────────
|
||||
for (const [, tag, attrString, inner] of html.matchAll(/<(a|button)\b([^>]*)>([\s\S]*?)<\/\1>/gi)) {
|
||||
const a = attrs(attrString);
|
||||
if (a.get('aria-hidden') === 'true') continue;
|
||||
// An <a> with no href is not a link; it is a target for one.
|
||||
if (tag.toLowerCase() === 'a' && !a.has('href')) continue;
|
||||
if (named(attrString, inner)) continue;
|
||||
|
||||
fail(
|
||||
page,
|
||||
`<${tag}>`,
|
||||
`has no accessible name: ${a.get('href') ? `href="${a.get('href')}"` : `class="${a.get('class') ?? ''}"`}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── 6. main and the skip link ─────────────────────────────────────────────
|
||||
const mains = [...html.matchAll(/<main\b([^>]*)>/gi)];
|
||||
if (mains.length === 0) fail(page, '<main>', 'has no <main> landmark');
|
||||
if (mains.length > 1) fail(page, '<main>', `has ${mains.length} <main> elements`);
|
||||
|
||||
const skip = /<a\b([^>]*class="[^"]*skip-link[^"]*"[^>]*)>/i.exec(html);
|
||||
if (skip) {
|
||||
const target = attrs(skip[1]).get('href') ?? '';
|
||||
if (!target.startsWith('#')) {
|
||||
fail(page, 'skip link', `points at ${JSON.stringify(target)}, which is not an in-page anchor`);
|
||||
} else {
|
||||
const id = target.slice(1);
|
||||
if (!new RegExp(`\\bid=["']${id}["']`).test(html)) {
|
||||
fail(page, 'skip link', `points at #${id}, and nothing on the page has that id`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 7. positive tabindex ──────────────────────────────────────────────────
|
||||
for (const m of html.matchAll(/\btabindex\s*=\s*["']?(-?\d+)/gi)) {
|
||||
if (Number(m[1]) > 0) {
|
||||
fail(page, 'tabindex', `is ${m[1]} — a positive tabindex reorders the tab sequence`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------------------
|
||||
Report
|
||||
--------------------------------------------------------------------------------------- */
|
||||
|
||||
if (failures.length === 0) {
|
||||
console.log(`checkA11y: ${pages.length} built pages pass all seven structural checks.`);
|
||||
} else {
|
||||
// Grouped by page: a shared component's defect otherwise prints fifty times and buries
|
||||
// the one page that has a real problem of its own.
|
||||
const byPage = new Map();
|
||||
for (const f of failures) {
|
||||
if (!byPage.has(f.page)) byPage.set(f.page, []);
|
||||
byPage.get(f.page).push(f);
|
||||
}
|
||||
|
||||
console.error(`\ncheckA11y: ${failures.length} problem(s) across ${byPage.size} page(s):\n`);
|
||||
for (const [page, items] of byPage) {
|
||||
console.error(` ${page}`);
|
||||
for (const item of items) console.error(` ✗ ${item.what}: ${item.detail}`);
|
||||
}
|
||||
console.error(`
|
||||
These are structural, so they are the same in every browser and for every reader. A defect
|
||||
repeated across many pages is usually one shared component — fix it there rather than on
|
||||
each page.
|
||||
`);
|
||||
process.exit(1);
|
||||
}
|
||||
263
scripts/checkCsp.mjs
Normal file
@@ -0,0 +1,263 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* checkCsp.mjs — PLAN.md §6 and D48, added in phase 10.
|
||||
*
|
||||
* §6 promises "a strict CSP with no external origins". D48 decided that promise should be
|
||||
* a real response header sent by the container itself, not a `<meta>` (which ignores
|
||||
* `frame-ancestors`) and not advice in an operator's proxy config (which lives outside the
|
||||
* artifact we ship and test). `astro.config.mjs` sets it up; this checks it arrived.
|
||||
*
|
||||
* node scripts/checkCsp.mjs # verify the built output
|
||||
* node scripts/checkCsp.mjs --write # rewrite src/config/cspHashes.mjs from the build
|
||||
* node scripts/checkCsp.mjs --reset # empty it, so the next harvest starts from nothing
|
||||
*
|
||||
* Three things are checked, and each one has already been wrong once:
|
||||
*
|
||||
* 1. **Every built route has a policy.** `staticHeaders` writes `dist/_headers.json`; a
|
||||
* route missing from it is a page served with no CSP at all, which is the failure mode
|
||||
* nobody notices because the page looks perfect.
|
||||
*
|
||||
* 2. **Every inline script and style is covered by its page's own policy.** This is the
|
||||
* real check. Astro does not hash `<script is:inline>`, and Starlight ships six of
|
||||
* them per documentation page — so the first build with CSP on had a strict, correct
|
||||
* header and a dead theme switcher. Hashing is verified per page against that page's
|
||||
* header, not against a global list, because that is what the browser does.
|
||||
*
|
||||
* 3. **The directives §6 actually promised are present.** A policy that lost
|
||||
* `frame-ancestors` in a refactor still passes checks 1 and 2 while no longer stopping
|
||||
* anything.
|
||||
*
|
||||
* `--write` harvests the hashes from check 2 into `src/config/cspHashes.mjs`, which
|
||||
* `astro.config.mjs` feeds back into the next build. So the sequence is reset → build →
|
||||
* write → build → verify, which is what `npm run csp:hashes` runs. It resets first because
|
||||
* harvesting only ever collects what the build did NOT cover — see `--reset` below.
|
||||
*
|
||||
* No token and no network: everything read here is in `dist/`.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const headersFile = path.join(root, 'dist', '_headers.json');
|
||||
const clientDir = path.join(root, 'dist', 'client');
|
||||
const hashesFile = path.join(root, 'src', 'config', 'cspHashes.mjs');
|
||||
|
||||
const write = process.argv.includes('--write');
|
||||
const reset = process.argv.includes('--reset');
|
||||
|
||||
const failures = [];
|
||||
const fail = (what, detail) => failures.push({ what, detail });
|
||||
|
||||
/**
|
||||
* Rewrites the two exported arrays in `src/config/cspHashes.mjs`, leaving every comment and
|
||||
* the JSDoc types above them untouched.
|
||||
*/
|
||||
const writeHashes = (script, style) => {
|
||||
const source = fs.readFileSync(hashesFile, 'utf8');
|
||||
const list = (hashes) =>
|
||||
hashes.size === 0 ? '[]' : `[\n${[...hashes].sort().map((h) => ` '${h}',`).join('\n')}\n]`;
|
||||
|
||||
fs.writeFileSync(
|
||||
hashesFile,
|
||||
source
|
||||
.replace(
|
||||
/export const inlineScriptHashes = [\s\S]*?;\n/,
|
||||
`export const inlineScriptHashes = ${list(script)};\n`,
|
||||
)
|
||||
.replace(
|
||||
/export const inlineStyleHashes = [\s\S]*?;\n/,
|
||||
`export const inlineStyleHashes = ${list(style)};\n`,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* `--reset` empties the generated file, and `npm run csp:hashes` runs it FIRST.
|
||||
*
|
||||
* Without it the regeneration is not idempotent, and its failure mode is the worst
|
||||
* available: harvesting collects the blocks the build did not cover, so running it against
|
||||
* a build that is already correct finds nothing, writes two empty arrays and produces a
|
||||
* build with no hashes at all. Emptying first means the harvest always sees the same thing
|
||||
* — every inline block Astro does not hash on its own — whatever state the file was in.
|
||||
*/
|
||||
if (reset) {
|
||||
writeHashes(new Set(), new Set());
|
||||
console.log('checkCsp --reset: src/config/cspHashes.mjs emptied, ready to re-harvest.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// ── The build has to be there ───────────────────────────────────────────────
|
||||
if (!fs.existsSync(headersFile)) {
|
||||
console.error(`
|
||||
checkCsp: dist/_headers.json does not exist.
|
||||
|
||||
That file is written by the Node adapter's \`staticHeaders\` option, so either the build
|
||||
has not run (\`npm run build\`) or \`staticHeaders\` was turned off in astro.config.mjs —
|
||||
in which case the CSP is a <meta> tag and \`frame-ancestors\` is being ignored (D48).
|
||||
`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* `_headers.json` is keyed by an internal route id, so the pathname lives in the record.
|
||||
* Normalised without a trailing slash: the file says `/docs/first-run`, the built page is
|
||||
* at `docs/first-run/index.html`, and `build.format: 'directory'` serves it at
|
||||
* `/docs/first-run/`.
|
||||
*/
|
||||
const byPath = new Map();
|
||||
for (const record of Object.values(JSON.parse(fs.readFileSync(headersFile, 'utf8')))) {
|
||||
const csp = record.headers?.find((h) => h.key.toLowerCase() === 'content-security-policy');
|
||||
byPath.set(record.pathname.replace(/\/$/, '') || '/', csp?.value ?? null);
|
||||
}
|
||||
|
||||
// ── Walk the built HTML ─────────────────────────────────────────────────────
|
||||
const pages = [];
|
||||
const walk = (dir) => {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(full);
|
||||
else if (entry.name === 'index.html' || entry.name.endsWith('.html')) pages.push(full);
|
||||
}
|
||||
};
|
||||
walk(clientDir);
|
||||
|
||||
/**
|
||||
* Inline only: anything with a `src` is a fetched file and is covered by `'self'`.
|
||||
* The body is hashed exactly as written, because that is what the browser hashes — one
|
||||
* byte of whitespace either side changes the digest.
|
||||
*/
|
||||
const INLINE_SCRIPT = /<script(?![^>]*\bsrc\s*=)([^>]*)>([\s\S]*?)<\/script>/g;
|
||||
const INLINE_STYLE = /<style([^>]*)>([\s\S]*?)<\/style>/g;
|
||||
|
||||
/**
|
||||
* `<script type="application/ld+json">` (D50) is a data block, not code: the browser never
|
||||
* executes it, and CSP's script-src is not enforced against it. Demanding a hash for one
|
||||
* would be wrong twice over — it would add the structured data's own text to the list of
|
||||
* scripts allowed to run, and that text changes whenever a fact or the brand name does, so
|
||||
* the generated hash file would churn on edits that cannot affect security.
|
||||
*/
|
||||
const DATA_BLOCK = /type\s*=\s*["']application\/(ld\+json|json)["']/i;
|
||||
|
||||
const sha256 = (body) => `sha256-${createHash('sha256').update(body, 'utf8').digest('base64')}`;
|
||||
|
||||
const harvested = { script: new Set(), style: new Set() };
|
||||
let inlineScripts = 0;
|
||||
let inlineStyles = 0;
|
||||
let uncovered = 0;
|
||||
|
||||
for (const file of pages) {
|
||||
const rel = path.relative(clientDir, file).replace(/\\/g, '/');
|
||||
const pathname = '/' + rel.replace(/index\.html$/, '').replace(/\.html$/, '').replace(/\/$/, '');
|
||||
const csp = byPath.get(pathname === '/' ? '/' : pathname.replace(/\/$/, ''));
|
||||
|
||||
if (csp === undefined) {
|
||||
fail(pathname, 'is a built page with no entry in dist/_headers.json — it ships with no CSP');
|
||||
continue;
|
||||
}
|
||||
if (csp === null) {
|
||||
fail(pathname, 'has an entry in dist/_headers.json but no Content-Security-Policy header');
|
||||
continue;
|
||||
}
|
||||
|
||||
const html = fs.readFileSync(file, 'utf8');
|
||||
|
||||
for (const [kind, re, counter] of [
|
||||
['script', INLINE_SCRIPT, 'inlineScripts'],
|
||||
['style', INLINE_STYLE, 'inlineStyles'],
|
||||
]) {
|
||||
re.lastIndex = 0;
|
||||
let match;
|
||||
while ((match = re.exec(html))) {
|
||||
const [, attrs, body] = match;
|
||||
if (kind === 'script' && DATA_BLOCK.test(attrs)) continue;
|
||||
// An empty inline block needs no hash; browsers do not enforce one.
|
||||
if (body.trim() === '') continue;
|
||||
if (counter === 'inlineScripts') inlineScripts++;
|
||||
else inlineStyles++;
|
||||
|
||||
const hash = sha256(body);
|
||||
if (csp.includes(hash)) continue;
|
||||
|
||||
uncovered++;
|
||||
harvested[kind].add(hash);
|
||||
if (!write) {
|
||||
fail(
|
||||
`${pathname} (inline <${kind}>)`,
|
||||
`${hash} is not in that page's policy — ${JSON.stringify(body.trim().slice(0, 60))}…`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── The directives §6 promised, on a page that has to have them ─────────────
|
||||
const REQUIRED = [
|
||||
"default-src 'self'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
"object-src 'none'",
|
||||
"frame-ancestors 'none'",
|
||||
];
|
||||
const home = byPath.get('/');
|
||||
if (!home) {
|
||||
fail('/', 'the homepage has no CSP header at all');
|
||||
} else {
|
||||
for (const directive of REQUIRED) {
|
||||
if (!home.includes(directive)) fail('/ policy', `is missing "${directive}" (PLAN.md §6)`);
|
||||
}
|
||||
// The point of the whole exercise: a hash and 'unsafe-inline' in the same script
|
||||
// directive means browsers ignore 'unsafe-inline' — but if the hashes ever went away it
|
||||
// would quietly start applying.
|
||||
const scriptSrc = /script-src ([^;]*)/.exec(home)?.[1] ?? '';
|
||||
if (scriptSrc.includes("'unsafe-inline'")) {
|
||||
fail('/ policy', "script-src contains 'unsafe-inline' — D48 says the hashes carry this");
|
||||
}
|
||||
if (scriptSrc.includes("'unsafe-eval'")) {
|
||||
fail('/ policy', "script-src contains 'unsafe-eval' ('wasm-unsafe-eval' is the intended one)");
|
||||
}
|
||||
}
|
||||
|
||||
// ── --write: regenerate the hash file ───────────────────────────────────────
|
||||
if (write) {
|
||||
writeHashes(harvested.script, harvested.style);
|
||||
console.log(
|
||||
`checkCsp --write: harvested ${harvested.script.size} script and ${harvested.style.size} ` +
|
||||
`style hash(es) from ${pages.length} pages into src/config/cspHashes.mjs.`,
|
||||
);
|
||||
if (failures.length) {
|
||||
console.error('\ncheckCsp --write: the build is still wrong in ways hashes cannot fix:\n');
|
||||
for (const f of failures) console.error(` ✗ ${f.what}\n ${f.detail}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('Now rebuild so the next build embeds them (npm run csp:hashes does both).');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// ── Report ──────────────────────────────────────────────────────────────────
|
||||
if (failures.length === 0) {
|
||||
console.log(
|
||||
`checkCsp: ${pages.length} pages carry a policy; ` +
|
||||
`${inlineScripts} inline script(s) and ${inlineStyles} inline style(s) are all hashed.`,
|
||||
);
|
||||
} else {
|
||||
console.error(`\ncheckCsp: ${failures.length} problem(s) with the Content-Security-Policy:\n`);
|
||||
for (const f of failures) console.error(` ✗ ${f.what}\n ${f.detail}`);
|
||||
if (uncovered) {
|
||||
console.error(`
|
||||
${uncovered} inline block(s) are not covered by a hash. In a browser this is silent: the
|
||||
page renders and the script simply never runs — Starlight's theme switch and mobile
|
||||
sidebar are inline scripts, so this is how the documentation loses them.
|
||||
|
||||
If the inline block is legitimate (usually: Starlight was upgraded), run
|
||||
|
||||
npm run csp:hashes
|
||||
|
||||
which rebuilds, harvests the hashes into src/config/cspHashes.mjs and rebuilds again.
|
||||
Read what changed before committing it — that file is a list of scripts allowed to run.
|
||||
`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -60,8 +60,28 @@ async function api(pathname) {
|
||||
return res;
|
||||
}
|
||||
|
||||
const raw = async (repo, filePath, ref) =>
|
||||
(await api(`${repo}/raw/${filePath}?ref=${encodeURIComponent(ref)}`)).text();
|
||||
/**
|
||||
* A file's bytes, read through the `contents` endpoint rather than `raw`.
|
||||
*
|
||||
* `raw` answers with `Cache-Control: public, max-age=21600`, so the CDN in front of Gitea
|
||||
* serves a copy for six hours and this check can read a blob most of a working day old.
|
||||
* That is not theoretical: on the day of the engagement cutover it reported website's
|
||||
* MODULE_API_VERSION as 1.6.0 -- the value from two weeks earlier -- and failed a site
|
||||
* whose number was right. A check that goes red on stale data is a check people learn to
|
||||
* ignore, which is the one failure mode this file exists to avoid.
|
||||
*
|
||||
* `contents` answers `private, must-revalidate`, which the CDN does not cache, so it is
|
||||
* always the ref's current blob. The cost is a JSON parse and a base64 decode.
|
||||
*/
|
||||
async function raw(repo, filePath, ref) {
|
||||
const meta = await json(`${repo}/contents/${filePath}?ref=${encodeURIComponent(ref)}`);
|
||||
if (meta.encoding !== 'base64' || typeof meta.content !== 'string') {
|
||||
throw new Error(
|
||||
`${repo}:${filePath}@${ref} did not come back as a base64 file (encoding ${meta.encoding}).`
|
||||
);
|
||||
}
|
||||
return Buffer.from(meta.content, 'base64').toString('utf8');
|
||||
}
|
||||
|
||||
const json = async (pathname) => (await api(pathname)).json();
|
||||
|
||||
@@ -72,33 +92,42 @@ function extract(text, re, what, authority) {
|
||||
return m[1];
|
||||
}
|
||||
|
||||
// Every game the site quotes facts for (PLAN.md §16). Checks 1, 2, 4 and 5 run once per
|
||||
// game, and a row's label carries the game's key so a failure names which one moved.
|
||||
const GAMES = Object.entries(platform.games);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. The wire protocol, from the sidecar itself
|
||||
// 1. Each game's wire protocol, from its sidecar
|
||||
// ---------------------------------------------------------------------------
|
||||
async function checkProtocol() {
|
||||
const authority = 'link main:sidecar/src/main.rs';
|
||||
const src = await raw('link', 'sidecar/src/main.rs', 'main');
|
||||
const value = extract(
|
||||
src,
|
||||
/pub const PROTOCOL_VERSION:\s*u32\s*=\s*(\d+)\s*;/,
|
||||
'PROTOCOL_VERSION',
|
||||
authority
|
||||
);
|
||||
record('protocol (sidecar)', platform.protocol, Number(value), authority);
|
||||
for (const [key, game] of GAMES) {
|
||||
const authority = `${game.sidecarRepo} main:sidecar/src/main.rs`;
|
||||
const src = await raw(game.sidecarRepo, 'sidecar/src/main.rs', 'main');
|
||||
const value = extract(
|
||||
src,
|
||||
/pub const PROTOCOL_VERSION:\s*u32\s*=\s*(\d+)\s*;/,
|
||||
'PROTOCOL_VERSION',
|
||||
authority
|
||||
);
|
||||
record(`${key} protocol (sidecar)`, game.protocol, Number(value), authority);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. The overlay's declared protocol — the third declaration site
|
||||
// 2. Each game plugin's declared protocol — the third declaration site
|
||||
//
|
||||
// CLAUDE.md: a protocol bump has to land in overlay.toml in the same PR as the emitters,
|
||||
// or the installer refuses to pair the sidecar with the overlay. If these two ever
|
||||
// disagree, the site must not print either number.
|
||||
// or the installer refuses to pair the sidecar with the plugin. If these two ever
|
||||
// disagree, the site must not print either number. Both games keep the file under the
|
||||
// same name at the root of their plugin repository.
|
||||
// ---------------------------------------------------------------------------
|
||||
async function checkOverlayProtocol() {
|
||||
const authority = 'servuo-plugins main:overlay.toml';
|
||||
const toml = await raw('servuo-plugins', 'overlay.toml', 'main');
|
||||
const value = extract(toml, /^\s*protocol\s*=\s*(\d+)\s*$/m, 'protocol', authority);
|
||||
record('protocol (overlay)', platform.protocol, Number(value), authority);
|
||||
for (const [key, game] of GAMES) {
|
||||
const authority = `${game.pluginRepo} main:overlay.toml`;
|
||||
const toml = await raw(game.pluginRepo, 'overlay.toml', 'main');
|
||||
const value = extract(toml, /^\s*protocol\s*=\s*(\d+)\s*$/m, 'protocol', authority);
|
||||
record(`${key} protocol (plugin)`, game.protocol, Number(value), authority);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -117,7 +146,7 @@ async function checkModuleApi() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. The capabilities the installed module actually declares
|
||||
// 4. The capabilities each game's module actually declares
|
||||
//
|
||||
// §12 names "module-uo's capability list" as one of the facts platform.json holds, and it
|
||||
// was the one fact nothing re-read. That mattered from phase 3 onwards, because the
|
||||
@@ -130,36 +159,109 @@ async function checkModuleApi() {
|
||||
// array is not a changed capability set. A slug appearing or disappearing is.
|
||||
// ---------------------------------------------------------------------------
|
||||
async function checkModuleCapabilities() {
|
||||
const authority = 'Module-uo main:module.json';
|
||||
const manifest = JSON.parse(await raw('Module-uo', 'module.json', 'main'));
|
||||
const declared = [...(manifest.capabilities || [])].sort();
|
||||
const expected = [...platform.moduleUoCapabilities].sort();
|
||||
for (const [key, game] of GAMES) {
|
||||
const authority = `${game.module.repo} main:module.json`;
|
||||
const manifest = JSON.parse(await raw(game.module.repo, 'module.json', 'main'));
|
||||
const declared = [...(manifest.capabilities || [])].sort();
|
||||
const expected = [...game.module.capabilities].sort();
|
||||
|
||||
record('moduleUoCapabilities', expected.join(' '), declared.join(' '), authority);
|
||||
record(`${key} module capabilities`, expected.join(' '), declared.join(' '), authority);
|
||||
// The range the module declares against core. The manifest docs use it as the worked
|
||||
// example of a caret range resolving across minor bumps, so it is a quoted fact.
|
||||
record(`${key} module coreApi`, game.module.coreApi, manifest.coreApi, authority);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. The current bundle
|
||||
// 5. Each game's current bundle
|
||||
//
|
||||
// The manifests live at the ROOT of the `bundles` branch — `current.json`,
|
||||
// `bundle-<tag>.json` — not under `bundles/`. Fetching the directory 404s.
|
||||
// One manifest per game on the installer's `bundles` branch, at
|
||||
// `v2/<game>/current.json` (schema 2). The ROOT `current.json` is the schema-1 manifest
|
||||
// from before the installer learned a second game; it is frozen and no longer read here,
|
||||
// because a check against a file that stopped moving passes forever.
|
||||
//
|
||||
// Each bundle is protocol-checked by CI when it is published, so its protocol is a third
|
||||
// independent read of the same number rather than a duplicate of checks 1 and 2.
|
||||
// ---------------------------------------------------------------------------
|
||||
async function checkBundle() {
|
||||
const authority = 'installer bundles:current.json';
|
||||
const current = JSON.parse(await raw('installer', 'current.json', 'bundles'));
|
||||
async function readBundle(game) {
|
||||
const file = `v2/${game.bundleGame}/current.json`;
|
||||
const authority = `installer bundles:${file}`;
|
||||
const current = JSON.parse(await raw('installer', file, 'bundles'));
|
||||
if (current.schema !== 2) {
|
||||
throw new Error(`${authority} is schema ${current.schema}, and this check reads schema 2.`);
|
||||
}
|
||||
return { current, authority };
|
||||
}
|
||||
|
||||
record('bundle.tag', platform.bundle.tag, current.bundle, authority);
|
||||
record('bundle.sidecar', platform.bundle.sidecar, current.link?.tag, authority);
|
||||
record('bundle.overlay', platform.bundle.overlay, current.overlay?.tag, authority);
|
||||
async function checkBundle() {
|
||||
const { uo, rust } = platform.games;
|
||||
|
||||
{
|
||||
const { current, authority } = await readBundle(uo);
|
||||
record('uo bundle.tag', uo.bundle.tag, current.bundle, authority);
|
||||
record('uo bundle.sidecar', uo.bundle.sidecar, current.sidecar?.tag, authority);
|
||||
record('uo bundle.overlay', uo.bundle.overlay, current.payload?.tag, authority);
|
||||
record('uo bundle.servuoMin', uo.bundle.servuoMin, current.payload?.compat?.min_version, authority);
|
||||
record('uo protocol (bundle)', uo.protocol, current.protocol, authority);
|
||||
}
|
||||
|
||||
{
|
||||
const { current, authority } = await readBundle(rust);
|
||||
const compat = current.payload?.compat ?? {};
|
||||
record('rust bundle.tag', rust.bundle.tag, current.bundle, authority);
|
||||
record('rust bundle.sidecar', rust.bundle.sidecar, current.sidecar?.tag, authority);
|
||||
record('rust bundle.plugin', rust.bundle.plugin, current.payload?.tag, authority);
|
||||
record('rust bundle.oxideMin', rust.bundle.oxideMin, compat.frameworks?.oxide?.min_version, authority);
|
||||
record('rust bundle.carbonMin', rust.bundle.carbonMin, compat.frameworks?.carbon?.min_version, authority);
|
||||
record(
|
||||
'rust bundle.requiresPlugins',
|
||||
[...rust.bundle.requiresPlugins].sort().join(' '),
|
||||
[...(compat.requires_plugins ?? [])].sort().join(' '),
|
||||
authority
|
||||
);
|
||||
record('rust bundle.runicnpc', rust.bundle.runicnpc, current.npc?.tag, authority);
|
||||
record('rust protocol (bundle)', rust.protocol, current.protocol, authority);
|
||||
// The bundle carries RunicNPC's API too; check 5b reads it from the plugin's own repo.
|
||||
record('runicnpc api (bundle)', platform.runicnpc.api, current.npc?.api, authority);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5b. RunicNPC's own declarations
|
||||
//
|
||||
// RunicNPC stands alone as well as shipping in the Rust bundle (D61), so its facts are
|
||||
// read from its own `plugin.toml` too — the file its release workflow folds into the
|
||||
// manifest the installer and the egg read. The API version is the number a plugin author
|
||||
// calling `RunicNpc_*` relies on, and the site quotes it beside the API reference (D62).
|
||||
// ---------------------------------------------------------------------------
|
||||
async function checkRunicNpc() {
|
||||
const authority = 'runicnpc-rust main:plugin.toml';
|
||||
const toml = await raw('runicnpc-rust', 'plugin.toml', 'main');
|
||||
const npc = platform.runicnpc;
|
||||
|
||||
record('runicnpc api', npc.api, Number(extract(toml, /^\s*api\s*=\s*(\d+)\s*$/m, 'api', authority)), authority);
|
||||
record(
|
||||
'bundle.servuoMin',
|
||||
platform.bundle.servuoMin,
|
||||
current.overlay?.servuo?.min_version,
|
||||
'runicnpc oxideMin',
|
||||
npc.oxideMin,
|
||||
extract(toml, /^\s*min_oxide_version\s*=\s*"([^"]+)"/m, 'min_oxide_version', authority),
|
||||
authority
|
||||
);
|
||||
record(
|
||||
'runicnpc carbonMin',
|
||||
npc.carbonMin,
|
||||
extract(toml, /^\s*min_carbon_version\s*=\s*"([^"]+)"/m, 'min_carbon_version', authority),
|
||||
authority
|
||||
);
|
||||
const required = extract(toml, /^\s*requires_plugins\s*=\s*\[([^\]]*)\]/m, 'requires_plugins', authority)
|
||||
.split(',')
|
||||
.map((name) => name.trim().replace(/^"|"$/g, ''))
|
||||
.filter(Boolean);
|
||||
record(
|
||||
'runicnpc requiresPlugins',
|
||||
[...npc.requiresPlugins].sort().join(' '),
|
||||
required.sort().join(' '),
|
||||
authority
|
||||
);
|
||||
// The bundle is protocol-checked by CI when it is published, so this is a third
|
||||
// independent read of the same number rather than a duplicate of check 1.
|
||||
record('protocol (bundle)', platform.protocol, current.protocol, authority);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -329,6 +431,7 @@ async function main() {
|
||||
checkModuleApi,
|
||||
checkModuleCapabilities,
|
||||
checkBundle,
|
||||
checkRunicNpc,
|
||||
checkReleases,
|
||||
checkWebsiteHasNoReleases,
|
||||
checkAndroidApk,
|
||||
|
||||
@@ -55,12 +55,18 @@ const checked = [];
|
||||
const ok = (what) => checked.push(what);
|
||||
const fail = (what, detail) => failures.push({ what, detail });
|
||||
|
||||
/** Same raw-file accessor checkFacts.mjs uses, and for the same reason. */
|
||||
/** Same file accessor checkFacts.mjs uses, and for the same reason -- including the CDN one. */
|
||||
async function raw(repo, filePath, ref) {
|
||||
const url = `${BASE}/api/v1/repos/${ORG}/${repo}/raw/${filePath}?ref=${encodeURIComponent(ref)}`;
|
||||
const url = `${BASE}/api/v1/repos/${ORG}/${repo}/contents/${filePath}?ref=${encodeURIComponent(ref)}`;
|
||||
const res = await fetch(url, { headers: { Authorization: `token ${TOKEN}` } });
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText} for ${url}`);
|
||||
return res.text();
|
||||
const meta = await res.json();
|
||||
if (meta.encoding !== 'base64' || typeof meta.content !== 'string') {
|
||||
throw new Error(
|
||||
`${repo}:${filePath}@${ref} did not come back as a base64 file (encoding ${meta.encoding}).`
|
||||
);
|
||||
}
|
||||
return Buffer.from(meta.content, 'base64').toString('utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
527
scripts/checkReference.mjs
Normal file
@@ -0,0 +1,527 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* checkReference.mjs — PLAN.md §12, added in phase 8.
|
||||
*
|
||||
* The Reference section names things: every environment variable, every config key, every
|
||||
* installer command, every canonical document. §1 forbids re-specifying a contract, and
|
||||
* this is the machinery that makes writing the NAMES down safe anyway — the same bargain
|
||||
* checkQuickstart.mjs struck for the quickstart, applied to six more sources.
|
||||
*
|
||||
* Phase 15 (PLAN.md §16) added the Rust group's three: rust-link's `sidecar.toml` and its
|
||||
* environment overrides, the bridge plugin's `RunicGateway.json`, and the event kinds with the
|
||||
* class each is served under (D71).
|
||||
*
|
||||
* Each enumeration in `src/data/reference.mjs` is compared against its authority, read from
|
||||
* the repository that owns it over the Gitea API — never from a working tree, per §1's
|
||||
* process rule. Every comparison is a SET comparison in both directions:
|
||||
*
|
||||
* - a name this site lists that the source no longer has fails (the reference is stale);
|
||||
* - a name the source has that this site does not list fails (the reference is
|
||||
* incomplete, which is the failure mode a hand-maintained list actually has).
|
||||
*
|
||||
* The second direction is the one that earns its keep. A reference page does not usually
|
||||
* rot by describing something that vanished — it rots by quietly not mentioning the three
|
||||
* things added since it was written.
|
||||
*
|
||||
* Descriptions are deliberately NOT checked. Nothing here can know whether a one-line
|
||||
* summary is still true, so it does not pretend to; keeping them terse is the mitigation.
|
||||
*
|
||||
* GITEA_TOKEN=<token> node scripts/checkReference.mjs
|
||||
*
|
||||
* Anonymous raw fetches fail on this instance, so the token is required. A check that
|
||||
* silently skips itself is worse than no check.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
envVars,
|
||||
sidecarConfig,
|
||||
installerCommands,
|
||||
bridgeCfg,
|
||||
visibilityLadder,
|
||||
canonicalDocs,
|
||||
rustSidecarConfig,
|
||||
rustSidecarEnv,
|
||||
rustPluginConfig,
|
||||
rustEventKinds,
|
||||
} from '../src/data/reference.mjs';
|
||||
import {
|
||||
runicnpcOwners,
|
||||
runicnpcCalls,
|
||||
runicnpcHooks,
|
||||
runicnpcListKeys,
|
||||
runicnpcPlacementKeys,
|
||||
runicnpcRouteKeys,
|
||||
runicnpcProfileFields,
|
||||
runicnpcCommands,
|
||||
runicnpcPermissions,
|
||||
runicnpcPlaceOptions,
|
||||
runicnpcConfig,
|
||||
} from '../src/data/runicnpc.mjs';
|
||||
|
||||
const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
||||
const platform = JSON.parse(readFileSync(path.join(ROOT, 'src/data/platform.json'), 'utf8'));
|
||||
|
||||
const BASE = platform.gitea.base;
|
||||
const ORG = platform.gitea.org;
|
||||
const TOKEN = process.env.GITEA_TOKEN?.trim();
|
||||
|
||||
const failures = [];
|
||||
const checked = [];
|
||||
const ok = (what) => checked.push(what);
|
||||
const fail = (what, detail) => failures.push({ what, detail });
|
||||
|
||||
/** Same file accessor checkFacts.mjs and checkQuickstart.mjs use, CDN caveat included. */
|
||||
async function raw(repo, filePath, ref = 'main') {
|
||||
const url = `${BASE}/api/v1/repos/${ORG}/${repo}/contents/${filePath}?ref=${encodeURIComponent(ref)}`;
|
||||
const res = await fetch(url, { headers: { Authorization: `token ${TOKEN}` } });
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText} for ${url}`);
|
||||
const meta = await res.json();
|
||||
if (meta.encoding !== 'base64' || typeof meta.content !== 'string') {
|
||||
throw new Error(
|
||||
`${repo}:${filePath}@${ref} did not come back as a base64 file (encoding ${meta.encoding}).`
|
||||
);
|
||||
}
|
||||
return Buffer.from(meta.content, 'base64').toString('utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* The one comparison this whole script performs, so the failure messages are identical
|
||||
* everywhere and say which direction broke.
|
||||
*/
|
||||
function compareSets(label, mine, theirs, hint) {
|
||||
const mineSet = new Set(mine);
|
||||
const theirsSet = new Set(theirs);
|
||||
|
||||
const stale = [...mineSet].filter((k) => !theirsSet.has(k));
|
||||
const missing = [...theirsSet].filter((k) => !mineSet.has(k));
|
||||
|
||||
for (const k of stale) {
|
||||
fail(`${label}: ${k}`, `listed here, but ${hint} no longer has it — remove it, and re-read the prose around it`);
|
||||
}
|
||||
for (const k of missing) {
|
||||
fail(`${label}: ${k}`, `is in ${hint} and NOT listed here — add it, or the reference is lying by omission`);
|
||||
}
|
||||
if (!stale.length && !missing.length) ok(`${label} (${mineSet.size})`);
|
||||
}
|
||||
|
||||
/** `KEY=value` lines. Commented-out suggestions are prose about a variable, not a key. */
|
||||
const envKeysOf = (text) =>
|
||||
text
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.match(/^([A-Z][A-Z0-9_]*)=/))
|
||||
.filter(Boolean)
|
||||
.map((m) => m[1]);
|
||||
|
||||
/** `Key=value` lines from the plugin's config, same rule about comments. */
|
||||
const cfgKeysOf = (text) =>
|
||||
text
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.match(/^([A-Za-z][A-Za-z0-9]*)=/))
|
||||
.filter(Boolean)
|
||||
.map((m) => m[1]);
|
||||
|
||||
/**
|
||||
* A sidecar's TOML keys, parsed from its serde structs rather than from a sample file,
|
||||
* because the sample is GENERATED by the binary on first run and no committed copy is
|
||||
* authoritative. Each `pub name: T` inside a `struct XCfg` is one key, and the struct name
|
||||
* gives the section. Both sidecars are written this way, so one parser serves both.
|
||||
*/
|
||||
function tomlKeysOf(configRs) {
|
||||
const keys = [];
|
||||
for (const m of configRs.matchAll(/struct\s+(\w+)Cfg\s*\{([\s\S]*?)\n\}/g)) {
|
||||
const section = m[1].toLowerCase();
|
||||
for (const f of m[2].matchAll(/pub\s+(\w+)\s*:/g)) keys.push(`${section}.${f[1]}`);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/** The body of a C# class, by brace counting from its opening brace. */
|
||||
function classBody(source, name) {
|
||||
const start = source.search(new RegExp(`class\\s+${name}\\b`));
|
||||
if (start < 0) return null;
|
||||
const open = source.indexOf('{', start);
|
||||
let depth = 0;
|
||||
for (let i = open; i < source.length; i++) {
|
||||
if (source[i] === '{') depth++;
|
||||
else if (source[i] === '}' && --depth === 0) return source.slice(open + 1, i);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The quoted strings inside `const NAME = Object.freeze([ … ])`, comments removed first —
|
||||
* the module's comments are prose, and prose has apostrophes.
|
||||
*/
|
||||
function frozenListOf(source, name) {
|
||||
const m = source.match(new RegExp(`const\\s+${name}\\s*=\\s*Object\\.freeze\\(\\[([\\s\\S]*?)\\]\\)`));
|
||||
if (!m) return null;
|
||||
const code = m[1].replace(/\/\/.*$/gm, '');
|
||||
return [...code.matchAll(/'([^'\n]+)'/g)].map((x) => x[1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* One section of a Markdown document: from the line starting with `heading` to the next
|
||||
* heading of the same or a higher level. Null when there is no such heading, which every
|
||||
* caller reports — a section that silently vanished must not read as an empty one.
|
||||
*/
|
||||
function mdSection(md, heading) {
|
||||
const lines = md.split(/\r?\n/);
|
||||
const start = lines.findIndex((l) => l.startsWith(heading));
|
||||
if (start < 0) return null;
|
||||
const level = heading.match(/^#+/)[0].length;
|
||||
const stop = new RegExp(`^#{1,${level}} `);
|
||||
let end = lines.length;
|
||||
for (let i = start + 1; i < lines.length; i++) {
|
||||
if (stop.test(lines[i])) {
|
||||
end = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return lines.slice(start + 1, end).join('\n');
|
||||
}
|
||||
|
||||
/** The backticked names in the first cell of every table row of a section. */
|
||||
function firstCellNames(section) {
|
||||
return section
|
||||
.split('\n')
|
||||
.filter((l) => l.startsWith('| `'))
|
||||
.map((l) => [...l.split('|')[1].matchAll(/`([^`]+)`/g)].map((m) => m[1]));
|
||||
}
|
||||
|
||||
const collapse = (s) => s.replace(/\s+/g, ' ').trim();
|
||||
|
||||
/** The top-level keys of the first ```json block in a section. */
|
||||
function jsonKeysOf(section) {
|
||||
const m = section?.match(/```json\s*\n([\s\S]*?)\n```/);
|
||||
if (!m) return null;
|
||||
try {
|
||||
return Object.keys(JSON.parse(m[1]));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkRunicNpcApi(md) {
|
||||
const hint = 'docs main:runicnpc/API.md';
|
||||
|
||||
// The version the specification describes, against the one platform.json quotes (and
|
||||
// checkFacts holds to the plugin's own plugin.toml). Together they close the loop.
|
||||
const version = Number(md.match(/\*\*API version (\d+)\*\*/)?.[1]);
|
||||
if (version !== platform.runicnpc.api) {
|
||||
fail('RunicNPC API version', `${hint} describes API ${version || '(none found)'}; platform.json says ${platform.runicnpc.api}`);
|
||||
} else ok(`RunicNPC API version (${version})`);
|
||||
|
||||
// Calls: `### \`RunicNpc_X(params)\` → \`returns\`` headings, compared by whole signature.
|
||||
const theirCalls = new Map(
|
||||
[...md.matchAll(/^### `(RunicNpc_\w+)\(([^)]*)\)` → `([^`]+)`\s*$/gm)].map((m) => [m[1], collapse(`(${m[2]}) → ${m[3]}`)]),
|
||||
);
|
||||
const myCalls = new Map(Object.entries(runicnpcCalls).map(([n, c]) => [n, collapse(`(${c.params}) → ${c.returns}`)]));
|
||||
compareSignatures('RunicNPC call', myCalls, theirCalls, hint);
|
||||
|
||||
// Hooks: the first cell of each row of "Hooks it raises".
|
||||
const hooksSection = mdSection(md, '## Hooks it raises');
|
||||
if (!hooksSection) fail('RunicNPC hooks', `no "## Hooks it raises" section in ${hint}`);
|
||||
else {
|
||||
const theirHooks = new Map(
|
||||
[...hooksSection.matchAll(/^\| `(OnRunicNpc\w+)\(([^)]*)\)` \|/gm)].map((m) => [m[1], collapse(`(${m[2]})`)]),
|
||||
);
|
||||
const myHooks = new Map(Object.entries(runicnpcHooks).map(([n, h]) => [n, collapse(`(${h.params})`)]));
|
||||
compareSignatures('RunicNPC hook', myHooks, theirHooks, hint);
|
||||
}
|
||||
|
||||
// The data shapes, by field name (D73).
|
||||
const shape = (label, heading, mine, read) => {
|
||||
const section = mdSection(md, heading);
|
||||
const theirs = section && read(section);
|
||||
if (!theirs || theirs.length === 0) fail(label, `could not read the "${heading}" section of ${hint}`);
|
||||
else compareSets(label, mine, theirs, `${hint} "${heading.replace(/^#+ /, '')}"`);
|
||||
};
|
||||
|
||||
shape('RunicNPC owner', '## Owners', Object.keys(runicnpcOwners), (s) => firstCellNames(s).flat());
|
||||
shape('RunicNpc_List key', '### `RunicNpc_List', Object.keys(runicnpcListKeys), (s) => firstCellNames(s).flat());
|
||||
|
||||
// A profile's fields. A cell may name several (`hurtByPlayers`, `hurtsPlayers`), and a bare
|
||||
// name after a dotted one shares its prefix (`phases[].damageDealt`, `aimCone`).
|
||||
shape(
|
||||
'RunicNPC profile field',
|
||||
'### A profile',
|
||||
Object.values(runicnpcProfileFields).flatMap((group) => Object.keys(group)),
|
||||
(s) =>
|
||||
firstCellNames(s).flatMap(([first, ...rest]) => {
|
||||
const prefix = first.includes('.') ? first.slice(0, first.lastIndexOf('.') + 1) : '';
|
||||
return [first, ...rest.map((n) => (n.includes('.') ? n : prefix + n))];
|
||||
}),
|
||||
);
|
||||
|
||||
// A placement's keys: the example's, plus the optional ones its bullets introduce.
|
||||
shape('RunicNPC placement key', '### A placement', Object.keys(runicnpcPlacementKeys), (s) => {
|
||||
const keys = jsonKeysOf(s);
|
||||
return keys && [...new Set([...keys, ...[...s.matchAll(/^- `(\w+)`/gm)].map((m) => m[1])])];
|
||||
});
|
||||
shape('RunicNPC route key', '### A route', Object.keys(runicnpcRouteKeys), jsonKeysOf);
|
||||
|
||||
// The page itself: every call has exactly one <Signature call="…" />, so "in full" is held
|
||||
// by the build and not by whoever last edited api.mdx.
|
||||
const page = readFileSync(path.join(ROOT, 'src/content/docs/docs/rust/runicnpc/api.mdx'), 'utf8');
|
||||
const onPage = [...page.matchAll(/<Signature\s+call="(\w+)"/g)].map((m) => m[1]);
|
||||
let covered = true;
|
||||
for (const call of Object.keys(runicnpcCalls)) {
|
||||
const n = onPage.filter((c) => c === call).length;
|
||||
if (n !== 1) {
|
||||
covered = false;
|
||||
fail(`RunicNPC API page: ${call}`, `has ${n} <Signature call="${call}" /> on api.mdx — every call needs exactly one, with its prose`);
|
||||
}
|
||||
}
|
||||
if (covered) ok(`RunicNPC API page covers every call (${onPage.length})`);
|
||||
}
|
||||
|
||||
/** Compare name → signature maps: both directions by name, then each shared name by signature. */
|
||||
function compareSignatures(label, mine, theirs, hint) {
|
||||
compareSets(label, [...mine.keys()], [...theirs.keys()], hint);
|
||||
let same = true;
|
||||
for (const [name, sig] of mine) {
|
||||
if (theirs.has(name) && theirs.get(name) !== sig) {
|
||||
same = false;
|
||||
fail(`${label} ${name}`, `signature differs — here "${sig}", ${hint} "${theirs.get(name)}"`);
|
||||
}
|
||||
}
|
||||
if (same) ok(`${label} signatures (${mine.size})`);
|
||||
}
|
||||
|
||||
/** The text of a C# method, by brace counting from the first brace after its name. */
|
||||
function methodBody(source, name) {
|
||||
const start = source.search(new RegExp(`\\b${name}\\s*\\(`));
|
||||
if (start < 0) return null;
|
||||
const open = source.indexOf('{', start);
|
||||
let depth = 0;
|
||||
for (let i = open; i < source.length; i++) {
|
||||
if (source[i] === '{') depth++;
|
||||
else if (source[i] === '}' && --depth === 0) return source.slice(open + 1, i);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function checkRunicNpcSource(cs) {
|
||||
const hint = 'runicnpc-rust main:plugin/RunicNPC.cs';
|
||||
|
||||
// The verbs: every `rnpc.<verb>` console command. The chat command answers the same verbs.
|
||||
compareSets(
|
||||
'RunicNPC command',
|
||||
[...new Set(Object.values(runicnpcCommands).flatMap((s) => Object.keys(s)).map((usage) => usage.split(' ')[0]))],
|
||||
[...cs.matchAll(/\[ConsoleCommand\("rnpc\.(\w+)"\)\]/g)].map((m) => m[1]),
|
||||
`${hint} [ConsoleCommand]`,
|
||||
);
|
||||
|
||||
// A command's permission must be one the plugin registers.
|
||||
const perms = [...cs.matchAll(/const\s+string\s+Perm\w+\s*=\s*"(runicnpc\.\w+)"/g)].map((m) => m[1]);
|
||||
compareSets('RunicNPC permission', Object.keys(runicnpcPermissions), perms, `${hint} Perm constants`);
|
||||
for (const [section, commands] of Object.entries(runicnpcCommands)) {
|
||||
for (const [usage, c] of Object.entries(commands)) {
|
||||
if (c.perm.startsWith('runicnpc.') && !perms.includes(c.perm)) {
|
||||
fail(`RunicNPC command ${usage}`, `(${section}) needs "${c.perm}", which the plugin does not register`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const options = methodBody(cs, 'ParseOptions');
|
||||
if (!options) fail('RunicNPC place option', `could not find ParseOptions in ${hint}`);
|
||||
else {
|
||||
compareSets(
|
||||
'RunicNPC place option',
|
||||
Object.keys(runicnpcPlaceOptions),
|
||||
[...options.matchAll(/case\s+"(\w+)":/g)].map((m) => m[1]),
|
||||
`${hint} ParseOptions`,
|
||||
);
|
||||
}
|
||||
|
||||
// The config: `[JsonProperty("name")] public Type Field`, and a field whose type is another
|
||||
// config class is a section, its keys written `section.key`.
|
||||
const jsonKeys = (className, prefix = '') => {
|
||||
const body = classBody(cs, className);
|
||||
if (!body) return null;
|
||||
return [...body.matchAll(/\[JsonProperty\("(\w+)"\)\]\s*public\s+(\w+)\s+\w+/g)].flatMap(([, key, type]) => {
|
||||
const nested = /Config$/.test(type) ? jsonKeys(type, `${prefix}${key}.`) : null;
|
||||
return nested ?? [`${prefix}${key}`];
|
||||
});
|
||||
};
|
||||
const configKeys = jsonKeys('PluginConfig');
|
||||
if (!configKeys) fail('RunicNPC.json', `could not find class PluginConfig in ${hint}`);
|
||||
else compareSets('RunicNPC.json', Object.keys(runicnpcConfig), configKeys, `${hint} PluginConfig`);
|
||||
}
|
||||
|
||||
async function run() {
|
||||
if (!TOKEN) {
|
||||
console.error('checkReference: GITEA_TOKEN is not set. This check cannot run anonymously.');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// ── 1. Environment variables ──────────────────────────────────────────────
|
||||
compareSets(
|
||||
'env',
|
||||
Object.keys(envVars),
|
||||
envKeysOf(await raw('website', '.env.example')),
|
||||
'website main:.env.example',
|
||||
);
|
||||
|
||||
// ── 2. sidecar.toml ───────────────────────────────────────────────────────
|
||||
//
|
||||
// Parsed from the serde structs rather than from a sample file, because the sample is
|
||||
// GENERATED by the binary on first run and no committed copy is authoritative. Each
|
||||
// `pub name: T` inside a `struct XCfg` is one key, and the struct name gives the section.
|
||||
compareSets(
|
||||
'sidecar.toml',
|
||||
Object.keys(sidecarConfig),
|
||||
tomlKeysOf(await raw('link', 'sidecar/src/config.rs')),
|
||||
'link main:sidecar/src/config.rs',
|
||||
);
|
||||
|
||||
// ── 3. Installer commands ─────────────────────────────────────────────────
|
||||
const cliRs = await raw('installer', 'src/cli.rs');
|
||||
const cmdBlock = cliRs.match(/enum\s+Command\s*\{([\s\S]*?)\n\}/);
|
||||
const cmds = cmdBlock ? [...cmdBlock[1].matchAll(/^\s*([A-Z]\w*)\s*[,{]/gm)].map((m) => m[1]) : [];
|
||||
compareSets('installer command', Object.keys(installerCommands), cmds, 'installer main:src/cli.rs');
|
||||
|
||||
// ── 4. Bridge.cfg ─────────────────────────────────────────────────────────
|
||||
const bridgeKeys = Object.values(bridgeCfg).flatMap((group) => Object.keys(group));
|
||||
compareSets(
|
||||
'Bridge.cfg',
|
||||
bridgeKeys,
|
||||
cfgKeysOf(await raw('servuo-plugins', 'overlay/Config/Bridge.cfg')),
|
||||
'servuo-plugins main:overlay/Config/Bridge.cfg',
|
||||
);
|
||||
|
||||
// ── 5. The visibility ladder ──────────────────────────────────────────────
|
||||
//
|
||||
// A security boundary, so it is checked against the module that enforces it rather than
|
||||
// against prose. The order matters as much as the membership: it is a ladder, and a
|
||||
// reader reasoning about "staff and above" needs the rungs in the right sequence.
|
||||
const vis = await raw('Module-uo', 'server/utils/shardVisibility.js');
|
||||
const ladderMatch = vis.match(/const\s+LADDER\s*=\s*\[([\s\S]*?)\]/);
|
||||
const ladder = ladderMatch
|
||||
? [...ladderMatch[1].matchAll(/'([a-z_]+)'/g)].map((m) => m[1])
|
||||
: [];
|
||||
if (ladder.length === 0) {
|
||||
fail('visibility ladder', 'could not find LADDER in Module-uo main:server/utils/shardVisibility.js');
|
||||
} else if (ladder.join(' ') !== visibilityLadder.join(' ')) {
|
||||
fail(
|
||||
'visibility ladder',
|
||||
`order or membership differs — here "${visibilityLadder.join(' → ')}", upstream "${ladder.join(' → ')}"`,
|
||||
);
|
||||
} else ok(`visibility ladder (${ladder.length} rungs, in order)`);
|
||||
|
||||
// ── 6. Canonical documents ────────────────────────────────────────────────
|
||||
//
|
||||
// Existence only. A link to a document that moved is the single most likely way this
|
||||
// section breaks, and it is exactly what a build can answer.
|
||||
for (const docPath of Object.values(canonicalDocs).flatMap((group) => Object.keys(group))) {
|
||||
const url = `${BASE}/api/v1/repos/${ORG}/docs/contents/${docPath}?ref=main`;
|
||||
const res = await fetch(url, { headers: { Authorization: `token ${TOKEN}` } });
|
||||
if (res.ok) ok(`canonical doc ${docPath}`);
|
||||
else fail(`canonical doc ${docPath}`, `not found in docs main (HTTP ${res.status})`);
|
||||
}
|
||||
|
||||
// ── 7. Rust: rust-link's sidecar.toml, and the variables that override it ──
|
||||
//
|
||||
// PLAN.md §16, phase 15. The same parser as uo-link's — both sidecars declare their file
|
||||
// as serde structs — plus the environment overrides, which a Pterodactyl egg sets and an
|
||||
// operator meets on the panel before they ever see the file.
|
||||
const rustConfigRs = await raw('Rust-Link', 'sidecar/src/config.rs');
|
||||
compareSets(
|
||||
'rust-link sidecar.toml',
|
||||
Object.keys(rustSidecarConfig),
|
||||
tomlKeysOf(rustConfigRs),
|
||||
'Rust-Link main:sidecar/src/config.rs',
|
||||
);
|
||||
compareSets(
|
||||
'rust-link environment variable',
|
||||
Object.keys(rustSidecarEnv),
|
||||
[...rustConfigRs.matchAll(/get\("(RUSTLINK_[A-Z_]+)"\)/g)].map((m) => m[1]),
|
||||
'Rust-Link main:sidecar/src/config.rs',
|
||||
);
|
||||
for (const [envName, key] of Object.entries(rustSidecarEnv)) {
|
||||
if (!(key in rustSidecarConfig)) fail(`rust-link environment variable ${envName}`, `says it overrides "${key}", which is not a key listed here`);
|
||||
}
|
||||
|
||||
// ── 8. Rust: the bridge plugin's RunicGateway.json ────────────────────────
|
||||
//
|
||||
// The public fields of `PluginConfig`, which the framework serialises as the file.
|
||||
const pluginCs = await raw('Rust-Plugins', 'overlay/oxide/plugins/RunicGateway.cs');
|
||||
const pluginBody = classBody(pluginCs, 'PluginConfig');
|
||||
if (!pluginBody) {
|
||||
fail('RunicGateway.json', 'could not find class PluginConfig in Rust-Plugins main:overlay/oxide/plugins/RunicGateway.cs');
|
||||
} else {
|
||||
compareSets(
|
||||
'RunicGateway.json',
|
||||
Object.values(rustPluginConfig).flatMap((group) => Object.keys(group)),
|
||||
[...pluginBody.matchAll(/public\s+[\w<>[\],]+\s+(\w+)\s*=/g)].map((m) => m[1]),
|
||||
'Rust-Plugins main:overlay/oxide/plugins/RunicGateway.cs PluginConfig',
|
||||
);
|
||||
}
|
||||
|
||||
// ── 9. Rust: the event kinds, and who may see each ────────────────────────
|
||||
//
|
||||
// D71: checked against the module's own catalogue, because the module is what enforces
|
||||
// the boundary (the same reasoning as check 5). Three comparisons, each a security
|
||||
// statement: a kind listed public here must be public there, a presence kind must be one
|
||||
// of the module's presence kinds, and a staff kind must be staff there. A kind moved from
|
||||
// staff to public upstream fails this check twice over, which is the point.
|
||||
const catalogue = await raw('Module-Rust', 'server/catalogue.js');
|
||||
const upPublic = frozenListOf(catalogue, 'PUBLIC_KINDS');
|
||||
const upStaff = frozenListOf(catalogue, 'STAFF_KINDS');
|
||||
const upPresence = frozenListOf(catalogue, 'PRESENCE_KINDS');
|
||||
if (!upPublic || !upStaff || !upPresence) {
|
||||
fail('Rust event kinds', 'could not find PUBLIC_KINDS, STAFF_KINDS and PRESENCE_KINDS in Module-Rust main:server/catalogue.js');
|
||||
} else {
|
||||
const hint = 'Module-Rust main:server/catalogue.js';
|
||||
compareSets('Rust presence kind', Object.keys(rustEventKinds.presence), upPresence, `${hint} PRESENCE_KINDS`);
|
||||
compareSets(
|
||||
'Rust public kind',
|
||||
Object.keys(rustEventKinds.public),
|
||||
upPublic.filter((k) => !upPresence.includes(k)),
|
||||
`${hint} PUBLIC_KINDS (less its presence kinds)`,
|
||||
);
|
||||
compareSets('Rust staff kind', Object.keys(rustEventKinds.staff), upStaff, `${hint} STAFF_KINDS`);
|
||||
for (const k of upPresence) {
|
||||
if (!upPublic.includes(k)) fail(`Rust presence kind ${k}`, 'is a presence kind upstream but not a public one — the page explains presence as a narrowing of public, so re-read it');
|
||||
}
|
||||
}
|
||||
|
||||
// ── 10. RunicNPC: the API, against its specification ──────────────────────
|
||||
//
|
||||
// PLAN.md §16, D62 and D73. The one place this site carries a contract in full rather than
|
||||
// linking to it, so the one place it must be held to the contract most tightly: every call
|
||||
// and hook by its whole signature, and every field name of every data shape, both ways.
|
||||
// `docs` `main:runicnpc/API.md` is normative, so it is the authority, not the plugin source.
|
||||
await checkRunicNpcApi(await raw('docs', 'runicnpc/API.md'));
|
||||
|
||||
// ── 11. RunicNPC: the commands, permissions, options and config ───────────
|
||||
//
|
||||
// Names, checked against the plugin's own source the way RunicGateway.json is (check 8):
|
||||
// the console verbs it registers, the permissions it registers, the `key=` options
|
||||
// `place` and `here` parse, and the JSON keys of its config classes.
|
||||
checkRunicNpcSource(await raw('runicnpc-rust', 'plugin/RunicNPC.cs'));
|
||||
|
||||
// ── Report ────────────────────────────────────────────────────────────────
|
||||
if (failures.length === 0) {
|
||||
console.log(`checkReference: ${checked.length} enumeration check(s) passed against their sources.`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(`\ncheckReference: ${failures.length} disagreement(s) with the platform:\n`);
|
||||
for (const f of failures) console.error(` ✗ ${f.what}\n ${f.detail}`);
|
||||
console.error(`
|
||||
The Reference section names things, which is only safe while the names are checked
|
||||
(§1, and the same bargain checkQuickstart.mjs struck). Update src/data/reference.mjs
|
||||
to match the source. Do not "fix" the check.
|
||||
`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
run().catch((err) => {
|
||||
console.error(`checkReference: ${err.message}`);
|
||||
process.exit(2);
|
||||
});
|
||||
171
scripts/checkScreens.mjs
Normal file
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* checkScreens.mjs — the screenshots agree with what the pages say about them.
|
||||
*
|
||||
* PLAN.md §12, §13 phase 9, D45.
|
||||
*
|
||||
* node scripts/checkScreens.mjs
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHAT IT PROVES, AND WHY EACH ONE IS WORTH A CHECK
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* 1. EVERY DECLARED SCREEN HAS A FILE. A missing image is invisible in review — the page
|
||||
* still builds, still lays out, and only a reader sees the broken frame.
|
||||
*
|
||||
* 2. EVERY FILE IS THE DECLARED SIZE. `width` and `height` reach the markup as intrinsic
|
||||
* attributes, and an attribute that disagrees with the file is a page that jumps as the
|
||||
* image decodes. It also catches a re-capture taken at the wrong viewport, which looks
|
||||
* fine on its own and wrong beside the others.
|
||||
*
|
||||
* 3. NOTHING IN public/screens IS ORPHANED. A capture that stopped being referenced is a
|
||||
* file the container still ships and nobody looks at — and, worse, one that never gets
|
||||
* retaken, so it silently becomes the oldest thing in the repository.
|
||||
*
|
||||
* 4. EVERY DECLARED SCREEN IS ACTUALLY USED. The mirror of 3: an entry in `screens.mjs`
|
||||
* that no page renders is a capture being maintained for nothing. Usage is a literal
|
||||
* search for the id across `src/`, which is how both readers of the data refer to one —
|
||||
* `<Screenshot id="admin-users" />` and the `groupScreens` map on `/features/`.
|
||||
*
|
||||
* 5. THE ALT TEXT AND CAPTION SAY SOMETHING. An empty alt on an editorial image is an
|
||||
* accessibility failure the build cannot otherwise see, and a caption is the sentence
|
||||
* that makes a screenshot evidence rather than decoration.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY IT READS THE PNG HEADER ITSELF
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* It does not: it reads the WebP header, and it does it with twenty lines rather than a
|
||||
* dependency. `sharp` is already here for the brand assets and could answer this, but this
|
||||
* check runs in CI on every pull request and a check that needs a native image library to
|
||||
* tell you a file is 1920 pixels wide is a check that will one day fail for a reason that
|
||||
* has nothing to do with screenshots.
|
||||
*/
|
||||
|
||||
import { readdirSync, readFileSync, existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { screens, WEB, PHONE } from '../src/data/screens.mjs';
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.join(HERE, '..');
|
||||
const DIR = path.join(ROOT, 'public', 'screens');
|
||||
const SRC = path.join(ROOT, 'src');
|
||||
|
||||
const problems = [];
|
||||
|
||||
/**
|
||||
* The pixel size of a WebP file, from its header.
|
||||
*
|
||||
* A RIFF container: "RIFF" size "WEBP" then one of three chunk types. Lossy ("VP8 ") and
|
||||
* lossless ("VP8L") pack the dimensions differently, and an animated or extended file
|
||||
* ("VP8X") states them outright. `cwebp` at quality 82 writes VP8 , but a future change of
|
||||
* encoder should not turn this check into a mystery, so all three are handled.
|
||||
*/
|
||||
function webpSize(file) {
|
||||
const buf = readFileSync(file);
|
||||
|
||||
if (buf.length < 30 || buf.toString('ascii', 0, 4) !== 'RIFF' || buf.toString('ascii', 8, 12) !== 'WEBP') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const chunk = buf.toString('ascii', 12, 16);
|
||||
|
||||
if (chunk === 'VP8X') {
|
||||
return {
|
||||
width: 1 + (buf[24] | (buf[25] << 8) | (buf[26] << 16)),
|
||||
height: 1 + (buf[27] | (buf[28] << 8) | (buf[29] << 16)),
|
||||
};
|
||||
}
|
||||
|
||||
if (chunk === 'VP8L') {
|
||||
const bits = buf[21] | (buf[22] << 8) | (buf[23] << 16) | (buf[24] << 24);
|
||||
return { width: 1 + (bits & 0x3fff), height: 1 + ((bits >> 14) & 0x3fff) };
|
||||
}
|
||||
|
||||
if (chunk === 'VP8 ') {
|
||||
return {
|
||||
width: buf.readUInt16LE(26) & 0x3fff,
|
||||
height: buf.readUInt16LE(28) & 0x3fff,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Every file under `src/`, read once, so usage is a search rather than a guess. */
|
||||
function sourceText() {
|
||||
const out = [];
|
||||
|
||||
const walk = (dir) => {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(full);
|
||||
else if (/\.(astro|mdx?|mjs|js|ts|tsx)$/.test(entry.name)) out.push(readFileSync(full, 'utf8'));
|
||||
}
|
||||
};
|
||||
|
||||
walk(SRC);
|
||||
return out;
|
||||
}
|
||||
|
||||
const sources = sourceText();
|
||||
const declared = new Set();
|
||||
|
||||
for (const shot of screens) {
|
||||
const name = `${shot.id}.webp`;
|
||||
const file = path.join(DIR, name);
|
||||
declared.add(name);
|
||||
|
||||
if (!existsSync(file)) {
|
||||
problems.push(
|
||||
`${shot.id}: no file at public/screens/${name}. ` +
|
||||
`Retake it: node scripts/captureScreens.mjs ${shot.id}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const want = shot.family === 'web' ? WEB : PHONE;
|
||||
const size = webpSize(file);
|
||||
|
||||
if (!size) {
|
||||
problems.push(`${shot.id}: public/screens/${name} is not a WebP this check can read.`);
|
||||
} else if (size.width !== want.width || size.height !== want.height) {
|
||||
problems.push(
|
||||
`${shot.id}: file is ${size.width}x${size.height}, ` +
|
||||
`declared ${want.width}x${want.height} for the "${shot.family}" family.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!shot.alt || shot.alt.length < 20) {
|
||||
problems.push(`${shot.id}: alt text is missing or too short to describe the screen.`);
|
||||
}
|
||||
|
||||
if (!shot.caption) {
|
||||
problems.push(`${shot.id}: no caption.`);
|
||||
}
|
||||
|
||||
const used = sources.some((text) => text.includes(`'${shot.id}'`) || text.includes(`"${shot.id}"`));
|
||||
|
||||
if (!used) {
|
||||
problems.push(
|
||||
`${shot.id}: declared but no page renders it. Use it, or delete the entry and its file.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (existsSync(DIR)) {
|
||||
for (const name of readdirSync(DIR)) {
|
||||
if (!declared.has(name)) {
|
||||
problems.push(`public/screens/${name}: not declared in src/data/screens.mjs.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (problems.length > 0) {
|
||||
console.error(`\ncheckScreens: ${problems.length} problem(s)\n`);
|
||||
for (const problem of problems) console.error(` - ${problem}`);
|
||||
console.error('');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`checkScreens: ${screens.length} screens, all present, sized and used.`);
|
||||
79
scripts/checkSidebar.mjs
Normal file
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* checkSidebar.mjs — PLAN.md §12, added in phase 8.
|
||||
*
|
||||
* `src/config/sidebar.mjs` holds two trees: `docsSidebar`, which Starlight renders, and
|
||||
* `plannedSidebar`, the tree §10 planned. While pages were still being written the second
|
||||
* was a checklist. Now that every page exists it is a second copy of the first, maintained
|
||||
* by hand — and a hand-maintained copy with nothing reading it is exactly the shape of
|
||||
* thing §1 is about.
|
||||
*
|
||||
* It had already drifted, silently: phase 7 added the `Content` page under D37 and this
|
||||
* list was never updated. Nothing failed, because nothing read it. That is the whole
|
||||
* argument for this check.
|
||||
*
|
||||
* So the two must agree on groups, labels AND order. Order is checked because the order of
|
||||
* "Getting started" IS the installation path — §10 calls it the priority of the whole
|
||||
* project — and a reordering that nobody noticed would be a worse defect than a missing
|
||||
* page.
|
||||
*
|
||||
* node scripts/checkSidebar.mjs
|
||||
*
|
||||
* No token and no network: both trees are in this repository.
|
||||
*/
|
||||
|
||||
import { docsSidebar, plannedSidebar, flatLabels } from '../src/config/sidebar.mjs';
|
||||
|
||||
const failures = [];
|
||||
const fail = (what, detail) => failures.push({ what, detail });
|
||||
|
||||
// A nested group (RunicNPC inside Rust, phase 16) flattens to `Group › Page` labels, so its
|
||||
// pages and their order are held to the plan exactly as a top-level group's are.
|
||||
const live = new Map(docsSidebar.map((g) => [g.label, flatLabels(g.items)]));
|
||||
const planned = new Map(Object.entries(plannedSidebar).map(([label, items]) => [label, flatLabels(items)]));
|
||||
|
||||
// ── Groups ──────────────────────────────────────────────────────────────────
|
||||
for (const label of live.keys()) {
|
||||
if (!planned.has(label)) fail(`group ${label}`, 'is in the live sidebar and not in plannedSidebar');
|
||||
}
|
||||
for (const label of planned.keys()) {
|
||||
if (!live.has(label)) fail(`group ${label}`, 'is in plannedSidebar and not in the live sidebar');
|
||||
}
|
||||
|
||||
// ── Pages, in order ─────────────────────────────────────────────────────────
|
||||
for (const [label, liveItems] of live) {
|
||||
const plannedItems = planned.get(label);
|
||||
if (!plannedItems) continue;
|
||||
|
||||
for (const page of liveItems) {
|
||||
if (!plannedItems.includes(page)) fail(`${label} → ${page}`, 'is live but not in plannedSidebar');
|
||||
}
|
||||
for (const page of plannedItems) {
|
||||
if (!liveItems.includes(page)) fail(`${label} → ${page}`, 'is planned but has no live sidebar entry');
|
||||
}
|
||||
|
||||
// Only meaningful once membership matches; otherwise it just repeats the above.
|
||||
if (liveItems.length === plannedItems.length && liveItems.every((p) => plannedItems.includes(p))) {
|
||||
if (liveItems.join(' | ') !== plannedItems.join(' | ')) {
|
||||
fail(
|
||||
`${label} order`,
|
||||
`live "${liveItems.join(' → ')}" vs planned "${plannedItems.join(' → ')}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Report ──────────────────────────────────────────────────────────────────
|
||||
if (failures.length === 0) {
|
||||
const pages = [...live.values()].reduce((n, items) => n + items.length, 0);
|
||||
console.log(`checkSidebar: ${live.size} groups and ${pages} pages agree with plannedSidebar.`);
|
||||
} else {
|
||||
console.error(`\ncheckSidebar: ${failures.length} disagreement(s) between the two trees:\n`);
|
||||
for (const f of failures) console.error(` ✗ ${f.what}\n ${f.detail}`);
|
||||
console.error(`
|
||||
Both trees are in src/config/sidebar.mjs. Decide which one is right — if a page was
|
||||
deliberately added, renamed or reordered, plannedSidebar records that decision and
|
||||
should move with it.
|
||||
`);
|
||||
process.exit(1);
|
||||
}
|
||||
449
scripts/checkTerms.mjs
Normal file
@@ -0,0 +1,449 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* checkTerms.mjs — PLAN.md §16, D63 and D64. The twelfth check, added in phase 14 and widened
|
||||
* to the whole site in phase 17 (D79).
|
||||
*
|
||||
* D63: outside a game's own section, the site may NAME the games it supports, but it may not
|
||||
* use their VOCABULARY. Core concepts keep core's names — a Team, a game server, the sidecar,
|
||||
* the bridge plugin, the game module — because that is what core itself calls them, and a
|
||||
* site that says "shard" on a core page is describing one game's deployment as though it were
|
||||
* the platform.
|
||||
*
|
||||
* D64: a convention that holds only while everyone remembers it does not hold. This is the
|
||||
* same argument `checkTokens.mjs` makes for colours, applied to words. The vocabulary problem
|
||||
* was measured before this check existed — "shard" 227 times in 45 files — and none of that
|
||||
* was anybody's decision; it was the residue of a site written while there was one game.
|
||||
*
|
||||
* It runs in two passes, one before the build and one after.
|
||||
*
|
||||
* node scripts/checkTerms.mjs # the SOURCE pass: docs pages and the sidebar
|
||||
* node scripts/checkTerms.mjs --built # the BUILT pass: every page in dist/, as rendered
|
||||
*
|
||||
* ── The source pass (phase 14) ──────────────────────────────────────────────────────────
|
||||
*
|
||||
* - every page under `src/content/docs`, frontmatter included (a title and a description
|
||||
* are what a reader sees first, in the sidebar and in search results);
|
||||
* - `src/config/sidebar.mjs`, as DATA rather than as text: every group label and every
|
||||
* page label, in both trees. Its comments are not scanned, because a comment is not
|
||||
* something a reader of the site ever sees.
|
||||
*
|
||||
* It needs no build, so it runs early and fails fast, and it names a file and a line.
|
||||
*
|
||||
* ── The built pass (phase 17, D79) ──────────────────────────────────────────────────────
|
||||
*
|
||||
* The marketing pages are `.astro` templates fed from `src/data/`, so most of the words a
|
||||
* reader sees on them are not in the template at all: a capability's label, a screenshot's
|
||||
* caption, a version read from `platform.json`. A source scan sees an expression and skips
|
||||
* it, or sees an id like `shard-status` and takes it for prose. So this pass reads what the
|
||||
* build actually produced — the same argument `checkLinks.mjs` makes for links. For every
|
||||
* page in `dist/client` it reads the visible text, plus `alt`, `title`, `aria-label`,
|
||||
* `placeholder`, the `<title>` and the meta description. It does not read link targets,
|
||||
* scripts, styles or comments. It names a route and a snippet rather than a line; the source
|
||||
* pass still gives lines for the docs, where most of the words are.
|
||||
*
|
||||
* Two parts of every docs page are skipped, because each is checked somewhere better:
|
||||
* Starlight's sidebar (the source pass reads both trees as data, group by group) and the
|
||||
* previous/next links (each is a neighbouring page's title, checked on that page).
|
||||
*
|
||||
* ── Where a game's words are allowed ────────────────────────────────────────────────────
|
||||
*
|
||||
* - in that game's own docs directory (`GAME_DIRS`) and its own sidebar group — a group
|
||||
* whose label is the game's `name` in `platform.json`;
|
||||
* - on that game's own routes (`GAME_ROUTES`): its docs group, its module page, and for
|
||||
* Rust the standalone RunicNPC page;
|
||||
* - inside an element marked `data-game="…"`: `<GameExample>` on a docs page (D68) and a
|
||||
* game-tagged `<Screenshot>` (D67). Both print the game's name beside the words, which is
|
||||
* the bargain: a game's words are honest on a core page when the game is named right
|
||||
* there. In the source pass, the `<GameExample>` tags themselves mark the region.
|
||||
*
|
||||
* What is NOT read, deliberately: link targets and bare URLs or paths (a repository is called
|
||||
* what it is called, and an address is not prose — but LINK TEXT is read, so `[Module-uo](…)`
|
||||
* fails and `[releases](…)` passes), MDX `import` lines, and, in the source pass only, `{…}`
|
||||
* expressions — the built pass reads what they rendered.
|
||||
*
|
||||
* No token and no network: everything it reads is in this repository or its build output.
|
||||
*/
|
||||
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { readdir } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
import { docsSidebar, plannedSidebar, flatLabels } from '../src/config/sidebar.mjs';
|
||||
|
||||
const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
||||
const platform = JSON.parse(readFileSync(path.join(ROOT, 'src/data/platform.json'), 'utf8'));
|
||||
const BUILT = process.argv.includes('--built');
|
||||
|
||||
/**
|
||||
* The words, each tagged with the game it belongs to. Matching is case-insensitive and on
|
||||
* word boundaries, so "shard" does not match `SHARD_VISIBILITY` and "wipe" does not match
|
||||
* "wiped" unless it is listed.
|
||||
*
|
||||
* A word is on this list because it is a game's name for something core has its own name for
|
||||
* (shard → game server, guild and clan → Team), or because it names one game's component
|
||||
* (uo-link → the sidecar, ServUO → the game server). Game NAMES — Ultima Online, UO, Rust —
|
||||
* are not on it: D63 allows them everywhere. Steam is not on it either: Steam sign-in is a
|
||||
* core identity provider, even though Rust was its first user.
|
||||
*/
|
||||
const TERMS = [
|
||||
{ game: 'uo', word: 'shard', re: /\bshards?\b/gi },
|
||||
{ game: 'uo', word: 'ServUO', re: /\bservuo\b/gi },
|
||||
{ game: 'uo', word: 'uo-link', re: /\buo-?link\b/gi },
|
||||
{ game: 'uo', word: 'Module-uo', re: /\bmodule-uo\b/gi },
|
||||
{ game: 'uo', word: 'Bridge.cfg', re: /\bbridge\.cfg\b/gi },
|
||||
{ game: 'uo', word: 'cliloc', re: /\bclilocs?\b/gi },
|
||||
{ game: 'uo', word: 'guild', re: /\bguilds?\b/gi },
|
||||
{ game: 'uo', word: 'IDOC', re: /\bidocs?\b/gi },
|
||||
{ game: 'rust', word: 'rust-link', re: /\brust-?link\b/gi },
|
||||
{ game: 'rust', word: 'Rust-Plugins', re: /\brust-plugins\b/gi },
|
||||
{ game: 'rust', word: 'Module-Rust', re: /\bmodule-rust\b/gi },
|
||||
{ game: 'rust', word: 'Oxide', re: /\boxide\b/gi },
|
||||
{ game: 'rust', word: 'Carbon', re: /\bcarbon\b/gi },
|
||||
{ game: 'rust', word: 'uMod', re: /\bumod\b/gi },
|
||||
{ game: 'rust', word: 'clan', re: /\bclans?\b/gi },
|
||||
{ game: 'rust', word: 'wipe', re: /\bwipes?\b/gi },
|
||||
];
|
||||
|
||||
/** Each game's own docs directory. A page under one may use that game's words. */
|
||||
const GAME_DIRS = {
|
||||
uo: 'src/content/docs/docs/ultima-online/',
|
||||
rust: 'src/content/docs/docs/rust/',
|
||||
};
|
||||
|
||||
/**
|
||||
* Each game's own routes, for the built pass: its docs group, the page about its module, and
|
||||
* for Rust the standalone RunicNPC page (D61), which a server owner reads without knowing what
|
||||
* the platform is. A route matches when the page's path starts with one of these.
|
||||
*/
|
||||
const GAME_ROUTES = {
|
||||
uo: ['/docs/ultima-online/', '/modules/uo/'],
|
||||
rust: ['/docs/rust/', '/modules/rust/', '/runicnpc/'],
|
||||
};
|
||||
|
||||
/**
|
||||
* The allowlist: an exact phrase on one page that may use a word the rule would otherwise
|
||||
* refuse. Short on purpose, and every entry says why. `file` is where the source pass meets
|
||||
* it and `route` is where the built pass does. An entry that no longer matches fails the
|
||||
* check too, so the list cannot rot into a set of permanent exemptions.
|
||||
*/
|
||||
const ALLOW = [
|
||||
{
|
||||
file: 'src/content/docs/docs/administration/notifications-and-email.mdx',
|
||||
route: '/docs/administration/notifications-and-email/',
|
||||
text: 'Guild (server) ID',
|
||||
why: "Discord's own word, not a game's: it is the label on core's Discord Bot screen.",
|
||||
},
|
||||
];
|
||||
|
||||
for (const game of new Set([...Object.keys(GAME_DIRS), ...Object.keys(GAME_ROUTES)])) {
|
||||
if (!platform.games[game]) throw new Error(`checkTerms: "${game}" is given a section here, but platform.json has no such game.`);
|
||||
}
|
||||
for (const t of TERMS) {
|
||||
if (!platform.games[t.game]) throw new Error(`checkTerms: "${t.word}" is tagged "${t.game}", which platform.json does not know.`);
|
||||
}
|
||||
|
||||
const failures = [];
|
||||
const fail = (where, detail) => failures.push({ where, detail });
|
||||
|
||||
/** Replace a span with spaces, keeping newlines, so later line numbers stay true. */
|
||||
const blank = (text) => text.replace(/[^\n]/g, ' ');
|
||||
|
||||
/** Hits for every term the given context may not use. `allowed` is a game key, or null. */
|
||||
function scan(text, allowed) {
|
||||
const hits = [];
|
||||
for (const t of TERMS) {
|
||||
if (t.game === allowed) continue;
|
||||
t.re.lastIndex = 0;
|
||||
for (const m of text.matchAll(t.re)) hits.push({ term: t, value: m[0], index: m.index });
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
const gameName = (game) => platform.games[game].name;
|
||||
|
||||
async function* walk(dir, pattern) {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) yield* walk(full, pattern);
|
||||
else if (pattern.test(entry.name)) yield full;
|
||||
}
|
||||
}
|
||||
|
||||
// ═══ The source pass ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
/** The game whose directory holds this file, or null for a core page. */
|
||||
function gameOfPath(relative) {
|
||||
const posix = relative.split(path.sep).join('/');
|
||||
for (const [game, dir] of Object.entries(GAME_DIRS)) if (posix.startsWith(dir)) return game;
|
||||
return null;
|
||||
}
|
||||
|
||||
function lineOf(source, index) {
|
||||
return source.slice(0, index).split('\n').length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn an MDX source into the text a reader would actually see, with everything that is not
|
||||
* prose blanked out. Returns that text plus the GameExample regions, as offsets into it.
|
||||
*/
|
||||
function proseOf(source, relative) {
|
||||
let text = source;
|
||||
|
||||
// GameExample regions first, while the tags are still intact. Recorded, then the tags
|
||||
// themselves are blanked so the attribute does not read as prose.
|
||||
const regions = [];
|
||||
for (const m of text.matchAll(/<GameExample\s+game="([^"]*)"\s*>([\s\S]*?)<\/GameExample>/g)) {
|
||||
const game = m[1];
|
||||
if (!platform.games[game]) {
|
||||
fail(`${relative}:${lineOf(source, m.index)}`, `<GameExample game="${game}"> names a game platform.json does not have`);
|
||||
}
|
||||
regions.push({ game, start: m.index, end: m.index + m[0].length });
|
||||
}
|
||||
text = text.replace(/<\/?GameExample\b[^>]*>/g, blank);
|
||||
|
||||
text = text.replace(/<!--[\s\S]*?-->/g, blank); // HTML comments
|
||||
text = text.replace(/^import\s.*$/gm, blank); // MDX imports
|
||||
text = text.replace(/\]\([^)\s]*\)/g, (m) => '] ' + blank(m.slice(2))); // link targets, not their text
|
||||
text = text.replace(/\b(?:href|src)=(?:"[^"]*"|\{`[^`]*`\})/g, blank); // JSX/HTML link targets
|
||||
text = text.replace(/\bhttps?:\/\/[^\s)>"'`]+/g, blank); // bare URLs
|
||||
|
||||
// `{…}` expressions, outside code fences only — a fenced JSON example's braces are prose.
|
||||
// Innermost first, repeatedly, so nested and multi-line expressions both go.
|
||||
const parts = text.split(/(^```[\s\S]*?^```)/m);
|
||||
for (let i = 0; i < parts.length; i += 2) {
|
||||
let prev;
|
||||
do {
|
||||
prev = parts[i];
|
||||
parts[i] = parts[i].replace(/\{[^{}]*\}/g, blank);
|
||||
} while (parts[i] !== prev);
|
||||
}
|
||||
text = parts.join('');
|
||||
|
||||
return { text, regions };
|
||||
}
|
||||
|
||||
async function sourcePass() {
|
||||
// ── 1. The documentation pages ──
|
||||
let files = 0;
|
||||
const allowUsed = new Set();
|
||||
|
||||
for await (const file of walk(path.join(ROOT, 'src', 'content', 'docs'), /\.mdx?$/)) {
|
||||
const relative = path.relative(ROOT, file).split(path.sep).join('/');
|
||||
const source = readFileSync(file, 'utf8').replace(/\r\n/g, '\n');
|
||||
files++;
|
||||
|
||||
let { text, regions } = proseOf(source, relative);
|
||||
|
||||
for (const [i, entry] of ALLOW.entries()) {
|
||||
if (entry.file !== relative) continue;
|
||||
if (text.includes(entry.text)) {
|
||||
allowUsed.add(i);
|
||||
text = text.split(entry.text).join(blank(entry.text));
|
||||
}
|
||||
}
|
||||
|
||||
const pageGame = gameOfPath(relative);
|
||||
for (const hit of scan(text, pageGame)) {
|
||||
const region = regions.find((r) => hit.index >= r.start && hit.index < r.end);
|
||||
if (region && region.game === hit.term.game) continue;
|
||||
const where = region ? `inside <GameExample game="${region.game}">` : pageGame ? `in the ${gameName(pageGame)} group` : 'on a core page';
|
||||
fail(`${relative}:${lineOf(source, hit.index)}`, `"${hit.value}" is ${gameName(hit.term.game)} vocabulary, ${where}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [i, entry] of ALLOW.entries()) {
|
||||
if (!allowUsed.has(i)) fail(`allowlist: ${entry.file}`, `"${entry.text}" no longer appears there — remove the entry`);
|
||||
}
|
||||
|
||||
// ── 2. The sidebar, as data ──
|
||||
const gameByName = new Map(Object.entries(platform.games).map(([key, g]) => [g.name, key]));
|
||||
const trees = [
|
||||
['docsSidebar', docsSidebar.map((g) => [g.label, flatLabels(g.items)])],
|
||||
['plannedSidebar', Object.entries(plannedSidebar).map(([label, items]) => [label, flatLabels(items)])],
|
||||
];
|
||||
let labels = 0;
|
||||
for (const [treeName, groups] of trees) {
|
||||
for (const [groupLabel, items] of groups) {
|
||||
const game = gameByName.get(groupLabel) ?? null;
|
||||
for (const label of [groupLabel, ...items]) {
|
||||
labels++;
|
||||
for (const hit of scan(label, game)) {
|
||||
fail(
|
||||
`src/config/sidebar.mjs ${treeName} → ${groupLabel}`,
|
||||
`"${label}" uses "${hit.value}", ${gameName(hit.term.game)} vocabulary, ${game ? `in the ${gameName(game)} group` : 'in a core group'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return `${files} docs pages and ${labels} sidebar labels use only their own game's words`;
|
||||
}
|
||||
|
||||
// ═══ The built pass ════════════════════════════════════════════════════════════════════
|
||||
|
||||
const DIST = path.join(ROOT, 'dist', 'client');
|
||||
|
||||
/** `dist/client/a/b/index.html` → `/a/b/`; `dist/client/404.html` → `/404`. */
|
||||
function routeOf(file) {
|
||||
const rel = path.relative(DIST, file).split(path.sep).join('/');
|
||||
if (rel === 'index.html') return '/';
|
||||
if (rel.endsWith('/index.html')) return `/${rel.slice(0, -'index.html'.length)}`;
|
||||
return `/${rel.replace(/\.html$/, '')}`;
|
||||
}
|
||||
|
||||
function gameOfRoute(route) {
|
||||
for (const [game, prefixes] of Object.entries(GAME_ROUTES)) {
|
||||
if (prefixes.some((p) => route.startsWith(p))) return game;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the element opening at `start` closes, counting nested elements of the same name.
|
||||
* Astro's output is well formed, which is what makes this enough without a parser.
|
||||
*/
|
||||
function elementEnd(html, start, tag) {
|
||||
const re = new RegExp(`<(/?)${tag}\\b[^>]*?(/?)>`, 'gi');
|
||||
re.lastIndex = start;
|
||||
let depth = 0;
|
||||
for (let m = re.exec(html); m; m = re.exec(html)) {
|
||||
if (m[2] === '/') continue; // self-closing
|
||||
depth += m[1] === '/' ? -1 : 1;
|
||||
if (depth === 0) return re.lastIndex;
|
||||
}
|
||||
return html.length;
|
||||
}
|
||||
|
||||
/** Every element whose opening tag matches `openRe`, as `{ start, end, match }`. */
|
||||
function elements(html, openRe) {
|
||||
const found = [];
|
||||
for (const m of html.matchAll(openRe)) {
|
||||
const tag = /^<([a-z0-9-]+)/i.exec(m[0])[1];
|
||||
found.push({ start: m.index, end: elementEnd(html, m.index, tag), match: m });
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', rsquo: '’', lsquo: '‘', ldquo: '“', rdquo: '”', mdash: '—', ndash: '–', hellip: '…', rarr: '→', middot: '·' };
|
||||
const decode = (s) =>
|
||||
s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (m, e) => {
|
||||
if (e[0] === '#') return String.fromCodePoint(e[1] === 'x' || e[1] === 'X' ? parseInt(e.slice(2), 16) : Number(e.slice(1)));
|
||||
return ENTITIES[e.toLowerCase()] ?? m;
|
||||
});
|
||||
|
||||
/**
|
||||
* Elements whose text runs on into the next word with no space between: a word split across
|
||||
* two `<span>`s by a syntax highlighter is still one word to a reader. Everything else is a
|
||||
* boundary, so `<h3>Guilds</h3><p>Guild…` does not read as "GuildsGuild".
|
||||
*/
|
||||
const INLINE = /^(?:a|abbr|b|bdi|bdo|cite|code|data|dfn|em|i|kbd|mark|q|s|samp|small|span|strong|sub|sup|time|u|var|wbr)$/i;
|
||||
|
||||
/** The words a reader sees in a piece of HTML: its text, and the attributes that are read aloud or shown. */
|
||||
function visibleText(html) {
|
||||
const attrs = [];
|
||||
for (const m of html.matchAll(/\s(?:alt|title|aria-label|placeholder)="([^"]*)"/gi)) attrs.push(m[1]);
|
||||
let text = html.replace(/<\/?([a-z][a-z0-9-]*)\b[^>]*>/gi, (_, tag) => (INLINE.test(tag) ? '' : ' '));
|
||||
text = decode([text, ...attrs.map(decode)].join(' \n '));
|
||||
text = text.replace(/\bhttps?:\/\/\S+/g, ' '); // bare URLs
|
||||
text = text.replace(/(^|[\s(“"'])\/[\w./#?=&%-]+/g, '$1 '); // bare paths — an address, not prose
|
||||
return text.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function snippet(text, index, length) {
|
||||
const from = Math.max(0, index - 50);
|
||||
const to = Math.min(text.length, index + length + 50);
|
||||
return `${from > 0 ? '…' : ''}${text.slice(from, to).trim()}${to < text.length ? '…' : ''}`;
|
||||
}
|
||||
|
||||
async function builtPass() {
|
||||
if (!existsSync(DIST)) {
|
||||
console.error('checkTerms --built: dist/client does not exist. Run `npm run build` first.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let pages = 0;
|
||||
let regionsSeen = 0;
|
||||
const allowUsed = new Set();
|
||||
|
||||
for await (const file of walk(DIST, /\.html$/)) {
|
||||
const route = routeOf(file);
|
||||
let html = readFileSync(file, 'utf8');
|
||||
pages++;
|
||||
|
||||
// The head: only the parts a reader sees, in a tab or a search result.
|
||||
const head = /<head\b[\s\S]*?<\/head>/i.exec(html)?.[0] ?? '';
|
||||
const headText = [/<title>([\s\S]*?)<\/title>/i.exec(head)?.[1] ?? '', /<meta\s+name="description"\s+content="([^"]*)"/i.exec(head)?.[1] ?? ''].join(' \n ');
|
||||
html = html.replace(/<head\b[\s\S]*?<\/head>/i, ' ');
|
||||
|
||||
html = html.replace(/<(script|style|template|noscript)\b[\s\S]*?<\/\1>/gi, ' ');
|
||||
html = html.replace(/<!--[\s\S]*?-->/g, ' ');
|
||||
|
||||
// Checked elsewhere (see the header): Starlight's sidebar and its previous/next links.
|
||||
for (const skip of [/<nav\b[^>]*\baria-label="Main"[^>]*>/gi, /<div\b[^>]*\bclass="pagination-links\b[^>]*>/gi]) {
|
||||
for (const el of elements(html, skip).reverse()) html = html.slice(0, el.start) + ' ' + html.slice(el.end);
|
||||
}
|
||||
|
||||
const pageGame = gameOfRoute(route);
|
||||
const pieces = [{ text: decode(headText), allowed: pageGame, where: 'in the page title or description' }];
|
||||
|
||||
// Regions marked with a game. Read with that game's words allowed, then cut out.
|
||||
for (const el of elements(html, /<[a-z][a-z0-9-]*\b[^>]*\bdata-game="([^"]*)"[^>]*>/gi).reverse()) {
|
||||
const game = el.match[1];
|
||||
regionsSeen++;
|
||||
if (!platform.games[game]) {
|
||||
fail(route, `an element is marked data-game="${game}", which platform.json does not have`);
|
||||
continue;
|
||||
}
|
||||
pieces.push({ text: visibleText(html.slice(el.start, el.end)), allowed: game, also: pageGame, where: `inside a block marked ${gameName(game)}` });
|
||||
html = html.slice(0, el.start) + ' ' + html.slice(el.end);
|
||||
}
|
||||
pieces.push({ text: visibleText(html), allowed: pageGame, where: pageGame ? `on a ${gameName(pageGame)} page` : 'on a core page' });
|
||||
|
||||
for (const piece of pieces) {
|
||||
let { text } = piece;
|
||||
for (const [i, entry] of ALLOW.entries()) {
|
||||
if (entry.route !== route || !text.includes(entry.text)) continue;
|
||||
allowUsed.add(i);
|
||||
text = text.split(entry.text).join(' '.repeat(entry.text.length));
|
||||
}
|
||||
for (const hit of scan(text, piece.allowed)) {
|
||||
if (piece.also && hit.term.game === piece.also) continue;
|
||||
fail(route, `"${hit.value}" is ${gameName(hit.term.game)} vocabulary, ${piece.where}\n ${snippet(text, hit.index, hit.value.length)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [i, entry] of ALLOW.entries()) {
|
||||
if (!allowUsed.has(i)) fail(`allowlist: ${entry.route}`, `"${entry.text}" no longer appears in the built page — remove the entry`);
|
||||
}
|
||||
|
||||
return `${pages} built pages use only their own game's words (${regionsSeen} game-marked blocks read with their game's words allowed)`;
|
||||
}
|
||||
|
||||
// ═══ Report ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const summary = BUILT ? await builtPass() : await sourcePass();
|
||||
|
||||
if (failures.length === 0) {
|
||||
console.log(`checkTerms${BUILT ? ' --built' : ''}: ${summary} (${TERMS.length} terms, ${ALLOW.length} allowlisted phrase${ALLOW.length === 1 ? '' : 's'}).`);
|
||||
} else {
|
||||
console.error(`\ncheckTerms${BUILT ? ' --built' : ''}: ${failures.length} game word(s) outside their game's section:\n`);
|
||||
for (const f of failures) console.error(` ✗ ${f.where}\n ${f.detail}`);
|
||||
console.error(`
|
||||
PLAN.md §16, D63: name the games anywhere, but keep their vocabulary in their own section.
|
||||
Use core's word instead — a Team, a game server, the sidecar, the bridge plugin, the game
|
||||
module. A marketing page describes what a game module adds in core's words and links to
|
||||
the game's own page (D75). If a docs page genuinely needs a game's real example, wrap it in
|
||||
<GameExample game="…"> (D68); a screenshot of a game screen carries its game tag (D67).
|
||||
Add to the allowlist in this script only for a word that is not a game's at all, and say why.
|
||||
`);
|
||||
process.exit(1);
|
||||
}
|
||||
205
scripts/mockRustDemo.mjs
Normal file
@@ -0,0 +1,205 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* mockRustDemo.mjs — the game data on the Rust demo's screenshots. PLAN.md §16 phase 18, D81.
|
||||
*
|
||||
* node scripts/mockRustDemo.mjs > rust-demo.sql
|
||||
* docker exec -i <db container> mariadb -u<user> -p<password> <rust demo db> < rust-demo.sql
|
||||
*
|
||||
* Prints SQL; it connects to nothing. Run it after `seedDemo.mjs --game rust`, with the demo
|
||||
* site connected to its server at least once, so the server row and its current wipe exist.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY THIS IS MOCK DATA, WHEN THE UO CAPTURES ARE NOT
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* D4 asks for real captures, and phase 9 got them from a real shard with a person signed in.
|
||||
* The Rust rig has no players. Phase 18 first staged stand-in players on it with a rig-only
|
||||
* plugin; that cost a morning of a server the update had left rebuilding its navmesh, and the
|
||||
* org lead chose this instead (D81): the feed, the leaderboard and the linked Steam account
|
||||
* are written into the demo's database here, in the module's own row shapes, and everything
|
||||
* else on the screens is the real server's (its status, its world map, its sync state).
|
||||
*
|
||||
* Every name is a role (D80): player, player2 … player7, and moderator. The Steam ids sit in a
|
||||
* range no real account uses.
|
||||
*
|
||||
* Re-running replaces what it wrote: it deletes the server's events, stats, weapon kills and
|
||||
* these players first. Real events that arrived from the server are deleted with them.
|
||||
*/
|
||||
|
||||
const SERVER = 'rust-oxide';
|
||||
const ID_BASE = 76561199920000000n;
|
||||
|
||||
/** The cast. `n` makes the Steam id; `user` links one to a site account by username. */
|
||||
const CAST = [
|
||||
{ n: 1, name: 'player', user: 'player' },
|
||||
{ n: 2, name: 'player2' },
|
||||
{ n: 3, name: 'player3' },
|
||||
{ n: 4, name: 'player4' },
|
||||
{ n: 5, name: 'player5' },
|
||||
{ n: 6, name: 'player6' },
|
||||
{ n: 7, name: 'player7' },
|
||||
{ n: 9, name: 'moderator' },
|
||||
];
|
||||
|
||||
const steam = (n) => String(ID_BASE + BigInt(n));
|
||||
const who = (n) => CAST.find((c) => c.n === n).name;
|
||||
const now = Date.now();
|
||||
const ago = (minutes) => now - Math.round(minutes * 60_000);
|
||||
|
||||
// ── The feed, oldest first ─────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Each frame is the shape the bridge plugin emits (copied from frames the walk database
|
||||
// holds), less `serverId`/`wipeId`/`t`/`type`, which `frame()` adds.
|
||||
|
||||
const death = (killer, victim, weapon, distance, grid, extra = {}) => ({
|
||||
kind: 'player.death',
|
||||
steamId: steam(victim),
|
||||
name: who(victim),
|
||||
attackerId: steam(killer),
|
||||
attackerName: who(killer),
|
||||
attackerType: 'player',
|
||||
weapon,
|
||||
distance,
|
||||
grid,
|
||||
sleeping: false,
|
||||
...extra,
|
||||
});
|
||||
|
||||
const world = (event, fields = {}) => ({ kind: 'world.event', event, ...fields });
|
||||
|
||||
const FEED = [
|
||||
[150, { kind: 'player.connected', steamId: steam(1), name: who(1) }],
|
||||
[148, { kind: 'player.connected', steamId: steam(2), name: who(2) }],
|
||||
[146, { kind: 'player.connected', steamId: steam(3), name: who(3) }],
|
||||
[141, world('cargo.arrived', { x: 2900, z: -2100 })],
|
||||
[137, { kind: 'player.connected', steamId: steam(9), name: who(9) }],
|
||||
[131, { kind: 'player.chat', steamId: steam(2), name: who(2), channel: 'Global', message: 'anyone selling scrap at outpost?' }],
|
||||
[126, death(3, 4, 'bolt_rifle.entity', 187.4, 'AF10')],
|
||||
[119, { kind: 'player.connected', steamId: steam(5), name: who(5) }],
|
||||
[112, world('crate.hack.started', { steamId: steam(2), name: who(2), where: 'oilrig_small', x: 3358, z: 1721 })],
|
||||
[104, death(1, 2, 'ak47u.entity', 22.4, 'R18')],
|
||||
[98, world('heli.arrived', { x: 400, z: -900 })],
|
||||
[96, world('crate.unlocked', { steamId: steam(5), name: who(5), where: 'oilrig_small', x: 3358, z: 1721 })],
|
||||
[91, { kind: 'player.connected', steamId: steam(6), name: who(6) }],
|
||||
[86, death(5, 3, 'lr300.entity', 64.9, 'H7')],
|
||||
[80, world('heli.downed', {
|
||||
x: 2453, z: -943,
|
||||
credit: {
|
||||
blow: steam(3), last: steam(1), most: steam(1),
|
||||
damagers: [{ steamId: steam(1), damage: 5200 }, { steamId: steam(3), damage: 3900 }, { steamId: steam(6), damage: 900 }],
|
||||
},
|
||||
names: { [steam(1)]: who(1), [steam(3)]: who(3), [steam(6)]: who(6) },
|
||||
creditRule: 'most',
|
||||
})],
|
||||
[74, { kind: 'player.chat', steamId: steam(9), name: who(9), channel: 'Global', message: 'wipe is Thursday 19:00, bases stay up till then' }],
|
||||
[69, { kind: 'npc.boss.died', name: 'Gate Warden', killerName: who(6), killerId: steam(6), damagers: 3 }],
|
||||
[63, death(6, 1, 'semi_auto_rifle.entity', 41.2, 'K12')],
|
||||
[58, world('airdrop.looted', { steamId: steam(4), name: who(4), x: 164, z: -1507 })],
|
||||
[52, { kind: 'player.connected', steamId: steam(7), name: who(7) }],
|
||||
[47, death(2, 6, 'mp5.entity', 12.6, 'R17', { }) ],
|
||||
[41, { kind: 'dt.won', steamId: steam(4), name: who(4), invaderCount: 2, invaders: [steam(4), steam(7)], x: 150, z: -800 }],
|
||||
[36, world('bradley.destroyed', {
|
||||
where: 'launch_site', x: -1200, z: 300,
|
||||
credit: {
|
||||
blow: steam(1), last: steam(1), most: steam(5),
|
||||
damagers: [{ steamId: steam(5), damage: 630 }, { steamId: steam(1), damage: 385 }],
|
||||
},
|
||||
names: { [steam(1)]: who(1), [steam(5)]: who(5) },
|
||||
creditRule: 'most',
|
||||
})],
|
||||
[31, death(1, 7, 'ak47u.entity', 96.3, 'J9')],
|
||||
[25, { kind: 'gc.ended', steamId: steam(7), name: who(7), x: 200, z: -800 }],
|
||||
[21, {
|
||||
kind: 'raid.completed', base: 'raideasy7', difficulty: 'Easy', level: 0, pvp: false,
|
||||
id: 'raid-demo-1', key: 'demo', ownerId: steam(6), ownerName: who(6), raiderCount: 1,
|
||||
raiders: [{ steamId: steam(6), name: who(6) }], x: 280, z: -800,
|
||||
}],
|
||||
[16, death(3, 5, 'bolt_rifle.entity', 241.7, 'AB4', { })],
|
||||
[12, { kind: 'player.chat', steamId: steam(1), name: who(1), channel: 'Global', message: 'gg on the heli' }],
|
||||
[8, world('cargo.docking', { harbour: 'harbor_2', harbourName: 'Harbor', x: 2595, z: -2215 })],
|
||||
[5, death(4, 2, 'thompson.entity', 18.9, 'Q14')],
|
||||
[2, { kind: 'player.disconnected', steamId: steam(2), name: who(2), reason: 'Disconnected', sessionSec: 8760 }],
|
||||
];
|
||||
|
||||
// ── The leaderboard ────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Consistent with the feed in spirit, not to the kill: a wipe is days long and the feed above
|
||||
// is its last two hours.
|
||||
const STATS = {
|
||||
1: { kills: 31, deaths: 12, npc_kills: 58, headshots: 14, best_pvp_m: 212.4, structures: 340, playtime_sec: 61200, heli_kills: 2, apc_kills: 1, crates_hacked: 3, drops_looted: 5 },
|
||||
3: { kills: 27, deaths: 9, npc_kills: 22, headshots: 19, best_pvp_m: 288.1, structures: 120, playtime_sec: 48900, heli_kills: 1, crates_hacked: 1, drops_looted: 2 },
|
||||
5: { kills: 19, deaths: 15, npc_kills: 41, headshots: 6, best_pvp_m: 133.0, structures: 510, playtime_sec: 55800, apc_kills: 1, crates_hacked: 2, drops_looted: 7 },
|
||||
6: { kills: 16, deaths: 11, npc_kills: 30, headshots: 5, best_pvp_m: 97.6, structures: 260, playtime_sec: 40100, raids_completed: 2, drops_looted: 3 },
|
||||
4: { kills: 12, deaths: 18, npc_kills: 25, headshots: 3, best_pvp_m: 74.2, structures: 180, playtime_sec: 37400, treasures_won: 1, drops_looted: 4 },
|
||||
2: { kills: 9, deaths: 21, npc_kills: 47, headshots: 2, best_pvp_m: 58.8, structures: 610, playtime_sec: 52300, crates_hacked: 2, drops_looted: 6 },
|
||||
7: { kills: 6, deaths: 10, npc_kills: 13, headshots: 1, best_pvp_m: 96.3, structures: 90, playtime_sec: 21000, guarded_won: 1, drops_looted: 1 },
|
||||
9: { kills: 2, deaths: 1, npc_kills: 4, headshots: 0, best_pvp_m: 35.0, structures: 40, playtime_sec: 9800 },
|
||||
};
|
||||
|
||||
const WEAPON_KILLS = {
|
||||
1: { 'ak47u.entity': 21, 'semi_auto_rifle.entity': 6, 'python.entity': 4 },
|
||||
3: { 'bolt_rifle.entity': 22, 'lr300.entity': 5 },
|
||||
5: { 'lr300.entity': 13, 'mp5.entity': 6 },
|
||||
6: { 'semi_auto_rifle.entity': 10, 'ak47u.entity': 6 },
|
||||
4: { 'thompson.entity': 8, 'mp5.entity': 4 },
|
||||
2: { 'mp5.entity': 7, 'thompson.entity': 2 },
|
||||
7: { 'ak47u.entity': 4, 'python.entity': 2 },
|
||||
9: { 'lr300.entity': 2 },
|
||||
};
|
||||
|
||||
// ── SQL ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const q = (value) =>
|
||||
value === null || value === undefined ? 'NULL' : `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "''")}'`;
|
||||
|
||||
const ids = CAST.map((c) => q(steam(c.n))).join(', ');
|
||||
const out = [];
|
||||
|
||||
out.push('-- mockRustDemo.mjs — PLAN.md §16 phase 18 (D81). Generated; do not edit by hand.');
|
||||
out.push(`SET @server = ${q(SERVER)};`);
|
||||
out.push(
|
||||
"SET @wipe = (SELECT wipe_id FROM rust_wipes WHERE server_id = @server ORDER BY last_seen DESC LIMIT 1);",
|
||||
);
|
||||
out.push("SELECT IF(@wipe IS NULL, 'NO WIPE YET: connect the site to the server first', CONCAT('wipe ', @wipe)) AS mock;");
|
||||
out.push('DELETE FROM rust_events WHERE server_id = @server;');
|
||||
out.push('DELETE FROM rust_player_wipe_stats WHERE server_id = @server;');
|
||||
out.push('DELETE FROM rust_weapon_kills WHERE server_id = @server;');
|
||||
out.push(`DELETE FROM rust_account_links WHERE steam_id IN (${ids});`);
|
||||
out.push(`DELETE FROM rust_players WHERE steam_id IN (${ids});`);
|
||||
|
||||
for (const c of CAST) {
|
||||
out.push(
|
||||
`INSERT INTO rust_players (steam_id, name, first_seen, last_seen) VALUES (${q(steam(c.n))}, ${q(c.name)}, NOW() - INTERVAL 6 DAY, NOW() - INTERVAL 2 MINUTE);`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const [minutes, body] of FEED) {
|
||||
const t = ago(minutes);
|
||||
const raw = JSON.stringify({ ...body, serverId: SERVER, t, type: 'event', wipeId: '@@WIPE@@' });
|
||||
const [before, after] = raw.split('"@@WIPE@@"');
|
||||
out.push(
|
||||
`INSERT INTO rust_events (server_id, wipe_id, kind, t, steam_id, raw, created_at) VALUES (@server, @wipe, ${q(body.kind)}, ${t}, ${q(body.steamId ?? null)}, CONCAT(${q(before)}, JSON_QUOTE(@wipe), ${q(after)}), FROM_UNIXTIME(${Math.floor(t / 1000)}));`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const [n, row] of Object.entries(STATS)) {
|
||||
const cols = Object.keys(row);
|
||||
out.push(
|
||||
`INSERT INTO rust_player_wipe_stats (server_id, wipe_id, steam_id, sessions, last_seen, ${cols.join(', ')}) VALUES (@server, @wipe, ${q(steam(Number(n)))}, 6, NOW() - INTERVAL 2 MINUTE, ${cols.map((k) => row[k]).join(', ')});`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const [n, weapons] of Object.entries(WEAPON_KILLS)) {
|
||||
for (const [weapon, kills] of Object.entries(weapons)) {
|
||||
out.push(
|
||||
`INSERT INTO rust_weapon_kills (server_id, wipe_id, steam_id, weapon, kills) VALUES (@server, @wipe, ${q(steam(Number(n)))}, ${q(weapon)}, ${kills});`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const c of CAST.filter((member) => member.user)) {
|
||||
out.push(
|
||||
`INSERT INTO rust_account_links (steam_id, user_id, name, server_id, linked_at) SELECT ${q(steam(c.n))}, id, ${q(c.name)}, @server, NOW() - INTERVAL 5 DAY FROM users WHERE username = ${q(c.user)};`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(out.join('\n'));
|
||||
717
scripts/seedDemo.mjs
Normal file
@@ -0,0 +1,717 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* seedDemo.mjs — the deployment the screenshots are taken of. PLAN.md §13 phase 9, D45.
|
||||
*
|
||||
* node scripts/seedDemo.mjs → seed (idempotent; safe to re-run)
|
||||
* node scripts/seedDemo.mjs --dry-run → say what it would do, write nothing
|
||||
* node scripts/seedDemo.mjs --game rust → the Rust demo instead (PLAN.md §16 phase 18)
|
||||
*
|
||||
* Environment (all optional; the defaults are this machine's review stack):
|
||||
*
|
||||
* RG_BASE http://localhost:3000 the website the seed drives
|
||||
* RG_ADMIN_USER demoadmin (rust: admin) an existing admin, created by website's own
|
||||
* RG_ADMIN_PASS DemoReview!2026 `npm run seed` — see PLAN.md §13 phase 9
|
||||
* RG_DEMO_PASS DemoReview!2026 the password every seeded cast member gets
|
||||
* UOLINK_BASE http://127.0.0.1:8080 sidecar REST, written to Admin → Shard
|
||||
* UOLINK_WS ws://127.0.0.1:8080/ws sidecar WebSocket
|
||||
* UOLINK_TOKEN (unset) sidecar auth token; skipped when absent
|
||||
* UOLINK_PROTOCOL (platform.json) wire protocol to pin — see the note below
|
||||
* RG_RUST_SERVERS (unset) rust only: a JSON file outside the repository,
|
||||
* [{ id, name, sidecarBaseUrl, sidecarToken }]
|
||||
*
|
||||
* One deployment runs one game module (the core refuses a second at boot), so the Rust demo
|
||||
* is a second deployment with its own database, and `--game rust` seeds that one. Its cast
|
||||
* is named for its roles — admin, moderator, player — and so is every stand-in on the game
|
||||
* server behind it (D80): a name in a screenshot should say what the account is, not
|
||||
* invent a person.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY THE SEED DRIVES THE API AND NEVER THE DATABASE
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* Every row this creates could have been an INSERT, and every INSERT would have been a
|
||||
* second implementation of a rule the website already owns: how a body is sanitized, what
|
||||
* a slug may contain, which excerpt is derived when none is given, how a password is
|
||||
* hashed. A seed that writes SQL directly produces a database the product could not have
|
||||
* produced, and screenshots of that database show a product that does not exist.
|
||||
*
|
||||
* So this speaks HTTP to a running site, as an admin, through the same endpoints the admin
|
||||
* panel calls. The cost is that the site has to be up; the benefit is that the content is
|
||||
* real, and that this script keeps working when a column moves.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY IT IS IDEMPOTENT RATHER THAN DESTRUCTIVE
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* Re-running must not double the news list, and must not erase a screenshot rig somebody
|
||||
* has been adjusting by hand. Every step therefore looks before it writes and reports
|
||||
* `= exists` rather than failing. That also makes the script usable as a repair: point it
|
||||
* at a stack that has drifted and it puts back only what is missing.
|
||||
*
|
||||
* What it deliberately does NOT create: anything the shard owns. Teams arrive from the
|
||||
* guild board over the bridge, the marketplace from player vendors, the atlas from real
|
||||
* spawners (PLAN.md §13 phase 9, D42). Seeding those would be inventing game state that
|
||||
* the product is supposed to be showing, which is exactly what D4 forbids.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import platform from '../src/data/platform.json' with { type: 'json' };
|
||||
|
||||
const gameAt = process.argv.indexOf('--game');
|
||||
const GAME = gameAt === -1 ? 'uo' : process.argv[gameAt + 1];
|
||||
|
||||
if (!['uo', 'rust'].includes(GAME)) {
|
||||
console.error(`--game is uo or rust, not "${GAME}"`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const BASE = (process.env.RG_BASE || 'http://localhost:3000').replace(/\/+$/, '');
|
||||
const API = `${BASE}/api/v1`;
|
||||
const ADMIN_USER = process.env.RG_ADMIN_USER || (GAME === 'rust' ? 'admin' : 'demoadmin');
|
||||
const ADMIN_PASS = process.env.RG_ADMIN_PASS || 'DemoReview!2026';
|
||||
const DEMO_PASS = process.env.RG_DEMO_PASS || 'DemoReview!2026';
|
||||
const UOLINK_BASE = process.env.UOLINK_BASE || 'http://127.0.0.1:8080';
|
||||
const UOLINK_WS = process.env.UOLINK_WS || 'ws://127.0.0.1:8080/ws';
|
||||
const UOLINK_TOKEN = process.env.UOLINK_TOKEN || '';
|
||||
// The pinned wire protocol, read from `platform.json` rather than written down here.
|
||||
//
|
||||
// It was a literal `4` until the Asset Bridge cutover, with a note explaining that
|
||||
// `module-uo` pinned 3 on a fresh install while the sidecar spoke 4, so a new deployment
|
||||
// read nothing from its shard until somebody edited the number in Admin → Shard. That debt
|
||||
// has since been paid: the module's schema fragment defaults the column to the protocol its
|
||||
// build speaks and carries a one-shot migration per bump, so both a fresh install and an
|
||||
// upgraded one land on the right number by themselves.
|
||||
//
|
||||
// What remains is the rig's own reason to state it: this seed points a demo deployment at a
|
||||
// sidecar, and if it pins the wrong number every REST call comes back `409`. Reading it from
|
||||
// `platform.json` means the number is the one `checkFacts.mjs` verified against `link`'s
|
||||
// `main` — so the rig cannot quietly drift two protocols behind the platform again, which is
|
||||
// exactly what the literal did.
|
||||
const UOLINK_PROTOCOL = Number(process.env.UOLINK_PROTOCOL || platform.games.uo.protocol);
|
||||
|
||||
const DRY = process.argv.includes('--dry-run');
|
||||
|
||||
// ── The demo deployment's identity (D43) ───────────────────────────────────────────────
|
||||
//
|
||||
// A neutral demo brand rather than UOMysticmoon: the screenshots show the platform, not a
|
||||
// private shard, and §15's demo VM can wear the same identity so the imagery stays true the
|
||||
// day it exists. The name is deliberately "… Demo" rather than an invented community —
|
||||
// nobody should have to wonder whether they are looking at a real server they could join.
|
||||
// The published contact address lives in exactly one file in this repository (D13), and
|
||||
// `checkFacts.mjs` fails the build if a literal address appears anywhere else — including
|
||||
// here. So the demo wears the same address the site publishes, read from the same place.
|
||||
const brandDefault = JSON.parse(
|
||||
readFileSync(new URL('../brand-default/brand.json', import.meta.url), 'utf8'),
|
||||
);
|
||||
|
||||
const SETTINGS = {
|
||||
site_title: 'Runic Gateway Demo',
|
||||
site_mode: 'live',
|
||||
status_message: 'Live — the demo shard is up.',
|
||||
homepage_teaser:
|
||||
'A public demonstration of Runic Gateway: a self-hosted community site wired to a ' +
|
||||
'live game server. Everything on this site is real data from the shard behind it.',
|
||||
contact_email: brandDefault.contactEmail,
|
||||
};
|
||||
|
||||
// ── The cast ───────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Five accounts, one per role the admin screens distinguish, so a screenshot of the users
|
||||
// table shows the role column doing something. Names are ordinary fantasy given names and
|
||||
// belong to nobody.
|
||||
const USERS = [
|
||||
{ username: 'aldricmoss', role: 'moderator' },
|
||||
{ username: 'brannwen', role: 'editor' },
|
||||
{ username: 'sablequill', role: 'player' },
|
||||
{ username: 'tobinreed', role: 'player' },
|
||||
{ username: 'mirenavox', role: 'player' },
|
||||
];
|
||||
|
||||
// ── The Rust demo (PLAN.md §16 phase 18) ───────────────────────────────────────────────
|
||||
//
|
||||
// The same neutral identity (D43) over a Rust server. The cast is two accounts besides the
|
||||
// admin, each named for its role (D80). Teams, the feed, the leaderboard and the map are the
|
||||
// game's, as they are for UO; what the site owns here is the server row, the NPC profiles
|
||||
// and a permission group, so those are seeded.
|
||||
const RUST_SETTINGS = {
|
||||
...SETTINGS,
|
||||
status_message: 'Live — the demo server is up.',
|
||||
homepage_teaser:
|
||||
'A public demonstration of Runic Gateway: a self-hosted community site wired to a ' +
|
||||
'live Rust server. Everything on this site is real data from the server behind it.',
|
||||
};
|
||||
|
||||
const RUST_USERS = [
|
||||
{ username: 'moderator', role: 'moderator' },
|
||||
{ username: 'player', role: 'player' },
|
||||
];
|
||||
|
||||
// Two RunicNPC profiles: a guard who fights, and a passive trader who talks. Their kit is
|
||||
// one the rig's Kits holds; a profile naming a kit Kits does not have is refused by RunicNPC
|
||||
// and shows as refused on the screen, which is not the screen being shown. The guard's name
|
||||
// sorts first, so the editor the NPC screenshot opens (its first Edit) is the fuller one.
|
||||
const NPC_KIT = process.env.RG_NPC_KIT || 'rnpc_campguard';
|
||||
const NPC_PROFILES = [
|
||||
{
|
||||
name: 'guard',
|
||||
allServers: true,
|
||||
body: {
|
||||
names: ['Camp Guard', 'Gate Guard'],
|
||||
kits: [NPC_KIT],
|
||||
prefab: 'scientistnpc_roam',
|
||||
role: 'guard',
|
||||
movement: { mode: 'wander', radius: 12 },
|
||||
health: 300,
|
||||
faction: 'wardens',
|
||||
alertRadius: 30,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'trader',
|
||||
allServers: true,
|
||||
body: {
|
||||
names: ['Quartermaster'],
|
||||
kits: [NPC_KIT],
|
||||
prefab: 'scientistnpc_roam',
|
||||
role: 'passive',
|
||||
movement: { mode: 'stand', radius: 0 },
|
||||
hurtByPlayers: false,
|
||||
use: { mode: 'chat', lines: ['Back again, {player}?', 'Supplies are short this wipe.'], title: '', text: '' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// One group the site authored, so the permissions screen shows the site doing something
|
||||
// beside what the server already had. Its permissions are ones the rig's plugins register,
|
||||
// and its member is the `player` account, through its linked Steam id.
|
||||
const PERM_GROUP = {
|
||||
name: 'vip',
|
||||
title: 'VIP',
|
||||
rank: 10,
|
||||
permissions: ['backpacks.use', 'autodoors.use'],
|
||||
members: ['player'],
|
||||
};
|
||||
|
||||
// Who sees who is online, and the map's player layer. Both default to staff, which is right
|
||||
// for a real community and wrong for a demo whose public pages exist to be looked at: with
|
||||
// them at staff, the public feed leaves out every death and the map shows no one. The only
|
||||
// players on the demo server are the stand-ins phase 18 staged, named by role (D80), so
|
||||
// opening them to the public shows nobody's real name.
|
||||
const RUST_VISIBILITY = { fleet: 'public', map: { fleet: { players: 'public' } } };
|
||||
|
||||
// A weekly wipe, so the server list has a next wipe to show: Thursday evening, the usual
|
||||
// Rust community habit.
|
||||
const RUST_WIPE = { wipeRule: 'weekly', wipeDay: 4, wipeTime: '19:00', wipeTz: 'America/Chicago' };
|
||||
|
||||
const siteSettings = GAME === 'rust' ? RUST_SETTINGS : SETTINGS;
|
||||
const cast = GAME === 'rust' ? RUST_USERS : USERS;
|
||||
|
||||
// ── News, five-on-friday, the newsletter ───────────────────────────────────────────────
|
||||
//
|
||||
// Written as a small community's real output rather than lorem: a patch note, an event, a
|
||||
// maintenance notice and a Friday post. Bodies are short HTML because that is what the
|
||||
// editor stores, and the list screens show the excerpt anyway.
|
||||
const POSTS = [
|
||||
{
|
||||
category: 'news',
|
||||
title: 'Autumn patch: vendor search, and a fix for house decay',
|
||||
excerpt:
|
||||
'Player-vendor listings are now searchable from the site, and the decay timer no ' +
|
||||
'longer resets when a co-owner logs in.',
|
||||
body:
|
||||
'<p>The autumn patch is live. The headline change is that <strong>every player ' +
|
||||
'vendor on the shard is now searchable from this site</strong> — the marketplace ' +
|
||||
'page reads the same live feed the game does, so a listing appears within a minute ' +
|
||||
'of being priced.</p><p>We also fixed the house decay timer resetting when a ' +
|
||||
'co-owner logged in. That bug had been quietly keeping condemned houses alive since ' +
|
||||
'spring.</p><p>Full notes are on the wiki.</p>',
|
||||
published: true,
|
||||
},
|
||||
{
|
||||
category: 'news',
|
||||
title: 'The Harvest Moon festival opens this weekend',
|
||||
excerpt:
|
||||
'Three days of gatherings at the crossroads, with a champion spawn on the last ' +
|
||||
'night. Everyone is welcome, no signup needed.',
|
||||
body:
|
||||
'<p>The Harvest Moon festival runs from Friday evening to Sunday night at the ' +
|
||||
'crossroads north of town. There is no signup and no entry fee — turn up.</p>' +
|
||||
'<p>Saturday is the market day; bring anything you want to sell and we will set out ' +
|
||||
'extra vendor stalls. Sunday night closes with a champion spawn, which will be ' +
|
||||
'announced in game and on the shard status page here.</p>',
|
||||
published: true,
|
||||
},
|
||||
{
|
||||
category: 'news',
|
||||
title: 'Scheduled maintenance, Tuesday 03:00 UTC',
|
||||
excerpt:
|
||||
'About twenty minutes of downtime for a server restart and a world save. The site ' +
|
||||
'stays up throughout.',
|
||||
body:
|
||||
'<p>We are restarting the shard on Tuesday at 03:00 UTC for a world save and a ' +
|
||||
'server update. Expect about twenty minutes of downtime.</p><p>This site stays up ' +
|
||||
'while the shard is down — the status panel will simply show the shard as offline, ' +
|
||||
'and the marketplace and atlas will show their last known state.</p>',
|
||||
published: true,
|
||||
},
|
||||
{
|
||||
category: 'five-on-friday',
|
||||
title: 'Five on Friday: the ones who keep the roads clear',
|
||||
excerpt:
|
||||
'Five players who spent the week doing unglamorous work, and what they were up to.',
|
||||
body:
|
||||
'<p>Five people who made the week better for everybody else:</p><ol><li>Sable, for ' +
|
||||
'restocking the free reagent stall three times without being asked.</li><li>Tobin, ' +
|
||||
'for guiding two new players through their first dungeon.</li><li>Mirena, for the ' +
|
||||
'map corrections on the wiki.</li><li>Brannwen, for writing up the champion ' +
|
||||
'rotation.</li><li>Aldric, for handling a difficult report quietly and well.</li>' +
|
||||
'</ol>',
|
||||
published: true,
|
||||
},
|
||||
{
|
||||
category: 'newsletter',
|
||||
title: 'Monthly notes — what changed, and what is next',
|
||||
excerpt:
|
||||
'A month of changes in one place: the vendor search, the new guides, and what we ' +
|
||||
'are working on next.',
|
||||
body:
|
||||
'<p>A quiet, productive month. The vendor search shipped, the wiki gained four ' +
|
||||
'guides, and the guild boards now update on the site within a minute of a change in ' +
|
||||
'game.</p><p>Next month we are looking at the champion boards and at making the ' +
|
||||
'atlas easier to read on a phone.</p>',
|
||||
published: true,
|
||||
},
|
||||
];
|
||||
|
||||
// ── The wiki ───────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// One category and four pages, because the wiki index screenshot needs a category with
|
||||
// enough in it to look like a wiki rather than a placeholder.
|
||||
const WIKI_CATEGORY = {
|
||||
slug: 'guides',
|
||||
title: 'Guides',
|
||||
description: 'How things work here, written by the people who play here.',
|
||||
};
|
||||
|
||||
const WIKI_PAGES = [
|
||||
{
|
||||
slug: 'getting-started',
|
||||
title: 'Getting started',
|
||||
excerpt: 'What to install, how to connect, and the first hour.',
|
||||
body:
|
||||
'<h2>Before you connect</h2><p>You need a game client and an account. Make the ' +
|
||||
'account on this site — the shard accepts accounts created here, and it saves you ' +
|
||||
'typing your password into a chat window.</p><h2>The first hour</h2><p>Start in ' +
|
||||
'town, take the newcomer quest, and do not sell your starting tools. If you get ' +
|
||||
'stuck, ask in Discord: somebody is usually around.</p>',
|
||||
},
|
||||
{
|
||||
slug: 'player-vendors',
|
||||
title: 'Player vendors',
|
||||
excerpt: 'How to hire one, how to price, and how the site search finds you.',
|
||||
body:
|
||||
'<h2>Hiring a vendor</h2><p>Any house you own or co-own can hold vendors. Hire one ' +
|
||||
'from an innkeeper and place it inside.</p><h2>Being findable</h2><p>Everything a ' +
|
||||
'vendor holds is published to the marketplace on this site within about a minute, ' +
|
||||
'including the price and the house it stands in. If a listing looks stale, the ' +
|
||||
'shard was probably down when you priced it — it will correct itself on the next ' +
|
||||
'sweep.</p>',
|
||||
},
|
||||
{
|
||||
slug: 'housing-and-decay',
|
||||
title: 'Housing and decay',
|
||||
excerpt: 'Placement rules, the decay timer, and what IDOC actually means here.',
|
||||
body:
|
||||
'<h2>Placement</h2><p>Houses can be placed anywhere the client allows, with the ' +
|
||||
'usual clearance rules. There is no lottery.</p><h2>Decay</h2><p>A house decays if ' +
|
||||
'nobody with access logs in for long enough. The site lists houses approaching ' +
|
||||
'collapse on the housing page, which is the same data the game uses — not a ' +
|
||||
'prediction.</p>',
|
||||
},
|
||||
{
|
||||
slug: 'community-rules',
|
||||
title: 'Community rules',
|
||||
excerpt: 'The short version: do not be the reason somebody stops playing.',
|
||||
body:
|
||||
'<h2>The rules</h2><ol><li>No harassment, in game or on the site.</li><li>No ' +
|
||||
'exploiting bugs — report them instead, and you will usually be thanked in ' +
|
||||
'public.</li><li>One account per person for events with prizes.</li></ol>' +
|
||||
'<h2>Appeals</h2><p>Every moderation action can be appealed from your account page. ' +
|
||||
'An appeal is read by somebody who was not involved in the original action.</p>',
|
||||
},
|
||||
];
|
||||
|
||||
// ── HTTP plumbing ──────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// One cookie jar, because the session is a cookie and `fetch` has no jar of its own. Only
|
||||
// the value of the auth cookie matters, so this keeps exactly that.
|
||||
|
||||
let cookie = '';
|
||||
let created = 0;
|
||||
let existed = 0;
|
||||
|
||||
function keepCookies(res) {
|
||||
const raw = res.headers.getSetCookie?.() ?? [];
|
||||
for (const line of raw) {
|
||||
const [pair] = line.split(';');
|
||||
if (pair.trim()) cookie = pair.trim();
|
||||
}
|
||||
}
|
||||
|
||||
async function call(method, path, body) {
|
||||
const res = await fetch(`${API}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(cookie ? { Cookie: cookie } : {}),
|
||||
},
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
keepCookies(res);
|
||||
const text = await res.text();
|
||||
let data = null;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
data = text;
|
||||
}
|
||||
return { ok: res.ok, status: res.status, data };
|
||||
}
|
||||
|
||||
function say(mark, what) {
|
||||
console.log(` ${mark} ${what}`);
|
||||
if (mark === '+') created += 1;
|
||||
if (mark === '=') existed += 1;
|
||||
}
|
||||
|
||||
function fail(what, res) {
|
||||
console.error(`\n ! ${what} failed — HTTP ${res.status}`);
|
||||
console.error(` ${JSON.stringify(res.data)?.slice(0, 400)}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
// ── The steps ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function login() {
|
||||
const res = await call('POST', '/auth/login', { username: ADMIN_USER, password: ADMIN_PASS });
|
||||
if (!res.ok) {
|
||||
console.error(
|
||||
`\nCould not log in as "${ADMIN_USER}". Create the admin first, from the website repo:\n` +
|
||||
` cd website/server && DB_NAME=<demo db> ADMIN_USERNAME=${ADMIN_USER} ` +
|
||||
`ADMIN_PASSWORD='…' node db/seed.js\n`,
|
||||
);
|
||||
fail('login', res);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`\nsigned in as ${ADMIN_USER} at ${BASE}`);
|
||||
}
|
||||
|
||||
async function settings() {
|
||||
console.log('\nsite settings (D43 — the neutral demo identity)');
|
||||
if (DRY) {
|
||||
for (const [k, v] of Object.entries(siteSettings)) say('~', `${k} = ${v}`);
|
||||
return;
|
||||
}
|
||||
const res = await call('PUT', '/admin/settings', siteSettings);
|
||||
if (!res.ok) return fail('settings', res);
|
||||
for (const [k, v] of Object.entries(siteSettings)) say('+', `${k} = ${String(v).slice(0, 60)}`);
|
||||
}
|
||||
|
||||
async function uoLink() {
|
||||
console.log('\nshard connection (Admin → Shard)');
|
||||
if (!UOLINK_TOKEN) {
|
||||
say('~', 'UOLINK_TOKEN unset — leaving the sidecar config alone');
|
||||
return;
|
||||
}
|
||||
const now = await call('GET', '/admin/uo-link/config');
|
||||
if (now.status === 404) {
|
||||
say('~', 'no /admin/uo-link route — the uo module is not installed');
|
||||
return;
|
||||
}
|
||||
if (
|
||||
now.ok &&
|
||||
now.data?.config?.baseUrl === UOLINK_BASE &&
|
||||
now.data?.config?.protocol === UOLINK_PROTOCOL &&
|
||||
now.data?.config?.enabled
|
||||
) {
|
||||
say('=', `already pointed at ${UOLINK_BASE} (protocol ${UOLINK_PROTOCOL})`);
|
||||
return;
|
||||
}
|
||||
if (DRY) return say('~', `would point the site at ${UOLINK_BASE}`);
|
||||
const res = await call('PUT', '/admin/uo-link/config', {
|
||||
baseUrl: UOLINK_BASE,
|
||||
wsUrl: UOLINK_WS,
|
||||
token: UOLINK_TOKEN,
|
||||
protocol: UOLINK_PROTOCOL,
|
||||
enabled: true,
|
||||
});
|
||||
if (!res.ok) return fail('uo-link config', res);
|
||||
say('+', `pointed at ${UOLINK_BASE} (protocol ${UOLINK_PROTOCOL})`);
|
||||
}
|
||||
|
||||
async function users() {
|
||||
console.log('\naccounts');
|
||||
const list = await call('GET', '/admin/users');
|
||||
if (!list.ok) return fail('list users', list);
|
||||
const rows = Array.isArray(list.data) ? list.data : (list.data?.users ?? []);
|
||||
const have = new Set(rows.map((u) => u.username));
|
||||
for (const user of cast) {
|
||||
if (have.has(user.username)) {
|
||||
say('=', `${user.username} (${user.role})`);
|
||||
continue;
|
||||
}
|
||||
if (DRY) {
|
||||
say('~', `${user.username} (${user.role})`);
|
||||
continue;
|
||||
}
|
||||
const res = await call('POST', '/admin/users', {
|
||||
username: user.username,
|
||||
password: DEMO_PASS,
|
||||
role: user.role,
|
||||
});
|
||||
if (!res.ok) {
|
||||
fail(`create ${user.username}`, res);
|
||||
continue;
|
||||
}
|
||||
say('+', `${user.username} (${user.role})`);
|
||||
}
|
||||
}
|
||||
|
||||
async function posts() {
|
||||
console.log('\nposts');
|
||||
const list = await call('GET', '/admin/posts');
|
||||
if (!list.ok) return fail('list posts', list);
|
||||
const rows = Array.isArray(list.data) ? list.data : (list.data?.posts ?? []);
|
||||
const have = new Set(rows.map((p) => p.title));
|
||||
for (const post of POSTS) {
|
||||
if (have.has(post.title)) {
|
||||
say('=', `${post.category}: ${post.title}`);
|
||||
continue;
|
||||
}
|
||||
if (DRY) {
|
||||
say('~', `${post.category}: ${post.title}`);
|
||||
continue;
|
||||
}
|
||||
const res = await call('POST', '/admin/posts', post);
|
||||
if (!res.ok) {
|
||||
fail(`create post "${post.title}"`, res);
|
||||
continue;
|
||||
}
|
||||
say('+', `${post.category}: ${post.title}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function wiki() {
|
||||
console.log('\nwiki');
|
||||
const cats = await call('GET', '/admin/wiki/categories');
|
||||
if (!cats.ok) return fail('list wiki categories', cats);
|
||||
const catRows = Array.isArray(cats.data) ? cats.data : (cats.data?.categories ?? []);
|
||||
let category = catRows.find((c) => c.slug === WIKI_CATEGORY.slug);
|
||||
if (category) {
|
||||
say('=', `category ${WIKI_CATEGORY.slug}`);
|
||||
} else if (DRY) {
|
||||
say('~', `category ${WIKI_CATEGORY.slug}`);
|
||||
} else {
|
||||
const res = await call('POST', '/admin/wiki/categories', WIKI_CATEGORY);
|
||||
if (!res.ok) return fail('create wiki category', res);
|
||||
category = res.data?.category ?? res.data;
|
||||
say('+', `category ${WIKI_CATEGORY.slug}`);
|
||||
}
|
||||
|
||||
const pages = await call('GET', '/admin/wiki');
|
||||
if (!pages.ok) return fail('list wiki pages', pages);
|
||||
const pageRows = Array.isArray(pages.data) ? pages.data : (pages.data?.pages ?? []);
|
||||
const have = new Set(pageRows.map((p) => p.slug));
|
||||
for (const page of WIKI_PAGES) {
|
||||
if (have.has(page.slug)) {
|
||||
say('=', `page ${page.slug}`);
|
||||
continue;
|
||||
}
|
||||
if (DRY) {
|
||||
say('~', `page ${page.slug}`);
|
||||
continue;
|
||||
}
|
||||
const res = await call('POST', '/admin/wiki', {
|
||||
...page,
|
||||
category_id: category?.id ?? null,
|
||||
published: true,
|
||||
});
|
||||
if (!res.ok) {
|
||||
fail(`create wiki page "${page.slug}"`, res);
|
||||
continue;
|
||||
}
|
||||
say('+', `page ${page.slug}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── The Rust steps ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** The servers file, read once. The tokens in it are sent to the site and never printed. */
|
||||
function rustServerList() {
|
||||
const file = process.env.RG_RUST_SERVERS;
|
||||
if (!file) return [];
|
||||
return JSON.parse(readFileSync(file, 'utf8'));
|
||||
}
|
||||
|
||||
async function rustServers() {
|
||||
console.log('\nRust servers (Admin → Rust → Servers)');
|
||||
const wanted = rustServerList();
|
||||
if (wanted.length === 0) {
|
||||
say('~', 'RG_RUST_SERVERS unset — leaving the server list alone');
|
||||
return;
|
||||
}
|
||||
const now = await call('GET', '/admin/rust/servers');
|
||||
if (now.status === 404) {
|
||||
say('~', 'no /admin/rust route — the rust module is not installed');
|
||||
return;
|
||||
}
|
||||
if (!now.ok) return fail('list servers', now);
|
||||
for (const server of wanted) {
|
||||
const have = (now.data?.servers ?? []).find((row) => row.id === server.id);
|
||||
if (
|
||||
have &&
|
||||
have.sidecarBaseUrl === server.sidecarBaseUrl &&
|
||||
have.enabled &&
|
||||
have.schedule?.rule === RUST_WIPE.wipeRule
|
||||
) {
|
||||
say('=', `${server.id} at ${server.sidecarBaseUrl}`);
|
||||
continue;
|
||||
}
|
||||
if (DRY) {
|
||||
say('~', `${server.id} at ${server.sidecarBaseUrl}`);
|
||||
continue;
|
||||
}
|
||||
const res = await call('PUT', `/admin/rust/servers/${encodeURIComponent(server.id)}`, {
|
||||
name: server.name,
|
||||
sidecarBaseUrl: server.sidecarBaseUrl,
|
||||
// Omitted on an existing row, which is how the API says "keep the token" — so a re-run
|
||||
// never needs the token file to hold a token it already sent.
|
||||
sidecarToken: have ? undefined : server.sidecarToken,
|
||||
enabled: true,
|
||||
...RUST_WIPE,
|
||||
});
|
||||
if (!res.ok) {
|
||||
fail(`save server ${server.id}`, res);
|
||||
continue;
|
||||
}
|
||||
say('+', `${server.id} "${server.name}" at ${server.sidecarBaseUrl}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function rustVisibility() {
|
||||
console.log('\nvisibility (Admin → Rust → Visibility)');
|
||||
const now = await call('GET', '/admin/rust/visibility');
|
||||
if (!now.ok) return fail('read visibility', now);
|
||||
if (now.data?.presence?.fleet === 'public' && now.data?.map?.fleet?.players === 'public') {
|
||||
say('=', 'who is online and the map’s players are public');
|
||||
return;
|
||||
}
|
||||
if (DRY) return say('~', 'would make who is online and the map’s players public');
|
||||
const res = await call('PUT', '/admin/rust/visibility', RUST_VISIBILITY);
|
||||
if (!res.ok) return fail('visibility', res);
|
||||
say('+', 'who is online and the map’s players are public');
|
||||
}
|
||||
|
||||
async function npcProfiles() {
|
||||
console.log('\nRunicNPC profiles (Admin → Rust → NPCs)');
|
||||
const now = await call('GET', '/admin/rust/npcs');
|
||||
if (!now.ok) return fail('list NPC profiles', now);
|
||||
const live = (now.data?.profiles ?? []).filter((profile) => !profile.replaced);
|
||||
for (const profile of NPC_PROFILES) {
|
||||
const have = live.find((row) => row.name === profile.name);
|
||||
if (DRY) {
|
||||
say('~', `profile ${profile.name}`);
|
||||
continue;
|
||||
}
|
||||
// An existing profile is saved again rather than skipped, so a change to its body here
|
||||
// reaches the demo on the next run. RunicNPC answers the push the same either way.
|
||||
const res = have
|
||||
? await call('PUT', `/admin/rust/npcs/profiles/${have.id}`, profile)
|
||||
: await call('POST', '/admin/rust/npcs/profiles', profile);
|
||||
if (!res.ok) {
|
||||
fail(`${have ? 'save' : 'create'} profile ${profile.name}`, res);
|
||||
continue;
|
||||
}
|
||||
say(have ? '=' : '+', `profile ${profile.name}${have ? ' (saved again)' : ''}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function permGroup() {
|
||||
console.log('\npermission group (Admin → Rust → Permissions)');
|
||||
for (const server of rustServerList()) {
|
||||
const base = `/admin/rust/permissions/servers/${encodeURIComponent(server.id)}`;
|
||||
const now = await call('GET', base);
|
||||
if (!now.ok) {
|
||||
fail(`read permissions on ${server.id}`, now);
|
||||
continue;
|
||||
}
|
||||
if (DRY) {
|
||||
say('~', `${server.id}: group ${PERM_GROUP.name}`);
|
||||
continue;
|
||||
}
|
||||
let id = (now.data?.groups ?? []).find((group) => group.name === PERM_GROUP.name)?.id;
|
||||
if (id) {
|
||||
say('=', `${server.id}: group ${PERM_GROUP.name}`);
|
||||
} else {
|
||||
const made = await call('POST', `${base}/groups`, {
|
||||
name: PERM_GROUP.name,
|
||||
title: PERM_GROUP.title,
|
||||
rank: PERM_GROUP.rank,
|
||||
});
|
||||
if (!made.ok) {
|
||||
fail(`create group ${PERM_GROUP.name} on ${server.id}`, made);
|
||||
continue;
|
||||
}
|
||||
id = made.data.id;
|
||||
say('+', `${server.id}: group ${PERM_GROUP.name}`);
|
||||
}
|
||||
// The permission set is replaced whole, so saving it again is the update.
|
||||
const perms = await call('PUT', `/admin/rust/permissions/groups/${id}/permissions`, {
|
||||
permissions: PERM_GROUP.permissions,
|
||||
});
|
||||
if (!perms.ok) fail(`grant ${PERM_GROUP.name}'s permissions`, perms);
|
||||
else say('=', ` permissions ${PERM_GROUP.permissions.join(', ')}`);
|
||||
for (const username of PERM_GROUP.members) {
|
||||
const added = await call('POST', `/admin/rust/permissions/groups/${id}/members`, { username });
|
||||
if (added.ok) say('=', ` member ${username}`);
|
||||
else if (added.status === 409) say('=', ` member ${username} (already)`);
|
||||
else fail(`add ${username} to ${PERM_GROUP.name}`, added);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── main ───────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
console.log(
|
||||
`${DRY ? '\nseedDemo — DRY RUN, nothing will be written' : '\nseedDemo'} (${GAME})`,
|
||||
);
|
||||
|
||||
await login();
|
||||
await settings();
|
||||
await users();
|
||||
|
||||
// The news and the wiki are written in one game's voice (a patch note about house decay, a
|
||||
// champion spawn), so they belong to the UO demo. The Rust captures show none of them.
|
||||
if (GAME === 'rust') {
|
||||
await rustServers();
|
||||
await rustVisibility();
|
||||
await npcProfiles();
|
||||
await permGroup();
|
||||
} else {
|
||||
await uoLink();
|
||||
await posts();
|
||||
await wiki();
|
||||
}
|
||||
|
||||
console.log(
|
||||
`\n${DRY ? 'would create' : 'created'} ${created}, already present ${existed}` +
|
||||
(process.exitCode ? ' — with failures above' : ''),
|
||||
);
|
||||
console.log(
|
||||
GAME === 'rust'
|
||||
? '\nWhat this does NOT seed, on purpose: the feed, the leaderboard, who is online, clans\n' +
|
||||
'and the map. Those arrive from the server over the bridge — start the server, and\n' +
|
||||
'they populate themselves. PLAN.md §16 phase 18 says how its players were staged.\n'
|
||||
: '\nWhat this does NOT seed, on purpose: teams, the marketplace, houses, points boards\n' +
|
||||
'and the atlas. Those arrive from the shard over the bridge (D42) — start the sidecar\n' +
|
||||
'and the shard, and they populate themselves.\n',
|
||||
);
|
||||
204
scripts/serve.mjs
Normal file
@@ -0,0 +1,204 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 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 three
|
||||
* reasons, two of them things `@astrojs/node` gets wrong.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* 1. THE ADAPTER SERVES THE WRONG PAGE'S CONTENT-SECURITY-POLICY
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* `@astrojs/node`'s `staticHeaders` writes one policy per prerendered route into
|
||||
* `dist/_headers.json` and looks the right one up per request. The lookup, in
|
||||
* `dist/serve-static.js`, is:
|
||||
*
|
||||
* headersMap.find((header) => header.pathname.includes(baselessPathname))
|
||||
*
|
||||
* `String.includes` — a SUBSTRING test, not equality, taking the first match. So:
|
||||
*
|
||||
* - `/modules/` matches the record for `/docs/modules/building-a-module`,
|
||||
* - `/architecture/` matches `/docs/architecture/...`,
|
||||
* - and `/`, which is a substring of every path in the file, matches whichever record
|
||||
* happens to be first — here `/404`.
|
||||
*
|
||||
* Every prerendered page was therefore served some other page's policy. Because the
|
||||
* policies are per-page hash lists, that is not a cosmetic mismatch: the browser refused
|
||||
* the page's own stylesheet. `/modules/` and `/architecture/` rendered unstyled sections
|
||||
* with `Refused to apply inline style` in a console, and the homepage only looked fine
|
||||
* because it happens to share a hash with the 404 page.
|
||||
*
|
||||
* Astro's static-header machinery is otherwise exactly what §6 wants, so this replaces the
|
||||
* lookup rather than the mechanism: the same `_headers.json`, matched by pathname
|
||||
* EQUALITY. The workaround is deliberately small and obvious so it can be deleted whole
|
||||
* when the upstream `find` is fixed — the check for that is whether `/modules/` and
|
||||
* `/docs/modules/building-a-module` are served different policies.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* 2. THE HEADERS THAT ARE NOT CSP
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* A few security headers have nothing to do with Astro and no other place to live. They
|
||||
* are set here rather than written into an operator's reverse-proxy configuration (D48,
|
||||
* again): the container should be correct on its own, and a proxy someone else configures
|
||||
* is a promise this repository cannot check.
|
||||
*
|
||||
* The two routes that render per request — `/beta` and `/brand/*` — have no entry in
|
||||
* `_headers.json`, because nothing prerendered them. They get `frame-ancestors 'none'` on
|
||||
* its own, which is the one directive a `<meta>` CSP cannot express and therefore the one
|
||||
* thing Astro's per-page meta tag leaves them missing.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const root = path.join(here, '..');
|
||||
|
||||
const port = Number(process.env.PORT ?? 4321);
|
||||
const host = process.env.HOST ?? '0.0.0.0';
|
||||
|
||||
/* ---------------------------------------------------------------------------------------
|
||||
The policies, matched exactly
|
||||
--------------------------------------------------------------------------------------- */
|
||||
|
||||
const normalise = (pathname) => {
|
||||
const clean = pathname.split('?')[0].split('#')[0];
|
||||
const trimmed = clean.replace(/\/+$/, '');
|
||||
return trimmed === '' ? '/' : trimmed;
|
||||
};
|
||||
|
||||
const policies = new Map();
|
||||
const headersFile = path.join(root, 'dist', '_headers.json');
|
||||
|
||||
if (fs.existsSync(headersFile)) {
|
||||
for (const record of Object.values(JSON.parse(fs.readFileSync(headersFile, 'utf8')))) {
|
||||
const csp = record.headers?.find((h) => h.key.toLowerCase() === 'content-security-policy');
|
||||
if (csp) policies.set(normalise(record.pathname), csp.value);
|
||||
}
|
||||
} else {
|
||||
// Not fatal: the site still serves, with the per-page <meta> policy Astro also emits.
|
||||
// Loud, because a deployment silently losing its response-header CSP is exactly what §6
|
||||
// is trying to prevent.
|
||||
console.error(
|
||||
'[serve] dist/_headers.json is missing — pages will be served without a CSP response\n' +
|
||||
' header. Check that astro.config.mjs still sets `staticHeaders: true`.'
|
||||
);
|
||||
}
|
||||
|
||||
const FRAME_ONLY = "frame-ancestors 'none'";
|
||||
|
||||
/**
|
||||
* Headers with no page-by-page component. Each is the browser default made explicit, and
|
||||
* each closes something the CSP does not:
|
||||
*
|
||||
* - `X-Content-Type-Options` stops a browser guessing that a .txt is HTML.
|
||||
* - `Referrer-Policy` keeps the path of the page a reader came from out of requests to
|
||||
* other origins — there are none today (D9), and this is what keeps that true if a
|
||||
* link is ever followed off-site.
|
||||
* - `X-Frame-Options` says again, for anything too old to honour `frame-ancestors`.
|
||||
* - `Permissions-Policy` turns off hardware this site has no reason to ask for. A
|
||||
* marketing page requesting a camera should be impossible, not merely unlikely.
|
||||
*/
|
||||
const STATIC_HEADERS = {
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'Referrer-Policy': 'strict-origin-when-cross-origin',
|
||||
'X-Frame-Options': 'DENY',
|
||||
'Permissions-Policy': 'camera=(), microphone=(), geolocation=(), payment=(), usb=()',
|
||||
};
|
||||
|
||||
/* ---------------------------------------------------------------------------------------
|
||||
The server
|
||||
--------------------------------------------------------------------------------------- */
|
||||
|
||||
// The adapter's entry starts its own listener on import unless this is set.
|
||||
process.env.ASTRO_NODE_AUTOSTART = 'disabled';
|
||||
|
||||
// `pathToFileURL`, not the bare path: on Windows an absolute path starts with a drive
|
||||
// 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);
|
||||
res.setHeader('Content-Security-Policy', policy ?? FRAME_ONLY);
|
||||
|
||||
/**
|
||||
* The adapter will set its own (wrong) `Content-Security-Policy` from inside the static
|
||||
* handler, overwriting what was just set. Rather than race it, every later attempt to
|
||||
* set that one header is ignored — the correct value is already on the response, and
|
||||
* this request's policy cannot change halfway through serving it.
|
||||
*/
|
||||
const setHeader = res.setHeader.bind(res);
|
||||
res.setHeader = (name, value) => {
|
||||
if (String(name).toLowerCase() === 'content-security-policy') return res;
|
||||
return setHeader(name, value);
|
||||
};
|
||||
|
||||
handler(req, res);
|
||||
});
|
||||
|
||||
server.listen(port, host, () => {
|
||||
console.log(`[serve] listening on http://${host}:${port} — ${policies.size} prerendered policies`);
|
||||
});
|
||||
53
src/components/DocsPageTitle.astro
Normal file
@@ -0,0 +1,53 @@
|
||||
---
|
||||
/**
|
||||
* Overrides Starlight's `PageTitle` for one attribute: `tabindex="-1"` on the heading.
|
||||
*
|
||||
* Phase 10 found and fixed this on the marketing chrome — following a skip link moves the
|
||||
* viewport but not the keyboard focus, because the target of the link is not focusable.
|
||||
* Chrome papers over it; not every browser does, and a reader who lands past the header
|
||||
* only to find Tab returning them to the top of the nav has not been skipped anywhere.
|
||||
* `Base.astro`'s `<main>` gained `tabindex="-1"` then.
|
||||
*
|
||||
* Phase 11's browser walk found the same defect still standing on the other forty pages.
|
||||
* Starlight's skip link points at the page's `<h1>` rather than at a landmark, and an
|
||||
* `<h1>` is no more focusable than a `<main>`, so the docs half of the site had the fix
|
||||
* that the marketing half had.
|
||||
*
|
||||
* The rest of this file is Starlight's own implementation, copied because the override
|
||||
* mechanism replaces a component rather than decorating it. That is a small drift risk —
|
||||
* if Starlight restyles its `h1`, this copy will not follow — so it is deliberately kept
|
||||
* to exactly what upstream has, with nothing of ours added beyond the attribute. The
|
||||
* check for drift is visual: a documentation title that stops matching the marketing
|
||||
* chrome's.
|
||||
*
|
||||
* The id is Starlight's `PAGE_TITLE_ID`, written out rather than imported: `./constants`
|
||||
* is not one of the subpaths the package exports, so importing it reaches past the
|
||||
* package's own boundary. It is what `SkipLink.astro` puts in its `href`, so the two must
|
||||
* agree; if a Starlight upgrade ever renames it, the skip link stops resolving at all and
|
||||
* the first Tab on a documentation page lands somewhere obviously wrong.
|
||||
*/
|
||||
const PAGE_TITLE_ID = '_top';
|
||||
---
|
||||
|
||||
<h1 id={PAGE_TITLE_ID} tabindex="-1">{Astro.locals.starlightRoute.entry.data.title}</h1>
|
||||
|
||||
<style>
|
||||
@layer starlight.core {
|
||||
h1 {
|
||||
margin-top: 1rem;
|
||||
font-size: var(--sl-text-h1);
|
||||
line-height: var(--sl-line-height-headings);
|
||||
font-weight: 600;
|
||||
color: var(--sl-color-white);
|
||||
}
|
||||
|
||||
/* Ours, and the only line that is: the heading is focusable now, so it can be
|
||||
focused, and a focus ring drawn around a page title reads as an error rather than
|
||||
as a destination. Removing it is safe only because this element is reachable by
|
||||
exactly one route — the skip link, which the reader took deliberately. It is never
|
||||
in the tab sequence. */
|
||||
h1:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
import { brand } from '../lib/brand.mjs';
|
||||
import { renderBrand } from '../lib/brand.mjs';
|
||||
import { legal } from '../data/legal.mjs';
|
||||
import { footerColumns } from '../data/footer.mjs';
|
||||
import platform from '../data/platform.json';
|
||||
|
||||
/**
|
||||
@@ -13,35 +14,15 @@ import platform from '../data/platform.json';
|
||||
* reader browses to alongside Features, it is a thing they go looking for, and the line
|
||||
* that already carries the licence and the copyright is where people look.
|
||||
*/
|
||||
// `/beta` renders per request, so the footer it gets must read the mount rather than the
|
||||
// value baked at build time. See `renderBrand` in src/lib/brand.mjs (phase 11).
|
||||
const brand = renderBrand(Astro);
|
||||
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
const columns = [
|
||||
{
|
||||
heading: 'Product',
|
||||
links: [
|
||||
{ href: '/features/', label: 'Features' },
|
||||
{ href: '/architecture/', label: 'Architecture' },
|
||||
{ href: '/modules/', label: 'Modules' },
|
||||
{ href: '/app/', label: 'Android app' },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Documentation',
|
||||
links: [
|
||||
{ href: '/docs/', label: 'Getting started' },
|
||||
{ href: '/docs/', label: 'Administration' },
|
||||
{ href: '/docs/', label: 'Building a module' },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Project',
|
||||
links: [
|
||||
{ href: brand.giteaOrg, label: 'Source' },
|
||||
{ href: brand.discordInvite, label: 'Discord' },
|
||||
{ href: '/community/', label: 'Community' },
|
||||
],
|
||||
},
|
||||
];
|
||||
// The columns live in src/data/footer.mjs so a test can read them — see the note there,
|
||||
// and test/footer.test.mjs. The two Project links come from the mounted brand (§7).
|
||||
const columns = footerColumns(brand);
|
||||
|
||||
const isExternal = (href: string) => href.startsWith('http');
|
||||
---
|
||||
@@ -81,8 +62,9 @@ const isExternal = (href: string) => href.startsWith('http');
|
||||
</span>
|
||||
</p>
|
||||
<p class="site-footer__meta">
|
||||
<span class="chip chip--version">Protocol {platform.protocol}</span>
|
||||
<span class="chip chip--version">Bundle {platform.bundle.tag}</span>
|
||||
{/* D66, as on the hero: the shared version only. Each game's protocol and bundle
|
||||
are on that game's pages. */}
|
||||
<span class="chip chip--version">Module API {platform.moduleApi}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
37
src/components/GameExample.astro
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
import platform from '../data/platform.json';
|
||||
|
||||
/**
|
||||
* A worked example that belongs to one game, quoted on a core page (PLAN.md §16, D68).
|
||||
*
|
||||
* D63 keeps a game's vocabulary inside that game's section, but a core page sometimes needs a
|
||||
* real example rather than an invented one: a module manifest, an event dimension, the flag
|
||||
* that tells the installer which game it is setting up. §1 prefers the real thing, because an
|
||||
* invented example is checked by nothing and goes stale without anybody noticing.
|
||||
*
|
||||
* So the example keeps its game's words and says whose they are. This prints the game's name
|
||||
* above the block — the same bargain D67 strikes for a screenshot: a game's words are honest
|
||||
* on a core page when the game is named right beside them. `scripts/checkTerms.mjs` reads the
|
||||
* `game` attribute and allows that game's words, and only that game's, between the tags.
|
||||
*
|
||||
* The name comes from `platform.json` rather than from the caller, so the label cannot say
|
||||
* one game while the attribute that the check reads says another.
|
||||
*/
|
||||
interface Props {
|
||||
/** A key of `platform.json`'s `games`: `uo` or `rust`. */
|
||||
game: keyof typeof platform.games;
|
||||
}
|
||||
|
||||
const { game } = Astro.props;
|
||||
const entry = platform.games[game];
|
||||
if (!entry) {
|
||||
throw new Error(
|
||||
`GameExample: unknown game "${game}". Use a key of platform.json's games: ${Object.keys(platform.games).join(', ')}.`,
|
||||
);
|
||||
}
|
||||
---
|
||||
|
||||
<figure class="game-example" data-game={game}>
|
||||
<figcaption class="game-example__label">{entry.name}</figcaption>
|
||||
<div class="game-example__body"><slot /></div>
|
||||
</figure>
|
||||
107
src/components/GameFeatures.astro
Normal file
@@ -0,0 +1,107 @@
|
||||
---
|
||||
import type { Capability } from '../data/capabilities.mjs';
|
||||
|
||||
/**
|
||||
* A game's own feature list, with the argument for each — PLAN.md §16, phase 17.
|
||||
*
|
||||
* Rendered only by a game's page under `/modules/` (`GAME_ROUTES` in `checkTerms.mjs`),
|
||||
* which is what lets the items be written in the game's own words. It is `/features/`'s
|
||||
* card treatment (D20: the label and the detail, never the label alone), so a reader who has
|
||||
* seen that page reads this one the same way.
|
||||
*
|
||||
* A feature with a `demoPath` gets the per-capability demo link of D25. The markup is the
|
||||
* exact contract `scripts/checkBrand.mjs` enforces and `applyBrand.mjs` rewrites at boot —
|
||||
* see the note in `/features/` before touching it. A stock build hides every one.
|
||||
*/
|
||||
interface Props {
|
||||
title: string;
|
||||
lede?: string;
|
||||
items: Capability[];
|
||||
}
|
||||
|
||||
const { title, lede, items } = Astro.props;
|
||||
---
|
||||
|
||||
<section class="page section feats">
|
||||
<h2>{title}</h2>
|
||||
{lede && <p class="prose feats__lede">{lede}</p>}
|
||||
|
||||
<ul class="feats__items">
|
||||
{
|
||||
items.map((item) => (
|
||||
<li class="panel feats__item">
|
||||
<h3>{item.label}</h3>
|
||||
<p class="feats__detail">{item.detail}</p>
|
||||
{item.demoPath && (
|
||||
<a
|
||||
class="demo-link"
|
||||
href="" data-demo-url="" data-demo-path={item.demoPath}
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
See it running
|
||||
</a>
|
||||
)}
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.feats h2 {
|
||||
margin: 0;
|
||||
font-size: clamp(1.5rem, 3vw, 1.95rem);
|
||||
}
|
||||
|
||||
.feats__lede {
|
||||
margin: 0.85rem 0 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.feats__items {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin: 1.75rem 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 21rem), 1fr));
|
||||
}
|
||||
|
||||
.feats__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.feats__item h3 {
|
||||
margin: 0 0 0.6rem;
|
||||
color: var(--gold);
|
||||
font-size: 1.04rem;
|
||||
}
|
||||
|
||||
.feats__detail {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.94rem;
|
||||
}
|
||||
|
||||
/* As on /features/: a link rather than a button, and the flex item itself, so the
|
||||
global rule that hides an empty demo link takes its margin with it. */
|
||||
.demo-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
margin-top: 1rem;
|
||||
color: var(--portal);
|
||||
font-size: 0.88rem;
|
||||
text-decoration-color: color-mix(in srgb, var(--portal) 40%, transparent);
|
||||
}
|
||||
|
||||
.demo-link:hover {
|
||||
color: var(--portal-bright);
|
||||
}
|
||||
|
||||
.demo-link::after {
|
||||
content: '\2197';
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
import { brand } from '../lib/brand.mjs';
|
||||
import Search from './Search.astro';
|
||||
import { renderBrand } from '../lib/brand.mjs';
|
||||
|
||||
/**
|
||||
* The marketing header. The docs get Starlight's own header, themed to match in
|
||||
@@ -20,9 +21,15 @@ import { brand } from '../lib/brand.mjs';
|
||||
*/
|
||||
const { pathname } = Astro.url;
|
||||
|
||||
// `/beta` renders per request, so the lockup name it gets must read the mount rather than
|
||||
// the value baked at build time. See `renderBrand` in src/lib/brand.mjs (phase 11).
|
||||
const brand = renderBrand(Astro);
|
||||
|
||||
const links = [
|
||||
{ href: '/features/', label: 'Features' },
|
||||
{ href: '/docs/', label: 'Docs' },
|
||||
// D78 (phase 17): the standalone RunicNPC page, for a Rust server owner who runs no site.
|
||||
{ href: '/runicnpc/', label: 'RunicNPC' },
|
||||
{ href: '/app/', label: 'App' },
|
||||
{ href: '/community/', label: 'Community' },
|
||||
];
|
||||
@@ -55,6 +62,8 @@ const isCurrent = (href: string) =>
|
||||
))
|
||||
}
|
||||
</nav>
|
||||
|
||||
<Search />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
110
src/components/Screenshot.astro
Normal file
@@ -0,0 +1,110 @@
|
||||
---
|
||||
import { screenById, WEB, PHONE } from '../data/screens.mjs';
|
||||
import platform from '../data/platform.json';
|
||||
|
||||
/**
|
||||
* One screenshot, as a figure with its caption. PLAN.md §13 phase 9, D4 / D44.
|
||||
*
|
||||
* -----------------------------------------------------------------------------------------
|
||||
* WHY THE PAGE PASSES AN ID AND NOTHING ELSE
|
||||
* -----------------------------------------------------------------------------------------
|
||||
* A marketing page and a documentation page show the same administration screen for
|
||||
* different reasons, and the thing they must not do is describe it differently. The alt
|
||||
* text and the caption therefore live with the capture in `screens.mjs`, next to the route
|
||||
* they came from, and a page asks for `admin-shard` rather than restating what is in it.
|
||||
*
|
||||
* It also means a re-capture cannot silently invalidate a caption: the sentence and the
|
||||
* frame it describes are edited in the same file.
|
||||
*
|
||||
* -----------------------------------------------------------------------------------------
|
||||
* WHY IT FAILS THE BUILD ON AN UNKNOWN ID
|
||||
* -----------------------------------------------------------------------------------------
|
||||
* The alternative is a page that renders a broken image, which looks like a deployment
|
||||
* problem rather than a typo and survives review. `checkScreens.mjs` covers the other
|
||||
* direction — a declared screen whose file is missing — so between them a screenshot is
|
||||
* either complete or the build stops.
|
||||
*/
|
||||
interface Props {
|
||||
/** An `id` from `src/data/screens.mjs`. */
|
||||
id: string;
|
||||
/** Suppress the caption where the surrounding prose already says it. */
|
||||
bare?: boolean;
|
||||
}
|
||||
|
||||
const { id, bare = false } = Astro.props;
|
||||
|
||||
const shot = screenById(id);
|
||||
|
||||
if (!shot) {
|
||||
throw new Error(`Screenshot "${id}" is not declared in src/data/screens.mjs`);
|
||||
}
|
||||
|
||||
const src = `/screens/${shot.id}.webp`;
|
||||
|
||||
// Intrinsic size comes from the family rather than the entry: every capture in a family is
|
||||
// taken at one geometry (see screens.mjs), and `checkScreens.mjs` asserts the files really
|
||||
// are that size, so these attributes cannot drift from the pixels.
|
||||
const { width, height } = shot.family === 'web' ? WEB : PHONE;
|
||||
|
||||
// D67: a game's screen says whose it is. The name comes from `platform.json`, as
|
||||
// GameExample's does, so the label cannot disagree with the tag the check reads.
|
||||
const game = shot.game ? platform.games[shot.game] : null;
|
||||
if (shot.game && !game) {
|
||||
throw new Error(`Screenshot "${id}" is tagged game "${shot.game}", which platform.json does not have`);
|
||||
}
|
||||
---
|
||||
|
||||
<figure class="shot" data-game={shot.game}>
|
||||
<img
|
||||
src={src}
|
||||
alt={shot.alt}
|
||||
width={width}
|
||||
height={height}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
{
|
||||
(game || !bare) && (
|
||||
<figcaption>
|
||||
{game && <span class="shot__game">{game.name}</span>}
|
||||
{!bare && shot.caption}
|
||||
</figcaption>
|
||||
)
|
||||
}
|
||||
</figure>
|
||||
|
||||
<style>
|
||||
.shot {
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.shot img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.shot figcaption {
|
||||
margin: 0.85rem 0 0;
|
||||
color: var(--dim);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* The game's name, ahead of the caption (D67). Styled here rather than with the global
|
||||
chip, because the docs pages render this component without the marketing stylesheet. */
|
||||
.shot__game {
|
||||
display: inline-block;
|
||||
margin-right: 0.5rem;
|
||||
padding: 0.05rem 0.55rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-pill);
|
||||
color: var(--muted);
|
||||
font-size: 0.76rem;
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
279
src/components/Search.astro
Normal file
@@ -0,0 +1,279 @@
|
||||
---
|
||||
/**
|
||||
* Site search for the marketing pages (D47).
|
||||
*
|
||||
* The documentation has had search since phase 1 — Starlight builds a Pagefind index at the
|
||||
* end of every build and puts a box in its own header. The marketing pages were outside it
|
||||
* twice over: not indexed, so a reader searching "Teams" in the docs found the architecture
|
||||
* page and never the feature page; and with no box, so a reader who arrived on the homepage
|
||||
* had a four-item nav and no way to ask a question.
|
||||
*
|
||||
* D47 closed both. `Base.astro` marks its `<main>` as a Pagefind body, which puts the ten
|
||||
* marketing pages in the same index the docs already query, and this is the box.
|
||||
*
|
||||
* ── Why it is built this way ────────────────────────────────────────────────
|
||||
* The marketing pages ship almost no JavaScript, and Pagefind's own UI bundle is 120 kB
|
||||
* before the index and the WASM. Loading that on a homepage so that some visitors can
|
||||
* search would be a poor trade, so **nothing is fetched until the dialog is opened** —
|
||||
* the button is inert markup, and the first open injects the stylesheet and the script.
|
||||
* Opening search a second time costs nothing more.
|
||||
*
|
||||
* `<dialog>` rather than a hand-built overlay: the browser gives us the focus trap, the
|
||||
* inert background, Escape-to-close and the top layer for free, and every one of those is
|
||||
* a thing an accessibility pass would otherwise have to find missing.
|
||||
*
|
||||
* In `astro dev` there is no `/pagefind/` — the index is written by the build. Rather than
|
||||
* fail silently, the dialog says so.
|
||||
*/
|
||||
---
|
||||
|
||||
<div class="site-search">
|
||||
<button type="button" class="site-search__open" data-search-open aria-haspopup="dialog">
|
||||
<svg aria-hidden="true" focusable="false" viewBox="0 0 20 20" width="16" height="16">
|
||||
<circle cx="9" cy="9" r="6" fill="none" stroke="currentColor" stroke-width="2"></circle>
|
||||
<line x1="13.5" y1="13.5" x2="18" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round"></line>
|
||||
</svg>
|
||||
<span>Search</span>
|
||||
<kbd aria-hidden="true">/</kbd>
|
||||
</button>
|
||||
|
||||
<dialog class="site-search__dialog" data-search-dialog aria-label="Search this site">
|
||||
<div class="site-search__panel">
|
||||
<div class="site-search__head">
|
||||
<h2 class="site-search__title">Search</h2>
|
||||
<button type="button" class="site-search__close" data-search-close>Close</button>
|
||||
</div>
|
||||
<div data-search-mount></div>
|
||||
<p class="site-search__note" data-search-note hidden>
|
||||
Search is built with the site, so it is not available in the dev server. Run
|
||||
<code>npm run build && npm start</code> to try it.
|
||||
</p>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const dialog = document.querySelector<HTMLDialogElement>('[data-search-dialog]');
|
||||
const mount = document.querySelector<HTMLElement>('[data-search-mount]');
|
||||
const note = document.querySelector<HTMLElement>('[data-search-note]');
|
||||
|
||||
if (dialog && mount) {
|
||||
let loaded: Promise<void> | null = null;
|
||||
|
||||
/**
|
||||
* Pagefind's UI bundle is an IIFE that hangs `PagefindUI` off `window`, so it is a
|
||||
* `<script src>` and not a dynamic `import()`. Both are covered by `script-src 'self'`
|
||||
* (D48); the WASM the index needs is why that directive also carries
|
||||
* `'wasm-unsafe-eval'`.
|
||||
*/
|
||||
const load = () =>
|
||||
(loaded ??= new Promise<void>((resolve, reject) => {
|
||||
const css = document.createElement('link');
|
||||
css.rel = 'stylesheet';
|
||||
css.href = '/pagefind/pagefind-ui.css';
|
||||
document.head.append(css);
|
||||
|
||||
const js = document.createElement('script');
|
||||
js.src = '/pagefind/pagefind-ui.js';
|
||||
js.onload = () => {
|
||||
new (window as any).PagefindUI({
|
||||
element: mount,
|
||||
showSubResults: true,
|
||||
showImages: false,
|
||||
// `resetStyles: false` was tried and is wrong here. Pagefind's reset is what
|
||||
// styles its own input and buttons; without it they fall back to user-agent
|
||||
// defaults, which on this ground meant black text typed into a dark field and
|
||||
// a Clear button with a 1990s `outset` border. The palette is bound to our
|
||||
// tokens below instead, which is the supported way round.
|
||||
translations: {
|
||||
placeholder: 'Search the site and documentation',
|
||||
zero_results: 'Nothing found for [SEARCH_TERM]',
|
||||
},
|
||||
});
|
||||
resolve();
|
||||
};
|
||||
js.onerror = () => reject(new Error('pagefind-ui.js did not load'));
|
||||
document.head.append(js);
|
||||
}).catch((error) => {
|
||||
// A dev server, or a build served without its index. Say which.
|
||||
if (note) note.hidden = false;
|
||||
loaded = null;
|
||||
throw error;
|
||||
}));
|
||||
|
||||
const open = () => {
|
||||
// Deliberately not awaited: the dialog should appear at once and fill in, rather
|
||||
// than the button seeming dead for as long as the bundle takes.
|
||||
load().catch(() => {});
|
||||
if (!dialog.open) dialog.showModal();
|
||||
window.setTimeout(() => {
|
||||
dialog.querySelector<HTMLInputElement>('input[type="text"]')?.focus();
|
||||
}, 50);
|
||||
};
|
||||
|
||||
document
|
||||
.querySelectorAll<HTMLButtonElement>('[data-search-open]')
|
||||
.forEach((button) => button.addEventListener('click', open));
|
||||
|
||||
document
|
||||
.querySelectorAll<HTMLButtonElement>('[data-search-close]')
|
||||
.forEach((button) => button.addEventListener('click', () => dialog.close()));
|
||||
|
||||
// Clicking the backdrop closes it. `<dialog>` reports backdrop clicks as clicks on the
|
||||
// dialog itself, so the test is whether the click landed outside the panel's box.
|
||||
dialog.addEventListener('click', (event) => {
|
||||
if (event.target !== dialog) return;
|
||||
const box = dialog.getBoundingClientRect();
|
||||
const outside =
|
||||
event.clientX < box.left ||
|
||||
event.clientX > box.right ||
|
||||
event.clientY < box.top ||
|
||||
event.clientY > box.bottom;
|
||||
if (outside) dialog.close();
|
||||
});
|
||||
|
||||
/**
|
||||
* `/` and Ctrl/⌘-K, the two the documentation already answers to — the shortcut a
|
||||
* reader learns in the docs should work on the way back out.
|
||||
*/
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (dialog.open) return;
|
||||
const target = event.target as HTMLElement | null;
|
||||
const typing =
|
||||
target?.isContentEditable ||
|
||||
['INPUT', 'TEXTAREA', 'SELECT'].includes(target?.tagName ?? '');
|
||||
if (typing) return;
|
||||
|
||||
if (event.key === '/' || ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k')) {
|
||||
event.preventDefault();
|
||||
open();
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.site-search__open {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0.7rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-pill);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
font-size: 0.92rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.site-search__open:hover {
|
||||
color: var(--ink);
|
||||
border-color: var(--gold);
|
||||
}
|
||||
|
||||
.site-search__open kbd {
|
||||
padding: 0 0.35rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Narrow viewports get the icon alone: the header has a lockup and four links to fit,
|
||||
and "Search" beside a magnifier is the word the icon already says. */
|
||||
@media (max-width: 46rem) {
|
||||
.site-search__open span,
|
||||
.site-search__open kbd {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.site-search__open {
|
||||
padding: 0.45rem;
|
||||
}
|
||||
}
|
||||
|
||||
.site-search__dialog {
|
||||
width: min(46rem, calc(100vw - 2rem));
|
||||
margin-inline: auto;
|
||||
margin-block-start: min(12vh, 6rem);
|
||||
padding: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-panel);
|
||||
background: var(--panel-flat);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.site-search__dialog::backdrop {
|
||||
background: var(--scrim);
|
||||
}
|
||||
|
||||
.site-search__panel {
|
||||
padding: 1.1rem 1.25rem 1.4rem;
|
||||
}
|
||||
|
||||
.site-search__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.site-search__title {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.site-search__close {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.site-search__close:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.site-search__note {
|
||||
margin: 0.75rem 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
/* Pagefind ships its own palette; these bind it to the site's tokens so the dialog is
|
||||
not a differently-coloured window sitting on the page. */
|
||||
.site-search__panel :global(.pagefind-ui) {
|
||||
--pagefind-ui-primary: var(--ink);
|
||||
--pagefind-ui-text: var(--ink);
|
||||
--pagefind-ui-background: var(--panel-flat);
|
||||
--pagefind-ui-border: var(--line);
|
||||
--pagefind-ui-tag: var(--bg);
|
||||
--pagefind-ui-border-width: 1px;
|
||||
--pagefind-ui-border-radius: var(--radius-input);
|
||||
--pagefind-ui-font: inherit;
|
||||
}
|
||||
|
||||
.site-search__panel :global(.pagefind-ui__result-link) {
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
/* The match highlight. Pagefind marks matched terms with <mark>, and the user-agent
|
||||
default for that is black on pure yellow — legible, and a hole punched through the
|
||||
palette on every result. Gold at low opacity reads as a highlight against this ground
|
||||
without becoming the loudest thing on the page.
|
||||
|
||||
`.pagefind-ui--reset` is in the selector because Pagefind's reset declares
|
||||
`.pagefind-ui--reset mark { all: revert }`, which is the same specificity as a plain
|
||||
descendant rule and is injected after this stylesheet — so it won on order and put the
|
||||
yellow back. One more class is enough; `!important` is not needed and would be a worse
|
||||
way to say the same thing. */
|
||||
.site-search__panel :global(.pagefind-ui--reset mark) {
|
||||
background: color-mix(in srgb, var(--gold) 26%, transparent);
|
||||
color: var(--ink);
|
||||
}
|
||||
</style>
|
||||
21
src/components/Signature.astro
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
import { Code } from '@astrojs/starlight/components';
|
||||
import { runicnpcCalls } from '../data/runicnpc.mjs';
|
||||
|
||||
/**
|
||||
* One RunicNPC call's signature, printed from `src/data/runicnpc.mjs` (PLAN.md §16, D62).
|
||||
*
|
||||
* The API page writes each call's prose by hand and prints its signature through this, so the
|
||||
* signature a reader copies is the one `scripts/checkReference.mjs` holds against
|
||||
* `docs` `main:runicnpc/API.md`. The check also requires exactly one of these per call.
|
||||
*/
|
||||
interface Props {
|
||||
call: keyof typeof runicnpcCalls;
|
||||
}
|
||||
|
||||
const { call } = Astro.props;
|
||||
const sig = runicnpcCalls[call];
|
||||
if (!sig) throw new Error(`Signature: "${call}" is not in runicnpcCalls (src/data/runicnpc.mjs).`);
|
||||
---
|
||||
|
||||
<Code lang="csharp" code={`${sig.returns} ${call}(${sig.params})`} />
|
||||
75
src/components/StructuredData.astro
Normal file
@@ -0,0 +1,75 @@
|
||||
---
|
||||
import platform from '../data/platform.json';
|
||||
import { brand } from '../lib/brand.mjs';
|
||||
|
||||
/**
|
||||
* Structured data for the homepage (D50, phase 10).
|
||||
*
|
||||
* Two blocks and no more. `Organization` so the project's name resolves to an entity with a
|
||||
* mark and a support channel rather than to whichever page happens to rank; and
|
||||
* `SoftwareApplication` because what the site describes is software someone installs, and
|
||||
* the licence and platform are facts a search result can usefully carry.
|
||||
*
|
||||
* ── What this deliberately is not ───────────────────────────────────────────
|
||||
* It carries no ratings, no counts, no price, no `aggregateRating` — the vocabulary is
|
||||
* full of fields that turn a result into an advert, and every one of them here would be
|
||||
* invented. §11's "understated honesty" applies to markup a reader never sees as much as to
|
||||
* the prose, and inventing a rating is the exact thing that gets structured data ignored.
|
||||
*
|
||||
* Breadcrumb and Article markup for the forty documentation pages was considered and
|
||||
* rejected: Starlight already renders breadcrumbs a reader can see, and forty more blocks
|
||||
* would be forty more places for a fact to go stale.
|
||||
*
|
||||
* ── Where the values come from ──────────────────────────────────────────────
|
||||
* Every one is read — `brand.mjs` for text, `platform.json` for the platform's facts —
|
||||
* so `checkFacts.mjs` already guards them and the mount already reaches them. Nothing here
|
||||
* is typed twice. It is a data block, not code: no browser executes it, no CSP hash covers
|
||||
* it, and `applyBrand.mjs` is free to rewrite the name inside it at boot (both scripts know
|
||||
* about `application/ld+json` explicitly, because both would otherwise get it wrong).
|
||||
*/
|
||||
const site = Astro.site!;
|
||||
const url = (p: string) => new URL(p, site).href;
|
||||
|
||||
const organization = {
|
||||
'@type': 'Organization',
|
||||
'@id': url('/#organization'),
|
||||
name: brand.siteName,
|
||||
url: url('/'),
|
||||
logo: url('/brand/icon-512.png'),
|
||||
description: brand.tagline,
|
||||
// The support front door (D10). The Gitea org is where the code is; Discord is where a
|
||||
// person gets an answer, so both are listed and neither is described as the other.
|
||||
sameAs: [brand.giteaOrg, brand.discordInvite].filter(Boolean),
|
||||
};
|
||||
|
||||
const application = {
|
||||
'@type': 'SoftwareApplication',
|
||||
'@id': url('/#software'),
|
||||
name: brand.siteName,
|
||||
url: url('/'),
|
||||
description: brand.tagline,
|
||||
applicationCategory: 'WebApplication',
|
||||
// What an operator actually runs it on: a container on their own host, and an Android
|
||||
// client. Not "Windows" — the installer runs there, the platform does not require it.
|
||||
operatingSystem: 'Linux, Windows, Android',
|
||||
license: 'https://www.gnu.org/licenses/gpl-3.0.html',
|
||||
// D66: the Module API is the one version every deployment shares. Each game has its own
|
||||
// bundle, so no single bundle tag describes the platform any more.
|
||||
softwareVersion: platform.moduleApi,
|
||||
publisher: { '@id': url('/#organization') },
|
||||
// Self-hosted and free, and `offers` is the only way the vocabulary can say so. Omitting
|
||||
// it reads as "price unknown"; stating zero is simply true.
|
||||
offers: {
|
||||
'@type': 'Offer',
|
||||
price: '0',
|
||||
priceCurrency: 'USD',
|
||||
},
|
||||
};
|
||||
|
||||
const graph = {
|
||||
'@context': 'https://schema.org',
|
||||
'@graph': [organization, application],
|
||||
};
|
||||
---
|
||||
|
||||
<script type="application/ld+json" set:html={JSON.stringify(graph)} is:inline />
|
||||
63
src/components/SupportedGames.astro
Normal file
@@ -0,0 +1,63 @@
|
||||
---
|
||||
import { games } from '../data/games/index.mjs';
|
||||
|
||||
/**
|
||||
* "Supported games: Ultima Online · Rust" — PLAN.md §16, D63.
|
||||
*
|
||||
* The one place a core page names the games, and the way it hands a reader on to them. Core
|
||||
* pages describe what a game module adds in core's words (D75); this strip is the link to
|
||||
* the page where each game says it in its own. The names come from `platform.json` through
|
||||
* `games/index.mjs`, so a third game appears here the day it is added there.
|
||||
*/
|
||||
interface Props {
|
||||
/** Extra class for the caller's spacing. */
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { class: className } = Astro.props;
|
||||
---
|
||||
|
||||
<p class:list={['games', className]}>
|
||||
<span class="games__label">Supported games</span>
|
||||
{
|
||||
games.map((game, index) => (
|
||||
<>
|
||||
{index > 0 && <span class="games__sep" aria-hidden="true">·</span>}
|
||||
<a class="games__link" href={game.href}>
|
||||
{game.name}
|
||||
</a>
|
||||
</>
|
||||
))
|
||||
}
|
||||
</p>
|
||||
|
||||
<style>
|
||||
.games {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.35rem 0.65rem;
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.games__label {
|
||||
color: var(--dim);
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.games__label::after {
|
||||
content: ':';
|
||||
}
|
||||
|
||||
.games__sep {
|
||||
color: var(--dim);
|
||||
}
|
||||
|
||||
.games__link {
|
||||
color: var(--gold);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
35
src/components/Ticks.astro
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
/**
|
||||
* A short description from a data file, rendered inline (PLAN.md §16, phase 16).
|
||||
*
|
||||
* Reference data in `src/data/` is plain strings, because a check reads the same file. Three
|
||||
* inline marks are understood, and nothing else: `code`, **bold**, and [a link](/path/). The
|
||||
* text is never parsed as HTML, so a description cannot inject markup.
|
||||
*/
|
||||
interface Props {
|
||||
text: string;
|
||||
}
|
||||
|
||||
const { text } = Astro.props;
|
||||
|
||||
type Token = { kind: 'text' | 'code' | 'strong'; value: string } | { kind: 'link'; value: string; href: string };
|
||||
|
||||
const tokens: Token[] = [];
|
||||
const pattern = /`([^`]+)`|\*\*([^*]+)\*\*|\[([^\]]+)\]\(([^)\s]+)\)/g;
|
||||
let last = 0;
|
||||
for (const m of String(text ?? '').matchAll(pattern)) {
|
||||
if (m.index > last) tokens.push({ kind: 'text', value: text.slice(last, m.index) });
|
||||
if (m[1] !== undefined) tokens.push({ kind: 'code', value: m[1] });
|
||||
else if (m[2] !== undefined) tokens.push({ kind: 'strong', value: m[2] });
|
||||
else tokens.push({ kind: 'link', value: m[3], href: m[4] });
|
||||
last = m.index + m[0].length;
|
||||
}
|
||||
if (last < text.length) tokens.push({ kind: 'text', value: text.slice(last) });
|
||||
---
|
||||
|
||||
{tokens.map((t) =>
|
||||
t.kind === 'code' ? <code>{t.value}</code>
|
||||
: t.kind === 'strong' ? <strong>{t.value}</strong>
|
||||
: t.kind === 'link' ? <a href={t.href}>{t.value}</a>
|
||||
: t.value
|
||||
)}
|
||||
@@ -1,61 +1,37 @@
|
||||
---
|
||||
import { screensOf, PHONE } from '../../data/screens.mjs';
|
||||
import platform from '../../data/platform.json';
|
||||
|
||||
/**
|
||||
* The app's screenshot strip — defined now, empty until phase 9 (D26).
|
||||
* The app's screenshot strip. Reserved in phase 5 (D26), filled in phase 9.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY A COMPONENT THAT RENDERS NOTHING IS WORTH COMMITTING
|
||||
* WHY THIS COMPONENT EXISTED FOR A PHASE WITH NOTHING IN IT
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* PLAN.md §10 says `/app/` shows "the 14 existing screenshots". They exist —
|
||||
* `docs/android/screenshots/` on the `docs` repository — and they are the wrong fourteen:
|
||||
* a trusted-device and recovery-code smoke test from 2026-07-22, captured against a
|
||||
* PLAN.md §10 said `/app/` shows "the 14 existing screenshots". They exist —
|
||||
* `docs/android/screenshots/` on the `docs` repository — and they are the wrong fourteen: a
|
||||
* trusted-device and recovery-code smoke test from 2026-07-22, captured against a
|
||||
* development instance with no seeded content, before the theming work that changed how
|
||||
* every screen looks. Five of them are two-factor prompts. The home shot is an empty page.
|
||||
*
|
||||
* Shipping them would break two things at once: D4, which says real screenshots from the
|
||||
* review stack rather than placeholders, and §1, because they would show an app that no
|
||||
* longer looks like that. D26 records the decision — the slot is reserved, phase 9 fills
|
||||
* it, and phase 9 is already the phase that stands up the review stack and seeds the
|
||||
* content the web screenshots need. Adding an emulator pass to a rig that is being built
|
||||
* anyway is most of the work already done, and it has the property that the phone shots
|
||||
* and the browser shots then show the same deployment on the same day.
|
||||
*
|
||||
* The component exists rather than the page carrying a `TODO` because a defined shape is
|
||||
* what makes phase 9 a data change instead of a design task: fill `shots`, and the section
|
||||
* appears with a heading, a caption line and a grid. Nothing else has to be decided then.
|
||||
* Shipping them would have broken D4 and §1 at once, so D26 reserved the slot for the phase
|
||||
* that stands up the review stack anyway. The shape was defined then and the data arrived
|
||||
* now, which is exactly what it was for: filling it was a data change.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHAT PHASE 9 SHOULD PUT HERE
|
||||
* WHY IT READS screens.mjs RATHER THAN HOLDING ITS OWN LIST
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* Portrait captures at the device's own pixel size, from an API 36 emulator pointed at the
|
||||
* seeded review stack, one per idea rather than one per screen: the shard hub with live
|
||||
* data, the marketplace, a character sheet, the news list, the notification settings, and
|
||||
* the drawer showing a deployment's own navigation. Six is plenty. Fourteen was never a
|
||||
* target — it was the number that happened to exist.
|
||||
* The draft carried its own `shots` array, written before there was anywhere else to put
|
||||
* one. There is now: `src/data/screens.mjs` holds every capture the site ships, web and
|
||||
* phone alike, and `scripts/checkScreens.mjs` proves each one exists at the size the markup
|
||||
* claims. A second list here would be the one nothing checks.
|
||||
*
|
||||
* They belong in `public/`, not `brand-default/`: these are editorial content shipped with
|
||||
* the image, not branding an operator overrides (§7).
|
||||
* The phone captures come from an emulator pointed at the same seeded deployment the web
|
||||
* screenshots were taken from, on the same day — which is the property D26 was really
|
||||
* after, since the app takes its colours, type and navigation from the site it connects to.
|
||||
*/
|
||||
|
||||
/**
|
||||
* One capture. `width` and `height` are the real pixel dimensions and are required rather
|
||||
* than optional: without them the page reflows as each image decodes, and a strip of six
|
||||
* phone screenshots is the worst possible place for that.
|
||||
*
|
||||
* Frontmatter is TypeScript, so this is an interface rather than the JSDoc typedef the
|
||||
* `.mjs` data files use — and it has to be typed explicitly, because an empty array
|
||||
* annotated by inference is `any[]` and `astro check` is right to refuse it.
|
||||
*/
|
||||
interface Shot {
|
||||
/** Site-absolute path under `/screens/`. */
|
||||
src: string;
|
||||
/** What the screen shows, for somebody who cannot see it. */
|
||||
alt: string;
|
||||
caption: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
const shots: Shot[] = [];
|
||||
const shots = screensOf('phone');
|
||||
---
|
||||
|
||||
{
|
||||
@@ -63,23 +39,27 @@ const shots: Shot[] = [];
|
||||
<section class="page section shots">
|
||||
<h2>What it looks like</h2>
|
||||
<p class="prose shots__lede">
|
||||
Captured against a real deployment with real content, not mocked up. The app takes
|
||||
its colours, type and navigation from the site it is connected to, so these show one
|
||||
community's app rather than a neutral one.
|
||||
Captured from the app against two demo deployments: one with a real Ultima Online
|
||||
server behind it, and one with a real Rust server whose players are a demo cast. The
|
||||
app takes its colours, type and navigation from the site it is connected to, so these
|
||||
show one community's app rather than a neutral one.
|
||||
</p>
|
||||
|
||||
<ul class="shots__grid">
|
||||
{shots.map((shot) => (
|
||||
<li class="shots__item">
|
||||
<li class="shots__item" data-game={shot.game}>
|
||||
<img
|
||||
src={shot.src}
|
||||
src={`/screens/${shot.id}.webp`}
|
||||
alt={shot.alt}
|
||||
width={shot.width}
|
||||
height={shot.height}
|
||||
width={PHONE.width}
|
||||
height={PHONE.height}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<p class="shots__caption">{shot.caption}</p>
|
||||
<p class="shots__caption">
|
||||
{shot.game && <span class="shots__game">{platform.games[shot.game].name}</span>}
|
||||
{shot.caption}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -116,6 +96,19 @@ const shots: Shot[] = [];
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
/* The game's name on a game's screen (D67), as Screenshot.astro prints it. */
|
||||
.shots__game {
|
||||
display: inline-block;
|
||||
margin-right: 0.5rem;
|
||||
padding: 0.05rem 0.55rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-pill);
|
||||
color: var(--muted);
|
||||
font-size: 0.74rem;
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.shots__caption {
|
||||
margin: 0.85rem 0 0;
|
||||
color: var(--dim);
|
||||
|
||||
@@ -8,9 +8,11 @@ import platform from '../../data/platform.json';
|
||||
* The other two draw runtime shapes. This one draws a code boundary, and it is here because
|
||||
* it is the claim the whole project rests on: that a community platform can be built once
|
||||
* and pointed at any game. An evaluator has every reason to read that as marketing, so the
|
||||
* page draws the seam and then says plainly what does and does not prove it — one module
|
||||
* exists, the second is a paper exercise, and the exit criterion for calling the contract
|
||||
* proven is written down (§2, and the entries `/modules/` renders from `notBuilt.mjs`).
|
||||
* page draws the seam and then says plainly what does and does not prove it — two modules
|
||||
* exist, both built inside this project, and the exit criterion for calling the contract
|
||||
* proven by an outsider is written down (§2, §16, and the entries `/modules/` renders from
|
||||
* `notBuilt.mjs`). Until phase 13 this said one module and a paper exercise; Module-Rust's
|
||||
* release made that false.
|
||||
*
|
||||
* The Module API version is read from `platform.json` like every other number on this site
|
||||
* (§12). It is the one place a version genuinely belongs in this diagram: the seam is
|
||||
@@ -24,8 +26,8 @@ import platform from '../../data/platform.json';
|
||||
<p class="eyebrow">Where the game stops</p>
|
||||
<h2>A seam, with a version on it</h2>
|
||||
<p class="prose">
|
||||
The core site does not know what a shard is, what a guild is, or that Ultima Online
|
||||
exists. Everything that does lives in an installable module on the other side of a
|
||||
The core site does not know what game it serves, what that game calls a group of
|
||||
players, or that Ultima Online and Rust exist. Everything that does lives in an installable module on the other side of a
|
||||
declared interface — which is what makes "put your game on it" a shape rather than a
|
||||
slogan.
|
||||
</p>
|
||||
@@ -73,7 +75,7 @@ import platform from '../../data/platform.json';
|
||||
<!-- The module: everything that knows a game exists. -->
|
||||
<rect class="host" x="8" y="306" width="364" height="186" rx="14" />
|
||||
<text class="host-title" x="28" y="340">Game module</text>
|
||||
<text class="host-sub" x="28" y="360">one per deployment; UO today</text>
|
||||
<text class="host-sub" x="28" y="360">one per deployment; UO or Rust today</text>
|
||||
|
||||
<rect class="node" x="28" y="378" width="156" height="46" rx="10" />
|
||||
<text class="node-title" x="46" y="406">Routes</text>
|
||||
@@ -121,8 +123,8 @@ import platform from '../../data/platform.json';
|
||||
<p>
|
||||
Core owns the Teams primitive — the roster, the forum, the notifications, the voice
|
||||
channel — and does not own the <em>word</em>. A Team cannot be created in core at
|
||||
all; it arrives from the module, which is why the UO module calls them guilds and
|
||||
builds those pages itself. That is the pattern the whole interface is built on: core
|
||||
all; it arrives from the module, which is why each module calls them by its own
|
||||
game’s word and builds those pages itself. That is the pattern the whole interface is built on: core
|
||||
supplies the machinery, the module supplies the meaning.
|
||||
</p>
|
||||
</section>
|
||||
@@ -130,11 +132,12 @@ import platform from '../../data/platform.json';
|
||||
<section>
|
||||
<h3>What this does not yet prove</h3>
|
||||
<p>
|
||||
One module exists and it is Ultima Online. A second, for a different game, is a
|
||||
written dry-run that was deliberately never implemented — it exists to test whether
|
||||
the contract generalises on paper. Until somebody builds the second one, the seam is
|
||||
a well-argued design rather than a demonstrated one, and this site says so wherever
|
||||
it comes up.
|
||||
Two modules exist: Ultima Online, and Rust, which this project built second, from
|
||||
the Integration Kit. Where the Rust module needed something the contract did not
|
||||
yet offer, the gap was closed in core, for every module, rather than in a fork — so
|
||||
the seam now has two games on its far side instead of one. What two modules built
|
||||
inside this project cannot prove is that somebody outside it can do the same, and
|
||||
that is why the Integration Kit still says draft.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
* doing capacity planning, and "how many machines is this" is the first question they have.
|
||||
*
|
||||
* Drawn generically for the same reason the homepage's diagram is (D17) — "your game host",
|
||||
* not "your ServUO box" — with the prose beside it naming the real components. The boundary
|
||||
* not a particular game's server — with the prose beside it linking to each game's page,
|
||||
* which names its real components (D75). Until phase 17 the caption named one game's and
|
||||
* said two machines was also the maximum, which stopped being true when Rust shipped: one
|
||||
* sidecar serves one server, so six servers means six sidecars. The boundary
|
||||
* is the one drawn argument: everything above it is reachable because you published it, and
|
||||
* everything below it is not reachable at all.
|
||||
*
|
||||
@@ -73,8 +76,10 @@
|
||||
</svg>
|
||||
|
||||
<p class="diagram__caption">
|
||||
Today the game server is a ServUO shard and the sidecar is uo-link. Two machines is
|
||||
the minimum and also the maximum — nothing here scales by adding a third.
|
||||
The sidecar and the bridge plugin are each game’s own — see
|
||||
<a href="/modules/uo/">Ultima Online</a> and <a href="/modules/rust/">Rust</a>. Two
|
||||
machines is the minimum. A community running several game servers runs a sidecar
|
||||
beside each one, on whichever host that server already lives on.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -92,10 +97,10 @@
|
||||
<section>
|
||||
<h3>The game host</h3>
|
||||
<p>
|
||||
The machine your game server is already on. One installer binary puts the plugin
|
||||
into the server's tree, installs the sidecar beside it and registers the service —
|
||||
then prints four values. It never contacts your website; you paste those four
|
||||
values into the admin panel yourself, and that is the moment the two halves meet.
|
||||
The machine your game server is already on. One installer binary puts the bridge
|
||||
plugin into the server's tree, installs the sidecar beside it and registers the
|
||||
service — then prints what the site needs. It never contacts your website; you enter
|
||||
those values in the admin panel yourself, and that is the moment the two halves meet.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
import platform from '../../data/platform.json';
|
||||
import { capabilityGroups, assertCapabilityCoverage } from '../../data/capabilities.mjs';
|
||||
import { capabilityGroups } from '../../data/capabilities.mjs';
|
||||
import { games, assertGameCoverage } from '../../data/games/index.mjs';
|
||||
import SupportedGames from '../SupportedGames.astro';
|
||||
|
||||
/**
|
||||
* The grouped capabilities (PLAN.md §10). All five groups, named only — the argument for
|
||||
@@ -8,8 +9,10 @@ import { capabilityGroups, assertCapabilityCoverage } from '../../data/capabilit
|
||||
* copy to keep true.
|
||||
*
|
||||
* The call below is the point of the exercise: it throws, and therefore fails the build, if
|
||||
* the "Game intelligence" list and the module's own declared capabilities have drifted
|
||||
* apart. `checkFacts.mjs` already keeps `platform.json` honest against the module manifest;
|
||||
* either game's own feature list (`src/data/games/`) and its module's declared capabilities
|
||||
* have drifted apart. The homepage names neither list — its module-supplied group is the
|
||||
* neutral one, in core's words (D75) — but it is the page a reader meets first, so it is
|
||||
* the page that should refuse to build when a game page is out of date. `checkFacts.mjs` already keeps `platform.json` honest against the module manifest;
|
||||
* this makes the page honest against `platform.json`, which is the half that was missing.
|
||||
*
|
||||
* The "not built" line at the bottom is not a disclaimer bolted on — §2's absent-features
|
||||
@@ -17,17 +20,18 @@ import { capabilityGroups, assertCapabilityCoverage } from '../../data/capabilit
|
||||
* what exists while quietly omitting the well-known things that do not is the exact failure
|
||||
* §1 is written to prevent.
|
||||
*/
|
||||
assertCapabilityCoverage(platform.moduleUoCapabilities);
|
||||
for (const game of games) assertGameCoverage(game.key);
|
||||
---
|
||||
|
||||
<section class="page section caps">
|
||||
<p class="eyebrow">What it does</p>
|
||||
<h2>A community site, and a window into the game</h2>
|
||||
<p class="prose caps__lede">
|
||||
The core is game-agnostic: it does not know what a shard is. Everything that does arrives
|
||||
as an installable <a href="/modules/">module</a>, which is why the same platform can carry
|
||||
a different game without a fork.
|
||||
The core is game-agnostic: it does not know what game you run. Everything that does
|
||||
arrives as an installable <a href="/modules/">module</a>, which is why the same platform
|
||||
carries two games without a fork.
|
||||
</p>
|
||||
<SupportedGames class="caps__games" />
|
||||
|
||||
<div class="caps__grid">
|
||||
{
|
||||
@@ -52,7 +56,7 @@ assertCapabilityCoverage(platform.moduleUoCapabilities);
|
||||
|
||||
<p class="caps__foot prose">
|
||||
Some things people reasonably expect are <strong>deliberately not built</strong> — a Matrix
|
||||
integration, more than one game module active at once, a second game module. They are
|
||||
integration, more than one game module active at once, a finished Integration Kit. They are
|
||||
listed rather than left out, on <a href="/features/">features</a> and{' '}
|
||||
<a href="/integrations/">integrations</a>.
|
||||
</p>
|
||||
@@ -69,6 +73,10 @@ assertCapabilityCoverage(platform.moduleUoCapabilities);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.caps__games {
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.caps__grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
---
|
||||
import platform from '../../data/platform.json';
|
||||
|
||||
/**
|
||||
* The data path (PLAN.md §13 phase 3), drawn as inline SVG per §11's motif rule — hand-drawn
|
||||
* geometry, used where it explains something, and no raster anywhere.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* THE LABELS ARE GENERIC, WITH UO AS THE CAPTION
|
||||
* THE LABELS ARE GENERIC; THE CAPTION NAMES THE GAMES
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* The org lead settled this before the diagram was drawn. The nodes say "your game server"
|
||||
* and "sidecar", not "ServUO shard" and "uo-link", because §10's rule is that a reader
|
||||
@@ -14,10 +12,12 @@ import platform from '../../data/platform.json';
|
||||
* repositories in order to connect a game server — and because the tagline promises a
|
||||
* platform, not a UO product.
|
||||
*
|
||||
* It does NOT hide what actually ships. The sub-labels and the caption name ServUO and
|
||||
* uo-link outright, because §1 says the technical truth wins and today there is exactly one
|
||||
* implementation of this shape. An operator running a shard has to see themselves in the
|
||||
* picture on the first screen.
|
||||
* It does NOT hide what actually ships: the caption names the games that ship on this shape,
|
||||
* because §1 says the technical truth wins and an operator has to see their game in the
|
||||
* picture on the first screen. Until phase 13 the sub-labels named ServUO and uo-link, which
|
||||
* was true while there was exactly one implementation. With two, naming one would mislead,
|
||||
* and D63 (PLAN.md §16) keeps each game's vocabulary on that game's own pages — so the
|
||||
* caption names the games and the diagram names nothing else.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY THE SVG IS aria-hidden
|
||||
@@ -74,11 +74,11 @@ import platform from '../../data/platform.json';
|
||||
|
||||
<rect class="node" x="20" y="16" width="340" height="76" rx="12" />
|
||||
<text class="node-title" x="42" y="50">Your game server</text>
|
||||
<text class="node-sub" x="42" y="72">ServUO today · opens no inbound port</text>
|
||||
<text class="node-sub" x="42" y="72">opens no inbound port</text>
|
||||
|
||||
<rect class="node" x="20" y="148" width="340" height="76" rx="12" />
|
||||
<text class="node-title" x="42" y="182">Sidecar</text>
|
||||
<text class="node-sub" x="42" y="204">uo-link · the only network-facing part</text>
|
||||
<text class="node-sub" x="42" y="204">the only network-facing part</text>
|
||||
|
||||
<rect class="node node--self" x="20" y="280" width="340" height="76" rx="12" />
|
||||
<text class="node-title" x="42" y="314">Runic Gateway</text>
|
||||
@@ -90,8 +90,9 @@ import platform from '../../data/platform.json';
|
||||
</svg>
|
||||
|
||||
<p class="datapath__caption">
|
||||
Today that game server is a ServUO shard and that sidecar is uo-link. The shape is the
|
||||
contract; the implementations are what plug into it.
|
||||
Two games ship on this shape today, Ultima Online and Rust, each with its own plugin
|
||||
and its own sidecar. The shape is the contract; the implementations are what plug
|
||||
into it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -109,8 +110,8 @@ import platform from '../../data/platform.json';
|
||||
<h3>The sidecar</h3>
|
||||
<p>
|
||||
A small service beside the game, and the only piece of the bridge anything else can
|
||||
reach. It speaks a versioned wire protocol — protocol {platform.protocol} today — so
|
||||
a mismatched pair is refused rather than misread, and it answers only your website's
|
||||
reach. It speaks a versioned wire protocol — each game pins its own — so a
|
||||
mismatched pair is refused rather than misread, and it answers only your website's
|
||||
backend, over an authenticated WebSocket and REST.
|
||||
</p>
|
||||
</li>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { brand } from '../../lib/brand.mjs';
|
||||
/**
|
||||
* The get-started CTA (PLAN.md §10, the `/` row), built around the trap in §10's
|
||||
* "installation path": a "Runic Gateway install" is two independent installs. The installer
|
||||
* binary sets up the shard side only and never contacts the website; the website is a
|
||||
* binary sets up the game side only and never contacts the website; the website is a
|
||||
* separate Docker deployment.
|
||||
*
|
||||
* That belongs on the homepage rather than being saved for the docs. It is the single
|
||||
@@ -13,7 +13,9 @@ import { brand } from '../../lib/brand.mjs';
|
||||
* project. Saying it here is what makes the docs a confirmation rather than a surprise.
|
||||
*
|
||||
* The two halves are ordered site-first because that is the order they must be done in: the
|
||||
* shard side ends by pasting four values into the site's admin panel, which has to exist.
|
||||
* game side ends by entering what the installer printed into the site's admin panel, which
|
||||
* has to exist. Which screen that is belongs to the game module, so this says "the game
|
||||
* module's screen" rather than naming one game's menu (D63).
|
||||
*
|
||||
* Both "read the docs" links point at `/docs/` rather than at a page inside the journey.
|
||||
* Phases 7 and 8 write those pages and own their slugs; guessing one now would put a URL in
|
||||
@@ -44,9 +46,10 @@ import { brand } from '../../lib/brand.mjs';
|
||||
<div class="start__half">
|
||||
<h3><span class="start__num">2</span> The game side</h3>
|
||||
<p>
|
||||
One binary, run on the machine the game server already lives on. It syncs the plugin,
|
||||
installs the sidecar as a service, and prints four values. You paste those into
|
||||
Admin → Shard, and the two halves find each other.
|
||||
One binary, run on the machine the game server already lives on — or, for Rust, a
|
||||
Pterodactyl egg. It puts the bridge plugin in place, installs the sidecar, and prints
|
||||
what the site needs. You enter that on the game module’s screen in the admin panel,
|
||||
and the two halves find each other.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -55,9 +55,9 @@ import platform from '../../data/platform.json';
|
||||
</div>
|
||||
|
||||
<div class="chips">
|
||||
<span class="chip chip--version">Protocol {platform.protocol}</span>
|
||||
{/* D66: only the version every deployment shares. A protocol and a bundle belong
|
||||
to a game, so they live on that game's pages rather than on the front door. */}
|
||||
<span class="chip chip--version">Module API {platform.moduleApi}</span>
|
||||
<span class="chip chip--version">Bundle {platform.bundle.tag}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
63
src/components/home/WhatItLooksLike.astro
Normal file
@@ -0,0 +1,63 @@
|
||||
---
|
||||
import Screenshot from '../Screenshot.astro';
|
||||
|
||||
/**
|
||||
* The homepage's screenshots, one per game. PLAN.md §13 phase 9, D4 / D44; §16 phase 18.
|
||||
*
|
||||
* -----------------------------------------------------------------------------------------
|
||||
* WHY ONE PER GAME, AND WHY THESE
|
||||
* -----------------------------------------------------------------------------------------
|
||||
* `DataPath` above it draws the claim — a private game server, a sidecar, a public site —
|
||||
* and a diagram of a data path is a promise that the data arrives. This is the page where
|
||||
* it arrives, captured from a deployment wired to a running game server, so the section
|
||||
* directly under the diagram is the diagram's evidence.
|
||||
*
|
||||
* It is one game's screen, and says so (D76, taken in phase 17 under D67): the screenshot is
|
||||
* tagged `uo` in `screens.mjs`, so it prints "Ultima Online" beside it and may keep that
|
||||
* game's words in its alt and caption. A core screen was the alternative and was declined:
|
||||
* the claim here is live game state on a public page, which no core screen shows. Phase 18
|
||||
* added a Rust capture beside it, labelled the same way: the live map, because every pixel
|
||||
* of it is the running server's — the Rust demo's players are a demo cast (D81), and the
|
||||
* map shows none of them.
|
||||
*
|
||||
* A gallery here would compete with `Capabilities` further down, which is the part of the
|
||||
* homepage that enumerates. So: one figure per game, its signature screen, and the rest of
|
||||
* the set on each game's page under `/modules/`, where each sits beside the claim it supports.
|
||||
*/
|
||||
---
|
||||
|
||||
<section class="page section looks">
|
||||
<p class="eyebrow">What it looks like</p>
|
||||
<h2>The other end of that diagram</h2>
|
||||
<p class="prose looks__lede">
|
||||
Two demo deployments, each with a real game server behind it. On the first, the gold
|
||||
supply, the state of the link and the player online are all read from an Ultima Online
|
||||
server over the bridge. On the second, the map is the running Rust server's own: its
|
||||
island, its monuments, and the events on it as they happen.
|
||||
</p>
|
||||
|
||||
<div class="looks__pair">
|
||||
<Screenshot id="shard-status" />
|
||||
<Screenshot id="rust-map" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.looks h2 {
|
||||
margin: 0.35rem 0 0.75rem;
|
||||
font-size: clamp(1.6rem, 3.2vw, 2.1rem);
|
||||
}
|
||||
|
||||
.looks__lede {
|
||||
margin: 0;
|
||||
max-width: 46rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Side by side where there is room for two screenshots to stay readable, stacked below. */
|
||||
.looks__pair {
|
||||
display: grid;
|
||||
gap: 0 1.5rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 30rem), 1fr));
|
||||
}
|
||||
</style>
|
||||
41
src/config/cspHashes.mjs
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* cspHashes.mjs — GENERATED. Do not edit by hand.
|
||||
*
|
||||
* Regenerate with `npm run csp:hashes` (which builds, harvests and rebuilds).
|
||||
* `npm run check:csp` fails if this file no longer covers what the build emits.
|
||||
*
|
||||
* ── Why this file exists ────────────────────────────────────────────────────
|
||||
* Astro's `security.csp` (D48) hashes the scripts and styles it processes itself. It does
|
||||
* not hash `<script is:inline>` — by design, because an inline script is the author's own
|
||||
* text and Astro never parses it. Starlight ships six of them on every documentation page:
|
||||
* the theme provider, the theme-picker sync, the mobile menu, the sidebar scroll restore.
|
||||
*
|
||||
* That combination fails in the worst way available. The build succeeds, the header is
|
||||
* strict and correct, every page renders — and the theme switch, the mobile sidebar and
|
||||
* the sidebar's scroll position are dead, with the explanation only in a browser console
|
||||
* nobody opens. `'unsafe-inline'` would fix all six and give up the single directive CSP
|
||||
* exists to enforce, so instead the hashes are enumerated here and checked.
|
||||
*
|
||||
* These are Starlight's, not ours: a Starlight upgrade that edits one byte of one of those
|
||||
* scripts invalidates a hash. `check:csp` is what turns that from a silent breakage into a
|
||||
* red build, and regenerating this file is the acknowledgement that the upgrade was read.
|
||||
*/
|
||||
|
||||
/**
|
||||
* SHA-256 hashes of inline `<script>` bodies Astro does not hash for us.
|
||||
*
|
||||
* The template-literal type is not decoration: Astro types this option as `CspHashEntry[]`,
|
||||
* so a plain `string[]` fails `astro check`.
|
||||
*
|
||||
* @type {`sha256-${string}`[]}
|
||||
*/
|
||||
export const inlineScriptHashes = [
|
||||
'sha256-7eCV4jtsr4t4knb3c4FCRPeu7GGZeOUGE3XvWix0XOQ=',
|
||||
'sha256-GkZBRnvSuhtx/cvzvukVkX2JJZW+DdPlVr7BX8Tefqo=',
|
||||
'sha256-VWo5Wp4aqSj6nSgMpeAp9cKieaoIfwFUAunAVugI5gA=',
|
||||
'sha256-f/zAUE74ucc3JYp4r4QQvkJofoQdkOIhHYK+jeZ6eko=',
|
||||
'sha256-wX2yOADeV+NMngflD5uYi3vl50SHC4sfM1EmylVjlX4=',
|
||||
];
|
||||
|
||||
/** @type {`sha256-${string}`[]} SHA-256 hashes of inline `<style>` bodies Astro does not hash. */
|
||||
export const inlineStyleHashes = [];
|
||||
22
src/config/redirects.mjs
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Docs pages that moved, and where they went — PLAN.md §16, D60.
|
||||
*
|
||||
* Every page that moves keeps its old URL working, because the old URL is in somebody's
|
||||
* bookmarks, in an installer's output and in search results. With the Node adapter these are
|
||||
* not files in `dist/client`: the server answers each one with a real `301`.
|
||||
*
|
||||
* Its own file, rather than inline in `astro.config.mjs`, so that `test/headers.test.mjs` can
|
||||
* read the same list and assert every entry against the running server — the config cannot be
|
||||
* imported by plain Node, because Starlight ships TypeScript.
|
||||
*
|
||||
* Phase 14 moved the Ultima Online pages into their own group. `connect-a-game-server` did
|
||||
* NOT move: its URL is now the game-neutral hand-off page, which is a better landing for an
|
||||
* old link than a redirect into one game would be.
|
||||
*/
|
||||
export const docsRedirects = {
|
||||
'/docs/administration/the-shard-connection': '/docs/ultima-online/the-shard-connection/',
|
||||
'/docs/administration/client-files': '/docs/ultima-online/client-files/',
|
||||
'/docs/reference/bridge-cfg': '/docs/ultima-online/bridge-cfg/',
|
||||
'/docs/reference/sidecar-toml': '/docs/ultima-online/sidecar-toml/',
|
||||
'/docs/reference/event-catalog': '/docs/ultima-online/event-catalog/',
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* The documentation sidebar — PLAN.md §10 "Documentation", five groups, ~37 pages.
|
||||
* The documentation sidebar — PLAN.md §10 "Documentation": five core groups, then one group
|
||||
* per game after them (§16, D60).
|
||||
*
|
||||
* The pages themselves land in phases 7 and 8. This file exists in phase 1 because the
|
||||
* shape of the journey is a design decision that is already made, and because a sidebar
|
||||
@@ -22,7 +23,7 @@ export const docsSidebar = [
|
||||
{ label: 'Install the site', slug: 'docs/getting-started/install-the-site' },
|
||||
{ label: 'First run', slug: 'docs/getting-started/first-run' },
|
||||
{ label: 'Install a game module', slug: 'docs/getting-started/install-a-game-module' },
|
||||
{ label: 'Connect a game server', slug: 'docs/getting-started/connect-a-game-server' },
|
||||
{ label: 'Connect your game server', slug: 'docs/getting-started/connect-a-game-server' },
|
||||
{ label: 'Verify the whole stack', slug: 'docs/getting-started/verify-the-whole-stack' },
|
||||
],
|
||||
},
|
||||
@@ -36,20 +37,114 @@ export const docsSidebar = [
|
||||
{ label: 'Users and roles', slug: 'docs/administration/users-and-roles' },
|
||||
{ label: 'Authentication', slug: 'docs/administration/authentication' },
|
||||
{ label: 'Teams', slug: 'docs/administration/teams' },
|
||||
{ label: 'Scheduled events', slug: 'docs/administration/events' },
|
||||
{ label: 'Moderation', slug: 'docs/administration/moderation' },
|
||||
{ label: 'Notifications and email', slug: 'docs/administration/notifications-and-email' },
|
||||
{ label: 'Engagement rules', slug: 'docs/administration/engagement-rules' },
|
||||
{ label: 'Message templates', slug: 'docs/administration/message-templates' },
|
||||
{ label: 'Managing modules', slug: 'docs/administration/managing-modules' },
|
||||
{ label: 'The shard connection', slug: 'docs/administration/the-shard-connection' },
|
||||
{ label: 'Maintenance and upgrades', slug: 'docs/administration/maintenance-and-upgrades' },
|
||||
{ label: 'Troubleshooting', slug: 'docs/administration/troubleshooting' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Modules',
|
||||
items: [
|
||||
{ label: 'The module system', slug: 'docs/modules/the-module-system' },
|
||||
{ label: 'Installing modules', slug: 'docs/modules/installing-modules' },
|
||||
{ label: 'Module lifecycle', slug: 'docs/modules/module-lifecycle' },
|
||||
{ label: 'The module manifest', slug: 'docs/modules/the-module-manifest' },
|
||||
{ label: 'The module API', slug: 'docs/modules/the-module-api' },
|
||||
{ label: 'Building a module', slug: 'docs/modules/building-a-module' },
|
||||
{ label: 'The Integration Kit', slug: 'docs/modules/the-integration-kit' },
|
||||
{ label: 'Testing and release', slug: 'docs/modules/testing-and-release' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Architecture',
|
||||
items: [
|
||||
{ label: 'System architecture', slug: 'docs/architecture/system-architecture' },
|
||||
{ label: 'The bridge', slug: 'docs/architecture/the-bridge' },
|
||||
{ label: 'Authentication architecture', slug: 'docs/architecture/authentication-architecture' },
|
||||
{ label: 'Teams architecture', slug: 'docs/architecture/teams-architecture' },
|
||||
{ label: 'Events architecture', slug: 'docs/architecture/events-architecture' },
|
||||
{ label: 'Protocol versions', slug: 'docs/architecture/protocol-versions' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Reference',
|
||||
items: [
|
||||
{ label: 'Environment variables', slug: 'docs/reference/environment-variables' },
|
||||
{ label: 'Installer CLI', slug: 'docs/reference/installer-cli' },
|
||||
{ label: 'HTTP API', slug: 'docs/reference/http-api' },
|
||||
{ label: 'Canonical documents', slug: 'docs/reference/canonical-documents' },
|
||||
],
|
||||
},
|
||||
// One group per game, after Reference (PLAN.md §16, D60). Everything above this line is
|
||||
// core and is held to core's words by checkTerms.mjs (D63, D64). A group whose label is a
|
||||
// game's `name` in platform.json is that game's group, and may use that game's words — so
|
||||
// the label is load-bearing: renaming it here without renaming the game turns it core.
|
||||
{
|
||||
label: 'Ultima Online',
|
||||
items: [
|
||||
{ label: 'The Ultima Online module', slug: 'docs/ultima-online' },
|
||||
{ label: 'Connect a ServUO shard', slug: 'docs/ultima-online/connect-a-servuo-shard' },
|
||||
{ label: 'Verify the bridge', slug: 'docs/ultima-online/verify-the-bridge' },
|
||||
{ label: 'The shard connection', slug: 'docs/ultima-online/the-shard-connection' },
|
||||
{ label: 'Client files', slug: 'docs/ultima-online/client-files' },
|
||||
{ label: 'Troubleshooting', slug: 'docs/ultima-online/troubleshooting' },
|
||||
{ label: 'Bridge.cfg', slug: 'docs/ultima-online/bridge-cfg' },
|
||||
{ label: 'sidecar.toml', slug: 'docs/ultima-online/sidecar-toml' },
|
||||
{ label: 'Shard event catalog', slug: 'docs/ultima-online/event-catalog' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Rust',
|
||||
items: [
|
||||
{ label: 'The Rust module', slug: 'docs/rust' },
|
||||
{ label: 'Install the bridge', slug: 'docs/rust/install-the-bridge' },
|
||||
{ label: 'Connect the site', slug: 'docs/rust/connect-the-site' },
|
||||
{ label: 'Verify the bridge', slug: 'docs/rust/verify-the-bridge' },
|
||||
{ label: 'Linking Steam accounts', slug: 'docs/rust/linking-steam-accounts' },
|
||||
{ label: 'Permissions and groups', slug: 'docs/rust/permissions-and-groups' },
|
||||
{ label: 'Mod config from the site', slug: 'docs/rust/mod-config' },
|
||||
{ label: 'Who sees who is online', slug: 'docs/rust/who-sees-who-is-online' },
|
||||
{ label: 'Events, zones and rewards', slug: 'docs/rust/events-zones-and-rewards' },
|
||||
{ label: 'Optional plugins', slug: 'docs/rust/optional-plugins' },
|
||||
{ label: 'The live map', slug: 'docs/rust/the-live-map' },
|
||||
{ label: 'Troubleshooting', slug: 'docs/rust/troubleshooting' },
|
||||
{ label: 'sidecar.toml', slug: 'docs/rust/sidecar-toml' },
|
||||
{ label: 'RunicGateway.json', slug: 'docs/rust/runicgateway-json' },
|
||||
{ label: 'Event kinds', slug: 'docs/rust/event-kinds' },
|
||||
// §16, D61/D72: RunicNPC is a subgroup of Rust, nested so it reads as part of the game.
|
||||
{
|
||||
label: 'RunicNPC',
|
||||
items: [
|
||||
{ label: 'RunicNPC', slug: 'docs/rust/runicnpc' },
|
||||
{ label: 'Install RunicNPC', slug: 'docs/rust/runicnpc/install' },
|
||||
{ label: 'Commands', slug: 'docs/rust/runicnpc/commands' },
|
||||
{ label: 'NPC profiles', slug: 'docs/rust/runicnpc/npc-profiles' },
|
||||
{ label: 'NPC placements', slug: 'docs/rust/runicnpc/npc-placements' },
|
||||
{ label: 'Cost and caps', slug: 'docs/rust/runicnpc/cost-and-caps' },
|
||||
{ label: 'Profile fields', slug: 'docs/rust/runicnpc/profile-fields' },
|
||||
{ label: 'The API', slug: 'docs/rust/runicnpc/api' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* The full planned tree, kept next to the live sidebar so phases 7 and 8 have their
|
||||
* checklist in the place they will be working. Not exported into the Starlight config —
|
||||
* it names pages that do not exist yet.
|
||||
* The tree §10 planned, kept as the record of what was intended — every page it names now
|
||||
* exists, as of phase 8.
|
||||
*
|
||||
* It was the phases 7/8 checklist, and a checklist with nothing left on it is no longer
|
||||
* pulling its weight: it is a second copy of the tree above, maintained by hand, and it had
|
||||
* already drifted once (phase 7 added `Content` under D37 and this list was not updated,
|
||||
* which nothing caught because nothing reads it). `checkSidebar.mjs` now asserts the two
|
||||
* agree, which is what makes keeping it safe.
|
||||
*
|
||||
* Not exported into the Starlight config.
|
||||
*/
|
||||
export const plannedSidebar = {
|
||||
'Getting started': [
|
||||
@@ -58,20 +153,23 @@ export const plannedSidebar = {
|
||||
'Install the site',
|
||||
'First run',
|
||||
'Install a game module',
|
||||
'Connect a game server',
|
||||
'Connect your game server',
|
||||
'Verify the whole stack',
|
||||
],
|
||||
Administration: [
|
||||
'Configuration',
|
||||
'Branding and theming',
|
||||
'Navigation and pages',
|
||||
'Content',
|
||||
'Users and roles',
|
||||
'Authentication',
|
||||
'Teams',
|
||||
'Scheduled events',
|
||||
'Moderation',
|
||||
'Notifications and email',
|
||||
'Engagement rules',
|
||||
'Message templates',
|
||||
'Managing modules',
|
||||
'The shard connection',
|
||||
'Maintenance and upgrades',
|
||||
'Troubleshooting',
|
||||
],
|
||||
@@ -90,15 +188,71 @@ export const plannedSidebar = {
|
||||
'The bridge',
|
||||
'Authentication architecture',
|
||||
'Teams architecture',
|
||||
'Events architecture',
|
||||
'Protocol versions',
|
||||
],
|
||||
Reference: [
|
||||
'Environment variables',
|
||||
'Installer CLI',
|
||||
'sidecar.toml',
|
||||
'Bridge.cfg',
|
||||
'HTTP API',
|
||||
'Event catalog',
|
||||
'Canonical documents',
|
||||
],
|
||||
'Ultima Online': [
|
||||
'The Ultima Online module',
|
||||
'Connect a ServUO shard',
|
||||
'Verify the bridge',
|
||||
'The shard connection',
|
||||
'Client files',
|
||||
'Troubleshooting',
|
||||
'Bridge.cfg',
|
||||
'sidecar.toml',
|
||||
'Shard event catalog',
|
||||
],
|
||||
// PLAN.md §16, D70 (phase 15). RunicNPC's subgroup lands in phase 16 (D61).
|
||||
Rust: [
|
||||
'The Rust module',
|
||||
'Install the bridge',
|
||||
'Connect the site',
|
||||
'Verify the bridge',
|
||||
'Linking Steam accounts',
|
||||
'Permissions and groups',
|
||||
'Mod config from the site',
|
||||
'Who sees who is online',
|
||||
'Events, zones and rewards',
|
||||
'Optional plugins',
|
||||
'The live map',
|
||||
'Troubleshooting',
|
||||
'sidecar.toml',
|
||||
'RunicGateway.json',
|
||||
'Event kinds',
|
||||
{
|
||||
RunicNPC: [
|
||||
'RunicNPC',
|
||||
'Install RunicNPC',
|
||||
'Commands',
|
||||
'NPC profiles',
|
||||
'NPC placements',
|
||||
'Cost and caps',
|
||||
'Profile fields',
|
||||
'The API',
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* A group's items as flat labels, a nested group's pages written `Group › Page` after the
|
||||
* group's own label. Both trees go through this, so `checkSidebar.mjs` compares nested
|
||||
* groups as strictly as top-level ones, and `checkTerms.mjs` reads every label.
|
||||
*
|
||||
* In `docsSidebar` a nested group is `{ label, items }`; in `plannedSidebar` it is
|
||||
* `{ Label: [pages] }`.
|
||||
*/
|
||||
export function flatLabels(items) {
|
||||
return items.flatMap((item) => {
|
||||
if (typeof item === 'string') return [item];
|
||||
if (Array.isArray(item.items)) return [item.label, ...flatLabels(item.items).map((l) => `${item.label} › ${l}`)];
|
||||
if ('label' in item) return [item.label];
|
||||
return Object.entries(item).flatMap(([label, pages]) => [label, ...flatLabels(pages).map((l) => `${label} › ${l}`)]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ description: Local accounts and two-factor, SSO providers and the link-only poli
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
**Admin → Authentication** has four tabs: Local Accounts, Google, Discord and Custom
|
||||
**Admin → Authentication** has five tabs: Local Accounts, Google, Discord, Steam and Custom
|
||||
Providers. One session model sits behind all of them — a web cookie, a mobile bearer token
|
||||
and an SSO sign-in all produce the same session.
|
||||
|
||||
@@ -35,6 +35,19 @@ account is still a stranger.
|
||||
Configuring Google here also unlocks **email delivery**, which reuses the same OAuth client
|
||||
— see [Notifications and email](/docs/administration/notifications-and-email/).
|
||||
|
||||
### Steam
|
||||
|
||||
Steam signs people in with OpenID rather than OAuth, so it needs no client ID or secret — but
|
||||
it does need a **Steam Web API key**, for the name and avatar Steam's sign-in does not carry,
|
||||
and saving Steam as enabled without one is refused. It follows the same link-only policy, more
|
||||
strictly: **Steam never creates an account**, whatever registration allows. A person may link
|
||||
several Steam accounts, and each is listed on their account screen with its name, avatar and
|
||||
Steam ID.
|
||||
|
||||
A game module can also be told which Steam accounts a signed-in person has proved are theirs,
|
||||
which is how a game that knows its players by Steam account can recognise a member. The Rust
|
||||
module uses it — see [Linking Steam accounts](/docs/rust/linking-steam-accounts/).
|
||||
|
||||
## Trusted devices
|
||||
|
||||
A second factor that asks on every sign-in on the same laptop trains people to click
|
||||
|
||||
@@ -4,6 +4,7 @@ description: Colours, fonts and corners from the Appearance screen; logo, hero a
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
import Screenshot from '../../../../components/Screenshot.astro';
|
||||
|
||||
One prebuilt image runs as any community's site. Nothing about your identity is compiled
|
||||
in — it is a theme row in the database, three image files on a mount, and a few environment
|
||||
@@ -31,6 +32,8 @@ Two things the screen tells you that are easy to miss:
|
||||
- **The accent reaches the mobile app and the Discord bot**, both of which theme themselves
|
||||
from this site's public branding. Changing it here changes them.
|
||||
|
||||
<Screenshot id="admin-appearance" />
|
||||
|
||||
## Brand assets
|
||||
|
||||
The same screen uploads three images, and each applies as soon as the upload finishes —
|
||||
|
||||
@@ -16,7 +16,7 @@ Two places hold configuration, and the line between them is not arbitrary.
|
||||
|
||||
The rule behind the split: **anything that needs a restart or a shell is environment;
|
||||
anything an administrator should be able to change without either is in the panel.** That
|
||||
is why the Discord bot token, the OAuth client secrets and the shard's auth token are *not*
|
||||
is why the Discord bot token, the OAuth client secrets and the game module's sidecar token are *not*
|
||||
environment variables — they are entered in the panel and stored encrypted.
|
||||
|
||||
## Settings
|
||||
@@ -59,9 +59,10 @@ per-Team settings:
|
||||
## Email
|
||||
|
||||
Configured on the same screen and covered in
|
||||
[Notifications and email](/docs/administration/notifications-and-email/): it is Gmail over
|
||||
OAuth2, it reuses the Google authentication client, and it must be set up on the
|
||||
[Authentication](/docs/administration/authentication/) page first.
|
||||
[Notifications and email](/docs/administration/notifications-and-email/): pick a mail
|
||||
transport, enter its host, port and credentials, and send a test. It depends on nothing
|
||||
else on the site — a relay is the recommended posture, a mailbox provider over SMTP the
|
||||
simplest, and your own MTA needs no credentials at all.
|
||||
|
||||
<Aside type="note" title="Until email is connected, the contact form is a mailto: link">
|
||||
That is a deliberate fallback rather than a failure — but it does mean the *Contact email*
|
||||
|
||||
@@ -27,7 +27,7 @@ glance.
|
||||
|
||||
<Aside type="caution" title="Publishing a news post announces it">
|
||||
Publishing is what triggers the announcement pipeline — the Discord `#news` leg, and any leg
|
||||
an installed module adds, such as the `uo` module's in-game town crier. It fires on
|
||||
an installed module adds, such as the Ultima Online module's in-game town crier. It fires on
|
||||
publication, so an accidental publish is an accidental announcement. See
|
||||
[Notifications and email](/docs/administration/notifications-and-email/).
|
||||
</Aside>
|
||||
|
||||
175
src/content/docs/docs/administration/engagement-rules.mdx
Normal file
@@ -0,0 +1,175 @@
|
||||
---
|
||||
title: Engagement rules
|
||||
description: Decide what your site mails and shows people — the rule editor, saved audiences, the trigger catalog and the send log that answers "did they actually get it".
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
[Notifications and email](/docs/administration/notifications-and-email/) is about where a
|
||||
message goes. [Message templates](/docs/administration/message-templates/) is about what it
|
||||
says. This page is the part in between: **what makes one get sent at all.**
|
||||
|
||||
A **rule** is four decisions — *when* (a trigger), *to whom* (an audience), *by what*
|
||||
(channels), and *how often* (timing). **Admin → Engagement → Rules.**
|
||||
|
||||
## Nothing sends until you turn it on
|
||||
|
||||
Every rule arrives switched **off**. That is true of the ones you make, and it is true of
|
||||
the ones your modules ship with them: install a game module and you get a shelf of ready
|
||||
rules, all dark, none of them mailing anybody. Turning one on is a deliberate, separate
|
||||
act.
|
||||
|
||||
The same caution runs through the rest of the screen. Every rule carries a **hard ceiling
|
||||
on sends per hour** — you cannot save one without a number — because the failure mode of an
|
||||
automated mailer is not a wrong message, it is ten thousand of them at four in the morning.
|
||||
|
||||
<Aside type="caution" title="Upgrading? Your Team emails are here now">
|
||||
Team notification email used to be its own pipeline. It is engagement rules now, and — like
|
||||
every other seeded rule — those four rules arrive **disabled**. If your members were getting
|
||||
Team mail before an upgrade, it stops until you turn them on. The admin dashboard says so
|
||||
while it is true.
|
||||
</Aside>
|
||||
|
||||
## What a rule is made of
|
||||
|
||||
**The trigger** is the event that fires it: a house falling into disrepair, a post being
|
||||
published, a login failing. Pick it from what is registered — see
|
||||
[the catalog](#the-trigger-catalog) below. A rule's trigger is **fixed once the rule
|
||||
exists**: its cooldowns, its pending messages and its whole send history are about one
|
||||
event, so changing it would silently be a different rule wearing the same name. Make a new
|
||||
one instead.
|
||||
|
||||
**The audience** is who hears about it. Some are built in — the person the event is about,
|
||||
everyone subscribed to it, staff. Others come from your modules and are named in their own
|
||||
vocabulary. You can also point a rule at a **saved audience** you composed yourself; see
|
||||
[Audiences](#audiences).
|
||||
|
||||
**The channels** are how it reaches them: on the site, by email, by push. A rule can name
|
||||
more than one, and each channel picks its own template — the same event can be a sentence
|
||||
in the inbox and a properly laid-out letter in the mail.
|
||||
|
||||
**The timing** is the part worth reading twice.
|
||||
|
||||
- A **delay** holds the message before it goes, so a situation that resolves itself never
|
||||
produces a message at all.
|
||||
- **Cancel on** names the events that call it back. A warning that a house is about to
|
||||
collapse waits fifteen minutes and is cancelled outright if the owner turns up and
|
||||
repairs it — nobody is told their house was in danger after it stopped being in danger.
|
||||
- A **cooldown** is the "not again for a while" limit, counted **per person, per subject
|
||||
and per channel**. Per subject, so a cooldown about one house says nothing about another.
|
||||
Per channel, so "one a day about this house" means one email *and* one inbox item, which
|
||||
is what an operator setting that limit means.
|
||||
|
||||
## Audiences
|
||||
|
||||
**Admin → Engagement → Audiences** is where you build a named set of people out of the ones
|
||||
your modules declare — *members of this Team*, *the sitting governors* — and combine them:
|
||||
all of these, any of these, none of these.
|
||||
|
||||
One rule governs the whole screen: **composition narrows and never widens.**
|
||||
|
||||
- The ceiling of a saved audience is **derived** from the tightest thing in it, never
|
||||
chosen. That is true of "any of" too, where the intuitive answer — the widest of the two —
|
||||
is the wrong one. A ceiling says what an expression is *allowed* to reach, not what it
|
||||
happens to resolve to today.
|
||||
- **"None of" is only offered inside an "all of" group.** Alone it would have to mean
|
||||
"everybody except these", which is a broadcast built out of a short list, and it is not
|
||||
offered anywhere it would mean that.
|
||||
- Two audiences with no relationship between them — staff and "the person this is about",
|
||||
say — have no honest combined ceiling, so the save is refused rather than guessing which
|
||||
side to take.
|
||||
|
||||
Before you save a rule, the editor shows you a **reach preview**: a number, never a list of
|
||||
names. It will also tell you when a number is a floor rather than an answer, and when an
|
||||
audience resolves to nobody at all and why.
|
||||
|
||||
## The ceiling, and why a rule will not offer the audience you expected
|
||||
|
||||
Every trigger declares the **widest audience a rule may ever give it**. It is the security
|
||||
boundary of the whole system, and it is set in code by whoever declared the event, not in
|
||||
the admin panel. Staff-only events cannot be widened into public ones by anybody, including
|
||||
you.
|
||||
|
||||
Seven values, and they are a **tree, not a ladder**:
|
||||
|
||||
| Ceiling | Who that is |
|
||||
| --- | --- |
|
||||
| `everyone` | Everyone, including signed-out visitors |
|
||||
| `authenticated` | Any signed-in user |
|
||||
| `subscribers` | Signed-in users subscribed to this event |
|
||||
| `members` | Members of a module-declared list |
|
||||
| `staff` | Staff only — admins, editors and moderators |
|
||||
| `admin` | Administrators only |
|
||||
| `owner` | Only the user the event is about |
|
||||
|
||||
<Aside type="note" title="Fewer people is not less exposure">
|
||||
The tempting reading is a ladder — that a staff-only event could obviously also go to just
|
||||
one person. It cannot, and the example is the whole argument: cheat detection is a
|
||||
staff-only event, and "just one person" would be *the player it was detected on*. The
|
||||
question a ceiling answers is never how many, it is **which**.
|
||||
</Aside>
|
||||
|
||||
So `staff` does not permit `owner`, `members` does not permit `subscribers`, and the editor
|
||||
simply does not offer you the audiences the trigger forbids. The one exception proves the
|
||||
rule: `admin` sits under `staff`, because every administrator really is staff.
|
||||
|
||||
## The trigger catalog
|
||||
|
||||
**Admin → Engagement → Triggers** lists every event a rule can be built on, and it is
|
||||
read-only on purpose — **there is no table behind it**. A trigger is declared in code, by
|
||||
the site or by an installed module, so what you are looking at is whatever registered on
|
||||
this boot. Uninstall a module and its triggers stop appearing; nothing was deleted.
|
||||
|
||||
Two things it shows that are invisible everywhere else:
|
||||
|
||||
- **The variables** each event carries, with an example of each. This is the list a template
|
||||
is allowed to reference — when a message comes out with a hole in it, this is the screen
|
||||
that says why.
|
||||
- **The ceiling**, so when the rule editor offers you a narrower set of audiences than you
|
||||
expected, you can see the number it is obeying.
|
||||
|
||||
### Dormant rules
|
||||
|
||||
A rule can be switched on and still be unable to fire — most often because the module that
|
||||
declared its trigger, or the audience it points at, is no longer installed. Those are
|
||||
badged **dormant** in the list, with the reason, because "this rule cannot fire" is a
|
||||
different fact from "this rule is off" and you need both. The on/off switch keeps working
|
||||
on a dormant rule, deliberately: a rule whose module has gone is exactly the rule you most
|
||||
want to be able to stop.
|
||||
|
||||
## The send log
|
||||
|
||||
**Admin → Engagement → Send Log** answers one question: *did that person get that message,
|
||||
and if not, why not?* Every attempt is a row — when, what fired it, which user, which
|
||||
channel, and the result. Filter by result to go straight to what failed.
|
||||
|
||||
| Result | What it means |
|
||||
| --- | --- |
|
||||
| **Sent** | Handed to the channel successfully |
|
||||
| **Failed** | The attempt errored — the reason is on the row, not hidden in a tooltip |
|
||||
| **Not sent** | Suppressed before it was attempted: unsubscribed, unverified, or on the [suppression list](/docs/administration/troubleshooting/) |
|
||||
| **Bounced** | The receiving server rejected it after the fact |
|
||||
| **Marked as spam** | The recipient reported it |
|
||||
|
||||
Test sends from the template editor land here too, labelled as such, so you can confirm
|
||||
your own test arrived before turning a rule on for real.
|
||||
|
||||
<Aside type="tip" title="Two things it will not show you, on purpose">
|
||||
**The email address.** The log stores a one-way hash of it — enough to tie a bounce back to
|
||||
a delivery, not enough to become a second address book.
|
||||
|
||||
**A name.** It holds the user id, and that is deliberate: joining the account list in would
|
||||
quietly turn a delivery log into a staff-readable directory. Paste the id into Moderation,
|
||||
which is where a person's record belongs.
|
||||
</Aside>
|
||||
|
||||
## What a game module brings
|
||||
|
||||
A module declares its own triggers and its own audiences, in its own vocabulary, and it may
|
||||
ship rules and message bodies to go with them. The Ultima Online module ships a large family
|
||||
of them — houses falling to ruin, vendors running out of gold, a governor being seated, a
|
||||
Team's fortunes — written in the voice of an in-world office rather than a system alert.
|
||||
|
||||
All of them arrive **disabled**, like every other seeded rule. Read the list in
|
||||
**Admin → Engagement → Triggers**, turn on the ones your site should send, and check the
|
||||
send log the first time each one fires.
|
||||
209
src/content/docs/docs/administration/events.mdx
Normal file
@@ -0,0 +1,209 @@
|
||||
---
|
||||
title: Scheduled events
|
||||
description: Author an event as phases and steps, price it against this deployment's caps before it runs, and let it change a live game world unattended — with a ledger that makes the undo automatic.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
import GameExample from '../../../../components/GameExample.astro';
|
||||
|
||||
An **event** is a scheduled, bounded, audited change to a live game world. You write it once
|
||||
as a sequence of phases, publish a version of it, put it on the calendar, and it runs — at
|
||||
four in the morning if that is when you scheduled it, with nobody watching.
|
||||
|
||||
That last clause is the whole reason this feature is shaped the way it is. Everything below
|
||||
that looks like extra ceremony — the switchboard, the caps, the dry run, the ledger — is
|
||||
there because the thing being automated is somebody's game world, and the person who
|
||||
authored the change is asleep when it happens.
|
||||
|
||||
**Core owns the engine; the installed module owns the meaning.** Core decides whether an
|
||||
action is permitted, when it runs, in what order, how many times, within what budget, what
|
||||
it created and who is told. The module says which verbs exist and performs them. Core never
|
||||
learns a game word: every label you see in the step editor came from the module that
|
||||
registered it.
|
||||
|
||||
## Where it is
|
||||
|
||||
**Admin → Events**, its own group in the sidebar:
|
||||
|
||||
| Row | Who sees it |
|
||||
|---|---|
|
||||
| **Events** — the definitions, and their runs | Admin, editor, moderator |
|
||||
| **Calendar** — month and list view, with series | Admin, editor, moderator |
|
||||
| **Actions** — what this deployment permits, and the caps | **Admin only** |
|
||||
| **My participation** — your own attendance | Everyone |
|
||||
|
||||
Reading is staff-wide on purpose. A moderator's power over this feature is the **run
|
||||
console** — the screen you open when an event is doing something wrong at two in the
|
||||
morning — and hiding it from the one role that exists for incident response would be a
|
||||
strange way to build an incident tool. The narrower gates are on the actions, not the rows:
|
||||
authoring is admin and editor, publishing a version and starting a run are admin only, and
|
||||
all of it is enforced on the server rather than by hiding a button.
|
||||
|
||||
## Authoring
|
||||
|
||||
A **definition** is the thing that gets listed, searched, scheduled and audited: a title, a
|
||||
slug, a storyline, a schedule, and an ordered list of **phases**. Each phase holds **steps**,
|
||||
and a step is one action with its parameters.
|
||||
|
||||
A phase advances on a condition — after a duration, or when something happens in the game a
|
||||
given number of times. The vocabulary of "something that happens" is the trigger catalog the
|
||||
installed module already ships, so a module gains phase conditions by declaring one more
|
||||
entry in a list it already had.
|
||||
|
||||
<Aside type="note" title="A timeline, not a node graph">
|
||||
The phase editor is a vertical list, deliberately. The condition grammar has no branching —
|
||||
it is `and` / `or` / `not` over comparisons and nothing else — and a canvas would advertise
|
||||
power the engine does not have. Phases in order, each with its steps, its advance condition,
|
||||
its budget draw and its failure policy, is exactly what it can do.
|
||||
</Aside>
|
||||
|
||||
### Versions are immutable, and a run pins one
|
||||
|
||||
Publishing takes a snapshot. The run that starts on Saturday holds the version that was
|
||||
published, not the one you edited on Friday — which is what makes a run reproducible and an
|
||||
audit answerable after a change. **A running event cannot be edited**; you edit the
|
||||
definition, publish a new version, and the next run picks it up.
|
||||
|
||||
## Nothing is enabled until you enable it
|
||||
|
||||
**Admin → Events → Actions** lists every action the installed modules registered, and
|
||||
**everything above a notification arrives switched off.** Installing a module must never
|
||||
start doing things to your world.
|
||||
|
||||
Each row has two controls: whether the action is permitted on this deployment at all, and its
|
||||
**per-run caps** — how much of a budget dimension one run may consume. Dimensions are
|
||||
declared by the module, and consumption is counted in the database with a conditional
|
||||
update, not checked in application code.
|
||||
|
||||
<GameExample game="uo">
|
||||
The Ultima Online module declares `uo.creatures`, `uo.bosses`, `uo.rewards` and others —
|
||||
one per kind of thing an event can spend.
|
||||
</GameExample>
|
||||
|
||||
That distinction matters more than it sounds. A stolen admin session has already passed every
|
||||
role check there is; it still cannot exceed the cap, because the cap is a condition on the
|
||||
`UPDATE` that spends the budget.
|
||||
|
||||
<Aside type="caution" title="A cap breach is a refusal, not a failure">
|
||||
A step that would exceed a cap does not run, does not retry, and is recorded `refused` with
|
||||
the dimension and both numbers — *"asks for 12 of `uo.creatures`; 0 of 5 is already spent this
|
||||
run"*. That is an authoring mistake being reported to the author, not an outage.
|
||||
</Aside>
|
||||
|
||||
## Dry run before anything unattended
|
||||
|
||||
**Verify** materialises the whole plan without touching the world: every step is dispatched
|
||||
with a verify flag, and you get back what *would* happen and what it *would* cost against the
|
||||
caps, in the module's own words. Refusals show up here, before the calendar entry exists.
|
||||
|
||||
A definition that has never been verified is exactly the one worth not scheduling. Verifying
|
||||
is cheap, and it is the last point a human sees the plan.
|
||||
|
||||
## Running one
|
||||
|
||||
Runs start on the schedule, or by hand. A **series** groups definitions into an arc, so a
|
||||
three-part story reads as one thing on the calendar rather than three unrelated entries.
|
||||
|
||||
The **run console** shows live status, the steps and their attempts, the budget consumed
|
||||
against each cap, any failures, and the cleanup. Its controls are:
|
||||
|
||||
- **Pause** and **resume** — resume carries a run past any settled step, including one that
|
||||
failed or was refused.
|
||||
- **Skip**, **retry** and **confirm** a single step. *Confirm* is how a human-cue step
|
||||
advances: the run posts the instruction, waits, and moves on when somebody says they did it.
|
||||
- **Advance** a phase by hand.
|
||||
- **Cancel**, with or without cleanup.
|
||||
|
||||
Every one of those is logged with the person who did it.
|
||||
|
||||
## What an event does to a world, and how it is undone
|
||||
|
||||
Two different things, and the difference is the whole safety story.
|
||||
|
||||
**What it owns.** Creatures, bosses, oracle NPCs, decoration, a temporary gate — things the
|
||||
run created. Each one is written to a **resource ledger** as it is made, with the run and
|
||||
step that made it.
|
||||
|
||||
**What it borrows.** A spawner's respawn timer, a starting skill cap, a seasonal flag — values
|
||||
that already existed and are being changed for the duration. Those are **leases**: the game
|
||||
keeps the original, the site records both halves, and the lease carries its own deadline.
|
||||
|
||||
<Aside type="tip" title="Cleanup is generated, never authored">
|
||||
There is no undo phase for you to write, and that is on purpose: an operator cannot be relied
|
||||
on to write the undo, and an aborted run never reaches the phase they wrote it in. Teardown
|
||||
steps are derived from the ledger and run on **every** terminal path — completion,
|
||||
cancellation and abort alike.
|
||||
|
||||
A lease is safer still. The game restores the baseline when the deadline passes whether or not
|
||||
it ever hears from the site again, and a lease is never written to disk — so a game-server
|
||||
restart puts every borrowed value back too.
|
||||
</Aside>
|
||||
|
||||
## The game server has its own switches
|
||||
|
||||
They live in the bridge plugin's config on the game server's host, not in the site's
|
||||
database, and no event can turn them on.
|
||||
|
||||
**`EventsEnabled` is off by default**, in both games. Turning it on is consenting to the site
|
||||
changing and watching your world unattended.
|
||||
|
||||
<GameExample game="uo">
|
||||
`EventsEnabled` is a different switch from `AdminWriteEnabled`. Turning the admin plane on is
|
||||
consenting to staff moderation driven from a screen somebody is looking at; turning events on
|
||||
is consenting to unattended changes. One switch could not honestly express both. Every key
|
||||
is in [`Bridge.cfg`](/docs/ultima-online/bridge-cfg/), which only the shard host can edit.
|
||||
</GameExample>
|
||||
|
||||
<GameExample game="rust">
|
||||
`EventsEnabled` is in each server's [`RunicGateway.json`](/docs/rust/runicgateway-json/). An
|
||||
administrator can edit it on the host, or from the site's
|
||||
[Mod config](/docs/rust/mod-config/) screen, which reaches the game host's config files
|
||||
through the bridge.
|
||||
</GameExample>
|
||||
|
||||
Beside it sit the game's own ceilings — how much one call may place, how long something it
|
||||
creates may stand, how much one run may own in total. **They refuse rather than clamp**, for
|
||||
the same reason the caps do: a quietly shortened request leaves the two halves disagreeing
|
||||
about what actually happened.
|
||||
|
||||
## What players see
|
||||
|
||||
The public calendar at `/site/events` carries what is scheduled, what is happening now, what
|
||||
finished recently, and published results. A run that was cancelled says so — *"Did not
|
||||
happen"* — rather than quietly disappearing.
|
||||
|
||||
**Listing is separate from publishing.** A definition has its own *listed* switch, because
|
||||
publishing is what makes an event runnable and a surprise invasion should not have to be
|
||||
advertised a fortnight in advance in order to be allowed to happen. Unlisting hides the
|
||||
definition, its runs and its results from the public pages and from a participant's own
|
||||
history; it hides nothing from staff.
|
||||
|
||||
Where a module can tell who took part, a run can keep a **participation ledger** — scores and
|
||||
ranks, published as a results table when the run finishes. Ranks are computed at publication
|
||||
and stored, so somebody added afterwards does not silently renumber a table people have
|
||||
already read. A signed-in person sees their own attendance under their account, and staff see
|
||||
theirs on the same screen.
|
||||
|
||||
## When something goes wrong
|
||||
|
||||
- **`degraded` is not `failed`.** If the game server disappears mid-run, the run degrades,
|
||||
world-changing steps park unattempted, and it recovers when the connection does. The public
|
||||
page does not say so — that is operator information.
|
||||
- **`refused` means a bound said no**, and it is reported with the numbers.
|
||||
- **The run log answers "why did phase 3 not start?"** as a query, not by reading a wall of
|
||||
text. It is kept for 90 days after a run reaches a terminal state — and a run still in
|
||||
flight keeps every line it has, however old, because the question it answers is still open.
|
||||
- **Cleanup can be re-run** from the run console if a teardown was interrupted.
|
||||
|
||||
## Where the record is
|
||||
|
||||
[`website/EVENTS.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/EVENTS.md)
|
||||
is the design of record — the model, the data, the security argument and what was deliberately
|
||||
left out.
|
||||
[`website/MODULE_API.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md)
|
||||
is the contract a module registers its verbs against, and
|
||||
[`link/ADMIN_CONTROLS.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/ADMIN_CONTROLS.md)
|
||||
is what the site may ask a game to do at all.
|
||||
|
||||
For how the engine is put together, see
|
||||
[Events architecture](/docs/architecture/events-architecture/).
|
||||
@@ -102,7 +102,7 @@ Three rules for whatever proxy you use:
|
||||
- **Terminate TLS at the proxy.** The app speaks HTTP; it is not meant to hold a
|
||||
certificate.
|
||||
|
||||
## Upgrading the shard side
|
||||
## Upgrading the game server side
|
||||
|
||||
A different deployment on a different host, and it moves on its own schedule:
|
||||
|
||||
@@ -111,15 +111,18 @@ sudo runicgateway update # re-resolves the bundle; --verify to see it first
|
||||
sudo runicgateway doctor # confirm afterwards
|
||||
```
|
||||
|
||||
`update` replaces the sidecar and restarts its service, re-syncs the overlay, and tells you
|
||||
when ServUO needs restarting — it never restarts your shard itself. Because it resolves a
|
||||
**bundle**, the sidecar and the plugin move together and cannot end up disagreeing about the
|
||||
protocol.
|
||||
`update` replaces the sidecar and restarts its service, redeploys the bridge plugin, and
|
||||
tells you when the game server needs restarting — it never restarts your game server itself.
|
||||
Because it resolves a **bundle**, the sidecar and the plugin move together and cannot end up
|
||||
disagreeing about the protocol. Add the game's own flags as you did at install; see the
|
||||
[Installer CLI](/docs/reference/installer-cli/).
|
||||
|
||||
Each game can have its own day-two chores on top of this. Ultima Online's are in [its
|
||||
group](/docs/ultima-online/client-files/#after-a-client-update).
|
||||
|
||||
<Aside type="note" title="Update the two sides in either order, but verify after each">
|
||||
They are independent deployments joined by a version-checked contract: a mismatch is
|
||||
rejected with a `409` rather than mis-parsed. So the worst case is a bridge that refuses to
|
||||
pair until both sides are current — visible on
|
||||
[the shard connection screen](/docs/administration/the-shard-connection/), and not silent
|
||||
corruption.
|
||||
pair until both sides are current — visible on the game module's connection screen, and
|
||||
not silent corruption.
|
||||
</Aside>
|
||||
|
||||
@@ -4,10 +4,13 @@ description: The five states a module can be in, installing and upgrading, disab
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
import Screenshot from '../../../../components/Screenshot.astro';
|
||||
|
||||
Installing your first module is [Getting started](/docs/getting-started/install-a-game-module/).
|
||||
This is what the screen means afterwards.
|
||||
|
||||
<Screenshot id="admin-modules" />
|
||||
|
||||
## The five states
|
||||
|
||||
`installed → enabled → started`, with `disabled` and `startup_failed` as recoverable
|
||||
@@ -38,6 +41,10 @@ Two consequences of how boots work:
|
||||
Paste the new release's install-manifest URL and press Install. The bundle is verified
|
||||
against its `sha256`, unpacked over the old one, and takes effect at the restart.
|
||||
|
||||
**A site runs one module.** Installing a module with a different id beside the one you have
|
||||
is refused with `409`, and nothing is downloaded. To change game, remove the installed module
|
||||
first.
|
||||
|
||||
An upgrade **deliberately leaves the state alone**: upgrading an enabled module must not
|
||||
silently switch it off, and re-installing a disabled one must not silently switch it on.
|
||||
|
||||
|
||||
133
src/content/docs/docs/administration/message-templates.mdx
Normal file
@@ -0,0 +1,133 @@
|
||||
---
|
||||
title: Message templates
|
||||
description: Edit what your site's email actually says — the block editor, the variable palette, the preview, test sends, and the send log that tells you whether a message arrived.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
Every message your site sends — password resets, invitations, notifications — is a
|
||||
**template** you can edit. They ship working, so a fresh site mails correctly before you
|
||||
open this screen at all. You come here when you want it to sound like your community.
|
||||
|
||||
**Admin → Engagement → Templates.**
|
||||
|
||||
## What is in the list
|
||||
|
||||
Each row is one message. The ones marked **system** are the ones the site itself depends
|
||||
on: the password reset, the invitation, the address-confirmation mail. You can edit every
|
||||
word of those, but you cannot delete them — a site with no password-reset body is a site
|
||||
where nobody can get back in.
|
||||
|
||||
The rest are the general-purpose bodies that rules send. Those you can delete, as long as
|
||||
no rule is currently pointing at one.
|
||||
|
||||
<Aside type="tip" title="Your edits survive upgrades">
|
||||
When you edit a shipped template, the site remembers that a person changed it. Later
|
||||
versions may ship an improved default for the same message — and it will **not** be applied
|
||||
over your words. You will see a note on the row telling you a newer default exists, and it
|
||||
is up to you whether to look at it.
|
||||
</Aside>
|
||||
|
||||
## Editing a message
|
||||
|
||||
The editor has the message on the left and a live preview on the right.
|
||||
|
||||
### The body is blocks, not HTML
|
||||
|
||||
You build a message out of pieces: a heading, a paragraph, a button, a divider, an image,
|
||||
or an item list. Add one from the row of buttons, click it to edit it, and use the arrows
|
||||
to move it. There is no HTML to write, which is deliberate — email HTML is a genuinely
|
||||
horrible format, and the blocks already produce something that survives Outlook.
|
||||
|
||||
### Variables are chosen, never typed
|
||||
|
||||
Under most text fields is a row of small grey names: `siteName`, `resetUrl`, `title`. Those
|
||||
are the **variables** this particular message is given when it is sent. Click one and it is
|
||||
inserted as a token; the message that goes out has the real value in its place.
|
||||
|
||||
You cannot invent a variable. If you type one the message is not given — a typo, or a name
|
||||
you remembered from a different message — the save is refused and the error names the
|
||||
variable. That is on purpose: a variable that does not exist renders as *nothing*, so
|
||||
without the check the mistake would be invisible until it reached somebody's inbox as a
|
||||
sentence with a hole in it.
|
||||
|
||||
To see every variable a given event provides, with an example of each, look at
|
||||
**Admin → Engagement → Triggers**.
|
||||
|
||||
### Both halves of the message
|
||||
|
||||
Every email goes out in two forms: the designed HTML one, and a plain-text one for clients
|
||||
that will not show HTML. The plain-text half is generated from your blocks automatically,
|
||||
and you can see it under the **Plain text** tab.
|
||||
|
||||
If the generated version is not good enough, write your own in **Plain-text part** at the
|
||||
bottom of the editor. Whatever you write there replaces the generated text completely.
|
||||
|
||||
A published message must have *something* in its text part. If every block you used
|
||||
contributes nothing to it — a message made only of dividers and images, say — the save is
|
||||
refused.
|
||||
|
||||
### Draft and published
|
||||
|
||||
A **draft** is not what goes out. While a message is a draft, the site sends the shipped
|
||||
default in its place, so you can leave something half-finished without breaking anything.
|
||||
Switch it to **Published** when you want your version to be the one people receive.
|
||||
|
||||
## The preview
|
||||
|
||||
The preview is rendered by the server using the same code that renders the real message, so
|
||||
what you see is what will arrive — not an approximation drawn by the browser.
|
||||
|
||||
It fills the variables in with example values, so you never need to trigger a real event to
|
||||
see what a message looks like.
|
||||
|
||||
Three controls are worth knowing:
|
||||
|
||||
- **Desktop / Mobile** — the same body at a reading-pane width and a phone width.
|
||||
- **Dark mode** — an approximation of what mail clients that invert light messages will do
|
||||
to yours. Worth a glance: a design that relies on a light background can come out as
|
||||
dark-on-dark for a large minority of readers.
|
||||
- **Plain text** — the other half of the message, as described above.
|
||||
|
||||
## Sending yourself a test
|
||||
|
||||
The **Send a test** box sends the message to any address you type, through whatever mail
|
||||
transport the site is configured with (**Admin → Settings → Email delivery** — see
|
||||
[Notifications and email](/docs/administration/notifications-and-email/)).
|
||||
|
||||
It sends **what is on screen**, saved or not. That is the point of it: try a wording, send
|
||||
it to yourself, look at it in a real inbox, and only then decide whether to save.
|
||||
|
||||
Test sends are recorded in the send log like any other message, including when they fail.
|
||||
|
||||
## Making a new template
|
||||
|
||||
You do not start from a blank page. Pick a message that is close to what you want, press
|
||||
**Duplicate**, and give the copy a key.
|
||||
|
||||
The **key** is how a rule refers to the template — `notify.team-welcome`, say. Lowercase
|
||||
letters, digits, dots and dashes, and it cannot be changed later, so pick one that will
|
||||
still make sense in a year.
|
||||
|
||||
The copy always starts as a draft. Once you are happy with it, publish it and point a rule
|
||||
at it in **Admin → Engagement → Rules**.
|
||||
|
||||
<Aside type="caution" title="A template a rule is using cannot be deleted">
|
||||
If you try, the site tells you which rules are still pointing at it. Repoint or delete
|
||||
those first. The alternative — letting the delete through — would leave a rule that quietly
|
||||
stops producing mail, and nothing on screen would say why.
|
||||
</Aside>
|
||||
|
||||
## Did it arrive?
|
||||
|
||||
**Admin → Engagement → Send Log** lists every message the site tried to deliver, newest
|
||||
first, successes and failures alike. When mail is not arriving, this is the screen that
|
||||
tells you whether the site tried and the relay refused, or whether it never tried at all.
|
||||
|
||||
Failures carry the reason the mail server gave, which is usually the actual answer — a
|
||||
rejected sender address, a bad password, a relay that will not accept your domain.
|
||||
|
||||
The log does not store anybody's email address. It keeps a one-way fingerprint instead, so
|
||||
that a bounce can be matched back to a delivery without the log itself becoming a second
|
||||
copy of your members' addresses. The rest of that screen — and the rules that decide a
|
||||
message is sent at all — is [Engagement rules](/docs/administration/engagement-rules/).
|
||||
@@ -10,7 +10,7 @@ not overlap.
|
||||
|
||||
| Screen | Is about | Comes from |
|
||||
|---|---|---|
|
||||
| **Moderation** | Your **Discord** guild — bans, kicks, mutes, warnings, joins, leaves, filter and spam hits | the bot, captured live |
|
||||
| **Moderation** | Your **Discord** server — bans, kicks, mutes, warnings, joins, leaves, filter and spam hits | the bot, captured live |
|
||||
| **Reports** | **Team forum content** members have reported | the site |
|
||||
| **Appeals** | Sanctions people are asking you to reverse | the site |
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Notifications and email
|
||||
description: Email over Gmail OAuth2, the announcement pipeline and its legs, the Discord bot, and opt-in push to the mobile app.
|
||||
description: Email over SMTP, the announcement pipeline and its legs, the Discord bot, and opt-in push to the mobile app.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
@@ -9,21 +9,67 @@ Four separate delivery paths, each optional, each off until you configure it. A
|
||||
configures none of them still works — it just never reaches anyone who is not looking at
|
||||
it.
|
||||
|
||||
This page is about the paths themselves. What decides that a particular message gets sent
|
||||
down one of them is a rule — see [Engagement rules](/docs/administration/engagement-rules/).
|
||||
|
||||
## Email
|
||||
|
||||
**Admin → Settings → Email delivery.** The site sends contact-form messages (and test
|
||||
messages) through **Gmail over OAuth2**, delivered to the *Contact email* setting.
|
||||
**Admin → Settings → Email delivery.** The site sends contact-form messages, invitations,
|
||||
password resets, team notifications and test messages through **SMTP**. Contact-form mail
|
||||
goes to the *Contact email* setting.
|
||||
|
||||
It reuses the **Google authentication client**, so the order is fixed: configure Google on
|
||||
the [Authentication](/docs/administration/authentication/) page first, then press **Connect
|
||||
Gmail** here. Until then the panel reads *Unconfigured* and says exactly that.
|
||||
You pick a mail transport and fill in the fields it asks for. There is no consent flow and
|
||||
no redirect to bounce through — it is a form, and the credentials go straight into the
|
||||
database encrypted at rest, write-only: the panel will tell you a password is *set*, and
|
||||
will never show it to you again.
|
||||
|
||||
The refresh token it stores is encrypted at rest like every other secret.
|
||||
### Three ways to point it somewhere
|
||||
|
||||
<Aside type="note" title="There is no SMTP option">
|
||||
Gmail over OAuth2 is the only supported delivery path today. Until it is connected, the
|
||||
contact form falls back to a `mailto:` link to the contact address — which works, and puts
|
||||
the message in the visitor's own mail client rather than in your logs.
|
||||
Any SMTP server works. Which one you should use depends on how much mail you expect to send.
|
||||
|
||||
**A relay — the recommended one.** Mailgun, SES, Postmark or equivalent: their host, port
|
||||
`587`, *Implicit TLS* **off**, and your API key as the password. Deliverability is the hard
|
||||
part of sending mail — reputation, DKIM, bounce handling — and this is the option where
|
||||
somebody else owns it. Use this for anything with real volume.
|
||||
|
||||
**A mailbox provider over SMTP — the simplest.** For example `smtp.gmail.com`, port `587`,
|
||||
*Implicit TLS* **off**, your address as the username, and an
|
||||
[app password](https://support.google.com/accounts/answer/185833) — not your account
|
||||
password, and it requires 2-Step Verification to be on. Fine for a small site; subject to
|
||||
the provider's daily send caps.
|
||||
|
||||
**Your own MTA.** If you already run mail on the same host: its address, port `25`,
|
||||
*Implicit TLS* **off**, username and password blank. The site treats a username with no
|
||||
password as incomplete, since that authenticates as nobody.
|
||||
|
||||
<Aside type="caution" title="The two fields that cause most failures">
|
||||
**Implicit TLS** belongs *on* only for port **465**. On port `587` leave it **off** — the
|
||||
connection still upgrades to TLS, using STARTTLS. Port 587 with it on does not report an
|
||||
error; it hangs.
|
||||
|
||||
**Send from** must be an address the account is allowed to send as. Unlike a username, this
|
||||
is not verified when you save it — a server that refuses your sender rejects the mail for
|
||||
SPF/DMARC reasons that look like nothing at all from the outside. **Send test** is what
|
||||
proves it, and it names this specifically when it happens.
|
||||
</Aside>
|
||||
|
||||
Until a transport is configured, the contact form falls back to a `mailto:` link to the
|
||||
contact address — which works, and puts the message in the visitor's own mail client rather
|
||||
than in your logs. Invitations surface a copyable accept link instead, and password resets
|
||||
still answer normally.
|
||||
|
||||
<Aside type="note" title="Upgrading from the Gmail connect flow">
|
||||
Earlier versions authorised a mailbox with a **Connect Gmail** consent flow that borrowed
|
||||
the Google authentication client. That flow has been removed.
|
||||
|
||||
If your site used it, mail **stops** on upgrade until you enter SMTP credentials — and
|
||||
nothing errors when it does, because every sender degrades politely. The admin dashboard
|
||||
warns you while it is true. `smtp.gmail.com` port 587 with an app password is the shortest
|
||||
route back.
|
||||
|
||||
Single sign-on is unaffected: the Google provider exists for SSO in its own right, and email
|
||||
merely borrowed its credentials. Removing the borrow also removes a trap — rotating the SSO
|
||||
secret used to break outbound mail silently.
|
||||
</Aside>
|
||||
|
||||
## Announcements
|
||||
@@ -36,7 +82,7 @@ at the wrong host.
|
||||
Which legs exist depends on what has registered one:
|
||||
|
||||
- **Discord `#news`** is core's, and needs the bot below.
|
||||
- **A module may add its own.** The `uo` module adds an in-game town crier, so a news post
|
||||
- **A module may add its own.** The Ultima Online module adds an in-game town crier, so a news post
|
||||
is announced to players who are logged into the game and never visit the site.
|
||||
|
||||
A leg brings its own settings with it — the town crier's duration is a module setting, not
|
||||
@@ -44,7 +90,7 @@ a core one — which is why they are documented with the module rather than here
|
||||
|
||||
## The Discord bot
|
||||
|
||||
**Admin → Discord Bot**: enable it, give it the guild (server) ID and the bot token, and
|
||||
**Admin → Discord Bot**: enable it, give it the **Guild (server) ID** and the bot token, and
|
||||
save. The token is stored **encrypted in the database** and is never an environment
|
||||
variable.
|
||||
|
||||
@@ -75,14 +121,45 @@ Two properties matter for what you have to trust:
|
||||
Without `NTFY_PUBLIC_URL` / `NTFY_ALLOWED_ORIGINS`, the app simply shows push as
|
||||
unavailable for your instance — nothing breaks.
|
||||
|
||||
A tickle raised by an engagement rule carries a pointer to the matching item in the
|
||||
[on-site inbox](#on-site-notifications) where there is one, so the app opens on the thing
|
||||
that happened rather than on a list. It is still only a pointer: the content is fetched, not
|
||||
delivered.
|
||||
|
||||
## On-site notifications
|
||||
|
||||
The third way to reach somebody, and the only one that needs no relay, no mailbox and no
|
||||
app: an item in their **notification inbox** on the site itself. A bell in the header
|
||||
carries the unread count; the list lives at **Account → Notifications**.
|
||||
|
||||
Two things are worth knowing before you enable a rule that uses it:
|
||||
|
||||
- **It is the one channel that is on by default.** Push and email are opt-in — both reach
|
||||
somebody somewhere else, so both have to be asked for. An inbox item is a row on a page
|
||||
the person chose to open, so it is opt-*out*: they switch it off per notification under
|
||||
Account → Notifications → Settings.
|
||||
- **The body is plain text, always.** The in-app template renders through the same block
|
||||
editor as your mail, but only the text of each block is stored, so nothing an operator
|
||||
writes can become markup on somebody else's page. Links are site-relative or dropped.
|
||||
|
||||
Old, read items are pruned nightly (90 days by default). **Unread items are never pruned** —
|
||||
an inbox that quietly deleted things nobody had seen would make the unread badge meaningless.
|
||||
|
||||
## Who receives what
|
||||
|
||||
The per-person side of this lives in the player portal, not the admin panel: each member
|
||||
chooses which Team and forum notifications they want, and how. Two defaults are worth
|
||||
knowing because they are not symmetrical:
|
||||
The per-person side of this lives in the player portal, not the admin panel: **Account →
|
||||
Notifications → Settings** is a grid of every notification against every channel, and each
|
||||
member sets their own. The defaults are not symmetrical, and the asymmetry is deliberate:
|
||||
|
||||
- **Push is opt-out** once a device is registered.
|
||||
- **Email is opt-in.**
|
||||
- **Push is opt-in.**
|
||||
- **On the site is opt-out** — see above.
|
||||
- **Muting a Team silences all three for that Team**, whatever the grid says, without
|
||||
touching any of their other Teams.
|
||||
|
||||
The operator's side of the same question — which events exist, and how wide an audience each
|
||||
one may ever be given — is [Engagement rules](/docs/administration/engagement-rules/). When
|
||||
a message went nowhere and you want to know why, the send log there is the screen that says.
|
||||
|
||||
<Aside type="caution" title="Nothing here retries">
|
||||
The announcement dispatcher sends once, and the Team notification bridge states plainly that
|
||||
|
||||
@@ -9,8 +9,8 @@ Teams are a core platform primitive: membership, roles, forums, notifications, m
|
||||
and the Discord integrations are all core's, and none of it knows what a Team *is* in your
|
||||
game.
|
||||
|
||||
**Core cannot create a Team.** Teams arrive from the installed module — with the `uo`
|
||||
module, they are the shard's guilds. On a deployment with no module, the Team machinery is
|
||||
**Core cannot create a Team.** Teams arrive from the installed game module, which maps
|
||||
whatever the game calls its groups of players onto a Team. On a deployment with no module, the Team machinery is
|
||||
present and permanently empty. That is not a bug to work around; it is the contract that
|
||||
lets the same forum, notification and moderation code serve any game.
|
||||
|
||||
@@ -25,15 +25,14 @@ reconciles them.
|
||||
**Admin → Teams** shows a sync panel per module: last attempt, last success, consecutive
|
||||
failures and the last error, with **Sync now** and **Resync now**.
|
||||
|
||||
The wording on that panel is exact and worth reading:
|
||||
|
||||
> Core has never had an answer it could trust. What is shown below is not a confirmed empty
|
||||
> shard.
|
||||
The wording on that panel is exact and worth reading. Until a sync has succeeded, it says
|
||||
that core has never had an answer it could trust, and that the list below it is not a
|
||||
confirmed empty one.
|
||||
|
||||
An empty list therefore means one of two very different things — there are no Teams, or
|
||||
nobody could ask. The panel tells you which, and a *last success: never* with a *last
|
||||
error* of `no uo-link configured` is the second. Fix
|
||||
[the shard connection](/docs/administration/the-shard-connection/) and sync again.
|
||||
nobody could ask. The panel tells you which: a *last success: never* with a *last error*
|
||||
that names the game connection is the second. Fix the game module's connection to its game
|
||||
server and sync again. Each game's group in the sidebar covers its own connection screen.
|
||||
|
||||
## Forums
|
||||
|
||||
@@ -48,6 +47,52 @@ Two rules are structural rather than settings:
|
||||
about a leader has to reach someone above them. See
|
||||
[Moderation](/docs/administration/moderation/).
|
||||
|
||||
## Team notification emails
|
||||
|
||||
**Team emails are sent by the engagement rules, and they arrive switched off.**
|
||||
|
||||
Someone posting in a Team forum used to send mail with no configuration at all. It now goes
|
||||
through the same engine as everything else the site sends: the forum post raises an event,
|
||||
an **engagement rule** decides who is told and through which message template, and the
|
||||
outbox delivers it. Push notifications to the app and the Discord bridge below are
|
||||
unaffected — only the email moved.
|
||||
|
||||
The practical consequence on an existing site: **nobody gets Team email until you turn a
|
||||
rule on.** Open **Admin → Engagement → Rules**. Four rules are waiting there, one per Team
|
||||
event, all switched off, and the screen says so at the top for as long as they all are.
|
||||
Switch on the ones your site wants.
|
||||
|
||||
| Rule | Sends when |
|
||||
|---|---|
|
||||
| **Team forum posts** | someone posts a new thread or reply |
|
||||
| **Team announcements** | a leader posts an announcement |
|
||||
| **Team — new member** | someone joins, at most once an hour per person |
|
||||
| **Team — leadership change** | a new leader is named, at most once an hour per person |
|
||||
|
||||
The first two are the ones most sites want. The last two describe things that already show
|
||||
up on the Team's activity feed and arrive from a sweep rather than from a person doing
|
||||
something — which is why they ship off and with a cooldown.
|
||||
|
||||
<Aside type="note" title="Members still control their own mail">
|
||||
A rule decides whether the site sends at all. Each member still chooses, per Team, between
|
||||
no email, one message per post, and a daily digest — on their own notifications screen or
|
||||
through the unsubscribe link in any Team email. Turning a rule on does not sign anybody up.
|
||||
</Aside>
|
||||
|
||||
**Digests are re-read at the moment they are sent**, not assembled as posts arrive. A site
|
||||
that was down for two days sends one digest rather than two days of backlog, a post a
|
||||
moderator hid is not in it, and somebody who lost access to a forum between the post and the
|
||||
send does not receive it.
|
||||
|
||||
**Unsubscribe links keep working.** A link in mail sent before this change still does what
|
||||
it says. What changed is that it is now precise: it stops the emails it came with and leaves
|
||||
that Team's push notifications alone, where before it silenced both.
|
||||
|
||||
You can change what any of these messages say — see
|
||||
[Message templates](/docs/administration/message-templates/) — decide which of them are sent
|
||||
at all under [Engagement rules](/docs/administration/engagement-rules/), and see who was
|
||||
actually sent what in **Admin → Engagement → Send log**.
|
||||
|
||||
## The Discord bridges
|
||||
|
||||
Two integrations, both optional, both configured from **Admin → Teams**.
|
||||
@@ -59,8 +104,8 @@ event leaves the site.
|
||||
|
||||
**Voice channels** — gives each Team a Discord voice channel of its own, with access
|
||||
granted by a per-Team role, so a Team's members can see and join theirs and nobody else
|
||||
can. It needs the bot reachable, and members need a linked Discord account and guild
|
||||
membership.
|
||||
can. It needs the bot reachable, and members need a linked Discord account and membership of
|
||||
your Discord server.
|
||||
|
||||
Its three settings deserve a thought each:
|
||||
|
||||
@@ -68,10 +113,10 @@ Its three settings deserve a thought each:
|
||||
|---|---|
|
||||
| **Minimum members** | How large a Team must be to get a channel. Every active member counts, linked account or not. |
|
||||
| **Grace window (days)** | How long a Team keeps its channel after it stops qualifying. A Team that recovers inside the window keeps the same channel; zero removes it on the next pass. |
|
||||
| **Staff roles** | Roles that can see and join every Team's channel. Guild administrators already can, so this is for staff who are not administrators. |
|
||||
| **Staff roles** | Roles that can see and join every Team's channel. Discord server administrators already can, so this is for staff who are not administrators. |
|
||||
|
||||
<Aside type="caution" title="Voice channels are a per-guild ceiling, not a per-Team one">
|
||||
Discord's role and channel limits apply to the whole guild, so a site with many small Teams
|
||||
<Aside type="caution" title="Voice channels are a per-server ceiling, not a per-Team one">
|
||||
Discord's role and channel limits apply to the whole Discord server, so a site with many small Teams
|
||||
can exhaust them. The minimum-members setting is the lever that keeps the count sane, and
|
||||
it is easier to raise it before provisioning than to unpick channels afterwards.
|
||||
</Aside>
|
||||
|
||||
@@ -67,44 +67,98 @@ deployment has nothing supervising it — `npm start` in a terminal, a container
|
||||
|
||||
## The game screens are empty or say offline
|
||||
|
||||
Work outwards from the game, and stop at the first check that fails.
|
||||
Work outwards from the game, and stop at the first check that fails. The order is the same
|
||||
for every game; the commands are each game's own.
|
||||
|
||||
1. **In game:** `[bridge status` — `connected=False` means the shard cannot reach the
|
||||
sidecar.
|
||||
2. **On the shard host:** `curl -s http://127.0.0.1:8080/health` — `plugin_connected: true`
|
||||
is the value that matters.
|
||||
3. **On the shard host:** `runicgateway doctor` — checks the install record, every overlay
|
||||
file hash, the service, and that the sidecar and overlay agree on a protocol.
|
||||
4. **On the site:** the [shard connection screen](/docs/administration/the-shard-connection/)
|
||||
— its four indicators say which link is broken.
|
||||
1. **On the game server:** the bridge plugin says whether it is connected to the sidecar.
|
||||
2. **On the game server's host:** the sidecar's `/health` answers without a token, and
|
||||
*plugin connected* is the value that matters.
|
||||
3. **On the game server's host:** `runicgateway doctor` checks the install record, the
|
||||
files it deployed, the service, and that the sidecar and the bridge plugin agree on a
|
||||
protocol.
|
||||
4. **On the site:** the game module's connection screen says which link is broken.
|
||||
|
||||
Two log lines with specific meanings: **`409`** is a protocol mismatch (set the Protocol
|
||||
field to what `/health` reports), and **`401`** is the auth token (read the live one back
|
||||
with `uo-link-sidecar --print-config`; do not retype it from a screenshot).
|
||||
Two responses from a sidecar mean the same thing for every game: **`409`** is a protocol
|
||||
mismatch (the sidecar refuses rather than mis-parsing), and **`401`** is the auth token.
|
||||
|
||||
<Aside type="note" title="“Nothing changed and it stopped working” usually means a ServUO update">
|
||||
An update to the server tree can revert `Scripts.csproj`, at which point the plugin sits in
|
||||
the tree and never compiles — and ServUO ignores the script build's exit code, so the boot
|
||||
looks clean. `doctor` catches it by comparing file hashes against the install record.
|
||||
</Aside>
|
||||
Each game's group in the sidebar has its own troubleshooting page with the exact commands:
|
||||
[Ultima Online](/docs/ultima-online/troubleshooting/) and [Rust](/docs/rust/troubleshooting/).
|
||||
|
||||
## Teams are missing
|
||||
|
||||
Check the sync panel on **Admin → Teams** before anything else: *last success: never* with
|
||||
`no uo-link configured` means the shard connection, not the Team machinery. And on a site
|
||||
with **no module installed**, an empty Team list is correct and final — core cannot create
|
||||
a Team. See [Teams](/docs/administration/teams/).
|
||||
an error naming the game connection means the module could not ask its game server, not
|
||||
that the Team machinery is broken. And on a site with **no module installed**, an empty
|
||||
Team list is correct and final — core cannot create a Team. See
|
||||
[Teams](/docs/administration/teams/).
|
||||
|
||||
## Email and announcements never arrive
|
||||
|
||||
- **The contact form opens a mail client.** Email delivery is not connected; that is the
|
||||
documented fallback. Connect Gmail in **Settings → Email delivery** — after configuring
|
||||
the Google provider, which it reuses.
|
||||
- **The contact form opens a mail client.** Email delivery is not configured; that is the
|
||||
documented fallback. Enter SMTP credentials in **Settings → Email delivery**. If this site
|
||||
used to send mail and stopped, the Gmail connect flow was removed — the admin dashboard
|
||||
says so, and [Notifications and email](/docs/administration/notifications-and-email/) has
|
||||
the migration.
|
||||
- **Mail is configured but nothing arrives, and there is no error.** Two usual causes, both
|
||||
invisible without a test send. *Implicit TLS* left on for port 587 hangs rather than
|
||||
failing; and a **Send from** address the server will not let you send as is rejected for
|
||||
SPF/DMARC reasons. Press **Send test** — its failure message names both cases.
|
||||
- **Email was working and the toggle is still on.** *Enable email sending* now gates every
|
||||
message, not just some of them. If it is off, nothing is sent, including the contact
|
||||
form.
|
||||
- **A published post announced nothing.** The Discord bot is a separate container. If the
|
||||
Discord Bot screen says *bot unreachable*, it is not running.
|
||||
- **A missed announcement does not come back.** Nothing retries; the post itself is still
|
||||
on the site.
|
||||
|
||||
## Nothing is sent for one particular event
|
||||
|
||||
Mail works, other notifications arrive, but this one thing never produces anything. The
|
||||
answer is almost always in **Engagement → Rules**, and it is one of four:
|
||||
|
||||
- **The rule is off.** Every rule ships disabled, including the ones your modules bring
|
||||
with them, so "installed" is not "on".
|
||||
- **The rule is badged *dormant*.** It is switched on but cannot fire — usually because the
|
||||
module that declared its trigger, or the audience it points at, is no longer installed.
|
||||
- **It fired and was held back by its own cooldown**, which is per person, per subject and
|
||||
per channel. The Send Log shows nothing for a message that was never queued.
|
||||
- **The audience resolves to nobody.** The rule editor's reach preview is the fastest way
|
||||
to find that out — it will tell you the count is zero and why.
|
||||
|
||||
[Engagement rules](/docs/administration/engagement-rules/) walks through all four.
|
||||
|
||||
## One person stopped receiving email
|
||||
|
||||
Everyone else is getting mail, so the transport is fine. Check
|
||||
**Engagement → Suppressions**, then **Engagement → Send Log**.
|
||||
|
||||
- **They are on the suppression list.** The site stops mailing an address once the
|
||||
receiving server says the mailbox does not exist. Addresses are stored one way and
|
||||
shown masked (`d***@example.com`), so search by their domain to find the row. If they
|
||||
have since fixed their mailbox, press **Lift a suppression** and type the full
|
||||
address — the screen genuinely does not have it, which is why you are asked.
|
||||
- **Suppression only affects engagement rules.** Password resets, invites and address
|
||||
verification still go out to a suppressed address, because those are things the person
|
||||
asked for themselves. So "they can reset their password but get no notifications" is
|
||||
the expected shape of this problem, not a contradiction.
|
||||
- **The Send Log says *Not sent*.** That is a suppression: nothing was sent to the mail
|
||||
server at all. *Bounced* means it was sent and the mailbox does not exist. *Failed*
|
||||
means the relay refused it for some other reason — that one is about your
|
||||
configuration, not about them.
|
||||
- **The Send Log has no row for them at all.** They were excluded before anything was
|
||||
queued. Either they have not opted in on **Notifications** for that stream, or
|
||||
*Require a verified email address* is on in **Settings** and they have not confirmed
|
||||
theirs. The rule editor's audience preview shows how many people each of those removes.
|
||||
|
||||
## Everyone stopped receiving email at once
|
||||
|
||||
Do **not** start clearing the suppression list — it is almost certainly not the cause.
|
||||
A whole-deployment stop is a transport problem: an expired password, a relay that has
|
||||
started refusing you, or *Enable email sending* switched off. The Send Log will show
|
||||
*Failed* rather than *Bounced* or *Not sent*, and **Settings → Email delivery** shows the
|
||||
last error. A wrong password never suppresses anybody; only the receiving server saying a
|
||||
specific mailbox does not exist does that.
|
||||
|
||||
## Uploads and modules fail with permission errors
|
||||
|
||||
Docker created a bind-mount source that the container user cannot write — usually because
|
||||
|
||||
@@ -4,6 +4,7 @@ description: The four roles and what each one reaches, creating accounts, and in
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
import Screenshot from '../../../../components/Screenshot.astro';
|
||||
|
||||
## The four roles
|
||||
|
||||
@@ -24,6 +25,8 @@ Admin routes are re-validated against the database on **every request**, not jus
|
||||
Demoting an account takes effect at once — the open session does not keep its access until
|
||||
it expires.
|
||||
|
||||
<Screenshot id="admin-users" />
|
||||
|
||||
## Creating an account
|
||||
|
||||
**Admin → Users → + Add user** creates one directly: username, password, role, and it is
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
---
|
||||
title: Authentication architecture
|
||||
description: One session model behind three very different front doors — cookies, bearer tokens and SSO — and where the boundaries actually are.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
The administrator's view of this is
|
||||
[Authentication](/docs/administration/authentication/). This is how it is built.
|
||||
|
||||
## One session service, three surfaces
|
||||
|
||||
The governing decision: **there is a single source of truth for sessions**, and every
|
||||
authentication surface produces the *same* session model.
|
||||
|
||||
```
|
||||
browser native app SSO provider
|
||||
(httpOnly JWT) (bearer + refresh) (OAuth2 / OIDC + PKCE)
|
||||
│ │ │
|
||||
└───────────────────┼────────────────────────┘
|
||||
▼
|
||||
sessionService
|
||||
createSession(user, authMethod)
|
||||
validateSession()
|
||||
```
|
||||
|
||||
Controllers call `createSession`; middleware calls `validateSession`. Nothing invents its
|
||||
own notion of "logged in".
|
||||
|
||||
That matters more than it sounds. Three front doors with three session implementations is
|
||||
three places for an authorization bug to hide, and the one that gets least attention is the
|
||||
one that gets exploited.
|
||||
|
||||
<Aside type="note" title="`utils/auth.js` is a facade">
|
||||
It exists for backward compatibility and is a thin wrapper. New work goes through the
|
||||
session service.
|
||||
</Aside>
|
||||
|
||||
## The three surfaces
|
||||
|
||||
**Web** — a JWT signed with `JWT_SECRET`, carried in an `httpOnly`, `sameSite=Lax` cookie.
|
||||
`secure` is decided **per request** (`COOKIE_SECURE=auto` → `secure: req.secure`), which is
|
||||
what lets one deployment work both over HTTPS through a proxy and over plain HTTP on a LAN
|
||||
address.
|
||||
|
||||
**Mobile** — short-lived bearer access tokens plus **rotated, hashed, revocable** refresh
|
||||
tokens. Hashed server-side, so a database disclosure does not hand over live sessions.
|
||||
|
||||
**SSO** — Google, Discord or a custom OIDC provider, PKCE-guarded.
|
||||
|
||||
## SSO is link-only, by policy
|
||||
|
||||
**An external identity must already be linked to an existing account.** Identities are
|
||||
never auto-provisioned.
|
||||
|
||||
This is a deliberate policy rather than an unimplemented feature. Auto-provisioning turns
|
||||
"anyone with a Google account" into "anyone with an account here", which is not a decision
|
||||
a site operator should make by installing an OAuth client.
|
||||
|
||||
## Admin is re-validated every request
|
||||
|
||||
Roles are **re-checked against the database on every admin request**, not trusted from the
|
||||
token.
|
||||
|
||||
The consequence is the point: a demoted user loses access **at once**, rather than when
|
||||
their token happens to expire. A stateless JWT that carried the role would keep asserting it
|
||||
for up to a day.
|
||||
|
||||
## Trusted devices gate the second factor only
|
||||
|
||||
A second, separate httpOnly cookie (`rg_trust`, 30 days by default) lets a browser or app
|
||||
**skip the TOTP step** on future logins — **never the password**.
|
||||
|
||||
Four properties, each chosen:
|
||||
|
||||
- It is **opaque and sha256-hashed server-side**, stored in a table. It is not a JWT claim,
|
||||
so the stateless session token is unchanged.
|
||||
- It is **per-row revocable**, from the admin panel or by the user.
|
||||
- It **deliberately outlives logout.** Logging out ends a session; it does not make the
|
||||
device untrusted, because the device is still the same device.
|
||||
- It is **cleared** on untrust, password change, password reset, or disabling TOTP.
|
||||
|
||||
**Recovery codes** (bcrypt, single-use) are the lockout fallback. Every trusted-device and
|
||||
MFA action is audit-logged.
|
||||
|
||||
## The login-hardening layer
|
||||
|
||||
Bot scoring with automatic IP banning, TOTP 2FA, a honeypot field, and rate limiting with
|
||||
backoff. The admin *Bot Activity* panel is deliberately **read plus emergency-unban only** —
|
||||
it is a window onto an automatic system, not a control surface for it.
|
||||
|
||||
## Where core's boundaries stop
|
||||
|
||||
Core's security boundaries end at **authentication, roles and the session**.
|
||||
|
||||
A module that serves game data brings its **own** audience rules, and core does not police
|
||||
them beyond the gates it hands over — `requireAuth`, `requireRole`, and the tier group
|
||||
gates. See [The module API](/docs/modules/the-module-api/#registerroutes-and-the-tier-gate).
|
||||
|
||||
The Ultima Online module's is the worked example, and it is a real boundary rather than a
|
||||
convenience filter: an admin-configurable, per-feature and per-field audience ladder with **fail-closed
|
||||
defaults**, applied at routes, at SSE subscribe time, *and* at the navigation. All three,
|
||||
because a surface that is filtered in only two of those places leaks through the third.
|
||||
|
||||
## Content Security Policy
|
||||
|
||||
`script-src 'self'` with **no inline script**, which is why [module chunks are served
|
||||
same-origin](/docs/modules/building-a-module/) and why an import map was never an option.
|
||||
|
||||
`form-action 'self'` is pinned explicitly rather than inherited, because it blocks an
|
||||
injected form POSTing credentials off-origin — an exfiltration path `connect-src` does not
|
||||
cover.
|
||||
|
||||
Violation reports go to a **same-origin** sink that stores nothing: reports describe attacks
|
||||
against this site and are not handed to a third-party collector. It parses both wire formats
|
||||
(browsers disagree), and always answers `204` even for malformed input — a `4xx` would make
|
||||
the error handler log attacker-supplied bodies and turn an open endpoint into a log-flood
|
||||
primitive.
|
||||
|
||||
## Canonical document
|
||||
|
||||
[`BACKEND_DESIGN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/BACKEND_DESIGN.md)
|
||||
§6 is normative for everything on this page.
|
||||
166
src/content/docs/docs/architecture/events-architecture.mdx
Normal file
@@ -0,0 +1,166 @@
|
||||
---
|
||||
title: Events architecture
|
||||
description: An event does not edit the world — it holds a lease. How a game-agnostic engine schedules changes to a live game world it cannot name.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
The Event System is a game-agnostic engine for **scheduled, bounded, audited** changes to a
|
||||
live game world. Core runs it and cannot name a single thing in your game.
|
||||
|
||||
The administrator's view is [Scheduled events](/docs/administration/events/).
|
||||
|
||||
## The two sentences the design turns on
|
||||
|
||||
**An event does not edit the world. It holds a lease.**
|
||||
|
||||
Anything an event changes that already existed is borrowed, not set: the game keeps the
|
||||
baseline, the site records both halves, and the lease carries its own deadline. When the
|
||||
deadline passes the game restores the value — whether or not it ever hears from the site
|
||||
again. A lease is never written to the game's save file either, so a server restart also
|
||||
puts every borrowed value back. That is the difference between automating a change and
|
||||
handing an unattended process a `[set` command.
|
||||
|
||||
**The module declares; core dispatches.** A module says a verb exists, what it costs and what
|
||||
it needs; core decides whether it is permitted, when it runs, in what order, how many times,
|
||||
within what budget, what it created and who is told. Nothing crosses that line as a string
|
||||
core interprets — the browser posts an action *id* and a params object, both validated
|
||||
against the registry before anything is dispatched. There is no passthrough field and no
|
||||
place a request body can name a game command.
|
||||
|
||||
## What is a table, and what deliberately is not
|
||||
|
||||
Eleven core tables, no ORM, and no migration system — which makes every table a permanent
|
||||
commitment. The rule applied was: **a table is for what must be queried, claimed or joined.**
|
||||
|
||||
| Kind | Where it lives |
|
||||
|---|---|
|
||||
| Definitions, series, versions, runs, steps, budget, resources, participants, gates, settings, log | Tables |
|
||||
| Phases | Configuration inside an immutable version snapshot. A phase has no identity a query needs; a step does |
|
||||
| Actions, budget dimensions, conditions | Registry entries a module declares at load. A stored one would outlive the module that can perform it |
|
||||
| A reward catalog | Neither. A reward is an ordinary action, so a granted reward is an ordinary ledger row |
|
||||
|
||||
**The step is the unit of execution, and it is a row** — one action invocation with a due
|
||||
time, a status, an attempt count and a claim. Retries, timeouts, duplicate execution and
|
||||
resumption after a crash are then all properties of that row rather than of a process's
|
||||
memory, which is what lets the runner be killed mid-run and pick up where it stopped.
|
||||
|
||||
**One run per occurrence, guaranteed by a unique index** on the definition, the scope and the
|
||||
scheduled instant — not by the claim. Two application instances cannot both start the same
|
||||
occurrence, because the second insert fails.
|
||||
|
||||
## Budgets are enforced in SQL
|
||||
|
||||
Consumption is spent with a conditional update:
|
||||
|
||||
```sql
|
||||
UPDATE event_run_budget
|
||||
SET consumed = consumed + ?
|
||||
WHERE run_id = ? AND dimension = ? AND consumed + ? <= cap
|
||||
```
|
||||
|
||||
No transaction, no read-then-write, and no way for two concurrent steps to both squeeze past
|
||||
the same ceiling.
|
||||
|
||||
<Aside type="tip" title="Why that is the strongest control here">
|
||||
A compromised admin session has already passed every role check the application has. It has
|
||||
not passed this one, because this one is not a check — it is a condition on the write. That is
|
||||
the reason per-run quotas were kept after the delegation model was dropped.
|
||||
</Aside>
|
||||
|
||||
## The ledger, and why cleanup is generated
|
||||
|
||||
Every world write appends to a resource ledger before it is confirmed: the run, the step, the
|
||||
owning module, an opaque kind and reference, and — for a borrowed value — the baseline
|
||||
alongside what was applied.
|
||||
|
||||
Teardown is then **derived from the ledger**, never authored, and runs on every terminal path:
|
||||
completion, cancellation and abort alike. An operator cannot be relied on to write the undo,
|
||||
and an aborted run never reaches the phase they wrote it in.
|
||||
|
||||
Two rules make that hold up:
|
||||
|
||||
- **A unique index across non-reverted rows** stops two events leasing the same target. The
|
||||
second one is refused rather than layered on top of the first.
|
||||
- **A restore is a compare-and-set.** If the current value is not what the lease applied,
|
||||
somebody else changed it since; the row is marked `drifted` rather than stamped over. The
|
||||
ledger would rather say "I do not know what happened here" than lie about having undone it.
|
||||
|
||||
## At-most-once, on a wire that can lose an answer
|
||||
|
||||
Every command the site sends the game carries an **idempotency key**, and the game executes a
|
||||
given key at most once — a repeat is answered with the original reply rather than re-run.
|
||||
|
||||
Without it, a lost acknowledgement is indistinguishable from a command that never applied, so
|
||||
every world write has to be declared un-retryable and one has to be *lost* rather than risk
|
||||
*doubling* it. The key is what makes a world-changing step an ordinary retried row like any
|
||||
other.
|
||||
|
||||
<Aside type="caution" title="The rule that pays for it">
|
||||
**Do not answer an error after changing the world.** The store treats a handler that ran and
|
||||
deliberately refused as a transient outcome and releases the key, so the answer is not frozen
|
||||
for ever — the acceptance walk found a refusal ("the last save was 227 seconds ago") replayed
|
||||
identically six times, with a number that could never age. A handler that has already changed
|
||||
something must not take that path.
|
||||
</Aside>
|
||||
|
||||
## Three layers, and the role check is only one of them
|
||||
|
||||
1. **Declaration** — a module says a verb exists. That is code the operator installed; it is
|
||||
not a permission.
|
||||
2. **Enablement** — an admin turns an action on for this deployment and sets its caps.
|
||||
Nothing above a notification is on by default.
|
||||
3. **Invocation** — the role check, then the cap, then the game's own switches. Admin routes
|
||||
are re-validated against the database on every request, so a demotion takes effect on the
|
||||
next click.
|
||||
|
||||
The game's switches are the layer outside the site's own database: they live in the bridge
|
||||
plugin's own config on the game server's host — `EventsEnabled`, in both games — and are off
|
||||
out of the box. No event and no role check can turn one on. On Rust an administrator can
|
||||
still edit that file from the game module's config screen, which reaches the host's config
|
||||
files through the bridge, so there it is a separate deliberate act rather than an unreachable
|
||||
one. The game's own ceilings **refuse rather than clamp** — because a silently shortened
|
||||
request leaves the two halves disagreeing about what happened.
|
||||
|
||||
<Aside type="note" title="Stated plainly">
|
||||
The module boundary is **not** a security boundary — a module runs in the same process with
|
||||
full access, and the module system's own documentation says so. None of the above defends
|
||||
against a hostile module. It defends against a compromised session and an operator mistake,
|
||||
both of which are made larger by *scheduling*: a change that happens while nobody is watching.
|
||||
That is why the caps and the leases matter more here than the role check does.
|
||||
</Aside>
|
||||
|
||||
## Where it meets everything else
|
||||
|
||||
- **[Engagement](/docs/administration/engagement-rules/)** — core registers `event.` triggers
|
||||
and owns none of the delivery. Who is told about a run is an ordinary rule.
|
||||
- **[The bridge](/docs/architecture/the-bridge/)** — every world verb becomes a command on the
|
||||
same versioned wire the game already speaks, through the same sidecar. Core still holds no
|
||||
game connection.
|
||||
- **[Teams](/docs/architecture/teams-architecture/)** — "this Team's members" is already a
|
||||
registered audience, so an event scoped to one Team needs no event-side feature at all.
|
||||
- **News** — an event does not write posts. A core action links an *existing* post to a run and
|
||||
enqueues it through the announcement pipeline, so the in-game town crier and Discord arrive
|
||||
as legs that already exist.
|
||||
|
||||
## What it deliberately does not do
|
||||
|
||||
- **No branching.** The condition grammar is `and` / `or` / `not` over comparisons, and the
|
||||
phase editor is a timeline rather than a canvas, because a canvas would promise power the
|
||||
engine has not got.
|
||||
- **No delegation, grants or proposal queue.** Permissions gate on the admin roles that
|
||||
already exist. The whole authorisation decision lives behind one function, which is what
|
||||
keeps a coordinator model a later option rather than a redesign.
|
||||
- **No event invoking another event.** It already works by composition — a second event's
|
||||
condition can be the first one completing.
|
||||
- **No mutation of game-owned content without a baseline.** If it cannot be restored, it
|
||||
cannot be leased, and it is out.
|
||||
|
||||
## Canonical documents
|
||||
|
||||
[`website/EVENTS.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/EVENTS.md)
|
||||
is the design of record;
|
||||
[`website/MODULE_API.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md)
|
||||
is the contract a module registers against; and
|
||||
[`link/v7.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v7.md)
|
||||
is the wire protocol the world verbs travel on.
|
||||
144
src/content/docs/docs/architecture/protocol-versions.mdx
Normal file
@@ -0,0 +1,144 @@
|
||||
---
|
||||
title: Protocol versions
|
||||
description: One number per game, declared in three places, that decides whether a game server and a sidecar are allowed to talk to each other.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
import GameExample from '../../../../components/GameExample.astro';
|
||||
import platform from '../../../../data/platform.json';
|
||||
|
||||
The loopback wire protocol between the bridge plugin and the sidecar is a **versioned
|
||||
compatibility contract**, not a build dependency. Nothing compiles the three sides together,
|
||||
so the number is what stops a mismatch from being discovered as corrupted data.
|
||||
|
||||
Each game has its own protocol, numbered independently: {platform.games.uo.name} is on
|
||||
**{platform.games.uo.protocol}** and {platform.games.rust.name} on
|
||||
**{platform.games.rust.protocol}**. The two numbers are not comparable — they version two
|
||||
different wires. Everything below holds for both.
|
||||
|
||||
## Three declaration sites
|
||||
|
||||
The same number is written down in three places, and they must move together:
|
||||
|
||||
1. **The sidecar's source** declares `PROTOCOL_VERSION` — what the sidecar speaks.
|
||||
2. **The bridge plugin's release manifest** declares `protocol` — what the plugin speaks.
|
||||
3. **The bundle manifest** copies the plugin's number in CI, so a released pair carries its
|
||||
own claim.
|
||||
|
||||
<GameExample game="uo">
|
||||
| Where | What declares it |
|
||||
|---|---|
|
||||
| `link` `sidecar/src/main.rs` | `PROTOCOL_VERSION`, currently {platform.games.uo.protocol} |
|
||||
| `servuo-plugins` `overlay.toml` | `protocol`, currently {platform.games.uo.protocol} |
|
||||
</GameExample>
|
||||
|
||||
<GameExample game="rust">
|
||||
| Where | What declares it |
|
||||
|---|---|
|
||||
| `Rust-Link` `sidecar/src/main.rs` | `PROTOCOL_VERSION`, currently {platform.games.rust.protocol} |
|
||||
| `Rust-Plugins` `overlay.toml` | `protocol`, currently {platform.games.rust.protocol} |
|
||||
</GameExample>
|
||||
|
||||
<Aside type="caution" title="Bump the plugin's manifest in the same PR as the emitters">
|
||||
CI folds the plugin's manifest into the release manifest, and **the installer refuses to
|
||||
pair a bridge plugin and a sidecar whose protocol numbers disagree**.
|
||||
|
||||
A bump that lands separately from the emitters does not fail loudly — it silently fails to
|
||||
compose into a bundle, and the next release simply does not appear.
|
||||
</Aside>
|
||||
|
||||
## How a mismatch is caught
|
||||
|
||||
Two independent mechanisms, at two different boundaries.
|
||||
|
||||
**Sidecar ↔ website.** Every sidecar response carries its protocol version in a header. A
|
||||
mismatch is rejected with **`409`** rather than mis-parsed, and the game module's admin
|
||||
screen is where an administrator sees it.
|
||||
|
||||
**Bridge plugin ↔ sidecar.** The installer resolves a **bundle** — an exact,
|
||||
protocol-checked pair of sidecar and bridge plugin published by CI — and never "latest of
|
||||
each". That is the whole
|
||||
reason bundles exist: two independently released components that must agree cannot be
|
||||
allowed to be chosen independently.
|
||||
|
||||
## What a bump obliges
|
||||
|
||||
Changing a message shape means editing every side plus the specification: the bridge plugin,
|
||||
the sidecar, the game module, and the protocol document in `docs`.
|
||||
|
||||
**`website` is usually not on that list, and its absence is the interesting part.** Core
|
||||
holds no game connection and names no game noun, so most protocol bumps do not reach it at
|
||||
all. One reaches core only when what changes is the shape of something core owns — a ledger,
|
||||
a place to store a picture — rather than a game noun.
|
||||
|
||||
<GameExample game="uo">
|
||||
Protocol 8 taught the bridge to carry a game's own client files, and touched four
|
||||
repositories:
|
||||
|
||||
| Repository | What had to change |
|
||||
|---|---|
|
||||
| `servuo-plugins` | The extractors and the decoders they call, the switches and caps in `Bridge.cfg`, and `overlay.toml` |
|
||||
| `link` | `PROTOCOL_VERSION`, a cap on how large a line the shard may send, and the endpoints that carry the new commands |
|
||||
| `module-uo` | The importers, the admin screen, and the pages that render a picture |
|
||||
| `docs` | The protocol document and the integration guide |
|
||||
|
||||
Core's entire share of eight phases of work was a **deletion**: a developer tool it no longer
|
||||
needed. Everything the bump needed — somewhere to put a picture — core already offered every
|
||||
module.
|
||||
</GameExample>
|
||||
|
||||
**A protocol bump can also require a store migration**, because the sidecar persists what it
|
||||
forwards. That is not automatic, and it is rare: every frame is persisted whole, so a bump
|
||||
that only widens a frame, or adds a kind, or adds a guarantee about how a command is
|
||||
executed, asks nothing of a store that defines no schema for a frame's contents. That is the
|
||||
dumb-forwarder property paying for itself.
|
||||
|
||||
<GameExample game="uo">
|
||||
It has happened once: version 4 added a column to a table that already existed. Versions 5
|
||||
to 8 needed none. Version 8 is the largest bump the protocol has had, and it moves megabytes
|
||||
of artwork rather than events — and it changed **no line** of the sidecar's store, because
|
||||
the things it carries are answers to requests rather than events to keep.
|
||||
</GameExample>
|
||||
|
||||
## This is not the module API version
|
||||
|
||||
Two different numbers, versioning two different contracts, and confusing them is easy.
|
||||
|
||||
| | Versions | Lives in | Checked |
|
||||
|---|---|---|---|
|
||||
| **`PROTOCOL_VERSION`** | The game ↔ sidecar wire, one per game | The sidecar, the bridge plugin, the bundle | The sidecar's version header, and the installer's pairing check |
|
||||
| **`MODULE_API_VERSION`** | The website ↔ module contract | `website`, and every module's `coreApi` | At module load, before the module's code runs |
|
||||
|
||||
A module that never talks to a game server has no protocol version at all. See [The module
|
||||
manifest](/docs/modules/the-module-manifest/#coreapi-and-what-a-range-means).
|
||||
|
||||
## When a contract owes a bump
|
||||
|
||||
The rule this project settled on: **a contract owes a bump only once it has landed on
|
||||
`main`.**
|
||||
|
||||
While a version has only ever existed on a development branch, additions join it in place
|
||||
rather than forcing a new number. Once it has shipped, it is somebody else's dependency and
|
||||
a change to it is a change to a published contract.
|
||||
|
||||
## If you are building a bridge for another game
|
||||
|
||||
You do not inherit this protocol — you define your own between your plugin and your sidecar.
|
||||
What is worth inheriting is the **shape**:
|
||||
|
||||
- Declare the version on both sides, in files a release can read.
|
||||
- Make a released pair carry its own compatibility claim, so a deployment tool can refuse a
|
||||
bad combination rather than discovering it at runtime.
|
||||
- Reject a mismatch **loudly and early**. A `409` is a good outcome; a successful parse of a
|
||||
message you did not expect is not.
|
||||
|
||||
## Canonical documents
|
||||
|
||||
Each game's protocol has its own record in the
|
||||
[`docs` repository](https://gitea.whitlocktech.com/RunicGateway/docs). For Ultima Online,
|
||||
[`link/v8.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v8.md)
|
||||
is the current protocol's record, including its cross-repository obligations, and
|
||||
[`link/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md)
|
||||
§7 is the wire protocol. For Rust,
|
||||
[the Rust bridge protocol](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/rust-link/PROTOCOL.md)
|
||||
is both.
|
||||
162
src/content/docs/docs/architecture/system-architecture.mdx
Normal file
@@ -0,0 +1,162 @@
|
||||
---
|
||||
title: System architecture
|
||||
description: The whole platform in one place — what each repository is, what talks to what, and the invariants that hold across all of them.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
import GameExample from '../../../../components/GameExample.astro';
|
||||
|
||||
The drawn version of this, for evaluators, is on
|
||||
[Architecture](/architecture/). This page is the detailed account.
|
||||
|
||||
## Fourteen repositories, deployed independently
|
||||
|
||||
Nothing here is a monorepo. Each repository has its own history, its own CI and its own
|
||||
release cadence; what binds them is a set of **versioned contracts**, not a build.
|
||||
|
||||
Seven of them are core, and serve every game:
|
||||
|
||||
| Repository | What it is |
|
||||
|---|---|
|
||||
| `website` | The Node/Express + MariaDB + React site. The only internet-facing web app |
|
||||
| `installer` | Deploys a game server's side: the bridge plugin, and the sidecar with its service |
|
||||
| `Android-app` | Native Android client of the website API |
|
||||
| `Integration-kit` | The instruction book for putting a different game on the platform |
|
||||
| `docs` | Canonical design docs and the protocol specs |
|
||||
| `runicgateway.com` | This site |
|
||||
| `.profile` | The organisation landing page |
|
||||
|
||||
The other seven belong to a game. Each game has the same three: **a game module** that adds
|
||||
its screens to the site, **a sidecar** that is the only network-facing part of its bridge,
|
||||
and **a bridge plugin** that runs inside the game server and feeds the sidecar.
|
||||
|
||||
<GameExample game="uo">
|
||||
| Repository | Its part |
|
||||
|---|---|
|
||||
| `Module-uo` | The game module |
|
||||
| `link` | The sidecar, uo-link, in Rust |
|
||||
| `servuo-plugins` | The bridge plugin, a C# overlay ServUO compiles at boot |
|
||||
</GameExample>
|
||||
|
||||
<GameExample game="rust">
|
||||
| Repository | Its part |
|
||||
|---|---|
|
||||
| `Module-Rust` | The game module |
|
||||
| `Rust-Link` | The sidecar, rust-link, in Rust |
|
||||
| `Rust-Plugins` | The bridge plugin, for Oxide and Carbon |
|
||||
| `runicnpc-rust` | RunicNPC, an NPC plugin the bridge drives, which also works on its own |
|
||||
</GameExample>
|
||||
|
||||
## The layers
|
||||
|
||||
```
|
||||
Browser (React SPA) Native Android app
|
||||
│ cookie │ bearer
|
||||
└──────────┬─────────────────┘
|
||||
▼
|
||||
┌────────────────────────┐
|
||||
│ website (Node) │
|
||||
│ middleware → router │
|
||||
│ → controller → model │
|
||||
│ → db │
|
||||
└───────┬────────────┬───┘
|
||||
│ │ loads at boot
|
||||
▼ ▼
|
||||
MariaDB modules/<id>/ ← installed, never built
|
||||
│
|
||||
▼
|
||||
the game, via whatever
|
||||
bridge that module owns
|
||||
```
|
||||
|
||||
The backend is strictly layered — `middleware → router → controller → model → db` — with
|
||||
models in `.model.js` (logic) and `.db.js` (SQL) pairs, and **raw parameterised queries with
|
||||
no ORM anywhere**.
|
||||
|
||||
## Core is game-agnostic
|
||||
|
||||
Since the module system shipped on **2026-08-12**, nothing in core knows about any
|
||||
particular game. Routes, tables, pages, navigation and push streams for a game arrive from
|
||||
[a module](/docs/modules/the-module-system/) the operator installed. Core provides the seams;
|
||||
the module fills them.
|
||||
|
||||
That is why the architecture below describes a game module as something core *loads* rather
|
||||
than as part of the platform. There are two, and core depends on neither.
|
||||
|
||||
## The invariants
|
||||
|
||||
These hold across repository boundaries, and every one of them is load-bearing.
|
||||
|
||||
### The game is never network-reachable
|
||||
|
||||
The game server's bridge plugin **dials out** to the sidecar over loopback TCP,
|
||||
newline-delimited JSON, on `127.0.0.1`. The sidecar is the listener; the game opens no port.
|
||||
Only the sidecar is exposed, and only the website's backend talks to it.
|
||||
|
||||
See [The bridge](/docs/architecture/the-bridge/).
|
||||
|
||||
### A wedged sidecar can never stall the game
|
||||
|
||||
In the bridge plugin, `Emit()` enqueues onto a **bounded, drop-oldest** queue and returns
|
||||
immediately. It never touches the socket from the game's main thread. Every world read
|
||||
happens on the main thread; a dedicated writer thread drains the queue.
|
||||
|
||||
Dropping game events is strictly better than pausing the game to deliver them.
|
||||
|
||||
### The website degrades rather than fails
|
||||
|
||||
The sidecar REST client never throws — every call returns `{ ok, data, status }`. The public
|
||||
site still renders with the game shown offline.
|
||||
|
||||
That guarantee covers **reading the configuration too**: resolving the admin-managed config
|
||||
decrypts a stored token, which throws if the ciphertext cannot be authenticated (a rotated
|
||||
`SECRET_ENC_KEY`, or a database dump restored under a different key). That is caught inside
|
||||
the client and reported as unavailable, so a wrong key degrades the game's screens instead of
|
||||
500-ing it — and the admin config screen keeps working, which is the screen you need in order
|
||||
to recover.
|
||||
|
||||
### Sensitive events never reach the public
|
||||
|
||||
Ingested events fan out over two SSE channels: a **public allowlist** stream, and an
|
||||
**admin-only** stream that additionally carries staff audit, cheat detection and login
|
||||
attempts with IPs.
|
||||
|
||||
**The catalog is the module's; the boundary is core's.** A module declares which of its
|
||||
kinds are public-safe, and core enforces the split. A sensitive kind cannot reach the public
|
||||
channel.
|
||||
|
||||
### A failed module never takes the site down
|
||||
|
||||
The loader catches failures across a module's entire lifecycle and marks it
|
||||
`startup_failed`. The site comes up with that module's routes and navigation absent, and the
|
||||
admin panel says why. See [Module
|
||||
lifecycle](/docs/modules/module-lifecycle/#failure-is-contained-by-construction).
|
||||
|
||||
### Secrets are encrypted at rest
|
||||
|
||||
OAuth client secrets, the sidecar token, the Discord bot token and the mail transport's
|
||||
credentials are AES-256-GCM encrypted, keyed by `SECRET_ENC_KEY`. **The sidecar token and the
|
||||
mail credentials are write-only in the API** — neither is ever returned to any client; the
|
||||
email panel reports only that a password is *set*.
|
||||
|
||||
<Aside type="caution" title="Rotating that key orphans every stored secret">
|
||||
Nothing re-encrypts. What was stored under the old key can no longer be read, and every
|
||||
stored secret has to be entered again. See [Environment
|
||||
variables](/docs/reference/environment-variables/).
|
||||
</Aside>
|
||||
|
||||
## A deploy is two independent installs
|
||||
|
||||
Worth stating plainly, because it is the single most common misunderstanding: **the
|
||||
installer binary sets up the game server side only, and never contacts the website.** The website
|
||||
is a separate Docker deployment on, usually, a different machine.
|
||||
|
||||
The [installation path](/docs/getting-started/requirements/) walks both in order.
|
||||
|
||||
## Canonical documents
|
||||
|
||||
[`ARCHITECTURE.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/ARCHITECTURE.md)
|
||||
holds the canonical diagram, and
|
||||
[`BACKEND_DESIGN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/BACKEND_DESIGN.md)
|
||||
is the full API, schema and security contract. See [Canonical
|
||||
documents](/docs/reference/canonical-documents/) for the whole map.
|
||||