Compare commits
1 Commits
c3c347d237
...
feat/phase
| Author | SHA1 | Date | |
|---|---|---|---|
| a6fc0431d7 |
@@ -1,42 +0,0 @@
|
|||||||
# 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
70
.env.example
@@ -1,70 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
---
|
|
||||||
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.
|
|
||||||
-->
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
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.
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
---
|
|
||||||
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. -->
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
<!--
|
|
||||||
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.
|
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -158,45 +158,3 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
run: npm run check:reference
|
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
|
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
# 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
141
CONTRIBUTING.md
@@ -1,141 +0,0 @@
|
|||||||
# 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
409
DEPLOY.md
@@ -1,409 +0,0 @@
|
|||||||
# 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
132
Dockerfile
@@ -1,132 +0,0 @@
|
|||||||
# 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"]
|
|
||||||
440
PLAN.md
440
PLAN.md
@@ -225,7 +225,7 @@ Taken by the org lead (Colby Whitlock) on 2026-08-19. Recorded so they are not r
|
|||||||
| **D3** | **All ten documentation conflicts are fixed**, not just the operator-facing five. | §4. |
|
| **D3** | **All ten documentation conflicts are fixed**, not just the operator-facing five. | §4. |
|
||||||
| **D4** | **Real web screenshots**, captured from the local review stack, not placeholders. | §13 phase 6. Needs seeded, presentable demo content. |
|
| **D4** | **Real web screenshots**, captured from the local review stack, not placeholders. | §13 phase 6. Needs seeded, presentable demo content. |
|
||||||
| **D5** | **Claude drafts `/privacy` and `/terms`** from what the code actually collects; the org lead reviews before ship. | §9. |
|
| **D5** | **Claude drafts `/privacy` and `/terms`** from what the code actually collects; the org lead reviews before ship. | §9. |
|
||||||
| **D6** | **Ship the image and compose file; the org lead deploys.** DNS and TLS terminate at their existing reverse proxy. | §13 phase 9. **Amended by D54 (2026-08-25):** a merge to `main` now builds, publishes and deploys. What D6 protected is held by `needs: build` and by every check having run on the pull request. |
|
| **D6** | **Ship the image and compose file; the org lead deploys.** DNS and TLS terminate at their existing reverse proxy. | §13 phase 9. This repo never touches the production host. |
|
||||||
| **D7** | **The site sends no email at all.** No SMTP, no notifications, no mailbox behind the domain yet. | §8 designs the signup so it works anyway — see "The opt-in link removes the need for email". A contact address is still required; D13 supplies it. |
|
| **D7** | **The site sends no email at all.** No SMTP, no notifications, no mailbox behind the domain yet. | §8 designs the signup so it works anyway — see "The opt-in link removes the need for email". A contact address is still required; D13 supplies it. |
|
||||||
| **D8** | **Understated honesty.** The site reads as finished; factual badges appear only where they save a reader wasted effort. **The Integration Kit stays marked draft until a second module is successfully built against it.** | §11, §10. A status with an exit criterion, not a mood. |
|
| **D8** | **Understated honesty.** The site reads as finished; factual badges appear only where they save a reader wasted effort. **The Integration Kit stays marked draft until a second module is successfully built against it.** | §11, §10. A status with an exit criterion, not a mood. |
|
||||||
| **D9** | **No analytics.** No tracking scripts, no third-party requests, no cookie banner. | Reverse-proxy access logs are the only traffic data. |
|
| **D9** | **No analytics.** No tracking scripts, no third-party requests, no cookie banner. | Reverse-proxy access logs are the only traffic data. |
|
||||||
@@ -236,7 +236,7 @@ Taken by the org lead (Colby Whitlock) on 2026-08-19. Recorded so they are not r
|
|||||||
|
|
||||||
**Decisions after D13 are recorded where they were taken**, in the section describing the phase that
|
**Decisions after D13 are recorded where they were taken**, in the section describing the phase that
|
||||||
raised them, rather than appended here — a decision is only re-litigated when its reasoning is
|
raised them, rather than appended here — a decision is only re-litigated when its reasoning is
|
||||||
somewhere other than the thing it decided. The count of record is **fifty-nine**:
|
somewhere other than the thing it decided. The count of record is **forty-six**:
|
||||||
|
|
||||||
| # | Where | What it settled |
|
| # | Where | What it settled |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -248,11 +248,6 @@ somewhere other than the thing it decided. The count of record is **fifty-nine**
|
|||||||
| D34–D37 | §10, "How phase 7 built the documentation journey" | One PR for all twenty pages, a self-contained install quickstart with a drift check, every admin screen walked before it was described, a thirteenth Administration page for content |
|
| D34–D37 | §10, "How phase 7 built the documentation journey" | One PR for all twenty pages, a self-contained install quickstart with a drift check, every admin screen walked before it was described, a thirteenth Administration page for content |
|
||||||
| D38–D41 | §10, "How phase 8 built the builder and reference docs" | One PR for all twenty pages again, Reference enumerates names and checks every one of them, the docs section links to the drawn diagrams rather than importing them, `plannedSidebar` becomes a checked invariant |
|
| D38–D41 | §10, "How phase 8 built the builder and reference docs" | One PR for all twenty pages again, Reference enumerates names and checks every one of them, the docs section links to the drawn diagrams rather than importing them, `plannedSidebar` becomes a checked invariant |
|
||||||
| D42–D46 | §10, "How phase 9 took the screenshots" | The full rig behind the imagery, a neutral demo brand, the captures beside the claims, a committed and checked capture pipeline, the world dressed in the plugin repo's scaffolding |
|
| D42–D46 | §10, "How phase 9 took the screenshots" | The full rig behind the imagery, a neutral demo brand, the captures beside the claims, a committed and checked capture pipeline, the world dressed in the plugin repo's scaffolding |
|
||||||
| D47–D50 | §6, "How phase 10 polished it" | Search reaches the marketing pages, the CSP is a real response header from the container, `robots.txt` allows everything and names the sitemap, two blocks of structured data and no more |
|
|
||||||
| D51–D53 | §6, "How phase 11 validated it" | The chrome and the head follow the brand mount while the consent sentence does not, the documentation half gets phase 10's skip-link fix, and no twelfth check |
|
|
||||||
| D54–D57 | §6, "How phase 12 delivered it" | A merge deploys (amending D6), the proxy is documented by its requirements rather than by an example, the container trusts the forwarded headers with nothing to configure, and the operator note is its own file while the two policy files are pointers |
|
|
||||||
| D58 | §6, "How phase 12 delivered it" | `node_modules` ships in three layers because Cloudflare refuses a request body over 100 MB, and the workflow counts layers before it pushes |
|
|
||||||
| D59 | §6, "How phase 12 delivered it" | The container publishes on every interface (amending D55), with `SITE_BIND_ADDR` to narrow it |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -300,403 +295,6 @@ inside the org's existing tooling family. Node 22 LTS.
|
|||||||
- **No authenticated surface exists on the site at all.** The CSV export is a CLI run against the
|
- **No authenticated surface exists on the site at all.** The CSV export is a CLI run against the
|
||||||
bind mount, not an HTTP route — see §8.
|
bind mount, not an HTTP route — see §8.
|
||||||
|
|
||||||
### How phase 10 polished it
|
|
||||||
|
|
||||||
Four decisions, D47–D50, taken 2026-08-25. Three of them were straightforward; the fourth turned
|
|
||||||
into the phase's real work, because the thing that was supposed to be a configuration flag was
|
|
||||||
broken in a dependency and broken *silently*.
|
|
||||||
|
|
||||||
**D47 — search reaches the marketing pages, and the marketing header gets a box.** The
|
|
||||||
documentation had search from phase 1: Starlight builds a Pagefind index at the end of every build.
|
|
||||||
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. `Base.astro` now marks its
|
|
||||||
`<main>` as a Pagefind body, which puts all ten in the index the docs already query, and a
|
|
||||||
`Search.astro` in the header opens the same index in a `<dialog>`.
|
|
||||||
|
|
||||||
Three things about the build are worth keeping. **Nothing is fetched until the dialog is opened** —
|
|
||||||
Pagefind's UI bundle is 120 kB before the index and the WASM, and these pages otherwise ship almost
|
|
||||||
no JavaScript, so the button is inert markup and the first open injects the script. **`<dialog>`
|
|
||||||
rather than a hand-built overlay**, because the browser supplies the focus trap, the inert
|
|
||||||
background, Escape-to-close and the top layer, and every one of those is something an accessibility
|
|
||||||
pass would otherwise find missing. And **the index needed an explicit title**: Pagefind titles a
|
|
||||||
result from the first `<h1>`, and these pages have editorial ones — `/app/`'s is "The app for a
|
|
||||||
deployment you already use", `/terms/`'s is "Short, and only about what we run". Correct on the page
|
|
||||||
under an eyebrow that names the section; unscannable as four rows in a result list, which is exactly
|
|
||||||
what the first walk of the finished search produced. `data-pagefind-meta` now carries the page's
|
|
||||||
short name, the one already in the nav and the browser tab.
|
|
||||||
|
|
||||||
**Two things about styling somebody else's widget.** Pagefind's UI takes a `resetStyles`
|
|
||||||
option; setting it to `false` — on the reasoning that the site's own type and colour should
|
|
||||||
show through — is wrong, because that reset is what styles Pagefind's 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 an `outset` border. The palette is bound through
|
|
||||||
Pagefind's custom properties instead. And the match highlight needed one extra class in the
|
|
||||||
selector: the reset declares `.pagefind-ui--reset mark { all: revert }`, same specificity as a
|
|
||||||
plain descendant rule and injected after our stylesheet, so it won on order and put the
|
|
||||||
user-agent yellow back on every result.
|
|
||||||
|
|
||||||
It also closed a note phase 2 left here. Pagefind indexes at build time, so the boot rewrite
|
|
||||||
(§7, D15) reached the pages and not the search results: a site renamed through the mount would
|
|
||||||
answer a search for its own name with the stock one. `applyBrand.mjs` now re-indexes after a rewrite
|
|
||||||
— only when it actually rewrote something, so a stock deployment still pays nothing.
|
|
||||||
|
|
||||||
**D48 — the CSP is a real response header, sent by the container.** The alternatives were a
|
|
||||||
`<meta http-equiv>`, which is what Astro emits by default and which silently ignores
|
|
||||||
`frame-ancestors` — the one directive that stops the site being framed — and writing the headers
|
|
||||||
into an operator's reverse-proxy configuration, which puts the strictest promise in §6 outside the
|
|
||||||
artifact this repository builds and tests. Neither is good enough for a security boundary, so the
|
|
||||||
Node adapter's `staticHeaders` is on: the build writes one policy per prerendered route into
|
|
||||||
`dist/_headers.json` and the server sends it.
|
|
||||||
|
|
||||||
**Three things fought this, and each is the same shape: correct build, broken page, no error.**
|
|
||||||
|
|
||||||
1. **Astro does not hash `<script is:inline>`.** It hashes what it processes; an inline script is
|
|
||||||
the author's own text, which it never parses. Starlight ships six per documentation page — the
|
|
||||||
theme provider, the theme-picker sync, the mobile menu, the sidebar scroll restore. The first
|
|
||||||
build with CSP enabled produced a strict, correct header and a documentation site whose theme
|
|
||||||
switch and mobile sidebar did nothing, with the explanation only in a console. `'unsafe-inline'`
|
|
||||||
would have fixed all six and given up the single directive CSP exists to enforce, so instead the
|
|
||||||
hashes are enumerated in a generated `src/config/cspHashes.mjs` and `scripts/checkCsp.mjs`
|
|
||||||
verifies, per page, that every inline block is covered by *that page's own* policy. A Starlight
|
|
||||||
upgrade that edits one byte turns the build red; `npm run csp:hashes` re-harvests it.
|
|
||||||
|
|
||||||
2. **Expressive Code cannot be hashed at all.** Around 3,700 inline `style` **attributes** across
|
|
||||||
the documentation carry every syntax colour, and CSP hashes cover `<style>` elements, never
|
|
||||||
attributes — Astro's own documentation records Shiki as incompatible with CSP for this reason.
|
|
||||||
The policy therefore carries `style-src-attr 'unsafe-inline'`, scoped to that directive: a style
|
|
||||||
attribute cannot execute script, so `script-src` is untouched. The marketing pages emit none.
|
|
||||||
|
|
||||||
3. **`@astrojs/node` served the wrong page's policy.** Its per-request lookup is
|
|
||||||
`headersMap.find((h) => h.pathname.includes(baselessPathname))` — a substring test taking the
|
|
||||||
first match. `/modules/` was served the policy built for `/docs/modules/building-a-module`;
|
|
||||||
`/architecture/` got a docs page's; and `/`, a substring of every path in the file, got whichever
|
|
||||||
record came first, which was `/404`. Since each policy is a list of per-page hashes, the browser
|
|
||||||
refused each page's own stylesheet: `/modules/` and `/architecture/` were rendering unstyled,
|
|
||||||
and the homepage looked perfect only because it happened to share a hash with the 404 page.
|
|
||||||
`scripts/serve.mjs` — a thin wrapper `npm start` now runs instead of the adapter's entry — keeps
|
|
||||||
the same `_headers.json` and matches by equality. It is small on purpose so it can be deleted
|
|
||||||
whole when upstream is fixed, and it is where the non-CSP security headers live too.
|
|
||||||
|
|
||||||
**This is why `test/headers.test.mjs` exists.** Every other check in this repository reads
|
|
||||||
`dist/`, and every file on disk was right — the bytes on the wire were not. It starts the server
|
|
||||||
and reads the responses, and reverting the wrapper to the substring lookup fails it.
|
|
||||||
|
|
||||||
**D49 — `robots.txt` allows everything and names the sitemap.** The sitemap has covered all fifty
|
|
||||||
URLs since phase 1 (Starlight bundles `@astrojs/sitemap`) and nothing pointed at it; a crawler finds
|
|
||||||
one either from this file or from a search console, and D9's posture extends to not having an
|
|
||||||
account with anyone. Nothing is disallowed: there is no authenticated surface (§6), `/brand/*` is
|
|
||||||
derived images with no text, and `/beta/` is a page a person is meant to find. The 404 is kept out
|
|
||||||
of the *search index* instead, with `data-pagefind-ignore`, which is the right layer for it.
|
|
||||||
|
|
||||||
**D50 — two blocks of structured data, and no more.** `Organization` so the project's name resolves
|
|
||||||
to an entity rather than to whichever page ranks, and `SoftwareApplication` because what the site
|
|
||||||
describes is software someone installs. No ratings, no counts, no invented `aggregateRating` — §11's
|
|
||||||
understated honesty applies to markup a reader never sees, and inventing a rating is what gets
|
|
||||||
structured data ignored. Breadcrumb and `Article` markup on the forty documentation pages was
|
|
||||||
rejected: Starlight already renders breadcrumbs a reader can see, and it would be forty more places
|
|
||||||
for a fact to go stale. Every value is read from `brand.json` or `platform.json`, so `checkFacts.mjs`
|
|
||||||
already guards them.
|
|
||||||
|
|
||||||
It is a `<script type="application/ld+json">`, which is a data block: no browser executes it and no
|
|
||||||
CSP hash covers it. **Both `checkCsp.mjs` and `applyBrand.mjs` had to be taught that explicitly** —
|
|
||||||
the first would have demanded a hash for text that changes whenever a fact does, and the second
|
|
||||||
would have refused to rewrite the homepage at all, which is §7 failing on the page that matters
|
|
||||||
most.
|
|
||||||
|
|
||||||
**What the walk found.** Ten marketing pages and a documentation sample, at 390, 768 and 1280 in
|
|
||||||
real Chrome. No horizontal overflow at any width, on any page — the responsive work of phases 3 and
|
|
||||||
4 held, including with a search button added to the header. The CSP violations above. The consent
|
|
||||||
checkbox on `/beta` measured 17×17 against WCAG 2.2 SC 2.5.8's 24px minimum, and is now 24 — the one
|
|
||||||
control on the site a person must hit precisely, on the page a phone is most likely to arrive at.
|
|
||||||
And following the skip link moved the scroll but not the focus, because a `<main>` is not focusable;
|
|
||||||
Chrome papers over that and not every browser does, so it now carries `tabindex="-1"`.
|
|
||||||
|
|
||||||
**`checkA11y.mjs` is the eleventh check**, and the eighth in CI. Seven structural rules over every
|
|
||||||
built page, ours and Starlight's forty. 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. Its own first run reported every marketing page as having two `<main>`
|
|
||||||
landmarks — this repository comments its markup heavily, and one of those comments quotes the tag it
|
|
||||||
is explaining, so comments are stripped before anything is counted. It was then verified by breaking
|
|
||||||
each of its rules in turn.
|
|
||||||
|
|
||||||
### How phase 11 validated it
|
|
||||||
|
|
||||||
Three decisions, D51–D53, taken 2026-08-25. The phase's list in §13 is a list of things to run, and
|
|
||||||
running them was the easy half: `npm run verify` was green on `main` before this phase started and
|
|
||||||
is green now. The half that mattered was the part no script does — a real browser, at three widths,
|
|
||||||
across every page, and a signup and a brand mount walked end to end against the built server.
|
|
||||||
|
|
||||||
That found three things, two of which are fixed here. All three are recorded, including the one that
|
|
||||||
was left, because a defect nobody wrote down is one the next phase re-discovers.
|
|
||||||
|
|
||||||
**What was run.** Fourteen steps: `astro check`, the production build, all eleven checks (sidebar,
|
|
||||||
screens, tokens, brand, data safety, links, facts, quickstart, reference, a11y, CSP) and both test
|
|
||||||
suites — 36 tests offline and 5 against a running server. Then, outside CI: fifty pages at 390, 768
|
|
||||||
and 1280 in the installed Chrome; the signup's whole decision path against a scratch store; the
|
|
||||||
export CLI including a deletion; a full brand mount, applied and restarted; and every off-site link
|
|
||||||
the built site publishes.
|
|
||||||
|
|
||||||
**The browser walk came back clean on everything phase 10 had claimed.** No horizontal overflow at
|
|
||||||
any width on any page. No CSP violation anywhere — the wrapper in `scripts/serve.mjs` holds for all
|
|
||||||
fifty policies. No failed request, no image without an `alt`, no image that failed to decode, and no
|
|
||||||
console error other than the 404 page's own 404. Search opens, reaches both chromes, and closes on
|
|
||||||
Escape at both widths; the documentation theme switcher and mobile sidebar work, which is the thing
|
|
||||||
a wrong CSP breaks first and silently; twelve tab stops on the homepage all draw a focus ring and
|
|
||||||
follow the visual order.
|
|
||||||
|
|
||||||
**D51 — the chrome and the head follow the mount; the consent sentence does not.** The brand walk
|
|
||||||
mounted a complete `brand.json` — a different site name, tagline, contact address, Discord invite,
|
|
||||||
Gitea org, demo URL and Play opt-in URL — restarted, and asked every page whether any stock string
|
|
||||||
survived. Forty-nine came back clean. `/beta` did not.
|
|
||||||
|
|
||||||
The reason is structural and was written down in `brand.mjs` before it was true: `applyBrand.mjs`
|
|
||||||
rewrites files in `dist/client`, and `/beta` renders per request, so its HTML never exists as a file
|
|
||||||
to rewrite. `liveBrand()` was added in phase 5 for exactly this, and `/beta` used it — for
|
|
||||||
`betaOptInUrl`, and nothing else. Everything *around* the form came from the shared chrome, and the
|
|
||||||
shared chrome was baked: the page's `<title>`, its `og:site_name`, `og:title` and `og:image:alt`,
|
|
||||||
the header lockup, and the footer's Source and Discord links. On a mounted deployment the one page
|
|
||||||
that asks a person for their address under a stated identity was the one page still stating the
|
|
||||||
wrong one.
|
|
||||||
|
|
||||||
Both accessors were right for a page that renders one way. The chrome is neither, so
|
|
||||||
`renderBrand(Astro)` picks by `Astro.isPrerendered` — the stock value where the boot rewrite will
|
|
||||||
reach it, the mount where it will not — and `Base.astro`, `Header.astro` and `Footer.astro` call it.
|
|
||||||
Doing it by discriminator rather than by calling `liveBrand()` everywhere matters: unconditional
|
|
||||||
live reads would also change the forty-nine, where a build machine that happened to have a mount
|
|
||||||
would bake mounted text into HTML the boot rewrite then has nothing to replace.
|
|
||||||
|
|
||||||
**`CONSENT_TEXT` is deliberately excluded.** It names the operator of the list inside a sentence a
|
|
||||||
person agrees to, and it is stored verbatim in their row — so making it follow a mounted name would
|
|
||||||
change the recorded text of a consent already given. It stays a constant and moves only with a
|
|
||||||
consent-version bump, which `test/legal.test.mjs` already enforces.
|
|
||||||
|
|
||||||
Two strings on `/beta` are knowingly left as they are: "A Runic Gateway deployment to connect to"
|
|
||||||
and "If you already run a Runic Gateway deployment". Both name the *platform the app connects to*
|
|
||||||
rather than the operator of this site, which is the one thing on the page a rebrand does not change.
|
|
||||||
|
|
||||||
**D52 — the documentation half gets phase 10's skip-link fix.** Phase 10 found that following the
|
|
||||||
skip link moved the viewport but not the keyboard focus, because a `<main>` is not focusable, and
|
|
||||||
fixed it on the marketing chrome. The walk found the identical defect standing on the other forty
|
|
||||||
pages: Starlight's skip link targets the page `<h1>`, and an `<h1>` is no more focusable than a
|
|
||||||
`<main>`. A `PageTitle` override adds `tabindex="-1"` and suppresses the ring on an element that is
|
|
||||||
reachable by exactly one deliberate route and is never in the tab sequence. It is Starlight's own
|
|
||||||
implementation with one attribute added, because the override mechanism replaces a component rather
|
|
||||||
than decorating it; the drift risk that creates is named in the file.
|
|
||||||
|
|
||||||
**The heading anchor links were checked and left.** Starlight's ¶ links measure 23.98×34.8 beside an
|
|
||||||
`h2` and 19.86×28.8 beside an `h3` at 390px, under WCAG 2.2 SC 2.5.8's 24px. They are exempt under
|
|
||||||
that criterion's *Equivalent* clause: the mobile table of contents on the same page offers a
|
|
||||||
388×34.5 link to every one of the same anchors. Recorded rather than fixed, so the next walk does
|
|
||||||
not re-raise it.
|
|
||||||
|
|
||||||
**D53 — no twelfth check.** Two of the phase's rigs were good enough to be tempting. An external
|
|
||||||
link sweep found 73 of 74 destinations alive (the 74th, gnu.org's licence text, is unreachable from
|
|
||||||
this network rather than gone), and a brand-mount walk would have caught D51 and would guard it. Both
|
|
||||||
were left as throwaway scripts. A link check makes the build depend on other people's uptime, and a
|
|
||||||
browser walk needs Chrome on the runner — CI would gain two ways to be red for reasons that are not
|
|
||||||
about this repository. The eleven checks stand, and what the rigs found is written down here instead.
|
|
||||||
|
|
||||||
**What the signup walk proved.** Every branch of the decision path, each against the store rather
|
|
||||||
than against the page it renders: an address is added and its consent text stored verbatim; the same
|
|
||||||
address again is a duplicate and not a second row; a filled honeypot stores nothing and is
|
|
||||||
indistinguishable from success; a malformed address and an unticked consent box are both refused on
|
|
||||||
the server with the browser's validation disabled; an unsigned form token stores nothing; and a
|
|
||||||
burst is stopped at `perHour` with a message that says the count is attempts rather than signups.
|
|
||||||
The `minSeconds` gate is real enough to be worth knowing about — the first pass of the walk recorded
|
|
||||||
four `too-fast` rows and nothing else, because a script fills a form faster than a person can.
|
|
||||||
|
|
||||||
The export CLI writes both files Play needs, marks the rows exported, refuses to re-export without
|
|
||||||
`--all`, and `remove` overwrites the address, the IP hash and the user agent rather than flagging
|
|
||||||
the row — which is what `/privacy` promises.
|
|
||||||
|
|
||||||
**And the mount itself.** The rewrite reached 51 files and re-indexed all 50 pages for search, so
|
|
||||||
results agree with the pages. `/brand/*` served the mounted `theme.css` and fell back per key to the
|
|
||||||
default `wordmark.svg`. Three path-traversal shapes were refused. The demo slot opened and twelve
|
|
||||||
per-capability deep links pointed into the mounted demo. And an opt-in URL pasted into the mounted
|
|
||||||
file reached the confirmation screen on the next request, with no restart — which is the one part of
|
|
||||||
§7 that has to be true on the day the closed test opens.
|
|
||||||
|
|
||||||
### How phase 12 delivered it
|
|
||||||
|
|
||||||
The last phase, and the one that turns a repository into a deployment: a two-stage `Dockerfile`, a
|
|
||||||
pull-only `docker-compose.yml` carrying both bind mounts, `.env.example`, the publishing workflow,
|
|
||||||
`CONTRIBUTING.md` with the AI-disclosure requirement, the community-health files this repository was
|
|
||||||
the only one in the organisation to lack, and `DEPLOY.md`. Four decisions, **D54–D57**, taking the
|
|
||||||
count of record to **fifty-seven** — plus **D58**, added when the merge that shipped the phase could
|
|
||||||
not publish its own image, and **D59**, which amends D55's loopback binding. **Fifty-nine.**
|
|
||||||
|
|
||||||
It also found a defect that would have made the closed beta impossible, and it is the only phase
|
|
||||||
that could have found it. Everything before this ran the site the way a developer runs it: one
|
|
||||||
process, plain HTTP, an origin the browser and the server agree about by construction. The site does
|
|
||||||
not run that way. Standing a real container behind a real proxy is a different question, and it had
|
|
||||||
a different answer.
|
|
||||||
|
|
||||||
#### D54 — a merge deploys, which amends D6
|
|
||||||
|
|
||||||
D6 said "ship the image and the compose file; the org lead deploys", and it was written before there
|
|
||||||
was a host to deploy to. The org lead amended it on 2026-08-25: `.gitea/workflows/build-image.yml`
|
|
||||||
builds, pushes `runicgateway-site:latest` and `:sha-<7>`, and then rolls the container over on a
|
|
||||||
runner labelled **`rgcom`** on the site's own host, out of **`/opt/runicgateway.com`**. There is no
|
|
||||||
release step and no promotion, so **a merge is a publication**.
|
|
||||||
|
|
||||||
What D6 was protecting is held by something else now. Every one of the eleven checks and both test
|
|
||||||
suites have already run on the pull request; the deploy job is `needs: build`, so a failed build
|
|
||||||
never reaches the host at all; and the job then waits for the container's own healthcheck rather
|
|
||||||
than for `up -d` to return, because `npm start` runs the brand rewrite *before* the server starts and
|
|
||||||
"running" therefore arrives well before "serving". A pinned `IMAGE_TAG` survives an automatic
|
|
||||||
deploy — Compose recreates on the pinned tag — because a pin is a decision and a merge should not
|
|
||||||
quietly undo it.
|
|
||||||
|
|
||||||
The runner registers with the label `rgcom:host`. The `:host` suffix is what makes jobs run on the
|
|
||||||
machine rather than inside a job container; without it the deploy fails on the `cd`, having no
|
|
||||||
Docker socket and no compose directory.
|
|
||||||
|
|
||||||
#### D55 — a generic proxy, documented by its requirements rather than by an example
|
|
||||||
|
|
||||||
The site runs on its own host, behind whatever reverse proxy the org lead puts there. So
|
|
||||||
`DEPLOY.md` does not carry a worked Caddyfile or nginx block that would be wrong for three readers
|
|
||||||
out of four; it states the four things the proxy must do. The container originally bound to
|
|
||||||
`127.0.0.1` by default so that the safe configuration was the default one; **amended by D59
|
|
||||||
(2026-08-26)** — it publishes on every interface, and which interfaces is a variable.
|
|
||||||
|
|
||||||
Two of the four are worth repeating here because they are silent when wrong:
|
|
||||||
|
|
||||||
- **`X-Forwarded-For` is load-bearing.** The signup rate-limits per client from the first entry of
|
|
||||||
that header, falling back to the connection's peer address. Behind a proxy that does not set it,
|
|
||||||
the peer address is *the proxy* — so every visitor on earth shares one bucket and the third signup
|
|
||||||
of any hour closes the form for everybody. It fails toward refusing signups rather than toward
|
|
||||||
accepting abuse, which is the right direction and still a broken page.
|
|
||||||
- **A second Content-Security-Policy from the proxy breaks every page.** Browsers enforce the
|
|
||||||
intersection of duplicate policies, and this site's is a list of per-page hashes, so a generic
|
|
||||||
policy added at the proxy forbids the page's own stylesheet. The container already sends CSP,
|
|
||||||
`X-Content-Type-Options`, `Referrer-Policy`, `X-Frame-Options` and `Permissions-Policy` (D48), so
|
|
||||||
the proxy's job is to add none of them. The same paragraph names the three Cloudflare features
|
|
||||||
that rewrite HTML — Rocket Loader, HTML minification and email obfuscation — because each of them
|
|
||||||
breaks the hashes in exactly the same way, from a dashboard rather than from a config file.
|
|
||||||
|
|
||||||
#### D56 — the container trusts the forwarded headers, with nothing to configure
|
|
||||||
|
|
||||||
**The defect.** `@astrojs/node` builds the URL of every request from the connection and the `Host`
|
|
||||||
header alone. In `astro/app/node`'s `createRequestFromNodeRequest`:
|
|
||||||
|
|
||||||
```js
|
|
||||||
const isEncrypted = "encrypted" in req.socket && req.socket.encrypted;
|
|
||||||
const protocol = isEncrypted ? "https" : "http";
|
|
||||||
```
|
|
||||||
|
|
||||||
`x-forwarded-proto` is never consulted on that path — and `security.allowedDomains`, which sounds
|
|
||||||
like the answer, is not: on this code path it gates only whether `Astro.clientAddress` may come from
|
|
||||||
`x-forwarded-for`. Behind a proxy that terminates TLS, the browser sends `Origin:
|
|
||||||
https://runicgateway.com` and the container computes `http://runicgateway.com`, because its own
|
|
||||||
socket is plaintext. Astro's CSRF middleware then compares the two for equality:
|
|
||||||
|
|
||||||
```js
|
|
||||||
const isSameOrigin = request.headers.get("origin") === url.origin;
|
|
||||||
```
|
|
||||||
|
|
||||||
So **every beta signup, from every visitor, is answered `403 Cross-site POST form submissions are
|
|
||||||
forbidden`** — on the one route that accepts a POST, on the site whose nearest real deadline is a
|
|
||||||
closed test that cannot start without it. No proxy configuration fixes it; a proxy cannot make the
|
|
||||||
container's socket encrypted. Fifty pages look perfectly healthy while the form silently refuses
|
|
||||||
everyone.
|
|
||||||
|
|
||||||
`scripts/serve.mjs` already exists to wrap this adapter's mistakes (D48), so the fix went there:
|
|
||||||
`x-forwarded-proto: https` marks the socket encrypted, and `x-forwarded-host` replaces `Host` for
|
|
||||||
the proxies that rewrite it to the upstream address instead of passing it through.
|
|
||||||
|
|
||||||
**Both are trusted unconditionally, with no flag to set** — the org lead's call, and the right one.
|
|
||||||
The image is meant to be deployed and work; an operator who has to discover a `TRUST_PROXY` variable
|
|
||||||
to make the signup work is an operator who ships a dead form, and the broken configuration would be
|
|
||||||
the default. It costs nothing: a cross-site form submission cannot make a victim's browser send
|
|
||||||
`x-forwarded-proto`, so the CSRF check is exactly as strong as it was, and the site has no cookie,
|
|
||||||
session or credential to protect in the first place. Two assertions in `test/headers.test.mjs` hold
|
|
||||||
both halves — a proxy-shaped POST is accepted, and a genuinely cross-origin one is still refused.
|
|
||||||
|
|
||||||
#### D57 — the operator note is its own file, and two policies are pointers
|
|
||||||
|
|
||||||
`DEPLOY.md` rather than a README section: the README is for someone working *on* the site and was
|
|
||||||
already long, and deployment is a different task for a different sitting — it is also the file that
|
|
||||||
gets opened on the host. It carries what the site needs, first deploy, the proxy, DNS and TLS,
|
|
||||||
branding without a rebuild, the tester-list CLI, updating, rolling back, backups, and a symptoms
|
|
||||||
table.
|
|
||||||
|
|
||||||
This repository was also the only one of the ten with no `CONTRIBUTING.md`, no
|
|
||||||
`CODE_OF_CONDUCT.md`, no `SECURITY.md` and no issue or pull-request templates, so phase 12 added
|
|
||||||
them. **The two policy files are pointers to the organisation's copies in `docs`, not copies** —
|
|
||||||
because a copy would hard-code the contact address in a tenth place, and D13's whole promise is that
|
|
||||||
the address lives only in `brand.json` and moves for the cost of a file copy. `checkFacts.mjs` scans
|
|
||||||
`src/` and `scripts/`; these files honour the same rule voluntarily, and say so, so that the next
|
|
||||||
person does not "fix" the missing address.
|
|
||||||
|
|
||||||
`SECURITY.md` is not only a pointer, though. It names what is actually worth reporting *here* —
|
|
||||||
the signup's store, rate limit and signed form token; path traversal out of the branding mount; and
|
|
||||||
a page served no policy or another page's — and states plainly that there is no authenticated
|
|
||||||
surface to attack.
|
|
||||||
|
|
||||||
**No twelfth check.** D53 was re-tested against this phase's new drift risk — the environment
|
|
||||||
variables the code reads, `.env.example` declares and the README tabulates — and the org lead held
|
|
||||||
the line. Eleven checks stand.
|
|
||||||
|
|
||||||
#### D58 — the image ships in three layers, because of the registry rather than the site
|
|
||||||
|
|
||||||
The merge that landed phase 12 could not publish the image it had just built. `docker push` answered
|
|
||||||
**`413 Payload Too Large`** on one blob and stopped; nothing reached the registry, and `needs: build`
|
|
||||||
meant nothing reached the host either. The site was merged and undeployed, and the log said only that
|
|
||||||
a digest was too large.
|
|
||||||
|
|
||||||
**The limit is Cloudflare's, not Gitea's.** `gitea.whitlocktech.com` is proxied, and Cloudflare
|
|
||||||
refuses a request body over **100 MB** on every plan below Enterprise — a plan limit, not a setting.
|
|
||||||
`docker push` uploads each layer as a single monolithic `PUT`, so the ceiling applies per layer, and
|
|
||||||
the rejection happens at the edge with Gitea never seeing the request. It is invisible from the
|
|
||||||
Gitea side and unfixable from it.
|
|
||||||
|
|
||||||
Measured on the merge commit, one layer was over and only just: **`COPY node_modules` at 108.8 MB
|
|
||||||
compressed**, against a 188.6 MB image whose next largest layer was the 47.6 MB Node base. Two
|
|
||||||
packages account for it, `@pagefind` (the search binaries) and `@img` (sharp's libvips), and both are
|
|
||||||
needed at **run** time — the boot rewrite re-indexes the site and re-derives the brand images — so
|
|
||||||
what could move was where they land, not whether they ship.
|
|
||||||
|
|
||||||
The build stage now moves those two aside after `npm prune`, and the runtime stage copies them as
|
|
||||||
their own layers: **46.8 + 50.5 + 11.5 MB** in place of 108.8, largest layer 50.5, and the image
|
|
||||||
**exactly the same total size**, because the same bytes are simply divided differently. Moving rather
|
|
||||||
than copying twice is what keeps the three disjoint — whatever remains in `node_modules` is the
|
|
||||||
remainder by construction, so a dependency added later needs no maintenance here.
|
|
||||||
|
|
||||||
**The workflow now counts layers before it pushes**, because a split is a margin and not a
|
|
||||||
guarantee: `docker save`, re-compress anything over 8 MB the way the push would, and fail at **90 MB**
|
|
||||||
— not 100, since the blob is not the only thing in the request — naming the layer and what would
|
|
||||||
have happened. It was tested in both directions, against the fixed image and the broken one, and the
|
|
||||||
number it reports for the broken layer (108 MB) agrees with what the registry recorded.
|
|
||||||
|
|
||||||
This is a workflow step and not a twelfth check script: it needs a built image rather than a source
|
|
||||||
tree, which is the one thing the eleven never have. D53 holds.
|
|
||||||
|
|
||||||
#### D59 — the container publishes on every interface, which amends D55
|
|
||||||
|
|
||||||
D55 bound the published port to `127.0.0.1`, reasoning that TLS terminates at a proxy on the same
|
|
||||||
host and nothing else has business reaching the container. That is the right default for the host
|
|
||||||
this eventually runs on, and the wrong one for every step before it: a loopback binding cannot be
|
|
||||||
opened from a browser on another machine, which is exactly what an operator wants to do first —
|
|
||||||
look at the thing on the VM's own address, before DNS exists, before the proxy exists, from a
|
|
||||||
desktop or a phone that is not the VM.
|
|
||||||
|
|
||||||
The org lead settled it on 2026-08-26: **publish on all interfaces**, the way a normal bridge
|
|
||||||
publish behaves. `docker-compose.yml` now reads
|
|
||||||
`"${SITE_BIND_ADDR:-0.0.0.0}:${SITE_HOST_PORT:-4321}:4321"`, so the site answers on
|
|
||||||
`http://<vm-ip>:4321` out of the box.
|
|
||||||
|
|
||||||
**Which addresses it answers on is a variable, not an edit.** `SITE_BIND_ADDR` in `.env` narrows it
|
|
||||||
to one interface or back to loopback without touching a file that `docker compose pull` replaces,
|
|
||||||
which also removes the "change the port line" instruction D55 had to give proxies running in another
|
|
||||||
container or on another machine.
|
|
||||||
|
|
||||||
What is honestly given up: on a host with a public address, port 4321 answers from the internet
|
|
||||||
directly — plain HTTP beside the proxy's 443, and with no proxy in the path to set `X-Forwarded-For`,
|
|
||||||
so signups arriving that way share one rate-limit bucket. The site has no login and no secret behind
|
|
||||||
it, so this is untidy rather than dangerous, and `DEPLOY.md` §3.1 says so and gives both remedies
|
|
||||||
(firewall the port, or narrow the binding).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Branding is bind-mounted data
|
## 7. Branding is bind-mounted data
|
||||||
@@ -951,8 +549,7 @@ that omitted sideloading would read, to somebody who knows the APK exists, as a
|
|||||||
rewrites files in `dist/client`; an on-demand route's HTML never was a file, so `/beta` reading
|
rewrites files in `dist/client`; an on-demand route's HTML never was a file, so `/beta` reading
|
||||||
`brand` would show stock values forever. It reads the mounted `brand.json` itself, guarded by an
|
`brand` would show stock values forever. It reads the mounted `brand.json` itself, guarded by an
|
||||||
mtime check. That is strictly better where it applies — pasting the opt-in URL into the mount
|
mtime check. That is strictly better where it applies — pasting the opt-in URL into the mount
|
||||||
takes effect on the **next request**, with no restart. *Phase 11 found it had been applied to one
|
takes effect on the **next request**, with no restart.
|
||||||
field and not to the chrome around it, and added `renderBrand()` — see D51.*
|
|
||||||
- **`checkLinks.mjs` learned what an on-demand route is.** `/beta` is the first on-demand *page*,
|
- **`checkLinks.mjs` learned what an on-demand route is.** `/beta` is the first on-demand *page*,
|
||||||
and rule 1 resolves links against the build, where it has no file. The fix is not a
|
and rule 1 resolves links against the build, where it has no file. The fix is not a
|
||||||
`PLANNED_ROUTES` entry — that list's reverse check fires when a route has been *built*, and an
|
`PLANNED_ROUTES` entry — that list's reverse check fires when a route has been *built*, and an
|
||||||
@@ -1622,17 +1219,6 @@ a mechanism rather than diligence:
|
|||||||
replaceable by a file copy, and that promise survives exactly as long as nobody types the address
|
replaceable by a file copy, and that promise survives exactly as long as nobody types the address
|
||||||
into a paragraph. Same argument as `checkTokens.mjs` and colour literals — the check is the
|
into a paragraph. Same argument as `checkTokens.mjs` and colour literals — the check is the
|
||||||
mechanism, diligence is not.
|
mechanism, diligence is not.
|
||||||
|
|
||||||
**All three network checks read a file through Gitea's `contents` endpoint, never `raw`** —
|
|
||||||
`checkFacts.mjs`, `checkQuickstart.mjs`, `checkReference.mjs`. Phase 12b found the reason.
|
|
||||||
`raw` answers with `Cache-Control: public, max-age=21600`, so the CDN in front of Gitea keeps
|
|
||||||
a copy for six hours: on cutover day this check read `website`'s `version.js` from a fortnight
|
|
||||||
earlier and failed the site for saying Module API 1.9.0 when `main` said 1.6.0 — except that
|
|
||||||
`main` said 1.9.0, and nothing anyone could edit here would have made it pass. `contents`
|
|
||||||
answers `private, must-revalidate` and is not cached, at the cost of a base64 decode. Same
|
|
||||||
argument as `checkLinks.mjs` fetching nothing: a check that goes red on someone else's
|
|
||||||
infrastructure is a check people learn to ignore, and one that goes red on a stale copy is
|
|
||||||
worse — it is indistinguishable from the failure it exists to report.
|
|
||||||
- **`scripts/checkLinks.mjs`** — every internal link resolves; every outbound link into a
|
- **`scripts/checkLinks.mjs`** — every internal link resolves; every outbound link into a
|
||||||
`RunicGateway` repo points at a branch path, not a commit permalink. **Built in phase 4** (D23),
|
`RunicGateway` repo points at a branch path, not a commit permalink. **Built in phase 4** (D23),
|
||||||
and it reads `dist/client` rather than `src/`: half the links these pages carry are assembled from
|
and it reads `dist/client` rather than `src/`: half the links these pages carry are assembled from
|
||||||
@@ -1683,23 +1269,13 @@ a mechanism rather than diligence:
|
|||||||
| **7** | Docs — the journey: Getting started (7) + Administration (**13**, per D37) — twenty pages in one PR (D34), with the install page self-contained and drift-checked (D35) and every admin screen walked before it was described (D36). **The installation path is the priority of the whole project** |
|
| **7** | Docs — the journey: Getting started (7) + Administration (**13**, per D37) — twenty pages in one PR (D34), with the install page self-contained and drift-checked (D35) and every admin screen walked before it was described (D36). **The installation path is the priority of the whole project** |
|
||||||
| **8** | Docs — builder and reference: Modules (8) + Architecture (5) + Reference (7) — twenty pages in one PR (D38), with Reference enumerating names and **checking every one of them** against its source (D39), and `plannedSidebar` becoming a checked invariant (D41) |
|
| **8** | Docs — builder and reference: Modules (8) + Architecture (5) + Reference (7) — twenty pages in one PR (D38), with Reference enumerating names and **checking every one of them** against its source (D39), and `plannedSidebar` becoming a checked invariant (D41) |
|
||||||
| **9** | Screenshots (D4): stand up the local review stack, seed presentable content, capture the admin panel, Teams, forums, marketplace, spawn atlas and shard console; build the screenshot components. **Plus an emulator pass against the same seeded stack** to fill `/app/`'s reserved slot (D26) |
|
| **9** | Screenshots (D4): stand up the local review stack, seed presentable content, capture the admin panel, Teams, forums, marketplace, spawn atlas and shard console; build the screenshot components. **Plus an emulator pass against the same seeded stack** to fill `/app/`'s reserved slot (D26) |
|
||||||
| **10** | Polish: responsive, accessibility, SEO/OpenGraph/sitemap/robots, full-text search, CSP headers. See D47–D50 — the CSP was the work, because `@astrojs/node` served every page another page's policy |
|
| **10** | Polish: responsive, accessibility, SEO/OpenGraph/sitemap/robots, full-text search, CSP headers |
|
||||||
| **11** | Validation: `astro check`, production build, **all eleven check scripts** (tokens, brand, links, facts, quickstart, data safety, reference, sidebar, screens, a11y, CSP) plus both test suites, mobile layout verified in a real browser, a signup walked end to end. See D51–D53 — the scripts were green before the phase started; the browser walk and a real brand mount are what found the three defects |
|
| **11** | Validation: `astro check`, production build, **all nine check scripts** (tokens, brand, links, facts, quickstart, data safety, reference, sidebar, screens), mobile layout verified in a real browser, a signup walked end to end |
|
||||||
| **12** | Delivery: two-stage Dockerfile, pull-only `docker-compose.yml` with both bind mounts, `.env.example`, the Gitea Actions workflow that publishes **and deploys** (D54), README, CONTRIBUTING, the community-health files this repo alone lacked, and `DEPLOY.md` (D55, D57). It found the one defect that only a container behind a proxy can find — see D56 |
|
| **12** | Delivery: Dockerfile, `docker-compose.yml` with both bind mounts documented, Gitea Actions workflow publishing to the registry, README, CONTRIBUTING with the AI-disclosure requirement, and an operator note covering DNS, TLS and the reverse proxy (D6) |
|
||||||
|
|
||||||
Phases 5 and 6 are deliberately adjacent and early: the beta cannot start without `/privacy`, and
|
Phases 5 and 6 are deliberately adjacent and early: the beta cannot start without `/privacy`, and
|
||||||
the closed test is the nearest real deadline.
|
the closed test is the nearest real deadline.
|
||||||
|
|
||||||
**All twelve are built, as of 2026-08-25.** The `rgcom` runner was registered on the host the next
|
|
||||||
day, in host mode as §7 of `DEPLOY.md` requires. What is left is not a phase: point the DNS record at
|
|
||||||
the host (§14, N1), and — when the demo VM exists (§15) and the Play track is open — put two URLs
|
|
||||||
into the mounted `brand.json`. Neither is a code change, which was the point.
|
|
||||||
|
|
||||||
**One thing did need a code change.** The merge that landed phase 12 built its image and then could
|
|
||||||
not publish it: Cloudflare rejected the largest layer with `413 Payload Too Large`, so the registry
|
|
||||||
stayed empty and the deploy never ran. See **D58** — the layer is split, and the workflow now counts
|
|
||||||
layers before it pushes.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 14. Still needed from the org lead
|
## 14. Still needed from the org lead
|
||||||
@@ -1707,9 +1283,7 @@ layers before it pushes.
|
|||||||
None of these block starting Phase 0 or Phase 1.
|
None of these block starting Phase 0 or Phase 1.
|
||||||
|
|
||||||
**N1 — Resolved.** `runicgateway.com` is registered through **Cloudflare**, with DNS on Cloudflare.
|
**N1 — Resolved.** `runicgateway.com` is registered through **Cloudflare**, with DNS on Cloudflare.
|
||||||
The domain does not resolve to anything yet. Phase 12 shipped everything needed to point it: the
|
The domain does not resolve to anything yet; the record is pointed at the host in phase 12.
|
||||||
`A` record, the proxy requirements and the three Cloudflare features that break a hash-based CSP are
|
|
||||||
in `DEPLOY.md` §4. Creating the record is the org lead's, on the day the host is up.
|
|
||||||
|
|
||||||
**N2 — Settled by D13, and no longer blocking anything.** As of 2026-08-19 no mailbox exists at the
|
**N2 — Settled by D13, and no longer blocking anything.** As of 2026-08-19 no mailbox exists at the
|
||||||
domain and the org lead chose not to wait for one. **`whitlocktech@gmail.com` is the published
|
domain and the org lead chose not to wait for one. **`whitlocktech@gmail.com` is the published
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ Where the console offers free text about security practices, two things are wort
|
|||||||
| App info and performance | Other app data | No | No | Not collected by us. Stored on the device only. |
|
| 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 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 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. |
|
| Device or other IDs | Device or other IDs | No | No | Not collected. |
|
||||||
|
|
||||||
## Each answer, and why it is the truthful one
|
## Each answer, and why it is the truthful one
|
||||||
@@ -84,23 +83,12 @@ Push is off until you enable it. When you do, the app mints a random, unguessabl
|
|||||||
|
|
||||||
**Messages → Other user-generated content.** Not collected by us.
|
**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.
|
Forum posts, Team activity, character and shard information, notification preferences: all of it is a live read or write against the deployment. Nothing is cached for offline use and nothing is duplicated anywhere else — the app with no signal is an app with no content, which is a limitation and also an accurate description of where the data lives.
|
||||||
|
|
||||||
- **Why that answer:** Content is written to the community’s own installation. We have no copy, no access and no way to obtain one.
|
- **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
|
- **Retention:** Held by the deployment, under its operator’s policy
|
||||||
- **Read from:** `PLAN.md §9 section 2`
|
- **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
|
### No analytics, no crash reporting, no advertising
|
||||||
|
|
||||||
**Device or other IDs → Device or other IDs.** Not collected.
|
**Device or other IDs → Device or other IDs.** Not collected.
|
||||||
@@ -128,4 +116,4 @@ Not part of the Data Safety form — that form is about the app — but a review
|
|||||||
- **Your browser’s user-agent string, truncated** — With the row; blanked on removal.
|
- **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.
|
- **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.
|
Last generated from data dated 2026-08-24. Regenerate with `npm run play:datasafety` after any change to what the app stores.
|
||||||
|
|||||||
107
README.md
107
README.md
@@ -11,12 +11,11 @@ closed beta: **players**, who want the app.
|
|||||||
platform state, the org lead's decisions, the information architecture, and the build phases. Read
|
platform state, the org lead's decisions, the information architecture, and the build phases. Read
|
||||||
it before changing anything here.
|
it before changing anything here.
|
||||||
|
|
||||||
**Status: phase 12 of 12 — delivery. The site is built.** Fifty pages: ten marketing, legal and
|
**Status: phase 5 of 12 — the app and the beta.** The foundation, the branding pipeline, the
|
||||||
app pages and forty of documentation, with real screenshots of the product, full-text search, a
|
homepage and the five marketing pages are built, and `/app/` and `/beta/` now join them: a signed
|
||||||
per-page Content-Security-Policy, eleven checks that fail the build when the platform moves out from
|
APK beside the closed-test signup, backed by a SQLite store on a bind mount and an export CLI. Next
|
||||||
under a claim, and a closed-beta signup backed by SQLite on a bind mount. This phase is the part
|
are the legal pages (phase 6) and then the documentation — the installation path, which is the
|
||||||
that makes it a deployment rather than a repository — the container image, the compose file, the
|
priority of the whole project — in phases 7 and 8.
|
||||||
publishing workflow and [`DEPLOY.md`](DEPLOY.md).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -32,43 +31,21 @@ npm run build # → dist/ (prerendered pages + the Node server entry)
|
|||||||
npm start # serve the built site
|
npm start # serve the built site
|
||||||
```
|
```
|
||||||
|
|
||||||
Node 22 LTS or newer. Nothing else — no database, no game server, no container runtime.
|
Node 22 LTS or newer.
|
||||||
|
|
||||||
## 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
|
## The checks, and why they are not optional
|
||||||
|
|
||||||
Eleven of them, from `PLAN.md` §12. None is a linter; each one enforces a promise the site makes
|
Two of them, both from `PLAN.md` §12. Neither is a linter; each one enforces a promise the site
|
||||||
that would otherwise decay quietly.
|
makes that would otherwise decay quietly.
|
||||||
|
|
||||||
```bash
|
```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
|
npm run check:tokens # no colour literal outside the token file
|
||||||
npm run check:brand # the branding pipeline's two quiet failures
|
npm run check:brand # the branding pipeline's two quiet failures
|
||||||
npm run check:datasafety # the Play declaration still matches /privacy
|
npm run check:datasafety # the Play declaration still matches /privacy
|
||||||
npm run check # astro check
|
npm run check # astro check
|
||||||
npm test # the beta signup's decision path, and the policy data
|
npm test # the beta signup's decision path, and the policy data
|
||||||
npm run build # everything below reads the build
|
npm run build && npm run check:links # every internal link resolves (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: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
|
npm run verify # all of the above, in that order
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -104,22 +81,6 @@ carry are assembled from data files and template literals and a source scan sees
|
|||||||
also refuses a commit permalink into any org repository — those stop tracking the document they name
|
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.
|
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,
|
**`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
|
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
|
a build identical to one where it works. It covers the honeypot, the signed form token, the timing
|
||||||
@@ -186,40 +147,6 @@ 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
|
the emblem and the Cinzel outlines from the sibling checkouts in the workspace. Its output is
|
||||||
committed so that CI never needs either.
|
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
|
## Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -243,17 +170,12 @@ src/
|
|||||||
lib/betaSignup.mjs Everything between a POST body and a row. Never throws.
|
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.
|
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/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.
|
brand-default/ The stock brand, baked into the image and always complete.
|
||||||
scripts/ The build-time checks, plus applyBrand and serve (boot),
|
scripts/ The build-time checks, plus applyBrand (boot), brand:assets (manual)
|
||||||
brand:assets (manual) and beta.mjs (the tester-list CLI).
|
and beta.mjs (the tester-list CLI).
|
||||||
test/ node --test. The logic the other checks cannot see.
|
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
|
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.
|
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
|
Two directories are bind mounts at runtime and are **not** in the repository: `brand/` overrides
|
||||||
@@ -264,18 +186,13 @@ and §7.
|
|||||||
|
|
||||||
Branch from `main` (`feature/…`, `fix/…`, `docs/…`, `chore/…`) and use
|
Branch from `main` (`feature/…`, `fix/…`, `docs/…`, `chore/…`) and use
|
||||||
[Conventional Commits](https://www.conventionalcommits.org/). Run `npm run verify` before opening a
|
[Conventional Commits](https://www.conventionalcommits.org/). Run `npm run verify` before opening a
|
||||||
pull request. **[CONTRIBUTING.md](CONTRIBUTING.md)** has the rest, including the two rules from
|
pull request.
|
||||||
`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
|
**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
|
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
|
`Co-Authored-By: Claude <noreply@anthropic.com>`. Undisclosed AI-generated contributions may be
|
||||||
closed.
|
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
|
## Licence
|
||||||
|
|
||||||
GPL-3.0-or-later, in common with every repository in the organisation. See [LICENSE](LICENSE).
|
GPL-3.0-or-later, in common with every repository in the organisation. See [LICENSE](LICENSE).
|
||||||
|
|||||||
43
SECURITY.md
43
SECURITY.md
@@ -1,43 +0,0 @@
|
|||||||
# 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,7 +3,6 @@ import { defineConfig } from 'astro/config';
|
|||||||
import node from '@astrojs/node';
|
import node from '@astrojs/node';
|
||||||
import starlight from '@astrojs/starlight';
|
import starlight from '@astrojs/starlight';
|
||||||
|
|
||||||
import { inlineScriptHashes, inlineStyleHashes } from './src/config/cspHashes.mjs';
|
|
||||||
import { docsSidebar } from './src/config/sidebar.mjs';
|
import { docsSidebar } from './src/config/sidebar.mjs';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -21,72 +20,13 @@ import { docsSidebar } from './src/config/sidebar.mjs';
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
site: 'https://runicgateway.com',
|
site: 'https://runicgateway.com',
|
||||||
output: 'static',
|
output: 'static',
|
||||||
// `staticHeaders` is what turns §6's CSP from a promise into a response header (D48).
|
adapter: node({ mode: 'standalone' }),
|
||||||
// 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: {
|
build: {
|
||||||
// Directory-style URLs, so every link in prose can end in a slash and mean it.
|
// Directory-style URLs, so every link in prose can end in a slash and mean it.
|
||||||
format: 'directory',
|
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: [
|
integrations: [
|
||||||
starlight({
|
starlight({
|
||||||
title: 'Runic Gateway',
|
title: 'Runic Gateway',
|
||||||
@@ -109,9 +49,6 @@ export default defineConfig({
|
|||||||
// Starlight builds its own head, so the docs otherwise miss the brand stylesheet,
|
// Starlight builds its own head, so the docs otherwise miss the brand stylesheet,
|
||||||
// the manifest and the OG card entirely. See the component.
|
// the manifest and the OG card entirely. See the component.
|
||||||
Head: './src/components/DocsHead.astro',
|
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,
|
credits: false,
|
||||||
sidebar: docsSidebar,
|
sidebar: docsSidebar,
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
# 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"
|
|
||||||
1
package-lock.json
generated
1
package-lock.json
generated
@@ -15,7 +15,6 @@
|
|||||||
"@fontsource-variable/inter": "^5.3.0",
|
"@fontsource-variable/inter": "^5.3.0",
|
||||||
"astro": "^7.2.4",
|
"astro": "^7.2.4",
|
||||||
"better-sqlite3": "^12.11.1",
|
"better-sqlite3": "^12.11.1",
|
||||||
"pagefind": "^1.5.2",
|
|
||||||
"sharp": "^0.35.3"
|
"sharp": "^0.35.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
11
package.json
11
package.json
@@ -12,7 +12,7 @@
|
|||||||
"dev": "astro dev",
|
"dev": "astro dev",
|
||||||
"build": "astro build",
|
"build": "astro build",
|
||||||
"preview": "astro preview",
|
"preview": "astro preview",
|
||||||
"start": "node scripts/applyBrand.mjs && node scripts/serve.mjs",
|
"start": "node scripts/applyBrand.mjs && node ./dist/server/entry.mjs",
|
||||||
"check": "astro check",
|
"check": "astro check",
|
||||||
"check:facts": "node scripts/checkFacts.mjs",
|
"check:facts": "node scripts/checkFacts.mjs",
|
||||||
"check:tokens": "node scripts/checkTokens.mjs",
|
"check:tokens": "node scripts/checkTokens.mjs",
|
||||||
@@ -23,16 +23,12 @@
|
|||||||
"check:reference": "node scripts/checkReference.mjs",
|
"check:reference": "node scripts/checkReference.mjs",
|
||||||
"check:sidebar": "node scripts/checkSidebar.mjs",
|
"check:sidebar": "node scripts/checkSidebar.mjs",
|
||||||
"check:screens": "node scripts/checkScreens.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",
|
"play:datasafety": "node scripts/playDataSafety.mjs",
|
||||||
"beta": "node scripts/beta.mjs",
|
"beta": "node scripts/beta.mjs",
|
||||||
"test": "node --test test/beta.test.mjs test/legal.test.mjs test/footer.test.mjs",
|
"test": "node --test test/beta.test.mjs test/legal.test.mjs",
|
||||||
"brand:assets": "node scripts/buildBrandAssets.mjs",
|
"brand:assets": "node scripts/buildBrandAssets.mjs",
|
||||||
"screens:capture": "node scripts/captureScreens.mjs",
|
"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"
|
||||||
"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": {
|
"dependencies": {
|
||||||
"@astrojs/node": "^11.1.4",
|
"@astrojs/node": "^11.1.4",
|
||||||
@@ -41,7 +37,6 @@
|
|||||||
"@fontsource-variable/inter": "^5.3.0",
|
"@fontsource-variable/inter": "^5.3.0",
|
||||||
"astro": "^7.2.4",
|
"astro": "^7.2.4",
|
||||||
"better-sqlite3": "^12.11.1",
|
"better-sqlite3": "^12.11.1",
|
||||||
"pagefind": "^1.5.2",
|
|
||||||
"sharp": "^0.35.3"
|
"sharp": "^0.35.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 114 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 53 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 58 KiB |
@@ -229,71 +229,10 @@ const counts = new Map(replacements.map((r) => [r.field, 0]));
|
|||||||
counts.set('demoDeep', 0);
|
counts.set('demoDeep', 0);
|
||||||
let filesTouched = 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)) {
|
for (const file of walk(CLIENT)) {
|
||||||
const before = readFileSync(file, 'utf8');
|
const before = readFileSync(file, 'utf8');
|
||||||
let after = before;
|
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) {
|
for (const { field, from, to } of replacements) {
|
||||||
if (!after.includes(from)) continue;
|
if (!after.includes(from)) continue;
|
||||||
counts.set(field, counts.get(field) + after.split(from).length - 1);
|
counts.set(field, counts.get(field) + after.split(from).length - 1);
|
||||||
@@ -331,47 +270,6 @@ if (counts.get('demoDeep')) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (inlineCollisions.length) {
|
// Pagefind builds its search index from the HTML at BUILD time (phase 10), so a rename
|
||||||
console.error(
|
// applied here reaches the pages but not the search results. Worth fixing when search
|
||||||
`\n[brand] ${inlineCollisions.length} file(s) were LEFT UNCHANGED: a brand value occurs ` +
|
// lands; recorded here rather than in a plan section nobody will re-read.
|
||||||
`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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,278 +0,0 @@
|
|||||||
#!/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);
|
|
||||||
}
|
|
||||||
@@ -1,263 +0,0 @@
|
|||||||
#!/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,28 +60,8 @@ async function api(pathname) {
|
|||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const raw = async (repo, filePath, ref) =>
|
||||||
* A file's bytes, read through the `contents` endpoint rather than `raw`.
|
(await api(`${repo}/raw/${filePath}?ref=${encodeURIComponent(ref)}`)).text();
|
||||||
*
|
|
||||||
* `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();
|
const json = async (pathname) => (await api(pathname)).json();
|
||||||
|
|
||||||
|
|||||||
@@ -55,18 +55,12 @@ const checked = [];
|
|||||||
const ok = (what) => checked.push(what);
|
const ok = (what) => checked.push(what);
|
||||||
const fail = (what, detail) => failures.push({ what, detail });
|
const fail = (what, detail) => failures.push({ what, detail });
|
||||||
|
|
||||||
/** Same file accessor checkFacts.mjs uses, and for the same reason -- including the CDN one. */
|
/** Same raw-file accessor checkFacts.mjs uses, and for the same reason. */
|
||||||
async function raw(repo, filePath, ref) {
|
async function raw(repo, filePath, ref) {
|
||||||
const url = `${BASE}/api/v1/repos/${ORG}/${repo}/contents/${filePath}?ref=${encodeURIComponent(ref)}`;
|
const url = `${BASE}/api/v1/repos/${ORG}/${repo}/raw/${filePath}?ref=${encodeURIComponent(ref)}`;
|
||||||
const res = await fetch(url, { headers: { Authorization: `token ${TOKEN}` } });
|
const res = await fetch(url, { headers: { Authorization: `token ${TOKEN}` } });
|
||||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText} for ${url}`);
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText} for ${url}`);
|
||||||
const meta = await res.json();
|
return res.text();
|
||||||
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');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -53,18 +53,12 @@ const checked = [];
|
|||||||
const ok = (what) => checked.push(what);
|
const ok = (what) => checked.push(what);
|
||||||
const fail = (what, detail) => failures.push({ what, detail });
|
const fail = (what, detail) => failures.push({ what, detail });
|
||||||
|
|
||||||
/** Same file accessor checkFacts.mjs and checkQuickstart.mjs use, CDN caveat included. */
|
/** Same raw-file accessor checkFacts.mjs and checkQuickstart.mjs use. */
|
||||||
async function raw(repo, filePath, ref = 'main') {
|
async function raw(repo, filePath, ref = 'main') {
|
||||||
const url = `${BASE}/api/v1/repos/${ORG}/${repo}/contents/${filePath}?ref=${encodeURIComponent(ref)}`;
|
const url = `${BASE}/api/v1/repos/${ORG}/${repo}/raw/${filePath}?ref=${encodeURIComponent(ref)}`;
|
||||||
const res = await fetch(url, { headers: { Authorization: `token ${TOKEN}` } });
|
const res = await fetch(url, { headers: { Authorization: `token ${TOKEN}` } });
|
||||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText} for ${url}`);
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText} for ${url}`);
|
||||||
const meta = await res.json();
|
return res.text();
|
||||||
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');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
* UOLINK_BASE http://127.0.0.1:8080 sidecar REST, written to Admin → Shard
|
* 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_WS ws://127.0.0.1:8080/ws sidecar WebSocket
|
||||||
* UOLINK_TOKEN (unset) sidecar auth token; skipped when absent
|
* UOLINK_TOKEN (unset) sidecar auth token; skipped when absent
|
||||||
* UOLINK_PROTOCOL (platform.json) wire protocol to pin — see the note below
|
* UOLINK_PROTOCOL 4 wire protocol to pin — see the note below
|
||||||
*
|
*
|
||||||
* ---------------------------------------------------------------------------------------
|
* ---------------------------------------------------------------------------------------
|
||||||
* WHY THE SEED DRIVES THE API AND NEVER THE DATABASE
|
* WHY THE SEED DRIVES THE API AND NEVER THE DATABASE
|
||||||
@@ -45,8 +45,6 @@
|
|||||||
|
|
||||||
import { readFileSync } from 'node:fs';
|
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 BASE = (process.env.RG_BASE || 'http://localhost:3000').replace(/\/+$/, '');
|
||||||
const API = `${BASE}/api/v1`;
|
const API = `${BASE}/api/v1`;
|
||||||
const ADMIN_USER = process.env.RG_ADMIN_USER || 'demoadmin';
|
const ADMIN_USER = process.env.RG_ADMIN_USER || 'demoadmin';
|
||||||
@@ -55,21 +53,16 @@ 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_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_WS = process.env.UOLINK_WS || 'ws://127.0.0.1:8080/ws';
|
||||||
const UOLINK_TOKEN = process.env.UOLINK_TOKEN || '';
|
const UOLINK_TOKEN = process.env.UOLINK_TOKEN || '';
|
||||||
// The pinned wire protocol, read from `platform.json` rather than written down here.
|
// The pinned wire protocol has to be STATED, not left to the module's default.
|
||||||
//
|
//
|
||||||
// It was a literal `4` until the Asset Bridge cutover, with a note explaining that
|
// `module-uo`'s schema fragment still carries `protocol INT NOT NULL DEFAULT 3`, from the
|
||||||
// `module-uo` pinned 3 on a fresh install while the sidecar spoke 4, so a new deployment
|
// protocol-3 cutover; the sidecar on `link` `main` speaks 4. The module handles protocol 4's
|
||||||
// read nothing from its shard until somebody edited the number in Admin → Shard. That debt
|
// frames — `guild.roster` and `guild.leave` ingest landed with the Teams cutover — but a
|
||||||
// has since been paid: the module's schema fragment defaults the column to the protocol its
|
// FRESH install pins 3, and the sidecar answers a 3 with `409 protocol version mismatch` on
|
||||||
// build speaks and carries a one-shot migration per bump, so both a fresh install and an
|
// every REST call. So a new deployment reads nothing from its shard until somebody edits the
|
||||||
// upgraded one land on the right number by themselves.
|
// number in Admin → Shard. Raised with the org lead rather than patched from here: the fix
|
||||||
//
|
// belongs in `module-uo`, not in this repo's screenshot rig (PLAN.md §13 phase 9).
|
||||||
// What remains is the rig's own reason to state it: this seed points a demo deployment at a
|
const UOLINK_PROTOCOL = Number(process.env.UOLINK_PROTOCOL || 4);
|
||||||
// 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');
|
const DRY = process.argv.includes('--dry-run');
|
||||||
|
|
||||||
|
|||||||
@@ -1,204 +0,0 @@
|
|||||||
#!/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`);
|
|
||||||
});
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
---
|
|
||||||
/**
|
|
||||||
* 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,7 +1,6 @@
|
|||||||
---
|
---
|
||||||
import { renderBrand } from '../lib/brand.mjs';
|
import { brand } from '../lib/brand.mjs';
|
||||||
import { legal } from '../data/legal.mjs';
|
import { legal } from '../data/legal.mjs';
|
||||||
import { footerColumns } from '../data/footer.mjs';
|
|
||||||
import platform from '../data/platform.json';
|
import platform from '../data/platform.json';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -14,15 +13,35 @@ import platform from '../data/platform.json';
|
|||||||
* reader browses to alongside Features, it is a thing they go looking for, and the line
|
* 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.
|
* 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 year = new Date().getFullYear();
|
||||||
|
|
||||||
// The columns live in src/data/footer.mjs so a test can read them — see the note there,
|
const columns = [
|
||||||
// and test/footer.test.mjs. The two Project links come from the mounted brand (§7).
|
{
|
||||||
const columns = footerColumns(brand);
|
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' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const isExternal = (href: string) => href.startsWith('http');
|
const isExternal = (href: string) => href.startsWith('http');
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
---
|
---
|
||||||
import Search from './Search.astro';
|
import { brand } from '../lib/brand.mjs';
|
||||||
import { renderBrand } from '../lib/brand.mjs';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The marketing header. The docs get Starlight's own header, themed to match in
|
* The marketing header. The docs get Starlight's own header, themed to match in
|
||||||
@@ -21,10 +20,6 @@ import { renderBrand } from '../lib/brand.mjs';
|
|||||||
*/
|
*/
|
||||||
const { pathname } = Astro.url;
|
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 = [
|
const links = [
|
||||||
{ href: '/features/', label: 'Features' },
|
{ href: '/features/', label: 'Features' },
|
||||||
{ href: '/docs/', label: 'Docs' },
|
{ href: '/docs/', label: 'Docs' },
|
||||||
@@ -60,8 +55,6 @@ const isCurrent = (href: string) =>
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<Search />
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -1,279 +0,0 @@
|
|||||||
---
|
|
||||||
/**
|
|
||||||
* 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>
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
---
|
|
||||||
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 />
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 = [];
|
|
||||||
@@ -36,14 +36,10 @@ export const docsSidebar = [
|
|||||||
{ label: 'Users and roles', slug: 'docs/administration/users-and-roles' },
|
{ label: 'Users and roles', slug: 'docs/administration/users-and-roles' },
|
||||||
{ label: 'Authentication', slug: 'docs/administration/authentication' },
|
{ label: 'Authentication', slug: 'docs/administration/authentication' },
|
||||||
{ label: 'Teams', slug: 'docs/administration/teams' },
|
{ label: 'Teams', slug: 'docs/administration/teams' },
|
||||||
{ label: 'Scheduled events', slug: 'docs/administration/events' },
|
|
||||||
{ label: 'Moderation', slug: 'docs/administration/moderation' },
|
{ label: 'Moderation', slug: 'docs/administration/moderation' },
|
||||||
{ label: 'Notifications and email', slug: 'docs/administration/notifications-and-email' },
|
{ 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: 'Managing modules', slug: 'docs/administration/managing-modules' },
|
||||||
{ label: 'The shard connection', slug: 'docs/administration/the-shard-connection' },
|
{ 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: 'Maintenance and upgrades', slug: 'docs/administration/maintenance-and-upgrades' },
|
||||||
{ label: 'Troubleshooting', slug: 'docs/administration/troubleshooting' },
|
{ label: 'Troubleshooting', slug: 'docs/administration/troubleshooting' },
|
||||||
],
|
],
|
||||||
@@ -68,7 +64,6 @@ export const docsSidebar = [
|
|||||||
{ label: 'The bridge', slug: 'docs/architecture/the-bridge' },
|
{ label: 'The bridge', slug: 'docs/architecture/the-bridge' },
|
||||||
{ label: 'Authentication architecture', slug: 'docs/architecture/authentication-architecture' },
|
{ label: 'Authentication architecture', slug: 'docs/architecture/authentication-architecture' },
|
||||||
{ label: 'Teams architecture', slug: 'docs/architecture/teams-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: 'Protocol versions', slug: 'docs/architecture/protocol-versions' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -80,7 +75,7 @@ export const docsSidebar = [
|
|||||||
{ label: 'sidecar.toml', slug: 'docs/reference/sidecar-toml' },
|
{ label: 'sidecar.toml', slug: 'docs/reference/sidecar-toml' },
|
||||||
{ label: 'Bridge.cfg', slug: 'docs/reference/bridge-cfg' },
|
{ label: 'Bridge.cfg', slug: 'docs/reference/bridge-cfg' },
|
||||||
{ label: 'HTTP API', slug: 'docs/reference/http-api' },
|
{ label: 'HTTP API', slug: 'docs/reference/http-api' },
|
||||||
{ label: 'Shard event catalog', slug: 'docs/reference/event-catalog' },
|
{ label: 'Event catalog', slug: 'docs/reference/event-catalog' },
|
||||||
{ label: 'Canonical documents', slug: 'docs/reference/canonical-documents' },
|
{ label: 'Canonical documents', slug: 'docs/reference/canonical-documents' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -116,14 +111,10 @@ export const plannedSidebar = {
|
|||||||
'Users and roles',
|
'Users and roles',
|
||||||
'Authentication',
|
'Authentication',
|
||||||
'Teams',
|
'Teams',
|
||||||
'Scheduled events',
|
|
||||||
'Moderation',
|
'Moderation',
|
||||||
'Notifications and email',
|
'Notifications and email',
|
||||||
'Engagement rules',
|
|
||||||
'Message templates',
|
|
||||||
'Managing modules',
|
'Managing modules',
|
||||||
'The shard connection',
|
'The shard connection',
|
||||||
'Client files',
|
|
||||||
'Maintenance and upgrades',
|
'Maintenance and upgrades',
|
||||||
'Troubleshooting',
|
'Troubleshooting',
|
||||||
],
|
],
|
||||||
@@ -142,7 +133,6 @@ export const plannedSidebar = {
|
|||||||
'The bridge',
|
'The bridge',
|
||||||
'Authentication architecture',
|
'Authentication architecture',
|
||||||
'Teams architecture',
|
'Teams architecture',
|
||||||
'Events architecture',
|
|
||||||
'Protocol versions',
|
'Protocol versions',
|
||||||
],
|
],
|
||||||
Reference: [
|
Reference: [
|
||||||
@@ -151,7 +141,7 @@ export const plannedSidebar = {
|
|||||||
'sidecar.toml',
|
'sidecar.toml',
|
||||||
'Bridge.cfg',
|
'Bridge.cfg',
|
||||||
'HTTP API',
|
'HTTP API',
|
||||||
'Shard event catalog',
|
'Event catalog',
|
||||||
'Canonical documents',
|
'Canonical documents',
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,103 +0,0 @@
|
|||||||
---
|
|
||||||
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.
|
|
||||||
@@ -59,10 +59,9 @@ per-Team settings:
|
|||||||
## Email
|
## Email
|
||||||
|
|
||||||
Configured on the same screen and covered in
|
Configured on the same screen and covered in
|
||||||
[Notifications and email](/docs/administration/notifications-and-email/): pick a mail
|
[Notifications and email](/docs/administration/notifications-and-email/): it is Gmail over
|
||||||
transport, enter its host, port and credentials, and send a test. It depends on nothing
|
OAuth2, it reuses the Google authentication client, and it must be set up on the
|
||||||
else on the site — a relay is the recommended posture, a mailbox provider over SMTP the
|
[Authentication](/docs/administration/authentication/) page first.
|
||||||
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">
|
<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*
|
That is a deliberate fallback rather than a failure — but it does mean the *Contact email*
|
||||||
|
|||||||
@@ -1,175 +0,0 @@
|
|||||||
---
|
|
||||||
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.
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
---
|
|
||||||
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/).
|
|
||||||
@@ -116,13 +116,6 @@ when ServUO needs restarting — it never restarts your shard itself. Because it
|
|||||||
**bundle**, the sidecar and the plugin move together and cannot end up disagreeing about the
|
**bundle**, the sidecar and the plugin move together and cannot end up disagreeing about the
|
||||||
protocol.
|
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">
|
<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
|
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
|
rejected with a `409` rather than mis-parsed. So the worst case is a bridge that refuses to
|
||||||
|
|||||||
@@ -1,133 +0,0 @@
|
|||||||
---
|
|
||||||
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/).
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: Notifications and email
|
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.
|
description: Email over Gmail OAuth2, the announcement pipeline and its legs, the Discord bot, and opt-in push to the mobile app.
|
||||||
---
|
---
|
||||||
|
|
||||||
import { Aside } from '@astrojs/starlight/components';
|
import { Aside } from '@astrojs/starlight/components';
|
||||||
@@ -9,67 +9,21 @@ Four separate delivery paths, each optional, each off until you configure it. A
|
|||||||
configures none of them still works — it just never reaches anyone who is not looking at
|
configures none of them still works — it just never reaches anyone who is not looking at
|
||||||
it.
|
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
|
## Email
|
||||||
|
|
||||||
**Admin → Settings → Email delivery.** The site sends contact-form messages, invitations,
|
**Admin → Settings → Email delivery.** The site sends contact-form messages (and test
|
||||||
password resets, team notifications and test messages through **SMTP**. Contact-form mail
|
messages) through **Gmail over OAuth2**, delivered to the *Contact email* setting.
|
||||||
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
|
It reuses the **Google authentication client**, so the order is fixed: configure Google on
|
||||||
no redirect to bounce through — it is a form, and the credentials go straight into the
|
the [Authentication](/docs/administration/authentication/) page first, then press **Connect
|
||||||
database encrypted at rest, write-only: the panel will tell you a password is *set*, and
|
Gmail** here. Until then the panel reads *Unconfigured* and says exactly that.
|
||||||
will never show it to you again.
|
|
||||||
|
|
||||||
### Three ways to point it somewhere
|
The refresh token it stores is encrypted at rest like every other secret.
|
||||||
|
|
||||||
Any SMTP server works. Which one you should use depends on how much mail you expect to send.
|
<Aside type="note" title="There is no SMTP option">
|
||||||
|
Gmail over OAuth2 is the only supported delivery path today. Until it is connected, the
|
||||||
**A relay — the recommended one.** Mailgun, SES, Postmark or equivalent: their host, port
|
contact form falls back to a `mailto:` link to the contact address — which works, and puts
|
||||||
`587`, *Implicit TLS* **off**, and your API key as the password. Deliverability is the hard
|
the message in the visitor's own mail client rather than in your logs.
|
||||||
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>
|
</Aside>
|
||||||
|
|
||||||
## Announcements
|
## Announcements
|
||||||
@@ -121,45 +75,14 @@ Two properties matter for what you have to trust:
|
|||||||
Without `NTFY_PUBLIC_URL` / `NTFY_ALLOWED_ORIGINS`, the app simply shows push as
|
Without `NTFY_PUBLIC_URL` / `NTFY_ALLOWED_ORIGINS`, the app simply shows push as
|
||||||
unavailable for your instance — nothing breaks.
|
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
|
## Who receives what
|
||||||
|
|
||||||
The per-person side of this lives in the player portal, not the admin panel: **Account →
|
The per-person side of this lives in the player portal, not the admin panel: each member
|
||||||
Notifications → Settings** is a grid of every notification against every channel, and each
|
chooses which Team and forum notifications they want, and how. Two defaults are worth
|
||||||
member sets their own. The defaults are not symmetrical, and the asymmetry is deliberate:
|
knowing because they are not symmetrical:
|
||||||
|
|
||||||
|
- **Push is opt-out** once a device is registered.
|
||||||
- **Email is opt-in.**
|
- **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">
|
<Aside type="caution" title="Nothing here retries">
|
||||||
The announcement dispatcher sends once, and the Team notification bridge states plainly that
|
The announcement dispatcher sends once, and the Team notification bridge states plainly that
|
||||||
|
|||||||
@@ -48,52 +48,6 @@ Two rules are structural rather than settings:
|
|||||||
about a leader has to reach someone above them. See
|
about a leader has to reach someone above them. See
|
||||||
[Moderation](/docs/administration/moderation/).
|
[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
|
## The Discord bridges
|
||||||
|
|
||||||
Two integrations, both optional, both configured from **Admin → Teams**.
|
Two integrations, both optional, both configured from **Admin → Teams**.
|
||||||
|
|||||||
@@ -81,13 +81,6 @@ lines, and a duration in seconds. Re-posting the same id **replaces** that messa
|
|||||||
The id is the useful part — give a recurring announcement a stable one and you can update or
|
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.
|
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
|
## What reaches the public
|
||||||
|
|
||||||
Events from the shard fan out over two separate streams, and the split is a security
|
Events from the shard fan out over two separate streams, and the split is a security
|
||||||
|
|||||||
@@ -88,23 +88,6 @@ the tree and never compiles — and ServUO ignores the script build's exit code,
|
|||||||
looks clean. `doctor` catches it by comparing file hashes against the install record.
|
looks clean. `doctor` catches it by comparing file hashes against the install record.
|
||||||
</Aside>
|
</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
|
## Teams are missing
|
||||||
|
|
||||||
Check the sync panel on **Admin → Teams** before anything else: *last success: never* with
|
Check the sync panel on **Admin → Teams** before anything else: *last success: never* with
|
||||||
@@ -114,71 +97,14 @@ a Team. See [Teams](/docs/administration/teams/).
|
|||||||
|
|
||||||
## Email and announcements never arrive
|
## Email and announcements never arrive
|
||||||
|
|
||||||
- **The contact form opens a mail client.** Email delivery is not configured; that is the
|
- **The contact form opens a mail client.** Email delivery is not connected; that is the
|
||||||
documented fallback. Enter SMTP credentials in **Settings → Email delivery**. If this site
|
documented fallback. Connect Gmail in **Settings → Email delivery** — after configuring
|
||||||
used to send mail and stopped, the Gmail connect flow was removed — the admin dashboard
|
the Google provider, which it reuses.
|
||||||
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
|
- **A published post announced nothing.** The Discord bot is a separate container. If the
|
||||||
Discord Bot screen says *bot unreachable*, it is not running.
|
Discord Bot screen says *bot unreachable*, it is not running.
|
||||||
- **A missed announcement does not come back.** Nothing retries; the post itself is still
|
- **A missed announcement does not come back.** Nothing retries; the post itself is still
|
||||||
on the site.
|
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
|
## Uploads and modules fail with permission errors
|
||||||
|
|
||||||
Docker created a bind-mount source that the container user cannot write — usually because
|
Docker created a bind-mount source that the container user cannot write — usually because
|
||||||
|
|||||||
@@ -1,163 +0,0 @@
|
|||||||
---
|
|
||||||
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.
|
|
||||||
@@ -4,13 +4,12 @@ description: One number, declared in three repositories, that decides whether a
|
|||||||
---
|
---
|
||||||
|
|
||||||
import { Aside } from '@astrojs/starlight/components';
|
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
|
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,
|
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.
|
so the number is what stops a mismatch from being discovered as corrupted data.
|
||||||
|
|
||||||
The current protocol is **{platform.protocol}**.
|
The current protocol is **4**.
|
||||||
|
|
||||||
## Three declaration sites
|
## Three declaration sites
|
||||||
|
|
||||||
@@ -18,8 +17,8 @@ The same number is written down in three places, and they must move together.
|
|||||||
|
|
||||||
| Where | What declares it |
|
| Where | What declares it |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `link/sidecar/src/main.rs` | `PROTOCOL_VERSION`, currently {platform.protocol} — what the sidecar speaks |
|
| `link/sidecar/src/main.rs` | `pub const PROTOCOL_VERSION: u32 = 4` — what the sidecar speaks |
|
||||||
| `servuo-plugins/overlay.toml` | `protocol`, currently {platform.protocol} — what the plugin overlay speaks |
|
| `servuo-plugins/overlay.toml` | `protocol = 4` — what the plugin overlay speaks |
|
||||||
| The bundle manifest | Copied from `overlay.toml` by CI, so a released pair carries its own claim |
|
| 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">
|
<Aside type="caution" title="Bump the overlay in the same PR as the emitters">
|
||||||
@@ -45,35 +44,19 @@ allowed to be chosen independently.
|
|||||||
|
|
||||||
## What a bump obliges
|
## What a bump obliges
|
||||||
|
|
||||||
Changing a message shape means editing every side plus the specification. The most recent
|
Changing a message shape means editing every side plus the specification. A protocol-4
|
||||||
bump — **8**, which taught the bridge to carry a game's own client files — touched four
|
change touched:
|
||||||
repositories:
|
|
||||||
|
|
||||||
| Repository | What had to change |
|
| Repository | What had to change |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `servuo-plugins` | The extractors and the decoders they call, the switches and caps in `Bridge.cfg`, and `overlay.toml` |
|
| `servuo-plugins` | The emitters, the config keys, 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 |
|
| `link` | `PROTOCOL_VERSION`, a store migration, and the projections |
|
||||||
| `module-uo` | The importers, the admin screen, and the pages that render a picture |
|
| `module-uo` | The tables, the ingest, and the kind-to-feature map |
|
||||||
| `docs` | The protocol document and the integration guide |
|
| `docs` | The protocol document and the integration guide |
|
||||||
|
|
||||||
**`website` is not on that list, and its absence is the interesting part.** Core holds no
|
Note `link`'s entry: **a protocol bump can require a store migration**, because the sidecar
|
||||||
game connection and names no game noun, so most protocol bumps do not reach it at all. The
|
persists what it forwards. That is not automatic, and version 4 was the first bump that
|
||||||
one before this did, because what changed then was not a game *noun* but the shape of a
|
needed one.
|
||||||
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
|
## This is not the module API version
|
||||||
|
|
||||||
@@ -109,10 +92,8 @@ What is worth inheriting is the **shape**:
|
|||||||
|
|
||||||
## Canonical documents
|
## Canonical documents
|
||||||
|
|
||||||
[`link/v8.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v8.md)
|
[`link/v4.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v4.md)
|
||||||
is the current protocol's record, including its cross-repository obligations, and
|
is the protocol-4 record, including its cross-repository obligations;
|
||||||
[`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)
|
[`link/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md)
|
||||||
§7 is the wire protocol, and
|
§7 is the wire protocol, and
|
||||||
[`link/INTEGRATION.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/INTEGRATION.md)
|
[`link/INTEGRATION.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/INTEGRATION.md)
|
||||||
|
|||||||
@@ -114,10 +114,9 @@ lifecycle](/docs/modules/module-lifecycle/#failure-is-contained-by-construction)
|
|||||||
|
|
||||||
### Secrets are encrypted at rest
|
### Secrets are encrypted at rest
|
||||||
|
|
||||||
OAuth client secrets, the sidecar token, the Discord bot token and the mail transport's
|
OAuth client secrets, the sidecar token and the Gmail refresh token are AES-256-GCM
|
||||||
credentials are AES-256-GCM encrypted, keyed by `SECRET_ENC_KEY`. **The sidecar token and the
|
encrypted, keyed by `SECRET_ENC_KEY`. **The sidecar token is write-only in the API** — it is
|
||||||
mail credentials are write-only in the API** — neither is ever returned to any client; the
|
never returned to any client.
|
||||||
email panel reports only that a password is *set*.
|
|
||||||
|
|
||||||
<Aside type="caution" title="Rotating that key orphans every stored secret">
|
<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
|
Nothing re-encrypts. What was stored under the old key can no longer be read, and every
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ responsibility. The sidecar is a **dumb forwarder** — it makes no access-contr
|
|||||||
and holds no policy. Access control and the admin-toggleable visibility scope live on the
|
and holds no policy. Access control and the admin-toggleable visibility scope live on the
|
||||||
**website**, where an administrator can see and change them.
|
**website**, where an administrator can see and change them.
|
||||||
|
|
||||||
## Three ways in
|
## Two ways in
|
||||||
|
|
||||||
**Live events** arrive over an outbound **WebSocket** and are routed by the module's ingest
|
**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,
|
dispatcher. Kinds are handled differently by nature: state-changing kinds update tables,
|
||||||
@@ -77,23 +77,6 @@ than accumulating history.
|
|||||||
|
|
||||||
**Point-in-time reads and commands** go over **REST**, through a client that never throws.
|
**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
|
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
|
protocol mismatch fails fast with `409`** rather than being mis-parsed — see [Protocol
|
||||||
versions](/docs/architecture/protocol-versions/).
|
versions](/docs/architecture/protocol-versions/).
|
||||||
@@ -112,7 +95,7 @@ A representative line looks like:
|
|||||||
"price":75000,"commission":3750}
|
"price":75000,"commission":3750}
|
||||||
```
|
```
|
||||||
|
|
||||||
The full catalog is the [Shard event catalog](/docs/reference/event-catalog/).
|
The full catalog is [Event catalog](/docs/reference/event-catalog/).
|
||||||
|
|
||||||
## Two design details worth stealing
|
## Two design details worth stealing
|
||||||
|
|
||||||
|
|||||||
@@ -36,20 +36,11 @@ the [`uo` module](/docs/getting-started/install-a-game-module/) and the installe
|
|||||||
|---|---|
|
|---|---|
|
||||||
| **A working ServUO install** | It must currently boot and compile scripts cleanly. The installer deploys onto a healthy shard; it does not repair a broken one. |
|
| **A working ServUO install** | It must currently boot and compile scripts cleanly. The installer deploys onto a healthy shard; it does not repair a broken one. |
|
||||||
| **ServUO {platform.bundle.servuoMin}** *(patch tier only)* | The base install works on any reasonably current ServUO. The optional patch tier is written and tested against stock {platform.bundle.servuoMin}; on any other version it is unsupported, and skipping it still leaves you with a working bridge. |
|
| **ServUO {platform.bundle.servuoMin}** *(patch tier only)* | The base install works on any reasonably current ServUO. The optional patch tier is written and tested against stock {platform.bundle.servuoMin}; on any other version it is unsupported, and skipping it still leaves you with a working bridge. |
|
||||||
| **`libgdiplus`** *(Linux shard hosts only)* | Only needed for **artwork**. ServUO runs under Mono on Linux, and the library it decodes sprites with is a thin layer over this one — in the decode path, not merely the encode. Without it creature portraits and item pictures stay empty, and names and the spawn atlas are unaffected because neither touches a pixel. `sudo apt-get install libgdiplus`; `doctor` checks for it. **Windows hosts need nothing.** |
|
|
||||||
| **The shard stopped** | `ServUO.exe` locks `Scripts.dll` and rewrites `Saves/` on exit. The installer refuses to deploy under a running shard. |
|
| **The shard stopped** | `ServUO.exe` locks `Scripts.dll` and rewrites `Saves/` on exit. The installer refuses to deploy under a running shard. |
|
||||||
| **Administrator / root** | It writes into system directories and registers a service. |
|
| **Administrator / root** | It writes into system directories and registers a service. |
|
||||||
| **Outbound HTTPS** | To fetch the bundle and its two artifacts. No Gitea account and no git client are needed. |
|
| **Outbound HTTPS** | To fetch the bundle and its two artifacts. No Gitea account and no git client are needed. |
|
||||||
| **The sidecar on the same host as the shard** | The shard connects to `127.0.0.1:7788`. Splitting them is not supported — that loopback socket *is* the trust boundary for inbound commands. |
|
| **The sidecar on the same host as the shard** | The shard connects to `127.0.0.1:7788`. Splitting them is not supported — that loopback socket *is* the trust boundary for inbound commands. |
|
||||||
|
|
||||||
<Aside type="note" title="You do not need to install a game client for this">
|
|
||||||
You already have one. A ServUO shard cannot boot without a UO client — it resolves one at
|
|
||||||
startup to read the world's own data — so the artwork, the animations and the name table
|
|
||||||
the site shows are already sitting on that host. The shard reads them there and hands the
|
|
||||||
results over the bridge; nothing is converted on a desktop and nothing is uploaded. See
|
|
||||||
[Client files](/docs/administration/client-files/).
|
|
||||||
</Aside>
|
|
||||||
|
|
||||||
<Aside type="caution" title="Back up before the shard install">
|
<Aside type="caution" title="Back up before the shard install">
|
||||||
The overlay overwrites `Scripts/Scripts.csproj`, a stock file, and the optional patch tier
|
The overlay overwrites `Scripts/Scripts.csproj`, a stock file, and the optional patch tier
|
||||||
edits stock sources. A copy of `Scripts/` and `Config/` costs nothing and is the difference
|
edits stock sources. A copy of `Scripts/` and `Config/` costs nothing and is the difference
|
||||||
|
|||||||
@@ -86,18 +86,11 @@ So verify each link in the chain, in order. Each check tells you which one to fi
|
|||||||
|
|
||||||
<Aside type="note" title="`runicgateway doctor` answers most of this in one command">
|
<Aside type="note" title="`runicgateway doctor` answers most of this in one command">
|
||||||
Run on the shard host, it checks the install record, the ServUO tree, every overlay file
|
Run on the shard host, it checks the install record, the ServUO tree, every overlay file
|
||||||
hash, the patch tier, the sidecar, its service, `/health`, that the sidecar and overlay
|
hash, the patch tier, the sidecar, its service, `/health`, and that the sidecar and overlay
|
||||||
agree on a protocol, and — on Linux — that the host can decode an image at all. Its output is the first thing anyone helping you will ask for. It
|
agree on a protocol. Its output is the first thing anyone helping you will ask for. It
|
||||||
exits non-zero when a check failed, so a monitoring system can run it too.
|
exits non-zero when a check failed, so a monitoring system can run it too.
|
||||||
</Aside>
|
</Aside>
|
||||||
|
|
||||||
<Aside type="note" title="A bridge can be green and still show no artwork">
|
|
||||||
Creature portraits and item pictures are a separate switch and a separate import, so a
|
|
||||||
perfectly healthy bridge shows a bestiary of text until somebody presses *Update* on
|
|
||||||
**Admin → Client files**. That is the expected first-run state, not a fault — see [Client
|
|
||||||
files](/docs/administration/client-files/).
|
|
||||||
</Aside>
|
|
||||||
|
|
||||||
## What "working" looks like a week later
|
## What "working" looks like a week later
|
||||||
|
|
||||||
- The public shard page shows live status, and the admin dashboard shows events arriving.
|
- The public shard page shows live status, and the admin dashboard shows events arriving.
|
||||||
|
|||||||
@@ -43,9 +43,8 @@ They meet at four values pasted into the module's shard screen, and at protocol
|
|||||||
{platform.protocol}, which both sides check before they will pair.
|
{platform.protocol}, which both sides check before they will pair.
|
||||||
|
|
||||||
You can stop after the first one. A site with no game server attached is a complete
|
You can stop after the first one. A site with no game server attached is a complete
|
||||||
community website — news, wiki, pages, Teams, forums, accounts, moderation and the event
|
community website — news, wiki, pages, Teams, forums, accounts and moderation are all core,
|
||||||
calendar are all core, and none of them knows a game exists. The second install is what
|
and none of them knows a game exists. The second install is what fills the game screens.
|
||||||
fills the game screens, and what lets an event reach into a world.
|
|
||||||
|
|
||||||
## Start here
|
## Start here
|
||||||
|
|
||||||
@@ -60,8 +59,8 @@ you should expect to see before you move on:
|
|||||||
6. [Verify the whole stack](/docs/getting-started/verify-the-whole-stack/) — proving it works, rather than assuming
|
6. [Verify the whole stack](/docs/getting-started/verify-the-whole-stack/) — proving it works, rather than assuming
|
||||||
|
|
||||||
Then **Administration** covers running it: configuration, branding, content, users,
|
Then **Administration** covers running it: configuration, branding, content, users,
|
||||||
authentication, Teams, scheduled events, moderation, notifications, modules, the shard
|
authentication, Teams, moderation, notifications, modules, the shard connection, upgrades,
|
||||||
connection, upgrades, and what to do when something is wrong.
|
and what to do when something is wrong.
|
||||||
|
|
||||||
## Where the truth lives
|
## Where the truth lives
|
||||||
|
|
||||||
|
|||||||
@@ -45,11 +45,7 @@ moved turns this page red rather than leaving a dead link.
|
|||||||
- **"Why is the module system like this?"** → `MODULE_SYSTEM.md`.
|
- **"Why is the module system like this?"** → `MODULE_SYSTEM.md`.
|
||||||
- **"What does this API return?"** → your own deployment's `/api/docs`, then
|
- **"What does this API return?"** → your own deployment's `/api/docs`, then
|
||||||
`BACKEND_DESIGN.md` §4.
|
`BACKEND_DESIGN.md` §4.
|
||||||
- **"What can the shard send?"** → `link/PLAN.md` §5, and `v8.md` for the current
|
- **"What can the shard send?"** → `link/PLAN.md` §5, and `v4.md` for the current protocol.
|
||||||
protocol — which is also where the asset plane is specified.
|
|
||||||
- **"What may a scheduled event do to the world?"** → `website/EVENTS.md` for the model,
|
|
||||||
`MODULE_API.md` for the verbs a module may declare, and `link/ADMIN_CONTROLS.md` for what
|
|
||||||
the site may ask a game to do at all.
|
|
||||||
- **"Who may see this?"** → `SHARD_VISIBILITY.md` for the administrator's view,
|
- **"Who may see this?"** → `SHARD_VISIBILITY.md` for the administrator's view,
|
||||||
`modules/uo/API.md` §4 for the specification.
|
`modules/uo/API.md` §4 for the specification.
|
||||||
- **"How do I set a shard up?"** → `installer/INSTALL.md`.
|
- **"How do I set a shard up?"** → `installer/INSTALL.md`.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
title: Shard event catalog
|
title: Event catalog
|
||||||
description: What a game server can tell the website, how those events are grouped, and the five-rung ladder that decides who may see each one.
|
description: What a game server can tell the website, how those events are grouped, and the five-rung ladder that decides who may see each one.
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -8,12 +8,6 @@ import { visibilityLadder } from '../../../../data/reference.mjs';
|
|||||||
|
|
||||||
The events a shard emits, and the mechanism that decides who may see them.
|
The events a shard emits, and the mechanism that decides who may see them.
|
||||||
|
|
||||||
**Not to be confused with a scheduled event.** This page is about what the game tells the
|
|
||||||
site, unprompted, as things happen in the world. An *event* in the sense of a thing you put
|
|
||||||
on the calendar and run — phases, steps, a boss at eight o'clock — is
|
|
||||||
[Scheduled events](/docs/administration/events/). The two do meet: a kind listed below is
|
|
||||||
exactly what a scheduled event's phase can wait for.
|
|
||||||
|
|
||||||
The exact wire shapes are in the protocol specification and are **not** restated here — a
|
The exact wire shapes are in the protocol specification and are **not** restated here — a
|
||||||
copy of a wire format is a copy that will be wrong after the next bump. This page is the map
|
copy of a wire format is a copy that will be wrong after the next bump. This page is the map
|
||||||
and the security model.
|
and the security model.
|
||||||
|
|||||||
@@ -139,19 +139,9 @@ const community = {
|
|||||||
{
|
{
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
detail:
|
detail:
|
||||||
'On the site, by push and by email, chosen per notification by each person rather ' +
|
'Web, push and email, chosen per stream by each person rather than per person by ' +
|
||||||
'than per person by you. The on-site inbox arrives by default and can be switched ' +
|
'you. Push arrives by default and can be switched off; email only ever arrives if ' +
|
||||||
'off; push and email only ever arrive if they were asked for.',
|
'it was asked for.',
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Event calendar',
|
|
||||||
demoPath: '/site/events',
|
|
||||||
detail:
|
|
||||||
'What is scheduled, what is happening now, what finished recently, and the ' +
|
|
||||||
'results afterwards — with arcs, so a three-part story reads as one thing rather ' +
|
|
||||||
'than three unrelated entries. A run that was cancelled says so instead of ' +
|
|
||||||
'quietly vanishing. Core owns the whole calendar and can run an event on its ' +
|
|
||||||
'own; what an event may do inside a game world comes from the installed module.',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Wiki',
|
label: 'Wiki',
|
||||||
@@ -232,8 +222,7 @@ const gameIntelligence = {
|
|||||||
detail:
|
detail:
|
||||||
'Every player vendor on the server and what is on it, searchable without logging ' +
|
'Every player vendor on the server and what is on it, searchable without logging ' +
|
||||||
'in to the game. Item names arrive from the world as numeric ids and are resolved ' +
|
'in to the game. Item names arrive from the world as numeric ids and are resolved ' +
|
||||||
"against the game's own string table, so they read as names rather than numbers — " +
|
"against the game's own string table, so they read as names rather than numbers.",
|
||||||
"beside the item's own picture, in the colour it was dyed.",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Houses and IDOC decay',
|
label: 'Houses and IDOC decay',
|
||||||
@@ -250,8 +239,7 @@ const gameIntelligence = {
|
|||||||
detail:
|
detail:
|
||||||
"A bestiary and spawn map built by reading your shard's own spawn tables, so it " +
|
"A bestiary and spawn map built by reading your shard's own spawn tables, so it " +
|
||||||
"describes your server rather than someone else's idea of the game. Regions, " +
|
"describes your server rather than someone else's idea of the game. Regions, " +
|
||||||
'landmarks and champion altars come with it, and each creature is shown as the ' +
|
'landmarks and champion altars come with it.',
|
||||||
'artwork your own client draws it with.',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Champion boards',
|
label: 'Champion boards',
|
||||||
@@ -318,17 +306,6 @@ const administration = {
|
|||||||
'view with an emergency unban, deliberately — it is not somewhere to tune a ' +
|
'view with an emergency unban, deliberately — it is not somewhere to tune a ' +
|
||||||
'threshold at three in the morning.',
|
'threshold at three in the morning.',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: 'Scheduled world events',
|
|
||||||
detail:
|
|
||||||
'Author an event as phases and steps, publish a version, put it on the calendar ' +
|
|
||||||
'and let it run unattended — with a dry run first that prices the whole plan ' +
|
|
||||||
'against this deployment’s caps. Every action arrives switched off, every run ' +
|
|
||||||
'has a per-run budget enforced in the database rather than in a role check, and ' +
|
|
||||||
'everything an event creates or borrows is written to a ledger so the undo is ' +
|
|
||||||
'generated rather than authored. Pause, resume, skip a step or cancel with ' +
|
|
||||||
'cleanup, all logged with the person who did it.',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: 'Module management',
|
label: 'Module management',
|
||||||
detail:
|
detail:
|
||||||
|
|||||||
@@ -285,10 +285,9 @@ export const collected = [
|
|||||||
title: 'Everything you read and post in the app',
|
title: 'Everything you read and post in the app',
|
||||||
body:
|
body:
|
||||||
'Forum posts, Team activity, character and shard information, notification ' +
|
'Forum posts, Team activity, character and shard information, notification ' +
|
||||||
'preferences: all of it is a live read or write against the deployment. Apart from ' +
|
'preferences: all of it is a live read or write against the deployment. Nothing is ' +
|
||||||
'the notification snapshot described in the next entry, nothing is cached for ' +
|
'cached for offline use and nothing is duplicated anywhere else — the app with no ' +
|
||||||
'offline use and nothing is duplicated anywhere else — the app with no signal is ' +
|
'signal is an app with no content, which is a limitation and also an accurate ' +
|
||||||
'an app with almost no content, which is a limitation and also an accurate ' +
|
|
||||||
'description of where the data lives.',
|
'description of where the data lives.',
|
||||||
retention: {
|
retention: {
|
||||||
summary: 'Held by the deployment, under its operator’s policy',
|
summary: 'Held by the deployment, under its operator’s policy',
|
||||||
@@ -305,40 +304,6 @@ export const collected = [
|
|||||||
'access and no way to obtain one.',
|
'access and no way to obtain one.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'app-inbox-cache',
|
|
||||||
scope: 'app',
|
|
||||||
title: 'A snapshot of your notifications, so the inbox opens without a signal',
|
|
||||||
body:
|
|
||||||
'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.',
|
|
||||||
retention: {
|
|
||||||
summary: 'Until you sign out, or the thirty are pushed out by newer ones',
|
|
||||||
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.',
|
|
||||||
},
|
|
||||||
source: 'core/inbox/DataStoreInboxCache.kt, data/repository/AuthRepository.kt',
|
|
||||||
play: {
|
|
||||||
category: 'Messages',
|
|
||||||
type: 'Other in-app messages',
|
|
||||||
collected: false,
|
|
||||||
shared: false,
|
|
||||||
answer: 'Not collected by us. Stored on the device only.',
|
|
||||||
because:
|
|
||||||
'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.',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'app-no-analytics',
|
id: 'app-no-analytics',
|
||||||
scope: 'app',
|
scope: 'app',
|
||||||
@@ -436,70 +401,6 @@ export const collected = [
|
|||||||
},
|
},
|
||||||
source: 'website server/db/schema.sql — team_forum_*, mod_actions, content_reports',
|
source: 'website server/db/schema.sql — team_forum_*, mod_actions, content_reports',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'deploy-engagement',
|
|
||||||
scope: 'deployment',
|
|
||||||
title: 'Notifications, and the record of what was sent',
|
|
||||||
body:
|
|
||||||
'An operator can have the site notify people about things that happen on it — on ' +
|
|
||||||
'the site, by email, by push — so an address is now used for more than getting ' +
|
|
||||||
'into an account. Each member chooses this per notification and per channel, and ' +
|
|
||||||
'email and push are both off until they ask for them. Alongside that the site ' +
|
|
||||||
'keeps a delivery log: what fired, which account, which channel, whether it ' +
|
|
||||||
'arrived, and a one-way hash of the address rather than the address. Addresses ' +
|
|
||||||
'that bounce or are reported as spam go on a suppression list, which stores the ' +
|
|
||||||
'same hash plus a masked form (`d***@example.com`, never the local part) so an ' +
|
|
||||||
'operator can see what was suppressed without the list becoming a second address ' +
|
|
||||||
'book.',
|
|
||||||
retention: {
|
|
||||||
summary:
|
|
||||||
'The delivery log is kept for a period the operator sets (180 days by default) and then ' +
|
|
||||||
'swept; the suppression list does not expire',
|
|
||||||
detail:
|
|
||||||
'A nightly sweep removes delivery-log entries, finished items from the send queue and the ' +
|
|
||||||
'per-person rate-limit rows once they pass the horizon the operator has set for each — ' +
|
|
||||||
'the defaults are 180 days for the log and 30 for the other two. Two things deliberately ' +
|
|
||||||
'do not expire. An item still waiting to be sent is never swept however old it is, ' +
|
|
||||||
'because it is a message the site still intends to deliver. And the suppression list is ' +
|
|
||||||
'permanent by design: it records a standing decision to stop mailing an address, and ' +
|
|
||||||
'ageing an entry out would mean mailing an address that already bounced or asked to be ' +
|
|
||||||
'left alone. An operator can remove an entry from it deliberately, one at a time. ' +
|
|
||||||
'Deleting an account detaches its rows from it rather than deleting them — a delivery ' +
|
|
||||||
'history stops naming a person, and a suppressed address stays suppressed.',
|
|
||||||
},
|
|
||||||
source:
|
|
||||||
'website server/db/schema.sql — engagement_sends, engagement_suppressions, ' +
|
|
||||||
'notification_channel_prefs; server/src/utils/engagementRetentionPrune.js',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'deploy-events',
|
|
||||||
scope: 'deployment',
|
|
||||||
title: 'Who took part in a scheduled event',
|
|
||||||
body:
|
|
||||||
'Where the operator runs scheduled events, a run can count who took part — kept as ' +
|
|
||||||
'the name the game module knows a participant by, a score, and a rank, linked to a ' +
|
|
||||||
'site account where one is linked and left unlinked where it is not. That is what ' +
|
|
||||||
'the published results table renders, and what a signed-in person sees as their own ' +
|
|
||||||
'event history. Beside it the site records what each run did: which step ran, what ' +
|
|
||||||
'it created or borrowed in the game world, whether the undo succeeded, and which ' +
|
|
||||||
'staff account started, paused or cancelled it.',
|
|
||||||
retention: {
|
|
||||||
summary:
|
|
||||||
"A run's diagnostic log is swept after 90 days; the run itself and its participants " +
|
|
||||||
'are kept until the operator removes them',
|
|
||||||
detail:
|
|
||||||
'The log that answers "why did this run stall" is deleted 90 days after a run ' +
|
|
||||||
'reaches a terminal state, and only then — a run still in flight keeps every line ' +
|
|
||||||
'it has, however old, because the question it answers is still open. The run, its ' +
|
|
||||||
'steps, what it created and its participant list are not swept: they are the ' +
|
|
||||||
'record of what was done to a shared world, and deleting one silently would ' +
|
|
||||||
'unmake an audit. Deleting an account detaches its participation rows rather than ' +
|
|
||||||
'removing them — the result table keeps the score and stops naming a person.',
|
|
||||||
},
|
|
||||||
source:
|
|
||||||
'website server/db/schema.sql — event_runs, event_run_participants, ' +
|
|
||||||
'event_run_resources, event_run_log; server/src/utils/eventRunner.js',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'deploy-game-data',
|
id: 'deploy-game-data',
|
||||||
scope: 'deployment',
|
scope: 'deployment',
|
||||||
@@ -510,7 +411,7 @@ export const collected = [
|
|||||||
'Which of it is visible to the public is the operator’s decision, made in the ' +
|
'Which of it is visible to the public is the operator’s decision, made in the ' +
|
||||||
'admin panel — the bridge itself forwards, and the site decides.',
|
'admin panel — the bridge itself forwards, and the site decides.',
|
||||||
retention: { summary: 'Operator-configured' },
|
retention: { summary: 'Operator-configured' },
|
||||||
source: 'docs/link/v7.md — the visibility framework',
|
source: 'docs/link/v4.md — the visibility framework',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
/**
|
|
||||||
* The footer's link columns.
|
|
||||||
*
|
|
||||||
* Data rather than markup, and in `src/data/` beside `legal.mjs` and `collection.mjs`, for
|
|
||||||
* one reason: the links shipped wrong. All three entries under "Documentation" pointed at
|
|
||||||
* `/docs/`, so the column rendered three different labels that went to the same page — and
|
|
||||||
* every check passed, because each href resolved perfectly well. `checkLinks.mjs` asks
|
|
||||||
* whether a link is broken; nothing asked whether a link goes where its label says.
|
|
||||||
*
|
|
||||||
* `test/footer.test.mjs` asks that now, which it can only do because the columns are
|
|
||||||
* importable. That is the whole reason this file exists.
|
|
||||||
*
|
|
||||||
* The two Project links are brand-supplied (§7, D13), so this is a function of the rendered
|
|
||||||
* brand rather than a constant — the mounted `brand.json` decides them, and `/beta` renders
|
|
||||||
* per request, so they cannot be baked at build time.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Documentation sections a footer link may point into, and where each one starts. */
|
|
||||||
export const docsEntryPoints = {
|
|
||||||
'getting-started': '/docs/getting-started/requirements/',
|
|
||||||
administration: '/docs/administration/configuration/',
|
|
||||||
modules: '/docs/modules/building-a-module/',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function footerColumns(brand) {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
heading: 'Product',
|
|
||||||
links: [
|
|
||||||
{ href: '/features/', label: 'Features' },
|
|
||||||
{ href: '/architecture/', label: 'Architecture' },
|
|
||||||
{ href: '/modules/', label: 'Modules' },
|
|
||||||
{ href: '/app/', label: 'Android app' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// Each of these lands INSIDE the section it names. The docs home — "What is Runic
|
|
||||||
// Gateway?", the first page of Getting started — is the header's `Docs` link, so a
|
|
||||||
// footer entry pointing there as well would be a fourth way to the same page rather
|
|
||||||
// than a way into the section.
|
|
||||||
heading: 'Documentation',
|
|
||||||
links: [
|
|
||||||
{ href: docsEntryPoints['getting-started'], label: 'Getting started' },
|
|
||||||
{ href: docsEntryPoints.administration, label: 'Administration' },
|
|
||||||
{ href: docsEntryPoints.modules, label: 'Building a module' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
heading: 'Project',
|
|
||||||
links: [
|
|
||||||
{ href: brand.giteaOrg, label: 'Source' },
|
|
||||||
{ href: brand.discordInvite, label: 'Discord' },
|
|
||||||
{ href: '/community/', label: 'Community' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -29,7 +29,7 @@ export const legal = {
|
|||||||
* from git: a build timestamp would move on every rebuild and tell a reader nothing,
|
* from git: a build timestamp would move on every rebuild and tell a reader nothing,
|
||||||
* and a commit date would move when a stylesheet changed.
|
* and a commit date would move when a stylesheet changed.
|
||||||
*/
|
*/
|
||||||
lastUpdated: '2026-09-01',
|
lastUpdated: '2026-08-24',
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The minimum age to sign up for the beta. The org lead's decision, 2026-08-24 (D31).
|
* The minimum age to sign up for the beta. The org lead's decision, 2026-08-24 (D31).
|
||||||
|
|||||||
@@ -12,23 +12,23 @@
|
|||||||
"wrong protocol number in the first place."
|
"wrong protocol number in the first place."
|
||||||
],
|
],
|
||||||
|
|
||||||
"verifiedOn": "2026-09-15",
|
"verifiedOn": "2026-08-19",
|
||||||
|
|
||||||
"protocol": 8,
|
"protocol": 4,
|
||||||
|
|
||||||
"moduleApi": "1.10.0",
|
"moduleApi": "1.6.0",
|
||||||
|
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"tag": "2026.09.15",
|
"tag": "2026.08.19",
|
||||||
"sidecar": "v2.3.0",
|
"sidecar": "v2.0.0",
|
||||||
"overlay": "v1.3.0",
|
"overlay": "v1.0.0",
|
||||||
"servuoMin": "57.4"
|
"servuoMin": "57.4"
|
||||||
},
|
},
|
||||||
|
|
||||||
"releases": {
|
"releases": {
|
||||||
"link": "v2.3.0",
|
"link": "v2.0.0",
|
||||||
"installer": "v0.2.0",
|
"installer": "v0.1.1",
|
||||||
"Module-uo": "v1.3.0",
|
"Module-uo": "v1.0.2",
|
||||||
"Android-app": "v0.5.0"
|
"Android-app": "v0.5.0"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -151,49 +151,6 @@ export const bridgeCfg = {
|
|||||||
AdminReasonMaxLength: 'Reason field cap',
|
AdminReasonMaxLength: 'Reason field cap',
|
||||||
AdminBanMaxDurationSec: 'Longest ban the site may set',
|
AdminBanMaxDurationSec: 'Longest ban the site may set',
|
||||||
},
|
},
|
||||||
'Scheduled events': {
|
|
||||||
EventsEnabled:
|
|
||||||
'Whether the website may run scheduled events against this world at all. Off by ' +
|
|
||||||
'default, and deliberately a separate switch from AdminWriteEnabled',
|
|
||||||
EventsSweepSeconds: 'How often expired gates are collected and lost objects pruned',
|
|
||||||
EventsMinSaveIntervalSec:
|
|
||||||
'Shortest gap between world saves, counted from the last save by anyone. A save ' +
|
|
||||||
'asked for too soon is refused rather than queued',
|
|
||||||
},
|
|
||||||
'Event caps': {
|
|
||||||
EventsMaxCreatures: 'Creatures one call may spawn',
|
|
||||||
EventsMaxBosses: 'Enhanced "boss" variants one call may spawn',
|
|
||||||
EventsMaxNpcs: 'Oracle NPCs one call may place',
|
|
||||||
EventsMaxDecor: 'Decoration items one call may place',
|
|
||||||
EventsMaxGateMinutes: 'Longest a temporary gate may stand',
|
|
||||||
EventsMaxOwnedPerRun: 'Objects one run may own across every verb — the runaway bound',
|
|
||||||
EventsMaxSpread: 'How far from the chosen spot things may be scattered',
|
|
||||||
EventsMaxBossMultiplier: 'How much harder than normal a boss may be made',
|
|
||||||
EventsMaxGrantPerRun: 'How many characters one item grant may reach',
|
|
||||||
EventsMaxGrantStack: 'How large one granted stack may be',
|
|
||||||
},
|
|
||||||
'The oracle NPC': {
|
|
||||||
EventsOracleMaxLines: 'Keyword lines it will answer to',
|
|
||||||
EventsOracleGreetRange: 'How close a player must be to be greeted',
|
|
||||||
EventsOracleSpeechRange: 'How close a player must be to be heard',
|
|
||||||
EventsOracleGreetCooldownSec: 'How often it greets the same player',
|
|
||||||
EventsOracleAnswerCooldownSec: 'How often it answers the same player',
|
|
||||||
},
|
|
||||||
Leases: {
|
|
||||||
LeaseMaxDurationSec:
|
|
||||||
'Longest lease this shard will hold, whatever the site asks for. A longer request ' +
|
|
||||||
'is refused rather than shortened',
|
|
||||||
LeaseGraceSec: 'How long a restored lease stays listed, so a late teardown still gets a verdict',
|
|
||||||
},
|
|
||||||
Participation: {
|
|
||||||
ParticipationSweepSeconds: "How often everyone standing in a run's area is credited",
|
|
||||||
ParticipationKillWeight: 'What one kill inside the area is worth against one minute in it',
|
|
||||||
ParticipationMaxRuns: 'Runs counted at once',
|
|
||||||
ParticipationMaxMembers: 'Members counted per run',
|
|
||||||
ParticipationMaxRadius: 'Widest area an event may declare',
|
|
||||||
ParticipationGraceSec: "How long a closed run's tally stays readable",
|
|
||||||
ParticipationSnapshotChunk: "Members resolved per yield of the game's core thread",
|
|
||||||
},
|
|
||||||
Accounts: {
|
Accounts: {
|
||||||
SignupMode: 'How game accounts may be created',
|
SignupMode: 'How game accounts may be created',
|
||||||
AccountCreateEnabled: 'Allow creation at all',
|
AccountCreateEnabled: 'Allow creation at all',
|
||||||
@@ -201,33 +158,6 @@ export const bridgeCfg = {
|
|||||||
AccountNameMaxLength: 'Account name cap',
|
AccountNameMaxLength: 'Account name cap',
|
||||||
AccountPasswordMaxLength: 'Account password cap',
|
AccountPasswordMaxLength: 'Account password cap',
|
||||||
},
|
},
|
||||||
'Client assets': {
|
|
||||||
AssetsEnabled:
|
|
||||||
'Whether the website may read the UO client files on this host \u2014 art, animations, ' +
|
|
||||||
'the string table \u2014 over the link at all. Its own switch, because it is its own consent',
|
|
||||||
AssetBatchBytes:
|
|
||||||
'Byte budget for one reply page, inside the 1 MiB line cap the sidecar accepts',
|
|
||||||
AssetBodyBatch:
|
|
||||||
'Bodies one catalogue request may name. Counted in items rather than bytes, because ' +
|
|
||||||
'what it bounds is building and deleting that many real mobiles on the core thread. ' +
|
|
||||||
'A larger request is refused, never truncated',
|
|
||||||
AssetFetchKeys:
|
|
||||||
'How many keys one fetch may name. The byte budget above still decides where a page is cut',
|
|
||||||
AssetScanMs: 'How long a catalogue scan may run before the page it has is returned',
|
|
||||||
AssetPlayerDirection:
|
|
||||||
'Which of the five directions a player body renders as. 0 is head-on, facing the viewer',
|
|
||||||
AssetCreatureDirection:
|
|
||||||
'The same for everything else. 1 is the front three-quarter \u2014 a wolf seen head-on ' +
|
|
||||||
'is a dark blob',
|
|
||||||
},
|
|
||||||
'Spawn files': {
|
|
||||||
TreeEnabled:
|
|
||||||
'Whether the shard configuration itself \u2014 spawn files, regions, locations, champion ' +
|
|
||||||
'spawns, decoration \u2014 may cross the bridge. A third switch for a third consent: ' +
|
|
||||||
'this is the work of the operator rather than the game client. Off means the spawn ' +
|
|
||||||
'atlas needs a shared filesystem again',
|
|
||||||
TreeChunkBytes: 'How large a slice of one file may be before it is compressed and sent',
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -245,14 +175,12 @@ export const canonicalDocs = {
|
|||||||
'website/MODULE_SYSTEM.md': 'Why the module system is shaped this way',
|
'website/MODULE_SYSTEM.md': 'Why the module system is shaped this way',
|
||||||
'website/MODULE_API.md': 'Everything a module may do — the contract',
|
'website/MODULE_API.md': 'Everything a module may do — the contract',
|
||||||
'website/TEAMS.md': 'Teams as a platform primitive',
|
'website/TEAMS.md': 'Teams as a platform primitive',
|
||||||
'website/EVENTS.md': 'The Event System — leases, the ledger, and the module seam',
|
|
||||||
'website/SHARD_VISIBILITY.md': 'The audience ladder, for administrators',
|
'website/SHARD_VISIBILITY.md': 'The audience ladder, for administrators',
|
||||||
'website/THEMING_AND_NAV.md': 'Admin-configurable theme, assets and navigation',
|
'website/THEMING_AND_NAV.md': 'Admin-configurable theme, assets and navigation',
|
||||||
'website/TRUSTED_DEVICES_MFA.md': 'Trusted devices and the second factor',
|
'website/TRUSTED_DEVICES_MFA.md': 'Trusted devices and the second factor',
|
||||||
'link/PLAN.md': 'The sidecar design of record, the data catalog and the wire protocol',
|
'link/PLAN.md': 'The sidecar design of record, the data catalog and the wire protocol',
|
||||||
'link/INTEGRATION.md': 'Integrating with the sidecar',
|
'link/INTEGRATION.md': 'Integrating with the sidecar',
|
||||||
'link/v8.md': 'The current protocol, and its cross-repository obligations',
|
'link/v4.md': 'Protocol 4, and its cross-repository obligations',
|
||||||
'link/SHARD_PREREQS.md': 'What a shard host needs before any of this works',
|
|
||||||
'link/ADMIN_CONTROLS.md': 'What the site may command the game to do',
|
'link/ADMIN_CONTROLS.md': 'What the site may command the game to do',
|
||||||
'installer/INSTALL.md': 'The operator guide for setting a shard up',
|
'installer/INSTALL.md': 'The operator guide for setting a shard up',
|
||||||
'installer/PLAN.md': "The installer's design of record",
|
'installer/PLAN.md': "The installer's design of record",
|
||||||
|
|||||||
@@ -34,8 +34,7 @@
|
|||||||
* ---------------------------------------------------------------------------------------
|
* ---------------------------------------------------------------------------------------
|
||||||
* A demo deployment of this platform, wired to a real ServUO shard over a real sidecar
|
* A demo deployment of this platform, wired to a real ServUO shard over a real sidecar
|
||||||
* (D42): the marketplace rows are player vendors the game actually holds, the atlas is
|
* (D42): the marketplace rows are player vendors the game actually holds, the atlas is
|
||||||
* parsed from the shard's own spawn files, the guild rosters came over the bridge, and
|
* parsed from the shard's own spawn files, the guild rosters came over the bridge. The
|
||||||
* since protocol 8 the artwork in both came off that host's own UO client. The
|
|
||||||
* deployment is branded "Runic Gateway Demo" rather than a real community's name (D43) —
|
* deployment is branded "Runic Gateway Demo" rather than a real community's name (D43) —
|
||||||
* the screenshots show the platform, not somebody's private shard.
|
* the screenshots show the platform, not somebody's private shard.
|
||||||
*
|
*
|
||||||
@@ -82,19 +81,19 @@ export const screens = [
|
|||||||
route: '/uo/market',
|
route: '/uo/market',
|
||||||
admin: false,
|
admin: false,
|
||||||
scrollY: 470,
|
scrollY: 470,
|
||||||
alt: 'The marketplace page, listing items for sale by player vendors — each row showing the item picture, its name, the shop, the seller, the location and the price — above a search box and price filters.',
|
alt: 'The marketplace page, listing items for sale by player vendors with their prices, shop names and locations, above a search box and price filters.',
|
||||||
caption:
|
caption:
|
||||||
'Player vendors, searchable from the website — the same index the in-game vendor search reads, honouring the same per-vendor opt-out. The names and the pictures both come out of the client on the shard host.',
|
'Player vendors, searchable from the website — the same index the in-game vendor search reads, honouring the same per-vendor opt-out.',
|
||||||
family: 'web',
|
family: 'web',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'spawn-atlas',
|
id: 'spawn-atlas',
|
||||||
route: '/uo/atlas',
|
route: '/uo/atlas',
|
||||||
admin: false,
|
admin: false,
|
||||||
scrollY: 466,
|
scrollY: 430,
|
||||||
alt: 'The spawn atlas, listing creatures — sea serpent, water elemental, orc, ettin, horse, goat, sheep — each with the artwork from the game client beside how many of them spawn and on which facets.',
|
alt: 'The spawn atlas, listing creatures with how many of them spawn and on which facets, above a search box and facet filters.',
|
||||||
caption:
|
caption:
|
||||||
"The spawn atlas is parsed from the shard's own spawn files, so it stays accurate whether or not the server is up. Every portrait was decoded on the shard host and came over the same bridge.",
|
"The spawn atlas is parsed from the shard's own spawn files, so it stays accurate whether or not the server is up.",
|
||||||
family: 'web',
|
family: 'web',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -152,15 +151,6 @@ export const screens = [
|
|||||||
'The shard connection, showing a live sidecar. The token is write-only: it is never sent back to any client, including this screen.',
|
'The shard connection, showing a live sidecar. The token is write-only: it is never sent back to any client, including this screen.',
|
||||||
family: 'web',
|
family: 'web',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'admin-client-files',
|
|
||||||
route: '/admin/uo/files',
|
|
||||||
admin: true,
|
|
||||||
alt: 'The client files screen, showing the creature portraits section: 1,095 pictures held of 1,095 catalogued, 746 of 800 creatures matched, the last import and the extractor version, above Update and Re-import everything buttons.',
|
|
||||||
caption:
|
|
||||||
'Client files, imported from the UO client on the shard host over the same bridge. Nothing here happens on a restart — these buttons are the only thing that imports.',
|
|
||||||
family: 'web',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'admin-modules',
|
id: 'admin-modules',
|
||||||
route: '/admin/modules',
|
route: '/admin/modules',
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import '../styles/global.css';
|
|||||||
|
|
||||||
import Header from '../components/Header.astro';
|
import Header from '../components/Header.astro';
|
||||||
import Footer from '../components/Footer.astro';
|
import Footer from '../components/Footer.astro';
|
||||||
import { renderBrand } from '../lib/brand.mjs';
|
import { brand } from '../lib/brand.mjs';
|
||||||
import { token } from '../lib/tokens.mjs';
|
import { token } from '../lib/tokens.mjs';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -16,10 +16,6 @@ interface Props {
|
|||||||
|
|
||||||
const { title, description, bareTitle = false } = Astro.props;
|
const { title, description, bareTitle = false } = Astro.props;
|
||||||
|
|
||||||
// Per-request routes read the mount; prerendered pages take the stock value and let the
|
|
||||||
// boot rewrite carry the mount in. See `renderBrand` in src/lib/brand.mjs (phase 11).
|
|
||||||
const brand = renderBrand(Astro);
|
|
||||||
|
|
||||||
const fullTitle = bareTitle ? title : `${title} — ${brand.siteName}`;
|
const fullTitle = bareTitle ? title : `${title} — ${brand.siteName}`;
|
||||||
const canonical = new URL(Astro.url.pathname, Astro.site);
|
const canonical = new URL(Astro.url.pathname, Astro.site);
|
||||||
---
|
---
|
||||||
@@ -89,37 +85,7 @@ const canonical = new URL(Astro.url.pathname, Astro.site);
|
|||||||
<div class="site">
|
<div class="site">
|
||||||
<Header />
|
<Header />
|
||||||
|
|
||||||
<!--
|
<main id="main">
|
||||||
`data-pagefind-body` is what puts the marketing pages into the search index the
|
|
||||||
documentation already had (D47). It is on `<main>` and not on `<body>` deliberately:
|
|
||||||
the header and footer are on all ten pages, so indexing them would make every page
|
|
||||||
a result for "Discord", "Privacy" and the product's own name.
|
|
||||||
|
|
||||||
Pagefind indexes a page only if it finds this attribute, which is why the docs were
|
|
||||||
the whole index before now — Starlight marks its own content and nothing else did.
|
|
||||||
|
|
||||||
The explicit title is worth the second attribute. Pagefind titles a result from the
|
|
||||||
first <h1> it finds, and these pages have EDITORIAL h1s — /app/'s is "The app for a
|
|
||||||
deployment you already use", /terms/'s is "Short, and only about what we run". Read
|
|
||||||
on the page under an eyebrow that says "Android app" those are right; read as four
|
|
||||||
rows in a result list they are unscannable, and the first walk of this search turned
|
|
||||||
up exactly that. The page's short name — the one in the nav and the browser tab —
|
|
||||||
is what a reader is looking for in a list.
|
|
||||||
-->
|
|
||||||
<!--
|
|
||||||
`tabindex="-1"` is what makes the skip link above actually skip. Following it moves
|
|
||||||
the SCROLL to this element, but a container is not focusable, so focus stays where
|
|
||||||
it was; Chrome papers over that by moving its sequential-navigation point, and not
|
|
||||||
every browser or screen reader does. `-1` makes the element focusable by script and
|
|
||||||
fragment only — never by Tab — so the link lands here for everyone and the tab order
|
|
||||||
is unchanged. (Negative, so it is not the positive `tabindex` checkA11y refuses.)
|
|
||||||
-->
|
|
||||||
<main
|
|
||||||
id="main"
|
|
||||||
tabindex="-1"
|
|
||||||
data-pagefind-body
|
|
||||||
data-pagefind-meta={`title:${bareTitle ? brand.siteName : title}`}
|
|
||||||
>
|
|
||||||
<slot />
|
<slot />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -113,37 +113,3 @@ export function liveBrand() {
|
|||||||
cache = { mtimeMs, value: Object.freeze(merged) };
|
cache = { mtimeMs, value: Object.freeze(merged) };
|
||||||
return cache.value;
|
return cache.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =========================================================================================
|
|
||||||
THE CHROME, WHICH RENDERS BOTH WAYS (phase 11)
|
|
||||||
=========================================================================================
|
|
||||||
|
|
||||||
The two accessors above are each correct for a page that only ever renders one way. The
|
|
||||||
header, the footer and `Base.astro`'s head are neither: the same components render at
|
|
||||||
build time for the forty-nine prerendered pages and at request time for `/beta`.
|
|
||||||
|
|
||||||
Phase 11's brand walk is what found that. With a full brand mounted, every page came
|
|
||||||
back rebranded except `/beta`, which still carried the stock site name in its title, its
|
|
||||||
OG tags and its header lockup, and the stock Discord and Gitea links in its footer — the
|
|
||||||
one page whose whole job is to ask a person for their address under a stated identity.
|
|
||||||
`/beta` already read the mount, but only for `betaOptInUrl`; everything around the form
|
|
||||||
came from the chrome, and the chrome was baked.
|
|
||||||
|
|
||||||
Calling `liveBrand()` from the chrome unconditionally would fix `/beta` and quietly move
|
|
||||||
a build-time value onto the runtime path for the other forty-nine, where a build machine
|
|
||||||
that happened to have a mount would bake mounted text into HTML the boot rewrite then
|
|
||||||
has nothing to replace. `isPrerendered` is the honest discriminator, so it is the one
|
|
||||||
used, in one place, rather than the same reasoning repeated in three components:
|
|
||||||
|
|
||||||
- prerendered -> the stock value, which `applyBrand.mjs` rewrites at boot,
|
|
||||||
- on demand -> the mount, read now.
|
|
||||||
|
|
||||||
The consent sentence is deliberately NOT here. It names the operator of the list in a
|
|
||||||
statement a person agrees to and which is stored verbatim in their row, so making it
|
|
||||||
follow a mounted name would change the recorded text of an existing consent. The org
|
|
||||||
lead settled that on 2026-08-25: the chrome and the head are brandable, `CONSENT_TEXT`
|
|
||||||
stays a constant and changes only with a consent-version bump.
|
|
||||||
*/
|
|
||||||
export function renderBrand(astro) {
|
|
||||||
return astro?.isPrerendered === false ? liveBrand() : brand;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,9 +9,7 @@ import Base from '../layouts/Base.astro';
|
|||||||
---
|
---
|
||||||
|
|
||||||
<Base title="Page not found" description="That page does not exist on this site.">
|
<Base title="Page not found" description="That page does not exist on this site.">
|
||||||
<!-- The one page `Base`'s `data-pagefind-body` should not reach (D47): a search result
|
<section class="page notfound">
|
||||||
reading "That page does not exist" would be a small cruelty. -->
|
|
||||||
<section class="page notfound" data-pagefind-ignore>
|
|
||||||
<p class="eyebrow">404</p>
|
<p class="eyebrow">404</p>
|
||||||
<h1>That page does not exist</h1>
|
<h1>That page does not exist</h1>
|
||||||
<p class="prose">
|
<p class="prose">
|
||||||
|
|||||||
@@ -178,10 +178,8 @@ const notice = result ? NOTICES[result.outcome] : null;
|
|||||||
const optInUrl = isSuccess(result?.outcome) ? brand.betaOptInUrl : '';
|
const optInUrl = isSuccess(result?.outcome) ? brand.betaOptInUrl : '';
|
||||||
|
|
||||||
const title = 'The closed beta';
|
const title = 'The closed beta';
|
||||||
// Interpolated rather than written out, because this is the head: it becomes the meta
|
const description =
|
||||||
// description and `og:description`, and phase 11 made the rest of this page's head follow
|
'Join the list for the Runic Gateway Android app closed test. No email is ever sent.';
|
||||||
// the mount. A description naming a brand the title does not is worse than either.
|
|
||||||
const description = `Join the list for the ${brand.siteName} Android app closed test. No email is ever sent.`;
|
|
||||||
|
|
||||||
const formToken = issueFormToken();
|
const formToken = issueFormToken();
|
||||||
---
|
---
|
||||||
@@ -598,13 +596,9 @@ const formToken = issueFormToken();
|
|||||||
|
|
||||||
.beta-form__consent input {
|
.beta-form__consent input {
|
||||||
flex: none;
|
flex: none;
|
||||||
/* 24px exactly — WCAG 2.2 SC 2.5.8's minimum target size, which this box missed at
|
margin-top: 0.2rem;
|
||||||
1.05rem (17px). It is the only control on the site a person has to hit precisely,
|
width: 1.05rem;
|
||||||
and it is on the page a phone is most likely to arrive at, so the phase 10 walk
|
height: 1.05rem;
|
||||||
measuring it at 17x17 on a 390px viewport was worth acting on rather than
|
|
||||||
explaining away. */
|
|
||||||
width: 1.5rem;
|
|
||||||
height: 1.5rem;
|
|
||||||
accent-color: var(--portal);
|
accent-color: var(--portal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
---
|
---
|
||||||
import Base from '../layouts/Base.astro';
|
import Base from '../layouts/Base.astro';
|
||||||
import StructuredData from '../components/StructuredData.astro';
|
|
||||||
import { brand } from '../lib/brand.mjs';
|
import { brand } from '../lib/brand.mjs';
|
||||||
|
|
||||||
import Hero from '../components/home/Hero.astro';
|
import Hero from '../components/home/Hero.astro';
|
||||||
@@ -28,8 +27,6 @@ import GetStarted from '../components/home/GetStarted.astro';
|
|||||||
---
|
---
|
||||||
|
|
||||||
<Base title={`${brand.siteName} — ${brand.tagline}`} description={brand.tagline} bareTitle>
|
<Base title={`${brand.siteName} — ${brand.tagline}`} description={brand.tagline} bareTitle>
|
||||||
<StructuredData slot="head" />
|
|
||||||
|
|
||||||
<Hero />
|
<Hero />
|
||||||
<DataPath />
|
<DataPath />
|
||||||
<WhatItLooksLike />
|
<WhatItLooksLike />
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
import type { APIRoute } from 'astro';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `/robots.txt` (D49, phase 10).
|
|
||||||
*
|
|
||||||
* The site had a sitemap covering all fifty URLs — Starlight bundles `@astrojs/sitemap`, so
|
|
||||||
* it has been written on every build since phase 1 — and nothing that pointed at it. A
|
|
||||||
* crawler finds a sitemap two ways: submitted by hand in a search console, or named here.
|
|
||||||
* There is no search console for this project (D9's no-analytics posture extends to not
|
|
||||||
* having accounts with anyone), so this file is the only way it is ever found.
|
|
||||||
*
|
|
||||||
* **Everything is allowed.** The site has no authenticated surface at all (§6), so there is
|
|
||||||
* no private area to keep out of an index, and the two exclusions worth arguing about were
|
|
||||||
* both rejected:
|
|
||||||
*
|
|
||||||
* - `/brand/*` is derived images and a stylesheet. Nothing links them as pages and they
|
|
||||||
* carry no text; disallowing them would only stop an image crawler fetching the OG card
|
|
||||||
* that exists to be fetched.
|
|
||||||
* - `/beta/` is a live page a person is meant to find. It is the closed test's front door
|
|
||||||
* and the nearest real deadline this project has — hiding it from search to keep the
|
|
||||||
* signup list small would be solving a problem nobody has.
|
|
||||||
*
|
|
||||||
* The 404 page is excluded from the *search index* instead (`data-pagefind-ignore`), which
|
|
||||||
* is the right layer for it: it is never a URL a crawler is given.
|
|
||||||
*
|
|
||||||
* Written as a route rather than a file in `public/` so the sitemap URL is derived from
|
|
||||||
* `site` in `astro.config.mjs`. A hand-written copy would be one more place the domain is
|
|
||||||
* spelled out, and the first thing to go stale if it ever changes.
|
|
||||||
*/
|
|
||||||
export const GET: APIRoute = ({ site }) =>
|
|
||||||
new Response(
|
|
||||||
[
|
|
||||||
'User-agent: *',
|
|
||||||
'Allow: /',
|
|
||||||
'',
|
|
||||||
`Sitemap: ${new URL('sitemap-index.xml', site)}`,
|
|
||||||
'',
|
|
||||||
].join('\n'),
|
|
||||||
{ headers: { 'Content-Type': 'text/plain; charset=utf-8' } }
|
|
||||||
);
|
|
||||||
@@ -243,20 +243,6 @@ svg {
|
|||||||
padding-inline: 0.45rem;
|
padding-inline: 0.45rem;
|
||||||
font-size: 0.86rem;
|
font-size: 0.86rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Search (D47) shares the second row with the links rather than taking a third. `order`
|
|
||||||
was the obvious way to lift it beside the lockup instead, and it was rejected: the tab
|
|
||||||
order follows the DOM, so a keyboard user would tab from the lockup down to the links
|
|
||||||
and back up to a button above them. Keeping the visual order the same as the focus
|
|
||||||
order is worth more here than the row it saves. */
|
|
||||||
.site-nav {
|
|
||||||
width: auto;
|
|
||||||
flex: 1 1 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-search {
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Footer ------------------------------------------------------------- */
|
/* ---- Footer ------------------------------------------------------------- */
|
||||||
|
|||||||
@@ -113,11 +113,6 @@
|
|||||||
--shadow-raised: 0 22px 48px rgb(0 0 0 / 38%);
|
--shadow-raised: 0 22px 48px rgb(0 0 0 / 38%);
|
||||||
--panel-grad: linear-gradient(180deg, var(--panel-a), var(--panel-b));
|
--panel-grad: linear-gradient(180deg, var(--panel-a), var(--panel-b));
|
||||||
--glow-portal: 0 0 32px rgb(21 180 222 / 22%);
|
--glow-portal: 0 0 32px rgb(21 180 222 / 22%);
|
||||||
/* Behind the search dialog (phase 10, D47). A token rather than a literal for the same
|
|
||||||
reason as everything else here: a mounted theme.css can only redefine properties, so
|
|
||||||
a scrim written into the component is a piece of the site an operator can never
|
|
||||||
recolour — and a light theme would need this one lighter, not merely less opaque. */
|
|
||||||
--scrim: rgb(0 0 0 / 60%);
|
|
||||||
|
|
||||||
/* ---- Layout ------------------------------------------------------------
|
/* ---- Layout ------------------------------------------------------------
|
||||||
Here rather than in global.css so a theme can widen the measure without
|
Here rather than in global.css so a theme can widen the measure without
|
||||||
|
|||||||
@@ -1,97 +0,0 @@
|
|||||||
/**
|
|
||||||
* The footer's links, tested where a mistake is invisible to every other check.
|
|
||||||
*
|
|
||||||
* ---------------------------------------------------------------------------------------
|
|
||||||
* WHY THIS FILE EXISTS
|
|
||||||
* ---------------------------------------------------------------------------------------
|
|
||||||
* The footer shipped with all three "Documentation" links pointing at `/docs/`. Three
|
|
||||||
* labels — Getting started, Administration, Building a module — and one destination. It
|
|
||||||
* reached production and stayed there through eleven checks and two test suites, because
|
|
||||||
* none of them could see it:
|
|
||||||
*
|
|
||||||
* - `checkLinks.mjs` resolves every internal link against the build. `/docs/` resolves.
|
|
||||||
* Three links to a page that exists are three valid links.
|
|
||||||
* - `checkSidebar.mjs` compares the docs tree to the planned tree. The footer is not the
|
|
||||||
* sidebar and was never in scope.
|
|
||||||
* - `checkA11y.mjs` checks structure. Three correctly-marked-up links are correct markup.
|
|
||||||
*
|
|
||||||
* The bug is not a broken link. It is a link that goes somewhere other than where its label
|
|
||||||
* says, which is the one property nothing was asserting. So that is what this file asserts,
|
|
||||||
* and the reason `src/data/footer.mjs` exists at all — a column list inside an `.astro`
|
|
||||||
* component cannot be imported by a test.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { test } from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
|
|
||||||
import { footerColumns, docsEntryPoints } from '../src/data/footer.mjs';
|
|
||||||
|
|
||||||
/** A stand-in for the rendered brand; only the two Project links read it. */
|
|
||||||
const brand = {
|
|
||||||
giteaOrg: 'https://gitea.example.com/Org',
|
|
||||||
discordInvite: 'https://discord.gg/example',
|
|
||||||
};
|
|
||||||
|
|
||||||
const columns = footerColumns(brand);
|
|
||||||
const documentation = columns.find((c) => c.heading === 'Documentation');
|
|
||||||
|
|
||||||
test('every link in a column has its own destination', () => {
|
|
||||||
for (const column of columns) {
|
|
||||||
const hrefs = column.links.map((l) => l.href);
|
|
||||||
assert.equal(
|
|
||||||
new Set(hrefs).size,
|
|
||||||
hrefs.length,
|
|
||||||
`the ${column.heading} column has two links pointing at the same page: ${hrefs.join(', ')}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('no two columns offer the same destination twice', () => {
|
|
||||||
const all = columns.flatMap((c) => c.links.map((l) => l.href));
|
|
||||||
assert.equal(new Set(all).size, all.length, `a footer destination is repeated: ${all.join(', ')}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('each documentation link lands inside the section its label names', () => {
|
|
||||||
// The exact failure that shipped: `/docs/` under all three labels satisfies "starts with
|
|
||||||
// /docs/" but names no section, so the prefixes below are section prefixes, not `/docs/`.
|
|
||||||
const expected = [
|
|
||||||
['Getting started', '/docs/getting-started/'],
|
|
||||||
['Administration', '/docs/administration/'],
|
|
||||||
['Building a module', '/docs/modules/'],
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const [label, prefix] of expected) {
|
|
||||||
const link = documentation.links.find((l) => l.label === label);
|
|
||||||
assert.ok(link, `the Documentation column no longer has a "${label}" link`);
|
|
||||||
assert.ok(
|
|
||||||
link.href.startsWith(prefix),
|
|
||||||
`"${label}" points at ${link.href}, which is not inside ${prefix}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('no documentation link is the docs home, which the header already carries', () => {
|
|
||||||
for (const link of documentation.links) {
|
|
||||||
assert.notEqual(
|
|
||||||
link.href,
|
|
||||||
'/docs/',
|
|
||||||
`"${link.label}" points at the docs home; the header's Docs link is that page`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('every documentation entry point is a directory URL', () => {
|
|
||||||
// Astro builds these as directories with an index.html; a missing trailing slash costs a
|
|
||||||
// redirect on every click and reads as a broken path in the status bar.
|
|
||||||
for (const [section, href] of Object.entries(docsEntryPoints)) {
|
|
||||||
assert.ok(href.endsWith('/'), `the ${section} entry point (${href}) needs a trailing slash`);
|
|
||||||
assert.ok(href.startsWith('/docs/'), `the ${section} entry point (${href}) is not under /docs/`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('the brand supplies the two Project links rather than the code', () => {
|
|
||||||
const project = columns.find((c) => c.heading === 'Project');
|
|
||||||
const hrefs = project.links.map((l) => l.href);
|
|
||||||
assert.ok(hrefs.includes(brand.giteaOrg), 'the Source link no longer reads brand.giteaOrg');
|
|
||||||
assert.ok(hrefs.includes(brand.discordInvite), 'the Discord link no longer reads brand.discordInvite');
|
|
||||||
});
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
/**
|
|
||||||
* The headers the built site actually sends. PLAN.md §6 / D48, phase 10.
|
|
||||||
*
|
|
||||||
* ---------------------------------------------------------------------------------------
|
|
||||||
* WHY THIS IS A TEST AND NOT A CHECK SCRIPT
|
|
||||||
* ---------------------------------------------------------------------------------------
|
|
||||||
* `scripts/checkCsp.mjs` reads `dist/_headers.json` and proves the build computed the right
|
|
||||||
* policy for every route. That is necessary and it is not sufficient, because the defect
|
|
||||||
* this file exists for happened entirely *after* the build was correct: `@astrojs/node`
|
|
||||||
* matched a request to a policy with `pathname.includes(...)`, a substring test, and served
|
|
||||||
* `/modules/` the policy built for `/docs/modules/building-a-module`. Every file on disk was
|
|
||||||
* right. The bytes on the wire were not.
|
|
||||||
*
|
|
||||||
* Nothing that reads `dist/` can see that. The only way to know what a reader receives is
|
|
||||||
* to start the server and ask it, so this starts `scripts/serve.mjs` on an ephemeral port
|
|
||||||
* and reads the responses.
|
|
||||||
*
|
|
||||||
* The symptom is worth restating, because it is what makes this worth a test rather than a
|
|
||||||
* comment: a page served another page's hash list renders with its own stylesheet REFUSED.
|
|
||||||
* `/modules/` and `/architecture/` were shipping unstyled sections, and the only trace was
|
|
||||||
* a console message. The homepage looked perfect throughout — it happened to share a hash
|
|
||||||
* with the 404 page it was being given.
|
|
||||||
*
|
|
||||||
* ---------------------------------------------------------------------------------------
|
|
||||||
* IT NEEDS A BUILD
|
|
||||||
* ---------------------------------------------------------------------------------------
|
|
||||||
* `dist/` is an input here, so this file is NOT in `npm test` — that runs before the build,
|
|
||||||
* both locally and in CI. It is `npm run test:served`, which `npm run verify` runs after
|
|
||||||
* `npm run build`. With no build present it skips rather than fails, so that a developer
|
|
||||||
* running the whole file by hand gets an explanation instead of a stack trace.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { spawn } from 'node:child_process';
|
|
||||||
import { createHash } from 'node:crypto';
|
|
||||||
import fs from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { after, before, describe, it } from 'node:test';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
|
|
||||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
||||||
const built = fs.existsSync(path.join(root, 'dist', 'server', 'entry.mjs'));
|
|
||||||
|
|
||||||
const PORT = 41732;
|
|
||||||
const base = `http://127.0.0.1:${PORT}`;
|
|
||||||
|
|
||||||
let server;
|
|
||||||
|
|
||||||
/** Wait for the port to answer rather than sleeping a guessed number of milliseconds. */
|
|
||||||
async function waitForServer(timeoutMs = 30000) {
|
|
||||||
const deadline = Date.now() + timeoutMs;
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
try {
|
|
||||||
await fetch(base + '/', { signal: AbortSignal.timeout(1000) });
|
|
||||||
return;
|
|
||||||
} catch {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error(`serve.mjs did not answer on ${base} within ${timeoutMs}ms`);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('the headers the server sends', { skip: built ? false : 'no build in dist/ — run npm run build first' }, () => {
|
|
||||||
before(async () => {
|
|
||||||
server = spawn(process.execPath, [path.join(root, 'scripts', 'serve.mjs')], {
|
|
||||||
cwd: root,
|
|
||||||
env: { ...process.env, PORT: String(PORT), HOST: '127.0.0.1' },
|
|
||||||
stdio: 'ignore',
|
|
||||||
});
|
|
||||||
await waitForServer();
|
|
||||||
});
|
|
||||||
|
|
||||||
after(() => server?.kill());
|
|
||||||
|
|
||||||
const cspOf = async (route) => {
|
|
||||||
const res = await fetch(base + route);
|
|
||||||
assert.equal(res.status, route === '/404' ? 404 : 200, `${route} status`);
|
|
||||||
const csp = res.headers.get('content-security-policy');
|
|
||||||
assert.ok(csp, `${route} has no Content-Security-Policy header`);
|
|
||||||
return csp;
|
|
||||||
};
|
|
||||||
|
|
||||||
it('gives each prerendered route its OWN policy, not a substring match', async () => {
|
|
||||||
// The exact pair the upstream bug confused: one is a substring of the other.
|
|
||||||
const marketing = await cspOf('/modules/');
|
|
||||||
const docs = await cspOf('/docs/modules/building-a-module/');
|
|
||||||
assert.notEqual(marketing, docs, '/modules/ was served the docs page\'s policy');
|
|
||||||
|
|
||||||
// And the homepage, which matched whichever record came first in the file.
|
|
||||||
const home = await cspOf('/');
|
|
||||||
const notFound = await cspOf('/404');
|
|
||||||
assert.notEqual(home, notFound, '/ was served the 404 page\'s policy');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("covers a page's own inline styles with hashes in the policy it is served", async () => {
|
|
||||||
for (const route of ['/', '/modules/', '/architecture/', '/docs/']) {
|
|
||||||
const csp = await cspOf(route);
|
|
||||||
const html = await (await fetch(base + route)).text();
|
|
||||||
|
|
||||||
let counted = 0;
|
|
||||||
for (const [, body] of html.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/g)) {
|
|
||||||
if (body.trim() === '') continue;
|
|
||||||
counted++;
|
|
||||||
const hash = `sha256-${createHash('sha256').update(body, 'utf8').digest('base64')}`;
|
|
||||||
assert.ok(csp.includes(hash), `${route}: an inline <style> is not hashed in its own policy`);
|
|
||||||
}
|
|
||||||
assert.ok(counted > 0, `${route}: expected at least one inline style to check`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses framing everywhere, including the routes that render per request', async () => {
|
|
||||||
for (const route of ['/', '/docs/', '/beta/', '/brand/theme.css']) {
|
|
||||||
const res = await fetch(base + route);
|
|
||||||
const csp = res.headers.get('content-security-policy') ?? '';
|
|
||||||
assert.match(csp, /frame-ancestors 'none'/, `${route} can be framed`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sends the non-CSP security headers on every response', async () => {
|
|
||||||
for (const route of ['/', '/docs/', '/beta/']) {
|
|
||||||
const res = await fetch(base + route);
|
|
||||||
assert.equal(res.headers.get('x-content-type-options'), 'nosniff', route);
|
|
||||||
assert.equal(res.headers.get('x-frame-options'), 'DENY', route);
|
|
||||||
assert.match(res.headers.get('referrer-policy') ?? '', /strict-origin/, route);
|
|
||||||
assert.match(res.headers.get('permissions-policy') ?? '', /camera=\(\)/, route);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("never falls back to 'unsafe-inline' for scripts", async () => {
|
|
||||||
for (const route of ['/', '/docs/', '/beta/']) {
|
|
||||||
const csp = (await fetch(base + route)).headers.get('content-security-policy') ?? '';
|
|
||||||
const scriptSrc = /script-src ([^;]*)/.exec(csp)?.[1] ?? '';
|
|
||||||
assert.ok(!scriptSrc.includes("'unsafe-inline'"), `${route} script-src allows unsafe-inline`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* D56, phase 12. The signup POST behind a TLS-terminating proxy.
|
|
||||||
*
|
|
||||||
* `@astrojs/node` derives the request URL's protocol from `req.socket.encrypted` and
|
|
||||||
* never reads `x-forwarded-proto`, so a container reached over plaintext by a proxy
|
|
||||||
* computes `http://<host>` while the browser is sending `Origin: https://<host>`. Astro's
|
|
||||||
* CSRF middleware compares those two for equality, so the answer is 403 — for every
|
|
||||||
* visitor, on the only page that accepts a POST.
|
|
||||||
*
|
|
||||||
* This is the shape of a request as a proxy actually delivers it, and it belongs in this
|
|
||||||
* file rather than in `npm test` for the same reason everything else here does: the build
|
|
||||||
* was already correct, the store was already correct, and the failure existed only in the
|
|
||||||
* bytes on the wire. `serve.mjs` normalises both forwarded headers; these assertions are
|
|
||||||
* what stop that being deleted as unnecessary.
|
|
||||||
*
|
|
||||||
* It stops at the origin check deliberately — a signup that reached the store would write
|
|
||||||
* a row into whatever `data/` the developer running the suite happens to have.
|
|
||||||
*/
|
|
||||||
it('accepts a form POST forwarded by a proxy that terminated TLS', async () => {
|
|
||||||
const host = 'runicgateway.com';
|
|
||||||
const res = await fetch(base + '/beta/', {
|
|
||||||
method: 'POST',
|
|
||||||
redirect: 'manual',
|
|
||||||
headers: {
|
|
||||||
host,
|
|
||||||
origin: `https://${host}`,
|
|
||||||
'x-forwarded-proto': 'https',
|
|
||||||
'x-forwarded-host': host,
|
|
||||||
'x-forwarded-for': '203.0.113.7',
|
|
||||||
'content-type': 'application/x-www-form-urlencoded',
|
|
||||||
},
|
|
||||||
// No form token, so the signup refuses it — but it must refuse it as a stale form,
|
|
||||||
// rendering the page, rather than as a cross-site request.
|
|
||||||
body: 'email=&consent=&ts=',
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.notEqual(res.status, 403, 'the proxy-shaped POST was refused as cross-site (D56)');
|
|
||||||
assert.equal(res.status, 200);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('still refuses a genuinely cross-site POST', async () => {
|
|
||||||
const res = await fetch(base + '/beta/', {
|
|
||||||
method: 'POST',
|
|
||||||
redirect: 'manual',
|
|
||||||
headers: {
|
|
||||||
host: 'runicgateway.com',
|
|
||||||
origin: 'https://not-us.example.com',
|
|
||||||
'x-forwarded-proto': 'https',
|
|
||||||
'x-forwarded-host': 'runicgateway.com',
|
|
||||||
'content-type': 'application/x-www-form-urlencoded',
|
|
||||||
},
|
|
||||||
body: 'email=&consent=&ts=',
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(res.status, 403, 'the forwarded-header fix weakened the CSRF check');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user