Compare commits
56 Commits
bb06f1de44
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| f499f2b72b | |||
| 971fa9c032 | |||
| a2faf07104 | |||
| 29c96d0b21 | |||
| 1313e748ae | |||
| fbd7bbe6fd | |||
| 2d19ee4220 | |||
| d9d7a8d47f | |||
| 556dee7355 |
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
|
||||
@@ -38,12 +38,73 @@ jobs:
|
||||
# rewrite replaces is distinctive enough to replace blindly.
|
||||
run: npm run check:brand
|
||||
|
||||
- name: Play Data Safety declaration
|
||||
# PLAN.md §9 / D33 — PLAY_DATA_SAFETY.md is generated from the same
|
||||
# src/data/collection.mjs rows that /privacy section 2 renders, so the published
|
||||
# policy and the answers given to Google cannot drift apart. This re-runs the
|
||||
# generator and fails if the committed copy differs.
|
||||
#
|
||||
# It runs before the build because it needs neither one: it is the cheapest check
|
||||
# here and the one whose failure is easiest to act on.
|
||||
run: npm run check:datasafety
|
||||
|
||||
- name: Types
|
||||
run: npm run check
|
||||
|
||||
- name: Unit tests
|
||||
# PLAN.md §8 — the beta signup's decision path: honeypot, form token, timing, rate
|
||||
# limit, cap, validation, duplicate, removal.
|
||||
#
|
||||
# The first thing in this repository that the other checks cannot see. They all read
|
||||
# the built output, and none of this appears there: a honeypot that has stopped
|
||||
# working produces a build that is identical in every way to one where it works.
|
||||
#
|
||||
# The test file is NAMED rather than the directory passed. `node --test test/` fails
|
||||
# on Node 22 with MODULE_NOT_FOUND — directory mode is not portable across the
|
||||
# versions this org runs, and this workflow pins 22 while developers are on 24, so
|
||||
# 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: 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
|
||||
|
||||
- name: Links
|
||||
# PLAN.md §12 — every internal link resolves, and every outbound link into a
|
||||
# RunicGateway repository points at a branch path rather than a commit permalink.
|
||||
#
|
||||
# It runs AFTER the build, and that ordering is the design rather than a
|
||||
# convenience: it reads the built HTML, so links assembled from data files and
|
||||
# template literals are checked as the strings they actually become. A source scan
|
||||
# would see an expression and skip most of what phase 4 added.
|
||||
#
|
||||
# No network: the outbound rule is about the shape of a URL, and a build that
|
||||
# fails because some other host is slow is a check people learn to ignore.
|
||||
run: npm run check:links
|
||||
|
||||
- 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.
|
||||
@@ -62,3 +123,80 @@ jobs:
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: npm run check:facts
|
||||
|
||||
- name: Quickstart against website main
|
||||
# PLAN.md §12, phase 7 (D35). /docs/getting-started/install-the-site/ prints a
|
||||
# Compose file and an environment file the reader copies without leaving the page,
|
||||
# which is the one place this site knowingly keeps a copy of another repo's file.
|
||||
#
|
||||
# So the copy is checked in BOTH directions: every value it states must match
|
||||
# website's own docker-compose.yml and .env.example on main, and every service and
|
||||
# variable THEY have must be either included or listed as deliberately omitted with
|
||||
# a reason. A new variable upstream turns this repo red until someone decides
|
||||
# whether a first install needs it — the same intent as the facts check above.
|
||||
#
|
||||
# Same token, and for the same reason: it reads another repository in the org.
|
||||
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 eleven, plus two test suites. `npm run verify` runs all of them in dependency order, and
|
||||
that is what CI does on every pull request.
|
||||
|
||||
```bash
|
||||
npm run verify
|
||||
```
|
||||
|
||||
Three of them read other repositories over the Gitea API and need a token with access to the
|
||||
organisation, not just this repository:
|
||||
|
||||
```bash
|
||||
GITEA_TOKEN=<token> npm run check:facts # every version agrees with its authority
|
||||
GITEA_TOKEN=<token> npm run check:quickstart # the install page still matches website's own files
|
||||
GITEA_TOKEN=<token> npm run check:reference # every name the Reference lists still exists
|
||||
```
|
||||
|
||||
Without a token they fail rather than skip, deliberately: a check that silently passes when it could
|
||||
not do its job is worse than no check. CI maps the org-level `REGISTRY_TOKEN` secret into
|
||||
`GITEA_TOKEN` for those three steps.
|
||||
|
||||
The README's "The checks, and why they are not optional" section explains what each one guards.
|
||||
Read it before adding a page — several of them constrain how a page may be written, particularly
|
||||
`check:tokens` (no colour literal outside `src/styles/tokens.css`) and `check:links` (no commit
|
||||
permalinks into org repositories).
|
||||
|
||||
## Writing for this site
|
||||
|
||||
- **Understated honesty** (D8). The site reads as finished. Where something is not built, the
|
||||
absence is stated as data in `src/data/notBuilt.mjs` and rendered — never implied by silence and
|
||||
never dressed up as a roadmap.
|
||||
- **No version in prose.** Every externally-sourced fact lives in `src/data/platform.json` and is
|
||||
re-read from its authority by `check:facts`. If you find yourself typing a version number into a
|
||||
sentence, put it in the data file instead.
|
||||
- **No email address in `src/` or `scripts/`** (D13). The published contact is a `brand.json` field
|
||||
so that changing it stays a file copy and a restart. `check:facts` enforces this.
|
||||
- **British spelling**, in common with the rest of the organisation's prose.
|
||||
|
||||
## Branch and pull-request workflow
|
||||
|
||||
1. Branch from `main` with a descriptive name (`feature/…`, `fix/…`, `docs/…`, `chore/…`).
|
||||
2. Keep changes focused; small pull requests are easier to review.
|
||||
3. Run `npm run verify` before opening the pull request.
|
||||
4. Push and open a pull request against `main`. Fill out the template, including the **AI-assisted
|
||||
contributions** disclosure.
|
||||
5. A maintainer will review; address feedback with follow-up commits.
|
||||
|
||||
### Commit messages
|
||||
|
||||
[Conventional Commits](https://www.conventionalcommits.org/) — `type(scope): summary`, in common
|
||||
with every repository in the organisation. For example `fix(docs): correct the installer flag on the
|
||||
quickstart`.
|
||||
|
||||
### What merging does
|
||||
|
||||
Merging to `main` builds a container image, publishes it to the Gitea registry and **deploys it**
|
||||
(`.gitea/workflows/build-image.yml`). There is no separate release step and no manual promotion, so
|
||||
a merge is a publication. [`DEPLOY.md`](DEPLOY.md) describes the whole path, including how to roll
|
||||
back to a previous build.
|
||||
|
||||
## AI-assisted contributions (disclosure required)
|
||||
|
||||
This project is developed openly with AI assistance, and we ask the same transparency of everyone.
|
||||
**If you used an AI tool** (Claude, Copilot, ChatGPT, Cursor, etc.) to help produce a contribution,
|
||||
you must disclose it:
|
||||
|
||||
- Tick the AI-usage box in the pull-request template and name the tool(s).
|
||||
- Mark AI-authored commits with a trailer, e.g. `Co-Authored-By: Claude <noreply@anthropic.com>` or
|
||||
`Assisted-By: <tool>`.
|
||||
- You remain responsible for every line you submit: review it, understand it, and make sure it is
|
||||
correct and that you have the right to contribute it.
|
||||
|
||||
Disclosed AI assistance is welcome. Undisclosed AI-generated contributions are not, and may be
|
||||
closed.
|
||||
|
||||
## Licence
|
||||
|
||||
Runic Gateway is licensed under the **GNU General Public License v3.0 or later** (see
|
||||
[LICENSE](LICENSE)). By submitting a contribution you agree that it is licensed under the same terms
|
||||
(inbound = outbound) and that you have the right to contribute it.
|
||||
|
||||
[org]: https://gitea.whitlocktech.com/RunicGateway
|
||||
[issues]: https://gitea.whitlocktech.com/RunicGateway/runicgateway.com/issues
|
||||
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-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-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"]
|
||||
131
PLAY_DATA_SAFETY.md
Normal file
@@ -0,0 +1,131 @@
|
||||
<!--
|
||||
GENERATED FILE — do not edit.
|
||||
|
||||
Source: src/data/collection.mjs (scope "app") + src/data/legal.mjs
|
||||
Generator: scripts/playDataSafety.mjs
|
||||
|
||||
Edit the data file and run `npm run play:datasafety`. CI runs the same
|
||||
generator with --check, so a hand edit here fails the build rather than
|
||||
quietly disagreeing with the published privacy policy.
|
||||
-->
|
||||
|
||||
# Google Play Data Safety — the answers, and what they are based on
|
||||
|
||||
The Play Console asks, for every category of data, whether the app **collects** it, whether it is **shared**, whether collection is **required or optional**, and *why*. This file holds the answers for the Runic Gateway Android app, generated from the same inventory the published privacy policy renders — see `/privacy`, section 2.
|
||||
|
||||
> **This is not a filled-in form.** Play’s definitions change and no check here can read them. Every answer below is a fact about the code with the reasoning attached; read the console’s current wording against them when you fill the form. What this file exists to prevent is somebody answering from memory about what the app stores.
|
||||
|
||||
## The premise every answer rests on
|
||||
|
||||
We operate **no server the app talks to.** The app ships pointed at nothing: its first screen asks for the address of a Runic Gateway deployment and validates it before anything else in the app runs. That deployment belongs to whoever runs that community. Data therefore travels from the device to *their* server, and there is no endpoint of ours anywhere in the path — not for content, not for telemetry, and not for crash reports, of which there are none.
|
||||
|
||||
That is why nearly every answer below is "not collected", and it is also the answer most likely to be questioned in a review. The supporting facts are in the table: each row names the file it was read out of.
|
||||
|
||||
Where the console offers free text about security practices, two things are worth saying: credentials are held in Android’s encrypted storage (AES-256-GCM via Jetpack Security), and push notifications carry **no content** — a relay receives a stream name and a reference, and the app fetches the actual message over its own authenticated connection.
|
||||
|
||||
## Data types
|
||||
|
||||
| Category | Data type | Collected by us | Shared by us | Answer |
|
||||
|---|---|---|---|---|
|
||||
| Personal info | User IDs | No | No | Not collected by us. |
|
||||
| Personal info | User IDs | No | No | Not collected by us. |
|
||||
| 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
|
||||
|
||||
### Your sign-in tokens
|
||||
|
||||
**Personal info → User IDs.** Not collected by us.
|
||||
|
||||
When you sign in to a deployment, the app keeps the access and refresh tokens it was issued, plus the username, role and account id they belong to. They are held in encrypted storage on the device (AES-256-GCM through Jetpack Security) and are sent to exactly one place: the deployment that issued them.
|
||||
|
||||
- **Why that answer:** The credentials are issued by, and returned to, a server the user nominated. Nothing reaches an endpoint under our control, because we run none.
|
||||
- **Retention:** On the device until you sign out
|
||||
- **In detail:** Signing out clears them; uninstalling the app removes them with it.
|
||||
- **Read from:** `core/auth/EncryptedTokenStore.kt`
|
||||
|
||||
### The trusted-device token, if you asked for one
|
||||
|
||||
**Personal info → User IDs.** Not collected by us.
|
||||
|
||||
Ticking “trust this device” during two-factor sign-in stores an opaque token so the deployment can skip the second factor next time. It lives in its own encrypted store, deliberately separate from the session, because it has to outlive a sign-out to be worth anything — and the deployment holds only a hash of it, so the copy on your phone is the only usable one.
|
||||
|
||||
- **Why that answer:** Same as the session tokens: minted by the user’s deployment, stored on the device, presented back to that same deployment.
|
||||
- **Retention:** On the device until it expires or you revoke it
|
||||
- **In detail:** Thirty days, and revocable at any time from the deployment’s Trusted Devices screen, which is also where it can be revoked if the phone is lost.
|
||||
- **Read from:** `core/auth/EncryptedTrustTokenStore.kt`
|
||||
|
||||
### The address of the deployment you chose
|
||||
|
||||
**App info and performance → Other app data.** Not collected by us. Stored on the device only.
|
||||
|
||||
The app ships pointed at nothing and asks for an address on first run. That address is stored in ordinary preferences rather than encrypted storage — it is not a secret, it is the equivalent of a bookmark — and it is what every other screen in the app talks to.
|
||||
|
||||
- **Why that answer:** It never leaves the phone. It is the destination of requests, not the contents of one.
|
||||
- **Retention:** On the device until you change it or uninstall
|
||||
- **Read from:** `core/prefs/ServerPreferences.kt`
|
||||
|
||||
### Push registration, if you turn notifications on
|
||||
|
||||
**Messages → Other in-app messages.** Not collected by us. Declare the relay hop in the console’s free-text security section if it asks.
|
||||
|
||||
Push is off until you enable it. When you do, the app mints a random, unguessable topic name on the notification relay the deployment nominates, and registers that topic’s URL with the deployment so it has somewhere to send a nudge. What actually travels through the relay is content-free — a stream name and a reference, never the message — and the app then fetches the real content over its authenticated connection to the deployment. A leaked topic name therefore reveals nothing, which is the reason the relay needs no account and holds nothing about you.
|
||||
|
||||
- **Why that answer:** The notification passes through a relay chosen by the deployment, and it carries no content — the app pulls the content itself, authenticated. Neither hop reaches a server we operate.
|
||||
- **Retention:** Until you turn push off, sign out, or uninstall
|
||||
- **In detail:** Signing out or disabling push unregisters the device with the deployment and discards the topic. The relay retains whatever its own operator configures it to; if the deployment points at a relay it does not run, that relay is a third party to both of us, and it still only ever sees a tickle.
|
||||
- **Read from:** `core/push/NtfyTopic.kt, core/push/PushPreferences.kt`
|
||||
|
||||
### Everything you read and post in the app
|
||||
|
||||
**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. 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.
|
||||
|
||||
There is no third-party SDK in the app at all — no Firebase, no Crashlytics, no advertising identifier, no measurement library. That is checkable rather than claimed: it is what the dependency list and the manifest say, and a build that gained one would gain permissions with it.
|
||||
|
||||
- **Why that answer:** No advertising ID, no analytics identifier, and no library that would generate one is linked into the build.
|
||||
- **Retention:** Nothing to retain
|
||||
- **Read from:** `app/build.gradle.kts, app/src/main/AndroidManifest.xml`
|
||||
|
||||
## The rest of the listing
|
||||
|
||||
- **Privacy policy URL:** `/privacy` on this site. It is the URL Play is given, and section 2 of it is about the app specifically.
|
||||
- **Target audience:** adults. The beta is stated as **18 or older** (D31); the app contains no content directed at children and no age verification.
|
||||
- **Account deletion:** the app creates no account with us — an account belongs to the deployment the user chose, and is deleted there. The only list we hold is the beta signup, which is erased on request; `/privacy` section 4 says how to ask.
|
||||
- **Data deletion request URL:** the contact address published on `/privacy`, which is read from the mounted `brand.json` rather than typed anywhere in the source (D13).
|
||||
|
||||
## What the website collects, for the same reviewer
|
||||
|
||||
Not part of the Data Safety form — that form is about the app — but a reviewer who follows the privacy policy URL lands on a page covering three things, so it is worth knowing which of them the site itself is responsible for:
|
||||
|
||||
- **Your email address** — Until the beta ends, or until you ask.
|
||||
- **The wording you agreed to, and when** — For the life of the row.
|
||||
- **A one-way hash of your IP address — never the address** — With the row; the rate-limit log is pruned after 48 hours.
|
||||
- **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-09-01. Regenerate with `npm run play:datasafety` after any change to what the app stores.
|
||||
161
README.md
@@ -11,10 +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 1 of 12 — the foundation.** The scaffold, the token file, the typography, the layout
|
||||
shell and the two build-time checks are in place. The homepage is phase 3, the marketing pages
|
||||
phase 4, and the documentation — the installation path, which is the priority of the whole project —
|
||||
phase 7.
|
||||
**Status: phase 12 of 12 — delivery. The site is built.** Fifty pages: ten marketing, legal and
|
||||
app pages and forty of documentation, with real screenshots of the product, full-text search, a
|
||||
per-page Content-Security-Policy, eleven checks that fail the build when the platform moves out from
|
||||
under a claim, and a closed-beta signup backed by SQLite on a bind mount. This phase is the part
|
||||
that makes it a deployment rather than a repository — the container image, the compose file, the
|
||||
publishing workflow and [`DEPLOY.md`](DEPLOY.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -30,18 +32,44 @@ npm run build # → dist/ (prerendered pages + the Node server entry)
|
||||
npm start # serve the built site
|
||||
```
|
||||
|
||||
Node 22 LTS or newer.
|
||||
Node 22 LTS or newer. Nothing else — no database, no game server, no container runtime.
|
||||
|
||||
## Running it in production
|
||||
|
||||
One container, pulled from the Gitea registry, with two bind mounts and a reverse proxy in front.
|
||||
**[`DEPLOY.md`](DEPLOY.md) is the operator's guide**: first deploy, what the proxy must and must not
|
||||
do, DNS and TLS, branding without a rebuild, managing the tester list, rolling back, and the
|
||||
symptoms table.
|
||||
|
||||
```bash
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
Merging to `main` builds the image, publishes it as `runicgateway-site:latest` and `:sha-<7>`, and
|
||||
deploys it — `.gitea/workflows/build-image.yml`. There is no separate release step, so **a merge is
|
||||
a publication**.
|
||||
|
||||
## The checks, and why they are not optional
|
||||
|
||||
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.
|
||||
Eleven 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:screens # every screenshot has an entry, at the size declared
|
||||
npm run check:tokens # no colour literal outside the token file
|
||||
GITEA_TOKEN=<token> npm run check:facts # every version agrees with its authority
|
||||
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 run verify # all of the above, then a production build
|
||||
npm test # the beta signup's decision path, and the policy data
|
||||
npm run build # everything below reads the build
|
||||
npm run check:links # every internal link resolves
|
||||
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
|
||||
```
|
||||
|
||||
**`checkFacts.mjs`** re-reads every version, protocol number and bundle tag in
|
||||
@@ -71,6 +99,60 @@ every literal `/brand/...` URL in the source through the route's own classifier,
|
||||
asking for a size that is not on the allowlist fails the build rather than 404ing in a browser; and
|
||||
it refuses a brand string short enough that replacing it blindly at boot could corrupt a page.
|
||||
|
||||
**`checkLinks.mjs`** reads `dist/client` rather than `src/`, because half the links these pages
|
||||
carry are assembled from data files and template literals and a source scan sees an expression. It
|
||||
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
|
||||
window, the per-connection rate limit, the global cap, address validation, idempotent duplicates and
|
||||
removal. Run the file by name — `node --test test/` fails on Node 22, which is what CI uses.
|
||||
|
||||
## The closed-beta signup
|
||||
|
||||
`/beta` is the only page that renders per request and the only one that writes anything. It handles
|
||||
its own POST, so the form works with JavaScript disabled and every outcome renders in the real
|
||||
layout. The store is SQLite on the `data/` bind mount; **the raw IP address is never recorded**,
|
||||
only a salted hash used to rate-limit.
|
||||
|
||||
There is no admin page, by design — the tester list is managed from a shell:
|
||||
|
||||
```bash
|
||||
npm run beta -- stats # counts, and where the store lives
|
||||
npm run beta -- export # a CSV record + a .txt to paste into Play; marks rows exported
|
||||
npm run beta -- export -- --all # everything, including already-exported rows
|
||||
npm run beta -- remove someone@example.com
|
||||
```
|
||||
|
||||
| Variable | Default | What it does |
|
||||
|---|---|---|
|
||||
| `DATA_DIR` | `./data` | The bind mount holding `beta.sqlite` and `exports/` |
|
||||
| `BETA_IP_SALT` | random per process | Salts `ip_hash`. Unset means rate limits reset on restart |
|
||||
| `BETA_FORM_KEY` | random per process | Signs the form token, so a script must fetch the page before posting |
|
||||
| `BETA_TOTAL_CAP` | `500` | Rows above which the form closes and says so |
|
||||
| `BETA_PER_HOUR` / `BETA_PER_DAY` | `3` / `24` | Attempts one connection may make |
|
||||
|
||||
Neither random default is a placeholder to be replaced by a constant: a hard-coded salt would make
|
||||
every deployment's hashes identical and therefore reversible by anyone holding this repository.
|
||||
|
||||
## Branding is bind-mounted data
|
||||
|
||||
`brand-default/` is baked into the image and always complete. `brand/` is the bind mount and may be
|
||||
@@ -104,24 +186,74 @@ 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
|
||||
|
||||
```
|
||||
src/
|
||||
data/platform.json Every externally-sourced fact. No version is written in prose.
|
||||
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.
|
||||
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/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.
|
||||
lib/brand.mjs The single accessor for brand text.
|
||||
lib/brand.mjs The single accessor for brand text, plus liveBrand() for the two
|
||||
routes that render per request and so miss the boot rewrite.
|
||||
lib/brandAssets.mjs Mount-first resolution and on-demand derivation.
|
||||
lib/betaStore.mjs The SQLite store: schema, dedupe, rate-limit window, cap, removal.
|
||||
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) and brand:assets (manual).
|
||||
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
|
||||
@@ -132,13 +264,18 @@ and §7.
|
||||
|
||||
Branch from `main` (`feature/…`, `fix/…`, `docs/…`, `chore/…`) and use
|
||||
[Conventional Commits](https://www.conventionalcommits.org/). Run `npm run verify` before opening a
|
||||
pull request.
|
||||
pull request. **[CONTRIBUTING.md](CONTRIBUTING.md)** has the rest, including the two rules from
|
||||
`PLAN.md` that constrain how a page may be written at all: the site never re-specifies a contract,
|
||||
and no fact is stated in prose.
|
||||
|
||||
**AI-assisted contributions must be disclosed**, per org policy: tick the box in the pull request
|
||||
template naming the tool, and mark AI-authored commits with a trailer such as
|
||||
`Co-Authored-By: Claude <noreply@anthropic.com>`. Undisclosed AI-generated contributions may be
|
||||
closed.
|
||||
|
||||
Security problems go to [SECURITY.md](SECURITY.md), never to a public issue. Everyone taking part is
|
||||
covered by the [Code of Conduct](CODE_OF_CONDUCT.md).
|
||||
|
||||
## Licence
|
||||
|
||||
GPL-3.0-or-later, in common with every repository in the organisation. See [LICENSE](LICENSE).
|
||||
|
||||
43
SECURITY.md
Normal file
@@ -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,7 @@ import { defineConfig } from 'astro/config';
|
||||
import node from '@astrojs/node';
|
||||
import starlight from '@astrojs/starlight';
|
||||
|
||||
import { inlineScriptHashes, inlineStyleHashes } from './src/config/cspHashes.mjs';
|
||||
import { docsSidebar } from './src/config/sidebar.mjs';
|
||||
|
||||
/**
|
||||
@@ -20,13 +21,72 @@ 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',
|
||||
},
|
||||
|
||||
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 +109,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,
|
||||
|
||||
@@ -28,5 +28,20 @@
|
||||
"is a non-empty URL, so the site gains a working demo by way of one line in a mounted",
|
||||
"file — no rebuild, consistent with §7."
|
||||
],
|
||||
"demoUrl": ""
|
||||
"demoUrl": "",
|
||||
|
||||
"$comment_beta": [
|
||||
"PLAN.md §8 / D27. The Google Play closed-test opt-in URL. Empty until the track",
|
||||
"exists, and /beta renders a waiting state rather than a broken link while it is.",
|
||||
"",
|
||||
"It is safe to publish once it is filled in, and that is the whole reason the beta can",
|
||||
"work with a site that sends no email (D7): the opt-in link only works for addresses",
|
||||
"already on the tester list, so anyone else who opens it is refused. Google does not",
|
||||
"notify testers on the email-list path either — Discord carries the announcement.",
|
||||
"",
|
||||
"/beta is one of the two routes that render per request, so unlike every other field",
|
||||
"here this one is read from the mounted copy on the NEXT REQUEST rather than at the",
|
||||
"next restart. Paste the URL in and reload the page."
|
||||
],
|
||||
"betaOptInUrl": ""
|
||||
}
|
||||
|
||||
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"
|
||||
1262
package-lock.json
generated
24
package.json
@@ -12,13 +12,27 @@
|
||||
"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",
|
||||
"check:brand": "node scripts/checkBrand.mjs",
|
||||
"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: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/footer.test.mjs",
|
||||
"brand:assets": "node scripts/buildBrandAssets.mjs",
|
||||
"verify": "npm run check:tokens && npm run check:brand && npm run check:facts && npm run check && npm run build"
|
||||
"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: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: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",
|
||||
@@ -26,11 +40,15 @@
|
||||
"@fontsource-variable/cinzel": "^5.3.0",
|
||||
"@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",
|
||||
"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-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-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/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 |
@@ -175,6 +175,34 @@ if (demoFrom !== demoTo) {
|
||||
replacements.push({ field: 'demoUrl', from: attr(demoFrom), to: attr(demoTo) });
|
||||
}
|
||||
|
||||
/**
|
||||
* The demo's DEEP links (§15 / D25), which `/features/` writes one of per capability that
|
||||
* has a stable public route:
|
||||
*
|
||||
* <a class="demo-link" href="" data-demo-url="" data-demo-path="/uo/market">see it live</a>
|
||||
*
|
||||
* The slot above cannot express these. It is a literal string swap of a whole URL, so it
|
||||
* can only ever put the demo's root in an `href` — and reversing it would not even find a
|
||||
* deep link, whose `href` is the root plus a path and therefore matches no literal the
|
||||
* script knows.
|
||||
*
|
||||
* This pass is a different shape on purpose: it does not replace a previous value, it
|
||||
* RECOMPUTES both attributes from `data-demo-path`, which never changes. That makes it
|
||||
* idempotent and exactly reversible, so it runs unconditionally in the loop below rather
|
||||
* than only when the demo URL moved. `data-demo-url` is still filled with the bare root
|
||||
* because `global.css` hides `[data-demo-url='']` — the visibility rule stays one rule for
|
||||
* both kinds of link, and only the `href` differs.
|
||||
*/
|
||||
const DEEP_LINK = /href="[^"]*" data-demo-url="[^"]*" data-demo-path="([^"]*)"/g;
|
||||
|
||||
const deepLinkTo = (demoPath) => {
|
||||
const href = demoTo ? `${demoTo.replace(/\/+$/, '')}${demoPath}` : '';
|
||||
return (
|
||||
`href="${escapeHtml(href)}" data-demo-url="${escapeHtml(demoTo)}" ` +
|
||||
`data-demo-path="${demoPath}"`
|
||||
);
|
||||
};
|
||||
|
||||
if (!replacements.length) {
|
||||
console.log('[brand] mount matches what is already applied; nothing to rewrite.');
|
||||
process.exit(0);
|
||||
@@ -198,18 +226,89 @@ function* walk(dir) {
|
||||
}
|
||||
|
||||
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);
|
||||
after = after.split(from).join(to);
|
||||
}
|
||||
|
||||
// After the literal swaps, never before: the plain-slot replacement also matches the
|
||||
// first two attributes of a deep link, so it runs first and this pass corrects the
|
||||
// `href` it just wrote. Recomputing rather than replacing is what makes that safe.
|
||||
after = after.replace(DEEP_LINK, (whole, demoPath) => {
|
||||
const rebuilt = deepLinkTo(demoPath);
|
||||
if (rebuilt !== whole) counts.set('demoDeep', counts.get('demoDeep') + 1);
|
||||
return rebuilt;
|
||||
});
|
||||
|
||||
if (after !== before) {
|
||||
writeFileSync(file, after);
|
||||
filesTouched++;
|
||||
@@ -226,7 +325,53 @@ for (const { field, from, to } of replacements) {
|
||||
if (demoFrom !== demoTo) {
|
||||
console.log(` ${'demoUrl'.padEnd(14)} ${demoTo ? `slot shown -> ${demoTo}` : 'slot hidden'} (${counts.get('demoUrl')}x)`);
|
||||
}
|
||||
if (counts.get('demoDeep')) {
|
||||
console.log(
|
||||
` ${'demoUrl deep'.padEnd(14)} ${demoTo ? `linked -> ${demoTo}/…` : 'links hidden'} (${counts.get('demoDeep')}x)`
|
||||
);
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
||||
169
scripts/beta.mjs
Normal file
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* beta.mjs — the closed-beta tester list, from the shell. PLAN.md §8, phase 5.
|
||||
*
|
||||
* node scripts/beta.mjs export → data/exports/<date>.csv, marks rows exported
|
||||
* node scripts/beta.mjs export --all → everything, including already-exported rows
|
||||
* node scripts/beta.mjs remove <email> → a deletion request
|
||||
* node scripts/beta.mjs stats
|
||||
*
|
||||
* In the container, with the compose file of §6:
|
||||
*
|
||||
* docker compose exec site node scripts/beta.mjs export
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY THIS IS A CLI AND NOT AN ADMIN PAGE
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* §8 is explicit, and the argument is worth restating where somebody might be tempted to
|
||||
* "improve" it. An authenticated HTTP surface on a marketing site is a login form, a
|
||||
* session, a password to rotate, a lockout policy and a thing to patch — brought into
|
||||
* existence for an operation performed by the one person who already has shell on the host,
|
||||
* against a file already on their disk. Adding it would mean this site had an attack
|
||||
* surface where it currently has none, and the only thing gained is not having to type a
|
||||
* command.
|
||||
*
|
||||
* The CSV lands in the bind mount and is opened locally. Google Play has no API for adding
|
||||
* an individual tester — every route into a closed test ends with a human pasting a list —
|
||||
* so the last step is manual no matter how this is built.
|
||||
*
|
||||
* `remove` exists because §9 promises deletion on request, and a promise with no mechanism
|
||||
* behind it is a sentence.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
EXPORT_DIR,
|
||||
close,
|
||||
markExported,
|
||||
pending,
|
||||
removeSignup,
|
||||
stats,
|
||||
} from '../src/lib/betaStore.mjs';
|
||||
|
||||
const [command, ...rest] = process.argv.slice(2);
|
||||
|
||||
const USAGE = `
|
||||
node scripts/beta.mjs export [--all] write a CSV of the tester list
|
||||
node scripts/beta.mjs remove <email> honour a deletion request
|
||||
node scripts/beta.mjs stats counts, and where the store lives
|
||||
`;
|
||||
|
||||
/**
|
||||
* RFC 4180 quoting. Overkill for addresses that have already been validated against a
|
||||
* regex that admits no commas or quotes — and worth having anyway, because the day this
|
||||
* function is wrong is the day somebody pastes a corrupted list into a system that emails
|
||||
* strangers, and nothing about that failure would be visible in the CSV.
|
||||
*/
|
||||
const csvCell = (value) => {
|
||||
const text = value === null || value === undefined ? '' : String(value);
|
||||
return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
|
||||
};
|
||||
|
||||
function doExport(all) {
|
||||
const rows = pending({ all });
|
||||
|
||||
if (!rows.length) {
|
||||
console.log(
|
||||
all
|
||||
? 'Nothing to export — the list is empty.'
|
||||
: 'Nothing new to export. Use --all to re-export rows already marked exported.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
fs.mkdirSync(EXPORT_DIR, { recursive: true });
|
||||
|
||||
// Dated rather than sequential, and suffixed only if a second export happens the same
|
||||
// day: the file name should say when the list was taken, because that is the question
|
||||
// being asked when somebody finds three of these in a directory next year.
|
||||
const day = new Date().toISOString().slice(0, 10);
|
||||
let file = path.join(EXPORT_DIR, `${day}.csv`);
|
||||
for (let n = 2; fs.existsSync(file); n += 1) {
|
||||
file = path.join(EXPORT_DIR, `${day}-${n}.csv`);
|
||||
}
|
||||
|
||||
// Two files, deliberately. Play's tester list wants addresses and nothing else — one per
|
||||
// line, ready to paste — while the CSV is the record: when they signed up, what they
|
||||
// agreed to, what state the row is in. Producing only the CSV would mean hand-editing it
|
||||
// before every paste, which is where a mistake would come from.
|
||||
const csv = [
|
||||
['id', 'email', 'created_at', 'status', 'consent_text'].join(','),
|
||||
...rows.map((row) =>
|
||||
[row.id, row.email, row.created_at, row.status, row.consent_text].map(csvCell).join(',')
|
||||
),
|
||||
].join('\r\n');
|
||||
|
||||
const listFile = file.replace(/\.csv$/, '.txt');
|
||||
fs.writeFileSync(file, `${csv}\r\n`, 'utf8');
|
||||
fs.writeFileSync(listFile, `${rows.map((row) => row.email).join('\n')}\n`, 'utf8');
|
||||
|
||||
const marked = markExported(rows.map((row) => row.id));
|
||||
|
||||
console.log(`Wrote ${rows.length} row(s):`);
|
||||
console.log(` ${file} the record`);
|
||||
console.log(` ${listFile} paste this into Play`);
|
||||
console.log(`Marked ${marked} row(s) exported.`);
|
||||
console.log(
|
||||
'\nPlay Console → Testing → Closed testing → your track → Testers → paste the list.\n' +
|
||||
'Testers still have to open the opt-in link themselves; being on the list is not enough.'
|
||||
);
|
||||
}
|
||||
|
||||
function doRemove(email) {
|
||||
if (!email) {
|
||||
console.error('remove needs an address: node scripts/beta.mjs remove someone@example.com');
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
|
||||
const result = removeSignup(email.trim().toLowerCase());
|
||||
|
||||
if (result.removed) {
|
||||
console.log(`Removed #${result.id}. The address is overwritten, not just flagged.`);
|
||||
console.log(
|
||||
'If that row was already exported, remove the address from the Play tester list too — ' +
|
||||
'this store is not the only copy once a CSV has been pasted.'
|
||||
);
|
||||
} else if (result.alreadyRemoved) {
|
||||
console.log(`#${result.id} was already removed. Nothing to do.`);
|
||||
} else {
|
||||
console.log('No such address on the list. Nothing to do.');
|
||||
}
|
||||
}
|
||||
|
||||
function doStats() {
|
||||
const s = stats();
|
||||
const rows = [
|
||||
['store', s.path],
|
||||
['total rows', s.total],
|
||||
['new (not yet exported)', s.new],
|
||||
['exported', s.exported],
|
||||
['removed', s.removed],
|
||||
['counting toward the cap', `${s.live} / ${s.cap}`],
|
||||
['attempts, last 24h', s.attemptsLastDay],
|
||||
];
|
||||
|
||||
const width = Math.max(...rows.map(([label]) => label.length));
|
||||
for (const [label, value] of rows) console.log(` ${String(label).padEnd(width)} ${value}`);
|
||||
}
|
||||
|
||||
try {
|
||||
switch (command) {
|
||||
case 'export':
|
||||
doExport(rest.includes('--all'));
|
||||
break;
|
||||
case 'remove':
|
||||
doRemove(rest[0]);
|
||||
break;
|
||||
case 'stats':
|
||||
doStats();
|
||||
break;
|
||||
default:
|
||||
console.log(USAGE);
|
||||
process.exitCode = command ? 2 : 0;
|
||||
}
|
||||
} finally {
|
||||
close();
|
||||
}
|
||||
189
scripts/captureScreens.mjs
Normal file
@@ -0,0 +1,189 @@
|
||||
#!/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 });
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -148,6 +148,7 @@ if (brand) {
|
||||
'discordInvite',
|
||||
'giteaOrg',
|
||||
'demoUrl',
|
||||
'betaOptInUrl',
|
||||
];
|
||||
|
||||
for (const field of REQUIRED_FIELDS) {
|
||||
@@ -213,9 +214,175 @@ if (brand) {
|
||||
' be string-replaced. It is handled by the data-attribute gate instead.'
|
||||
);
|
||||
}
|
||||
|
||||
// betaOptInUrl is out for the same arithmetic reason and a second, stronger one: the
|
||||
// only page that reads it renders per request, so it never passes through the boot
|
||||
// rewrite at all. `liveBrand()` in src/lib/brand.mjs reads the mount directly. Putting
|
||||
// it in TEXT_FIELDS would not make it work — it would be a rewrite that never matches.
|
||||
if (rewritable.includes('betaOptInUrl')) {
|
||||
fail(
|
||||
'betaOptInUrl must not be in TEXT_FIELDS: its default is the empty string, and\n' +
|
||||
' /beta is server-rendered, so it reads the mounted brand.json via liveBrand().'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* =======================================================================================
|
||||
4. The demo slot's markup contract (§15 / D12)
|
||||
=======================================================================================
|
||||
|
||||
`applyBrand.mjs` reveals the demo link by string-replacing an exact pair of empty
|
||||
attributes in the built HTML. That is a contract between a script and a template that
|
||||
share no code, and it fails in the quietest possible way: an attribute inserted between
|
||||
the two, or `href` written after `data-demo-url`, produces a build where the demo URL is
|
||||
set in the mount, the boot log says nothing, and the link is simply never there.
|
||||
|
||||
Both halves are checked, and neither is retyped from memory — the literal is derived from
|
||||
the same expression `applyBrand.mjs` uses, so the two cannot drift apart. */
|
||||
|
||||
const applyForCheck = existsSync(path.join(ROOT, 'scripts/applyBrand.mjs'))
|
||||
? readFileSync(path.join(ROOT, 'scripts/applyBrand.mjs'), 'utf8')
|
||||
: '';
|
||||
|
||||
const attrTemplate = applyForCheck.match(
|
||||
/`href="\$\{escapeHtml\(value\)\}" data-demo-url="\$\{escapeHtml\(value\)\}"`/
|
||||
);
|
||||
|
||||
if (!attrTemplate) {
|
||||
fail(
|
||||
'applyBrand.mjs no longer builds the demo attributes as `href="..." data-demo-url="..."`.\n' +
|
||||
' Update the expected pair below to match, and re-check every template that writes it.'
|
||||
);
|
||||
} else {
|
||||
// What the script will look for when the applied value is the stock empty string.
|
||||
const EMPTY_PAIR = 'href="" data-demo-url=""';
|
||||
|
||||
let slots = 0;
|
||||
const strays = [];
|
||||
|
||||
for await (const file of walk(path.join(ROOT, 'src'))) {
|
||||
if (path.extname(file) !== '.astro') continue;
|
||||
|
||||
// Comments discuss the contract at length, including in the template that implements
|
||||
// it. Scanning them would make the check fail on its own documentation.
|
||||
// Blanked rather than removed: keeping every newline and every offset means the line
|
||||
// numbers reported below are the ones in the file, not the ones in a shortened copy.
|
||||
const blank = (match) => match.replace(/[^\n]/g, ' ');
|
||||
const source = readFileSync(file, 'utf8')
|
||||
.replace(/\/\*[\s\S]*?\*\//g, blank)
|
||||
.replace(/<!--[\s\S]*?-->/g, blank);
|
||||
|
||||
const relative = path.relative(ROOT, file);
|
||||
|
||||
slots += source.split(EMPTY_PAIR).length - 1;
|
||||
|
||||
for (const match of source.matchAll(/data-demo-url/g)) {
|
||||
const start = match.index - EMPTY_PAIR.indexOf('data-demo-url');
|
||||
if (source.slice(start, start + EMPTY_PAIR.length) !== EMPTY_PAIR) {
|
||||
strays.push(`${relative}:${source.slice(0, match.index).split('\n').length}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!slots) {
|
||||
fail(
|
||||
`no demo slot found in src/**/*.astro — expected the literal \`${EMPTY_PAIR}\`.\n` +
|
||||
' §15 reserves this slot so that gaining a demo instance is one line in the mounted\n' +
|
||||
' brand.json. Removing it makes that a rebuild.'
|
||||
);
|
||||
}
|
||||
|
||||
for (const site of strays) {
|
||||
fail(
|
||||
`${site} writes data-demo-url outside the exact pair \`${EMPTY_PAIR}\`.\n` +
|
||||
' applyBrand.mjs replaces that literal at boot; anything else is invisible to it and\n' +
|
||||
' the slot will never appear.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* =======================================================================================
|
||||
5. The demo DEEP-link contract (§15 / D25)
|
||||
=======================================================================================
|
||||
|
||||
`/features/` links individual capabilities into the demo, which the slot in §4 cannot
|
||||
express — it swaps a whole URL, so it can only ever produce the demo's root. Those links
|
||||
carry a third attribute and `applyBrand.mjs` recomputes all three from it.
|
||||
|
||||
Same failure mode as §4 and the same reason to check it: a template and a script with no
|
||||
shared code, agreeing on an exact byte sequence, where disagreement is silent. This one
|
||||
is worse in one respect — a broken deep link is INVISIBLE in a stock build, because the
|
||||
stock build hides every demo link. It would first appear on the day the org lead sets
|
||||
`demoUrl` and finds the new links pointing at the demo's front page, or at nothing.
|
||||
|
||||
The regex is not retyped here either: it is lifted out of `applyBrand.mjs` and run
|
||||
against the stock literal, so this fails if the script's pattern stops matching what the
|
||||
templates write — whichever side moved. */
|
||||
|
||||
const deepPattern = applyForCheck.match(/const DEEP_LINK = \/(.*)\/g;/);
|
||||
const EMPTY_DEEP_PREFIX = 'href="" data-demo-url="" ';
|
||||
let deepLinkCount = 0;
|
||||
|
||||
if (!deepPattern) {
|
||||
fail(
|
||||
'applyBrand.mjs no longer defines DEEP_LINK as a single /…/g literal.\n' +
|
||||
' §15/D25 relies on it to fill the per-capability demo links. Update this check to\n' +
|
||||
' match the new shape rather than deleting it.'
|
||||
);
|
||||
} else {
|
||||
// Does the script's own pattern still match what a template writes in a stock build?
|
||||
const sample = `${EMPTY_DEEP_PREFIX}data-demo-path="/example"`;
|
||||
let matches = false;
|
||||
try {
|
||||
matches = new RegExp(deepPattern[1]).test(sample);
|
||||
} catch (error) {
|
||||
fail(`applyBrand.mjs's DEEP_LINK is not a usable pattern: ${error.message}`);
|
||||
}
|
||||
|
||||
if (!matches) {
|
||||
fail(
|
||||
`applyBrand.mjs's DEEP_LINK no longer matches the stock markup \`${sample}\`.\n` +
|
||||
' Every per-capability demo link would be left empty and hidden, on a deployment\n' +
|
||||
' that has a demo configured — which is the one place nobody would look.'
|
||||
);
|
||||
}
|
||||
|
||||
const deepStrays = [];
|
||||
let deepLinks = 0;
|
||||
|
||||
for await (const file of walk(path.join(ROOT, 'src'))) {
|
||||
if (path.extname(file) !== '.astro') continue;
|
||||
|
||||
// Blanked, not stripped — same reason as §4: the line numbers reported have to be the
|
||||
// ones in the file.
|
||||
const blank = (match) => match.replace(/[^\n]/g, ' ');
|
||||
const source = readFileSync(file, 'utf8')
|
||||
.replace(/\/\*[\s\S]*?\*\//g, blank)
|
||||
.replace(/<!--[\s\S]*?-->/g, blank);
|
||||
|
||||
const relative = path.relative(ROOT, file);
|
||||
|
||||
for (const match of source.matchAll(/data-demo-path/g)) {
|
||||
deepLinks++;
|
||||
const start = match.index - EMPTY_DEEP_PREFIX.length;
|
||||
if (start < 0 || source.slice(start, match.index) !== EMPTY_DEEP_PREFIX) {
|
||||
deepStrays.push(`${relative}:${source.slice(0, match.index).split('\n').length}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const site of deepStrays) {
|
||||
fail(
|
||||
`${site} writes data-demo-path without the exact prefix \`${EMPTY_DEEP_PREFIX}\`.\n` +
|
||||
' applyBrand.mjs matches all three attributes together and in that order; anything\n' +
|
||||
' else is invisible to it and the link will never point anywhere.'
|
||||
);
|
||||
}
|
||||
|
||||
deepLinkCount = deepLinks;
|
||||
}
|
||||
|
||||
/* ======================================================================================= */
|
||||
|
||||
if (failures.length) {
|
||||
@@ -227,5 +394,6 @@ if (failures.length) {
|
||||
|
||||
console.log(
|
||||
`checkBrand: brand-default is complete, ${referenced.size} /brand/ URL(s) resolve, ` +
|
||||
`and every rewritable string is safe to replace.`
|
||||
`every rewritable string is safe to replace, and the demo slot plus ${deepLinkCount} ` +
|
||||
`deep link(s) match their contracts.`
|
||||
);
|
||||
|
||||
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();
|
||||
|
||||
@@ -117,7 +137,29 @@ async function checkModuleApi() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. The current bundle
|
||||
// 4. The capabilities the installed 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
|
||||
// homepage renders the list rather than merely storing it: `src/data/capabilities.mjs`
|
||||
// asserts at build time that every declared slug is claimed by a named capability on the
|
||||
// page and vice versa. Without this check that assertion was anchored to a local copy
|
||||
// nobody was verifying, so the whole chain rested on someone remembering.
|
||||
//
|
||||
// Sorted before comparing: the manifest's order is the module's business, and a reordered
|
||||
// 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();
|
||||
|
||||
record('moduleUoCapabilities', expected.join(' '), declared.join(' '), authority);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. The current bundle
|
||||
//
|
||||
// The manifests live at the ROOT of the `bundles` branch — `current.json`,
|
||||
// `bundle-<tag>.json` — not under `bundles/`. Fetching the directory 404s.
|
||||
@@ -141,7 +183,7 @@ async function checkBundle() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Release versions, per repo
|
||||
// 6. Release versions, per repo
|
||||
// ---------------------------------------------------------------------------
|
||||
async function checkReleases() {
|
||||
for (const [repo, expected] of Object.entries(platform.releases)) {
|
||||
@@ -152,7 +194,7 @@ async function checkReleases() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. `website` still publishes nothing
|
||||
// 7. `website` still publishes nothing
|
||||
//
|
||||
// It ships as container images and is never tagged, so the site refers to the platform by
|
||||
// bundle tag and Module API version instead. The day that changes, this repo should notice
|
||||
@@ -165,7 +207,48 @@ async function checkWebsiteHasNoReleases() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. D13 — the contact address lives in exactly one file
|
||||
// 8. The Android APK `/app/` offers, and the Android version it claims to need
|
||||
//
|
||||
// The app is on no store, so the download block on `/app/` links straight at a release
|
||||
// asset — the one kind of link on this site that 404s the moment a filename changes,
|
||||
// because the filename carries the version. Both asset names are asserted against
|
||||
// releases/latest, so a release that renames or drops either turns this repo red before a
|
||||
// visitor finds a dead link.
|
||||
//
|
||||
// `minSdk` is checked for a different reason. "Android 10 or newer" is prose derived from a
|
||||
// number, and it is exactly the kind of derived claim §12 exists to stop rotting: raising
|
||||
// the minimum in the app would otherwise leave this site telling people with Android 10
|
||||
// that it works for them. The mapping from API level to the marketing version is a fixed
|
||||
// table, so checking the number is enough to protect the sentence.
|
||||
//
|
||||
// What is NOT checked is `serviceable` — see the comment beside it in platform.json. No
|
||||
// fetch can tell whether a build works, so that value is a person's word, and the site
|
||||
// treats it as the gate on the link rather than the link as the gate on itself.
|
||||
// ---------------------------------------------------------------------------
|
||||
async function checkAndroidApk() {
|
||||
const authority = 'Android-app releases/latest assets';
|
||||
const release = await json('Android-app/releases/latest');
|
||||
const names = new Set((release.assets || []).map((asset) => asset.name));
|
||||
|
||||
const apk = platform.androidApk;
|
||||
record(`apk asset`, true, names.has(apk.asset), `${authority} → ${apk.asset}`);
|
||||
record(`apk checksums`, true, names.has(apk.checksums), `${authority} → ${apk.checksums}`);
|
||||
|
||||
// The asset name carries the version, so it has to agree with the release this site
|
||||
// already quotes — a mismatch here means one of the two was updated alone.
|
||||
const tag = String(release.tag_name || '').replace(/^v/, '');
|
||||
record('apk names the release', true, apk.asset.includes(tag), `${authority} → ${release.tag_name}`);
|
||||
|
||||
const gradleAuthority = 'Android-app main:app/build.gradle.kts';
|
||||
const gradle = await raw('Android-app', 'app/build.gradle.kts', 'main');
|
||||
const minSdk = Number(
|
||||
extract(gradle, /minSdk\s*=\s*(\d+)/, 'minSdk', gradleAuthority)
|
||||
);
|
||||
record('android minSdk', apk.minSdk, minSdk, gradleAuthority);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 9. D13 — the contact address lives in exactly one file
|
||||
// ---------------------------------------------------------------------------
|
||||
const CONTACT_CHECK = 'contact address (D13)';
|
||||
|
||||
@@ -179,6 +262,25 @@ const SCAN_EXT = new Set([
|
||||
// every commit, and the noreply Gitea uses for the bot identity.
|
||||
const ALLOWED_ADDRESSES = new Set(['noreply@anthropic.com', 'claude@whitlocktech.net']);
|
||||
|
||||
/**
|
||||
* Domains reserved by RFC 2606 and RFC 6761 for documentation and examples.
|
||||
*
|
||||
* Phase 5 is what needed this, and the exemption is principled rather than a concession.
|
||||
* The rule being enforced is that no CONTACT address appears outside `brand.json` (D13), so
|
||||
* that changing the published address stays a file copy. An `example.com` address cannot be
|
||||
* a contact address — the domain is reserved precisely so that documentation can use it and
|
||||
* it can never route to anybody — so exempting these weakens nothing.
|
||||
*
|
||||
* Without it the rule would have forbidden the signup form's `placeholder="you@example.com"`
|
||||
* and the CLI's usage line, which is the check telling somebody to write a worse page in
|
||||
* order to satisfy a rule about a different problem. A check people have to work around is
|
||||
* one they eventually switch off.
|
||||
*
|
||||
* Matched on the domain, not on the exact address, because these appear with whatever local
|
||||
* part reads best in context.
|
||||
*/
|
||||
const RESERVED_DOMAINS = /@(?:[a-z0-9-]+\.)*(?:example\.(?:com|net|org)|example|invalid|test|localhost)$/i;
|
||||
|
||||
async function* walk(dir) {
|
||||
let entries;
|
||||
try {
|
||||
@@ -204,6 +306,7 @@ async function checkContactAddressIsIsolated() {
|
||||
const text = readFileSync(file, 'utf8');
|
||||
for (const match of text.matchAll(EMAIL_RE)) {
|
||||
if (ALLOWED_ADDRESSES.has(match[0].toLowerCase())) continue;
|
||||
if (RESERVED_DOMAINS.test(match[0])) continue;
|
||||
const line = text.slice(0, match.index).split('\n').length;
|
||||
offenders.push(`${path.relative(ROOT, file)}:${line} — ${match[0]}`);
|
||||
}
|
||||
@@ -244,9 +347,11 @@ async function main() {
|
||||
checkProtocol,
|
||||
checkOverlayProtocol,
|
||||
checkModuleApi,
|
||||
checkModuleCapabilities,
|
||||
checkBundle,
|
||||
checkReleases,
|
||||
checkWebsiteHasNoReleases,
|
||||
checkAndroidApk,
|
||||
];
|
||||
|
||||
for (const check of network) {
|
||||
|
||||
364
scripts/checkLinks.mjs
Normal file
@@ -0,0 +1,364 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* checkLinks.mjs — PLAN.md §12
|
||||
*
|
||||
* Two rules, both of which §12 states and neither of which had a check until phase 4:
|
||||
*
|
||||
* 1. Every internal link resolves.
|
||||
* 2. Every outbound link into a RunicGateway repository points at a BRANCH path, never a
|
||||
* commit permalink.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY IT READS THE BUILD AND NOT THE SOURCE
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* The obvious implementation greps `href="…"` out of `src/**` and resolves it against the
|
||||
* file tree. It would have missed most of what phase 4 added. Half the links on these pages
|
||||
* are built from data — `capabilityGroups`, `notBuilt.mjs`, a template literal over
|
||||
* `platform.gitea.base` — and a source scan sees an expression rather than a URL. A link
|
||||
* that is wrong in a data file is exactly as broken as one that is wrong in markup, and it
|
||||
* is harder to spot by eye, so it is the one that most needs checking.
|
||||
*
|
||||
* So this runs against `dist/client` after a build, where every link is a real string. The
|
||||
* cost is that the check needs a build first, which is why it sits after `npm run build` in
|
||||
* `verify` and in CI. A stale `dist` would check stale links, and that is the one failure
|
||||
* mode worth knowing about — running it by hand after editing a page means building first.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHAT IT DELIBERATELY DOES NOT CHECK
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* `/brand/*` — those URLs are served by a route that derives them on request from whatever
|
||||
* is mounted, so nothing corresponding exists in `dist/client` to point at. They are not
|
||||
* unchecked: `scripts/checkBrand.mjs` already resolves every one of them against that
|
||||
* route's own allowlist, which is a stronger check than file existence.
|
||||
*
|
||||
* Off-site URLs are not fetched. A build that fails because gnu.org is slow is a build
|
||||
* that teaches people to ignore this check. The one outbound rule here is about the SHAPE
|
||||
* of a URL, which is decidable without the network.
|
||||
*
|
||||
* In-page fragments (`#main`) are not resolved against the ids on the page. It would be a
|
||||
* fair check to add; it is not one §12 asks for, and the site has exactly one of them.
|
||||
*
|
||||
* node scripts/checkLinks.mjs [--dist <path>]
|
||||
*/
|
||||
|
||||
import { readFileSync, existsSync, statSync } from 'node:fs';
|
||||
import { readdir } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
||||
|
||||
const distArg = process.argv.indexOf('--dist');
|
||||
const DIST =
|
||||
distArg !== -1 && process.argv[distArg + 1]
|
||||
? path.resolve(process.argv[distArg + 1])
|
||||
: path.join(ROOT, 'dist', 'client');
|
||||
|
||||
const platform = JSON.parse(readFileSync(path.join(ROOT, 'src/data/platform.json'), 'utf8'));
|
||||
|
||||
/** `gitea.whitlocktech.com`, from the same place every page reads it. */
|
||||
const GITEA_HOST = new URL(platform.gitea.base).host;
|
||||
|
||||
/**
|
||||
* Prefixes served by a route rather than by a file in the build. A link starting with one
|
||||
* of these is somebody else's check — see the header.
|
||||
*/
|
||||
const RUNTIME_PREFIXES = ['/brand/'];
|
||||
|
||||
/**
|
||||
* Pages that render per request, and therefore have no file in `dist/client` to resolve
|
||||
* against — discovered from the source rather than listed here.
|
||||
*
|
||||
* Phase 5 is what made this necessary. Until then the only on-demand route was `/brand/*`,
|
||||
* which is an asset route with its own checker and is skipped by prefix above; `/beta/` is
|
||||
* the first on-demand PAGE, and it is linked from `/app/`, the header and the footer like
|
||||
* any other. Rule 1 read `dist/client`, saw nothing at `beta/index.html`, and failed a link
|
||||
* that is perfectly good.
|
||||
*
|
||||
* The tempting fix — an entry in `PLANNED_ROUTES` — would be wrong, and wrong in the exact
|
||||
* way that list's own comment warns about. Its reverse check fires when a route HAS been
|
||||
* built, and an on-demand route never produces a file, so the entry could never rot out. It
|
||||
* would become the permanent exemption the two-way check exists to prevent.
|
||||
*
|
||||
* So the route is derived instead: a file under `src/pages/` that exports `prerender =
|
||||
* false` IS an on-demand route, and its path maps to a URL by Astro's own file-routing
|
||||
* rules. That is a fact about the source, checkable at the same moment, and it cannot go
|
||||
* stale — delete `beta.astro` and the links to `/beta/` start failing again immediately,
|
||||
* which is the behaviour rule 1 is there to provide.
|
||||
*
|
||||
* Dynamic segments (`[...file].ts`) are deliberately not handled: the only one is the brand
|
||||
* route, already covered by prefix, and inventing a matcher for a case that does not exist
|
||||
* would be guessing at a shape nobody has written yet.
|
||||
*/
|
||||
async function findOnDemandRoutes() {
|
||||
const pagesDir = path.join(ROOT, 'src', 'pages');
|
||||
const routes = new Set();
|
||||
|
||||
for await (const file of walk(pagesDir, ['.astro', '.ts', '.js'])) {
|
||||
const source = readFileSync(file, 'utf8');
|
||||
if (!/export\s+const\s+prerender\s*=\s*false/.test(source)) continue;
|
||||
|
||||
const relative = path.relative(pagesDir, file).split(path.sep).join('/');
|
||||
if (relative.includes('[')) continue;
|
||||
|
||||
const withoutExt = relative.replace(/\.(astro|ts|js)$/, '');
|
||||
const name = withoutExt.replace(/(^|\/)index$/, '');
|
||||
routes.add(name ? `/${name}/` : '/');
|
||||
}
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes the site links today that a later phase builds.
|
||||
*
|
||||
* This exists because of a convention phase 3 recorded and phase 1 started: the header,
|
||||
* the footer and the homepage link the FINAL routes of §10 rather than growing links phase
|
||||
* by phase. Nothing is deployed until phase 12, so no visitor ever meets one of these
|
||||
* 404s, and no page has to be revisited later to add a link that was always going to be
|
||||
* there. That convention and rule 1 of this check are in direct tension, and this is where
|
||||
* the tension is resolved — explicitly, with a phase against each entry, rather than by
|
||||
* weakening the rule.
|
||||
*
|
||||
* It is self-cleaning in both directions, which is the only reason it is safe to have:
|
||||
*
|
||||
* - a link to a route that is neither built nor listed here FAILS, so the list cannot be
|
||||
* used by accident;
|
||||
* - an entry here whose route HAS since been built also fails, so the list cannot rot
|
||||
* into a permanent exemption after the page arrives.
|
||||
*
|
||||
* Adding to it is a deliberate act. If a route is not in §10, it does not belong here.
|
||||
*/
|
||||
const PLANNED_ROUTES = new Map([
|
||||
// Empty as of phase 6, which built `/privacy/` and `/terms/` — the last two routes §10
|
||||
// named that no page served. The Map stays because §10 is not finished: phases 7 and 8
|
||||
// add the documentation journey, and the convention above (link the final route, not the
|
||||
// route that exists today) is what the list exists to make safe.
|
||||
//
|
||||
// An empty list is not a dormant one. Rule 3 below still runs, so adding an entry for a
|
||||
// route that has since been built fails immediately rather than sitting here unread.
|
||||
]);
|
||||
|
||||
/** Planned routes actually linked from somewhere, so the reverse check can be reported. */
|
||||
const plannedSeen = new Set();
|
||||
|
||||
const failures = [];
|
||||
let linksChecked = 0;
|
||||
let outboundChecked = 0;
|
||||
|
||||
function fail(file, line, message) {
|
||||
failures.push({ file, line, message });
|
||||
}
|
||||
|
||||
async function* walk(dir, extensions = ['.html']) {
|
||||
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, extensions);
|
||||
else if (extensions.includes(path.extname(entry.name))) yield full;
|
||||
}
|
||||
}
|
||||
|
||||
const lineOf = (source, index) => source.slice(0, index).split('\n').length;
|
||||
|
||||
/**
|
||||
* Does a site-absolute path correspond to something the build will serve?
|
||||
*
|
||||
* Astro is configured with `format: 'directory'`, so `/features/` is
|
||||
* `dist/client/features/index.html`. The other shapes are accepted because a route can
|
||||
* legitimately be a file — `/manifest.webmanifest` is one, and `/404.html` is another.
|
||||
*/
|
||||
function resolvesInBuild(pathname) {
|
||||
const clean = pathname.replace(/[?#].*$/, '');
|
||||
const relative = decodeURIComponent(clean).replace(/^\/+/, '');
|
||||
const base = path.join(DIST, relative);
|
||||
|
||||
const candidates = [
|
||||
path.join(base, 'index.html'),
|
||||
`${base.replace(/[\\/]+$/, '')}.html`,
|
||||
base.replace(/[\\/]+$/, ''),
|
||||
];
|
||||
|
||||
return candidates.some((candidate) => {
|
||||
if (!existsSync(candidate)) return false;
|
||||
// A bare directory that has no index.html is not a page anybody can open.
|
||||
return statSync(candidate).isFile();
|
||||
});
|
||||
}
|
||||
|
||||
if (!existsSync(DIST)) {
|
||||
console.error(
|
||||
`\ncheckLinks: no build at ${path.relative(ROOT, DIST)}.\n\n` +
|
||||
' This check reads the built HTML rather than the source, so that links written by\n' +
|
||||
' data files and template literals are checked as the strings they become. Run\n' +
|
||||
' `npm run build` first — `npm run verify` already does.\n'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const onDemandRoutes = await findOnDemandRoutes();
|
||||
|
||||
/* =======================================================================================
|
||||
1. Internal links resolve
|
||||
======================================================================================= */
|
||||
|
||||
for await (const file of walk(DIST)) {
|
||||
const relative = path.relative(ROOT, file);
|
||||
const source = readFileSync(file, 'utf8');
|
||||
|
||||
for (const match of source.matchAll(/(?:href|src)="([^"]*)"/g)) {
|
||||
const value = match[1];
|
||||
|
||||
// Off-site, protocol-relative, and the non-navigational schemes. `mailto:` addresses
|
||||
// are checkFacts.mjs's business (D13) and are not links to anywhere on this site.
|
||||
if (/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(value)) continue;
|
||||
|
||||
// Fragments and query-only links stay on the page they are already on.
|
||||
if (!value || value.startsWith('#') || value.startsWith('?')) continue;
|
||||
|
||||
// Relative links. Astro emits site-absolute paths for everything the site itself
|
||||
// writes; a relative one is almost certainly a mistake, but resolving it correctly
|
||||
// needs the emitting page's directory, so it is reported rather than guessed at.
|
||||
if (!value.startsWith('/')) {
|
||||
fail(
|
||||
relative,
|
||||
lineOf(source, match.index),
|
||||
`relative link "${value}" — write it site-absolute, starting with "/", so it means ` +
|
||||
`the same thing from every page that renders the component`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (RUNTIME_PREFIXES.some((prefix) => value.startsWith(prefix))) continue;
|
||||
|
||||
// The demo slot and its deep links ship empty and hidden in a stock build (§15/D25);
|
||||
// `href=""` is the contract, not a broken link. checkBrand.mjs owns their shape.
|
||||
if (value === '') continue;
|
||||
|
||||
linksChecked++;
|
||||
|
||||
if (resolvesInBuild(value)) continue;
|
||||
|
||||
// A page that renders per request has no file to find. Checked here rather than as a
|
||||
// prefix skip, so an on-demand route still has to EXIST — see findOnDemandRoutes.
|
||||
if (onDemandRoutes.has(value.replace(/[?#].*$/, ''))) continue;
|
||||
|
||||
const planned = PLANNED_ROUTES.get(value.replace(/[?#].*$/, ''));
|
||||
if (planned) {
|
||||
plannedSeen.add(value.replace(/[?#].*$/, ''));
|
||||
continue;
|
||||
}
|
||||
|
||||
fail(
|
||||
relative,
|
||||
lineOf(source, match.index),
|
||||
`"${value}" does not resolve — nothing in the build serves it.\n` +
|
||||
` If a later phase builds it, add it to PLANNED_ROUTES in this script with the\n` +
|
||||
` phase that does. If not, the link is wrong.`
|
||||
);
|
||||
}
|
||||
|
||||
/* =====================================================================================
|
||||
2. Outbound repository links point at a branch, not a commit
|
||||
=====================================================================================
|
||||
|
||||
§12's rule, and the reason for it: a commit permalink is a fact frozen at a sha while
|
||||
the document it names keeps moving. Every link on this site into one of these
|
||||
repositories is meant to show a reader the CURRENT state of something — the module
|
||||
contract, the operator guide, the protocol — and a permalink quietly stops doing that
|
||||
the day after it is written, without ever 404ing. It is the failure mode a link
|
||||
checker would otherwise call healthy.
|
||||
|
||||
Gitea writes both shapes as `/<owner>/<repo>/src/<kind>/<ref>/…`, so the kind segment
|
||||
is what decides it, and a 40-character hex ref is caught even when the kind segment
|
||||
says branch — which is what a "branch" named after a sha actually is. */
|
||||
|
||||
for (const match of source.matchAll(/https?:\/\/[^\s"'<>)]+/g)) {
|
||||
const raw = match[1] ?? match[0];
|
||||
let url;
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (url.host !== GITEA_HOST) continue;
|
||||
|
||||
outboundChecked++;
|
||||
|
||||
const segments = url.pathname.split('/').filter(Boolean);
|
||||
// <owner>/<repo>/<kind>/<refkind>/<ref>/…
|
||||
const kind = segments[2];
|
||||
const refKind = segments[3];
|
||||
const ref = segments[4];
|
||||
|
||||
if (!['src', 'raw', 'media'].includes(kind)) continue;
|
||||
|
||||
if (refKind === 'commit' || refKind === 'tag') {
|
||||
fail(
|
||||
relative,
|
||||
lineOf(source, match.index),
|
||||
`${raw}\n points at a ${refKind}, not a branch. §12 requires branch paths, so a ` +
|
||||
`reader always\n sees the document as it is now rather than as it was.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ref && /^[0-9a-f]{40}$/i.test(ref)) {
|
||||
fail(
|
||||
relative,
|
||||
lineOf(source, match.index),
|
||||
`${raw}\n names a commit sha as its ref. Use a branch name — "main" for anything ` +
|
||||
`canonical.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* =======================================================================================
|
||||
3. The planned-route list has not rotted
|
||||
=======================================================================================
|
||||
|
||||
The half that makes an exemption list safe. Once a phase builds one of these, the entry
|
||||
stops being a promise and starts being a hole in rule 1 — so the build fails until it is
|
||||
deleted. Reported per route, with the phase that was waiting for it, because the person
|
||||
who just built the page is the person who should remove the line. */
|
||||
|
||||
const selfSource = readFileSync(path.join(ROOT, 'scripts/checkLinks.mjs'), 'utf8');
|
||||
|
||||
for (const [route, owner] of PLANNED_ROUTES) {
|
||||
if (!resolvesInBuild(route)) continue;
|
||||
const entry = selfSource.indexOf(`['${route}'`);
|
||||
fail(
|
||||
'scripts/checkLinks.mjs',
|
||||
entry === -1 ? 1 : lineOf(selfSource, entry),
|
||||
`PLANNED_ROUTES still lists "${route}" (${owner}), but the build now serves it.\n` +
|
||||
` Delete the entry: every link to it is checked properly from here on.`
|
||||
);
|
||||
}
|
||||
|
||||
if (failures.length) {
|
||||
console.error('\ncheckLinks: broken or non-canonical links.\n');
|
||||
for (const failure of failures) {
|
||||
console.error(` ${failure.file}:${failure.line}\n ${failure.message}\n`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const pending = [...plannedSeen].sort();
|
||||
|
||||
console.log(
|
||||
`checkLinks: ${linksChecked} internal link(s) resolve and ${outboundChecked} repository ` +
|
||||
`link(s) point at a branch.`
|
||||
);
|
||||
|
||||
if (pending.length) {
|
||||
console.log(
|
||||
` ${pending.length} link(s) point at a planned route: ` +
|
||||
`${pending.join(', ')} — allowed until the phase that builds it.`
|
||||
);
|
||||
}
|
||||
222
scripts/checkQuickstart.mjs
Normal file
@@ -0,0 +1,222 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* checkQuickstart.mjs — PLAN.md §12, added in phase 7 for D35.
|
||||
*
|
||||
* The org lead chose a self-contained quickstart: `/docs/getting-started/install-the-site/`
|
||||
* prints a Compose file and an environment file the reader can copy without going to
|
||||
* another repository first. That is the one place this site knowingly keeps a copy of
|
||||
* somebody else's file, and §1 is a long argument about why copies rot.
|
||||
*
|
||||
* So the copy is checked rather than trusted. Every service, image, published port, mount
|
||||
* and environment key in `src/data/quickstart.mjs` is re-read from `website`'s own
|
||||
* `docker-compose.yml` and `.env.example` on `main`, over the Gitea API — never from a
|
||||
* working tree, per §1's process rule — and any disagreement fails the build.
|
||||
*
|
||||
* It checks in BOTH directions, which is the property that keeps it honest:
|
||||
*
|
||||
* - every value the quickstart states must match upstream's;
|
||||
* - every service and variable upstream has must be either included or listed as
|
||||
* deliberately omitted, WITH a reason. A new variable in `.env.example` therefore turns
|
||||
* this repo red until someone decides whether a first install needs it — the same
|
||||
* intent as checkFacts.mjs and the Integration Kit's checkCoreApi.js;
|
||||
* - and an entry in either omission list that upstream no longer has fails too, so the
|
||||
* lists cannot rot into permanent exemptions.
|
||||
*
|
||||
* GITEA_TOKEN=<token> node scripts/checkQuickstart.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 { parse as parseYaml } from 'yaml';
|
||||
|
||||
import {
|
||||
compose,
|
||||
services,
|
||||
omittedServices,
|
||||
env,
|
||||
envOmitted,
|
||||
notInUpstreamEnvExample,
|
||||
} from '../src/data/quickstart.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 uses, and for the same reason -- including the CDN one. */
|
||||
async function raw(repo, filePath, 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}`);
|
||||
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');
|
||||
}
|
||||
|
||||
/**
|
||||
* `KEY=value` lines from a dotenv file. Commented-out suggestions (`# MODULES=…`) are NOT
|
||||
* keys: they are prose about a variable, and treating them as declared would make the
|
||||
* omission list argue with documentation rather than with configuration.
|
||||
*/
|
||||
function envKeys(text) {
|
||||
const out = new Map();
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const m = line.match(/^([A-Z][A-Z0-9_]*)=(.*)$/);
|
||||
if (m) out.set(m[1], m[2].replace(/\s+#.*$/, '').trim());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Published host:container port pairs, as written. */
|
||||
const portsOf = (svc) => (svc.ports ?? []).map(String);
|
||||
|
||||
/** Container-side paths of every volume entry, which is what a reader's site depends on. */
|
||||
const mountTargets = (svc) => (svc.volumes ?? []).map((v) => String(v).split(':')[1]);
|
||||
|
||||
async function run() {
|
||||
if (!TOKEN) {
|
||||
console.error('checkQuickstart: GITEA_TOKEN is not set. This check cannot run anonymously.');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const upstreamComposeText = await raw('website', 'docker-compose.yml', 'main');
|
||||
const upstreamEnvText = await raw('website', '.env.example', 'main');
|
||||
|
||||
const upstream = parseYaml(upstreamComposeText);
|
||||
const ours = parseYaml(compose);
|
||||
|
||||
if (!upstream?.services) throw new Error('website main:docker-compose.yml has no services block — the file shape changed.');
|
||||
|
||||
// ── 1. The services we ship ───────────────────────────────────────────────
|
||||
for (const name of services) {
|
||||
const mine = ours.services?.[name];
|
||||
const theirs = upstream.services?.[name];
|
||||
if (!mine) { fail(`service ${name}`, 'declared in quickstart.mjs but absent from its own compose text'); continue; }
|
||||
if (!theirs) { fail(`service ${name}`, 'no longer exists in website main:docker-compose.yml'); continue; }
|
||||
|
||||
if (String(mine.image) !== String(theirs.image)) {
|
||||
fail(`service ${name}: image`, `quickstart "${mine.image}" vs upstream "${theirs.image}"`);
|
||||
} else ok(`service ${name}: image`);
|
||||
|
||||
const minePorts = portsOf(mine).join(', ');
|
||||
const theirPorts = portsOf(theirs).join(', ');
|
||||
if (minePorts !== theirPorts) {
|
||||
fail(`service ${name}: ports`, `quickstart [${minePorts}] vs upstream [${theirPorts}]`);
|
||||
} else ok(`service ${name}: ports`);
|
||||
|
||||
// Every mount we keep must land where upstream lands it. Upstream may have mounts we
|
||||
// dropped (the schema bind, which needs a checkout); dropping one is safe, moving one
|
||||
// is not.
|
||||
for (const target of mountTargets(mine)) {
|
||||
if (!mountTargets(theirs).includes(target)) {
|
||||
fail(`service ${name}: mount ${target}`, 'upstream mounts nothing at that container path');
|
||||
} else ok(`service ${name}: mount ${target}`);
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(mine.environment ?? {})) {
|
||||
const theirValue = theirs.environment?.[key];
|
||||
if (theirValue === undefined) {
|
||||
fail(`service ${name}: ${key}`, 'upstream no longer sets it in the compose file');
|
||||
} else if (String(theirValue) !== String(value)) {
|
||||
fail(`service ${name}: ${key}`, `quickstart "${value}" vs upstream "${theirValue}"`);
|
||||
} else ok(`service ${name}: ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. The services we left out, and any that appeared ────────────────────
|
||||
const upstreamServiceNames = Object.keys(upstream.services);
|
||||
for (const [name, reason] of Object.entries(omittedServices)) {
|
||||
if (!upstreamServiceNames.includes(name)) {
|
||||
fail(`omitted service ${name}`, 'upstream no longer has this service — drop it from omittedServices');
|
||||
} else if (!reason?.trim()) {
|
||||
fail(`omitted service ${name}`, 'listed without a reason');
|
||||
} else ok(`omitted service ${name}`);
|
||||
}
|
||||
for (const name of upstreamServiceNames) {
|
||||
if (!services.includes(name) && !(name in omittedServices)) {
|
||||
fail(`service ${name}`, 'is new in website main:docker-compose.yml — include it in the quickstart or record why not');
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. The environment file ───────────────────────────────────────────────
|
||||
const theirEnv = envKeys(upstreamEnvText);
|
||||
const mineEnv = new Map(env.map((e) => [e.key, e]));
|
||||
|
||||
for (const entry of env) {
|
||||
const theirValue = theirEnv.get(entry.key);
|
||||
const excused = notInUpstreamEnvExample[entry.key];
|
||||
|
||||
if (theirValue === undefined) {
|
||||
if (excused) {
|
||||
ok(`env ${entry.key} (absent upstream, declared: ${excused})`);
|
||||
} else {
|
||||
fail(`env ${entry.key}`, 'not in website main:.env.example — either it is gone, or it needs a reason in notInUpstreamEnvExample');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (excused) {
|
||||
fail(
|
||||
`env ${entry.key}`,
|
||||
'is now in website main:.env.example — remove it from notInUpstreamEnvExample, and re-read the prose that describes it as missing',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// A value an operator is told to replace is a placeholder on both sides; comparing two
|
||||
// placeholders would only ever assert that two people picked the same filler words.
|
||||
if (!entry.fill && theirValue !== String(entry.value)) {
|
||||
fail(`env ${entry.key}`, `quickstart "${entry.value}" vs upstream "${theirValue}"`);
|
||||
} else ok(`env ${entry.key}`);
|
||||
}
|
||||
|
||||
for (const [key, reason] of Object.entries(envOmitted)) {
|
||||
if (!theirEnv.has(key)) {
|
||||
fail(`omitted env ${key}`, 'upstream .env.example no longer sets it — drop it from envOmitted');
|
||||
} else if (!reason?.trim()) {
|
||||
fail(`omitted env ${key}`, 'listed without a reason');
|
||||
} else ok(`omitted env ${key}`);
|
||||
}
|
||||
|
||||
for (const key of theirEnv.keys()) {
|
||||
if (!mineEnv.has(key) && !(key in envOmitted)) {
|
||||
fail(`env ${key}`, 'is new in website main:.env.example — add it to the quickstart or record why a first install does not need it');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Report ────────────────────────────────────────────────────────────────
|
||||
if (failures.length === 0) {
|
||||
console.log(`checkQuickstart: ${checked.length} checks passed against website main.`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(`checkQuickstart: ${failures.length} disagreement(s) with website main:\n`);
|
||||
for (const f of failures) console.error(` ✗ ${f.what}\n ${f.detail}`);
|
||||
console.error(
|
||||
'\nThe quickstart on /docs/getting-started/install-the-site/ is a copy of website\'s own\n'
|
||||
+ 'deployment files (D35). Either update src/data/quickstart.mjs to match, or record the\n'
|
||||
+ 'difference with a reason. Do not "fix" the check.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
run().catch((err) => {
|
||||
console.error(`checkQuickstart: ${err.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
197
scripts/checkReference.mjs
Normal file
@@ -0,0 +1,197 @@
|
||||
#!/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.
|
||||
*
|
||||
* 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,
|
||||
} from '../src/data/reference.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]);
|
||||
|
||||
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.
|
||||
const configRs = await raw('link', 'sidecar/src/config.rs');
|
||||
const sidecarKeys = [];
|
||||
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)) sidecarKeys.push(`${section}.${f[1]}`);
|
||||
}
|
||||
compareSets('sidecar.toml', Object.keys(sidecarConfig), sidecarKeys, '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.keys(canonicalDocs)) {
|
||||
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})`);
|
||||
}
|
||||
|
||||
// ── 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.`);
|
||||
77
scripts/checkSidebar.mjs
Normal file
@@ -0,0 +1,77 @@
|
||||
#!/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 } from '../src/config/sidebar.mjs';
|
||||
|
||||
const failures = [];
|
||||
const fail = (what, detail) => failures.push({ what, detail });
|
||||
|
||||
const live = new Map(docsSidebar.map((g) => [g.label, g.items.map((i) => i.label)]));
|
||||
const planned = new Map(Object.entries(plannedSidebar));
|
||||
|
||||
// ── 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);
|
||||
}
|
||||
231
scripts/playDataSafety.mjs
Normal file
@@ -0,0 +1,231 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* playDataSafety.mjs — PLAN.md §9, phase 6 (D33).
|
||||
*
|
||||
* Writes `PLAY_DATA_SAFETY.md`: the answers to Google Play's Data Safety form, generated
|
||||
* from the same `src/data/collection.mjs` rows that `/privacy` section 2 renders.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY IT IS GENERATED AND CHECKED RATHER THAN WRITTEN
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* §9 says the declaration is "filled from section 2, and section 2 is written knowing that
|
||||
* is what it is for". Two documents describing the same code drift — that is the premise of
|
||||
* `capabilities.mjs` (D18) and `notBuilt.mjs` (D22) — and this pair drifts worse than
|
||||
* either, because one half is a published legal page and the other is a form at Google that
|
||||
* cannot be corrected without a review round. The app gaining a crash reporter must not be
|
||||
* able to leave a "not collected" answer standing in a file nobody re-reads.
|
||||
*
|
||||
* So the markdown is an output, not a source. `--check` recomputes it and fails if the
|
||||
* committed copy differs, which is what puts it in `verify` and in CI: editing the doc by
|
||||
* hand fails the build and names the data file to edit instead.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHAT THIS DOCUMENT IS NOT
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* It is not a filled-in form and it does not claim to know Play's current definitions.
|
||||
* Play's testing and disclosure requirements have changed more than once — §8 says so, and
|
||||
* `playPolicy.verifiedOn` exists for the same reason — and there is no API to read them
|
||||
* from. What this generates is the FACTS, arranged as the console arranges its questions,
|
||||
* with the answer each fact supports and why. Whoever fills the form reads the console's
|
||||
* own definitions against these, which is a job for a person; what they must never do is
|
||||
* answer from memory about what the app stores.
|
||||
*
|
||||
* node scripts/playDataSafety.mjs # write PLAY_DATA_SAFETY.md
|
||||
* node scripts/playDataSafety.mjs --check # fail if the committed copy is stale
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
import { collectedIn, playRows } from '../src/data/collection.mjs';
|
||||
import { legal } from '../src/data/legal.mjs';
|
||||
|
||||
const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
||||
const OUT = path.join(ROOT, 'PLAY_DATA_SAFETY.md');
|
||||
const CHECK = process.argv.includes('--check');
|
||||
|
||||
const GENERATOR = 'scripts/playDataSafety.mjs';
|
||||
|
||||
/** Cell text: the table is markdown, so a pipe would end the column early. */
|
||||
const cell = (text) => String(text).replace(/\|/g, '\\|').replace(/\s*\n\s*/g, ' ');
|
||||
|
||||
const yesNo = (value) => (value ? 'Yes' : 'No');
|
||||
|
||||
function render() {
|
||||
const rows = playRows();
|
||||
const site = collectedIn('site');
|
||||
|
||||
const lines = [];
|
||||
|
||||
lines.push('<!--');
|
||||
lines.push(' GENERATED FILE — do not edit.');
|
||||
lines.push('');
|
||||
lines.push(` Source: src/data/collection.mjs (scope "app") + src/data/legal.mjs`);
|
||||
lines.push(` Generator: ${GENERATOR}`);
|
||||
lines.push('');
|
||||
lines.push(' Edit the data file and run `npm run play:datasafety`. CI runs the same');
|
||||
lines.push(' generator with --check, so a hand edit here fails the build rather than');
|
||||
lines.push(' quietly disagreeing with the published privacy policy.');
|
||||
lines.push('-->');
|
||||
lines.push('');
|
||||
lines.push('# Google Play Data Safety — the answers, and what they are based on');
|
||||
lines.push('');
|
||||
lines.push(
|
||||
'The Play Console asks, for every category of data, whether the app **collects** it, ' +
|
||||
'whether it is **shared**, whether collection is **required or optional**, and *why*. ' +
|
||||
'This file holds the answers for the Runic Gateway Android app, generated from the ' +
|
||||
'same inventory the published privacy policy renders — see `/privacy`, section 2.'
|
||||
);
|
||||
lines.push('');
|
||||
lines.push(
|
||||
'> **This is not a filled-in form.** Play’s definitions change and no check here can ' +
|
||||
'read them. Every answer below is a fact about the code with the reasoning attached; ' +
|
||||
'read the console’s current wording against them when you fill the form. What this ' +
|
||||
'file exists to prevent is somebody answering from memory about what the app stores.'
|
||||
);
|
||||
lines.push('');
|
||||
|
||||
/* ---------------------------------------------------------------------------------
|
||||
The one answer that shapes every other one.
|
||||
--------------------------------------------------------------------------------- */
|
||||
lines.push('## The premise every answer rests on');
|
||||
lines.push('');
|
||||
lines.push(
|
||||
'We operate **no server the app talks to.** The app ships pointed at nothing: its ' +
|
||||
'first screen asks for the address of a Runic Gateway deployment and validates it ' +
|
||||
'before anything else in the app runs. That deployment belongs to whoever runs that ' +
|
||||
'community. Data therefore travels from the device to *their* server, and there is ' +
|
||||
'no endpoint of ours anywhere in the path — not for content, not for telemetry, and ' +
|
||||
'not for crash reports, of which there are none.'
|
||||
);
|
||||
lines.push('');
|
||||
lines.push(
|
||||
'That is why nearly every answer below is "not collected", and it is also the answer ' +
|
||||
'most likely to be questioned in a review. The supporting facts are in the table: ' +
|
||||
'each row names the file it was read out of.'
|
||||
);
|
||||
lines.push('');
|
||||
lines.push(
|
||||
'Where the console offers free text about security practices, two things are worth ' +
|
||||
'saying: credentials are held in Android’s encrypted storage (AES-256-GCM via ' +
|
||||
'Jetpack Security), and push notifications carry **no content** — a relay receives a ' +
|
||||
'stream name and a reference, and the app fetches the actual message over its own ' +
|
||||
'authenticated connection.'
|
||||
);
|
||||
lines.push('');
|
||||
|
||||
/* --------------------------------------------------------------------------------- */
|
||||
lines.push('## Data types');
|
||||
lines.push('');
|
||||
lines.push('| Category | Data type | Collected by us | Shared by us | Answer |');
|
||||
lines.push('|---|---|---|---|---|');
|
||||
for (const row of rows) {
|
||||
lines.push(
|
||||
`| ${cell(row.play.category)} | ${cell(row.play.type)} | ${yesNo(row.play.collected)} ` +
|
||||
`| ${yesNo(row.play.shared)} | ${cell(row.play.answer)} |`
|
||||
);
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
lines.push('## Each answer, and why it is the truthful one');
|
||||
lines.push('');
|
||||
for (const row of rows) {
|
||||
lines.push(`### ${row.title}`);
|
||||
lines.push('');
|
||||
lines.push(`**${row.play.category} → ${row.play.type}.** ${cell(row.play.answer)}`);
|
||||
lines.push('');
|
||||
lines.push(cell(row.body));
|
||||
lines.push('');
|
||||
lines.push(`- **Why that answer:** ${cell(row.play.because)}`);
|
||||
lines.push(`- **Retention:** ${cell(row.retention.summary)}`);
|
||||
if (row.retention.detail) lines.push(`- **In detail:** ${cell(row.retention.detail)}`);
|
||||
lines.push(`- **Read from:** \`${row.source}\``);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------------- */
|
||||
lines.push('## The rest of the listing');
|
||||
lines.push('');
|
||||
lines.push(
|
||||
`- **Privacy policy URL:** \`/privacy\` on this site. It is the URL Play is given, and ` +
|
||||
'section 2 of it is about the app specifically.'
|
||||
);
|
||||
lines.push(
|
||||
`- **Target audience:** adults. The beta is stated as **${legal.minimumAge} or older** ` +
|
||||
'(D31); the app contains no content directed at children and no age verification.'
|
||||
);
|
||||
lines.push(
|
||||
'- **Account deletion:** the app creates no account with us — an account belongs to ' +
|
||||
'the deployment the user chose, and is deleted there. The only list we hold is the ' +
|
||||
'beta signup, which is erased on request; `/privacy` section 4 says how to ask.'
|
||||
);
|
||||
lines.push(
|
||||
'- **Data deletion request URL:** the contact address published on `/privacy`, which ' +
|
||||
'is read from the mounted `brand.json` rather than typed anywhere in the source (D13).'
|
||||
);
|
||||
lines.push('');
|
||||
|
||||
lines.push('## What the website collects, for the same reviewer');
|
||||
lines.push('');
|
||||
lines.push(
|
||||
'Not part of the Data Safety form — that form is about the app — but a reviewer who ' +
|
||||
'follows the privacy policy URL lands on a page covering three things, so it is ' +
|
||||
'worth knowing which of them the site itself is responsible for:'
|
||||
);
|
||||
lines.push('');
|
||||
for (const row of site) {
|
||||
lines.push(`- **${cell(row.title)}** — ${cell(row.retention.summary)}.`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push(
|
||||
`Last generated from data dated ${legal.lastUpdated}. Regenerate with ` +
|
||||
'`npm run play:datasafety` after any change to what the app stores.'
|
||||
);
|
||||
lines.push('');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
const rendered = render();
|
||||
|
||||
if (!CHECK) {
|
||||
writeFileSync(OUT, rendered, 'utf8');
|
||||
console.log(`playDataSafety: wrote ${path.relative(ROOT, OUT)}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Line endings are normalised before comparing, and that is not fussiness.
|
||||
*
|
||||
* The repository has no `.gitattributes` and Windows checkouts run with
|
||||
* `core.autocrlf=true`, so this file is stored with LF and lands on a Windows disk with
|
||||
* CRLF. A byte comparison would then fail for every developer on Windows while passing in
|
||||
* CI — the worst shape a check can have, because the fix people reach for is to stop
|
||||
* running it. What is being asserted is that the CONTENT agrees, and a line ending is not
|
||||
* content.
|
||||
*/
|
||||
const normalise = (text) => text.split('\r\n').join('\n');
|
||||
|
||||
let committed = null;
|
||||
try {
|
||||
committed = readFileSync(OUT, 'utf8');
|
||||
} catch {
|
||||
/* handled below */
|
||||
}
|
||||
|
||||
if (committed !== null && normalise(committed) === normalise(rendered)) {
|
||||
console.log('playDataSafety: PLAY_DATA_SAFETY.md matches src/data/collection.mjs.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.error(
|
||||
`\nplayDataSafety: ${path.relative(ROOT, OUT)} is ${committed === null ? 'missing' : 'stale'}.\n\n` +
|
||||
' It is generated from src/data/collection.mjs — the same rows /privacy renders —\n' +
|
||||
' so that the published policy and the Data Safety declaration cannot disagree\n' +
|
||||
' (§9, D33). Run:\n\n' +
|
||||
' npm run play:datasafety\n\n' +
|
||||
' and commit the result. If the change came from editing the markdown by hand,\n' +
|
||||
' make it in the data file instead: the page has to move with it.\n'
|
||||
);
|
||||
process.exit(1);
|
||||
469
scripts/seedDemo.mjs
Normal file
@@ -0,0 +1,469 @@
|
||||
#!/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
|
||||
*
|
||||
* 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 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
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* 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 BASE = (process.env.RG_BASE || 'http://localhost:3000').replace(/\/+$/, '');
|
||||
const API = `${BASE}/api/v1`;
|
||||
const ADMIN_USER = process.env.RG_ADMIN_USER || '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.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' },
|
||||
];
|
||||
|
||||
// ── 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(SETTINGS)) say('~', `${k} = ${v}`);
|
||||
return;
|
||||
}
|
||||
const res = await call('PUT', '/admin/settings', SETTINGS);
|
||||
if (!res.ok) return fail('settings', res);
|
||||
for (const [k, v] of Object.entries(SETTINGS)) 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 USERS) {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── main ───────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
console.log(DRY ? '\nseedDemo — DRY RUN, nothing will be written' : '\nseedDemo');
|
||||
|
||||
await login();
|
||||
await settings();
|
||||
await uoLink();
|
||||
await users();
|
||||
await posts();
|
||||
await wiki();
|
||||
|
||||
console.log(
|
||||
`\n${DRY ? 'would create' : 'created'} ${created}, already present ${existed}` +
|
||||
(process.exitCode ? ' — with failures above' : ''),
|
||||
);
|
||||
console.log(
|
||||
'\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,5 +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';
|
||||
|
||||
/**
|
||||
@@ -7,39 +9,20 @@ import platform from '../data/platform.json';
|
||||
* memory. The version chip reads `platform.json` (§12); the contact address and the links
|
||||
* read `brand.json` (§7, D13).
|
||||
*
|
||||
* `/privacy` and `/terms` are linked from every page (§9) — those pages land in phase 6,
|
||||
* which is why they are the only two entries deliberately left out of the columns below
|
||||
* until then.
|
||||
* `/privacy` and `/terms` are linked from every page (§9). Phase 6 built them and put them
|
||||
* in the legal bar at the foot rather than in the columns: a legal link is not a thing a
|
||||
* 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');
|
||||
---
|
||||
@@ -71,9 +54,12 @@ const isExternal = (href: string) => href.startsWith('http');
|
||||
<div class="site-footer__legal">
|
||||
<p>
|
||||
{brand.siteName} is free software under the{' '}
|
||||
<a href="https://www.gnu.org/licenses/gpl-3.0.html" rel="noopener noreferrer"
|
||||
>GPL-3.0-or-later</a
|
||||
>. © {year}.
|
||||
<a href={legal.licence.url} rel="noopener noreferrer">{legal.licence.id}</a>. ©
|
||||
{' '}{year}.
|
||||
<span class="site-footer__links">
|
||||
<a href="/privacy/">Privacy</a>
|
||||
<a href="/terms/">Terms</a>
|
||||
</span>
|
||||
</p>
|
||||
<p class="site-footer__meta">
|
||||
<span class="chip chip--version">Protocol {platform.protocol}</span>
|
||||
@@ -84,6 +70,16 @@ const isExternal = (href: string) => href.startsWith('http');
|
||||
</footer>
|
||||
|
||||
<style>
|
||||
/* Sits on the licence line rather than in a column of its own — see the header. The
|
||||
separator is a border so it never appears at the start of a wrapped line. */
|
||||
.site-footer__links {
|
||||
display: inline-flex;
|
||||
gap: 0.9rem;
|
||||
margin-left: 0.9rem;
|
||||
padding-left: 0.9rem;
|
||||
border-left: 1px solid var(--line-soft);
|
||||
}
|
||||
|
||||
.site-footer__meta {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
|
||||
@@ -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,6 +21,10 @@ 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' },
|
||||
@@ -55,6 +60,8 @@ const isCurrent = (href: string) =>
|
||||
))
|
||||
}
|
||||
</nav>
|
||||
|
||||
<Search />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
132
src/components/NotBuilt.astro
Normal file
@@ -0,0 +1,132 @@
|
||||
---
|
||||
import { notBuiltFor, assertScopeNonEmpty } from '../data/notBuilt.mjs';
|
||||
|
||||
/**
|
||||
* The deliberate absences (PLAN.md §2, D22), rendered for one page's scope.
|
||||
*
|
||||
* §2 describes its absent-features list as "as load-bearing as the rest", and this is the
|
||||
* component that makes that true on a page rather than in a plan. It reads the shared list
|
||||
* so `/features/`, `/integrations/` and `/modules/` cannot drift into telling three
|
||||
* different stories about the same six things.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY IT LOOKS LIKE THE REST OF THE PAGE
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* Not a warning box, not a muted footnote, not an accordion. D8's "understated honesty" is
|
||||
* a house style with a specific consequence here: a section that is visually apologetic
|
||||
* teaches a reader that absences are embarrassing, and a section that is visually hidden
|
||||
* teaches them to go looking for the ones you did not mention. These are decisions with
|
||||
* reasons, so they are set as decisions with reasons — the same panels as everything else,
|
||||
* in the same place in the rhythm.
|
||||
*
|
||||
* The one visual difference is the `resolvedBy` line, which every entry carries. An absence
|
||||
* with an exit condition is a position; an absence without one is a hole. D8 gives the
|
||||
* Integration Kit's draft status a defined removal condition and this generalises it.
|
||||
*/
|
||||
interface Props {
|
||||
/** Which page is asking: `features`, `integrations` or `modules`. */
|
||||
scope: string;
|
||||
/** Section heading. Each page frames the same list for its own reader. */
|
||||
title: string;
|
||||
}
|
||||
|
||||
const { scope, title } = Astro.props;
|
||||
|
||||
assertScopeNonEmpty(scope);
|
||||
const entries = notBuiltFor(scope);
|
||||
---
|
||||
|
||||
<section class="page section notbuilt">
|
||||
<p class="eyebrow">Not built</p>
|
||||
<h2>{title}</h2>
|
||||
<p class="prose notbuilt__lede">
|
||||
Every one of these is a decision rather than a backlog item, so each says why. Where the
|
||||
reasoning was written down in the open, it is linked.
|
||||
</p>
|
||||
|
||||
<ul class="notbuilt__grid">
|
||||
{
|
||||
entries.map((entry) => (
|
||||
<li class="panel notbuilt__item">
|
||||
<h3>{entry.title}</h3>
|
||||
<p class="notbuilt__body">{entry.body}</p>
|
||||
<p class="notbuilt__resolved">
|
||||
<span class="notbuilt__resolved-label">What would change it</span>
|
||||
{entry.resolvedBy}
|
||||
</p>
|
||||
{entry.link && (
|
||||
<p class="notbuilt__link">
|
||||
<a href={entry.link.href} rel="noopener noreferrer">
|
||||
{entry.link.label}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.notbuilt h2 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: clamp(1.6rem, 3.2vw, 2.1rem);
|
||||
}
|
||||
|
||||
.notbuilt__lede {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.notbuilt__grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin: 2.25rem 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr));
|
||||
}
|
||||
|
||||
.notbuilt__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.notbuilt__item h3 {
|
||||
margin: 0 0 0.6rem;
|
||||
color: var(--gold);
|
||||
font-size: 1.02rem;
|
||||
}
|
||||
|
||||
/* Takes the slack, so the exit condition sits at the foot of every card in a row
|
||||
rather than immediately under a body of whatever length — the same kind of
|
||||
statement in the same place on each, which is what makes them readable as a row. */
|
||||
.notbuilt__body {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.94rem;
|
||||
}
|
||||
|
||||
.notbuilt__resolved {
|
||||
margin: 1rem 0 0;
|
||||
padding-top: 0.85rem;
|
||||
border-top: 1px solid var(--line-soft);
|
||||
color: var(--dim);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.notbuilt__resolved-label {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.11em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.notbuilt__link {
|
||||
margin: 0.85rem 0 0;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
</style>
|
||||
55
src/components/PageHeader.astro
Normal file
@@ -0,0 +1,55 @@
|
||||
---
|
||||
/**
|
||||
* The opening of every marketing page except the homepage — eyebrow, `<h1>`, lede.
|
||||
*
|
||||
* A component rather than four copies of the same three elements, because phase 4 writes
|
||||
* five pages and phases 5 and 6 write four more. The homepage is deliberately not one of
|
||||
* them: its `<h1>` is the tagline inside the hero, set against the emblem, and pulling that
|
||||
* into a shared header would either flatten the hero or push its layout in here (D19).
|
||||
*
|
||||
* The `<h1>` is the page's own name, not the product's, and `Base` appends the site name to
|
||||
* the document title — so a page sets a short `title` and gets "Features — Runic Gateway"
|
||||
* in the tab and "Features" on the page.
|
||||
*/
|
||||
interface Props {
|
||||
/** Small uppercase line above the title. What kind of page this is. */
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const { eyebrow, title } = Astro.props;
|
||||
---
|
||||
|
||||
<header class="page section pagehead">
|
||||
<p class="eyebrow">{eyebrow}</p>
|
||||
<h1>{title}</h1>
|
||||
<div class="prose pagehead__lede">
|
||||
<slot />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
/* The section rhythm gives generous space below; the header wants less, because the
|
||||
first section under it is part of the same thought. */
|
||||
.pagehead {
|
||||
padding-bottom: clamp(1rem, 2.5vw, 1.75rem);
|
||||
}
|
||||
|
||||
.pagehead h1 {
|
||||
margin: 0 0 1rem;
|
||||
font-size: clamp(2rem, 5vw, 2.9rem);
|
||||
}
|
||||
|
||||
.pagehead__lede {
|
||||
color: var(--muted);
|
||||
font-size: 1.06rem;
|
||||
}
|
||||
|
||||
.pagehead__lede :global(p) {
|
||||
margin: 0 0 0.85rem;
|
||||
}
|
||||
|
||||
.pagehead__lede :global(p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
81
src/components/Screenshot.astro
Normal file
@@ -0,0 +1,81 @@
|
||||
---
|
||||
import { screenById, WEB, PHONE } from '../data/screens.mjs';
|
||||
|
||||
/**
|
||||
* 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;
|
||||
---
|
||||
|
||||
<figure class="shot">
|
||||
<img
|
||||
src={src}
|
||||
alt={shot.alt}
|
||||
width={width}
|
||||
height={height}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
{!bare && <figcaption>{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;
|
||||
}
|
||||
</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>
|
||||
73
src/components/StructuredData.astro
Normal file
@@ -0,0 +1,73 @@
|
||||
---
|
||||
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',
|
||||
softwareVersion: platform.bundle.tag,
|
||||
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 />
|
||||
99
src/components/app/Screenshots.astro
Normal file
@@ -0,0 +1,99 @@
|
||||
---
|
||||
import { screensOf, PHONE } from '../../data/screens.mjs';
|
||||
|
||||
/**
|
||||
* The app's screenshot strip. Reserved in phase 5 (D26), filled in phase 9.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY THIS COMPONENT EXISTED FOR A PHASE WITH NOTHING IN IT
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* 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 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.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY IT READS screens.mjs RATHER THAN HOLDING ITS OWN LIST
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
const shots = screensOf('phone');
|
||||
---
|
||||
|
||||
{
|
||||
shots.length > 0 && (
|
||||
<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.
|
||||
</p>
|
||||
|
||||
<ul class="shots__grid">
|
||||
{shots.map((shot) => (
|
||||
<li class="shots__item">
|
||||
<img
|
||||
src={`/screens/${shot.id}.webp`}
|
||||
alt={shot.alt}
|
||||
width={PHONE.width}
|
||||
height={PHONE.height}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<p class="shots__caption">{shot.caption}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
<style>
|
||||
.shots h2 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: clamp(1.6rem, 3.2vw, 2.1rem);
|
||||
}
|
||||
|
||||
.shots__lede {
|
||||
margin: 0 0 2.25rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.shots__grid {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr));
|
||||
}
|
||||
|
||||
.shots__item img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.shots__caption {
|
||||
margin: 0.85rem 0 0;
|
||||
color: var(--dim);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
</style>
|
||||
142
src/components/architecture/Allowlist.astro
Normal file
@@ -0,0 +1,142 @@
|
||||
---
|
||||
/**
|
||||
* "What reaches the public" — the second of `/architecture/`'s three diagrams (D21).
|
||||
*
|
||||
* The homepage states the split in one sentence inside the data-path walk ("a public one
|
||||
* carrying an allowlist of safe events, and a staff-only one carrying the rest… that split
|
||||
* is a security boundary, not a preference"). This is the page where that sentence has to
|
||||
* become a picture, because it is the single design decision a technical evaluator is most
|
||||
* entitled to be suspicious of: a live feed of a game world contains things that must never
|
||||
* be published, and "we filter it" is a claim, not a mechanism.
|
||||
*
|
||||
* So the diagram draws the shape of the mechanism — one stream in, one decision, two streams
|
||||
* out — and the notes say where the decision lives and what happens when it is wrong in
|
||||
* either direction. What it deliberately does NOT do is enumerate event kinds: that is the
|
||||
* catalog's job in the docs, it changes with the protocol, and a marketing page holding a
|
||||
* copy of it would be a copy that goes stale (§1).
|
||||
*
|
||||
* The rings sit behind the filter rather than behind the whole picture, on the phase-3
|
||||
* principle that they mark the one place the argument actually happens.
|
||||
*/
|
||||
---
|
||||
|
||||
<section class="page section diagram" id="allowlist">
|
||||
<div class="diagram__head">
|
||||
<p class="eyebrow">What reaches the public</p>
|
||||
<h2>One feed in, two feeds out</h2>
|
||||
<p class="prose">
|
||||
A live game world emits things that are fine on a front page and things that are not:
|
||||
who logged in from which address, what the cheat detector flagged, what a staff member
|
||||
did to whom. Both arrive on the same connection, so something has to divide them.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="diagram__body">
|
||||
<div class="diagram__figure">
|
||||
<svg viewBox="0 0 380 470" class="flow" aria-hidden="true" focusable="false">
|
||||
<!-- Centred on the filter: the one place in the picture where the argument is. -->
|
||||
<g class="rings">
|
||||
<circle cx="190" cy="178" r="96" />
|
||||
<circle cx="190" cy="178" r="136" />
|
||||
<circle cx="190" cy="178" r="176" />
|
||||
</g>
|
||||
|
||||
<rect class="node" x="20" y="12" width="340" height="60" rx="10" />
|
||||
<text class="node-title" x="40" y="38">Everything the game emits</text>
|
||||
<text class="node-sub" x="40" y="58">one authenticated stream, from the sidecar</text>
|
||||
|
||||
<path class="spine spine--live" d="M190 80 V132" />
|
||||
<path class="arrow arrow--live" d="M190 140 l-6 -10 h12 Z" />
|
||||
|
||||
<rect class="node node--self" x="20" y="142" width="340" height="72" rx="10" />
|
||||
<text class="node-title" x="40" y="172">Your site decides</text>
|
||||
<text class="node-sub" x="40" y="192">one allowlist, in one place, on your server</text>
|
||||
|
||||
<!-- Diverging: the public leg in cyan because it is still a live feed; the staff
|
||||
leg in gold because it is the privileged one. -->
|
||||
<path class="spine spine--live" d="M120 222 C120 268 96 268 96 306" />
|
||||
<path class="arrow arrow--live" d="M96 314 l-6 -10 h12 Z" />
|
||||
|
||||
<path class="spine" d="M260 222 C260 268 284 268 284 306" />
|
||||
<path class="arrow" d="M284 314 l-6 -10 h12 Z" />
|
||||
|
||||
<rect class="node" x="8" y="316" width="176" height="128" rx="10" />
|
||||
<text class="node-title" x="26" y="344">Public pages</text>
|
||||
<text class="node-sub" x="26" y="366">an allowlist of event</text>
|
||||
<text class="node-sub" x="26" y="382">kinds, and nothing</text>
|
||||
<text class="node-sub" x="26" y="398">outside it</text>
|
||||
<text class="node-audience" x="26" y="424">anyone at all</text>
|
||||
|
||||
<rect class="node" x="196" y="316" width="176" height="128" rx="10" />
|
||||
<text class="node-title" x="214" y="344">Staff console</text>
|
||||
<text class="node-sub" x="214" y="366">the rest: audit trail,</text>
|
||||
<text class="node-sub" x="214" y="382">login attempts,</text>
|
||||
<text class="node-sub" x="214" y="398">addresses, cheat flags</text>
|
||||
<text class="node-audience" x="214" y="424">signed-in staff only</text>
|
||||
</svg>
|
||||
|
||||
<p class="diagram__caption">
|
||||
The allowlist is the security boundary. A new kind of event is invisible to the public
|
||||
until somebody adds it, which is the safe direction to fail in.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="diagram__notes">
|
||||
<section>
|
||||
<h3>It is an allowlist, not a blocklist</h3>
|
||||
<p>
|
||||
The public stream carries the kinds of event that are named as safe; everything else
|
||||
goes to the staff stream by default. That ordering is the whole point. A blocklist
|
||||
fails open — the day the game emits something new, it is already published — and an
|
||||
allowlist fails closed, so the worst case is a page that is missing something rather
|
||||
than a page that has published an address.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>The decision lives on your server</h3>
|
||||
<p>
|
||||
Not in the sidecar and not in the game. The bridge is a deliberately dumb forwarder:
|
||||
it moves what the game emits and makes no judgements about audience. Everything
|
||||
about who may see what is decided by the site you run, in one place, where you can
|
||||
read it — and where changing it does not mean redeploying anything on the game host.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>More than two audiences, in practice</h3>
|
||||
<p>
|
||||
Two streams is the transport. Above it sits a configurable audience model — logged
|
||||
out, signed in, linked to a game account, staff — that decides how much of a given
|
||||
surface each of those sees. The public stream is the floor of that, and it is the
|
||||
one that is a boundary rather than a setting.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>When the game is down</h3>
|
||||
<p>
|
||||
Nothing arrives, and the site carries on. Live surfaces say the server is offline
|
||||
and everything that does not depend on it — the wiki, the news, accounts, the forums
|
||||
— is unaffected. A site that goes down with the game it reports on is not much of a
|
||||
status page.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
/* Each outcome node ends with a line naming its audience, set apart from the
|
||||
description above it rather than reading as another line of it.
|
||||
|
||||
Its own class, not `:nth-last-of-type`: an index into a list of `<text>`
|
||||
siblings is correct only until somebody adds a label, and it fails by
|
||||
styling the wrong words rather than by failing. */
|
||||
.node-audience {
|
||||
fill: var(--muted);
|
||||
font-family: var(--sans);
|
||||
font-size: 11.5px;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
166
src/components/architecture/ModuleSeam.astro
Normal file
@@ -0,0 +1,166 @@
|
||||
---
|
||||
import platform from '../../data/platform.json';
|
||||
|
||||
/**
|
||||
* "Where the game stops and the platform starts" — the third of `/architecture/`'s diagrams
|
||||
* (D21).
|
||||
*
|
||||
* 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`).
|
||||
*
|
||||
* 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
|
||||
* literally a version check, and a module whose declared range does not match refuses to
|
||||
* load rather than half-loading.
|
||||
*/
|
||||
---
|
||||
|
||||
<section class="page section diagram" id="module-seam">
|
||||
<div class="diagram__head">
|
||||
<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
|
||||
declared interface — which is what makes "put your game on it" a shape rather than a
|
||||
slogan.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="diagram__body">
|
||||
<div class="diagram__figure">
|
||||
<svg viewBox="0 0 380 500" class="flow" aria-hidden="true" focusable="false">
|
||||
<!-- Core: what ships in the image, on every deployment, module or not. -->
|
||||
<rect class="host" x="8" y="8" width="364" height="186" rx="14" />
|
||||
<text class="host-title" x="28" y="42">Runic Gateway core</text>
|
||||
<text class="host-sub" x="28" y="62">game-agnostic; the same image everywhere</text>
|
||||
|
||||
<rect class="node node--self" x="28" y="80" width="156" height="46" rx="10" />
|
||||
<text class="node-title" x="46" y="108">Accounts</text>
|
||||
|
||||
<rect class="node node--self" x="196" y="80" width="156" height="46" rx="10" />
|
||||
<text class="node-title" x="214" y="108">Teams</text>
|
||||
|
||||
<rect class="node node--self" x="28" y="134" width="156" height="46" rx="10" />
|
||||
<text class="node-title" x="46" y="162">Wiki and posts</text>
|
||||
|
||||
<rect class="node node--self" x="196" y="134" width="156" height="46" rx="10" />
|
||||
<text class="node-title" x="214" y="162">Admin and API</text>
|
||||
|
||||
<!-- The seam. Both boundary lines and the label between them: this is the one
|
||||
thing in the picture that is neither core nor module. -->
|
||||
<path class="boundary" d="M8 224 H372" />
|
||||
<text class="seam-label" x="190" y="252" text-anchor="middle">
|
||||
Module API {platform.moduleApi}
|
||||
</text>
|
||||
<path class="boundary" d="M8 272 H372" />
|
||||
|
||||
<!-- Registers upward; is asked downward. Two arrows, opposite directions, because
|
||||
the traffic across a seam is not one-way and drawing it as one-way is what
|
||||
makes people think a module is a plugin that only listens. -->
|
||||
<path class="spine" d="M120 300 V206" />
|
||||
<path class="arrow" d="M120 198 l-6 10 h12 Z" />
|
||||
<text class="seam-arrow" x="136" y="216">registers</text>
|
||||
|
||||
<path class="spine" d="M260 200 V294" />
|
||||
<path class="arrow" d="M260 302 l-6 -10 h12 Z" />
|
||||
<text class="seam-arrow" x="244" y="290" text-anchor="end">calls</text>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<rect class="node" x="28" y="378" width="156" height="46" rx="10" />
|
||||
<text class="node-title" x="46" y="406">Routes</text>
|
||||
|
||||
<rect class="node" x="196" y="378" width="156" height="46" rx="10" />
|
||||
<text class="node-title" x="214" y="406">Screens</text>
|
||||
|
||||
<rect class="node" x="28" y="432" width="156" height="46" rx="10" />
|
||||
<text class="node-title" x="46" y="460">Its own tables</text>
|
||||
|
||||
<rect class="node" x="196" y="432" width="156" height="46" rx="10" />
|
||||
<text class="node-title" x="214" y="460">Nav rows</text>
|
||||
</svg>
|
||||
|
||||
<p class="diagram__caption">
|
||||
A module declares which versions of the interface it speaks. If that does not match
|
||||
what the site offers, it refuses to load and the site comes up without it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="diagram__notes">
|
||||
<section>
|
||||
<h3>The module brings its own everything</h3>
|
||||
<p>
|
||||
Not just screens: its routes, its database tables, its navigation rows, its slice of
|
||||
the OpenAPI spec and its own prebuilt client bundle. Installing it is a paste in the
|
||||
admin panel or a line in your environment — never a build step, because production
|
||||
runs an image you pulled, and an operator who has to compile something has been
|
||||
handed a maintenance job rather than a feature.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Failure is contained by design</h3>
|
||||
<p>
|
||||
A module that will not load is marked as failed and the site starts without it.
|
||||
Disabling one is a kill switch, not a visibility flag — its routes stop answering
|
||||
and its live connections close. Uninstalling keeps the data, and destroying the data
|
||||
is a separate, deliberate choice made in its own dialog.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Teams is the shape of the contract</h3>
|
||||
<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
|
||||
supplies the machinery, the module supplies the meaning.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<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.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
/* The seam label sits between the two boundary rules rather than beside them: it is
|
||||
the name of the gap, not an annotation on either side of it. Gold, because it is
|
||||
the one contract in the picture. */
|
||||
.seam-label {
|
||||
fill: var(--gold);
|
||||
font-family: var(--sans);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Two words, because two arrows crossing a boundary in opposite directions is
|
||||
ambiguous without them — and the ambiguity is the exact misreading this diagram
|
||||
exists to prevent, that a module is something core talks at. */
|
||||
.seam-arrow {
|
||||
fill: var(--dim);
|
||||
font-family: var(--sans);
|
||||
font-size: 11px;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
122
src/components/architecture/TwoHosts.astro
Normal file
@@ -0,0 +1,122 @@
|
||||
---
|
||||
/**
|
||||
* "What you actually deploy" — the first of `/architecture/`'s three diagrams (D21).
|
||||
*
|
||||
* This one exists because of a specific, repeated misunderstanding that §10 names and the
|
||||
* homepage's CTA already spends two sentences on: a Runic Gateway install is two
|
||||
* independent installs, on two machines, and neither installs the other. The homepage says
|
||||
* it; this page draws it, because an evaluator deciding whether to run the software is
|
||||
* 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
|
||||
* is the one drawn argument: everything above it is reachable because you published it, and
|
||||
* everything below it is not reachable at all.
|
||||
*
|
||||
* The vocabulary and the layout are `src/styles/diagram.css`; only the geometry is here.
|
||||
*/
|
||||
---
|
||||
|
||||
<section class="page section diagram" id="two-hosts">
|
||||
<div class="diagram__head">
|
||||
<p class="eyebrow">What you deploy</p>
|
||||
<h2>Two hosts, two installs</h2>
|
||||
<p class="prose">
|
||||
Almost everyone gets this wrong once. The website and the game-side bridge are separate
|
||||
deployments on separate machines, and neither one installs the other — so a "Runic
|
||||
Gateway install" is really two, done in that order.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="diagram__body">
|
||||
<div class="diagram__figure">
|
||||
<svg viewBox="0 0 380 546" class="flow" aria-hidden="true" focusable="false">
|
||||
<!-- The web host, and everything that runs on it. -->
|
||||
<rect class="host" x="8" y="8" width="364" height="232" rx="14" />
|
||||
<text class="host-title" x="28" y="42">Your web host</text>
|
||||
<text class="host-sub" x="28" y="62">a VPS, a home server, anything running Docker</text>
|
||||
|
||||
<rect class="node node--self" x="28" y="80" width="324" height="60" rx="10" />
|
||||
<text class="node-title" x="46" y="106">Runic Gateway</text>
|
||||
<text class="node-sub" x="46" y="126">one container, pulled not built</text>
|
||||
|
||||
<rect class="node" x="28" y="150" width="156" height="60" rx="10" />
|
||||
<text class="node-title" x="46" y="176">Game module</text>
|
||||
<text class="node-sub" x="46" y="196">installed, not built</text>
|
||||
|
||||
<rect class="node" x="196" y="150" width="156" height="60" rx="10" />
|
||||
<text class="node-title" x="214" y="176">Database</text>
|
||||
<text class="node-sub" x="214" y="196">your data, your disk</text>
|
||||
|
||||
<!-- The one hop between them, and the only one. Two arrowheads because the traffic
|
||||
genuinely goes both ways: the site calls the sidecar for point-in-time reads,
|
||||
and the sidecar pushes the live feed back up. -->
|
||||
<path class="spine spine--live" d="M190 248 V312" />
|
||||
<path class="arrow arrow--live" d="M190 240 l-6 10 h12 Z" />
|
||||
<path class="arrow arrow--live" d="M190 320 l-6 -10 h12 Z" />
|
||||
|
||||
<path class="boundary" d="M8 280 H372" />
|
||||
<text class="boundary-label" x="372" y="273" text-anchor="end">the network</text>
|
||||
|
||||
<!-- The game host. Nothing here is reachable from outside except the sidecar. -->
|
||||
<rect class="host" x="8" y="320" width="364" height="214" rx="14" />
|
||||
<text class="host-title" x="28" y="354">Your game host</text>
|
||||
<text class="host-sub" x="28" y="374">where the game server already runs</text>
|
||||
|
||||
<rect class="node" x="28" y="392" width="324" height="60" rx="10" />
|
||||
<text class="node-title" x="46" y="418">Sidecar</text>
|
||||
<text class="node-sub" x="46" y="438">the only part of this with a port open</text>
|
||||
|
||||
<rect class="node" x="28" y="462" width="324" height="60" rx="10" />
|
||||
<text class="node-title" x="46" y="488">Game server</text>
|
||||
<text class="node-sub" x="46" y="508">dials out over loopback; listens for nothing</text>
|
||||
</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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="diagram__notes">
|
||||
<section>
|
||||
<h3>The web host</h3>
|
||||
<p>
|
||||
A Docker Compose deployment: the site, its database, and whichever game module you
|
||||
installed. Images are pulled rather than built, so nothing compiles here and an
|
||||
upgrade is a pull and a restart. This is the only machine anybody points a browser
|
||||
at, and the only one that needs a certificate.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<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.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Why they share a host</h3>
|
||||
<p>
|
||||
The game talks to the sidecar over loopback, on the same machine, and dials
|
||||
<em>out</em> to do it. That is what lets the game server open no port at all — and it
|
||||
is also why there is no macOS installer build. The pair has to sit together, and no
|
||||
game server anybody runs is on one.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>What crosses between them</h3>
|
||||
<p>
|
||||
One authenticated connection, in both directions: a WebSocket carrying the live feed
|
||||
up, and REST calls going down for point-in-time questions. Nothing else on either
|
||||
machine talks to the other, and the sidecar answers your site and nobody else.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
167
src/components/home/Capabilities.astro
Normal file
@@ -0,0 +1,167 @@
|
||||
---
|
||||
import platform from '../../data/platform.json';
|
||||
import { capabilityGroups, assertCapabilityCoverage } from '../../data/capabilities.mjs';
|
||||
|
||||
/**
|
||||
* The grouped capabilities (PLAN.md §10). All five groups, named only — the argument for
|
||||
* each one is `/features/`'s job in phase 4, and repeating it here would create a second
|
||||
* 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;
|
||||
* 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
|
||||
* list is described there as "as load-bearing as the rest", and a homepage that lists only
|
||||
* what exists while quietly omitting the well-known things that do not is the exact failure
|
||||
* §1 is written to prevent.
|
||||
*/
|
||||
assertCapabilityCoverage(platform.moduleUoCapabilities);
|
||||
---
|
||||
|
||||
<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.
|
||||
</p>
|
||||
|
||||
<div class="caps__grid">
|
||||
{
|
||||
capabilityGroups.map((group) => (
|
||||
<section class:list={['panel', 'caps__group', group.items.length > 8 && 'caps__group--wide']}>
|
||||
<header class="caps__group-head">
|
||||
<h3>{group.title}</h3>
|
||||
{group.moduleSupplied && <span class="chip">Module-supplied</span>}
|
||||
</header>
|
||||
|
||||
<p class="caps__summary">{group.summary}</p>
|
||||
|
||||
<ul class="caps__items">
|
||||
{group.items.map((item) => (
|
||||
<li>{item.label}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<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
|
||||
listed rather than left out, on <a href="/features/">features</a> and{' '}
|
||||
<a href="/integrations/">integrations</a>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.caps h2 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: clamp(1.6rem, 3.2vw, 2.1rem);
|
||||
}
|
||||
|
||||
.caps__lede {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.caps__grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin-top: 2.25rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 19rem), 1fr));
|
||||
}
|
||||
|
||||
.caps__group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.caps__group-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.caps__group h3 {
|
||||
margin: 0;
|
||||
color: var(--gold);
|
||||
font-size: 1.06rem;
|
||||
}
|
||||
|
||||
.caps__summary {
|
||||
margin: 0.6rem 0 1rem;
|
||||
color: var(--dim);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.caps__items {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
font-size: 0.94rem;
|
||||
}
|
||||
|
||||
.caps__items li {
|
||||
position: relative;
|
||||
padding-left: 1.1rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.caps__items li + li {
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
/* A drawn marker rather than a list bullet: it takes the portal colour, so it
|
||||
tracks a mounted theme the way a `list-style` glyph would not. */
|
||||
.caps__items li::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0.62em;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--portal);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
/* Five groups in a three-column grid leaves a hole, and the one group that is
|
||||
twice the length of the others is the obvious thing to put in it. Game
|
||||
intelligence takes both remaining slots on the top row and sets its items
|
||||
in two columns, which fills the row and gives the module-supplied group the
|
||||
prominence it has earned by being the only one that is module-supplied.
|
||||
|
||||
The width is read from the content — a group long enough to need it gets it
|
||||
— rather than named, so a future group of that size lands the same way.
|
||||
|
||||
Guarded by a width query because `span 2` in a grid that is only one column
|
||||
wide is an overflow, not a layout. */
|
||||
@media (min-width: 62rem) {
|
||||
.caps__group--wide {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.caps__group--wide .caps__items {
|
||||
columns: 2;
|
||||
column-gap: 1.75rem;
|
||||
}
|
||||
|
||||
/* `columns` would otherwise break an item across the column boundary, and a
|
||||
capability split over two columns reads as two capabilities. */
|
||||
.caps__group--wide .caps__items li {
|
||||
break-inside: avoid;
|
||||
}
|
||||
}
|
||||
|
||||
.caps__foot {
|
||||
margin: 2rem 0 0;
|
||||
color: var(--dim);
|
||||
font-size: 0.94rem;
|
||||
}
|
||||
</style>
|
||||
235
src/components/home/DataPath.astro
Normal file
@@ -0,0 +1,235 @@
|
||||
---
|
||||
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 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
|
||||
* should never need to know that `link`, `servuo-plugins` and `installer` are three
|
||||
* 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.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY THE SVG IS aria-hidden
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* Not because it is decorative — it is the opposite — but because the steps beside it carry
|
||||
* the same four stages in full prose, at real font sizes, in reading order. A `role="img"`
|
||||
* with a `<desc>` would make a screen reader read the same path twice, and the second
|
||||
* telling would be the worse one. The picture is for people who can see it; the list is the
|
||||
* canonical version and everyone gets it.
|
||||
*
|
||||
* That also means the diagram must never gain a fact the list does not have.
|
||||
*
|
||||
* The concentric rings behind the nodes are the emblem's own geometry, centred on the
|
||||
* boundary line — the one place in the picture where the argument actually happens.
|
||||
*/
|
||||
---
|
||||
|
||||
<section class="page section datapath">
|
||||
<div class="datapath__head">
|
||||
<p class="eyebrow">How it works</p>
|
||||
<h2>One path, one direction</h2>
|
||||
<p class="prose">
|
||||
Everything the website knows about your game arrives the same way. There is no second
|
||||
route in, and nothing on the internet can reach the game to ask.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="datapath__body">
|
||||
<div class="datapath__figure">
|
||||
<svg viewBox="0 0 380 500" class="flow" aria-hidden="true" focusable="false">
|
||||
<!-- The emblem's concentric rings, centred on the boundary. Drawn first so the
|
||||
panels sit over them. -->
|
||||
<g class="rings">
|
||||
<circle cx="190" cy="252" r="112" />
|
||||
<circle cx="190" cy="252" r="158" />
|
||||
<circle cx="190" cy="252" r="204" />
|
||||
</g>
|
||||
|
||||
<!-- Loopback hop: same host, no network involved. -->
|
||||
<path class="spine" d="M190 92 V140" />
|
||||
<path class="arrow" d="M190 148 l-6 -10 h12 Z" />
|
||||
|
||||
<!-- The network hop, and the only one. Drawn in the portal colour because this is
|
||||
the live feed, and the live signal is cyan everywhere on the site. -->
|
||||
<path class="spine spine--live" d="M190 224 V272" />
|
||||
<path class="arrow arrow--live" d="M190 280 l-6 -10 h12 Z" />
|
||||
|
||||
<path class="spine" d="M190 356 V404" />
|
||||
<path class="arrow" d="M190 412 l-6 -10 h12 Z" />
|
||||
|
||||
<!-- The boundary the whole design exists to draw. -->
|
||||
<path class="boundary" d="M8 252 H372" />
|
||||
<text class="boundary-label" x="372" y="245" text-anchor="end">the network</text>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<text class="node-sub" x="42" y="336">your public website</text>
|
||||
|
||||
<rect class="node" x="20" y="412" width="340" height="76" rx="12" />
|
||||
<text class="node-title" x="42" y="446">Browser and app</text>
|
||||
<text class="node-sub" x="42" y="468">anyone you choose to let in</text>
|
||||
</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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ol class="datapath__steps">
|
||||
<li>
|
||||
<h3>Your game server</h3>
|
||||
<p>
|
||||
A plugin inside the server dials <strong>out</strong> to the sidecar over loopback.
|
||||
The game never listens for anything, so there is nothing on it to find. Events go
|
||||
onto a bounded queue and the game moves on — a sidecar that is wedged or missing
|
||||
cannot slow the world down.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<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
|
||||
backend, over an authenticated WebSocket and REST.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<h3>Runic Gateway</h3>
|
||||
<p>
|
||||
Your site ingests the live feed and fans it back out on two streams: a public one
|
||||
carrying an allowlist of safe events, and a staff-only one carrying the rest. That
|
||||
split is a security boundary, not a preference. When the game is down the site stays
|
||||
up and shows it as offline.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<h3>Browser and app</h3>
|
||||
<p>
|
||||
The web client reads same-origin JSON and server-sent events. The Android app talks
|
||||
to the same documented API with bearer tokens. Neither has any idea where the game
|
||||
server is, because neither is ever told.
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.datapath__head h2 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: clamp(1.6rem, 3.2vw, 2.1rem);
|
||||
}
|
||||
|
||||
.datapath__head .prose {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.datapath__body {
|
||||
display: grid;
|
||||
gap: clamp(1.75rem, 4vw, 3rem);
|
||||
margin-top: 2.5rem;
|
||||
grid-template-columns: minmax(0, 380px) minmax(0, 1fr);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.datapath__figure {
|
||||
position: sticky;
|
||||
top: calc(var(--header-h) + 1.5rem);
|
||||
}
|
||||
|
||||
.datapath__caption {
|
||||
margin: 1rem 0 0;
|
||||
max-width: 380px;
|
||||
color: var(--dim);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* The SVG vocabulary this diagram draws with -- .node, .spine, .arrow,
|
||||
.boundary, .rings -- now lives in src/styles/diagram.css, shared with
|
||||
/architecture/'s three. It was duplicated in four files the moment the
|
||||
second diagram existed, and the rules it holds are decisions about what a
|
||||
diagram on this site looks like rather than about this one.
|
||||
|
||||
The layout below stays here: the right-hand column is a numbered walk,
|
||||
not the notes column .diagram__body assumes. */
|
||||
|
||||
/* ---- The list ---------------------------------------------------------- */
|
||||
.datapath__steps {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
counter-reset: step;
|
||||
}
|
||||
|
||||
.datapath__steps li {
|
||||
position: relative;
|
||||
padding-left: 3.25rem;
|
||||
counter-increment: step;
|
||||
}
|
||||
|
||||
.datapath__steps li + li {
|
||||
margin-top: 1.75rem;
|
||||
}
|
||||
|
||||
.datapath__steps li::before {
|
||||
content: counter(step);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border: 1px solid var(--gold-deep);
|
||||
border-radius: var(--radius-pill);
|
||||
color: var(--gold);
|
||||
font-family: var(--display);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.datapath__steps h3 {
|
||||
margin: 0.3rem 0 0.4rem;
|
||||
font-size: 1.08rem;
|
||||
}
|
||||
|
||||
.datapath__steps p {
|
||||
margin: 0;
|
||||
max-width: var(--measure);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.datapath__body {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
/* Sticky is a wide-screen affordance: the figure should scroll away with
|
||||
everything else once it is above the list rather than beside it. */
|
||||
.datapath__figure {
|
||||
position: static;
|
||||
justify-self: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
117
src/components/home/GetStarted.astro
Normal file
@@ -0,0 +1,117 @@
|
||||
---
|
||||
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
|
||||
* separate Docker deployment.
|
||||
*
|
||||
* That belongs on the homepage rather than being saved for the docs. It is the single
|
||||
* misunderstanding most likely to make an evaluator think the software is broken, it costs
|
||||
* two sentences to prevent, and §13 calls the installation path the priority of the whole
|
||||
* 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.
|
||||
*
|
||||
* 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
|
||||
* this file that nothing checks and that a later phase would have to remember to fix.
|
||||
*/
|
||||
---
|
||||
|
||||
<section class="page section start">
|
||||
<div class="panel start__panel">
|
||||
<p class="eyebrow">Getting started</p>
|
||||
<h2>An install is two installs</h2>
|
||||
<p class="start__lede prose">
|
||||
This trips up almost everyone once. The website and the game-side bridge are separate
|
||||
deployments on separate machines, and neither one installs the other. Doing them in
|
||||
order takes an evening.
|
||||
</p>
|
||||
|
||||
<div class="start__halves">
|
||||
<div class="start__half">
|
||||
<h3><span class="start__num">1</span> The site</h3>
|
||||
<p>
|
||||
A Docker Compose deployment on whatever host serves your community — a small VPS is
|
||||
plenty. Pull the images, bring it up, create the first admin, then install a game
|
||||
module from the admin panel.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="start__actions">
|
||||
<a class="btn btn--primary" href="/docs/">Read the install guide</a>
|
||||
<a class="btn btn--ghost" href={brand.giteaOrg} rel="noopener noreferrer">Browse the source</a>
|
||||
<a class="btn btn--ghost" href={brand.discordInvite} rel="noopener noreferrer">Ask on Discord</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.start__panel {
|
||||
padding: clamp(1.5rem, 4vw, 2.75rem);
|
||||
}
|
||||
|
||||
.start h2 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: clamp(1.5rem, 3vw, 2rem);
|
||||
}
|
||||
|
||||
.start__lede {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.start__halves {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
margin-top: 2rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr));
|
||||
}
|
||||
|
||||
.start__half h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.start__num {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
flex: none;
|
||||
border: 1px solid var(--gold-deep);
|
||||
border-radius: var(--radius-pill);
|
||||
color: var(--gold);
|
||||
font-family: var(--display);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.start__half p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.start__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-top: 2.25rem;
|
||||
}
|
||||
</style>
|
||||
183
src/components/home/Hero.astro
Normal file
@@ -0,0 +1,183 @@
|
||||
---
|
||||
import { brand } from '../../lib/brand.mjs';
|
||||
import platform from '../../data/platform.json';
|
||||
|
||||
/**
|
||||
* The hero (PLAN.md §13 phase 3).
|
||||
*
|
||||
* The org lead chose an emblem hero over a type-only one: the mark carries recognition
|
||||
* across the site, the Android launcher icon and the Play listing, and showing it large is
|
||||
* what makes those three read as one product (D11, §11).
|
||||
*
|
||||
* It costs what D16 already accepted — the emblem is raster illustration, so a mounted
|
||||
* `theme.css` recolours everything around it and not the mark itself. Replacing the mark
|
||||
* means replacing `logo.png`, and because every size here is derived on request from
|
||||
* whichever `logo.png` is in force (D14), that one file changes the hero, the header, the
|
||||
* tab icon and the installed app icon together.
|
||||
*
|
||||
* The glow behind it is drawn in CSS from the portal tokens, so it DOES follow a mounted
|
||||
* theme. That is deliberate: the part that can track the operator's palette does.
|
||||
*
|
||||
* The <h1> is the tagline rather than the product name. The name is in the header, in the
|
||||
* page title and in the footer; a visitor who has just arrived needs the sentence more than
|
||||
* the noun. Both strings are brand fields, rewritten at boot by `applyBrand.mjs` (D15).
|
||||
*/
|
||||
---
|
||||
|
||||
<section class="hero">
|
||||
<div class="page hero__inner">
|
||||
<div class="hero__copy">
|
||||
<p class="eyebrow">Self-hosted community platform</p>
|
||||
|
||||
<h1>{brand.tagline}</h1>
|
||||
|
||||
<p class="hero__lede">
|
||||
{brand.siteName} is a community website for a game server — accounts, teams, forums, a
|
||||
wiki, news and a full admin panel — with a one-way bridge that puts the server's live
|
||||
world on the public site. The game itself never listens on the internet.
|
||||
</p>
|
||||
|
||||
<div class="hero__actions">
|
||||
<a class="btn btn--primary" href="/docs/">Install it</a>
|
||||
<a class="btn btn--ghost" href="/features/">See what it does</a>
|
||||
|
||||
{/*
|
||||
The demo slot (§15 / D12). `global.css` hides `[data-demo-url='']`, so a stock
|
||||
build renders nothing here; `applyBrand.mjs` fills both attributes at boot when a
|
||||
mounted `brand.json` sets `demoUrl`, and the link appears.
|
||||
|
||||
The attribute pair is a literal contract with that script — `href` immediately
|
||||
followed by `data-demo-url`, both empty, in this order. Astro preserves attribute
|
||||
order, so what is written here is what ends up in the HTML it searches for. Do not
|
||||
insert an attribute between them.
|
||||
*/}
|
||||
<a class="btn demo-cta" href="" data-demo-url="">See it running</a>
|
||||
</div>
|
||||
|
||||
<div class="chips">
|
||||
<span class="chip chip--version">Protocol {platform.protocol}</span>
|
||||
<span class="chip chip--version">Module API {platform.moduleApi}</span>
|
||||
<span class="chip chip--version">Bundle {platform.bundle.tag}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hero__mark">
|
||||
{/*
|
||||
`alt=""` because the emblem is the product's mark sitting beside the product's own
|
||||
sentence — announcing it would add nothing a reader of the <h1> does not have.
|
||||
|
||||
Sizes are on `brandAssets.mjs`'s allowlist; `checkBrand.mjs` puts every URL below
|
||||
through the route's own classifier, so a plausible-but-underivable size fails the
|
||||
build rather than 404ing in production.
|
||||
*/}
|
||||
<picture>
|
||||
<source
|
||||
type="image/avif"
|
||||
srcset="/brand/logo-256.avif 256w, /brand/logo-384.avif 384w, /brand/logo-512.avif 512w"
|
||||
sizes="(max-width: 900px) 176px, 320px"
|
||||
/>
|
||||
<img
|
||||
src="/brand/logo-384.webp"
|
||||
srcset="/brand/logo-256.webp 256w, /brand/logo-384.webp 384w, /brand/logo-512.webp 512w"
|
||||
sizes="(max-width: 900px) 176px, 320px"
|
||||
width="384"
|
||||
height="384"
|
||||
alt=""
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
/>
|
||||
</picture>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.hero {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding-block: clamp(2.5rem, 7vw, 5rem) clamp(2rem, 5vw, 3.5rem);
|
||||
}
|
||||
|
||||
.hero__inner {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
gap: clamp(1.5rem, 5vw, 3.5rem);
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.hero__copy {
|
||||
max-width: 40rem;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
color: var(--gold);
|
||||
font-size: clamp(2.1rem, 5.2vw, 3.35rem);
|
||||
}
|
||||
|
||||
.hero__lede {
|
||||
margin: 1.15rem 0 0;
|
||||
max-width: var(--measure);
|
||||
color: var(--muted);
|
||||
font-size: clamp(1rem, 1.6vw, 1.13rem);
|
||||
}
|
||||
|
||||
.hero__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1.9rem;
|
||||
}
|
||||
|
||||
.hero .chips {
|
||||
margin-top: 1.75rem;
|
||||
}
|
||||
|
||||
/* ---- The mark ---------------------------------------------------------
|
||||
The glow is a radial gradient mixed from the portal tokens rather than a
|
||||
literal, so a mounted theme.css moves it with the rest of the palette.
|
||||
It is behind the emblem and outside the flow, so it costs no layout. */
|
||||
.hero__mark {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.hero__mark::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
inset: 50% auto auto 50%;
|
||||
translate: -50% -50%;
|
||||
width: 150%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: var(--radius-pill);
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
color-mix(in srgb, var(--portal-deep) 34%, transparent) 0%,
|
||||
color-mix(in srgb, var(--portal-deep) 8%, transparent) 45%,
|
||||
transparent 68%
|
||||
);
|
||||
}
|
||||
|
||||
.hero__mark img {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: block;
|
||||
width: clamp(176px, 26vw, 320px);
|
||||
height: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.hero__inner {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
/* The mark leads on a narrow screen: it is the fastest thing to recognise,
|
||||
and stacking it under the copy would push it below the fold entirely. */
|
||||
.hero__mark {
|
||||
order: -1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
107
src/components/home/SelfHosted.astro
Normal file
@@ -0,0 +1,107 @@
|
||||
---
|
||||
/**
|
||||
* The self-hosted argument (PLAN.md §10, the `/` row).
|
||||
*
|
||||
* Every claim below is from §2's verified state, and each is deliberately the kind of thing
|
||||
* that can be checked by running the software rather than by trusting the page. Where a
|
||||
* claim would need a qualifier, the qualifier is on the card — "understated honesty" (D8)
|
||||
* is a house style, and a hedge in small print is the opposite of it.
|
||||
*
|
||||
* Nothing here is a version or a number, so nothing here needs `platform.json`. If a card
|
||||
* ever gains one, it reads it from there like everything else (§12).
|
||||
*/
|
||||
|
||||
const points = [
|
||||
{
|
||||
title: 'It runs on your box',
|
||||
body:
|
||||
'Docker Compose, with prebuilt images that are pulled rather than built — nothing ' +
|
||||
'compiles on your server. One command up, one command back.',
|
||||
},
|
||||
{
|
||||
title: 'The game stays off the internet',
|
||||
body:
|
||||
'The game host opens no inbound port. The sidecar beside it is the only exposed ' +
|
||||
'part of the bridge, and it answers exactly one caller: your website.',
|
||||
},
|
||||
{
|
||||
title: 'Branding is data, not a rebuild',
|
||||
body:
|
||||
'Name, colours, logo and contact address live in a mounted file. The same image ' +
|
||||
'runs as any community — including this site, which is built the same way.',
|
||||
},
|
||||
{
|
||||
title: 'No analytics, anywhere',
|
||||
body:
|
||||
'This site has no trackers, no third-party requests and no cookie banner, because ' +
|
||||
'it collects nothing. Your deployment talks to the services you configure, and to ' +
|
||||
'nothing you did not.',
|
||||
},
|
||||
{
|
||||
title: 'Documented, not just working',
|
||||
body:
|
||||
'The whole backend is described by an OpenAPI 3.0 spec that ships with it, so the ' +
|
||||
'API you build against is the API that is actually there.',
|
||||
},
|
||||
{
|
||||
title: 'Free software',
|
||||
body:
|
||||
'GPL-3.0-or-later, every repository in the open. If this project stops, what you ' +
|
||||
'are running does not.',
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<section class="page section selfhosted">
|
||||
<p class="eyebrow">Why self-hosted</p>
|
||||
<h2>Your server, your data, your rules</h2>
|
||||
<p class="prose selfhosted__lede">
|
||||
There is no hosted tier and no account with us. The whole thing is software you run,
|
||||
which is the only arrangement under which "the game is not on the internet" can mean
|
||||
anything.
|
||||
</p>
|
||||
|
||||
<ul class="selfhosted__grid">
|
||||
{
|
||||
points.map((point) => (
|
||||
<li class="panel">
|
||||
<h3>{point.title}</h3>
|
||||
<p>{point.body}</p>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.selfhosted h2 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: clamp(1.6rem, 3.2vw, 2.1rem);
|
||||
}
|
||||
|
||||
.selfhosted__lede {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.selfhosted__grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin: 2.25rem 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 17rem), 1fr));
|
||||
}
|
||||
|
||||
.selfhosted__grid h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--gold);
|
||||
font-size: 1.02rem;
|
||||
}
|
||||
|
||||
.selfhosted__grid p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.94rem;
|
||||
}
|
||||
</style>
|
||||
44
src/components/home/WhatItLooksLike.astro
Normal file
@@ -0,0 +1,44 @@
|
||||
---
|
||||
import Screenshot from '../Screenshot.astro';
|
||||
|
||||
/**
|
||||
* The homepage's one screenshot. PLAN.md §13 phase 9, D4 / D44.
|
||||
*
|
||||
* -----------------------------------------------------------------------------------------
|
||||
* WHY ONE, AND WHY THIS ONE
|
||||
* -----------------------------------------------------------------------------------------
|
||||
* `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 shard, so the section directly
|
||||
* under the diagram is the diagram's evidence.
|
||||
*
|
||||
* A gallery here would compete with `Capabilities` further down, which is the part of the
|
||||
* homepage that enumerates. So: one figure, the signature screen, and the rest of the set
|
||||
* on `/features/` where each one 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">
|
||||
A demo deployment with a real shard behind it. The gold supply, the state of the link and
|
||||
the player online in Britain are all read from the game server over the bridge. None of it
|
||||
is typed in, and none of it is a mock-up.
|
||||
</p>
|
||||
|
||||
<Screenshot id="shard-status" />
|
||||
</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);
|
||||
}
|
||||
</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 = [];
|
||||
@@ -16,14 +16,87 @@
|
||||
export const docsSidebar = [
|
||||
{
|
||||
label: 'Getting started',
|
||||
items: [{ label: 'What is Runic Gateway?', slug: 'docs' }],
|
||||
items: [
|
||||
{ label: 'What is Runic Gateway?', slug: 'docs' },
|
||||
{ label: 'Requirements', slug: 'docs/getting-started/requirements' },
|
||||
{ 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: 'Verify the whole stack', slug: 'docs/getting-started/verify-the-whole-stack' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Administration',
|
||||
items: [
|
||||
{ label: 'Configuration', slug: 'docs/administration/configuration' },
|
||||
{ label: 'Branding and theming', slug: 'docs/administration/branding-and-theming' },
|
||||
{ label: 'Navigation and pages', slug: 'docs/administration/navigation-and-pages' },
|
||||
{ label: 'Content', slug: 'docs/administration/content' },
|
||||
{ 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: 'Client files', slug: 'docs/administration/client-files' },
|
||||
{ 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: 'sidecar.toml', slug: 'docs/reference/sidecar-toml' },
|
||||
{ label: 'Bridge.cfg', slug: 'docs/reference/bridge-cfg' },
|
||||
{ label: 'HTTP API', slug: 'docs/reference/http-api' },
|
||||
{ label: 'Shard event catalog', slug: 'docs/reference/event-catalog' },
|
||||
{ label: 'Canonical documents', slug: 'docs/reference/canonical-documents' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 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': [
|
||||
@@ -39,13 +112,18 @@ export const plannedSidebar = {
|
||||
'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',
|
||||
'Client files',
|
||||
'Maintenance and upgrades',
|
||||
'Troubleshooting',
|
||||
],
|
||||
@@ -64,6 +142,7 @@ export const plannedSidebar = {
|
||||
'The bridge',
|
||||
'Authentication architecture',
|
||||
'Teams architecture',
|
||||
'Events architecture',
|
||||
'Protocol versions',
|
||||
],
|
||||
Reference: [
|
||||
@@ -72,7 +151,7 @@ export const plannedSidebar = {
|
||||
'sidecar.toml',
|
||||
'Bridge.cfg',
|
||||
'HTTP API',
|
||||
'Event catalog',
|
||||
'Shard event catalog',
|
||||
'Canonical documents',
|
||||
],
|
||||
};
|
||||
|
||||
82
src/content/docs/docs/administration/authentication.mdx
Normal file
@@ -0,0 +1,82 @@
|
||||
---
|
||||
title: Authentication
|
||||
description: Local accounts and two-factor, SSO providers and the link-only policy, and the layer that keeps automated traffic out.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
**Admin → Authentication** has four tabs: Local Accounts, Google, Discord 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.
|
||||
|
||||
## Local accounts
|
||||
|
||||
Username and password sign-in is **always enabled and cannot be turned off**. It is how you
|
||||
manage accounts and how SSO identities get linked in the first place, so there is no
|
||||
configuration on this tab beyond that statement.
|
||||
|
||||
**Two-factor** is a per-account, opt-in TOTP code, set up by each person under **Account**
|
||||
in the sidebar. Nobody can enable it on someone else's behalf, and staff accounts are the
|
||||
ones worth insisting on.
|
||||
|
||||
## SSO providers
|
||||
|
||||
Google and Discord each need a client ID and secret from that provider's developer console;
|
||||
Custom Providers takes any OAuth2/OIDC issuer. Secrets are encrypted at rest with
|
||||
`SECRET_ENC_KEY` and are never returned to any client.
|
||||
|
||||
<Aside type="caution" title="SSO is link-only, by policy">
|
||||
An external identity can only sign in to an account it is **already linked to**. Signing in
|
||||
with Google does not create an account, ever. People link a provider themselves from their
|
||||
own account screen, and that link is what grants the access — so a stranger with a Google
|
||||
account is still a stranger.
|
||||
</Aside>
|
||||
|
||||
Configuring Google here also unlocks **email delivery**, which reuses the same OAuth client
|
||||
— see [Notifications and email](/docs/administration/notifications-and-email/).
|
||||
|
||||
## Trusted devices
|
||||
|
||||
A second factor that asks on every sign-in on the same laptop trains people to click
|
||||
through it. A device can be remembered after a successful two-factor challenge, and the
|
||||
trust rides the browser's own cookie jar — including the in-app browser tab the Android app
|
||||
opens for SSO, which is why signing in there does not ask again.
|
||||
|
||||
Trust is per device and revocable, and it survives signing out: signing out ends a session,
|
||||
not the statement that this machine is yours.
|
||||
|
||||
## What keeps the automated traffic out
|
||||
|
||||
Four layers, all on by default:
|
||||
|
||||
- **Rate limiting and backoff** on the login routes, so a password guess costs time.
|
||||
- **A honeypot field** that a human never fills in and a naive bot always does.
|
||||
- **Bot scoring**, which accumulates points against an address for behaviour no human
|
||||
produces, and bans it automatically past a threshold.
|
||||
- **IP bans** from that scoring.
|
||||
|
||||
**Admin → Web Bot Activity** shows the live state: currently banned addresses with their
|
||||
score and expiry, and the recent events with the reason, path and points that produced
|
||||
them. It is deliberately **read-only apart from an emergency unban** — there is nothing to
|
||||
tune here, and the panel exists so that a legitimate user locked out by their office's
|
||||
shared address can be let back in.
|
||||
|
||||
<Aside type="note" title="The scoring state is in memory, and resets when the server restarts">
|
||||
So a restart clears every automatic ban. That is a reasonable escape hatch when you have
|
||||
locked yourself out, and a reason not to treat this list as a permanent record.
|
||||
</Aside>
|
||||
|
||||
## Getting locked out
|
||||
|
||||
Two situations worth knowing before they happen at three in the morning:
|
||||
|
||||
- **Your address is banned.** Restart the app container — the in-memory state goes with it.
|
||||
- **You lost your second factor.** Use one of the recovery codes issued when you enabled
|
||||
it. If those are gone too, another administrator opens **Users → View** on your account
|
||||
and presses **Reset two-factor**, which turns TOTP off, revokes your trusted devices and
|
||||
clears your recovery codes so a password sign-in works again. That is the practical
|
||||
argument for a site never having exactly one admin.
|
||||
|
||||
The same screen lists an account's trusted devices and revokes them individually or all at
|
||||
once — the right response to a lost or stolen laptop, and something to reach for before
|
||||
resetting the whole second factor.
|
||||
@@ -0,0 +1,88 @@
|
||||
---
|
||||
title: Branding and theming
|
||||
description: Colours, fonts and corners from the Appearance screen; logo, hero and favicon from a mounted directory; the portal hero from its own editor.
|
||||
---
|
||||
|
||||
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
|
||||
variables for the values that must exist before the database does.
|
||||
|
||||
## Appearance
|
||||
|
||||
**Admin → Appearance** themes the public site, the admin panel and the player portal
|
||||
together.
|
||||
|
||||
**Presets** — *Runic Gateway*, *Modern*, *Fantasy*, *Custom* — set a whole palette at once.
|
||||
Anything you set below a preset overrides it field by field, and a colour you never set
|
||||
keeps following the preset. That is the useful property: pick the preset closest to what
|
||||
you want, change the two colours that are wrong, and the rest still moves with it.
|
||||
|
||||
| Group | What is in it |
|
||||
|---|---|
|
||||
| **Colors** | Background, deep background, panel top and bottom, accent, bright accent, ink/headings, body text |
|
||||
| **Fonts** | Body serif, display/headings, interface sans — each with a "follow the preset" default |
|
||||
| **Corners & depth** | Radius for pills and buttons, flat panels, cards, inputs; and card shadow |
|
||||
|
||||
Two things the screen tells you that are easy to miss:
|
||||
|
||||
- **Live and maintenance status colours are never themed.** Green has to keep meaning live.
|
||||
- **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 —
|
||||
there is nothing to save.
|
||||
|
||||
| Asset | Where it shows | Limit |
|
||||
|---|---|---|
|
||||
| **Logo** | Site header, admin sidebar, player portal, and link previews when a page is shared | 1 MB |
|
||||
| **Hero image** | Behind the portal hero, unless the hero editor has its own background | 8 MB |
|
||||
| **Favicon** | The browser tab. PNG only; 32×32 or 64×64 works everywhere | 512 KB |
|
||||
|
||||
Underneath, these are files on the `./brand` bind mount from
|
||||
[Install the site](/docs/getting-started/install-the-site/), pointed at by `BRAND_LOGO`,
|
||||
`BRAND_HERO` and `BRAND_FAVICON`. An upload writes there; so does copying a file in by
|
||||
hand. Both are supported, and the mount is why replacing a logo never means rebuilding an
|
||||
image.
|
||||
|
||||
<Aside type="note" title="The “powered by Runic Gateway” mark in the footer is not yours to theme">
|
||||
It is the project's badge rather than your instance's, and it does not change with the
|
||||
theme.
|
||||
</Aside>
|
||||
|
||||
## The text that comes from the environment
|
||||
|
||||
A few identity values are read before the database is available — the server templates them
|
||||
into `index.html` at boot so that link previews and the tab title are right on the very
|
||||
first request:
|
||||
|
||||
`BRAND_NAME`, `BRAND_SHORT_NAME`, `BRAND_TAGLINE`, `BRAND_DESCRIPTION`,
|
||||
`BRAND_ACCENT_COLOR`, `BRAND_URL`, `BRAND_CONTACT_EMAIL`.
|
||||
|
||||
Where an admin-editable setting exists for the same thing — site title, contact email — the
|
||||
**setting wins**. The variable is the value a fresh deployment starts from.
|
||||
|
||||
## The portal hero
|
||||
|
||||
**Admin → Hero Editor** composes the front page's hero directly: drag elements to place
|
||||
them, drag the corner handle to resize (text scales with the box), Delete removes the
|
||||
selected one. The palette adds text, buttons, the moon, a badge or an image.
|
||||
|
||||
Its own background image and overlay darkness are set at the bottom of the editor, and a
|
||||
background set here **wins over** the Appearance screen's hero image.
|
||||
|
||||
Work is not live until you press **Publish**; **Preview** opens it in a new tab, and
|
||||
**Revert to live** throws away an unpublished draft. Until anything is published at all,
|
||||
the portal renders the shipped hero with the homepage teaser from
|
||||
[Settings](/docs/administration/configuration/) underneath it.
|
||||
|
||||
<Aside type="caution" title="Check a hero on a phone before publishing it">
|
||||
The editor is a canvas, and a layout that reads well at desktop width can put text over a
|
||||
face or off the edge on a narrow screen. Preview it there.
|
||||
</Aside>
|
||||
103
src/content/docs/docs/administration/client-files.mdx
Normal file
@@ -0,0 +1,103 @@
|
||||
---
|
||||
title: Client files
|
||||
description: Creature portraits, item pictures and the game's own name table — where they come from, the one button that imports them, and why nothing here happens on a restart.
|
||||
---
|
||||
|
||||
import Screenshot from '../../../../components/Screenshot.astro';
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
Most of what a game shows you is not text. Ultima Online keeps its creature artwork, its
|
||||
item graphics and even its item *names* inside the client files, and a site that cannot read
|
||||
them shows a bestiary of words and a marketplace of numbers.
|
||||
|
||||
With the `uo` module installed, **Client files** appears in the admin sidebar at
|
||||
`/admin/uo/files`. It is where those three things arrive.
|
||||
|
||||
<Screenshot id="admin-client-files" />
|
||||
|
||||
## Where they come from
|
||||
|
||||
A ServUO shard cannot boot without a UO client — it resolves one at startup to read the
|
||||
world's own data. So the files were already on the shard host, and the shard reads and
|
||||
decodes them there, handing the results over the bridge like everything else.
|
||||
|
||||
**Nothing is converted on a desktop and nothing is uploaded.** Earlier versions of this
|
||||
platform asked an operator to install a third-party tool, build a converter against it and
|
||||
copy the output onto the web host. That path is gone.
|
||||
|
||||
## Three things, one page
|
||||
|
||||
| Section | Fills | How it arrives |
|
||||
|---|---|---|
|
||||
| **Creature portraits** | The bestiary and the spawn atlas | One picture per creature body, imported as a **set** |
|
||||
| **Item and land pictures** | Marketplace listings and character sheets | **One at a time**, shortly after a page asks for one |
|
||||
| **Item and title names (clilocs)** | Anywhere an item is named | The whole table at once — tens of thousands of names |
|
||||
|
||||
They are one page because they are one job: they come out of one client install, and they
|
||||
all change at the same moment — when you patch it.
|
||||
|
||||
<Aside type="caution" title="Nothing here happens on a restart">
|
||||
Boot deliberately never asks the shard for client files. A client patch is an event **you**
|
||||
know about and the website does not, and a site that re-read hundreds of megabytes on every
|
||||
restart to discover nothing had changed would pay for the rare case forever.
|
||||
|
||||
So after you patch your client, the site keeps serving the old pictures and the old names
|
||||
until somebody presses a button on this page. That is the whole reason the page exists.
|
||||
</Aside>
|
||||
|
||||
## Update, or re-import everything
|
||||
|
||||
Every section offers the same pair, and the difference is worth knowing:
|
||||
|
||||
- **Update** asks the shard what changed first and transfers only that. When nothing has, it
|
||||
costs one small round trip and answers *"unchanged"*.
|
||||
- **Re-import everything** fetches the lot. It is for the case the first cannot see — you
|
||||
restored a backup, or lost the uploads volume, and the database still remembers pictures
|
||||
that are no longer on disk.
|
||||
|
||||
Item and land pictures work differently, because there are tens of thousands of item
|
||||
graphics times every dye colour and importing them as a set would be absurd. They arrive
|
||||
lazily instead. The two buttons there — *Fetch waiting pictures* and *Refresh the ones I
|
||||
have* — exist for the two moments waiting is the wrong answer: you have just linked a shard,
|
||||
or you have just patched a client.
|
||||
|
||||
## When the page says something is wrong
|
||||
|
||||
Every one of these is a reported state with a reason, not an error. The site keeps serving
|
||||
whatever is already imported in all of them.
|
||||
|
||||
| What you see | What it means |
|
||||
|---|---|
|
||||
| **The shard is busy with another client-file request** | Not a fault. The shard serves one of these at a time, and an import — or the item-picture pass refilling itself — is holding it. It frees itself. |
|
||||
| **The shard is not answering for client files** | The ordinary bridge problem: see [The shard connection](/docs/administration/the-shard-connection/). |
|
||||
| **…set `AssetsEnabled` on the shard** | The asset plane is switched off in [`Bridge.cfg`](/docs/reference/bridge-cfg/). It is a separate switch on purpose — turning it on is consenting to the website reading this host's client files. |
|
||||
| **The shard host cannot render images** | A Linux host with no `libgdiplus`. Names are unaffected, because they have no pixels in them. |
|
||||
| **Waiting for you: *n* pictures … no longer offered** | The shard stopped offering artwork this site holds. A deletion is never silent here; it waits for you to approve or dismiss it. |
|
||||
|
||||
<Aside type="note" title="Linux shard hosts need one package">
|
||||
ServUO runs under Mono on Linux, and the library it decodes sprites with is a thin layer
|
||||
over **`libgdiplus`** — in the *decode* path, not merely the encode. Without it the shard
|
||||
cannot read a single sprite.
|
||||
|
||||
`sudo apt-get install libgdiplus`, or `dnf install libgdiplus`. `runicgateway doctor` checks
|
||||
for it, and Windows shard hosts need nothing. See
|
||||
[Requirements](/docs/getting-started/requirements/).
|
||||
</Aside>
|
||||
|
||||
## What it will not do
|
||||
|
||||
- **It never writes to the game.** Everything on this plane is a read.
|
||||
- **It never overwrites your own artwork.** A portrait you drew and named yourself always
|
||||
wins over an imported one.
|
||||
- **Creatures with no artwork stay as text.** That is normal rather than a failure — a stock
|
||||
client has no animation for most ghost and gargoyle bodies, and the shard reports nothing
|
||||
rather than guessing. A wrong picture is worse than no picture.
|
||||
|
||||
## Canonical documents
|
||||
|
||||
[`link/v8.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v8.md)
|
||||
is the asset plane's design of record;
|
||||
[`link/SHARD_PREREQS.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/SHARD_PREREQS.md)
|
||||
covers what a shard host needs first, and
|
||||
[`website/UPGRADE_NOTES.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/UPGRADE_NOTES.md)
|
||||
is what to do on a site that was running before this existed.
|
||||
100
src/content/docs/docs/administration/configuration.mdx
Normal file
@@ -0,0 +1,100 @@
|
||||
---
|
||||
title: Configuration
|
||||
description: What is set in the environment file, what is set in the admin panel, and why the split is where it is.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
Two places hold configuration, and the line between them is not arbitrary.
|
||||
|
||||
| | Environment (`.env`) | Admin panel |
|
||||
|---|---|---|
|
||||
| **What** | How the process runs: ports, database, secrets, proxy trust, log level | How the site behaves: titles, registration, forums, integrations |
|
||||
| **Changing it** | Edit the file, `docker compose up -d` | Save the form; effective immediately |
|
||||
| **Who** | Whoever has the host | Whoever has an admin account |
|
||||
| **Where it lives** | A file on the host | The database |
|
||||
|
||||
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*
|
||||
environment variables — they are entered in the panel and stored encrypted.
|
||||
|
||||
## Settings
|
||||
|
||||
**Admin → Settings**, the screen most of a new deployment's decisions live on.
|
||||
|
||||
| Field | What it does |
|
||||
|---|---|
|
||||
| **Site title** | Overrides `BRAND_NAME` in the page title, the header and link previews. |
|
||||
| **Homepage teaser** | Rich text under the hero heading, when no custom hero layout is published. |
|
||||
| **Maintenance message** | What visitors see while the site is in maintenance mode. |
|
||||
| **Status message** | A short line for announcements — a maintenance window, an outage. |
|
||||
| **Contact email** | Where the contact form delivers, and the address it falls back to as a `mailto:` link while email is unconfigured. |
|
||||
| **Player registration** | Disabled, password, SSO, or both. **Off by default.** |
|
||||
|
||||
### Player registration is off until you turn it on
|
||||
|
||||
A new site accepts no self-registration at all. The three ways to let people in:
|
||||
|
||||
- **Password** — a normal sign-up form.
|
||||
- **SSO** — sign-up through a linked provider, which needs a provider configured first.
|
||||
- **Invites** — leave registration off entirely and issue invitations from
|
||||
**Admin → Invites**. See [Users and roles](/docs/administration/users-and-roles/).
|
||||
|
||||
## Team forums
|
||||
|
||||
The same screen carries the forum switches, because they are site-wide policy rather than
|
||||
per-Team settings:
|
||||
|
||||
- **Enable team forums** — off by default. Switching them off hides them completely (every
|
||||
forum route answers *not found*) but **deletes nothing**: threads, posts, access grants
|
||||
and notification preferences all survive and come back exactly as they were.
|
||||
- **Images in forum posts** — disabled, remote URLs only, or uploads to your server.
|
||||
Enabling uploads means content stored on infrastructure you are responsible for, and the
|
||||
screen says so at some length before you can agree to it.
|
||||
- **Post edit window** — how long an author may edit their own post. Staff are not bound by
|
||||
it. Zero makes posts permanent once written; some bound is what stops a post being
|
||||
rewritten out from under someone quoting it.
|
||||
|
||||
## Email
|
||||
|
||||
Configured on the same screen and covered in
|
||||
[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*
|
||||
setting is doing real work on a site that has never configured delivery, and an unset one
|
||||
leaves a contact form that goes nowhere.
|
||||
</Aside>
|
||||
|
||||
## The environment file, in three groups
|
||||
|
||||
You wrote these in [Install the site](/docs/getting-started/install-the-site/); this is
|
||||
what they mean when you come back to them.
|
||||
|
||||
**Identity and process** — `NODE_ENV`, `PORT`, `INTERNAL_PORT`, `IMAGE_TAG`. `INTERNAL_PORT`
|
||||
is the server-to-bot channel and must never be published or proxied.
|
||||
|
||||
**Data and secrets** — the `DB_*` group, `JWT_SECRET`, `SECRET_ENC_KEY`, `BOT_INTERNAL_KEY`.
|
||||
The last two are required in production, and `SECRET_ENC_KEY` is the key everything else
|
||||
encrypted at rest is keyed by: change it and the stored secrets become unreadable.
|
||||
|
||||
**Behaviour at the edge** — `TRUST_PROXY`, `COOKIE_SECURE`, `COOKIE_NAME`,
|
||||
`JWT_EXPIRES_IN`. `COOKIE_NAME` is worth one warning: changing it on a live site logs
|
||||
everybody out.
|
||||
|
||||
<Aside type="caution" title="`MODULE_SOURCE_HOSTS` is bootstrap only">
|
||||
It seeds the module install allowlist the first time a site boots without one. After that
|
||||
the **setting** is authoritative and is edited in Admin → Modules — changing the variable on
|
||||
an existing deployment does nothing, deliberately, so a redeploy cannot silently undo an
|
||||
administrator's choice.
|
||||
</Aside>
|
||||
|
||||
## Branding is data, not configuration
|
||||
|
||||
The `BRAND_*` variables and the `/brand` mount are how one prebuilt image runs as any
|
||||
community's site. They get their own page:
|
||||
[Branding and theming](/docs/administration/branding-and-theming/).
|
||||
62
src/content/docs/docs/administration/content.mdx
Normal file
@@ -0,0 +1,62 @@
|
||||
---
|
||||
title: Content
|
||||
description: Posts and their categories, the wiki and its sections, and the activity log that records who changed what.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
Three content surfaces, one for each shape of writing a community does.
|
||||
|
||||
| Surface | For | Lives at |
|
||||
|---|---|---|
|
||||
| **Posts** | Dated writing: news, the newsletter, screenshots | `/site/news` and friends |
|
||||
| **Pages** | Standing pages: About, Rules, Donate — see [Navigation and pages](/docs/administration/navigation-and-pages/) | its own slug |
|
||||
| **Wiki** | Reference the community maintains: guides, lore, systems | `/wiki` |
|
||||
|
||||
## Posts
|
||||
|
||||
**Admin → Posts**, filtered by category. A new deployment seeds four:
|
||||
|
||||
- **News** — the default, and the one wired to announcements.
|
||||
- **Five on Friday** — a recurring short-form format.
|
||||
- **Newsletter** — longer, periodic.
|
||||
- **Screenshots** — image posts.
|
||||
|
||||
Each post is a draft until it is published, and the Posts list shows status and date at a
|
||||
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
|
||||
publication, so an accidental publish is an accidental announcement. See
|
||||
[Notifications and email](/docs/administration/notifications-and-email/).
|
||||
</Aside>
|
||||
|
||||
## The wiki
|
||||
|
||||
**Admin → Wiki** lists every page with its section and status, and **Manage sections**
|
||||
edits the grouping itself. A new site starts with eight pages in four sections — Guides,
|
||||
World & Lore, Systems & Gameplay, Community & Rules — as a skeleton to write into.
|
||||
|
||||
They are placeholders. None of them describes your game, and leaving them published means
|
||||
publishing an empty guide to it; either write them or unpublish them before you go live.
|
||||
|
||||
## Who may write what
|
||||
|
||||
Roles decide it, and the split is the useful part:
|
||||
|
||||
- **Editor** — the content roles. Posts, pages, wiki, and the activity log.
|
||||
- **Moderator** — moderation and Teams, not content authoring.
|
||||
- **Admin** — everything, including the system screens.
|
||||
|
||||
Full table in [Users and roles](/docs/administration/users-and-roles/).
|
||||
|
||||
## The activity log
|
||||
|
||||
**Admin → Activity** records what staff did: the action, a detail line, who did it, from
|
||||
which address, and when. Module installs, logins, content changes and moderation all land
|
||||
here.
|
||||
|
||||
Two things it is good for beyond curiosity: reconstructing what changed just before
|
||||
something broke, and confirming that an account which should not have done something did
|
||||
not. It is a record, not a workflow — nothing is actioned from this screen.
|
||||
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
|
||||
guild'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 shard should send, and check the
|
||||
send log the first time each one fires.
|
||||
191
src/content/docs/docs/administration/events.mdx
Normal file
@@ -0,0 +1,191 @@
|
||||
---
|
||||
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';
|
||||
|
||||
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 (`uo.creatures`, `uo.bosses`, `uo.rewards` and so on), and consumption
|
||||
is counted in the database with a conditional update, not checked in application code.
|
||||
|
||||
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 on the shard host, outside the site's reach, and the site cannot turn them on.
|
||||
|
||||
**`EventsEnabled` is off by default, and it 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 this on is consenting to the site changing and watching your world
|
||||
unattended. One switch could not honestly express both.
|
||||
|
||||
Beside it sit the game's own ceilings — how many creatures one call may spawn, how long a gate
|
||||
may stand, how much one run may own in total, how often the world may be saved. **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. See
|
||||
[Bridge.cfg](/docs/reference/bridge-cfg/) for every key.
|
||||
|
||||
## 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/).
|
||||
@@ -0,0 +1,132 @@
|
||||
---
|
||||
title: Maintenance and upgrades
|
||||
description: Upgrading the image, pinning a build, what to back up and how, where the logs are, and the reverse proxy.
|
||||
---
|
||||
|
||||
import { Aside, Steps } from '@astrojs/starlight/components';
|
||||
|
||||
## Upgrading the site
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
That is the whole routine. The image carries the server and the built client together;
|
||||
schema changes are applied on boot, and installed modules are on a volume the upgrade does
|
||||
not touch.
|
||||
|
||||
**Pin a build when you want a deploy you can reproduce.** `IMAGE_TAG` defaults to `latest`;
|
||||
every merge also publishes `sha-<7>`, so
|
||||
|
||||
```bash
|
||||
IMAGE_TAG=sha-042a151 docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
deploys an exact build, and putting that value in `.env` makes it the one this host runs
|
||||
until you change it. Rolling back is the same command with the previous tag — with one
|
||||
caveat that decides whether it works.
|
||||
|
||||
<Aside type="caution" title="A rollback is only safe if the schema did not move">
|
||||
Upgrades apply schema changes on boot; nothing un-applies them. Rolling the image back to a
|
||||
build that predates a schema change leaves the older code looking at a newer database.
|
||||
Restore the backup you took first, or stay forward.
|
||||
</Aside>
|
||||
|
||||
## Back up before you upgrade
|
||||
|
||||
Two volumes and one directory hold everything that cannot be re-downloaded: the database,
|
||||
the uploads, and `./modules`.
|
||||
|
||||
<Steps>
|
||||
|
||||
1. **Dump the database.** From the deployment directory, while the stack is up:
|
||||
|
||||
```bash
|
||||
docker compose exec -T db sh -c \
|
||||
'mariadb-dump -u root -p"$MARIADB_ROOT_PASSWORD" --single-transaction --routines runic_gateway' \
|
||||
> backup-$(date +%F).sql
|
||||
```
|
||||
|
||||
`--single-transaction` is what makes it consistent without locking the site.
|
||||
|
||||
2. **Copy the uploads volume.**
|
||||
|
||||
```bash
|
||||
docker run --rm -v <deployment>_uploads:/from -v "$PWD":/to alpine \
|
||||
tar czf /to/uploads-$(date +%F).tgz -C /from .
|
||||
```
|
||||
|
||||
The volume is named after the directory Compose runs in — `docker volume ls` shows the
|
||||
exact names.
|
||||
|
||||
3. **Keep `./modules`, `./brand` and your two files.** They are ordinary host directories;
|
||||
whatever backs up the rest of the host covers them.
|
||||
|
||||
</Steps>
|
||||
|
||||
Restoring the database is the same command inverted — `mariadb … < backup.sql` — into a
|
||||
stack whose image is the one the dump came from.
|
||||
|
||||
## Logs
|
||||
|
||||
`./logs/app.log` on the host, because the Compose file bind-mounts it there. `docker compose
|
||||
logs -f app` shows the same stream live.
|
||||
|
||||
`LOG_LEVEL` sets console verbosity and `FILE_LOG_LEVEL` the file's — the file keeps the
|
||||
fuller record on purpose. Nothing rotates them for you.
|
||||
|
||||
## Restarting
|
||||
|
||||
`docker compose restart app` is the ordinary restart, and it is what the admin panel's
|
||||
**Restart the server** button amounts to. Restarts are needed after installing, enabling or
|
||||
uninstalling a module, and are harmless otherwise.
|
||||
|
||||
`docker compose down` stops everything and keeps the data. **`docker compose down -v` also
|
||||
deletes the volumes** — the database and every upload. There is no undo.
|
||||
|
||||
## The reverse proxy
|
||||
|
||||
The app publishes port 3000 and binds all interfaces, so any proxy that can reach the host
|
||||
can serve it. Two settings make it correct rather than merely working, both covered in
|
||||
[Install the site](/docs/getting-started/install-the-site/): `TRUST_PROXY`, so the address
|
||||
your rate limiting and IP bans act on is the visitor's rather than the proxy's, and
|
||||
`COOKIE_SECURE=auto`.
|
||||
|
||||
Three rules for whatever proxy you use:
|
||||
|
||||
- **Forward only 3000.** `INTERNAL_PORT` (3001) is the server-to-bot channel and must never
|
||||
be reachable from outside; the Compose file deliberately does not publish it.
|
||||
- **Deny `/api/v1/internal` at the proxy** as well. Belt and braces: that route no longer
|
||||
rides the public listener, and an explicit deny costs nothing.
|
||||
- **Terminate TLS at the proxy.** The app speaks HTTP; it is not meant to hold a
|
||||
certificate.
|
||||
|
||||
## Upgrading the shard side
|
||||
|
||||
A different deployment on a different host, and it moves on its own schedule:
|
||||
|
||||
```bash
|
||||
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.
|
||||
|
||||
<Aside type="caution" title="After you patch the UO client, press one more button">
|
||||
Creature portraits, item pictures and the name table are read from that client, and the
|
||||
site deliberately never re-reads them on its own — a restart does not, and neither does
|
||||
`update`. They keep serving the old artwork until somebody presses *Update* on
|
||||
**Admin → Client files**. It is one round trip when nothing has changed.
|
||||
</Aside>
|
||||
|
||||
<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.
|
||||
</Aside>
|
||||
107
src/content/docs/docs/administration/managing-modules.mdx
Normal file
@@ -0,0 +1,107 @@
|
||||
---
|
||||
title: Managing modules
|
||||
description: The five states a module can be in, installing and upgrading, disable versus uninstall versus purge, and what to do when one fails to start.
|
||||
---
|
||||
|
||||
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
|
||||
states.
|
||||
|
||||
| State | Means |
|
||||
|---|---|
|
||||
| **Installed** | Files are on the volume; it mounts at the next restart |
|
||||
| **Enabled** | Allowed to run, and about to be loaded. Every boot resets each non-disabled module to this, then records the outcome |
|
||||
| **Started** | Running: routes mounted, schema applied |
|
||||
| **Disabled** | An operator switched it off. Its routes answer *not found* |
|
||||
| **Startup failed** | It tried and could not. The site came up without it |
|
||||
|
||||
**A module that fails to load never takes the site down.** Failure is caught across the
|
||||
whole lifecycle — require, schema, routes, registration, boot hook — and the site starts
|
||||
with that module's routes and navigation absent, and the reason recorded on this screen.
|
||||
|
||||
Two consequences of how boots work:
|
||||
|
||||
- **A failed module is retried on every restart.** Fix the underlying cause and restart; you
|
||||
do not need to touch the panel. A deterministically broken module re-records its failure
|
||||
each boot, which is the honest thing for it to do.
|
||||
- **Disabled is the only state a boot leaves alone.** Disabling is an operator's decision
|
||||
rather than an outcome, so it survives restarts untouched.
|
||||
|
||||
## Upgrading
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
<Aside type="caution" title="Check the Module API version before upgrading">
|
||||
A module declares which core API versions it accepts. If a module release requires a newer
|
||||
core than your image, upgrade the site first — see
|
||||
[Maintenance and upgrades](/docs/administration/maintenance-and-upgrades/).
|
||||
</Aside>
|
||||
|
||||
## Disable, uninstall, purge
|
||||
|
||||
Three different actions, in increasing order of destruction.
|
||||
|
||||
**Disable** flips the row and dispatches that module's shutdown hook, so it actually stops
|
||||
— releases its sockets, closes its streams — rather than merely becoming unreachable. Enable
|
||||
is deliberately not the mirror image: there is no boot hook re-dispatch, so enabling offers
|
||||
a restart.
|
||||
|
||||
**Uninstall** is non-destructive by default: the row goes to `disabled`, the directory is
|
||||
removed, and **the module's tables and data are retained**.
|
||||
|
||||
**Purge** runs the module's own `purge.sql` and destroys its data. It is never implied by
|
||||
an uninstall, and it is offered in two places — as a standalone action on an installed
|
||||
module, and as an opt-in checkbox in the uninstall dialog.
|
||||
|
||||
<Aside type="caution" title="Purge only works while the files are still there">
|
||||
`purge.sql` lives inside the directory an uninstall deletes. Uninstalling without ticking
|
||||
the box keeps the tables, and getting rid of them later means **reinstalling the module
|
||||
first**. Decide at the uninstall, not afterwards.
|
||||
</Aside>
|
||||
|
||||
## Where modules may be installed from
|
||||
|
||||
The allowlist at the bottom of the screen. Installing a module runs its code inside your
|
||||
server, so only listed hosts are permitted, over HTTPS, re-checked on every redirect. An
|
||||
empty list forbids every install.
|
||||
|
||||
`MODULE_SOURCE_HOSTS` seeds this list on a site's first boot and is ignored afterwards —
|
||||
the setting is authoritative, so a redeploy cannot silently undo your choice.
|
||||
|
||||
## The declarative path
|
||||
|
||||
`MODULES` in `.env` declares the set this deployment runs, resolved at every container
|
||||
start, each entry `<id>@<version>=<install manifest URL>`.
|
||||
|
||||
The division of ownership is the thing to remember: **the variable owns what is on the
|
||||
volume; the panel owns whether a module runs.** Uninstall a declared module from the panel
|
||||
and its files come back at the next start — disabled.
|
||||
|
||||
A module already unpacked at the declared version is a no-op that makes **no network call
|
||||
at all**, so a restart with no route to the internet comes up unchanged. A version that
|
||||
cannot be fetched is logged, shown on this screen, and never stops the site starting.
|
||||
|
||||
## Placing one by hand
|
||||
|
||||
Unpacking a module tarball into `./modules/<id>/` and restarting is a supported install —
|
||||
it is why that path is a bind mount rather than a named volume. The row it produces has no
|
||||
provenance columns, because nothing downloaded it.
|
||||
|
||||
<Aside type="note" title="Do not delete the `modules` directory itself">
|
||||
Docker recreates a missing bind-mount source as `root`, and the container user can then no
|
||||
longer write it — which breaks installing from the panel. If that happens,
|
||||
`chown 1000:1000 modules` on the host.
|
||||
</Aside>
|
||||
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 shard.
|
||||
|
||||
**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.house-idoc`, 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/).
|
||||
68
src/content/docs/docs/administration/moderation.mdx
Normal file
@@ -0,0 +1,68 @@
|
||||
---
|
||||
title: Moderation
|
||||
description: Three screens that do three different jobs — Discord moderation, content reports, and appeals against a sanction.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
The **Moderation** group in the sidebar holds three screens that are easy to confuse and do
|
||||
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 |
|
||||
| **Reports** | **Team forum content** members have reported | the site |
|
||||
| **Appeals** | Sanctions people are asking you to reverse | the site |
|
||||
|
||||
## Moderation (Discord)
|
||||
|
||||
Counts across a window you choose — 24 hours, 7 days, 30 days — for bans, kicks, mutes,
|
||||
warnings, joins, leaves, filter hits and spam hits, with a filterable list of recent
|
||||
actions and a tabbed event log (members, filter hits, spam hits).
|
||||
|
||||
Everything here arrives from the Discord bot, so a site with no bot configured shows zeros
|
||||
and empty lists rather than an error. Setting the bot up is
|
||||
[Notifications and email](/docs/administration/notifications-and-email/).
|
||||
|
||||
**Look up** takes you to a per-user view when you are investigating one account rather than
|
||||
browsing the window.
|
||||
|
||||
## Reports
|
||||
|
||||
Reports raised by members about Team forum content. Two design decisions show through in
|
||||
how this screen behaves:
|
||||
|
||||
- **They come to site staff, and a Team's own leaders never see them.** A leader moderates
|
||||
their own forum, so a report *about a leader* has to reach someone above them.
|
||||
- **Handling a report records a decision about the report.** It does not touch the content:
|
||||
hiding or removing a post is done in the forum, or as a sanction against the account.
|
||||
|
||||
The filters are *Open*, *Reviewing*, *Actioned*, *Dismissed* and *All*, and the count of
|
||||
open reports sits at the top so the screen is glanceable.
|
||||
|
||||
<Aside type="note" title="Dismissing is a real outcome, not a failure to act">
|
||||
A report that was not a problem should be dismissed rather than left open — an open queue
|
||||
that never empties stops being read, and the reporter's next report is the one that
|
||||
matters.
|
||||
</Aside>
|
||||
|
||||
## Appeals
|
||||
|
||||
An appeal is a request to reverse a sanction, filtered by *Open*, *Pending*, *Under
|
||||
review*, *Approved*, *Denied*, *Withdrawn* or *All*. Each row carries the target, the
|
||||
action being appealed, the appeal itself, who submitted it, its age and whether a reversal
|
||||
happened.
|
||||
|
||||
Two things worth building a habit around:
|
||||
|
||||
- **Age is the column that matters.** An appeal that nobody has looked at for three weeks
|
||||
is a worse outcome than a denial.
|
||||
- **The decision is recorded either way.** Approving an appeal records the reversal, so the
|
||||
history explains itself later without anyone having to remember.
|
||||
|
||||
## What is recorded, and where
|
||||
|
||||
Every staff action lands in **Admin → Activity** — who did what, from which address, when.
|
||||
That log is the thing to read when reconstructing a disputed decision, and it is a record
|
||||
rather than a workflow: nothing is actioned from it. See
|
||||
[Content](/docs/administration/content/).
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
title: Navigation and pages
|
||||
description: Renaming, reordering and hiding navigation entries in three navs, and composing standalone pages from blocks.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
## Navigation
|
||||
|
||||
**Admin → Navigation** edits three separate navigations — **Public site**, **Admin** and
|
||||
**Player portal** — each with the same tools: rename an entry, reorder it, hide it, group
|
||||
entries into a dropdown section, or add a link of your own.
|
||||
|
||||
A fresh site's public nav is the seeded one: the portal, News, Screenshots, Five on Friday,
|
||||
Newsletter, the wiki, and About. Until you change anything, the nav "renders exactly as
|
||||
coded" — there is no stored copy to drift from the code.
|
||||
|
||||
Two properties are worth understanding before you rely on this screen.
|
||||
|
||||
**It advertises; it does not authorise.** Renaming or hiding an entry changes what is
|
||||
listed, never what exists or who may reach it. Hiding *Wiki* does not close the wiki. Access
|
||||
is decided by roles and by a module's visibility settings, and this screen "can never show
|
||||
anyone a link their role, or the visibility settings of an installed module, would hide".
|
||||
|
||||
**You only edit what you can see.** Entries hidden from *you* — by your role, or by a
|
||||
module's visibility rules — are not listed, and they keep whatever setting they already
|
||||
had. So an administrator's view of this screen is not necessarily the whole nav, and
|
||||
editing it cannot damage the parts you cannot see.
|
||||
|
||||
<Aside type="note" title="A module's pages appear here like anything else">
|
||||
An installed module adds its own entries, and they can be renamed, reordered, grouped and
|
||||
hidden exactly like core's. What you cannot do is *reach past* the module's own visibility
|
||||
settings — those are set with the module, not here.
|
||||
</Aside>
|
||||
|
||||
**Reset to default** discards your customisation for that nav and goes back to the coded
|
||||
one. It is per-nav, not global.
|
||||
|
||||
## Pages
|
||||
|
||||
**Admin → Pages** composes standalone pages from blocks. A published page is live at its
|
||||
slug — `/about`, `/rules`, `/donate` — and a draft is visible only to staff.
|
||||
|
||||
This is the right tool for content that is not news and not a wiki article: the pages a
|
||||
navigation entry points at. A page you create is not linked from anywhere until you add it
|
||||
in **Navigation** — deliberately, because the two are separate decisions.
|
||||
|
||||
For everything else — news posts, the newsletter, screenshots, the wiki — see
|
||||
[Content](/docs/administration/content/).
|
||||
168
src/content/docs/docs/administration/notifications-and-email.mdx
Normal file
@@ -0,0 +1,168 @@
|
||||
---
|
||||
title: Notifications and email
|
||||
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';
|
||||
|
||||
Four separate delivery paths, each optional, each off until you configure it. A site that
|
||||
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, invitations,
|
||||
password resets, team notifications and test messages through **SMTP**. Contact-form mail
|
||||
goes to the *Contact email* setting.
|
||||
|
||||
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.
|
||||
|
||||
### Three ways to point it somewhere
|
||||
|
||||
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
|
||||
|
||||
Publishing a **news** post fans it out to every registered delivery leg. The dispatcher is
|
||||
an in-process poller, tuned by `ANNOUNCE_POLL_MS` (15 seconds by default), and the links in
|
||||
an announcement are built from `APP_BASE_URL` — so set that in production or the links point
|
||||
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
|
||||
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
|
||||
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
|
||||
save. The token is stored **encrypted in the database** and is never an environment
|
||||
variable.
|
||||
|
||||
The bot is a separate container. On the quickstart deployment from
|
||||
[Install the site](/docs/getting-started/install-the-site/) it is not running at all, and
|
||||
the panel says so — *bot unreachable* is the honest state of a site that never started one,
|
||||
not a failure. Add the `bot` service from the project's shipped Compose file when you want
|
||||
it.
|
||||
|
||||
What it does once connected: posts announcements, captures the moderation events on the
|
||||
[Moderation](/docs/administration/moderation/) screen, serves slash commands, and — if you
|
||||
switch them on — the Team notification bridge and per-Team voice channels from
|
||||
[Teams](/docs/administration/teams/).
|
||||
|
||||
## Push notifications
|
||||
|
||||
Opt-in push to the Android app, over a **self-hosted ntfy relay** — the `ntfy` service in
|
||||
the project's Compose file, plus `NTFY_BASE_URL` and friends.
|
||||
|
||||
Two properties matter for what you have to trust:
|
||||
|
||||
- **The relay only ever carries a content-free tickle.** The message says something
|
||||
happened; the app then fetches the actual content from the site over its own
|
||||
authenticated connection. So the relay never sees notification text.
|
||||
- **A device may only register an endpoint on an allowed origin**, derived from
|
||||
`NTFY_BASE_URL`. That is what stops a device pointing your server at somebody else's.
|
||||
|
||||
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: **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:
|
||||
|
||||
- **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
|
||||
a message is sent once and not retried. If Discord is down when a post is published, that
|
||||
announcement is gone — the post is still on the site, which is the thing that matters.
|
||||
</Aside>
|
||||
123
src/content/docs/docs/administration/teams.mdx
Normal file
@@ -0,0 +1,123 @@
|
||||
---
|
||||
title: Teams
|
||||
description: Core owns the Team machinery and cannot create a Team. What that means in practice, and what the admin screen controls.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
Teams are a core platform primitive: membership, roles, forums, notifications, moderation
|
||||
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
|
||||
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.
|
||||
|
||||
<Aside type="note" title="What that means when you are looking at an empty screen">
|
||||
*No Teams in the projection yet* on a site with no module installed is the correct and
|
||||
final state. Install a module, connect its game server, and Teams appear as that module
|
||||
reconciles them.
|
||||
</Aside>
|
||||
|
||||
## The projection, and why it can be stale
|
||||
|
||||
**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.
|
||||
|
||||
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.
|
||||
|
||||
## Forums
|
||||
|
||||
Team forums are switched on site-wide in **Settings**, along with whether images are
|
||||
allowed and how long an author may edit a post — see
|
||||
[Configuration](/docs/administration/configuration/).
|
||||
|
||||
Two rules are structural rather than settings:
|
||||
|
||||
- **A Team's leaders moderate their own forum.** That is the point of a Team forum.
|
||||
- **Reports about that forum do not go to them.** They go to site staff, because a report
|
||||
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**.
|
||||
|
||||
**Notification bridge** — sends Team notifications to a Discord channel: a default for
|
||||
every Team, overridable per Team. A message is sent once and never retried; the bridge is a
|
||||
courtesy, and nothing on the site depends on it arriving. With nothing configured, no Team
|
||||
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.
|
||||
|
||||
Its three settings deserve a thought each:
|
||||
|
||||
| Setting | What it decides |
|
||||
|---|---|
|
||||
| **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. |
|
||||
|
||||
<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
|
||||
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>
|
||||
108
src/content/docs/docs/administration/the-shard-connection.mdx
Normal file
@@ -0,0 +1,108 @@
|
||||
---
|
||||
title: The shard connection
|
||||
description: The module's shard screen — connection settings, what the status line means, game-account creation, the town crier, and what reaches the public.
|
||||
---
|
||||
|
||||
import platform from '../../../../data/platform.json';
|
||||
import Screenshot from '../../../../components/Screenshot.astro';
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
With the `uo` module installed, **Shard (uo-link)** appears in the admin sidebar at
|
||||
`/admin/uo/link`. It is the site's half of the bridge: the connection to the sidecar, and
|
||||
the controls that ride on it.
|
||||
|
||||
Setting it up for the first time is
|
||||
[Connect a game server](/docs/getting-started/connect-a-game-server/).
|
||||
|
||||
<Screenshot id="admin-shard" />
|
||||
|
||||
## Connection
|
||||
|
||||
Four fields, all four printed by the installer, plus the switch that turns the integration
|
||||
on:
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| **Base URL (REST)** | `http://<shard host>:8080` — point-in-time queries |
|
||||
| **WebSocket URL (feed)** | `ws://<shard host>:8080/ws` — the live event feed |
|
||||
| **Auth token** | The sidecar's token |
|
||||
| **Protocol** | {platform.protocol} today |
|
||||
|
||||
Saving restarts the ingest client, so a change takes effect immediately.
|
||||
|
||||
**The token is write-only.** It is encrypted at rest and never returned to any client, so
|
||||
the field is blank when you come back to the screen — losing it means reading it back from
|
||||
`sidecar.toml` on the shard host, not from the website.
|
||||
|
||||
## Reading the status line
|
||||
|
||||
The header carries the connection state, *Shard link*, *WS ingest*, *Reconnects* and *SSE
|
||||
clients*. Together they say **which** link is broken:
|
||||
|
||||
| Reading | Means |
|
||||
|---|---|
|
||||
| Disconnected, shard link down | The site cannot reach the sidecar at all — URL, firewall, or the service is not running |
|
||||
| Connected, but shard link down | The sidecar is up and the *game* is not talking to it |
|
||||
| Reconnects climbing | An unstable path between site and sidecar |
|
||||
| Live feed silent, everything else green | The bridge is fine and the shard is quiet |
|
||||
|
||||
A `409` in the logs is a protocol mismatch — set the Protocol field to what the sidecar's
|
||||
`/health` reports rather than guessing; it rejects rather than mis-parsing. A `401` is the
|
||||
token.
|
||||
|
||||
<Aside type="note" title="The site is designed to look normal while this is broken">
|
||||
Every read through the sidecar returns a result rather than throwing, so the public site
|
||||
renders with the shard shown offline. That is deliberate graceful degradation, and it is
|
||||
also why a broken bridge can go unnoticed — this screen, or `runicgateway doctor` on the
|
||||
shard host, is how you find out.
|
||||
</Aside>
|
||||
|
||||
## Game-account creation
|
||||
|
||||
Whether players can create a **game** account (for the game client) from the website. The
|
||||
game server's own `SignupMode` in `Bridge.cfg` has to agree.
|
||||
|
||||
| Mode | Behaviour |
|
||||
|---|---|
|
||||
| **Disabled** | Players may only link an account that already exists |
|
||||
| **Website** | The site creates game accounts |
|
||||
| **Hybrid** | Site or in-game — the recommended setting |
|
||||
| **Game only** | Created in the game client; the site only links |
|
||||
|
||||
With creation enabled, a *Create a game account* form appears in the player portal and
|
||||
after an invite is accepted.
|
||||
|
||||
## Town crier
|
||||
|
||||
Broadcast a message every in-game town crier announces until it expires: an id, one or more
|
||||
lines, and a duration in seconds. Re-posting the same id **replaces** that message, and
|
||||
**Remove by id** takes it down early.
|
||||
|
||||
The id is the useful part — give a recurring announcement a stable one and you can update or
|
||||
withdraw it without waiting for it to expire.
|
||||
|
||||
## Client files
|
||||
|
||||
The other half of what the bridge carries has its own screen: creature portraits, item
|
||||
pictures and the game's own name table, read from the UO client on the shard host. It is
|
||||
**Client files**, at `/admin/uo/files`, and it is where an operator goes after patching that
|
||||
client — nothing imports on a restart. See [Client files](/docs/administration/client-files/).
|
||||
|
||||
## What reaches the public
|
||||
|
||||
Events from the shard fan out over two separate streams, and the split is a security
|
||||
boundary rather than a preference:
|
||||
|
||||
- **The public stream** carries an allowlist of event kinds.
|
||||
- **The admin stream** adds staff audit events, cheat detection, login attempts and IP
|
||||
addresses.
|
||||
|
||||
The live feed at the bottom of this screen is the admin one — everything, as it arrives.
|
||||
Treat it accordingly: it is the screen you do not put in a screenshot.
|
||||
|
||||
<Aside type="caution" title="Visibility is decided on the website, not on the sidecar">
|
||||
The sidecar is a dumb forwarder. What is public, what is staff-only and what is off is
|
||||
decided on the site, so changing your mind is a settings change rather than a shard
|
||||
redeploy — and it also means an unreviewed default is a decision you have made by not
|
||||
making it.
|
||||
</Aside>
|
||||
192
src/content/docs/docs/administration/troubleshooting.mdx
Normal file
@@ -0,0 +1,192 @@
|
||||
---
|
||||
title: Troubleshooting
|
||||
description: The failures a deployment actually hits, what each one looks like, and the fix.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
Grouped by where the problem is, because the first useful question is always *which half is
|
||||
broken*.
|
||||
|
||||
## The site will not start
|
||||
|
||||
Read the log first — `docker compose logs app` — because the server says exactly why.
|
||||
|
||||
| What the log says | What it means |
|
||||
|---|---|
|
||||
| `SECRET_ENC_KEY must be set in production` | The key that encrypts stored secrets is missing. Set it in `.env` and start again. The container crash-loops until you do. |
|
||||
| A `BOT_INTERNAL_KEY` complaint | Blank, still a placeholder, or shorter than 16 characters. Required in production even when the bot is not running. |
|
||||
| A database connection error, repeatedly | The app came up before the database was ready, or `DB_*` is wrong. The Compose file's health check handles the first case; check the values for the second. |
|
||||
| Nothing at all, container restarting | The image did not pull. `docker compose pull` on its own shows the error. |
|
||||
|
||||
<Aside type="caution" title="Both of those key errors happen on the FIRST boot, not later">
|
||||
They are checked at require time, before the server listens. A deployment that has ever
|
||||
served a request has both of them set.
|
||||
</Aside>
|
||||
|
||||
## Nobody can sign in
|
||||
|
||||
- **Your address is rate-limited or bot-banned.** Both are working as designed. Check
|
||||
**Admin → Web Bot Activity** from another network, or restart the app container — the
|
||||
scoring state is in memory and resets with it.
|
||||
- **The password is right and the form still fails.** Check the log for the actual status:
|
||||
a `429` is the rate limiter, a `403` is usually the honeypot, and a `401` really is the
|
||||
password.
|
||||
- **SSO returns to the login page.** SSO is link-only: an identity that is not already
|
||||
linked to an account cannot sign in, and that is the expected outcome rather than a
|
||||
misconfiguration. Link it from the account screen first.
|
||||
- **Two-factor is lost.** Recovery codes, or another admin's **Reset two-factor** on
|
||||
**Users → View**. See [Authentication](/docs/administration/authentication/).
|
||||
|
||||
## A module will not start
|
||||
|
||||
**Admin → Modules** names the stage and the reason. The usual three:
|
||||
|
||||
| Reason | Fix |
|
||||
|---|---|
|
||||
| `module directory not present on the volume` | The row exists and the files do not — someone deleted the directory by hand. Reinstall, or remove the row with an uninstall. |
|
||||
| A schema failure | The module's schema fragment could not be applied. The log carries the SQL error. |
|
||||
| A version refusal | The module wants a newer core API than this image. Upgrade the site. |
|
||||
|
||||
Whatever the reason, **the site is up and the module's routes are absent** — that is by
|
||||
design, and it is why a broken module is an inconvenience rather than an outage. Fix the
|
||||
cause and restart: failed modules are retried on every boot.
|
||||
|
||||
**The install button rejects a URL.** The host must be in the allowlist on the same screen,
|
||||
and the URL must be HTTPS. An empty allowlist forbids every install.
|
||||
|
||||
**The install succeeds and nothing appears.** It needs a restart. The banner says so, and
|
||||
the row reads *Restart to start* until then.
|
||||
|
||||
## The Restart button did not bring the site back
|
||||
|
||||
The button exits the process and relies on a supervisor to start it again. If your
|
||||
deployment has nothing supervising it — `npm start` in a terminal, a container without
|
||||
`restart:` — the site stays down until you start it yourself. Compose with
|
||||
`restart: unless-stopped` is the supported shape.
|
||||
|
||||
## The game screens are empty or say offline
|
||||
|
||||
Work outwards from the game, and stop at the first check that fails.
|
||||
|
||||
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.
|
||||
|
||||
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).
|
||||
|
||||
<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>
|
||||
|
||||
## The bestiary has no pictures, or items show numbers
|
||||
|
||||
Those come out of the UO client on the shard host, and **nothing imports them on a
|
||||
restart** — a button on **Admin → Client files** is the only thing that does. Check that
|
||||
page first: it reports why rather than failing.
|
||||
|
||||
| What it says | What to do |
|
||||
|---|---|
|
||||
| Counts are zero and no import is recorded | Press *Update*. On a shard that was linked before this existed, nobody ever has. |
|
||||
| *…set `AssetsEnabled` on the shard* | The asset plane is off in `Bridge.cfg`. It is a separate switch on purpose. |
|
||||
| *The shard host cannot render images* | A Linux host with no `libgdiplus`. Install it and press *Update* again. Names are unaffected either way. |
|
||||
| *The shard is busy with another client-file request* | Not a fault. Something ordinary holds the slot; it frees itself. |
|
||||
| Pictures were fine and went blank | Check the uploads volume before anything else — the database still remembers pictures that are no longer on disk, and *Re-import everything* is the button for exactly that. |
|
||||
|
||||
Items reading as numbers rather than names is the same page, different section: it means the
|
||||
cliloc table has not been imported. See [Client files](/docs/administration/client-files/).
|
||||
|
||||
## 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/).
|
||||
|
||||
## Email and announcements never arrive
|
||||
|
||||
- **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
|
||||
the directory was deleted and recreated by Docker as `root`. `chown 1000:1000 modules` (or
|
||||
`logs`, or `brand`) on the host fixes it. Do not delete those directories.
|
||||
|
||||
## When you need to ask for help
|
||||
|
||||
Bring three things: the relevant lines from `docker compose logs app`, the output of
|
||||
`runicgateway doctor` if a game server is involved, and what you changed last. The
|
||||
[community page](/community/) has where to ask.
|
||||
70
src/content/docs/docs/administration/users-and-roles.mdx
Normal file
@@ -0,0 +1,70 @@
|
||||
---
|
||||
title: Users and roles
|
||||
description: The four roles and what each one reaches, creating accounts, and inviting people to a site that is not open for registration.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
import Screenshot from '../../../../components/Screenshot.astro';
|
||||
|
||||
## The four roles
|
||||
|
||||
| Role | Reaches |
|
||||
|---|---|
|
||||
| **Player** | The player portal: their own profile, their own characters and game account links, their Teams, forum access, notification preferences |
|
||||
| **Moderator** | Everything a player has, plus Moderation, Appeals, Reports and the Teams admin screen |
|
||||
| **Editor** | Everything a player has, plus Posts, Pages, Wiki and the Activity log |
|
||||
| **Admin** | All of it, including Users, Invites, Settings, Modules, Appearance, Navigation, Authentication and the module's own admin screens |
|
||||
|
||||
<Aside type="note" title="Staff are players too">
|
||||
Every self-service screen in the player portal is role-agnostic: it serves whoever is signed
|
||||
in. An administrator has characters and Teams like anyone else, and reaches them through the
|
||||
same portal. Nothing about being staff removes the player half of an account.
|
||||
</Aside>
|
||||
|
||||
Admin routes are re-validated against the database on **every request**, not just at sign-in.
|
||||
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
|
||||
active immediately. That is the right path for staff, and for the handful of accounts you
|
||||
create yourself.
|
||||
|
||||
The list shows each account's role, status and last login, with **View** and **Edit** on
|
||||
every row.
|
||||
|
||||
## Invites
|
||||
|
||||
**Admin → Invites** is the way to let a specific person in when self-registration is off —
|
||||
which is how every deployment starts.
|
||||
|
||||
Enter an email, pick the access level (player, moderator, editor or admin), and either
|
||||
**create and email** the invitation or generate a link to share yourself. The table tracks
|
||||
status, expiry and creation date, so an unaccepted invite is visible rather than forgotten.
|
||||
|
||||
This is worth preferring over creating accounts by hand for real people: the recipient sets
|
||||
their own password, and you never handle it.
|
||||
|
||||
## Opening registration
|
||||
|
||||
When you do want a public sign-up, that is **Settings → Player registration**: password,
|
||||
SSO, or both. See [Configuration](/docs/administration/configuration/).
|
||||
|
||||
Before opening it, know what is protecting the door: rate limiting, login backoff, a
|
||||
honeypot, bot scoring and automatic IP bans — all covered in
|
||||
[Authentication](/docs/administration/authentication/), along with two-factor and the SSO
|
||||
policy that an external identity can only ever sign in to an account it is already linked
|
||||
to.
|
||||
|
||||
## Status, and why deleting is the last resort
|
||||
|
||||
Editing an account sets its **status** as well as its role: *active*, *disabled*, *banned*
|
||||
or *pending*. Disabled and banned both stop the account being used; the difference is what
|
||||
you are recording — an account switched off versus an account sanctioned.
|
||||
|
||||
Prefer either to the **Delete** button. Content, moderation history and Team membership all
|
||||
reference the account, and a disabled one keeps those records readable while a deleted one
|
||||
leaves the history to explain itself.
|
||||
@@ -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).
|
||||
|
||||
`module-uo`'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.
|
||||
163
src/content/docs/docs/architecture/events-architecture.mdx
Normal file
@@ -0,0 +1,163 @@
|
||||
---
|
||||
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 the site cannot reach: `EventsEnabled` and
|
||||
`AdminWriteEnabled` live in a file on the shard host and are off out of the box, and 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 a guild-scoped event 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.
|
||||
119
src/content/docs/docs/architecture/protocol-versions.mdx
Normal file
@@ -0,0 +1,119 @@
|
||||
---
|
||||
title: Protocol versions
|
||||
description: One number, declared in three repositories, that decides whether a shard and a sidecar are allowed to talk to each other.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
import platform from '../../../../data/platform.json';
|
||||
|
||||
The loopback wire protocol between the game 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.
|
||||
|
||||
The current protocol is **{platform.protocol}**.
|
||||
|
||||
## Three declaration sites
|
||||
|
||||
The same number is written down in three places, and they must move together.
|
||||
|
||||
| Where | What declares it |
|
||||
|---|---|
|
||||
| `link/sidecar/src/main.rs` | `PROTOCOL_VERSION`, currently {platform.protocol} — what the sidecar speaks |
|
||||
| `servuo-plugins/overlay.toml` | `protocol`, currently {platform.protocol} — what the plugin overlay speaks |
|
||||
| The bundle manifest | Copied from `overlay.toml` by CI, so a released pair carries its own claim |
|
||||
|
||||
<Aside type="caution" title="Bump the overlay in the same PR as the emitters">
|
||||
CI folds `overlay.toml` into the release manifest, and **the installer refuses to pair an
|
||||
overlay 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 `X-UOLink-Version`. A mismatch is
|
||||
rejected with **`409`** rather than mis-parsed. The website's protocol expectation is
|
||||
admin-managed, alongside the base URL and token, on the shard configuration screen.
|
||||
|
||||
**Overlay ↔ sidecar.** The installer resolves a **bundle** — an exact, protocol-checked
|
||||
sidecar and overlay pair 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 most recent
|
||||
bump — **8**, which taught the bridge to carry a game's own client files — 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 |
|
||||
|
||||
**`website` is 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. The
|
||||
one before this did, because what changed then was not a game *noun* but the shape of a
|
||||
thing core owns the ledger for. This one did not reach core because everything it needed —
|
||||
somewhere to put a picture — core already offered every module. Its entire share of eight
|
||||
phases of work was a **deletion**: a developer tool it no longer needed.
|
||||
|
||||
**A protocol bump can also require a store migration**, because the sidecar persists what it
|
||||
forwards. That is not automatic, and it has happened once: version 4 added a column to a
|
||||
table that already existed. Versions 5, 6, 7 and 8 needed none, because every frame is
|
||||
persisted whole — 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.
|
||||
|
||||
Version 8 puts it more sharply still. It is the largest bump this 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. A
|
||||
forwarder that holds no opinion about what it forwards has nothing to migrate.
|
||||
|
||||
## 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 | `link`, `servuo-plugins`, the bundle | `X-UOLink-Version`, 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
|
||||
|
||||
[`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/v7.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v7.md)
|
||||
the one before it;
|
||||
[`link/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md)
|
||||
§7 is the wire protocol, and
|
||||
[`link/INTEGRATION.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/INTEGRATION.md)
|
||||
the integration guide.
|
||||
142
src/content/docs/docs/architecture/system-architecture.mdx
Normal file
@@ -0,0 +1,142 @@
|
||||
---
|
||||
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';
|
||||
|
||||
The drawn version of this, for evaluators, is on
|
||||
[Architecture](/architecture/). This page is the detailed account.
|
||||
|
||||
## Ten 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.
|
||||
|
||||
| Repository | What it is |
|
||||
|---|---|
|
||||
| `website` | The Node/Express + MariaDB + React site. The only internet-facing web app |
|
||||
| `Module-uo` | All the *Ultima Online* code, installed into the site as a module |
|
||||
| `link` | The **uo-link sidecar**, in Rust — the only network-facing bridge component |
|
||||
| `servuo-plugins` | The in-game plugin, C#, that feeds the sidecar |
|
||||
| `installer` | Deploys the shard side: sidecar plus plugin overlay |
|
||||
| `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 spec |
|
||||
| `runicgateway.com` | This site |
|
||||
| `.profile` | The organisation landing page |
|
||||
|
||||
## 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 `module-uo` as *the worked example* rather than
|
||||
as part of the platform. It is the module every other module is measured against, not a
|
||||
component core depends on.
|
||||
|
||||
## The invariants
|
||||
|
||||
These hold across repository boundaries, and every one of them is load-bearing.
|
||||
|
||||
### The game is never network-reachable
|
||||
|
||||
The ServUO shard **dials out** over loopback TCP `127.0.0.1:7788`, newline-delimited JSON,
|
||||
to the sidecar. 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
|
||||
|
||||
On the C# side, `Emit()` enqueues onto a **bounded, drop-oldest** queue and returns
|
||||
immediately. It never touches the socket from the game's core thread. Every world read
|
||||
happens on the core 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 shard 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 shard surface 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 shard 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.
|
||||
153
src/content/docs/docs/architecture/teams-architecture.mdx
Normal file
@@ -0,0 +1,153 @@
|
||||
---
|
||||
title: Teams architecture
|
||||
description: Teams is a contract, not a surface — how core owns guilds, clans and corporations without ever learning what one is called.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
Most games have groups: guilds, clans, corporations, tribes, crews. Runic Gateway supports
|
||||
them as a **core platform primitive**, while core itself never learns what yours is called.
|
||||
|
||||
The administrator's view is [Teams](/docs/administration/teams/).
|
||||
|
||||
## The sentence the design turns on
|
||||
|
||||
**Teams is a contract, not a surface.**
|
||||
|
||||
Core owns the tables, the sync, the access rules and the activity feed. It does **not** own
|
||||
the word for a Team, and therefore does not own the Team *page*. The module that owns the
|
||||
vocabulary owns the page.
|
||||
|
||||
That was not the first design. Core originally rendered Team pages with slots a module
|
||||
filled. It was inverted, and the inversion is the interesting part: instead of core naming
|
||||
places for a module's content, **a module declares a place on its own page for core to
|
||||
fill** — `registry.declareModuleSlot(id, name, { core })`, with core offering contributions
|
||||
rather than naming slots.
|
||||
|
||||
<Aside type="caution" title="Why the direction matters">
|
||||
The first version had core's fills naming three of `module-uo`'s slots **literally**. It
|
||||
worked for exactly one module and silently did nothing for any other game — an empty page
|
||||
with nothing logged.
|
||||
|
||||
It was found by writing the Integration Kit for an audience outside this project, which is
|
||||
precisely what that book is for.
|
||||
</Aside>
|
||||
|
||||
## Six invariants
|
||||
|
||||
Each has a test named against it.
|
||||
|
||||
1. **Module unavailability is staleness, never emptiness.** No Team subsystem may apply a
|
||||
destructive result derived from a failed, timed-out or unanswered module call.
|
||||
2. **Four authority paths stay four.** Game membership, leadership, forum access and
|
||||
external-platform access are separate tables answering separate questions, resolved by
|
||||
separate predicates. **No predicate reads another's table.**
|
||||
3. **Non-contamination.** A manual forum grant never writes the membership projection, in
|
||||
either direction, ever. Both facts coexist; neither migrates into the other.
|
||||
4. **A Team's name is immutable for the life of its record.** A rename is an archive plus a
|
||||
create.
|
||||
5. **Core never interprets module vocabulary.** Activity kinds, Team metadata and capability
|
||||
strings are opaque. Core stores, gates and displays; it never branches on content it does
|
||||
not own.
|
||||
6. **The game never touches the website.** Everything crosses the sidecar.
|
||||
|
||||
Invariant 1 deserves emphasis, because it is the one a naive implementation gets wrong: if
|
||||
the module fails to answer "who is in this Team?", the answer is **not** "nobody". Treating
|
||||
a timeout as an empty roster would silently disband every Team on the site.
|
||||
|
||||
## The rename rule
|
||||
|
||||
Core's key is **(`module_id`, `external_id`, `name`) taken together** — not `external_id`
|
||||
alone.
|
||||
|
||||
| Situation | What core does |
|
||||
|---|---|
|
||||
| New `external_id` | Create a Team |
|
||||
| Known id, same name | Update in place |
|
||||
| Known id, **different name** | **Archive** the row and create a new one |
|
||||
| Id absent from an authoritative full list | Archive as disbanded, subject to invariant 1 |
|
||||
|
||||
The archived Team keeps its forum, activity history, grants and integration record; all
|
||||
become read-only. It stays reachable at its old slug, `noindex`, with a banner linking to
|
||||
the successor — so a Discord message from before the rename lands somewhere that explains
|
||||
itself instead of 404-ing.
|
||||
|
||||
This puts the whole of *"is this a rename or a different group?"* **inside the module**. If
|
||||
your game has no persistent group id, synthesise `external_id` from whatever is stable, or
|
||||
fold the name into it so every rename is a fresh id. Core only ever sees "an id appeared /
|
||||
an id's name changed / an id is gone".
|
||||
|
||||
## The module-facing interface
|
||||
|
||||
A module registers a provider:
|
||||
|
||||
```js
|
||||
api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders })
|
||||
```
|
||||
|
||||
and pushes through `ctx.teams`:
|
||||
|
||||
| Call | What it does |
|
||||
|---|---|
|
||||
| `ctx.teams.publish(event)` | An optimisation — makes a membership change visible at once |
|
||||
| `ctx.teams.reconcile({ reason })` | A debounced *request*; returns immediately |
|
||||
| `ctx.teams.activity.push(items)` | Writes the per-Team feed |
|
||||
|
||||
**`ctx.teams` is push-only, and that is the contract.** There is no reader. A module
|
||||
*answers* questions about Teams; it does not ask them. A `getTeamRoster` would be core
|
||||
offering to read back the module's own answer — which the module already holds.
|
||||
|
||||
All three are fire-and-forget and never reject, because they are called from inside
|
||||
game-event handlers and a storage problem of core's must not become the module's control
|
||||
flow. Correctness comes from reconciliation either way.
|
||||
|
||||
### The six event kinds
|
||||
|
||||
`team.created` · `team.disbanded` · `team.member.added` · `team.member.removed` ·
|
||||
`team.leader.added` · `team.leader.removed`
|
||||
|
||||
Six rather than four because **leadership is its own authority path**: a leadership change
|
||||
has to be expressible without pretending someone joined or left.
|
||||
|
||||
**`team.created` and `team.disbanded` only ask for a reconciliation.** Core will not invent
|
||||
a Team from a delta — it would have no name, no roster and no leaders — and will not archive
|
||||
one from a delta either, because an archive driven by a message that may simply have been
|
||||
repeated is destruction on no evidence.
|
||||
|
||||
### The activity feed
|
||||
|
||||
Each item carries an already-**rendered** `summary`, which core stores verbatim. Core cannot
|
||||
phrase "gained 15,000 gold" for a game whose vocabulary it does not know, and a core that
|
||||
templated it would have re-acquired exactly the semantics the module system exists to
|
||||
remove.
|
||||
|
||||
`visibility` defaults to `'members'` — **fail closed**. The module chooses it per item; core
|
||||
enforces it on read.
|
||||
|
||||
A `dedupeKey` collision is a **successful no-op**, which is what makes a sidecar reconnect
|
||||
backfill safe to replay.
|
||||
|
||||
## Untrusted game data becomes a public page
|
||||
|
||||
This is the sharpest edge in the whole subsystem: a group name chosen by a player becomes a
|
||||
page on a public website.
|
||||
|
||||
So game-sourced names go through **reserved-name screening**, and game-sourced overrides
|
||||
through an **approval gate**. Neither is optional, and neither is something a module can
|
||||
waive.
|
||||
|
||||
## What is deliberately out of scope
|
||||
|
||||
Multi-module namespacing, Team hierarchies and alliances, cross-Team messaging, and
|
||||
platform-only Teams with no game backing.
|
||||
|
||||
**Matrix is research, not a roadmap item.** Of the five capabilities a shared interface
|
||||
would name, a Matrix implementation could honestly provide two — it has no
|
||||
channel-with-overwrites, no role object, no voice channel, and no slash-command
|
||||
registration. The settled outcome was a *capability contract*, not an integration.
|
||||
|
||||
## Canonical document
|
||||
|
||||
[`TEAMS.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/TEAMS.md)
|
||||
is normative — Part 1 for the invariants, Part 2 for the core, Parts 3–4 for pages and the
|
||||
activity feed.
|
||||
148
src/content/docs/docs/architecture/the-bridge.mdx
Normal file
@@ -0,0 +1,148 @@
|
||||
---
|
||||
title: The bridge
|
||||
description: How a game server reaches the website without ever being reachable itself — the sidecar, the loopback socket, and the rules that keep the game running.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
The bridge exists to answer one question safely: **how does a private game server's live
|
||||
state reach a public website?**
|
||||
|
||||
The answer is a **sidecar** — a small service that owns the connection to the game and the
|
||||
durable copy of what the game said. It is not optional, and the reasons are worth
|
||||
understanding before you build one for another game.
|
||||
|
||||
## The shape
|
||||
|
||||
```
|
||||
ServUO shard ──dials out──▶ uo-link sidecar ──HTTP + WS──▶ website
|
||||
(C# plugin) 127.0.0.1:7788 (Rust) bearer + version (module)
|
||||
newline JSON
|
||||
▲ │
|
||||
└──────── the game opens NO port ──────┘
|
||||
```
|
||||
|
||||
Three properties fall out of that diagram, and each is a rule rather than an
|
||||
implementation detail.
|
||||
|
||||
## 1. The game dials out
|
||||
|
||||
**The sidecar is the listener. The game connects to it.** The shard opens no port at all,
|
||||
and nothing on the internet can reach it even in principle.
|
||||
|
||||
This inverts the intuitive design — you would expect the thing with the data to serve it —
|
||||
and the inversion is the whole security argument. Only the sidecar is exposed, and only the
|
||||
website's backend talks to the sidecar.
|
||||
|
||||
The transport is deliberately boring: **newline-delimited JSON, one object per line**, over
|
||||
loopback TCP.
|
||||
|
||||
## 2. A wedged sidecar must never stall the game
|
||||
|
||||
This is the constraint the plugin is built around.
|
||||
|
||||
On the C# side, `Emit()` **enqueues onto a bounded, drop-oldest queue and returns
|
||||
immediately**. It never touches the socket from the game's core thread. Every world read
|
||||
happens on the core thread; a dedicated writer thread drains the queue.
|
||||
|
||||
<Aside type="caution" title="Dropping events beats pausing the game">
|
||||
If the queue fills, the oldest events are discarded. That is the correct trade: a game
|
||||
server that stutters because a logging sidecar is slow is a broken game server, and no
|
||||
website feature is worth a lag spike.
|
||||
|
||||
Design your own plugin the same way. The game thread must never block on I/O — not on a
|
||||
socket, not on a lock held by a writer, not on a DNS lookup.
|
||||
</Aside>
|
||||
|
||||
Inbound commands get the mirror rule: **every inbound handler marshals to the core thread
|
||||
before touching world state.**
|
||||
|
||||
## 3. The sidecar persists before it forwards
|
||||
|
||||
The sidecar owns a durable store. It is not a proxy that translates and forgets — if the
|
||||
website is down, the game's events are still recorded, and a reconnecting website catches
|
||||
up.
|
||||
|
||||
This is what "a *thin* sidecar" means in the Integration Kit: thin in *logic*, not thin in
|
||||
responsibility. The sidecar is a **dumb forwarder** — it makes no access-control decisions
|
||||
and holds no policy. Access control and the admin-toggleable visibility scope live on the
|
||||
**website**, where an administrator can see and change them.
|
||||
|
||||
## Three ways in
|
||||
|
||||
**Live events** arrive over an outbound **WebSocket** and are routed by the module's ingest
|
||||
dispatcher. Kinds are handled differently by nature: state-changing kinds update tables,
|
||||
notable kinds append to an events log, and high-frequency kinds only update state rather
|
||||
than accumulating history.
|
||||
|
||||
**Point-in-time reads and commands** go over **REST**, through a client that never throws.
|
||||
|
||||
**Bulk reads** — a game's own client artwork, its string table, its spawn files — are the
|
||||
newest and the least obvious. They go over the request/reply path in **pages**, with **one
|
||||
request in flight at a time** and a hard cap on how large a single line may be.
|
||||
|
||||
<Aside type="note" title="Why bulk data must not ride the event stream">
|
||||
It is the tempting shortcut, and it is wrong for a structural reason rather than a
|
||||
performance one: the sidecar **persists every event and broadcasts it to every connected
|
||||
client**. That is exactly what you want for "a house went IDOC" and exactly what you do not
|
||||
want for hundreds of megabytes of artwork, which is an *answer to a question somebody
|
||||
asked* rather than news.
|
||||
|
||||
Sending it as replies instead is what let the same bump move megabytes without the sidecar's
|
||||
store changing by a line. The single slot is the other half: it is what keeps the queue
|
||||
between the game and the writer thread shallow, so rule 2 above still holds while a
|
||||
transfer is running.
|
||||
</Aside>
|
||||
|
||||
Every call carries `Authorization: Bearer <token>` and an `X-UOLink-Version` header. **A
|
||||
protocol mismatch fails fast with `409`** rather than being mis-parsed — see [Protocol
|
||||
versions](/docs/architecture/protocol-versions/).
|
||||
|
||||
## What the shard can say
|
||||
|
||||
The catalog spans sessions and identity, character state, economy and commerce, housing and
|
||||
IDOC, combat and PvP, progression, cheat detection and staff audit, and server lifecycle.
|
||||
A representative line looks like:
|
||||
|
||||
```json
|
||||
{"t":1752,"kind":"vendor.sale",
|
||||
"buyer":{"serial":"0x1A2B","acct":"PerryAdimn"},
|
||||
"owner":{"serial":"0x33C1","acct":"Feng"},
|
||||
"item":{"serial":"0x4001A2","type":"Longsword","amount":1},
|
||||
"price":75000,"commission":3750}
|
||||
```
|
||||
|
||||
The full catalog is the [Shard event catalog](/docs/reference/event-catalog/).
|
||||
|
||||
## Two design details worth stealing
|
||||
|
||||
**`server.hello` is per-connection, not per-boot.** The sidecar restarts independently of
|
||||
the game, so anything it needs up front must be re-sent on **every** connect. An earlier
|
||||
draft emitted a "started" event once at boot; a sidecar that came up second never received
|
||||
it and had no idea which shard it was attached to.
|
||||
|
||||
It carries a `bootId` — a GUID generated at server start, stable across sidecar reconnects
|
||||
and changed on every game restart. That is how the sidecar tells *"I reconnected"* (keep
|
||||
cached state) from *"the game restarted"* (discard it).
|
||||
|
||||
**Rosters are sets, not signatures.** Guild membership is compared as a set rather than
|
||||
folded into a checksum, because a sum can collide: one member joining and another leaving
|
||||
between two sweeps offset each other, and the guild reads as unchanged. A set can also be
|
||||
*differenced*, which is what makes per-member leave events possible for a game that raises
|
||||
no event for leaving.
|
||||
|
||||
On a guild's **first** sweep there is no prior set, so nothing is reported as leaving — an
|
||||
unknown roster becoming known is not 155 people leaving at once.
|
||||
|
||||
## Building one for another game
|
||||
|
||||
The bridge is not UO-specific in shape, only in vocabulary. Chapters 3 and 4 of [the
|
||||
Integration Kit](/docs/modules/the-integration-kit/) cover the sidecar and the game-side
|
||||
plugin, and they are the two parts where the mistakes are most expensive.
|
||||
|
||||
## Canonical documents
|
||||
|
||||
[`link/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md)
|
||||
§5 and §7 are the data catalog and the wire protocol;
|
||||
[`link/INTEGRATION.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/INTEGRATION.md)
|
||||
is the integration guide. Both are normative; this page is not.
|
||||
140
src/content/docs/docs/getting-started/connect-a-game-server.mdx
Normal file
@@ -0,0 +1,140 @@
|
||||
---
|
||||
title: Connect a game server
|
||||
description: The installer binary on the shard host — what it deploys, what it asks, and the four values it prints for the website.
|
||||
---
|
||||
|
||||
import platform from '../../../../data/platform.json';
|
||||
import { Aside, Steps } from '@astrojs/starlight/components';
|
||||
|
||||
This is the second of the two installs, and it happens on the machine that runs your game
|
||||
server. One binary deploys the plugin, installs the sidecar, registers its service, and
|
||||
prints four values for you to paste into the website.
|
||||
|
||||
It never contacts your website, and it never starts or stops your shard.
|
||||
|
||||
## What gets deployed
|
||||
|
||||
| # | Component | Where it goes |
|
||||
|---|---|---|
|
||||
| 1 | **The plugin overlay** — C# source ServUO compiles at boot | into your ServUO tree |
|
||||
| 2 | **The uo-link sidecar** — a small Rust service | a system directory, plus a service |
|
||||
| 3 | **A record of the run** | `install.json`, with per-file hashes and backups |
|
||||
|
||||
```
|
||||
ServUO shard ──loopback TCP 127.0.0.1:7788──► uo-link sidecar ──HTTP + WebSocket──► website
|
||||
```
|
||||
|
||||
The shard **dials out**. It never listens for the website and is never reachable from the
|
||||
internet; only the sidecar is exposed, and only to your site.
|
||||
|
||||
## Install
|
||||
|
||||
<Steps>
|
||||
|
||||
1. **Download the binary for your OS, and `SHA256SUMS`**, from the
|
||||
[installer releases page](https://gitea.whitlocktech.com/RunicGateway/installer/releases)
|
||||
({platform.releases.installer}).
|
||||
|
||||
Releases are **unsigned** — there is no code-signing certificate, so that checksum file
|
||||
is the whole trust anchor. Check it:
|
||||
|
||||
```bash
|
||||
sha256sum -c SHA256SUMS --ignore-missing
|
||||
chmod +x runicgateway-installer-linux-x86_64
|
||||
```
|
||||
|
||||
On Windows, `(Get-FileHash .\runicgateway-installer-windows-x86_64.exe -Algorithm SHA256).Hash`
|
||||
and compare. Windows will also show a SmartScreen prompt on first run, for the same
|
||||
reason.
|
||||
|
||||
2. **Stop the shard.** `ServUO.exe` locks `Scripts.dll` and rewrites `Saves/` on exit, so
|
||||
the installer refuses to deploy under a running server.
|
||||
|
||||
3. **Run it, elevated.**
|
||||
|
||||
```bash
|
||||
sudo ./runicgateway-installer-linux-x86_64 install
|
||||
```
|
||||
|
||||
Add `--verify` first if you want to see every change it would make and write nothing.
|
||||
|
||||
It asks four things: your ServUO root, whether to apply the optional patch tier, the
|
||||
hostname your website should use to reach this machine, and your site's URL (used only
|
||||
to print a link at the end).
|
||||
|
||||
4. **Read the summary.** It reports the overlay sync file by file, the sidecar binary and
|
||||
its verified hash, the config and database paths, and the service state. Then it says
|
||||
what you must do next — restart ServUO yourself, because it will not do that for you.
|
||||
|
||||
</Steps>
|
||||
|
||||
<Aside type="note" title="It installs a bundle, not “latest of each”">
|
||||
The three components version independently but must agree on one wire protocol, so what it
|
||||
resolves is a **bundle**: an exact, protocol-checked pair of sidecar and overlay versions
|
||||
({platform.bundle.tag} today — sidecar {platform.bundle.sidecar}, overlay {platform.bundle.overlay}).
|
||||
`--bundle <tag>` pins an exact past combination, so a reinstall in six months reproduces
|
||||
today's install rather than tomorrow's.
|
||||
</Aside>
|
||||
|
||||
## The patch tier is optional
|
||||
|
||||
Most of the plugin is *added* files, which is why the base install is a safe copy. Two
|
||||
features need edits to stock ServUO sources, and those are opt-in, off unless you say yes,
|
||||
and refused where the target lines are not stock. Skipping the tier costs you vendor-sale
|
||||
events and in-game moderation audit forwarding; everything else works.
|
||||
|
||||
The tier is written and tested against stock ServUO {platform.bundle.servuoMin}. On any
|
||||
other version it is unsupported and untested, and the prompt makes you answer past a
|
||||
warning.
|
||||
|
||||
## Paste the four values into the site
|
||||
|
||||
A successful run ends by printing the one step it cannot do for you:
|
||||
|
||||
```
|
||||
Base URL http://shard.example.com:8080
|
||||
WebSocket URL ws://shard.example.com:8080/ws
|
||||
Protocol version 4
|
||||
Auth token 4f9c… (also in sidecar.toml)
|
||||
```
|
||||
|
||||
Every value comes from asking the installed sidecar itself, so it cannot drift from what
|
||||
the service actually runs.
|
||||
|
||||
On the site, sign in as an administrator and open **Shard (uo-link)** in the admin
|
||||
sidebar — `/admin/uo/link`. Tick *Enable the shard integration*, paste **Base URL**,
|
||||
**WebSocket URL**, **Auth token** and **Protocol**, and save. The ingest client restarts
|
||||
immediately.
|
||||
|
||||
<Aside type="caution" title="Installer v0.1.0 prints an older path for that screen">
|
||||
v0.1.0 prints `…/admin/shard`. Since the shard screens became part of the `uo` module — and
|
||||
a module owns one path segment wherever it appears — the screen moved to
|
||||
**`/admin/uo/link`**.
|
||||
|
||||
The old path does not fail visibly: the site has no route for it, so it sends you to the
|
||||
dashboard, and that looks like the link worked. The four values you were just told to paste
|
||||
then have nowhere to go. Use the sidebar, or the path above.
|
||||
|
||||
Fixed in **v0.1.1**, which prints the real path. Only matters if you are running the older
|
||||
binary.
|
||||
</Aside>
|
||||
|
||||
The token is encrypted at rest and **never returned to any client** — losing it means
|
||||
reading it back from `sidecar.toml` on the shard host, not from the website.
|
||||
|
||||
## If the website is on a different machine
|
||||
|
||||
The sidecar binds `127.0.0.1:8080`, reachable only from the shard host. If the site runs
|
||||
elsewhere, widen the bind and then narrow the access:
|
||||
|
||||
1. Set `[web] bind` in `sidecar.toml` to `0.0.0.0:8080` and restart the service.
|
||||
2. **Firewall that port to your website's address only.** The auth token is always on, but
|
||||
it travels as a plain bearer token — the sidecar speaks HTTP, not HTTPS.
|
||||
3. If the two hosts are not on a trusted network, put the sidecar behind a TLS reverse
|
||||
proxy or a VPN link and give the website the `https://` / `wss://` URLs.
|
||||
|
||||
Leave `[shard] bind` on `127.0.0.1:7788`. That socket accepts *inbound commands to the
|
||||
game*, and being loopback-only is what makes that safe.
|
||||
|
||||
Next: [Verify the whole stack](/docs/getting-started/verify-the-whole-stack/) — because a
|
||||
successful file copy is not a working bridge.
|
||||
81
src/content/docs/docs/getting-started/first-run.mdx
Normal file
@@ -0,0 +1,81 @@
|
||||
---
|
||||
title: First run
|
||||
description: Signing in as the first admin, what the site does before anyone visits, and the switch from maintenance to live.
|
||||
---
|
||||
|
||||
import { Aside, Steps } from '@astrojs/starlight/components';
|
||||
import Screenshot from '../../../../components/Screenshot.astro';
|
||||
|
||||
The site is up and nobody can see it yet. That is the intended state: a new deployment
|
||||
**starts in maintenance mode**, showing visitors a "coming soon" page while the admin panel
|
||||
stays reachable.
|
||||
|
||||
## Sign in
|
||||
|
||||
<Steps>
|
||||
|
||||
1. **Open `/admin/login`** — not `/`. The public site and the admin panel have separate
|
||||
sign-in screens, and in maintenance mode the public one is behind the coming-soon page.
|
||||
|
||||
2. **Use `ADMIN_USERNAME` and `ADMIN_PASSWORD` from your `.env`.**
|
||||
|
||||
That account was created on the first boot, and only because the `users` table was
|
||||
empty. The variables do nothing on later boots, so you can blank them once you are in.
|
||||
|
||||
3. **Set up two-factor**, under **Account** at the bottom of the sidebar. Optional,
|
||||
per-account, and the right moment is now rather than after the site is public.
|
||||
|
||||
</Steps>
|
||||
|
||||
<Aside type="caution" title="If the login screen rejects a password you are sure about">
|
||||
Login is rate-limited and backs off after repeated failures from one address, and the
|
||||
bot-scoring layer can ban an address outright. Both are working as designed. Give it a
|
||||
minute, and see [Authentication](/docs/administration/authentication/) for what the
|
||||
**Web Bot Activity** screen shows and how to lift a ban.
|
||||
</Aside>
|
||||
|
||||
<Screenshot id="admin-dashboard" />
|
||||
|
||||
## What is already there
|
||||
|
||||
The first boot seeds a working site rather than an empty one:
|
||||
|
||||
- **A wiki with eight pages**, arranged in sections — Guides, World & Lore, Systems &
|
||||
Gameplay, Community & Rules — as a skeleton to write into, not as content to keep.
|
||||
- **Post categories**: News, Five on Friday, Newsletter, Screenshots.
|
||||
- **A public navigation** covering those, the wiki and an About page.
|
||||
- **A portal hero** with placeholder copy that names no game.
|
||||
|
||||
None of it mentions a specific game, because core does not know about one. That arrives
|
||||
with a [module](/docs/getting-started/install-a-game-module/).
|
||||
|
||||
## The three things to set before going live
|
||||
|
||||
All three are on **Settings**:
|
||||
|
||||
| Setting | Why now |
|
||||
|---|---|
|
||||
| **Site title** | Overrides `BRAND_NAME` for the page title, the header and link previews. |
|
||||
| **Contact email** | Where the contact form delivers. Until email is configured, the form falls back to a `mailto:` link to this address — so an unset one means a contact form that goes nowhere. |
|
||||
| **Player registration** | **Off by default**: nobody can create an account. Choose password, SSO, both, or leave it off and invite people individually from **Invites**. |
|
||||
|
||||
The maintenance message and the homepage teaser are on the same screen, and both are worth
|
||||
a minute before anyone reads them.
|
||||
|
||||
## Switch to live
|
||||
|
||||
**Dashboard → Switch to Live.** The public site opens immediately; nothing else changes.
|
||||
|
||||
You can flip back at any time, and an admin who is signed in can preview the live site
|
||||
while the rest of the world still sees the maintenance page — so there is no need to go
|
||||
live in order to check your work.
|
||||
|
||||
<Aside type="note" title="Going live is not the same as being reachable">
|
||||
Live mode only decides what visitors are shown. Whether anyone can reach the site at all is
|
||||
your DNS, TLS and reverse proxy — see
|
||||
[Maintenance and upgrades](/docs/administration/maintenance-and-upgrades/).
|
||||
</Aside>
|
||||
|
||||
Next: [Install a game module](/docs/getting-started/install-a-game-module/), or skip
|
||||
straight to [Administration](/docs/administration/configuration/) if this deployment is a
|
||||
community site with no game server behind it.
|
||||
@@ -0,0 +1,93 @@
|
||||
---
|
||||
title: Install a game module
|
||||
description: Everything game-specific is a module. Installing one, what it adds, and the restart that makes it live.
|
||||
---
|
||||
|
||||
import platform from '../../../../data/platform.json';
|
||||
import { Aside, Steps } from '@astrojs/starlight/components';
|
||||
|
||||
Core knows nothing about any game. Every game-specific screen — shard status, the map
|
||||
atlas, the player marketplace, character sheets — comes from a **module**, a directory on a
|
||||
mounted volume that the server loads at start.
|
||||
|
||||
Today there is one: **`uo`**, for ServUO shards, published as
|
||||
[`Module-uo`](https://gitea.whitlocktech.com/RunicGateway/Module-uo) ({platform.releases['Module-uo']}).
|
||||
|
||||
## Install it
|
||||
|
||||
<Steps>
|
||||
|
||||
1. **Open Admin → Modules.**
|
||||
|
||||
2. **Paste the URL of a release's install manifest** into *Release install-manifest URL*
|
||||
and press **Install**.
|
||||
|
||||
For the current `uo` release that is the `module-uo-<version>.json` asset on
|
||||
[its releases page](https://gitea.whitlocktech.com/RunicGateway/Module-uo/releases).
|
||||
The site downloads the bundle, checks it against the `sha256` the manifest declares, and
|
||||
unpacks it onto the modules volume.
|
||||
|
||||
There is no catalog to browse, deliberately: a catalog would make core's release cadence
|
||||
decide which modules are allowed to exist.
|
||||
|
||||
3. **Restart when it asks.** A banner appears — *Modules are read from disk when the server
|
||||
starts* — with a **Restart the server** button. The row reads *Restart to start* until
|
||||
you do.
|
||||
|
||||
The button exits the process and lets your supervisor bring it back; on the Compose
|
||||
deployment from [Install the site](/docs/getting-started/install-the-site/), that is
|
||||
`restart: unless-stopped` doing its job. `docker compose restart app` is exactly
|
||||
equivalent.
|
||||
|
||||
4. **Confirm it started.** The module's row should read *Started*, and its screens should
|
||||
have appeared in the navigation.
|
||||
|
||||
</Steps>
|
||||
|
||||
<Aside type="note" title="Only listed hosts may be installed from">
|
||||
Installing a module runs its code inside your server, so the URL must be HTTPS and its host
|
||||
must be in the allowlist at the bottom of the same screen — re-checked on every redirect.
|
||||
It is seeded with `gitea.whitlocktech.com`, and an empty list forbids every install.
|
||||
</Aside>
|
||||
|
||||
## What the `uo` module adds
|
||||
|
||||
Watch the log at the restart and you will see exactly what it mounted:
|
||||
|
||||
```
|
||||
[uo] registered routes: public:/shard,/atlas admin:/shard,/uo-link player:/shard
|
||||
[modules] schema ensured for module "uo"
|
||||
[modules] module "uo" started
|
||||
```
|
||||
|
||||
Its capabilities are {platform.moduleUoCapabilities.join(', ')} — the shard console, the
|
||||
map atlas, the player-vendor marketplace, city governors, guilds, houses and IDOCs, champion
|
||||
boards, and the cliloc strings that make item names readable.
|
||||
|
||||
A module owns **one path segment** wherever it appears, so its pages live under `/uo/…`,
|
||||
`/admin/uo/…` and `/player/uo/…`. That boundary is visible in the URL on purpose.
|
||||
|
||||
<Aside type="caution" title="A module with no game server behind it is empty, not broken">
|
||||
Installing `uo` does not connect anything. Its screens exist and report the shard as
|
||||
offline until you
|
||||
[connect a game server](/docs/getting-started/connect-a-game-server/) — which is the same
|
||||
thing the public site does when the shard goes down, and is designed to be unremarkable.
|
||||
</Aside>
|
||||
|
||||
## The declarative alternative
|
||||
|
||||
A host whose Compose file is version-controlled can skip the panel entirely: set `MODULES`
|
||||
in `.env`, one entry per module, `<id>@<version>=<install manifest URL>`. The container
|
||||
resolves that set at every start.
|
||||
|
||||
A module already unpacked at the declared version is left alone **without a single network
|
||||
call**, so a restart with no route to the internet comes up unchanged. A failure is logged
|
||||
and shown in Admin → Modules, and never stops the site from starting.
|
||||
|
||||
The two surfaces agree on a rule worth knowing: **the variable owns what is on the volume,
|
||||
the admin panel owns whether a module runs.** A module you disable in the panel stays
|
||||
disabled even though its files are put back at the next start.
|
||||
|
||||
More on both in [Managing modules](/docs/administration/managing-modules/).
|
||||
|
||||
Next: [Connect a game server](/docs/getting-started/connect-a-game-server/).
|
||||