79 Commits

Author SHA1 Message Date
caab3476eb Merge pull request 'feat(legal): /delete-account/, the page Google Play is given (D85, D86)' (#45) from feat/delete-account-page into main
All checks were successful
PR checks / checks (push) Successful in 2m22s
Build and publish the image / build (push) Successful in 2m49s
Build and publish the image / deploy (push) Successful in 10s
Reviewed-on: #45
2026-10-30 06:42:54 +00:00
70b31302b7 fix(facts): quote Module API 1.20.0 and Module-uo v1.4.0
All checks were successful
PR checks / checks (pull_request) Successful in 2m34s
check:facts was red on main after account deletion's core and module
steps merged: core's MODULE_API is 1.20.0, Module-uo declares coreApi
^1.20.0, and Module-uo released v1.4.0. Every page reads these from
platform.json, and none needed rewording. Folded into this PR by the org
lead's choice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-30 01:40:52 -05:00
094c606238 feat(legal): /delete-account/, the page Google Play is given (D85, D86)
Some checks failed
PR checks / checks (pull_request) Failing after -31s
Step 5 of docs/website/ACCOUNT_DELETION.md (§4.5). The page explains how
to delete an account on a community's site and in the app, what goes,
what stays and for how long, and who to ask when you cannot sign in. It
cannot delete anything, because we hold no account.

- The facts are one deployment row in collection.mjs (deletion.goes,
  .stays with a keep each, .staff), in the website's own words. /privacy
  section 3 renders it as prose, the page renders its lists, and
  PLAY_DATA_SAFETY.md prints them under the console's deletion questions.
- The app rows that a deletion touches (session, trust token, push,
  inbox snapshot) say so. The Data Safety notes now answer "yes" to
  account deletion and name the URL. lastUpdated is 2026-10-30.
- D85: linked from the footer's legal bar, /privacy sections 2 and 3,
  and /app/'s "Two ways to get it".
- D86: someone who cannot sign in is sent to their community's operator,
  never to us.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-30 01:25:48 -05:00
338cc6f3de Merge pull request 'fix(legal): declare what the app sends as collected, not shared (D84)' (#44) from fix/play-data-safety-collected into main
Some checks failed
PR checks / checks (push) Failing after -1m24s
Build and publish the image / build (push) Successful in 3m5s
Build and publish the image / deploy (push) Successful in 30s
Reviewed-on: #44
2026-10-29 22:30:29 +00:00
d6d5a70ed4 fix(legal): declare what the app sends as collected, not shared (D84)
All checks were successful
PR checks / checks (pull_request) Successful in 2m33s
Play counts data that leaves the device as collected, whoever runs the
server it lands on. The generated Data Safety notes answered "not
collected" because we run no server, which is true of /privacy and the
wrong answer to the form (org lead, 2026-10-29, Android PLAN.md M8).

An audit of the app's edge (every body, query and header in data/api/)
found more leaving the phone than section 2 listed. New rows: the
User-Agent, game-account links and creation, the Contact form, staff
posts and moderation, notification read state and preferences, search
text. The sign-in and trusted-device rows now say what is sent (the
credentials, the device's make and model).

- PlayMapping: a list of console types, plus required and purposes,
  checked against PLAY_DATA_TYPES / PLAY_PURPOSES (the first version
  filed "Other user-generated content" under Messages).
- The generator answers per data type, lists every type not collected,
  and answers the form's security questions.
- The premise test now asserts what is still true: nothing is shared.
- legal.lastUpdated moves to 2026-10-29 with /privacy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-29 08:20:25 -05:00
04e8c14d1f Merge pull request 'fix(facts): quote Module-Rust v0.6.3' (#43) from fix/facts-module-rust-063 into main
Some checks failed
PR checks / checks (push) Successful in 2m44s
Build and publish the image / build (push) Failing after 1h35m32s
Build and publish the image / deploy (push) Has been skipped
Reviewed-on: #43
2026-10-29 11:59:52 +00:00
bda4662a89 fix(facts): quote Module-Rust v0.6.3
All checks were successful
PR checks / checks (pull_request) Successful in 9m11s
check:facts was red on main: platform.json said Module-Rust v0.6.0 and
releases/latest says v0.6.3. v0.6.1 and v0.6.3 are demo-only fixes (DM26,
DM27; there is no v0.6.2) and still require MODULE_API ^1.19.0, so no
page's prose changes. Every page that shows the version interpolates it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-29 05:48:51 -05:00
fcc1a06361 Merge pull request 'feat(demo): list the demo's sign-ins on /beta and link into it per game' (#42) from feat/demo-live into main
Some checks failed
Build and publish the image / build (push) Failing after 3m41s
Build and publish the image / deploy (push) Has been skipped
PR checks / checks (push) Failing after 4m37s
Reviewed-on: #42
2026-10-29 10:12:41 +00:00
769d213c80 feat(demo): list the demo's sign-ins on /beta and link into it per game
Some checks failed
PR checks / checks (pull_request) Failing after 2m16s
The public demo (demo.runicgateway.com) is live, so the site's demo slot
can be switched on. Two decisions of record, PLAN.md §15:

- D82: /beta reads the demo's GET /api/v1/public/settings demo.logins on
  the server, caches it for a minute, and lists the published sign-ins
  with the next wipe. If the demo does not answer, /beta links to its
  sign-in page instead. Gate two reads as in place.
- D83: a deep link into a game module's pages carries data-demo-game, and
  applyBrand.mjs fills it only for games in the new mounted demoGames
  list. The Rust features gain links into /rust and /rust/servers/demo.
  UO's stay hidden on a Rust demo.

The public-demo not-built entry is removed, because its resolvedBy was met.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-28 15:32:34 -05:00
8701126d64 Merge pull request 'fix(docker): pin Node 22.23.4 for the image and CI (Node2Shell)' (#41) from fix/node22-node2shell into main
All checks were successful
PR checks / checks (push) Successful in 3m41s
Build and publish the image / build (push) Successful in 3m53s
Build and publish the image / deploy (push) Successful in 24s
Reviewed-on: #41
2026-10-27 17:49:06 +00:00
e7bbc250a5 chore(facts): Module API 1.19.0, Module-Rust v0.6.0
All checks were successful
PR checks / checks (pull_request) Successful in 5m58s
checkFacts turned this PR red on drift from the demo-mode merges, not on
the Node change: website main is at MODULE_API 1.19.0, Module-Rust main
declares coreApi ^1.19.0, and Module-Rust released v0.6.0. Every page
reads the three values from platform.json; verifiedOn moves to today.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-27 12:37:48 -05:00
d59c993430 fix(docker): pin Node 22.23.4 for the image and CI (Node2Shell)
Some checks failed
PR checks / checks (pull_request) Failing after 2m24s
CVE-2026-111497 ("Node2Shell") is an out-of-bounds write in Node's
bundled Brotli decoder, fixed on 2026-10-21 in 22.23.4. The image used
the floating node:22-bookworm-slim tag, so a cached or stale base could
still carry an older 22.x. Pin the patched version in both stages and in
the PR checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-26 10:01:49 -05:00
9b11c5cb99 Merge pull request 'chore(site): weapon names in the Rust shots; Android-app v0.6.0 and Module-Rust v0.5.1' (#40) from chore/rust-weapon-shots-and-versions into main
All checks were successful
PR checks / checks (push) Successful in 2m15s
Build and publish the image / build (push) Successful in 1m54s
Build and publish the image / deploy (push) Successful in 50s
Reviewed-on: #40
2026-10-23 07:35:45 +00:00
06a8892895 chore(facts): Android-app v0.6.0 and Module-Rust v0.5.1
All checks were successful
PR checks / checks (pull_request) Successful in 2m28s
Both were released on 2026-10-23. Android-app v0.6.0 is the cutover that
ships the Rust screens and the app's core wording (M20), and its APK asset
is runic-gateway-0.6.0.apk. Module-Rust v0.5.1 brings the weapon names in
the feed. verifiedOn moves to 2026-10-23, and checkFacts agrees on all 41
facts.

androidApk.serviceable stays false. It records a person's confirmation
that the published APK works, and nobody has run v0.6.0 against a real
site yet. A smoke test of the signed APK on the emulator covered install,
launch, the HTTPS rule and one TLS round trip, but no Runic Gateway site
serves HTTPS locally.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-23 02:32:59 -05:00
a918e9248e feat(screens): retake the Rust feed shots with weapon names
rust-feed (web) and app-rust-server (phone) showed the prefab names the
module printed before Module-Rust#60 ("with ak47u entity"). Both were
retaken from the Rust demo running that fix, after re-running
mockRustDemo.mjs so the feed's times are fresh. They now read "with
Assault Rifle", "with Thompson", "with MP5A4".

The phone shot was taken with the emulator in Pacific/Honolulu so that no
row crosses midnight and picks up a date; the timezone was restored
afterwards. The real Oxide rig has nobody on, so the server reads 0 of
40 online, and app-rust-server's alt now says "how many players are
online" rather than "six".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-23 02:06:19 -05:00
abf7dceb06 Merge pull request 'feat(site): phase 18 — the Rust screenshots, web and app' (#39) from feat/phase-18-screenshots into main
All checks were successful
PR checks / checks (push) Successful in 2m33s
Build and publish the image / build (push) Successful in 3m26s
Build and publish the image / deploy (push) Successful in 50s
Reviewed-on: #39
2026-10-23 03:09:15 +00:00
c47e9c1aa4 feat(site): phase 18 - the Rust screenshots, web and app
All checks were successful
PR checks / checks (pull_request) Successful in 2m55s
Nine captures tagged rust (D65): the server list, feed, leaderboard,
live map, permissions and the RunicNPC profile editor on the web, and a
server's feed, its map and the player's Rust account in the app, from
an Android-app edge debug build.

D80: every name in a capture is a role (admin, moderator, player...),
from this phase on. D81: the Rust demo's players are mock data written
by scripts/mockRustDemo.mjs; the server, its status, map, permission
groups and RunicNPC sync state are the real Oxide test server's. The
captions and /modules/rust/ and /app/ say which part is a demo cast.

seedDemo.mjs gains --game rust (server row with a weekly wipe, public
presence for the demo, two NPC profiles, a VIP group). captureScreens.mjs
gains a click list per screen. The homepage shows the Rust live map beside
the UO capture (D76); three Rust docs pages carry their screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-22 22:05:06 -05:00
4f38e29ad7 Merge pull request 'feat(site): phase 17 — per-game module pages, /runicnpc/, and checkTerms over the built site' (#38) from feat/phase-17-marketing into main
All checks were successful
PR checks / checks (push) Successful in 2m26s
Build and publish the image / build (push) Successful in 3m0s
Build and publish the image / deploy (push) Successful in 53s
Reviewed-on: #38
2026-10-23 01:26:01 +00:00
56473d4d51 feat(site): phase 17 - per-game module pages, /runicnpc/, and checkTerms over the built site
All checks were successful
PR checks / checks (pull_request) Successful in 2m37s
PLAN.md §16 phase 17, built on the org lead's answers of 2026-10-22,
recorded as D75-D79 (count of record 79).

- D75: core marketing pages describe what a game module adds in core's
  words. "Game intelligence" became "What a game module adds"; each
  game's own list moved to src/data/games/ and renders only on the new
  /modules/uo/ and /modules/rust/. /modules/ is a card per game.
- The page-side capability check is per game (assertGameCoverage), the
  item phase 13 moved here.
- D76: the homepage keeps its game screenshot, labelled. screens.mjs
  entries may carry `game` (D67); Screenshot.astro and the app grid
  print the game's name and mark the figure data-game.
- D77: /app/ describes the Rust screens (released the same day).
- D78: /runicnpc/, the standalone page, is in the header and footer;
  the narrow header was re-measured so five links keep two rows.
- D79: checkTerms.mjs --built reads every built page's visible text
  after the build; game routes and data-game elements allow their
  game's words. Proven against ten plants. Still the twelfth check.
- Re-read fixes: /architecture/'s "two machines is also the maximum",
  Discord "guild" on /integrations/, and collection.mjs against what
  Steam sign-in and the Rust module keep (PLAY_DATA_SAFETY.md
  regenerated; the Play answers are unchanged).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-22 20:10:51 -05:00
ec65b2d5aa Merge pull request 'feat(docs): phase 16 — the RunicNPC subgroup, its API carried in full and drift-checked' (#37) from feat/phase-16-runicnpc into main
All checks were successful
Build and publish the image / build (push) Successful in 2m32s
PR checks / checks (push) Successful in 2m52s
Build and publish the image / deploy (push) Successful in 34s
Reviewed-on: #37
2026-10-22 21:07:54 +00:00
297cc83db5 docs(plan): record the Oxide floor ruling and the API.md table fix
All checks were successful
PR checks / checks (pull_request) Successful in 8m36s
The bundle's Oxide 2.0.7585 is the original compatible version and stays
until the CI staging drill reports an issue (org lead, 2026-10-22). The
API.md table break is fixed in docs #371.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-22 16:02:40 -05:00
ecc556d7ac feat(docs): phase 16 - the RunicNPC subgroup, its API carried in full and drift-checked
All checks were successful
PR checks / checks (pull_request) Successful in 2m11s
PLAN.md §16 phase 16, with the org lead's D72-D74 (count of record 74).

- Eight pages nested in the Rust group at /docs/rust/runicnpc/ (D72): the
  overview, Install RunicNPC, Commands, NPC profiles, NPC placements, Cost and
  caps, Profile fields (the one field reference the editor, rnpc.profile set,
  the data file and the API all share) and The API.
- The API page carries every call, hook and data shape (D62), in the site's
  own words, with no decision numbers and no per-call version history (D74).
  Signatures print from src/data/runicnpc.mjs through Signature.astro.
- checkReference.mjs gains two sections. Against docs main:runicnpc/API.md:
  the API version, 24 call and 10 hook signatures, and the field names of the
  profile, RunicNpc_List rows, placements, routes and owners (D73), plus
  exactly one <Signature> per call on api.mdx. Against runicnpc-rust
  main:plugin/RunicNPC.cs: console verbs, permissions, place options and
  config keys. Six planted drifts all failed before it was trusted.
- checkSidebar and checkTerms read nested sidebar groups via flatLabels().
- The Rust overview, Events zones and rewards, and Canonical documents link
  the new pages; the canonical documents gain the four runicnpc/ docs.

Both NPC admin screens were walked on the walk site first (D36); nothing was
saved. npm run verify is green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-22 15:49:07 -05:00
fe86fc369d Merge pull request 'docs(plan): phase 15's other-repository findings are fixed' (#36) from docs/phase-15-followup into main
All checks were successful
PR checks / checks (push) Successful in 2m42s
Build and publish the image / build (push) Successful in 3m23s
Build and publish the image / deploy (push) Successful in 31s
Reviewed-on: #36
2026-10-22 20:32:25 +00:00
9b28c95305 docs(plan): phase 15's other-repository findings are fixed
All checks were successful
PR checks / checks (pull_request) Successful in 2m42s
PLAN.md §16 recorded three phase-15 findings as reported rather than
fixed. The org lead asked for them fixed the same day: Module-Rust #59
(24 decision numbers in admin copy, and "1 player holds one") and
Rust-Plugins #45 (EventsEnabled's comment). The record now says so.

Reading every EventsEnabled check for that comment also showed the
switch applies at once when saved from the site's config editor, while
the bridge's other settings wait for its next reload. RunicGateway.json
now says that, and adds paying coins to what the switch stops.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-22 15:31:45 -05:00
aad3a37c09 Merge pull request 'feat(docs): phase 15 — the Rust group, its reference pages checked against their sources' (#35) from feat/phase-15-rust into main
All checks were successful
PR checks / checks (push) Successful in 2m17s
Build and publish the image / build (push) Successful in 3m41s
Build and publish the image / deploy (push) Successful in 33s
Reviewed-on: #35
2026-10-22 20:26:31 +00:00
75c8ffc07a feat(docs): phase 15 - the Rust group, its reference pages checked against their sources
All checks were successful
PR checks / checks (pull_request) Successful in 2m5s
PLAN.md §16 phase 15. A Rust group of fifteen pages after Ultima Online
(D70): the module, installing the bridge (installer, Pterodactyl egg, by
hand), connecting the site, verifying the bridge, linking Steam accounts,
permissions and groups, mod config, who sees who is online, events, zones
and rewards, optional plugins, the live map, troubleshooting, and three
reference pages - rust-link's sidecar.toml, the plugin's
RunicGateway.json, and the event kinds.

checkReference.mjs gains three Rust sections rather than a new check:
sidecar.toml keys and RUSTLINK_* overrides from Rust-Link config.rs,
RunicGateway.json from the plugin's PluginConfig, and the event kinds
in three classes against Module-Rust server/catalogue.js (D71). Every
admin screen was walked on the walk site before it was described (D36).

Core pages now hand off to the Rust group instead of linking out to the
docs repository. Three defects the re-read found here are fixed: the UO
/health sample's hard-coded protocol 4 (both games' samples now render
platform.json through <Code>), Authentication's missing Steam tab, and
two pages saying the site cannot reach EventsEnabled - false on Rust.

Installer v0.8.1 moved checkFacts red mid-phase; platform.json follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-22 15:23:07 -05:00
a100195e7b Merge pull request 'feat(docs): phase 14 — game-neutral core docs, an Ultima Online group, and checkTerms' (#34) from feat/phase-14-vocabulary into main
Some checks failed
Build and publish the image / build (push) Failing after -1m50s
Build and publish the image / deploy (push) Has been skipped
PR checks / checks (push) Successful in 8m56s
Reviewed-on: #34
2026-10-22 19:41:17 +00:00
4426d3e339 feat(docs): phase 14 - game-neutral core docs, an Ultima Online group, and checkTerms
All checks were successful
PR checks / checks (pull_request) Successful in 2m10s
PLAN.md §16, D63/D64, plus D67-D69 taken 2026-10-22.

- Core docs use core's words: a Team, a game server, the sidecar, the bridge
  plugin, the game module. Detail that belonged to Ultima Online moved into
  its pages rather than being dropped.
- New "Ultima Online" sidebar group (nine pages): an overview, Connect a
  ServUO shard, Verify the bridge, The shard connection, Client files,
  Troubleshooting, Bridge.cfg, sidecar.toml, Shard event catalog. Five moved
  URLs answer a real 301 (src/config/redirects.mjs, asserted by the served
  test).
- Getting started is game-neutral. "Connect your game server" keeps the old
  URL and hands off to each game; Rust links docs main:rust-link/INSTALL.md
  until phase 15. "Verify the whole stack" stays, neutral (D69).
- D68: GameExample.astro labels a game's real example on a core page with the
  game's name; checkTerms allows that game's words inside it.
- scripts/checkTerms.mjs, the twelfth check: 16 game words, docs pages and
  both sidebar trees, one allowlisted phrase (Discord's "Guild (server) ID").
  Wired into verify and pr-checks.yml.
- canonicalDocs grouped by game; the Rust group carries four documents.
- D67: the screenshot rule from #32 is adopted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-22 08:54:37 -05:00
6046214d71 Merge pull request 'feat(facts): phase 13 — per-game facts, Rust and RunicNPC checked, main green again' (#33) from feat/phase-13-facts into main
All checks were successful
Build and publish the image / build (push) Successful in 2m27s
PR checks / checks (push) Successful in 2m50s
Build and publish the image / deploy (push) Successful in 37s
Reviewed-on: #33
2026-10-22 13:11:14 +00:00
9938e71e07 feat(facts): phase 13 - per-game facts, Rust and RunicNPC checked, main green again
All checks were successful
PR checks / checks (pull_request) Successful in 8m25s
platform.json drops the single protocol/bundle/capability list for a
`games` object (uo, rust) plus a `runicnpc` block. checkFacts.mjs loops
the protocol, plugin-protocol, capability and bundle checks over both
games, reads the schema-2 bundles at v2/<game>/current.json, checks each
module's coreApi, and reads RunicNPC's plugin.toml. 41 facts, up from 19;
clears the two that were red on main (Module API 1.17.0, installer
v0.8.0).

D66: the hero, footer and structured data show the Module API only; each
game's protocol and bundle go on that game's pages, and the docs landing
table splits into shared versions plus one column per game.

The re-read section 1 requires found, and this fixes:
- three hard-coded "1.6.0" Module API versions in the module docs
- module-lifecycle saying modules load alphabetically (the loader now
  refuses a second module, W19), and the install pages never mentioning
  the one-module rule
- "there is one module" in five places, now two modules both built
  inside this project; the Integration Kit keeps its draft banner
- the homepage diagram's ServUO/uo-link sub-labels (D63 wording now)

notBuilt drops `second-module` (resolved by Module-Rust's release) and
rewords `multi-module`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-22 08:09:22 -05:00
2971b84d51 Merge pull request 'docs(plan): revision 4 — a second game, RunicNPC, and a vocabulary rule' (#32) from docs/second-game-plan into main
Some checks failed
PR checks / checks (push) Failing after 2m4s
Build and publish the image / build (push) Successful in 4m11s
Build and publish the image / deploy (push) Successful in 1m23s
Reviewed-on: #32
2026-10-22 12:59:55 +00:00
b1584043a3 docs(plan): revision 4 - a second game, RunicNPC, and a vocabulary rule
Some checks failed
PR checks / checks (pull_request) Failing after 1m52s
Adds PLAN.md section 16 with the org lead's six decisions of 2026-10-22
(D60-D65) and phases 13-18:

- D60 per-game sidebar groups (Ultima Online, Rust); core docs go
  game-neutral, moved pages keep redirects
- D61 RunicNPC as a subgroup inside Rust, plus a standalone /runicnpc/
- D62 the RunicNPC API carried in full, with a drift check against
  docs/runicnpc/API.md (a deliberate exception to section 1)
- D63 game names allowed outside game sections, game vocabulary not
- D64 checkTerms.mjs, a twelfth check, amending D53
- D65 Rust screenshots last, captured from the rigs

Also records what moved since the site's last fact check: Module API
1.17.0 and installer v0.8.0 (main is already red on both), Module-Rust
v0.5.0, the v2/rust bundle at protocol 14, and RunicNPC v1.0.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-22 07:17:09 -05:00
c3c347d237 Merge pull request 'docs(site): the Asset Bridge — protocol 8, a screen for client files, and pictures in the screenshots (Phase 9c)' (#31) from docs/asset-bridge-p9c into main
All checks were successful
PR checks / checks (push) Successful in 9m28s
Build and publish the image / build (push) Successful in 2m59s
Build and publish the image / deploy (push) Successful in 1m20s
Reviewed-on: #31
2026-09-15 12:11:16 +00:00
4e1ce2316a docs(site): the Asset Bridge — protocol 8, a screen for client files, and pictures in the screenshots (Phase 9c)
All checks were successful
PR checks / checks (pull_request) Successful in 1m41s
The last leg of the Asset Bridge (docs/link/v8.md §16, phase 9c). The cutover put
protocol 8 on `main` in every declaration site, which turned this repo red by
construction: `checkFacts.mjs` reads the protocol from `link`'s `main`.

Two checks were failing, and only one of them was expected.

**checkFacts (10)** — protocol 7 → 8 in all three reads (sidecar, overlay, bundle),
the bundle triple to 2026.09.15 / sidecar v2.3.0 / overlay v1.3.0, and the releases
to link v2.3.0, installer v0.2.0, Module-uo v1.3.0. `moduleApi` and the capability
list did not move: core's whole share of eight phases was a deletion.

**checkReference (9)** — nobody had planned for this one. `Bridge.cfg` grew nine keys
across phases 5, 6 and 7 and the reference page had gone on not mentioning them,
which is the failure mode that check exists for. They are listed as two new groups,
`Client assets` and `Spawn files`, because they are two separate consents.

## The prose the bridge changed

- **New page, Administration → Client files.** `Admin → Client files` is a screen an
  operator has to press a button on and the site had no page for it. It carries the
  rule nothing else states: **nothing here happens on a restart**, so a patched client
  keeps serving the old artwork until somebody says so.
- **Protocol versions** said the most recent bump touched five repositories and named
  `website`. Protocol 8 touched four and did not reach core — its absence is the
  interesting half, and the page now says why. The store-migration paragraph gains 8,
  which changed no line of the sidecar's store despite moving megabytes.
- **The bridge** said "two ways in". There are three: bulk reads go over the
  request/reply path in pages, one in flight at a time, and the reason they must not
  ride the event stream is structural rather than about speed.
- **Requirements** gains `libgdiplus` for Linux shard hosts, and the note that no game
  client has to be installed for any of this — a ServUO shard cannot boot without one.
- **Troubleshooting** gains the failure mode that did not exist before: a bestiary with
  no pictures, or items reading as numbers.
- **Maintenance** gains the button to press after patching a client, and **Verify the
  whole stack** the `doctor` check that landed with it.
- The spawn atlas and the marketplace both carry artwork now, so both capability
  descriptions say so, and `canonicalDocs` points at `link/v8.md`.

## Screenshots, retaken against the real thing

The atlas and marketplace shots predate the asset plane, so both were retaken on the
demo deployment against a real shard and a real sidecar, plus the new admin screen:
1,095 portraits imported in 2.1 s, 67,496 names in 1.4 s, 313 item pictures in 0.8 s.
Nothing here is a mock-up. The atlas shot moved 36px down its page because portraits
made the rows taller.

## One defect found on the way

`seedDemo.mjs` pinned the demo deployment at **protocol 4** with a comment explaining
a `module-uo` debt that has since been paid. Its default now comes from
`platform.json`, so the rig cannot drift two protocols behind the platform again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-15 05:45:38 -05:00
73f664c38e Merge pull request 'docs(site): the Event System — the platform's facts, and two pages for it' (#30) from docs/events-system into main
All checks were successful
Build and publish the image / build (push) Successful in 4m14s
Build and publish the image / deploy (push) Successful in 42s
PR checks / checks (push) Successful in 8m22s
Reviewed-on: #30
2026-09-10 04:27:50 +00:00
e91e76bfa9 docs(site): the Event System — the platform's facts, and two pages for it
All checks were successful
PR checks / checks (pull_request) Successful in 9m34s
Phase 16c of the events plan: `runicgateway.com`'s half of the workstream, now
that `main` carries the engine, the module, the app and the bundle.

The checks were already red and named their own answers:

* `checkFacts` — nine values had moved. Protocol 5 → 7 in all three declaration
  sites, `moduleApi` 1.9.0 → 1.10.0, the bundle to 2026.09.10 with sidecar
  v2.2.0 and overlay v1.2.0, `link` v2.2.0, `Module-uo` v1.2.2.
* `checkReference` — twenty-seven `Bridge.cfg` keys the site listed nowhere: the
  events switch and its sweep, the ten caps, the oracle NPC, the two lease keys
  and the seven participation keys. They are five new groups rather than an
  appendix to an existing one, because `EventsEnabled` is a second consent
  switch and belongs beside its own ceilings.

Two pages, matching the treatment Teams has:

* **Scheduled events** (Administration) — where it is and who sees it, authoring
  and immutable versions, the switchboard that arrives off, caps as a condition
  on an `UPDATE` rather than a role check, the dry run, the run console, what an
  event owns versus what it borrows, generated cleanup, the shard's own switches,
  and what a player sees.
* **Events architecture** — the two sentences it turns on, what is a table and
  what deliberately is not, budgets in SQL, the ledger's two rules, at-most-once
  on a wire that can lose an answer, the three layers, and the four omissions.

And the rest of the surface:

* `/privacy` gains **`deploy-events`** — the participation ledger is personal
  data and no row named it. Scores and ranks against a module-opaque member key,
  linked to an account where one is linked; the diagnostic log swept after 90
  days on terminal runs only; the run, its steps and its participants not swept
  at all, because they are the record of what was done to a shared world.
  `deploy-game-data`'s citation moves from `link/v4.md` to `v7.md`.
* **Protocol versions** — the most recent bump touched *five* repositories, and
  the `website` row is the interesting one: core is normally out of a protocol
  bump's reach and this one reached it, because what changed was not a game noun
  but the shape of a thing core owns the ledger for. The store-migration
  paragraph now says four bumps' worth rather than two.
* Two capability entries, so `/`, `/features/` and `/modules/` stop omitting the
  subsystem — an Administration item, and an **Event calendar** under Community
  with `/site/events` as its deep link. Deliberately *not* `needsModule`: a bare
  core can author and run an event, and only the world verbs need a module.
* **`reference/event-catalog` is retitled "Shard event catalog"** and says what
  it is not. Two things in the docs were called an event catalog; the route is
  unchanged, so nothing outside this repository breaks.
* `canonicalDocs` gains `website/EVENTS.md` and moves `link/v4.md` → `v7.md`.

No screenshots. Capturing the events surfaces means standing the whole rig back
up — game server, sidecar, core, module, a published event with a live run — for
two or three images that no check requires, and the engagement workstream's own
site leg added none either.

`npm run verify` green end to end, including `checkReference` against the
protocol spec that only reached `docs` `main` in RunicGateway/docs#232 — the
seventh cutover step, which 16b had left on `edge`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-09 22:04:27 -05:00
5f58a4cbf2 Merge pull request 'docs(site): /privacy tells the truth about engagement retention again' (#29) from docs/engagement-retention into main
All checks were successful
Build and publish the image / build (push) Successful in 1m22s
Build and publish the image / deploy (push) Successful in 11s
PR checks / checks (push) Successful in 1m32s
Reviewed-on: #29
2026-09-01 20:57:24 +00:00
82b55e3084 docs(site): /privacy tells the truth about engagement retention again
All checks were successful
PR checks / checks (pull_request) Successful in 1m23s
ENGAGEMENT.md Phase 14. The deploy-engagement entry in the collection
inventory said "Kept until the operator removes them; nothing here expires
on its own" and, in its detail, "the delivery log, the suppression list and
the per-person rate limits have no retention sweep, so they are as long as
the site is old". That was the true answer until Phase 14 landed a sweep,
and it is the kind of sentence a Play reviewer reads.

It now describes what the code does: a nightly sweep with an
operator-settable horizon per table (180 days for the delivery log, 30 for
the queue and the rate limits), and the two things that deliberately do NOT
expire — an item still waiting to be sent, because it is a message the site
still intends to deliver, and the suppression list, because ageing an entry
out would mean mailing an address that already bounced.

PLAY_DATA_SAFETY.md is unchanged and check:datasafety stays green:
deploy-engagement is deployment-scoped, and the Play answers are generated
from the app-scoped entries, because Play asks what the app collects rather
than what a self-hosted deployment keeps.

All ten checks green, 42 tests pass.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 15:41:14 -05:00
a0af01026e Merge pull request 'docs(site): the engagement system — cutover 7 of 7 (edge → main)' (#28) from edge into main
All checks were successful
PR checks / checks (push) Successful in 1m38s
Build and publish the image / build (push) Successful in 3m30s
Build and publish the image / deploy (push) Successful in 27s
Reviewed-on: #28
2026-09-01 18:04:20 +00:00
5c6735bd1d Merge pull request 'chore(facts): protocol 5, Module API 1.9.0, and the 2026.09.01 bundle' (#27) from docs/platform-facts-protocol-5 into edge
All checks were successful
PR checks / checks (pull_request) Successful in 1m24s
Reviewed-on: #27
2026-09-01 17:45:16 +00:00
5e987518c6 fix(checks): read files through Gitea's contents endpoint, not raw
The three checks that read another repository -- checkFacts, checkQuickstart,
checkReference -- fetched source files from the API's `raw` route, which answers
`Cache-Control: public, max-age=21600`. The CDN in front of Gitea caches that,
so the checks can read a blob most of a working day old.

It bit on cutover day. checkFacts reported

    FAIL  moduleApi
          platform.json says : 1.9.0
          website main:server/src/modules/version.js says : 1.6.0

against a `main` that says 1.9.0 -- the served copy was two weeks old
(`cf-cache-status: HIT`, `Age: 15713`, `last-modified: 18 Aug`). No edit in this
repository could have made it pass, and the same run reported a bundle triple
that had already been republished as still current: a stale read fails BOTH
ways, and the false pass is the dangerous one.

The `contents` endpoint answers `private, must-revalidate`, which the CDN
bypasses, so it is always the ref's current blob. The cost is a JSON parse and a
base64 decode. checkReference's canonical-document existence loop already used
it, which is why that half was never affected.

PLAN.md 12 records the finding next to the check it constrains.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 12:41:01 -05:00
3574bba4d5 chore(facts): the cutover values -- bundle 2026.09.01, sidecar v2.1.0, overlay v1.1.0
Phase 13 landed while this branch waited: link/servuo-plugins/website/Module-uo
are all on main, the bundle was republished at 2026.09.01, and Module-uo cut
v1.1.0. These are the four values PLAN.md 12 said could not be written until the
republish existed, plus the two releases that moved with it.

Every one is the value checkFacts.mjs itself reports as the authority's answer:

  bundle.tag       2026.08.19 -> 2026.09.01
  bundle.sidecar   v2.0.0     -> v2.1.0
  bundle.overlay   v1.0.0     -> v1.1.0
  releases.link    v2.0.0     -> v2.1.0
  releases.Module-uo v1.0.2   -> v1.1.0
  verifiedOn       2026-08-19 -> 2026-09-01

No page hardcodes any of them -- every quote is an interpolation of
platform.bundle.* or platform.releases.*, so there is nothing else to re-read.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 12:36:12 -05:00
d01fd55f43 Merge branch 'edge' into docs/platform-facts-protocol-5 2026-09-01 13:19:46 +00:00
83c438885e Merge pull request 'docs(admin): the engagement rules screen, and the privacy inventory an engagement mailer changes' (#26) from docs/engagement-rules-and-privacy into edge
Reviewed-on: #26
2026-09-01 13:18:56 +00:00
391a4a131b chore(facts): protocol 5 and Module API 1.9.0 -- HOLD until the cutover
Engagement Phase 12b. **This branch is deliberately red and must not be merged until the
Phase 13 cutover has landed link and servuo-plugins on `main` and CI has republished the
bundle.** checkFacts.mjs fetches every value from the source repo's `main`, so it fails
today exactly as designed:

  FAIL  protocol (sidecar)   platform.json 5   link main 4
  FAIL  protocol (overlay)   platform.json 5   servuo-plugins main 4
  FAIL  moduleApi            platform.json 1.9.0   website main 1.6.0
  FAIL  protocol (bundle)    platform.json 5   installer bundles:current.json 4

Every other check is green on this branch: checkLinks 2503, checkReference 22, checkA11y,
checkCsp, checkSidebar, 42 tests.

moduleApi is **1.9.0**, not the 1.7.0 the plan named -- Phase 11 moved it twice after that
sentence was written (1.8.0 for the `admin` ceiling, 1.9.0 for the module seed API).

## Also: the one page whose whole subject is the protocol number was the one page that
## hardcoded it

platform.json's own header says no version number is ever hardcoded in prose, and
`architecture/protocol-versions.mdx` had `4` written out three times -- the headline
sentence and both declaration-site rows. It now imports platform.json like every other
page that quotes a fact, so it moves with the JSON and cannot say 4 while the JSON says 5.

Note the interpolation is OUTSIDE the code spans: MDX does not evaluate an expression
inside backticks, so `PROTOCOL_VERSION: u32 = {platform.protocol}` would have rendered the
braces literally. Verified in the built HTML -- "currently 5", and no `platform.protocol`
survives anywhere in the output.

The same page's "what a bump obliges" section said version 4 was the first bump to need a
store migration, which read as though every bump does. v5 needed none (it only widens
frames the store already keeps whole -- docs link/v5.md), so the sentence now says which
did and which did not, and v5.md joins v4.md under canonical documents.

## The fill-in step, at merge time

Three values are not knowable today because the artefact does not exist yet. After the
bundle republishes, run:

    GITEA_TOKEN=<token> node scripts/checkFacts.mjs

and copy what it prints in the "says" column into src/data/platform.json:

  * bundle.tag, bundle.sidecar, bundle.overlay -- from installer bundles:current.json
  * releases.Module-uo -- if the cutover cuts a new module release
  * verifiedOn -- the date you ran it

Then re-read `getting-started/connect-a-game-server` and `administration/the-shard-connection`,
which quote the bundle, before merging. The check is green when all 19 agree.

AI-assisted: written with Claude Code.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 08:14:09 -05:00
c8a293b8f6 docs(admin): the engagement rules screen, and the privacy inventory an engagement mailer changes
Engagement Phase 12a. The site had pages for where a message goes (Notifications and
email) and what it says (Message templates), and nothing at all for what makes one get
sent -- the four Engagement screens the workstream built.

New page: Engagement rules. Rules, Audiences, the trigger catalog and the send log on
one page, sitting between the two it joins up. Templates already has its own page and
Suppressions is in Troubleshooting, so neither is repeated here.

Two things it exists to state plainly:

  * Every rule ships disabled, including the ones a module brings. "Installed" is not
    "on", and an upgrade whose Team mail went quiet is the same fact.
  * The ceiling is a TREE, not a ladder. The tempting reading -- a staff-only event
    could obviously also go to one person -- is wrong, and the example is the argument:
    "one person" for cheat detection is the player it was detected on.

Troubleshooting gains the symptom that page answers ("nothing is sent for one
particular event"): the rule is off, the rule is dormant, its own cooldown held it, or
the audience is empty.

Privacy: two rows the engagement work makes necessary, and one sentence it made false.

  * app-content claimed "Nothing is cached for offline use". Phase 8 shipped a DataStore
    snapshot of the inbox, so it was untrue -- and that row feeds the generated Play Data
    Safety answers, which is a store-review matter rather than a doc nit. The snapshot now
    has its own row and its own Play mapping (Messages / Other in-app messages; not
    collected by us, stored on the device), and app-content's claim is narrowed to
    everything else.
  * deploy-engagement, for the deployment scope: an address is now used for more than
    getting into an account, there is a delivery log holding a one-way hash of it, and
    there is a suppression list. Its retention line says what is true rather than what a
    reader assumes -- none of these tables has a retention sweep.

PLAY_DATA_SAFETY.md regenerated from the inventory; legal.lastUpdated moved with the page
it dates.

Verified: the whole `verify` chain green -- checkSidebar (plannedSidebar moved with the
live tree), checkFacts 19/19, checkQuickstart 59, checkReference 22, checkLinks 2605,
checkA11y, checkCsp, playDataSafety --check, 42 + 7 tests. Read in a browser as well, in
the served build.

AI-assisted: written with Claude Code.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 08:11:10 -05:00
916921551f Merge pull request 'docs(troubleshooting): suppression and bounces (engagement Phase 9)' (#25) from docs/engagement-deliverability into edge
Reviewed-on: #25
2026-08-31 15:53:24 +00:00
7709b055a4 docs(troubleshooting): suppression and bounces (engagement Phase 9)
ENGAGEMENT.md §6.0b assigns this repo the operator-facing half of Phase 9
(website#176 + docs#191). Two new sections, split along the line that actually
matters when somebody reports it.

"One person stopped receiving email" is the suppression case, and the three
things an operator gets wrong about it: they can still reset their password
(suppression scopes to engagement rules only, so that is the expected shape of
the problem rather than a contradiction); Not sent, Bounced and Failed in the
Send Log mean three different things and only one of them is about your
configuration; and no row at all means they were excluded before anything was
queued, by an opt-in or by the verification gate.

"Everyone stopped receiving email at once" exists to stop the wrong reflex. A
whole-deployment stop is never the suppression list — a wrong password never
suppresses anybody, only the receiving server naming a specific dead mailbox
does — and it says so before an operator starts clearing rows.

Also notes that lifting a suppression asks for the full address because
addresses are stored one way, so it reads as the privacy design rather than a
missing feature.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 10:49:26 -05:00
92a1a33121 Merge pull request 'docs(admin): on-site notifications, and the defaults page that was wrong' (#24) from docs/engagement-inapp-channel into edge
Reviewed-on: #24
2026-08-31 07:23:21 +00:00
283814dbf2 docs(admin): on-site notifications, and the defaults page that was wrong
Engagement Phase 7 gives the platform a third notification channel — an inbox
on the site itself — so `notifications-and-email.mdx` gains a section for it:
what it is, that it is the one channel on by default, that its body is always
plain text, and that the nightly prune takes read items only.

Two corrections in the same file, both of which were already false before this
phase and would have become misleading with it:

- "Who receives what" said push was opt-OUT. Push stream subscriptions have
  always been opt-IN, and engagement Phase 3 made that explicit in the channel
  registry. Rewritten as three defaults plus the Team mute that overrides all
  three, and pointed at the preferences grid the same phase gave the web.
- `capabilities.mjs` claimed "Web, push and email … push arrives by default".
  The web channel did not exist until now and push has never arrived by
  default. Reworded to name the on-site inbox as the opt-out one.

The push section now says what a tickle raised by an engagement rule carries,
and that it is still only a pointer.

Code: RunicGateway/website#TBD · Docs: RunicGateway/docs#TBD

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 02:07:35 -05:00
782e3df7e2 Merge pull request 'docs(admin): Team notification emails are engagement rules now' (#23) from docs/engagement-email-channel into edge
Reviewed-on: #23
2026-08-31 06:07:33 +00:00
e0d491d33b docs(admin): Team notification emails are engagement rules now
The Teams page gains the section engagement Phase 6 owes it. The operator-facing
fact is the one that has to land first: Team emails used to send with no
configuration and now arrive switched off, so nobody gets them until a rule is
turned on in Admin → Engagement → Rules.

Also says what each of the four seeded rules sends and why the two roster ones
ship off, that a rule decides whether the site sends at all while members still
choose per Team, why a digest is re-read at send time, and that unsubscribe links
in mail already sent still work — now stopping the emails they came with without
also silencing that Team's push.

Site: RunicGateway/website#TBD · Docs: RunicGateway/docs#TBD

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 20:12:27 -05:00
ebd6ba8dbf Merge pull request 'docs(admin): a page for the message-template editor' (#22) from docs/engagement-template-editor into edge
Reviewed-on: #22
2026-08-29 23:37:07 +00:00
289b6b3a8d docs(admin): a page for the message-template editor
The operator half of engagement Phase 5b, which 6.0b of ENGAGEMENT.md assigns to
this repo: what the Templates screen is for, how a shipped default is edited in
place without an upgrade taking the edit back, why variables are clicked rather
than typed, the two halves of every message, draft vs published, the preview and
its dark-mode approximation, test sends, duplicating to make a new template, and
the send log.

Written for someone running a site, not someone reading the design document: it
explains what to do and why the refusals exist, and names no version numbers - the
facts check reads authority from each repo's `main`, and none of this is there yet.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 18:14:21 -05:00
5ba91717c0 Merge pull request 'docs(admin): email is SMTP now, and the Gmail connect flow is gone' (#21) from docs/engagement-phase-1-smtp into edge
Reviewed-on: #21
2026-08-29 02:10:14 +00:00
2da014790e docs(admin): email is SMTP now, and the Gmail connect flow is gone
Engagement Phase 1's share of this repo (docs/website/ENGAGEMENT.md
§6.0b). Four pages described a delivery path that no longer exists — one
of them under the heading "There is no SMTP option", which is now the
opposite of true.

notifications-and-email.mdx: the Email section is rewritten around the
three postures the org lead settled on (§7.1 Q5), leading with a relay
and naming smtp.gmail.com:587 with an app password as the migration off
OAuth2. Two cautions carry the failures that produce no error at all —
Implicit TLS left on for port 587, which hangs, and an operator-typed
sender the relay will not accept, which is an SPF/DMARC rejection that
looks like nothing. Send test is what proves both.

troubleshooting.mdx gains those two, plus the enabled toggle, which now
gates every message rather than some of them.

configuration.mdx loses the "set up Google first" ordering constraint,
which is gone with the borrowed client. system-architecture.mdx's
encrypted-at-rest list is corrected: the Gmail refresh token is replaced
by the transport credentials, which are write-only like the sidecar
token.

This lands on `edge`, so nothing here is published while `main` still
carries the Gmail flow. platform.json and capabilities.mjs are untouched
— they belong to Phase 12.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-28 21:01:03 -05:00
268e1c98fc Merge pull request 'fix(footer): send each documentation link to the section its label names' (#20) from fix/footer-docs-links into main
All checks were successful
PR checks / checks (push) Successful in 1m35s
Build and publish the image / build (push) Successful in 1m43s
Build and publish the image / deploy (push) Successful in 31s
Reviewed-on: #20
2026-08-27 05:31:35 +00:00
12c416f5fc fix(footer): send each documentation link to the section its label names
All checks were successful
PR checks / checks (pull_request) Successful in 9m38s
All three links under "Documentation" pointed at /docs/. Three labels — Getting
started, Administration, Building a module — and one destination, which is the
docs home and also what the header's Docs link already opens.

They now land inside the section they name:

  Getting started     /docs/getting-started/requirements/
  Administration      /docs/administration/configuration/
  Building a module   /docs/modules/building-a-module/

The docs home stays the header's link rather than becoming a fourth route to the
same page.

Eleven checks and two suites could not see this, and the reason is worth keeping:
the bug is not a broken link. checkLinks resolves every internal href against the
build and /docs/ resolves — three links to a page that exists are three valid
links. checkSidebar compares the docs tree to the planned tree and never looks at
the footer. checkA11y checks structure, and three correctly marked-up anchors are
correct markup. Nothing asserted that a link goes where its label says.

So the columns move to src/data/footer.mjs, beside legal.mjs and collection.mjs,
and test/footer.test.mjs asserts it: every destination in a column distinct, no
destination repeated across columns, each documentation link inside its own
section prefix, none of them the docs home, and the two Project links still read
from the brand. A column list inside an .astro component cannot be imported by a
test, which is the whole reason for the move.

Verified by reintroducing the bug: the suite fails with "Administration points at
/docs/, which is not inside /docs/administration/". Restored, npm run verify is
green — 42 tests, eleven checks, and the built index.html renders three distinct
hrefs.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-27 00:30:15 -05:00
ce13a44ed7 Merge pull request 'feat(compose): publish on every interface, so the host's own address answers' (#19) from feat/publish-on-all-interfaces into main
All checks were successful
PR checks / checks (push) Successful in 1m27s
Build and publish the image / build (push) Successful in 1m54s
Build and publish the image / deploy (push) Successful in 10s
Reviewed-on: #19
2026-08-27 05:07:31 +00:00
b86f4cabf0 feat(compose): publish on every interface, so the host's own address answers
All checks were successful
PR checks / checks (pull_request) Successful in 9m56s
D55 bound the published port to 127.0.0.1, on the reasoning that TLS terminates
at a proxy on the same host and nothing else has business reaching the container.
That is right for the host this ends up on and wrong for every step before it: a
loopback binding cannot be opened from a browser on another machine, which is the
first thing an operator wants to do — look at the site on the VM's own address,
before DNS exists, before the proxy exists, from a desktop or a phone.

The port line is now "${SITE_BIND_ADDR:-0.0.0.0}:${SITE_HOST_PORT:-4321}:4321",
so http://<vm-ip>:4321 answers out of the box, the way a normal bridge publish
behaves. Which addresses it answers on is a variable rather than an edit:
SITE_BIND_ADDR narrows it to one interface, or back to loopback, without touching
a file that `docker compose pull` replaces. That also retires the "change the port
line yourself" instruction DEPLOY.md had to give a proxy running in another
container or on another machine.

What is given up, said plainly in DEPLOY.md §3.1: on a host with a public address
the default answers on port 4321 from the internet, plain HTTP beside the proxy's
443, with no proxy in the path to set X-Forwarded-For — so signups arriving that
way share one rate-limit bucket. There is no login and no secret behind it, so it
is untidy rather than dangerous, and both remedies are named (firewall the port,
or narrow the binding).

Verified by running it, not only by reading it: `docker compose config` accepts
both bindings and resolves host_ip 0.0.0.0 and 127.0.0.1 respectively; the stack
came up healthy, `docker compose port site 4321` reported 0.0.0.0:4321, and the
site answered 200 on both 127.0.0.1 and the machine's LAN address, still carrying
its own per-page CSP.

Recorded as D59, amending D55. Count of record fifty-nine.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-26 23:42:45 -05:00
e60812fb34 Merge pull request 'fix(image): ship node_modules in three layers, and count them before pushing' (#18) from fix/registry-layer-cap into main
All checks were successful
PR checks / checks (push) Successful in 1m20s
Build and publish the image / build (push) Successful in 4m3s
Build and publish the image / deploy (push) Successful in 46s
Reviewed-on: #18
2026-08-27 04:23:18 +00:00
92f00ab20a fix(image): ship node_modules in three layers, and count them before pushing
All checks were successful
PR checks / checks (pull_request) Successful in 9m47s
The merge that landed phase 12 built its image and could not publish it.
`docker push` answered 413 Payload Too Large on one blob and stopped, so the
registry stayed empty and `needs: build` meant the deploy never ran — 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: the instance is proxied, and Cloudflare
refuses a request body over 100 MB below Enterprise. A push uploads each layer
as one monolithic PUT, so the ceiling is per layer and the rejection happens at
the edge, where Gitea never sees it and no Gitea setting can lift it.

One layer was over, by nine megabytes: COPY node_modules at 108.8 MB compressed,
in a 188.6 MB image whose next largest layer is the 47.6 MB Node base. @pagefind
and @img (sharp's libvips) account for it and both are needed at run time — the
boot rewrite re-indexes the site and re-derives the brand images — so what could
move is where they land, not whether they ship.

The build stage moves them aside after `npm prune` and the runtime stage copies
them as their own layers: 46.8 + 50.5 + 11.5 MB, and the image is exactly the
same total size, the same bytes divided differently. Moving rather than copying
twice keeps the three disjoint, so a dependency added later needs no maintenance
here.

A split is a margin and not a guarantee, so the workflow now counts layers before
it pushes: docker save, re-compress anything over 8 MB the way the push would,
fail at 90 MB — not 100, the blob is not the only thing in the request — naming
the layer and what would have happened. Tested in both directions; on the broken
image it reports 108 MB, which is what the registry recorded.

Verified by running the built container, not only by measuring it: healthy in
~25s, the mounted brand rewritten across 51 files, 50 pages re-indexed by
pagefind, and the favicon served as `x-brand-source: derived:mount` — which is
sharp resolving from its new layer. npm run verify is green, all eleven checks
and both suites.

Recorded as D58; DEPLOY.md gains the symptom and what it means for the host
(nothing — the running container is untouched).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-26 23:10:34 -05:00
3b4067fa37 Merge pull request 'feat(delivery): phase 12 — the container, and the defect only a proxy could find' (#17) from feat/phase-12-delivery into main
Some checks failed
PR checks / checks (push) Successful in 1m31s
Build and publish the image / build (push) Failing after 1m58s
Build and publish the image / deploy (push) Has been skipped
Reviewed-on: #17
2026-08-27 03:53:50 +00:00
1558111050 docs(deploy): correct the image size, and say what a missing rgcom runner does
All checks were successful
PR checks / checks (pull_request) Successful in 1m19s
The image measures 757 MB, not the ~600 MB the requirements table guessed. And
until the rgcom runner exists the deploy job simply queues, which is worth stating
in both the workflow and DEPLOY.md: build has already published the image by then,
so the manual pull works throughout and the queued job goes when the runner does.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-25 16:57:25 -05:00
f2e59a2426 feat(delivery): phase 12 — the container, and the defect only a proxy could find
All checks were successful
PR checks / checks (pull_request) Successful in 9m46s
PLAN.md §13 phase 12, the last one. Four decisions of record, D54–D57, taking the
count to fifty-seven; recorded in §6, "How phase 12 delivered it".

A two-stage Dockerfile, a pull-only docker-compose.yml carrying both bind mounts,
.env.example, the workflow that publishes and deploys, CONTRIBUTING.md, the
community-health files this was the only repository of the ten to lack, and
DEPLOY.md.

D54 — a merge deploys, amending D6. build-image.yml pushes
runicgateway-site:latest and :sha-<7>, then rolls the container over on the
`rgcom` runner out of /opt/runicgateway.com, and waits for the container's own
healthcheck rather than for `up -d` to return.

D55 — the site runs on its own host behind a generic reverse proxy, so DEPLOY.md
states the four requirements rather than one worked example, and the container
binds 127.0.0.1 so the safe configuration is the default.

D56 — @astrojs/node derives the request protocol from req.socket.encrypted and
never reads x-forwarded-proto, so behind a TLS-terminating proxy the browser sends
Origin: https://… while the container computes http://… and Astro's CSRF check
compares them for equality. Every beta signup, from every visitor, was answered
403. serve.mjs now normalises both forwarded headers, unconditionally — the image
should deploy and work. Two assertions in test/headers.test.mjs hold both halves.

D57 — DEPLOY.md rather than a README section; SECURITY.md and CODE_OF_CONDUCT.md
are pointers to the org's copies rather than copies, because a copy would hard-code
the contact address D13 confines to brand.json.

Verified: npm run verify green (eleven checks, 36 unit tests, 7 served tests,
astro check 0 errors). The image was built and run with both mounts — a mounted
brand reached 51 files and all 50 search pages, /brand/* fell back per file, a
proxy-shaped signup reached the store, and the export CLI wrote both Play files to
the host mount. docker compose config caught a YAML trap in the healthcheck: a
block sequence reads the `: ` in `r.ok ? 0 : 1` as a mapping.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-25 16:54:38 -05:00
18064062a9 Merge pull request 'feat(validation): phase 11 — the walk that found what the checks could not' (#16) from feat/phase-11-validation into main
All checks were successful
PR checks / checks (push) Successful in 1m18s
Reviewed-on: #16
2026-08-25 20:36:57 +00:00
de9d25bbe7 feat(validation): phase 11 — the walk that found what the checks could not
All checks were successful
PR checks / checks (pull_request) Successful in 1m26s
The checks were green before this phase started and are green now. What found
anything was the part no script does: fifty pages at three widths in a real
browser, a signup walked against its store, and a full brand mount applied and
restarted.

D51 — the chrome and the head follow the mount; the consent sentence does not.
With a complete brand.json mounted, forty-nine pages came back rebranded and
/beta did not. applyBrand.mjs rewrites files in dist/client and /beta renders
per request, so its HTML never exists as a file to rewrite; liveBrand() was
there for exactly that and was used for betaOptInUrl alone. Everything around
the form — title, OG tags, header lockup, footer Source and Discord links —
came from the shared chrome, and the shared chrome was baked. renderBrand()
picks by Astro.isPrerendered, in one place, so the other forty-nine keep taking
the value the boot rewrite will replace. CONSENT_TEXT stays a constant: it is
stored verbatim in a person's row, so following a mounted name would change the
recorded text of a consent already given.

D52 — the documentation half gets phase 10's skip-link fix. Starlight's skip
link targets the page <h1>, which is no more focusable than the <main> phase 10
fixed, so following it moved the viewport and not the focus on forty pages. A
PageTitle override adds tabindex="-1".

D53 — no twelfth check. The external-link sweep (73 of 74 alive) and the
brand-mount walk stay throwaway scripts: one would make the build depend on
other people's uptime and the other needs Chrome on the runner.

Also recorded and deliberately not fixed: Starlight's heading anchor links
measure under 24px at 390, and are exempt under SC 2.5.8's Equivalent clause
because the mobile table of contents links to every one of the same anchors.

npm run verify green — fourteen steps, both suites, all eleven checks.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-25 15:16:08 -05:00
58883951a4 Merge pull request 'feat(polish): phase 10 — search, accessibility, SEO and a real CSP' (#15) from feat/phase-10-polish into main
All checks were successful
PR checks / checks (push) Successful in 9m35s
Reviewed-on: #15
2026-08-25 19:28:51 +00:00
e71ff4acd4 feat(polish): phase 10 — search, accessibility, SEO and a real CSP
All checks were successful
PR checks / checks (pull_request) Successful in 9m36s
PLAN.md §13 phase 10, with four decisions of record — D47-D50, taking the count
to fifty. Three were straightforward; the CSP turned into the phase's real work,
because the thing meant to be a configuration flag was broken in a dependency and
broken silently.

D47 — search reaches the marketing pages, and the header gets a box.
Base.astro marks its <main> as a Pagefind body, so all ten join the index the
docs already query, and Search.astro opens it in a <dialog>. Nothing is fetched
until the dialog is opened (the bundle is 120 kB and these pages otherwise ship
almost no JavaScript). Pagefind titles a result from the first <h1>, and these
pages have editorial ones — "The app for a deployment you already use" — so the
index is given the page's short name instead. applyBrand.mjs now re-indexes after
a rewrite, closing a note phase 2 left for this phase.

D48 — the CSP is a real response header, sent by the container. Not a <meta>,
which ignores frame-ancestors, and not advice for someone's reverse proxy, which
puts the strictest promise in §6 outside what this repo tests. Three things
fought it, all the same shape — correct build, broken page, no error:

  * Astro does not hash <script is:inline>, and Starlight ships six per docs
    page, so the first build with CSP on had a strict header and a dead theme
    switcher. The hashes are now generated into src/config/cspHashes.mjs and
    checkCsp.mjs verifies every inline block against its own page's policy.
  * Expressive Code writes ~3,700 inline style ATTRIBUTES, which cannot be
    hashed, hence style-src-attr 'unsafe-inline' — scoped to that directive, so
    script-src is untouched.
  * @astrojs/node matched a request to a policy with pathname.includes(), a
    substring test: /modules/ was served /docs/modules/building-a-module's
    policy and rendered with its own stylesheet refused. scripts/serve.mjs keeps
    the same _headers.json and matches by equality; test/headers.test.mjs starts
    the server and reads the responses, because nothing that reads dist/ can see
    this.

D49 — robots.txt allows everything and names the sitemap (there was no way to
find it: no robots.txt, and D9 rules out a search console). D50 — Organization
and SoftwareApplication, no ratings and no docs-wide Article markup.

checkA11y.mjs is the eleventh check: seven structural rules over all fifty pages,
verified by breaking each in turn. The walk at 390/768/1280 found no overflow
anywhere, the CSP violations above, a 17x17 consent checkbox (WCAG 2.2 SC 2.5.8
wants 24), and a skip link that moved the scroll but not the focus.

npm run verify is green: fourteen steps, both test suites, all eleven checks.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-25 14:14:53 -05:00
a34c2ce538 Merge pull request 'feat(screens): retake the two frames a signed-in character changes' (#14) from feat/screens-signed-in-retake into main
All checks were successful
PR checks / checks (push) Successful in 9m51s
Reviewed-on: #14
2026-08-25 15:38:38 +00:00
8b6efd5c0a feat(screens): retake the two frames a signed-in character changes
All checks were successful
PR checks / checks (pull_request) Successful in 1m13s
The org lead signed a character into the shard by hand — the step automation
could not reach — so shard-status and app-shard were retaken. The shard page
now reads 1 player online in Britain, and the app's card agrees.

Two things worth noticing in the retake. Presence reaches the public page as
counts and regions rather than names, which is the visibility framework working
unprompted. And the guild board's online column did not move: it refreshes only
when a guild's signature changes, which is the stale-roster defect wearing a
different hat.

A sixth defect, visible in the shipped phone capture: the app interpolates a
count into a fixed plural and says "1 players online". Raised, not fixed —
it wants a plurals resource in the app.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-25 10:09:52 -05:00
ebb3f71786 Merge pull request 'feat(screens): phase 9 — real screenshots, from a real shard' (#13) from feat/phase-9-screens into main
All checks were successful
PR checks / checks (push) Successful in 1m16s
Reviewed-on: #13
2026-08-25 15:04:19 +00:00
c29ec94f46 feat(screens): phase 9 — real screenshots, from a real shard
All checks were successful
PR checks / checks (pull_request) Successful in 1m25s
D4 asked for screenshots of the review stack rather than placeholders. Seventeen
of them: eleven of the site in a browser, six of the app on a phone, all from one
demo deployment wired to a running ServUO shard over a real sidecar, captured on
one day (D42).

The deployment is branded "Runic Gateway Demo" rather than a real community (D43),
and the captures sit beside the claims they support — the homepage, /features/, and
five of the administration pages phase 7 could describe but not show (D44).

The rig is committed rather than remembered (D45):

  scripts/seedDemo.mjs        content, by driving the site's own API — never SQL,
                              because a row the product could not have produced is
                              a screenshot of a product that does not exist
  src/data/screens.mjs        every capture: route, viewport, scroll, alt, caption
  scripts/captureScreens.mjs  npm run screens:capture
  scripts/checkScreens.mjs    the ninth check script, in CI

Shard-side dressing is servuo-plugins' scaffolding (D46), never deployed.

The rig found five things nothing else had. One is fixed upstream — a fresh
module-uo install pinned wire protocol 3 against a sidecar speaking 4, released as
v1.0.2, which this repo's own facts check then caught in platform.json. Four are
raised as product observations and worked around in the rig: a renamed guild
member never reaches the site, a guild deleted while the shard is down is a ghost
row forever, "Houses in danger" cannot show a house that was already collapsing,
and the app's news list prints raw ISO timestamps.

Players online reads 0. Logging a character in needs a UO client driven by hand,
and that is where this stopped — PLAN.md §10 says exactly why, and how to retake
the two frames that would change.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-25 09:55:55 -05:00
31d914ba44 Merge pull request 'docs(builder): phase 8 — modules, architecture and reference' (#12) from feat/phase-8-docs into main
All checks were successful
PR checks / checks (push) Successful in 1m11s
Reviewed-on: #12
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-24 17:33:22 +00:00
b3cb6bf1eb Merge pull request 'fix(docs): clear the quickstart drift the upstream fixes caused' (#11) from fix/quickstart-drift-and-installer-note into main
Some checks failed
PR checks / checks (push) Has been cancelled
Reviewed-on: #11
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-24 17:32:49 +00:00
d89ce06bb8 docs(builder): phase 8 — modules, architecture and reference
All checks were successful
PR checks / checks (pull_request) Successful in 1m13s
Twenty pages completing the tree section 10 planned: Modules (8), Architecture
(5) and Reference (7). Four decisions, D38-D41, recorded in PLAN.md section 10.

D39 is the one that shaped the phase. Section 1 forbids re-specifying a
contract, and a Reference section is exactly where that rule is most tempting to
break, so the line is drawn at names: every environment variable, config key,
installer command, visibility rung and canonical document is listed with one
terse line saying what it is FOR, while shapes, semantics and every "why" stay
in the canonical document.

That is only safe because the names are checked. checkReference.mjs compares six
enumerations against the repositories that own them, over the Gitea API, as set
comparisons in BOTH directions -- and the second direction is the one that earns
its keep, because a reference page does not usually rot by describing something
that vanished, it rots by quietly not mentioning what was added since.

The check went green on its first run, which is the least trustworthy possible
outcome, so it was verified by breaking it: seven mutations, all caught. The one
worth keeping is the visibility ladder REORDERED with its membership unchanged
-- it is a security boundary, and a set comparison alone would have passed it.

D41 turns plannedSidebar from a checklist into a checked invariant, and finding
out why was the phase's first defect: it had already drifted, because phase 7
added the Content page under D37 and never updated the list. Nothing failed,
because nothing read it. checkSidebar.mjs now asserts the two trees agree on
groups, labels and order -- order because the order of Getting started IS the
installation path.

Two more things the writing found. PLAN.md's page count was wrong and had been
since section 10 was written ("roughly 38, 37 planned" for a tree of forty).
And module.json's `mounts` and the SPA's paths are different mechanisms that no
single document stated plainly -- module-uo declares admin: ["/shard",
"/uo-link"] while its screen lives at /admin/uo/link, because API routes are
deliberately NOT namespaced while SPA routes are. That is precisely the
distinction the installer got wrong in v0.1.0, and it now has a named home.

D40: the docs link to /architecture/'s drawn diagrams rather than importing
them. Those components carry marketing chrome and depend on diagram.css, which
Starlight does not load; the docs use text diagrams, which paste into an issue.

npm run verify green: 40 pages across 5 groups agree with plannedSidebar, 2390
internal links resolve, 123 repository links point at a branch, 19 facts, 59
quickstart checks, 22 reference enumerations, astro check 0 errors, 36 tests.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-24 12:17:31 -05:00
e8cb6061fe fix(facts): installer v0.1.1 is released, so the note names a version
All checks were successful
PR checks / checks (pull_request) Successful in 9m22s
Recovering installer's orphan tag published v0.1.1, which moved the platform
under this branch and turned checkFacts red -- the check working exactly as it
should, since a version this site quotes had changed.

  FAIL  release installer
        platform.json says : v0.1.0
        installer releases/latest says : v0.1.1

The 500 that orphaned the tag was a race with the tag push one second earlier,
not a structural failure: re-running the workflow took the built-in orphan-tag
recovery path and published all four assets unchanged.

So the stale-path Aside stops saying "v0.1.0 is still the current download",
which is no longer true, and says the durable thing instead -- v0.1.0 prints
the old path, v0.1.1 prints the real one -- which stays correct however many
releases follow. platform.json and the PLAN.md version table move to v0.1.1,
and the phase 7 findings record the pipeline defect as a fourth finding.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-24 11:50:38 -05:00
a993b872ac fix(docs): clear the quickstart drift the upstream fixes caused
All checks were successful
PR checks / checks (pull_request) Successful in 1m1s
The three defects phase 7 found are fixed and merged: website#163
(SECRET_ENC_KEY missing from the root .env.example, plus BOT_INTERNAL_KEY in
the README's "set at least" list) and installer#22 + docs#174 (the handoff
printing /admin/shard).

website#163 turned checkQuickstart red here, which is precisely what the
declaration was built to do -- it fails the moment a declared key appears
upstream, so the note describing the omission cannot outlive the defect. The
SECRET_ENC_KEY entry is deleted and notInUpstreamEnvExample is now empty; the
export stays so the next divergence gets an entry rather than passing quietly.

The stale-path Aside on Connect a game server is pinned to v0.1.0 rather than
calling the installer permanently wrong, and now says WHY the old path is worse
than a 404: the SPA has no route for it, so it redirects to the dashboard and
the link looks like it worked.

v0.1.0 is still the current download, and not only because releases lag. The
release run for installer#22 built every artifact and pushed tag v0.1.1, then
took a 500 creating the release -- so the tag is orphaned and no binaries were
published. Raised on installer; nothing is worked around here.

This also recovers 084ee0b, which was pushed to feat/phase-7-docs after PR #10
had already merged f499f2b, and so never reached main.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-24 11:39:36 -05:00
bcb633403f Merge pull request 'docs(journey): phase 7 — the installation path and administration' (#10) from feat/phase-7-docs into main
All checks were successful
PR checks / checks (push) Successful in 1m7s
Reviewed-on: #10
2026-08-24 15:24:18 +00:00
196 changed files with 19982 additions and 1129 deletions

42
.dockerignore Normal file
View File

@@ -0,0 +1,42 @@
# What must never reach the build context.
#
# Two entries here are load-bearing rather than housekeeping, and both are about
# the bind mounts (PLAN.md §6, §7).
#
# brand/ is the OPERATOR's override. If a developer's local mount were copied
# in, the image would ship somebody's test logo as if it were stock —
# and, worse, it would win over brand-default/ on every deployment that
# does not mount its own. The mount is the only way brand/ is allowed
# to exist inside a container.
#
# data/ holds beta.sqlite: real addresses, given under a consent notice that
# says where they are stored. A published image is world-readable to
# anyone who can pull it. This line is the reason that cannot happen by
# accident.
#
# brand-default/ is deliberately NOT here. It is baked in and must always be
# complete; §7's whole per-file fallback rests on it.
node_modules
dist
.astro
.output
brand
data
.git
.gitea
.gitignore
.dockerignore
.env
.env.*
!.env.example
# Authoring inputs and working notes, none of which the running site reads.
PLAN.md
*.log
npm-debug.log*
.DS_Store
Thumbs.db

70
.env.example Normal file
View File

@@ -0,0 +1,70 @@
# runicgateway.com — production environment.
#
# Copy to `.env` beside docker-compose.yml on the host and fill in the two
# secrets. Everything else has a working default; this file exists so the
# defaults are visible rather than discovered.
#
# cp .env.example .env
#
# Nothing here is a credential for another service. The site talks to no API,
# sends no mail (D7) and has no database server — the only state it keeps is a
# SQLite file on the ./data mount.
# ---------------------------------------------------------------------------------------
# Deployment
# ---------------------------------------------------------------------------------------
# Which published build runs. `latest` follows main; pin `sha-<7>` for a
# reproducible deploy or to roll back — every merge publishes both tags.
IMAGE_TAG=latest
# Host port the container is published on.
SITE_HOST_PORT=4321
# Which of the host's addresses that port is published on. The default is every
# interface, so the site answers on the host's own address — http://<vm-ip>:4321
# — which is what a proxy in another container, another machine, or a browser
# elsewhere on the network needs.
#
# Narrow it if this host has a public address and you want only the proxy to
# reach the container: 127.0.0.1 for a proxy on this same host, or one interface
# address for the LAN but not a public NIC. Nothing else in the site changes.
SITE_BIND_ADDR=0.0.0.0
# ---------------------------------------------------------------------------------------
# The closed-beta signup (PLAN.md §8)
# ---------------------------------------------------------------------------------------
#
# THE TWO BELOW ARE THE ONLY VALUES THAT REALLY WANT SETTING. Both default to a
# random value generated per process, which is safe but forgetful: every restart
# invalidates every rate-limit window and every rendered form. That is the right
# default — a hard-coded salt shipped in a public repository would make every
# deployment's ip_hash values identical and therefore reversible by anyone who
# can read it — but it is not what you want on a host that restarts.
#
# Generate both once, keep them, and do not rotate them casually: changing the
# salt orphans the rate-limit history of everyone already counted.
#
# openssl rand -hex 32
# Salts the ip_hash column. The raw IP address is never stored — /privacy says
# so, and this is the mechanism that makes it true while still allowing a
# per-connection limit.
BETA_IP_SALT=
# Signs the hidden form token, so a script has to fetch the page before it can
# post. Rotating this only invalidates forms currently open in a browser.
BETA_FORM_KEY=
# Rows, across all time, above which the form closes and says so on the page.
BETA_TOTAL_CAP=500
# What one connection may do, in a rolling hour and a rolling day.
BETA_PER_HOUR=3
BETA_PER_DAY=24
# Seconds between the page rendering and the form posting. Below the minimum is
# treated as a script; above the maximum the form is stale and re-rendered.
# Twelve hours is the default maximum.
BETA_MIN_SECONDS=2
BETA_MAX_SECONDS=43200

View File

@@ -0,0 +1,48 @@
---
name: Bug report
about: Something on the site is broken, wrong, or behaving unexpectedly
title: "[bug] "
labels:
- bug
---
## Summary
<!-- A clear, concise description of the problem. -->
## Where
<!-- The URL, or the page and the section. If it is a documentation page, the heading. -->
- Page:
- Viewport width, if it is a layout problem:
- Browser and version:
## What happened, and what you expected
<!--
Include exact wording for a factual error, and the console message if there is
one. A screenshot helps for anything visual.
-->
## Is it a factual error?
<!--
The most valuable reports this repository gets are claims that are WRONG about
the platform — a version, a command, a flag, a capability that no longer works
that way. If so, say where the correct answer lives (which repository, which
file), because the fix is usually to a data file or a check rather than to the
sentence.
-->
## Additional context
<!-- Anything else that helps. -->
<!--
Security issue? Do NOT file it here — see SECURITY.md for the private route.
A problem with the PLATFORM rather than with this site (the website, the
sidecar, the shard plugin, the installer, the Android app) belongs in that
repository's tracker. This one only describes them.
-->

View File

@@ -0,0 +1,11 @@
blank_issues_enabled: true
contact_links:
- name: Security vulnerability
url: https://gitea.whitlocktech.com/RunicGateway/runicgateway.com/src/branch/main/SECURITY.md
about: Please do not open a public issue for security problems — report them privately instead (see SECURITY.md).
- name: Questions, help and the Android beta
url: https://discord.gg/t2Jav8yT4g
about: Discord is the front door — instant, and it needs no account here. Bug reports are welcome there too.
- name: A problem with the platform, not the site
url: https://gitea.whitlocktech.com/RunicGateway
about: The website, the sidecar, the shard plugin, the installer and the Android app each have their own tracker. This repository only describes them.

View File

@@ -0,0 +1,38 @@
---
name: Feature request
about: Suggest a page, a section, or a change to how the site explains something
title: "[feature] "
labels:
- enhancement
---
## Problem / motivation
<!--
What were you trying to find out, or do, when the site let you down? A missing
page is easier to judge from the question that went unanswered than from the
page title.
-->
## Proposed solution
<!-- What you would like to see. -->
## Does it belong here?
<!--
Two boundaries this repository holds deliberately (PLAN.md §1):
- The site TEACHES; `docs/` SPECIFIES. Protocol, module API, backend design and
installer behaviour are normative in the docs repository — a page here links
out rather than restating them, so that it cannot drift.
- Absences are stated as data, not implied. If the request is for something the
platform does not do yet, it may belong in src/data/notBuilt.mjs rather than
as a page.
Say which side you think it falls on; being wrong about it is fine.
-->
## Additional context
<!-- Mockups, links, related issues, the repository the change would describe. -->

View File

@@ -0,0 +1,42 @@
<!--
Thanks for contributing to Runic Gateway!
Please fill out the sections below and check every box before requesting review.
Merging to main publishes: it builds the image, pushes it to the registry and
deploys the site. There is no separate release step. See DEPLOY.md.
-->
## What & why
<!-- What does this PR change, and why? Link any related issue: "Closes #123". -->
## How it was tested
<!--
`npm run verify` output is the baseline. If the change touches a page, say what
you looked at and at what width; if it touches the container, say whether you
built and ran the image.
-->
## Checklist
- [ ] I have read [CONTRIBUTING.md](CONTRIBUTING.md).
- [ ] `npm run verify` passes locally (all eleven checks and both test suites).
- [ ] No fact is stated in prose — versions and platform facts come from `src/data/platform.json`.
- [ ] `PLAN.md` still describes what this repository does; a decision it records is either
unchanged or amended here, with the reasoning.
- [ ] My commits are reasonably scoped, with Conventional Commit messages.
## AI-assisted contributions (required)
This project **requires disclosure of AI tool usage**. Please pick one:
- [ ] No AI tools were used to produce this contribution.
- [ ] AI tools were used. Tool(s): `___________`. I have reviewed and understand
every change, and take responsibility for it. AI-authored commits are
marked with a `Co-Authored-By` / `Assisted-By` trailer.
## License
- [ ] I agree that my contribution is licensed under this project's license
(**GNU GPL v3.0 or later**), and I have the right to contribute it.

View File

@@ -0,0 +1,209 @@
# Build the container image, publish it to Gitea's container registry, then roll
# the site onto it — on every merge to main.
#
# Gitea Actions caution, learned elsewhere in this org and repeated from
# pr-checks.yml because it costs one comment and has already cost months: never
# leave an empty template expression anywhere in a `run:` script, not even inside
# a comment. The runner silently SKIPS the whole step without failing the job,
# and the problem is invisible in the workflow list.
#
# Two jobs, in sequence:
#
# build — builds and pushes the image (on `ubuntu-latest`)
# deploy — `needs: build`, so it starts only after a clean build and push, and
# pulls + recreates the stack on the host (on `rgcom`)
#
# Prerequisites, one-time:
#
# • A runner labelled `ubuntu-latest` whose jobs have the host Docker socket
# mounted (/var/run/docker.sock), so `docker build` talks to the host daemon.
# This also gives free layer caching between runs. The org already runs one.
#
# • A runner labelled `rgcom` ON the host that serves the site, able to reach
# the Docker daemon and /opt/runicgateway.com — the directory holding the
# production docker-compose.yml and .env. DEPLOY.md has the registration
# command and the directory layout.
#
# • Two repository secrets (Settings → Actions → Secrets), both of which
# already exist for pr-checks.yml's cross-repository checks:
# REGISTRY_USER — the Gitea username owning the token below
# REGISTRY_TOKEN — a token with write:package (and read:package)
#
# Produces, in gitea.whitlocktech.com/runicgateway/ :
# runicgateway-site:latest + runicgateway-site:sha-<7>
#
# and deploys `:latest`, which is what docker-compose.yml defaults IMAGE_TAG to.
#
# WHY THIS REPOSITORY DEPLOYS AND D6 SAID IT WOULD NOT: D6 was written before
# there was a host to deploy to, and read "ship the image, the org lead deploys".
# The org lead amended it on 2026-08-25 (D54): a marketing site whose content is
# its whole purpose is a bad fit for a manual step between merging a fix and the
# fix being visible. What D6 was protecting — that a bad build cannot reach
# production — is held by `needs: build` instead, plus every check in
# pr-checks.yml having already run on the pull request.
name: Build and publish the image
on:
push:
branches: [main]
workflow_dispatch: {}
concurrency:
group: image-${{ github.ref }}
cancel-in-progress: true
env:
REGISTRY: gitea.whitlocktech.com
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Check out the merged commit
uses: actions/checkout@v4
- name: Derive the image ref
# The registry path must be lowercase for Docker; the org is `RunicGateway`.
run: |
set -euo pipefail
OWNER="$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')"
SHORT_SHA="${GITHUB_SHA:0:7}"
echo "IMAGE=${REGISTRY}/${OWNER}/runicgateway-site" >> "$GITHUB_ENV"
echo "TAG=sha-${SHORT_SHA}" >> "$GITHUB_ENV"
- name: Verify the Docker daemon is reachable
# Fails fast with a clear message if the host socket is not mounted into
# the job container — the one hard runner prerequisite.
run: |
set -euo pipefail
if ! docker info >/dev/null 2>&1; then
echo "::error::Docker daemon not reachable. Mount /var/run/docker.sock into the runner's job containers."
exit 1
fi
echo "Docker daemon OK"
- name: Log in to the Gitea container registry
run: |
set -euo pipefail
echo "${{ secrets.REGISTRY_TOKEN }}" \
| docker login "${REGISTRY}" -u "${{ secrets.REGISTRY_USER }}" --password-stdin
- name: Build
# Two tags from one build: `latest` for the compose default, `sha-<7>` so
# a deploy can be pinned or rolled back to an exact commit.
run: |
set -euo pipefail
docker build -f Dockerfile \
-t "${IMAGE}:latest" \
-t "${IMAGE}:${TAG}" \
.
- name: No layer may exceed the registry's request limit
# Gitea is behind Cloudflare, which refuses a request body over 100 MB on
# every plan below Enterprise, and `docker push` uploads each layer as one
# monolithic PUT. An oversized layer is therefore rejected at the EDGE:
# Gitea never sees it, the log says only `413 Payload Too Large` against a
# blob digest, and the image is not published at all. That is what happened
# on the first merge after phase 12, and the Dockerfile's three-layer
# node_modules split is what fixed it.
#
# A split is a margin, not a guarantee, so this counts the layers before
# the push rather than letting the next fat dependency rediscover the 413.
# 90 MB, not 100: the cap is on the whole request, and the blob is not the
# only thing in it.
#
# Measured by re-compressing what `docker save` writes, because the daemon
# exposes uncompressed sizes only and the limit applies to the compressed
# blob. gzip is what the push uses, so the numbers agree to within a per
# cent; both archive layouts are handled, since a layer is already gzipped
# in one of them and plain in the other.
run: |
set -euo pipefail
LIMIT_MB=90
docker save "${IMAGE}:${TAG}" -o /tmp/image.tar
mkdir -p /tmp/layers
tar -xf /tmp/image.tar -C /tmp/layers
WORST_MB=0
WORST_FILE=""
# Only files big enough to matter; everything else is metadata.
while IFS= read -r f; do
if [ "$(head -c 2 "$f" | od -An -tx1 | tr -d ' \n')" = "1f8b" ]; then
SIZE=$(stat -c %s "$f") # already compressed
else
SIZE=$(gzip -c "$f" | wc -c) # compress it the way the push will
fi
MB=$(( SIZE / 1048576 ))
if [ "$MB" -gt "$WORST_MB" ]; then
WORST_MB=$MB
WORST_FILE=$f
fi
done < <(find /tmp/layers -type f -size +8M)
rm -rf /tmp/image.tar /tmp/layers
echo "Largest layer: ${WORST_MB} MB compressed (limit ${LIMIT_MB} MB)"
if [ "$WORST_MB" -gt "$LIMIT_MB" ]; then
echo "::error::A layer is ${WORST_MB} MB compressed (${WORST_FILE}). Cloudflare rejects a request body over 100 MB, so this push would fail with 413 Payload Too Large and publish nothing. Split the layer in the Dockerfile — see the COPY block that splits node_modules."
exit 1
fi
- name: Push
run: |
set -euo pipefail
docker push "${IMAGE}:latest"
docker push "${IMAGE}:${TAG}"
- name: Log out
if: always()
run: docker logout "${REGISTRY}" || true
deploy:
# Roll the site onto the image `build` just pushed. `needs: build` makes this
# wait for a clean build and push — if the build fails, deploy never fires and
# the running container is left alone rather than torn down for nothing.
needs: build
runs-on: rgcom
# Guard against a workflow_dispatch fired from a branch: only main is deployed.
if: github.ref == 'refs/heads/main'
# Until a runner with this label exists, this job simply QUEUES. That is the
# intended behaviour and it breaks nothing: `build` has already published the
# image, so `docker compose pull && up -d` by hand is available the whole time,
# and the queued job runs the moment the runner registers.
steps:
- name: Pull the fresh image and recreate the container
# No `down` first, deliberately. There is one service and no database to
# keep still, so `up -d` recreates it in place when the pulled digest
# differs — a couple of seconds of connection refused behind the proxy
# rather than the whole stack stopped while an image is fetched.
run: |
set -euo pipefail
cd /opt/runicgateway.com
docker compose pull
docker compose up -d --remove-orphans
docker compose ps
- name: Wait for the container to report healthy
# The image's healthcheck watches an actual response, and `npm start` runs
# the brand rewrite before the server starts — so "running" arrives well
# before "serving". Without this the job would go green on a container
# that is about to crash-loop on, say, an unwritable ./data.
run: |
set -euo pipefail
cd /opt/runicgateway.com
for attempt in $(seq 1 30); do
STATUS="$(docker compose ps --format '{{.Health}}' site | head -n 1)"
echo "attempt ${attempt}: ${STATUS:-unknown}"
if [ "$STATUS" = "healthy" ]; then
echo "Site is healthy."
exit 0
fi
sleep 5
done
echo "::error::The site did not become healthy within 150s."
docker compose logs --tail 100 site
exit 1

View File

@@ -22,7 +22,7 @@ jobs:
- name: Set up Node - name: Set up Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: '22' node-version: '22.23.4'
cache: npm cache: npm
- name: Install - name: Install
@@ -65,6 +65,40 @@ jobs:
# the shorter form would pass locally and break only here. # the shorter form would pass locally and break only here.
run: npm test run: npm test
- name: Sidebar
# PLAN.md §12, phase 8. src/config/sidebar.mjs holds two trees — the one Starlight
# renders and the one §10 planned — and they must agree on groups, labels and
# ORDER. Order because the order of "Getting started" IS the installation path.
#
# While pages were being written the planned tree was a checklist; now that every
# page exists it is a hand-maintained second copy, and it had already drifted
# unnoticed (phase 7 added Content under D37 and never updated it). Nothing caught
# that because nothing read it.
#
# No token, no network, no build — so it runs early and fails fast.
run: npm run check:sidebar
- name: Game vocabulary
# PLAN.md §16, D63/D64 — the twelfth check, phase 14. The site may NAME the games it
# supports anywhere, but a game's vocabulary (shard, guild, clan, uo-link, Oxide…)
# belongs in that game's own docs group, its sidebar group, or a <GameExample> block
# that labels it (D68). Core pages use core's words: a Team, a game server, the
# sidecar, the bridge plugin, the game module.
#
# No token, no network, no build — so it runs early and fails fast.
run: npm run check:terms
- name: Screenshots
# PLAN.md §12, phase 9 (D45). src/data/screens.mjs is the one list of what the site
# shows of itself: every entry must have a file, at the size the markup declares, and
# every file must have an entry. The size half is the one that repays the check —
# a re-capture taken at the wrong viewport looks perfectly fine on its own and only
# reveals itself as a page that reflows while it decodes.
#
# No browser and no game server: the capture tool is an authoring script whose output
# is committed, exactly like the brand assets, so CI only reads what it produced.
run: npm run check:screens
- name: Production build - name: Production build
run: npm run build run: npm run build
@@ -81,6 +115,17 @@ jobs:
# fails because some other host is slow is a check people learn to ignore. # fails because some other host is slow is a check people learn to ignore.
run: npm run check:links run: npm run check:links
- name: Game vocabulary in the built pages
# PLAN.md §16, D79 — phase 17 widened the twelfth check to the whole site. The
# marketing pages are templates fed from src/data/, so the words a reader sees are
# mostly not in any source file: a capability label, a screenshot caption, a value
# from platform.json. This pass reads every built page's visible text, alt, title and
# aria-label, the way check:links reads its hrefs. A game's routes, and elements
# marked data-game (<GameExample>, a game-tagged screenshot), allow that game's words.
#
# Still the twelfth check, not a thirteenth: the same script, the same word list.
run: npm run check:terms:built
- name: Platform facts - name: Platform facts
# PLAN.md §12 — every version, protocol number and bundle tag is re-read from # PLAN.md §12 — every version, protocol number and bundle tag is re-read from
# its authority over the Gitea API and must agree with src/data/platform.json. # its authority over the Gitea API and must agree with src/data/platform.json.
@@ -115,3 +160,64 @@ jobs:
env: env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }} GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: npm run check:quickstart run: npm run check:quickstart
- name: Reference enumerations against their sources
# PLAN.md §12, phase 8. The Reference section names things — every environment
# variable, config key, installer command, visibility rung and canonical document.
# §1 forbids re-specifying a contract, and this is what makes writing the NAMES
# down safe anyway: each list is a SET comparison against the repository that owns
# it, in both directions.
#
# The second direction is the one that earns its keep. A reference page does not
# usually rot by describing something that vanished — it rots by quietly not
# mentioning the three things added since it was written.
#
# Descriptions are deliberately NOT checked; nothing here can know whether a
# one-line summary is still true, so it does not pretend to.
#
# Same token, and for the same reason: it reads five other repositories in the org.
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: npm run check:reference
- name: The headers the server actually sends
# PLAN.md §6 / D48, phase 10. Every other check reads dist/; this one starts
# scripts/serve.mjs and reads the responses, because the defect it exists for
# happened after the build was already correct. @astrojs/node matched a request to a
# policy with a SUBSTRING test, so /modules/ was served the policy built for
# /docs/modules/building-a-module — every file on disk right, the bytes on the wire
# wrong, and the page rendered with its own stylesheet refused.
#
# It needs the build, so it cannot live in the "Unit tests" step above.
run: npm run test:served
- name: Accessibility
# PLAN.md §13, phase 10. Seven structural rules over every built page: one <h1> and
# no skipped heading level, an alt attribute on every image, a label on every form
# control, an accessible name on every link and button, <html lang>, one <main> with
# a skip link that reaches it, and no positive tabindex.
#
# Structural on purpose. A static check cannot measure contrast on a rendered page
# or find a focus trap, and a check that pretended to would be trusted for things it
# cannot see. What it does catch is the class of defect that is invisible to a
# sighted author and permanent once shipped — and it covers Starlight's forty pages
# too, so a dependency upgrade that loses a label is a red build rather than a
# discovery.
#
# After the build, because it reads dist/client. No token and no network.
run: npm run check:a11y
- name: Content-Security-Policy
# PLAN.md §6 / D48. The policy is a real response header — the Node adapter's
# staticHeaders writes dist/_headers.json and the standalone server sends it — so
# frame-ancestors applies and the operator's proxy needs no CSP config.
#
# The check that matters is the second one: every inline script and style must be
# covered by a hash in ITS OWN page's policy. Astro does not hash <script is:inline>,
# and Starlight ships six of them per documentation page, so the first build with CSP
# enabled had a strict, correct header and a dead theme switcher — a failure with no
# symptom except a console message. A Starlight upgrade can reintroduce it at any
# time, which is why this runs on every PR rather than once.
#
# After the build, because it reads dist/. No token and no network.
run: npm run check:csp

17
CODE_OF_CONDUCT.md Normal file
View File

@@ -0,0 +1,17 @@
# Code of Conduct
This repository is covered by the Runic Gateway organisation's Code of Conduct — the Contributor
Covenant, v2.1 — which applies identically across all ten repositories:
**[RunicGateway/docs → CODE_OF_CONDUCT.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/CODE_OF_CONDUCT.md)**
It covers the standards expected of everyone taking part, the scope (project spaces and public
spaces where somebody represents the project), the enforcement guidelines, and **how to report
unacceptable behaviour privately**.
Reporting goes to the organisation maintainer. That document carries the address; this file
deliberately does not, for the same reason [SECURITY.md](SECURITY.md) does not — **D13** (`PLAN.md`
§5) keeps the published contact address in one bind-mounted file so that changing it costs a file
copy rather than a commit in ten repositories.
Reports are handled privately, and the reporter's identity is not shared with the person reported.

141
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,141 @@
# Contributing to runicgateway.com
Thanks for your interest. This repository is the **public marketing and documentation site** for
[Runic Gateway][org] — the platform that puts a private game server's live state on a public website
without ever exposing the game to the internet.
It is a site, not a component. Nothing else in the organisation depends on it, and it depends on
everything: almost every sentence here describes something that lives in another repository.
By participating you agree to abide by our [Code of Conduct](CODE_OF_CONDUCT.md).
## Read the plan first
**[`PLAN.md`](PLAN.md) is the design of record.** It is not a sketch — it carries the verified
platform state, the org lead's fifty-odd decisions, the information architecture, the accuracy
machinery and the build phases. A change that contradicts a decision recorded there needs the
decision changed first, in the same pull request, with the reasoning written down.
Two things it records are worth knowing before you write a line:
- **§1 — the site never re-specifies a contract.** `docs/` is normative for the protocol, the module
API, the backend design and the installer. This site teaches, links out, and quotes versions from
data rather than prose. A page that restates a contract is a page that will be wrong later, and
nothing will notice.
- **§12 — the checks are the mechanism, and their failure is the feature.** When the platform moves,
this repository goes red so that somebody updates the site. Do not route around a check; if one is
wrong, fix the check and say why in the pull request.
## Ways to contribute
- **Report a bug** or **request a feature** through the [issue tracker][issues] — templates are
provided.
- **Improve a page, a check or the build** by opening a pull request.
- **Never** report a security vulnerability in a public issue. See [SECURITY.md](SECURITY.md).
If you spot a claim on the site that is *wrong about the platform* — a version, a capability, a
command that no longer exists — that is the most valuable report this repository can receive.
## Development setup
**Prerequisites:** Node 22 LTS or newer. Nothing else — no database, no game server, no container
runtime for ordinary work.
```bash
npm install
npm run dev # http://localhost:4321
```
```bash
npm run build # → dist/ (prerendered pages + the Node server entry)
npm start # serve the built site, exactly as the container does
```
Two directories are **bind mounts at runtime and not in the repository**: `brand/` overrides the
stock branding per file, and `data/` holds the beta signup store. Both are optional locally; an
absent `brand/` produces exactly the stock site, and `data/` is created on first write.
## The checks
There are twelve, plus two test suites. `npm run verify` runs all of them in dependency order, and
that is what CI does on every pull request.
```bash
npm run verify
```
Three of them read other repositories over the Gitea API and need a token with access to the
organisation, not just this repository:
```bash
GITEA_TOKEN=<token> npm run check:facts # every version agrees with its authority
GITEA_TOKEN=<token> npm run check:quickstart # the install page still matches website's own files
GITEA_TOKEN=<token> npm run check:reference # every name the Reference lists still exists
```
Without a token they fail rather than skip, deliberately: a check that silently passes when it could
not do its job is worse than no check. CI maps the org-level `REGISTRY_TOKEN` secret into
`GITEA_TOKEN` for those three steps.
The README's "The checks, and why they are not optional" section explains what each one guards.
Read it before adding a page — several of them constrain how a page may be written, particularly
`check:tokens` (no colour literal outside `src/styles/tokens.css`) and `check:links` (no commit
permalinks into org repositories).
## Writing for this site
- **Understated honesty** (D8). The site reads as finished. Where something is not built, the
absence is stated as data in `src/data/notBuilt.mjs` and rendered — never implied by silence and
never dressed up as a roadmap.
- **No version in prose.** Every externally-sourced fact lives in `src/data/platform.json` and is
re-read from its authority by `check:facts`. If you find yourself typing a version number into a
sentence, put it in the data file instead.
- **No email address in `src/` or `scripts/`** (D13). The published contact is a `brand.json` field
so that changing it stays a file copy and a restart. `check:facts` enforces this.
- **British spelling**, in common with the rest of the organisation's prose.
## Branch and pull-request workflow
1. Branch from `main` with a descriptive name (`feature/…`, `fix/…`, `docs/…`, `chore/…`).
2. Keep changes focused; small pull requests are easier to review.
3. Run `npm run verify` before opening the pull request.
4. Push and open a pull request against `main`. Fill out the template, including the **AI-assisted
contributions** disclosure.
5. A maintainer will review; address feedback with follow-up commits.
### Commit messages
[Conventional Commits](https://www.conventionalcommits.org/) — `type(scope): summary`, in common
with every repository in the organisation. For example `fix(docs): correct the installer flag on the
quickstart`.
### What merging does
Merging to `main` builds a container image, publishes it to the Gitea registry and **deploys it**
(`.gitea/workflows/build-image.yml`). There is no separate release step and no manual promotion, so
a merge is a publication. [`DEPLOY.md`](DEPLOY.md) describes the whole path, including how to roll
back to a previous build.
## AI-assisted contributions (disclosure required)
This project is developed openly with AI assistance, and we ask the same transparency of everyone.
**If you used an AI tool** (Claude, Copilot, ChatGPT, Cursor, etc.) to help produce a contribution,
you must disclose it:
- Tick the AI-usage box in the pull-request template and name the tool(s).
- Mark AI-authored commits with a trailer, e.g. `Co-Authored-By: Claude <noreply@anthropic.com>` or
`Assisted-By: <tool>`.
- You remain responsible for every line you submit: review it, understand it, and make sure it is
correct and that you have the right to contribute it.
Disclosed AI assistance is welcome. Undisclosed AI-generated contributions are not, and may be
closed.
## Licence
Runic Gateway is licensed under the **GNU General Public License v3.0 or later** (see
[LICENSE](LICENSE)). By submitting a contribution you agree that it is licensed under the same terms
(inbound = outbound) and that you have the right to contribute it.
[org]: https://gitea.whitlocktech.com/RunicGateway
[issues]: https://gitea.whitlocktech.com/RunicGateway/runicgateway.com/issues

417
DEPLOY.md Normal file
View File

@@ -0,0 +1,417 @@
# 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 and the games it runs, 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.
**The public demo** is two fields. `demoUrl` shows the homepage's "See it running" button and the
core features' links into it; `demoGames` lists the game modules it runs (`["rust"]` for
`demo.runicgateway.com`), and only those games' links show. Both need the restart. `/beta` also
reads `demoUrl` live: it fetches the demo's published sign-ins from
`<demoUrl>/api/v1/public/settings` and lists them, so the container must be able to reach the
demo's public address. If it cannot, `/beta` links to the demo's sign-in page instead and logs
`[beta] could not read the demo's sign-ins`.
To see which source answered a given asset:
```bash
curl -sI http://127.0.0.1:4321/brand/logo.png | grep -i x-brand-source
```
`mount`, `default`, or `derived:mount` / `derived:default` when the size was generated on demand
from whichever `logo.png` is in force.
## 6. The closed-beta tester list
There is no admin page, by design (`PLAN.md` §8) — the site has no authenticated surface at all. The
list is managed from a shell against the mount.
```bash
cd /opt/runicgateway.com
docker compose exec site node scripts/beta.mjs stats
docker compose exec site node scripts/beta.mjs export # marks the rows exported
docker compose exec site node scripts/beta.mjs export --all # everything, again
docker compose exec site node scripts/beta.mjs remove someone@example.com
```
`export` writes two files into `data/exports/` — a CSV record, and a `.txt` of one address per line,
which is the format Google Play's tester list accepts. They are on the bind mount, so they are on the
host at `./data/exports/` and can be copied off with `scp` like any other file.
`remove` is a deletion request, and it **overwrites** the address, the IP hash and the user agent
rather than flagging the row. That is what `/privacy` promises; the row survives only as an anonymous
record that a signup happened.
## 7. Updating, and the automatic deploy
Merging to `main` builds the image, pushes it, and **deploys it** (D54). No manual step, no release
tag. What protects production is that every one of the eleven checks and both test suites have
already run on the pull request, and the deploy job is `needs: build`, so a failed build never
reaches the host.
That requires a Gitea Actions runner **on this host**, labelled `rgcom`, running jobs directly on
the host rather than inside a container — it needs the host's Docker daemon and
`/opt/runicgateway.com`.
```bash
# On the host, once. Get the registration token from
# Gitea → the repository → Settings → Actions → Runners → Create new runner
act_runner register \
--no-interactive \
--instance https://gitea.whitlocktech.com \
--token <REGISTRATION_TOKEN> \
--name runicgateway-com-host \
--labels rgcom:host
```
`rgcom:host` — the `:host` suffix is what makes jobs run on the machine rather than in a job
container. Without it the job starts in a container with no Docker socket and no
`/opt/runicgateway.com`, and fails on the `cd`.
Make sure the user the runner runs as can talk to Docker (`docker ps` succeeds) and can read and
write `/opt/runicgateway.com`.
**Until that runner exists, the deploy job just queues**, and nothing is harmed: the image has
already been built and pushed by the time it would run, so the manual update below works throughout,
and the queued job goes as soon as the runner registers.
**One way the automatic deploy can fail before it starts.** The registry is behind Cloudflare, which
refuses a request body over 100 MB, and `docker push` uploads each image layer as a single request —
so a layer that grows past that is rejected at the edge with `413 Payload Too Large`, publishing
nothing. `needs: build` then keeps the deploy from running at all, which means the container you are
already serving is left alone; the site is simply not updated. The workflow checks layer sizes before
it pushes and fails with a message naming the layer, so this should announce itself rather than
arriving as a `413`. Either way it is fixed in the `Dockerfile` (see the `COPY` block that splits
`node_modules`) and nothing needs doing on the host.
**To update by hand instead** — always available, and what you do if the runner is down:
```bash
cd /opt/runicgateway.com
docker compose pull
docker compose up -d
docker compose ps
```
The compose file has no `build:` at all, so a production host can only ever pull.
## 8. Rolling back
Every merge publishes two tags: `latest` and `sha-<7>` of the commit. To pin:
```bash
cd /opt/runicgateway.com
sed -i 's/^IMAGE_TAG=.*/IMAGE_TAG=sha-1806406/' .env
docker compose pull && docker compose up -d
```
Set it back to `latest` to resume following `main`. Note that while it is pinned, the automatic
deploy still runs and still pulls — but Compose recreates the container on the *pinned* tag, so the
site stays where you put it. That is the intended behaviour: a pin is a decision, and a merge should
not quietly undo it.
The tags are listed under **Packages** on the organisation's Gitea page.
## 9. Backups
One file matters: `data/beta.sqlite`. The rest of the site is in the image and in git.
It is a live SQLite database in WAL mode, so **do not just `cp` it** — a copy taken mid-write can
miss committed rows sitting in the `-wal` file. Either use SQLite's own backup, which is safe against
a running writer:
```bash
cd /opt/runicgateway.com
docker compose exec site node -e "const db=require('better-sqlite3')(process.env.DATA_DIR+'/beta.sqlite');db.exec(\"VACUUM INTO '/app/data/beta-backup.sqlite'\");db.close()"
mv data/beta-backup.sqlite /somewhere/safe/beta-$(date +%F).sqlite
```
or stop the container first and copy all three files (`beta.sqlite`, `-wal`, `-shm`) together.
`brand/` is worth keeping too, if you have customised it — it is the one part of a running
deployment that exists nowhere else.
## 10. When something is wrong
```bash
cd /opt/runicgateway.com
docker compose logs --tail 200 site
docker compose ps
```
| Symptom | Cause worth checking first |
|---|---|
| Container restarts, or never becomes healthy | `data/` not writable by uid 1000 — `sudo chown -R 1000:1000 data` |
| `/beta` says the signup is unavailable | Same. The rest of the site is unaffected, which is by design |
| Every visitor hits the rate limit | The proxy is not setting `X-Forwarded-For` — [§3.2](#32-set-x-forwarded-for) |
| Rate limits reset on every restart | `BETA_IP_SALT` is unset in `.env` — [§2.3](#23-fill-in-the-two-secrets) |
| Pages render unstyled; console says `Refused to apply inline style` | A second CSP from the proxy or from Cloudflare Rocket Loader / minification — [§3.3](#33-do-not-add-security-headers-at-the-proxy), [§4](#4-dns-and-tls) |
| A new logo or site name has not appeared | The mount needs a `docker compose restart site`, not just a file edit — [§5](#5-branding-without-a-rebuild) |
| Search finds the old site name | Same restart; the boot rewrite re-indexes |
| The site is stock despite files in `brand/` | Check the mount actually landed: `docker compose exec site ls /app/brand` |
| A merge did not deploy, and the build job is red | If it failed on the layer check or on a `413`, a layer grew past Cloudflare's 100 MB request limit — [§7](#7-updating-and-the-automatic-deploy). The running container is untouched; the fix is in the Dockerfile, not on the host |
Everything the container writes goes to stdout, so `docker compose logs` is the whole log. The
reverse proxy's access log is the only traffic data that exists — there are no analytics anywhere on
the site (D9).

132
Dockerfile Normal file
View File

@@ -0,0 +1,132 @@
# runicgateway.com — the one container (PLAN.md §6).
#
# Two stages. The first has the whole toolchain and produces `dist/`; the second
# carries the built site, the pruned runtime dependencies and nothing else.
#
# Debian slim rather than Alpine, deliberately. Two of the runtime dependencies
# are native — `better-sqlite3` (the beta store, §8) and `sharp` (the brand
# derivations, §7) — and both publish prebuilt binaries for glibc. On musl they
# are compiled from source instead, which means a C++ toolchain, libvips headers
# and several minutes in the image build, to save about sixty megabytes on a
# thing that is pulled a few times a year. A third, `pagefind`, ships a platform
# binary and is needed at RUN time, not just build time: the boot rewrite
# re-indexes the site after the brand strings change.
# ---------------------------------------------------------------------------------------
# Stage 1 — build
# ---------------------------------------------------------------------------------------
FROM node:22.23.4-bookworm-slim AS build
WORKDIR /build
# Dependencies first, so an edit to a page does not re-resolve the tree.
COPY package.json package-lock.json ./
RUN npm ci
# Then the source. .dockerignore keeps node_modules, dist and BOTH bind mounts out.
COPY . .
# Prerenders every marketing, legal and documentation page, builds the Node
# server entry for the two routes that run per request, and writes the per-route
# Content-Security-Policy into dist/_headers.json (D48).
#
# No token and no network: everything the build reads is in this context. The
# checks that DO need the Gitea API — facts, quickstart, reference — run in CI
# against the pull request, which is the right place for them. An image build
# that could fail because another repository's server was slow would be an image
# build people learn to retry rather than read.
RUN npm run build
# Drop the devDependencies from the tree the runtime stage inherits. Pruning
# here rather than running a second `npm ci --omit=dev` below keeps the native
# modules exactly as they were resolved and built once.
RUN npm prune --omit=dev
# Set the two largest packages aside so the runtime stage can copy them as their
# own layers. See the COPY block below for why a single node_modules layer could
# not be pushed at all. Moving them rather than copying them twice is what keeps
# the three layers disjoint: whatever is left in node_modules is exactly the
# remainder, and a dependency added later lands in it automatically.
RUN mkdir -p /split \
&& mv node_modules/@pagefind /split/ \
&& mv node_modules/@img /split/
# ---------------------------------------------------------------------------------------
# Stage 2 — runtime
# ---------------------------------------------------------------------------------------
FROM node:22.23.4-bookworm-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production \
HOST=0.0.0.0 \
PORT=4321
# Both bind mounts, named here so the code's own `process.cwd()` defaults are
# never what a container relies on. See docker-compose.yml.
ENV BRAND_DIR=/app/brand \
DATA_DIR=/app/data
# The stock brand, baked in and always complete (§7). Every /brand/* URL resolves
# against the mount first and this second, per file.
ENV BRAND_DEFAULT_DIR=/app/brand-default
# `dist/` is owned by `node` because the boot rewrite WRITES to it: applyBrand.mjs
# rewrites the prerendered HTML from what it last applied to what the mount now
# says, records that in dist/.brand-applied.json, and re-indexes dist/client/pagefind
# so search finds the mounted site name. A read-only dist would make §7's promise
# — recolour and rename by copying a file — fail at boot with a permission error.
# node_modules arrives in THREE layers, not one, and the reason is the registry
# rather than anything about the site.
#
# Gitea sits behind Cloudflare, which refuses a request body over 100 MB on every
# plan below Enterprise, and `docker push` uploads each layer as one monolithic
# PUT. A single `COPY node_modules` measured **108.8 MB compressed** — nine over —
# so the first merge to `main` after phase 12 failed with `413 Payload Too Large`
# on that one blob, from the edge, with Gitea never seeing the request. Nothing
# was published, and `needs: build` meant nothing was deployed either.
#
# `@pagefind` (the search index binaries) and `@img` (sharp's libvips) are the two
# packages that make it fat and both are needed at RUN time — the boot rewrite
# re-indexes the site and re-derives the brand images — so the fix is where they
# land, not whether they ship. Split, they measure 54.7 + 50.6 + 12.1 MB, the
# largest with about 45 MB of headroom.
#
# That headroom is why the workflow counts layers before it pushes: this is a
# margin, not a guarantee, and a dependency that grows past it would otherwise
# come back as the same unreadable 413. See `.gitea/workflows/build-image.yml`.
COPY --from=build --chown=node:node /build/node_modules ./node_modules
COPY --from=build --chown=node:node /split/@pagefind ./node_modules/@pagefind
COPY --from=build --chown=node:node /split/@img ./node_modules/@img
COPY --from=build --chown=node:node /build/dist ./dist
COPY --from=build --chown=node:node /build/brand-default ./brand-default
COPY --from=build --chown=node:node /build/scripts ./scripts
COPY --from=build --chown=node:node /build/package.json ./package.json
# `src/` is here for one reason: the tester-list CLI. §8 has no admin page by
# design, so managing the closed beta is `docker compose exec site node
# scripts/beta.mjs …`, and that reaches into src/lib/betaStore.mjs. Nothing
# serving a request reads it — the pages were prerendered in stage 1.
COPY --from=build --chown=node:node /build/src ./src
# Both mount points exist in the image, owned by the runtime user. An operator
# who forgets a mount then gets a working stock site and an empty store rather
# than a container that will not start; and `data/` being writable by uid 1000
# BEFORE Docker creates it is what stops the store failing to open. If the host
# directory is owned by someone else, `chown 1000:1000 ./data` on the host.
RUN mkdir -p /app/brand /app/data && chown -R node:node /app/brand /app/data
USER node
EXPOSE 4321
# Cheap, and it tests the thing that actually breaks: `npm start` runs the brand
# rewrite BEFORE the server, so a container can sit alive for a long moment with
# nothing listening. A healthcheck that only watched the process would call that
# healthy.
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:' + (process.env.PORT || 4321) + '/').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
# applyBrand.mjs, then serve.mjs. Not dist/server/entry.mjs directly — see the
# header comment in scripts/serve.mjs for the adapter bug that wrapper exists for.
CMD ["npm", "start"]

1436
PLAN.md

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
<!-- <!--
GENERATED FILE — do not edit. GENERATED FILE — do not edit.
Source: src/data/collection.mjs (scope "app") + src/data/legal.mjs Source: src/data/collection.mjs (scope "app", and the deletion row) + src/data/legal.mjs
Generator: scripts/playDataSafety.mjs Generator: scripts/playDataSafety.mjs
Edit the data file and run `npm run play:datasafety`. CI runs the same Edit the data file and run `npm run play:datasafety`. CI runs the same
@@ -19,48 +19,99 @@ The Play Console asks, for every category of data, whether the app **collects**
We operate **no server the app talks to.** The app ships pointed at nothing: its first screen asks for the address of a Runic Gateway deployment and validates it before anything else in the app runs. That deployment belongs to whoever runs that community. Data therefore travels from the device to *their* server, and there is no endpoint of ours anywhere in the path — not for content, not for telemetry, and not for crash reports, of which there are none. We operate **no server the app talks to.** The app ships pointed at nothing: its first screen asks for the address of a Runic Gateway deployment and validates it before anything else in the app runs. That deployment belongs to whoever runs that community. Data therefore travels from the device to *their* server, and there is no endpoint of ours anywhere in the path — not for content, not for telemetry, and not for crash reports, of which there are none.
That is why nearly every answer below is "not collected", and it is also the answer most likely to be questioned in a review. The supporting facts are in the table: each row names the file it was read out of. Play still counts what the app sends to that server as **collected**: the data leaves the device, and the form does not ask whose server it lands on. So everything the app sends is declared collected, not shared, and required, which is the conservative answer and the accurate one (org lead, 2026-10-29). What stays true, and what `/privacy` says, is that none of it reaches us.
Where the console offers free text about security practices, two things are worth saying: credentials are held in Android’s encrypted storage (AES-256-GCM via Jetpack Security), and push notifications carry **no content** — a relay receives a stream name and a reference, and the app fetches the actual message over its own authenticated connection. ## Data collection and security
## Data types - **Does the app collect or share any of the required user data types?** Yes, it collects them (below). It shares none.
- **Is all of the user data collected by the app encrypted in transit?** Yes. The release build’s network security config sets `cleartextTrafficPermitted="false"` for every connection, and the app refuses an `http` deployment address outside debug builds. Read from: `app/src/main/res/xml/network_security_config.xml`, `core/net/ServerUrl.kt`.
- **Is any of it processed ephemerally?** No. The deployment keeps what it receives, under its operator’s policy, and we cannot promise otherwise on its behalf.
- **Free text on security practices**, where the console offers it: credentials are held in Android’s encrypted storage (AES-256-GCM via Jetpack Security), and push notifications carry **no content**: a relay receives a stream name and a reference, and the app fetches the actual message over its own authenticated connection.
| Category | Data type | Collected by us | Shared by us | Answer | ## Data types the app collects
|---|---|---|---|---|
| Personal info | User IDs | No | No | Not collected by us. | | Category | Data type | Collected | Shared | Required | Purposes | From |
| Personal info | User IDs | No | No | Not collected by us. | |---|---|---|---|---|---|---|
| App info and performance | Other app data | No | No | Not collected by us. Stored on the device only. | | Personal info | Name | Yes | No | Yes | App functionality | Your name, email address and message, if you use Contact |
| 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. | | Personal info | Email address | Yes | No | Yes | App functionality | Your name, email address and message, if you use Contact |
| Messages | Other user-generated content | No | No | Not collected by us. | | Personal info | User IDs | Yes | No | Yes | App functionality; Fraud prevention, security, and compliance; Account management | Your sign-in, and the tokens it issues; Linking a game account |
| Device or other IDs | Device or other IDs | No | No | Not collected. | | Messages | Other in-app messages | Yes | No | Yes | App functionality | Your name, email address and message, if you use Contact; What you post and send from the app |
| App activity | App interactions | Yes | No | Yes | App functionality; Personalization | Which notifications you have read, and which you want |
| App activity | In-app search history | Yes | No | Yes | App functionality | What you search for |
| App activity | Other user-generated content | Yes | No | Yes | App functionality | What you post and send from the app |
| App info and performance | Diagnostics | Yes | No | Yes | App functionality; Fraud prevention, security, and compliance | The app’s version and your Android version |
| Device or other IDs | Device or other IDs | Yes | No | Yes | App functionality; Fraud prevention, security, and compliance; Account management | The trusted-device token and the device’s name, if you asked for one; Push registration, if you turn notifications on |
## Data types the app does not collect
The form lists every type, so every one is answered. Vocabulary read from Play Console Help on 2026-10-29; check it against the console’s current list.
- **Location:** none collected.
- **Personal info:** Address, Phone number, Race and ethnicity, Political or religious beliefs, Sexual orientation, Other info: not collected.
- **Financial info:** none collected.
- **Health and fitness:** none collected.
- **Messages:** Emails, SMS or MMS: not collected.
- **Photos and videos:** none collected.
- **Audio files:** none collected.
- **Files and docs:** none collected.
- **Calendar:** none collected.
- **Contacts:** none collected.
- **App activity:** Installed apps, Other actions: not collected.
- **Web browsing:** none collected.
- **App info and performance:** Crash logs, Other app performance data: not collected.
## Held on the device only
Not collected, because none of it leaves the phone; listed so nobody declares it by mistake.
- **The address of the deployment you chose.** It never leaves the phone. It is the destination of requests, not the contents of one.
- **A snapshot of your notifications, so the inbox opens without a signal.** The snapshot is written on the phone from data the deployment had already delivered. It is not uploaded anywhere.
- **No analytics, no crash reporting, no advertising.** No crash reporter, no advertising ID, no analytics identifier, and no library that would generate one is linked into the build.
## Each answer, and why it is the truthful one ## Each answer, and why it is the truthful one
### Your sign-in tokens ### Your sign-in, and the tokens it issues
**Personal info → User IDs.** Not collected by us. **Personal info → User IDs.** Collected, not shared, required.
When you sign in to a deployment, the app keeps the access and refresh tokens it was issued, plus the username, role and account id they belong to. They are held in encrypted storage on the device (AES-256-GCM through Jetpack Security) and are sent to exactly one place: the deployment that issued them. Purposes: App functionality; Account management; Fraud prevention, security, and compliance.
- **Why that answer:** The credentials are issued by, and returned to, a server the user nominated. Nothing reaches an endpoint under our control, because we run none. Signing in sends your username and password to the deployment, or with single sign-on a one-time code from the provider, plus a two-factor code when the account has one. The deployment answers with an access and a refresh token and the username, role and account id they belong to. The tokens are held in encrypted storage on the device (AES-256-GCM through Jetpack Security) and are sent to exactly one place: the deployment that issued them, with every request. Changing your username or password, setting up two-factor sign-in and generating recovery codes from the app go to the same place. So does deleting the account, which sends your username, your password and, if the account has one, a two-factor code.
- **Retention:** On the device until you sign out
- **In detail:** Signing out clears them; uninstalling the app removes them with it.
- **Read from:** `core/auth/EncryptedTokenStore.kt`
### The trusted-device token, if you asked for one - **Why that answer:** The username, password and tokens leave the phone for the deployment the user chose, and Play counts that as collection whoever runs the server. None of it reaches us. Security is a purpose because the deployment checks two-factor codes and trusted devices with it.
- **Retention:** On the device until you sign out or delete the account
- **In detail:** Signing out clears them, and so does deleting the account; uninstalling the app removes them with it.
- **Read from:** `data/api/AuthApi.kt, data/api/SsoApi.kt, data/api/MeApi.kt, core/auth/EncryptedTokenStore.kt`
**Personal info → User IDs.** Not collected by us. ### The trusted-device token and the device’s name, if you asked for one
Ticking “trust this device” during two-factor sign-in stores an opaque token so the deployment can skip the second factor next time. It lives in its own encrypted store, deliberately separate from the session, because it has to outlive a sign-out to be worth anything — and the deployment holds only a hash of it, so the copy on your phone is the only usable one. **Device or other IDs → Device or other IDs.** Collected, not shared, required.
- **Why that answer:** Same as the session tokens: minted by the user’s deployment, stored on the device, presented back to that same deployment. Purposes: Account management; Fraud prevention, security, and compliance.
Ticking “trust this device” during two-factor sign-in stores an opaque token so the deployment can skip the second factor next time. It lives in its own encrypted store, deliberately separate from the session, because it has to outlive a sign-out to be worth anything — and the deployment holds only a hash of it, so the copy on your phone is the only usable one. The phone’s make and model, such as “Samsung SM-S918B”, goes with the request as the device’s label, so you can tell it apart in the deployment’s list of trusted devices. It is a model name, not a hardware identifier.
- **Why that answer:** The token identifies this device to the deployment, and the label is sent with it. Both are presented to the deployment that minted the token and to nothing else.
- **Retention:** On the device until it expires or you revoke it - **Retention:** On the device until it expires or you revoke it
- **In detail:** Thirty days, and revocable at any time from the deployment’s Trusted Devices screen, which is also where it can be revoked if the phone is lost. - **In detail:** Thirty days, and revocable at any time from the deployment’s Trusted Devices screen, which is also where it can be revoked if the phone is lost. Deleting the account from the app drops it from the phone too, since there is no next sign-in for it to skip.
- **Read from:** `core/auth/EncryptedTrustTokenStore.kt` - **Read from:** `core/auth/EncryptedTrustTokenStore.kt, core/auth/DeviceNameProvider.kt`
### The app’s version and your Android version
**App info and performance → Diagnostics.** Collected, not shared, required.
Purposes: App functionality; Fraud prevention, security, and compliance.
Every request carries a User-Agent such as “RunicGatewayApp/0.6.0 (Android 15)”. It is there because a deployment turns away traffic that looks like a scanner, and it names nothing about you or the phone beyond those two version numbers.
- **Why that answer:** It is technical information about the app and the device, and it is sent with every request. The deployment uses it to tell the app from a scanner.
- **Retention:** Under the deployment operator’s policy
- **In detail:** A deployment’s access log may keep it, as any web server keeps the user agent of a browser.
- **Read from:** `di/NetworkModule.kt, core/net/UserAgentInterceptor.kt`
### The address of the deployment you chose ### The address of the deployment you chose
**App info and performance → Other app data.** Not collected by us. Stored on the device only. **No console type.** Not collected. Stored on the device only.
The app ships pointed at nothing and asks for an address on first run. That address is stored in ordinary preferences rather than encrypted storage — it is not a secret, it is the equivalent of a bookmark — and it is what every other screen in the app talks to. The app ships pointed at nothing and asks for an address on first run. That address is stored in ordinary preferences rather than encrypted storage — it is not a secret, it is the equivalent of a bookmark — and it is what every other screen in the app talks to.
@@ -70,32 +121,97 @@ The app ships pointed at nothing and asks for an address on first run. That addr
### Push registration, if you turn notifications on ### Push registration, if you turn notifications on
**Messages → Other in-app messages.** Not collected by us. Declare the relay hop in the console’s free-text security section if it asks. **Device or other IDs → Device or other IDs.** Collected, not shared, required. Mention the relay in the free-text security section if the console asks.
Purposes: App functionality.
Push is off until you enable it. When you do, the app mints a random, unguessable topic name on the notification relay the deployment nominates, and registers that topic’s URL with the deployment so it has somewhere to send a nudge. What actually travels through the relay is content-free — a stream name and a reference, never the message — and the app then fetches the real content over its authenticated connection to the deployment. A leaked topic name therefore reveals nothing, which is the reason the relay needs no account and holds nothing about you. Push is off until you enable it. When you do, the app mints a random, unguessable topic name on the notification relay the deployment nominates, and registers that topic’s URL with the deployment so it has somewhere to send a nudge. What actually travels through the relay is content-free — a stream name and a reference, never the message — and the app then fetches the real content over its authenticated connection to the deployment. A leaked topic name therefore reveals nothing, which is the reason the relay needs no account and holds nothing about you.
- **Why that answer:** The notification passes through a relay chosen by the deployment, and it carries no content — the app pulls the content itself, authenticated. Neither hop reaches a server we operate. - **Why that answer:** The topic URL is an address for this device, and the app sends it to the deployment. The relay is the one the deployment nominates to deliver its own notifications, so it carries them on the deployment’s behalf rather than receiving a share. It only ever sees a topic name and a tickle; the app pulls the content itself, authenticated. No hop reaches a server we operate.
- **Retention:** Until you turn push off, sign out, or uninstall - **Retention:** Until you turn push off, sign out, delete the account, or uninstall
- **In detail:** Signing out or disabling push unregisters the device with the deployment and discards the topic. The relay retains whatever its own operator configures it to; if the deployment points at a relay it does not run, that relay is a third party to both of us, and it still only ever sees a tickle. - **In detail:** Signing out or disabling push unregisters the device with the deployment and discards the topic; deleting the account drops the registration on the deployment with everything else. The relay retains whatever its own operator configures it to; if the deployment points at a relay it does not run, that relay is a third party to both of us, and it still only ever sees a tickle.
- **Read from:** `core/push/NtfyTopic.kt, core/push/PushPreferences.kt` - **Read from:** `core/push/NtfyTopic.kt, core/push/PushPreferences.kt, data/api/NotificationsApi.kt`
### Everything you read and post in the app ### Linking a game account
**Messages → Other user-generated content.** Not collected by us. **Personal info → User IDs.** Collected, not shared, required.
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. Purposes: App functionality; Account management.
- **Why that answer:** Content is written to the community’s own installation. We have no copy, no access and no way to obtain one. Linking your site account to a game sends the one-time code the game gave you. Where a game server offers it, the app can also create a new game account, which sends the account name and password you chose. Unlinking sends the id being unlinked, such as a Steam id. All of it goes to the deployment, which keeps the link against your site account.
- **Why that answer:** Game account names, Steam ids and link codes identify a player, and the app sends them to the deployment.
- **Retention:** Held by the deployment until you unlink, under its operator’s policy
- **Read from:** `data/api/PlayerShardApi.kt, data/api/PlayerRustApi.kt, data/api/MeApi.kt`
### Your name, email address and message, if you use Contact
**Personal info → Name; Personal info → Email address; Messages → Other in-app messages.** Collected, not shared, required.
Purposes: App functionality.
The Contact screen sends the name, email address and message you type to the deployment, which passes them on to its operator by email so they can reply. Nothing is sent until you press send.
- **Why that answer:** The form’s three fields leave the phone for the deployment, whose operator receives them.
- **Retention:** Held by the deployment’s operator, under their policy
- **In detail:** The message is delivered to the operator’s mailbox, and kept as long as they keep it.
- **Read from:** `data/api/PublicApi.kt, data/api/dto/ContactDto.kt`
### What you post and send from the app
**Messages → Other in-app messages; App activity → Other user-generated content.** Collected, not shared, required.
Purposes: App functionality.
Most of the app is reading. What it sends is what you write, and most of that is for staff: news posts, wiki categories, a broadcast to the game, the answer to a player’s help page, and the account and reason on a kick or a ban. All of it is a live write to the deployment. Apart from the notification snapshot described below, nothing is cached for offline use and nothing is duplicated anywhere else — the app with no signal is an app with almost no content, which is a limitation and also an accurate description of where the data lives.
- **Why that answer:** Posts, broadcasts and replies are 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:** `data/api/AdminApi.kt`
### Which notifications you have read, and which you want
**App activity → App interactions.** Collected, not shared, required.
Purposes: App functionality; Personalization.
Opening a notification marks it read on the deployment, so the badge agrees on every device you use. Your choice of notification streams, and of which arrive by push or email, is saved there too.
- **Why that answer:** Read state and preferences are actions in the app that are sent to, and kept by, the deployment.
- **Retention:** Held by the deployment, under its operator’s policy
- **Read from:** `data/api/NotificationsApi.kt`
### What you search for
**App activity → In-app search history.** Collected, not shared, required.
Purposes: App functionality.
Searching the wiki, a game’s market or its creature atlas sends what you typed to the deployment, because the deployment does the searching. The app keeps no history of searches.
- **Why that answer:** Search text leaves the phone in the request to the deployment.
- **Retention:** Under the deployment operator’s policy
- **In detail:** The query is part of the request’s address, so a deployment’s access log may record it. That is why it is declared rather than called ephemeral.
- **Read from:** `data/api/PublicApi.kt`
### A snapshot of your notifications, so the inbox opens without a signal
**No console type.** Not collected. 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.
- **Retention:** Until you sign out or delete the account, or the thirty are pushed out by newer ones
- **In detail:** Signing out, or deleting the account, 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. **App info and performance → Crash logs.** Not collected. The advertising ID declaration is "no".
There is no third-party SDK in the app at all — no Firebase, no Crashlytics, no advertising identifier, no measurement library. That is checkable rather than claimed: it is what the dependency list and the manifest say, and a build that gained one would gain permissions with it. There is no third-party SDK in the app at all — no Firebase, no Crashlytics, no advertising identifier, no measurement library. That is checkable rather than claimed: it is what the dependency list and the manifest say, and a build that gained one would gain permissions with it.
- **Why that answer:** No advertising ID, no analytics identifier, and no library that would generate one is linked into the build. - **Why that answer:** No crash reporter, no advertising ID, no analytics identifier, and no library that would generate one is linked into the build.
- **Retention:** Nothing to retain - **Retention:** Nothing to retain
- **Read from:** `app/build.gradle.kts, app/src/main/AndroidManifest.xml` - **Read from:** `app/build.gradle.kts, app/src/main/AndroidManifest.xml`
@@ -103,8 +219,31 @@ There is no third-party SDK in the app at all — no Firebase, no Crashlytics, n
- **Privacy policy URL:** `/privacy` on this site. It is the URL Play is given, and section 2 of it is about the app specifically. - **Privacy policy URL:** `/privacy` on this site. It is the URL Play is given, and section 2 of it is about the app specifically.
- **Target audience:** adults. The beta is stated as **18 or older** (D31); the app contains no content directed at children and no age verification. - **Target audience:** adults. The beta is stated as **18 or older** (D31); the app contains no content directed at children and no age verification.
- **Account deletion:** the app creates no account with us — an account belongs to the deployment the user chose, and is deleted there. The only list we hold is the beta signup, which is erased on request; `/privacy` section 4 says how to ask. - **Accounts the app can start:** the sign-in screen links to the deployment’s own sign-up page, which opens in the browser, and where a game server offers it the app creates a game account on it. Read from: `ui/auth/LoginViewModel.kt`, `data/api/PlayerShardApi.kt`.
- **Data deletion request URL:** the contact address published on `/privacy`, which is read from the mounted `brand.json` rather than typed anywhere in the source (D13). - **Can users delete their account? Yes,** in the app (*My account* → *Delete my account…*) and on the deployment’s website (*Account* → *Delete my account*). It takes the password, and a two-factor code where the account has one, and the account is deleted at once. The account belongs to the deployment the user chose, so it is deleted there; we hold none. Read from: `ui/auth/DeleteAccountScreen.kt`, `data/api/MeApi.kt`.
- **Delete account URL:** `/delete-account/` on this site (D85). It names the app and us, gives the steps in the app and on the website, says what is deleted and what is kept and for how long, and tells someone who cannot sign in to ask their community’s operator, who can delete any account from the admin panel (D86).
- **Deleting some data without deleting the account:** the deployment holds it, so that is a request to its operator. The one list we hold, the beta signup, is erased on request; `/privacy` section 4 says how to ask.
## What an account deletion deletes and keeps
The console asks what is deleted and what is kept. These are the same words `/delete-account/` renders, from the same row of `src/data/collection.mjs`.
**Deleted at once:**
- your sign-in, password, email address, two-factor settings and recovery codes
- every signed-in device, trusted device and push registration
- your linked sign-ins, such as Google, Discord or Steam
- your game-account links, and anything this site granted in the game through them
- your notification settings and inbox
**Kept:**
- What you wrote where others read it, such as forum posts, stays. Other members see it as written by “[deleted player]”. *Until a moderator or the operator removes it, as with any post.*
- Reports you filed and appeals you made stay as moderation records. Staff can still see who it was, and the activity log keeps its IP addresses. *Until the operator removes them.*
- Event results stay with the score and the in-game name, with no site account behind them. *Until the operator removes them.*
- A game account stays on the game server. Only its link to this site goes. *For as long as the game server keeps it, which is up to whoever runs that server.*
- The record of notifications sent to you keeps a one-way hash of your email address, never the address, and no longer names your account. An address that bounced or was reported as spam stays on the suppression list, so it is never mailed again. *The record is swept after a period the operator sets, 180 days by default. The suppression list does not expire.*
- If you were staff, your username can never be used again, by anyone.
## What the website collects, for the same reviewer ## What the website collects, for the same reviewer
@@ -116,4 +255,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-08-24. Regenerate with `npm run play:datasafety` after any change to what the app stores. Last generated from data dated 2026-10-30. Regenerate with `npm run play:datasafety` after any change to what the app stores.

113
README.md
View File

@@ -11,11 +11,12 @@ closed beta: **players**, who want the app.
platform state, the org lead's decisions, the information architecture, and the build phases. Read 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 5 of 12 — the app and the beta.** The foundation, the branding pipeline, the **Status: phase 12 of 12 — delivery. The site is built.** Fifty pages: ten marketing, legal and
homepage and the five marketing pages are built, and `/app/` and `/beta/` now join them: a signed app pages and forty of documentation, with real screenshots of the product, full-text search, a
APK beside the closed-test signup, backed by a SQLite store on a bind mount and an export CLI. Next per-page Content-Security-Policy, twelve checks that fail the build when the platform moves out from
are the legal pages (phase 6) and then the documentation — the installation path, which is the under a claim, and a closed-beta signup backed by SQLite on a bind mount. This phase is the part
priority of the whole project — in phases 7 and 8. that makes it a deployment rather than a repository — the container image, the compose file, the
publishing workflow and [`DEPLOY.md`](DEPLOY.md).
--- ---
@@ -31,21 +32,45 @@ npm run build # → dist/ (prerendered pages + the Node server entry)
npm start # serve the built site npm start # serve the built site
``` ```
Node 22 LTS or newer. Node 22.23.4+ (or 24.22.1+); older builds carry the Node2Shell Brotli bug, CVE-2026-111497. Nothing else — no database, no game server, no container runtime.
## Running it in production
One container, pulled from the Gitea registry, with two bind mounts and a reverse proxy in front.
**[`DEPLOY.md`](DEPLOY.md) is the operator's guide**: first deploy, what the proxy must and must not
do, DNS and TLS, branding without a rebuild, managing the tester list, rolling back, and the
symptoms table.
```bash
docker compose pull && docker compose up -d
```
Merging to `main` builds the image, publishes it as `runicgateway-site:latest` and `:sha-<7>`, and
deploys it — `.gitea/workflows/build-image.yml`. There is no separate release step, so **a merge is
a publication**.
## The checks, and why they are not optional ## The checks, and why they are not optional
Two of them, both from `PLAN.md` §12. Neither is a linter; each one enforces a promise the site Twelve of them, from `PLAN.md` §12. None is a linter; each one enforces a promise the site makes
makes that would otherwise decay quietly. that would otherwise decay quietly.
```bash ```bash
npm run check:sidebar # the rendered docs tree still matches the planned one
npm run check:terms # no game's vocabulary outside its section: docs + sidebar
npm run check:screens # every screenshot has an entry, at the size declared
npm run check:tokens # no colour literal outside the token file npm run check: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 && npm run check:links # every internal link resolves (reads the build) npm run build # everything below reads the build
npm run check:links # every internal link resolves
npm run check:terms:built # the same rule over every built page, as rendered
GITEA_TOKEN=<token> npm run check:facts # every version agrees with its authority GITEA_TOKEN=<token> npm run check: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
``` ```
@@ -81,6 +106,22 @@ carry are assembled from data files and template literals and a source scan sees
also refuses a commit permalink into any org repository — those stop tracking the document they name 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
@@ -147,6 +188,40 @@ Regenerating the stock assets is a separate, manual step — `npm run brand:asse
the emblem and the Cinzel outlines from the sibling checkouts in the workspace. Its output is 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
``` ```
@@ -155,11 +230,15 @@ src/
data/collection.mjs What is collected, in three scopes. /privacy renders it and the data/collection.mjs What is collected, in three scopes. /privacy renders it and the
Play Data Safety notes are generated from it — one inventory. Play Data Safety notes are generated from it — one inventory.
data/legal.mjs The values /terms, /privacy and the consent sentence must share. data/legal.mjs The values /terms, /privacy and the consent sentence must share.
data/games/ One file per game, in that game's words, checked against its
module's capabilities. Only that game's pages render it.
styles/tokens.css THE token file — the only place a colour literal may appear. styles/tokens.css THE token file — the only place a colour literal may appear.
styles/global.css The layout shell, built entirely from tokens. styles/global.css The layout shell, built entirely from tokens.
styles/starlight.css Restates our tokens as Starlight's, so the docs cannot drift. styles/starlight.css Restates our tokens as Starlight's, so the docs cannot drift.
layouts/, components/ The marketing chrome. layouts/, components/ The marketing chrome.
pages/ Marketing routes. pages/ Marketing routes.
pages/modules/ One page per game module; with /runicnpc/, the only marketing
routes that may use a game's own words (D63, checkTerms --built).
pages/beta.astro The signup. Renders AND handles its own POST — runs per request. pages/beta.astro The signup. Renders AND handles its own POST — runs per request.
content/docs/docs/ Documentation. The extra level mounts Starlight at /docs. content/docs/docs/ Documentation. The extra level mounts Starlight at /docs.
pages/brand/ GET /brand/* — the mount, resolved and derived. Runs per request. pages/brand/ GET /brand/* — the mount, resolved and derived. Runs per request.
@@ -170,12 +249,17 @@ src/
lib/betaSignup.mjs Everything between a POST body and a row. Never throws. lib/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 (boot), brand:assets (manual) scripts/ The build-time checks, plus applyBrand and serve (boot),
and beta.mjs (the tester-list CLI). brand:assets (manual) 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
@@ -186,13 +270,18 @@ 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. pull request. **[CONTRIBUTING.md](CONTRIBUTING.md)** has the rest, including the two rules from
`PLAN.md` that constrain how a page may be written at all: the site never re-specifies a contract,
and no fact is stated in prose.
**AI-assisted contributions must be disclosed**, per org policy: tick the box in the pull request **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 Normal file
View File

@@ -0,0 +1,43 @@
# Security Policy
**Please do not report security vulnerabilities through public issues, pull requests or the wiki.**
A public report tips off attackers before a fix is available.
Report privately, using the contact route in the organisation's security policy:
**[RunicGateway/docs → SECURITY.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/SECURITY.md)**
That document is the single copy for all ten repositories, and it carries the address, what to
include in a report and what to expect back.
## Why this file is a pointer rather than a copy
Every other repository in the organisation states the reporting address inline. This one does not,
and the reason is a decision of record rather than an oversight: **D13** (`PLAN.md` §5) confines the
published contact address to `brand.json`, a bind-mounted file, so that changing it is a file copy
and a container restart rather than a commit. `scripts/checkFacts.mjs` fails the build if an address
appears anywhere in `src/` or `scripts/`, and this file honours the same rule voluntarily — a
hard-coded address in the repository root would be one more place to forget when the address moves.
## What is worth reporting here
This site holds one thing of value and has one writing endpoint.
- **The closed-beta signup** (`/beta`, `PLAN.md` §8) is the only route that writes. It stores an
email address, a consent record and a **salted hash of the IP address** — never the address
itself. Anything that lets a caller read rows, bypass the rate limit or the total cap, forge the
signed form token, or recover an IP from a hash is in scope and worth reporting.
- **The branding mount** (`GET /brand/*`, §7) reads files from a directory an operator controls.
Path traversal out of that directory, or reaching a file type outside the route's allowlist, is in
scope.
- **The Content-Security-Policy** is a real response header written by `scripts/serve.mjs`. A page
that is served no policy, or another page's policy, is a defect worth reporting — that exact bug
has happened here once already (`PLAN.md` D48).
The site has **no authenticated surface at all**, by design: the tester list is managed from a shell
against the bind mount, not from an admin page. There is no session, no cookie and no login to
attack.
Vulnerabilities in the **platform itself** — the website, the sidecar, the shard plugin, the
installer or the Android app — belong in the organisation's policy linked above, not here. This
repository only describes them.

View File

@@ -3,6 +3,8 @@ 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 { docsRedirects } from './src/config/redirects.mjs';
import { docsSidebar } from './src/config/sidebar.mjs'; import { docsSidebar } from './src/config/sidebar.mjs';
/** /**
@@ -20,13 +22,76 @@ 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',
adapter: node({ mode: 'standalone' }), // `staticHeaders` is what turns §6's CSP from a promise into a response header (D48).
// Without it the policy ships as a `<meta http-equiv>`, and a meta CSP silently ignores
// `frame-ancestors` — the one directive that stops the site being framed. With it, the
// build writes `_headers.json` next to the server entry and the standalone server sends
// the policy as a real header on every prerendered route, so the operator's reverse proxy
// needs no CSP configuration at all and cannot get it wrong.
adapter: node({ mode: 'standalone', staticHeaders: true }),
build: { 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',
}, },
// Pages that moved into a game's own sidebar group (PLAN.md §16, D60). See the file.
redirects: docsRedirects,
security: {
csp: {
directives: [
// The whole posture in one line: nothing loads from anywhere but this origin.
// §6 could promise this without exceptions because the fonts are self-hosted and
// D9 rules out analytics — there is no CDN to whitelist and no beacon to allow.
"default-src 'self'",
// Not covered by `default-src`, and each one closes a specific door: no injected
// `<base>` can re-point every relative URL on the page, the signup form can only
// post to us, no plugin content at all, and the site cannot be framed. The last
// of those is the reason `staticHeaders` is on.
"base-uri 'self'",
"form-action 'self'",
"object-src 'none'",
"frame-ancestors 'none'",
// One `url(data:image/svg+xml)` survives bundling into the stylesheet. Data URLs
// are a real (if small) exfiltration-free risk surface, so this is the only
// relaxation of `default-src` on the image directive and it is scoped to images.
"img-src 'self' data:",
],
scriptDirective: {
resources: [
"'self'",
// Pagefind (D47) compiles its index with `WebAssembly.instantiate`, which a
// strict `script-src` blocks outright — search silently returns nothing. This
// permits WASM compilation *only*; it does not restore `eval`.
"'wasm-unsafe-eval'",
],
// Starlight's own `is:inline` scripts, which Astro does not hash because it never
// parses them. Generated — see src/config/cspHashes.mjs and `npm run check:csp`.
hashes: inlineScriptHashes,
},
styleDirective: {
// No `'self'` here, though `style-src` needs it and gets it: Astro's default
// already supplies it, and naming it alongside an `attribute`-kind resource makes
// the build warn — browsers do not fall back from `style-src-attr` to `style-src`,
// so a `'self'` written here would apply to neither scope the author meant.
resources: [
// Starlight and Expressive Code write ~3,700 inline `style` attributes into the
// documentation — icon sizing, the theme select's width, and every syntax
// colour, which Expressive Code emits as custom properties on the element. They
// cannot be hashed (CSP hashes cover `<style>` elements, never attributes), and
// Astro's own docs record Shiki as incompatible with CSP for exactly this
// reason. Scoped to `style-src-attr` deliberately: a style attribute cannot
// execute script, so this leaves the directive CSP exists for — `script-src` —
// untouched. The marketing pages emit zero inline style attributes.
{ resource: "'unsafe-inline'", kind: 'attribute' },
],
hashes: inlineStyleHashes,
},
},
},
integrations: [ integrations: [
starlight({ starlight({
title: 'Runic Gateway', title: 'Runic Gateway',
@@ -49,6 +114,9 @@ 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,

View File

@@ -23,13 +23,22 @@
"giteaOrg": "https://gitea.whitlocktech.com/RunicGateway", "giteaOrg": "https://gitea.whitlocktech.com/RunicGateway",
"$comment_demo": [ "$comment_demo": [
"§15 / D12. A public demo instance is planned and out of scope today. The homepage's", "§15 / D12. The public demo's address, e.g. https://demo.runicgateway.com. The",
"'See it running' slot and /features/'s per-capability affordance render only when this", "homepage's 'See it running' slot and /features/'s per-capability affordance render only",
"is a non-empty URL, so the site gains a working demo by way of one line in a mounted", "when this is a non-empty URL, so the site gains a working demo by way of one line in a",
"file — no rebuild, consistent with §7." "mounted file — no rebuild, consistent with §7. /beta also reads the demo's published",
"sign-ins from it, on the next request (D82)."
], ],
"demoUrl": "", "demoUrl": "",
"$comment_demoGames": [
"D83. The game modules the demo at demoUrl runs, by module id (\"rust\", \"uo\"). A",
"'See it running' link into a game's own pages shows only for a game listed here, so",
"a Rust demo never links to a /uo/ page it does not have. Core's links (the wiki, the",
"event calendar, news) follow demoUrl alone. Read at the next restart, like demoUrl."
],
"demoGames": [],
"$comment_beta": [ "$comment_beta": [
"PLAN.md §8 / D27. The Google Play closed-test opt-in URL. Empty until the track", "PLAN.md §8 / D27. The Google Play closed-test opt-in URL. Empty until the track",
"exists, and /beta renders a waiting state rather than a broken link while it is.", "exists, and /beta renders a waiting state rather than a broken link while it is.",

104
docker-compose.yml Normal file
View File

@@ -0,0 +1,104 @@
# runicgateway.com — production.
#
# Pull-only, in common with the rest of the org: `image:` and no `build:`, so a
# production host can never accidentally build. The image is published to the
# Gitea registry by .gitea/workflows/build-image.yml on every merge to main.
#
# Full operator guide, including DNS, TLS and the reverse proxy: DEPLOY.md.
#
# docker compose pull && docker compose up -d
#
# One service. §6 records why there is no second one: the dynamic surface is two
# routes, and one container is one thing to deploy, one thing to patch and one
# log to read.
services:
site:
# IMAGE_TAG defaults to `latest`. Pin a build for a reproducible deploy or a
# rollback — e.g. IMAGE_TAG=sha-1806406 in .env; every merge publishes both.
image: gitea.whitlocktech.com/runicgateway/runicgateway-site:${IMAGE_TAG:-latest}
restart: unless-stopped
# Secrets and tuning for the beta signup (§8). The file is optional in the
# sense that the site starts without it — but read the note in .env.example
# about BETA_IP_SALT and BETA_FORM_KEY before deciding to skip it: their
# defaults are random PER PROCESS, so leaving them unset means every restart
# forgets who has been rate-limited.
env_file: .env
volumes:
# ---------------------------------------------------------------------------
# The branding mount (§7). Read-only: nothing in the container ever writes
# here, and the whole point of the directory is that a human puts files in
# it from the host.
#
# May be empty, partial or complete. Every file resolves against this mount
# first and the image's brand-default/ second, PER FILE — so a directory
# holding only theme.css recolours the site and leaves every logo stock,
# and an empty directory produces exactly the stock site.
#
# Changing a file here takes a RESTART, not a rebuild: `docker compose
# restart site` re-runs the boot rewrite, which is what puts a new site
# name into forty-nine prerendered pages. The exception is brand.json's
# betaOptInUrl, which /beta reads live on every request — so the closed
# test can be opened by editing one file, with no restart at all.
- ./brand:/app/brand:ro
# ---------------------------------------------------------------------------
# The beta signup store (§8): beta.sqlite and exports/. Read-WRITE, and the
# only thing this site persists.
#
# A bind mount rather than a named volume because the tester list has to be
# reachable from the host — `sqlite3 ./data/beta.sqlite`, a backup by `cp`,
# and the CSV the export CLI writes into ./data/exports/ for pasting into
# Play. A named volume would put all three behind `docker cp`.
#
# The container runs as uid 1000. Docker creates a MISSING bind-mount source
# as root:root, and the store then fails to open — so create the directory
# yourself and, if it is owned by someone else, `chown 1000:1000 ./data`.
# DEPLOY.md has the two commands.
- ./data:/app/data
# Published on ALL interfaces by default, so the site answers on the host's
# own address — `http://<vm-ip>:4321` — and not only on its loopback. That is
# what makes it reachable from the rest of the network: a proxy in another
# container or on another machine, a browser on the LAN, a phone on the same
# wifi checking the mobile layout.
#
# It is deliberately a variable rather than a fixed address, because the safe
# binding depends on where this host sits. Set SITE_BIND_ADDR in .env to
# narrow it without touching this file:
#
# SITE_BIND_ADDR=127.0.0.1 loopback only — a proxy on THIS host, nothing else
# SITE_BIND_ADDR=192.168.1.10 one interface — the LAN, but not a public NIC
# SITE_BIND_ADDR=0.0.0.0 every interface (the default)
#
# On a host with a public address, `0.0.0.0` means port 4321 answers from the
# internet directly, beside whatever the proxy serves on 443 — plain HTTP, no
# TLS. Firewall the port, or narrow the binding. DEPLOY.md, "Putting a proxy
# in front of it".
ports:
- "${SITE_BIND_ADDR:-0.0.0.0}:${SITE_HOST_PORT:-4321}:4321"
# Repeats the image's own HEALTHCHECK so `docker compose ps` reports it even
# when the image is pinned to an older tag that predates it. It watches an
# actual response rather than the process, because `npm start` runs the brand
# rewrite before the server: there is a real window where the container is up
# and nothing is listening.
#
# The command is a QUOTED flow sequence, which is not a style choice: written as a
# block sequence, YAML reads the `: ` inside `r.ok ? 0 : 1` as a key/value separator
# and `docker compose config` refuses the file with "healthcheck.test.3 must be a
# string". Keep the quotes.
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:4321/').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
interval: 30s
timeout: 5s
start_period: 40s
retries: 3
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"

839
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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 ./dist/server/entry.mjs", "start": "node scripts/applyBrand.mjs && node scripts/serve.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",
@@ -20,11 +20,21 @@
"check:links": "node scripts/checkLinks.mjs", "check:links": "node scripts/checkLinks.mjs",
"check:datasafety": "node scripts/playDataSafety.mjs --check", "check:datasafety": "node scripts/playDataSafety.mjs --check",
"check:quickstart": "node scripts/checkQuickstart.mjs", "check:quickstart": "node scripts/checkQuickstart.mjs",
"check:reference": "node scripts/checkReference.mjs",
"check:sidebar": "node scripts/checkSidebar.mjs",
"check:terms": "node scripts/checkTerms.mjs",
"check:terms:built": "node scripts/checkTerms.mjs --built",
"check:screens": "node scripts/checkScreens.mjs",
"check:a11y": "node scripts/checkA11y.mjs",
"check:csp": "node scripts/checkCsp.mjs",
"play:datasafety": "node scripts/playDataSafety.mjs", "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": "node --test test/beta.test.mjs test/legal.test.mjs test/footer.test.mjs test/demo.test.mjs",
"brand:assets": "node scripts/buildBrandAssets.mjs", "brand:assets": "node scripts/buildBrandAssets.mjs",
"verify": "npm run check:tokens && npm run check:brand && npm run check:datasafety && npm run check && npm test && npm run build && npm run check:links && npm run check:facts && npm run check:quickstart" "screens:capture": "node scripts/captureScreens.mjs",
"csp:hashes": "node scripts/checkCsp.mjs --reset && astro build && node scripts/checkCsp.mjs --write && astro build && node scripts/checkCsp.mjs",
"verify": "npm run check:sidebar && npm run check:terms && npm run check:screens && npm run check:tokens && npm run check:brand && npm run check:datasafety && npm run check && npm test && npm run build && npm run check:links && npm run check:terms:built && npm run check:facts && npm run check:quickstart && npm run check:reference && npm run test:served && npm run check:a11y && npm run check:csp",
"test:served": "node --test test/headers.test.mjs"
}, },
"dependencies": { "dependencies": {
"@astrojs/node": "^11.1.4", "@astrojs/node": "^11.1.4",
@@ -33,12 +43,14 @@
"@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": {
"@astrojs/check": "^0.9.10", "@astrojs/check": "^0.9.10",
"opentype.js": "^2.0.0", "opentype.js": "^2.0.0",
"yaml": "^2.8.1", "puppeteer-core": "^23.11.1",
"typescript": "^6.0.3" "typescript": "^6.0.3",
"yaml": "^2.8.1"
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

BIN
public/screens/guilds.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

BIN
public/screens/houses.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

BIN
public/screens/news.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

View File

@@ -193,17 +193,38 @@ if (demoFrom !== demoTo) {
* because `global.css` hides `[data-demo-url='']` — the visibility rule stays one rule for * because `global.css` hides `[data-demo-url='']` — the visibility rule stays one rule for
* both kinds of link, and only the `href` differs. * both kinds of link, and only the `href` differs.
*/ */
const DEEP_LINK = /href="[^"]*" data-demo-url="[^"]*" data-demo-path="([^"]*)"/g; const DEEP_LINK = /href="[^"]*" data-demo-url="[^"]*" data-demo-path="([^"]*)"(?: data-demo-game="([^"]*)")?/g;
const deepLinkTo = (demoPath) => { /**
const href = demoTo ? `${demoTo.replace(/\/+$/, '')}${demoPath}` : ''; * Which games the demo runs (D83). A deep link into a game module's pages carries that
* game's id in `data-demo-game`, and it is filled only when the mounted `demoGames` names
* it: the demo is a Rust deployment, and a `/uo/market` link into it is a 404. Core's own
* links (`/wiki`, `/site/events`) carry no game and follow `demoUrl` alone, because every
* deployment has them.
*
* An absent or malformed list means no game's links, not every game's. A demo whose games
* nobody named is one whose game links cannot be known to work.
*/
const gamesOf = (value) => (Array.isArray(value) ? value.filter((id) => typeof id === 'string') : []);
const gamesFrom = gamesOf(previous.demoGames);
const gamesTo = gamesOf(resolved.demoGames);
const gamesChanged = gamesFrom.join(',') !== gamesTo.join(',');
if ('demoGames' in mounted && !Array.isArray(mounted.demoGames)) {
console.warn('[brand] the mounted "demoGames" is not a list of game ids — no game links will show.');
}
const deepLinkTo = (demoPath, game) => {
const root = !game || gamesTo.includes(game) ? demoTo : '';
const href = root ? `${root.replace(/\/+$/, '')}${demoPath}` : '';
return ( return (
`href="${escapeHtml(href)}" data-demo-url="${escapeHtml(demoTo)}" ` + `href="${escapeHtml(href)}" data-demo-url="${escapeHtml(root)}" ` +
`data-demo-path="${demoPath}"` `data-demo-path="${demoPath}"` +
(game === undefined ? '' : ` data-demo-game="${game}"`)
); );
}; };
if (!replacements.length) { if (!replacements.length && !gamesChanged) {
console.log('[brand] mount matches what is already applied; nothing to rewrite.'); console.log('[brand] mount matches what is already applied; nothing to rewrite.');
process.exit(0); process.exit(0);
} }
@@ -229,10 +250,71 @@ 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);
@@ -242,8 +324,8 @@ for (const file of walk(CLIENT)) {
// After the literal swaps, never before: the plain-slot replacement also matches the // After the literal swaps, never before: the plain-slot replacement also matches the
// first two attributes of a deep link, so it runs first and this pass corrects the // first two attributes of a deep link, so it runs first and this pass corrects the
// `href` it just wrote. Recomputing rather than replacing is what makes that safe. // `href` it just wrote. Recomputing rather than replacing is what makes that safe.
after = after.replace(DEEP_LINK, (whole, demoPath) => { after = after.replace(DEEP_LINK, (whole, demoPath, game) => {
const rebuilt = deepLinkTo(demoPath); const rebuilt = deepLinkTo(demoPath, game);
if (rebuilt !== whole) counts.set('demoDeep', counts.get('demoDeep') + 1); if (rebuilt !== whole) counts.set('demoDeep', counts.get('demoDeep') + 1);
return rebuilt; return rebuilt;
}); });
@@ -269,7 +351,51 @@ if (counts.get('demoDeep')) {
` ${'demoUrl deep'.padEnd(14)} ${demoTo ? `linked -> ${demoTo}/…` : 'links hidden'} (${counts.get('demoDeep')}x)` ` ${'demoUrl deep'.padEnd(14)} ${demoTo ? `linked -> ${demoTo}/…` : 'links hidden'} (${counts.get('demoDeep')}x)`
); );
} }
if (gamesChanged) {
console.log(` ${'demoGames'.padEnd(14)} ${gamesTo.length ? gamesTo.join(', ') : 'no game links'}`);
}
// Pagefind builds its search index from the HTML at BUILD time (phase 10), so a rename if (inlineCollisions.length) {
// applied here reaches the pages but not the search results. Worth fixing when search console.error(
// lands; recorded here rather than in a plan section nobody will re-read. `\n[brand] ${inlineCollisions.length} file(s) were LEFT UNCHANGED: a brand value occurs ` +
`inside an inline <script> or <style>, and rewriting it would break that block's CSP ` +
`hash (§6, D48) — the page would render and the script would silently not run.\n`
);
for (const { file, fields } of inlineCollisions) {
console.error(` ! ${file} (${fields.join(', ')})`);
}
console.error(
`\n Those pages keep the stock text. Fix it by taking the brand value out of the inline\n` +
` block — move it into markup the CSP does not hash, or into /brand/theme.css.\n`
);
}
/* ---------------------------------------------------------------------------------------
Search
--------------------------------------------------------------------------------------- */
/**
* Pagefind builds its index from the built HTML at BUILD time, so everything above reaches
* the pages and none of it reaches the search results: a site renamed through the mount
* would answer a search for its own name with the stock one, and every result title would
* still carry the old suffix. Phase 2 recorded that and left it for this phase, when
* search became site-wide (D47).
*
* The fix is to re-index, which is cheap and needs nothing the container does not already
* have — Pagefind is what Starlight ran at build. It only runs when a rewrite actually
* happened, so the stock deployment, which is the common case, still pays nothing.
*/
if (filesTouched > 0) {
const pagefind = await import('pagefind');
try {
const { index } = await pagefind.createIndex();
const { page_count } = await index.addDirectory({ path: CLIENT });
await index.writeFiles({ outputPath: path.join(CLIENT, 'pagefind') });
console.log(`[brand] re-indexed ${page_count} page(s) for search so results agree with it.`);
} catch (error) {
// Search degrading to stale titles is not a reason to refuse to serve the site.
console.error(`[brand] could not rebuild the search index; it keeps the built one: ${error.message}`);
} finally {
await pagefind.close();
}
}

206
scripts/captureScreens.mjs Normal file
View File

@@ -0,0 +1,206 @@
#!/usr/bin/env node
/**
* captureScreens.mjs — retakes the screenshots in `src/data/screens.mjs`.
*
* PLAN.md §13 phase 9, D4 / D45.
*
* node scripts/captureScreens.mjs # every web screen
* node scripts/captureScreens.mjs shard-status admin-users
* RG_DEMO=http://localhost:3000 node scripts/captureScreens.mjs
*
* ---------------------------------------------------------------------------------------
* AN AUTHORING TOOL, LIKE buildBrandAssets.mjs — NOT A CHECK
* ---------------------------------------------------------------------------------------
* This never runs in CI and CI never needs it: its output is committed, because the site
* must build from a clean checkout with no game server, no database and no browser. What
* CI runs is `checkScreens.mjs`, which only reads the files this produced.
*
* It exists because D4 asks for real screenshots of a real deployment, and the way real
* screenshots rot is that the recipe for taking them lives in somebody's memory. The rig
* is written down in PLAN.md §13; the framing — route, viewport, scroll offset, whether to
* sign in — is written down in `screens.mjs`; and this turns the two into files.
*
* ---------------------------------------------------------------------------------------
* WHY puppeteer-core AND NOT puppeteer
* ---------------------------------------------------------------------------------------
* `puppeteer` downloads its own Chromium — a hundred-odd megabytes fetched on every clean
* install of a repository that needs a browser once per redesign. `puppeteer-core` drives
* a Chrome that is already on the machine, which every machine that can look at this site
* has. Point `RG_CHROME` at it if it is somewhere unusual.
*
* ---------------------------------------------------------------------------------------
* WHY IT SIGNS IN THROUGH THE API RATHER THAN THE LOGIN FORM
* ---------------------------------------------------------------------------------------
* The administration screens need a session, and typing into the login form is the part of
* a browser script most likely to break on a redesign — a moved field, a renamed button, a
* React input that ignores synthetic typing. The session cookie is the only thing actually
* wanted, so this asks the API for one from inside the page and lets the browser store it.
* If that call stops returning 200 the script says so and stops, rather than quietly
* screenshotting a login screen twelve times.
*/
import { existsSync, mkdirSync, readdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import sharp from 'sharp';
import { screens, screensOf, WEB } from '../src/data/screens.mjs';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const OUT = path.join(HERE, '..', 'public', 'screens');
const BASE = (process.env.RG_DEMO || 'http://localhost:3000').replace(/\/+$/, '');
const USER = process.env.RG_ADMIN_USER || 'demoadmin';
const PASS = process.env.RG_ADMIN_PASS || 'DemoReview!2026';
/** Where Chrome usually is, per platform. First hit wins; `RG_CHROME` beats all of them. */
const CHROME_CANDIDATES = [
process.env.RG_CHROME,
'C:/Program Files/Google/Chrome/Application/chrome.exe',
'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/usr/bin/google-chrome',
'/usr/bin/chromium',
].filter(Boolean);
const wanted = process.argv.slice(2).filter((arg) => !arg.startsWith('-'));
const todo = screensOf('web').filter((shot) => wanted.length === 0 || wanted.includes(shot.id));
if (todo.length === 0) {
const known = screens.map((shot) => shot.id).join(', ');
console.error(`Nothing to capture. Known ids: ${known}`);
process.exit(1);
}
const chrome = CHROME_CANDIDATES.find((candidate) => existsSync(candidate));
if (!chrome) {
console.error(
'No Chrome found. Set RG_CHROME to the browser executable — this script drives an\n' +
'installed Chrome rather than downloading one (see the header).',
);
process.exit(1);
}
const puppeteer = (await import('puppeteer-core')).default;
mkdirSync(OUT, { recursive: true });
const browser = await puppeteer.launch({
executablePath: chrome,
headless: 'new',
defaultViewport: { ...WEB.viewport, deviceScaleFactor: WEB.scale },
// Scrollbars are the browser's furniture, not the product's, and a colour profile that
// is not sRGB makes the palette in a screenshot disagree with the palette on the page.
args: ['--hide-scrollbars', '--force-color-profile=srgb'],
});
/**
* One page per privilege level rather than signing in and out around each shot: signing
* out is the step that gets forgotten, and a public page captured with an admin session
* shows a navigation bar the public never sees.
*/
const anon = await browser.newPage();
const admin = await browser.newPage();
await admin.goto(BASE, { waitUntil: 'domcontentloaded' });
const status = await admin.evaluate(
async (username, password) => {
const res = await fetch('/api/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ username, password }),
});
return res.status;
},
USER,
PASS,
);
if (status !== 200) {
console.error(
`Could not sign in as "${USER}" at ${BASE} (HTTP ${status}).\n` +
'Seed the demo first — see PLAN.md §13 phase 9 and scripts/seedDemo.mjs.',
);
await browser.close();
process.exit(1);
}
let failures = 0;
for (const shot of todo) {
const page = shot.admin ? admin : anon;
const url = BASE + shot.route;
try {
await page.goto(url, { waitUntil: 'networkidle2', timeout: 30_000 });
// A screen whose subject is behind a tab or a button the route cannot reach. Each step is
// the visible text the element starts with; the first visible button, link or tab that
// matches is clicked. A step that matches nothing fails the shot rather than capturing
// the wrong screen.
for (const label of shot.click ?? []) {
const clicked = await page.evaluate((text) => {
const candidates = [...document.querySelectorAll('button, a, [role="tab"], [role="button"], li')];
const hit = candidates.find(
(el) => el.offsetParent !== null && el.textContent.trim().startsWith(text),
);
if (hit) hit.click();
return Boolean(hit);
}, label);
if (!clicked) throw new Error(`nothing to click starting "${label}"`);
await new Promise((resolve) => setTimeout(resolve, 800));
}
if (shot.scrollY) {
await page.evaluate((y) => window.scrollTo(0, y), shot.scrollY);
}
// Live pages settle after their first paint: a shard panel fills in from an event
// stream, a list re-sorts once its data lands. A second is cheap and the difference
// between a screenshot of the product and a screenshot of its loading state.
await new Promise((resolve) => setTimeout(resolve, 1200));
const png = await page.screenshot({ type: 'png' });
const file = path.join(OUT, `${shot.id}.webp`);
// Quality 82 is where UI text stops visibly softening; the files land near 150 KB,
// which is what makes a page with five of them still a page and not a download.
await sharp(png).webp({ quality: 82 }).toFile(file);
const meta = await sharp(file).metadata();
if (meta.width !== WEB.width || meta.height !== WEB.height) {
console.error(
` ! ${shot.id}: got ${meta.width}x${meta.height}, expected ${WEB.width}x${WEB.height}`,
);
failures++;
continue;
}
console.log(` + ${shot.id.padEnd(18)} ${shot.route.padEnd(20)} ${meta.width}x${meta.height}`);
} catch (err) {
console.error(` ! ${shot.id}: ${err.message}`);
failures++;
}
}
await browser.close();
// A file left behind by a screen that has since been renamed or dropped is a file the
// site still ships and nothing points at. Say so; do not delete somebody's work silently.
if (wanted.length === 0) {
const declared = new Set(screensOf('web').map((shot) => `${shot.id}.webp`));
const phones = new Set(screensOf('phone').map((shot) => `${shot.id}.webp`));
const orphans = readdirSync(OUT).filter((name) => !declared.has(name) && !phones.has(name));
if (orphans.length > 0) {
console.log(`\nNot declared in screens.mjs, left alone: ${orphans.join(', ')}`);
}
}
console.log(`\n${todo.length - failures} captured, ${failures} failed.`);
process.exit(failures > 0 ? 1 : 0);

278
scripts/checkA11y.mjs Normal file
View File

@@ -0,0 +1,278 @@
#!/usr/bin/env node
/**
* checkA11y.mjs — PLAN.md §13 phase 10.
*
* Every other rule this repository cares about is enforced by a script — the facts, the
* links, the tokens, the sidebar, the screenshots, the CSP. Accessibility was the exception:
* it was a thing someone checked once, by hand, on the pages they happened to open. This
* makes it the eleventh check so a regression fails a build instead of waiting for a reader
* who cannot use the page and will not file an issue.
*
* node scripts/checkA11y.mjs
*
* ── What it checks, and why each one ────────────────────────────────────────
* A static check cannot measure contrast against a rendered page or find a focus trap, and
* pretending otherwise would be worse than not checking. What it CAN do is catch the class
* of defect that is invisible to a sighted author and permanent once shipped:
*
* 1. **One `<h1>` per page, and no skipped heading level.** The heading tree is the
* document outline a screen-reader user navigates by. Two `<h1>`s or an `<h2>` under
* nothing reads as a page with no structure at all.
* 2. **Every `<img>` has an `alt`.** Not "a non-empty alt": `alt=""` is correct and
* deliberate for the header mark, which sits inside a link that already says the
* product's name. A MISSING attribute is what makes a screen reader read the filename.
* 3. **Every form control has a label.** `<label for>`, a wrapping `<label>`,
* `aria-label` or `aria-labelledby`. The signup form is the only place on this site
* where a person is asked to type something, so it is the one place this must hold.
* 4. **Every link and button has an accessible name.** An icon-only control with no text
* and no `aria-label` is announced as "link", which is no name at all. The search
* button is icon-only under 46rem, which is exactly this hazard.
* 5. **`<html lang>` is set**, or a screen reader reads English prose with whatever voice
* the reader last used.
* 6. **One `<main>` per page and a skip link that points at it.** The site's header is a
* lockup, four links and a search box in front of every page; without a working skip
* link a keyboard user walks all six on every navigation.
* 7. **No positive `tabindex`.** It reorders the tab sequence away from the visual one
* and is almost never what the author meant.
*
* Both chromes are checked — the marketing pages and Starlight's forty. Starlight is
* generally careful, so the docs half is a regression alarm on a dependency rather than a
* review of our own markup, and it has already earned its place once: it is what would have
* caught the `<h2>`-without-`<h1>` shape if a docs page had ever lost its title.
*
* No token and no network: everything read here is in `dist/`.
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const clientDir = path.join(root, 'dist', 'client');
if (!fs.existsSync(clientDir)) {
console.error('\ncheckA11y: dist/client does not exist. Run `npm run build` first.\n');
process.exit(1);
}
const failures = [];
const fail = (page, what, detail) => failures.push({ page, what, detail });
const pages = [];
const walk = (dir) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full);
else if (entry.name.endsWith('.html')) pages.push(full);
}
};
walk(clientDir);
/* ---------------------------------------------------------------------------------------
A very small amount of HTML reading
Not a parser. Everything below is a tag-level question — does this element carry this
attribute, what text sits between these two tags — and a regex answers those on
generated, well-formed output. A DOM parser would be a dependency, and this repository's
checks are dependency-free on purpose (§12): the reader runs them the same way CI does.
--------------------------------------------------------------------------------------- */
/** Takes the ATTRIBUTE STRING — what is between the tag name and the `>` — not the tag. */
const attrs = (attrString) => {
const found = new Map();
const re = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g;
let m;
while ((m = re.exec(attrString))) {
found.set(m[1].toLowerCase(), m[2] ?? m[3] ?? m[4] ?? '');
}
return found;
};
/** Text a screen reader would announce: markup and comments stripped, entities loosened. */
const textOf = (html) =>
html
.replace(/<!--[\s\S]*?-->/g, '')
.replace(/<[^>]*>/g, ' ')
.replace(/&[a-zA-Z#0-9]+;/g, ' ')
.replace(/\s+/g, ' ')
.trim();
/** An element's own accessible name, near enough for "is there one at all". */
const named = (tag, inner) => {
const a = attrs(tag);
if (a.get('aria-label')?.trim()) return true;
if (a.get('aria-labelledby')?.trim()) return true;
if (a.get('title')?.trim()) return true;
if (textOf(inner)) return true;
// An image child with alt text names the control.
for (const img of inner.matchAll(/<img\b([^>]*)>/gi)) {
if (attrs(img[1]).get('alt')?.trim()) return true;
}
// An SVG with a title element does too.
if (/<svg\b[^>]*>[\s\S]*?<title\b[^>]*>[^<]+<\/title>/i.test(inner)) return true;
return false;
};
for (const file of pages) {
const page = '/' + path.relative(clientDir, file).replace(/\\/g, '/');
/**
* Comments are stripped before anything is counted, and that is not a nicety: this
* repository comments its markup heavily, and several of those comments quote the tags
* they are explaining. `Base.astro`'s note about `data-pagefind-body` contains the text
* "<main>", and the first run of this check reported every marketing page as having two
* `<main>` landmarks because of it. Stripping once, up front, also keeps every offset
* below measured against the same string.
*/
const html = fs.readFileSync(file, 'utf8').replace(/<!--[\s\S]*?-->/g, '');
// ── 5. lang ───────────────────────────────────────────────────────────────
const htmlTag = /<html\b([^>]*)>/i.exec(html);
if (!htmlTag) fail(page, '<html>', 'has no <html> element');
else if (!attrs(htmlTag[1]).get('lang')?.trim()) fail(page, '<html>', 'has no lang attribute');
// ── 1. headings ───────────────────────────────────────────────────────────
const headings = [...html.matchAll(/<h([1-6])\b([^>]*)>([\s\S]*?)<\/h\1>/gi)]
// `aria-hidden` headings are decorative and out of the outline by definition.
.filter((m) => attrs(m[2]).get('aria-hidden') !== 'true')
.map((m) => ({ level: Number(m[1]), text: textOf(m[3]) }));
const h1s = headings.filter((h) => h.level === 1);
if (h1s.length === 0) fail(page, 'headings', 'has no <h1>');
if (h1s.length > 1) {
fail(page, 'headings', `has ${h1s.length} <h1>s: ${h1s.map((h) => JSON.stringify(h.text)).join(', ')}`);
}
let previous = 0;
for (const heading of headings) {
if (previous && heading.level > previous + 1) {
fail(
page,
'headings',
`jumps from h${previous} to h${heading.level} at ${JSON.stringify(heading.text.slice(0, 50))}`,
);
}
previous = heading.level;
}
// ── 2. images ─────────────────────────────────────────────────────────────
for (const img of html.matchAll(/<img\b([^>]*)>/gi)) {
const a = attrs(img[1]);
if (!a.has('alt')) {
fail(page, '<img>', `has no alt attribute: src=${a.get('src') ?? '(none)'}`);
}
}
// ── 3. form controls ──────────────────────────────────────────────────────
const labelledIds = new Set(
[...html.matchAll(/<label\b([^>]*)>/gi)]
.map((m) => attrs(m[1]).get('for'))
.filter(Boolean),
);
/**
* A control wrapped in its own `<label>` needs no `for` and — this is the part that took
* a wrong answer to find — needs no `id` either, so it cannot be recorded by id. Starlight
* labels its theme and language selects exactly this way. What is recorded instead is the
* character offset of each wrapped control, which identifies it uniquely without
* requiring it to have any attributes at all.
*/
const wrappedAt = new Set();
for (const label of html.matchAll(/<label\b[^>]*>([\s\S]*?)<\/label>/gi)) {
const base = label.index + label[0].indexOf(label[1]);
for (const control of label[1].matchAll(/<(input|select|textarea)\b[^>]*>/gi)) {
wrappedAt.add(base + control.index);
}
}
for (const control of html.matchAll(/<(input|select|textarea)\b([^>]*)>/gi)) {
const a = attrs(control[2]);
const type = (a.get('type') ?? 'text').toLowerCase();
// These are not things a person types into and are named by other means.
if (['hidden', 'submit', 'button', 'reset', 'image'].includes(type)) continue;
const id = a.get('id');
const hasLabel =
wrappedAt.has(control.index) ||
(id && labelledIds.has(id)) ||
a.get('aria-label')?.trim() ||
a.get('aria-labelledby')?.trim() ||
a.get('title')?.trim();
if (!hasLabel) {
fail(
page,
`<${control[1]}>`,
`has no label: ${id ? `id="${id}"` : `name="${a.get('name') ?? '(none)'}"`} — ` +
'a placeholder is not a label',
);
}
}
// ── 4. link and button names ──────────────────────────────────────────────
for (const [, tag, attrString, inner] of html.matchAll(/<(a|button)\b([^>]*)>([\s\S]*?)<\/\1>/gi)) {
const a = attrs(attrString);
if (a.get('aria-hidden') === 'true') continue;
// An <a> with no href is not a link; it is a target for one.
if (tag.toLowerCase() === 'a' && !a.has('href')) continue;
if (named(attrString, inner)) continue;
fail(
page,
`<${tag}>`,
`has no accessible name: ${a.get('href') ? `href="${a.get('href')}"` : `class="${a.get('class') ?? ''}"`}`,
);
}
// ── 6. main and the skip link ─────────────────────────────────────────────
const mains = [...html.matchAll(/<main\b([^>]*)>/gi)];
if (mains.length === 0) fail(page, '<main>', 'has no <main> landmark');
if (mains.length > 1) fail(page, '<main>', `has ${mains.length} <main> elements`);
const skip = /<a\b([^>]*class="[^"]*skip-link[^"]*"[^>]*)>/i.exec(html);
if (skip) {
const target = attrs(skip[1]).get('href') ?? '';
if (!target.startsWith('#')) {
fail(page, 'skip link', `points at ${JSON.stringify(target)}, which is not an in-page anchor`);
} else {
const id = target.slice(1);
if (!new RegExp(`\\bid=["']${id}["']`).test(html)) {
fail(page, 'skip link', `points at #${id}, and nothing on the page has that id`);
}
}
}
// ── 7. positive tabindex ──────────────────────────────────────────────────
for (const m of html.matchAll(/\btabindex\s*=\s*["']?(-?\d+)/gi)) {
if (Number(m[1]) > 0) {
fail(page, 'tabindex', `is ${m[1]} — a positive tabindex reorders the tab sequence`);
}
}
}
/* ---------------------------------------------------------------------------------------
Report
--------------------------------------------------------------------------------------- */
if (failures.length === 0) {
console.log(`checkA11y: ${pages.length} built pages pass all seven structural checks.`);
} else {
// Grouped by page: a shared component's defect otherwise prints fifty times and buries
// the one page that has a real problem of its own.
const byPage = new Map();
for (const f of failures) {
if (!byPage.has(f.page)) byPage.set(f.page, []);
byPage.get(f.page).push(f);
}
console.error(`\ncheckA11y: ${failures.length} problem(s) across ${byPage.size} page(s):\n`);
for (const [page, items] of byPage) {
console.error(` ${page}`);
for (const item of items) console.error(` ✗ ${item.what}: ${item.detail}`);
}
console.error(`
These are structural, so they are the same in every browser and for every reader. A defect
repeated across many pages is usually one shared component — fix it there rather than on
each page.
`);
process.exit(1);
}

View File

@@ -32,6 +32,7 @@ import path from 'node:path';
import sharp from 'sharp'; import sharp from 'sharp';
import { classify } from '../src/lib/brandAssets.mjs'; import { classify } from '../src/lib/brandAssets.mjs';
import { games } from '../src/data/games/index.mjs';
const ROOT = fileURLToPath(new URL('..', import.meta.url)); const ROOT = fileURLToPath(new URL('..', import.meta.url));
const DEFAULTS = path.join(ROOT, 'brand-default'); const DEFAULTS = path.join(ROOT, 'brand-default');
@@ -157,6 +158,20 @@ if (brand) {
} }
} }
// D83: the one list-valued field. Each entry is a game module's id, checked against the
// games this site describes, so a typo is a failed build rather than a demo whose game
// links silently never appear.
const gameIds = games.map((game) => game.key);
if (!Array.isArray(brand.demoGames)) {
fail('brand.json is missing the list field "demoGames" (an empty list is the stock value).');
} else {
for (const id of brand.demoGames) {
if (!gameIds.includes(id)) {
fail(`brand.json's "demoGames" names ${JSON.stringify(id)}, which is not one of ${gameIds.join(', ')}.`);
}
}
}
/** /**
* Kept in step with `TEXT_FIELDS` in `applyBrand.mjs` by reading that file rather than by * Kept in step with `TEXT_FIELDS` in `applyBrand.mjs` by reading that file rather than by
* restating the list. A field added there and forgotten here would be unchecked; a field * restating the list. A field added there and forgotten here would be unchecked; a field
@@ -348,6 +363,23 @@ if (!deepPattern) {
); );
} }
// A game's link (D83) must also be read WITH its game. A pattern that matched the first
// three attributes and dropped the fourth would treat a /uo/ link as a core one and fill
// it on every demo, which is the 404 the attribute exists to prevent.
const gameSample = `${sample} data-demo-game="rust"`;
let gameRead = null;
try {
gameRead = new RegExp(deepPattern[1]).exec(gameSample)?.[2] ?? null;
} catch {
// Reported above.
}
if (gameRead !== 'rust') {
fail(
`applyBrand.mjs's DEEP_LINK does not read the game out of \`${gameSample}\`.\n` +
' Game links would follow demoUrl alone and point into modules the demo may not run.'
);
}
const deepStrays = []; const deepStrays = [];
let deepLinks = 0; let deepLinks = 0;

263
scripts/checkCsp.mjs Normal file
View File

@@ -0,0 +1,263 @@
#!/usr/bin/env node
/**
* checkCsp.mjs — PLAN.md §6 and D48, added in phase 10.
*
* §6 promises "a strict CSP with no external origins". D48 decided that promise should be
* a real response header sent by the container itself, not a `<meta>` (which ignores
* `frame-ancestors`) and not advice in an operator's proxy config (which lives outside the
* artifact we ship and test). `astro.config.mjs` sets it up; this checks it arrived.
*
* node scripts/checkCsp.mjs # verify the built output
* node scripts/checkCsp.mjs --write # rewrite src/config/cspHashes.mjs from the build
* node scripts/checkCsp.mjs --reset # empty it, so the next harvest starts from nothing
*
* Three things are checked, and each one has already been wrong once:
*
* 1. **Every built route has a policy.** `staticHeaders` writes `dist/_headers.json`; a
* route missing from it is a page served with no CSP at all, which is the failure mode
* nobody notices because the page looks perfect.
*
* 2. **Every inline script and style is covered by its page's own policy.** This is the
* real check. Astro does not hash `<script is:inline>`, and Starlight ships six of
* them per documentation page — so the first build with CSP on had a strict, correct
* header and a dead theme switcher. Hashing is verified per page against that page's
* header, not against a global list, because that is what the browser does.
*
* 3. **The directives §6 actually promised are present.** A policy that lost
* `frame-ancestors` in a refactor still passes checks 1 and 2 while no longer stopping
* anything.
*
* `--write` harvests the hashes from check 2 into `src/config/cspHashes.mjs`, which
* `astro.config.mjs` feeds back into the next build. So the sequence is reset → build →
* write → build → verify, which is what `npm run csp:hashes` runs. It resets first because
* harvesting only ever collects what the build did NOT cover — see `--reset` below.
*
* No token and no network: everything read here is in `dist/`.
*/
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const headersFile = path.join(root, 'dist', '_headers.json');
const clientDir = path.join(root, 'dist', 'client');
const hashesFile = path.join(root, 'src', 'config', 'cspHashes.mjs');
const write = process.argv.includes('--write');
const reset = process.argv.includes('--reset');
const failures = [];
const fail = (what, detail) => failures.push({ what, detail });
/**
* Rewrites the two exported arrays in `src/config/cspHashes.mjs`, leaving every comment and
* the JSDoc types above them untouched.
*/
const writeHashes = (script, style) => {
const source = fs.readFileSync(hashesFile, 'utf8');
const list = (hashes) =>
hashes.size === 0 ? '[]' : `[\n${[...hashes].sort().map((h) => ` '${h}',`).join('\n')}\n]`;
fs.writeFileSync(
hashesFile,
source
.replace(
/export const inlineScriptHashes = [\s\S]*?;\n/,
`export const inlineScriptHashes = ${list(script)};\n`,
)
.replace(
/export const inlineStyleHashes = [\s\S]*?;\n/,
`export const inlineStyleHashes = ${list(style)};\n`,
),
);
};
/**
* `--reset` empties the generated file, and `npm run csp:hashes` runs it FIRST.
*
* Without it the regeneration is not idempotent, and its failure mode is the worst
* available: harvesting collects the blocks the build did not cover, so running it against
* a build that is already correct finds nothing, writes two empty arrays and produces a
* build with no hashes at all. Emptying first means the harvest always sees the same thing
* — every inline block Astro does not hash on its own — whatever state the file was in.
*/
if (reset) {
writeHashes(new Set(), new Set());
console.log('checkCsp --reset: src/config/cspHashes.mjs emptied, ready to re-harvest.');
process.exit(0);
}
// ── The build has to be there ───────────────────────────────────────────────
if (!fs.existsSync(headersFile)) {
console.error(`
checkCsp: dist/_headers.json does not exist.
That file is written by the Node adapter's \`staticHeaders\` option, so either the build
has not run (\`npm run build\`) or \`staticHeaders\` was turned off in astro.config.mjs —
in which case the CSP is a <meta> tag and \`frame-ancestors\` is being ignored (D48).
`);
process.exit(1);
}
/**
* `_headers.json` is keyed by an internal route id, so the pathname lives in the record.
* Normalised without a trailing slash: the file says `/docs/first-run`, the built page is
* at `docs/first-run/index.html`, and `build.format: 'directory'` serves it at
* `/docs/first-run/`.
*/
const byPath = new Map();
for (const record of Object.values(JSON.parse(fs.readFileSync(headersFile, 'utf8')))) {
const csp = record.headers?.find((h) => h.key.toLowerCase() === 'content-security-policy');
byPath.set(record.pathname.replace(/\/$/, '') || '/', csp?.value ?? null);
}
// ── Walk the built HTML ─────────────────────────────────────────────────────
const pages = [];
const walk = (dir) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full);
else if (entry.name === 'index.html' || entry.name.endsWith('.html')) pages.push(full);
}
};
walk(clientDir);
/**
* Inline only: anything with a `src` is a fetched file and is covered by `'self'`.
* The body is hashed exactly as written, because that is what the browser hashes — one
* byte of whitespace either side changes the digest.
*/
const INLINE_SCRIPT = /<script(?![^>]*\bsrc\s*=)([^>]*)>([\s\S]*?)<\/script>/g;
const INLINE_STYLE = /<style([^>]*)>([\s\S]*?)<\/style>/g;
/**
* `<script type="application/ld+json">` (D50) is a data block, not code: the browser never
* executes it, and CSP's script-src is not enforced against it. Demanding a hash for one
* would be wrong twice over — it would add the structured data's own text to the list of
* scripts allowed to run, and that text changes whenever a fact or the brand name does, so
* the generated hash file would churn on edits that cannot affect security.
*/
const DATA_BLOCK = /type\s*=\s*["']application\/(ld\+json|json)["']/i;
const sha256 = (body) => `sha256-${createHash('sha256').update(body, 'utf8').digest('base64')}`;
const harvested = { script: new Set(), style: new Set() };
let inlineScripts = 0;
let inlineStyles = 0;
let uncovered = 0;
for (const file of pages) {
const rel = path.relative(clientDir, file).replace(/\\/g, '/');
const pathname = '/' + rel.replace(/index\.html$/, '').replace(/\.html$/, '').replace(/\/$/, '');
const csp = byPath.get(pathname === '/' ? '/' : pathname.replace(/\/$/, ''));
if (csp === undefined) {
fail(pathname, 'is a built page with no entry in dist/_headers.json — it ships with no CSP');
continue;
}
if (csp === null) {
fail(pathname, 'has an entry in dist/_headers.json but no Content-Security-Policy header');
continue;
}
const html = fs.readFileSync(file, 'utf8');
for (const [kind, re, counter] of [
['script', INLINE_SCRIPT, 'inlineScripts'],
['style', INLINE_STYLE, 'inlineStyles'],
]) {
re.lastIndex = 0;
let match;
while ((match = re.exec(html))) {
const [, attrs, body] = match;
if (kind === 'script' && DATA_BLOCK.test(attrs)) continue;
// An empty inline block needs no hash; browsers do not enforce one.
if (body.trim() === '') continue;
if (counter === 'inlineScripts') inlineScripts++;
else inlineStyles++;
const hash = sha256(body);
if (csp.includes(hash)) continue;
uncovered++;
harvested[kind].add(hash);
if (!write) {
fail(
`${pathname} (inline <${kind}>)`,
`${hash} is not in that page's policy — ${JSON.stringify(body.trim().slice(0, 60))}…`,
);
}
}
}
}
// ── The directives §6 promised, on a page that has to have them ─────────────
const REQUIRED = [
"default-src 'self'",
"base-uri 'self'",
"form-action 'self'",
"object-src 'none'",
"frame-ancestors 'none'",
];
const home = byPath.get('/');
if (!home) {
fail('/', 'the homepage has no CSP header at all');
} else {
for (const directive of REQUIRED) {
if (!home.includes(directive)) fail('/ policy', `is missing "${directive}" (PLAN.md §6)`);
}
// The point of the whole exercise: a hash and 'unsafe-inline' in the same script
// directive means browsers ignore 'unsafe-inline' — but if the hashes ever went away it
// would quietly start applying.
const scriptSrc = /script-src ([^;]*)/.exec(home)?.[1] ?? '';
if (scriptSrc.includes("'unsafe-inline'")) {
fail('/ policy', "script-src contains 'unsafe-inline' — D48 says the hashes carry this");
}
if (scriptSrc.includes("'unsafe-eval'")) {
fail('/ policy', "script-src contains 'unsafe-eval' ('wasm-unsafe-eval' is the intended one)");
}
}
// ── --write: regenerate the hash file ───────────────────────────────────────
if (write) {
writeHashes(harvested.script, harvested.style);
console.log(
`checkCsp --write: harvested ${harvested.script.size} script and ${harvested.style.size} ` +
`style hash(es) from ${pages.length} pages into src/config/cspHashes.mjs.`,
);
if (failures.length) {
console.error('\ncheckCsp --write: the build is still wrong in ways hashes cannot fix:\n');
for (const f of failures) console.error(` ✗ ${f.what}\n ${f.detail}`);
process.exit(1);
}
console.log('Now rebuild so the next build embeds them (npm run csp:hashes does both).');
process.exit(0);
}
// ── Report ──────────────────────────────────────────────────────────────────
if (failures.length === 0) {
console.log(
`checkCsp: ${pages.length} pages carry a policy; ` +
`${inlineScripts} inline script(s) and ${inlineStyles} inline style(s) are all hashed.`,
);
} else {
console.error(`\ncheckCsp: ${failures.length} problem(s) with the Content-Security-Policy:\n`);
for (const f of failures) console.error(` ✗ ${f.what}\n ${f.detail}`);
if (uncovered) {
console.error(`
${uncovered} inline block(s) are not covered by a hash. In a browser this is silent: the
page renders and the script simply never runs — Starlight's theme switch and mobile
sidebar are inline scripts, so this is how the documentation loses them.
If the inline block is legitimate (usually: Starlight was upgraded), run
npm run csp:hashes
which rebuilds, harvests the hashes into src/config/cspHashes.mjs and rebuilds again.
Read what changed before committing it — that file is a list of scripts allowed to run.
`);
}
process.exit(1);
}

View File

@@ -60,8 +60,28 @@ async function api(pathname) {
return res; return res;
} }
const raw = async (repo, filePath, ref) => /**
(await api(`${repo}/raw/${filePath}?ref=${encodeURIComponent(ref)}`)).text(); * A file's bytes, read through the `contents` endpoint rather than `raw`.
*
* `raw` answers with `Cache-Control: public, max-age=21600`, so the CDN in front of Gitea
* serves a copy for six hours and this check can read a blob most of a working day old.
* That is not theoretical: on the day of the engagement cutover it reported website's
* MODULE_API_VERSION as 1.6.0 -- the value from two weeks earlier -- and failed a site
* whose number was right. A check that goes red on stale data is a check people learn to
* ignore, which is the one failure mode this file exists to avoid.
*
* `contents` answers `private, must-revalidate`, which the CDN does not cache, so it is
* always the ref's current blob. The cost is a JSON parse and a base64 decode.
*/
async function raw(repo, filePath, ref) {
const meta = await json(`${repo}/contents/${filePath}?ref=${encodeURIComponent(ref)}`);
if (meta.encoding !== 'base64' || typeof meta.content !== 'string') {
throw new Error(
`${repo}:${filePath}@${ref} did not come back as a base64 file (encoding ${meta.encoding}).`
);
}
return Buffer.from(meta.content, 'base64').toString('utf8');
}
const json = async (pathname) => (await api(pathname)).json(); const json = async (pathname) => (await api(pathname)).json();
@@ -72,33 +92,42 @@ function extract(text, re, what, authority) {
return m[1]; return m[1];
} }
// Every game the site quotes facts for (PLAN.md §16). Checks 1, 2, 4 and 5 run once per
// game, and a row's label carries the game's key so a failure names which one moved.
const GAMES = Object.entries(platform.games);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 1. The wire protocol, from the sidecar itself // 1. Each game's wire protocol, from its sidecar
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function checkProtocol() { async function checkProtocol() {
const authority = 'link main:sidecar/src/main.rs'; for (const [key, game] of GAMES) {
const src = await raw('link', 'sidecar/src/main.rs', 'main'); const authority = `${game.sidecarRepo} main:sidecar/src/main.rs`;
const value = extract( const src = await raw(game.sidecarRepo, 'sidecar/src/main.rs', 'main');
src, const value = extract(
/pub const PROTOCOL_VERSION:\s*u32\s*=\s*(\d+)\s*;/, src,
'PROTOCOL_VERSION', /pub const PROTOCOL_VERSION:\s*u32\s*=\s*(\d+)\s*;/,
authority 'PROTOCOL_VERSION',
); authority
record('protocol (sidecar)', platform.protocol, Number(value), authority); );
record(`${key} protocol (sidecar)`, game.protocol, Number(value), authority);
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 2. The overlay's declared protocol — the third declaration site // 2. Each game plugin's declared protocol — the third declaration site
// //
// CLAUDE.md: a protocol bump has to land in overlay.toml in the same PR as the emitters, // CLAUDE.md: a protocol bump has to land in overlay.toml in the same PR as the emitters,
// or the installer refuses to pair the sidecar with the overlay. If these two ever // or the installer refuses to pair the sidecar with the plugin. If these two ever
// disagree, the site must not print either number. // disagree, the site must not print either number. Both games keep the file under the
// same name at the root of their plugin repository.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function checkOverlayProtocol() { async function checkOverlayProtocol() {
const authority = 'servuo-plugins main:overlay.toml'; for (const [key, game] of GAMES) {
const toml = await raw('servuo-plugins', 'overlay.toml', 'main'); const authority = `${game.pluginRepo} main:overlay.toml`;
const value = extract(toml, /^\s*protocol\s*=\s*(\d+)\s*$/m, 'protocol', authority); const toml = await raw(game.pluginRepo, 'overlay.toml', 'main');
record('protocol (overlay)', platform.protocol, Number(value), authority); const value = extract(toml, /^\s*protocol\s*=\s*(\d+)\s*$/m, 'protocol', authority);
record(`${key} protocol (plugin)`, game.protocol, Number(value), authority);
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -117,7 +146,7 @@ async function checkModuleApi() {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 4. The capabilities the installed module actually declares // 4. The capabilities each game's module actually declares
// //
// §12 names "module-uo's capability list" as one of the facts platform.json holds, and it // §12 names "module-uo's capability list" as one of the facts platform.json holds, and it
// was the one fact nothing re-read. That mattered from phase 3 onwards, because the // was the one fact nothing re-read. That mattered from phase 3 onwards, because the
@@ -130,36 +159,109 @@ async function checkModuleApi() {
// array is not a changed capability set. A slug appearing or disappearing is. // array is not a changed capability set. A slug appearing or disappearing is.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function checkModuleCapabilities() { async function checkModuleCapabilities() {
const authority = 'Module-uo main:module.json'; for (const [key, game] of GAMES) {
const manifest = JSON.parse(await raw('Module-uo', 'module.json', 'main')); const authority = `${game.module.repo} main:module.json`;
const declared = [...(manifest.capabilities || [])].sort(); const manifest = JSON.parse(await raw(game.module.repo, 'module.json', 'main'));
const expected = [...platform.moduleUoCapabilities].sort(); const declared = [...(manifest.capabilities || [])].sort();
const expected = [...game.module.capabilities].sort();
record('moduleUoCapabilities', expected.join(' '), declared.join(' '), authority); record(`${key} module capabilities`, expected.join(' '), declared.join(' '), authority);
// The range the module declares against core. The manifest docs use it as the worked
// example of a caret range resolving across minor bumps, so it is a quoted fact.
record(`${key} module coreApi`, game.module.coreApi, manifest.coreApi, authority);
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 5. The current bundle // 5. Each game's current bundle
// //
// The manifests live at the ROOT of the `bundles` branch — `current.json`, // One manifest per game on the installer's `bundles` branch, at
// `bundle-<tag>.json` — not under `bundles/`. Fetching the directory 404s. // `v2/<game>/current.json` (schema 2). The ROOT `current.json` is the schema-1 manifest
// from before the installer learned a second game; it is frozen and no longer read here,
// because a check against a file that stopped moving passes forever.
//
// Each bundle is protocol-checked by CI when it is published, so its protocol is a third
// independent read of the same number rather than a duplicate of checks 1 and 2.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function checkBundle() { async function readBundle(game) {
const authority = 'installer bundles:current.json'; const file = `v2/${game.bundleGame}/current.json`;
const current = JSON.parse(await raw('installer', 'current.json', 'bundles')); const authority = `installer bundles:${file}`;
const current = JSON.parse(await raw('installer', file, 'bundles'));
if (current.schema !== 2) {
throw new Error(`${authority} is schema ${current.schema}, and this check reads schema 2.`);
}
return { current, authority };
}
record('bundle.tag', platform.bundle.tag, current.bundle, authority); async function checkBundle() {
record('bundle.sidecar', platform.bundle.sidecar, current.link?.tag, authority); const { uo, rust } = platform.games;
record('bundle.overlay', platform.bundle.overlay, current.overlay?.tag, authority);
{
const { current, authority } = await readBundle(uo);
record('uo bundle.tag', uo.bundle.tag, current.bundle, authority);
record('uo bundle.sidecar', uo.bundle.sidecar, current.sidecar?.tag, authority);
record('uo bundle.overlay', uo.bundle.overlay, current.payload?.tag, authority);
record('uo bundle.servuoMin', uo.bundle.servuoMin, current.payload?.compat?.min_version, authority);
record('uo protocol (bundle)', uo.protocol, current.protocol, authority);
}
{
const { current, authority } = await readBundle(rust);
const compat = current.payload?.compat ?? {};
record('rust bundle.tag', rust.bundle.tag, current.bundle, authority);
record('rust bundle.sidecar', rust.bundle.sidecar, current.sidecar?.tag, authority);
record('rust bundle.plugin', rust.bundle.plugin, current.payload?.tag, authority);
record('rust bundle.oxideMin', rust.bundle.oxideMin, compat.frameworks?.oxide?.min_version, authority);
record('rust bundle.carbonMin', rust.bundle.carbonMin, compat.frameworks?.carbon?.min_version, authority);
record(
'rust bundle.requiresPlugins',
[...rust.bundle.requiresPlugins].sort().join(' '),
[...(compat.requires_plugins ?? [])].sort().join(' '),
authority
);
record('rust bundle.runicnpc', rust.bundle.runicnpc, current.npc?.tag, authority);
record('rust protocol (bundle)', rust.protocol, current.protocol, authority);
// The bundle carries RunicNPC's API too; check 5b reads it from the plugin's own repo.
record('runicnpc api (bundle)', platform.runicnpc.api, current.npc?.api, authority);
}
}
// ---------------------------------------------------------------------------
// 5b. RunicNPC's own declarations
//
// RunicNPC stands alone as well as shipping in the Rust bundle (D61), so its facts are
// read from its own `plugin.toml` too — the file its release workflow folds into the
// manifest the installer and the egg read. The API version is the number a plugin author
// calling `RunicNpc_*` relies on, and the site quotes it beside the API reference (D62).
// ---------------------------------------------------------------------------
async function checkRunicNpc() {
const authority = 'runicnpc-rust main:plugin.toml';
const toml = await raw('runicnpc-rust', 'plugin.toml', 'main');
const npc = platform.runicnpc;
record('runicnpc api', npc.api, Number(extract(toml, /^\s*api\s*=\s*(\d+)\s*$/m, 'api', authority)), authority);
record( record(
'bundle.servuoMin', 'runicnpc oxideMin',
platform.bundle.servuoMin, npc.oxideMin,
current.overlay?.servuo?.min_version, extract(toml, /^\s*min_oxide_version\s*=\s*"([^"]+)"/m, 'min_oxide_version', authority),
authority
);
record(
'runicnpc carbonMin',
npc.carbonMin,
extract(toml, /^\s*min_carbon_version\s*=\s*"([^"]+)"/m, 'min_carbon_version', authority),
authority
);
const required = extract(toml, /^\s*requires_plugins\s*=\s*\[([^\]]*)\]/m, 'requires_plugins', authority)
.split(',')
.map((name) => name.trim().replace(/^"|"$/g, ''))
.filter(Boolean);
record(
'runicnpc requiresPlugins',
[...npc.requiresPlugins].sort().join(' '),
required.sort().join(' '),
authority authority
); );
// The bundle is protocol-checked by CI when it is published, so this is a third
// independent read of the same number rather than a duplicate of check 1.
record('protocol (bundle)', platform.protocol, current.protocol, authority);
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -329,6 +431,7 @@ async function main() {
checkModuleApi, checkModuleApi,
checkModuleCapabilities, checkModuleCapabilities,
checkBundle, checkBundle,
checkRunicNpc,
checkReleases, checkReleases,
checkWebsiteHasNoReleases, checkWebsiteHasNoReleases,
checkAndroidApk, checkAndroidApk,

View File

@@ -55,12 +55,18 @@ 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 raw-file accessor checkFacts.mjs uses, and for the same reason. */ /** Same file accessor checkFacts.mjs uses, and for the same reason -- including the CDN one. */
async function raw(repo, filePath, ref) { async function raw(repo, filePath, ref) {
const url = `${BASE}/api/v1/repos/${ORG}/${repo}/raw/${filePath}?ref=${encodeURIComponent(ref)}`; const url = `${BASE}/api/v1/repos/${ORG}/${repo}/contents/${filePath}?ref=${encodeURIComponent(ref)}`;
const res = await fetch(url, { headers: { Authorization: `token ${TOKEN}` } }); 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}`);
return res.text(); const meta = await res.json();
if (meta.encoding !== 'base64' || typeof meta.content !== 'string') {
throw new Error(
`${repo}:${filePath}@${ref} did not come back as a base64 file (encoding ${meta.encoding}).`
);
}
return Buffer.from(meta.content, 'base64').toString('utf8');
} }
/** /**

527
scripts/checkReference.mjs Normal file
View File

@@ -0,0 +1,527 @@
#!/usr/bin/env node
/**
* checkReference.mjs — PLAN.md §12, added in phase 8.
*
* The Reference section names things: every environment variable, every config key, every
* installer command, every canonical document. §1 forbids re-specifying a contract, and
* this is the machinery that makes writing the NAMES down safe anyway — the same bargain
* checkQuickstart.mjs struck for the quickstart, applied to six more sources.
*
* Phase 15 (PLAN.md §16) added the Rust group's three: rust-link's `sidecar.toml` and its
* environment overrides, the bridge plugin's `RunicGateway.json`, and the event kinds with the
* class each is served under (D71).
*
* Each enumeration in `src/data/reference.mjs` is compared against its authority, read from
* the repository that owns it over the Gitea API — never from a working tree, per §1's
* process rule. Every comparison is a SET comparison in both directions:
*
* - a name this site lists that the source no longer has fails (the reference is stale);
* - a name the source has that this site does not list fails (the reference is
* incomplete, which is the failure mode a hand-maintained list actually has).
*
* The second direction is the one that earns its keep. A reference page does not usually
* rot by describing something that vanished — it rots by quietly not mentioning the three
* things added since it was written.
*
* Descriptions are deliberately NOT checked. Nothing here can know whether a one-line
* summary is still true, so it does not pretend to; keeping them terse is the mitigation.
*
* GITEA_TOKEN=<token> node scripts/checkReference.mjs
*
* Anonymous raw fetches fail on this instance, so the token is required. A check that
* silently skips itself is worse than no check.
*/
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import {
envVars,
sidecarConfig,
installerCommands,
bridgeCfg,
visibilityLadder,
canonicalDocs,
rustSidecarConfig,
rustSidecarEnv,
rustPluginConfig,
rustEventKinds,
} from '../src/data/reference.mjs';
import {
runicnpcOwners,
runicnpcCalls,
runicnpcHooks,
runicnpcListKeys,
runicnpcPlacementKeys,
runicnpcRouteKeys,
runicnpcProfileFields,
runicnpcCommands,
runicnpcPermissions,
runicnpcPlaceOptions,
runicnpcConfig,
} from '../src/data/runicnpc.mjs';
const ROOT = fileURLToPath(new URL('..', import.meta.url));
const platform = JSON.parse(readFileSync(path.join(ROOT, 'src/data/platform.json'), 'utf8'));
const BASE = platform.gitea.base;
const ORG = platform.gitea.org;
const TOKEN = process.env.GITEA_TOKEN?.trim();
const failures = [];
const checked = [];
const ok = (what) => checked.push(what);
const fail = (what, detail) => failures.push({ what, detail });
/** Same file accessor checkFacts.mjs and checkQuickstart.mjs use, CDN caveat included. */
async function raw(repo, filePath, ref = 'main') {
const url = `${BASE}/api/v1/repos/${ORG}/${repo}/contents/${filePath}?ref=${encodeURIComponent(ref)}`;
const res = await fetch(url, { headers: { Authorization: `token ${TOKEN}` } });
if (!res.ok) throw new Error(`${res.status} ${res.statusText} for ${url}`);
const meta = await res.json();
if (meta.encoding !== 'base64' || typeof meta.content !== 'string') {
throw new Error(
`${repo}:${filePath}@${ref} did not come back as a base64 file (encoding ${meta.encoding}).`
);
}
return Buffer.from(meta.content, 'base64').toString('utf8');
}
/**
* The one comparison this whole script performs, so the failure messages are identical
* everywhere and say which direction broke.
*/
function compareSets(label, mine, theirs, hint) {
const mineSet = new Set(mine);
const theirsSet = new Set(theirs);
const stale = [...mineSet].filter((k) => !theirsSet.has(k));
const missing = [...theirsSet].filter((k) => !mineSet.has(k));
for (const k of stale) {
fail(`${label}: ${k}`, `listed here, but ${hint} no longer has it — remove it, and re-read the prose around it`);
}
for (const k of missing) {
fail(`${label}: ${k}`, `is in ${hint} and NOT listed here — add it, or the reference is lying by omission`);
}
if (!stale.length && !missing.length) ok(`${label} (${mineSet.size})`);
}
/** `KEY=value` lines. Commented-out suggestions are prose about a variable, not a key. */
const envKeysOf = (text) =>
text
.split(/\r?\n/)
.map((l) => l.match(/^([A-Z][A-Z0-9_]*)=/))
.filter(Boolean)
.map((m) => m[1]);
/** `Key=value` lines from the plugin's config, same rule about comments. */
const cfgKeysOf = (text) =>
text
.split(/\r?\n/)
.map((l) => l.match(/^([A-Za-z][A-Za-z0-9]*)=/))
.filter(Boolean)
.map((m) => m[1]);
/**
* A sidecar's TOML keys, parsed from its serde structs rather than from a sample file,
* because the sample is GENERATED by the binary on first run and no committed copy is
* authoritative. Each `pub name: T` inside a `struct XCfg` is one key, and the struct name
* gives the section. Both sidecars are written this way, so one parser serves both.
*/
function tomlKeysOf(configRs) {
const keys = [];
for (const m of configRs.matchAll(/struct\s+(\w+)Cfg\s*\{([\s\S]*?)\n\}/g)) {
const section = m[1].toLowerCase();
for (const f of m[2].matchAll(/pub\s+(\w+)\s*:/g)) keys.push(`${section}.${f[1]}`);
}
return keys;
}
/** The body of a C# class, by brace counting from its opening brace. */
function classBody(source, name) {
const start = source.search(new RegExp(`class\\s+${name}\\b`));
if (start < 0) return null;
const open = source.indexOf('{', start);
let depth = 0;
for (let i = open; i < source.length; i++) {
if (source[i] === '{') depth++;
else if (source[i] === '}' && --depth === 0) return source.slice(open + 1, i);
}
return null;
}
/**
* The quoted strings inside `const NAME = Object.freeze([ … ])`, comments removed first —
* the module's comments are prose, and prose has apostrophes.
*/
function frozenListOf(source, name) {
const m = source.match(new RegExp(`const\\s+${name}\\s*=\\s*Object\\.freeze\\(\\[([\\s\\S]*?)\\]\\)`));
if (!m) return null;
const code = m[1].replace(/\/\/.*$/gm, '');
return [...code.matchAll(/'([^'\n]+)'/g)].map((x) => x[1]);
}
/**
* One section of a Markdown document: from the line starting with `heading` to the next
* heading of the same or a higher level. Null when there is no such heading, which every
* caller reports — a section that silently vanished must not read as an empty one.
*/
function mdSection(md, heading) {
const lines = md.split(/\r?\n/);
const start = lines.findIndex((l) => l.startsWith(heading));
if (start < 0) return null;
const level = heading.match(/^#+/)[0].length;
const stop = new RegExp(`^#{1,${level}} `);
let end = lines.length;
for (let i = start + 1; i < lines.length; i++) {
if (stop.test(lines[i])) {
end = i;
break;
}
}
return lines.slice(start + 1, end).join('\n');
}
/** The backticked names in the first cell of every table row of a section. */
function firstCellNames(section) {
return section
.split('\n')
.filter((l) => l.startsWith('| `'))
.map((l) => [...l.split('|')[1].matchAll(/`([^`]+)`/g)].map((m) => m[1]));
}
const collapse = (s) => s.replace(/\s+/g, ' ').trim();
/** The top-level keys of the first ```json block in a section. */
function jsonKeysOf(section) {
const m = section?.match(/```json\s*\n([\s\S]*?)\n```/);
if (!m) return null;
try {
return Object.keys(JSON.parse(m[1]));
} catch {
return null;
}
}
async function checkRunicNpcApi(md) {
const hint = 'docs main:runicnpc/API.md';
// The version the specification describes, against the one platform.json quotes (and
// checkFacts holds to the plugin's own plugin.toml). Together they close the loop.
const version = Number(md.match(/\*\*API version (\d+)\*\*/)?.[1]);
if (version !== platform.runicnpc.api) {
fail('RunicNPC API version', `${hint} describes API ${version || '(none found)'}; platform.json says ${platform.runicnpc.api}`);
} else ok(`RunicNPC API version (${version})`);
// Calls: `### \`RunicNpc_X(params)\` → \`returns\`` headings, compared by whole signature.
const theirCalls = new Map(
[...md.matchAll(/^### `(RunicNpc_\w+)\(([^)]*)\)` → `([^`]+)`\s*$/gm)].map((m) => [m[1], collapse(`(${m[2]}) → ${m[3]}`)]),
);
const myCalls = new Map(Object.entries(runicnpcCalls).map(([n, c]) => [n, collapse(`(${c.params}) → ${c.returns}`)]));
compareSignatures('RunicNPC call', myCalls, theirCalls, hint);
// Hooks: the first cell of each row of "Hooks it raises".
const hooksSection = mdSection(md, '## Hooks it raises');
if (!hooksSection) fail('RunicNPC hooks', `no "## Hooks it raises" section in ${hint}`);
else {
const theirHooks = new Map(
[...hooksSection.matchAll(/^\| `(OnRunicNpc\w+)\(([^)]*)\)` \|/gm)].map((m) => [m[1], collapse(`(${m[2]})`)]),
);
const myHooks = new Map(Object.entries(runicnpcHooks).map(([n, h]) => [n, collapse(`(${h.params})`)]));
compareSignatures('RunicNPC hook', myHooks, theirHooks, hint);
}
// The data shapes, by field name (D73).
const shape = (label, heading, mine, read) => {
const section = mdSection(md, heading);
const theirs = section && read(section);
if (!theirs || theirs.length === 0) fail(label, `could not read the "${heading}" section of ${hint}`);
else compareSets(label, mine, theirs, `${hint} "${heading.replace(/^#+ /, '')}"`);
};
shape('RunicNPC owner', '## Owners', Object.keys(runicnpcOwners), (s) => firstCellNames(s).flat());
shape('RunicNpc_List key', '### `RunicNpc_List', Object.keys(runicnpcListKeys), (s) => firstCellNames(s).flat());
// A profile's fields. A cell may name several (`hurtByPlayers`, `hurtsPlayers`), and a bare
// name after a dotted one shares its prefix (`phases[].damageDealt`, `aimCone`).
shape(
'RunicNPC profile field',
'### A profile',
Object.values(runicnpcProfileFields).flatMap((group) => Object.keys(group)),
(s) =>
firstCellNames(s).flatMap(([first, ...rest]) => {
const prefix = first.includes('.') ? first.slice(0, first.lastIndexOf('.') + 1) : '';
return [first, ...rest.map((n) => (n.includes('.') ? n : prefix + n))];
}),
);
// A placement's keys: the example's, plus the optional ones its bullets introduce.
shape('RunicNPC placement key', '### A placement', Object.keys(runicnpcPlacementKeys), (s) => {
const keys = jsonKeysOf(s);
return keys && [...new Set([...keys, ...[...s.matchAll(/^- `(\w+)`/gm)].map((m) => m[1])])];
});
shape('RunicNPC route key', '### A route', Object.keys(runicnpcRouteKeys), jsonKeysOf);
// The page itself: every call has exactly one <Signature call="…" />, so "in full" is held
// by the build and not by whoever last edited api.mdx.
const page = readFileSync(path.join(ROOT, 'src/content/docs/docs/rust/runicnpc/api.mdx'), 'utf8');
const onPage = [...page.matchAll(/<Signature\s+call="(\w+)"/g)].map((m) => m[1]);
let covered = true;
for (const call of Object.keys(runicnpcCalls)) {
const n = onPage.filter((c) => c === call).length;
if (n !== 1) {
covered = false;
fail(`RunicNPC API page: ${call}`, `has ${n} <Signature call="${call}" /> on api.mdx — every call needs exactly one, with its prose`);
}
}
if (covered) ok(`RunicNPC API page covers every call (${onPage.length})`);
}
/** Compare name → signature maps: both directions by name, then each shared name by signature. */
function compareSignatures(label, mine, theirs, hint) {
compareSets(label, [...mine.keys()], [...theirs.keys()], hint);
let same = true;
for (const [name, sig] of mine) {
if (theirs.has(name) && theirs.get(name) !== sig) {
same = false;
fail(`${label} ${name}`, `signature differs — here "${sig}", ${hint} "${theirs.get(name)}"`);
}
}
if (same) ok(`${label} signatures (${mine.size})`);
}
/** The text of a C# method, by brace counting from the first brace after its name. */
function methodBody(source, name) {
const start = source.search(new RegExp(`\\b${name}\\s*\\(`));
if (start < 0) return null;
const open = source.indexOf('{', start);
let depth = 0;
for (let i = open; i < source.length; i++) {
if (source[i] === '{') depth++;
else if (source[i] === '}' && --depth === 0) return source.slice(open + 1, i);
}
return null;
}
function checkRunicNpcSource(cs) {
const hint = 'runicnpc-rust main:plugin/RunicNPC.cs';
// The verbs: every `rnpc.<verb>` console command. The chat command answers the same verbs.
compareSets(
'RunicNPC command',
[...new Set(Object.values(runicnpcCommands).flatMap((s) => Object.keys(s)).map((usage) => usage.split(' ')[0]))],
[...cs.matchAll(/\[ConsoleCommand\("rnpc\.(\w+)"\)\]/g)].map((m) => m[1]),
`${hint} [ConsoleCommand]`,
);
// A command's permission must be one the plugin registers.
const perms = [...cs.matchAll(/const\s+string\s+Perm\w+\s*=\s*"(runicnpc\.\w+)"/g)].map((m) => m[1]);
compareSets('RunicNPC permission', Object.keys(runicnpcPermissions), perms, `${hint} Perm constants`);
for (const [section, commands] of Object.entries(runicnpcCommands)) {
for (const [usage, c] of Object.entries(commands)) {
if (c.perm.startsWith('runicnpc.') && !perms.includes(c.perm)) {
fail(`RunicNPC command ${usage}`, `(${section}) needs "${c.perm}", which the plugin does not register`);
}
}
}
const options = methodBody(cs, 'ParseOptions');
if (!options) fail('RunicNPC place option', `could not find ParseOptions in ${hint}`);
else {
compareSets(
'RunicNPC place option',
Object.keys(runicnpcPlaceOptions),
[...options.matchAll(/case\s+"(\w+)":/g)].map((m) => m[1]),
`${hint} ParseOptions`,
);
}
// The config: `[JsonProperty("name")] public Type Field`, and a field whose type is another
// config class is a section, its keys written `section.key`.
const jsonKeys = (className, prefix = '') => {
const body = classBody(cs, className);
if (!body) return null;
return [...body.matchAll(/\[JsonProperty\("(\w+)"\)\]\s*public\s+(\w+)\s+\w+/g)].flatMap(([, key, type]) => {
const nested = /Config$/.test(type) ? jsonKeys(type, `${prefix}${key}.`) : null;
return nested ?? [`${prefix}${key}`];
});
};
const configKeys = jsonKeys('PluginConfig');
if (!configKeys) fail('RunicNPC.json', `could not find class PluginConfig in ${hint}`);
else compareSets('RunicNPC.json', Object.keys(runicnpcConfig), configKeys, `${hint} PluginConfig`);
}
async function run() {
if (!TOKEN) {
console.error('checkReference: GITEA_TOKEN is not set. This check cannot run anonymously.');
process.exit(2);
}
// ── 1. Environment variables ──────────────────────────────────────────────
compareSets(
'env',
Object.keys(envVars),
envKeysOf(await raw('website', '.env.example')),
'website main:.env.example',
);
// ── 2. sidecar.toml ───────────────────────────────────────────────────────
//
// Parsed from the serde structs rather than from a sample file, because the sample is
// GENERATED by the binary on first run and no committed copy is authoritative. Each
// `pub name: T` inside a `struct XCfg` is one key, and the struct name gives the section.
compareSets(
'sidecar.toml',
Object.keys(sidecarConfig),
tomlKeysOf(await raw('link', 'sidecar/src/config.rs')),
'link main:sidecar/src/config.rs',
);
// ── 3. Installer commands ─────────────────────────────────────────────────
const cliRs = await raw('installer', 'src/cli.rs');
const cmdBlock = cliRs.match(/enum\s+Command\s*\{([\s\S]*?)\n\}/);
const cmds = cmdBlock ? [...cmdBlock[1].matchAll(/^\s*([A-Z]\w*)\s*[,{]/gm)].map((m) => m[1]) : [];
compareSets('installer command', Object.keys(installerCommands), cmds, 'installer main:src/cli.rs');
// ── 4. Bridge.cfg ─────────────────────────────────────────────────────────
const bridgeKeys = Object.values(bridgeCfg).flatMap((group) => Object.keys(group));
compareSets(
'Bridge.cfg',
bridgeKeys,
cfgKeysOf(await raw('servuo-plugins', 'overlay/Config/Bridge.cfg')),
'servuo-plugins main:overlay/Config/Bridge.cfg',
);
// ── 5. The visibility ladder ──────────────────────────────────────────────
//
// A security boundary, so it is checked against the module that enforces it rather than
// against prose. The order matters as much as the membership: it is a ladder, and a
// reader reasoning about "staff and above" needs the rungs in the right sequence.
const vis = await raw('Module-uo', 'server/utils/shardVisibility.js');
const ladderMatch = vis.match(/const\s+LADDER\s*=\s*\[([\s\S]*?)\]/);
const ladder = ladderMatch
? [...ladderMatch[1].matchAll(/'([a-z_]+)'/g)].map((m) => m[1])
: [];
if (ladder.length === 0) {
fail('visibility ladder', 'could not find LADDER in Module-uo main:server/utils/shardVisibility.js');
} else if (ladder.join(' ') !== visibilityLadder.join(' ')) {
fail(
'visibility ladder',
`order or membership differs — here "${visibilityLadder.join(' → ')}", upstream "${ladder.join(' → ')}"`,
);
} else ok(`visibility ladder (${ladder.length} rungs, in order)`);
// ── 6. Canonical documents ────────────────────────────────────────────────
//
// Existence only. A link to a document that moved is the single most likely way this
// section breaks, and it is exactly what a build can answer.
for (const docPath of Object.values(canonicalDocs).flatMap((group) => Object.keys(group))) {
const url = `${BASE}/api/v1/repos/${ORG}/docs/contents/${docPath}?ref=main`;
const res = await fetch(url, { headers: { Authorization: `token ${TOKEN}` } });
if (res.ok) ok(`canonical doc ${docPath}`);
else fail(`canonical doc ${docPath}`, `not found in docs main (HTTP ${res.status})`);
}
// ── 7. Rust: rust-link's sidecar.toml, and the variables that override it ──
//
// PLAN.md §16, phase 15. The same parser as uo-link's — both sidecars declare their file
// as serde structs — plus the environment overrides, which a Pterodactyl egg sets and an
// operator meets on the panel before they ever see the file.
const rustConfigRs = await raw('Rust-Link', 'sidecar/src/config.rs');
compareSets(
'rust-link sidecar.toml',
Object.keys(rustSidecarConfig),
tomlKeysOf(rustConfigRs),
'Rust-Link main:sidecar/src/config.rs',
);
compareSets(
'rust-link environment variable',
Object.keys(rustSidecarEnv),
[...rustConfigRs.matchAll(/get\("(RUSTLINK_[A-Z_]+)"\)/g)].map((m) => m[1]),
'Rust-Link main:sidecar/src/config.rs',
);
for (const [envName, key] of Object.entries(rustSidecarEnv)) {
if (!(key in rustSidecarConfig)) fail(`rust-link environment variable ${envName}`, `says it overrides "${key}", which is not a key listed here`);
}
// ── 8. Rust: the bridge plugin's RunicGateway.json ────────────────────────
//
// The public fields of `PluginConfig`, which the framework serialises as the file.
const pluginCs = await raw('Rust-Plugins', 'overlay/oxide/plugins/RunicGateway.cs');
const pluginBody = classBody(pluginCs, 'PluginConfig');
if (!pluginBody) {
fail('RunicGateway.json', 'could not find class PluginConfig in Rust-Plugins main:overlay/oxide/plugins/RunicGateway.cs');
} else {
compareSets(
'RunicGateway.json',
Object.values(rustPluginConfig).flatMap((group) => Object.keys(group)),
[...pluginBody.matchAll(/public\s+[\w<>[\],]+\s+(\w+)\s*=/g)].map((m) => m[1]),
'Rust-Plugins main:overlay/oxide/plugins/RunicGateway.cs PluginConfig',
);
}
// ── 9. Rust: the event kinds, and who may see each ────────────────────────
//
// D71: checked against the module's own catalogue, because the module is what enforces
// the boundary (the same reasoning as check 5). Three comparisons, each a security
// statement: a kind listed public here must be public there, a presence kind must be one
// of the module's presence kinds, and a staff kind must be staff there. A kind moved from
// staff to public upstream fails this check twice over, which is the point.
const catalogue = await raw('Module-Rust', 'server/catalogue.js');
const upPublic = frozenListOf(catalogue, 'PUBLIC_KINDS');
const upStaff = frozenListOf(catalogue, 'STAFF_KINDS');
const upPresence = frozenListOf(catalogue, 'PRESENCE_KINDS');
if (!upPublic || !upStaff || !upPresence) {
fail('Rust event kinds', 'could not find PUBLIC_KINDS, STAFF_KINDS and PRESENCE_KINDS in Module-Rust main:server/catalogue.js');
} else {
const hint = 'Module-Rust main:server/catalogue.js';
compareSets('Rust presence kind', Object.keys(rustEventKinds.presence), upPresence, `${hint} PRESENCE_KINDS`);
compareSets(
'Rust public kind',
Object.keys(rustEventKinds.public),
upPublic.filter((k) => !upPresence.includes(k)),
`${hint} PUBLIC_KINDS (less its presence kinds)`,
);
compareSets('Rust staff kind', Object.keys(rustEventKinds.staff), upStaff, `${hint} STAFF_KINDS`);
for (const k of upPresence) {
if (!upPublic.includes(k)) fail(`Rust presence kind ${k}`, 'is a presence kind upstream but not a public one — the page explains presence as a narrowing of public, so re-read it');
}
}
// ── 10. RunicNPC: the API, against its specification ──────────────────────
//
// PLAN.md §16, D62 and D73. The one place this site carries a contract in full rather than
// linking to it, so the one place it must be held to the contract most tightly: every call
// and hook by its whole signature, and every field name of every data shape, both ways.
// `docs` `main:runicnpc/API.md` is normative, so it is the authority, not the plugin source.
await checkRunicNpcApi(await raw('docs', 'runicnpc/API.md'));
// ── 11. RunicNPC: the commands, permissions, options and config ───────────
//
// Names, checked against the plugin's own source the way RunicGateway.json is (check 8):
// the console verbs it registers, the permissions it registers, the `key=` options
// `place` and `here` parse, and the JSON keys of its config classes.
checkRunicNpcSource(await raw('runicnpc-rust', 'plugin/RunicNPC.cs'));
// ── Report ────────────────────────────────────────────────────────────────
if (failures.length === 0) {
console.log(`checkReference: ${checked.length} enumeration check(s) passed against their sources.`);
return;
}
console.error(`\ncheckReference: ${failures.length} disagreement(s) with the platform:\n`);
for (const f of failures) console.error(` ✗ ${f.what}\n ${f.detail}`);
console.error(`
The Reference section names things, which is only safe while the names are checked
(§1, and the same bargain checkQuickstart.mjs struck). Update src/data/reference.mjs
to match the source. Do not "fix" the check.
`);
process.exit(1);
}
run().catch((err) => {
console.error(`checkReference: ${err.message}`);
process.exit(2);
});

171
scripts/checkScreens.mjs Normal file
View File

@@ -0,0 +1,171 @@
#!/usr/bin/env node
/**
* checkScreens.mjs — the screenshots agree with what the pages say about them.
*
* PLAN.md §12, §13 phase 9, D45.
*
* node scripts/checkScreens.mjs
*
* ---------------------------------------------------------------------------------------
* WHAT IT PROVES, AND WHY EACH ONE IS WORTH A CHECK
* ---------------------------------------------------------------------------------------
* 1. EVERY DECLARED SCREEN HAS A FILE. A missing image is invisible in review — the page
* still builds, still lays out, and only a reader sees the broken frame.
*
* 2. EVERY FILE IS THE DECLARED SIZE. `width` and `height` reach the markup as intrinsic
* attributes, and an attribute that disagrees with the file is a page that jumps as the
* image decodes. It also catches a re-capture taken at the wrong viewport, which looks
* fine on its own and wrong beside the others.
*
* 3. NOTHING IN public/screens IS ORPHANED. A capture that stopped being referenced is a
* file the container still ships and nobody looks at — and, worse, one that never gets
* retaken, so it silently becomes the oldest thing in the repository.
*
* 4. EVERY DECLARED SCREEN IS ACTUALLY USED. The mirror of 3: an entry in `screens.mjs`
* that no page renders is a capture being maintained for nothing. Usage is a literal
* search for the id across `src/`, which is how both readers of the data refer to one —
* `<Screenshot id="admin-users" />` and the `groupScreens` map on `/features/`.
*
* 5. THE ALT TEXT AND CAPTION SAY SOMETHING. An empty alt on an editorial image is an
* accessibility failure the build cannot otherwise see, and a caption is the sentence
* that makes a screenshot evidence rather than decoration.
*
* ---------------------------------------------------------------------------------------
* WHY IT READS THE PNG HEADER ITSELF
* ---------------------------------------------------------------------------------------
* It does not: it reads the WebP header, and it does it with twenty lines rather than a
* dependency. `sharp` is already here for the brand assets and could answer this, but this
* check runs in CI on every pull request and a check that needs a native image library to
* tell you a file is 1920 pixels wide is a check that will one day fail for a reason that
* has nothing to do with screenshots.
*/
import { readdirSync, readFileSync, existsSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { screens, WEB, PHONE } from '../src/data/screens.mjs';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(HERE, '..');
const DIR = path.join(ROOT, 'public', 'screens');
const SRC = path.join(ROOT, 'src');
const problems = [];
/**
* The pixel size of a WebP file, from its header.
*
* A RIFF container: "RIFF" size "WEBP" then one of three chunk types. Lossy ("VP8 ") and
* lossless ("VP8L") pack the dimensions differently, and an animated or extended file
* ("VP8X") states them outright. `cwebp` at quality 82 writes VP8 , but a future change of
* encoder should not turn this check into a mystery, so all three are handled.
*/
function webpSize(file) {
const buf = readFileSync(file);
if (buf.length < 30 || buf.toString('ascii', 0, 4) !== 'RIFF' || buf.toString('ascii', 8, 12) !== 'WEBP') {
return null;
}
const chunk = buf.toString('ascii', 12, 16);
if (chunk === 'VP8X') {
return {
width: 1 + (buf[24] | (buf[25] << 8) | (buf[26] << 16)),
height: 1 + (buf[27] | (buf[28] << 8) | (buf[29] << 16)),
};
}
if (chunk === 'VP8L') {
const bits = buf[21] | (buf[22] << 8) | (buf[23] << 16) | (buf[24] << 24);
return { width: 1 + (bits & 0x3fff), height: 1 + ((bits >> 14) & 0x3fff) };
}
if (chunk === 'VP8 ') {
return {
width: buf.readUInt16LE(26) & 0x3fff,
height: buf.readUInt16LE(28) & 0x3fff,
};
}
return null;
}
/** Every file under `src/`, read once, so usage is a search rather than a guess. */
function sourceText() {
const out = [];
const walk = (dir) => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full);
else if (/\.(astro|mdx?|mjs|js|ts|tsx)$/.test(entry.name)) out.push(readFileSync(full, 'utf8'));
}
};
walk(SRC);
return out;
}
const sources = sourceText();
const declared = new Set();
for (const shot of screens) {
const name = `${shot.id}.webp`;
const file = path.join(DIR, name);
declared.add(name);
if (!existsSync(file)) {
problems.push(
`${shot.id}: no file at public/screens/${name}. ` +
`Retake it: node scripts/captureScreens.mjs ${shot.id}`,
);
continue;
}
const want = shot.family === 'web' ? WEB : PHONE;
const size = webpSize(file);
if (!size) {
problems.push(`${shot.id}: public/screens/${name} is not a WebP this check can read.`);
} else if (size.width !== want.width || size.height !== want.height) {
problems.push(
`${shot.id}: file is ${size.width}x${size.height}, ` +
`declared ${want.width}x${want.height} for the "${shot.family}" family.`,
);
}
if (!shot.alt || shot.alt.length < 20) {
problems.push(`${shot.id}: alt text is missing or too short to describe the screen.`);
}
if (!shot.caption) {
problems.push(`${shot.id}: no caption.`);
}
const used = sources.some((text) => text.includes(`'${shot.id}'`) || text.includes(`"${shot.id}"`));
if (!used) {
problems.push(
`${shot.id}: declared but no page renders it. Use it, or delete the entry and its file.`,
);
}
}
if (existsSync(DIR)) {
for (const name of readdirSync(DIR)) {
if (!declared.has(name)) {
problems.push(`public/screens/${name}: not declared in src/data/screens.mjs.`);
}
}
}
if (problems.length > 0) {
console.error(`\ncheckScreens: ${problems.length} problem(s)\n`);
for (const problem of problems) console.error(` - ${problem}`);
console.error('');
process.exit(1);
}
console.log(`checkScreens: ${screens.length} screens, all present, sized and used.`);

79
scripts/checkSidebar.mjs Normal file
View File

@@ -0,0 +1,79 @@
#!/usr/bin/env node
/**
* checkSidebar.mjs — PLAN.md §12, added in phase 8.
*
* `src/config/sidebar.mjs` holds two trees: `docsSidebar`, which Starlight renders, and
* `plannedSidebar`, the tree §10 planned. While pages were still being written the second
* was a checklist. Now that every page exists it is a second copy of the first, maintained
* by hand — and a hand-maintained copy with nothing reading it is exactly the shape of
* thing §1 is about.
*
* It had already drifted, silently: phase 7 added the `Content` page under D37 and this
* list was never updated. Nothing failed, because nothing read it. That is the whole
* argument for this check.
*
* So the two must agree on groups, labels AND order. Order is checked because the order of
* "Getting started" IS the installation path — §10 calls it the priority of the whole
* project — and a reordering that nobody noticed would be a worse defect than a missing
* page.
*
* node scripts/checkSidebar.mjs
*
* No token and no network: both trees are in this repository.
*/
import { docsSidebar, plannedSidebar, flatLabels } from '../src/config/sidebar.mjs';
const failures = [];
const fail = (what, detail) => failures.push({ what, detail });
// A nested group (RunicNPC inside Rust, phase 16) flattens to `Group › Page` labels, so its
// pages and their order are held to the plan exactly as a top-level group's are.
const live = new Map(docsSidebar.map((g) => [g.label, flatLabels(g.items)]));
const planned = new Map(Object.entries(plannedSidebar).map(([label, items]) => [label, flatLabels(items)]));
// ── Groups ──────────────────────────────────────────────────────────────────
for (const label of live.keys()) {
if (!planned.has(label)) fail(`group ${label}`, 'is in the live sidebar and not in plannedSidebar');
}
for (const label of planned.keys()) {
if (!live.has(label)) fail(`group ${label}`, 'is in plannedSidebar and not in the live sidebar');
}
// ── Pages, in order ─────────────────────────────────────────────────────────
for (const [label, liveItems] of live) {
const plannedItems = planned.get(label);
if (!plannedItems) continue;
for (const page of liveItems) {
if (!plannedItems.includes(page)) fail(`${label} → ${page}`, 'is live but not in plannedSidebar');
}
for (const page of plannedItems) {
if (!liveItems.includes(page)) fail(`${label} → ${page}`, 'is planned but has no live sidebar entry');
}
// Only meaningful once membership matches; otherwise it just repeats the above.
if (liveItems.length === plannedItems.length && liveItems.every((p) => plannedItems.includes(p))) {
if (liveItems.join(' | ') !== plannedItems.join(' | ')) {
fail(
`${label} order`,
`live "${liveItems.join(' → ')}" vs planned "${plannedItems.join(' → ')}"`,
);
}
}
}
// ── Report ──────────────────────────────────────────────────────────────────
if (failures.length === 0) {
const pages = [...live.values()].reduce((n, items) => n + items.length, 0);
console.log(`checkSidebar: ${live.size} groups and ${pages} pages agree with plannedSidebar.`);
} else {
console.error(`\ncheckSidebar: ${failures.length} disagreement(s) between the two trees:\n`);
for (const f of failures) console.error(` ✗ ${f.what}\n ${f.detail}`);
console.error(`
Both trees are in src/config/sidebar.mjs. Decide which one is right — if a page was
deliberately added, renamed or reordered, plannedSidebar records that decision and
should move with it.
`);
process.exit(1);
}

449
scripts/checkTerms.mjs Normal file
View File

@@ -0,0 +1,449 @@
#!/usr/bin/env node
/**
* checkTerms.mjs — PLAN.md §16, D63 and D64. The twelfth check, added in phase 14 and widened
* to the whole site in phase 17 (D79).
*
* D63: outside a game's own section, the site may NAME the games it supports, but it may not
* use their VOCABULARY. Core concepts keep core's names — a Team, a game server, the sidecar,
* the bridge plugin, the game module — because that is what core itself calls them, and a
* site that says "shard" on a core page is describing one game's deployment as though it were
* the platform.
*
* D64: a convention that holds only while everyone remembers it does not hold. This is the
* same argument `checkTokens.mjs` makes for colours, applied to words. The vocabulary problem
* was measured before this check existed — "shard" 227 times in 45 files — and none of that
* was anybody's decision; it was the residue of a site written while there was one game.
*
* It runs in two passes, one before the build and one after.
*
* node scripts/checkTerms.mjs # the SOURCE pass: docs pages and the sidebar
* node scripts/checkTerms.mjs --built # the BUILT pass: every page in dist/, as rendered
*
* ── The source pass (phase 14) ──────────────────────────────────────────────────────────
*
* - every page under `src/content/docs`, frontmatter included (a title and a description
* are what a reader sees first, in the sidebar and in search results);
* - `src/config/sidebar.mjs`, as DATA rather than as text: every group label and every
* page label, in both trees. Its comments are not scanned, because a comment is not
* something a reader of the site ever sees.
*
* It needs no build, so it runs early and fails fast, and it names a file and a line.
*
* ── The built pass (phase 17, D79) ──────────────────────────────────────────────────────
*
* The marketing pages are `.astro` templates fed from `src/data/`, so most of the words a
* reader sees on them are not in the template at all: a capability's label, a screenshot's
* caption, a version read from `platform.json`. A source scan sees an expression and skips
* it, or sees an id like `shard-status` and takes it for prose. So this pass reads what the
* build actually produced — the same argument `checkLinks.mjs` makes for links. For every
* page in `dist/client` it reads the visible text, plus `alt`, `title`, `aria-label`,
* `placeholder`, the `<title>` and the meta description. It does not read link targets,
* scripts, styles or comments. It names a route and a snippet rather than a line; the source
* pass still gives lines for the docs, where most of the words are.
*
* Two parts of every docs page are skipped, because each is checked somewhere better:
* Starlight's sidebar (the source pass reads both trees as data, group by group) and the
* previous/next links (each is a neighbouring page's title, checked on that page).
*
* ── Where a game's words are allowed ────────────────────────────────────────────────────
*
* - in that game's own docs directory (`GAME_DIRS`) and its own sidebar group — a group
* whose label is the game's `name` in `platform.json`;
* - on that game's own routes (`GAME_ROUTES`): its docs group, its module page, and for
* Rust the standalone RunicNPC page;
* - inside an element marked `data-game="…"`: `<GameExample>` on a docs page (D68) and a
* game-tagged `<Screenshot>` (D67). Both print the game's name beside the words, which is
* the bargain: a game's words are honest on a core page when the game is named right
* there. In the source pass, the `<GameExample>` tags themselves mark the region.
*
* What is NOT read, deliberately: link targets and bare URLs or paths (a repository is called
* what it is called, and an address is not prose — but LINK TEXT is read, so `[Module-uo](…)`
* fails and `[releases](…)` passes), MDX `import` lines, and, in the source pass only, `{…}`
* expressions — the built pass reads what they rendered.
*
* No token and no network: everything it reads is in this repository or its build output.
*/
import { readFileSync, existsSync } from 'node:fs';
import { readdir } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import { docsSidebar, plannedSidebar, flatLabels } from '../src/config/sidebar.mjs';
const ROOT = fileURLToPath(new URL('..', import.meta.url));
const platform = JSON.parse(readFileSync(path.join(ROOT, 'src/data/platform.json'), 'utf8'));
const BUILT = process.argv.includes('--built');
/**
* The words, each tagged with the game it belongs to. Matching is case-insensitive and on
* word boundaries, so "shard" does not match `SHARD_VISIBILITY` and "wipe" does not match
* "wiped" unless it is listed.
*
* A word is on this list because it is a game's name for something core has its own name for
* (shard → game server, guild and clan → Team), or because it names one game's component
* (uo-link → the sidecar, ServUO → the game server). Game NAMES — Ultima Online, UO, Rust —
* are not on it: D63 allows them everywhere. Steam is not on it either: Steam sign-in is a
* core identity provider, even though Rust was its first user.
*/
const TERMS = [
{ game: 'uo', word: 'shard', re: /\bshards?\b/gi },
{ game: 'uo', word: 'ServUO', re: /\bservuo\b/gi },
{ game: 'uo', word: 'uo-link', re: /\buo-?link\b/gi },
{ game: 'uo', word: 'Module-uo', re: /\bmodule-uo\b/gi },
{ game: 'uo', word: 'Bridge.cfg', re: /\bbridge\.cfg\b/gi },
{ game: 'uo', word: 'cliloc', re: /\bclilocs?\b/gi },
{ game: 'uo', word: 'guild', re: /\bguilds?\b/gi },
{ game: 'uo', word: 'IDOC', re: /\bidocs?\b/gi },
{ game: 'rust', word: 'rust-link', re: /\brust-?link\b/gi },
{ game: 'rust', word: 'Rust-Plugins', re: /\brust-plugins\b/gi },
{ game: 'rust', word: 'Module-Rust', re: /\bmodule-rust\b/gi },
{ game: 'rust', word: 'Oxide', re: /\boxide\b/gi },
{ game: 'rust', word: 'Carbon', re: /\bcarbon\b/gi },
{ game: 'rust', word: 'uMod', re: /\bumod\b/gi },
{ game: 'rust', word: 'clan', re: /\bclans?\b/gi },
{ game: 'rust', word: 'wipe', re: /\bwipes?\b/gi },
];
/** Each game's own docs directory. A page under one may use that game's words. */
const GAME_DIRS = {
uo: 'src/content/docs/docs/ultima-online/',
rust: 'src/content/docs/docs/rust/',
};
/**
* Each game's own routes, for the built pass: its docs group, the page about its module, and
* for Rust the standalone RunicNPC page (D61), which a server owner reads without knowing what
* the platform is. A route matches when the page's path starts with one of these.
*/
const GAME_ROUTES = {
uo: ['/docs/ultima-online/', '/modules/uo/'],
rust: ['/docs/rust/', '/modules/rust/', '/runicnpc/'],
};
/**
* The allowlist: an exact phrase on one page that may use a word the rule would otherwise
* refuse. Short on purpose, and every entry says why. `file` is where the source pass meets
* it and `route` is where the built pass does. An entry that no longer matches fails the
* check too, so the list cannot rot into a set of permanent exemptions.
*/
const ALLOW = [
{
file: 'src/content/docs/docs/administration/notifications-and-email.mdx',
route: '/docs/administration/notifications-and-email/',
text: 'Guild (server) ID',
why: "Discord's own word, not a game's: it is the label on core's Discord Bot screen.",
},
];
for (const game of new Set([...Object.keys(GAME_DIRS), ...Object.keys(GAME_ROUTES)])) {
if (!platform.games[game]) throw new Error(`checkTerms: "${game}" is given a section here, but platform.json has no such game.`);
}
for (const t of TERMS) {
if (!platform.games[t.game]) throw new Error(`checkTerms: "${t.word}" is tagged "${t.game}", which platform.json does not know.`);
}
const failures = [];
const fail = (where, detail) => failures.push({ where, detail });
/** Replace a span with spaces, keeping newlines, so later line numbers stay true. */
const blank = (text) => text.replace(/[^\n]/g, ' ');
/** Hits for every term the given context may not use. `allowed` is a game key, or null. */
function scan(text, allowed) {
const hits = [];
for (const t of TERMS) {
if (t.game === allowed) continue;
t.re.lastIndex = 0;
for (const m of text.matchAll(t.re)) hits.push({ term: t, value: m[0], index: m.index });
}
return hits;
}
const gameName = (game) => platform.games[game].name;
async function* walk(dir, pattern) {
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) yield* walk(full, pattern);
else if (pattern.test(entry.name)) yield full;
}
}
// ═══ The source pass ═══════════════════════════════════════════════════════════════════
/** The game whose directory holds this file, or null for a core page. */
function gameOfPath(relative) {
const posix = relative.split(path.sep).join('/');
for (const [game, dir] of Object.entries(GAME_DIRS)) if (posix.startsWith(dir)) return game;
return null;
}
function lineOf(source, index) {
return source.slice(0, index).split('\n').length;
}
/**
* Turn an MDX source into the text a reader would actually see, with everything that is not
* prose blanked out. Returns that text plus the GameExample regions, as offsets into it.
*/
function proseOf(source, relative) {
let text = source;
// GameExample regions first, while the tags are still intact. Recorded, then the tags
// themselves are blanked so the attribute does not read as prose.
const regions = [];
for (const m of text.matchAll(/<GameExample\s+game="([^"]*)"\s*>([\s\S]*?)<\/GameExample>/g)) {
const game = m[1];
if (!platform.games[game]) {
fail(`${relative}:${lineOf(source, m.index)}`, `<GameExample game="${game}"> names a game platform.json does not have`);
}
regions.push({ game, start: m.index, end: m.index + m[0].length });
}
text = text.replace(/<\/?GameExample\b[^>]*>/g, blank);
text = text.replace(/<!--[\s\S]*?-->/g, blank); // HTML comments
text = text.replace(/^import\s.*$/gm, blank); // MDX imports
text = text.replace(/\]\([^)\s]*\)/g, (m) => '] ' + blank(m.slice(2))); // link targets, not their text
text = text.replace(/\b(?:href|src)=(?:"[^"]*"|\{`[^`]*`\})/g, blank); // JSX/HTML link targets
text = text.replace(/\bhttps?:\/\/[^\s)>"'`]+/g, blank); // bare URLs
// `{…}` expressions, outside code fences only — a fenced JSON example's braces are prose.
// Innermost first, repeatedly, so nested and multi-line expressions both go.
const parts = text.split(/(^```[\s\S]*?^```)/m);
for (let i = 0; i < parts.length; i += 2) {
let prev;
do {
prev = parts[i];
parts[i] = parts[i].replace(/\{[^{}]*\}/g, blank);
} while (parts[i] !== prev);
}
text = parts.join('');
return { text, regions };
}
async function sourcePass() {
// ── 1. The documentation pages ──
let files = 0;
const allowUsed = new Set();
for await (const file of walk(path.join(ROOT, 'src', 'content', 'docs'), /\.mdx?$/)) {
const relative = path.relative(ROOT, file).split(path.sep).join('/');
const source = readFileSync(file, 'utf8').replace(/\r\n/g, '\n');
files++;
let { text, regions } = proseOf(source, relative);
for (const [i, entry] of ALLOW.entries()) {
if (entry.file !== relative) continue;
if (text.includes(entry.text)) {
allowUsed.add(i);
text = text.split(entry.text).join(blank(entry.text));
}
}
const pageGame = gameOfPath(relative);
for (const hit of scan(text, pageGame)) {
const region = regions.find((r) => hit.index >= r.start && hit.index < r.end);
if (region && region.game === hit.term.game) continue;
const where = region ? `inside <GameExample game="${region.game}">` : pageGame ? `in the ${gameName(pageGame)} group` : 'on a core page';
fail(`${relative}:${lineOf(source, hit.index)}`, `"${hit.value}" is ${gameName(hit.term.game)} vocabulary, ${where}`);
}
}
for (const [i, entry] of ALLOW.entries()) {
if (!allowUsed.has(i)) fail(`allowlist: ${entry.file}`, `"${entry.text}" no longer appears there — remove the entry`);
}
// ── 2. The sidebar, as data ──
const gameByName = new Map(Object.entries(platform.games).map(([key, g]) => [g.name, key]));
const trees = [
['docsSidebar', docsSidebar.map((g) => [g.label, flatLabels(g.items)])],
['plannedSidebar', Object.entries(plannedSidebar).map(([label, items]) => [label, flatLabels(items)])],
];
let labels = 0;
for (const [treeName, groups] of trees) {
for (const [groupLabel, items] of groups) {
const game = gameByName.get(groupLabel) ?? null;
for (const label of [groupLabel, ...items]) {
labels++;
for (const hit of scan(label, game)) {
fail(
`src/config/sidebar.mjs ${treeName} → ${groupLabel}`,
`"${label}" uses "${hit.value}", ${gameName(hit.term.game)} vocabulary, ${game ? `in the ${gameName(game)} group` : 'in a core group'}`,
);
}
}
}
}
return `${files} docs pages and ${labels} sidebar labels use only their own game's words`;
}
// ═══ The built pass ════════════════════════════════════════════════════════════════════
const DIST = path.join(ROOT, 'dist', 'client');
/** `dist/client/a/b/index.html` → `/a/b/`; `dist/client/404.html` → `/404`. */
function routeOf(file) {
const rel = path.relative(DIST, file).split(path.sep).join('/');
if (rel === 'index.html') return '/';
if (rel.endsWith('/index.html')) return `/${rel.slice(0, -'index.html'.length)}`;
return `/${rel.replace(/\.html$/, '')}`;
}
function gameOfRoute(route) {
for (const [game, prefixes] of Object.entries(GAME_ROUTES)) {
if (prefixes.some((p) => route.startsWith(p))) return game;
}
return null;
}
/**
* Where the element opening at `start` closes, counting nested elements of the same name.
* Astro's output is well formed, which is what makes this enough without a parser.
*/
function elementEnd(html, start, tag) {
const re = new RegExp(`<(/?)${tag}\\b[^>]*?(/?)>`, 'gi');
re.lastIndex = start;
let depth = 0;
for (let m = re.exec(html); m; m = re.exec(html)) {
if (m[2] === '/') continue; // self-closing
depth += m[1] === '/' ? -1 : 1;
if (depth === 0) return re.lastIndex;
}
return html.length;
}
/** Every element whose opening tag matches `openRe`, as `{ start, end, match }`. */
function elements(html, openRe) {
const found = [];
for (const m of html.matchAll(openRe)) {
const tag = /^<([a-z0-9-]+)/i.exec(m[0])[1];
found.push({ start: m.index, end: elementEnd(html, m.index, tag), match: m });
}
return found;
}
const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', rsquo: '’', lsquo: '‘', ldquo: '“', rdquo: '”', mdash: '—', ndash: '–', hellip: '…', rarr: '→', middot: '·' };
const decode = (s) =>
s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (m, e) => {
if (e[0] === '#') return String.fromCodePoint(e[1] === 'x' || e[1] === 'X' ? parseInt(e.slice(2), 16) : Number(e.slice(1)));
return ENTITIES[e.toLowerCase()] ?? m;
});
/**
* Elements whose text runs on into the next word with no space between: a word split across
* two `<span>`s by a syntax highlighter is still one word to a reader. Everything else is a
* boundary, so `<h3>Guilds</h3><p>Guild…` does not read as "GuildsGuild".
*/
const INLINE = /^(?:a|abbr|b|bdi|bdo|cite|code|data|dfn|em|i|kbd|mark|q|s|samp|small|span|strong|sub|sup|time|u|var|wbr)$/i;
/** The words a reader sees in a piece of HTML: its text, and the attributes that are read aloud or shown. */
function visibleText(html) {
const attrs = [];
for (const m of html.matchAll(/\s(?:alt|title|aria-label|placeholder)="([^"]*)"/gi)) attrs.push(m[1]);
let text = html.replace(/<\/?([a-z][a-z0-9-]*)\b[^>]*>/gi, (_, tag) => (INLINE.test(tag) ? '' : ' '));
text = decode([text, ...attrs.map(decode)].join(' \n '));
text = text.replace(/\bhttps?:\/\/\S+/g, ' '); // bare URLs
text = text.replace(/(^|[\s(“"'])\/[\w./#?=&%-]+/g, '$1 '); // bare paths — an address, not prose
return text.replace(/\s+/g, ' ');
}
function snippet(text, index, length) {
const from = Math.max(0, index - 50);
const to = Math.min(text.length, index + length + 50);
return `${from > 0 ? '…' : ''}${text.slice(from, to).trim()}${to < text.length ? '…' : ''}`;
}
async function builtPass() {
if (!existsSync(DIST)) {
console.error('checkTerms --built: dist/client does not exist. Run `npm run build` first.');
process.exit(1);
}
let pages = 0;
let regionsSeen = 0;
const allowUsed = new Set();
for await (const file of walk(DIST, /\.html$/)) {
const route = routeOf(file);
let html = readFileSync(file, 'utf8');
pages++;
// The head: only the parts a reader sees, in a tab or a search result.
const head = /<head\b[\s\S]*?<\/head>/i.exec(html)?.[0] ?? '';
const headText = [/<title>([\s\S]*?)<\/title>/i.exec(head)?.[1] ?? '', /<meta\s+name="description"\s+content="([^"]*)"/i.exec(head)?.[1] ?? ''].join(' \n ');
html = html.replace(/<head\b[\s\S]*?<\/head>/i, ' ');
html = html.replace(/<(script|style|template|noscript)\b[\s\S]*?<\/\1>/gi, ' ');
html = html.replace(/<!--[\s\S]*?-->/g, ' ');
// Checked elsewhere (see the header): Starlight's sidebar and its previous/next links.
for (const skip of [/<nav\b[^>]*\baria-label="Main"[^>]*>/gi, /<div\b[^>]*\bclass="pagination-links\b[^>]*>/gi]) {
for (const el of elements(html, skip).reverse()) html = html.slice(0, el.start) + ' ' + html.slice(el.end);
}
const pageGame = gameOfRoute(route);
const pieces = [{ text: decode(headText), allowed: pageGame, where: 'in the page title or description' }];
// Regions marked with a game. Read with that game's words allowed, then cut out.
for (const el of elements(html, /<[a-z][a-z0-9-]*\b[^>]*\bdata-game="([^"]*)"[^>]*>/gi).reverse()) {
const game = el.match[1];
regionsSeen++;
if (!platform.games[game]) {
fail(route, `an element is marked data-game="${game}", which platform.json does not have`);
continue;
}
pieces.push({ text: visibleText(html.slice(el.start, el.end)), allowed: game, also: pageGame, where: `inside a block marked ${gameName(game)}` });
html = html.slice(0, el.start) + ' ' + html.slice(el.end);
}
pieces.push({ text: visibleText(html), allowed: pageGame, where: pageGame ? `on a ${gameName(pageGame)} page` : 'on a core page' });
for (const piece of pieces) {
let { text } = piece;
for (const [i, entry] of ALLOW.entries()) {
if (entry.route !== route || !text.includes(entry.text)) continue;
allowUsed.add(i);
text = text.split(entry.text).join(' '.repeat(entry.text.length));
}
for (const hit of scan(text, piece.allowed)) {
if (piece.also && hit.term.game === piece.also) continue;
fail(route, `"${hit.value}" is ${gameName(hit.term.game)} vocabulary, ${piece.where}\n ${snippet(text, hit.index, hit.value.length)}`);
}
}
}
for (const [i, entry] of ALLOW.entries()) {
if (!allowUsed.has(i)) fail(`allowlist: ${entry.route}`, `"${entry.text}" no longer appears in the built page — remove the entry`);
}
return `${pages} built pages use only their own game's words (${regionsSeen} game-marked blocks read with their game's words allowed)`;
}
// ═══ Report ════════════════════════════════════════════════════════════════════════════
const summary = BUILT ? await builtPass() : await sourcePass();
if (failures.length === 0) {
console.log(`checkTerms${BUILT ? ' --built' : ''}: ${summary} (${TERMS.length} terms, ${ALLOW.length} allowlisted phrase${ALLOW.length === 1 ? '' : 's'}).`);
} else {
console.error(`\ncheckTerms${BUILT ? ' --built' : ''}: ${failures.length} game word(s) outside their game's section:\n`);
for (const f of failures) console.error(` ✗ ${f.where}\n ${f.detail}`);
console.error(`
PLAN.md §16, D63: name the games anywhere, but keep their vocabulary in their own section.
Use core's word instead — a Team, a game server, the sidecar, the bridge plugin, the game
module. A marketing page describes what a game module adds in core's words and links to
the game's own page (D75). If a docs page genuinely needs a game's real example, wrap it in
<GameExample game="…"> (D68); a screenshot of a game screen carries its game tag (D67).
Add to the allowlist in this script only for a word that is not a game's at all, and say why.
`);
process.exit(1);
}

205
scripts/mockRustDemo.mjs Normal file
View File

@@ -0,0 +1,205 @@
#!/usr/bin/env node
/**
* mockRustDemo.mjs — the game data on the Rust demo's screenshots. PLAN.md §16 phase 18, D81.
*
* node scripts/mockRustDemo.mjs > rust-demo.sql
* docker exec -i <db container> mariadb -u<user> -p<password> <rust demo db> < rust-demo.sql
*
* Prints SQL; it connects to nothing. Run it after `seedDemo.mjs --game rust`, with the demo
* site connected to its server at least once, so the server row and its current wipe exist.
*
* ---------------------------------------------------------------------------------------
* WHY THIS IS MOCK DATA, WHEN THE UO CAPTURES ARE NOT
* ---------------------------------------------------------------------------------------
* D4 asks for real captures, and phase 9 got them from a real shard with a person signed in.
* The Rust rig has no players. Phase 18 first staged stand-in players on it with a rig-only
* plugin; that cost a morning of a server the update had left rebuilding its navmesh, and the
* org lead chose this instead (D81): the feed, the leaderboard and the linked Steam account
* are written into the demo's database here, in the module's own row shapes, and everything
* else on the screens is the real server's (its status, its world map, its sync state).
*
* Every name is a role (D80): player, player2 … player7, and moderator. The Steam ids sit in a
* range no real account uses.
*
* Re-running replaces what it wrote: it deletes the server's events, stats, weapon kills and
* these players first. Real events that arrived from the server are deleted with them.
*/
const SERVER = 'rust-oxide';
const ID_BASE = 76561199920000000n;
/** The cast. `n` makes the Steam id; `user` links one to a site account by username. */
const CAST = [
{ n: 1, name: 'player', user: 'player' },
{ n: 2, name: 'player2' },
{ n: 3, name: 'player3' },
{ n: 4, name: 'player4' },
{ n: 5, name: 'player5' },
{ n: 6, name: 'player6' },
{ n: 7, name: 'player7' },
{ n: 9, name: 'moderator' },
];
const steam = (n) => String(ID_BASE + BigInt(n));
const who = (n) => CAST.find((c) => c.n === n).name;
const now = Date.now();
const ago = (minutes) => now - Math.round(minutes * 60_000);
// ── The feed, oldest first ─────────────────────────────────────────────────────────────
//
// Each frame is the shape the bridge plugin emits (copied from frames the walk database
// holds), less `serverId`/`wipeId`/`t`/`type`, which `frame()` adds.
const death = (killer, victim, weapon, distance, grid, extra = {}) => ({
kind: 'player.death',
steamId: steam(victim),
name: who(victim),
attackerId: steam(killer),
attackerName: who(killer),
attackerType: 'player',
weapon,
distance,
grid,
sleeping: false,
...extra,
});
const world = (event, fields = {}) => ({ kind: 'world.event', event, ...fields });
const FEED = [
[150, { kind: 'player.connected', steamId: steam(1), name: who(1) }],
[148, { kind: 'player.connected', steamId: steam(2), name: who(2) }],
[146, { kind: 'player.connected', steamId: steam(3), name: who(3) }],
[141, world('cargo.arrived', { x: 2900, z: -2100 })],
[137, { kind: 'player.connected', steamId: steam(9), name: who(9) }],
[131, { kind: 'player.chat', steamId: steam(2), name: who(2), channel: 'Global', message: 'anyone selling scrap at outpost?' }],
[126, death(3, 4, 'bolt_rifle.entity', 187.4, 'AF10')],
[119, { kind: 'player.connected', steamId: steam(5), name: who(5) }],
[112, world('crate.hack.started', { steamId: steam(2), name: who(2), where: 'oilrig_small', x: 3358, z: 1721 })],
[104, death(1, 2, 'ak47u.entity', 22.4, 'R18')],
[98, world('heli.arrived', { x: 400, z: -900 })],
[96, world('crate.unlocked', { steamId: steam(5), name: who(5), where: 'oilrig_small', x: 3358, z: 1721 })],
[91, { kind: 'player.connected', steamId: steam(6), name: who(6) }],
[86, death(5, 3, 'lr300.entity', 64.9, 'H7')],
[80, world('heli.downed', {
x: 2453, z: -943,
credit: {
blow: steam(3), last: steam(1), most: steam(1),
damagers: [{ steamId: steam(1), damage: 5200 }, { steamId: steam(3), damage: 3900 }, { steamId: steam(6), damage: 900 }],
},
names: { [steam(1)]: who(1), [steam(3)]: who(3), [steam(6)]: who(6) },
creditRule: 'most',
})],
[74, { kind: 'player.chat', steamId: steam(9), name: who(9), channel: 'Global', message: 'wipe is Thursday 19:00, bases stay up till then' }],
[69, { kind: 'npc.boss.died', name: 'Gate Warden', killerName: who(6), killerId: steam(6), damagers: 3 }],
[63, death(6, 1, 'semi_auto_rifle.entity', 41.2, 'K12')],
[58, world('airdrop.looted', { steamId: steam(4), name: who(4), x: 164, z: -1507 })],
[52, { kind: 'player.connected', steamId: steam(7), name: who(7) }],
[47, death(2, 6, 'mp5.entity', 12.6, 'R17', { }) ],
[41, { kind: 'dt.won', steamId: steam(4), name: who(4), invaderCount: 2, invaders: [steam(4), steam(7)], x: 150, z: -800 }],
[36, world('bradley.destroyed', {
where: 'launch_site', x: -1200, z: 300,
credit: {
blow: steam(1), last: steam(1), most: steam(5),
damagers: [{ steamId: steam(5), damage: 630 }, { steamId: steam(1), damage: 385 }],
},
names: { [steam(1)]: who(1), [steam(5)]: who(5) },
creditRule: 'most',
})],
[31, death(1, 7, 'ak47u.entity', 96.3, 'J9')],
[25, { kind: 'gc.ended', steamId: steam(7), name: who(7), x: 200, z: -800 }],
[21, {
kind: 'raid.completed', base: 'raideasy7', difficulty: 'Easy', level: 0, pvp: false,
id: 'raid-demo-1', key: 'demo', ownerId: steam(6), ownerName: who(6), raiderCount: 1,
raiders: [{ steamId: steam(6), name: who(6) }], x: 280, z: -800,
}],
[16, death(3, 5, 'bolt_rifle.entity', 241.7, 'AB4', { })],
[12, { kind: 'player.chat', steamId: steam(1), name: who(1), channel: 'Global', message: 'gg on the heli' }],
[8, world('cargo.docking', { harbour: 'harbor_2', harbourName: 'Harbor', x: 2595, z: -2215 })],
[5, death(4, 2, 'thompson.entity', 18.9, 'Q14')],
[2, { kind: 'player.disconnected', steamId: steam(2), name: who(2), reason: 'Disconnected', sessionSec: 8760 }],
];
// ── The leaderboard ────────────────────────────────────────────────────────────────────
//
// Consistent with the feed in spirit, not to the kill: a wipe is days long and the feed above
// is its last two hours.
const STATS = {
1: { kills: 31, deaths: 12, npc_kills: 58, headshots: 14, best_pvp_m: 212.4, structures: 340, playtime_sec: 61200, heli_kills: 2, apc_kills: 1, crates_hacked: 3, drops_looted: 5 },
3: { kills: 27, deaths: 9, npc_kills: 22, headshots: 19, best_pvp_m: 288.1, structures: 120, playtime_sec: 48900, heli_kills: 1, crates_hacked: 1, drops_looted: 2 },
5: { kills: 19, deaths: 15, npc_kills: 41, headshots: 6, best_pvp_m: 133.0, structures: 510, playtime_sec: 55800, apc_kills: 1, crates_hacked: 2, drops_looted: 7 },
6: { kills: 16, deaths: 11, npc_kills: 30, headshots: 5, best_pvp_m: 97.6, structures: 260, playtime_sec: 40100, raids_completed: 2, drops_looted: 3 },
4: { kills: 12, deaths: 18, npc_kills: 25, headshots: 3, best_pvp_m: 74.2, structures: 180, playtime_sec: 37400, treasures_won: 1, drops_looted: 4 },
2: { kills: 9, deaths: 21, npc_kills: 47, headshots: 2, best_pvp_m: 58.8, structures: 610, playtime_sec: 52300, crates_hacked: 2, drops_looted: 6 },
7: { kills: 6, deaths: 10, npc_kills: 13, headshots: 1, best_pvp_m: 96.3, structures: 90, playtime_sec: 21000, guarded_won: 1, drops_looted: 1 },
9: { kills: 2, deaths: 1, npc_kills: 4, headshots: 0, best_pvp_m: 35.0, structures: 40, playtime_sec: 9800 },
};
const WEAPON_KILLS = {
1: { 'ak47u.entity': 21, 'semi_auto_rifle.entity': 6, 'python.entity': 4 },
3: { 'bolt_rifle.entity': 22, 'lr300.entity': 5 },
5: { 'lr300.entity': 13, 'mp5.entity': 6 },
6: { 'semi_auto_rifle.entity': 10, 'ak47u.entity': 6 },
4: { 'thompson.entity': 8, 'mp5.entity': 4 },
2: { 'mp5.entity': 7, 'thompson.entity': 2 },
7: { 'ak47u.entity': 4, 'python.entity': 2 },
9: { 'lr300.entity': 2 },
};
// ── SQL ────────────────────────────────────────────────────────────────────────────────
const q = (value) =>
value === null || value === undefined ? 'NULL' : `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "''")}'`;
const ids = CAST.map((c) => q(steam(c.n))).join(', ');
const out = [];
out.push('-- mockRustDemo.mjs — PLAN.md §16 phase 18 (D81). Generated; do not edit by hand.');
out.push(`SET @server = ${q(SERVER)};`);
out.push(
"SET @wipe = (SELECT wipe_id FROM rust_wipes WHERE server_id = @server ORDER BY last_seen DESC LIMIT 1);",
);
out.push("SELECT IF(@wipe IS NULL, 'NO WIPE YET: connect the site to the server first', CONCAT('wipe ', @wipe)) AS mock;");
out.push('DELETE FROM rust_events WHERE server_id = @server;');
out.push('DELETE FROM rust_player_wipe_stats WHERE server_id = @server;');
out.push('DELETE FROM rust_weapon_kills WHERE server_id = @server;');
out.push(`DELETE FROM rust_account_links WHERE steam_id IN (${ids});`);
out.push(`DELETE FROM rust_players WHERE steam_id IN (${ids});`);
for (const c of CAST) {
out.push(
`INSERT INTO rust_players (steam_id, name, first_seen, last_seen) VALUES (${q(steam(c.n))}, ${q(c.name)}, NOW() - INTERVAL 6 DAY, NOW() - INTERVAL 2 MINUTE);`,
);
}
for (const [minutes, body] of FEED) {
const t = ago(minutes);
const raw = JSON.stringify({ ...body, serverId: SERVER, t, type: 'event', wipeId: '@@WIPE@@' });
const [before, after] = raw.split('"@@WIPE@@"');
out.push(
`INSERT INTO rust_events (server_id, wipe_id, kind, t, steam_id, raw, created_at) VALUES (@server, @wipe, ${q(body.kind)}, ${t}, ${q(body.steamId ?? null)}, CONCAT(${q(before)}, JSON_QUOTE(@wipe), ${q(after)}), FROM_UNIXTIME(${Math.floor(t / 1000)}));`,
);
}
for (const [n, row] of Object.entries(STATS)) {
const cols = Object.keys(row);
out.push(
`INSERT INTO rust_player_wipe_stats (server_id, wipe_id, steam_id, sessions, last_seen, ${cols.join(', ')}) VALUES (@server, @wipe, ${q(steam(Number(n)))}, 6, NOW() - INTERVAL 2 MINUTE, ${cols.map((k) => row[k]).join(', ')});`,
);
}
for (const [n, weapons] of Object.entries(WEAPON_KILLS)) {
for (const [weapon, kills] of Object.entries(weapons)) {
out.push(
`INSERT INTO rust_weapon_kills (server_id, wipe_id, steam_id, weapon, kills) VALUES (@server, @wipe, ${q(steam(Number(n)))}, ${q(weapon)}, ${kills});`,
);
}
}
for (const c of CAST.filter((member) => member.user)) {
out.push(
`INSERT INTO rust_account_links (steam_id, user_id, name, server_id, linked_at) SELECT ${q(steam(c.n))}, id, ${q(c.name)}, @server, NOW() - INTERVAL 5 DAY FROM users WHERE username = ${q(c.user)};`,
);
}
console.log(out.join('\n'));

View File

@@ -38,7 +38,14 @@ import { readFileSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import path from 'node:path'; import path from 'node:path';
import { collectedIn, playRows } from '../src/data/collection.mjs'; import {
collectedIn,
deletionRow,
PLAY_DATA_TYPES,
PLAY_PURPOSES,
PLAY_VOCABULARY_READ,
playRows,
} from '../src/data/collection.mjs';
import { legal } from '../src/data/legal.mjs'; import { legal } from '../src/data/legal.mjs';
const ROOT = fileURLToPath(new URL('..', import.meta.url)); const ROOT = fileURLToPath(new URL('..', import.meta.url));
@@ -52,6 +59,33 @@ const cell = (text) => String(text).replace(/\|/g, '\\|').replace(/\s*\n\s*/g, '
const yesNo = (value) => (value ? 'Yes' : 'No'); const yesNo = (value) => (value ? 'Yes' : 'No');
/**
* The form asks per data type, not per row, and two rows can answer the same type (the
* trusted-device token and the push topic are both "Device or other IDs"). So the
* collected rows are folded into one entry per type, in the console's order: required if
* any row requires it, the union of the purposes, and the rows it came from.
*/
function declaredTypes(rows) {
const entries = [];
for (const [category, types] of Object.entries(PLAY_DATA_TYPES)) {
for (const type of types) {
const from = rows.filter(
(row) => row.play.collected && row.play.types.some((t) => t.category === category && t.type === type)
);
if (!from.length) continue;
const purposes = new Set(from.flatMap((row) => row.play.purposes));
entries.push({
category,
type,
required: from.some((row) => row.play.required),
purposes: PLAY_PURPOSES.filter((p) => purposes.has(p)),
rows: from,
});
}
}
return entries;
}
function render() { function render() {
const rows = playRows(); const rows = playRows();
const site = collectedIn('site'); const site = collectedIn('site');
@@ -61,7 +95,9 @@ function render() {
lines.push('<!--'); lines.push('<!--');
lines.push(' GENERATED FILE — do not edit.'); lines.push(' GENERATED FILE — do not edit.');
lines.push(''); lines.push('');
lines.push(` Source: src/data/collection.mjs (scope "app") + src/data/legal.mjs`); lines.push(
` Source: src/data/collection.mjs (scope "app", and the deletion row) + src/data/legal.mjs`
);
lines.push(` Generator: ${GENERATOR}`); lines.push(` Generator: ${GENERATOR}`);
lines.push(''); lines.push('');
lines.push(' Edit the data file and run `npm run play:datasafety`. CI runs the same'); lines.push(' Edit the data file and run `npm run play:datasafety`. CI runs the same');
@@ -101,40 +137,87 @@ function render() {
); );
lines.push(''); lines.push('');
lines.push( lines.push(
'That is why nearly every answer below is "not collected", and it is also the answer ' + 'Play still counts what the app sends to that server as **collected**: the data leaves ' +
'most likely to be questioned in a review. The supporting facts are in the table: ' + 'the device, and the form does not ask whose server it lands on. So everything the ' +
'each row names the file it was read out of.' 'app sends is declared collected, not shared, and required, which is the conservative ' +
); 'answer and the accurate one (org lead, 2026-10-29). What stays true, and what ' +
lines.push(''); '`/privacy` says, is that none of it reaches us.'
lines.push(
'Where the console offers free text about security practices, two things are worth ' +
'saying: credentials are held in Android’s encrypted storage (AES-256-GCM via ' +
'Jetpack Security), and push notifications carry **no content** — a relay receives a ' +
'stream name and a reference, and the app fetches the actual message over its own ' +
'authenticated connection.'
); );
lines.push(''); lines.push('');
/* --------------------------------------------------------------------------------- */ /* --------------------------------------------------------------------------------- */
lines.push('## Data types'); lines.push('## Data collection and security');
lines.push(''); lines.push('');
lines.push('| Category | Data type | Collected by us | Shared by us | Answer |'); lines.push('- **Does the app collect or share any of the required user data types?** Yes, it collects them (below). It shares none.');
lines.push('|---|---|---|---|---|'); lines.push(
for (const row of rows) { '- **Is all of the user data collected by the app encrypted in transit?** Yes. The ' +
'release build’s network security config sets `cleartextTrafficPermitted="false"` ' +
'for every connection, and the app refuses an `http` deployment address outside ' +
'debug builds. Read from: `app/src/main/res/xml/network_security_config.xml`, ' +
'`core/net/ServerUrl.kt`.'
);
lines.push(
'- **Is any of it processed ephemerally?** No. The deployment keeps what it receives, ' +
'under its operator’s policy, and we cannot promise otherwise on its behalf.'
);
lines.push(
'- **Free text on security practices**, where the console offers it: credentials are ' +
'held in Android’s encrypted storage (AES-256-GCM via Jetpack Security), and push ' +
'notifications carry **no content**: a relay receives a stream name and a reference, ' +
'and the app fetches the actual message over its own authenticated connection.'
);
lines.push('');
/* --------------------------------------------------------------------------------- */
const declared = declaredTypes(rows);
lines.push('## Data types the app collects');
lines.push('');
lines.push('| Category | Data type | Collected | Shared | Required | Purposes | From |');
lines.push('|---|---|---|---|---|---|---|');
for (const entry of declared) {
lines.push( lines.push(
`| ${cell(row.play.category)} | ${cell(row.play.type)} | ${yesNo(row.play.collected)} ` + `| ${cell(entry.category)} | ${cell(entry.type)} | Yes | No | ${yesNo(entry.required)} ` +
`| ${yesNo(row.play.shared)} | ${cell(row.play.answer)} |` `| ${cell(entry.purposes.join('; '))} | ${cell(entry.rows.map((r) => r.title).join('; '))} |`
); );
} }
lines.push(''); lines.push('');
lines.push('## Data types the app does not collect');
lines.push('');
lines.push(
'The form lists every type, so every one is answered. Vocabulary read from Play ' +
`Console Help on ${PLAY_VOCABULARY_READ}; check it against the console’s current list.`
);
lines.push('');
for (const [category, types] of Object.entries(PLAY_DATA_TYPES)) {
const rest = types.filter((type) => !declared.some((e) => e.category === category && e.type === type));
if (!rest.length) continue;
const all = rest.length === types.length;
lines.push(`- **${category}:** ${all ? 'none collected' : `${rest.join(', ')}: not collected`}.`);
}
lines.push('');
const onDevice = rows.filter((row) => !row.play.collected);
lines.push('## Held on the device only');
lines.push('');
lines.push('Not collected, because none of it leaves the phone; listed so nobody declares it by mistake.');
lines.push('');
for (const row of onDevice) lines.push(`- **${cell(row.title)}.** ${cell(row.play.because)}`);
lines.push('');
lines.push('## Each answer, and why it is the truthful one'); lines.push('## Each answer, and why it is the truthful one');
lines.push(''); lines.push('');
for (const row of rows) { for (const row of rows) {
const types = row.play.types.map((t) => `${t.category} → ${t.type}`).join('; ');
lines.push(`### ${row.title}`); lines.push(`### ${row.title}`);
lines.push(''); lines.push('');
lines.push(`**${row.play.category} → ${row.play.type}.** ${cell(row.play.answer)}`); lines.push(`**${types || 'No console type'}.** ${cell(row.play.answer)}`);
lines.push(''); lines.push('');
if (row.play.collected) {
lines.push(`Purposes: ${row.play.purposes.join('; ')}.`);
lines.push('');
}
lines.push(cell(row.body)); lines.push(cell(row.body));
lines.push(''); lines.push('');
lines.push(`- **Why that answer:** ${cell(row.play.because)}`); lines.push(`- **Why that answer:** ${cell(row.play.because)}`);
@@ -156,14 +239,49 @@ function render() {
'(D31); the app contains no content directed at children and no age verification.' '(D31); the app contains no content directed at children and no age verification.'
); );
lines.push( lines.push(
'- **Account deletion:** the app creates no account with us — an account belongs to ' + '- **Accounts the app can start:** the sign-in screen links to the deployment’s own ' +
'the deployment the user chose, and is deleted there. The only list we hold is the ' + 'sign-up page, which opens in the browser, and where a game server offers it the app ' +
'beta signup, which is erased on request; `/privacy` section 4 says how to ask.' 'creates a game account on it. Read from: `ui/auth/LoginViewModel.kt`, ' +
'`data/api/PlayerShardApi.kt`.'
); );
lines.push( lines.push(
'- **Data deletion request URL:** the contact address published on `/privacy`, which ' + '- **Can users delete their account? Yes,** in the app (*My account* → *Delete my ' +
'is read from the mounted `brand.json` rather than typed anywhere in the source (D13).' 'account…*) and on the deployment’s website (*Account* → *Delete my account*). It ' +
'takes the password, and a two-factor code where the account has one, and the account ' +
'is deleted at once. The account belongs to the deployment the user chose, so it is ' +
'deleted there; we hold none. Read from: `ui/auth/DeleteAccountScreen.kt`, ' +
'`data/api/MeApi.kt`.'
); );
lines.push(
'- **Delete account URL:** `/delete-account/` on this site (D85). It names the app and ' +
'us, gives the steps in the app and on the website, says what is deleted and what is ' +
'kept and for how long, and tells someone who cannot sign in to ask their community’s ' +
'operator, who can delete any account from the admin panel (D86).'
);
lines.push(
'- **Deleting some data without deleting the account:** the deployment holds it, so ' +
'that is a request to its operator. The one list we hold, the beta signup, is erased ' +
'on request; `/privacy` section 4 says how to ask.'
);
lines.push('');
/* --------------------------------------------------------------------------------- */
const deletion = deletionRow().deletion;
lines.push('## What an account deletion deletes and keeps');
lines.push('');
lines.push(
'The console asks what is deleted and what is kept. These are the same words ' +
'`/delete-account/` renders, from the same row of `src/data/collection.mjs`.'
);
lines.push('');
lines.push('**Deleted at once:**');
lines.push('');
for (const item of deletion.goes) lines.push(`- ${cell(item)}`);
lines.push('');
lines.push('**Kept:**');
lines.push('');
for (const entry of deletion.stays) lines.push(`- ${cell(entry.what)} *${cell(entry.keep)}*`);
lines.push(`- ${cell(deletion.staff)}`);
lines.push(''); lines.push('');
lines.push('## What the website collects, for the same reviewer'); lines.push('## What the website collects, for the same reviewer');

717
scripts/seedDemo.mjs Normal file
View File

@@ -0,0 +1,717 @@
#!/usr/bin/env node
/**
* seedDemo.mjs — the deployment the screenshots are taken of. PLAN.md §13 phase 9, D45.
*
* node scripts/seedDemo.mjs → seed (idempotent; safe to re-run)
* node scripts/seedDemo.mjs --dry-run → say what it would do, write nothing
* node scripts/seedDemo.mjs --game rust → the Rust demo instead (PLAN.md §16 phase 18)
*
* Environment (all optional; the defaults are this machine's review stack):
*
* RG_BASE http://localhost:3000 the website the seed drives
* RG_ADMIN_USER demoadmin (rust: admin) an existing admin, created by website's own
* RG_ADMIN_PASS DemoReview!2026 `npm run seed` — see PLAN.md §13 phase 9
* RG_DEMO_PASS DemoReview!2026 the password every seeded cast member gets
* UOLINK_BASE http://127.0.0.1:8080 sidecar REST, written to Admin → Shard
* UOLINK_WS ws://127.0.0.1:8080/ws sidecar WebSocket
* UOLINK_TOKEN (unset) sidecar auth token; skipped when absent
* UOLINK_PROTOCOL (platform.json) wire protocol to pin — see the note below
* RG_RUST_SERVERS (unset) rust only: a JSON file outside the repository,
* [{ id, name, sidecarBaseUrl, sidecarToken }]
*
* One deployment runs one game module (the core refuses a second at boot), so the Rust demo
* is a second deployment with its own database, and `--game rust` seeds that one. Its cast
* is named for its roles — admin, moderator, player — and so is every stand-in on the game
* server behind it (D80): a name in a screenshot should say what the account is, not
* invent a person.
*
* ---------------------------------------------------------------------------------------
* WHY THE SEED DRIVES THE API AND NEVER THE DATABASE
* ---------------------------------------------------------------------------------------
* Every row this creates could have been an INSERT, and every INSERT would have been a
* second implementation of a rule the website already owns: how a body is sanitized, what
* a slug may contain, which excerpt is derived when none is given, how a password is
* hashed. A seed that writes SQL directly produces a database the product could not have
* produced, and screenshots of that database show a product that does not exist.
*
* So this speaks HTTP to a running site, as an admin, through the same endpoints the admin
* panel calls. The cost is that the site has to be up; the benefit is that the content is
* real, and that this script keeps working when a column moves.
*
* ---------------------------------------------------------------------------------------
* WHY IT IS IDEMPOTENT RATHER THAN DESTRUCTIVE
* ---------------------------------------------------------------------------------------
* Re-running must not double the news list, and must not erase a screenshot rig somebody
* has been adjusting by hand. Every step therefore looks before it writes and reports
* `= exists` rather than failing. That also makes the script usable as a repair: point it
* at a stack that has drifted and it puts back only what is missing.
*
* What it deliberately does NOT create: anything the shard owns. Teams arrive from the
* guild board over the bridge, the marketplace from player vendors, the atlas from real
* spawners (PLAN.md §13 phase 9, D42). Seeding those would be inventing game state that
* the product is supposed to be showing, which is exactly what D4 forbids.
*/
import { readFileSync } from 'node:fs';
import platform from '../src/data/platform.json' with { type: 'json' };
const gameAt = process.argv.indexOf('--game');
const GAME = gameAt === -1 ? 'uo' : process.argv[gameAt + 1];
if (!['uo', 'rust'].includes(GAME)) {
console.error(`--game is uo or rust, not "${GAME}"`);
process.exit(1);
}
const BASE = (process.env.RG_BASE || 'http://localhost:3000').replace(/\/+$/, '');
const API = `${BASE}/api/v1`;
const ADMIN_USER = process.env.RG_ADMIN_USER || (GAME === 'rust' ? 'admin' : 'demoadmin');
const ADMIN_PASS = process.env.RG_ADMIN_PASS || 'DemoReview!2026';
const DEMO_PASS = process.env.RG_DEMO_PASS || 'DemoReview!2026';
const UOLINK_BASE = process.env.UOLINK_BASE || 'http://127.0.0.1:8080';
const UOLINK_WS = process.env.UOLINK_WS || 'ws://127.0.0.1:8080/ws';
const UOLINK_TOKEN = process.env.UOLINK_TOKEN || '';
// The pinned wire protocol, read from `platform.json` rather than written down here.
//
// It was a literal `4` until the Asset Bridge cutover, with a note explaining that
// `module-uo` pinned 3 on a fresh install while the sidecar spoke 4, so a new deployment
// read nothing from its shard until somebody edited the number in Admin → Shard. That debt
// has since been paid: the module's schema fragment defaults the column to the protocol its
// build speaks and carries a one-shot migration per bump, so both a fresh install and an
// upgraded one land on the right number by themselves.
//
// What remains is the rig's own reason to state it: this seed points a demo deployment at a
// sidecar, and if it pins the wrong number every REST call comes back `409`. Reading it from
// `platform.json` means the number is the one `checkFacts.mjs` verified against `link`'s
// `main` — so the rig cannot quietly drift two protocols behind the platform again, which is
// exactly what the literal did.
const UOLINK_PROTOCOL = Number(process.env.UOLINK_PROTOCOL || platform.games.uo.protocol);
const DRY = process.argv.includes('--dry-run');
// ── The demo deployment's identity (D43) ───────────────────────────────────────────────
//
// A neutral demo brand rather than UOMysticmoon: the screenshots show the platform, not a
// private shard, and §15's demo VM can wear the same identity so the imagery stays true the
// day it exists. The name is deliberately "… Demo" rather than an invented community —
// nobody should have to wonder whether they are looking at a real server they could join.
// The published contact address lives in exactly one file in this repository (D13), and
// `checkFacts.mjs` fails the build if a literal address appears anywhere else — including
// here. So the demo wears the same address the site publishes, read from the same place.
const brandDefault = JSON.parse(
readFileSync(new URL('../brand-default/brand.json', import.meta.url), 'utf8'),
);
const SETTINGS = {
site_title: 'Runic Gateway Demo',
site_mode: 'live',
status_message: 'Live — the demo shard is up.',
homepage_teaser:
'A public demonstration of Runic Gateway: a self-hosted community site wired to a ' +
'live game server. Everything on this site is real data from the shard behind it.',
contact_email: brandDefault.contactEmail,
};
// ── The cast ───────────────────────────────────────────────────────────────────────────
//
// Five accounts, one per role the admin screens distinguish, so a screenshot of the users
// table shows the role column doing something. Names are ordinary fantasy given names and
// belong to nobody.
const USERS = [
{ username: 'aldricmoss', role: 'moderator' },
{ username: 'brannwen', role: 'editor' },
{ username: 'sablequill', role: 'player' },
{ username: 'tobinreed', role: 'player' },
{ username: 'mirenavox', role: 'player' },
];
// ── The Rust demo (PLAN.md §16 phase 18) ───────────────────────────────────────────────
//
// The same neutral identity (D43) over a Rust server. The cast is two accounts besides the
// admin, each named for its role (D80). Teams, the feed, the leaderboard and the map are the
// game's, as they are for UO; what the site owns here is the server row, the NPC profiles
// and a permission group, so those are seeded.
const RUST_SETTINGS = {
...SETTINGS,
status_message: 'Live — the demo server is up.',
homepage_teaser:
'A public demonstration of Runic Gateway: a self-hosted community site wired to a ' +
'live Rust server. Everything on this site is real data from the server behind it.',
};
const RUST_USERS = [
{ username: 'moderator', role: 'moderator' },
{ username: 'player', role: 'player' },
];
// Two RunicNPC profiles: a guard who fights, and a passive trader who talks. Their kit is
// one the rig's Kits holds; a profile naming a kit Kits does not have is refused by RunicNPC
// and shows as refused on the screen, which is not the screen being shown. The guard's name
// sorts first, so the editor the NPC screenshot opens (its first Edit) is the fuller one.
const NPC_KIT = process.env.RG_NPC_KIT || 'rnpc_campguard';
const NPC_PROFILES = [
{
name: 'guard',
allServers: true,
body: {
names: ['Camp Guard', 'Gate Guard'],
kits: [NPC_KIT],
prefab: 'scientistnpc_roam',
role: 'guard',
movement: { mode: 'wander', radius: 12 },
health: 300,
faction: 'wardens',
alertRadius: 30,
},
},
{
name: 'trader',
allServers: true,
body: {
names: ['Quartermaster'],
kits: [NPC_KIT],
prefab: 'scientistnpc_roam',
role: 'passive',
movement: { mode: 'stand', radius: 0 },
hurtByPlayers: false,
use: { mode: 'chat', lines: ['Back again, {player}?', 'Supplies are short this wipe.'], title: '', text: '' },
},
},
];
// One group the site authored, so the permissions screen shows the site doing something
// beside what the server already had. Its permissions are ones the rig's plugins register,
// and its member is the `player` account, through its linked Steam id.
const PERM_GROUP = {
name: 'vip',
title: 'VIP',
rank: 10,
permissions: ['backpacks.use', 'autodoors.use'],
members: ['player'],
};
// Who sees who is online, and the map's player layer. Both default to staff, which is right
// for a real community and wrong for a demo whose public pages exist to be looked at: with
// them at staff, the public feed leaves out every death and the map shows no one. The only
// players on the demo server are the stand-ins phase 18 staged, named by role (D80), so
// opening them to the public shows nobody's real name.
const RUST_VISIBILITY = { fleet: 'public', map: { fleet: { players: 'public' } } };
// A weekly wipe, so the server list has a next wipe to show: Thursday evening, the usual
// Rust community habit.
const RUST_WIPE = { wipeRule: 'weekly', wipeDay: 4, wipeTime: '19:00', wipeTz: 'America/Chicago' };
const siteSettings = GAME === 'rust' ? RUST_SETTINGS : SETTINGS;
const cast = GAME === 'rust' ? RUST_USERS : USERS;
// ── News, five-on-friday, the newsletter ───────────────────────────────────────────────
//
// Written as a small community's real output rather than lorem: a patch note, an event, a
// maintenance notice and a Friday post. Bodies are short HTML because that is what the
// editor stores, and the list screens show the excerpt anyway.
const POSTS = [
{
category: 'news',
title: 'Autumn patch: vendor search, and a fix for house decay',
excerpt:
'Player-vendor listings are now searchable from the site, and the decay timer no ' +
'longer resets when a co-owner logs in.',
body:
'<p>The autumn patch is live. The headline change is that <strong>every player ' +
'vendor on the shard is now searchable from this site</strong> — the marketplace ' +
'page reads the same live feed the game does, so a listing appears within a minute ' +
'of being priced.</p><p>We also fixed the house decay timer resetting when a ' +
'co-owner logged in. That bug had been quietly keeping condemned houses alive since ' +
'spring.</p><p>Full notes are on the wiki.</p>',
published: true,
},
{
category: 'news',
title: 'The Harvest Moon festival opens this weekend',
excerpt:
'Three days of gatherings at the crossroads, with a champion spawn on the last ' +
'night. Everyone is welcome, no signup needed.',
body:
'<p>The Harvest Moon festival runs from Friday evening to Sunday night at the ' +
'crossroads north of town. There is no signup and no entry fee — turn up.</p>' +
'<p>Saturday is the market day; bring anything you want to sell and we will set out ' +
'extra vendor stalls. Sunday night closes with a champion spawn, which will be ' +
'announced in game and on the shard status page here.</p>',
published: true,
},
{
category: 'news',
title: 'Scheduled maintenance, Tuesday 03:00 UTC',
excerpt:
'About twenty minutes of downtime for a server restart and a world save. The site ' +
'stays up throughout.',
body:
'<p>We are restarting the shard on Tuesday at 03:00 UTC for a world save and a ' +
'server update. Expect about twenty minutes of downtime.</p><p>This site stays up ' +
'while the shard is down — the status panel will simply show the shard as offline, ' +
'and the marketplace and atlas will show their last known state.</p>',
published: true,
},
{
category: 'five-on-friday',
title: 'Five on Friday: the ones who keep the roads clear',
excerpt:
'Five players who spent the week doing unglamorous work, and what they were up to.',
body:
'<p>Five people who made the week better for everybody else:</p><ol><li>Sable, for ' +
'restocking the free reagent stall three times without being asked.</li><li>Tobin, ' +
'for guiding two new players through their first dungeon.</li><li>Mirena, for the ' +
'map corrections on the wiki.</li><li>Brannwen, for writing up the champion ' +
'rotation.</li><li>Aldric, for handling a difficult report quietly and well.</li>' +
'</ol>',
published: true,
},
{
category: 'newsletter',
title: 'Monthly notes — what changed, and what is next',
excerpt:
'A month of changes in one place: the vendor search, the new guides, and what we ' +
'are working on next.',
body:
'<p>A quiet, productive month. The vendor search shipped, the wiki gained four ' +
'guides, and the guild boards now update on the site within a minute of a change in ' +
'game.</p><p>Next month we are looking at the champion boards and at making the ' +
'atlas easier to read on a phone.</p>',
published: true,
},
];
// ── The wiki ───────────────────────────────────────────────────────────────────────────
//
// One category and four pages, because the wiki index screenshot needs a category with
// enough in it to look like a wiki rather than a placeholder.
const WIKI_CATEGORY = {
slug: 'guides',
title: 'Guides',
description: 'How things work here, written by the people who play here.',
};
const WIKI_PAGES = [
{
slug: 'getting-started',
title: 'Getting started',
excerpt: 'What to install, how to connect, and the first hour.',
body:
'<h2>Before you connect</h2><p>You need a game client and an account. Make the ' +
'account on this site — the shard accepts accounts created here, and it saves you ' +
'typing your password into a chat window.</p><h2>The first hour</h2><p>Start in ' +
'town, take the newcomer quest, and do not sell your starting tools. If you get ' +
'stuck, ask in Discord: somebody is usually around.</p>',
},
{
slug: 'player-vendors',
title: 'Player vendors',
excerpt: 'How to hire one, how to price, and how the site search finds you.',
body:
'<h2>Hiring a vendor</h2><p>Any house you own or co-own can hold vendors. Hire one ' +
'from an innkeeper and place it inside.</p><h2>Being findable</h2><p>Everything a ' +
'vendor holds is published to the marketplace on this site within about a minute, ' +
'including the price and the house it stands in. If a listing looks stale, the ' +
'shard was probably down when you priced it — it will correct itself on the next ' +
'sweep.</p>',
},
{
slug: 'housing-and-decay',
title: 'Housing and decay',
excerpt: 'Placement rules, the decay timer, and what IDOC actually means here.',
body:
'<h2>Placement</h2><p>Houses can be placed anywhere the client allows, with the ' +
'usual clearance rules. There is no lottery.</p><h2>Decay</h2><p>A house decays if ' +
'nobody with access logs in for long enough. The site lists houses approaching ' +
'collapse on the housing page, which is the same data the game uses — not a ' +
'prediction.</p>',
},
{
slug: 'community-rules',
title: 'Community rules',
excerpt: 'The short version: do not be the reason somebody stops playing.',
body:
'<h2>The rules</h2><ol><li>No harassment, in game or on the site.</li><li>No ' +
'exploiting bugs — report them instead, and you will usually be thanked in ' +
'public.</li><li>One account per person for events with prizes.</li></ol>' +
'<h2>Appeals</h2><p>Every moderation action can be appealed from your account page. ' +
'An appeal is read by somebody who was not involved in the original action.</p>',
},
];
// ── HTTP plumbing ──────────────────────────────────────────────────────────────────────
//
// One cookie jar, because the session is a cookie and `fetch` has no jar of its own. Only
// the value of the auth cookie matters, so this keeps exactly that.
let cookie = '';
let created = 0;
let existed = 0;
function keepCookies(res) {
const raw = res.headers.getSetCookie?.() ?? [];
for (const line of raw) {
const [pair] = line.split(';');
if (pair.trim()) cookie = pair.trim();
}
}
async function call(method, path, body) {
const res = await fetch(`${API}${path}`, {
method,
headers: {
'Content-Type': 'application/json',
...(cookie ? { Cookie: cookie } : {}),
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
keepCookies(res);
const text = await res.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = text;
}
return { ok: res.ok, status: res.status, data };
}
function say(mark, what) {
console.log(` ${mark} ${what}`);
if (mark === '+') created += 1;
if (mark === '=') existed += 1;
}
function fail(what, res) {
console.error(`\n ! ${what} failed — HTTP ${res.status}`);
console.error(` ${JSON.stringify(res.data)?.slice(0, 400)}`);
process.exitCode = 1;
}
// ── The steps ──────────────────────────────────────────────────────────────────────────
async function login() {
const res = await call('POST', '/auth/login', { username: ADMIN_USER, password: ADMIN_PASS });
if (!res.ok) {
console.error(
`\nCould not log in as "${ADMIN_USER}". Create the admin first, from the website repo:\n` +
` cd website/server && DB_NAME=<demo db> ADMIN_USERNAME=${ADMIN_USER} ` +
`ADMIN_PASSWORD='…' node db/seed.js\n`,
);
fail('login', res);
process.exit(1);
}
console.log(`\nsigned in as ${ADMIN_USER} at ${BASE}`);
}
async function settings() {
console.log('\nsite settings (D43 — the neutral demo identity)');
if (DRY) {
for (const [k, v] of Object.entries(siteSettings)) say('~', `${k} = ${v}`);
return;
}
const res = await call('PUT', '/admin/settings', siteSettings);
if (!res.ok) return fail('settings', res);
for (const [k, v] of Object.entries(siteSettings)) say('+', `${k} = ${String(v).slice(0, 60)}`);
}
async function uoLink() {
console.log('\nshard connection (Admin → Shard)');
if (!UOLINK_TOKEN) {
say('~', 'UOLINK_TOKEN unset — leaving the sidecar config alone');
return;
}
const now = await call('GET', '/admin/uo-link/config');
if (now.status === 404) {
say('~', 'no /admin/uo-link route — the uo module is not installed');
return;
}
if (
now.ok &&
now.data?.config?.baseUrl === UOLINK_BASE &&
now.data?.config?.protocol === UOLINK_PROTOCOL &&
now.data?.config?.enabled
) {
say('=', `already pointed at ${UOLINK_BASE} (protocol ${UOLINK_PROTOCOL})`);
return;
}
if (DRY) return say('~', `would point the site at ${UOLINK_BASE}`);
const res = await call('PUT', '/admin/uo-link/config', {
baseUrl: UOLINK_BASE,
wsUrl: UOLINK_WS,
token: UOLINK_TOKEN,
protocol: UOLINK_PROTOCOL,
enabled: true,
});
if (!res.ok) return fail('uo-link config', res);
say('+', `pointed at ${UOLINK_BASE} (protocol ${UOLINK_PROTOCOL})`);
}
async function users() {
console.log('\naccounts');
const list = await call('GET', '/admin/users');
if (!list.ok) return fail('list users', list);
const rows = Array.isArray(list.data) ? list.data : (list.data?.users ?? []);
const have = new Set(rows.map((u) => u.username));
for (const user of cast) {
if (have.has(user.username)) {
say('=', `${user.username} (${user.role})`);
continue;
}
if (DRY) {
say('~', `${user.username} (${user.role})`);
continue;
}
const res = await call('POST', '/admin/users', {
username: user.username,
password: DEMO_PASS,
role: user.role,
});
if (!res.ok) {
fail(`create ${user.username}`, res);
continue;
}
say('+', `${user.username} (${user.role})`);
}
}
async function posts() {
console.log('\nposts');
const list = await call('GET', '/admin/posts');
if (!list.ok) return fail('list posts', list);
const rows = Array.isArray(list.data) ? list.data : (list.data?.posts ?? []);
const have = new Set(rows.map((p) => p.title));
for (const post of POSTS) {
if (have.has(post.title)) {
say('=', `${post.category}: ${post.title}`);
continue;
}
if (DRY) {
say('~', `${post.category}: ${post.title}`);
continue;
}
const res = await call('POST', '/admin/posts', post);
if (!res.ok) {
fail(`create post "${post.title}"`, res);
continue;
}
say('+', `${post.category}: ${post.title}`);
}
}
async function wiki() {
console.log('\nwiki');
const cats = await call('GET', '/admin/wiki/categories');
if (!cats.ok) return fail('list wiki categories', cats);
const catRows = Array.isArray(cats.data) ? cats.data : (cats.data?.categories ?? []);
let category = catRows.find((c) => c.slug === WIKI_CATEGORY.slug);
if (category) {
say('=', `category ${WIKI_CATEGORY.slug}`);
} else if (DRY) {
say('~', `category ${WIKI_CATEGORY.slug}`);
} else {
const res = await call('POST', '/admin/wiki/categories', WIKI_CATEGORY);
if (!res.ok) return fail('create wiki category', res);
category = res.data?.category ?? res.data;
say('+', `category ${WIKI_CATEGORY.slug}`);
}
const pages = await call('GET', '/admin/wiki');
if (!pages.ok) return fail('list wiki pages', pages);
const pageRows = Array.isArray(pages.data) ? pages.data : (pages.data?.pages ?? []);
const have = new Set(pageRows.map((p) => p.slug));
for (const page of WIKI_PAGES) {
if (have.has(page.slug)) {
say('=', `page ${page.slug}`);
continue;
}
if (DRY) {
say('~', `page ${page.slug}`);
continue;
}
const res = await call('POST', '/admin/wiki', {
...page,
category_id: category?.id ?? null,
published: true,
});
if (!res.ok) {
fail(`create wiki page "${page.slug}"`, res);
continue;
}
say('+', `page ${page.slug}`);
}
}
// ── The Rust steps ─────────────────────────────────────────────────────────────────────
/** The servers file, read once. The tokens in it are sent to the site and never printed. */
function rustServerList() {
const file = process.env.RG_RUST_SERVERS;
if (!file) return [];
return JSON.parse(readFileSync(file, 'utf8'));
}
async function rustServers() {
console.log('\nRust servers (Admin → Rust → Servers)');
const wanted = rustServerList();
if (wanted.length === 0) {
say('~', 'RG_RUST_SERVERS unset — leaving the server list alone');
return;
}
const now = await call('GET', '/admin/rust/servers');
if (now.status === 404) {
say('~', 'no /admin/rust route — the rust module is not installed');
return;
}
if (!now.ok) return fail('list servers', now);
for (const server of wanted) {
const have = (now.data?.servers ?? []).find((row) => row.id === server.id);
if (
have &&
have.sidecarBaseUrl === server.sidecarBaseUrl &&
have.enabled &&
have.schedule?.rule === RUST_WIPE.wipeRule
) {
say('=', `${server.id} at ${server.sidecarBaseUrl}`);
continue;
}
if (DRY) {
say('~', `${server.id} at ${server.sidecarBaseUrl}`);
continue;
}
const res = await call('PUT', `/admin/rust/servers/${encodeURIComponent(server.id)}`, {
name: server.name,
sidecarBaseUrl: server.sidecarBaseUrl,
// Omitted on an existing row, which is how the API says "keep the token" — so a re-run
// never needs the token file to hold a token it already sent.
sidecarToken: have ? undefined : server.sidecarToken,
enabled: true,
...RUST_WIPE,
});
if (!res.ok) {
fail(`save server ${server.id}`, res);
continue;
}
say('+', `${server.id} "${server.name}" at ${server.sidecarBaseUrl}`);
}
}
async function rustVisibility() {
console.log('\nvisibility (Admin → Rust → Visibility)');
const now = await call('GET', '/admin/rust/visibility');
if (!now.ok) return fail('read visibility', now);
if (now.data?.presence?.fleet === 'public' && now.data?.map?.fleet?.players === 'public') {
say('=', 'who is online and the map’s players are public');
return;
}
if (DRY) return say('~', 'would make who is online and the map’s players public');
const res = await call('PUT', '/admin/rust/visibility', RUST_VISIBILITY);
if (!res.ok) return fail('visibility', res);
say('+', 'who is online and the map’s players are public');
}
async function npcProfiles() {
console.log('\nRunicNPC profiles (Admin → Rust → NPCs)');
const now = await call('GET', '/admin/rust/npcs');
if (!now.ok) return fail('list NPC profiles', now);
const live = (now.data?.profiles ?? []).filter((profile) => !profile.replaced);
for (const profile of NPC_PROFILES) {
const have = live.find((row) => row.name === profile.name);
if (DRY) {
say('~', `profile ${profile.name}`);
continue;
}
// An existing profile is saved again rather than skipped, so a change to its body here
// reaches the demo on the next run. RunicNPC answers the push the same either way.
const res = have
? await call('PUT', `/admin/rust/npcs/profiles/${have.id}`, profile)
: await call('POST', '/admin/rust/npcs/profiles', profile);
if (!res.ok) {
fail(`${have ? 'save' : 'create'} profile ${profile.name}`, res);
continue;
}
say(have ? '=' : '+', `profile ${profile.name}${have ? ' (saved again)' : ''}`);
}
}
async function permGroup() {
console.log('\npermission group (Admin → Rust → Permissions)');
for (const server of rustServerList()) {
const base = `/admin/rust/permissions/servers/${encodeURIComponent(server.id)}`;
const now = await call('GET', base);
if (!now.ok) {
fail(`read permissions on ${server.id}`, now);
continue;
}
if (DRY) {
say('~', `${server.id}: group ${PERM_GROUP.name}`);
continue;
}
let id = (now.data?.groups ?? []).find((group) => group.name === PERM_GROUP.name)?.id;
if (id) {
say('=', `${server.id}: group ${PERM_GROUP.name}`);
} else {
const made = await call('POST', `${base}/groups`, {
name: PERM_GROUP.name,
title: PERM_GROUP.title,
rank: PERM_GROUP.rank,
});
if (!made.ok) {
fail(`create group ${PERM_GROUP.name} on ${server.id}`, made);
continue;
}
id = made.data.id;
say('+', `${server.id}: group ${PERM_GROUP.name}`);
}
// The permission set is replaced whole, so saving it again is the update.
const perms = await call('PUT', `/admin/rust/permissions/groups/${id}/permissions`, {
permissions: PERM_GROUP.permissions,
});
if (!perms.ok) fail(`grant ${PERM_GROUP.name}'s permissions`, perms);
else say('=', ` permissions ${PERM_GROUP.permissions.join(', ')}`);
for (const username of PERM_GROUP.members) {
const added = await call('POST', `/admin/rust/permissions/groups/${id}/members`, { username });
if (added.ok) say('=', ` member ${username}`);
else if (added.status === 409) say('=', ` member ${username} (already)`);
else fail(`add ${username} to ${PERM_GROUP.name}`, added);
}
}
}
// ── main ───────────────────────────────────────────────────────────────────────────────
console.log(
`${DRY ? '\nseedDemo — DRY RUN, nothing will be written' : '\nseedDemo'} (${GAME})`,
);
await login();
await settings();
await users();
// The news and the wiki are written in one game's voice (a patch note about house decay, a
// champion spawn), so they belong to the UO demo. The Rust captures show none of them.
if (GAME === 'rust') {
await rustServers();
await rustVisibility();
await npcProfiles();
await permGroup();
} else {
await uoLink();
await posts();
await wiki();
}
console.log(
`\n${DRY ? 'would create' : 'created'} ${created}, already present ${existed}` +
(process.exitCode ? ' — with failures above' : ''),
);
console.log(
GAME === 'rust'
? '\nWhat this does NOT seed, on purpose: the feed, the leaderboard, who is online, clans\n' +
'and the map. Those arrive from the server over the bridge — start the server, and\n' +
'they populate themselves. PLAN.md §16 phase 18 says how its players were staged.\n'
: '\nWhat this does NOT seed, on purpose: teams, the marketplace, houses, points boards\n' +
'and the atlas. Those arrive from the shard over the bridge (D42) — start the sidecar\n' +
'and the shard, and they populate themselves.\n',
);

204
scripts/serve.mjs Normal file
View File

@@ -0,0 +1,204 @@
#!/usr/bin/env node
/**
* serve.mjs — the production entry point (PLAN.md §6, D48, D56).
*
* `npm start` runs `applyBrand.mjs` and then this, instead of `dist/server/entry.mjs`
* directly. It is a thin wrapper around the adapter's own handler and exists for three
* reasons, two of them things `@astrojs/node` gets wrong.
*
* ---------------------------------------------------------------------------------------
* 1. THE ADAPTER SERVES THE WRONG PAGE'S CONTENT-SECURITY-POLICY
* ---------------------------------------------------------------------------------------
* `@astrojs/node`'s `staticHeaders` writes one policy per prerendered route into
* `dist/_headers.json` and looks the right one up per request. The lookup, in
* `dist/serve-static.js`, is:
*
* headersMap.find((header) => header.pathname.includes(baselessPathname))
*
* `String.includes` — a SUBSTRING test, not equality, taking the first match. So:
*
* - `/modules/` matches the record for `/docs/modules/building-a-module`,
* - `/architecture/` matches `/docs/architecture/...`,
* - and `/`, which is a substring of every path in the file, matches whichever record
* happens to be first — here `/404`.
*
* Every prerendered page was therefore served some other page's policy. Because the
* policies are per-page hash lists, that is not a cosmetic mismatch: the browser refused
* the page's own stylesheet. `/modules/` and `/architecture/` rendered unstyled sections
* with `Refused to apply inline style` in a console, and the homepage only looked fine
* because it happens to share a hash with the 404 page.
*
* Astro's static-header machinery is otherwise exactly what §6 wants, so this replaces the
* lookup rather than the mechanism: the same `_headers.json`, matched by pathname
* EQUALITY. The workaround is deliberately small and obvious so it can be deleted whole
* when the upstream `find` is fixed — the check for that is whether `/modules/` and
* `/docs/modules/building-a-module` are served different policies.
*
* ---------------------------------------------------------------------------------------
* 2. THE HEADERS THAT ARE NOT CSP
* ---------------------------------------------------------------------------------------
* A few security headers have nothing to do with Astro and no other place to live. They
* are set here rather than written into an operator's reverse-proxy configuration (D48,
* again): the container should be correct on its own, and a proxy someone else configures
* is a promise this repository cannot check.
*
* The two routes that render per request — `/beta` and `/brand/*` — have no entry in
* `_headers.json`, because nothing prerendered them. They get `frame-ancestors 'none'` on
* its own, which is the one directive a `<meta>` CSP cannot express and therefore the one
* thing Astro's per-page meta tag leaves them missing.
*/
import fs from 'node:fs';
import http from 'node:http';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
const root = path.join(here, '..');
const port = Number(process.env.PORT ?? 4321);
const host = process.env.HOST ?? '0.0.0.0';
/* ---------------------------------------------------------------------------------------
The policies, matched exactly
--------------------------------------------------------------------------------------- */
const normalise = (pathname) => {
const clean = pathname.split('?')[0].split('#')[0];
const trimmed = clean.replace(/\/+$/, '');
return trimmed === '' ? '/' : trimmed;
};
const policies = new Map();
const headersFile = path.join(root, 'dist', '_headers.json');
if (fs.existsSync(headersFile)) {
for (const record of Object.values(JSON.parse(fs.readFileSync(headersFile, 'utf8')))) {
const csp = record.headers?.find((h) => h.key.toLowerCase() === 'content-security-policy');
if (csp) policies.set(normalise(record.pathname), csp.value);
}
} else {
// Not fatal: the site still serves, with the per-page <meta> policy Astro also emits.
// Loud, because a deployment silently losing its response-header CSP is exactly what §6
// is trying to prevent.
console.error(
'[serve] dist/_headers.json is missing — pages will be served without a CSP response\n' +
' header. Check that astro.config.mjs still sets `staticHeaders: true`.'
);
}
const FRAME_ONLY = "frame-ancestors 'none'";
/**
* Headers with no page-by-page component. Each is the browser default made explicit, and
* each closes something the CSP does not:
*
* - `X-Content-Type-Options` stops a browser guessing that a .txt is HTML.
* - `Referrer-Policy` keeps the path of the page a reader came from out of requests to
* other origins — there are none today (D9), and this is what keeps that true if a
* link is ever followed off-site.
* - `X-Frame-Options` says again, for anything too old to honour `frame-ancestors`.
* - `Permissions-Policy` turns off hardware this site has no reason to ask for. A
* marketing page requesting a camera should be impossible, not merely unlikely.
*/
const STATIC_HEADERS = {
'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'strict-origin-when-cross-origin',
'X-Frame-Options': 'DENY',
'Permissions-Policy': 'camera=(), microphone=(), geolocation=(), payment=(), usb=()',
};
/* ---------------------------------------------------------------------------------------
The server
--------------------------------------------------------------------------------------- */
// The adapter's entry starts its own listener on import unless this is set.
process.env.ASTRO_NODE_AUTOSTART = 'disabled';
// `pathToFileURL`, not the bare path: on Windows an absolute path starts with a drive
// letter, and Node's ESM loader reads `c:` as an unsupported URL scheme.
const { handler } = await import(pathToFileURL(path.join(root, 'dist', 'server', 'entry.mjs')).href);
/* ---------------------------------------------------------------------------------------
3. THE FORWARDED HEADERS THE ADAPTER DOES NOT READ
--------------------------------------------------------------------------------------- */
/**
* Make the request look, to the adapter, like what the browser actually sent.
*
* `@astrojs/node` builds the URL of every request from the connection and the `Host`
* header alone — `astro/app/node`'s `createRequestFromNodeRequest`:
*
* const isEncrypted = "encrypted" in req.socket && req.socket.encrypted;
* const protocol = isEncrypted ? "https" : "http";
*
* `x-forwarded-proto` is never consulted on this path. (`security.allowedDomains` does not
* help: on this code path it gates only whether `Astro.clientAddress` may come from
* `x-forwarded-for`.)
*
* Behind a proxy that terminates TLS — which is how this site is deployed, and the only
* way it is deployed — that is fatal to the one route that accepts a POST. The browser
* sends `Origin: https://runicgateway.com`; the container computes `http://runicgateway.com`
* because its own socket is plaintext; and Astro's CSRF middleware compares the two for
* EQUALITY:
*
* const isSameOrigin = request.headers.get("origin") === url.origin;
*
* So every beta signup, from every visitor, is answered `403 Cross-site POST form
* submissions are forbidden`. No proxy configuration can fix it — a proxy cannot make this
* container's socket encrypted — and nothing else on the site changes, so the symptom is a
* form that silently refuses everyone while fifty pages look perfectly healthy.
*
* Both headers are trusted unconditionally, with no flag to set. The image is meant to be
* deployed and work: it publishes on loopback for a proxy to reach, and an operator who has
* to discover a `TRUST_PROXY` variable to make the signup work is an operator who ships a
* dead form. Trusting them costs nothing here — a cross-site form submission cannot make a
* victim's browser send `x-forwarded-proto`, so the CSRF check is exactly as strong as it
* was, and the site has no cookie, session or credential to protect in the first place.
*
* `x-forwarded-host` is handled for the same reason at one remove: most proxies pass `Host`
* through untouched, but some rewrite it to the upstream address and put the real name here
* instead, which produces the identical mismatch.
*/
const firstForwarded = (value) => value?.toString().split(',')[0].trim();
const applyForwardedHeaders = (req) => {
const proto = firstForwarded(req.headers['x-forwarded-proto']);
if (proto === 'https' && !req.socket.encrypted) {
// What `"encrypted" in req.socket` reads. Defined on the socket rather than passed
// along, because the adapter is given the raw request and looks there itself.
Object.defineProperty(req.socket, 'encrypted', { value: true, configurable: true });
}
const forwardedHost = firstForwarded(req.headers['x-forwarded-host']);
if (forwardedHost && !/[/\\]/.test(forwardedHost)) {
req.headers.host = forwardedHost;
}
};
const server = http.createServer((req, res) => {
applyForwardedHeaders(req);
const policy = policies.get(normalise(req.url ?? '/'));
for (const [key, value] of Object.entries(STATIC_HEADERS)) res.setHeader(key, value);
res.setHeader('Content-Security-Policy', policy ?? FRAME_ONLY);
/**
* The adapter will set its own (wrong) `Content-Security-Policy` from inside the static
* handler, overwriting what was just set. Rather than race it, every later attempt to
* set that one header is ignored — the correct value is already on the response, and
* this request's policy cannot change halfway through serving it.
*/
const setHeader = res.setHeader.bind(res);
res.setHeader = (name, value) => {
if (String(name).toLowerCase() === 'content-security-policy') return res;
return setHeader(name, value);
};
handler(req, res);
});
server.listen(port, host, () => {
console.log(`[serve] listening on http://${host}:${port} — ${policies.size} prerendered policies`);
});

View File

@@ -0,0 +1,53 @@
---
/**
* Overrides Starlight's `PageTitle` for one attribute: `tabindex="-1"` on the heading.
*
* Phase 10 found and fixed this on the marketing chrome — following a skip link moves the
* viewport but not the keyboard focus, because the target of the link is not focusable.
* Chrome papers over it; not every browser does, and a reader who lands past the header
* only to find Tab returning them to the top of the nav has not been skipped anywhere.
* `Base.astro`'s `<main>` gained `tabindex="-1"` then.
*
* Phase 11's browser walk found the same defect still standing on the other forty pages.
* Starlight's skip link points at the page's `<h1>` rather than at a landmark, and an
* `<h1>` is no more focusable than a `<main>`, so the docs half of the site had the fix
* that the marketing half had.
*
* The rest of this file is Starlight's own implementation, copied because the override
* mechanism replaces a component rather than decorating it. That is a small drift risk —
* if Starlight restyles its `h1`, this copy will not follow — so it is deliberately kept
* to exactly what upstream has, with nothing of ours added beyond the attribute. The
* check for drift is visual: a documentation title that stops matching the marketing
* chrome's.
*
* The id is Starlight's `PAGE_TITLE_ID`, written out rather than imported: `./constants`
* is not one of the subpaths the package exports, so importing it reaches past the
* package's own boundary. It is what `SkipLink.astro` puts in its `href`, so the two must
* agree; if a Starlight upgrade ever renames it, the skip link stops resolving at all and
* the first Tab on a documentation page lands somewhere obviously wrong.
*/
const PAGE_TITLE_ID = '_top';
---
<h1 id={PAGE_TITLE_ID} tabindex="-1">{Astro.locals.starlightRoute.entry.data.title}</h1>
<style>
@layer starlight.core {
h1 {
margin-top: 1rem;
font-size: var(--sl-text-h1);
line-height: var(--sl-line-height-headings);
font-weight: 600;
color: var(--sl-color-white);
}
/* Ours, and the only line that is: the heading is focusable now, so it can be
focused, and a focus ring drawn around a page title reads as an error rather than
as a destination. Removing it is safe only because this element is reachable by
exactly one route — the skip link, which the reader took deliberately. It is never
in the tab sequence. */
h1:focus {
outline: none;
}
}
</style>

View File

@@ -1,6 +1,7 @@
--- ---
import { brand } from '../lib/brand.mjs'; import { renderBrand } 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';
/** /**
@@ -12,36 +13,18 @@ import platform from '../data/platform.json';
* in the legal bar at the foot rather than in the columns: a legal link is not a thing a * in the legal bar at the foot rather than in the columns: a legal link is not a thing a
* reader browses to alongside Features, it is a thing they go looking for, and the line * 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.
* `/delete-account/` joined them for the same reason (D85): it is the address Google Play
* is given, and a player looking for it looks where the privacy link is.
*/ */
// `/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();
const columns = [ // The columns live in src/data/footer.mjs so a test can read them — see the note there,
{ // and test/footer.test.mjs. The two Project links come from the mounted brand (§7).
heading: 'Product', const columns = footerColumns(brand);
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');
--- ---
@@ -78,11 +61,13 @@ const isExternal = (href: string) => href.startsWith('http');
<span class="site-footer__links"> <span class="site-footer__links">
<a href="/privacy/">Privacy</a> <a href="/privacy/">Privacy</a>
<a href="/terms/">Terms</a> <a href="/terms/">Terms</a>
<a href="/delete-account/">Delete account</a>
</span> </span>
</p> </p>
<p class="site-footer__meta"> <p class="site-footer__meta">
<span class="chip chip--version">Protocol {platform.protocol}</span> {/* D66, as on the hero: the shared version only. Each game's protocol and bundle
<span class="chip chip--version">Bundle {platform.bundle.tag}</span> are on that game's pages. */}
<span class="chip chip--version">Module API {platform.moduleApi}</span>
</p> </p>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,37 @@
---
import platform from '../data/platform.json';
/**
* A worked example that belongs to one game, quoted on a core page (PLAN.md §16, D68).
*
* D63 keeps a game's vocabulary inside that game's section, but a core page sometimes needs a
* real example rather than an invented one: a module manifest, an event dimension, the flag
* that tells the installer which game it is setting up. §1 prefers the real thing, because an
* invented example is checked by nothing and goes stale without anybody noticing.
*
* So the example keeps its game's words and says whose they are. This prints the game's name
* above the block — the same bargain D67 strikes for a screenshot: a game's words are honest
* on a core page when the game is named right beside them. `scripts/checkTerms.mjs` reads the
* `game` attribute and allows that game's words, and only that game's, between the tags.
*
* The name comes from `platform.json` rather than from the caller, so the label cannot say
* one game while the attribute that the check reads says another.
*/
interface Props {
/** A key of `platform.json`'s `games`: `uo` or `rust`. */
game: keyof typeof platform.games;
}
const { game } = Astro.props;
const entry = platform.games[game];
if (!entry) {
throw new Error(
`GameExample: unknown game "${game}". Use a key of platform.json's games: ${Object.keys(platform.games).join(', ')}.`,
);
}
---
<figure class="game-example" data-game={game}>
<figcaption class="game-example__label">{entry.name}</figcaption>
<div class="game-example__body"><slot /></div>
</figure>

View File

@@ -0,0 +1,112 @@
---
import type { Capability } from '../data/capabilities.mjs';
/**
* A game's own feature list, with the argument for each — PLAN.md §16, phase 17.
*
* Rendered only by a game's page under `/modules/` (`GAME_ROUTES` in `checkTerms.mjs`),
* which is what lets the items be written in the game's own words. It is `/features/`'s
* card treatment (D20: the label and the detail, never the label alone), so a reader who has
* seen that page reads this one the same way.
*
* A feature with a `demoPath` gets the per-capability demo link of D25. The markup is the
* exact contract `scripts/checkBrand.mjs` enforces and `applyBrand.mjs` rewrites at boot —
* see the note in `/features/` before touching it. A stock build hides every one.
*
* `game` is the module's id, written as `data-demo-game` straight after the path: these
* links are into that module's pages, so they show only on a demo whose mounted
* `demoGames` names it (D83).
*/
interface Props {
title: string;
lede?: string;
game: string;
items: Capability[];
}
const { title, lede, game, items } = Astro.props;
---
<section class="page section feats">
<h2>{title}</h2>
{lede && <p class="prose feats__lede">{lede}</p>}
<ul class="feats__items">
{
items.map((item) => (
<li class="panel feats__item">
<h3>{item.label}</h3>
<p class="feats__detail">{item.detail}</p>
{item.demoPath && (
<a
class="demo-link"
href="" data-demo-url="" data-demo-path={item.demoPath} data-demo-game={game}
rel="noopener noreferrer"
>
See it running
</a>
)}
</li>
))
}
</ul>
</section>
<style>
.feats h2 {
margin: 0;
font-size: clamp(1.5rem, 3vw, 1.95rem);
}
.feats__lede {
margin: 0.85rem 0 0;
color: var(--muted);
}
.feats__items {
display: grid;
gap: 1rem;
margin: 1.75rem 0 0;
padding: 0;
list-style: none;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 21rem), 1fr));
}
.feats__item {
display: flex;
flex-direction: column;
}
.feats__item h3 {
margin: 0 0 0.6rem;
color: var(--gold);
font-size: 1.04rem;
}
.feats__detail {
flex: 1;
margin: 0;
color: var(--muted);
font-size: 0.94rem;
}
/* As on /features/: a link rather than a button, and the flex item itself, so the
global rule that hides an empty demo link takes its margin with it. */
.demo-link {
display: inline-flex;
align-items: center;
gap: 0.35rem;
margin-top: 1rem;
color: var(--portal);
font-size: 0.88rem;
text-decoration-color: color-mix(in srgb, var(--portal) 40%, transparent);
}
.demo-link:hover {
color: var(--portal-bright);
}
.demo-link::after {
content: '\2197';
}
</style>

View File

@@ -1,5 +1,6 @@
--- ---
import { brand } from '../lib/brand.mjs'; import Search from './Search.astro';
import { renderBrand } from '../lib/brand.mjs';
/** /**
* The marketing header. The docs get Starlight's own header, themed to match in * The marketing header. The docs get Starlight's own header, themed to match in
@@ -20,9 +21,15 @@ import { brand } from '../lib/brand.mjs';
*/ */
const { pathname } = Astro.url; 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' },
// D78 (phase 17): the standalone RunicNPC page, for a Rust server owner who runs no site.
{ href: '/runicnpc/', label: 'RunicNPC' },
{ href: '/app/', label: 'App' }, { href: '/app/', label: 'App' },
{ href: '/community/', label: 'Community' }, { href: '/community/', label: 'Community' },
]; ];
@@ -55,6 +62,8 @@ const isCurrent = (href: string) =>
)) ))
} }
</nav> </nav>
<Search />
</div> </div>
</header> </header>

View File

@@ -0,0 +1,110 @@
---
import { screenById, WEB, PHONE } from '../data/screens.mjs';
import platform from '../data/platform.json';
/**
* One screenshot, as a figure with its caption. PLAN.md §13 phase 9, D4 / D44.
*
* -----------------------------------------------------------------------------------------
* WHY THE PAGE PASSES AN ID AND NOTHING ELSE
* -----------------------------------------------------------------------------------------
* A marketing page and a documentation page show the same administration screen for
* different reasons, and the thing they must not do is describe it differently. The alt
* text and the caption therefore live with the capture in `screens.mjs`, next to the route
* they came from, and a page asks for `admin-shard` rather than restating what is in it.
*
* It also means a re-capture cannot silently invalidate a caption: the sentence and the
* frame it describes are edited in the same file.
*
* -----------------------------------------------------------------------------------------
* WHY IT FAILS THE BUILD ON AN UNKNOWN ID
* -----------------------------------------------------------------------------------------
* The alternative is a page that renders a broken image, which looks like a deployment
* problem rather than a typo and survives review. `checkScreens.mjs` covers the other
* direction — a declared screen whose file is missing — so between them a screenshot is
* either complete or the build stops.
*/
interface Props {
/** An `id` from `src/data/screens.mjs`. */
id: string;
/** Suppress the caption where the surrounding prose already says it. */
bare?: boolean;
}
const { id, bare = false } = Astro.props;
const shot = screenById(id);
if (!shot) {
throw new Error(`Screenshot "${id}" is not declared in src/data/screens.mjs`);
}
const src = `/screens/${shot.id}.webp`;
// Intrinsic size comes from the family rather than the entry: every capture in a family is
// taken at one geometry (see screens.mjs), and `checkScreens.mjs` asserts the files really
// are that size, so these attributes cannot drift from the pixels.
const { width, height } = shot.family === 'web' ? WEB : PHONE;
// D67: a game's screen says whose it is. The name comes from `platform.json`, as
// GameExample's does, so the label cannot disagree with the tag the check reads.
const game = shot.game ? platform.games[shot.game] : null;
if (shot.game && !game) {
throw new Error(`Screenshot "${id}" is tagged game "${shot.game}", which platform.json does not have`);
}
---
<figure class="shot" data-game={shot.game}>
<img
src={src}
alt={shot.alt}
width={width}
height={height}
loading="lazy"
decoding="async"
/>
{
(game || !bare) && (
<figcaption>
{game && <span class="shot__game">{game.name}</span>}
{!bare && shot.caption}
</figcaption>
)
}
</figure>
<style>
.shot {
margin: 2rem 0;
}
.shot img {
display: block;
width: 100%;
height: auto;
border: 1px solid var(--line);
border-radius: var(--radius-card);
box-shadow: var(--shadow-card);
}
.shot figcaption {
margin: 0.85rem 0 0;
color: var(--dim);
font-size: 0.9rem;
line-height: 1.5;
}
/* The game's name, ahead of the caption (D67). Styled here rather than with the global
chip, because the docs pages render this component without the marketing stylesheet. */
.shot__game {
display: inline-block;
margin-right: 0.5rem;
padding: 0.05rem 0.55rem;
border: 1px solid var(--line);
border-radius: var(--radius-pill);
color: var(--muted);
font-size: 0.76rem;
letter-spacing: 0.04em;
white-space: nowrap;
}
</style>

279
src/components/Search.astro Normal file
View File

@@ -0,0 +1,279 @@
---
/**
* Site search for the marketing pages (D47).
*
* The documentation has had search since phase 1 — Starlight builds a Pagefind index at the
* end of every build and puts a box in its own header. The marketing pages were outside it
* twice over: not indexed, so a reader searching "Teams" in the docs found the architecture
* page and never the feature page; and with no box, so a reader who arrived on the homepage
* had a four-item nav and no way to ask a question.
*
* D47 closed both. `Base.astro` marks its `<main>` as a Pagefind body, which puts the ten
* marketing pages in the same index the docs already query, and this is the box.
*
* ── Why it is built this way ────────────────────────────────────────────────
* The marketing pages ship almost no JavaScript, and Pagefind's own UI bundle is 120 kB
* before the index and the WASM. Loading that on a homepage so that some visitors can
* search would be a poor trade, so **nothing is fetched until the dialog is opened** —
* the button is inert markup, and the first open injects the stylesheet and the script.
* Opening search a second time costs nothing more.
*
* `<dialog>` rather than a hand-built overlay: the browser gives us the focus trap, the
* inert background, Escape-to-close and the top layer for free, and every one of those is
* a thing an accessibility pass would otherwise have to find missing.
*
* In `astro dev` there is no `/pagefind/` — the index is written by the build. Rather than
* fail silently, the dialog says so.
*/
---
<div class="site-search">
<button type="button" class="site-search__open" data-search-open aria-haspopup="dialog">
<svg aria-hidden="true" focusable="false" viewBox="0 0 20 20" width="16" height="16">
<circle cx="9" cy="9" r="6" fill="none" stroke="currentColor" stroke-width="2"></circle>
<line x1="13.5" y1="13.5" x2="18" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round"></line>
</svg>
<span>Search</span>
<kbd aria-hidden="true">/</kbd>
</button>
<dialog class="site-search__dialog" data-search-dialog aria-label="Search this site">
<div class="site-search__panel">
<div class="site-search__head">
<h2 class="site-search__title">Search</h2>
<button type="button" class="site-search__close" data-search-close>Close</button>
</div>
<div data-search-mount></div>
<p class="site-search__note" data-search-note hidden>
Search is built with the site, so it is not available in the dev server. Run
<code>npm run build &amp;&amp; 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>

View File

@@ -0,0 +1,21 @@
---
import { Code } from '@astrojs/starlight/components';
import { runicnpcCalls } from '../data/runicnpc.mjs';
/**
* One RunicNPC call's signature, printed from `src/data/runicnpc.mjs` (PLAN.md §16, D62).
*
* The API page writes each call's prose by hand and prints its signature through this, so the
* signature a reader copies is the one `scripts/checkReference.mjs` holds against
* `docs` `main:runicnpc/API.md`. The check also requires exactly one of these per call.
*/
interface Props {
call: keyof typeof runicnpcCalls;
}
const { call } = Astro.props;
const sig = runicnpcCalls[call];
if (!sig) throw new Error(`Signature: "${call}" is not in runicnpcCalls (src/data/runicnpc.mjs).`);
---
<Code lang="csharp" code={`${sig.returns} ${call}(${sig.params})`} />

View File

@@ -0,0 +1,75 @@
---
import platform from '../data/platform.json';
import { brand } from '../lib/brand.mjs';
/**
* Structured data for the homepage (D50, phase 10).
*
* Two blocks and no more. `Organization` so the project's name resolves to an entity with a
* mark and a support channel rather than to whichever page happens to rank; and
* `SoftwareApplication` because what the site describes is software someone installs, and
* the licence and platform are facts a search result can usefully carry.
*
* ── What this deliberately is not ───────────────────────────────────────────
* It carries no ratings, no counts, no price, no `aggregateRating` — the vocabulary is
* full of fields that turn a result into an advert, and every one of them here would be
* invented. §11's "understated honesty" applies to markup a reader never sees as much as to
* the prose, and inventing a rating is the exact thing that gets structured data ignored.
*
* Breadcrumb and Article markup for the forty documentation pages was considered and
* rejected: Starlight already renders breadcrumbs a reader can see, and forty more blocks
* would be forty more places for a fact to go stale.
*
* ── Where the values come from ──────────────────────────────────────────────
* Every one is read — `brand.mjs` for text, `platform.json` for the platform's facts —
* so `checkFacts.mjs` already guards them and the mount already reaches them. Nothing here
* is typed twice. It is a data block, not code: no browser executes it, no CSP hash covers
* it, and `applyBrand.mjs` is free to rewrite the name inside it at boot (both scripts know
* about `application/ld+json` explicitly, because both would otherwise get it wrong).
*/
const site = Astro.site!;
const url = (p: string) => new URL(p, site).href;
const organization = {
'@type': 'Organization',
'@id': url('/#organization'),
name: brand.siteName,
url: url('/'),
logo: url('/brand/icon-512.png'),
description: brand.tagline,
// The support front door (D10). The Gitea org is where the code is; Discord is where a
// person gets an answer, so both are listed and neither is described as the other.
sameAs: [brand.giteaOrg, brand.discordInvite].filter(Boolean),
};
const application = {
'@type': 'SoftwareApplication',
'@id': url('/#software'),
name: brand.siteName,
url: url('/'),
description: brand.tagline,
applicationCategory: 'WebApplication',
// What an operator actually runs it on: a container on their own host, and an Android
// client. Not "Windows" — the installer runs there, the platform does not require it.
operatingSystem: 'Linux, Windows, Android',
license: 'https://www.gnu.org/licenses/gpl-3.0.html',
// D66: the Module API is the one version every deployment shares. Each game has its own
// bundle, so no single bundle tag describes the platform any more.
softwareVersion: platform.moduleApi,
publisher: { '@id': url('/#organization') },
// Self-hosted and free, and `offers` is the only way the vocabulary can say so. Omitting
// it reads as "price unknown"; stating zero is simply true.
offers: {
'@type': 'Offer',
price: '0',
priceCurrency: 'USD',
},
};
const graph = {
'@context': 'https://schema.org',
'@graph': [organization, application],
};
---
<script type="application/ld+json" set:html={JSON.stringify(graph)} is:inline />

View File

@@ -0,0 +1,63 @@
---
import { games } from '../data/games/index.mjs';
/**
* "Supported games: Ultima Online · Rust" — PLAN.md §16, D63.
*
* The one place a core page names the games, and the way it hands a reader on to them. Core
* pages describe what a game module adds in core's words (D75); this strip is the link to
* the page where each game says it in its own. The names come from `platform.json` through
* `games/index.mjs`, so a third game appears here the day it is added there.
*/
interface Props {
/** Extra class for the caller's spacing. */
class?: string;
}
const { class: className } = Astro.props;
---
<p class:list={['games', className]}>
<span class="games__label">Supported games</span>
{
games.map((game, index) => (
<>
{index > 0 && <span class="games__sep" aria-hidden="true">·</span>}
<a class="games__link" href={game.href}>
{game.name}
</a>
</>
))
}
</p>
<style>
.games {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.35rem 0.65rem;
margin: 0;
font-size: 0.95rem;
}
.games__label {
color: var(--dim);
font-size: 0.78rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.games__label::after {
content: ':';
}
.games__sep {
color: var(--dim);
}
.games__link {
color: var(--gold);
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,35 @@
---
/**
* A short description from a data file, rendered inline (PLAN.md §16, phase 16).
*
* Reference data in `src/data/` is plain strings, because a check reads the same file. Three
* inline marks are understood, and nothing else: `code`, **bold**, and [a link](/path/). The
* text is never parsed as HTML, so a description cannot inject markup.
*/
interface Props {
text: string;
}
const { text } = Astro.props;
type Token = { kind: 'text' | 'code' | 'strong'; value: string } | { kind: 'link'; value: string; href: string };
const tokens: Token[] = [];
const pattern = /`([^`]+)`|\*\*([^*]+)\*\*|\[([^\]]+)\]\(([^)\s]+)\)/g;
let last = 0;
for (const m of String(text ?? '').matchAll(pattern)) {
if (m.index > last) tokens.push({ kind: 'text', value: text.slice(last, m.index) });
if (m[1] !== undefined) tokens.push({ kind: 'code', value: m[1] });
else if (m[2] !== undefined) tokens.push({ kind: 'strong', value: m[2] });
else tokens.push({ kind: 'link', value: m[3], href: m[4] });
last = m.index + m[0].length;
}
if (last < text.length) tokens.push({ kind: 'text', value: text.slice(last) });
---
{tokens.map((t) =>
t.kind === 'code' ? <code>{t.value}</code>
: t.kind === 'strong' ? <strong>{t.value}</strong>
: t.kind === 'link' ? <a href={t.href}>{t.value}</a>
: t.value
)}

View File

@@ -1,61 +1,37 @@
--- ---
import { screensOf, PHONE } from '../../data/screens.mjs';
import platform from '../../data/platform.json';
/** /**
* The app's screenshot strip — defined now, empty until phase 9 (D26). * The app's screenshot strip. Reserved in phase 5 (D26), filled in phase 9.
* *
* --------------------------------------------------------------------------------------- * ---------------------------------------------------------------------------------------
* WHY A COMPONENT THAT RENDERS NOTHING IS WORTH COMMITTING * WHY THIS COMPONENT EXISTED FOR A PHASE WITH NOTHING IN IT
* --------------------------------------------------------------------------------------- * ---------------------------------------------------------------------------------------
* PLAN.md §10 says `/app/` shows "the 14 existing screenshots". They exist — * PLAN.md §10 said `/app/` shows "the 14 existing screenshots". They exist —
* `docs/android/screenshots/` on the `docs` repository — and they are the wrong fourteen: * `docs/android/screenshots/` on the `docs` repository — and they are the wrong fourteen: a
* a trusted-device and recovery-code smoke test from 2026-07-22, captured against a * trusted-device and recovery-code smoke test from 2026-07-22, captured against a
* development instance with no seeded content, before the theming work that changed how * development instance with no seeded content, before the theming work that changed how
* every screen looks. Five of them are two-factor prompts. The home shot is an empty page. * every screen looks. Five of them are two-factor prompts. The home shot is an empty page.
* *
* Shipping them would break two things at once: D4, which says real screenshots from the * Shipping them would have broken D4 and §1 at once, so D26 reserved the slot for the phase
* review stack rather than placeholders, and §1, because they would show an app that no * that stands up the review stack anyway. The shape was defined then and the data arrived
* longer looks like that. D26 records the decision — the slot is reserved, phase 9 fills * now, which is exactly what it was for: filling it was a data change.
* it, and phase 9 is already the phase that stands up the review stack and seeds the
* content the web screenshots need. Adding an emulator pass to a rig that is being built
* anyway is most of the work already done, and it has the property that the phone shots
* and the browser shots then show the same deployment on the same day.
*
* The component exists rather than the page carrying a `TODO` because a defined shape is
* what makes phase 9 a data change instead of a design task: fill `shots`, and the section
* appears with a heading, a caption line and a grid. Nothing else has to be decided then.
* *
* --------------------------------------------------------------------------------------- * ---------------------------------------------------------------------------------------
* WHAT PHASE 9 SHOULD PUT HERE * WHY IT READS screens.mjs RATHER THAN HOLDING ITS OWN LIST
* --------------------------------------------------------------------------------------- * ---------------------------------------------------------------------------------------
* Portrait captures at the device's own pixel size, from an API 36 emulator pointed at the * The draft carried its own `shots` array, written before there was anywhere else to put
* seeded review stack, one per idea rather than one per screen: the shard hub with live * one. There is now: `src/data/screens.mjs` holds every capture the site ships, web and
* data, the marketplace, a character sheet, the news list, the notification settings, and * phone alike, and `scripts/checkScreens.mjs` proves each one exists at the size the markup
* the drawer showing a deployment's own navigation. Six is plenty. Fourteen was never a * claims. A second list here would be the one nothing checks.
* target — it was the number that happened to exist.
* *
* They belong in `public/`, not `brand-default/`: these are editorial content shipped with * The phone captures come from an emulator pointed at the same seeded deployment the web
* the image, not branding an operator overrides (§7). * screenshots were taken from, on the same day — which is the property D26 was really
* after, since the app takes its colours, type and navigation from the site it connects to.
*/ */
/** const shots = screensOf('phone');
* One capture. `width` and `height` are the real pixel dimensions and are required rather
* than optional: without them the page reflows as each image decodes, and a strip of six
* phone screenshots is the worst possible place for that.
*
* Frontmatter is TypeScript, so this is an interface rather than the JSDoc typedef the
* `.mjs` data files use — and it has to be typed explicitly, because an empty array
* annotated by inference is `any[]` and `astro check` is right to refuse it.
*/
interface Shot {
/** Site-absolute path under `/screens/`. */
src: string;
/** What the screen shows, for somebody who cannot see it. */
alt: string;
caption: string;
width: number;
height: number;
}
const shots: Shot[] = [];
--- ---
{ {
@@ -63,23 +39,27 @@ const shots: Shot[] = [];
<section class="page section shots"> <section class="page section shots">
<h2>What it looks like</h2> <h2>What it looks like</h2>
<p class="prose shots__lede"> <p class="prose shots__lede">
Captured against a real deployment with real content, not mocked up. The app takes Captured from the app against two demo deployments: one with a real Ultima Online
its colours, type and navigation from the site it is connected to, so these show one server behind it, and one with a real Rust server whose players are a demo cast. The
community's app rather than a neutral one. app takes its colours, type and navigation from the site it is connected to, so these
show one community's app rather than a neutral one.
</p> </p>
<ul class="shots__grid"> <ul class="shots__grid">
{shots.map((shot) => ( {shots.map((shot) => (
<li class="shots__item"> <li class="shots__item" data-game={shot.game}>
<img <img
src={shot.src} src={`/screens/${shot.id}.webp`}
alt={shot.alt} alt={shot.alt}
width={shot.width} width={PHONE.width}
height={shot.height} height={PHONE.height}
loading="lazy" loading="lazy"
decoding="async" decoding="async"
/> />
<p class="shots__caption">{shot.caption}</p> <p class="shots__caption">
{shot.game && <span class="shots__game">{platform.games[shot.game].name}</span>}
{shot.caption}
</p>
</li> </li>
))} ))}
</ul> </ul>
@@ -116,6 +96,19 @@ const shots: Shot[] = [];
box-shadow: var(--shadow-card); box-shadow: var(--shadow-card);
} }
/* The game's name on a game's screen (D67), as Screenshot.astro prints it. */
.shots__game {
display: inline-block;
margin-right: 0.5rem;
padding: 0.05rem 0.55rem;
border: 1px solid var(--line);
border-radius: var(--radius-pill);
color: var(--muted);
font-size: 0.74rem;
letter-spacing: 0.04em;
white-space: nowrap;
}
.shots__caption { .shots__caption {
margin: 0.85rem 0 0; margin: 0.85rem 0 0;
color: var(--dim); color: var(--dim);

View File

@@ -8,9 +8,11 @@ import platform from '../../data/platform.json';
* The other two draw runtime shapes. This one draws a code boundary, and it is here because * The other two draw runtime shapes. This one draws a code boundary, and it is here because
* it is the claim the whole project rests on: that a community platform can be built once * it is the claim the whole project rests on: that a community platform can be built once
* and pointed at any game. An evaluator has every reason to read that as marketing, so the * and pointed at any game. An evaluator has every reason to read that as marketing, so the
* page draws the seam and then says plainly what does and does not prove it — one module * page draws the seam and then says plainly what does and does not prove it — two modules
* exists, the second is a paper exercise, and the exit criterion for calling the contract * exist, both built inside this project, and the exit criterion for calling the contract
* proven is written down (§2, and the entries `/modules/` renders from `notBuilt.mjs`). * proven by an outsider is written down (§2, §16, and the entries `/modules/` renders from
* `notBuilt.mjs`). Until phase 13 this said one module and a paper exercise; Module-Rust's
* release made that false.
* *
* The Module API version is read from `platform.json` like every other number on this site * The Module API version is read from `platform.json` like every other number on this site
* (§12). It is the one place a version genuinely belongs in this diagram: the seam is * (§12). It is the one place a version genuinely belongs in this diagram: the seam is
@@ -24,8 +26,8 @@ import platform from '../../data/platform.json';
<p class="eyebrow">Where the game stops</p> <p class="eyebrow">Where the game stops</p>
<h2>A seam, with a version on it</h2> <h2>A seam, with a version on it</h2>
<p class="prose"> <p class="prose">
The core site does not know what a shard is, what a guild is, or that Ultima Online The core site does not know what game it serves, what that game calls a group of
exists. Everything that does lives in an installable module on the other side of a players, or that Ultima Online and Rust exist. Everything that does lives in an installable module on the other side of a
declared interface — which is what makes "put your game on it" a shape rather than a declared interface — which is what makes "put your game on it" a shape rather than a
slogan. slogan.
</p> </p>
@@ -73,7 +75,7 @@ import platform from '../../data/platform.json';
<!-- The module: everything that knows a game exists. --> <!-- The module: everything that knows a game exists. -->
<rect class="host" x="8" y="306" width="364" height="186" rx="14" /> <rect class="host" x="8" y="306" width="364" height="186" rx="14" />
<text class="host-title" x="28" y="340">Game module</text> <text class="host-title" x="28" y="340">Game module</text>
<text class="host-sub" x="28" y="360">one per deployment; UO today</text> <text class="host-sub" x="28" y="360">one per deployment; UO or Rust today</text>
<rect class="node" x="28" y="378" width="156" height="46" rx="10" /> <rect class="node" x="28" y="378" width="156" height="46" rx="10" />
<text class="node-title" x="46" y="406">Routes</text> <text class="node-title" x="46" y="406">Routes</text>
@@ -121,8 +123,8 @@ import platform from '../../data/platform.json';
<p> <p>
Core owns the Teams primitive — the roster, the forum, the notifications, the voice Core owns the Teams primitive — the roster, the forum, the notifications, the voice
channel — and does not own the <em>word</em>. A Team cannot be created in core at channel — and does not own the <em>word</em>. A Team cannot be created in core at
all; it arrives from the module, which is why the UO module calls them guilds and all; it arrives from the module, which is why each module calls them by its own
builds those pages itself. That is the pattern the whole interface is built on: core game’s word and builds those pages itself. That is the pattern the whole interface is built on: core
supplies the machinery, the module supplies the meaning. supplies the machinery, the module supplies the meaning.
</p> </p>
</section> </section>
@@ -130,11 +132,12 @@ import platform from '../../data/platform.json';
<section> <section>
<h3>What this does not yet prove</h3> <h3>What this does not yet prove</h3>
<p> <p>
One module exists and it is Ultima Online. A second, for a different game, is a Two modules exist: Ultima Online, and Rust, which this project built second, from
written dry-run that was deliberately never implemented — it exists to test whether the Integration Kit. Where the Rust module needed something the contract did not
the contract generalises on paper. Until somebody builds the second one, the seam is yet offer, the gap was closed in core, for every module, rather than in a fork — so
a well-argued design rather than a demonstrated one, and this site says so wherever the seam now has two games on its far side instead of one. What two modules built
it comes up. inside this project cannot prove is that somebody outside it can do the same, and
that is why the Integration Kit still says draft.
</p> </p>
</section> </section>
</div> </div>

View File

@@ -9,7 +9,10 @@
* doing capacity planning, and "how many machines is this" is the first question they have. * doing capacity planning, and "how many machines is this" is the first question they have.
* *
* Drawn generically for the same reason the homepage's diagram is (D17) — "your game host", * Drawn generically for the same reason the homepage's diagram is (D17) — "your game host",
* not "your ServUO box" — with the prose beside it naming the real components. The boundary * not a particular game's server — with the prose beside it linking to each game's page,
* which names its real components (D75). Until phase 17 the caption named one game's and
* said two machines was also the maximum, which stopped being true when Rust shipped: one
* sidecar serves one server, so six servers means six sidecars. The boundary
* is the one drawn argument: everything above it is reachable because you published it, and * is the one drawn argument: everything above it is reachable because you published it, and
* everything below it is not reachable at all. * everything below it is not reachable at all.
* *
@@ -73,8 +76,10 @@
</svg> </svg>
<p class="diagram__caption"> <p class="diagram__caption">
Today the game server is a ServUO shard and the sidecar is uo-link. Two machines is The sidecar and the bridge plugin are each game’s own — see
the minimum and also the maximum — nothing here scales by adding a third. <a href="/modules/uo/">Ultima Online</a> and <a href="/modules/rust/">Rust</a>. Two
machines is the minimum. A community running several game servers runs a sidecar
beside each one, on whichever host that server already lives on.
</p> </p>
</div> </div>
@@ -92,10 +97,10 @@
<section> <section>
<h3>The game host</h3> <h3>The game host</h3>
<p> <p>
The machine your game server is already on. One installer binary puts the plugin The machine your game server is already on. One installer binary puts the bridge
into the server's tree, installs the sidecar beside it and registers the service — plugin into the server's tree, installs the sidecar beside it and registers the
then prints four values. It never contacts your website; you paste those four service — then prints what the site needs. It never contacts your website; you enter
values into the admin panel yourself, and that is the moment the two halves meet. those values in the admin panel yourself, and that is the moment the two halves meet.
</p> </p>
</section> </section>

View File

@@ -1,6 +1,7 @@
--- ---
import platform from '../../data/platform.json'; import { capabilityGroups } from '../../data/capabilities.mjs';
import { capabilityGroups, assertCapabilityCoverage } from '../../data/capabilities.mjs'; import { games, assertGameCoverage } from '../../data/games/index.mjs';
import SupportedGames from '../SupportedGames.astro';
/** /**
* The grouped capabilities (PLAN.md §10). All five groups, named only — the argument for * The grouped capabilities (PLAN.md §10). All five groups, named only — the argument for
@@ -8,8 +9,10 @@ import { capabilityGroups, assertCapabilityCoverage } from '../../data/capabilit
* copy to keep true. * copy to keep true.
* *
* The call below is the point of the exercise: it throws, and therefore fails the build, if * The call below is the point of the exercise: it throws, and therefore fails the build, if
* the "Game intelligence" list and the module's own declared capabilities have drifted * either game's own feature list (`src/data/games/`) and its module's declared capabilities
* apart. `checkFacts.mjs` already keeps `platform.json` honest against the module manifest; * have drifted apart. The homepage names neither list — its module-supplied group is the
* neutral one, in core's words (D75) — but it is the page a reader meets first, so it is
* the page that should refuse to build when a game page is out of date. `checkFacts.mjs` already keeps `platform.json` honest against the module manifest;
* this makes the page honest against `platform.json`, which is the half that was missing. * this makes the page honest against `platform.json`, which is the half that was missing.
* *
* The "not built" line at the bottom is not a disclaimer bolted on — §2's absent-features * The "not built" line at the bottom is not a disclaimer bolted on — §2's absent-features
@@ -17,17 +20,18 @@ import { capabilityGroups, assertCapabilityCoverage } from '../../data/capabilit
* what exists while quietly omitting the well-known things that do not is the exact failure * what exists while quietly omitting the well-known things that do not is the exact failure
* §1 is written to prevent. * §1 is written to prevent.
*/ */
assertCapabilityCoverage(platform.moduleUoCapabilities); for (const game of games) assertGameCoverage(game.key);
--- ---
<section class="page section caps"> <section class="page section caps">
<p class="eyebrow">What it does</p> <p class="eyebrow">What it does</p>
<h2>A community site, and a window into the game</h2> <h2>A community site, and a window into the game</h2>
<p class="prose caps__lede"> <p class="prose caps__lede">
The core is game-agnostic: it does not know what a shard is. Everything that does arrives The core is game-agnostic: it does not know what game you run. Everything that does
as an installable <a href="/modules/">module</a>, which is why the same platform can carry arrives as an installable <a href="/modules/">module</a>, which is why the same platform
a different game without a fork. carries two games without a fork.
</p> </p>
<SupportedGames class="caps__games" />
<div class="caps__grid"> <div class="caps__grid">
{ {
@@ -52,7 +56,7 @@ assertCapabilityCoverage(platform.moduleUoCapabilities);
<p class="caps__foot prose"> <p class="caps__foot prose">
Some things people reasonably expect are <strong>deliberately not built</strong> — a Matrix Some things people reasonably expect are <strong>deliberately not built</strong> — a Matrix
integration, more than one game module active at once, a second game module. They are integration, more than one game module active at once, a finished Integration Kit. They are
listed rather than left out, on <a href="/features/">features</a> and{' '} listed rather than left out, on <a href="/features/">features</a> and{' '}
<a href="/integrations/">integrations</a>. <a href="/integrations/">integrations</a>.
</p> </p>
@@ -69,6 +73,10 @@ assertCapabilityCoverage(platform.moduleUoCapabilities);
color: var(--muted); color: var(--muted);
} }
.caps__games {
margin-top: 1.25rem;
}
.caps__grid { .caps__grid {
display: grid; display: grid;
gap: 1rem; gap: 1rem;

View File

@@ -1,12 +1,10 @@
--- ---
import platform from '../../data/platform.json';
/** /**
* The data path (PLAN.md §13 phase 3), drawn as inline SVG per §11's motif rule — hand-drawn * The data path (PLAN.md §13 phase 3), drawn as inline SVG per §11's motif rule — hand-drawn
* geometry, used where it explains something, and no raster anywhere. * geometry, used where it explains something, and no raster anywhere.
* *
* --------------------------------------------------------------------------------------- * ---------------------------------------------------------------------------------------
* THE LABELS ARE GENERIC, WITH UO AS THE CAPTION * THE LABELS ARE GENERIC; THE CAPTION NAMES THE GAMES
* --------------------------------------------------------------------------------------- * ---------------------------------------------------------------------------------------
* The org lead settled this before the diagram was drawn. The nodes say "your game server" * The org lead settled this before the diagram was drawn. The nodes say "your game server"
* and "sidecar", not "ServUO shard" and "uo-link", because §10's rule is that a reader * and "sidecar", not "ServUO shard" and "uo-link", because §10's rule is that a reader
@@ -14,10 +12,12 @@ import platform from '../../data/platform.json';
* repositories in order to connect a game server — and because the tagline promises a * repositories in order to connect a game server — and because the tagline promises a
* platform, not a UO product. * platform, not a UO product.
* *
* It does NOT hide what actually ships. The sub-labels and the caption name ServUO and * It does NOT hide what actually ships: the caption names the games that ship on this shape,
* uo-link outright, because §1 says the technical truth wins and today there is exactly one * because §1 says the technical truth wins and an operator has to see their game in the
* implementation of this shape. An operator running a shard has to see themselves in the * picture on the first screen. Until phase 13 the sub-labels named ServUO and uo-link, which
* picture on the first screen. * was true while there was exactly one implementation. With two, naming one would mislead,
* and D63 (PLAN.md §16) keeps each game's vocabulary on that game's own pages — so the
* caption names the games and the diagram names nothing else.
* *
* --------------------------------------------------------------------------------------- * ---------------------------------------------------------------------------------------
* WHY THE SVG IS aria-hidden * WHY THE SVG IS aria-hidden
@@ -74,11 +74,11 @@ import platform from '../../data/platform.json';
<rect class="node" x="20" y="16" width="340" height="76" rx="12" /> <rect class="node" x="20" y="16" width="340" height="76" rx="12" />
<text class="node-title" x="42" y="50">Your game server</text> <text class="node-title" x="42" y="50">Your game server</text>
<text class="node-sub" x="42" y="72">ServUO today · opens no inbound port</text> <text class="node-sub" x="42" y="72">opens no inbound port</text>
<rect class="node" x="20" y="148" width="340" height="76" rx="12" /> <rect class="node" x="20" y="148" width="340" height="76" rx="12" />
<text class="node-title" x="42" y="182">Sidecar</text> <text class="node-title" x="42" y="182">Sidecar</text>
<text class="node-sub" x="42" y="204">uo-link · the only network-facing part</text> <text class="node-sub" x="42" y="204">the only network-facing part</text>
<rect class="node node--self" x="20" y="280" width="340" height="76" rx="12" /> <rect class="node node--self" x="20" y="280" width="340" height="76" rx="12" />
<text class="node-title" x="42" y="314">Runic Gateway</text> <text class="node-title" x="42" y="314">Runic Gateway</text>
@@ -90,8 +90,9 @@ import platform from '../../data/platform.json';
</svg> </svg>
<p class="datapath__caption"> <p class="datapath__caption">
Today that game server is a ServUO shard and that sidecar is uo-link. The shape is the Two games ship on this shape today, Ultima Online and Rust, each with its own plugin
contract; the implementations are what plug into it. and its own sidecar. The shape is the contract; the implementations are what plug
into it.
</p> </p>
</div> </div>
@@ -109,8 +110,8 @@ import platform from '../../data/platform.json';
<h3>The sidecar</h3> <h3>The sidecar</h3>
<p> <p>
A small service beside the game, and the only piece of the bridge anything else can A small service beside the game, and the only piece of the bridge anything else can
reach. It speaks a versioned wire protocol — protocol {platform.protocol} today — so reach. It speaks a versioned wire protocol — each game pins its own — so a
a mismatched pair is refused rather than misread, and it answers only your website's mismatched pair is refused rather than misread, and it answers only your website's
backend, over an authenticated WebSocket and REST. backend, over an authenticated WebSocket and REST.
</p> </p>
</li> </li>

View File

@@ -4,7 +4,7 @@ import { brand } from '../../lib/brand.mjs';
/** /**
* The get-started CTA (PLAN.md §10, the `/` row), built around the trap in §10's * The get-started CTA (PLAN.md §10, the `/` row), built around the trap in §10's
* "installation path": a "Runic Gateway install" is two independent installs. The installer * "installation path": a "Runic Gateway install" is two independent installs. The installer
* binary sets up the shard side only and never contacts the website; the website is a * binary sets up the game side only and never contacts the website; the website is a
* separate Docker deployment. * separate Docker deployment.
* *
* That belongs on the homepage rather than being saved for the docs. It is the single * That belongs on the homepage rather than being saved for the docs. It is the single
@@ -13,7 +13,9 @@ import { brand } from '../../lib/brand.mjs';
* project. Saying it here is what makes the docs a confirmation rather than a surprise. * project. Saying it here is what makes the docs a confirmation rather than a surprise.
* *
* The two halves are ordered site-first because that is the order they must be done in: the * The two halves are ordered site-first because that is the order they must be done in: the
* shard side ends by pasting four values into the site's admin panel, which has to exist. * game side ends by entering what the installer printed into the site's admin panel, which
* has to exist. Which screen that is belongs to the game module, so this says "the game
* module's screen" rather than naming one game's menu (D63).
* *
* Both "read the docs" links point at `/docs/` rather than at a page inside the journey. * Both "read the docs" links point at `/docs/` rather than at a page inside the journey.
* Phases 7 and 8 write those pages and own their slugs; guessing one now would put a URL in * Phases 7 and 8 write those pages and own their slugs; guessing one now would put a URL in
@@ -44,9 +46,10 @@ import { brand } from '../../lib/brand.mjs';
<div class="start__half"> <div class="start__half">
<h3><span class="start__num">2</span> The game side</h3> <h3><span class="start__num">2</span> The game side</h3>
<p> <p>
One binary, run on the machine the game server already lives on. It syncs the plugin, One binary, run on the machine the game server already lives on — or, for Rust, a
installs the sidecar as a service, and prints four values. You paste those into Pterodactyl egg. It puts the bridge plugin in place, installs the sidecar, and prints
Admin → Shard, and the two halves find each other. what the site needs. You enter that on the game module’s screen in the admin panel,
and the two halves find each other.
</p> </p>
</div> </div>
</div> </div>

View File

@@ -55,9 +55,9 @@ import platform from '../../data/platform.json';
</div> </div>
<div class="chips"> <div class="chips">
<span class="chip chip--version">Protocol {platform.protocol}</span> {/* D66: only the version every deployment shares. A protocol and a bundle belong
to a game, so they live on that game's pages rather than on the front door. */}
<span class="chip chip--version">Module API {platform.moduleApi}</span> <span class="chip chip--version">Module API {platform.moduleApi}</span>
<span class="chip chip--version">Bundle {platform.bundle.tag}</span>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,63 @@
---
import Screenshot from '../Screenshot.astro';
/**
* The homepage's screenshots, one per game. PLAN.md §13 phase 9, D4 / D44; §16 phase 18.
*
* -----------------------------------------------------------------------------------------
* WHY ONE PER GAME, AND WHY THESE
* -----------------------------------------------------------------------------------------
* `DataPath` above it draws the claim — a private game server, a sidecar, a public site —
* and a diagram of a data path is a promise that the data arrives. This is the page where
* it arrives, captured from a deployment wired to a running game server, so the section
* directly under the diagram is the diagram's evidence.
*
* It is one game's screen, and says so (D76, taken in phase 17 under D67): the screenshot is
* tagged `uo` in `screens.mjs`, so it prints "Ultima Online" beside it and may keep that
* game's words in its alt and caption. A core screen was the alternative and was declined:
* the claim here is live game state on a public page, which no core screen shows. Phase 18
* added a Rust capture beside it, labelled the same way: the live map, because every pixel
* of it is the running server's — the Rust demo's players are a demo cast (D81), and the
* map shows none of them.
*
* A gallery here would compete with `Capabilities` further down, which is the part of the
* homepage that enumerates. So: one figure per game, its signature screen, and the rest of
* the set on each game's page under `/modules/`, where each sits beside the claim it supports.
*/
---
<section class="page section looks">
<p class="eyebrow">What it looks like</p>
<h2>The other end of that diagram</h2>
<p class="prose looks__lede">
Two demo deployments, each with a real game server behind it. On the first, the gold
supply, the state of the link and the player online are all read from an Ultima Online
server over the bridge. On the second, the map is the running Rust server's own: its
island, its monuments, and the events on it as they happen.
</p>
<div class="looks__pair">
<Screenshot id="shard-status" />
<Screenshot id="rust-map" />
</div>
</section>
<style>
.looks h2 {
margin: 0.35rem 0 0.75rem;
font-size: clamp(1.6rem, 3.2vw, 2.1rem);
}
.looks__lede {
margin: 0;
max-width: 46rem;
color: var(--muted);
}
/* Side by side where there is room for two screenshots to stay readable, stacked below. */
.looks__pair {
display: grid;
gap: 0 1.5rem;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 30rem), 1fr));
}
</style>

41
src/config/cspHashes.mjs Normal file
View File

@@ -0,0 +1,41 @@
/**
* cspHashes.mjs — GENERATED. Do not edit by hand.
*
* Regenerate with `npm run csp:hashes` (which builds, harvests and rebuilds).
* `npm run check:csp` fails if this file no longer covers what the build emits.
*
* ── Why this file exists ────────────────────────────────────────────────────
* Astro's `security.csp` (D48) hashes the scripts and styles it processes itself. It does
* not hash `<script is:inline>` — by design, because an inline script is the author's own
* text and Astro never parses it. Starlight ships six of them on every documentation page:
* the theme provider, the theme-picker sync, the mobile menu, the sidebar scroll restore.
*
* That combination fails in the worst way available. The build succeeds, the header is
* strict and correct, every page renders — and the theme switch, the mobile sidebar and
* the sidebar's scroll position are dead, with the explanation only in a browser console
* nobody opens. `'unsafe-inline'` would fix all six and give up the single directive CSP
* exists to enforce, so instead the hashes are enumerated here and checked.
*
* These are Starlight's, not ours: a Starlight upgrade that edits one byte of one of those
* scripts invalidates a hash. `check:csp` is what turns that from a silent breakage into a
* red build, and regenerating this file is the acknowledgement that the upgrade was read.
*/
/**
* SHA-256 hashes of inline `<script>` bodies Astro does not hash for us.
*
* The template-literal type is not decoration: Astro types this option as `CspHashEntry[]`,
* so a plain `string[]` fails `astro check`.
*
* @type {`sha256-${string}`[]}
*/
export const inlineScriptHashes = [
'sha256-7eCV4jtsr4t4knb3c4FCRPeu7GGZeOUGE3XvWix0XOQ=',
'sha256-GkZBRnvSuhtx/cvzvukVkX2JJZW+DdPlVr7BX8Tefqo=',
'sha256-VWo5Wp4aqSj6nSgMpeAp9cKieaoIfwFUAunAVugI5gA=',
'sha256-f/zAUE74ucc3JYp4r4QQvkJofoQdkOIhHYK+jeZ6eko=',
'sha256-wX2yOADeV+NMngflD5uYi3vl50SHC4sfM1EmylVjlX4=',
];
/** @type {`sha256-${string}`[]} SHA-256 hashes of inline `<style>` bodies Astro does not hash. */
export const inlineStyleHashes = [];

22
src/config/redirects.mjs Normal file
View File

@@ -0,0 +1,22 @@
/**
* Docs pages that moved, and where they went — PLAN.md §16, D60.
*
* Every page that moves keeps its old URL working, because the old URL is in somebody's
* bookmarks, in an installer's output and in search results. With the Node adapter these are
* not files in `dist/client`: the server answers each one with a real `301`.
*
* Its own file, rather than inline in `astro.config.mjs`, so that `test/headers.test.mjs` can
* read the same list and assert every entry against the running server — the config cannot be
* imported by plain Node, because Starlight ships TypeScript.
*
* Phase 14 moved the Ultima Online pages into their own group. `connect-a-game-server` did
* NOT move: its URL is now the game-neutral hand-off page, which is a better landing for an
* old link than a redirect into one game would be.
*/
export const docsRedirects = {
'/docs/administration/the-shard-connection': '/docs/ultima-online/the-shard-connection/',
'/docs/administration/client-files': '/docs/ultima-online/client-files/',
'/docs/reference/bridge-cfg': '/docs/ultima-online/bridge-cfg/',
'/docs/reference/sidecar-toml': '/docs/ultima-online/sidecar-toml/',
'/docs/reference/event-catalog': '/docs/ultima-online/event-catalog/',
};

View File

@@ -1,5 +1,6 @@
/** /**
* The documentation sidebar — PLAN.md §10 "Documentation", five groups, ~37 pages. * The documentation sidebar — PLAN.md §10 "Documentation": five core groups, then one group
* per game after them (§16, D60).
* *
* The pages themselves land in phases 7 and 8. This file exists in phase 1 because the * The pages themselves land in phases 7 and 8. This file exists in phase 1 because the
* shape of the journey is a design decision that is already made, and because a sidebar * shape of the journey is a design decision that is already made, and because a sidebar
@@ -22,7 +23,7 @@ export const docsSidebar = [
{ label: 'Install the site', slug: 'docs/getting-started/install-the-site' }, { label: 'Install the site', slug: 'docs/getting-started/install-the-site' },
{ label: 'First run', slug: 'docs/getting-started/first-run' }, { label: 'First run', slug: 'docs/getting-started/first-run' },
{ label: 'Install a game module', slug: 'docs/getting-started/install-a-game-module' }, { label: 'Install a game module', slug: 'docs/getting-started/install-a-game-module' },
{ label: 'Connect a game server', slug: 'docs/getting-started/connect-a-game-server' }, { label: 'Connect your game server', slug: 'docs/getting-started/connect-a-game-server' },
{ label: 'Verify the whole stack', slug: 'docs/getting-started/verify-the-whole-stack' }, { label: 'Verify the whole stack', slug: 'docs/getting-started/verify-the-whole-stack' },
], ],
}, },
@@ -36,20 +37,114 @@ export const docsSidebar = [
{ label: 'Users and roles', slug: 'docs/administration/users-and-roles' }, { label: '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: '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' },
], ],
}, },
{
label: 'Modules',
items: [
{ label: 'The module system', slug: 'docs/modules/the-module-system' },
{ label: 'Installing modules', slug: 'docs/modules/installing-modules' },
{ label: 'Module lifecycle', slug: 'docs/modules/module-lifecycle' },
{ label: 'The module manifest', slug: 'docs/modules/the-module-manifest' },
{ label: 'The module API', slug: 'docs/modules/the-module-api' },
{ label: 'Building a module', slug: 'docs/modules/building-a-module' },
{ label: 'The Integration Kit', slug: 'docs/modules/the-integration-kit' },
{ label: 'Testing and release', slug: 'docs/modules/testing-and-release' },
],
},
{
label: 'Architecture',
items: [
{ label: 'System architecture', slug: 'docs/architecture/system-architecture' },
{ label: 'The bridge', slug: 'docs/architecture/the-bridge' },
{ label: 'Authentication architecture', slug: 'docs/architecture/authentication-architecture' },
{ label: 'Teams architecture', slug: 'docs/architecture/teams-architecture' },
{ label: 'Events architecture', slug: 'docs/architecture/events-architecture' },
{ label: 'Protocol versions', slug: 'docs/architecture/protocol-versions' },
],
},
{
label: 'Reference',
items: [
{ label: 'Environment variables', slug: 'docs/reference/environment-variables' },
{ label: 'Installer CLI', slug: 'docs/reference/installer-cli' },
{ label: 'HTTP API', slug: 'docs/reference/http-api' },
{ label: 'Canonical documents', slug: 'docs/reference/canonical-documents' },
],
},
// One group per game, after Reference (PLAN.md §16, D60). Everything above this line is
// core and is held to core's words by checkTerms.mjs (D63, D64). A group whose label is a
// game's `name` in platform.json is that game's group, and may use that game's words — so
// the label is load-bearing: renaming it here without renaming the game turns it core.
{
label: 'Ultima Online',
items: [
{ label: 'The Ultima Online module', slug: 'docs/ultima-online' },
{ label: 'Connect a ServUO shard', slug: 'docs/ultima-online/connect-a-servuo-shard' },
{ label: 'Verify the bridge', slug: 'docs/ultima-online/verify-the-bridge' },
{ label: 'The shard connection', slug: 'docs/ultima-online/the-shard-connection' },
{ label: 'Client files', slug: 'docs/ultima-online/client-files' },
{ label: 'Troubleshooting', slug: 'docs/ultima-online/troubleshooting' },
{ label: 'Bridge.cfg', slug: 'docs/ultima-online/bridge-cfg' },
{ label: 'sidecar.toml', slug: 'docs/ultima-online/sidecar-toml' },
{ label: 'Shard event catalog', slug: 'docs/ultima-online/event-catalog' },
],
},
{
label: 'Rust',
items: [
{ label: 'The Rust module', slug: 'docs/rust' },
{ label: 'Install the bridge', slug: 'docs/rust/install-the-bridge' },
{ label: 'Connect the site', slug: 'docs/rust/connect-the-site' },
{ label: 'Verify the bridge', slug: 'docs/rust/verify-the-bridge' },
{ label: 'Linking Steam accounts', slug: 'docs/rust/linking-steam-accounts' },
{ label: 'Permissions and groups', slug: 'docs/rust/permissions-and-groups' },
{ label: 'Mod config from the site', slug: 'docs/rust/mod-config' },
{ label: 'Who sees who is online', slug: 'docs/rust/who-sees-who-is-online' },
{ label: 'Events, zones and rewards', slug: 'docs/rust/events-zones-and-rewards' },
{ label: 'Optional plugins', slug: 'docs/rust/optional-plugins' },
{ label: 'The live map', slug: 'docs/rust/the-live-map' },
{ label: 'Troubleshooting', slug: 'docs/rust/troubleshooting' },
{ label: 'sidecar.toml', slug: 'docs/rust/sidecar-toml' },
{ label: 'RunicGateway.json', slug: 'docs/rust/runicgateway-json' },
{ label: 'Event kinds', slug: 'docs/rust/event-kinds' },
// §16, D61/D72: RunicNPC is a subgroup of Rust, nested so it reads as part of the game.
{
label: 'RunicNPC',
items: [
{ label: 'RunicNPC', slug: 'docs/rust/runicnpc' },
{ label: 'Install RunicNPC', slug: 'docs/rust/runicnpc/install' },
{ label: 'Commands', slug: 'docs/rust/runicnpc/commands' },
{ label: 'NPC profiles', slug: 'docs/rust/runicnpc/npc-profiles' },
{ label: 'NPC placements', slug: 'docs/rust/runicnpc/npc-placements' },
{ label: 'Cost and caps', slug: 'docs/rust/runicnpc/cost-and-caps' },
{ label: 'Profile fields', slug: 'docs/rust/runicnpc/profile-fields' },
{ label: 'The API', slug: 'docs/rust/runicnpc/api' },
],
},
],
},
]; ];
/** /**
* The full planned tree, kept next to the live sidebar so phases 7 and 8 have their * The tree §10 planned, kept as the record of what was intended — every page it names now
* checklist in the place they will be working. Not exported into the Starlight config — * exists, as of phase 8.
* it names pages that do not exist yet. *
* It was the phases 7/8 checklist, and a checklist with nothing left on it is no longer
* pulling its weight: it is a second copy of the tree above, maintained by hand, and it had
* already drifted once (phase 7 added `Content` under D37 and this list was not updated,
* which nothing caught because nothing reads it). `checkSidebar.mjs` now asserts the two
* agree, which is what makes keeping it safe.
*
* Not exported into the Starlight config.
*/ */
export const plannedSidebar = { export const plannedSidebar = {
'Getting started': [ 'Getting started': [
@@ -58,20 +153,23 @@ export const plannedSidebar = {
'Install the site', 'Install the site',
'First run', 'First run',
'Install a game module', 'Install a game module',
'Connect a game server', 'Connect your game server',
'Verify the whole stack', 'Verify the whole stack',
], ],
Administration: [ Administration: [
'Configuration', 'Configuration',
'Branding and theming', 'Branding and theming',
'Navigation and pages', 'Navigation and pages',
'Content',
'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',
'Maintenance and upgrades', 'Maintenance and upgrades',
'Troubleshooting', 'Troubleshooting',
], ],
@@ -90,15 +188,71 @@ export const plannedSidebar = {
'The bridge', 'The bridge',
'Authentication architecture', 'Authentication architecture',
'Teams architecture', 'Teams architecture',
'Events architecture',
'Protocol versions', 'Protocol versions',
], ],
Reference: [ Reference: [
'Environment variables', 'Environment variables',
'Installer CLI', 'Installer CLI',
'sidecar.toml',
'Bridge.cfg',
'HTTP API', 'HTTP API',
'Event catalog',
'Canonical documents', 'Canonical documents',
], ],
'Ultima Online': [
'The Ultima Online module',
'Connect a ServUO shard',
'Verify the bridge',
'The shard connection',
'Client files',
'Troubleshooting',
'Bridge.cfg',
'sidecar.toml',
'Shard event catalog',
],
// PLAN.md §16, D70 (phase 15). RunicNPC's subgroup lands in phase 16 (D61).
Rust: [
'The Rust module',
'Install the bridge',
'Connect the site',
'Verify the bridge',
'Linking Steam accounts',
'Permissions and groups',
'Mod config from the site',
'Who sees who is online',
'Events, zones and rewards',
'Optional plugins',
'The live map',
'Troubleshooting',
'sidecar.toml',
'RunicGateway.json',
'Event kinds',
{
RunicNPC: [
'RunicNPC',
'Install RunicNPC',
'Commands',
'NPC profiles',
'NPC placements',
'Cost and caps',
'Profile fields',
'The API',
],
},
],
}; };
/**
* A group's items as flat labels, a nested group's pages written `Group › Page` after the
* group's own label. Both trees go through this, so `checkSidebar.mjs` compares nested
* groups as strictly as top-level ones, and `checkTerms.mjs` reads every label.
*
* In `docsSidebar` a nested group is `{ label, items }`; in `plannedSidebar` it is
* `{ Label: [pages] }`.
*/
export function flatLabels(items) {
return items.flatMap((item) => {
if (typeof item === 'string') return [item];
if (Array.isArray(item.items)) return [item.label, ...flatLabels(item.items).map((l) => `${item.label} › ${l}`)];
if ('label' in item) return [item.label];
return Object.entries(item).flatMap(([label, pages]) => [label, ...flatLabels(pages).map((l) => `${label} › ${l}`)]);
});
}

View File

@@ -5,7 +5,7 @@ description: Local accounts and two-factor, SSO providers and the link-only poli
import { Aside } from '@astrojs/starlight/components'; import { Aside } from '@astrojs/starlight/components';
**Admin → Authentication** has four tabs: Local Accounts, Google, Discord and Custom **Admin → Authentication** has five tabs: Local Accounts, Google, Discord, Steam and Custom
Providers. One session model sits behind all of them — a web cookie, a mobile bearer token Providers. One session model sits behind all of them — a web cookie, a mobile bearer token
and an SSO sign-in all produce the same session. and an SSO sign-in all produce the same session.
@@ -35,6 +35,19 @@ account is still a stranger.
Configuring Google here also unlocks **email delivery**, which reuses the same OAuth client Configuring Google here also unlocks **email delivery**, which reuses the same OAuth client
— see [Notifications and email](/docs/administration/notifications-and-email/). — see [Notifications and email](/docs/administration/notifications-and-email/).
### Steam
Steam signs people in with OpenID rather than OAuth, so it needs no client ID or secret — but
it does need a **Steam Web API key**, for the name and avatar Steam's sign-in does not carry,
and saving Steam as enabled without one is refused. It follows the same link-only policy, more
strictly: **Steam never creates an account**, whatever registration allows. A person may link
several Steam accounts, and each is listed on their account screen with its name, avatar and
Steam ID.
A game module can also be told which Steam accounts a signed-in person has proved are theirs,
which is how a game that knows its players by Steam account can recognise a member. The Rust
module uses it — see [Linking Steam accounts](/docs/rust/linking-steam-accounts/).
## Trusted devices ## Trusted devices
A second factor that asks on every sign-in on the same laptop trains people to click A second factor that asks on every sign-in on the same laptop trains people to click

View File

@@ -4,6 +4,7 @@ description: Colours, fonts and corners from the Appearance screen; logo, hero a
--- ---
import { Aside } from '@astrojs/starlight/components'; import { Aside } from '@astrojs/starlight/components';
import Screenshot from '../../../../components/Screenshot.astro';
One prebuilt image runs as any community's site. Nothing about your identity is compiled One prebuilt image runs as any community's site. Nothing about your identity is compiled
in — it is a theme row in the database, three image files on a mount, and a few environment in — it is a theme row in the database, three image files on a mount, and a few environment
@@ -31,6 +32,8 @@ Two things the screen tells you that are easy to miss:
- **The accent reaches the mobile app and the Discord bot**, both of which theme themselves - **The accent reaches the mobile app and the Discord bot**, both of which theme themselves
from this site's public branding. Changing it here changes them. from this site's public branding. Changing it here changes them.
<Screenshot id="admin-appearance" />
## Brand assets ## Brand assets
The same screen uploads three images, and each applies as soon as the upload finishes — The same screen uploads three images, and each applies as soon as the upload finishes —

View File

@@ -16,7 +16,7 @@ Two places hold configuration, and the line between them is not arbitrary.
The rule behind the split: **anything that needs a restart or a shell is environment; The rule behind the split: **anything that needs a restart or a shell is environment;
anything an administrator should be able to change without either is in the panel.** That anything an administrator should be able to change without either is in the panel.** That
is why the Discord bot token, the OAuth client secrets and the shard's auth token are *not* is why the Discord bot token, the OAuth client secrets and the game module's sidecar token are *not*
environment variables — they are entered in the panel and stored encrypted. environment variables — they are entered in the panel and stored encrypted.
## Settings ## Settings
@@ -59,9 +59,10 @@ 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/): it is Gmail over [Notifications and email](/docs/administration/notifications-and-email/): pick a mail
OAuth2, it reuses the Google authentication client, and it must be set up on the transport, enter its host, port and credentials, and send a test. It depends on nothing
[Authentication](/docs/administration/authentication/) page first. else on the site — a relay is the recommended posture, a mailbox provider over SMTP the
simplest, and your own MTA needs no credentials at all.
<Aside type="note" title="Until email is connected, the contact form is a mailto: link"> <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*

View File

@@ -27,7 +27,7 @@ glance.
<Aside type="caution" title="Publishing a news post announces it"> <Aside type="caution" title="Publishing a news post announces it">
Publishing is what triggers the announcement pipeline — the Discord `#news` leg, and any leg Publishing is what triggers the announcement pipeline — the Discord `#news` leg, and any leg
an installed module adds, such as the `uo` module's in-game town crier. It fires on an installed module adds, such as the Ultima Online module's in-game town crier. It fires on
publication, so an accidental publish is an accidental announcement. See publication, so an accidental publish is an accidental announcement. See
[Notifications and email](/docs/administration/notifications-and-email/). [Notifications and email](/docs/administration/notifications-and-email/).
</Aside> </Aside>

View File

@@ -0,0 +1,175 @@
---
title: Engagement rules
description: Decide what your site mails and shows people — the rule editor, saved audiences, the trigger catalog and the send log that answers "did they actually get it".
---
import { Aside } from '@astrojs/starlight/components';
[Notifications and email](/docs/administration/notifications-and-email/) is about where a
message goes. [Message templates](/docs/administration/message-templates/) is about what it
says. This page is the part in between: **what makes one get sent at all.**
A **rule** is four decisions — *when* (a trigger), *to whom* (an audience), *by what*
(channels), and *how often* (timing). **Admin → Engagement → Rules.**
## Nothing sends until you turn it on
Every rule arrives switched **off**. That is true of the ones you make, and it is true of
the ones your modules ship with them: install a game module and you get a shelf of ready
rules, all dark, none of them mailing anybody. Turning one on is a deliberate, separate
act.
The same caution runs through the rest of the screen. Every rule carries a **hard ceiling
on sends per hour** — you cannot save one without a number — because the failure mode of an
automated mailer is not a wrong message, it is ten thousand of them at four in the morning.
<Aside type="caution" title="Upgrading? Your Team emails are here now">
Team notification email used to be its own pipeline. It is engagement rules now, and — like
every other seeded rule — those four rules arrive **disabled**. If your members were getting
Team mail before an upgrade, it stops until you turn them on. The admin dashboard says so
while it is true.
</Aside>
## What a rule is made of
**The trigger** is the event that fires it: a house falling into disrepair, a post being
published, a login failing. Pick it from what is registered — see
[the catalog](#the-trigger-catalog) below. A rule's trigger is **fixed once the rule
exists**: its cooldowns, its pending messages and its whole send history are about one
event, so changing it would silently be a different rule wearing the same name. Make a new
one instead.
**The audience** is who hears about it. Some are built in — the person the event is about,
everyone subscribed to it, staff. Others come from your modules and are named in their own
vocabulary. You can also point a rule at a **saved audience** you composed yourself; see
[Audiences](#audiences).
**The channels** are how it reaches them: on the site, by email, by push. A rule can name
more than one, and each channel picks its own template — the same event can be a sentence
in the inbox and a properly laid-out letter in the mail.
**The timing** is the part worth reading twice.
- A **delay** holds the message before it goes, so a situation that resolves itself never
produces a message at all.
- **Cancel on** names the events that call it back. A warning that a house is about to
collapse waits fifteen minutes and is cancelled outright if the owner turns up and
repairs it — nobody is told their house was in danger after it stopped being in danger.
- A **cooldown** is the "not again for a while" limit, counted **per person, per subject
and per channel**. Per subject, so a cooldown about one house says nothing about another.
Per channel, so "one a day about this house" means one email *and* one inbox item, which
is what an operator setting that limit means.
## Audiences
**Admin → Engagement → Audiences** is where you build a named set of people out of the ones
your modules declare — *members of this Team*, *the sitting governors* — and combine them:
all of these, any of these, none of these.
One rule governs the whole screen: **composition narrows and never widens.**
- The ceiling of a saved audience is **derived** from the tightest thing in it, never
chosen. That is true of "any of" too, where the intuitive answer — the widest of the two —
is the wrong one. A ceiling says what an expression is *allowed* to reach, not what it
happens to resolve to today.
- **"None of" is only offered inside an "all of" group.** Alone it would have to mean
"everybody except these", which is a broadcast built out of a short list, and it is not
offered anywhere it would mean that.
- Two audiences with no relationship between them — staff and "the person this is about",
say — have no honest combined ceiling, so the save is refused rather than guessing which
side to take.
Before you save a rule, the editor shows you a **reach preview**: a number, never a list of
names. It will also tell you when a number is a floor rather than an answer, and when an
audience resolves to nobody at all and why.
## The ceiling, and why a rule will not offer the audience you expected
Every trigger declares the **widest audience a rule may ever give it**. It is the security
boundary of the whole system, and it is set in code by whoever declared the event, not in
the admin panel. Staff-only events cannot be widened into public ones by anybody, including
you.
Seven values, and they are a **tree, not a ladder**:
| Ceiling | Who that is |
| --- | --- |
| `everyone` | Everyone, including signed-out visitors |
| `authenticated` | Any signed-in user |
| `subscribers` | Signed-in users subscribed to this event |
| `members` | Members of a module-declared list |
| `staff` | Staff only — admins, editors and moderators |
| `admin` | Administrators only |
| `owner` | Only the user the event is about |
<Aside type="note" title="Fewer people is not less exposure">
The tempting reading is a ladder — that a staff-only event could obviously also go to just
one person. It cannot, and the example is the whole argument: cheat detection is a
staff-only event, and "just one person" would be *the player it was detected on*. The
question a ceiling answers is never how many, it is **which**.
</Aside>
So `staff` does not permit `owner`, `members` does not permit `subscribers`, and the editor
simply does not offer you the audiences the trigger forbids. The one exception proves the
rule: `admin` sits under `staff`, because every administrator really is staff.
## The trigger catalog
**Admin → Engagement → Triggers** lists every event a rule can be built on, and it is
read-only on purpose — **there is no table behind it**. A trigger is declared in code, by
the site or by an installed module, so what you are looking at is whatever registered on
this boot. Uninstall a module and its triggers stop appearing; nothing was deleted.
Two things it shows that are invisible everywhere else:
- **The variables** each event carries, with an example of each. This is the list a template
is allowed to reference — when a message comes out with a hole in it, this is the screen
that says why.
- **The ceiling**, so when the rule editor offers you a narrower set of audiences than you
expected, you can see the number it is obeying.
### Dormant rules
A rule can be switched on and still be unable to fire — most often because the module that
declared its trigger, or the audience it points at, is no longer installed. Those are
badged **dormant** in the list, with the reason, because "this rule cannot fire" is a
different fact from "this rule is off" and you need both. The on/off switch keeps working
on a dormant rule, deliberately: a rule whose module has gone is exactly the rule you most
want to be able to stop.
## The send log
**Admin → Engagement → Send Log** answers one question: *did that person get that message,
and if not, why not?* Every attempt is a row — when, what fired it, which user, which
channel, and the result. Filter by result to go straight to what failed.
| Result | What it means |
| --- | --- |
| **Sent** | Handed to the channel successfully |
| **Failed** | The attempt errored — the reason is on the row, not hidden in a tooltip |
| **Not sent** | Suppressed before it was attempted: unsubscribed, unverified, or on the [suppression list](/docs/administration/troubleshooting/) |
| **Bounced** | The receiving server rejected it after the fact |
| **Marked as spam** | The recipient reported it |
Test sends from the template editor land here too, labelled as such, so you can confirm
your own test arrived before turning a rule on for real.
<Aside type="tip" title="Two things it will not show you, on purpose">
**The email address.** The log stores a one-way hash of it — enough to tie a bounce back to
a delivery, not enough to become a second address book.
**A name.** It holds the user id, and that is deliberate: joining the account list in would
quietly turn a delivery log into a staff-readable directory. Paste the id into Moderation,
which is where a person's record belongs.
</Aside>
## What a game module brings
A module declares its own triggers and its own audiences, in its own vocabulary, and it may
ship rules and message bodies to go with them. The Ultima Online module ships a large family
of them — houses falling to ruin, vendors running out of gold, a governor being seated, a
Team's fortunes — written in the voice of an in-world office rather than a system alert.
All of them arrive **disabled**, like every other seeded rule. Read the list in
**Admin → Engagement → Triggers**, turn on the ones your site should send, and check the
send log the first time each one fires.

View File

@@ -0,0 +1,209 @@
---
title: Scheduled events
description: Author an event as phases and steps, price it against this deployment's caps before it runs, and let it change a live game world unattended — with a ledger that makes the undo automatic.
---
import { Aside } from '@astrojs/starlight/components';
import GameExample from '../../../../components/GameExample.astro';
An **event** is a scheduled, bounded, audited change to a live game world. You write it once
as a sequence of phases, publish a version of it, put it on the calendar, and it runs — at
four in the morning if that is when you scheduled it, with nobody watching.
That last clause is the whole reason this feature is shaped the way it is. Everything below
that looks like extra ceremony — the switchboard, the caps, the dry run, the ledger — is
there because the thing being automated is somebody's game world, and the person who
authored the change is asleep when it happens.
**Core owns the engine; the installed module owns the meaning.** Core decides whether an
action is permitted, when it runs, in what order, how many times, within what budget, what
it created and who is told. The module says which verbs exist and performs them. Core never
learns a game word: every label you see in the step editor came from the module that
registered it.
## Where it is
**Admin → Events**, its own group in the sidebar:
| Row | Who sees it |
|---|---|
| **Events** — the definitions, and their runs | Admin, editor, moderator |
| **Calendar** — month and list view, with series | Admin, editor, moderator |
| **Actions** — what this deployment permits, and the caps | **Admin only** |
| **My participation** — your own attendance | Everyone |
Reading is staff-wide on purpose. A moderator's power over this feature is the **run
console** — the screen you open when an event is doing something wrong at two in the
morning — and hiding it from the one role that exists for incident response would be a
strange way to build an incident tool. The narrower gates are on the actions, not the rows:
authoring is admin and editor, publishing a version and starting a run are admin only, and
all of it is enforced on the server rather than by hiding a button.
## Authoring
A **definition** is the thing that gets listed, searched, scheduled and audited: a title, a
slug, a storyline, a schedule, and an ordered list of **phases**. Each phase holds **steps**,
and a step is one action with its parameters.
A phase advances on a condition — after a duration, or when something happens in the game a
given number of times. The vocabulary of "something that happens" is the trigger catalog the
installed module already ships, so a module gains phase conditions by declaring one more
entry in a list it already had.
<Aside type="note" title="A timeline, not a node graph">
The phase editor is a vertical list, deliberately. The condition grammar has no branching —
it is `and` / `or` / `not` over comparisons and nothing else — and a canvas would advertise
power the engine does not have. Phases in order, each with its steps, its advance condition,
its budget draw and its failure policy, is exactly what it can do.
</Aside>
### Versions are immutable, and a run pins one
Publishing takes a snapshot. The run that starts on Saturday holds the version that was
published, not the one you edited on Friday — which is what makes a run reproducible and an
audit answerable after a change. **A running event cannot be edited**; you edit the
definition, publish a new version, and the next run picks it up.
## Nothing is enabled until you enable it
**Admin → Events → Actions** lists every action the installed modules registered, and
**everything above a notification arrives switched off.** Installing a module must never
start doing things to your world.
Each row has two controls: whether the action is permitted on this deployment at all, and its
**per-run caps** — how much of a budget dimension one run may consume. Dimensions are
declared by the module, and consumption is counted in the database with a conditional
update, not checked in application code.
<GameExample game="uo">
The Ultima Online module declares `uo.creatures`, `uo.bosses`, `uo.rewards` and others —
one per kind of thing an event can spend.
</GameExample>
That distinction matters more than it sounds. A stolen admin session has already passed every
role check there is; it still cannot exceed the cap, because the cap is a condition on the
`UPDATE` that spends the budget.
<Aside type="caution" title="A cap breach is a refusal, not a failure">
A step that would exceed a cap does not run, does not retry, and is recorded `refused` with
the dimension and both numbers — *"asks for 12 of `uo.creatures`; 0 of 5 is already spent this
run"*. That is an authoring mistake being reported to the author, not an outage.
</Aside>
## Dry run before anything unattended
**Verify** materialises the whole plan without touching the world: every step is dispatched
with a verify flag, and you get back what *would* happen and what it *would* cost against the
caps, in the module's own words. Refusals show up here, before the calendar entry exists.
A definition that has never been verified is exactly the one worth not scheduling. Verifying
is cheap, and it is the last point a human sees the plan.
## Running one
Runs start on the schedule, or by hand. A **series** groups definitions into an arc, so a
three-part story reads as one thing on the calendar rather than three unrelated entries.
The **run console** shows live status, the steps and their attempts, the budget consumed
against each cap, any failures, and the cleanup. Its controls are:
- **Pause** and **resume** — resume carries a run past any settled step, including one that
failed or was refused.
- **Skip**, **retry** and **confirm** a single step. *Confirm* is how a human-cue step
advances: the run posts the instruction, waits, and moves on when somebody says they did it.
- **Advance** a phase by hand.
- **Cancel**, with or without cleanup.
Every one of those is logged with the person who did it.
## What an event does to a world, and how it is undone
Two different things, and the difference is the whole safety story.
**What it owns.** Creatures, bosses, oracle NPCs, decoration, a temporary gate — things the
run created. Each one is written to a **resource ledger** as it is made, with the run and
step that made it.
**What it borrows.** A spawner's respawn timer, a starting skill cap, a seasonal flag — values
that already existed and are being changed for the duration. Those are **leases**: the game
keeps the original, the site records both halves, and the lease carries its own deadline.
<Aside type="tip" title="Cleanup is generated, never authored">
There is no undo phase for you to write, and that is on purpose: an operator cannot be relied
on to write the undo, and an aborted run never reaches the phase they wrote it in. Teardown
steps are derived from the ledger and run on **every** terminal path — completion,
cancellation and abort alike.
A lease is safer still. The game restores the baseline when the deadline passes whether or not
it ever hears from the site again, and a lease is never written to disk — so a game-server
restart puts every borrowed value back too.
</Aside>
## The game server has its own switches
They live in the bridge plugin's config on the game server's host, not in the site's
database, and no event can turn them on.
**`EventsEnabled` is off by default**, in both games. Turning it on is consenting to the site
changing and watching your world unattended.
<GameExample game="uo">
`EventsEnabled` is a different switch from `AdminWriteEnabled`. Turning the admin plane on is
consenting to staff moderation driven from a screen somebody is looking at; turning events on
is consenting to unattended changes. One switch could not honestly express both. Every key
is in [`Bridge.cfg`](/docs/ultima-online/bridge-cfg/), which only the shard host can edit.
</GameExample>
<GameExample game="rust">
`EventsEnabled` is in each server's [`RunicGateway.json`](/docs/rust/runicgateway-json/). An
administrator can edit it on the host, or from the site's
[Mod config](/docs/rust/mod-config/) screen, which reaches the game host's config files
through the bridge.
</GameExample>
Beside it sit the game's own ceilings — how much one call may place, how long something it
creates may stand, how much one run may own in total. **They refuse rather than clamp**, for
the same reason the caps do: a quietly shortened request leaves the two halves disagreeing
about what actually happened.
## What players see
The public calendar at `/site/events` carries what is scheduled, what is happening now, what
finished recently, and published results. A run that was cancelled says so — *"Did not
happen"* — rather than quietly disappearing.
**Listing is separate from publishing.** A definition has its own *listed* switch, because
publishing is what makes an event runnable and a surprise invasion should not have to be
advertised a fortnight in advance in order to be allowed to happen. Unlisting hides the
definition, its runs and its results from the public pages and from a participant's own
history; it hides nothing from staff.
Where a module can tell who took part, a run can keep a **participation ledger** — scores and
ranks, published as a results table when the run finishes. Ranks are computed at publication
and stored, so somebody added afterwards does not silently renumber a table people have
already read. A signed-in person sees their own attendance under their account, and staff see
theirs on the same screen.
## When something goes wrong
- **`degraded` is not `failed`.** If the game server disappears mid-run, the run degrades,
world-changing steps park unattempted, and it recovers when the connection does. The public
page does not say so — that is operator information.
- **`refused` means a bound said no**, and it is reported with the numbers.
- **The run log answers "why did phase 3 not start?"** as a query, not by reading a wall of
text. It is kept for 90 days after a run reaches a terminal state — and a run still in
flight keeps every line it has, however old, because the question it answers is still open.
- **Cleanup can be re-run** from the run console if a teardown was interrupted.
## Where the record is
[`website/EVENTS.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/EVENTS.md)
is the design of record — the model, the data, the security argument and what was deliberately
left out.
[`website/MODULE_API.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md)
is the contract a module registers its verbs against, and
[`link/ADMIN_CONTROLS.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/ADMIN_CONTROLS.md)
is what the site may ask a game to do at all.
For how the engine is put together, see
[Events architecture](/docs/architecture/events-architecture/).

View File

@@ -102,7 +102,7 @@ Three rules for whatever proxy you use:
- **Terminate TLS at the proxy.** The app speaks HTTP; it is not meant to hold a - **Terminate TLS at the proxy.** The app speaks HTTP; it is not meant to hold a
certificate. certificate.
## Upgrading the shard side ## Upgrading the game server side
A different deployment on a different host, and it moves on its own schedule: A different deployment on a different host, and it moves on its own schedule:
@@ -111,15 +111,18 @@ sudo runicgateway update # re-resolves the bundle; --verify to see it first
sudo runicgateway doctor # confirm afterwards sudo runicgateway doctor # confirm afterwards
``` ```
`update` replaces the sidecar and restarts its service, re-syncs the overlay, and tells you `update` replaces the sidecar and restarts its service, redeploys the bridge plugin, and
when ServUO needs restarting — it never restarts your shard itself. Because it resolves a tells you when the game server needs restarting — it never restarts your game server itself.
**bundle**, the sidecar and the plugin move together and cannot end up disagreeing about the Because it resolves a **bundle**, the sidecar and the plugin move together and cannot end up
protocol. disagreeing about the protocol. Add the game's own flags as you did at install; see the
[Installer CLI](/docs/reference/installer-cli/).
Each game can have its own day-two chores on top of this. Ultima Online's are in [its
group](/docs/ultima-online/client-files/#after-a-client-update).
<Aside type="note" title="Update the two sides in either order, but verify after each"> <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
pair until both sides are current — visible on pair until both sides are current — visible on the game module's connection screen, and
[the shard connection screen](/docs/administration/the-shard-connection/), and not silent not silent corruption.
corruption.
</Aside> </Aside>

View File

@@ -4,10 +4,13 @@ description: The five states a module can be in, installing and upgrading, disab
--- ---
import { Aside } from '@astrojs/starlight/components'; import { Aside } from '@astrojs/starlight/components';
import Screenshot from '../../../../components/Screenshot.astro';
Installing your first module is [Getting started](/docs/getting-started/install-a-game-module/). Installing your first module is [Getting started](/docs/getting-started/install-a-game-module/).
This is what the screen means afterwards. This is what the screen means afterwards.
<Screenshot id="admin-modules" />
## The five states ## The five states
`installed → enabled → started`, with `disabled` and `startup_failed` as recoverable `installed → enabled → started`, with `disabled` and `startup_failed` as recoverable
@@ -38,6 +41,10 @@ Two consequences of how boots work:
Paste the new release's install-manifest URL and press Install. The bundle is verified Paste the new release's install-manifest URL and press Install. The bundle is verified
against its `sha256`, unpacked over the old one, and takes effect at the restart. against its `sha256`, unpacked over the old one, and takes effect at the restart.
**A site runs one module.** Installing a module with a different id beside the one you have
is refused with `409`, and nothing is downloaded. To change game, remove the installed module
first.
An upgrade **deliberately leaves the state alone**: upgrading an enabled module must not An upgrade **deliberately leaves the state alone**: upgrading an enabled module must not
silently switch it off, and re-installing a disabled one must not silently switch it on. silently switch it off, and re-installing a disabled one must not silently switch it on.

View File

@@ -0,0 +1,133 @@
---
title: Message templates
description: Edit what your site's email actually says — the block editor, the variable palette, the preview, test sends, and the send log that tells you whether a message arrived.
---
import { Aside } from '@astrojs/starlight/components';
Every message your site sends — password resets, invitations, notifications — is a
**template** you can edit. They ship working, so a fresh site mails correctly before you
open this screen at all. You come here when you want it to sound like your community.
**Admin → Engagement → Templates.**
## What is in the list
Each row is one message. The ones marked **system** are the ones the site itself depends
on: the password reset, the invitation, the address-confirmation mail. You can edit every
word of those, but you cannot delete them — a site with no password-reset body is a site
where nobody can get back in.
The rest are the general-purpose bodies that rules send. Those you can delete, as long as
no rule is currently pointing at one.
<Aside type="tip" title="Your edits survive upgrades">
When you edit a shipped template, the site remembers that a person changed it. Later
versions may ship an improved default for the same message — and it will **not** be applied
over your words. You will see a note on the row telling you a newer default exists, and it
is up to you whether to look at it.
</Aside>
## Editing a message
The editor has the message on the left and a live preview on the right.
### The body is blocks, not HTML
You build a message out of pieces: a heading, a paragraph, a button, a divider, an image,
or an item list. Add one from the row of buttons, click it to edit it, and use the arrows
to move it. There is no HTML to write, which is deliberate — email HTML is a genuinely
horrible format, and the blocks already produce something that survives Outlook.
### Variables are chosen, never typed
Under most text fields is a row of small grey names: `siteName`, `resetUrl`, `title`. Those
are the **variables** this particular message is given when it is sent. Click one and it is
inserted as a token; the message that goes out has the real value in its place.
You cannot invent a variable. If you type one the message is not given — a typo, or a name
you remembered from a different message — the save is refused and the error names the
variable. That is on purpose: a variable that does not exist renders as *nothing*, so
without the check the mistake would be invisible until it reached somebody's inbox as a
sentence with a hole in it.
To see every variable a given event provides, with an example of each, look at
**Admin → Engagement → Triggers**.
### Both halves of the message
Every email goes out in two forms: the designed HTML one, and a plain-text one for clients
that will not show HTML. The plain-text half is generated from your blocks automatically,
and you can see it under the **Plain text** tab.
If the generated version is not good enough, write your own in **Plain-text part** at the
bottom of the editor. Whatever you write there replaces the generated text completely.
A published message must have *something* in its text part. If every block you used
contributes nothing to it — a message made only of dividers and images, say — the save is
refused.
### Draft and published
A **draft** is not what goes out. While a message is a draft, the site sends the shipped
default in its place, so you can leave something half-finished without breaking anything.
Switch it to **Published** when you want your version to be the one people receive.
## The preview
The preview is rendered by the server using the same code that renders the real message, so
what you see is what will arrive — not an approximation drawn by the browser.
It fills the variables in with example values, so you never need to trigger a real event to
see what a message looks like.
Three controls are worth knowing:
- **Desktop / Mobile** — the same body at a reading-pane width and a phone width.
- **Dark mode** — an approximation of what mail clients that invert light messages will do
to yours. Worth a glance: a design that relies on a light background can come out as
dark-on-dark for a large minority of readers.
- **Plain text** — the other half of the message, as described above.
## Sending yourself a test
The **Send a test** box sends the message to any address you type, through whatever mail
transport the site is configured with (**Admin → Settings → Email delivery** — see
[Notifications and email](/docs/administration/notifications-and-email/)).
It sends **what is on screen**, saved or not. That is the point of it: try a wording, send
it to yourself, look at it in a real inbox, and only then decide whether to save.
Test sends are recorded in the send log like any other message, including when they fail.
## Making a new template
You do not start from a blank page. Pick a message that is close to what you want, press
**Duplicate**, and give the copy a key.
The **key** is how a rule refers to the template — `notify.team-welcome`, say. Lowercase
letters, digits, dots and dashes, and it cannot be changed later, so pick one that will
still make sense in a year.
The copy always starts as a draft. Once you are happy with it, publish it and point a rule
at it in **Admin → Engagement → Rules**.
<Aside type="caution" title="A template a rule is using cannot be deleted">
If you try, the site tells you which rules are still pointing at it. Repoint or delete
those first. The alternative — letting the delete through — would leave a rule that quietly
stops producing mail, and nothing on screen would say why.
</Aside>
## Did it arrive?
**Admin → Engagement → Send Log** lists every message the site tried to deliver, newest
first, successes and failures alike. When mail is not arriving, this is the screen that
tells you whether the site tried and the relay refused, or whether it never tried at all.
Failures carry the reason the mail server gave, which is usually the actual answer — a
rejected sender address, a bad password, a relay that will not accept your domain.
The log does not store anybody's email address. It keeps a one-way fingerprint instead, so
that a bounce can be matched back to a delivery without the log itself becoming a second
copy of your members' addresses. The rest of that screen — and the rules that decide a
message is sent at all — is [Engagement rules](/docs/administration/engagement-rules/).

View File

@@ -10,7 +10,7 @@ not overlap.
| Screen | Is about | Comes from | | Screen | Is about | Comes from |
|---|---|---| |---|---|---|
| **Moderation** | Your **Discord** guild — bans, kicks, mutes, warnings, joins, leaves, filter and spam hits | the bot, captured live | | **Moderation** | Your **Discord** server — bans, kicks, mutes, warnings, joins, leaves, filter and spam hits | the bot, captured live |
| **Reports** | **Team forum content** members have reported | the site | | **Reports** | **Team forum content** members have reported | the site |
| **Appeals** | Sanctions people are asking you to reverse | the site | | **Appeals** | Sanctions people are asking you to reverse | the site |

View File

@@ -1,6 +1,6 @@
--- ---
title: Notifications and email title: Notifications and email
description: Email over Gmail OAuth2, the announcement pipeline and its legs, the Discord bot, and opt-in push to the mobile app. description: Email over SMTP, the announcement pipeline and its legs, the Discord bot, and opt-in push to the mobile app.
--- ---
import { Aside } from '@astrojs/starlight/components'; import { Aside } from '@astrojs/starlight/components';
@@ -9,21 +9,67 @@ Four separate delivery paths, each optional, each off until you configure it. A
configures none of them still works — it just never reaches anyone who is not looking at 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 (and test **Admin → Settings → Email delivery.** The site sends contact-form messages, invitations,
messages) through **Gmail over OAuth2**, delivered to the *Contact email* setting. password resets, team notifications and test messages through **SMTP**. Contact-form mail
goes to the *Contact email* setting.
It reuses the **Google authentication client**, so the order is fixed: configure Google on You pick a mail transport and fill in the fields it asks for. There is no consent flow and
the [Authentication](/docs/administration/authentication/) page first, then press **Connect no redirect to bounce through — it is a form, and the credentials go straight into the
Gmail** here. Until then the panel reads *Unconfigured* and says exactly that. database encrypted at rest, write-only: the panel will tell you a password is *set*, and
will never show it to you again.
The refresh token it stores is encrypted at rest like every other secret. ### Three ways to point it somewhere
<Aside type="note" title="There is no SMTP option"> Any SMTP server works. Which one you should use depends on how much mail you expect to send.
Gmail over OAuth2 is the only supported delivery path today. Until it is connected, the
contact form falls back to a `mailto:` link to the contact address — which works, and puts **A relay — the recommended one.** Mailgun, SES, Postmark or equivalent: their host, port
the message in the visitor's own mail client rather than in your logs. `587`, *Implicit TLS* **off**, and your API key as the password. Deliverability is the hard
part of sending mail — reputation, DKIM, bounce handling — and this is the option where
somebody else owns it. Use this for anything with real volume.
**A mailbox provider over SMTP — the simplest.** For example `smtp.gmail.com`, port `587`,
*Implicit TLS* **off**, your address as the username, and an
[app password](https://support.google.com/accounts/answer/185833) — not your account
password, and it requires 2-Step Verification to be on. Fine for a small site; subject to
the provider's daily send caps.
**Your own MTA.** If you already run mail on the same host: its address, port `25`,
*Implicit TLS* **off**, username and password blank. The site treats a username with no
password as incomplete, since that authenticates as nobody.
<Aside type="caution" title="The two fields that cause most failures">
**Implicit TLS** belongs *on* only for port **465**. On port `587` leave it **off** — the
connection still upgrades to TLS, using STARTTLS. Port 587 with it on does not report an
error; it hangs.
**Send from** must be an address the account is allowed to send as. Unlike a username, this
is not verified when you save it — a server that refuses your sender rejects the mail for
SPF/DMARC reasons that look like nothing at all from the outside. **Send test** is what
proves it, and it names this specifically when it happens.
</Aside>
Until a transport is configured, the contact form falls back to a `mailto:` link to the
contact address — which works, and puts the message in the visitor's own mail client rather
than in your logs. Invitations surface a copyable accept link instead, and password resets
still answer normally.
<Aside type="note" title="Upgrading from the Gmail connect flow">
Earlier versions authorised a mailbox with a **Connect Gmail** consent flow that borrowed
the Google authentication client. That flow has been removed.
If your site used it, mail **stops** on upgrade until you enter SMTP credentials — and
nothing errors when it does, because every sender degrades politely. The admin dashboard
warns you while it is true. `smtp.gmail.com` port 587 with an app password is the shortest
route back.
Single sign-on is unaffected: the Google provider exists for SSO in its own right, and email
merely borrowed its credentials. Removing the borrow also removes a trap — rotating the SSO
secret used to break outbound mail silently.
</Aside> </Aside>
## Announcements ## Announcements
@@ -36,7 +82,7 @@ at the wrong host.
Which legs exist depends on what has registered one: Which legs exist depends on what has registered one:
- **Discord `#news`** is core's, and needs the bot below. - **Discord `#news`** is core's, and needs the bot below.
- **A module may add its own.** The `uo` module adds an in-game town crier, so a news post - **A module may add its own.** The Ultima Online module adds an in-game town crier, so a news post
is announced to players who are logged into the game and never visit the site. is announced to players who are logged into the game and never visit the site.
A leg brings its own settings with it — the town crier's duration is a module setting, not A leg brings its own settings with it — the town crier's duration is a module setting, not
@@ -44,7 +90,7 @@ a core one — which is why they are documented with the module rather than here
## The Discord bot ## The Discord bot
**Admin → Discord Bot**: enable it, give it the guild (server) ID and the bot token, and **Admin → Discord Bot**: enable it, give it the **Guild (server) ID** and the bot token, and
save. The token is stored **encrypted in the database** and is never an environment save. The token is stored **encrypted in the database** and is never an environment
variable. variable.
@@ -75,14 +121,45 @@ Two properties matter for what you have to trust:
Without `NTFY_PUBLIC_URL` / `NTFY_ALLOWED_ORIGINS`, the app simply shows push as 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: each member The per-person side of this lives in the player portal, not the admin panel: **Account →
chooses which Team and forum notifications they want, and how. Two defaults are worth Notifications → Settings** is a grid of every notification against every channel, and each
knowing because they are not symmetrical: member sets their own. The defaults are not symmetrical, and the asymmetry is deliberate:
- **Push is opt-out** once a device is registered.
- **Email is opt-in.** - **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

View File

@@ -9,8 +9,8 @@ Teams are a core platform primitive: membership, roles, forums, notifications, m
and the Discord integrations are all core's, and none of it knows what a Team *is* in your and the Discord integrations are all core's, and none of it knows what a Team *is* in your
game. game.
**Core cannot create a Team.** Teams arrive from the installed module — with the `uo` **Core cannot create a Team.** Teams arrive from the installed game module, which maps
module, they are the shard's guilds. On a deployment with no module, the Team machinery is whatever the game calls its groups of players onto a Team. On a deployment with no module, the Team machinery is
present and permanently empty. That is not a bug to work around; it is the contract that present and permanently empty. That is not a bug to work around; it is the contract that
lets the same forum, notification and moderation code serve any game. lets the same forum, notification and moderation code serve any game.
@@ -25,15 +25,14 @@ reconciles them.
**Admin → Teams** shows a sync panel per module: last attempt, last success, consecutive **Admin → Teams** shows a sync panel per module: last attempt, last success, consecutive
failures and the last error, with **Sync now** and **Resync now**. failures and the last error, with **Sync now** and **Resync now**.
The wording on that panel is exact and worth reading: The wording on that panel is exact and worth reading. Until a sync has succeeded, it says
that core has never had an answer it could trust, and that the list below it is not a
> Core has never had an answer it could trust. What is shown below is not a confirmed empty confirmed empty one.
> shard.
An empty list therefore means one of two very different things — there are no Teams, or An empty list therefore means one of two very different things — there are no Teams, or
nobody could ask. The panel tells you which, and a *last success: never* with a *last nobody could ask. The panel tells you which: a *last success: never* with a *last error*
error* of `no uo-link configured` is the second. Fix that names the game connection is the second. Fix the game module's connection to its game
[the shard connection](/docs/administration/the-shard-connection/) and sync again. server and sync again. Each game's group in the sidebar covers its own connection screen.
## Forums ## Forums
@@ -48,6 +47,52 @@ 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**.
@@ -59,8 +104,8 @@ event leaves the site.
**Voice channels** — gives each Team a Discord voice channel of its own, with access **Voice channels** — gives each Team a Discord voice channel of its own, with access
granted by a per-Team role, so a Team's members can see and join theirs and nobody else granted by a per-Team role, so a Team's members can see and join theirs and nobody else
can. It needs the bot reachable, and members need a linked Discord account and guild can. It needs the bot reachable, and members need a linked Discord account and membership of
membership. your Discord server.
Its three settings deserve a thought each: Its three settings deserve a thought each:
@@ -68,10 +113,10 @@ Its three settings deserve a thought each:
|---|---| |---|---|
| **Minimum members** | How large a Team must be to get a channel. Every active member counts, linked account or not. | | **Minimum members** | How large a Team must be to get a channel. Every active member counts, linked account or not. |
| **Grace window (days)** | How long a Team keeps its channel after it stops qualifying. A Team that recovers inside the window keeps the same channel; zero removes it on the next pass. | | **Grace window (days)** | How long a Team keeps its channel after it stops qualifying. A Team that recovers inside the window keeps the same channel; zero removes it on the next pass. |
| **Staff roles** | Roles that can see and join every Team's channel. Guild administrators already can, so this is for staff who are not administrators. | | **Staff roles** | Roles that can see and join every Team's channel. Discord server administrators already can, so this is for staff who are not administrators. |
<Aside type="caution" title="Voice channels are a per-guild ceiling, not a per-Team one"> <Aside type="caution" title="Voice channels are a per-server ceiling, not a per-Team one">
Discord's role and channel limits apply to the whole guild, so a site with many small Teams Discord's role and channel limits apply to the whole Discord server, so a site with many small Teams
can exhaust them. The minimum-members setting is the lever that keeps the count sane, and can exhaust them. The minimum-members setting is the lever that keeps the count sane, and
it is easier to raise it before provisioning than to unpick channels afterwards. it is easier to raise it before provisioning than to unpick channels afterwards.
</Aside> </Aside>

View File

@@ -67,44 +67,98 @@ deployment has nothing supervising it — `npm start` in a terminal, a container
## The game screens are empty or say offline ## The game screens are empty or say offline
Work outwards from the game, and stop at the first check that fails. Work outwards from the game, and stop at the first check that fails. The order is the same
for every game; the commands are each game's own.
1. **In game:** `[bridge status` — `connected=False` means the shard cannot reach the 1. **On the game server:** the bridge plugin says whether it is connected to the sidecar.
sidecar. 2. **On the game server's host:** the sidecar's `/health` answers without a token, and
2. **On the shard host:** `curl -s http://127.0.0.1:8080/health` — `plugin_connected: true` *plugin connected* is the value that matters.
is the value that matters. 3. **On the game server's host:** `runicgateway doctor` checks the install record, the
3. **On the shard host:** `runicgateway doctor` — checks the install record, every overlay files it deployed, the service, and that the sidecar and the bridge plugin agree on a
file hash, the service, and that the sidecar and overlay agree on a protocol. protocol.
4. **On the site:** the [shard connection screen](/docs/administration/the-shard-connection/) 4. **On the site:** the game module's connection screen says which link is broken.
— its four indicators say which link is broken.
Two log lines with specific meanings: **`409`** is a protocol mismatch (set the Protocol Two responses from a sidecar mean the same thing for every game: **`409`** is a protocol
field to what `/health` reports), and **`401`** is the auth token (read the live one back mismatch (the sidecar refuses rather than mis-parsing), and **`401`** is the auth token.
with `uo-link-sidecar --print-config`; do not retype it from a screenshot).
<Aside type="note" title="“Nothing changed and it stopped working” usually means a ServUO update"> Each game's group in the sidebar has its own troubleshooting page with the exact commands:
An update to the server tree can revert `Scripts.csproj`, at which point the plugin sits in [Ultima Online](/docs/ultima-online/troubleshooting/) and [Rust](/docs/rust/troubleshooting/).
the tree and never compiles — and ServUO ignores the script build's exit code, so the boot
looks clean. `doctor` catches it by comparing file hashes against the install record.
</Aside>
## 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
`no uo-link configured` means the shard connection, not the Team machinery. And on a site an error naming the game connection means the module could not ask its game server, not
with **no module installed**, an empty Team list is correct and final — core cannot create that the Team machinery is broken. And on a site with **no module installed**, an empty
a Team. See [Teams](/docs/administration/teams/). Team list is correct and final — core cannot create 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 connected; that is the - **The contact form opens a mail client.** Email delivery is not configured; that is the
documented fallback. Connect Gmail in **Settings → Email delivery** — after configuring documented fallback. Enter SMTP credentials in **Settings → Email delivery**. If this site
the Google provider, which it reuses. used to send mail and stopped, the Gmail connect flow was removed — the admin dashboard
says so, and [Notifications and email](/docs/administration/notifications-and-email/) has
the migration.
- **Mail is configured but nothing arrives, and there is no error.** Two usual causes, both
invisible without a test send. *Implicit TLS* left on for port 587 hangs rather than
failing; and a **Send from** address the server will not let you send as is rejected for
SPF/DMARC reasons. Press **Send test** — its failure message names both cases.
- **Email was working and the toggle is still on.** *Enable email sending* now gates every
message, not just some of them. If it is off, nothing is sent, including the contact
form.
- **A published post announced nothing.** The Discord bot is a separate container. If the - **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

View File

@@ -4,6 +4,7 @@ description: The four roles and what each one reaches, creating accounts, and in
--- ---
import { Aside } from '@astrojs/starlight/components'; import { Aside } from '@astrojs/starlight/components';
import Screenshot from '../../../../components/Screenshot.astro';
## The four roles ## The four roles
@@ -24,6 +25,8 @@ Admin routes are re-validated against the database on **every request**, not jus
Demoting an account takes effect at once — the open session does not keep its access until Demoting an account takes effect at once — the open session does not keep its access until
it expires. it expires.
<Screenshot id="admin-users" />
## Creating an account ## Creating an account
**Admin → Users → + Add user** creates one directly: username, password, role, and it is **Admin → Users → + Add user** creates one directly: username, password, role, and it is

View File

@@ -0,0 +1,123 @@
---
title: Authentication architecture
description: One session model behind three very different front doors — cookies, bearer tokens and SSO — and where the boundaries actually are.
---
import { Aside } from '@astrojs/starlight/components';
The administrator's view of this is
[Authentication](/docs/administration/authentication/). This is how it is built.
## One session service, three surfaces
The governing decision: **there is a single source of truth for sessions**, and every
authentication surface produces the *same* session model.
```
browser native app SSO provider
(httpOnly JWT) (bearer + refresh) (OAuth2 / OIDC + PKCE)
│ │ │
└───────────────────┼────────────────────────┘
▼
sessionService
createSession(user, authMethod)
validateSession()
```
Controllers call `createSession`; middleware calls `validateSession`. Nothing invents its
own notion of "logged in".
That matters more than it sounds. Three front doors with three session implementations is
three places for an authorization bug to hide, and the one that gets least attention is the
one that gets exploited.
<Aside type="note" title="`utils/auth.js` is a facade">
It exists for backward compatibility and is a thin wrapper. New work goes through the
session service.
</Aside>
## The three surfaces
**Web** — a JWT signed with `JWT_SECRET`, carried in an `httpOnly`, `sameSite=Lax` cookie.
`secure` is decided **per request** (`COOKIE_SECURE=auto` → `secure: req.secure`), which is
what lets one deployment work both over HTTPS through a proxy and over plain HTTP on a LAN
address.
**Mobile** — short-lived bearer access tokens plus **rotated, hashed, revocable** refresh
tokens. Hashed server-side, so a database disclosure does not hand over live sessions.
**SSO** — Google, Discord or a custom OIDC provider, PKCE-guarded.
## SSO is link-only, by policy
**An external identity must already be linked to an existing account.** Identities are
never auto-provisioned.
This is a deliberate policy rather than an unimplemented feature. Auto-provisioning turns
"anyone with a Google account" into "anyone with an account here", which is not a decision
a site operator should make by installing an OAuth client.
## Admin is re-validated every request
Roles are **re-checked against the database on every admin request**, not trusted from the
token.
The consequence is the point: a demoted user loses access **at once**, rather than when
their token happens to expire. A stateless JWT that carried the role would keep asserting it
for up to a day.
## Trusted devices gate the second factor only
A second, separate httpOnly cookie (`rg_trust`, 30 days by default) lets a browser or app
**skip the TOTP step** on future logins — **never the password**.
Four properties, each chosen:
- It is **opaque and sha256-hashed server-side**, stored in a table. It is not a JWT claim,
so the stateless session token is unchanged.
- It is **per-row revocable**, from the admin panel or by the user.
- It **deliberately outlives logout.** Logging out ends a session; it does not make the
device untrusted, because the device is still the same device.
- It is **cleared** on untrust, password change, password reset, or disabling TOTP.
**Recovery codes** (bcrypt, single-use) are the lockout fallback. Every trusted-device and
MFA action is audit-logged.
## The login-hardening layer
Bot scoring with automatic IP banning, TOTP 2FA, a honeypot field, and rate limiting with
backoff. The admin *Bot Activity* panel is deliberately **read plus emergency-unban only** —
it is a window onto an automatic system, not a control surface for it.
## Where core's boundaries stop
Core's security boundaries end at **authentication, roles and the session**.
A module that serves game data brings its **own** audience rules, and core does not police
them beyond the gates it hands over — `requireAuth`, `requireRole`, and the tier group
gates. See [The module API](/docs/modules/the-module-api/#registerroutes-and-the-tier-gate).
The Ultima Online module's is the worked example, and it is a real boundary rather than a
convenience filter: an admin-configurable, per-feature and per-field audience ladder with **fail-closed
defaults**, applied at routes, at SSE subscribe time, *and* at the navigation. All three,
because a surface that is filtered in only two of those places leaks through the third.
## Content Security Policy
`script-src 'self'` with **no inline script**, which is why [module chunks are served
same-origin](/docs/modules/building-a-module/) and why an import map was never an option.
`form-action 'self'` is pinned explicitly rather than inherited, because it blocks an
injected form POSTing credentials off-origin — an exfiltration path `connect-src` does not
cover.
Violation reports go to a **same-origin** sink that stores nothing: reports describe attacks
against this site and are not handed to a third-party collector. It parses both wire formats
(browsers disagree), and always answers `204` even for malformed input — a `4xx` would make
the error handler log attacker-supplied bodies and turn an open endpoint into a log-flood
primitive.
## Canonical document
[`BACKEND_DESIGN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/BACKEND_DESIGN.md)
§6 is normative for everything on this page.

Some files were not shown because too many files have changed in this diff Show More