Compare commits
19 Commits
dae7964ca6
...
feat/phase
| Author | SHA1 | Date | |
|---|---|---|---|
| a6fc0431d7 | |||
| c29ec94f46 | |||
| 31d914ba44 | |||
| b3cb6bf1eb | |||
| d89ce06bb8 | |||
| e8cb6061fe | |||
| a993b872ac | |||
| bcb633403f | |||
| f499f2b72b | |||
| 971fa9c032 | |||
| a2faf07104 | |||
| 29c96d0b21 | |||
| 1313e748ae | |||
| fbd7bbe6fd | |||
| 2d19ee4220 | |||
| d9d7a8d47f | |||
| 556dee7355 | |||
| bb06f1de44 | |||
| fe4abe0ebf |
@@ -32,12 +32,79 @@ jobs:
|
||||
# PLAN.md §7 — no colour literal outside src/styles/tokens.css.
|
||||
run: npm run check:tokens
|
||||
|
||||
- name: Branding pipeline
|
||||
# PLAN.md §7 — brand-default is complete, every /brand/* URL the source asks for
|
||||
# resolves against the route's own allowlist, and every brand string the boot
|
||||
# rewrite replaces is distinctive enough to replace blindly.
|
||||
run: npm run check:brand
|
||||
|
||||
- name: Play Data Safety declaration
|
||||
# PLAN.md §9 / D33 — PLAY_DATA_SAFETY.md is generated from the same
|
||||
# src/data/collection.mjs rows that /privacy section 2 renders, so the published
|
||||
# policy and the answers given to Google cannot drift apart. This re-runs the
|
||||
# generator and fails if the committed copy differs.
|
||||
#
|
||||
# It runs before the build because it needs neither one: it is the cheapest check
|
||||
# here and the one whose failure is easiest to act on.
|
||||
run: npm run check:datasafety
|
||||
|
||||
- name: Types
|
||||
run: npm run check
|
||||
|
||||
- name: Unit tests
|
||||
# PLAN.md §8 — the beta signup's decision path: honeypot, form token, timing, rate
|
||||
# limit, cap, validation, duplicate, removal.
|
||||
#
|
||||
# The first thing in this repository that the other checks cannot see. They all read
|
||||
# the built output, and none of this appears there: a honeypot that has stopped
|
||||
# working produces a build that is identical in every way to one where it works.
|
||||
#
|
||||
# The test file is NAMED rather than the directory passed. `node --test test/` fails
|
||||
# on Node 22 with MODULE_NOT_FOUND — directory mode is not portable across the
|
||||
# versions this org runs, and this workflow pins 22 while developers are on 24, so
|
||||
# the shorter form would pass locally and break only here.
|
||||
run: npm test
|
||||
|
||||
- name: Sidebar
|
||||
# PLAN.md §12, phase 8. src/config/sidebar.mjs holds two trees — the one Starlight
|
||||
# renders and the one §10 planned — and they must agree on groups, labels and
|
||||
# ORDER. Order because the order of "Getting started" IS the installation path.
|
||||
#
|
||||
# While pages were being written the planned tree was a checklist; now that every
|
||||
# page exists it is a hand-maintained second copy, and it had already drifted
|
||||
# unnoticed (phase 7 added Content under D37 and never updated it). Nothing caught
|
||||
# that because nothing read it.
|
||||
#
|
||||
# No token, no network, no build — so it runs early and fails fast.
|
||||
run: npm run check:sidebar
|
||||
|
||||
- name: Screenshots
|
||||
# PLAN.md §12, phase 9 (D45). src/data/screens.mjs is the one list of what the site
|
||||
# shows of itself: every entry must have a file, at the size the markup declares, and
|
||||
# every file must have an entry. The size half is the one that repays the check —
|
||||
# a re-capture taken at the wrong viewport looks perfectly fine on its own and only
|
||||
# reveals itself as a page that reflows while it decodes.
|
||||
#
|
||||
# No browser and no game server: the capture tool is an authoring script whose output
|
||||
# is committed, exactly like the brand assets, so CI only reads what it produced.
|
||||
run: npm run check:screens
|
||||
|
||||
- name: Production build
|
||||
run: npm run build
|
||||
|
||||
- name: Links
|
||||
# PLAN.md §12 — every internal link resolves, and every outbound link into a
|
||||
# RunicGateway repository points at a branch path rather than a commit permalink.
|
||||
#
|
||||
# It runs AFTER the build, and that ordering is the design rather than a
|
||||
# convenience: it reads the built HTML, so links assembled from data files and
|
||||
# template literals are checked as the strings they actually become. A source scan
|
||||
# would see an expression and skip most of what phase 4 added.
|
||||
#
|
||||
# No network: the outbound rule is about the shape of a URL, and a build that
|
||||
# fails because some other host is slow is a check people learn to ignore.
|
||||
run: npm run check:links
|
||||
|
||||
- name: Platform facts
|
||||
# PLAN.md §12 — every version, protocol number and bundle tag is re-read from
|
||||
# its authority over the Gitea API and must agree with src/data/platform.json.
|
||||
@@ -56,3 +123,38 @@ jobs:
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: npm run check:facts
|
||||
|
||||
- name: Quickstart against website main
|
||||
# PLAN.md §12, phase 7 (D35). /docs/getting-started/install-the-site/ prints a
|
||||
# Compose file and an environment file the reader copies without leaving the page,
|
||||
# which is the one place this site knowingly keeps a copy of another repo's file.
|
||||
#
|
||||
# So the copy is checked in BOTH directions: every value it states must match
|
||||
# website's own docker-compose.yml and .env.example on main, and every service and
|
||||
# variable THEY have must be either included or listed as deliberately omitted with
|
||||
# a reason. A new variable upstream turns this repo red until someone decides
|
||||
# whether a first install needs it — the same intent as the facts check above.
|
||||
#
|
||||
# Same token, and for the same reason: it reads another repository in the org.
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: npm run check:quickstart
|
||||
|
||||
- name: Reference enumerations against their sources
|
||||
# PLAN.md §12, phase 8. The Reference section names things — every environment
|
||||
# variable, config key, installer command, visibility rung and canonical document.
|
||||
# §1 forbids re-specifying a contract, and this is what makes writing the NAMES
|
||||
# down safe anyway: each list is a SET comparison against the repository that owns
|
||||
# it, in both directions.
|
||||
#
|
||||
# The second direction is the one that earns its keep. A reference page does not
|
||||
# usually rot by describing something that vanished — it rots by quietly not
|
||||
# mentioning the three things added since it was written.
|
||||
#
|
||||
# Descriptions are deliberately NOT checked; nothing here can know whether a
|
||||
# one-line summary is still true, so it does not pretend to.
|
||||
#
|
||||
# Same token, and for the same reason: it reads five other repositories in the org.
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: npm run check:reference
|
||||
|
||||
674
PLAN.md
@@ -86,8 +86,8 @@ All values re-read from the Gitea API on **2026-08-19**, after revision 1.
|
||||
| **Current bundle** | **2026.08.19** (protocol 4, generated 09:05:52Z) | `installer` branch `bundles` → `current.json` |
|
||||
| uo-link sidecar | **v2.0.0** (2026-08-19) | release; in bundle 2026.08.19 |
|
||||
| Plugin overlay | **v1.0.0** (2026-08-19) | release; in bundle 2026.08.19 |
|
||||
| Installer | **v0.1.0** (2026-08-07) | release |
|
||||
| `module-uo` | **v1.0.1** (2026-08-19) | release |
|
||||
| Installer | **v0.1.1** (2026-08-24) | release |
|
||||
| `module-uo` | **v1.0.2** (2026-08-25) | release |
|
||||
| Android app | **v0.5.0** (2026-08-08), id `com.runicgateway.app` | release; `app/build.gradle.kts` |
|
||||
| ServUO | **57.4** — min version, and the only version the patch tier is verified against | bundle `overlay.servuo` |
|
||||
| `website` | **no releases** — ships as container images, never tagged | Gitea releases API (empty) |
|
||||
@@ -234,6 +234,21 @@ Taken by the org lead (Colby Whitlock) on 2026-08-19. Recorded so they are not r
|
||||
| **D12** | **A public demo instance is planned but out of scope today** — a Proxmox VM running the full stack including ServUO, with restricted settings and an hourly automatic reset. | §15. The IA reserves the slot so it lands later without a restructure. |
|
||||
| **D13** | **Publish the existing address.** No mailbox has been created at the domain and the org lead elected not to wait for one: `whitlocktech@gmail.com` is the published contact wherever the site needs one — `/privacy`, `/terms`, `/community`, the Play listing — and `docs/SECURITY.md` keeps the address it already carries. | Taken 2026-08-19, superseding §14 N2 as a blocker. The address lives **only** in `brand.json` (§7), never in prose, so moving to `privacy@`/`security@` later is a file edit and a restart. Phase 0's PR 0.10 is withdrawn, not deferred. |
|
||||
|
||||
**Decisions after D13 are recorded where they were taken**, in the section describing the phase that
|
||||
raised them, rather than appended here — a decision is only re-litigated when its reasoning is
|
||||
somewhere other than the thing it decided. The count of record is **forty-six**:
|
||||
|
||||
| # | Where | What it settled |
|
||||
|---|---|---|
|
||||
| D14–D16 | §7, "How phase 2 actually built it" | The branding pipeline: one raster in, brand text applied at boot, the mark is the real emblem |
|
||||
| D17–D19 | §10, "How phase 3 built the homepage" | The data-path diagram, all five groups on the homepage, the emblem-led hero |
|
||||
| D20–D25 | §10, "How phase 4 built the marketing pages" | `/features/` as the same list with detail, `/architecture/` as reasons not reference, the absences as data, the two absorbed scope items, `needsModule`, the demo deep links |
|
||||
| D26–D29 | §8, "How phase 5 built the app and the beta" | The screenshot slot reserved for phase 9, the demo as the tester target, `/beta` handling its own POST, equal billing for the APK and the beta |
|
||||
| D30–D33 | §9, "How phase 6 built the legal pages" | One logging hop and no edge provider, eighteen or older, no governing-law clause, the Data Safety notes as a generated document |
|
||||
| D34–D37 | §10, "How phase 7 built the documentation journey" | One PR for all twenty pages, a self-contained install quickstart with a drift check, every admin screen walked before it was described, a thirteenth Administration page for content |
|
||||
| D38–D41 | §10, "How phase 8 built the builder and reference docs" | One PR for all twenty pages again, Reference enumerates names and checks every one of them, the docs section links to the drawn diagrams rather than importing them, `plannedSidebar` becomes a checked invariant |
|
||||
| D42–D46 | §10, "How phase 9 took the screenshots" | The full rig behind the imagery, a neutral demo brand, the captures beside the claims, a committed and checked capture pipeline, the world dressed in the plugin repo's scaffolding |
|
||||
|
||||
---
|
||||
|
||||
## 6. Runtime shape
|
||||
@@ -247,6 +262,9 @@ POST and write it somewhere (§8), and branding must be overridable by dropping
|
||||
mount **without rebuilding the image** (§7) — which means the bytes cannot be fingerprinted into
|
||||
the build output.
|
||||
|
||||
*Amended 2026-08-24 by D28: the signup is `/beta` itself rather than a `POST /api/beta-signup`
|
||||
endpoint. The dynamic surface is still two routes and the reasoning above is unchanged; see §8.*
|
||||
|
||||
Why not a separate API service: one container is one thing to deploy, one thing to patch, and one
|
||||
log to read. The dynamic surface is three endpoints.
|
||||
|
||||
@@ -256,7 +274,7 @@ log to read. The dynamic surface is three endpoints.
|
||||
│ Astro (Node adapter) │
|
||||
│ ├── prerendered pages ......... marketing, docs, legal — plain HTML │
|
||||
│ ├── GET /brand/* ............ reads the bind mount, falls back to defaults │
|
||||
│ └── POST /api/beta-signup ..... writes SQLite on the bind mount │
|
||||
│ └── GET + POST /beta/ ......... renders the form; writes SQLite on the mount │
|
||||
│ │
|
||||
│ /app/brand-default ..... baked into the image (stock logo, tokens, brand.json) │
|
||||
└─────────┬──────────────────────────────────────────────┬───────────────────────────┘
|
||||
@@ -309,6 +327,34 @@ the build and the mount could never replace them.
|
||||
`brand.json` exists so that renaming the product, changing the Discord invite or adding a contact
|
||||
address does not require a rebuild either — the same class of change as swapping a logo.
|
||||
|
||||
### How phase 2 actually built it
|
||||
|
||||
Three decisions taken during the build (org lead, 2026-08-20). They refine the mechanism above
|
||||
rather than change what it promises.
|
||||
|
||||
**D14 — one raster in, every size out.** Only `logo.png`, `wordmark.svg`, `og-image.png`,
|
||||
`theme.css` and `brand.json` are baked into `brand-default/`. Every other image in the table above
|
||||
— all the logo sizes, both install icons, the apple-touch icon, the favicons and the `.ico` — is
|
||||
**derived at request time** from whichever `logo.png` is in force, cached in memory, and limited to
|
||||
an allowlist of sizes. Precomputing them would have meant an operator producing fifteen files to
|
||||
change a mark, and the realistic outcome of that is a deployment with a new header and the old
|
||||
favicon. "A file copy" now means one file.
|
||||
|
||||
**D15 — brand text is applied at boot, not at render.** §6 prerenders every page, so a value read
|
||||
at build time is baked into HTML the mount cannot reach; §7 promises otherwise. `npm start` runs
|
||||
`scripts/applyBrand.mjs` before the server opens a socket, rewriting the built HTML from what was
|
||||
baked to what the mount says. Every page stays prerendered, Pagefind still has static HTML to index,
|
||||
and the documentation is covered by the same pass as the marketing pages. The alternatives — server
|
||||
-rendering the brand-bearing pages, which is the whole site because of the footer, or accepting
|
||||
build-time text — were rejected. The script rewrites from a **record of what it last applied**
|
||||
rather than from the defaults, because the naive version works exactly once and then silently
|
||||
ignores every later edit.
|
||||
|
||||
**D16 — the mark is the real emblem** (D11 carried through). The header shows `runic-emblem.png`,
|
||||
not phase 1's placeholder glyph, so the site, the product and the Android launcher icon are one
|
||||
mark. The cost, accepted: it is raster art, so `theme.css` cannot recolour it — changing the mark
|
||||
means replacing `logo.png`.
|
||||
|
||||
### The rule that keeps the promise true
|
||||
|
||||
**Every colour, radius, shadow and font in the site's stylesheet is a CSS custom property defined in
|
||||
@@ -319,6 +365,17 @@ Without that check, "one CSS file changes the appearance" decays into "one CSS f
|
||||
the appearance, and then there is a hardcoded `#0e1318` in the footer". The check is the mechanism;
|
||||
diligence is not.
|
||||
|
||||
`scripts/checkBrand.mjs` is the second half of it, added in phase 2: it fails the build if
|
||||
`brand-default/` is incomplete, if any `/brand/*` URL in the source would 404 against the route's
|
||||
own allowlist, or if a brand string is short enough that replacing it blindly at boot could corrupt
|
||||
a page.
|
||||
|
||||
**The mounted stylesheet wins by cascade layer, not by link order.** `tokens.css` is wrapped in
|
||||
`@layer tokens` and `theme.css` is unlayered, so the mount takes precedence wherever the browser
|
||||
encounters it. The first attempt relied on `theme.css` being linked last, and it did not work:
|
||||
Astro emits its own stylesheet after the head markup, so the site's tokens landed after the
|
||||
operator's and every override was silently a no-op.
|
||||
|
||||
Token names deliberately match `website/client/src/styles/theme.css` where the concepts line up
|
||||
(`--bg`, `--panel-a`, `--accent`, `--ink`, `--line`, `--radius-card`, …), so a theme written for one
|
||||
is legible in the other.
|
||||
@@ -386,6 +443,17 @@ identify. The salt lives in the container environment, so rotating it destroys t
|
||||
deliberately. `consent_text` stores the wording itself rather than a version number, so a record can
|
||||
always answer "what exactly did this person agree to" without archaeology.
|
||||
|
||||
Phase 5 added the second table this section describes in prose but does not draw — `attempts`
|
||||
(`ip_hash`, `at`, `outcome`), which is the token bucket below, persisted as the events themselves
|
||||
rather than as a counter that would need a decay schedule and a clock it trusts. It is pruned on
|
||||
write, so nothing has to remember to run.
|
||||
|
||||
One consequence of `remove` that follows from the promise rather than from a separate choice: the
|
||||
address is **overwritten**, not flagged, so afterwards the store cannot tell a removed address from
|
||||
one it has never seen. Somebody who left and signs up again is an ordinary new row. Keeping a hash
|
||||
so the form could say "you were removed" would mean retaining a derived identifier for the one
|
||||
person who explicitly asked not to be retained.
|
||||
|
||||
### Abuse resistance without a third party
|
||||
|
||||
D9 and §6's CSP forbid external requests, so no captcha service. Instead:
|
||||
@@ -408,6 +476,11 @@ docker compose exec site node scripts/beta.mjs remove <email> # deletion reques
|
||||
docker compose exec site node scripts/beta.mjs stats
|
||||
```
|
||||
|
||||
Phase 5 note: `export` writes **two** files, not one. The `.csv` is the record (id, address, date,
|
||||
status, the consent wording); the `.txt` beside it is one address per line, which is what Play's
|
||||
tester list actually wants pasted. Producing only the CSV would mean hand-editing it before every
|
||||
paste, which is where a mistake would come from.
|
||||
|
||||
Deliberately not an admin page. An authenticated HTTP surface on a marketing site is a login form,
|
||||
a session, a password to rotate and a thing to patch — for an operation performed by the one person
|
||||
who already has shell on the host, against a file already on their disk. The CSV lands in the bind
|
||||
@@ -415,6 +488,79 @@ mount and is opened locally.
|
||||
|
||||
`remove` exists because §9 promises deletion on request and a promise needs a mechanism.
|
||||
|
||||
### How phase 5 built the app and the beta
|
||||
|
||||
Four decisions taken before the pages were written (org lead, 2026-08-24), plus what the
|
||||
repositories said when the plan above was checked against them.
|
||||
|
||||
**Three things this section had assumed that turned out not to hold.** §10 promised `/app/` "the 14
|
||||
existing screenshots"; they exist and are the wrong fourteen (D26). §8 never said what a tester
|
||||
would point the app at, and the app points at nothing by default (D27). And `/app/` can offer a
|
||||
download today, because every `Android-app` release attaches a signed APK — which §8 and §10 both
|
||||
omitted, having been written as though Play were the only delivery path.
|
||||
|
||||
**D26 — the screenshots are reserved for phase 9, and the slot ships empty.** `docs/android/screenshots/`
|
||||
is 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 changed how every screen looks, and five
|
||||
of the fourteen are two-factor prompts. Shipping them would break D4 and would show an app that no
|
||||
longer looks like that. Phase 9 already stands up the review stack and seeds content for the web
|
||||
screenshots, so it gains an emulator pass and the phone shots then show the same deployment on the
|
||||
same day. `src/components/app/Screenshots.astro` exists now, rendering nothing, so filling it is a
|
||||
data change rather than a design task. Rejected: shipping the fourteen, and pulling phase 9's rig
|
||||
forward into phase 5.
|
||||
|
||||
*Filled in phase 9:* six captures, from an emulator pointed at the same seeded deployment the web
|
||||
screenshots came from, on the same day — see "How phase 9 took the screenshots" in §10. The
|
||||
component now reads `src/data/screens.mjs` rather than a list of its own, which is what made it a
|
||||
data change in the end.
|
||||
|
||||
**D27 — the public demo is the tester target, so the beta waits for it.** `ConnectScreen.kt` on
|
||||
`Android-app` `main` is unambiguous — nothing in the app runs until a valid Runic Gateway site has
|
||||
been entered and validated — so an installed app with no deployment behind it is a text field. The
|
||||
alternative considered and declined was naming UOMysticmoon, which would have opened the beta to
|
||||
players immediately at the cost of publishing a private shard's address on a public page. The
|
||||
consequence is accepted rather than hidden: **the beta cannot start until §15's demo VM exists**,
|
||||
which is the second of the two gates `/beta` states outright. It costs nothing today, because D28's
|
||||
other gate — no closed test track — is open anyway.
|
||||
|
||||
**D28 — `/beta` handles its own POST; there is no `/api/beta-signup`.** §6 specified an endpoint,
|
||||
and an endpoint cannot report a validation error without JavaScript: it answers with JSON, which
|
||||
makes the form script-only, or with a redirect, which returns a person who mistyped an address to a
|
||||
blank form with no explanation. Both are poor on a page whose job is conversion, and the first is
|
||||
worse on a site with no analytics — a form that silently does nothing for a reader with scripts off
|
||||
has no way of telling anyone it is broken. Handling the POST in the page costs one on-demand route
|
||||
and buys a form that works with JavaScript disabled, renders every outcome in the real layout, and
|
||||
needs no client-side code, so nothing on it argues with the CSP.
|
||||
|
||||
**D29 — the APK and the beta get equal billing, and the APK link is currently off.** Two panels of
|
||||
the same weight: sideload today, or join the closed test for Play delivery and automatic updates.
|
||||
The beta argues for itself on convenience rather than on being the only door. But the org lead
|
||||
reports that the published `v0.5.0` build does not work, so `platform.json`'s `androidApk.serviceable`
|
||||
is `false` and the panel renders a plain statement that the build is being replaced rather than a
|
||||
link. That flag is the one value in `platform.json` with no authority to check it against, and
|
||||
deliberately so — no fetch can tell whether an APK runs. `checkFacts.mjs` asserts the two assets
|
||||
still exist and that `minSdk` still says what "Android 10 or newer" claims, so the link is correct
|
||||
the moment a working build flips the boolean. The panel is not removed while the link is off: a page
|
||||
that omitted sideloading would read, to somebody who knows the APK exists, as a page hiding it.
|
||||
|
||||
**Three mechanisms this phase added that the plan did not anticipate.**
|
||||
|
||||
- **`liveBrand()`, because a server-rendered page cannot use the boot rewrite.** §7's mechanism
|
||||
rewrites files in `dist/client`; an on-demand route's HTML never was a file, so `/beta` reading
|
||||
`brand` would show stock values forever. It reads the mounted `brand.json` itself, guarded by an
|
||||
mtime check. That is strictly better where it applies — pasting the opt-in URL into the mount
|
||||
takes effect on the **next request**, with no restart.
|
||||
- **`checkLinks.mjs` learned what an on-demand route is.** `/beta` is the first on-demand *page*,
|
||||
and rule 1 resolves links against the build, where it has no file. The fix is not a
|
||||
`PLANNED_ROUTES` entry — that list's reverse check fires when a route has been *built*, and an
|
||||
on-demand route never produces a file, so the entry could never rot out and would become the
|
||||
permanent exemption the two-way check exists to prevent. Instead the routes are derived from the
|
||||
source: a page exporting `prerender = false` is one. Delete `beta.astro` and the links fail again.
|
||||
- **A test suite, for the first time in this repository.** The five checks of §12 all read built
|
||||
output, and none of this phase's logic appears there — a honeypot can stop working entirely and
|
||||
produce a build identical to one where it works. `node --test`, named file rather than directory
|
||||
(`node --test test/` fails on Node 22, which is what CI runs).
|
||||
|
||||
---
|
||||
|
||||
## 9. Legal pages
|
||||
@@ -456,6 +602,64 @@ because it cannot.
|
||||
|
||||
Both pages are linked from the footer on every page, and `/privacy` is the URL given to Play.
|
||||
|
||||
### How phase 6 built the legal pages
|
||||
|
||||
Four decisions taken before either page was written (org lead, 2026-08-24).
|
||||
|
||||
**D30 — One hop in front of the site, and the page says so.** §9 requires `/privacy` to state the
|
||||
access logs and their retention, which needed a fact rather than a guess. The domain's DNS is on
|
||||
Cloudflare but the records are **DNS-only**: no edge provider terminates the connection, so the
|
||||
reverse proxy on the org lead's own host keeps the only access log there is — IP, path, user agent,
|
||||
timestamp — read when something is broken or being attacked, rotated on the proxy's own schedule.
|
||||
The page describes it qualitatively rather than quoting a retention number, because the number
|
||||
belongs to the proxy's configuration and a policy that states one the deployment does not enforce is
|
||||
worse than one that does not. **If the record is ever proxied, this section is wrong and has to be
|
||||
rewritten** — an edge provider that terminates TLS is a processor, and D9's "no third-party
|
||||
requests" would still be true of the browser while ceasing to be the whole story.
|
||||
|
||||
**D31 — Eighteen or older.** Play asks, and the answer decides whether consent alone is a lawful
|
||||
basis in the EEA. Eighteen was chosen over thirteen (Google's own account minimum, but below the
|
||||
children's-consent threshold in several EEA states, so a 13–15 year old's consent would need a
|
||||
parent's — which this form cannot obtain) and over sixteen (sufficient, but no simpler to state).
|
||||
The number lives in `src/data/legal.mjs` because four surfaces render it: `/terms`, `/privacy`, the
|
||||
eligibility list on `/beta`, and the consent sentence itself. **Nothing verifies it and no surface
|
||||
implies otherwise** — the pages say in as many words that ticking the box is the whole of it, which
|
||||
is both accurate and the only claim the code supports.
|
||||
|
||||
Adding the clause changed `CONSENT_TEXT`, which is stored per row rather than versioned — so rows
|
||||
written from now on carry the new sentence and older ones keep theirs. `CONSENT_VERSION` gained a
|
||||
suffix rather than a new date, because the change landed on the day the original wording was
|
||||
written and two different sentences must not share the label an operator groups a CSV by.
|
||||
|
||||
**D32 — No governing-law clause.** Nothing of value is contracted for on this site: it sells
|
||||
nothing, the software is free under a licence that carries its own terms, and the beta is a list of
|
||||
addresses people asked to be on. A jurisdiction clause here would be decoration, and §9's standard
|
||||
for these pages is that accurate and specific beats boilerplate. It stays available: adding one
|
||||
later is a clause, not a rewrite.
|
||||
|
||||
**D33 — The Play Data Safety notes are a generated repository document.** §9 says the declaration is
|
||||
"filled from section 2, and section 2 is written knowing that is what it is for" — so the two are
|
||||
one array, `src/data/collection.mjs`, rendered by `/privacy` as prose and by
|
||||
`scripts/playDataSafety.mjs` as the console's own questions into a committed
|
||||
`PLAY_DATA_SAFETY.md`. `--check` regenerates and fails if the committed copy differs, and CI runs
|
||||
it, so a hand edit is a red build that names the data file to edit instead. The document is
|
||||
operator-facing rather than published: it is a form's worth of console vocabulary no visitor is
|
||||
looking for, and `/privacy` already says the same things in prose.
|
||||
|
||||
Two properties of that file are worth keeping. **It does not pretend to know Play's current
|
||||
definitions** — there is no API to read them from and the requirements have changed more than once
|
||||
(the same reason `playPolicy` carries a `verifiedOn` date), so it holds the facts arranged as the
|
||||
console arranges its questions, with the answer each fact supports and why; a person reads the
|
||||
console's wording against them. And **a test asserts that every mapped row answers "not collected,
|
||||
not shared"**, failing with the reason rather than a diff: "we operate no server the app talks to"
|
||||
is the premise of the whole section, and a telemetry endpoint added later must not be able to
|
||||
produce a row that quietly contradicts the lede three inches above it.
|
||||
|
||||
**What phase 6 also closed.** `/privacy/` and `/terms/` were the last two entries in
|
||||
`checkLinks.mjs`'s `PLANNED_ROUTES`; building them emptied the list, and its reverse check is what
|
||||
forced the deletion. The list itself stays, because §10's documentation routes land in phases 7 and 8
|
||||
under the same convention.
|
||||
|
||||
---
|
||||
|
||||
## 10. Information architecture
|
||||
@@ -472,22 +676,151 @@ Organised by what a reader is trying to do. A reader should never need to know t
|
||||
| `/architecture/` | The system explained visually, for a technical evaluator deciding whether to run it |
|
||||
| `/modules/` | What a module is, `module-uo` as the worked example, writing your own, the Integration Kit (draft-badged per D8) |
|
||||
| `/integrations/` | Discord, mobile + ntfy push, SSO — with an explicit "not built" list |
|
||||
| `/app/` | The Android app: what it does, the 14 existing screenshots, and the beta CTA |
|
||||
| `/beta/` | The closed-beta signup (§8) |
|
||||
| `/app/` | The Android app: what it does, the signed-APK download beside the beta CTA, and six phone captures **phase 9 filled** (D26 — the 14 existing screenshots were the wrong fourteen) |
|
||||
| `/beta/` | The closed-beta signup (§8). The one page that handles its own POST (D28) |
|
||||
| `/community/` | Discord (`discord.gg/t2Jav8yT4g`) as the front door, the Gitea org for code and contributions, the `brand.json` contact address for vulnerabilities (D13) — the split in §14 N3 |
|
||||
| `/privacy/`, `/terms/` | §9 |
|
||||
|
||||
**Feature grouping**, using project terminology:
|
||||
|
||||
- **Community** — Teams, Team forums, notifications, wiki, news and newsletter, player self-service
|
||||
- **Game intelligence** *(module-supplied; `module-uo` today)* — shard status, economy, player-vendor
|
||||
marketplace, houses and IDOCs, character sheets, spawn atlas, champion boards, points leaderboards
|
||||
- **Game intelligence** *(module-supplied; `module-uo` today)* — shard status, economy, character
|
||||
sheets, points and loyalty boards, player-vendor marketplace, houses and IDOCs, spawn atlas,
|
||||
champion boards, guilds, city governors
|
||||
- **Administration** — roles, moderation and appeals, content reports, audit log, bot scoring and IP
|
||||
bans, module management, the shard connection
|
||||
- **Integration** — modules, the sidecar bridge, Discord (slash commands, notifications, voice),
|
||||
mobile and push, SSO
|
||||
- **Infrastructure** — self-hosted, Docker, prebuilt pull-only images, branding as data, OpenAPI
|
||||
|
||||
Guilds and city governors were added to Game intelligence in phase 3: `module-uo` declares them as
|
||||
capabilities and the site was omitting two of the eight. That correction is now mechanical rather
|
||||
than editorial — see D18.
|
||||
|
||||
**Community is core machinery, but two of its six need a module to fill them.** Teams and Team
|
||||
forums are marked as such (D24). Core owns every part of the Team machinery and cannot create a
|
||||
Team: they arrive from the installed module, so on a deployment with no module the feature is
|
||||
present and permanently empty. The group's summary says so; `/features/` says why.
|
||||
|
||||
### How phase 3 built the homepage
|
||||
|
||||
Three decisions taken before the page was written (org lead, 2026-08-20).
|
||||
|
||||
**D17 — the data path is drawn generically, and captioned specifically.** The diagram's nodes read
|
||||
"your game server", "sidecar", "Runic Gateway", "browser and app", because a reader should not have
|
||||
to know this org's repository layout to understand the picture, and because the tagline promises a
|
||||
platform. It does not hide what ships: the sub-labels and the caption name ServUO and uo-link
|
||||
outright, since there is exactly one implementation of the shape today and §1 says the technical
|
||||
truth wins. Rejected: naming the real components in the nodes (reads as a UO product), and omitting
|
||||
UO entirely (advertises a generality one module proves).
|
||||
|
||||
**D18 — all five groups on the homepage, named only.** Not three with a link out: Integration and
|
||||
Infrastructure carry the module and self-hosted arguments, which are the differentiators, and hiding
|
||||
them until phase 4 would have made the front page look smaller than the product. The per-capability
|
||||
argument stays `/features/`'s job so there is one copy of it.
|
||||
|
||||
The list is **data with a check behind it** (`src/data/capabilities.mjs`). Every Game-intelligence
|
||||
item names the `module-uo` capability slug it comes from, and the build fails if the page and
|
||||
`platform.json` disagree in either direction. Closing that loop needed a fifteenth fact in
|
||||
`checkFacts.mjs`: §12 listed the capability list as an externally-sourced fact and nothing re-read
|
||||
it, so the whole chain rested on someone remembering. Manifest → `platform.json` → page is now
|
||||
checked end to end.
|
||||
|
||||
**D19 — the hero leads with the emblem.** Chosen over a type-only hero: the mark is already the
|
||||
site logo, the Android launcher icon and the Play listing, and showing it large is what makes the
|
||||
three read as one product (D11). It costs what D16 already accepted — raster art a mounted
|
||||
`theme.css` cannot recolour — but every size is derived from whichever `logo.png` is in force
|
||||
(D14), so the hero, the header, the tab icon and the installed icon still change together from one
|
||||
file.
|
||||
|
||||
**A convention, not a decision:** the homepage links the final routes — `/features/`,
|
||||
`/modules/`, `/integrations/` — which phases 4 to 6 have not written yet. The header and footer
|
||||
already did this from phase 1. Nothing is deployed until phase 12, so no visitor meets a 404, and
|
||||
nothing has to be rewritten later. Links *into the documentation* are the exception: they point at
|
||||
`/docs/`, because phases 7 and 8 own those slugs and a guessed one would be a stale URL nothing
|
||||
checks.
|
||||
|
||||
### How phase 4 built the marketing pages
|
||||
|
||||
Six decisions taken before coding (org lead, 2026-08-20), plus two scope items the phase table had
|
||||
never assigned to anyone.
|
||||
|
||||
**D20 — `/features/` is the homepage's list with a `detail` line, not a second list.** Every
|
||||
capability in `src/data/capabilities.mjs` gained a sentence or two of argument; `/` renders the
|
||||
label, `/features/` renders the label and the detail. Rejected: slicing the page by reader
|
||||
(players / staff / operators / builders), which reads better but makes the same capability appear
|
||||
twice and breaks the one-to-one mapping the coverage check depends on; and deep-diving only the
|
||||
differentiators, which would have left the page looking smaller than the homepage promised.
|
||||
`assertDetailCoverage()` fails the build on a capability with no detail — the homepage would still
|
||||
look right, and `/features/` would render a heading with nothing under it.
|
||||
|
||||
**D21 — `/architecture/` draws reasons, not reference.** Three new inline SVGs, each drawing one
|
||||
boundary: two hosts and two installs, the public/staff allowlist, and the core/module seam. It
|
||||
carries no endpoint tables, no configuration keys, no schema and no event catalog — phase 8 owns
|
||||
those, they are canonical in `docs/`, and a second copy here is a copy that goes stale (§1).
|
||||
Rejected: reusing the homepage's data-path diagram larger (a visitor arriving from `/` meets the
|
||||
same picture twice), and adding a component/version table (starts becoming the Reference section).
|
||||
The vocabulary the four diagrams now share moved to `src/styles/diagram.css`.
|
||||
|
||||
**D22 — the deliberate absences are one data file.** `src/data/notBuilt.mjs`, each entry tagged with
|
||||
the pages that render it, because the homepage already promises a reader they will find the list on
|
||||
both `/features/` and `/integrations/` and two hand-written copies is how the inconvenient half
|
||||
stops appearing on one of them. Every entry carries a `resolvedBy`: an absence with an exit
|
||||
condition is a position, an absence without one is a hole. That generalises what D8 already required
|
||||
of the Integration Kit's draft badge.
|
||||
|
||||
**D23 — phase 4 absorbs `/community/` and `checkLinks.mjs`.** Neither had a phase. §10 specifies the
|
||||
page and §14 N3 specifies its contents, and the header and footer have linked it since phase 1 — a
|
||||
page the site pointed at that no phase built. `checkLinks.mjs` is specified in §12 and phase 4 is
|
||||
what makes it load-bearing: it roughly quadrupled the internal link count and added the first
|
||||
outbound links into the repositories.
|
||||
|
||||
**D24 — `needsModule`, because "core" and "module-supplied" were not enough.** Writing the
|
||||
`/features/` detail for Teams exposed a claim phase 3 had shipped: the Community group said
|
||||
"everything here works on a deployment with no game module installed at all", and that is false.
|
||||
`teams.module_id` is `NOT NULL` on `website` `main`, there is no create route anywhere under
|
||||
`/api/v1/admin/teams`, and sync is gated on `teamProvider.providerModuleId()`. Core owns the whole
|
||||
Team machinery — tables, roster resolver, forums, notification streams, Discord bridge, voice,
|
||||
activity feed, `/admin/teams` — and deliberately cannot *originate* a Team, because core does not
|
||||
own the word for one. On a bare core the feature is present, correct and permanently empty. Teams
|
||||
and Team forums are marked; the group summary was requalified; the homepage changed by one sentence
|
||||
and kept D18's five named groups. Rejected: a sixth group for Teams (says it loudest, costs the
|
||||
five-group grid phase 3 tuned), and fixing only the wording (leaves the distinction one sentence
|
||||
deep and unguarded).
|
||||
|
||||
**D25 — the demo affordance on `/features/` is a per-capability deep link.** `brand.json` had
|
||||
promised one since phase 2 without defining it. Capabilities with a stable public route carry a link
|
||||
appended to the mounted `demoUrl`; the rest carry nothing, and that asymmetry is honest — a
|
||||
character sheet is reachable only by the account it belongs to, and a Team forum lives behind an id
|
||||
no static page can know. Paths are read from the real route tables on `main`, never guessed, which
|
||||
also means they are the *module's* routes: a deployment running a different module deep-links
|
||||
somewhere else.
|
||||
|
||||
That needed the branding pipeline extended, because the phase-3 slot could not express it. The slot
|
||||
is a literal swap of a whole URL, so it can only ever put the demo's root in an `href`, and
|
||||
reversing it would not even find a deep link — whose `href` is the root plus a path, matching no
|
||||
literal the script knows. `applyBrand.mjs` gained a second pass that **recomputes** all three
|
||||
attributes from the immutable `data-demo-path`, making it idempotent and exactly reversible, and
|
||||
`checkBrand.mjs` gained a guard that lifts the pattern out of `applyBrand.mjs` and runs it against
|
||||
the stock markup, so the two cannot drift. Both directions were proved against a real mount.
|
||||
|
||||
**One thing the checks caught about each other.** A scoped `:has([data-demo-url=''])` rule, added to
|
||||
hide the wrapper around a hidden demo link, made `checkBrand.mjs` fail: it cannot tell a CSS selector
|
||||
from an attribute, and it should not have to. The right fix was to delete the wrapper and let the
|
||||
link be the flex item, so the existing hide rule takes the margin with it — a case where the check
|
||||
being blunt pointed at simpler markup rather than at a needed exemption.
|
||||
|
||||
**And one thing no check caught.** `[data-demo-url=''] { display: none }` is specificity 0,1,0, and
|
||||
so is the scoped class Astro puts on the same element — so a component that sets `display` wins on
|
||||
source order, because component styles are emitted after `global.css`. `/features/`'s `.demo-link`
|
||||
set `display: inline-flex` for its arrow, and twelve links to a demo that does not exist rendered on
|
||||
the page, each pointing at `href=""` — which a browser resolves to the page it is already on.
|
||||
`checkBrand.mjs` was green throughout: the attributes were perfect and the defect was three files
|
||||
away, in the cascade. It was found by looking at the rendered page at 390px, which is not a
|
||||
mechanism, and it is the argument for keeping the live browser pass in every phase. The rule is now
|
||||
`!important` and says why in the stylesheet: while there is no demo these elements do not render,
|
||||
and no component may overrule that by accident.
|
||||
|
||||
### Documentation
|
||||
|
||||
```
|
||||
@@ -496,7 +829,7 @@ Getting started What is Runic Gateway? · Requirements · Install the site ·
|
||||
Verify the whole stack
|
||||
|
||||
Administration Configuration · Branding and theming · Navigation and pages ·
|
||||
Users and roles · Authentication · Teams · Moderation ·
|
||||
Content · Users and roles · Authentication · Teams · Moderation ·
|
||||
Notifications and email · Managing modules ·
|
||||
The shard connection · Maintenance and upgrades · Troubleshooting
|
||||
|
||||
@@ -511,7 +844,7 @@ Reference Environment variables · Installer CLI · sidecar.toml ·
|
||||
Bridge.cfg · HTTP API · Event catalog · Canonical documents
|
||||
```
|
||||
|
||||
Roughly 37 pages. Every Reference page is a **navigable summary plus a link to the canonical
|
||||
**Forty pages** — thirty-nine planned, plus the Content page D37 added in phase 7. (This said "roughly 38, 37 planned" until phase 8 counted the tree: 7 + 13 + 8 + 5 + 7. `checkSidebar.mjs` now keeps the count honest.) Every Reference page is a **navigable summary plus a link to the canonical
|
||||
document** — never a re-specification, per §1.
|
||||
|
||||
### The installation path
|
||||
@@ -526,13 +859,290 @@ website; the website is a separate Docker deployment.
|
||||
3. First run — first admin, maintenance → live
|
||||
4. Install a game module — admin panel, `MODULES` env, or by hand
|
||||
5. Connect a game server — the installer binary on the shard host (ServUO-specific today)
|
||||
6. Paste the four values into Admin → Shard — **protocol 4**, per §2
|
||||
6. Paste the four values into **Shard (uo-link)**, `/admin/uo/link` — **protocol 4**, per §2.
|
||||
(Not `/admin/shard`: the screen belongs to the module now, and the installer still prints the
|
||||
old path — see "How phase 7 built the documentation journey" below)
|
||||
7. Verify — `[bridge status` in game, `/health` reporting `plugin_connected: true`, then `doctor`
|
||||
8. Configure authentication and integrations
|
||||
|
||||
Each step states what the operator should expect to see, and links the failure modes to
|
||||
Troubleshooting.
|
||||
|
||||
### How phase 7 built the documentation journey
|
||||
|
||||
Four decisions taken before a page was written (org lead, 2026-08-24), and three things the live
|
||||
site disproved while it was being written.
|
||||
|
||||
**D34 — one PR for all twenty pages.** Twenty, not nineteen: see D37. The alternative on the table
|
||||
was splitting Getting started from Administration so the installation path could land first; the
|
||||
org lead kept the phase whole, as every phase before it has been.
|
||||
|
||||
**D35 — the install page is self-contained.** `/docs/getting-started/install-the-site/` prints a
|
||||
complete Compose file and a complete `.env` that an operator copies without going to another
|
||||
repository first. §1 argues at length against exactly this — it is a second copy of somebody else's
|
||||
file, free to rot — so the copy is not trusted, it is checked. `src/data/quickstart.mjs` holds both
|
||||
files and the page renders them; `scripts/checkQuickstart.mjs` re-reads `website`'s own
|
||||
`docker-compose.yml` and `.env.example` from `main` over the Gitea API and fails the build on any
|
||||
disagreement, in **both** directions:
|
||||
|
||||
- every value the quickstart states must match upstream's;
|
||||
- every service and variable upstream has must be **either included or listed as deliberately
|
||||
omitted, with a reason**, so a new variable in `.env.example` turns this repo red until someone
|
||||
decides whether a first install needs it;
|
||||
- and an entry in either omission list that upstream no longer has fails too, so the lists cannot
|
||||
rot into permanent exemptions.
|
||||
|
||||
Same mechanism and same intent as `checkFacts.mjs`. It caught two stale entries on its first run —
|
||||
`TOTP_ISSUER` and `MODULES`, which are commented *suggestions* upstream rather than keys — which is
|
||||
the check earning its place before the page had shipped.
|
||||
|
||||
**D36 — every Administration screen was walked before it was described.** Not read from source:
|
||||
opened, in a browser, on a real deployment. The rig was the quickstart itself — the exact two files
|
||||
from D35, against the published image — so one run proved the install page and produced the
|
||||
screenshots' worth of detail the admin pages needed. Three of the four defects below came from that
|
||||
walk, and no check could have found any of them.
|
||||
|
||||
**D37 — a thirteenth Administration page.** §10's planned twelve named no home for Posts, Pages,
|
||||
Wiki, Activity, Invites, the Hero editor or Web Bot Activity, all of which are real admin nav rows.
|
||||
Rather than mirror the panel one page per row — which would organise the docs by the app's menu,
|
||||
against this section's own principle — content authoring became one page, **Content**, and the
|
||||
other four folded into the page that already owned their subject: Invites into Users and roles, the
|
||||
Hero editor into Branding and theming, Web Bot Activity into Authentication.
|
||||
|
||||
**What the live deployment disproved.**
|
||||
|
||||
- **The documented Compose deploy does not boot.** `SECRET_ENC_KEY` is required in production —
|
||||
`utils/secretBox.js` throws at require time, so the container crash-loops before it listens — and
|
||||
it is **missing from website's root `.env.example`**, the file Compose actually reads. It is
|
||||
present in `server/.env.example`, which is the file local development copies, which is why this
|
||||
has never bitten anyone in dev. The quickstart carries it, declared as an upstream omission so the
|
||||
check fails the day it is fixed. **Fixed in website#163** (merged 2026-08-24), which also adds
|
||||
`BOT_INTERNAL_KEY` to the README's "set at least" list — required in production even on a
|
||||
deployment running no bot. The declaration did exactly what it was built to do: this repo went red
|
||||
on the next run, and the entry is deleted here.
|
||||
- **The installer points operators at a screen that no longer exists.** It prints
|
||||
`<site>/admin/shard`, and INSTALL.md §5 repeats it. Since the module-system cutover a module owns
|
||||
one path segment, and the screen is **`/admin/uo/link`**, labelled *Shard (uo-link)*. The old path
|
||||
does not even 404 — the SPA sends the operator to the dashboard, so the link looks like it worked
|
||||
and the four values have nowhere to go. **Fixed in installer#22** (the path is a named constant and
|
||||
both handoff tests assert it) **and docs#174**, both merged 2026-08-24, and shipped in installer
|
||||
**v0.1.1**. Getting there found a fourth defect, in `installer`'s release pipeline: the run for the
|
||||
fix built every artifact and pushed tag `v0.1.1`, then took a `500` from `POST /releases` one
|
||||
second later, leaving an orphan tag and no binaries. Re-running the workflow published it — the
|
||||
failure was a race with the tag push, not a structural one — so the note here names v0.1.0 as the
|
||||
version that prints the old path rather than describing the installer as currently wrong.
|
||||
- **The admin "Restart the server" button opens a `window.confirm`.** Its text is the honest
|
||||
warning that a deployment with no supervisor does not come back — which is exactly why
|
||||
`restart: unless-stopped` is called out as load-bearing on the install page rather than left as
|
||||
boilerplate.
|
||||
|
||||
**And the fourth defect, the one only a look found — three phases running.** The `.env` block's
|
||||
prose says *every highlighted line must be changed*, and `mark` given the variable **names**
|
||||
highlighted the names alone, leaving the values a reader has to replace unmarked. The build passed,
|
||||
every check passed, and the page was quietly wrong about its own highlighting. Marking the whole
|
||||
`KEY=value` string fixed it. See phase 4 (cascade), phase 5 (literal backticks)
|
||||
and phase 6 (the card void) for the same lesson.
|
||||
|
||||
---
|
||||
|
||||
### How phase 8 built the builder and reference docs
|
||||
|
||||
Twenty more pages — Modules (8), Architecture (5), Reference (7) — completing the tree §10
|
||||
planned. Four decisions, taken by the org lead before anything was written.
|
||||
|
||||
**D38 — one PR for all twenty pages, again.** The alternative on the table was splitting the
|
||||
prose (Modules + Architecture) from Reference, since only Reference needed new checking
|
||||
machinery. Rejected for the same reason D34 was: the three sections cross-reference each
|
||||
other heavily, and a split means either landing pages whose links point at nothing yet or
|
||||
writing the links twice.
|
||||
|
||||
**D39 — Reference enumerates the NAMES, and checks every one of them.** This is the phase's
|
||||
central decision, because §1 forbids re-specifying a contract and a Reference section is
|
||||
exactly where that rule is most tempting to break.
|
||||
|
||||
The line drawn: **names are on the page, semantics are not.** Every environment variable,
|
||||
config key, installer command, visibility rung and canonical document is listed, with one
|
||||
terse line saying what it is *for*. Shapes, defaults that matter, interactions and every
|
||||
"why" stay in the canonical document.
|
||||
|
||||
That is only safe because `scripts/checkReference.mjs` compares each list against the
|
||||
repository that owns it — six sources, over the Gitea API, never from a working tree — as a
|
||||
**set comparison 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.
|
||||
|
||||
The alternative considered was strict summary-plus-link with nothing enumerated. It needs no
|
||||
machinery and cannot rot — but a Reference section that cannot answer "what variables are
|
||||
there?" without a click-through is a link farm, and the checking machinery turned out to be
|
||||
one script.
|
||||
|
||||
Descriptions are deliberately **not** checked, and the script says so. Nothing can know
|
||||
whether a one-line summary is still true; keeping them short enough to re-read is the
|
||||
mitigation, not a check.
|
||||
|
||||
**D40 — the docs link to the drawn diagrams rather than importing them.** `/architecture/`'s
|
||||
three diagrams are Astro components carrying marketing chrome and depending on
|
||||
`src/styles/diagram.css`, which Starlight does not load. Reusing them inside the docs would
|
||||
have coupled the two layouts for one page's benefit. The docs use text diagrams in code
|
||||
blocks — which are also copy-pasteable into an issue — and link out to the drawn versions.
|
||||
|
||||
**D41 — `plannedSidebar` stops being a checklist and becomes a checked invariant.** It was
|
||||
written in phase 1 so phases 7 and 8 had their checklist where they would be working. With
|
||||
every page now written it is a second, hand-maintained copy of the live tree, which is the
|
||||
exact shape §1 warns about — so `checkSidebar.mjs` asserts the two agree on groups, labels
|
||||
**and order**.
|
||||
|
||||
Order, because the order of "Getting started" *is* the installation path, and a reordering
|
||||
nobody noticed would be a worse defect than a missing page.
|
||||
|
||||
**What the checks found, before any of the pages shipped.**
|
||||
|
||||
- **`plannedSidebar` had already drifted.** Phase 7 added the Content page under D37 and
|
||||
never updated the planned list. Nothing failed, because nothing read it — which is the
|
||||
whole argument for D41. Reproduced by deleting the entry again and watching the new check
|
||||
catch it.
|
||||
- **The page count in this document was wrong**, and had been since §10 was written: it said
|
||||
"roughly 38 — 37 planned", where the tree it describes is forty.
|
||||
- **`module.json`'s `mounts` and the SPA's paths are different mechanisms**, which is not
|
||||
stated plainly in any one place. `module-uo` declares `admin: ["/shard", "/uo-link"]` and
|
||||
its screen lives at `/admin/uo/link`; API routes are deliberately *not* namespaced while
|
||||
SPA routes are. That is the distinction the installer got wrong in v0.1.0, and it now has
|
||||
a named home on *The module system*.
|
||||
|
||||
**The check was verified by breaking it, not by watching it pass.** It went green on its
|
||||
first run, which is the least trustworthy possible outcome, so seven mutations were fed
|
||||
through it — a stale name, an omitted name, a renamed key in each of three sources, a
|
||||
canonical document that moved, and the visibility ladder **reordered with its membership
|
||||
unchanged**. All seven failed the build. The ladder case is the one worth keeping: it is a
|
||||
security boundary, and a set comparison alone would have passed it.
|
||||
|
||||
---
|
||||
|
||||
### How phase 9 took the screenshots
|
||||
|
||||
D4 said real screenshots from the review stack rather than placeholders, and left the how
|
||||
open. Five decisions settled it, taken by the org lead before the rig was built.
|
||||
|
||||
**D42 — the full rig: a real shard, a real sidecar, a real site.** ServUO with the bridge
|
||||
overlay on this machine, the Rust sidecar beside it, `website` `main` with `module-uo`
|
||||
installed, and the demo database seeded on top for what a fresh shard cannot produce.
|
||||
|
||||
The alternatives were cheaper and both of them lie a little. Sidecar-only screenshots the
|
||||
degraded state — a reachable bridge with nothing behind it. Everything-database-seeded
|
||||
produces pages that look identical to the real thing and were produced by nothing: the
|
||||
marketplace would be rows somebody typed. This is the one option where the marketplace rows
|
||||
are player vendors the game actually holds, the atlas is parsed from the shard's own spawn
|
||||
files, and "Candlewick House is now IDOC" happened.
|
||||
|
||||
**D43 — a neutral demo brand.** The deployment is "Runic Gateway Demo", not UOMysticmoon.
|
||||
The screenshots show the platform rather than one private community, which is the same
|
||||
instinct as D27's refusal to publicise a real shard — and §15's demo instance can wear this
|
||||
identity the day it exists, so the imagery stays true rather than becoming a period piece.
|
||||
The name says "Demo" deliberately: nobody should have to wonder whether they are looking at
|
||||
a server they could join.
|
||||
|
||||
**D44 — the captures sit beside the claims they support, in two places.** A figure set on
|
||||
`/features/`, one on the homepage, and inline shots on the phase-7 administration pages that
|
||||
describe a screen in prose. Eleven web captures.
|
||||
|
||||
The administration pages are where a screenshot does the most work, because phase 7
|
||||
described thirteen screens it could not show. A dedicated `/screenshots/` gallery was
|
||||
rejected for the reason galleries usually are: a page nobody visits does less than a figure
|
||||
sitting under the sentence it proves.
|
||||
|
||||
**D45 — the rig is committed, not remembered.** Three files rather than a folder of images:
|
||||
`scripts/seedDemo.mjs` puts the content there by driving the site's own API,
|
||||
`src/data/screens.mjs` declares every capture with its route, viewport, scroll offset and
|
||||
caption, and `scripts/captureScreens.mjs` turns the second into files.
|
||||
`scripts/checkScreens.mjs` is the ninth check script and runs in CI.
|
||||
|
||||
The argument is the same one D35 made for the install quickstart: the way real screenshots
|
||||
rot is that the recipe for taking them lives in somebody's memory. Re-taking the set after a
|
||||
redesign is now `npm run screens:capture`, and the check fails if an entry has no file, a
|
||||
file is the wrong size, a file is orphaned, or a declared screen is rendered nowhere.
|
||||
|
||||
**Why the seed drives the API and never the database.** Every row it creates could have been
|
||||
an `INSERT`, and every `INSERT` would be a second implementation of a rule the website owns —
|
||||
how a body is sanitized, which excerpt is derived, how a password is hashed. A seed that
|
||||
writes SQL produces a database the product could not have produced, and screenshots of that
|
||||
database show a product that does not exist.
|
||||
|
||||
**D46 — the world gets dressed in `servuo-plugins/tools`.** `BridgeSeeder` builds a world at
|
||||
realistic scale; it never needed the world to look like anything, so a vendor traded as
|
||||
"Seed Shop 810" and a character was "Seed004A" — and every one of those strings travels the
|
||||
whole bridge and lands on the marketplace, the guild roster and the housing pages.
|
||||
`BridgeDemoDress` renames them in place and seeds nothing, drawing names from fixed tables
|
||||
hashed off each object's serial, so a re-run reproduces the same world and a screenshot can
|
||||
be retaken later and still match.
|
||||
|
||||
**What the rig found.** A screenshot rig is an integration test with a human in the loop, and
|
||||
this one turned up six things nothing else had:
|
||||
|
||||
- **A fresh `module-uo` install pinned wire protocol 3 while the sidecar speaks 4**, so a new
|
||||
deployment 409s on every shard read until an admin edits the number by hand. The protocol-4
|
||||
cutover bumped `link`, `servuo-plugins` and `docs` and missed the module's own default.
|
||||
Fixed upstream and released as `module-uo` **v1.0.2** — which is what this repository's own
|
||||
facts check then noticed, since `platform.json` still said v1.0.1.
|
||||
- **A renamed guild member never reaches the site.** The plugin folds name, abbreviation,
|
||||
leader, member count and alliance into the signature it compares, and re-emits the roster
|
||||
only when the member *set* changes — so renaming a member leaves the published roster stale
|
||||
indefinitely.
|
||||
- **A guild deleted while the shard is offline is a ghost row forever.** The "gone" pass
|
||||
compares against a cache that is cleared on reconnect, so nothing emits `guild.remove`. The
|
||||
demo's guild board was showing two guilds the world no longer had, a week after they went.
|
||||
- **"Houses in danger" cannot show a house that was already collapsing.** The ingest writes
|
||||
that column only from the `house.decay` transition feed, while the registry frame's stage is
|
||||
deliberately left alone so the two cannot clobber each other. A house already in IDOC when
|
||||
the site connects is therefore invisible — the page said none while the shard had two.
|
||||
- **The Android news list prints raw ISO timestamps.** Found while choosing the phone
|
||||
captures; the news screen was dropped from that set rather than shipping a picture of it.
|
||||
- **The app says "1 players online".** `shard_online_count` and `ShardEventText.kt` both
|
||||
interpolate a count into a fixed plural. Found in the retake after a character was signed in,
|
||||
and it is in the shipped phone capture — a `plurals` resource is the fix, in the app.
|
||||
|
||||
The first is fixed. The rest are raised as product observations, with the rig working around
|
||||
them: the guilds are built *after* the rename, and the IDOC staging is two passes with a wait
|
||||
between them so the site watches the collapse happen. All of that is scaffolding under
|
||||
`servuo-plugins/tools/`, which is never deployed.
|
||||
|
||||
**The emulator pass (D26), and the AVD that would not take it.** The six phone captures come
|
||||
from an emulator pointed at the same deployment on the same day, signed in as an ordinary
|
||||
player, reached through `adb reverse` — the app's debug network policy permits cleartext to
|
||||
`localhost` only, which is a better default than the one that would have made `10.0.2.2`
|
||||
work. The device is API 35 rather than the API 36 the plan named: the API 36 image on this
|
||||
machine had 200 MB free and refused the install, and wiping somebody's development device to
|
||||
take a screenshot is not a trade worth making.
|
||||
|
||||
The shard screen is the one worth having. It shows the two houses entering IDOC in its live
|
||||
activity feed — the same event that reached `/uo/houses` in the browser, on the same rig, in
|
||||
the same minute.
|
||||
|
||||
**The character that had to be logged in by hand.** The org lead asked for a player in the
|
||||
world, and the scaffolding does its half — it sets a known password on a seeded account,
|
||||
because `BridgeSeeder` gives every account a random GUID nobody kept. Driving the client is
|
||||
where automation stopped. ClassicUO stores its password crypted, so a plaintext one in
|
||||
`settings.json` decrypts to garbage and auto-login fails; posted mouse clicks reach the client
|
||||
but posted text does not; and the remaining route — taking the foreground and typing — was
|
||||
tried once, failed to take focus, and typed into the browser window the person at this machine
|
||||
was using. It was not tried again.
|
||||
|
||||
The org lead signed in instead, and the two frames that depended on it were retaken: the shard
|
||||
page now reads **1 player online, in Britain**, and the app's shard card agrees. Both came from
|
||||
the same `npm run screens:capture shard-status app-shard`, which is the whole point of D45 —
|
||||
the thing that changed was the world, not the recipe.
|
||||
|
||||
Two things that pass is worth noticing here. Presence reaches the public page as **counts and
|
||||
regions, not names**, which is the visibility framework doing its job unprompted. And the
|
||||
**guild board's "online" column did not move**: it is refreshed only when a guild's signature
|
||||
changes, which is the same defect as the stale roster above wearing a different hat.
|
||||
|
||||
**A layout decision worth recording.** The `/features/` figures are one-up at the column's
|
||||
full width, not a two-column grid. Two-up was built first and is the obvious layout for a set
|
||||
of figures — but these are screenshots of a dense interface, and halving the width puts the
|
||||
product's own type at about a third of its real size, which reads as a thumbnail of something
|
||||
rather than a picture of it. A long section of legible evidence beats a tidy grid of
|
||||
unreadable tiles.
|
||||
|
||||
---
|
||||
|
||||
## 11. Visual direction
|
||||
@@ -597,6 +1207,7 @@ a mechanism rather than diligence:
|
||||
| Protocol version | `link` `main:sidecar/src/main.rs` → `PROTOCOL_VERSION` |
|
||||
| Overlay protocol | `servuo-plugins` `main:overlay.toml` → `protocol` |
|
||||
| Module API | `website` `main:server/src/modules/version.js` → `MODULE_API_VERSION` |
|
||||
| Module capability list | `Module-uo` `main:module.json` → `capabilities` (added in phase 3) |
|
||||
| Bundle + component pins | `installer` branch `bundles`, **root** `current.json` |
|
||||
| Release versions | Gitea releases API per repo |
|
||||
|
||||
@@ -609,7 +1220,36 @@ a mechanism rather than diligence:
|
||||
into a paragraph. Same argument as `checkTokens.mjs` and colour literals — the check is the
|
||||
mechanism, diligence is not.
|
||||
- **`scripts/checkLinks.mjs`** — every internal link resolves; every outbound link into a
|
||||
`RunicGateway` repo points at a branch path, not a commit permalink.
|
||||
`RunicGateway` repo points at a branch path, not a commit permalink. **Built in phase 4** (D23),
|
||||
and it reads `dist/client` rather than `src/`: half the links these pages carry are assembled from
|
||||
data files and template literals, and a source scan sees an expression rather than a URL. It runs
|
||||
after the build for that reason, in `verify` and in CI. It fetches nothing — the outbound rule is
|
||||
about the shape of a URL, and a check that fails when someone else's host is slow is a check
|
||||
people learn to ignore.
|
||||
|
||||
It carries one exemption list, `PLANNED_ROUTES`, because §10's convention is that the header,
|
||||
footer and homepage link the *final* routes rather than growing links phase by phase. That is safe
|
||||
only because the list is checked in both directions: a link to a route that is neither built nor
|
||||
listed fails, **and an entry whose route has since been built also fails**, so the list cannot rot
|
||||
into a permanent exemption once the page arrives.
|
||||
|
||||
- **`scripts/checkBrand.mjs`** also guards the demo slot and, since phase 4, the per-capability deep
|
||||
links (D25) — lifting the pattern out of `applyBrand.mjs` and running it against the stock markup,
|
||||
so a template and a script that share no code cannot drift apart. Both are invisible in a stock
|
||||
build, which is exactly why they need a check rather than a look.
|
||||
- **`scripts/checkQuickstart.mjs`** — added in phase 7 for D35. The install page prints a Compose
|
||||
file and an `.env` verbatim, which is the one place this site knowingly copies another repo's
|
||||
file; this re-reads `website` `main:docker-compose.yml` and `main:.env.example` and fails on any
|
||||
disagreement. Two-directional, like `PLANNED_ROUTES`: a value that drifts fails, **and** a service
|
||||
or variable that appears upstream fails until it is either included or recorded as deliberately
|
||||
omitted with a reason. Its own first run found two stale entries.
|
||||
- **`scripts/checkScreens.mjs`** — added in phase 9 for D45. `src/data/screens.mjs` is the one
|
||||
list of what the site shows of itself, and this proves every entry has a file at the size the
|
||||
markup declares, that nothing in `public/screens/` is orphaned, and that every declared
|
||||
capture is rendered somewhere. The size half is the one that repays it: 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.
|
||||
- **`scripts/checkTokens.mjs`** — no colour literal outside the token file (§7).
|
||||
- `astro check` plus a production build, in CI on every PR.
|
||||
|
||||
@@ -623,14 +1263,14 @@ a mechanism rather than diligence:
|
||||
| **1** | Foundation: Astro + Node adapter scaffold, the token file, typography, layout shell, header/footer, docs theming and sidebar, `platform.json` + `checkFacts.mjs` + `checkTokens.mjs` |
|
||||
| **2** | Branding pipeline (§7): `/brand/*` resolution, `brand-default` contents, the emblem's web derivatives and lockup, `brand.json` wiring |
|
||||
| **3** | Homepage: hero, the data-path diagram as inline SVG, grouped capability sections, CTA, the reserved demo slot |
|
||||
| **4** | Marketing: `/features/`, `/architecture/`, `/modules/`, `/integrations/` |
|
||||
| **5** | The app and the beta: `/app/`, `/beta/`, the signup endpoint, the SQLite store, rate limiting, the export CLI (§8) |
|
||||
| **4** | Marketing: `/features/`, `/architecture/`, `/modules/`, `/integrations/`, **and `/community/`** — plus `checkLinks.mjs`, the capability `detail` lines, `notBuilt.mjs` and the demo deep links. See D20–D25 |
|
||||
| **5** | The app and the beta: `/app/`, `/beta/`, the signup handler, the SQLite store, rate limiting, the export CLI (§8). **Also the repository's first `node --test` suite**, and phase 9 inherits an emulator pass (D26) |
|
||||
| **6** | Legal: `/privacy/`, `/terms/`, footer links, and the Play Data Safety notes (§9) |
|
||||
| **7** | Docs — the journey: Getting started (7) + Administration (12). **The installation path is the priority of the whole project** |
|
||||
| **8** | Docs — builder and reference: Modules (8) + Architecture (5) + Reference (7) |
|
||||
| **9** | Screenshots (D4): stand up the local review stack, seed presentable content, capture the admin panel, Teams, forums, marketplace, spawn atlas and shard console; build the screenshot components |
|
||||
| **7** | Docs — the journey: Getting started (7) + Administration (**13**, per D37) — twenty pages in one PR (D34), with the install page self-contained and drift-checked (D35) and every admin screen walked before it was described (D36). **The installation path is the priority of the whole project** |
|
||||
| **8** | Docs — builder and reference: Modules (8) + Architecture (5) + Reference (7) — twenty pages in one PR (D38), with Reference enumerating names and **checking every one of them** against its source (D39), and `plannedSidebar` becoming a checked invariant (D41) |
|
||||
| **9** | Screenshots (D4): stand up the local review stack, seed presentable content, capture the admin panel, Teams, forums, marketplace, spawn atlas and shard console; build the screenshot components. **Plus an emulator pass against the same seeded stack** to fill `/app/`'s reserved slot (D26) |
|
||||
| **10** | Polish: responsive, accessibility, SEO/OpenGraph/sitemap/robots, full-text search, CSP headers |
|
||||
| **11** | Validation: `astro check`, production build, all four check scripts, mobile layout verified in a real browser, a signup walked end to end |
|
||||
| **11** | Validation: `astro check`, production build, **all nine check scripts** (tokens, brand, links, facts, quickstart, data safety, reference, sidebar, screens), mobile layout verified in a real browser, a signup walked end to end |
|
||||
| **12** | Delivery: Dockerfile, `docker-compose.yml` with both bind mounts documented, Gitea Actions workflow publishing to the registry, README, CONTRIBUTING with the AI-disclosure requirement, and an operator note covering DNS, TLS and the reverse proxy (D6) |
|
||||
|
||||
Phases 5 and 6 are deliberately adjacent and early: the beta cannot start without `/privacy`, and
|
||||
|
||||
119
PLAY_DATA_SAFETY.md
Normal file
@@ -0,0 +1,119 @@
|
||||
<!--
|
||||
GENERATED FILE — do not edit.
|
||||
|
||||
Source: src/data/collection.mjs (scope "app") + src/data/legal.mjs
|
||||
Generator: scripts/playDataSafety.mjs
|
||||
|
||||
Edit the data file and run `npm run play:datasafety`. CI runs the same
|
||||
generator with --check, so a hand edit here fails the build rather than
|
||||
quietly disagreeing with the published privacy policy.
|
||||
-->
|
||||
|
||||
# Google Play Data Safety — the answers, and what they are based on
|
||||
|
||||
The Play Console asks, for every category of data, whether the app **collects** it, whether it is **shared**, whether collection is **required or optional**, and *why*. This file holds the answers for the Runic Gateway Android app, generated from the same inventory the published privacy policy renders — see `/privacy`, section 2.
|
||||
|
||||
> **This is not a filled-in form.** Play’s definitions change and no check here can read them. Every answer below is a fact about the code with the reasoning attached; read the console’s current wording against them when you fill the form. What this file exists to prevent is somebody answering from memory about what the app stores.
|
||||
|
||||
## The premise every answer rests on
|
||||
|
||||
We operate **no server the app talks to.** The app ships pointed at nothing: its first screen asks for the address of a Runic Gateway deployment and validates it before anything else in the app runs. That deployment belongs to whoever runs that community. Data therefore travels from the device to *their* server, and there is no endpoint of ours anywhere in the path — not for content, not for telemetry, and not for crash reports, of which there are none.
|
||||
|
||||
That is why nearly every answer below is "not collected", and it is also the answer most likely to be questioned in a review. The supporting facts are in the table: each row names the file it was read out of.
|
||||
|
||||
Where the console offers free text about security practices, two things are worth saying: credentials are held in Android’s encrypted storage (AES-256-GCM via Jetpack Security), and push notifications carry **no content** — a relay receives a stream name and a reference, and the app fetches the actual message over its own authenticated connection.
|
||||
|
||||
## Data types
|
||||
|
||||
| Category | Data type | Collected by us | Shared by us | Answer |
|
||||
|---|---|---|---|---|
|
||||
| Personal info | User IDs | No | No | Not collected by us. |
|
||||
| Personal info | User IDs | No | No | Not collected by us. |
|
||||
| App info and performance | Other app data | No | No | Not collected by us. Stored on the device only. |
|
||||
| Messages | Other in-app messages | No | No | Not collected by us. Declare the relay hop in the console’s free-text security section if it asks. |
|
||||
| Messages | Other user-generated content | No | No | Not collected by us. |
|
||||
| Device or other IDs | Device or other IDs | No | No | Not collected. |
|
||||
|
||||
## Each answer, and why it is the truthful one
|
||||
|
||||
### Your sign-in tokens
|
||||
|
||||
**Personal info → User IDs.** Not collected by us.
|
||||
|
||||
When you sign in to a deployment, the app keeps the access and refresh tokens it was issued, plus the username, role and account id they belong to. They are held in encrypted storage on the device (AES-256-GCM through Jetpack Security) and are sent to exactly one place: the deployment that issued them.
|
||||
|
||||
- **Why that answer:** The credentials are issued by, and returned to, a server the user nominated. Nothing reaches an endpoint under our control, because we run none.
|
||||
- **Retention:** On the device until you sign out
|
||||
- **In detail:** Signing out clears them; uninstalling the app removes them with it.
|
||||
- **Read from:** `core/auth/EncryptedTokenStore.kt`
|
||||
|
||||
### The trusted-device token, if you asked for one
|
||||
|
||||
**Personal info → User IDs.** Not collected by us.
|
||||
|
||||
Ticking “trust this device” during two-factor sign-in stores an opaque token so the deployment can skip the second factor next time. It lives in its own encrypted store, deliberately separate from the session, because it has to outlive a sign-out to be worth anything — and the deployment holds only a hash of it, so the copy on your phone is the only usable one.
|
||||
|
||||
- **Why that answer:** Same as the session tokens: minted by the user’s deployment, stored on the device, presented back to that same deployment.
|
||||
- **Retention:** On the device until it expires or you revoke it
|
||||
- **In detail:** Thirty days, and revocable at any time from the deployment’s Trusted Devices screen, which is also where it can be revoked if the phone is lost.
|
||||
- **Read from:** `core/auth/EncryptedTrustTokenStore.kt`
|
||||
|
||||
### The address of the deployment you chose
|
||||
|
||||
**App info and performance → Other app data.** Not collected by us. Stored on the device only.
|
||||
|
||||
The app ships pointed at nothing and asks for an address on first run. That address is stored in ordinary preferences rather than encrypted storage — it is not a secret, it is the equivalent of a bookmark — and it is what every other screen in the app talks to.
|
||||
|
||||
- **Why that answer:** It never leaves the phone. It is the destination of requests, not the contents of one.
|
||||
- **Retention:** On the device until you change it or uninstall
|
||||
- **Read from:** `core/prefs/ServerPreferences.kt`
|
||||
|
||||
### Push registration, if you turn notifications on
|
||||
|
||||
**Messages → Other in-app messages.** Not collected by us. Declare the relay hop in the console’s free-text security section if it asks.
|
||||
|
||||
Push is off until you enable it. When you do, the app mints a random, unguessable topic name on the notification relay the deployment nominates, and registers that topic’s URL with the deployment so it has somewhere to send a nudge. What actually travels through the relay is content-free — a stream name and a reference, never the message — and the app then fetches the real content over its authenticated connection to the deployment. A leaked topic name therefore reveals nothing, which is the reason the relay needs no account and holds nothing about you.
|
||||
|
||||
- **Why that answer:** The notification passes through a relay chosen by the deployment, and it carries no content — the app pulls the content itself, authenticated. Neither hop reaches a server we operate.
|
||||
- **Retention:** Until you turn push off, sign out, or uninstall
|
||||
- **In detail:** Signing out or disabling push unregisters the device with the deployment and discards the topic. The relay retains whatever its own operator configures it to; if the deployment points at a relay it does not run, that relay is a third party to both of us, and it still only ever sees a tickle.
|
||||
- **Read from:** `core/push/NtfyTopic.kt, core/push/PushPreferences.kt`
|
||||
|
||||
### Everything you read and post in the app
|
||||
|
||||
**Messages → Other user-generated content.** Not collected by us.
|
||||
|
||||
Forum posts, Team activity, character and shard information, notification preferences: all of it is a live read or write against the deployment. Nothing is cached for offline use and nothing is duplicated anywhere else — the app with no signal is an app with no content, which is a limitation and also an accurate description of where the data lives.
|
||||
|
||||
- **Why that answer:** Content is written to the community’s own installation. We have no copy, no access and no way to obtain one.
|
||||
- **Retention:** Held by the deployment, under its operator’s policy
|
||||
- **Read from:** `PLAN.md §9 section 2`
|
||||
|
||||
### No analytics, no crash reporting, no advertising
|
||||
|
||||
**Device or other IDs → Device or other IDs.** Not collected.
|
||||
|
||||
There is no third-party SDK in the app at all — no Firebase, no Crashlytics, no advertising identifier, no measurement library. That is checkable rather than claimed: it is what the dependency list and the manifest say, and a build that gained one would gain permissions with it.
|
||||
|
||||
- **Why that answer:** No advertising ID, no analytics identifier, and no library that would generate one is linked into the build.
|
||||
- **Retention:** Nothing to retain
|
||||
- **Read from:** `app/build.gradle.kts, app/src/main/AndroidManifest.xml`
|
||||
|
||||
## The rest of the listing
|
||||
|
||||
- **Privacy policy URL:** `/privacy` on this site. It is the URL Play is given, and section 2 of it is about the app specifically.
|
||||
- **Target audience:** adults. The beta is stated as **18 or older** (D31); the app contains no content directed at children and no age verification.
|
||||
- **Account deletion:** the app creates no account with us — an account belongs to the deployment the user chose, and is deleted there. The only list we hold is the beta signup, which is erased on request; `/privacy` section 4 says how to ask.
|
||||
- **Data deletion request URL:** the contact address published on `/privacy`, which is read from the mounted `brand.json` rather than typed anywhere in the source (D13).
|
||||
|
||||
## What the website collects, for the same reviewer
|
||||
|
||||
Not part of the Data Safety form — that form is about the app — but a reviewer who follows the privacy policy URL lands on a page covering three things, so it is worth knowing which of them the site itself is responsible for:
|
||||
|
||||
- **Your email address** — Until the beta ends, or until you ask.
|
||||
- **The wording you agreed to, and when** — For the life of the row.
|
||||
- **A one-way hash of your IP address — never the address** — With the row; the rate-limit log is pruned after 48 hours.
|
||||
- **Your browser’s user-agent string, truncated** — With the row; blanked on removal.
|
||||
- **The web server’s access log** — Short-term operational retention, then rotated away.
|
||||
|
||||
Last generated from data dated 2026-08-24. Regenerate with `npm run play:datasafety` after any change to what the app stores.
|
||||
110
README.md
@@ -11,10 +11,11 @@ closed beta: **players**, who want the app.
|
||||
platform state, the org lead's decisions, the information architecture, and the build phases. Read
|
||||
it before changing anything here.
|
||||
|
||||
**Status: phase 1 of 12 — the foundation.** The scaffold, the token file, the typography, the layout
|
||||
shell and the two build-time checks are in place. The homepage is phase 3, the marketing pages
|
||||
phase 4, and the documentation — the installation path, which is the priority of the whole project —
|
||||
phase 7.
|
||||
**Status: phase 5 of 12 — the app and the beta.** The foundation, the branding pipeline, the
|
||||
homepage and the five marketing pages are built, and `/app/` and `/beta/` now join them: a signed
|
||||
APK beside the closed-test signup, backed by a SQLite store on a bind mount and an export CLI. Next
|
||||
are the legal pages (phase 6) and then the documentation — the installation path, which is the
|
||||
priority of the whole project — in phases 7 and 8.
|
||||
|
||||
---
|
||||
|
||||
@@ -39,9 +40,13 @@ makes that would otherwise decay quietly.
|
||||
|
||||
```bash
|
||||
npm run check:tokens # no colour literal outside the token file
|
||||
GITEA_TOKEN=<token> npm run check:facts # every version agrees with its authority
|
||||
npm run check:brand # the branding pipeline's two quiet failures
|
||||
npm run check:datasafety # the Play declaration still matches /privacy
|
||||
npm run check # astro check
|
||||
npm run verify # all of the above, then a production build
|
||||
npm test # the beta signup's decision path, and the policy data
|
||||
npm run build && npm run check:links # every internal link resolves (reads the build)
|
||||
GITEA_TOKEN=<token> npm run check:facts # every version agrees with its authority
|
||||
npm run verify # all of the above, in that order
|
||||
```
|
||||
|
||||
**`checkFacts.mjs`** re-reads every version, protocol number and bundle tag in
|
||||
@@ -66,22 +71,111 @@ restart; a mounted `theme.css` can only redefine custom properties, so a literal
|
||||
piece of the site an operator can never reach. Without the check, "one CSS file changes the
|
||||
appearance" becomes "one CSS file changes most of the appearance".
|
||||
|
||||
**`checkBrand.mjs`** guards the two things about the branding pipeline that fail quietly. It puts
|
||||
every literal `/brand/...` URL in the source through the route's own classifier, so a template
|
||||
asking for a size that is not on the allowlist fails the build rather than 404ing in a browser; and
|
||||
it refuses a brand string short enough that replacing it blindly at boot could corrupt a page.
|
||||
|
||||
**`checkLinks.mjs`** reads `dist/client` rather than `src/`, because half the links these pages
|
||||
carry are assembled from data files and template literals and a source scan sees an expression. It
|
||||
also refuses a commit permalink into any org repository — those stop tracking the document they name
|
||||
without ever 404ing, which is the failure a link checker would otherwise call healthy.
|
||||
|
||||
**`npm test`** is the one check that reads none of the above. Everything else inspects built output,
|
||||
and the beta signup's logic does not appear there: a honeypot can stop working entirely and produce
|
||||
a build identical to one where it works. It covers the honeypot, the signed form token, the timing
|
||||
window, the per-connection rate limit, the global cap, address validation, idempotent duplicates and
|
||||
removal. Run the file by name — `node --test test/` fails on Node 22, which is what CI uses.
|
||||
|
||||
## The closed-beta signup
|
||||
|
||||
`/beta` is the only page that renders per request and the only one that writes anything. It handles
|
||||
its own POST, so the form works with JavaScript disabled and every outcome renders in the real
|
||||
layout. The store is SQLite on the `data/` bind mount; **the raw IP address is never recorded**,
|
||||
only a salted hash used to rate-limit.
|
||||
|
||||
There is no admin page, by design — the tester list is managed from a shell:
|
||||
|
||||
```bash
|
||||
npm run beta -- stats # counts, and where the store lives
|
||||
npm run beta -- export # a CSV record + a .txt to paste into Play; marks rows exported
|
||||
npm run beta -- export -- --all # everything, including already-exported rows
|
||||
npm run beta -- remove someone@example.com
|
||||
```
|
||||
|
||||
| Variable | Default | What it does |
|
||||
|---|---|---|
|
||||
| `DATA_DIR` | `./data` | The bind mount holding `beta.sqlite` and `exports/` |
|
||||
| `BETA_IP_SALT` | random per process | Salts `ip_hash`. Unset means rate limits reset on restart |
|
||||
| `BETA_FORM_KEY` | random per process | Signs the form token, so a script must fetch the page before posting |
|
||||
| `BETA_TOTAL_CAP` | `500` | Rows above which the form closes and says so |
|
||||
| `BETA_PER_HOUR` / `BETA_PER_DAY` | `3` / `24` | Attempts one connection may make |
|
||||
|
||||
Neither random default is a placeholder to be replaced by a constant: a hard-coded salt would make
|
||||
every deployment's hashes identical and therefore reversible by anyone holding this repository.
|
||||
|
||||
## Branding is bind-mounted data
|
||||
|
||||
`brand-default/` is baked into the image and always complete. `brand/` is the bind mount and may be
|
||||
empty, partial or full. **Every file resolves against the mount first and the defaults second, per
|
||||
file**, so overriding only `theme.css` leaves every logo stock and an empty mount produces exactly
|
||||
the stock site. Nothing here goes through Vite, which would fingerprint the filenames into the build
|
||||
and put them out of the mount's reach.
|
||||
|
||||
**Rebranding is one file.** `brand-default/` holds a single raster — `logo.png` — and `GET /brand/*`
|
||||
derives every size the site asks for from whichever `logo.png` is in force: the header mark at three
|
||||
pixel ratios, the install icons, the apple-touch icon, the favicons and a real multi-resolution
|
||||
`favicon.ico`. Drop in one file, restart, and the browser tab and the installed icon change with the
|
||||
header.
|
||||
|
||||
**Brand text is applied at boot.** Pages are prerendered, so the site name, tagline and links are
|
||||
baked into HTML that a mounted file cannot reach. `npm start` runs `scripts/applyBrand.mjs` first,
|
||||
which rewrites the built HTML from what it last applied to what the mount now says — recorded in
|
||||
`dist/.brand-applied.json`, so the second edit works as well as the first. An empty mount makes it a
|
||||
no-op.
|
||||
|
||||
**A mounted `theme.css` wins by cascade layer, not by link order.** `tokens.css` is inside
|
||||
`@layer tokens`; the mounted stylesheet is unlayered and therefore beats it wherever the browser
|
||||
encounters it. Do not "fix" this by reordering the links — Astro emits its own stylesheet after the
|
||||
head markup, which is what made the ordering approach silently useless.
|
||||
|
||||
To try it: put a `theme.css`, a `logo.png` or a `brand.json` in `brand/`, run `npm run build` and
|
||||
`npm start`. `curl -I` any `/brand/*` URL and the `X-Brand-Source` header says which of mount,
|
||||
defaults or derivation answered.
|
||||
|
||||
Regenerating the stock assets is a separate, manual step — `npm run brand:assets` — because it reads
|
||||
the emblem and the Cinzel outlines from the sibling checkouts in the workspace. Its output is
|
||||
committed so that CI never needs either.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
src/
|
||||
data/platform.json Every externally-sourced fact. No version is written in prose.
|
||||
data/collection.mjs What is collected, in three scopes. /privacy renders it and the
|
||||
Play Data Safety notes are generated from it — one inventory.
|
||||
data/legal.mjs The values /terms, /privacy and the consent sentence must share.
|
||||
styles/tokens.css THE token file — the only place a colour literal may appear.
|
||||
styles/global.css The layout shell, built entirely from tokens.
|
||||
styles/starlight.css Restates our tokens as Starlight's, so the docs cannot drift.
|
||||
layouts/, components/ The marketing chrome.
|
||||
pages/ Marketing routes.
|
||||
pages/beta.astro The signup. Renders AND handles its own POST — runs per request.
|
||||
content/docs/docs/ Documentation. The extra level mounts Starlight at /docs.
|
||||
lib/brand.mjs The single accessor for brand text.
|
||||
pages/brand/ GET /brand/* — the mount, resolved and derived. Runs per request.
|
||||
lib/brand.mjs The single accessor for brand text, plus liveBrand() for the two
|
||||
routes that render per request and so miss the boot rewrite.
|
||||
lib/brandAssets.mjs Mount-first resolution and on-demand derivation.
|
||||
lib/betaStore.mjs The SQLite store: schema, dedupe, rate-limit window, cap, removal.
|
||||
lib/betaSignup.mjs Everything between a POST body and a row. Never throws.
|
||||
lib/tokens.mjs Reads tokens.css at build time, for the few values that leave CSS.
|
||||
config/sidebar.mjs The documentation journey, and the planned tree behind it.
|
||||
brand-default/ The stock brand, baked into the image and always complete.
|
||||
scripts/ The build-time checks.
|
||||
scripts/ The build-time checks, plus applyBrand (boot), brand:assets (manual)
|
||||
and beta.mjs (the tester-list CLI).
|
||||
test/ node --test. The logic the other checks cannot see.
|
||||
PLAY_DATA_SAFETY.md GENERATED. The answers to Google Play's Data Safety form, from
|
||||
src/data/collection.mjs. Edit the data, run npm run play:datasafety.
|
||||
```
|
||||
|
||||
Two directories are bind mounts at runtime and are **not** in the repository: `brand/` overrides
|
||||
|
||||
@@ -32,15 +32,23 @@ export default defineConfig({
|
||||
title: 'Runic Gateway',
|
||||
// Marketing owns the 404 (§10); a Starlight-chrome 404 on `/features/` would be wrong.
|
||||
disable404Route: true,
|
||||
// Not a file in `public/`: the brand route derives this from whichever `logo.png` is
|
||||
// mounted (§7), so the docs' tab icon changes with a rebrand like everything else.
|
||||
// Starlight's default is `/favicon.svg`, which does not exist here — every docs page
|
||||
// was requesting a 404 for it.
|
||||
favicon: '/brand/favicon.ico',
|
||||
// Starlight's own light/dark switch is deliberate: §11 keeps marketing single-theme
|
||||
// but has the docs honour the reader's preference.
|
||||
customCss: ['./src/styles/tokens.css', './src/styles/starlight.css'],
|
||||
components: {
|
||||
// Not the `logo` option: that renders an <img>, and our mark is drawn in
|
||||
// currentColor so it inherits --gold and follows a mounted theme.css. An SVG
|
||||
// loaded through <img> is a separate document with nothing to inherit from, so it
|
||||
// renders black on black. The override inlines it instead — see the component.
|
||||
// Not the `logo` option: that takes an asset imported through Vite, which
|
||||
// fingerprints the filename into the build — and a fingerprinted logo is one the
|
||||
// bind mount can never replace (§7). The override points at the stable
|
||||
// `/brand/*` URL instead.
|
||||
SiteTitle: './src/components/DocsSiteTitle.astro',
|
||||
// Starlight builds its own head, so the docs otherwise miss the brand stylesheet,
|
||||
// the manifest and the OG card entirely. See the component.
|
||||
Head: './src/components/DocsHead.astro',
|
||||
},
|
||||
credits: false,
|
||||
sidebar: docsSidebar,
|
||||
|
||||
@@ -28,5 +28,20 @@
|
||||
"is a non-empty URL, so the site gains a working demo by way of one line in a mounted",
|
||||
"file — no rebuild, consistent with §7."
|
||||
],
|
||||
"demoUrl": ""
|
||||
"demoUrl": "",
|
||||
|
||||
"$comment_beta": [
|
||||
"PLAN.md §8 / D27. The Google Play closed-test opt-in URL. Empty until the track",
|
||||
"exists, and /beta renders a waiting state rather than a broken link while it is.",
|
||||
"",
|
||||
"It is safe to publish once it is filled in, and that is the whole reason the beta can",
|
||||
"work with a site that sends no email (D7): the opt-in link only works for addresses",
|
||||
"already on the tester list, so anyone else who opens it is refused. Google does not",
|
||||
"notify testers on the email-list path either — Discord carries the announcement.",
|
||||
"",
|
||||
"/beta is one of the two routes that render per request, so unlike every other field",
|
||||
"here this one is read from the mounted copy on the NEXT REQUEST rather than at the",
|
||||
"next restart. Paste the URL in and reload the page."
|
||||
],
|
||||
"betaOptInUrl": ""
|
||||
}
|
||||
|
||||
BIN
brand-default/logo.png
Normal file
|
After Width: | Height: | Size: 438 KiB |
BIN
brand-default/og-image.png
Normal file
|
After Width: | Height: | Size: 112 KiB |
116
brand-default/theme.css
Normal file
@@ -0,0 +1,116 @@
|
||||
/* ============================================================================
|
||||
theme.css — the stock theme (PLAN.md §7)
|
||||
|
||||
THIS FILE IS DELIBERATELY EMPTY OF RULES.
|
||||
|
||||
It is loaded last on every page, after the site's own stylesheet, so anything
|
||||
it declares wins. The stock site needs to override nothing, so the stock copy
|
||||
overrides nothing — an empty mount and a stock deployment must produce the
|
||||
same pixels, and the simplest way to guarantee that is for the default to say
|
||||
nothing at all.
|
||||
|
||||
It ships anyway, rather than being absent, for two reasons: the `<link>` in
|
||||
every page's head must resolve to a stylesheet rather than a 404, and this is
|
||||
the file an operator copies out, edits and mounts back. What follows is the
|
||||
whole reference they need.
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
HOW TO RECOLOUR THIS SITE
|
||||
----------------------------------------------------------------------------
|
||||
|
||||
Copy this file into the directory bind-mounted at /app/brand, uncomment the
|
||||
block below, change the values, and restart the container. No rebuild, no
|
||||
image push. Every asset and every field resolves against the mount first and
|
||||
the baked-in defaults second, per file, so overriding theme.css alone leaves
|
||||
the logo, the icons and the text exactly as they are.
|
||||
|
||||
docker cp <container>:/app/brand-default/theme.css ./brand/theme.css
|
||||
$EDITOR ./brand/theme.css
|
||||
docker compose restart
|
||||
|
||||
Only custom properties belong here. Every colour, radius, shadow and font in
|
||||
the site is one, defined in a single file, and `scripts/checkTokens.mjs`
|
||||
fails the build if a literal ever appears anywhere else — so there is no
|
||||
corner of the design this file cannot reach. Ordinary CSS rules will work,
|
||||
but they are the thing that breaks on the next release; properties are the
|
||||
supported surface.
|
||||
|
||||
Names match the product's own `client/src/styles/theme.css` where the
|
||||
concepts line up, so a theme written for a Runic Gateway deployment is
|
||||
legible here and mostly portable.
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
|
||||
:root {
|
||||
--bg: #0e1318; Page ground
|
||||
--bg-deep: #0b0f14; Header and footer ground
|
||||
--panel-a: #192231; Panel gradient, top
|
||||
--panel-b: #141a21; Panel gradient, bottom
|
||||
--panel-flat: #11161d; Flat panels, code blocks
|
||||
--line: #2a3544; Borders
|
||||
--line-soft: #1d2733; Hairlines and dividers
|
||||
|
||||
--accent: #7f99bd; Links and interface emphasis
|
||||
--ink: #eef3f8; Brightest text
|
||||
--head: #e6edf6; Headings
|
||||
--text: #c4cdd8; Body copy
|
||||
--muted: #aeb8c4; Secondary copy
|
||||
--dim: #6f7d8e; Captions and metadata
|
||||
|
||||
--gold: #c8a368; Emphasis, rules, the display face
|
||||
--gold-deep: #946b3c; Gold borders. Too dark for text.
|
||||
--gold-bright: #e4cb90; Highlights on gold
|
||||
--portal: #15b4de; The live-state signal and diagram lines
|
||||
--portal-deep: #0b6398; Glow fills. Too dark for text.
|
||||
--portal-bright: #1bd6f1;
|
||||
--danger: #ff4e43; Errors and destructive actions
|
||||
|
||||
--mode-live: #5fb98a; Status pill: running
|
||||
--mode-maint: #e6c26a; Status pill: maintenance
|
||||
|
||||
--display: 'Cinzel Variable', Georgia, serif;
|
||||
--sans: 'Inter Variable', system-ui, sans-serif;
|
||||
--mono: ui-monospace, Consolas, monospace;
|
||||
|
||||
--radius-pill: 999px;
|
||||
--radius-panel: 12px;
|
||||
--radius-card: 10px;
|
||||
--radius-input: 8px;
|
||||
|
||||
--measure: 68ch; Reading measure
|
||||
--page-max: 1180px; Content column
|
||||
--gutter: 24px;
|
||||
--header-h: 68px;
|
||||
}
|
||||
|
||||
The documentation pages carry a light theme as well, because §11 has the docs
|
||||
honour the reader's preference while the marketing pages stay dark. Those
|
||||
values are separate properties, so a light-mode change does not disturb the
|
||||
dark one:
|
||||
|
||||
:root {
|
||||
--light-bg: #f6f8fb;
|
||||
--light-panel: #ffffff;
|
||||
--light-line: #d6dee9;
|
||||
--light-ink: #16202c;
|
||||
--light-text: #33414f;
|
||||
--light-muted: #5a6875;
|
||||
--light-accent: #3c5f8f;
|
||||
--light-gold: #7a5a24;
|
||||
--light-portal: #0a5f80;
|
||||
}
|
||||
|
||||
TWO THINGS THIS FILE CANNOT DO
|
||||
----------------------------------------------------------------------------
|
||||
|
||||
The logo is artwork, not a colour. It is raster art shared with the product's
|
||||
own site and the Android launcher icon, so no property recolours it — replace
|
||||
`logo.png` in the mount instead, and the header mark, the favicon, the
|
||||
install icons and every other size follow from that one file.
|
||||
|
||||
Contrast is not checked for you. The stock palette is held to WCAG AA against
|
||||
the stock ground, and each value's measured ratio is recorded next to it in
|
||||
`src/styles/tokens.css`. Change the ground without changing the ink and that
|
||||
guarantee is gone, silently.
|
||||
|
||||
============================================================================ */
|
||||
5
brand-default/wordmark.svg
Normal file
|
After Width: | Height: | Size: 116 KiB |
1272
package-lock.json
generated
22
package.json
@@ -12,11 +12,23 @@
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"start": "node ./dist/server/entry.mjs",
|
||||
"start": "node scripts/applyBrand.mjs && node ./dist/server/entry.mjs",
|
||||
"check": "astro check",
|
||||
"check:facts": "node scripts/checkFacts.mjs",
|
||||
"check:tokens": "node scripts/checkTokens.mjs",
|
||||
"verify": "npm run check:tokens && npm run check:facts && npm run check && npm run build"
|
||||
"check:brand": "node scripts/checkBrand.mjs",
|
||||
"check:links": "node scripts/checkLinks.mjs",
|
||||
"check:datasafety": "node scripts/playDataSafety.mjs --check",
|
||||
"check:quickstart": "node scripts/checkQuickstart.mjs",
|
||||
"check:reference": "node scripts/checkReference.mjs",
|
||||
"check:sidebar": "node scripts/checkSidebar.mjs",
|
||||
"check:screens": "node scripts/checkScreens.mjs",
|
||||
"play:datasafety": "node scripts/playDataSafety.mjs",
|
||||
"beta": "node scripts/beta.mjs",
|
||||
"test": "node --test test/beta.test.mjs test/legal.test.mjs",
|
||||
"brand:assets": "node scripts/buildBrandAssets.mjs",
|
||||
"screens:capture": "node scripts/captureScreens.mjs",
|
||||
"verify": "npm run check:sidebar && npm run check:screens && npm run check:tokens && npm run check:brand && npm run check:datasafety && npm run check && npm test && npm run build && npm run check:links && npm run check:facts && npm run check:quickstart && npm run check:reference"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/node": "^11.1.4",
|
||||
@@ -24,10 +36,14 @@
|
||||
"@fontsource-variable/cinzel": "^5.3.0",
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
"astro": "^7.2.4",
|
||||
"better-sqlite3": "^12.11.1",
|
||||
"sharp": "^0.35.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "^0.9.10",
|
||||
"typescript": "^6.0.3"
|
||||
"opentype.js": "^2.0.0",
|
||||
"puppeteer-core": "^23.11.1",
|
||||
"typescript": "^6.0.3",
|
||||
"yaml": "^2.8.1"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
public/screens/admin-appearance.webp
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
public/screens/admin-dashboard.webp
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
public/screens/admin-modules.webp
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
public/screens/admin-shard.webp
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
public/screens/admin-users.webp
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
public/screens/app-account.webp
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
public/screens/app-drawer.webp
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
public/screens/app-home.webp
Normal file
|
After Width: | Height: | Size: 136 KiB |
BIN
public/screens/app-market.webp
Normal file
|
After Width: | Height: | Size: 89 KiB |
BIN
public/screens/app-shard.webp
Normal file
|
After Width: | Height: | Size: 111 KiB |
BIN
public/screens/app-wiki.webp
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
public/screens/guilds.webp
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
public/screens/houses.webp
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
public/screens/marketplace.webp
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
public/screens/news.webp
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
public/screens/shard-status.webp
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
public/screens/spawn-atlas.webp
Normal file
|
After Width: | Height: | Size: 58 KiB |
275
scripts/applyBrand.mjs
Normal file
@@ -0,0 +1,275 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* applyBrand.mjs — brand TEXT from the bind mount (PLAN.md §7)
|
||||
*
|
||||
* Runs immediately before the server, as part of `npm start`. For an empty mount — the
|
||||
* stock deployment, and the common case — it reads two small files, finds nothing to do
|
||||
* and exits. It is not a build step and it is not a template engine.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* THE PROBLEM THIS SOLVES
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* §7 promises that renaming the product, changing the Discord invite or publishing a
|
||||
* different contact address is the same class of change as swapping a logo: edit the file
|
||||
* in the mount, restart, done. §6 prerenders every page. Those two are in direct conflict,
|
||||
* because a value read at build time is baked into HTML that no mounted file can reach.
|
||||
*
|
||||
* Assets escape the conflict by being served per request from `/brand/*`. Text cannot: it
|
||||
* is inside the markup.
|
||||
*
|
||||
* Three ways out were considered and the org lead chose this one (2026-08-20):
|
||||
*
|
||||
* 1. THIS — rewrite the built HTML at boot, before the server opens a socket. Every page
|
||||
* stays prerendered, Pagefind still has static HTML to index in phase 10, and the docs
|
||||
* are covered by the same pass as the marketing pages.
|
||||
* 2. Mark the brand-bearing pages `prerender = false`. Simpler, but the footer is on
|
||||
* every page, so "the handful" is the whole site — and the docs would have to stay
|
||||
* static for search anyway, leaving them showing the stock name.
|
||||
* 3. Accept text as build-time and amend §7. Cheapest, and it gives up the promise.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY IT REWRITES FROM A RECORD RATHER THAN FROM THE DEFAULTS
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* The obvious version of this script replaces the DEFAULT value with the mounted one. It
|
||||
* works exactly once. The second time an operator edits the mount — renaming from "Foo" to
|
||||
* "Bar" — the default no longer appears anywhere in the HTML, every replacement matches
|
||||
* nothing, and the site silently keeps saying "Foo". The bug would surface as "the first
|
||||
* change worked and the second did nothing", which is a miserable thing to debug.
|
||||
*
|
||||
* So the script records what it baked, in `dist/.brand-applied.json`, and the next run
|
||||
* rewrites from that record to the new values. A fresh image has no record and starts from
|
||||
* the defaults, which is the same thing said differently.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHAT MAKES PLAIN STRING REPLACEMENT SAFE HERE
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* Not much, on its own — which is why `scripts/checkBrand.mjs` exists. It fails the build
|
||||
* if any rewritable default is short enough to collide with ordinary markup or prose. The
|
||||
* check is the mechanism; the eight-character minimum below is only its last line.
|
||||
*
|
||||
* Replacing the site name across the docs as well as the marketing pages is deliberate. If
|
||||
* the product is renamed, prose that says "Runic Gateway" should say the new name too.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
||||
|
||||
const DIST = process.env.BRAND_DIST || path.join(ROOT, 'dist');
|
||||
const CLIENT = path.join(DIST, 'client');
|
||||
const RECORD = path.join(DIST, '.brand-applied.json');
|
||||
|
||||
const MOUNT_DIR = process.env.BRAND_DIR || path.join(process.cwd(), 'brand');
|
||||
const DEFAULT_DIR = process.env.BRAND_DEFAULT_DIR || path.join(process.cwd(), 'brand-default');
|
||||
|
||||
/**
|
||||
* The fields that appear in markup as literal text, and may therefore be rewritten.
|
||||
*
|
||||
* `demoUrl` is not one of them and is handled separately below: its default is the empty
|
||||
* string, and there is no such thing as replacing every occurrence of "".
|
||||
*/
|
||||
const TEXT_FIELDS = ['siteName', 'tagline', 'contactEmail', 'discordInvite', 'giteaOrg'];
|
||||
|
||||
/**
|
||||
* Below this length a value is too likely to occur inside unrelated markup — a class name,
|
||||
* an attribute, a word in a sentence — for a blind replacement to be safe. `checkBrand.mjs`
|
||||
* enforces the same floor at build time, where the failure is cheap; this is the copy that
|
||||
* runs in production, where being wrong means corrupted pages.
|
||||
*/
|
||||
const MIN_REWRITABLE_LENGTH = 8;
|
||||
|
||||
const REWRITABLE_EXTENSIONS = new Set(['.html', '.webmanifest']);
|
||||
|
||||
function readJson(file, label) {
|
||||
try {
|
||||
return JSON.parse(readFileSync(file, 'utf8'));
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') return null;
|
||||
// A malformed mounted brand.json must not take the site down.
|
||||
//
|
||||
// The alternative — exit non-zero and let the container crash-loop — surfaces the typo
|
||||
// immediately, and that is genuinely tempting. But an operator editing a mount is
|
||||
// watching the logs, whereas the restart six months later that trips over the same file
|
||||
// is unattended, and a marketing site that is up with stock branding beats one that is
|
||||
// down with correct branding.
|
||||
console.error(`\n[brand] ${label} is not valid JSON and will be IGNORED:\n ${file}\n ${error.message}\n`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** `$comment` keys are documentation for whoever opens the mounted copy, not fields. */
|
||||
const fieldsOf = (object) =>
|
||||
Object.fromEntries(Object.entries(object || {}).filter(([key]) => !key.startsWith('$')));
|
||||
|
||||
const defaults = fieldsOf(readJson(path.join(DEFAULT_DIR, 'brand.json'), 'the stock brand.json'));
|
||||
const mounted = fieldsOf(readJson(path.join(MOUNT_DIR, 'brand.json'), 'the mounted brand.json'));
|
||||
|
||||
if (!Object.keys(defaults).length) {
|
||||
console.error(
|
||||
`\n[brand] no stock brand.json at ${path.join(DEFAULT_DIR, 'brand.json')}.\n` +
|
||||
`brand-default/ is baked into the image and must always be complete (§7).\n`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
for (const key of Object.keys(mounted)) {
|
||||
if (!(key in defaults)) {
|
||||
console.warn(`[brand] the mounted brand.json sets an unknown field "${key}" — ignoring it.`);
|
||||
}
|
||||
}
|
||||
|
||||
const resolved = { ...defaults, ...mounted };
|
||||
const previous = { ...defaults, ...(fieldsOf(readJson(RECORD, 'the applied-brand record')) || {}) };
|
||||
|
||||
/* ---------------------------------------------------------------------------------------
|
||||
Work out what actually changed
|
||||
--------------------------------------------------------------------------------------- */
|
||||
|
||||
const escapeHtml = (value) =>
|
||||
value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
|
||||
const replacements = [];
|
||||
|
||||
for (const field of TEXT_FIELDS) {
|
||||
const from = previous[field];
|
||||
const to = resolved[field];
|
||||
if (typeof from !== 'string' || typeof to !== 'string' || from === to) continue;
|
||||
|
||||
if (from.length < MIN_REWRITABLE_LENGTH) {
|
||||
console.error(
|
||||
`[brand] refusing to rewrite "${field}": the value being replaced (${JSON.stringify(from)}) ` +
|
||||
`is under ${MIN_REWRITABLE_LENGTH} characters and would match unrelated markup.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
replacements.push({ field, from, to });
|
||||
// Astro escapes `&`, `<`, `>` and `"` when it writes a value into markup, so a Discord
|
||||
// invite or a Gitea URL carrying a query string appears in the HTML in its escaped form.
|
||||
// Adding the escaped pair rather than unescaping the document keeps this a string
|
||||
// operation on bytes, with no parser to disagree with the browser's.
|
||||
const escapedFrom = escapeHtml(from);
|
||||
if (escapedFrom !== from) replacements.push({ field, from: escapedFrom, to: escapeHtml(to) });
|
||||
}
|
||||
|
||||
/**
|
||||
* The demo slot (§15 / D12) is a rendering decision rather than a piece of text, and this
|
||||
* is the one place a string replacement can still express it.
|
||||
*
|
||||
* The markup contract, which phase 3 writes and this script relies on:
|
||||
*
|
||||
* <a class="demo-cta" href="" data-demo-url="">See it running</a>
|
||||
*
|
||||
* `global.css` hides `[data-demo-url='']`, so a stock build renders nothing. Setting
|
||||
* `demoUrl` in the mount turns both empty attributes into the URL, which fills the link and
|
||||
* reveals it in the same edit. Going back to an empty value reverses it, because the
|
||||
* previous value is in the record.
|
||||
*/
|
||||
const demoFrom = previous.demoUrl || '';
|
||||
const demoTo = resolved.demoUrl || '';
|
||||
|
||||
if (demoFrom !== demoTo) {
|
||||
const attr = (value) => `href="${escapeHtml(value)}" data-demo-url="${escapeHtml(value)}"`;
|
||||
replacements.push({ field: 'demoUrl', from: attr(demoFrom), to: attr(demoTo) });
|
||||
}
|
||||
|
||||
/**
|
||||
* The demo's DEEP links (§15 / D25), which `/features/` writes one of per capability that
|
||||
* has a stable public route:
|
||||
*
|
||||
* <a class="demo-link" href="" data-demo-url="" data-demo-path="/uo/market">see it live</a>
|
||||
*
|
||||
* The slot above cannot express these. It is a literal string swap of a whole URL, so it
|
||||
* can only ever put the demo's root in an `href` — and reversing it would not even find a
|
||||
* deep link, whose `href` is the root plus a path and therefore matches no literal the
|
||||
* script knows.
|
||||
*
|
||||
* This pass is a different shape on purpose: it does not replace a previous value, it
|
||||
* RECOMPUTES both attributes from `data-demo-path`, which never changes. That makes it
|
||||
* idempotent and exactly reversible, so it runs unconditionally in the loop below rather
|
||||
* than only when the demo URL moved. `data-demo-url` is still filled with the bare root
|
||||
* because `global.css` hides `[data-demo-url='']` — the visibility rule stays one rule for
|
||||
* both kinds of link, and only the `href` differs.
|
||||
*/
|
||||
const DEEP_LINK = /href="[^"]*" data-demo-url="[^"]*" data-demo-path="([^"]*)"/g;
|
||||
|
||||
const deepLinkTo = (demoPath) => {
|
||||
const href = demoTo ? `${demoTo.replace(/\/+$/, '')}${demoPath}` : '';
|
||||
return (
|
||||
`href="${escapeHtml(href)}" data-demo-url="${escapeHtml(demoTo)}" ` +
|
||||
`data-demo-path="${demoPath}"`
|
||||
);
|
||||
};
|
||||
|
||||
if (!replacements.length) {
|
||||
console.log('[brand] mount matches what is already applied; nothing to rewrite.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------------------
|
||||
Rewrite
|
||||
--------------------------------------------------------------------------------------- */
|
||||
|
||||
if (!existsSync(CLIENT)) {
|
||||
console.error(`\n[brand] no build to rewrite at ${CLIENT}. Run \`npm run build\` first.\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function* walk(dir) {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) yield* walk(full);
|
||||
else if (REWRITABLE_EXTENSIONS.has(path.extname(entry.name))) yield full;
|
||||
}
|
||||
}
|
||||
|
||||
const counts = new Map(replacements.map((r) => [r.field, 0]));
|
||||
counts.set('demoDeep', 0);
|
||||
let filesTouched = 0;
|
||||
|
||||
for (const file of walk(CLIENT)) {
|
||||
const before = readFileSync(file, 'utf8');
|
||||
let after = before;
|
||||
|
||||
for (const { field, from, to } of replacements) {
|
||||
if (!after.includes(from)) continue;
|
||||
counts.set(field, counts.get(field) + after.split(from).length - 1);
|
||||
after = after.split(from).join(to);
|
||||
}
|
||||
|
||||
// After the literal swaps, never before: the plain-slot replacement also matches the
|
||||
// first two attributes of a deep link, so it runs first and this pass corrects the
|
||||
// `href` it just wrote. Recomputing rather than replacing is what makes that safe.
|
||||
after = after.replace(DEEP_LINK, (whole, demoPath) => {
|
||||
const rebuilt = deepLinkTo(demoPath);
|
||||
if (rebuilt !== whole) counts.set('demoDeep', counts.get('demoDeep') + 1);
|
||||
return rebuilt;
|
||||
});
|
||||
|
||||
if (after !== before) {
|
||||
writeFileSync(file, after);
|
||||
filesTouched++;
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(RECORD, `${JSON.stringify(resolved, null, 2)}\n`);
|
||||
|
||||
console.log(`[brand] applied the mounted brand to ${filesTouched} file(s):`);
|
||||
for (const { field, from, to } of replacements) {
|
||||
if (from.startsWith('href=')) continue; // the demo pair, reported once below
|
||||
console.log(` ${field.padEnd(14)} ${JSON.stringify(from)} -> ${JSON.stringify(to)} (${counts.get(field)}x)`);
|
||||
}
|
||||
if (demoFrom !== demoTo) {
|
||||
console.log(` ${'demoUrl'.padEnd(14)} ${demoTo ? `slot shown -> ${demoTo}` : 'slot hidden'} (${counts.get('demoUrl')}x)`);
|
||||
}
|
||||
if (counts.get('demoDeep')) {
|
||||
console.log(
|
||||
` ${'demoUrl deep'.padEnd(14)} ${demoTo ? `linked -> ${demoTo}/…` : 'links hidden'} (${counts.get('demoDeep')}x)`
|
||||
);
|
||||
}
|
||||
|
||||
// Pagefind builds its search index from the HTML at BUILD time (phase 10), so a rename
|
||||
// applied here reaches the pages but not the search results. Worth fixing when search
|
||||
// lands; recorded here rather than in a plan section nobody will re-read.
|
||||
169
scripts/beta.mjs
Normal file
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* beta.mjs — the closed-beta tester list, from the shell. PLAN.md §8, phase 5.
|
||||
*
|
||||
* node scripts/beta.mjs export → data/exports/<date>.csv, marks rows exported
|
||||
* node scripts/beta.mjs export --all → everything, including already-exported rows
|
||||
* node scripts/beta.mjs remove <email> → a deletion request
|
||||
* node scripts/beta.mjs stats
|
||||
*
|
||||
* In the container, with the compose file of §6:
|
||||
*
|
||||
* docker compose exec site node scripts/beta.mjs export
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY THIS IS A CLI AND NOT AN ADMIN PAGE
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* §8 is explicit, and the argument is worth restating where somebody might be tempted to
|
||||
* "improve" it. An authenticated HTTP surface on a marketing site is a login form, a
|
||||
* session, a password to rotate, a lockout policy and a thing to patch — brought into
|
||||
* existence for an operation performed by the one person who already has shell on the host,
|
||||
* against a file already on their disk. Adding it would mean this site had an attack
|
||||
* surface where it currently has none, and the only thing gained is not having to type a
|
||||
* command.
|
||||
*
|
||||
* The CSV lands in the bind mount and is opened locally. Google Play has no API for adding
|
||||
* an individual tester — every route into a closed test ends with a human pasting a list —
|
||||
* so the last step is manual no matter how this is built.
|
||||
*
|
||||
* `remove` exists because §9 promises deletion on request, and a promise with no mechanism
|
||||
* behind it is a sentence.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
EXPORT_DIR,
|
||||
close,
|
||||
markExported,
|
||||
pending,
|
||||
removeSignup,
|
||||
stats,
|
||||
} from '../src/lib/betaStore.mjs';
|
||||
|
||||
const [command, ...rest] = process.argv.slice(2);
|
||||
|
||||
const USAGE = `
|
||||
node scripts/beta.mjs export [--all] write a CSV of the tester list
|
||||
node scripts/beta.mjs remove <email> honour a deletion request
|
||||
node scripts/beta.mjs stats counts, and where the store lives
|
||||
`;
|
||||
|
||||
/**
|
||||
* RFC 4180 quoting. Overkill for addresses that have already been validated against a
|
||||
* regex that admits no commas or quotes — and worth having anyway, because the day this
|
||||
* function is wrong is the day somebody pastes a corrupted list into a system that emails
|
||||
* strangers, and nothing about that failure would be visible in the CSV.
|
||||
*/
|
||||
const csvCell = (value) => {
|
||||
const text = value === null || value === undefined ? '' : String(value);
|
||||
return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
|
||||
};
|
||||
|
||||
function doExport(all) {
|
||||
const rows = pending({ all });
|
||||
|
||||
if (!rows.length) {
|
||||
console.log(
|
||||
all
|
||||
? 'Nothing to export — the list is empty.'
|
||||
: 'Nothing new to export. Use --all to re-export rows already marked exported.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
fs.mkdirSync(EXPORT_DIR, { recursive: true });
|
||||
|
||||
// Dated rather than sequential, and suffixed only if a second export happens the same
|
||||
// day: the file name should say when the list was taken, because that is the question
|
||||
// being asked when somebody finds three of these in a directory next year.
|
||||
const day = new Date().toISOString().slice(0, 10);
|
||||
let file = path.join(EXPORT_DIR, `${day}.csv`);
|
||||
for (let n = 2; fs.existsSync(file); n += 1) {
|
||||
file = path.join(EXPORT_DIR, `${day}-${n}.csv`);
|
||||
}
|
||||
|
||||
// Two files, deliberately. Play's tester list wants addresses and nothing else — one per
|
||||
// line, ready to paste — while the CSV is the record: when they signed up, what they
|
||||
// agreed to, what state the row is in. Producing only the CSV would mean hand-editing it
|
||||
// before every paste, which is where a mistake would come from.
|
||||
const csv = [
|
||||
['id', 'email', 'created_at', 'status', 'consent_text'].join(','),
|
||||
...rows.map((row) =>
|
||||
[row.id, row.email, row.created_at, row.status, row.consent_text].map(csvCell).join(',')
|
||||
),
|
||||
].join('\r\n');
|
||||
|
||||
const listFile = file.replace(/\.csv$/, '.txt');
|
||||
fs.writeFileSync(file, `${csv}\r\n`, 'utf8');
|
||||
fs.writeFileSync(listFile, `${rows.map((row) => row.email).join('\n')}\n`, 'utf8');
|
||||
|
||||
const marked = markExported(rows.map((row) => row.id));
|
||||
|
||||
console.log(`Wrote ${rows.length} row(s):`);
|
||||
console.log(` ${file} the record`);
|
||||
console.log(` ${listFile} paste this into Play`);
|
||||
console.log(`Marked ${marked} row(s) exported.`);
|
||||
console.log(
|
||||
'\nPlay Console → Testing → Closed testing → your track → Testers → paste the list.\n' +
|
||||
'Testers still have to open the opt-in link themselves; being on the list is not enough.'
|
||||
);
|
||||
}
|
||||
|
||||
function doRemove(email) {
|
||||
if (!email) {
|
||||
console.error('remove needs an address: node scripts/beta.mjs remove someone@example.com');
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
|
||||
const result = removeSignup(email.trim().toLowerCase());
|
||||
|
||||
if (result.removed) {
|
||||
console.log(`Removed #${result.id}. The address is overwritten, not just flagged.`);
|
||||
console.log(
|
||||
'If that row was already exported, remove the address from the Play tester list too — ' +
|
||||
'this store is not the only copy once a CSV has been pasted.'
|
||||
);
|
||||
} else if (result.alreadyRemoved) {
|
||||
console.log(`#${result.id} was already removed. Nothing to do.`);
|
||||
} else {
|
||||
console.log('No such address on the list. Nothing to do.');
|
||||
}
|
||||
}
|
||||
|
||||
function doStats() {
|
||||
const s = stats();
|
||||
const rows = [
|
||||
['store', s.path],
|
||||
['total rows', s.total],
|
||||
['new (not yet exported)', s.new],
|
||||
['exported', s.exported],
|
||||
['removed', s.removed],
|
||||
['counting toward the cap', `${s.live} / ${s.cap}`],
|
||||
['attempts, last 24h', s.attemptsLastDay],
|
||||
];
|
||||
|
||||
const width = Math.max(...rows.map(([label]) => label.length));
|
||||
for (const [label, value] of rows) console.log(` ${String(label).padEnd(width)} ${value}`);
|
||||
}
|
||||
|
||||
try {
|
||||
switch (command) {
|
||||
case 'export':
|
||||
doExport(rest.includes('--all'));
|
||||
break;
|
||||
case 'remove':
|
||||
doRemove(rest[0]);
|
||||
break;
|
||||
case 'stats':
|
||||
doStats();
|
||||
break;
|
||||
default:
|
||||
console.log(USAGE);
|
||||
process.exitCode = command ? 2 : 0;
|
||||
}
|
||||
} finally {
|
||||
close();
|
||||
}
|
||||
407
scripts/buildBrandAssets.mjs
Normal file
@@ -0,0 +1,407 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* buildBrandAssets.mjs — PLAN.md §7, §11, D11
|
||||
*
|
||||
* Generates the stock brand assets in `brand-default/` from the project's real artwork.
|
||||
* Its output is COMMITTED: `brand-default/` is baked into the image and must always be
|
||||
* complete (§7), and CI must not need the artwork, a font file, or a working network to
|
||||
* build the site. This script is an authoring tool, run by hand when the mark changes.
|
||||
*
|
||||
* node scripts/buildBrandAssets.mjs # regenerate everything
|
||||
* node scripts/buildBrandAssets.mjs --check # verify the committed output is current
|
||||
*
|
||||
* WHAT IT WRITES, AND WHAT IT DELIBERATELY DOES NOT
|
||||
* -------------------------------------------------------------------------------------
|
||||
* Four files, and only four:
|
||||
*
|
||||
* logo.png 512x512 the canonical raster mark
|
||||
* wordmark.svg the horizontal lockup, emblem + "Runic Gateway"
|
||||
* og-image.png 1200x630 the link preview card
|
||||
* theme.css written by hand, not here — listed only so the set is legible
|
||||
*
|
||||
* Every other size and format the site asks for — logo-64.webp, icon-192.png, favicon.ico,
|
||||
* apple-touch-icon.png — is DERIVED AT RUNTIME by `src/pages/brand/[...file].ts` from
|
||||
* whichever `logo.png` is in force. That is the decision that keeps §7's promise literally
|
||||
* true: "swapping a logo is a file copy" means ONE file, not fifteen. Precomputing the
|
||||
* derivatives here would mean an operator who drops in a new logo.png gets a new header
|
||||
* mark and the old favicon, which is worse than either outcome.
|
||||
*
|
||||
* THE SOURCES LIVE OUTSIDE THIS REPOSITORY, ON PURPOSE
|
||||
* -------------------------------------------------------------------------------------
|
||||
* The emblem belongs to the product (D11 — the same file is the website's logo and the
|
||||
* Android launcher icon; adopting it is what makes the three surfaces one product), and
|
||||
* Cinzel's outlines come from the Android app's font directory because opentype.js cannot
|
||||
* read the WOFF2 that `@fontsource-variable/cinzel` ships. Both are read from the sibling
|
||||
* checkouts in the workspace and neither is vendored: a 1.4 MB PNG and a 125 KB TTF in a
|
||||
* repository that needs them once per redesign is a cost paid on every clone forever.
|
||||
*
|
||||
* Override either with --emblem / --cinzel / --inter if the workspace is laid out
|
||||
* differently. Without them the script fails loudly rather than quietly skipping a file,
|
||||
* because a half-regenerated brand-default is worse than an untouched one.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
import opentype from 'opentype.js';
|
||||
import sharp from 'sharp';
|
||||
|
||||
const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
||||
const WORKSPACE = path.resolve(ROOT, '..');
|
||||
const OUT = path.join(ROOT, 'brand-default');
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const CHECK_ONLY = argv.includes('--check');
|
||||
|
||||
function flag(name, fallback) {
|
||||
const at = argv.indexOf(`--${name}`);
|
||||
return at !== -1 && argv[at + 1] ? path.resolve(argv[at + 1]) : fallback;
|
||||
}
|
||||
|
||||
const SOURCES = {
|
||||
emblem: flag(
|
||||
'emblem',
|
||||
path.join(WORKSPACE, 'website/client/public/assets/img/runic-emblem.png')
|
||||
),
|
||||
cinzel: flag(
|
||||
'cinzel',
|
||||
path.join(WORKSPACE, 'android-app/app/src/main/res/font/cinzel_variable.ttf')
|
||||
),
|
||||
inter: flag(
|
||||
'inter',
|
||||
path.join(WORKSPACE, 'android-app/app/src/main/res/font/inter_variable.ttf')
|
||||
),
|
||||
};
|
||||
|
||||
for (const [name, file] of Object.entries(SOURCES)) {
|
||||
if (existsSync(file)) continue;
|
||||
console.error(
|
||||
`\nbuildBrandAssets: the ${name} source is missing.\n\n expected: ${file}\n\n` +
|
||||
`This script reads the product's own artwork from the sibling checkouts in the\n` +
|
||||
`workspace (see the header). Pass --${name} <path> if yours is elsewhere.\n`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------------------
|
||||
Tokens
|
||||
-------------------------------------------------------------------------------------
|
||||
The generated assets are part of the design system, so their colours come from the token
|
||||
file rather than from this script. Same flat regex as `src/lib/tokens.mjs`, for the same
|
||||
reason: the file is one we own and keep flat, and a CSS parser here would be a
|
||||
dependency bought for four lookups.
|
||||
|
||||
Note the direction of the exception. `checkTokens.mjs` forbids a colour literal in
|
||||
`src/`; the literals it writes into `brand-default/` are fine and are meant to be there,
|
||||
because those files ARE the stock brand — the very thing an operator replaces. */
|
||||
const tokens = Object.fromEntries(
|
||||
readFileSync(path.join(ROOT, 'src/styles/tokens.css'), 'utf8')
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.matchAll(/(--[a-z0-9-]+)\s*:\s*([^;]+);/gi)
|
||||
.map((m) => [m[1], m[2].trim()])
|
||||
);
|
||||
|
||||
const brand = JSON.parse(readFileSync(path.join(OUT, 'brand.json'), 'utf8'));
|
||||
|
||||
/* -------------------------------------------------------------------------------------
|
||||
Type
|
||||
------------------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Cinzel and Inter both ship as variable fonts, and opentype.js reads the DEFAULT instance
|
||||
* unless told otherwise — for Cinzel that is wght 400, which is too light to carry a
|
||||
* wordmark. `variation.set` moves the axis before the outlines are taken.
|
||||
*/
|
||||
function loadFont(file, weight) {
|
||||
const font = opentype.parse(readFileSync(file).buffer);
|
||||
font.variation.set({ wght: weight });
|
||||
return font;
|
||||
}
|
||||
|
||||
/**
|
||||
* Text as outlines, never as a `<text>` element.
|
||||
*
|
||||
* An SVG referencing a font family only renders correctly where that font is installed.
|
||||
* Loaded through `<img>` — which is how `wordmark.svg` is used — the SVG is an independent
|
||||
* document that cannot see the page's `@font-face` rules, and librsvg (which sharp uses to
|
||||
* rasterise the OG card) resolves families through fontconfig, where Cinzel is not. Both
|
||||
* would silently fall back to a serif default. Outlines have no such dependency: the shape
|
||||
* is the file.
|
||||
*
|
||||
* The same class of mistake as phase 1's `currentColor`-through-`<img>` bug — an SVG in an
|
||||
* `<img>` inherits nothing from the page, neither colour nor fonts.
|
||||
*/
|
||||
function textPath(font, text, size, { x = 0, y = 0, tracking = 0, fill }) {
|
||||
const scale = size / font.unitsPerEm;
|
||||
const parts = [];
|
||||
let cursor = x;
|
||||
|
||||
// `charToGlyph` per character rather than `stringToGlyphs`, which runs opentype.js's
|
||||
// shaper and throws on Cinzel: "substitutionType : 62 lookupType: 6 - substFormat: 2 is
|
||||
// not yet supported", from a `ccmp` lookup it cannot read. Shaping buys nothing here —
|
||||
// the strings are Latin, and Cinzel is an all-caps face with no ligatures to form — so
|
||||
// the plain mapping is both sufficient and the more predictable of the two.
|
||||
const glyphs = [...text].map((char) => font.charToGlyph(char));
|
||||
|
||||
for (const [i, glyph] of glyphs.entries()) {
|
||||
// Every glyph is drawn at the ORIGIN and moved into place with a transform, rather
|
||||
// than drawn at `cursor` directly.
|
||||
//
|
||||
// Asking opentype.js for a path at a non-zero origin produces NaN coordinates in some
|
||||
// glyphs — which glyph depends on the exact cursor value, so it moves as the string or
|
||||
// the tracking changes. An SVG path parser stops at the first malformed command and
|
||||
// renders what it had, so the failure is silent and partial: the first draft of this
|
||||
// lockup read "Runic Gate" and looked like a typo rather than a bug. At the origin the
|
||||
// output is clean for every glyph, with and without the variation axis set.
|
||||
const glyphPath = glyph.getPath(0, 0, size);
|
||||
if (glyphPath.commands.length) {
|
||||
const dx = cursor.toFixed(2);
|
||||
const dy = y.toFixed(2);
|
||||
parts.push(`<path transform="translate(${dx} ${dy})" d="${glyphPath.toPathData(2)}"/>`);
|
||||
}
|
||||
cursor += glyph.advanceWidth * scale + tracking;
|
||||
// Kerning is per PAIR, so it is applied looking ahead rather than per glyph.
|
||||
if (glyphs[i + 1]) cursor += font.getKerningValue(glyph, glyphs[i + 1]) * scale;
|
||||
}
|
||||
|
||||
const markup = `<g fill="${fill}">${parts.join('')}</g>`;
|
||||
|
||||
// The guard that makes the bug above unable to ship again. A malformed path degrades
|
||||
// quietly in every renderer; this file is generated once and committed, so the check
|
||||
// costs nothing and the alternative is noticing in a link preview.
|
||||
if (markup.includes('NaN') || markup.includes('undefined')) {
|
||||
throw new Error(
|
||||
`buildBrandAssets: the outlines for ${JSON.stringify(text)} contain a malformed ` +
|
||||
`coordinate. This is the opentype.js positioning bug described above — the glyphs ` +
|
||||
`must be drawn at the origin and translated.`
|
||||
);
|
||||
}
|
||||
|
||||
return { width: cursor - x, markup };
|
||||
}
|
||||
|
||||
/** The advance width of a run, without building the outlines — for centring. */
|
||||
function measure(font, text, size, tracking = 0) {
|
||||
return textPath(font, text, size, { tracking, fill: 'none' }).width;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------------------
|
||||
The mark
|
||||
------------------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The emblem is a 1024x1024 illustration that does not fill its canvas — trimmed it is
|
||||
* 931x975, and off-centre by 43px. Left alone, a 40px header logo would render the mark at
|
||||
* about 36px and sit visibly high.
|
||||
*
|
||||
* So: trim the transparent margin, then re-centre on a square canvas with a small even
|
||||
* margin. Every derivative the runtime produces descends from this, which is what makes
|
||||
* "the header mark and the favicon are the same shape" true by construction rather than by
|
||||
* care.
|
||||
*/
|
||||
async function canonicalLogo(size = 512) {
|
||||
const margin = 0.02; // 2%, so the ring never touches a rounded mask's edge
|
||||
const inner = Math.round(size * (1 - margin * 2));
|
||||
|
||||
const trimmed = await sharp(SOURCES.emblem)
|
||||
.trim({ threshold: 1 })
|
||||
.resize(inner, inner, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
return sharp({
|
||||
create: {
|
||||
width: size,
|
||||
height: size,
|
||||
channels: 4,
|
||||
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
||||
},
|
||||
})
|
||||
.composite([{ input: trimmed, gravity: 'centre' }])
|
||||
.png({ compressionLevel: 9, palette: false })
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------------------
|
||||
The outputs
|
||||
------------------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The horizontal lockup (§7): the emblem beside the product name.
|
||||
*
|
||||
* The emblem rides along as a base64 PNG rather than a link, because a `<img src>`-loaded
|
||||
* SVG cannot fetch a sibling file — same isolation rule as the fonts above. It is embedded
|
||||
* at 2x the drawn size so the lockup stays sharp on a retina display without carrying the
|
||||
* full 512.
|
||||
*/
|
||||
async function buildWordmark() {
|
||||
const cinzel = loadFont(SOURCES.cinzel, 600);
|
||||
|
||||
const H = 120;
|
||||
// The mark does not fill the lockup's height. `canonicalLogo` trims the artwork to its
|
||||
// own edges, so a mark drawn at the full 120 touches the top and bottom of the canvas and
|
||||
// reads as cropped — the ring's extremities sit exactly on the boundary. The inset is
|
||||
// optical breathing room, not padding to align anything.
|
||||
const markSize = 104;
|
||||
const gap = 26;
|
||||
const type = 62;
|
||||
const tracking = type * 0.04; // matches .brand-lockup__name letter-spacing in global.css
|
||||
|
||||
const embedded = await sharp(await canonicalLogo(512))
|
||||
.resize(markSize * 2, markSize * 2)
|
||||
.png({ compressionLevel: 9 })
|
||||
.toBuffer();
|
||||
|
||||
// Cap height rather than baseline: Cinzel is all-caps, so optical centring means
|
||||
// centring the caps box, not the em box.
|
||||
const capHeight = cinzel.tables.os2.sCapHeight
|
||||
? (cinzel.tables.os2.sCapHeight / cinzel.unitsPerEm) * type
|
||||
: type * 0.7;
|
||||
const baseline = H / 2 + capHeight / 2;
|
||||
|
||||
const name = textPath(cinzel, brand.siteName, type, {
|
||||
x: markSize + gap,
|
||||
y: baseline,
|
||||
tracking,
|
||||
fill: tokens['--gold'],
|
||||
});
|
||||
|
||||
const width = Math.ceil(markSize + gap + name.width);
|
||||
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${width}" height="${H}" viewBox="0 0 ${width} ${H}" role="img" aria-label="${brand.siteName}">
|
||||
<title>${brand.siteName}</title>
|
||||
<image x="0" y="${(H - markSize) / 2}" width="${markSize}" height="${markSize}" xlink:href="data:image/png;base64,${embedded.toString('base64')}"/>
|
||||
${name.markup}
|
||||
</svg>
|
||||
`;
|
||||
|
||||
return Buffer.from(svg, 'utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* The link preview card (§7).
|
||||
*
|
||||
* Everything on it is derived: the mark from the emblem, the name and tagline from
|
||||
* `brand.json`, every colour from `tokens.css`. Nothing is typed in twice, so the card
|
||||
* cannot drift from the site the way a hand-made one does.
|
||||
*
|
||||
* It is a committed FILE rather than a runtime render because an operator who changes the
|
||||
* tagline in the mounted `brand.json` should be able to replace the card by dropping in a
|
||||
* PNG, which is the same gesture as replacing the logo — and because rendering type at
|
||||
* request time would put a font dependency into the container for one image.
|
||||
*/
|
||||
async function buildOgImage() {
|
||||
const W = 1200;
|
||||
const H = 630;
|
||||
|
||||
const cinzel = loadFont(SOURCES.cinzel, 600);
|
||||
const inter = loadFont(SOURCES.inter, 400);
|
||||
|
||||
const markSize = 180;
|
||||
const nameSize = 74;
|
||||
const nameTracking = nameSize * 0.04;
|
||||
const taglineSize = 30;
|
||||
|
||||
const nameWidth = measure(cinzel, brand.siteName, nameSize, nameTracking);
|
||||
const taglineWidth = measure(inter, brand.tagline, taglineSize);
|
||||
|
||||
const markY = 118;
|
||||
const nameBaseline = markY + markSize + 96;
|
||||
const taglineBaseline = nameBaseline + 74;
|
||||
|
||||
const mark = await sharp(await canonicalLogo(512))
|
||||
.resize(markSize, markSize)
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const name = textPath(cinzel, brand.siteName, nameSize, {
|
||||
x: (W - nameWidth) / 2,
|
||||
y: nameBaseline,
|
||||
tracking: nameTracking,
|
||||
fill: tokens['--gold'],
|
||||
});
|
||||
|
||||
const tagline = textPath(inter, brand.tagline, taglineSize, {
|
||||
x: (W - taglineWidth) / 2,
|
||||
y: taglineBaseline,
|
||||
fill: tokens['--muted'],
|
||||
});
|
||||
|
||||
// The glow is the portal's own colour at low opacity — the same treatment §11 asks for
|
||||
// behind the diagrams, so the card reads as part of the site rather than a poster of it.
|
||||
const backdrop = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}">
|
||||
<defs>
|
||||
<radialGradient id="glow" cx="50%" cy="${((markY + markSize / 2) / H) * 100}%" r="46%">
|
||||
<stop offset="0%" stop-color="${tokens['--portal-deep']}" stop-opacity="0.30"/>
|
||||
<stop offset="65%" stop-color="${tokens['--portal-deep']}" stop-opacity="0.06"/>
|
||||
<stop offset="100%" stop-color="${tokens['--portal-deep']}" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<rect width="${W}" height="${H}" fill="${tokens['--bg']}"/>
|
||||
<rect width="${W}" height="${H}" fill="url(#glow)"/>
|
||||
<rect x="0" y="${H - 6}" width="${W}" height="6" fill="${tokens['--gold-deep']}"/>
|
||||
</svg>`;
|
||||
|
||||
const type = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}">
|
||||
${name.markup}
|
||||
${tagline.markup}
|
||||
</svg>`;
|
||||
|
||||
return sharp(Buffer.from(backdrop))
|
||||
.composite([
|
||||
{ input: mark, left: Math.round((W - markSize) / 2), top: markY },
|
||||
{ input: Buffer.from(type), left: 0, top: 0 },
|
||||
])
|
||||
.png({ compressionLevel: 9 })
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------------------
|
||||
Write, or verify
|
||||
------------------------------------------------------------------------------------- */
|
||||
|
||||
const artifacts = [
|
||||
['logo.png', await canonicalLogo(512)],
|
||||
['wordmark.svg', await buildWordmark()],
|
||||
['og-image.png', await buildOgImage()],
|
||||
];
|
||||
|
||||
const digest = (buffer) => createHash('sha256').update(buffer).digest('hex').slice(0, 12);
|
||||
|
||||
let stale = 0;
|
||||
|
||||
for (const [name, bytes] of artifacts) {
|
||||
const file = path.join(OUT, name);
|
||||
const existing = existsSync(file) ? readFileSync(file) : null;
|
||||
const unchanged = existing && existing.equals(bytes);
|
||||
const size = `${(bytes.length / 1024).toFixed(1)} kB`.padStart(9);
|
||||
|
||||
if (CHECK_ONLY) {
|
||||
if (unchanged) {
|
||||
console.log(` ok ${name.padEnd(14)} ${size} ${digest(bytes)}`);
|
||||
} else {
|
||||
stale++;
|
||||
console.error(` STALE ${name.padEnd(14)} ${size} ${digest(bytes)}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
writeFileSync(file, bytes);
|
||||
console.log(` ${unchanged ? 'same' : 'wrote'.padEnd(4)} ${name.padEnd(14)} ${size} ${digest(bytes)}`);
|
||||
}
|
||||
|
||||
if (CHECK_ONLY && stale) {
|
||||
console.error(
|
||||
`\nbuildBrandAssets --check: ${stale} committed asset(s) no longer match what the\n` +
|
||||
`sources produce. Run \`node scripts/buildBrandAssets.mjs\` and commit the result.\n`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
CHECK_ONLY
|
||||
? '\nbuildBrandAssets: the committed brand-default assets are current.'
|
||||
: '\nbuildBrandAssets: brand-default is regenerated. Commit the result.'
|
||||
);
|
||||
189
scripts/captureScreens.mjs
Normal file
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* captureScreens.mjs — retakes the screenshots in `src/data/screens.mjs`.
|
||||
*
|
||||
* PLAN.md §13 phase 9, D4 / D45.
|
||||
*
|
||||
* node scripts/captureScreens.mjs # every web screen
|
||||
* node scripts/captureScreens.mjs shard-status admin-users
|
||||
* RG_DEMO=http://localhost:3000 node scripts/captureScreens.mjs
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* AN AUTHORING TOOL, LIKE buildBrandAssets.mjs — NOT A CHECK
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* This never runs in CI and CI never needs it: its output is committed, because the site
|
||||
* must build from a clean checkout with no game server, no database and no browser. What
|
||||
* CI runs is `checkScreens.mjs`, which only reads the files this produced.
|
||||
*
|
||||
* It exists because D4 asks for real screenshots of a real deployment, and the way real
|
||||
* screenshots rot is that the recipe for taking them lives in somebody's memory. The rig
|
||||
* is written down in PLAN.md §13; the framing — route, viewport, scroll offset, whether to
|
||||
* sign in — is written down in `screens.mjs`; and this turns the two into files.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY puppeteer-core AND NOT puppeteer
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* `puppeteer` downloads its own Chromium — a hundred-odd megabytes fetched on every clean
|
||||
* install of a repository that needs a browser once per redesign. `puppeteer-core` drives
|
||||
* a Chrome that is already on the machine, which every machine that can look at this site
|
||||
* has. Point `RG_CHROME` at it if it is somewhere unusual.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY IT SIGNS IN THROUGH THE API RATHER THAN THE LOGIN FORM
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* The administration screens need a session, and typing into the login form is the part of
|
||||
* a browser script most likely to break on a redesign — a moved field, a renamed button, a
|
||||
* React input that ignores synthetic typing. The session cookie is the only thing actually
|
||||
* wanted, so this asks the API for one from inside the page and lets the browser store it.
|
||||
* If that call stops returning 200 the script says so and stops, rather than quietly
|
||||
* screenshotting a login screen twelve times.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readdirSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import sharp from 'sharp';
|
||||
|
||||
import { screens, screensOf, WEB } from '../src/data/screens.mjs';
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const OUT = path.join(HERE, '..', 'public', 'screens');
|
||||
|
||||
const BASE = (process.env.RG_DEMO || 'http://localhost:3000').replace(/\/+$/, '');
|
||||
const USER = process.env.RG_ADMIN_USER || 'demoadmin';
|
||||
const PASS = process.env.RG_ADMIN_PASS || 'DemoReview!2026';
|
||||
|
||||
/** Where Chrome usually is, per platform. First hit wins; `RG_CHROME` beats all of them. */
|
||||
const CHROME_CANDIDATES = [
|
||||
process.env.RG_CHROME,
|
||||
'C:/Program Files/Google/Chrome/Application/chrome.exe',
|
||||
'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/usr/bin/google-chrome',
|
||||
'/usr/bin/chromium',
|
||||
].filter(Boolean);
|
||||
|
||||
const wanted = process.argv.slice(2).filter((arg) => !arg.startsWith('-'));
|
||||
const todo = screensOf('web').filter((shot) => wanted.length === 0 || wanted.includes(shot.id));
|
||||
|
||||
if (todo.length === 0) {
|
||||
const known = screens.map((shot) => shot.id).join(', ');
|
||||
console.error(`Nothing to capture. Known ids: ${known}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const chrome = CHROME_CANDIDATES.find((candidate) => existsSync(candidate));
|
||||
|
||||
if (!chrome) {
|
||||
console.error(
|
||||
'No Chrome found. Set RG_CHROME to the browser executable — this script drives an\n' +
|
||||
'installed Chrome rather than downloading one (see the header).',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const puppeteer = (await import('puppeteer-core')).default;
|
||||
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
executablePath: chrome,
|
||||
headless: 'new',
|
||||
defaultViewport: { ...WEB.viewport, deviceScaleFactor: WEB.scale },
|
||||
// Scrollbars are the browser's furniture, not the product's, and a colour profile that
|
||||
// is not sRGB makes the palette in a screenshot disagree with the palette on the page.
|
||||
args: ['--hide-scrollbars', '--force-color-profile=srgb'],
|
||||
});
|
||||
|
||||
/**
|
||||
* One page per privilege level rather than signing in and out around each shot: signing
|
||||
* out is the step that gets forgotten, and a public page captured with an admin session
|
||||
* shows a navigation bar the public never sees.
|
||||
*/
|
||||
const anon = await browser.newPage();
|
||||
const admin = await browser.newPage();
|
||||
|
||||
await admin.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||
|
||||
const status = await admin.evaluate(
|
||||
async (username, password) => {
|
||||
const res = await fetch('/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
return res.status;
|
||||
},
|
||||
USER,
|
||||
PASS,
|
||||
);
|
||||
|
||||
if (status !== 200) {
|
||||
console.error(
|
||||
`Could not sign in as "${USER}" at ${BASE} (HTTP ${status}).\n` +
|
||||
'Seed the demo first — see PLAN.md §13 phase 9 and scripts/seedDemo.mjs.',
|
||||
);
|
||||
await browser.close();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let failures = 0;
|
||||
|
||||
for (const shot of todo) {
|
||||
const page = shot.admin ? admin : anon;
|
||||
const url = BASE + shot.route;
|
||||
|
||||
try {
|
||||
await page.goto(url, { waitUntil: 'networkidle2', timeout: 30_000 });
|
||||
|
||||
if (shot.scrollY) {
|
||||
await page.evaluate((y) => window.scrollTo(0, y), shot.scrollY);
|
||||
}
|
||||
|
||||
// Live pages settle after their first paint: a shard panel fills in from an event
|
||||
// stream, a list re-sorts once its data lands. A second is cheap and the difference
|
||||
// between a screenshot of the product and a screenshot of its loading state.
|
||||
await new Promise((resolve) => setTimeout(resolve, 1200));
|
||||
|
||||
const png = await page.screenshot({ type: 'png' });
|
||||
const file = path.join(OUT, `${shot.id}.webp`);
|
||||
|
||||
// Quality 82 is where UI text stops visibly softening; the files land near 150 KB,
|
||||
// which is what makes a page with five of them still a page and not a download.
|
||||
await sharp(png).webp({ quality: 82 }).toFile(file);
|
||||
|
||||
const meta = await sharp(file).metadata();
|
||||
|
||||
if (meta.width !== WEB.width || meta.height !== WEB.height) {
|
||||
console.error(
|
||||
` ! ${shot.id}: got ${meta.width}x${meta.height}, expected ${WEB.width}x${WEB.height}`,
|
||||
);
|
||||
failures++;
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(` + ${shot.id.padEnd(18)} ${shot.route.padEnd(20)} ${meta.width}x${meta.height}`);
|
||||
} catch (err) {
|
||||
console.error(` ! ${shot.id}: ${err.message}`);
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
|
||||
// A file left behind by a screen that has since been renamed or dropped is a file the
|
||||
// site still ships and nothing points at. Say so; do not delete somebody's work silently.
|
||||
if (wanted.length === 0) {
|
||||
const declared = new Set(screensOf('web').map((shot) => `${shot.id}.webp`));
|
||||
const phones = new Set(screensOf('phone').map((shot) => `${shot.id}.webp`));
|
||||
const orphans = readdirSync(OUT).filter((name) => !declared.has(name) && !phones.has(name));
|
||||
|
||||
if (orphans.length > 0) {
|
||||
console.log(`\nNot declared in screens.mjs, left alone: ${orphans.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n${todo.length - failures} captured, ${failures} failed.`);
|
||||
process.exit(failures > 0 ? 1 : 0);
|
||||
399
scripts/checkBrand.mjs
Normal file
@@ -0,0 +1,399 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* checkBrand.mjs — PLAN.md §7
|
||||
*
|
||||
* The branding pipeline makes two promises that nothing else in the build can verify, and
|
||||
* both fail quietly rather than loudly. This is their mechanism, in the same spirit as
|
||||
* `checkTokens.mjs`: diligence does not survive contact with a year of commits.
|
||||
*
|
||||
* 1. EVERY `/brand/*` URL THE SITE ASKS FOR MUST ACTUALLY RESOLVE.
|
||||
* The route serves an allowlist of names and derives a fixed set of sizes. A template
|
||||
* that asks for `/brand/logo-44.webp` — a plausible number that is not on the list —
|
||||
* gets a 404, and a missing logo is exactly the kind of thing that looks like a
|
||||
* styling glitch and survives review. So every literal `/brand/...` in the source is
|
||||
* put through the route's own classifier, rather than a copy of its rules.
|
||||
*
|
||||
* 2. EVERY REWRITABLE BRAND STRING MUST BE SAFE TO REPLACE BLINDLY.
|
||||
* `applyBrand.mjs` swaps brand text in built HTML with plain string replacement.
|
||||
* That is safe only while the values are distinctive: a `siteName` of "Site", or a
|
||||
* tagline that contains the site name inside it, would corrupt pages at boot on a
|
||||
* machine nobody is watching. Checking it here makes the failure a red build.
|
||||
*
|
||||
* 3. `brand-default/` must be complete, because §7 says it always is.
|
||||
*
|
||||
* node scripts/checkBrand.mjs
|
||||
*/
|
||||
|
||||
import { readFileSync, existsSync, statSync } from 'node:fs';
|
||||
import { readdir } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
import sharp from 'sharp';
|
||||
|
||||
import { classify } from '../src/lib/brandAssets.mjs';
|
||||
|
||||
const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
||||
const DEFAULTS = path.join(ROOT, 'brand-default');
|
||||
|
||||
const failures = [];
|
||||
const fail = (message) => failures.push(message);
|
||||
|
||||
/* =======================================================================================
|
||||
1. brand-default is complete
|
||||
======================================================================================= */
|
||||
|
||||
/**
|
||||
* The stock set is deliberately small. Everything else the site requests — every logo size,
|
||||
* both PWA icons, the apple-touch icon, the favicons and the .ico — is derived at runtime
|
||||
* from `logo.png`, so that an operator rebrands by replacing one file rather than fifteen.
|
||||
* Adding a precomputed derivative here would quietly undo that.
|
||||
*/
|
||||
const REQUIRED = ['brand.json', 'theme.css', 'logo.png', 'wordmark.svg', 'og-image.png'];
|
||||
|
||||
for (const name of REQUIRED) {
|
||||
const file = path.join(DEFAULTS, name);
|
||||
if (!existsSync(file)) {
|
||||
fail(`brand-default/${name} is missing — §7 requires the stock brand to be complete.`);
|
||||
} else if (statSync(file).size === 0) {
|
||||
fail(`brand-default/${name} is empty.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (existsSync(path.join(DEFAULTS, 'logo.png'))) {
|
||||
const meta = await sharp(path.join(DEFAULTS, 'logo.png')).metadata();
|
||||
if (meta.width !== meta.height) {
|
||||
fail(`brand-default/logo.png is ${meta.width}x${meta.height}; the mark must be square.`);
|
||||
}
|
||||
// 512 is the largest thing anything asks for (icon-512.png). A smaller source would be
|
||||
// upscaled into an installed app icon, which is where it would be most visible.
|
||||
if (meta.width < 512) {
|
||||
fail(`brand-default/logo.png is ${meta.width}px; derivatives go up to 512 and must not upscale.`);
|
||||
}
|
||||
if (!meta.hasAlpha) {
|
||||
fail('brand-default/logo.png has no alpha channel; the mark would carry a background.');
|
||||
}
|
||||
}
|
||||
|
||||
/* =======================================================================================
|
||||
2. Every /brand/* URL in the source resolves
|
||||
======================================================================================= */
|
||||
|
||||
const SCAN_EXT = new Set(['.astro', '.ts', '.tsx', '.js', '.mjs', '.css', '.md', '.mdx', '.json']);
|
||||
|
||||
async function* walk(dir) {
|
||||
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()) {
|
||||
if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
|
||||
yield* walk(full);
|
||||
} else if (SCAN_EXT.has(path.extname(entry.name))) {
|
||||
yield full;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const referenced = new Map(); // name -> [where]
|
||||
|
||||
for await (const file of walk(path.join(ROOT, 'src'))) {
|
||||
const source = readFileSync(file, 'utf8');
|
||||
const relative = path.relative(ROOT, file);
|
||||
|
||||
for (const match of source.matchAll(/\/brand\/([a-z0-9][a-z0-9._-]*)/g)) {
|
||||
const name = match[1];
|
||||
const line = source.slice(0, match.index).split('\n').length;
|
||||
if (!referenced.has(name)) referenced.set(name, []);
|
||||
referenced.get(name).push(`${relative}:${line}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, sites] of referenced) {
|
||||
// The classifier is imported from the route's own module rather than reimplemented, so
|
||||
// this check cannot drift from what the server will actually do.
|
||||
if (!classify(name)) {
|
||||
fail(
|
||||
`/brand/${name} is requested by ${sites.join(', ')} but the route would 404 it.\n` +
|
||||
` Add it to STATIC_FILES, NAMED_DERIVATIVES or DERIVABLE_SIZES in ` +
|
||||
`src/lib/brandAssets.mjs — or use a size that is already on the list.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* =======================================================================================
|
||||
3. The rewritable brand strings are safe to replace blindly
|
||||
======================================================================================= */
|
||||
|
||||
const brandPath = path.join(DEFAULTS, 'brand.json');
|
||||
let brand = null;
|
||||
|
||||
if (existsSync(brandPath)) {
|
||||
try {
|
||||
brand = JSON.parse(readFileSync(brandPath, 'utf8'));
|
||||
} catch (error) {
|
||||
fail(`brand-default/brand.json is not valid JSON: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (brand) {
|
||||
const REQUIRED_FIELDS = [
|
||||
'siteName',
|
||||
'tagline',
|
||||
'contactEmail',
|
||||
'discordInvite',
|
||||
'giteaOrg',
|
||||
'demoUrl',
|
||||
'betaOptInUrl',
|
||||
];
|
||||
|
||||
for (const field of REQUIRED_FIELDS) {
|
||||
if (typeof brand[field] !== 'string') {
|
||||
fail(`brand.json is missing the string field "${field}".`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* added here and forgotten there would be silently build-time only. Either way the two
|
||||
* disagreeing is the bug, so the check is that they agree.
|
||||
*/
|
||||
const applySource = readFileSync(path.join(ROOT, 'scripts/applyBrand.mjs'), 'utf8');
|
||||
const declared = applySource.match(/const TEXT_FIELDS = \[([^\]]*)\]/);
|
||||
|
||||
if (!declared) {
|
||||
fail('could not find TEXT_FIELDS in scripts/applyBrand.mjs — has it been renamed?');
|
||||
} else {
|
||||
const rewritable = [...declared[1].matchAll(/'([^']+)'/g)].map((m) => m[1]);
|
||||
|
||||
for (const field of rewritable) {
|
||||
if (!(field in brand)) {
|
||||
fail(`applyBrand.mjs rewrites "${field}", which brand.json does not define.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = brand[field];
|
||||
|
||||
if (value.length < 8) {
|
||||
fail(
|
||||
`brand.json's "${field}" is ${JSON.stringify(value)} — under 8 characters.\n` +
|
||||
` applyBrand.mjs replaces this string across every built page at boot; a short\n` +
|
||||
` value will match unrelated markup and corrupt the output.`
|
||||
);
|
||||
}
|
||||
|
||||
if (/[<>]|="/.test(value)) {
|
||||
fail(`brand.json's "${field}" contains markup characters, which the boot rewrite cannot survive.`);
|
||||
}
|
||||
|
||||
// A value that occurs inside another value is the subtler failure: replacing the
|
||||
// shorter one first leaves the longer one half-rewritten, and which runs first is an
|
||||
// accident of declaration order.
|
||||
for (const other of rewritable) {
|
||||
if (other === field) continue;
|
||||
if (typeof brand[other] === 'string' && brand[other].includes(value)) {
|
||||
fail(
|
||||
`brand.json's "${field}" (${JSON.stringify(value)}) occurs inside "${other}".\n` +
|
||||
` The boot rewrite would corrupt one while replacing the other.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// demoUrl is gated by markup rather than replaced as text (see applyBrand.mjs), so it
|
||||
// is correct for it NOT to be in TEXT_FIELDS. Saying so out loud, because "the demo URL
|
||||
// is missing from the rewrite list" is an easy and wrong thing to conclude.
|
||||
if (rewritable.includes('demoUrl')) {
|
||||
fail(
|
||||
'demoUrl must not be in TEXT_FIELDS: its default is the empty string, which cannot\n' +
|
||||
' be string-replaced. It is handled by the data-attribute gate instead.'
|
||||
);
|
||||
}
|
||||
|
||||
// betaOptInUrl is out for the same arithmetic reason and a second, stronger one: the
|
||||
// only page that reads it renders per request, so it never passes through the boot
|
||||
// rewrite at all. `liveBrand()` in src/lib/brand.mjs reads the mount directly. Putting
|
||||
// it in TEXT_FIELDS would not make it work — it would be a rewrite that never matches.
|
||||
if (rewritable.includes('betaOptInUrl')) {
|
||||
fail(
|
||||
'betaOptInUrl must not be in TEXT_FIELDS: its default is the empty string, and\n' +
|
||||
' /beta is server-rendered, so it reads the mounted brand.json via liveBrand().'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* =======================================================================================
|
||||
4. The demo slot's markup contract (§15 / D12)
|
||||
=======================================================================================
|
||||
|
||||
`applyBrand.mjs` reveals the demo link by string-replacing an exact pair of empty
|
||||
attributes in the built HTML. That is a contract between a script and a template that
|
||||
share no code, and it fails in the quietest possible way: an attribute inserted between
|
||||
the two, or `href` written after `data-demo-url`, produces a build where the demo URL is
|
||||
set in the mount, the boot log says nothing, and the link is simply never there.
|
||||
|
||||
Both halves are checked, and neither is retyped from memory — the literal is derived from
|
||||
the same expression `applyBrand.mjs` uses, so the two cannot drift apart. */
|
||||
|
||||
const applyForCheck = existsSync(path.join(ROOT, 'scripts/applyBrand.mjs'))
|
||||
? readFileSync(path.join(ROOT, 'scripts/applyBrand.mjs'), 'utf8')
|
||||
: '';
|
||||
|
||||
const attrTemplate = applyForCheck.match(
|
||||
/`href="\$\{escapeHtml\(value\)\}" data-demo-url="\$\{escapeHtml\(value\)\}"`/
|
||||
);
|
||||
|
||||
if (!attrTemplate) {
|
||||
fail(
|
||||
'applyBrand.mjs no longer builds the demo attributes as `href="..." data-demo-url="..."`.\n' +
|
||||
' Update the expected pair below to match, and re-check every template that writes it.'
|
||||
);
|
||||
} else {
|
||||
// What the script will look for when the applied value is the stock empty string.
|
||||
const EMPTY_PAIR = 'href="" data-demo-url=""';
|
||||
|
||||
let slots = 0;
|
||||
const strays = [];
|
||||
|
||||
for await (const file of walk(path.join(ROOT, 'src'))) {
|
||||
if (path.extname(file) !== '.astro') continue;
|
||||
|
||||
// Comments discuss the contract at length, including in the template that implements
|
||||
// it. Scanning them would make the check fail on its own documentation.
|
||||
// Blanked rather than removed: keeping every newline and every offset means the line
|
||||
// numbers reported below are the ones in the file, not the ones in a shortened copy.
|
||||
const blank = (match) => match.replace(/[^\n]/g, ' ');
|
||||
const source = readFileSync(file, 'utf8')
|
||||
.replace(/\/\*[\s\S]*?\*\//g, blank)
|
||||
.replace(/<!--[\s\S]*?-->/g, blank);
|
||||
|
||||
const relative = path.relative(ROOT, file);
|
||||
|
||||
slots += source.split(EMPTY_PAIR).length - 1;
|
||||
|
||||
for (const match of source.matchAll(/data-demo-url/g)) {
|
||||
const start = match.index - EMPTY_PAIR.indexOf('data-demo-url');
|
||||
if (source.slice(start, start + EMPTY_PAIR.length) !== EMPTY_PAIR) {
|
||||
strays.push(`${relative}:${source.slice(0, match.index).split('\n').length}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!slots) {
|
||||
fail(
|
||||
`no demo slot found in src/**/*.astro — expected the literal \`${EMPTY_PAIR}\`.\n` +
|
||||
' §15 reserves this slot so that gaining a demo instance is one line in the mounted\n' +
|
||||
' brand.json. Removing it makes that a rebuild.'
|
||||
);
|
||||
}
|
||||
|
||||
for (const site of strays) {
|
||||
fail(
|
||||
`${site} writes data-demo-url outside the exact pair \`${EMPTY_PAIR}\`.\n` +
|
||||
' applyBrand.mjs replaces that literal at boot; anything else is invisible to it and\n' +
|
||||
' the slot will never appear.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* =======================================================================================
|
||||
5. The demo DEEP-link contract (§15 / D25)
|
||||
=======================================================================================
|
||||
|
||||
`/features/` links individual capabilities into the demo, which the slot in §4 cannot
|
||||
express — it swaps a whole URL, so it can only ever produce the demo's root. Those links
|
||||
carry a third attribute and `applyBrand.mjs` recomputes all three from it.
|
||||
|
||||
Same failure mode as §4 and the same reason to check it: a template and a script with no
|
||||
shared code, agreeing on an exact byte sequence, where disagreement is silent. This one
|
||||
is worse in one respect — a broken deep link is INVISIBLE in a stock build, because the
|
||||
stock build hides every demo link. It would first appear on the day the org lead sets
|
||||
`demoUrl` and finds the new links pointing at the demo's front page, or at nothing.
|
||||
|
||||
The regex is not retyped here either: it is lifted out of `applyBrand.mjs` and run
|
||||
against the stock literal, so this fails if the script's pattern stops matching what the
|
||||
templates write — whichever side moved. */
|
||||
|
||||
const deepPattern = applyForCheck.match(/const DEEP_LINK = \/(.*)\/g;/);
|
||||
const EMPTY_DEEP_PREFIX = 'href="" data-demo-url="" ';
|
||||
let deepLinkCount = 0;
|
||||
|
||||
if (!deepPattern) {
|
||||
fail(
|
||||
'applyBrand.mjs no longer defines DEEP_LINK as a single /…/g literal.\n' +
|
||||
' §15/D25 relies on it to fill the per-capability demo links. Update this check to\n' +
|
||||
' match the new shape rather than deleting it.'
|
||||
);
|
||||
} else {
|
||||
// Does the script's own pattern still match what a template writes in a stock build?
|
||||
const sample = `${EMPTY_DEEP_PREFIX}data-demo-path="/example"`;
|
||||
let matches = false;
|
||||
try {
|
||||
matches = new RegExp(deepPattern[1]).test(sample);
|
||||
} catch (error) {
|
||||
fail(`applyBrand.mjs's DEEP_LINK is not a usable pattern: ${error.message}`);
|
||||
}
|
||||
|
||||
if (!matches) {
|
||||
fail(
|
||||
`applyBrand.mjs's DEEP_LINK no longer matches the stock markup \`${sample}\`.\n` +
|
||||
' Every per-capability demo link would be left empty and hidden, on a deployment\n' +
|
||||
' that has a demo configured — which is the one place nobody would look.'
|
||||
);
|
||||
}
|
||||
|
||||
const deepStrays = [];
|
||||
let deepLinks = 0;
|
||||
|
||||
for await (const file of walk(path.join(ROOT, 'src'))) {
|
||||
if (path.extname(file) !== '.astro') continue;
|
||||
|
||||
// Blanked, not stripped — same reason as §4: the line numbers reported have to be the
|
||||
// ones in the file.
|
||||
const blank = (match) => match.replace(/[^\n]/g, ' ');
|
||||
const source = readFileSync(file, 'utf8')
|
||||
.replace(/\/\*[\s\S]*?\*\//g, blank)
|
||||
.replace(/<!--[\s\S]*?-->/g, blank);
|
||||
|
||||
const relative = path.relative(ROOT, file);
|
||||
|
||||
for (const match of source.matchAll(/data-demo-path/g)) {
|
||||
deepLinks++;
|
||||
const start = match.index - EMPTY_DEEP_PREFIX.length;
|
||||
if (start < 0 || source.slice(start, match.index) !== EMPTY_DEEP_PREFIX) {
|
||||
deepStrays.push(`${relative}:${source.slice(0, match.index).split('\n').length}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const site of deepStrays) {
|
||||
fail(
|
||||
`${site} writes data-demo-path without the exact prefix \`${EMPTY_DEEP_PREFIX}\`.\n` +
|
||||
' applyBrand.mjs matches all three attributes together and in that order; anything\n' +
|
||||
' else is invisible to it and the link will never point anywhere.'
|
||||
);
|
||||
}
|
||||
|
||||
deepLinkCount = deepLinks;
|
||||
}
|
||||
|
||||
/* ======================================================================================= */
|
||||
|
||||
if (failures.length) {
|
||||
console.error('\ncheckBrand: the branding pipeline has problems.\n');
|
||||
for (const failure of failures) console.error(` - ${failure}`);
|
||||
console.error('');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`checkBrand: brand-default is complete, ${referenced.size} /brand/ URL(s) resolve, ` +
|
||||
`every rewritable string is safe to replace, and the demo slot plus ${deepLinkCount} ` +
|
||||
`deep link(s) match their contracts.`
|
||||
);
|
||||
@@ -117,7 +117,29 @@ async function checkModuleApi() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. The current bundle
|
||||
// 4. The capabilities the installed module actually declares
|
||||
//
|
||||
// §12 names "module-uo's capability list" as one of the facts platform.json holds, and it
|
||||
// was the one fact nothing re-read. That mattered from phase 3 onwards, because the
|
||||
// homepage renders the list rather than merely storing it: `src/data/capabilities.mjs`
|
||||
// asserts at build time that every declared slug is claimed by a named capability on the
|
||||
// page and vice versa. Without this check that assertion was anchored to a local copy
|
||||
// nobody was verifying, so the whole chain rested on someone remembering.
|
||||
//
|
||||
// Sorted before comparing: the manifest's order is the module's business, and a reordered
|
||||
// array is not a changed capability set. A slug appearing or disappearing is.
|
||||
// ---------------------------------------------------------------------------
|
||||
async function checkModuleCapabilities() {
|
||||
const authority = 'Module-uo main:module.json';
|
||||
const manifest = JSON.parse(await raw('Module-uo', 'module.json', 'main'));
|
||||
const declared = [...(manifest.capabilities || [])].sort();
|
||||
const expected = [...platform.moduleUoCapabilities].sort();
|
||||
|
||||
record('moduleUoCapabilities', expected.join(' '), declared.join(' '), authority);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. The current bundle
|
||||
//
|
||||
// The manifests live at the ROOT of the `bundles` branch — `current.json`,
|
||||
// `bundle-<tag>.json` — not under `bundles/`. Fetching the directory 404s.
|
||||
@@ -141,7 +163,7 @@ async function checkBundle() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Release versions, per repo
|
||||
// 6. Release versions, per repo
|
||||
// ---------------------------------------------------------------------------
|
||||
async function checkReleases() {
|
||||
for (const [repo, expected] of Object.entries(platform.releases)) {
|
||||
@@ -152,7 +174,7 @@ async function checkReleases() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. `website` still publishes nothing
|
||||
// 7. `website` still publishes nothing
|
||||
//
|
||||
// It ships as container images and is never tagged, so the site refers to the platform by
|
||||
// bundle tag and Module API version instead. The day that changes, this repo should notice
|
||||
@@ -165,7 +187,48 @@ async function checkWebsiteHasNoReleases() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. D13 — the contact address lives in exactly one file
|
||||
// 8. The Android APK `/app/` offers, and the Android version it claims to need
|
||||
//
|
||||
// The app is on no store, so the download block on `/app/` links straight at a release
|
||||
// asset — the one kind of link on this site that 404s the moment a filename changes,
|
||||
// because the filename carries the version. Both asset names are asserted against
|
||||
// releases/latest, so a release that renames or drops either turns this repo red before a
|
||||
// visitor finds a dead link.
|
||||
//
|
||||
// `minSdk` is checked for a different reason. "Android 10 or newer" is prose derived from a
|
||||
// number, and it is exactly the kind of derived claim §12 exists to stop rotting: raising
|
||||
// the minimum in the app would otherwise leave this site telling people with Android 10
|
||||
// that it works for them. The mapping from API level to the marketing version is a fixed
|
||||
// table, so checking the number is enough to protect the sentence.
|
||||
//
|
||||
// What is NOT checked is `serviceable` — see the comment beside it in platform.json. No
|
||||
// fetch can tell whether a build works, so that value is a person's word, and the site
|
||||
// treats it as the gate on the link rather than the link as the gate on itself.
|
||||
// ---------------------------------------------------------------------------
|
||||
async function checkAndroidApk() {
|
||||
const authority = 'Android-app releases/latest assets';
|
||||
const release = await json('Android-app/releases/latest');
|
||||
const names = new Set((release.assets || []).map((asset) => asset.name));
|
||||
|
||||
const apk = platform.androidApk;
|
||||
record(`apk asset`, true, names.has(apk.asset), `${authority} → ${apk.asset}`);
|
||||
record(`apk checksums`, true, names.has(apk.checksums), `${authority} → ${apk.checksums}`);
|
||||
|
||||
// The asset name carries the version, so it has to agree with the release this site
|
||||
// already quotes — a mismatch here means one of the two was updated alone.
|
||||
const tag = String(release.tag_name || '').replace(/^v/, '');
|
||||
record('apk names the release', true, apk.asset.includes(tag), `${authority} → ${release.tag_name}`);
|
||||
|
||||
const gradleAuthority = 'Android-app main:app/build.gradle.kts';
|
||||
const gradle = await raw('Android-app', 'app/build.gradle.kts', 'main');
|
||||
const minSdk = Number(
|
||||
extract(gradle, /minSdk\s*=\s*(\d+)/, 'minSdk', gradleAuthority)
|
||||
);
|
||||
record('android minSdk', apk.minSdk, minSdk, gradleAuthority);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 9. D13 — the contact address lives in exactly one file
|
||||
// ---------------------------------------------------------------------------
|
||||
const CONTACT_CHECK = 'contact address (D13)';
|
||||
|
||||
@@ -179,6 +242,25 @@ const SCAN_EXT = new Set([
|
||||
// every commit, and the noreply Gitea uses for the bot identity.
|
||||
const ALLOWED_ADDRESSES = new Set(['noreply@anthropic.com', 'claude@whitlocktech.net']);
|
||||
|
||||
/**
|
||||
* Domains reserved by RFC 2606 and RFC 6761 for documentation and examples.
|
||||
*
|
||||
* Phase 5 is what needed this, and the exemption is principled rather than a concession.
|
||||
* The rule being enforced is that no CONTACT address appears outside `brand.json` (D13), so
|
||||
* that changing the published address stays a file copy. An `example.com` address cannot be
|
||||
* a contact address — the domain is reserved precisely so that documentation can use it and
|
||||
* it can never route to anybody — so exempting these weakens nothing.
|
||||
*
|
||||
* Without it the rule would have forbidden the signup form's `placeholder="you@example.com"`
|
||||
* and the CLI's usage line, which is the check telling somebody to write a worse page in
|
||||
* order to satisfy a rule about a different problem. A check people have to work around is
|
||||
* one they eventually switch off.
|
||||
*
|
||||
* Matched on the domain, not on the exact address, because these appear with whatever local
|
||||
* part reads best in context.
|
||||
*/
|
||||
const RESERVED_DOMAINS = /@(?:[a-z0-9-]+\.)*(?:example\.(?:com|net|org)|example|invalid|test|localhost)$/i;
|
||||
|
||||
async function* walk(dir) {
|
||||
let entries;
|
||||
try {
|
||||
@@ -204,6 +286,7 @@ async function checkContactAddressIsIsolated() {
|
||||
const text = readFileSync(file, 'utf8');
|
||||
for (const match of text.matchAll(EMAIL_RE)) {
|
||||
if (ALLOWED_ADDRESSES.has(match[0].toLowerCase())) continue;
|
||||
if (RESERVED_DOMAINS.test(match[0])) continue;
|
||||
const line = text.slice(0, match.index).split('\n').length;
|
||||
offenders.push(`${path.relative(ROOT, file)}:${line} — ${match[0]}`);
|
||||
}
|
||||
@@ -244,9 +327,11 @@ async function main() {
|
||||
checkProtocol,
|
||||
checkOverlayProtocol,
|
||||
checkModuleApi,
|
||||
checkModuleCapabilities,
|
||||
checkBundle,
|
||||
checkReleases,
|
||||
checkWebsiteHasNoReleases,
|
||||
checkAndroidApk,
|
||||
];
|
||||
|
||||
for (const check of network) {
|
||||
|
||||
364
scripts/checkLinks.mjs
Normal file
@@ -0,0 +1,364 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* checkLinks.mjs — PLAN.md §12
|
||||
*
|
||||
* Two rules, both of which §12 states and neither of which had a check until phase 4:
|
||||
*
|
||||
* 1. Every internal link resolves.
|
||||
* 2. Every outbound link into a RunicGateway repository points at a BRANCH path, never a
|
||||
* commit permalink.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY IT READS THE BUILD AND NOT THE SOURCE
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* The obvious implementation greps `href="…"` out of `src/**` and resolves it against the
|
||||
* file tree. It would have missed most of what phase 4 added. Half the links on these pages
|
||||
* are built from data — `capabilityGroups`, `notBuilt.mjs`, a template literal over
|
||||
* `platform.gitea.base` — and a source scan sees an expression rather than a URL. A link
|
||||
* that is wrong in a data file is exactly as broken as one that is wrong in markup, and it
|
||||
* is harder to spot by eye, so it is the one that most needs checking.
|
||||
*
|
||||
* So this runs against `dist/client` after a build, where every link is a real string. The
|
||||
* cost is that the check needs a build first, which is why it sits after `npm run build` in
|
||||
* `verify` and in CI. A stale `dist` would check stale links, and that is the one failure
|
||||
* mode worth knowing about — running it by hand after editing a page means building first.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHAT IT DELIBERATELY DOES NOT CHECK
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* `/brand/*` — those URLs are served by a route that derives them on request from whatever
|
||||
* is mounted, so nothing corresponding exists in `dist/client` to point at. They are not
|
||||
* unchecked: `scripts/checkBrand.mjs` already resolves every one of them against that
|
||||
* route's own allowlist, which is a stronger check than file existence.
|
||||
*
|
||||
* Off-site URLs are not fetched. A build that fails because gnu.org is slow is a build
|
||||
* that teaches people to ignore this check. The one outbound rule here is about the SHAPE
|
||||
* of a URL, which is decidable without the network.
|
||||
*
|
||||
* In-page fragments (`#main`) are not resolved against the ids on the page. It would be a
|
||||
* fair check to add; it is not one §12 asks for, and the site has exactly one of them.
|
||||
*
|
||||
* node scripts/checkLinks.mjs [--dist <path>]
|
||||
*/
|
||||
|
||||
import { readFileSync, existsSync, statSync } from 'node:fs';
|
||||
import { readdir } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
||||
|
||||
const distArg = process.argv.indexOf('--dist');
|
||||
const DIST =
|
||||
distArg !== -1 && process.argv[distArg + 1]
|
||||
? path.resolve(process.argv[distArg + 1])
|
||||
: path.join(ROOT, 'dist', 'client');
|
||||
|
||||
const platform = JSON.parse(readFileSync(path.join(ROOT, 'src/data/platform.json'), 'utf8'));
|
||||
|
||||
/** `gitea.whitlocktech.com`, from the same place every page reads it. */
|
||||
const GITEA_HOST = new URL(platform.gitea.base).host;
|
||||
|
||||
/**
|
||||
* Prefixes served by a route rather than by a file in the build. A link starting with one
|
||||
* of these is somebody else's check — see the header.
|
||||
*/
|
||||
const RUNTIME_PREFIXES = ['/brand/'];
|
||||
|
||||
/**
|
||||
* Pages that render per request, and therefore have no file in `dist/client` to resolve
|
||||
* against — discovered from the source rather than listed here.
|
||||
*
|
||||
* Phase 5 is what made this necessary. Until then the only on-demand route was `/brand/*`,
|
||||
* which is an asset route with its own checker and is skipped by prefix above; `/beta/` is
|
||||
* the first on-demand PAGE, and it is linked from `/app/`, the header and the footer like
|
||||
* any other. Rule 1 read `dist/client`, saw nothing at `beta/index.html`, and failed a link
|
||||
* that is perfectly good.
|
||||
*
|
||||
* The tempting fix — an entry in `PLANNED_ROUTES` — would be wrong, and wrong in the exact
|
||||
* way that list's own comment warns about. Its reverse check fires when a route HAS been
|
||||
* built, and an on-demand route never produces a file, so the entry could never rot out. It
|
||||
* would become the permanent exemption the two-way check exists to prevent.
|
||||
*
|
||||
* So the route is derived instead: a file under `src/pages/` that exports `prerender =
|
||||
* false` IS an on-demand route, and its path maps to a URL by Astro's own file-routing
|
||||
* rules. That is a fact about the source, checkable at the same moment, and it cannot go
|
||||
* stale — delete `beta.astro` and the links to `/beta/` start failing again immediately,
|
||||
* which is the behaviour rule 1 is there to provide.
|
||||
*
|
||||
* Dynamic segments (`[...file].ts`) are deliberately not handled: the only one is the brand
|
||||
* route, already covered by prefix, and inventing a matcher for a case that does not exist
|
||||
* would be guessing at a shape nobody has written yet.
|
||||
*/
|
||||
async function findOnDemandRoutes() {
|
||||
const pagesDir = path.join(ROOT, 'src', 'pages');
|
||||
const routes = new Set();
|
||||
|
||||
for await (const file of walk(pagesDir, ['.astro', '.ts', '.js'])) {
|
||||
const source = readFileSync(file, 'utf8');
|
||||
if (!/export\s+const\s+prerender\s*=\s*false/.test(source)) continue;
|
||||
|
||||
const relative = path.relative(pagesDir, file).split(path.sep).join('/');
|
||||
if (relative.includes('[')) continue;
|
||||
|
||||
const withoutExt = relative.replace(/\.(astro|ts|js)$/, '');
|
||||
const name = withoutExt.replace(/(^|\/)index$/, '');
|
||||
routes.add(name ? `/${name}/` : '/');
|
||||
}
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes the site links today that a later phase builds.
|
||||
*
|
||||
* This exists because of a convention phase 3 recorded and phase 1 started: the header,
|
||||
* the footer and the homepage link the FINAL routes of §10 rather than growing links phase
|
||||
* by phase. Nothing is deployed until phase 12, so no visitor ever meets one of these
|
||||
* 404s, and no page has to be revisited later to add a link that was always going to be
|
||||
* there. That convention and rule 1 of this check are in direct tension, and this is where
|
||||
* the tension is resolved — explicitly, with a phase against each entry, rather than by
|
||||
* weakening the rule.
|
||||
*
|
||||
* It is self-cleaning in both directions, which is the only reason it is safe to have:
|
||||
*
|
||||
* - a link to a route that is neither built nor listed here FAILS, so the list cannot be
|
||||
* used by accident;
|
||||
* - an entry here whose route HAS since been built also fails, so the list cannot rot
|
||||
* into a permanent exemption after the page arrives.
|
||||
*
|
||||
* Adding to it is a deliberate act. If a route is not in §10, it does not belong here.
|
||||
*/
|
||||
const PLANNED_ROUTES = new Map([
|
||||
// Empty as of phase 6, which built `/privacy/` and `/terms/` — the last two routes §10
|
||||
// named that no page served. The Map stays because §10 is not finished: phases 7 and 8
|
||||
// add the documentation journey, and the convention above (link the final route, not the
|
||||
// route that exists today) is what the list exists to make safe.
|
||||
//
|
||||
// An empty list is not a dormant one. Rule 3 below still runs, so adding an entry for a
|
||||
// route that has since been built fails immediately rather than sitting here unread.
|
||||
]);
|
||||
|
||||
/** Planned routes actually linked from somewhere, so the reverse check can be reported. */
|
||||
const plannedSeen = new Set();
|
||||
|
||||
const failures = [];
|
||||
let linksChecked = 0;
|
||||
let outboundChecked = 0;
|
||||
|
||||
function fail(file, line, message) {
|
||||
failures.push({ file, line, message });
|
||||
}
|
||||
|
||||
async function* walk(dir, extensions = ['.html']) {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) yield* walk(full, extensions);
|
||||
else if (extensions.includes(path.extname(entry.name))) yield full;
|
||||
}
|
||||
}
|
||||
|
||||
const lineOf = (source, index) => source.slice(0, index).split('\n').length;
|
||||
|
||||
/**
|
||||
* Does a site-absolute path correspond to something the build will serve?
|
||||
*
|
||||
* Astro is configured with `format: 'directory'`, so `/features/` is
|
||||
* `dist/client/features/index.html`. The other shapes are accepted because a route can
|
||||
* legitimately be a file — `/manifest.webmanifest` is one, and `/404.html` is another.
|
||||
*/
|
||||
function resolvesInBuild(pathname) {
|
||||
const clean = pathname.replace(/[?#].*$/, '');
|
||||
const relative = decodeURIComponent(clean).replace(/^\/+/, '');
|
||||
const base = path.join(DIST, relative);
|
||||
|
||||
const candidates = [
|
||||
path.join(base, 'index.html'),
|
||||
`${base.replace(/[\\/]+$/, '')}.html`,
|
||||
base.replace(/[\\/]+$/, ''),
|
||||
];
|
||||
|
||||
return candidates.some((candidate) => {
|
||||
if (!existsSync(candidate)) return false;
|
||||
// A bare directory that has no index.html is not a page anybody can open.
|
||||
return statSync(candidate).isFile();
|
||||
});
|
||||
}
|
||||
|
||||
if (!existsSync(DIST)) {
|
||||
console.error(
|
||||
`\ncheckLinks: no build at ${path.relative(ROOT, DIST)}.\n\n` +
|
||||
' This check reads the built HTML rather than the source, so that links written by\n' +
|
||||
' data files and template literals are checked as the strings they become. Run\n' +
|
||||
' `npm run build` first — `npm run verify` already does.\n'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const onDemandRoutes = await findOnDemandRoutes();
|
||||
|
||||
/* =======================================================================================
|
||||
1. Internal links resolve
|
||||
======================================================================================= */
|
||||
|
||||
for await (const file of walk(DIST)) {
|
||||
const relative = path.relative(ROOT, file);
|
||||
const source = readFileSync(file, 'utf8');
|
||||
|
||||
for (const match of source.matchAll(/(?:href|src)="([^"]*)"/g)) {
|
||||
const value = match[1];
|
||||
|
||||
// Off-site, protocol-relative, and the non-navigational schemes. `mailto:` addresses
|
||||
// are checkFacts.mjs's business (D13) and are not links to anywhere on this site.
|
||||
if (/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(value)) continue;
|
||||
|
||||
// Fragments and query-only links stay on the page they are already on.
|
||||
if (!value || value.startsWith('#') || value.startsWith('?')) continue;
|
||||
|
||||
// Relative links. Astro emits site-absolute paths for everything the site itself
|
||||
// writes; a relative one is almost certainly a mistake, but resolving it correctly
|
||||
// needs the emitting page's directory, so it is reported rather than guessed at.
|
||||
if (!value.startsWith('/')) {
|
||||
fail(
|
||||
relative,
|
||||
lineOf(source, match.index),
|
||||
`relative link "${value}" — write it site-absolute, starting with "/", so it means ` +
|
||||
`the same thing from every page that renders the component`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (RUNTIME_PREFIXES.some((prefix) => value.startsWith(prefix))) continue;
|
||||
|
||||
// The demo slot and its deep links ship empty and hidden in a stock build (§15/D25);
|
||||
// `href=""` is the contract, not a broken link. checkBrand.mjs owns their shape.
|
||||
if (value === '') continue;
|
||||
|
||||
linksChecked++;
|
||||
|
||||
if (resolvesInBuild(value)) continue;
|
||||
|
||||
// A page that renders per request has no file to find. Checked here rather than as a
|
||||
// prefix skip, so an on-demand route still has to EXIST — see findOnDemandRoutes.
|
||||
if (onDemandRoutes.has(value.replace(/[?#].*$/, ''))) continue;
|
||||
|
||||
const planned = PLANNED_ROUTES.get(value.replace(/[?#].*$/, ''));
|
||||
if (planned) {
|
||||
plannedSeen.add(value.replace(/[?#].*$/, ''));
|
||||
continue;
|
||||
}
|
||||
|
||||
fail(
|
||||
relative,
|
||||
lineOf(source, match.index),
|
||||
`"${value}" does not resolve — nothing in the build serves it.\n` +
|
||||
` If a later phase builds it, add it to PLANNED_ROUTES in this script with the\n` +
|
||||
` phase that does. If not, the link is wrong.`
|
||||
);
|
||||
}
|
||||
|
||||
/* =====================================================================================
|
||||
2. Outbound repository links point at a branch, not a commit
|
||||
=====================================================================================
|
||||
|
||||
§12's rule, and the reason for it: a commit permalink is a fact frozen at a sha while
|
||||
the document it names keeps moving. Every link on this site into one of these
|
||||
repositories is meant to show a reader the CURRENT state of something — the module
|
||||
contract, the operator guide, the protocol — and a permalink quietly stops doing that
|
||||
the day after it is written, without ever 404ing. It is the failure mode a link
|
||||
checker would otherwise call healthy.
|
||||
|
||||
Gitea writes both shapes as `/<owner>/<repo>/src/<kind>/<ref>/…`, so the kind segment
|
||||
is what decides it, and a 40-character hex ref is caught even when the kind segment
|
||||
says branch — which is what a "branch" named after a sha actually is. */
|
||||
|
||||
for (const match of source.matchAll(/https?:\/\/[^\s"'<>)]+/g)) {
|
||||
const raw = match[1] ?? match[0];
|
||||
let url;
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (url.host !== GITEA_HOST) continue;
|
||||
|
||||
outboundChecked++;
|
||||
|
||||
const segments = url.pathname.split('/').filter(Boolean);
|
||||
// <owner>/<repo>/<kind>/<refkind>/<ref>/…
|
||||
const kind = segments[2];
|
||||
const refKind = segments[3];
|
||||
const ref = segments[4];
|
||||
|
||||
if (!['src', 'raw', 'media'].includes(kind)) continue;
|
||||
|
||||
if (refKind === 'commit' || refKind === 'tag') {
|
||||
fail(
|
||||
relative,
|
||||
lineOf(source, match.index),
|
||||
`${raw}\n points at a ${refKind}, not a branch. §12 requires branch paths, so a ` +
|
||||
`reader always\n sees the document as it is now rather than as it was.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ref && /^[0-9a-f]{40}$/i.test(ref)) {
|
||||
fail(
|
||||
relative,
|
||||
lineOf(source, match.index),
|
||||
`${raw}\n names a commit sha as its ref. Use a branch name — "main" for anything ` +
|
||||
`canonical.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* =======================================================================================
|
||||
3. The planned-route list has not rotted
|
||||
=======================================================================================
|
||||
|
||||
The half that makes an exemption list safe. Once a phase builds one of these, the entry
|
||||
stops being a promise and starts being a hole in rule 1 — so the build fails until it is
|
||||
deleted. Reported per route, with the phase that was waiting for it, because the person
|
||||
who just built the page is the person who should remove the line. */
|
||||
|
||||
const selfSource = readFileSync(path.join(ROOT, 'scripts/checkLinks.mjs'), 'utf8');
|
||||
|
||||
for (const [route, owner] of PLANNED_ROUTES) {
|
||||
if (!resolvesInBuild(route)) continue;
|
||||
const entry = selfSource.indexOf(`['${route}'`);
|
||||
fail(
|
||||
'scripts/checkLinks.mjs',
|
||||
entry === -1 ? 1 : lineOf(selfSource, entry),
|
||||
`PLANNED_ROUTES still lists "${route}" (${owner}), but the build now serves it.\n` +
|
||||
` Delete the entry: every link to it is checked properly from here on.`
|
||||
);
|
||||
}
|
||||
|
||||
if (failures.length) {
|
||||
console.error('\ncheckLinks: broken or non-canonical links.\n');
|
||||
for (const failure of failures) {
|
||||
console.error(` ${failure.file}:${failure.line}\n ${failure.message}\n`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const pending = [...plannedSeen].sort();
|
||||
|
||||
console.log(
|
||||
`checkLinks: ${linksChecked} internal link(s) resolve and ${outboundChecked} repository ` +
|
||||
`link(s) point at a branch.`
|
||||
);
|
||||
|
||||
if (pending.length) {
|
||||
console.log(
|
||||
` ${pending.length} link(s) point at a planned route: ` +
|
||||
`${pending.join(', ')} — allowed until the phase that builds it.`
|
||||
);
|
||||
}
|
||||
216
scripts/checkQuickstart.mjs
Normal file
@@ -0,0 +1,216 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* checkQuickstart.mjs — PLAN.md §12, added in phase 7 for D35.
|
||||
*
|
||||
* The org lead chose a self-contained quickstart: `/docs/getting-started/install-the-site/`
|
||||
* prints a Compose file and an environment file the reader can copy without going to
|
||||
* another repository first. That is the one place this site knowingly keeps a copy of
|
||||
* somebody else's file, and §1 is a long argument about why copies rot.
|
||||
*
|
||||
* So the copy is checked rather than trusted. Every service, image, published port, mount
|
||||
* and environment key in `src/data/quickstart.mjs` is re-read from `website`'s own
|
||||
* `docker-compose.yml` and `.env.example` on `main`, over the Gitea API — never from a
|
||||
* working tree, per §1's process rule — and any disagreement fails the build.
|
||||
*
|
||||
* It checks in BOTH directions, which is the property that keeps it honest:
|
||||
*
|
||||
* - every value the quickstart states must match upstream's;
|
||||
* - every service and variable upstream has must be either included or listed as
|
||||
* deliberately omitted, WITH a reason. A new variable in `.env.example` therefore turns
|
||||
* this repo red until someone decides whether a first install needs it — the same
|
||||
* intent as checkFacts.mjs and the Integration Kit's checkCoreApi.js;
|
||||
* - and an entry in either omission list that upstream no longer has fails too, so the
|
||||
* lists cannot rot into permanent exemptions.
|
||||
*
|
||||
* GITEA_TOKEN=<token> node scripts/checkQuickstart.mjs
|
||||
*
|
||||
* Anonymous raw fetches fail on this instance, so the token is required. A check that
|
||||
* silently skips itself is worse than no check.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
import { parse as parseYaml } from 'yaml';
|
||||
|
||||
import {
|
||||
compose,
|
||||
services,
|
||||
omittedServices,
|
||||
env,
|
||||
envOmitted,
|
||||
notInUpstreamEnvExample,
|
||||
} from '../src/data/quickstart.mjs';
|
||||
|
||||
const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
||||
const platform = JSON.parse(readFileSync(path.join(ROOT, 'src/data/platform.json'), 'utf8'));
|
||||
|
||||
const BASE = platform.gitea.base;
|
||||
const ORG = platform.gitea.org;
|
||||
const TOKEN = process.env.GITEA_TOKEN?.trim();
|
||||
|
||||
const failures = [];
|
||||
const checked = [];
|
||||
|
||||
const ok = (what) => checked.push(what);
|
||||
const fail = (what, detail) => failures.push({ what, detail });
|
||||
|
||||
/** Same raw-file accessor checkFacts.mjs uses, and for the same reason. */
|
||||
async function raw(repo, filePath, ref) {
|
||||
const url = `${BASE}/api/v1/repos/${ORG}/${repo}/raw/${filePath}?ref=${encodeURIComponent(ref)}`;
|
||||
const res = await fetch(url, { headers: { Authorization: `token ${TOKEN}` } });
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText} for ${url}`);
|
||||
return res.text();
|
||||
}
|
||||
|
||||
/**
|
||||
* `KEY=value` lines from a dotenv file. Commented-out suggestions (`# MODULES=…`) are NOT
|
||||
* keys: they are prose about a variable, and treating them as declared would make the
|
||||
* omission list argue with documentation rather than with configuration.
|
||||
*/
|
||||
function envKeys(text) {
|
||||
const out = new Map();
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const m = line.match(/^([A-Z][A-Z0-9_]*)=(.*)$/);
|
||||
if (m) out.set(m[1], m[2].replace(/\s+#.*$/, '').trim());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Published host:container port pairs, as written. */
|
||||
const portsOf = (svc) => (svc.ports ?? []).map(String);
|
||||
|
||||
/** Container-side paths of every volume entry, which is what a reader's site depends on. */
|
||||
const mountTargets = (svc) => (svc.volumes ?? []).map((v) => String(v).split(':')[1]);
|
||||
|
||||
async function run() {
|
||||
if (!TOKEN) {
|
||||
console.error('checkQuickstart: GITEA_TOKEN is not set. This check cannot run anonymously.');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const upstreamComposeText = await raw('website', 'docker-compose.yml', 'main');
|
||||
const upstreamEnvText = await raw('website', '.env.example', 'main');
|
||||
|
||||
const upstream = parseYaml(upstreamComposeText);
|
||||
const ours = parseYaml(compose);
|
||||
|
||||
if (!upstream?.services) throw new Error('website main:docker-compose.yml has no services block — the file shape changed.');
|
||||
|
||||
// ── 1. The services we ship ───────────────────────────────────────────────
|
||||
for (const name of services) {
|
||||
const mine = ours.services?.[name];
|
||||
const theirs = upstream.services?.[name];
|
||||
if (!mine) { fail(`service ${name}`, 'declared in quickstart.mjs but absent from its own compose text'); continue; }
|
||||
if (!theirs) { fail(`service ${name}`, 'no longer exists in website main:docker-compose.yml'); continue; }
|
||||
|
||||
if (String(mine.image) !== String(theirs.image)) {
|
||||
fail(`service ${name}: image`, `quickstart "${mine.image}" vs upstream "${theirs.image}"`);
|
||||
} else ok(`service ${name}: image`);
|
||||
|
||||
const minePorts = portsOf(mine).join(', ');
|
||||
const theirPorts = portsOf(theirs).join(', ');
|
||||
if (minePorts !== theirPorts) {
|
||||
fail(`service ${name}: ports`, `quickstart [${minePorts}] vs upstream [${theirPorts}]`);
|
||||
} else ok(`service ${name}: ports`);
|
||||
|
||||
// Every mount we keep must land where upstream lands it. Upstream may have mounts we
|
||||
// dropped (the schema bind, which needs a checkout); dropping one is safe, moving one
|
||||
// is not.
|
||||
for (const target of mountTargets(mine)) {
|
||||
if (!mountTargets(theirs).includes(target)) {
|
||||
fail(`service ${name}: mount ${target}`, 'upstream mounts nothing at that container path');
|
||||
} else ok(`service ${name}: mount ${target}`);
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(mine.environment ?? {})) {
|
||||
const theirValue = theirs.environment?.[key];
|
||||
if (theirValue === undefined) {
|
||||
fail(`service ${name}: ${key}`, 'upstream no longer sets it in the compose file');
|
||||
} else if (String(theirValue) !== String(value)) {
|
||||
fail(`service ${name}: ${key}`, `quickstart "${value}" vs upstream "${theirValue}"`);
|
||||
} else ok(`service ${name}: ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. The services we left out, and any that appeared ────────────────────
|
||||
const upstreamServiceNames = Object.keys(upstream.services);
|
||||
for (const [name, reason] of Object.entries(omittedServices)) {
|
||||
if (!upstreamServiceNames.includes(name)) {
|
||||
fail(`omitted service ${name}`, 'upstream no longer has this service — drop it from omittedServices');
|
||||
} else if (!reason?.trim()) {
|
||||
fail(`omitted service ${name}`, 'listed without a reason');
|
||||
} else ok(`omitted service ${name}`);
|
||||
}
|
||||
for (const name of upstreamServiceNames) {
|
||||
if (!services.includes(name) && !(name in omittedServices)) {
|
||||
fail(`service ${name}`, 'is new in website main:docker-compose.yml — include it in the quickstart or record why not');
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. The environment file ───────────────────────────────────────────────
|
||||
const theirEnv = envKeys(upstreamEnvText);
|
||||
const mineEnv = new Map(env.map((e) => [e.key, e]));
|
||||
|
||||
for (const entry of env) {
|
||||
const theirValue = theirEnv.get(entry.key);
|
||||
const excused = notInUpstreamEnvExample[entry.key];
|
||||
|
||||
if (theirValue === undefined) {
|
||||
if (excused) {
|
||||
ok(`env ${entry.key} (absent upstream, declared: ${excused})`);
|
||||
} else {
|
||||
fail(`env ${entry.key}`, 'not in website main:.env.example — either it is gone, or it needs a reason in notInUpstreamEnvExample');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (excused) {
|
||||
fail(
|
||||
`env ${entry.key}`,
|
||||
'is now in website main:.env.example — remove it from notInUpstreamEnvExample, and re-read the prose that describes it as missing',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// A value an operator is told to replace is a placeholder on both sides; comparing two
|
||||
// placeholders would only ever assert that two people picked the same filler words.
|
||||
if (!entry.fill && theirValue !== String(entry.value)) {
|
||||
fail(`env ${entry.key}`, `quickstart "${entry.value}" vs upstream "${theirValue}"`);
|
||||
} else ok(`env ${entry.key}`);
|
||||
}
|
||||
|
||||
for (const [key, reason] of Object.entries(envOmitted)) {
|
||||
if (!theirEnv.has(key)) {
|
||||
fail(`omitted env ${key}`, 'upstream .env.example no longer sets it — drop it from envOmitted');
|
||||
} else if (!reason?.trim()) {
|
||||
fail(`omitted env ${key}`, 'listed without a reason');
|
||||
} else ok(`omitted env ${key}`);
|
||||
}
|
||||
|
||||
for (const key of theirEnv.keys()) {
|
||||
if (!mineEnv.has(key) && !(key in envOmitted)) {
|
||||
fail(`env ${key}`, 'is new in website main:.env.example — add it to the quickstart or record why a first install does not need it');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Report ────────────────────────────────────────────────────────────────
|
||||
if (failures.length === 0) {
|
||||
console.log(`checkQuickstart: ${checked.length} checks passed against website main.`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(`checkQuickstart: ${failures.length} disagreement(s) with website main:\n`);
|
||||
for (const f of failures) console.error(` ✗ ${f.what}\n ${f.detail}`);
|
||||
console.error(
|
||||
'\nThe quickstart on /docs/getting-started/install-the-site/ is a copy of website\'s own\n'
|
||||
+ 'deployment files (D35). Either update src/data/quickstart.mjs to match, or record the\n'
|
||||
+ 'difference with a reason. Do not "fix" the check.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
run().catch((err) => {
|
||||
console.error(`checkQuickstart: ${err.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
191
scripts/checkReference.mjs
Normal file
@@ -0,0 +1,191 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* checkReference.mjs — PLAN.md §12, added in phase 8.
|
||||
*
|
||||
* The Reference section names things: every environment variable, every config key, every
|
||||
* installer command, every canonical document. §1 forbids re-specifying a contract, and
|
||||
* this is the machinery that makes writing the NAMES down safe anyway — the same bargain
|
||||
* checkQuickstart.mjs struck for the quickstart, applied to six more sources.
|
||||
*
|
||||
* Each enumeration in `src/data/reference.mjs` is compared against its authority, read from
|
||||
* the repository that owns it over the Gitea API — never from a working tree, per §1's
|
||||
* process rule. Every comparison is a SET comparison in both directions:
|
||||
*
|
||||
* - a name this site lists that the source no longer has fails (the reference is stale);
|
||||
* - a name the source has that this site does not list fails (the reference is
|
||||
* incomplete, which is the failure mode a hand-maintained list actually has).
|
||||
*
|
||||
* The second direction is the one that earns its keep. A reference page does not usually
|
||||
* rot by describing something that vanished — it rots by quietly not mentioning the three
|
||||
* things added since it was written.
|
||||
*
|
||||
* Descriptions are deliberately NOT checked. Nothing here can know whether a one-line
|
||||
* summary is still true, so it does not pretend to; keeping them terse is the mitigation.
|
||||
*
|
||||
* GITEA_TOKEN=<token> node scripts/checkReference.mjs
|
||||
*
|
||||
* Anonymous raw fetches fail on this instance, so the token is required. A check that
|
||||
* silently skips itself is worse than no check.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
envVars,
|
||||
sidecarConfig,
|
||||
installerCommands,
|
||||
bridgeCfg,
|
||||
visibilityLadder,
|
||||
canonicalDocs,
|
||||
} from '../src/data/reference.mjs';
|
||||
|
||||
const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
||||
const platform = JSON.parse(readFileSync(path.join(ROOT, 'src/data/platform.json'), 'utf8'));
|
||||
|
||||
const BASE = platform.gitea.base;
|
||||
const ORG = platform.gitea.org;
|
||||
const TOKEN = process.env.GITEA_TOKEN?.trim();
|
||||
|
||||
const failures = [];
|
||||
const checked = [];
|
||||
const ok = (what) => checked.push(what);
|
||||
const fail = (what, detail) => failures.push({ what, detail });
|
||||
|
||||
/** Same raw-file accessor checkFacts.mjs and checkQuickstart.mjs use. */
|
||||
async function raw(repo, filePath, ref = 'main') {
|
||||
const url = `${BASE}/api/v1/repos/${ORG}/${repo}/raw/${filePath}?ref=${encodeURIComponent(ref)}`;
|
||||
const res = await fetch(url, { headers: { Authorization: `token ${TOKEN}` } });
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText} for ${url}`);
|
||||
return res.text();
|
||||
}
|
||||
|
||||
/**
|
||||
* The one comparison this whole script performs, so the failure messages are identical
|
||||
* everywhere and say which direction broke.
|
||||
*/
|
||||
function compareSets(label, mine, theirs, hint) {
|
||||
const mineSet = new Set(mine);
|
||||
const theirsSet = new Set(theirs);
|
||||
|
||||
const stale = [...mineSet].filter((k) => !theirsSet.has(k));
|
||||
const missing = [...theirsSet].filter((k) => !mineSet.has(k));
|
||||
|
||||
for (const k of stale) {
|
||||
fail(`${label}: ${k}`, `listed here, but ${hint} no longer has it — remove it, and re-read the prose around it`);
|
||||
}
|
||||
for (const k of missing) {
|
||||
fail(`${label}: ${k}`, `is in ${hint} and NOT listed here — add it, or the reference is lying by omission`);
|
||||
}
|
||||
if (!stale.length && !missing.length) ok(`${label} (${mineSet.size})`);
|
||||
}
|
||||
|
||||
/** `KEY=value` lines. Commented-out suggestions are prose about a variable, not a key. */
|
||||
const envKeysOf = (text) =>
|
||||
text
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.match(/^([A-Z][A-Z0-9_]*)=/))
|
||||
.filter(Boolean)
|
||||
.map((m) => m[1]);
|
||||
|
||||
/** `Key=value` lines from the plugin's config, same rule about comments. */
|
||||
const cfgKeysOf = (text) =>
|
||||
text
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.match(/^([A-Za-z][A-Za-z0-9]*)=/))
|
||||
.filter(Boolean)
|
||||
.map((m) => m[1]);
|
||||
|
||||
async function run() {
|
||||
if (!TOKEN) {
|
||||
console.error('checkReference: GITEA_TOKEN is not set. This check cannot run anonymously.');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// ── 1. Environment variables ──────────────────────────────────────────────
|
||||
compareSets(
|
||||
'env',
|
||||
Object.keys(envVars),
|
||||
envKeysOf(await raw('website', '.env.example')),
|
||||
'website main:.env.example',
|
||||
);
|
||||
|
||||
// ── 2. sidecar.toml ───────────────────────────────────────────────────────
|
||||
//
|
||||
// Parsed from the serde structs rather than from a sample file, because the sample is
|
||||
// GENERATED by the binary on first run and no committed copy is authoritative. Each
|
||||
// `pub name: T` inside a `struct XCfg` is one key, and the struct name gives the section.
|
||||
const configRs = await raw('link', 'sidecar/src/config.rs');
|
||||
const sidecarKeys = [];
|
||||
for (const m of configRs.matchAll(/struct\s+(\w+)Cfg\s*\{([\s\S]*?)\n\}/g)) {
|
||||
const section = m[1].toLowerCase();
|
||||
for (const f of m[2].matchAll(/pub\s+(\w+)\s*:/g)) sidecarKeys.push(`${section}.${f[1]}`);
|
||||
}
|
||||
compareSets('sidecar.toml', Object.keys(sidecarConfig), sidecarKeys, 'link main:sidecar/src/config.rs');
|
||||
|
||||
// ── 3. Installer commands ─────────────────────────────────────────────────
|
||||
const cliRs = await raw('installer', 'src/cli.rs');
|
||||
const cmdBlock = cliRs.match(/enum\s+Command\s*\{([\s\S]*?)\n\}/);
|
||||
const cmds = cmdBlock ? [...cmdBlock[1].matchAll(/^\s*([A-Z]\w*)\s*[,{]/gm)].map((m) => m[1]) : [];
|
||||
compareSets('installer command', Object.keys(installerCommands), cmds, 'installer main:src/cli.rs');
|
||||
|
||||
// ── 4. Bridge.cfg ─────────────────────────────────────────────────────────
|
||||
const bridgeKeys = Object.values(bridgeCfg).flatMap((group) => Object.keys(group));
|
||||
compareSets(
|
||||
'Bridge.cfg',
|
||||
bridgeKeys,
|
||||
cfgKeysOf(await raw('servuo-plugins', 'overlay/Config/Bridge.cfg')),
|
||||
'servuo-plugins main:overlay/Config/Bridge.cfg',
|
||||
);
|
||||
|
||||
// ── 5. The visibility ladder ──────────────────────────────────────────────
|
||||
//
|
||||
// A security boundary, so it is checked against the module that enforces it rather than
|
||||
// against prose. The order matters as much as the membership: it is a ladder, and a
|
||||
// reader reasoning about "staff and above" needs the rungs in the right sequence.
|
||||
const vis = await raw('Module-uo', 'server/utils/shardVisibility.js');
|
||||
const ladderMatch = vis.match(/const\s+LADDER\s*=\s*\[([\s\S]*?)\]/);
|
||||
const ladder = ladderMatch
|
||||
? [...ladderMatch[1].matchAll(/'([a-z_]+)'/g)].map((m) => m[1])
|
||||
: [];
|
||||
if (ladder.length === 0) {
|
||||
fail('visibility ladder', 'could not find LADDER in Module-uo main:server/utils/shardVisibility.js');
|
||||
} else if (ladder.join(' ') !== visibilityLadder.join(' ')) {
|
||||
fail(
|
||||
'visibility ladder',
|
||||
`order or membership differs — here "${visibilityLadder.join(' → ')}", upstream "${ladder.join(' → ')}"`,
|
||||
);
|
||||
} else ok(`visibility ladder (${ladder.length} rungs, in order)`);
|
||||
|
||||
// ── 6. Canonical documents ────────────────────────────────────────────────
|
||||
//
|
||||
// Existence only. A link to a document that moved is the single most likely way this
|
||||
// section breaks, and it is exactly what a build can answer.
|
||||
for (const docPath of Object.keys(canonicalDocs)) {
|
||||
const url = `${BASE}/api/v1/repos/${ORG}/docs/contents/${docPath}?ref=main`;
|
||||
const res = await fetch(url, { headers: { Authorization: `token ${TOKEN}` } });
|
||||
if (res.ok) ok(`canonical doc ${docPath}`);
|
||||
else fail(`canonical doc ${docPath}`, `not found in docs main (HTTP ${res.status})`);
|
||||
}
|
||||
|
||||
// ── Report ────────────────────────────────────────────────────────────────
|
||||
if (failures.length === 0) {
|
||||
console.log(`checkReference: ${checked.length} enumeration check(s) passed against their sources.`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(`\ncheckReference: ${failures.length} disagreement(s) with the platform:\n`);
|
||||
for (const f of failures) console.error(` ✗ ${f.what}\n ${f.detail}`);
|
||||
console.error(`
|
||||
The Reference section names things, which is only safe while the names are checked
|
||||
(§1, and the same bargain checkQuickstart.mjs struck). Update src/data/reference.mjs
|
||||
to match the source. Do not "fix" the check.
|
||||
`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
run().catch((err) => {
|
||||
console.error(`checkReference: ${err.message}`);
|
||||
process.exit(2);
|
||||
});
|
||||
171
scripts/checkScreens.mjs
Normal file
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* checkScreens.mjs — the screenshots agree with what the pages say about them.
|
||||
*
|
||||
* PLAN.md §12, §13 phase 9, D45.
|
||||
*
|
||||
* node scripts/checkScreens.mjs
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHAT IT PROVES, AND WHY EACH ONE IS WORTH A CHECK
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* 1. EVERY DECLARED SCREEN HAS A FILE. A missing image is invisible in review — the page
|
||||
* still builds, still lays out, and only a reader sees the broken frame.
|
||||
*
|
||||
* 2. EVERY FILE IS THE DECLARED SIZE. `width` and `height` reach the markup as intrinsic
|
||||
* attributes, and an attribute that disagrees with the file is a page that jumps as the
|
||||
* image decodes. It also catches a re-capture taken at the wrong viewport, which looks
|
||||
* fine on its own and wrong beside the others.
|
||||
*
|
||||
* 3. NOTHING IN public/screens IS ORPHANED. A capture that stopped being referenced is a
|
||||
* file the container still ships and nobody looks at — and, worse, one that never gets
|
||||
* retaken, so it silently becomes the oldest thing in the repository.
|
||||
*
|
||||
* 4. EVERY DECLARED SCREEN IS ACTUALLY USED. The mirror of 3: an entry in `screens.mjs`
|
||||
* that no page renders is a capture being maintained for nothing. Usage is a literal
|
||||
* search for the id across `src/`, which is how both readers of the data refer to one —
|
||||
* `<Screenshot id="admin-users" />` and the `groupScreens` map on `/features/`.
|
||||
*
|
||||
* 5. THE ALT TEXT AND CAPTION SAY SOMETHING. An empty alt on an editorial image is an
|
||||
* accessibility failure the build cannot otherwise see, and a caption is the sentence
|
||||
* that makes a screenshot evidence rather than decoration.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY IT READS THE PNG HEADER ITSELF
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* It does not: it reads the WebP header, and it does it with twenty lines rather than a
|
||||
* dependency. `sharp` is already here for the brand assets and could answer this, but this
|
||||
* check runs in CI on every pull request and a check that needs a native image library to
|
||||
* tell you a file is 1920 pixels wide is a check that will one day fail for a reason that
|
||||
* has nothing to do with screenshots.
|
||||
*/
|
||||
|
||||
import { readdirSync, readFileSync, existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { screens, WEB, PHONE } from '../src/data/screens.mjs';
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.join(HERE, '..');
|
||||
const DIR = path.join(ROOT, 'public', 'screens');
|
||||
const SRC = path.join(ROOT, 'src');
|
||||
|
||||
const problems = [];
|
||||
|
||||
/**
|
||||
* The pixel size of a WebP file, from its header.
|
||||
*
|
||||
* A RIFF container: "RIFF" size "WEBP" then one of three chunk types. Lossy ("VP8 ") and
|
||||
* lossless ("VP8L") pack the dimensions differently, and an animated or extended file
|
||||
* ("VP8X") states them outright. `cwebp` at quality 82 writes VP8 , but a future change of
|
||||
* encoder should not turn this check into a mystery, so all three are handled.
|
||||
*/
|
||||
function webpSize(file) {
|
||||
const buf = readFileSync(file);
|
||||
|
||||
if (buf.length < 30 || buf.toString('ascii', 0, 4) !== 'RIFF' || buf.toString('ascii', 8, 12) !== 'WEBP') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const chunk = buf.toString('ascii', 12, 16);
|
||||
|
||||
if (chunk === 'VP8X') {
|
||||
return {
|
||||
width: 1 + (buf[24] | (buf[25] << 8) | (buf[26] << 16)),
|
||||
height: 1 + (buf[27] | (buf[28] << 8) | (buf[29] << 16)),
|
||||
};
|
||||
}
|
||||
|
||||
if (chunk === 'VP8L') {
|
||||
const bits = buf[21] | (buf[22] << 8) | (buf[23] << 16) | (buf[24] << 24);
|
||||
return { width: 1 + (bits & 0x3fff), height: 1 + ((bits >> 14) & 0x3fff) };
|
||||
}
|
||||
|
||||
if (chunk === 'VP8 ') {
|
||||
return {
|
||||
width: buf.readUInt16LE(26) & 0x3fff,
|
||||
height: buf.readUInt16LE(28) & 0x3fff,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Every file under `src/`, read once, so usage is a search rather than a guess. */
|
||||
function sourceText() {
|
||||
const out = [];
|
||||
|
||||
const walk = (dir) => {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(full);
|
||||
else if (/\.(astro|mdx?|mjs|js|ts|tsx)$/.test(entry.name)) out.push(readFileSync(full, 'utf8'));
|
||||
}
|
||||
};
|
||||
|
||||
walk(SRC);
|
||||
return out;
|
||||
}
|
||||
|
||||
const sources = sourceText();
|
||||
const declared = new Set();
|
||||
|
||||
for (const shot of screens) {
|
||||
const name = `${shot.id}.webp`;
|
||||
const file = path.join(DIR, name);
|
||||
declared.add(name);
|
||||
|
||||
if (!existsSync(file)) {
|
||||
problems.push(
|
||||
`${shot.id}: no file at public/screens/${name}. ` +
|
||||
`Retake it: node scripts/captureScreens.mjs ${shot.id}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const want = shot.family === 'web' ? WEB : PHONE;
|
||||
const size = webpSize(file);
|
||||
|
||||
if (!size) {
|
||||
problems.push(`${shot.id}: public/screens/${name} is not a WebP this check can read.`);
|
||||
} else if (size.width !== want.width || size.height !== want.height) {
|
||||
problems.push(
|
||||
`${shot.id}: file is ${size.width}x${size.height}, ` +
|
||||
`declared ${want.width}x${want.height} for the "${shot.family}" family.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!shot.alt || shot.alt.length < 20) {
|
||||
problems.push(`${shot.id}: alt text is missing or too short to describe the screen.`);
|
||||
}
|
||||
|
||||
if (!shot.caption) {
|
||||
problems.push(`${shot.id}: no caption.`);
|
||||
}
|
||||
|
||||
const used = sources.some((text) => text.includes(`'${shot.id}'`) || text.includes(`"${shot.id}"`));
|
||||
|
||||
if (!used) {
|
||||
problems.push(
|
||||
`${shot.id}: declared but no page renders it. Use it, or delete the entry and its file.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (existsSync(DIR)) {
|
||||
for (const name of readdirSync(DIR)) {
|
||||
if (!declared.has(name)) {
|
||||
problems.push(`public/screens/${name}: not declared in src/data/screens.mjs.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (problems.length > 0) {
|
||||
console.error(`\ncheckScreens: ${problems.length} problem(s)\n`);
|
||||
for (const problem of problems) console.error(` - ${problem}`);
|
||||
console.error('');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`checkScreens: ${screens.length} screens, all present, sized and used.`);
|
||||
77
scripts/checkSidebar.mjs
Normal file
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* checkSidebar.mjs — PLAN.md §12, added in phase 8.
|
||||
*
|
||||
* `src/config/sidebar.mjs` holds two trees: `docsSidebar`, which Starlight renders, and
|
||||
* `plannedSidebar`, the tree §10 planned. While pages were still being written the second
|
||||
* was a checklist. Now that every page exists it is a second copy of the first, maintained
|
||||
* by hand — and a hand-maintained copy with nothing reading it is exactly the shape of
|
||||
* thing §1 is about.
|
||||
*
|
||||
* It had already drifted, silently: phase 7 added the `Content` page under D37 and this
|
||||
* list was never updated. Nothing failed, because nothing read it. That is the whole
|
||||
* argument for this check.
|
||||
*
|
||||
* So the two must agree on groups, labels AND order. Order is checked because the order of
|
||||
* "Getting started" IS the installation path — §10 calls it the priority of the whole
|
||||
* project — and a reordering that nobody noticed would be a worse defect than a missing
|
||||
* page.
|
||||
*
|
||||
* node scripts/checkSidebar.mjs
|
||||
*
|
||||
* No token and no network: both trees are in this repository.
|
||||
*/
|
||||
|
||||
import { docsSidebar, plannedSidebar } from '../src/config/sidebar.mjs';
|
||||
|
||||
const failures = [];
|
||||
const fail = (what, detail) => failures.push({ what, detail });
|
||||
|
||||
const live = new Map(docsSidebar.map((g) => [g.label, g.items.map((i) => i.label)]));
|
||||
const planned = new Map(Object.entries(plannedSidebar));
|
||||
|
||||
// ── Groups ──────────────────────────────────────────────────────────────────
|
||||
for (const label of live.keys()) {
|
||||
if (!planned.has(label)) fail(`group ${label}`, 'is in the live sidebar and not in plannedSidebar');
|
||||
}
|
||||
for (const label of planned.keys()) {
|
||||
if (!live.has(label)) fail(`group ${label}`, 'is in plannedSidebar and not in the live sidebar');
|
||||
}
|
||||
|
||||
// ── Pages, in order ─────────────────────────────────────────────────────────
|
||||
for (const [label, liveItems] of live) {
|
||||
const plannedItems = planned.get(label);
|
||||
if (!plannedItems) continue;
|
||||
|
||||
for (const page of liveItems) {
|
||||
if (!plannedItems.includes(page)) fail(`${label} → ${page}`, 'is live but not in plannedSidebar');
|
||||
}
|
||||
for (const page of plannedItems) {
|
||||
if (!liveItems.includes(page)) fail(`${label} → ${page}`, 'is planned but has no live sidebar entry');
|
||||
}
|
||||
|
||||
// Only meaningful once membership matches; otherwise it just repeats the above.
|
||||
if (liveItems.length === plannedItems.length && liveItems.every((p) => plannedItems.includes(p))) {
|
||||
if (liveItems.join(' | ') !== plannedItems.join(' | ')) {
|
||||
fail(
|
||||
`${label} order`,
|
||||
`live "${liveItems.join(' → ')}" vs planned "${plannedItems.join(' → ')}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Report ──────────────────────────────────────────────────────────────────
|
||||
if (failures.length === 0) {
|
||||
const pages = [...live.values()].reduce((n, items) => n + items.length, 0);
|
||||
console.log(`checkSidebar: ${live.size} groups and ${pages} pages agree with plannedSidebar.`);
|
||||
} else {
|
||||
console.error(`\ncheckSidebar: ${failures.length} disagreement(s) between the two trees:\n`);
|
||||
for (const f of failures) console.error(` ✗ ${f.what}\n ${f.detail}`);
|
||||
console.error(`
|
||||
Both trees are in src/config/sidebar.mjs. Decide which one is right — if a page was
|
||||
deliberately added, renamed or reordered, plannedSidebar records that decision and
|
||||
should move with it.
|
||||
`);
|
||||
process.exit(1);
|
||||
}
|
||||
231
scripts/playDataSafety.mjs
Normal file
@@ -0,0 +1,231 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* playDataSafety.mjs — PLAN.md §9, phase 6 (D33).
|
||||
*
|
||||
* Writes `PLAY_DATA_SAFETY.md`: the answers to Google Play's Data Safety form, generated
|
||||
* from the same `src/data/collection.mjs` rows that `/privacy` section 2 renders.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY IT IS GENERATED AND CHECKED RATHER THAN WRITTEN
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* §9 says the declaration is "filled from section 2, and section 2 is written knowing that
|
||||
* is what it is for". Two documents describing the same code drift — that is the premise of
|
||||
* `capabilities.mjs` (D18) and `notBuilt.mjs` (D22) — and this pair drifts worse than
|
||||
* either, because one half is a published legal page and the other is a form at Google that
|
||||
* cannot be corrected without a review round. The app gaining a crash reporter must not be
|
||||
* able to leave a "not collected" answer standing in a file nobody re-reads.
|
||||
*
|
||||
* So the markdown is an output, not a source. `--check` recomputes it and fails if the
|
||||
* committed copy differs, which is what puts it in `verify` and in CI: editing the doc by
|
||||
* hand fails the build and names the data file to edit instead.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHAT THIS DOCUMENT IS NOT
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* It is not a filled-in form and it does not claim to know Play's current definitions.
|
||||
* Play's testing and disclosure requirements have changed more than once — §8 says so, and
|
||||
* `playPolicy.verifiedOn` exists for the same reason — and there is no API to read them
|
||||
* from. What this generates is the FACTS, arranged as the console arranges its questions,
|
||||
* with the answer each fact supports and why. Whoever fills the form reads the console's
|
||||
* own definitions against these, which is a job for a person; what they must never do is
|
||||
* answer from memory about what the app stores.
|
||||
*
|
||||
* node scripts/playDataSafety.mjs # write PLAY_DATA_SAFETY.md
|
||||
* node scripts/playDataSafety.mjs --check # fail if the committed copy is stale
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
import { collectedIn, playRows } from '../src/data/collection.mjs';
|
||||
import { legal } from '../src/data/legal.mjs';
|
||||
|
||||
const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
||||
const OUT = path.join(ROOT, 'PLAY_DATA_SAFETY.md');
|
||||
const CHECK = process.argv.includes('--check');
|
||||
|
||||
const GENERATOR = 'scripts/playDataSafety.mjs';
|
||||
|
||||
/** Cell text: the table is markdown, so a pipe would end the column early. */
|
||||
const cell = (text) => String(text).replace(/\|/g, '\\|').replace(/\s*\n\s*/g, ' ');
|
||||
|
||||
const yesNo = (value) => (value ? 'Yes' : 'No');
|
||||
|
||||
function render() {
|
||||
const rows = playRows();
|
||||
const site = collectedIn('site');
|
||||
|
||||
const lines = [];
|
||||
|
||||
lines.push('<!--');
|
||||
lines.push(' GENERATED FILE — do not edit.');
|
||||
lines.push('');
|
||||
lines.push(` Source: src/data/collection.mjs (scope "app") + src/data/legal.mjs`);
|
||||
lines.push(` Generator: ${GENERATOR}`);
|
||||
lines.push('');
|
||||
lines.push(' Edit the data file and run `npm run play:datasafety`. CI runs the same');
|
||||
lines.push(' generator with --check, so a hand edit here fails the build rather than');
|
||||
lines.push(' quietly disagreeing with the published privacy policy.');
|
||||
lines.push('-->');
|
||||
lines.push('');
|
||||
lines.push('# Google Play Data Safety — the answers, and what they are based on');
|
||||
lines.push('');
|
||||
lines.push(
|
||||
'The Play Console asks, for every category of data, whether the app **collects** it, ' +
|
||||
'whether it is **shared**, whether collection is **required or optional**, and *why*. ' +
|
||||
'This file holds the answers for the Runic Gateway Android app, generated from the ' +
|
||||
'same inventory the published privacy policy renders — see `/privacy`, section 2.'
|
||||
);
|
||||
lines.push('');
|
||||
lines.push(
|
||||
'> **This is not a filled-in form.** Play’s definitions change and no check here can ' +
|
||||
'read them. Every answer below is a fact about the code with the reasoning attached; ' +
|
||||
'read the console’s current wording against them when you fill the form. What this ' +
|
||||
'file exists to prevent is somebody answering from memory about what the app stores.'
|
||||
);
|
||||
lines.push('');
|
||||
|
||||
/* ---------------------------------------------------------------------------------
|
||||
The one answer that shapes every other one.
|
||||
--------------------------------------------------------------------------------- */
|
||||
lines.push('## The premise every answer rests on');
|
||||
lines.push('');
|
||||
lines.push(
|
||||
'We operate **no server the app talks to.** The app ships pointed at nothing: its ' +
|
||||
'first screen asks for the address of a Runic Gateway deployment and validates it ' +
|
||||
'before anything else in the app runs. That deployment belongs to whoever runs that ' +
|
||||
'community. Data therefore travels from the device to *their* server, and there is ' +
|
||||
'no endpoint of ours anywhere in the path — not for content, not for telemetry, and ' +
|
||||
'not for crash reports, of which there are none.'
|
||||
);
|
||||
lines.push('');
|
||||
lines.push(
|
||||
'That is why nearly every answer below is "not collected", and it is also the answer ' +
|
||||
'most likely to be questioned in a review. The supporting facts are in the table: ' +
|
||||
'each row names the file it was read out of.'
|
||||
);
|
||||
lines.push('');
|
||||
lines.push(
|
||||
'Where the console offers free text about security practices, two things are worth ' +
|
||||
'saying: credentials are held in Android’s encrypted storage (AES-256-GCM via ' +
|
||||
'Jetpack Security), and push notifications carry **no content** — a relay receives a ' +
|
||||
'stream name and a reference, and the app fetches the actual message over its own ' +
|
||||
'authenticated connection.'
|
||||
);
|
||||
lines.push('');
|
||||
|
||||
/* --------------------------------------------------------------------------------- */
|
||||
lines.push('## Data types');
|
||||
lines.push('');
|
||||
lines.push('| Category | Data type | Collected by us | Shared by us | Answer |');
|
||||
lines.push('|---|---|---|---|---|');
|
||||
for (const row of rows) {
|
||||
lines.push(
|
||||
`| ${cell(row.play.category)} | ${cell(row.play.type)} | ${yesNo(row.play.collected)} ` +
|
||||
`| ${yesNo(row.play.shared)} | ${cell(row.play.answer)} |`
|
||||
);
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
lines.push('## Each answer, and why it is the truthful one');
|
||||
lines.push('');
|
||||
for (const row of rows) {
|
||||
lines.push(`### ${row.title}`);
|
||||
lines.push('');
|
||||
lines.push(`**${row.play.category} → ${row.play.type}.** ${cell(row.play.answer)}`);
|
||||
lines.push('');
|
||||
lines.push(cell(row.body));
|
||||
lines.push('');
|
||||
lines.push(`- **Why that answer:** ${cell(row.play.because)}`);
|
||||
lines.push(`- **Retention:** ${cell(row.retention.summary)}`);
|
||||
if (row.retention.detail) lines.push(`- **In detail:** ${cell(row.retention.detail)}`);
|
||||
lines.push(`- **Read from:** \`${row.source}\``);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------------- */
|
||||
lines.push('## The rest of the listing');
|
||||
lines.push('');
|
||||
lines.push(
|
||||
`- **Privacy policy URL:** \`/privacy\` on this site. It is the URL Play is given, and ` +
|
||||
'section 2 of it is about the app specifically.'
|
||||
);
|
||||
lines.push(
|
||||
`- **Target audience:** adults. The beta is stated as **${legal.minimumAge} or older** ` +
|
||||
'(D31); the app contains no content directed at children and no age verification.'
|
||||
);
|
||||
lines.push(
|
||||
'- **Account deletion:** the app creates no account with us — an account belongs to ' +
|
||||
'the deployment the user chose, and is deleted there. The only list we hold is the ' +
|
||||
'beta signup, which is erased on request; `/privacy` section 4 says how to ask.'
|
||||
);
|
||||
lines.push(
|
||||
'- **Data deletion request URL:** the contact address published on `/privacy`, which ' +
|
||||
'is read from the mounted `brand.json` rather than typed anywhere in the source (D13).'
|
||||
);
|
||||
lines.push('');
|
||||
|
||||
lines.push('## What the website collects, for the same reviewer');
|
||||
lines.push('');
|
||||
lines.push(
|
||||
'Not part of the Data Safety form — that form is about the app — but a reviewer who ' +
|
||||
'follows the privacy policy URL lands on a page covering three things, so it is ' +
|
||||
'worth knowing which of them the site itself is responsible for:'
|
||||
);
|
||||
lines.push('');
|
||||
for (const row of site) {
|
||||
lines.push(`- **${cell(row.title)}** — ${cell(row.retention.summary)}.`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push(
|
||||
`Last generated from data dated ${legal.lastUpdated}. Regenerate with ` +
|
||||
'`npm run play:datasafety` after any change to what the app stores.'
|
||||
);
|
||||
lines.push('');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
const rendered = render();
|
||||
|
||||
if (!CHECK) {
|
||||
writeFileSync(OUT, rendered, 'utf8');
|
||||
console.log(`playDataSafety: wrote ${path.relative(ROOT, OUT)}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Line endings are normalised before comparing, and that is not fussiness.
|
||||
*
|
||||
* The repository has no `.gitattributes` and Windows checkouts run with
|
||||
* `core.autocrlf=true`, so this file is stored with LF and lands on a Windows disk with
|
||||
* CRLF. A byte comparison would then fail for every developer on Windows while passing in
|
||||
* CI — the worst shape a check can have, because the fix people reach for is to stop
|
||||
* running it. What is being asserted is that the CONTENT agrees, and a line ending is not
|
||||
* content.
|
||||
*/
|
||||
const normalise = (text) => text.split('\r\n').join('\n');
|
||||
|
||||
let committed = null;
|
||||
try {
|
||||
committed = readFileSync(OUT, 'utf8');
|
||||
} catch {
|
||||
/* handled below */
|
||||
}
|
||||
|
||||
if (committed !== null && normalise(committed) === normalise(rendered)) {
|
||||
console.log('playDataSafety: PLAY_DATA_SAFETY.md matches src/data/collection.mjs.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.error(
|
||||
`\nplayDataSafety: ${path.relative(ROOT, OUT)} is ${committed === null ? 'missing' : 'stale'}.\n\n` +
|
||||
' It is generated from src/data/collection.mjs — the same rows /privacy renders —\n' +
|
||||
' so that the published policy and the Data Safety declaration cannot disagree\n' +
|
||||
' (§9, D33). Run:\n\n' +
|
||||
' npm run play:datasafety\n\n' +
|
||||
' and commit the result. If the change came from editing the markdown by hand,\n' +
|
||||
' make it in the data file instead: the page has to move with it.\n'
|
||||
);
|
||||
process.exit(1);
|
||||
462
scripts/seedDemo.mjs
Normal file
@@ -0,0 +1,462 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* seedDemo.mjs — the deployment the screenshots are taken of. PLAN.md §13 phase 9, D45.
|
||||
*
|
||||
* node scripts/seedDemo.mjs → seed (idempotent; safe to re-run)
|
||||
* node scripts/seedDemo.mjs --dry-run → say what it would do, write nothing
|
||||
*
|
||||
* Environment (all optional; the defaults are this machine's review stack):
|
||||
*
|
||||
* RG_BASE http://localhost:3000 the website the seed drives
|
||||
* RG_ADMIN_USER demoadmin an existing admin, created by website's own
|
||||
* RG_ADMIN_PASS DemoReview!2026 `npm run seed` — see PLAN.md §13 phase 9
|
||||
* RG_DEMO_PASS DemoReview!2026 the password every seeded cast member gets
|
||||
* UOLINK_BASE http://127.0.0.1:8080 sidecar REST, written to Admin → Shard
|
||||
* UOLINK_WS ws://127.0.0.1:8080/ws sidecar WebSocket
|
||||
* UOLINK_TOKEN (unset) sidecar auth token; skipped when absent
|
||||
* UOLINK_PROTOCOL 4 wire protocol to pin — see the note below
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY THE SEED DRIVES THE API AND NEVER THE DATABASE
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* Every row this creates could have been an INSERT, and every INSERT would have been a
|
||||
* second implementation of a rule the website already owns: how a body is sanitized, what
|
||||
* a slug may contain, which excerpt is derived when none is given, how a password is
|
||||
* hashed. A seed that writes SQL directly produces a database the product could not have
|
||||
* produced, and screenshots of that database show a product that does not exist.
|
||||
*
|
||||
* So this speaks HTTP to a running site, as an admin, through the same endpoints the admin
|
||||
* panel calls. The cost is that the site has to be up; the benefit is that the content is
|
||||
* real, and that this script keeps working when a column moves.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY IT IS IDEMPOTENT RATHER THAN DESTRUCTIVE
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* Re-running must not double the news list, and must not erase a screenshot rig somebody
|
||||
* has been adjusting by hand. Every step therefore looks before it writes and reports
|
||||
* `= exists` rather than failing. That also makes the script usable as a repair: point it
|
||||
* at a stack that has drifted and it puts back only what is missing.
|
||||
*
|
||||
* What it deliberately does NOT create: anything the shard owns. Teams arrive from the
|
||||
* guild board over the bridge, the marketplace from player vendors, the atlas from real
|
||||
* spawners (PLAN.md §13 phase 9, D42). Seeding those would be inventing game state that
|
||||
* the product is supposed to be showing, which is exactly what D4 forbids.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const BASE = (process.env.RG_BASE || 'http://localhost:3000').replace(/\/+$/, '');
|
||||
const API = `${BASE}/api/v1`;
|
||||
const ADMIN_USER = process.env.RG_ADMIN_USER || 'demoadmin';
|
||||
const ADMIN_PASS = process.env.RG_ADMIN_PASS || 'DemoReview!2026';
|
||||
const DEMO_PASS = process.env.RG_DEMO_PASS || 'DemoReview!2026';
|
||||
const UOLINK_BASE = process.env.UOLINK_BASE || 'http://127.0.0.1:8080';
|
||||
const UOLINK_WS = process.env.UOLINK_WS || 'ws://127.0.0.1:8080/ws';
|
||||
const UOLINK_TOKEN = process.env.UOLINK_TOKEN || '';
|
||||
// The pinned wire protocol has to be STATED, not left to the module's default.
|
||||
//
|
||||
// `module-uo`'s schema fragment still carries `protocol INT NOT NULL DEFAULT 3`, from the
|
||||
// protocol-3 cutover; the sidecar on `link` `main` speaks 4. The module handles protocol 4's
|
||||
// frames — `guild.roster` and `guild.leave` ingest landed with the Teams cutover — but a
|
||||
// FRESH install pins 3, and the sidecar answers a 3 with `409 protocol version mismatch` on
|
||||
// every REST call. So a new deployment reads nothing from its shard until somebody edits the
|
||||
// number in Admin → Shard. Raised with the org lead rather than patched from here: the fix
|
||||
// belongs in `module-uo`, not in this repo's screenshot rig (PLAN.md §13 phase 9).
|
||||
const UOLINK_PROTOCOL = Number(process.env.UOLINK_PROTOCOL || 4);
|
||||
|
||||
const DRY = process.argv.includes('--dry-run');
|
||||
|
||||
// ── The demo deployment's identity (D43) ───────────────────────────────────────────────
|
||||
//
|
||||
// A neutral demo brand rather than UOMysticmoon: the screenshots show the platform, not a
|
||||
// private shard, and §15's demo VM can wear the same identity so the imagery stays true the
|
||||
// day it exists. The name is deliberately "… Demo" rather than an invented community —
|
||||
// nobody should have to wonder whether they are looking at a real server they could join.
|
||||
// The published contact address lives in exactly one file in this repository (D13), and
|
||||
// `checkFacts.mjs` fails the build if a literal address appears anywhere else — including
|
||||
// here. So the demo wears the same address the site publishes, read from the same place.
|
||||
const brandDefault = JSON.parse(
|
||||
readFileSync(new URL('../brand-default/brand.json', import.meta.url), 'utf8'),
|
||||
);
|
||||
|
||||
const SETTINGS = {
|
||||
site_title: 'Runic Gateway Demo',
|
||||
site_mode: 'live',
|
||||
status_message: 'Live — the demo shard is up.',
|
||||
homepage_teaser:
|
||||
'A public demonstration of Runic Gateway: a self-hosted community site wired to a ' +
|
||||
'live game server. Everything on this site is real data from the shard behind it.',
|
||||
contact_email: brandDefault.contactEmail,
|
||||
};
|
||||
|
||||
// ── The cast ───────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Five accounts, one per role the admin screens distinguish, so a screenshot of the users
|
||||
// table shows the role column doing something. Names are ordinary fantasy given names and
|
||||
// belong to nobody.
|
||||
const USERS = [
|
||||
{ username: 'aldricmoss', role: 'moderator' },
|
||||
{ username: 'brannwen', role: 'editor' },
|
||||
{ username: 'sablequill', role: 'player' },
|
||||
{ username: 'tobinreed', role: 'player' },
|
||||
{ username: 'mirenavox', role: 'player' },
|
||||
];
|
||||
|
||||
// ── News, five-on-friday, the newsletter ───────────────────────────────────────────────
|
||||
//
|
||||
// Written as a small community's real output rather than lorem: a patch note, an event, a
|
||||
// maintenance notice and a Friday post. Bodies are short HTML because that is what the
|
||||
// editor stores, and the list screens show the excerpt anyway.
|
||||
const POSTS = [
|
||||
{
|
||||
category: 'news',
|
||||
title: 'Autumn patch: vendor search, and a fix for house decay',
|
||||
excerpt:
|
||||
'Player-vendor listings are now searchable from the site, and the decay timer no ' +
|
||||
'longer resets when a co-owner logs in.',
|
||||
body:
|
||||
'<p>The autumn patch is live. The headline change is that <strong>every player ' +
|
||||
'vendor on the shard is now searchable from this site</strong> — the marketplace ' +
|
||||
'page reads the same live feed the game does, so a listing appears within a minute ' +
|
||||
'of being priced.</p><p>We also fixed the house decay timer resetting when a ' +
|
||||
'co-owner logged in. That bug had been quietly keeping condemned houses alive since ' +
|
||||
'spring.</p><p>Full notes are on the wiki.</p>',
|
||||
published: true,
|
||||
},
|
||||
{
|
||||
category: 'news',
|
||||
title: 'The Harvest Moon festival opens this weekend',
|
||||
excerpt:
|
||||
'Three days of gatherings at the crossroads, with a champion spawn on the last ' +
|
||||
'night. Everyone is welcome, no signup needed.',
|
||||
body:
|
||||
'<p>The Harvest Moon festival runs from Friday evening to Sunday night at the ' +
|
||||
'crossroads north of town. There is no signup and no entry fee — turn up.</p>' +
|
||||
'<p>Saturday is the market day; bring anything you want to sell and we will set out ' +
|
||||
'extra vendor stalls. Sunday night closes with a champion spawn, which will be ' +
|
||||
'announced in game and on the shard status page here.</p>',
|
||||
published: true,
|
||||
},
|
||||
{
|
||||
category: 'news',
|
||||
title: 'Scheduled maintenance, Tuesday 03:00 UTC',
|
||||
excerpt:
|
||||
'About twenty minutes of downtime for a server restart and a world save. The site ' +
|
||||
'stays up throughout.',
|
||||
body:
|
||||
'<p>We are restarting the shard on Tuesday at 03:00 UTC for a world save and a ' +
|
||||
'server update. Expect about twenty minutes of downtime.</p><p>This site stays up ' +
|
||||
'while the shard is down — the status panel will simply show the shard as offline, ' +
|
||||
'and the marketplace and atlas will show their last known state.</p>',
|
||||
published: true,
|
||||
},
|
||||
{
|
||||
category: 'five-on-friday',
|
||||
title: 'Five on Friday: the ones who keep the roads clear',
|
||||
excerpt:
|
||||
'Five players who spent the week doing unglamorous work, and what they were up to.',
|
||||
body:
|
||||
'<p>Five people who made the week better for everybody else:</p><ol><li>Sable, for ' +
|
||||
'restocking the free reagent stall three times without being asked.</li><li>Tobin, ' +
|
||||
'for guiding two new players through their first dungeon.</li><li>Mirena, for the ' +
|
||||
'map corrections on the wiki.</li><li>Brannwen, for writing up the champion ' +
|
||||
'rotation.</li><li>Aldric, for handling a difficult report quietly and well.</li>' +
|
||||
'</ol>',
|
||||
published: true,
|
||||
},
|
||||
{
|
||||
category: 'newsletter',
|
||||
title: 'Monthly notes — what changed, and what is next',
|
||||
excerpt:
|
||||
'A month of changes in one place: the vendor search, the new guides, and what we ' +
|
||||
'are working on next.',
|
||||
body:
|
||||
'<p>A quiet, productive month. The vendor search shipped, the wiki gained four ' +
|
||||
'guides, and the guild boards now update on the site within a minute of a change in ' +
|
||||
'game.</p><p>Next month we are looking at the champion boards and at making the ' +
|
||||
'atlas easier to read on a phone.</p>',
|
||||
published: true,
|
||||
},
|
||||
];
|
||||
|
||||
// ── The wiki ───────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// One category and four pages, because the wiki index screenshot needs a category with
|
||||
// enough in it to look like a wiki rather than a placeholder.
|
||||
const WIKI_CATEGORY = {
|
||||
slug: 'guides',
|
||||
title: 'Guides',
|
||||
description: 'How things work here, written by the people who play here.',
|
||||
};
|
||||
|
||||
const WIKI_PAGES = [
|
||||
{
|
||||
slug: 'getting-started',
|
||||
title: 'Getting started',
|
||||
excerpt: 'What to install, how to connect, and the first hour.',
|
||||
body:
|
||||
'<h2>Before you connect</h2><p>You need a game client and an account. Make the ' +
|
||||
'account on this site — the shard accepts accounts created here, and it saves you ' +
|
||||
'typing your password into a chat window.</p><h2>The first hour</h2><p>Start in ' +
|
||||
'town, take the newcomer quest, and do not sell your starting tools. If you get ' +
|
||||
'stuck, ask in Discord: somebody is usually around.</p>',
|
||||
},
|
||||
{
|
||||
slug: 'player-vendors',
|
||||
title: 'Player vendors',
|
||||
excerpt: 'How to hire one, how to price, and how the site search finds you.',
|
||||
body:
|
||||
'<h2>Hiring a vendor</h2><p>Any house you own or co-own can hold vendors. Hire one ' +
|
||||
'from an innkeeper and place it inside.</p><h2>Being findable</h2><p>Everything a ' +
|
||||
'vendor holds is published to the marketplace on this site within about a minute, ' +
|
||||
'including the price and the house it stands in. If a listing looks stale, the ' +
|
||||
'shard was probably down when you priced it — it will correct itself on the next ' +
|
||||
'sweep.</p>',
|
||||
},
|
||||
{
|
||||
slug: 'housing-and-decay',
|
||||
title: 'Housing and decay',
|
||||
excerpt: 'Placement rules, the decay timer, and what IDOC actually means here.',
|
||||
body:
|
||||
'<h2>Placement</h2><p>Houses can be placed anywhere the client allows, with the ' +
|
||||
'usual clearance rules. There is no lottery.</p><h2>Decay</h2><p>A house decays if ' +
|
||||
'nobody with access logs in for long enough. The site lists houses approaching ' +
|
||||
'collapse on the housing page, which is the same data the game uses — not a ' +
|
||||
'prediction.</p>',
|
||||
},
|
||||
{
|
||||
slug: 'community-rules',
|
||||
title: 'Community rules',
|
||||
excerpt: 'The short version: do not be the reason somebody stops playing.',
|
||||
body:
|
||||
'<h2>The rules</h2><ol><li>No harassment, in game or on the site.</li><li>No ' +
|
||||
'exploiting bugs — report them instead, and you will usually be thanked in ' +
|
||||
'public.</li><li>One account per person for events with prizes.</li></ol>' +
|
||||
'<h2>Appeals</h2><p>Every moderation action can be appealed from your account page. ' +
|
||||
'An appeal is read by somebody who was not involved in the original action.</p>',
|
||||
},
|
||||
];
|
||||
|
||||
// ── HTTP plumbing ──────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// One cookie jar, because the session is a cookie and `fetch` has no jar of its own. Only
|
||||
// the value of the auth cookie matters, so this keeps exactly that.
|
||||
|
||||
let cookie = '';
|
||||
let created = 0;
|
||||
let existed = 0;
|
||||
|
||||
function keepCookies(res) {
|
||||
const raw = res.headers.getSetCookie?.() ?? [];
|
||||
for (const line of raw) {
|
||||
const [pair] = line.split(';');
|
||||
if (pair.trim()) cookie = pair.trim();
|
||||
}
|
||||
}
|
||||
|
||||
async function call(method, path, body) {
|
||||
const res = await fetch(`${API}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(cookie ? { Cookie: cookie } : {}),
|
||||
},
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
keepCookies(res);
|
||||
const text = await res.text();
|
||||
let data = null;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
data = text;
|
||||
}
|
||||
return { ok: res.ok, status: res.status, data };
|
||||
}
|
||||
|
||||
function say(mark, what) {
|
||||
console.log(` ${mark} ${what}`);
|
||||
if (mark === '+') created += 1;
|
||||
if (mark === '=') existed += 1;
|
||||
}
|
||||
|
||||
function fail(what, res) {
|
||||
console.error(`\n ! ${what} failed — HTTP ${res.status}`);
|
||||
console.error(` ${JSON.stringify(res.data)?.slice(0, 400)}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
// ── The steps ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function login() {
|
||||
const res = await call('POST', '/auth/login', { username: ADMIN_USER, password: ADMIN_PASS });
|
||||
if (!res.ok) {
|
||||
console.error(
|
||||
`\nCould not log in as "${ADMIN_USER}". Create the admin first, from the website repo:\n` +
|
||||
` cd website/server && DB_NAME=<demo db> ADMIN_USERNAME=${ADMIN_USER} ` +
|
||||
`ADMIN_PASSWORD='…' node db/seed.js\n`,
|
||||
);
|
||||
fail('login', res);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`\nsigned in as ${ADMIN_USER} at ${BASE}`);
|
||||
}
|
||||
|
||||
async function settings() {
|
||||
console.log('\nsite settings (D43 — the neutral demo identity)');
|
||||
if (DRY) {
|
||||
for (const [k, v] of Object.entries(SETTINGS)) say('~', `${k} = ${v}`);
|
||||
return;
|
||||
}
|
||||
const res = await call('PUT', '/admin/settings', SETTINGS);
|
||||
if (!res.ok) return fail('settings', res);
|
||||
for (const [k, v] of Object.entries(SETTINGS)) say('+', `${k} = ${String(v).slice(0, 60)}`);
|
||||
}
|
||||
|
||||
async function uoLink() {
|
||||
console.log('\nshard connection (Admin → Shard)');
|
||||
if (!UOLINK_TOKEN) {
|
||||
say('~', 'UOLINK_TOKEN unset — leaving the sidecar config alone');
|
||||
return;
|
||||
}
|
||||
const now = await call('GET', '/admin/uo-link/config');
|
||||
if (now.status === 404) {
|
||||
say('~', 'no /admin/uo-link route — the uo module is not installed');
|
||||
return;
|
||||
}
|
||||
if (
|
||||
now.ok &&
|
||||
now.data?.config?.baseUrl === UOLINK_BASE &&
|
||||
now.data?.config?.protocol === UOLINK_PROTOCOL &&
|
||||
now.data?.config?.enabled
|
||||
) {
|
||||
say('=', `already pointed at ${UOLINK_BASE} (protocol ${UOLINK_PROTOCOL})`);
|
||||
return;
|
||||
}
|
||||
if (DRY) return say('~', `would point the site at ${UOLINK_BASE}`);
|
||||
const res = await call('PUT', '/admin/uo-link/config', {
|
||||
baseUrl: UOLINK_BASE,
|
||||
wsUrl: UOLINK_WS,
|
||||
token: UOLINK_TOKEN,
|
||||
protocol: UOLINK_PROTOCOL,
|
||||
enabled: true,
|
||||
});
|
||||
if (!res.ok) return fail('uo-link config', res);
|
||||
say('+', `pointed at ${UOLINK_BASE} (protocol ${UOLINK_PROTOCOL})`);
|
||||
}
|
||||
|
||||
async function users() {
|
||||
console.log('\naccounts');
|
||||
const list = await call('GET', '/admin/users');
|
||||
if (!list.ok) return fail('list users', list);
|
||||
const rows = Array.isArray(list.data) ? list.data : (list.data?.users ?? []);
|
||||
const have = new Set(rows.map((u) => u.username));
|
||||
for (const user of USERS) {
|
||||
if (have.has(user.username)) {
|
||||
say('=', `${user.username} (${user.role})`);
|
||||
continue;
|
||||
}
|
||||
if (DRY) {
|
||||
say('~', `${user.username} (${user.role})`);
|
||||
continue;
|
||||
}
|
||||
const res = await call('POST', '/admin/users', {
|
||||
username: user.username,
|
||||
password: DEMO_PASS,
|
||||
role: user.role,
|
||||
});
|
||||
if (!res.ok) {
|
||||
fail(`create ${user.username}`, res);
|
||||
continue;
|
||||
}
|
||||
say('+', `${user.username} (${user.role})`);
|
||||
}
|
||||
}
|
||||
|
||||
async function posts() {
|
||||
console.log('\nposts');
|
||||
const list = await call('GET', '/admin/posts');
|
||||
if (!list.ok) return fail('list posts', list);
|
||||
const rows = Array.isArray(list.data) ? list.data : (list.data?.posts ?? []);
|
||||
const have = new Set(rows.map((p) => p.title));
|
||||
for (const post of POSTS) {
|
||||
if (have.has(post.title)) {
|
||||
say('=', `${post.category}: ${post.title}`);
|
||||
continue;
|
||||
}
|
||||
if (DRY) {
|
||||
say('~', `${post.category}: ${post.title}`);
|
||||
continue;
|
||||
}
|
||||
const res = await call('POST', '/admin/posts', post);
|
||||
if (!res.ok) {
|
||||
fail(`create post "${post.title}"`, res);
|
||||
continue;
|
||||
}
|
||||
say('+', `${post.category}: ${post.title}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function wiki() {
|
||||
console.log('\nwiki');
|
||||
const cats = await call('GET', '/admin/wiki/categories');
|
||||
if (!cats.ok) return fail('list wiki categories', cats);
|
||||
const catRows = Array.isArray(cats.data) ? cats.data : (cats.data?.categories ?? []);
|
||||
let category = catRows.find((c) => c.slug === WIKI_CATEGORY.slug);
|
||||
if (category) {
|
||||
say('=', `category ${WIKI_CATEGORY.slug}`);
|
||||
} else if (DRY) {
|
||||
say('~', `category ${WIKI_CATEGORY.slug}`);
|
||||
} else {
|
||||
const res = await call('POST', '/admin/wiki/categories', WIKI_CATEGORY);
|
||||
if (!res.ok) return fail('create wiki category', res);
|
||||
category = res.data?.category ?? res.data;
|
||||
say('+', `category ${WIKI_CATEGORY.slug}`);
|
||||
}
|
||||
|
||||
const pages = await call('GET', '/admin/wiki');
|
||||
if (!pages.ok) return fail('list wiki pages', pages);
|
||||
const pageRows = Array.isArray(pages.data) ? pages.data : (pages.data?.pages ?? []);
|
||||
const have = new Set(pageRows.map((p) => p.slug));
|
||||
for (const page of WIKI_PAGES) {
|
||||
if (have.has(page.slug)) {
|
||||
say('=', `page ${page.slug}`);
|
||||
continue;
|
||||
}
|
||||
if (DRY) {
|
||||
say('~', `page ${page.slug}`);
|
||||
continue;
|
||||
}
|
||||
const res = await call('POST', '/admin/wiki', {
|
||||
...page,
|
||||
category_id: category?.id ?? null,
|
||||
published: true,
|
||||
});
|
||||
if (!res.ok) {
|
||||
fail(`create wiki page "${page.slug}"`, res);
|
||||
continue;
|
||||
}
|
||||
say('+', `page ${page.slug}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── main ───────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
console.log(DRY ? '\nseedDemo — DRY RUN, nothing will be written' : '\nseedDemo');
|
||||
|
||||
await login();
|
||||
await settings();
|
||||
await uoLink();
|
||||
await users();
|
||||
await posts();
|
||||
await wiki();
|
||||
|
||||
console.log(
|
||||
`\n${DRY ? 'would create' : 'created'} ${created}, already present ${existed}` +
|
||||
(process.exitCode ? ' — with failures above' : ''),
|
||||
);
|
||||
console.log(
|
||||
'\nWhat this does NOT seed, on purpose: teams, the marketplace, houses, points boards\n' +
|
||||
'and the atlas. Those arrive from the shard over the bridge (D42) — start the sidecar\n' +
|
||||
'and the shard, and they populate themselves.\n',
|
||||
);
|
||||
@@ -1,23 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Runic Gateway">
|
||||
<!--
|
||||
A placeholder gateway glyph: concentric rings around an open portal, drawn from the
|
||||
emblem's geometry so the header is not empty before phase 2.
|
||||
|
||||
Phase 2 (PLAN.md §7, §11) replaces this with the real derivatives of runic-emblem.png —
|
||||
WebP and AVIF at header, hero and OG sizes, a multi-resolution favicon.ico, the 192/512
|
||||
PWA icons, and the horizontal lockup — all of them in /app/brand-default so the org lead
|
||||
can swap any of them with a file copy.
|
||||
|
||||
Everything is currentColor on purpose: no colour literal, so the mark inherits --gold
|
||||
from the token file and a mounted theme.css recolours it for free.
|
||||
-->
|
||||
<g fill="none" stroke="currentColor" stroke-linecap="round">
|
||||
<circle cx="32" cy="32" r="26" stroke-width="3" opacity="0.95" />
|
||||
<circle cx="32" cy="32" r="20" stroke-width="1.25" opacity="0.55" />
|
||||
<circle cx="32" cy="32" r="12.5" stroke-width="2" opacity="0.9" />
|
||||
<path d="M32 6v9M32 49v9M6 32h9M49 32h9" stroke-width="2.5" opacity="0.8" />
|
||||
<path d="M13.6 13.6l6.4 6.4M44 44l6.4 6.4M50.4 13.6L44 20M20 44l-6.4 6.4"
|
||||
stroke-width="1.25" opacity="0.4" />
|
||||
</g>
|
||||
<circle cx="32" cy="32" r="5.5" fill="currentColor" opacity="0.22" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
41
src/components/DocsHead.astro
Normal file
@@ -0,0 +1,41 @@
|
||||
---
|
||||
import Default from '@astrojs/starlight/components/Head.astro';
|
||||
|
||||
import { brand } from '../lib/brand.mjs';
|
||||
|
||||
/**
|
||||
* Overrides Starlight's `Head` so the documentation carries the same brand wiring as the
|
||||
* marketing pages (§7).
|
||||
*
|
||||
* Starlight builds its own head, and without this the docs were a different site: they
|
||||
* linked `/favicon.svg` — a Starlight default that does not exist here, so every docs page
|
||||
* requested a 404 — carried no manifest, no OG card, and crucially no `/brand/theme.css`,
|
||||
* which meant a mounted theme recoloured the marketing pages and left the documentation
|
||||
* stock. Half a rebrand is arguably worse than none, because it looks like a bug in the
|
||||
* product rather than a step somebody missed.
|
||||
*
|
||||
* Starlight's own `favicon` option handles the .ico (see `astro.config.mjs`); everything
|
||||
* that option cannot express is here.
|
||||
*/
|
||||
---
|
||||
|
||||
<Default><slot /></Default>
|
||||
|
||||
<link rel="icon" href="/brand/favicon-32.png" type="image/png" sizes="32x32" />
|
||||
<link rel="apple-touch-icon" href="/brand/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
|
||||
<meta property="og:image" content={new URL('/brand/og-image.png', Astro.site)} />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:image:alt" content={`${brand.siteName} — ${brand.tagline}`} />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
|
||||
<!--
|
||||
The operator's stylesheet, last (§7). Its position no longer decides whether it wins —
|
||||
`tokens.css` lives in `@layer tokens` and this file is unlayered, so it takes precedence
|
||||
wherever the browser encounters it. That is deliberate: Astro emits its bundled
|
||||
stylesheets after the head markup, and an ordering-based mechanism silently stopped
|
||||
working the moment it did.
|
||||
-->
|
||||
<link rel="stylesheet" href="/brand/theme.css" />
|
||||
@@ -1,25 +1,28 @@
|
||||
---
|
||||
import Mark from '../assets/placeholder-mark.svg?raw';
|
||||
import { brand } from '../lib/brand.mjs';
|
||||
|
||||
/**
|
||||
* Overrides Starlight's `SiteTitle` so the documentation header carries the same lockup as
|
||||
* the marketing header. One product, two chromes, one mark.
|
||||
*
|
||||
* It exists because Starlight's `logo` option renders an `<img>`, and our mark is an
|
||||
* inline-only asset: it is drawn in `currentColor` so it inherits `--gold` and follows a
|
||||
* bind-mounted `theme.css` for free (§7). An SVG loaded through `<img>` is an independent
|
||||
* document — `currentColor` has nothing to inherit from there, and the mark renders black
|
||||
* on black. Inlining it is what makes the token reach the artwork.
|
||||
*
|
||||
* Phase 2 replaces the placeholder with the real emblem derivatives; this component keeps
|
||||
* working, because what it needs is markup rather than a file.
|
||||
* The override still earns its place now that the mark is a raster image and Starlight's
|
||||
* own `logo` option would also render an `<img>`: that option takes an asset IMPORTED
|
||||
* through Vite, which fingerprints the filename into the build. A fingerprinted logo is one
|
||||
* the bind mount can never replace (§7), which is the whole point of `/brand/*`. Pointing
|
||||
* at the stable URL is what keeps the docs header swappable along with everything else.
|
||||
*/
|
||||
const { siteTitle, siteTitleHref } = Astro.locals.starlightRoute;
|
||||
---
|
||||
|
||||
<a href={siteTitleHref} class="site-title sl-flex">
|
||||
<span class="docs-mark" set:html={Mark} aria-hidden="true" />
|
||||
<img
|
||||
class="docs-mark"
|
||||
src="/brand/logo-32.webp"
|
||||
srcset="/brand/logo-32.webp 1x, /brand/logo-64.webp 2x, /brand/logo-96.webp 3x"
|
||||
width="32"
|
||||
height="32"
|
||||
alt=""
|
||||
/>
|
||||
<span translate="no">{siteTitle || brand.siteName}</span>
|
||||
</a>
|
||||
|
||||
@@ -37,15 +40,10 @@ const { siteTitle, siteTitleHref } = Astro.locals.starlightRoute;
|
||||
}
|
||||
|
||||
.docs-mark {
|
||||
display: inline-flex;
|
||||
display: block;
|
||||
flex: none;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
:global(:root[data-theme='light']) .docs-mark {
|
||||
color: var(--light-gold);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
span:last-child {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
import { brand } from '../lib/brand.mjs';
|
||||
import { legal } from '../data/legal.mjs';
|
||||
import platform from '../data/platform.json';
|
||||
|
||||
/**
|
||||
@@ -7,9 +8,10 @@ import platform from '../data/platform.json';
|
||||
* memory. The version chip reads `platform.json` (§12); the contact address and the links
|
||||
* read `brand.json` (§7, D13).
|
||||
*
|
||||
* `/privacy` and `/terms` are linked from every page (§9) — those pages land in phase 6,
|
||||
* which is why they are the only two entries deliberately left out of the columns below
|
||||
* until then.
|
||||
* `/privacy` and `/terms` are linked from every page (§9). Phase 6 built them and put them
|
||||
* in the legal bar at the foot rather than in the columns: a legal link is not a thing a
|
||||
* reader browses to alongside Features, it is a thing they go looking for, and the line
|
||||
* that already carries the licence and the copyright is where people look.
|
||||
*/
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
@@ -71,9 +73,12 @@ const isExternal = (href: string) => href.startsWith('http');
|
||||
<div class="site-footer__legal">
|
||||
<p>
|
||||
{brand.siteName} is free software under the{' '}
|
||||
<a href="https://www.gnu.org/licenses/gpl-3.0.html" rel="noopener noreferrer"
|
||||
>GPL-3.0-or-later</a
|
||||
>. © {year}.
|
||||
<a href={legal.licence.url} rel="noopener noreferrer">{legal.licence.id}</a>. ©
|
||||
{' '}{year}.
|
||||
<span class="site-footer__links">
|
||||
<a href="/privacy/">Privacy</a>
|
||||
<a href="/terms/">Terms</a>
|
||||
</span>
|
||||
</p>
|
||||
<p class="site-footer__meta">
|
||||
<span class="chip chip--version">Protocol {platform.protocol}</span>
|
||||
@@ -84,6 +89,16 @@ const isExternal = (href: string) => href.startsWith('http');
|
||||
</footer>
|
||||
|
||||
<style>
|
||||
/* Sits on the licence line rather than in a column of its own — see the header. The
|
||||
separator is a border so it never appears at the start of a wrapped line. */
|
||||
.site-footer__links {
|
||||
display: inline-flex;
|
||||
gap: 0.9rem;
|
||||
margin-left: 0.9rem;
|
||||
padding-left: 0.9rem;
|
||||
border-left: 1px solid var(--line-soft);
|
||||
}
|
||||
|
||||
.site-footer__meta {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
---
|
||||
import { brand } from '../lib/brand.mjs';
|
||||
import Mark from '../assets/placeholder-mark.svg?raw';
|
||||
|
||||
/**
|
||||
* The marketing header. The docs get Starlight's own header, themed to match in
|
||||
* `src/styles/starlight.css` — one site, two chromes, the same lockup.
|
||||
*
|
||||
* The mark is the product's real emblem (D11), served from the brand mount rather than
|
||||
* imported: the same artwork as the website's own logo and the Android launcher icon, so
|
||||
* the three surfaces read as one product. Phase 1's placeholder glyph is gone.
|
||||
*
|
||||
* It is an `<img>`, not an inline SVG, and that costs something worth naming. The emblem is
|
||||
* raster illustration, so a mounted `theme.css` cannot recolour it the way it recolours
|
||||
* everything else — replacing the mark means replacing `logo.png`. That is the trade D11
|
||||
* makes: a mark that already carries recognition, against a simpler one that would follow
|
||||
* the palette.
|
||||
*
|
||||
* The nav names the routes §10 specifies. Phase 3 onwards fills them in; a link added
|
||||
* here before its page exists fails `checkLinks.mjs`, which is the order we want.
|
||||
*/
|
||||
@@ -25,7 +34,15 @@ const isCurrent = (href: string) =>
|
||||
<header class="site-header">
|
||||
<div class="page site-header__inner">
|
||||
<a class="brand-lockup" href="/">
|
||||
<span class="brand-lockup__mark" set:html={Mark} />
|
||||
<img
|
||||
class="brand-lockup__mark"
|
||||
src="/brand/logo-40.webp"
|
||||
srcset="/brand/logo-40.webp 1x, /brand/logo-80.webp 2x, /brand/logo-120.webp 3x"
|
||||
width="40"
|
||||
height="40"
|
||||
alt=""
|
||||
fetchpriority="high"
|
||||
/>
|
||||
<span class="brand-lockup__name">{brand.siteName}</span>
|
||||
</a>
|
||||
|
||||
@@ -42,8 +59,9 @@ const isCurrent = (href: string) =>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
/* `alt=""` above is deliberate: the mark sits beside the site name in the same link, so
|
||||
announcing it would make a screen reader say the product's name twice. */
|
||||
.brand-lockup__mark {
|
||||
display: inline-flex;
|
||||
color: var(--gold);
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
|
||||
132
src/components/NotBuilt.astro
Normal file
@@ -0,0 +1,132 @@
|
||||
---
|
||||
import { notBuiltFor, assertScopeNonEmpty } from '../data/notBuilt.mjs';
|
||||
|
||||
/**
|
||||
* The deliberate absences (PLAN.md §2, D22), rendered for one page's scope.
|
||||
*
|
||||
* §2 describes its absent-features list as "as load-bearing as the rest", and this is the
|
||||
* component that makes that true on a page rather than in a plan. It reads the shared list
|
||||
* so `/features/`, `/integrations/` and `/modules/` cannot drift into telling three
|
||||
* different stories about the same six things.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY IT LOOKS LIKE THE REST OF THE PAGE
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* Not a warning box, not a muted footnote, not an accordion. D8's "understated honesty" is
|
||||
* a house style with a specific consequence here: a section that is visually apologetic
|
||||
* teaches a reader that absences are embarrassing, and a section that is visually hidden
|
||||
* teaches them to go looking for the ones you did not mention. These are decisions with
|
||||
* reasons, so they are set as decisions with reasons — the same panels as everything else,
|
||||
* in the same place in the rhythm.
|
||||
*
|
||||
* The one visual difference is the `resolvedBy` line, which every entry carries. An absence
|
||||
* with an exit condition is a position; an absence without one is a hole. D8 gives the
|
||||
* Integration Kit's draft status a defined removal condition and this generalises it.
|
||||
*/
|
||||
interface Props {
|
||||
/** Which page is asking: `features`, `integrations` or `modules`. */
|
||||
scope: string;
|
||||
/** Section heading. Each page frames the same list for its own reader. */
|
||||
title: string;
|
||||
}
|
||||
|
||||
const { scope, title } = Astro.props;
|
||||
|
||||
assertScopeNonEmpty(scope);
|
||||
const entries = notBuiltFor(scope);
|
||||
---
|
||||
|
||||
<section class="page section notbuilt">
|
||||
<p class="eyebrow">Not built</p>
|
||||
<h2>{title}</h2>
|
||||
<p class="prose notbuilt__lede">
|
||||
Every one of these is a decision rather than a backlog item, so each says why. Where the
|
||||
reasoning was written down in the open, it is linked.
|
||||
</p>
|
||||
|
||||
<ul class="notbuilt__grid">
|
||||
{
|
||||
entries.map((entry) => (
|
||||
<li class="panel notbuilt__item">
|
||||
<h3>{entry.title}</h3>
|
||||
<p class="notbuilt__body">{entry.body}</p>
|
||||
<p class="notbuilt__resolved">
|
||||
<span class="notbuilt__resolved-label">What would change it</span>
|
||||
{entry.resolvedBy}
|
||||
</p>
|
||||
{entry.link && (
|
||||
<p class="notbuilt__link">
|
||||
<a href={entry.link.href} rel="noopener noreferrer">
|
||||
{entry.link.label}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.notbuilt h2 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: clamp(1.6rem, 3.2vw, 2.1rem);
|
||||
}
|
||||
|
||||
.notbuilt__lede {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.notbuilt__grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin: 2.25rem 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr));
|
||||
}
|
||||
|
||||
.notbuilt__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.notbuilt__item h3 {
|
||||
margin: 0 0 0.6rem;
|
||||
color: var(--gold);
|
||||
font-size: 1.02rem;
|
||||
}
|
||||
|
||||
/* Takes the slack, so the exit condition sits at the foot of every card in a row
|
||||
rather than immediately under a body of whatever length — the same kind of
|
||||
statement in the same place on each, which is what makes them readable as a row. */
|
||||
.notbuilt__body {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.94rem;
|
||||
}
|
||||
|
||||
.notbuilt__resolved {
|
||||
margin: 1rem 0 0;
|
||||
padding-top: 0.85rem;
|
||||
border-top: 1px solid var(--line-soft);
|
||||
color: var(--dim);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.notbuilt__resolved-label {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.11em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.notbuilt__link {
|
||||
margin: 0.85rem 0 0;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
</style>
|
||||
55
src/components/PageHeader.astro
Normal file
@@ -0,0 +1,55 @@
|
||||
---
|
||||
/**
|
||||
* The opening of every marketing page except the homepage — eyebrow, `<h1>`, lede.
|
||||
*
|
||||
* A component rather than four copies of the same three elements, because phase 4 writes
|
||||
* five pages and phases 5 and 6 write four more. The homepage is deliberately not one of
|
||||
* them: its `<h1>` is the tagline inside the hero, set against the emblem, and pulling that
|
||||
* into a shared header would either flatten the hero or push its layout in here (D19).
|
||||
*
|
||||
* The `<h1>` is the page's own name, not the product's, and `Base` appends the site name to
|
||||
* the document title — so a page sets a short `title` and gets "Features — Runic Gateway"
|
||||
* in the tab and "Features" on the page.
|
||||
*/
|
||||
interface Props {
|
||||
/** Small uppercase line above the title. What kind of page this is. */
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const { eyebrow, title } = Astro.props;
|
||||
---
|
||||
|
||||
<header class="page section pagehead">
|
||||
<p class="eyebrow">{eyebrow}</p>
|
||||
<h1>{title}</h1>
|
||||
<div class="prose pagehead__lede">
|
||||
<slot />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
/* The section rhythm gives generous space below; the header wants less, because the
|
||||
first section under it is part of the same thought. */
|
||||
.pagehead {
|
||||
padding-bottom: clamp(1rem, 2.5vw, 1.75rem);
|
||||
}
|
||||
|
||||
.pagehead h1 {
|
||||
margin: 0 0 1rem;
|
||||
font-size: clamp(2rem, 5vw, 2.9rem);
|
||||
}
|
||||
|
||||
.pagehead__lede {
|
||||
color: var(--muted);
|
||||
font-size: 1.06rem;
|
||||
}
|
||||
|
||||
.pagehead__lede :global(p) {
|
||||
margin: 0 0 0.85rem;
|
||||
}
|
||||
|
||||
.pagehead__lede :global(p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
81
src/components/Screenshot.astro
Normal file
@@ -0,0 +1,81 @@
|
||||
---
|
||||
import { screenById, WEB, PHONE } from '../data/screens.mjs';
|
||||
|
||||
/**
|
||||
* One screenshot, as a figure with its caption. PLAN.md §13 phase 9, D4 / D44.
|
||||
*
|
||||
* -----------------------------------------------------------------------------------------
|
||||
* WHY THE PAGE PASSES AN ID AND NOTHING ELSE
|
||||
* -----------------------------------------------------------------------------------------
|
||||
* A marketing page and a documentation page show the same administration screen for
|
||||
* different reasons, and the thing they must not do is describe it differently. The alt
|
||||
* text and the caption therefore live with the capture in `screens.mjs`, next to the route
|
||||
* they came from, and a page asks for `admin-shard` rather than restating what is in it.
|
||||
*
|
||||
* It also means a re-capture cannot silently invalidate a caption: the sentence and the
|
||||
* frame it describes are edited in the same file.
|
||||
*
|
||||
* -----------------------------------------------------------------------------------------
|
||||
* WHY IT FAILS THE BUILD ON AN UNKNOWN ID
|
||||
* -----------------------------------------------------------------------------------------
|
||||
* The alternative is a page that renders a broken image, which looks like a deployment
|
||||
* problem rather than a typo and survives review. `checkScreens.mjs` covers the other
|
||||
* direction — a declared screen whose file is missing — so between them a screenshot is
|
||||
* either complete or the build stops.
|
||||
*/
|
||||
interface Props {
|
||||
/** An `id` from `src/data/screens.mjs`. */
|
||||
id: string;
|
||||
/** Suppress the caption where the surrounding prose already says it. */
|
||||
bare?: boolean;
|
||||
}
|
||||
|
||||
const { id, bare = false } = Astro.props;
|
||||
|
||||
const shot = screenById(id);
|
||||
|
||||
if (!shot) {
|
||||
throw new Error(`Screenshot "${id}" is not declared in src/data/screens.mjs`);
|
||||
}
|
||||
|
||||
const src = `/screens/${shot.id}.webp`;
|
||||
|
||||
// Intrinsic size comes from the family rather than the entry: every capture in a family is
|
||||
// taken at one geometry (see screens.mjs), and `checkScreens.mjs` asserts the files really
|
||||
// are that size, so these attributes cannot drift from the pixels.
|
||||
const { width, height } = shot.family === 'web' ? WEB : PHONE;
|
||||
---
|
||||
|
||||
<figure class="shot">
|
||||
<img
|
||||
src={src}
|
||||
alt={shot.alt}
|
||||
width={width}
|
||||
height={height}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
{!bare && <figcaption>{shot.caption}</figcaption>}
|
||||
</figure>
|
||||
|
||||
<style>
|
||||
.shot {
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.shot img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.shot figcaption {
|
||||
margin: 0.85rem 0 0;
|
||||
color: var(--dim);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
99
src/components/app/Screenshots.astro
Normal file
@@ -0,0 +1,99 @@
|
||||
---
|
||||
import { screensOf, PHONE } from '../../data/screens.mjs';
|
||||
|
||||
/**
|
||||
* The app's screenshot strip. Reserved in phase 5 (D26), filled in phase 9.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY THIS COMPONENT EXISTED FOR A PHASE WITH NOTHING IN IT
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* PLAN.md §10 said `/app/` shows "the 14 existing screenshots". They exist —
|
||||
* `docs/android/screenshots/` on the `docs` repository — and they are the wrong fourteen: a
|
||||
* trusted-device and recovery-code smoke test from 2026-07-22, captured against a
|
||||
* development instance with no seeded content, before the theming work that changed how
|
||||
* every screen looks. Five of them are two-factor prompts. The home shot is an empty page.
|
||||
*
|
||||
* Shipping them would have broken D4 and §1 at once, so D26 reserved the slot for the phase
|
||||
* that stands up the review stack anyway. The shape was defined then and the data arrived
|
||||
* now, which is exactly what it was for: filling it was a data change.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY IT READS screens.mjs RATHER THAN HOLDING ITS OWN LIST
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* The draft carried its own `shots` array, written before there was anywhere else to put
|
||||
* one. There is now: `src/data/screens.mjs` holds every capture the site ships, web and
|
||||
* phone alike, and `scripts/checkScreens.mjs` proves each one exists at the size the markup
|
||||
* claims. A second list here would be the one nothing checks.
|
||||
*
|
||||
* The phone captures come from an emulator pointed at the same seeded deployment the web
|
||||
* screenshots were taken from, on the same day — which is the property D26 was really
|
||||
* after, since the app takes its colours, type and navigation from the site it connects to.
|
||||
*/
|
||||
|
||||
const shots = screensOf('phone');
|
||||
---
|
||||
|
||||
{
|
||||
shots.length > 0 && (
|
||||
<section class="page section shots">
|
||||
<h2>What it looks like</h2>
|
||||
<p class="prose shots__lede">
|
||||
Captured against a real deployment with real content, not mocked up. The app takes its
|
||||
colours, type and navigation from the site it is connected to, so these show one
|
||||
community's app rather than a neutral one.
|
||||
</p>
|
||||
|
||||
<ul class="shots__grid">
|
||||
{shots.map((shot) => (
|
||||
<li class="shots__item">
|
||||
<img
|
||||
src={`/screens/${shot.id}.webp`}
|
||||
alt={shot.alt}
|
||||
width={PHONE.width}
|
||||
height={PHONE.height}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<p class="shots__caption">{shot.caption}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
<style>
|
||||
.shots h2 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: clamp(1.6rem, 3.2vw, 2.1rem);
|
||||
}
|
||||
|
||||
.shots__lede {
|
||||
margin: 0 0 2.25rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.shots__grid {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr));
|
||||
}
|
||||
|
||||
.shots__item img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.shots__caption {
|
||||
margin: 0.85rem 0 0;
|
||||
color: var(--dim);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
</style>
|
||||
142
src/components/architecture/Allowlist.astro
Normal file
@@ -0,0 +1,142 @@
|
||||
---
|
||||
/**
|
||||
* "What reaches the public" — the second of `/architecture/`'s three diagrams (D21).
|
||||
*
|
||||
* The homepage states the split in one sentence inside the data-path walk ("a public one
|
||||
* carrying an allowlist of safe events, and a staff-only one carrying the rest… that split
|
||||
* is a security boundary, not a preference"). This is the page where that sentence has to
|
||||
* become a picture, because it is the single design decision a technical evaluator is most
|
||||
* entitled to be suspicious of: a live feed of a game world contains things that must never
|
||||
* be published, and "we filter it" is a claim, not a mechanism.
|
||||
*
|
||||
* So the diagram draws the shape of the mechanism — one stream in, one decision, two streams
|
||||
* out — and the notes say where the decision lives and what happens when it is wrong in
|
||||
* either direction. What it deliberately does NOT do is enumerate event kinds: that is the
|
||||
* catalog's job in the docs, it changes with the protocol, and a marketing page holding a
|
||||
* copy of it would be a copy that goes stale (§1).
|
||||
*
|
||||
* The rings sit behind the filter rather than behind the whole picture, on the phase-3
|
||||
* principle that they mark the one place the argument actually happens.
|
||||
*/
|
||||
---
|
||||
|
||||
<section class="page section diagram" id="allowlist">
|
||||
<div class="diagram__head">
|
||||
<p class="eyebrow">What reaches the public</p>
|
||||
<h2>One feed in, two feeds out</h2>
|
||||
<p class="prose">
|
||||
A live game world emits things that are fine on a front page and things that are not:
|
||||
who logged in from which address, what the cheat detector flagged, what a staff member
|
||||
did to whom. Both arrive on the same connection, so something has to divide them.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="diagram__body">
|
||||
<div class="diagram__figure">
|
||||
<svg viewBox="0 0 380 470" class="flow" aria-hidden="true" focusable="false">
|
||||
<!-- Centred on the filter: the one place in the picture where the argument is. -->
|
||||
<g class="rings">
|
||||
<circle cx="190" cy="178" r="96" />
|
||||
<circle cx="190" cy="178" r="136" />
|
||||
<circle cx="190" cy="178" r="176" />
|
||||
</g>
|
||||
|
||||
<rect class="node" x="20" y="12" width="340" height="60" rx="10" />
|
||||
<text class="node-title" x="40" y="38">Everything the game emits</text>
|
||||
<text class="node-sub" x="40" y="58">one authenticated stream, from the sidecar</text>
|
||||
|
||||
<path class="spine spine--live" d="M190 80 V132" />
|
||||
<path class="arrow arrow--live" d="M190 140 l-6 -10 h12 Z" />
|
||||
|
||||
<rect class="node node--self" x="20" y="142" width="340" height="72" rx="10" />
|
||||
<text class="node-title" x="40" y="172">Your site decides</text>
|
||||
<text class="node-sub" x="40" y="192">one allowlist, in one place, on your server</text>
|
||||
|
||||
<!-- Diverging: the public leg in cyan because it is still a live feed; the staff
|
||||
leg in gold because it is the privileged one. -->
|
||||
<path class="spine spine--live" d="M120 222 C120 268 96 268 96 306" />
|
||||
<path class="arrow arrow--live" d="M96 314 l-6 -10 h12 Z" />
|
||||
|
||||
<path class="spine" d="M260 222 C260 268 284 268 284 306" />
|
||||
<path class="arrow" d="M284 314 l-6 -10 h12 Z" />
|
||||
|
||||
<rect class="node" x="8" y="316" width="176" height="128" rx="10" />
|
||||
<text class="node-title" x="26" y="344">Public pages</text>
|
||||
<text class="node-sub" x="26" y="366">an allowlist of event</text>
|
||||
<text class="node-sub" x="26" y="382">kinds, and nothing</text>
|
||||
<text class="node-sub" x="26" y="398">outside it</text>
|
||||
<text class="node-audience" x="26" y="424">anyone at all</text>
|
||||
|
||||
<rect class="node" x="196" y="316" width="176" height="128" rx="10" />
|
||||
<text class="node-title" x="214" y="344">Staff console</text>
|
||||
<text class="node-sub" x="214" y="366">the rest: audit trail,</text>
|
||||
<text class="node-sub" x="214" y="382">login attempts,</text>
|
||||
<text class="node-sub" x="214" y="398">addresses, cheat flags</text>
|
||||
<text class="node-audience" x="214" y="424">signed-in staff only</text>
|
||||
</svg>
|
||||
|
||||
<p class="diagram__caption">
|
||||
The allowlist is the security boundary. A new kind of event is invisible to the public
|
||||
until somebody adds it, which is the safe direction to fail in.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="diagram__notes">
|
||||
<section>
|
||||
<h3>It is an allowlist, not a blocklist</h3>
|
||||
<p>
|
||||
The public stream carries the kinds of event that are named as safe; everything else
|
||||
goes to the staff stream by default. That ordering is the whole point. A blocklist
|
||||
fails open — the day the game emits something new, it is already published — and an
|
||||
allowlist fails closed, so the worst case is a page that is missing something rather
|
||||
than a page that has published an address.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>The decision lives on your server</h3>
|
||||
<p>
|
||||
Not in the sidecar and not in the game. The bridge is a deliberately dumb forwarder:
|
||||
it moves what the game emits and makes no judgements about audience. Everything
|
||||
about who may see what is decided by the site you run, in one place, where you can
|
||||
read it — and where changing it does not mean redeploying anything on the game host.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>More than two audiences, in practice</h3>
|
||||
<p>
|
||||
Two streams is the transport. Above it sits a configurable audience model — logged
|
||||
out, signed in, linked to a game account, staff — that decides how much of a given
|
||||
surface each of those sees. The public stream is the floor of that, and it is the
|
||||
one that is a boundary rather than a setting.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>When the game is down</h3>
|
||||
<p>
|
||||
Nothing arrives, and the site carries on. Live surfaces say the server is offline
|
||||
and everything that does not depend on it — the wiki, the news, accounts, the forums
|
||||
— is unaffected. A site that goes down with the game it reports on is not much of a
|
||||
status page.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
/* Each outcome node ends with a line naming its audience, set apart from the
|
||||
description above it rather than reading as another line of it.
|
||||
|
||||
Its own class, not `:nth-last-of-type`: an index into a list of `<text>`
|
||||
siblings is correct only until somebody adds a label, and it fails by
|
||||
styling the wrong words rather than by failing. */
|
||||
.node-audience {
|
||||
fill: var(--muted);
|
||||
font-family: var(--sans);
|
||||
font-size: 11.5px;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
166
src/components/architecture/ModuleSeam.astro
Normal file
@@ -0,0 +1,166 @@
|
||||
---
|
||||
import platform from '../../data/platform.json';
|
||||
|
||||
/**
|
||||
* "Where the game stops and the platform starts" — the third of `/architecture/`'s diagrams
|
||||
* (D21).
|
||||
*
|
||||
* The other two draw runtime shapes. This one draws a code boundary, and it is here because
|
||||
* it is the claim the whole project rests on: that a community platform can be built once
|
||||
* and pointed at any game. An evaluator has every reason to read that as marketing, so the
|
||||
* page draws the seam and then says plainly what does and does not prove it — one module
|
||||
* exists, the second is a paper exercise, and the exit criterion for calling the contract
|
||||
* proven is written down (§2, and the entries `/modules/` renders from `notBuilt.mjs`).
|
||||
*
|
||||
* The Module API version is read from `platform.json` like every other number on this site
|
||||
* (§12). It is the one place a version genuinely belongs in this diagram: the seam is
|
||||
* literally a version check, and a module whose declared range does not match refuses to
|
||||
* load rather than half-loading.
|
||||
*/
|
||||
---
|
||||
|
||||
<section class="page section diagram" id="module-seam">
|
||||
<div class="diagram__head">
|
||||
<p class="eyebrow">Where the game stops</p>
|
||||
<h2>A seam, with a version on it</h2>
|
||||
<p class="prose">
|
||||
The core site does not know what a shard is, what a guild is, or that Ultima Online
|
||||
exists. Everything that does lives in an installable module on the other side of a
|
||||
declared interface — which is what makes "put your game on it" a shape rather than a
|
||||
slogan.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="diagram__body">
|
||||
<div class="diagram__figure">
|
||||
<svg viewBox="0 0 380 500" class="flow" aria-hidden="true" focusable="false">
|
||||
<!-- Core: what ships in the image, on every deployment, module or not. -->
|
||||
<rect class="host" x="8" y="8" width="364" height="186" rx="14" />
|
||||
<text class="host-title" x="28" y="42">Runic Gateway core</text>
|
||||
<text class="host-sub" x="28" y="62">game-agnostic; the same image everywhere</text>
|
||||
|
||||
<rect class="node node--self" x="28" y="80" width="156" height="46" rx="10" />
|
||||
<text class="node-title" x="46" y="108">Accounts</text>
|
||||
|
||||
<rect class="node node--self" x="196" y="80" width="156" height="46" rx="10" />
|
||||
<text class="node-title" x="214" y="108">Teams</text>
|
||||
|
||||
<rect class="node node--self" x="28" y="134" width="156" height="46" rx="10" />
|
||||
<text class="node-title" x="46" y="162">Wiki and posts</text>
|
||||
|
||||
<rect class="node node--self" x="196" y="134" width="156" height="46" rx="10" />
|
||||
<text class="node-title" x="214" y="162">Admin and API</text>
|
||||
|
||||
<!-- The seam. Both boundary lines and the label between them: this is the one
|
||||
thing in the picture that is neither core nor module. -->
|
||||
<path class="boundary" d="M8 224 H372" />
|
||||
<text class="seam-label" x="190" y="252" text-anchor="middle">
|
||||
Module API {platform.moduleApi}
|
||||
</text>
|
||||
<path class="boundary" d="M8 272 H372" />
|
||||
|
||||
<!-- Registers upward; is asked downward. Two arrows, opposite directions, because
|
||||
the traffic across a seam is not one-way and drawing it as one-way is what
|
||||
makes people think a module is a plugin that only listens. -->
|
||||
<path class="spine" d="M120 300 V206" />
|
||||
<path class="arrow" d="M120 198 l-6 10 h12 Z" />
|
||||
<text class="seam-arrow" x="136" y="216">registers</text>
|
||||
|
||||
<path class="spine" d="M260 200 V294" />
|
||||
<path class="arrow" d="M260 302 l-6 -10 h12 Z" />
|
||||
<text class="seam-arrow" x="244" y="290" text-anchor="end">calls</text>
|
||||
|
||||
<!-- The module: everything that knows a game exists. -->
|
||||
<rect class="host" x="8" y="306" width="364" height="186" rx="14" />
|
||||
<text class="host-title" x="28" y="340">Game module</text>
|
||||
<text class="host-sub" x="28" y="360">one per deployment; UO today</text>
|
||||
|
||||
<rect class="node" x="28" y="378" width="156" height="46" rx="10" />
|
||||
<text class="node-title" x="46" y="406">Routes</text>
|
||||
|
||||
<rect class="node" x="196" y="378" width="156" height="46" rx="10" />
|
||||
<text class="node-title" x="214" y="406">Screens</text>
|
||||
|
||||
<rect class="node" x="28" y="432" width="156" height="46" rx="10" />
|
||||
<text class="node-title" x="46" y="460">Its own tables</text>
|
||||
|
||||
<rect class="node" x="196" y="432" width="156" height="46" rx="10" />
|
||||
<text class="node-title" x="214" y="460">Nav rows</text>
|
||||
</svg>
|
||||
|
||||
<p class="diagram__caption">
|
||||
A module declares which versions of the interface it speaks. If that does not match
|
||||
what the site offers, it refuses to load and the site comes up without it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="diagram__notes">
|
||||
<section>
|
||||
<h3>The module brings its own everything</h3>
|
||||
<p>
|
||||
Not just screens: its routes, its database tables, its navigation rows, its slice of
|
||||
the OpenAPI spec and its own prebuilt client bundle. Installing it is a paste in the
|
||||
admin panel or a line in your environment — never a build step, because production
|
||||
runs an image you pulled, and an operator who has to compile something has been
|
||||
handed a maintenance job rather than a feature.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Failure is contained by design</h3>
|
||||
<p>
|
||||
A module that will not load is marked as failed and the site starts without it.
|
||||
Disabling one is a kill switch, not a visibility flag — its routes stop answering
|
||||
and its live connections close. Uninstalling keeps the data, and destroying the data
|
||||
is a separate, deliberate choice made in its own dialog.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Teams is the shape of the contract</h3>
|
||||
<p>
|
||||
Core owns the Teams primitive — the roster, the forum, the notifications, the voice
|
||||
channel — and does not own the <em>word</em>. A Team cannot be created in core at
|
||||
all; it arrives from the module, which is why the UO module calls them guilds and
|
||||
builds those pages itself. That is the pattern the whole interface is built on: core
|
||||
supplies the machinery, the module supplies the meaning.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>What this does not yet prove</h3>
|
||||
<p>
|
||||
One module exists and it is Ultima Online. A second, for a different game, is a
|
||||
written dry-run that was deliberately never implemented — it exists to test whether
|
||||
the contract generalises on paper. Until somebody builds the second one, the seam is
|
||||
a well-argued design rather than a demonstrated one, and this site says so wherever
|
||||
it comes up.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
/* The seam label sits between the two boundary rules rather than beside them: it is
|
||||
the name of the gap, not an annotation on either side of it. Gold, because it is
|
||||
the one contract in the picture. */
|
||||
.seam-label {
|
||||
fill: var(--gold);
|
||||
font-family: var(--sans);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Two words, because two arrows crossing a boundary in opposite directions is
|
||||
ambiguous without them — and the ambiguity is the exact misreading this diagram
|
||||
exists to prevent, that a module is something core talks at. */
|
||||
.seam-arrow {
|
||||
fill: var(--dim);
|
||||
font-family: var(--sans);
|
||||
font-size: 11px;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
122
src/components/architecture/TwoHosts.astro
Normal file
@@ -0,0 +1,122 @@
|
||||
---
|
||||
/**
|
||||
* "What you actually deploy" — the first of `/architecture/`'s three diagrams (D21).
|
||||
*
|
||||
* This one exists because of a specific, repeated misunderstanding that §10 names and the
|
||||
* homepage's CTA already spends two sentences on: a Runic Gateway install is two
|
||||
* independent installs, on two machines, and neither installs the other. The homepage says
|
||||
* it; this page draws it, because an evaluator deciding whether to run the software is
|
||||
* doing capacity planning, and "how many machines is this" is the first question they have.
|
||||
*
|
||||
* Drawn generically for the same reason the homepage's diagram is (D17) — "your game host",
|
||||
* not "your ServUO box" — with the prose beside it naming the real components. The boundary
|
||||
* is the one drawn argument: everything above it is reachable because you published it, and
|
||||
* everything below it is not reachable at all.
|
||||
*
|
||||
* The vocabulary and the layout are `src/styles/diagram.css`; only the geometry is here.
|
||||
*/
|
||||
---
|
||||
|
||||
<section class="page section diagram" id="two-hosts">
|
||||
<div class="diagram__head">
|
||||
<p class="eyebrow">What you deploy</p>
|
||||
<h2>Two hosts, two installs</h2>
|
||||
<p class="prose">
|
||||
Almost everyone gets this wrong once. The website and the game-side bridge are separate
|
||||
deployments on separate machines, and neither one installs the other — so a "Runic
|
||||
Gateway install" is really two, done in that order.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="diagram__body">
|
||||
<div class="diagram__figure">
|
||||
<svg viewBox="0 0 380 546" class="flow" aria-hidden="true" focusable="false">
|
||||
<!-- The web host, and everything that runs on it. -->
|
||||
<rect class="host" x="8" y="8" width="364" height="232" rx="14" />
|
||||
<text class="host-title" x="28" y="42">Your web host</text>
|
||||
<text class="host-sub" x="28" y="62">a VPS, a home server, anything running Docker</text>
|
||||
|
||||
<rect class="node node--self" x="28" y="80" width="324" height="60" rx="10" />
|
||||
<text class="node-title" x="46" y="106">Runic Gateway</text>
|
||||
<text class="node-sub" x="46" y="126">one container, pulled not built</text>
|
||||
|
||||
<rect class="node" x="28" y="150" width="156" height="60" rx="10" />
|
||||
<text class="node-title" x="46" y="176">Game module</text>
|
||||
<text class="node-sub" x="46" y="196">installed, not built</text>
|
||||
|
||||
<rect class="node" x="196" y="150" width="156" height="60" rx="10" />
|
||||
<text class="node-title" x="214" y="176">Database</text>
|
||||
<text class="node-sub" x="214" y="196">your data, your disk</text>
|
||||
|
||||
<!-- The one hop between them, and the only one. Two arrowheads because the traffic
|
||||
genuinely goes both ways: the site calls the sidecar for point-in-time reads,
|
||||
and the sidecar pushes the live feed back up. -->
|
||||
<path class="spine spine--live" d="M190 248 V312" />
|
||||
<path class="arrow arrow--live" d="M190 240 l-6 10 h12 Z" />
|
||||
<path class="arrow arrow--live" d="M190 320 l-6 -10 h12 Z" />
|
||||
|
||||
<path class="boundary" d="M8 280 H372" />
|
||||
<text class="boundary-label" x="372" y="273" text-anchor="end">the network</text>
|
||||
|
||||
<!-- The game host. Nothing here is reachable from outside except the sidecar. -->
|
||||
<rect class="host" x="8" y="320" width="364" height="214" rx="14" />
|
||||
<text class="host-title" x="28" y="354">Your game host</text>
|
||||
<text class="host-sub" x="28" y="374">where the game server already runs</text>
|
||||
|
||||
<rect class="node" x="28" y="392" width="324" height="60" rx="10" />
|
||||
<text class="node-title" x="46" y="418">Sidecar</text>
|
||||
<text class="node-sub" x="46" y="438">the only part of this with a port open</text>
|
||||
|
||||
<rect class="node" x="28" y="462" width="324" height="60" rx="10" />
|
||||
<text class="node-title" x="46" y="488">Game server</text>
|
||||
<text class="node-sub" x="46" y="508">dials out over loopback; listens for nothing</text>
|
||||
</svg>
|
||||
|
||||
<p class="diagram__caption">
|
||||
Today the game server is a ServUO shard and the sidecar is uo-link. Two machines is
|
||||
the minimum and also the maximum — nothing here scales by adding a third.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="diagram__notes">
|
||||
<section>
|
||||
<h3>The web host</h3>
|
||||
<p>
|
||||
A Docker Compose deployment: the site, its database, and whichever game module you
|
||||
installed. Images are pulled rather than built, so nothing compiles here and an
|
||||
upgrade is a pull and a restart. This is the only machine anybody points a browser
|
||||
at, and the only one that needs a certificate.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>The game host</h3>
|
||||
<p>
|
||||
The machine your game server is already on. One installer binary puts the plugin
|
||||
into the server's tree, installs the sidecar beside it and registers the service —
|
||||
then prints four values. It never contacts your website; you paste those four
|
||||
values into the admin panel yourself, and that is the moment the two halves meet.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Why they share a host</h3>
|
||||
<p>
|
||||
The game talks to the sidecar over loopback, on the same machine, and dials
|
||||
<em>out</em> to do it. That is what lets the game server open no port at all — and it
|
||||
is also why there is no macOS installer build. The pair has to sit together, and no
|
||||
game server anybody runs is on one.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>What crosses between them</h3>
|
||||
<p>
|
||||
One authenticated connection, in both directions: a WebSocket carrying the live feed
|
||||
up, and REST calls going down for point-in-time questions. Nothing else on either
|
||||
machine talks to the other, and the sidecar answers your site and nobody else.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
167
src/components/home/Capabilities.astro
Normal file
@@ -0,0 +1,167 @@
|
||||
---
|
||||
import platform from '../../data/platform.json';
|
||||
import { capabilityGroups, assertCapabilityCoverage } from '../../data/capabilities.mjs';
|
||||
|
||||
/**
|
||||
* The grouped capabilities (PLAN.md §10). All five groups, named only — the argument for
|
||||
* each one is `/features/`'s job in phase 4, and repeating it here would create a second
|
||||
* copy to keep true.
|
||||
*
|
||||
* The call below is the point of the exercise: it throws, and therefore fails the build, if
|
||||
* the "Game intelligence" list and the module's own declared capabilities have drifted
|
||||
* apart. `checkFacts.mjs` already keeps `platform.json` honest against the module manifest;
|
||||
* this makes the page honest against `platform.json`, which is the half that was missing.
|
||||
*
|
||||
* The "not built" line at the bottom is not a disclaimer bolted on — §2's absent-features
|
||||
* list is described there as "as load-bearing as the rest", and a homepage that lists only
|
||||
* what exists while quietly omitting the well-known things that do not is the exact failure
|
||||
* §1 is written to prevent.
|
||||
*/
|
||||
assertCapabilityCoverage(platform.moduleUoCapabilities);
|
||||
---
|
||||
|
||||
<section class="page section caps">
|
||||
<p class="eyebrow">What it does</p>
|
||||
<h2>A community site, and a window into the game</h2>
|
||||
<p class="prose caps__lede">
|
||||
The core is game-agnostic: it does not know what a shard is. Everything that does arrives
|
||||
as an installable <a href="/modules/">module</a>, which is why the same platform can carry
|
||||
a different game without a fork.
|
||||
</p>
|
||||
|
||||
<div class="caps__grid">
|
||||
{
|
||||
capabilityGroups.map((group) => (
|
||||
<section class:list={['panel', 'caps__group', group.items.length > 8 && 'caps__group--wide']}>
|
||||
<header class="caps__group-head">
|
||||
<h3>{group.title}</h3>
|
||||
{group.moduleSupplied && <span class="chip">Module-supplied</span>}
|
||||
</header>
|
||||
|
||||
<p class="caps__summary">{group.summary}</p>
|
||||
|
||||
<ul class="caps__items">
|
||||
{group.items.map((item) => (
|
||||
<li>{item.label}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<p class="caps__foot prose">
|
||||
Some things people reasonably expect are <strong>deliberately not built</strong> — a Matrix
|
||||
integration, more than one game module active at once, a second game module. They are
|
||||
listed rather than left out, on <a href="/features/">features</a> and{' '}
|
||||
<a href="/integrations/">integrations</a>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.caps h2 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: clamp(1.6rem, 3.2vw, 2.1rem);
|
||||
}
|
||||
|
||||
.caps__lede {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.caps__grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin-top: 2.25rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 19rem), 1fr));
|
||||
}
|
||||
|
||||
.caps__group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.caps__group-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.caps__group h3 {
|
||||
margin: 0;
|
||||
color: var(--gold);
|
||||
font-size: 1.06rem;
|
||||
}
|
||||
|
||||
.caps__summary {
|
||||
margin: 0.6rem 0 1rem;
|
||||
color: var(--dim);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.caps__items {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
font-size: 0.94rem;
|
||||
}
|
||||
|
||||
.caps__items li {
|
||||
position: relative;
|
||||
padding-left: 1.1rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.caps__items li + li {
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
/* A drawn marker rather than a list bullet: it takes the portal colour, so it
|
||||
tracks a mounted theme the way a `list-style` glyph would not. */
|
||||
.caps__items li::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0.62em;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--portal);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
/* Five groups in a three-column grid leaves a hole, and the one group that is
|
||||
twice the length of the others is the obvious thing to put in it. Game
|
||||
intelligence takes both remaining slots on the top row and sets its items
|
||||
in two columns, which fills the row and gives the module-supplied group the
|
||||
prominence it has earned by being the only one that is module-supplied.
|
||||
|
||||
The width is read from the content — a group long enough to need it gets it
|
||||
— rather than named, so a future group of that size lands the same way.
|
||||
|
||||
Guarded by a width query because `span 2` in a grid that is only one column
|
||||
wide is an overflow, not a layout. */
|
||||
@media (min-width: 62rem) {
|
||||
.caps__group--wide {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.caps__group--wide .caps__items {
|
||||
columns: 2;
|
||||
column-gap: 1.75rem;
|
||||
}
|
||||
|
||||
/* `columns` would otherwise break an item across the column boundary, and a
|
||||
capability split over two columns reads as two capabilities. */
|
||||
.caps__group--wide .caps__items li {
|
||||
break-inside: avoid;
|
||||
}
|
||||
}
|
||||
|
||||
.caps__foot {
|
||||
margin: 2rem 0 0;
|
||||
color: var(--dim);
|
||||
font-size: 0.94rem;
|
||||
}
|
||||
</style>
|
||||
235
src/components/home/DataPath.astro
Normal file
@@ -0,0 +1,235 @@
|
||||
---
|
||||
import platform from '../../data/platform.json';
|
||||
|
||||
/**
|
||||
* The data path (PLAN.md §13 phase 3), drawn as inline SVG per §11's motif rule — hand-drawn
|
||||
* geometry, used where it explains something, and no raster anywhere.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* THE LABELS ARE GENERIC, WITH UO AS THE CAPTION
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* The org lead settled this before the diagram was drawn. The nodes say "your game server"
|
||||
* and "sidecar", not "ServUO shard" and "uo-link", because §10's rule is that a reader
|
||||
* should never need to know that `link`, `servuo-plugins` and `installer` are three
|
||||
* repositories in order to connect a game server — and because the tagline promises a
|
||||
* platform, not a UO product.
|
||||
*
|
||||
* It does NOT hide what actually ships. The sub-labels and the caption name ServUO and
|
||||
* uo-link outright, because §1 says the technical truth wins and today there is exactly one
|
||||
* implementation of this shape. An operator running a shard has to see themselves in the
|
||||
* picture on the first screen.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY THE SVG IS aria-hidden
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* Not because it is decorative — it is the opposite — but because the steps beside it carry
|
||||
* the same four stages in full prose, at real font sizes, in reading order. A `role="img"`
|
||||
* with a `<desc>` would make a screen reader read the same path twice, and the second
|
||||
* telling would be the worse one. The picture is for people who can see it; the list is the
|
||||
* canonical version and everyone gets it.
|
||||
*
|
||||
* That also means the diagram must never gain a fact the list does not have.
|
||||
*
|
||||
* The concentric rings behind the nodes are the emblem's own geometry, centred on the
|
||||
* boundary line — the one place in the picture where the argument actually happens.
|
||||
*/
|
||||
---
|
||||
|
||||
<section class="page section datapath">
|
||||
<div class="datapath__head">
|
||||
<p class="eyebrow">How it works</p>
|
||||
<h2>One path, one direction</h2>
|
||||
<p class="prose">
|
||||
Everything the website knows about your game arrives the same way. There is no second
|
||||
route in, and nothing on the internet can reach the game to ask.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="datapath__body">
|
||||
<div class="datapath__figure">
|
||||
<svg viewBox="0 0 380 500" class="flow" aria-hidden="true" focusable="false">
|
||||
<!-- The emblem's concentric rings, centred on the boundary. Drawn first so the
|
||||
panels sit over them. -->
|
||||
<g class="rings">
|
||||
<circle cx="190" cy="252" r="112" />
|
||||
<circle cx="190" cy="252" r="158" />
|
||||
<circle cx="190" cy="252" r="204" />
|
||||
</g>
|
||||
|
||||
<!-- Loopback hop: same host, no network involved. -->
|
||||
<path class="spine" d="M190 92 V140" />
|
||||
<path class="arrow" d="M190 148 l-6 -10 h12 Z" />
|
||||
|
||||
<!-- The network hop, and the only one. Drawn in the portal colour because this is
|
||||
the live feed, and the live signal is cyan everywhere on the site. -->
|
||||
<path class="spine spine--live" d="M190 224 V272" />
|
||||
<path class="arrow arrow--live" d="M190 280 l-6 -10 h12 Z" />
|
||||
|
||||
<path class="spine" d="M190 356 V404" />
|
||||
<path class="arrow" d="M190 412 l-6 -10 h12 Z" />
|
||||
|
||||
<!-- The boundary the whole design exists to draw. -->
|
||||
<path class="boundary" d="M8 252 H372" />
|
||||
<text class="boundary-label" x="372" y="245" text-anchor="end">the network</text>
|
||||
|
||||
<rect class="node" x="20" y="16" width="340" height="76" rx="12" />
|
||||
<text class="node-title" x="42" y="50">Your game server</text>
|
||||
<text class="node-sub" x="42" y="72">ServUO today · opens no inbound port</text>
|
||||
|
||||
<rect class="node" x="20" y="148" width="340" height="76" rx="12" />
|
||||
<text class="node-title" x="42" y="182">Sidecar</text>
|
||||
<text class="node-sub" x="42" y="204">uo-link · the only network-facing part</text>
|
||||
|
||||
<rect class="node node--self" x="20" y="280" width="340" height="76" rx="12" />
|
||||
<text class="node-title" x="42" y="314">Runic Gateway</text>
|
||||
<text class="node-sub" x="42" y="336">your public website</text>
|
||||
|
||||
<rect class="node" x="20" y="412" width="340" height="76" rx="12" />
|
||||
<text class="node-title" x="42" y="446">Browser and app</text>
|
||||
<text class="node-sub" x="42" y="468">anyone you choose to let in</text>
|
||||
</svg>
|
||||
|
||||
<p class="datapath__caption">
|
||||
Today that game server is a ServUO shard and that sidecar is uo-link. The shape is the
|
||||
contract; the implementations are what plug into it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ol class="datapath__steps">
|
||||
<li>
|
||||
<h3>Your game server</h3>
|
||||
<p>
|
||||
A plugin inside the server dials <strong>out</strong> to the sidecar over loopback.
|
||||
The game never listens for anything, so there is nothing on it to find. Events go
|
||||
onto a bounded queue and the game moves on — a sidecar that is wedged or missing
|
||||
cannot slow the world down.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<h3>The sidecar</h3>
|
||||
<p>
|
||||
A small service beside the game, and the only piece of the bridge anything else can
|
||||
reach. It speaks a versioned wire protocol — protocol {platform.protocol} today — so
|
||||
a mismatched pair is refused rather than misread, and it answers only your website's
|
||||
backend, over an authenticated WebSocket and REST.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<h3>Runic Gateway</h3>
|
||||
<p>
|
||||
Your site ingests the live feed and fans it back out on two streams: a public one
|
||||
carrying an allowlist of safe events, and a staff-only one carrying the rest. That
|
||||
split is a security boundary, not a preference. When the game is down the site stays
|
||||
up and shows it as offline.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<h3>Browser and app</h3>
|
||||
<p>
|
||||
The web client reads same-origin JSON and server-sent events. The Android app talks
|
||||
to the same documented API with bearer tokens. Neither has any idea where the game
|
||||
server is, because neither is ever told.
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.datapath__head h2 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: clamp(1.6rem, 3.2vw, 2.1rem);
|
||||
}
|
||||
|
||||
.datapath__head .prose {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.datapath__body {
|
||||
display: grid;
|
||||
gap: clamp(1.75rem, 4vw, 3rem);
|
||||
margin-top: 2.5rem;
|
||||
grid-template-columns: minmax(0, 380px) minmax(0, 1fr);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.datapath__figure {
|
||||
position: sticky;
|
||||
top: calc(var(--header-h) + 1.5rem);
|
||||
}
|
||||
|
||||
.datapath__caption {
|
||||
margin: 1rem 0 0;
|
||||
max-width: 380px;
|
||||
color: var(--dim);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* The SVG vocabulary this diagram draws with -- .node, .spine, .arrow,
|
||||
.boundary, .rings -- now lives in src/styles/diagram.css, shared with
|
||||
/architecture/'s three. It was duplicated in four files the moment the
|
||||
second diagram existed, and the rules it holds are decisions about what a
|
||||
diagram on this site looks like rather than about this one.
|
||||
|
||||
The layout below stays here: the right-hand column is a numbered walk,
|
||||
not the notes column .diagram__body assumes. */
|
||||
|
||||
/* ---- The list ---------------------------------------------------------- */
|
||||
.datapath__steps {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
counter-reset: step;
|
||||
}
|
||||
|
||||
.datapath__steps li {
|
||||
position: relative;
|
||||
padding-left: 3.25rem;
|
||||
counter-increment: step;
|
||||
}
|
||||
|
||||
.datapath__steps li + li {
|
||||
margin-top: 1.75rem;
|
||||
}
|
||||
|
||||
.datapath__steps li::before {
|
||||
content: counter(step);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border: 1px solid var(--gold-deep);
|
||||
border-radius: var(--radius-pill);
|
||||
color: var(--gold);
|
||||
font-family: var(--display);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.datapath__steps h3 {
|
||||
margin: 0.3rem 0 0.4rem;
|
||||
font-size: 1.08rem;
|
||||
}
|
||||
|
||||
.datapath__steps p {
|
||||
margin: 0;
|
||||
max-width: var(--measure);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.datapath__body {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
/* Sticky is a wide-screen affordance: the figure should scroll away with
|
||||
everything else once it is above the list rather than beside it. */
|
||||
.datapath__figure {
|
||||
position: static;
|
||||
justify-self: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
117
src/components/home/GetStarted.astro
Normal file
@@ -0,0 +1,117 @@
|
||||
---
|
||||
import { brand } from '../../lib/brand.mjs';
|
||||
|
||||
/**
|
||||
* The get-started CTA (PLAN.md §10, the `/` row), built around the trap in §10's
|
||||
* "installation path": a "Runic Gateway install" is two independent installs. The installer
|
||||
* binary sets up the shard side only and never contacts the website; the website is a
|
||||
* separate Docker deployment.
|
||||
*
|
||||
* That belongs on the homepage rather than being saved for the docs. It is the single
|
||||
* misunderstanding most likely to make an evaluator think the software is broken, it costs
|
||||
* two sentences to prevent, and §13 calls the installation path the priority of the whole
|
||||
* project. Saying it here is what makes the docs a confirmation rather than a surprise.
|
||||
*
|
||||
* The two halves are ordered site-first because that is the order they must be done in: the
|
||||
* shard side ends by pasting four values into the site's admin panel, which has to exist.
|
||||
*
|
||||
* Both "read the docs" links point at `/docs/` rather than at a page inside the journey.
|
||||
* Phases 7 and 8 write those pages and own their slugs; guessing one now would put a URL in
|
||||
* this file that nothing checks and that a later phase would have to remember to fix.
|
||||
*/
|
||||
---
|
||||
|
||||
<section class="page section start">
|
||||
<div class="panel start__panel">
|
||||
<p class="eyebrow">Getting started</p>
|
||||
<h2>An install is two installs</h2>
|
||||
<p class="start__lede prose">
|
||||
This trips up almost everyone once. The website and the game-side bridge are separate
|
||||
deployments on separate machines, and neither one installs the other. Doing them in
|
||||
order takes an evening.
|
||||
</p>
|
||||
|
||||
<div class="start__halves">
|
||||
<div class="start__half">
|
||||
<h3><span class="start__num">1</span> The site</h3>
|
||||
<p>
|
||||
A Docker Compose deployment on whatever host serves your community — a small VPS is
|
||||
plenty. Pull the images, bring it up, create the first admin, then install a game
|
||||
module from the admin panel.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="start__half">
|
||||
<h3><span class="start__num">2</span> The game side</h3>
|
||||
<p>
|
||||
One binary, run on the machine the game server already lives on. It syncs the plugin,
|
||||
installs the sidecar as a service, and prints four values. You paste those into
|
||||
Admin → Shard, and the two halves find each other.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="start__actions">
|
||||
<a class="btn btn--primary" href="/docs/">Read the install guide</a>
|
||||
<a class="btn btn--ghost" href={brand.giteaOrg} rel="noopener noreferrer">Browse the source</a>
|
||||
<a class="btn btn--ghost" href={brand.discordInvite} rel="noopener noreferrer">Ask on Discord</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.start__panel {
|
||||
padding: clamp(1.5rem, 4vw, 2.75rem);
|
||||
}
|
||||
|
||||
.start h2 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: clamp(1.5rem, 3vw, 2rem);
|
||||
}
|
||||
|
||||
.start__lede {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.start__halves {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
margin-top: 2rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr));
|
||||
}
|
||||
|
||||
.start__half h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.start__num {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
flex: none;
|
||||
border: 1px solid var(--gold-deep);
|
||||
border-radius: var(--radius-pill);
|
||||
color: var(--gold);
|
||||
font-family: var(--display);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.start__half p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.start__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-top: 2.25rem;
|
||||
}
|
||||
</style>
|
||||
183
src/components/home/Hero.astro
Normal file
@@ -0,0 +1,183 @@
|
||||
---
|
||||
import { brand } from '../../lib/brand.mjs';
|
||||
import platform from '../../data/platform.json';
|
||||
|
||||
/**
|
||||
* The hero (PLAN.md §13 phase 3).
|
||||
*
|
||||
* The org lead chose an emblem hero over a type-only one: the mark carries recognition
|
||||
* across the site, the Android launcher icon and the Play listing, and showing it large is
|
||||
* what makes those three read as one product (D11, §11).
|
||||
*
|
||||
* It costs what D16 already accepted — the emblem is raster illustration, so a mounted
|
||||
* `theme.css` recolours everything around it and not the mark itself. Replacing the mark
|
||||
* means replacing `logo.png`, and because every size here is derived on request from
|
||||
* whichever `logo.png` is in force (D14), that one file changes the hero, the header, the
|
||||
* tab icon and the installed app icon together.
|
||||
*
|
||||
* The glow behind it is drawn in CSS from the portal tokens, so it DOES follow a mounted
|
||||
* theme. That is deliberate: the part that can track the operator's palette does.
|
||||
*
|
||||
* The <h1> is the tagline rather than the product name. The name is in the header, in the
|
||||
* page title and in the footer; a visitor who has just arrived needs the sentence more than
|
||||
* the noun. Both strings are brand fields, rewritten at boot by `applyBrand.mjs` (D15).
|
||||
*/
|
||||
---
|
||||
|
||||
<section class="hero">
|
||||
<div class="page hero__inner">
|
||||
<div class="hero__copy">
|
||||
<p class="eyebrow">Self-hosted community platform</p>
|
||||
|
||||
<h1>{brand.tagline}</h1>
|
||||
|
||||
<p class="hero__lede">
|
||||
{brand.siteName} is a community website for a game server — accounts, teams, forums, a
|
||||
wiki, news and a full admin panel — with a one-way bridge that puts the server's live
|
||||
world on the public site. The game itself never listens on the internet.
|
||||
</p>
|
||||
|
||||
<div class="hero__actions">
|
||||
<a class="btn btn--primary" href="/docs/">Install it</a>
|
||||
<a class="btn btn--ghost" href="/features/">See what it does</a>
|
||||
|
||||
{/*
|
||||
The demo slot (§15 / D12). `global.css` hides `[data-demo-url='']`, so a stock
|
||||
build renders nothing here; `applyBrand.mjs` fills both attributes at boot when a
|
||||
mounted `brand.json` sets `demoUrl`, and the link appears.
|
||||
|
||||
The attribute pair is a literal contract with that script — `href` immediately
|
||||
followed by `data-demo-url`, both empty, in this order. Astro preserves attribute
|
||||
order, so what is written here is what ends up in the HTML it searches for. Do not
|
||||
insert an attribute between them.
|
||||
*/}
|
||||
<a class="btn demo-cta" href="" data-demo-url="">See it running</a>
|
||||
</div>
|
||||
|
||||
<div class="chips">
|
||||
<span class="chip chip--version">Protocol {platform.protocol}</span>
|
||||
<span class="chip chip--version">Module API {platform.moduleApi}</span>
|
||||
<span class="chip chip--version">Bundle {platform.bundle.tag}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hero__mark">
|
||||
{/*
|
||||
`alt=""` because the emblem is the product's mark sitting beside the product's own
|
||||
sentence — announcing it would add nothing a reader of the <h1> does not have.
|
||||
|
||||
Sizes are on `brandAssets.mjs`'s allowlist; `checkBrand.mjs` puts every URL below
|
||||
through the route's own classifier, so a plausible-but-underivable size fails the
|
||||
build rather than 404ing in production.
|
||||
*/}
|
||||
<picture>
|
||||
<source
|
||||
type="image/avif"
|
||||
srcset="/brand/logo-256.avif 256w, /brand/logo-384.avif 384w, /brand/logo-512.avif 512w"
|
||||
sizes="(max-width: 900px) 176px, 320px"
|
||||
/>
|
||||
<img
|
||||
src="/brand/logo-384.webp"
|
||||
srcset="/brand/logo-256.webp 256w, /brand/logo-384.webp 384w, /brand/logo-512.webp 512w"
|
||||
sizes="(max-width: 900px) 176px, 320px"
|
||||
width="384"
|
||||
height="384"
|
||||
alt=""
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
/>
|
||||
</picture>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.hero {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding-block: clamp(2.5rem, 7vw, 5rem) clamp(2rem, 5vw, 3.5rem);
|
||||
}
|
||||
|
||||
.hero__inner {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
gap: clamp(1.5rem, 5vw, 3.5rem);
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.hero__copy {
|
||||
max-width: 40rem;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
color: var(--gold);
|
||||
font-size: clamp(2.1rem, 5.2vw, 3.35rem);
|
||||
}
|
||||
|
||||
.hero__lede {
|
||||
margin: 1.15rem 0 0;
|
||||
max-width: var(--measure);
|
||||
color: var(--muted);
|
||||
font-size: clamp(1rem, 1.6vw, 1.13rem);
|
||||
}
|
||||
|
||||
.hero__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1.9rem;
|
||||
}
|
||||
|
||||
.hero .chips {
|
||||
margin-top: 1.75rem;
|
||||
}
|
||||
|
||||
/* ---- The mark ---------------------------------------------------------
|
||||
The glow is a radial gradient mixed from the portal tokens rather than a
|
||||
literal, so a mounted theme.css moves it with the rest of the palette.
|
||||
It is behind the emblem and outside the flow, so it costs no layout. */
|
||||
.hero__mark {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.hero__mark::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
inset: 50% auto auto 50%;
|
||||
translate: -50% -50%;
|
||||
width: 150%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: var(--radius-pill);
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
color-mix(in srgb, var(--portal-deep) 34%, transparent) 0%,
|
||||
color-mix(in srgb, var(--portal-deep) 8%, transparent) 45%,
|
||||
transparent 68%
|
||||
);
|
||||
}
|
||||
|
||||
.hero__mark img {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: block;
|
||||
width: clamp(176px, 26vw, 320px);
|
||||
height: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.hero__inner {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
/* The mark leads on a narrow screen: it is the fastest thing to recognise,
|
||||
and stacking it under the copy would push it below the fold entirely. */
|
||||
.hero__mark {
|
||||
order: -1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
107
src/components/home/SelfHosted.astro
Normal file
@@ -0,0 +1,107 @@
|
||||
---
|
||||
/**
|
||||
* The self-hosted argument (PLAN.md §10, the `/` row).
|
||||
*
|
||||
* Every claim below is from §2's verified state, and each is deliberately the kind of thing
|
||||
* that can be checked by running the software rather than by trusting the page. Where a
|
||||
* claim would need a qualifier, the qualifier is on the card — "understated honesty" (D8)
|
||||
* is a house style, and a hedge in small print is the opposite of it.
|
||||
*
|
||||
* Nothing here is a version or a number, so nothing here needs `platform.json`. If a card
|
||||
* ever gains one, it reads it from there like everything else (§12).
|
||||
*/
|
||||
|
||||
const points = [
|
||||
{
|
||||
title: 'It runs on your box',
|
||||
body:
|
||||
'Docker Compose, with prebuilt images that are pulled rather than built — nothing ' +
|
||||
'compiles on your server. One command up, one command back.',
|
||||
},
|
||||
{
|
||||
title: 'The game stays off the internet',
|
||||
body:
|
||||
'The game host opens no inbound port. The sidecar beside it is the only exposed ' +
|
||||
'part of the bridge, and it answers exactly one caller: your website.',
|
||||
},
|
||||
{
|
||||
title: 'Branding is data, not a rebuild',
|
||||
body:
|
||||
'Name, colours, logo and contact address live in a mounted file. The same image ' +
|
||||
'runs as any community — including this site, which is built the same way.',
|
||||
},
|
||||
{
|
||||
title: 'No analytics, anywhere',
|
||||
body:
|
||||
'This site has no trackers, no third-party requests and no cookie banner, because ' +
|
||||
'it collects nothing. Your deployment talks to the services you configure, and to ' +
|
||||
'nothing you did not.',
|
||||
},
|
||||
{
|
||||
title: 'Documented, not just working',
|
||||
body:
|
||||
'The whole backend is described by an OpenAPI 3.0 spec that ships with it, so the ' +
|
||||
'API you build against is the API that is actually there.',
|
||||
},
|
||||
{
|
||||
title: 'Free software',
|
||||
body:
|
||||
'GPL-3.0-or-later, every repository in the open. If this project stops, what you ' +
|
||||
'are running does not.',
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<section class="page section selfhosted">
|
||||
<p class="eyebrow">Why self-hosted</p>
|
||||
<h2>Your server, your data, your rules</h2>
|
||||
<p class="prose selfhosted__lede">
|
||||
There is no hosted tier and no account with us. The whole thing is software you run,
|
||||
which is the only arrangement under which "the game is not on the internet" can mean
|
||||
anything.
|
||||
</p>
|
||||
|
||||
<ul class="selfhosted__grid">
|
||||
{
|
||||
points.map((point) => (
|
||||
<li class="panel">
|
||||
<h3>{point.title}</h3>
|
||||
<p>{point.body}</p>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.selfhosted h2 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: clamp(1.6rem, 3.2vw, 2.1rem);
|
||||
}
|
||||
|
||||
.selfhosted__lede {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.selfhosted__grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin: 2.25rem 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 17rem), 1fr));
|
||||
}
|
||||
|
||||
.selfhosted__grid h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--gold);
|
||||
font-size: 1.02rem;
|
||||
}
|
||||
|
||||
.selfhosted__grid p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.94rem;
|
||||
}
|
||||
</style>
|
||||
44
src/components/home/WhatItLooksLike.astro
Normal file
@@ -0,0 +1,44 @@
|
||||
---
|
||||
import Screenshot from '../Screenshot.astro';
|
||||
|
||||
/**
|
||||
* The homepage's one screenshot. PLAN.md §13 phase 9, D4 / D44.
|
||||
*
|
||||
* -----------------------------------------------------------------------------------------
|
||||
* WHY ONE, AND WHY THIS ONE
|
||||
* -----------------------------------------------------------------------------------------
|
||||
* `DataPath` above it draws the claim — a private game server, a sidecar, a public site —
|
||||
* and a diagram of a data path is a promise that the data arrives. This is the page where
|
||||
* it arrives, captured from a deployment wired to a running shard, so the section directly
|
||||
* under the diagram is the diagram's evidence.
|
||||
*
|
||||
* A gallery here would compete with `Capabilities` further down, which is the part of the
|
||||
* homepage that enumerates. So: one figure, the signature screen, and the rest of the set
|
||||
* on `/features/` where each one sits beside the claim it supports.
|
||||
*/
|
||||
---
|
||||
|
||||
<section class="page section looks">
|
||||
<p class="eyebrow">What it looks like</p>
|
||||
<h2>The other end of that diagram</h2>
|
||||
<p class="prose looks__lede">
|
||||
A demo deployment with a real shard behind it. The gold supply, the state of the link and
|
||||
the player online in Britain are all read from the game server over the bridge. None of it
|
||||
is typed in, and none of it is a mock-up.
|
||||
</p>
|
||||
|
||||
<Screenshot id="shard-status" />
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.looks h2 {
|
||||
margin: 0.35rem 0 0.75rem;
|
||||
font-size: clamp(1.6rem, 3.2vw, 2.1rem);
|
||||
}
|
||||
|
||||
.looks__lede {
|
||||
margin: 0;
|
||||
max-width: 46rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
</style>
|
||||
@@ -16,14 +16,82 @@
|
||||
export const docsSidebar = [
|
||||
{
|
||||
label: 'Getting started',
|
||||
items: [{ label: 'What is Runic Gateway?', slug: 'docs' }],
|
||||
items: [
|
||||
{ label: 'What is Runic Gateway?', slug: 'docs' },
|
||||
{ label: 'Requirements', slug: 'docs/getting-started/requirements' },
|
||||
{ label: 'Install the site', slug: 'docs/getting-started/install-the-site' },
|
||||
{ label: 'First run', slug: 'docs/getting-started/first-run' },
|
||||
{ label: 'Install a game module', slug: 'docs/getting-started/install-a-game-module' },
|
||||
{ label: 'Connect a game server', slug: 'docs/getting-started/connect-a-game-server' },
|
||||
{ label: 'Verify the whole stack', slug: 'docs/getting-started/verify-the-whole-stack' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Administration',
|
||||
items: [
|
||||
{ label: 'Configuration', slug: 'docs/administration/configuration' },
|
||||
{ label: 'Branding and theming', slug: 'docs/administration/branding-and-theming' },
|
||||
{ label: 'Navigation and pages', slug: 'docs/administration/navigation-and-pages' },
|
||||
{ label: 'Content', slug: 'docs/administration/content' },
|
||||
{ label: 'Users and roles', slug: 'docs/administration/users-and-roles' },
|
||||
{ label: 'Authentication', slug: 'docs/administration/authentication' },
|
||||
{ label: 'Teams', slug: 'docs/administration/teams' },
|
||||
{ label: 'Moderation', slug: 'docs/administration/moderation' },
|
||||
{ label: 'Notifications and email', slug: 'docs/administration/notifications-and-email' },
|
||||
{ label: 'Managing modules', slug: 'docs/administration/managing-modules' },
|
||||
{ label: 'The shard connection', slug: 'docs/administration/the-shard-connection' },
|
||||
{ label: 'Maintenance and upgrades', slug: 'docs/administration/maintenance-and-upgrades' },
|
||||
{ label: 'Troubleshooting', slug: 'docs/administration/troubleshooting' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Modules',
|
||||
items: [
|
||||
{ label: 'The module system', slug: 'docs/modules/the-module-system' },
|
||||
{ label: 'Installing modules', slug: 'docs/modules/installing-modules' },
|
||||
{ label: 'Module lifecycle', slug: 'docs/modules/module-lifecycle' },
|
||||
{ label: 'The module manifest', slug: 'docs/modules/the-module-manifest' },
|
||||
{ label: 'The module API', slug: 'docs/modules/the-module-api' },
|
||||
{ label: 'Building a module', slug: 'docs/modules/building-a-module' },
|
||||
{ label: 'The Integration Kit', slug: 'docs/modules/the-integration-kit' },
|
||||
{ label: 'Testing and release', slug: 'docs/modules/testing-and-release' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Architecture',
|
||||
items: [
|
||||
{ label: 'System architecture', slug: 'docs/architecture/system-architecture' },
|
||||
{ label: 'The bridge', slug: 'docs/architecture/the-bridge' },
|
||||
{ label: 'Authentication architecture', slug: 'docs/architecture/authentication-architecture' },
|
||||
{ label: 'Teams architecture', slug: 'docs/architecture/teams-architecture' },
|
||||
{ label: 'Protocol versions', slug: 'docs/architecture/protocol-versions' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Reference',
|
||||
items: [
|
||||
{ label: 'Environment variables', slug: 'docs/reference/environment-variables' },
|
||||
{ label: 'Installer CLI', slug: 'docs/reference/installer-cli' },
|
||||
{ label: 'sidecar.toml', slug: 'docs/reference/sidecar-toml' },
|
||||
{ label: 'Bridge.cfg', slug: 'docs/reference/bridge-cfg' },
|
||||
{ label: 'HTTP API', slug: 'docs/reference/http-api' },
|
||||
{ label: 'Event catalog', slug: 'docs/reference/event-catalog' },
|
||||
{ label: 'Canonical documents', slug: 'docs/reference/canonical-documents' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* The full planned tree, kept next to the live sidebar so phases 7 and 8 have their
|
||||
* checklist in the place they will be working. Not exported into the Starlight config —
|
||||
* it names pages that do not exist yet.
|
||||
* The tree §10 planned, kept as the record of what was intended — every page it names now
|
||||
* exists, as of phase 8.
|
||||
*
|
||||
* It was the phases 7/8 checklist, and a checklist with nothing left on it is no longer
|
||||
* pulling its weight: it is a second copy of the tree above, maintained by hand, and it had
|
||||
* already drifted once (phase 7 added `Content` under D37 and this list was not updated,
|
||||
* which nothing caught because nothing reads it). `checkSidebar.mjs` now asserts the two
|
||||
* agree, which is what makes keeping it safe.
|
||||
*
|
||||
* Not exported into the Starlight config.
|
||||
*/
|
||||
export const plannedSidebar = {
|
||||
'Getting started': [
|
||||
@@ -39,6 +107,7 @@ export const plannedSidebar = {
|
||||
'Configuration',
|
||||
'Branding and theming',
|
||||
'Navigation and pages',
|
||||
'Content',
|
||||
'Users and roles',
|
||||
'Authentication',
|
||||
'Teams',
|
||||
|
||||
82
src/content/docs/docs/administration/authentication.mdx
Normal file
@@ -0,0 +1,82 @@
|
||||
---
|
||||
title: Authentication
|
||||
description: Local accounts and two-factor, SSO providers and the link-only policy, and the layer that keeps automated traffic out.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
**Admin → Authentication** has four tabs: Local Accounts, Google, Discord and Custom
|
||||
Providers. One session model sits behind all of them — a web cookie, a mobile bearer token
|
||||
and an SSO sign-in all produce the same session.
|
||||
|
||||
## Local accounts
|
||||
|
||||
Username and password sign-in is **always enabled and cannot be turned off**. It is how you
|
||||
manage accounts and how SSO identities get linked in the first place, so there is no
|
||||
configuration on this tab beyond that statement.
|
||||
|
||||
**Two-factor** is a per-account, opt-in TOTP code, set up by each person under **Account**
|
||||
in the sidebar. Nobody can enable it on someone else's behalf, and staff accounts are the
|
||||
ones worth insisting on.
|
||||
|
||||
## SSO providers
|
||||
|
||||
Google and Discord each need a client ID and secret from that provider's developer console;
|
||||
Custom Providers takes any OAuth2/OIDC issuer. Secrets are encrypted at rest with
|
||||
`SECRET_ENC_KEY` and are never returned to any client.
|
||||
|
||||
<Aside type="caution" title="SSO is link-only, by policy">
|
||||
An external identity can only sign in to an account it is **already linked to**. Signing in
|
||||
with Google does not create an account, ever. People link a provider themselves from their
|
||||
own account screen, and that link is what grants the access — so a stranger with a Google
|
||||
account is still a stranger.
|
||||
</Aside>
|
||||
|
||||
Configuring Google here also unlocks **email delivery**, which reuses the same OAuth client
|
||||
— see [Notifications and email](/docs/administration/notifications-and-email/).
|
||||
|
||||
## Trusted devices
|
||||
|
||||
A second factor that asks on every sign-in on the same laptop trains people to click
|
||||
through it. A device can be remembered after a successful two-factor challenge, and the
|
||||
trust rides the browser's own cookie jar — including the in-app browser tab the Android app
|
||||
opens for SSO, which is why signing in there does not ask again.
|
||||
|
||||
Trust is per device and revocable, and it survives signing out: signing out ends a session,
|
||||
not the statement that this machine is yours.
|
||||
|
||||
## What keeps the automated traffic out
|
||||
|
||||
Four layers, all on by default:
|
||||
|
||||
- **Rate limiting and backoff** on the login routes, so a password guess costs time.
|
||||
- **A honeypot field** that a human never fills in and a naive bot always does.
|
||||
- **Bot scoring**, which accumulates points against an address for behaviour no human
|
||||
produces, and bans it automatically past a threshold.
|
||||
- **IP bans** from that scoring.
|
||||
|
||||
**Admin → Web Bot Activity** shows the live state: currently banned addresses with their
|
||||
score and expiry, and the recent events with the reason, path and points that produced
|
||||
them. It is deliberately **read-only apart from an emergency unban** — there is nothing to
|
||||
tune here, and the panel exists so that a legitimate user locked out by their office's
|
||||
shared address can be let back in.
|
||||
|
||||
<Aside type="note" title="The scoring state is in memory, and resets when the server restarts">
|
||||
So a restart clears every automatic ban. That is a reasonable escape hatch when you have
|
||||
locked yourself out, and a reason not to treat this list as a permanent record.
|
||||
</Aside>
|
||||
|
||||
## Getting locked out
|
||||
|
||||
Two situations worth knowing before they happen at three in the morning:
|
||||
|
||||
- **Your address is banned.** Restart the app container — the in-memory state goes with it.
|
||||
- **You lost your second factor.** Use one of the recovery codes issued when you enabled
|
||||
it. If those are gone too, another administrator opens **Users → View** on your account
|
||||
and presses **Reset two-factor**, which turns TOTP off, revokes your trusted devices and
|
||||
clears your recovery codes so a password sign-in works again. That is the practical
|
||||
argument for a site never having exactly one admin.
|
||||
|
||||
The same screen lists an account's trusted devices and revokes them individually or all at
|
||||
once — the right response to a lost or stolen laptop, and something to reach for before
|
||||
resetting the whole second factor.
|
||||
@@ -0,0 +1,88 @@
|
||||
---
|
||||
title: Branding and theming
|
||||
description: Colours, fonts and corners from the Appearance screen; logo, hero and favicon from a mounted directory; the portal hero from its own editor.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
import Screenshot from '../../../../components/Screenshot.astro';
|
||||
|
||||
One prebuilt image runs as any community's site. Nothing about your identity is compiled
|
||||
in — it is a theme row in the database, three image files on a mount, and a few environment
|
||||
variables for the values that must exist before the database does.
|
||||
|
||||
## Appearance
|
||||
|
||||
**Admin → Appearance** themes the public site, the admin panel and the player portal
|
||||
together.
|
||||
|
||||
**Presets** — *Runic Gateway*, *Modern*, *Fantasy*, *Custom* — set a whole palette at once.
|
||||
Anything you set below a preset overrides it field by field, and a colour you never set
|
||||
keeps following the preset. That is the useful property: pick the preset closest to what
|
||||
you want, change the two colours that are wrong, and the rest still moves with it.
|
||||
|
||||
| Group | What is in it |
|
||||
|---|---|
|
||||
| **Colors** | Background, deep background, panel top and bottom, accent, bright accent, ink/headings, body text |
|
||||
| **Fonts** | Body serif, display/headings, interface sans — each with a "follow the preset" default |
|
||||
| **Corners & depth** | Radius for pills and buttons, flat panels, cards, inputs; and card shadow |
|
||||
|
||||
Two things the screen tells you that are easy to miss:
|
||||
|
||||
- **Live and maintenance status colours are never themed.** Green has to keep meaning live.
|
||||
- **The accent reaches the mobile app and the Discord bot**, both of which theme themselves
|
||||
from this site's public branding. Changing it here changes them.
|
||||
|
||||
<Screenshot id="admin-appearance" />
|
||||
|
||||
## Brand assets
|
||||
|
||||
The same screen uploads three images, and each applies as soon as the upload finishes —
|
||||
there is nothing to save.
|
||||
|
||||
| Asset | Where it shows | Limit |
|
||||
|---|---|---|
|
||||
| **Logo** | Site header, admin sidebar, player portal, and link previews when a page is shared | 1 MB |
|
||||
| **Hero image** | Behind the portal hero, unless the hero editor has its own background | 8 MB |
|
||||
| **Favicon** | The browser tab. PNG only; 32×32 or 64×64 works everywhere | 512 KB |
|
||||
|
||||
Underneath, these are files on the `./brand` bind mount from
|
||||
[Install the site](/docs/getting-started/install-the-site/), pointed at by `BRAND_LOGO`,
|
||||
`BRAND_HERO` and `BRAND_FAVICON`. An upload writes there; so does copying a file in by
|
||||
hand. Both are supported, and the mount is why replacing a logo never means rebuilding an
|
||||
image.
|
||||
|
||||
<Aside type="note" title="The “powered by Runic Gateway” mark in the footer is not yours to theme">
|
||||
It is the project's badge rather than your instance's, and it does not change with the
|
||||
theme.
|
||||
</Aside>
|
||||
|
||||
## The text that comes from the environment
|
||||
|
||||
A few identity values are read before the database is available — the server templates them
|
||||
into `index.html` at boot so that link previews and the tab title are right on the very
|
||||
first request:
|
||||
|
||||
`BRAND_NAME`, `BRAND_SHORT_NAME`, `BRAND_TAGLINE`, `BRAND_DESCRIPTION`,
|
||||
`BRAND_ACCENT_COLOR`, `BRAND_URL`, `BRAND_CONTACT_EMAIL`.
|
||||
|
||||
Where an admin-editable setting exists for the same thing — site title, contact email — the
|
||||
**setting wins**. The variable is the value a fresh deployment starts from.
|
||||
|
||||
## The portal hero
|
||||
|
||||
**Admin → Hero Editor** composes the front page's hero directly: drag elements to place
|
||||
them, drag the corner handle to resize (text scales with the box), Delete removes the
|
||||
selected one. The palette adds text, buttons, the moon, a badge or an image.
|
||||
|
||||
Its own background image and overlay darkness are set at the bottom of the editor, and a
|
||||
background set here **wins over** the Appearance screen's hero image.
|
||||
|
||||
Work is not live until you press **Publish**; **Preview** opens it in a new tab, and
|
||||
**Revert to live** throws away an unpublished draft. Until anything is published at all,
|
||||
the portal renders the shipped hero with the homepage teaser from
|
||||
[Settings](/docs/administration/configuration/) underneath it.
|
||||
|
||||
<Aside type="caution" title="Check a hero on a phone before publishing it">
|
||||
The editor is a canvas, and a layout that reads well at desktop width can put text over a
|
||||
face or off the edge on a narrow screen. Preview it there.
|
||||
</Aside>
|
||||
99
src/content/docs/docs/administration/configuration.mdx
Normal file
@@ -0,0 +1,99 @@
|
||||
---
|
||||
title: Configuration
|
||||
description: What is set in the environment file, what is set in the admin panel, and why the split is where it is.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
Two places hold configuration, and the line between them is not arbitrary.
|
||||
|
||||
| | Environment (`.env`) | Admin panel |
|
||||
|---|---|---|
|
||||
| **What** | How the process runs: ports, database, secrets, proxy trust, log level | How the site behaves: titles, registration, forums, integrations |
|
||||
| **Changing it** | Edit the file, `docker compose up -d` | Save the form; effective immediately |
|
||||
| **Who** | Whoever has the host | Whoever has an admin account |
|
||||
| **Where it lives** | A file on the host | The database |
|
||||
|
||||
The rule behind the split: **anything that needs a restart or a shell is environment;
|
||||
anything an administrator should be able to change without either is in the panel.** That
|
||||
is why the Discord bot token, the OAuth client secrets and the shard's auth token are *not*
|
||||
environment variables — they are entered in the panel and stored encrypted.
|
||||
|
||||
## Settings
|
||||
|
||||
**Admin → Settings**, the screen most of a new deployment's decisions live on.
|
||||
|
||||
| Field | What it does |
|
||||
|---|---|
|
||||
| **Site title** | Overrides `BRAND_NAME` in the page title, the header and link previews. |
|
||||
| **Homepage teaser** | Rich text under the hero heading, when no custom hero layout is published. |
|
||||
| **Maintenance message** | What visitors see while the site is in maintenance mode. |
|
||||
| **Status message** | A short line for announcements — a maintenance window, an outage. |
|
||||
| **Contact email** | Where the contact form delivers, and the address it falls back to as a `mailto:` link while email is unconfigured. |
|
||||
| **Player registration** | Disabled, password, SSO, or both. **Off by default.** |
|
||||
|
||||
### Player registration is off until you turn it on
|
||||
|
||||
A new site accepts no self-registration at all. The three ways to let people in:
|
||||
|
||||
- **Password** — a normal sign-up form.
|
||||
- **SSO** — sign-up through a linked provider, which needs a provider configured first.
|
||||
- **Invites** — leave registration off entirely and issue invitations from
|
||||
**Admin → Invites**. See [Users and roles](/docs/administration/users-and-roles/).
|
||||
|
||||
## Team forums
|
||||
|
||||
The same screen carries the forum switches, because they are site-wide policy rather than
|
||||
per-Team settings:
|
||||
|
||||
- **Enable team forums** — off by default. Switching them off hides them completely (every
|
||||
forum route answers *not found*) but **deletes nothing**: threads, posts, access grants
|
||||
and notification preferences all survive and come back exactly as they were.
|
||||
- **Images in forum posts** — disabled, remote URLs only, or uploads to your server.
|
||||
Enabling uploads means content stored on infrastructure you are responsible for, and the
|
||||
screen says so at some length before you can agree to it.
|
||||
- **Post edit window** — how long an author may edit their own post. Staff are not bound by
|
||||
it. Zero makes posts permanent once written; some bound is what stops a post being
|
||||
rewritten out from under someone quoting it.
|
||||
|
||||
## Email
|
||||
|
||||
Configured on the same screen and covered in
|
||||
[Notifications and email](/docs/administration/notifications-and-email/): it is Gmail over
|
||||
OAuth2, it reuses the Google authentication client, and it must be set up on the
|
||||
[Authentication](/docs/administration/authentication/) page first.
|
||||
|
||||
<Aside type="note" title="Until email is connected, the contact form is a mailto: link">
|
||||
That is a deliberate fallback rather than a failure — but it does mean the *Contact email*
|
||||
setting is doing real work on a site that has never configured delivery, and an unset one
|
||||
leaves a contact form that goes nowhere.
|
||||
</Aside>
|
||||
|
||||
## The environment file, in three groups
|
||||
|
||||
You wrote these in [Install the site](/docs/getting-started/install-the-site/); this is
|
||||
what they mean when you come back to them.
|
||||
|
||||
**Identity and process** — `NODE_ENV`, `PORT`, `INTERNAL_PORT`, `IMAGE_TAG`. `INTERNAL_PORT`
|
||||
is the server-to-bot channel and must never be published or proxied.
|
||||
|
||||
**Data and secrets** — the `DB_*` group, `JWT_SECRET`, `SECRET_ENC_KEY`, `BOT_INTERNAL_KEY`.
|
||||
The last two are required in production, and `SECRET_ENC_KEY` is the key everything else
|
||||
encrypted at rest is keyed by: change it and the stored secrets become unreadable.
|
||||
|
||||
**Behaviour at the edge** — `TRUST_PROXY`, `COOKIE_SECURE`, `COOKIE_NAME`,
|
||||
`JWT_EXPIRES_IN`. `COOKIE_NAME` is worth one warning: changing it on a live site logs
|
||||
everybody out.
|
||||
|
||||
<Aside type="caution" title="`MODULE_SOURCE_HOSTS` is bootstrap only">
|
||||
It seeds the module install allowlist the first time a site boots without one. After that
|
||||
the **setting** is authoritative and is edited in Admin → Modules — changing the variable on
|
||||
an existing deployment does nothing, deliberately, so a redeploy cannot silently undo an
|
||||
administrator's choice.
|
||||
</Aside>
|
||||
|
||||
## Branding is data, not configuration
|
||||
|
||||
The `BRAND_*` variables and the `/brand` mount are how one prebuilt image runs as any
|
||||
community's site. They get their own page:
|
||||
[Branding and theming](/docs/administration/branding-and-theming/).
|
||||
62
src/content/docs/docs/administration/content.mdx
Normal file
@@ -0,0 +1,62 @@
|
||||
---
|
||||
title: Content
|
||||
description: Posts and their categories, the wiki and its sections, and the activity log that records who changed what.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
Three content surfaces, one for each shape of writing a community does.
|
||||
|
||||
| Surface | For | Lives at |
|
||||
|---|---|---|
|
||||
| **Posts** | Dated writing: news, the newsletter, screenshots | `/site/news` and friends |
|
||||
| **Pages** | Standing pages: About, Rules, Donate — see [Navigation and pages](/docs/administration/navigation-and-pages/) | its own slug |
|
||||
| **Wiki** | Reference the community maintains: guides, lore, systems | `/wiki` |
|
||||
|
||||
## Posts
|
||||
|
||||
**Admin → Posts**, filtered by category. A new deployment seeds four:
|
||||
|
||||
- **News** — the default, and the one wired to announcements.
|
||||
- **Five on Friday** — a recurring short-form format.
|
||||
- **Newsletter** — longer, periodic.
|
||||
- **Screenshots** — image posts.
|
||||
|
||||
Each post is a draft until it is published, and the Posts list shows status and date at a
|
||||
glance.
|
||||
|
||||
<Aside type="caution" title="Publishing a news post announces it">
|
||||
Publishing is what triggers the announcement pipeline — the Discord `#news` leg, and any leg
|
||||
an installed module adds, such as the `uo` module's in-game town crier. It fires on
|
||||
publication, so an accidental publish is an accidental announcement. See
|
||||
[Notifications and email](/docs/administration/notifications-and-email/).
|
||||
</Aside>
|
||||
|
||||
## The wiki
|
||||
|
||||
**Admin → Wiki** lists every page with its section and status, and **Manage sections**
|
||||
edits the grouping itself. A new site starts with eight pages in four sections — Guides,
|
||||
World & Lore, Systems & Gameplay, Community & Rules — as a skeleton to write into.
|
||||
|
||||
They are placeholders. None of them describes your game, and leaving them published means
|
||||
publishing an empty guide to it; either write them or unpublish them before you go live.
|
||||
|
||||
## Who may write what
|
||||
|
||||
Roles decide it, and the split is the useful part:
|
||||
|
||||
- **Editor** — the content roles. Posts, pages, wiki, and the activity log.
|
||||
- **Moderator** — moderation and Teams, not content authoring.
|
||||
- **Admin** — everything, including the system screens.
|
||||
|
||||
Full table in [Users and roles](/docs/administration/users-and-roles/).
|
||||
|
||||
## The activity log
|
||||
|
||||
**Admin → Activity** records what staff did: the action, a detail line, who did it, from
|
||||
which address, and when. Module installs, logins, content changes and moderation all land
|
||||
here.
|
||||
|
||||
Two things it is good for beyond curiosity: reconstructing what changed just before
|
||||
something broke, and confirming that an account which should not have done something did
|
||||
not. It is a record, not a workflow — nothing is actioned from this screen.
|
||||
@@ -0,0 +1,125 @@
|
||||
---
|
||||
title: Maintenance and upgrades
|
||||
description: Upgrading the image, pinning a build, what to back up and how, where the logs are, and the reverse proxy.
|
||||
---
|
||||
|
||||
import { Aside, Steps } from '@astrojs/starlight/components';
|
||||
|
||||
## Upgrading the site
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
That is the whole routine. The image carries the server and the built client together;
|
||||
schema changes are applied on boot, and installed modules are on a volume the upgrade does
|
||||
not touch.
|
||||
|
||||
**Pin a build when you want a deploy you can reproduce.** `IMAGE_TAG` defaults to `latest`;
|
||||
every merge also publishes `sha-<7>`, so
|
||||
|
||||
```bash
|
||||
IMAGE_TAG=sha-042a151 docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
deploys an exact build, and putting that value in `.env` makes it the one this host runs
|
||||
until you change it. Rolling back is the same command with the previous tag — with one
|
||||
caveat that decides whether it works.
|
||||
|
||||
<Aside type="caution" title="A rollback is only safe if the schema did not move">
|
||||
Upgrades apply schema changes on boot; nothing un-applies them. Rolling the image back to a
|
||||
build that predates a schema change leaves the older code looking at a newer database.
|
||||
Restore the backup you took first, or stay forward.
|
||||
</Aside>
|
||||
|
||||
## Back up before you upgrade
|
||||
|
||||
Two volumes and one directory hold everything that cannot be re-downloaded: the database,
|
||||
the uploads, and `./modules`.
|
||||
|
||||
<Steps>
|
||||
|
||||
1. **Dump the database.** From the deployment directory, while the stack is up:
|
||||
|
||||
```bash
|
||||
docker compose exec -T db sh -c \
|
||||
'mariadb-dump -u root -p"$MARIADB_ROOT_PASSWORD" --single-transaction --routines runic_gateway' \
|
||||
> backup-$(date +%F).sql
|
||||
```
|
||||
|
||||
`--single-transaction` is what makes it consistent without locking the site.
|
||||
|
||||
2. **Copy the uploads volume.**
|
||||
|
||||
```bash
|
||||
docker run --rm -v <deployment>_uploads:/from -v "$PWD":/to alpine \
|
||||
tar czf /to/uploads-$(date +%F).tgz -C /from .
|
||||
```
|
||||
|
||||
The volume is named after the directory Compose runs in — `docker volume ls` shows the
|
||||
exact names.
|
||||
|
||||
3. **Keep `./modules`, `./brand` and your two files.** They are ordinary host directories;
|
||||
whatever backs up the rest of the host covers them.
|
||||
|
||||
</Steps>
|
||||
|
||||
Restoring the database is the same command inverted — `mariadb … < backup.sql` — into a
|
||||
stack whose image is the one the dump came from.
|
||||
|
||||
## Logs
|
||||
|
||||
`./logs/app.log` on the host, because the Compose file bind-mounts it there. `docker compose
|
||||
logs -f app` shows the same stream live.
|
||||
|
||||
`LOG_LEVEL` sets console verbosity and `FILE_LOG_LEVEL` the file's — the file keeps the
|
||||
fuller record on purpose. Nothing rotates them for you.
|
||||
|
||||
## Restarting
|
||||
|
||||
`docker compose restart app` is the ordinary restart, and it is what the admin panel's
|
||||
**Restart the server** button amounts to. Restarts are needed after installing, enabling or
|
||||
uninstalling a module, and are harmless otherwise.
|
||||
|
||||
`docker compose down` stops everything and keeps the data. **`docker compose down -v` also
|
||||
deletes the volumes** — the database and every upload. There is no undo.
|
||||
|
||||
## The reverse proxy
|
||||
|
||||
The app publishes port 3000 and binds all interfaces, so any proxy that can reach the host
|
||||
can serve it. Two settings make it correct rather than merely working, both covered in
|
||||
[Install the site](/docs/getting-started/install-the-site/): `TRUST_PROXY`, so the address
|
||||
your rate limiting and IP bans act on is the visitor's rather than the proxy's, and
|
||||
`COOKIE_SECURE=auto`.
|
||||
|
||||
Three rules for whatever proxy you use:
|
||||
|
||||
- **Forward only 3000.** `INTERNAL_PORT` (3001) is the server-to-bot channel and must never
|
||||
be reachable from outside; the Compose file deliberately does not publish it.
|
||||
- **Deny `/api/v1/internal` at the proxy** as well. Belt and braces: that route no longer
|
||||
rides the public listener, and an explicit deny costs nothing.
|
||||
- **Terminate TLS at the proxy.** The app speaks HTTP; it is not meant to hold a
|
||||
certificate.
|
||||
|
||||
## Upgrading the shard side
|
||||
|
||||
A different deployment on a different host, and it moves on its own schedule:
|
||||
|
||||
```bash
|
||||
sudo runicgateway update # re-resolves the bundle; --verify to see it first
|
||||
sudo runicgateway doctor # confirm afterwards
|
||||
```
|
||||
|
||||
`update` replaces the sidecar and restarts its service, re-syncs the overlay, and tells you
|
||||
when ServUO needs restarting — it never restarts your shard itself. Because it resolves a
|
||||
**bundle**, the sidecar and the plugin move together and cannot end up disagreeing about the
|
||||
protocol.
|
||||
|
||||
<Aside type="note" title="Update the two sides in either order, but verify after each">
|
||||
They are independent deployments joined by a version-checked contract: a mismatch is
|
||||
rejected with a `409` rather than mis-parsed. So the worst case is a bridge that refuses to
|
||||
pair until both sides are current — visible on
|
||||
[the shard connection screen](/docs/administration/the-shard-connection/), and not silent
|
||||
corruption.
|
||||
</Aside>
|
||||
107
src/content/docs/docs/administration/managing-modules.mdx
Normal file
@@ -0,0 +1,107 @@
|
||||
---
|
||||
title: Managing modules
|
||||
description: The five states a module can be in, installing and upgrading, disable versus uninstall versus purge, and what to do when one fails to start.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
import Screenshot from '../../../../components/Screenshot.astro';
|
||||
|
||||
Installing your first module is [Getting started](/docs/getting-started/install-a-game-module/).
|
||||
This is what the screen means afterwards.
|
||||
|
||||
<Screenshot id="admin-modules" />
|
||||
|
||||
## The five states
|
||||
|
||||
`installed → enabled → started`, with `disabled` and `startup_failed` as recoverable
|
||||
states.
|
||||
|
||||
| State | Means |
|
||||
|---|---|
|
||||
| **Installed** | Files are on the volume; it mounts at the next restart |
|
||||
| **Enabled** | Allowed to run, and about to be loaded. Every boot resets each non-disabled module to this, then records the outcome |
|
||||
| **Started** | Running: routes mounted, schema applied |
|
||||
| **Disabled** | An operator switched it off. Its routes answer *not found* |
|
||||
| **Startup failed** | It tried and could not. The site came up without it |
|
||||
|
||||
**A module that fails to load never takes the site down.** Failure is caught across the
|
||||
whole lifecycle — require, schema, routes, registration, boot hook — and the site starts
|
||||
with that module's routes and navigation absent, and the reason recorded on this screen.
|
||||
|
||||
Two consequences of how boots work:
|
||||
|
||||
- **A failed module is retried on every restart.** Fix the underlying cause and restart; you
|
||||
do not need to touch the panel. A deterministically broken module re-records its failure
|
||||
each boot, which is the honest thing for it to do.
|
||||
- **Disabled is the only state a boot leaves alone.** Disabling is an operator's decision
|
||||
rather than an outcome, so it survives restarts untouched.
|
||||
|
||||
## Upgrading
|
||||
|
||||
Paste the new release's install-manifest URL and press Install. The bundle is verified
|
||||
against its `sha256`, unpacked over the old one, and takes effect at the restart.
|
||||
|
||||
An upgrade **deliberately leaves the state alone**: upgrading an enabled module must not
|
||||
silently switch it off, and re-installing a disabled one must not silently switch it on.
|
||||
|
||||
<Aside type="caution" title="Check the Module API version before upgrading">
|
||||
A module declares which core API versions it accepts. If a module release requires a newer
|
||||
core than your image, upgrade the site first — see
|
||||
[Maintenance and upgrades](/docs/administration/maintenance-and-upgrades/).
|
||||
</Aside>
|
||||
|
||||
## Disable, uninstall, purge
|
||||
|
||||
Three different actions, in increasing order of destruction.
|
||||
|
||||
**Disable** flips the row and dispatches that module's shutdown hook, so it actually stops
|
||||
— releases its sockets, closes its streams — rather than merely becoming unreachable. Enable
|
||||
is deliberately not the mirror image: there is no boot hook re-dispatch, so enabling offers
|
||||
a restart.
|
||||
|
||||
**Uninstall** is non-destructive by default: the row goes to `disabled`, the directory is
|
||||
removed, and **the module's tables and data are retained**.
|
||||
|
||||
**Purge** runs the module's own `purge.sql` and destroys its data. It is never implied by
|
||||
an uninstall, and it is offered in two places — as a standalone action on an installed
|
||||
module, and as an opt-in checkbox in the uninstall dialog.
|
||||
|
||||
<Aside type="caution" title="Purge only works while the files are still there">
|
||||
`purge.sql` lives inside the directory an uninstall deletes. Uninstalling without ticking
|
||||
the box keeps the tables, and getting rid of them later means **reinstalling the module
|
||||
first**. Decide at the uninstall, not afterwards.
|
||||
</Aside>
|
||||
|
||||
## Where modules may be installed from
|
||||
|
||||
The allowlist at the bottom of the screen. Installing a module runs its code inside your
|
||||
server, so only listed hosts are permitted, over HTTPS, re-checked on every redirect. An
|
||||
empty list forbids every install.
|
||||
|
||||
`MODULE_SOURCE_HOSTS` seeds this list on a site's first boot and is ignored afterwards —
|
||||
the setting is authoritative, so a redeploy cannot silently undo your choice.
|
||||
|
||||
## The declarative path
|
||||
|
||||
`MODULES` in `.env` declares the set this deployment runs, resolved at every container
|
||||
start, each entry `<id>@<version>=<install manifest URL>`.
|
||||
|
||||
The division of ownership is the thing to remember: **the variable owns what is on the
|
||||
volume; the panel owns whether a module runs.** Uninstall a declared module from the panel
|
||||
and its files come back at the next start — disabled.
|
||||
|
||||
A module already unpacked at the declared version is a no-op that makes **no network call
|
||||
at all**, so a restart with no route to the internet comes up unchanged. A version that
|
||||
cannot be fetched is logged, shown on this screen, and never stops the site starting.
|
||||
|
||||
## Placing one by hand
|
||||
|
||||
Unpacking a module tarball into `./modules/<id>/` and restarting is a supported install —
|
||||
it is why that path is a bind mount rather than a named volume. The row it produces has no
|
||||
provenance columns, because nothing downloaded it.
|
||||
|
||||
<Aside type="note" title="Do not delete the `modules` directory itself">
|
||||
Docker recreates a missing bind-mount source as `root`, and the container user can then no
|
||||
longer write it — which breaks installing from the panel. If that happens,
|
||||
`chown 1000:1000 modules` on the host.
|
||||
</Aside>
|
||||
68
src/content/docs/docs/administration/moderation.mdx
Normal file
@@ -0,0 +1,68 @@
|
||||
---
|
||||
title: Moderation
|
||||
description: Three screens that do three different jobs — Discord moderation, content reports, and appeals against a sanction.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
The **Moderation** group in the sidebar holds three screens that are easy to confuse and do
|
||||
not overlap.
|
||||
|
||||
| Screen | Is about | Comes from |
|
||||
|---|---|---|
|
||||
| **Moderation** | Your **Discord** guild — bans, kicks, mutes, warnings, joins, leaves, filter and spam hits | the bot, captured live |
|
||||
| **Reports** | **Team forum content** members have reported | the site |
|
||||
| **Appeals** | Sanctions people are asking you to reverse | the site |
|
||||
|
||||
## Moderation (Discord)
|
||||
|
||||
Counts across a window you choose — 24 hours, 7 days, 30 days — for bans, kicks, mutes,
|
||||
warnings, joins, leaves, filter hits and spam hits, with a filterable list of recent
|
||||
actions and a tabbed event log (members, filter hits, spam hits).
|
||||
|
||||
Everything here arrives from the Discord bot, so a site with no bot configured shows zeros
|
||||
and empty lists rather than an error. Setting the bot up is
|
||||
[Notifications and email](/docs/administration/notifications-and-email/).
|
||||
|
||||
**Look up** takes you to a per-user view when you are investigating one account rather than
|
||||
browsing the window.
|
||||
|
||||
## Reports
|
||||
|
||||
Reports raised by members about Team forum content. Two design decisions show through in
|
||||
how this screen behaves:
|
||||
|
||||
- **They come to site staff, and a Team's own leaders never see them.** A leader moderates
|
||||
their own forum, so a report *about a leader* has to reach someone above them.
|
||||
- **Handling a report records a decision about the report.** It does not touch the content:
|
||||
hiding or removing a post is done in the forum, or as a sanction against the account.
|
||||
|
||||
The filters are *Open*, *Reviewing*, *Actioned*, *Dismissed* and *All*, and the count of
|
||||
open reports sits at the top so the screen is glanceable.
|
||||
|
||||
<Aside type="note" title="Dismissing is a real outcome, not a failure to act">
|
||||
A report that was not a problem should be dismissed rather than left open — an open queue
|
||||
that never empties stops being read, and the reporter's next report is the one that
|
||||
matters.
|
||||
</Aside>
|
||||
|
||||
## Appeals
|
||||
|
||||
An appeal is a request to reverse a sanction, filtered by *Open*, *Pending*, *Under
|
||||
review*, *Approved*, *Denied*, *Withdrawn* or *All*. Each row carries the target, the
|
||||
action being appealed, the appeal itself, who submitted it, its age and whether a reversal
|
||||
happened.
|
||||
|
||||
Two things worth building a habit around:
|
||||
|
||||
- **Age is the column that matters.** An appeal that nobody has looked at for three weeks
|
||||
is a worse outcome than a denial.
|
||||
- **The decision is recorded either way.** Approving an appeal records the reversal, so the
|
||||
history explains itself later without anyone having to remember.
|
||||
|
||||
## What is recorded, and where
|
||||
|
||||
Every staff action lands in **Admin → Activity** — who did what, from which address, when.
|
||||
That log is the thing to read when reconstructing a disputed decision, and it is a record
|
||||
rather than a workflow: nothing is actioned from it. See
|
||||
[Content](/docs/administration/content/).
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
title: Navigation and pages
|
||||
description: Renaming, reordering and hiding navigation entries in three navs, and composing standalone pages from blocks.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
## Navigation
|
||||
|
||||
**Admin → Navigation** edits three separate navigations — **Public site**, **Admin** and
|
||||
**Player portal** — each with the same tools: rename an entry, reorder it, hide it, group
|
||||
entries into a dropdown section, or add a link of your own.
|
||||
|
||||
A fresh site's public nav is the seeded one: the portal, News, Screenshots, Five on Friday,
|
||||
Newsletter, the wiki, and About. Until you change anything, the nav "renders exactly as
|
||||
coded" — there is no stored copy to drift from the code.
|
||||
|
||||
Two properties are worth understanding before you rely on this screen.
|
||||
|
||||
**It advertises; it does not authorise.** Renaming or hiding an entry changes what is
|
||||
listed, never what exists or who may reach it. Hiding *Wiki* does not close the wiki. Access
|
||||
is decided by roles and by a module's visibility settings, and this screen "can never show
|
||||
anyone a link their role, or the visibility settings of an installed module, would hide".
|
||||
|
||||
**You only edit what you can see.** Entries hidden from *you* — by your role, or by a
|
||||
module's visibility rules — are not listed, and they keep whatever setting they already
|
||||
had. So an administrator's view of this screen is not necessarily the whole nav, and
|
||||
editing it cannot damage the parts you cannot see.
|
||||
|
||||
<Aside type="note" title="A module's pages appear here like anything else">
|
||||
An installed module adds its own entries, and they can be renamed, reordered, grouped and
|
||||
hidden exactly like core's. What you cannot do is *reach past* the module's own visibility
|
||||
settings — those are set with the module, not here.
|
||||
</Aside>
|
||||
|
||||
**Reset to default** discards your customisation for that nav and goes back to the coded
|
||||
one. It is per-nav, not global.
|
||||
|
||||
## Pages
|
||||
|
||||
**Admin → Pages** composes standalone pages from blocks. A published page is live at its
|
||||
slug — `/about`, `/rules`, `/donate` — and a draft is visible only to staff.
|
||||
|
||||
This is the right tool for content that is not news and not a wiki article: the pages a
|
||||
navigation entry points at. A page you create is not linked from anywhere until you add it
|
||||
in **Navigation** — deliberately, because the two are separate decisions.
|
||||
|
||||
For everything else — news posts, the newsletter, screenshots, the wiki — see
|
||||
[Content](/docs/administration/content/).
|
||||
@@ -0,0 +1,91 @@
|
||||
---
|
||||
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.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
Four separate delivery paths, each optional, each off until you configure it. A site that
|
||||
configures none of them still works — it just never reaches anyone who is not looking at
|
||||
it.
|
||||
|
||||
## Email
|
||||
|
||||
**Admin → Settings → Email delivery.** The site sends contact-form messages (and test
|
||||
messages) through **Gmail over OAuth2**, delivered to the *Contact email* setting.
|
||||
|
||||
It reuses the **Google authentication client**, so the order is fixed: configure Google on
|
||||
the [Authentication](/docs/administration/authentication/) page first, then press **Connect
|
||||
Gmail** here. Until then the panel reads *Unconfigured* and says exactly that.
|
||||
|
||||
The refresh token it stores is encrypted at rest like every other secret.
|
||||
|
||||
<Aside type="note" title="There is no SMTP option">
|
||||
Gmail over OAuth2 is the only supported delivery path today. Until it is connected, the
|
||||
contact form falls back to a `mailto:` link to the contact address — which works, and puts
|
||||
the message in the visitor's own mail client rather than in your logs.
|
||||
</Aside>
|
||||
|
||||
## Announcements
|
||||
|
||||
Publishing a **news** post fans it out to every registered delivery leg. The dispatcher is
|
||||
an in-process poller, tuned by `ANNOUNCE_POLL_MS` (15 seconds by default), and the links in
|
||||
an announcement are built from `APP_BASE_URL` — so set that in production or the links point
|
||||
at the wrong host.
|
||||
|
||||
Which legs exist depends on what has registered one:
|
||||
|
||||
- **Discord `#news`** is core's, and needs the bot below.
|
||||
- **A module may add its own.** The `uo` module adds an in-game town crier, so a news post
|
||||
is announced to players who are logged into the game and never visit the site.
|
||||
|
||||
A leg brings its own settings with it — the town crier's duration is a module setting, not
|
||||
a core one — which is why they are documented with the module rather than here.
|
||||
|
||||
## The Discord bot
|
||||
|
||||
**Admin → Discord Bot**: enable it, give it the guild (server) ID and the bot token, and
|
||||
save. The token is stored **encrypted in the database** and is never an environment
|
||||
variable.
|
||||
|
||||
The bot is a separate container. On the quickstart deployment from
|
||||
[Install the site](/docs/getting-started/install-the-site/) it is not running at all, and
|
||||
the panel says so — *bot unreachable* is the honest state of a site that never started one,
|
||||
not a failure. Add the `bot` service from the project's shipped Compose file when you want
|
||||
it.
|
||||
|
||||
What it does once connected: posts announcements, captures the moderation events on the
|
||||
[Moderation](/docs/administration/moderation/) screen, serves slash commands, and — if you
|
||||
switch them on — the Team notification bridge and per-Team voice channels from
|
||||
[Teams](/docs/administration/teams/).
|
||||
|
||||
## Push notifications
|
||||
|
||||
Opt-in push to the Android app, over a **self-hosted ntfy relay** — the `ntfy` service in
|
||||
the project's Compose file, plus `NTFY_BASE_URL` and friends.
|
||||
|
||||
Two properties matter for what you have to trust:
|
||||
|
||||
- **The relay only ever carries a content-free tickle.** The message says something
|
||||
happened; the app then fetches the actual content from the site over its own
|
||||
authenticated connection. So the relay never sees notification text.
|
||||
- **A device may only register an endpoint on an allowed origin**, derived from
|
||||
`NTFY_BASE_URL`. That is what stops a device pointing your server at somebody else's.
|
||||
|
||||
Without `NTFY_PUBLIC_URL` / `NTFY_ALLOWED_ORIGINS`, the app simply shows push as
|
||||
unavailable for your instance — nothing breaks.
|
||||
|
||||
## Who receives what
|
||||
|
||||
The per-person side of this lives in the player portal, not the admin panel: each member
|
||||
chooses which Team and forum notifications they want, and how. Two defaults are worth
|
||||
knowing because they are not symmetrical:
|
||||
|
||||
- **Push is opt-out** once a device is registered.
|
||||
- **Email is opt-in.**
|
||||
|
||||
<Aside type="caution" title="Nothing here retries">
|
||||
The announcement dispatcher sends once, and the Team notification bridge states plainly that
|
||||
a message is sent once and not retried. If Discord is down when a post is published, that
|
||||
announcement is gone — the post is still on the site, which is the thing that matters.
|
||||
</Aside>
|
||||
77
src/content/docs/docs/administration/teams.mdx
Normal file
@@ -0,0 +1,77 @@
|
||||
---
|
||||
title: Teams
|
||||
description: Core owns the Team machinery and cannot create a Team. What that means in practice, and what the admin screen controls.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
Teams are a core platform primitive: membership, roles, forums, notifications, moderation
|
||||
and the Discord integrations are all core's, and none of it knows what a Team *is* in your
|
||||
game.
|
||||
|
||||
**Core cannot create a Team.** Teams arrive from the installed module — with the `uo`
|
||||
module, they are the shard's guilds. On a deployment with no module, the Team machinery is
|
||||
present and permanently empty. That is not a bug to work around; it is the contract that
|
||||
lets the same forum, notification and moderation code serve any game.
|
||||
|
||||
<Aside type="note" title="What that means when you are looking at an empty screen">
|
||||
*No Teams in the projection yet* on a site with no module installed is the correct and
|
||||
final state. Install a module, connect its game server, and Teams appear as that module
|
||||
reconciles them.
|
||||
</Aside>
|
||||
|
||||
## The projection, and why it can be stale
|
||||
|
||||
**Admin → Teams** shows a sync panel per module: last attempt, last success, consecutive
|
||||
failures and the last error, with **Sync now** and **Resync now**.
|
||||
|
||||
The wording on that panel is exact and worth reading:
|
||||
|
||||
> Core has never had an answer it could trust. What is shown below is not a confirmed empty
|
||||
> shard.
|
||||
|
||||
An empty list therefore means one of two very different things — there are no Teams, or
|
||||
nobody could ask. The panel tells you which, and a *last success: never* with a *last
|
||||
error* of `no uo-link configured` is the second. Fix
|
||||
[the shard connection](/docs/administration/the-shard-connection/) and sync again.
|
||||
|
||||
## Forums
|
||||
|
||||
Team forums are switched on site-wide in **Settings**, along with whether images are
|
||||
allowed and how long an author may edit a post — see
|
||||
[Configuration](/docs/administration/configuration/).
|
||||
|
||||
Two rules are structural rather than settings:
|
||||
|
||||
- **A Team's leaders moderate their own forum.** That is the point of a Team forum.
|
||||
- **Reports about that forum do not go to them.** They go to site staff, because a report
|
||||
about a leader has to reach someone above them. See
|
||||
[Moderation](/docs/administration/moderation/).
|
||||
|
||||
## The Discord bridges
|
||||
|
||||
Two integrations, both optional, both configured from **Admin → Teams**.
|
||||
|
||||
**Notification bridge** — sends Team notifications to a Discord channel: a default for
|
||||
every Team, overridable per Team. A message is sent once and never retried; the bridge is a
|
||||
courtesy, and nothing on the site depends on it arriving. With nothing configured, no Team
|
||||
event leaves the site.
|
||||
|
||||
**Voice channels** — gives each Team a Discord voice channel of its own, with access
|
||||
granted by a per-Team role, so a Team's members can see and join theirs and nobody else
|
||||
can. It needs the bot reachable, and members need a linked Discord account and guild
|
||||
membership.
|
||||
|
||||
Its three settings deserve a thought each:
|
||||
|
||||
| Setting | What it decides |
|
||||
|---|---|
|
||||
| **Minimum members** | How large a Team must be to get a channel. Every active member counts, linked account or not. |
|
||||
| **Grace window (days)** | How long a Team keeps its channel after it stops qualifying. A Team that recovers inside the window keeps the same channel; zero removes it on the next pass. |
|
||||
| **Staff roles** | Roles that can see and join every Team's channel. Guild administrators already can, so this is for staff who are not administrators. |
|
||||
|
||||
<Aside type="caution" title="Voice channels are a per-guild ceiling, not a per-Team one">
|
||||
Discord's role and channel limits apply to the whole guild, so a site with many small Teams
|
||||
can exhaust them. The minimum-members setting is the lever that keeps the count sane, and
|
||||
it is easier to raise it before provisioning than to unpick channels afterwards.
|
||||
</Aside>
|
||||
101
src/content/docs/docs/administration/the-shard-connection.mdx
Normal file
@@ -0,0 +1,101 @@
|
||||
---
|
||||
title: The shard connection
|
||||
description: The module's shard screen — connection settings, what the status line means, game-account creation, the town crier, and what reaches the public.
|
||||
---
|
||||
|
||||
import platform from '../../../../data/platform.json';
|
||||
import Screenshot from '../../../../components/Screenshot.astro';
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
With the `uo` module installed, **Shard (uo-link)** appears in the admin sidebar at
|
||||
`/admin/uo/link`. It is the site's half of the bridge: the connection to the sidecar, and
|
||||
the controls that ride on it.
|
||||
|
||||
Setting it up for the first time is
|
||||
[Connect a game server](/docs/getting-started/connect-a-game-server/).
|
||||
|
||||
<Screenshot id="admin-shard" />
|
||||
|
||||
## Connection
|
||||
|
||||
Four fields, all four printed by the installer, plus the switch that turns the integration
|
||||
on:
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| **Base URL (REST)** | `http://<shard host>:8080` — point-in-time queries |
|
||||
| **WebSocket URL (feed)** | `ws://<shard host>:8080/ws` — the live event feed |
|
||||
| **Auth token** | The sidecar's token |
|
||||
| **Protocol** | {platform.protocol} today |
|
||||
|
||||
Saving restarts the ingest client, so a change takes effect immediately.
|
||||
|
||||
**The token is write-only.** It is encrypted at rest and never returned to any client, so
|
||||
the field is blank when you come back to the screen — losing it means reading it back from
|
||||
`sidecar.toml` on the shard host, not from the website.
|
||||
|
||||
## Reading the status line
|
||||
|
||||
The header carries the connection state, *Shard link*, *WS ingest*, *Reconnects* and *SSE
|
||||
clients*. Together they say **which** link is broken:
|
||||
|
||||
| Reading | Means |
|
||||
|---|---|
|
||||
| Disconnected, shard link down | The site cannot reach the sidecar at all — URL, firewall, or the service is not running |
|
||||
| Connected, but shard link down | The sidecar is up and the *game* is not talking to it |
|
||||
| Reconnects climbing | An unstable path between site and sidecar |
|
||||
| Live feed silent, everything else green | The bridge is fine and the shard is quiet |
|
||||
|
||||
A `409` in the logs is a protocol mismatch — set the Protocol field to what the sidecar's
|
||||
`/health` reports rather than guessing; it rejects rather than mis-parsing. A `401` is the
|
||||
token.
|
||||
|
||||
<Aside type="note" title="The site is designed to look normal while this is broken">
|
||||
Every read through the sidecar returns a result rather than throwing, so the public site
|
||||
renders with the shard shown offline. That is deliberate graceful degradation, and it is
|
||||
also why a broken bridge can go unnoticed — this screen, or `runicgateway doctor` on the
|
||||
shard host, is how you find out.
|
||||
</Aside>
|
||||
|
||||
## Game-account creation
|
||||
|
||||
Whether players can create a **game** account (for the game client) from the website. The
|
||||
game server's own `SignupMode` in `Bridge.cfg` has to agree.
|
||||
|
||||
| Mode | Behaviour |
|
||||
|---|---|
|
||||
| **Disabled** | Players may only link an account that already exists |
|
||||
| **Website** | The site creates game accounts |
|
||||
| **Hybrid** | Site or in-game — the recommended setting |
|
||||
| **Game only** | Created in the game client; the site only links |
|
||||
|
||||
With creation enabled, a *Create a game account* form appears in the player portal and
|
||||
after an invite is accepted.
|
||||
|
||||
## Town crier
|
||||
|
||||
Broadcast a message every in-game town crier announces until it expires: an id, one or more
|
||||
lines, and a duration in seconds. Re-posting the same id **replaces** that message, and
|
||||
**Remove by id** takes it down early.
|
||||
|
||||
The id is the useful part — give a recurring announcement a stable one and you can update or
|
||||
withdraw it without waiting for it to expire.
|
||||
|
||||
## What reaches the public
|
||||
|
||||
Events from the shard fan out over two separate streams, and the split is a security
|
||||
boundary rather than a preference:
|
||||
|
||||
- **The public stream** carries an allowlist of event kinds.
|
||||
- **The admin stream** adds staff audit events, cheat detection, login attempts and IP
|
||||
addresses.
|
||||
|
||||
The live feed at the bottom of this screen is the admin one — everything, as it arrives.
|
||||
Treat it accordingly: it is the screen you do not put in a screenshot.
|
||||
|
||||
<Aside type="caution" title="Visibility is decided on the website, not on the sidecar">
|
||||
The sidecar is a dumb forwarder. What is public, what is staff-only and what is off is
|
||||
decided on the site, so changing your mind is a settings change rather than a shard
|
||||
redeploy — and it also means an unreviewed default is a decision you have made by not
|
||||
making it.
|
||||
</Aside>
|
||||
118
src/content/docs/docs/administration/troubleshooting.mdx
Normal file
@@ -0,0 +1,118 @@
|
||||
---
|
||||
title: Troubleshooting
|
||||
description: The failures a deployment actually hits, what each one looks like, and the fix.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
Grouped by where the problem is, because the first useful question is always *which half is
|
||||
broken*.
|
||||
|
||||
## The site will not start
|
||||
|
||||
Read the log first — `docker compose logs app` — because the server says exactly why.
|
||||
|
||||
| What the log says | What it means |
|
||||
|---|---|
|
||||
| `SECRET_ENC_KEY must be set in production` | The key that encrypts stored secrets is missing. Set it in `.env` and start again. The container crash-loops until you do. |
|
||||
| A `BOT_INTERNAL_KEY` complaint | Blank, still a placeholder, or shorter than 16 characters. Required in production even when the bot is not running. |
|
||||
| A database connection error, repeatedly | The app came up before the database was ready, or `DB_*` is wrong. The Compose file's health check handles the first case; check the values for the second. |
|
||||
| Nothing at all, container restarting | The image did not pull. `docker compose pull` on its own shows the error. |
|
||||
|
||||
<Aside type="caution" title="Both of those key errors happen on the FIRST boot, not later">
|
||||
They are checked at require time, before the server listens. A deployment that has ever
|
||||
served a request has both of them set.
|
||||
</Aside>
|
||||
|
||||
## Nobody can sign in
|
||||
|
||||
- **Your address is rate-limited or bot-banned.** Both are working as designed. Check
|
||||
**Admin → Web Bot Activity** from another network, or restart the app container — the
|
||||
scoring state is in memory and resets with it.
|
||||
- **The password is right and the form still fails.** Check the log for the actual status:
|
||||
a `429` is the rate limiter, a `403` is usually the honeypot, and a `401` really is the
|
||||
password.
|
||||
- **SSO returns to the login page.** SSO is link-only: an identity that is not already
|
||||
linked to an account cannot sign in, and that is the expected outcome rather than a
|
||||
misconfiguration. Link it from the account screen first.
|
||||
- **Two-factor is lost.** Recovery codes, or another admin's **Reset two-factor** on
|
||||
**Users → View**. See [Authentication](/docs/administration/authentication/).
|
||||
|
||||
## A module will not start
|
||||
|
||||
**Admin → Modules** names the stage and the reason. The usual three:
|
||||
|
||||
| Reason | Fix |
|
||||
|---|---|
|
||||
| `module directory not present on the volume` | The row exists and the files do not — someone deleted the directory by hand. Reinstall, or remove the row with an uninstall. |
|
||||
| A schema failure | The module's schema fragment could not be applied. The log carries the SQL error. |
|
||||
| A version refusal | The module wants a newer core API than this image. Upgrade the site. |
|
||||
|
||||
Whatever the reason, **the site is up and the module's routes are absent** — that is by
|
||||
design, and it is why a broken module is an inconvenience rather than an outage. Fix the
|
||||
cause and restart: failed modules are retried on every boot.
|
||||
|
||||
**The install button rejects a URL.** The host must be in the allowlist on the same screen,
|
||||
and the URL must be HTTPS. An empty allowlist forbids every install.
|
||||
|
||||
**The install succeeds and nothing appears.** It needs a restart. The banner says so, and
|
||||
the row reads *Restart to start* until then.
|
||||
|
||||
## The Restart button did not bring the site back
|
||||
|
||||
The button exits the process and relies on a supervisor to start it again. If your
|
||||
deployment has nothing supervising it — `npm start` in a terminal, a container without
|
||||
`restart:` — the site stays down until you start it yourself. Compose with
|
||||
`restart: unless-stopped` is the supported shape.
|
||||
|
||||
## The game screens are empty or say offline
|
||||
|
||||
Work outwards from the game, and stop at the first check that fails.
|
||||
|
||||
1. **In game:** `[bridge status` — `connected=False` means the shard cannot reach the
|
||||
sidecar.
|
||||
2. **On the shard host:** `curl -s http://127.0.0.1:8080/health` — `plugin_connected: true`
|
||||
is the value that matters.
|
||||
3. **On the shard host:** `runicgateway doctor` — checks the install record, every overlay
|
||||
file hash, the service, and that the sidecar and overlay agree on a protocol.
|
||||
4. **On the site:** the [shard connection screen](/docs/administration/the-shard-connection/)
|
||||
— its four indicators say which link is broken.
|
||||
|
||||
Two log lines with specific meanings: **`409`** is a protocol mismatch (set the Protocol
|
||||
field to what `/health` reports), and **`401`** is the auth token (read the live one back
|
||||
with `uo-link-sidecar --print-config`; do not retype it from a screenshot).
|
||||
|
||||
<Aside type="note" title="“Nothing changed and it stopped working” usually means a ServUO update">
|
||||
An update to the server tree can revert `Scripts.csproj`, at which point the plugin sits in
|
||||
the tree and never compiles — and ServUO ignores the script build's exit code, so the boot
|
||||
looks clean. `doctor` catches it by comparing file hashes against the install record.
|
||||
</Aside>
|
||||
|
||||
## Teams are missing
|
||||
|
||||
Check the sync panel on **Admin → Teams** before anything else: *last success: never* with
|
||||
`no uo-link configured` means the shard connection, not the Team machinery. And on a site
|
||||
with **no module installed**, an empty Team list is correct and final — core cannot create
|
||||
a Team. See [Teams](/docs/administration/teams/).
|
||||
|
||||
## Email and announcements never arrive
|
||||
|
||||
- **The contact form opens a mail client.** Email delivery is not connected; that is the
|
||||
documented fallback. Connect Gmail in **Settings → Email delivery** — after configuring
|
||||
the Google provider, which it reuses.
|
||||
- **A published post announced nothing.** The Discord bot is a separate container. If the
|
||||
Discord Bot screen says *bot unreachable*, it is not running.
|
||||
- **A missed announcement does not come back.** Nothing retries; the post itself is still
|
||||
on the site.
|
||||
|
||||
## Uploads and modules fail with permission errors
|
||||
|
||||
Docker created a bind-mount source that the container user cannot write — usually because
|
||||
the directory was deleted and recreated by Docker as `root`. `chown 1000:1000 modules` (or
|
||||
`logs`, or `brand`) on the host fixes it. Do not delete those directories.
|
||||
|
||||
## When you need to ask for help
|
||||
|
||||
Bring three things: the relevant lines from `docker compose logs app`, the output of
|
||||
`runicgateway doctor` if a game server is involved, and what you changed last. The
|
||||
[community page](/community/) has where to ask.
|
||||
70
src/content/docs/docs/administration/users-and-roles.mdx
Normal file
@@ -0,0 +1,70 @@
|
||||
---
|
||||
title: Users and roles
|
||||
description: The four roles and what each one reaches, creating accounts, and inviting people to a site that is not open for registration.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
import Screenshot from '../../../../components/Screenshot.astro';
|
||||
|
||||
## The four roles
|
||||
|
||||
| Role | Reaches |
|
||||
|---|---|
|
||||
| **Player** | The player portal: their own profile, their own characters and game account links, their Teams, forum access, notification preferences |
|
||||
| **Moderator** | Everything a player has, plus Moderation, Appeals, Reports and the Teams admin screen |
|
||||
| **Editor** | Everything a player has, plus Posts, Pages, Wiki and the Activity log |
|
||||
| **Admin** | All of it, including Users, Invites, Settings, Modules, Appearance, Navigation, Authentication and the module's own admin screens |
|
||||
|
||||
<Aside type="note" title="Staff are players too">
|
||||
Every self-service screen in the player portal is role-agnostic: it serves whoever is signed
|
||||
in. An administrator has characters and Teams like anyone else, and reaches them through the
|
||||
same portal. Nothing about being staff removes the player half of an account.
|
||||
</Aside>
|
||||
|
||||
Admin routes are re-validated against the database on **every request**, not just at sign-in.
|
||||
Demoting an account takes effect at once — the open session does not keep its access until
|
||||
it expires.
|
||||
|
||||
<Screenshot id="admin-users" />
|
||||
|
||||
## Creating an account
|
||||
|
||||
**Admin → Users → + Add user** creates one directly: username, password, role, and it is
|
||||
active immediately. That is the right path for staff, and for the handful of accounts you
|
||||
create yourself.
|
||||
|
||||
The list shows each account's role, status and last login, with **View** and **Edit** on
|
||||
every row.
|
||||
|
||||
## Invites
|
||||
|
||||
**Admin → Invites** is the way to let a specific person in when self-registration is off —
|
||||
which is how every deployment starts.
|
||||
|
||||
Enter an email, pick the access level (player, moderator, editor or admin), and either
|
||||
**create and email** the invitation or generate a link to share yourself. The table tracks
|
||||
status, expiry and creation date, so an unaccepted invite is visible rather than forgotten.
|
||||
|
||||
This is worth preferring over creating accounts by hand for real people: the recipient sets
|
||||
their own password, and you never handle it.
|
||||
|
||||
## Opening registration
|
||||
|
||||
When you do want a public sign-up, that is **Settings → Player registration**: password,
|
||||
SSO, or both. See [Configuration](/docs/administration/configuration/).
|
||||
|
||||
Before opening it, know what is protecting the door: rate limiting, login backoff, a
|
||||
honeypot, bot scoring and automatic IP bans — all covered in
|
||||
[Authentication](/docs/administration/authentication/), along with two-factor and the SSO
|
||||
policy that an external identity can only ever sign in to an account it is already linked
|
||||
to.
|
||||
|
||||
## Status, and why deleting is the last resort
|
||||
|
||||
Editing an account sets its **status** as well as its role: *active*, *disabled*, *banned*
|
||||
or *pending*. Disabled and banned both stop the account being used; the difference is what
|
||||
you are recording — an account switched off versus an account sanctioned.
|
||||
|
||||
Prefer either to the **Delete** button. Content, moderation history and Team membership all
|
||||
reference the account, and a disabled one keeps those records readable while a deleted one
|
||||
leaves the history to explain itself.
|
||||
@@ -0,0 +1,123 @@
|
||||
---
|
||||
title: Authentication architecture
|
||||
description: One session model behind three very different front doors — cookies, bearer tokens and SSO — and where the boundaries actually are.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
The administrator's view of this is
|
||||
[Authentication](/docs/administration/authentication/). This is how it is built.
|
||||
|
||||
## One session service, three surfaces
|
||||
|
||||
The governing decision: **there is a single source of truth for sessions**, and every
|
||||
authentication surface produces the *same* session model.
|
||||
|
||||
```
|
||||
browser native app SSO provider
|
||||
(httpOnly JWT) (bearer + refresh) (OAuth2 / OIDC + PKCE)
|
||||
│ │ │
|
||||
└───────────────────┼────────────────────────┘
|
||||
▼
|
||||
sessionService
|
||||
createSession(user, authMethod)
|
||||
validateSession()
|
||||
```
|
||||
|
||||
Controllers call `createSession`; middleware calls `validateSession`. Nothing invents its
|
||||
own notion of "logged in".
|
||||
|
||||
That matters more than it sounds. Three front doors with three session implementations is
|
||||
three places for an authorization bug to hide, and the one that gets least attention is the
|
||||
one that gets exploited.
|
||||
|
||||
<Aside type="note" title="`utils/auth.js` is a facade">
|
||||
It exists for backward compatibility and is a thin wrapper. New work goes through the
|
||||
session service.
|
||||
</Aside>
|
||||
|
||||
## The three surfaces
|
||||
|
||||
**Web** — a JWT signed with `JWT_SECRET`, carried in an `httpOnly`, `sameSite=Lax` cookie.
|
||||
`secure` is decided **per request** (`COOKIE_SECURE=auto` → `secure: req.secure`), which is
|
||||
what lets one deployment work both over HTTPS through a proxy and over plain HTTP on a LAN
|
||||
address.
|
||||
|
||||
**Mobile** — short-lived bearer access tokens plus **rotated, hashed, revocable** refresh
|
||||
tokens. Hashed server-side, so a database disclosure does not hand over live sessions.
|
||||
|
||||
**SSO** — Google, Discord or a custom OIDC provider, PKCE-guarded.
|
||||
|
||||
## SSO is link-only, by policy
|
||||
|
||||
**An external identity must already be linked to an existing account.** Identities are
|
||||
never auto-provisioned.
|
||||
|
||||
This is a deliberate policy rather than an unimplemented feature. Auto-provisioning turns
|
||||
"anyone with a Google account" into "anyone with an account here", which is not a decision
|
||||
a site operator should make by installing an OAuth client.
|
||||
|
||||
## Admin is re-validated every request
|
||||
|
||||
Roles are **re-checked against the database on every admin request**, not trusted from the
|
||||
token.
|
||||
|
||||
The consequence is the point: a demoted user loses access **at once**, rather than when
|
||||
their token happens to expire. A stateless JWT that carried the role would keep asserting it
|
||||
for up to a day.
|
||||
|
||||
## Trusted devices gate the second factor only
|
||||
|
||||
A second, separate httpOnly cookie (`rg_trust`, 30 days by default) lets a browser or app
|
||||
**skip the TOTP step** on future logins — **never the password**.
|
||||
|
||||
Four properties, each chosen:
|
||||
|
||||
- It is **opaque and sha256-hashed server-side**, stored in a table. It is not a JWT claim,
|
||||
so the stateless session token is unchanged.
|
||||
- It is **per-row revocable**, from the admin panel or by the user.
|
||||
- It **deliberately outlives logout.** Logging out ends a session; it does not make the
|
||||
device untrusted, because the device is still the same device.
|
||||
- It is **cleared** on untrust, password change, password reset, or disabling TOTP.
|
||||
|
||||
**Recovery codes** (bcrypt, single-use) are the lockout fallback. Every trusted-device and
|
||||
MFA action is audit-logged.
|
||||
|
||||
## The login-hardening layer
|
||||
|
||||
Bot scoring with automatic IP banning, TOTP 2FA, a honeypot field, and rate limiting with
|
||||
backoff. The admin *Bot Activity* panel is deliberately **read plus emergency-unban only** —
|
||||
it is a window onto an automatic system, not a control surface for it.
|
||||
|
||||
## Where core's boundaries stop
|
||||
|
||||
Core's security boundaries end at **authentication, roles and the session**.
|
||||
|
||||
A module that serves game data brings its **own** audience rules, and core does not police
|
||||
them beyond the gates it hands over — `requireAuth`, `requireRole`, and the tier group
|
||||
gates. See [The module API](/docs/modules/the-module-api/#registerroutes-and-the-tier-gate).
|
||||
|
||||
`module-uo`'s is the worked example, and it is a real boundary rather than a convenience
|
||||
filter: an admin-configurable, per-feature and per-field audience ladder with **fail-closed
|
||||
defaults**, applied at routes, at SSE subscribe time, *and* at the navigation. All three,
|
||||
because a surface that is filtered in only two of those places leaks through the third.
|
||||
|
||||
## Content Security Policy
|
||||
|
||||
`script-src 'self'` with **no inline script**, which is why [module chunks are served
|
||||
same-origin](/docs/modules/building-a-module/) and why an import map was never an option.
|
||||
|
||||
`form-action 'self'` is pinned explicitly rather than inherited, because it blocks an
|
||||
injected form POSTing credentials off-origin — an exfiltration path `connect-src` does not
|
||||
cover.
|
||||
|
||||
Violation reports go to a **same-origin** sink that stores nothing: reports describe attacks
|
||||
against this site and are not handed to a third-party collector. It parses both wire formats
|
||||
(browsers disagree), and always answers `204` even for malformed input — a `4xx` would make
|
||||
the error handler log attacker-supplied bodies and turn an open endpoint into a log-flood
|
||||
primitive.
|
||||
|
||||
## Canonical document
|
||||
|
||||
[`BACKEND_DESIGN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/BACKEND_DESIGN.md)
|
||||
§6 is normative for everything on this page.
|
||||
100
src/content/docs/docs/architecture/protocol-versions.mdx
Normal file
@@ -0,0 +1,100 @@
|
||||
---
|
||||
title: Protocol versions
|
||||
description: One number, declared in three repositories, that decides whether a shard and a sidecar are allowed to talk to each other.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
The loopback wire protocol between the game plugin and the sidecar is a **versioned
|
||||
compatibility contract**, not a build dependency. Nothing compiles the three sides together,
|
||||
so the number is what stops a mismatch from being discovered as corrupted data.
|
||||
|
||||
The current protocol is **4**.
|
||||
|
||||
## Three declaration sites
|
||||
|
||||
The same number is written down in three places, and they must move together.
|
||||
|
||||
| Where | What declares it |
|
||||
|---|---|
|
||||
| `link/sidecar/src/main.rs` | `pub const PROTOCOL_VERSION: u32 = 4` — what the sidecar speaks |
|
||||
| `servuo-plugins/overlay.toml` | `protocol = 4` — what the plugin overlay speaks |
|
||||
| The bundle manifest | Copied from `overlay.toml` by CI, so a released pair carries its own claim |
|
||||
|
||||
<Aside type="caution" title="Bump the overlay in the same PR as the emitters">
|
||||
CI folds `overlay.toml` into the release manifest, and **the installer refuses to pair an
|
||||
overlay and a sidecar whose protocol numbers disagree**.
|
||||
|
||||
A bump that lands separately from the emitters does not fail loudly — it silently fails to
|
||||
compose into a bundle, and the next release simply does not appear.
|
||||
</Aside>
|
||||
|
||||
## How a mismatch is caught
|
||||
|
||||
Two independent mechanisms, at two different boundaries.
|
||||
|
||||
**Sidecar ↔ website.** Every sidecar response carries `X-UOLink-Version`. A mismatch is
|
||||
rejected with **`409`** rather than mis-parsed. The website's protocol expectation is
|
||||
admin-managed, alongside the base URL and token, on the shard configuration screen.
|
||||
|
||||
**Overlay ↔ sidecar.** The installer resolves a **bundle** — an exact, protocol-checked
|
||||
sidecar and overlay pair published by CI — and never "latest of each". That is the whole
|
||||
reason bundles exist: two independently released components that must agree cannot be
|
||||
allowed to be chosen independently.
|
||||
|
||||
## What a bump obliges
|
||||
|
||||
Changing a message shape means editing every side plus the specification. A protocol-4
|
||||
change touched:
|
||||
|
||||
| Repository | What had to change |
|
||||
|---|---|
|
||||
| `servuo-plugins` | The emitters, the config keys, and `overlay.toml` |
|
||||
| `link` | `PROTOCOL_VERSION`, a store migration, and the projections |
|
||||
| `module-uo` | The tables, the ingest, and the kind-to-feature map |
|
||||
| `docs` | The protocol document and the integration guide |
|
||||
|
||||
Note `link`'s entry: **a protocol bump can require a store migration**, because the sidecar
|
||||
persists what it forwards. That is not automatic, and version 4 was the first bump that
|
||||
needed one.
|
||||
|
||||
## This is not the module API version
|
||||
|
||||
Two different numbers, versioning two different contracts, and confusing them is easy.
|
||||
|
||||
| | Versions | Lives in | Checked |
|
||||
|---|---|---|---|
|
||||
| **`PROTOCOL_VERSION`** | The game ↔ sidecar wire | `link`, `servuo-plugins`, the bundle | `X-UOLink-Version`, and the installer's pairing check |
|
||||
| **`MODULE_API_VERSION`** | The website ↔ module contract | `website`, and every module's `coreApi` | At module load, before the module's code runs |
|
||||
|
||||
A module that never talks to a game server has no protocol version at all. See [The module
|
||||
manifest](/docs/modules/the-module-manifest/#coreapi-and-what-a-range-means).
|
||||
|
||||
## When a contract owes a bump
|
||||
|
||||
The rule this project settled on: **a contract owes a bump only once it has landed on
|
||||
`main`.**
|
||||
|
||||
While a version has only ever existed on a development branch, additions join it in place
|
||||
rather than forcing a new number. Once it has shipped, it is somebody else's dependency and
|
||||
a change to it is a change to a published contract.
|
||||
|
||||
## If you are building a bridge for another game
|
||||
|
||||
You do not inherit this protocol — you define your own between your plugin and your sidecar.
|
||||
What is worth inheriting is the **shape**:
|
||||
|
||||
- Declare the version on both sides, in files a release can read.
|
||||
- Make a released pair carry its own compatibility claim, so a deployment tool can refuse a
|
||||
bad combination rather than discovering it at runtime.
|
||||
- Reject a mismatch **loudly and early**. A `409` is a good outcome; a successful parse of a
|
||||
message you did not expect is not.
|
||||
|
||||
## Canonical documents
|
||||
|
||||
[`link/v4.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v4.md)
|
||||
is the protocol-4 record, including its cross-repository obligations;
|
||||
[`link/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md)
|
||||
§7 is the wire protocol, and
|
||||
[`link/INTEGRATION.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/INTEGRATION.md)
|
||||
the integration guide.
|
||||
141
src/content/docs/docs/architecture/system-architecture.mdx
Normal file
@@ -0,0 +1,141 @@
|
||||
---
|
||||
title: System architecture
|
||||
description: The whole platform in one place — what each repository is, what talks to what, and the invariants that hold across all of them.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
The drawn version of this, for evaluators, is on
|
||||
[Architecture](/architecture/). This page is the detailed account.
|
||||
|
||||
## Ten repositories, deployed independently
|
||||
|
||||
Nothing here is a monorepo. Each repository has its own history, its own CI and its own
|
||||
release cadence; what binds them is a set of **versioned contracts**, not a build.
|
||||
|
||||
| Repository | What it is |
|
||||
|---|---|
|
||||
| `website` | The Node/Express + MariaDB + React site. The only internet-facing web app |
|
||||
| `Module-uo` | All the *Ultima Online* code, installed into the site as a module |
|
||||
| `link` | The **uo-link sidecar**, in Rust — the only network-facing bridge component |
|
||||
| `servuo-plugins` | The in-game plugin, C#, that feeds the sidecar |
|
||||
| `installer` | Deploys the shard side: sidecar plus plugin overlay |
|
||||
| `Android-app` | Native Android client of the website API |
|
||||
| `Integration-kit` | The instruction book for putting a different game on the platform |
|
||||
| `docs` | Canonical design docs and the protocol spec |
|
||||
| `runicgateway.com` | This site |
|
||||
| `.profile` | The organisation landing page |
|
||||
|
||||
## The layers
|
||||
|
||||
```
|
||||
Browser (React SPA) Native Android app
|
||||
│ cookie │ bearer
|
||||
└──────────┬─────────────────┘
|
||||
▼
|
||||
┌────────────────────────┐
|
||||
│ website (Node) │
|
||||
│ middleware → router │
|
||||
│ → controller → model │
|
||||
│ → db │
|
||||
└───────┬────────────┬───┘
|
||||
│ │ loads at boot
|
||||
▼ ▼
|
||||
MariaDB modules/<id>/ ← installed, never built
|
||||
│
|
||||
▼
|
||||
the game, via whatever
|
||||
bridge that module owns
|
||||
```
|
||||
|
||||
The backend is strictly layered — `middleware → router → controller → model → db` — with
|
||||
models in `.model.js` (logic) and `.db.js` (SQL) pairs, and **raw parameterised queries with
|
||||
no ORM anywhere**.
|
||||
|
||||
## Core is game-agnostic
|
||||
|
||||
Since the module system shipped on **2026-08-12**, nothing in core knows about any
|
||||
particular game. Routes, tables, pages, navigation and push streams for a game arrive from
|
||||
[a module](/docs/modules/the-module-system/) the operator installed. Core provides the seams;
|
||||
the module fills them.
|
||||
|
||||
That is why the architecture below describes `module-uo` as *the worked example* rather than
|
||||
as part of the platform. It is the module every other module is measured against, not a
|
||||
component core depends on.
|
||||
|
||||
## The invariants
|
||||
|
||||
These hold across repository boundaries, and every one of them is load-bearing.
|
||||
|
||||
### The game is never network-reachable
|
||||
|
||||
The ServUO shard **dials out** over loopback TCP `127.0.0.1:7788`, newline-delimited JSON,
|
||||
to the sidecar. The sidecar is the listener; the game opens no port. Only the sidecar is
|
||||
exposed, and only the website's backend talks to it.
|
||||
|
||||
See [The bridge](/docs/architecture/the-bridge/).
|
||||
|
||||
### A wedged sidecar can never stall the game
|
||||
|
||||
On the C# side, `Emit()` enqueues onto a **bounded, drop-oldest** queue and returns
|
||||
immediately. It never touches the socket from the game's core thread. Every world read
|
||||
happens on the core thread; a dedicated writer thread drains the queue.
|
||||
|
||||
Dropping game events is strictly better than pausing the game to deliver them.
|
||||
|
||||
### The website degrades rather than fails
|
||||
|
||||
The sidecar REST client never throws — every call returns `{ ok, data, status }`. The public
|
||||
site still renders with the shard shown offline.
|
||||
|
||||
That guarantee covers **reading the configuration too**: resolving the admin-managed config
|
||||
decrypts a stored token, which throws if the ciphertext cannot be authenticated (a rotated
|
||||
`SECRET_ENC_KEY`, or a database dump restored under a different key). That is caught inside
|
||||
the client and reported as unavailable, so a wrong key degrades the shard surface instead of
|
||||
500-ing it — and the admin config screen keeps working, which is the screen you need in order
|
||||
to recover.
|
||||
|
||||
### Sensitive events never reach the public
|
||||
|
||||
Ingested events fan out over two SSE channels: a **public allowlist** stream, and an
|
||||
**admin-only** stream that additionally carries staff audit, cheat detection and login
|
||||
attempts with IPs.
|
||||
|
||||
**The catalog is the module's; the boundary is core's.** A module declares which of its
|
||||
kinds are public-safe, and core enforces the split. A sensitive kind cannot reach the public
|
||||
channel.
|
||||
|
||||
### A failed module never takes the site down
|
||||
|
||||
The loader catches failures across a module's entire lifecycle and marks it
|
||||
`startup_failed`. The site comes up with that module's routes and navigation absent, and the
|
||||
admin panel says why. See [Module
|
||||
lifecycle](/docs/modules/module-lifecycle/#failure-is-contained-by-construction).
|
||||
|
||||
### Secrets are encrypted at rest
|
||||
|
||||
OAuth client secrets, the sidecar token and the Gmail refresh token are AES-256-GCM
|
||||
encrypted, keyed by `SECRET_ENC_KEY`. **The sidecar token is write-only in the API** — it is
|
||||
never returned to any client.
|
||||
|
||||
<Aside type="caution" title="Rotating that key orphans every stored secret">
|
||||
Nothing re-encrypts. What was stored under the old key can no longer be read, and every
|
||||
stored secret has to be entered again. See [Environment
|
||||
variables](/docs/reference/environment-variables/).
|
||||
</Aside>
|
||||
|
||||
## A deploy is two independent installs
|
||||
|
||||
Worth stating plainly, because it is the single most common misunderstanding: **the
|
||||
installer binary sets up the shard side only, and never contacts the website.** The website
|
||||
is a separate Docker deployment on, usually, a different machine.
|
||||
|
||||
The [installation path](/docs/getting-started/requirements/) walks both in order.
|
||||
|
||||
## Canonical documents
|
||||
|
||||
[`ARCHITECTURE.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/ARCHITECTURE.md)
|
||||
holds the canonical diagram, and
|
||||
[`BACKEND_DESIGN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/BACKEND_DESIGN.md)
|
||||
is the full API, schema and security contract. See [Canonical
|
||||
documents](/docs/reference/canonical-documents/) for the whole map.
|
||||
153
src/content/docs/docs/architecture/teams-architecture.mdx
Normal file
@@ -0,0 +1,153 @@
|
||||
---
|
||||
title: Teams architecture
|
||||
description: Teams is a contract, not a surface — how core owns guilds, clans and corporations without ever learning what one is called.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
Most games have groups: guilds, clans, corporations, tribes, crews. Runic Gateway supports
|
||||
them as a **core platform primitive**, while core itself never learns what yours is called.
|
||||
|
||||
The administrator's view is [Teams](/docs/administration/teams/).
|
||||
|
||||
## The sentence the design turns on
|
||||
|
||||
**Teams is a contract, not a surface.**
|
||||
|
||||
Core owns the tables, the sync, the access rules and the activity feed. It does **not** own
|
||||
the word for a Team, and therefore does not own the Team *page*. The module that owns the
|
||||
vocabulary owns the page.
|
||||
|
||||
That was not the first design. Core originally rendered Team pages with slots a module
|
||||
filled. It was inverted, and the inversion is the interesting part: instead of core naming
|
||||
places for a module's content, **a module declares a place on its own page for core to
|
||||
fill** — `registry.declareModuleSlot(id, name, { core })`, with core offering contributions
|
||||
rather than naming slots.
|
||||
|
||||
<Aside type="caution" title="Why the direction matters">
|
||||
The first version had core's fills naming three of `module-uo`'s slots **literally**. It
|
||||
worked for exactly one module and silently did nothing for any other game — an empty page
|
||||
with nothing logged.
|
||||
|
||||
It was found by writing the Integration Kit for an audience outside this project, which is
|
||||
precisely what that book is for.
|
||||
</Aside>
|
||||
|
||||
## Six invariants
|
||||
|
||||
Each has a test named against it.
|
||||
|
||||
1. **Module unavailability is staleness, never emptiness.** No Team subsystem may apply a
|
||||
destructive result derived from a failed, timed-out or unanswered module call.
|
||||
2. **Four authority paths stay four.** Game membership, leadership, forum access and
|
||||
external-platform access are separate tables answering separate questions, resolved by
|
||||
separate predicates. **No predicate reads another's table.**
|
||||
3. **Non-contamination.** A manual forum grant never writes the membership projection, in
|
||||
either direction, ever. Both facts coexist; neither migrates into the other.
|
||||
4. **A Team's name is immutable for the life of its record.** A rename is an archive plus a
|
||||
create.
|
||||
5. **Core never interprets module vocabulary.** Activity kinds, Team metadata and capability
|
||||
strings are opaque. Core stores, gates and displays; it never branches on content it does
|
||||
not own.
|
||||
6. **The game never touches the website.** Everything crosses the sidecar.
|
||||
|
||||
Invariant 1 deserves emphasis, because it is the one a naive implementation gets wrong: if
|
||||
the module fails to answer "who is in this Team?", the answer is **not** "nobody". Treating
|
||||
a timeout as an empty roster would silently disband every Team on the site.
|
||||
|
||||
## The rename rule
|
||||
|
||||
Core's key is **(`module_id`, `external_id`, `name`) taken together** — not `external_id`
|
||||
alone.
|
||||
|
||||
| Situation | What core does |
|
||||
|---|---|
|
||||
| New `external_id` | Create a Team |
|
||||
| Known id, same name | Update in place |
|
||||
| Known id, **different name** | **Archive** the row and create a new one |
|
||||
| Id absent from an authoritative full list | Archive as disbanded, subject to invariant 1 |
|
||||
|
||||
The archived Team keeps its forum, activity history, grants and integration record; all
|
||||
become read-only. It stays reachable at its old slug, `noindex`, with a banner linking to
|
||||
the successor — so a Discord message from before the rename lands somewhere that explains
|
||||
itself instead of 404-ing.
|
||||
|
||||
This puts the whole of *"is this a rename or a different group?"* **inside the module**. If
|
||||
your game has no persistent group id, synthesise `external_id` from whatever is stable, or
|
||||
fold the name into it so every rename is a fresh id. Core only ever sees "an id appeared /
|
||||
an id's name changed / an id is gone".
|
||||
|
||||
## The module-facing interface
|
||||
|
||||
A module registers a provider:
|
||||
|
||||
```js
|
||||
api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders })
|
||||
```
|
||||
|
||||
and pushes through `ctx.teams`:
|
||||
|
||||
| Call | What it does |
|
||||
|---|---|
|
||||
| `ctx.teams.publish(event)` | An optimisation — makes a membership change visible at once |
|
||||
| `ctx.teams.reconcile({ reason })` | A debounced *request*; returns immediately |
|
||||
| `ctx.teams.activity.push(items)` | Writes the per-Team feed |
|
||||
|
||||
**`ctx.teams` is push-only, and that is the contract.** There is no reader. A module
|
||||
*answers* questions about Teams; it does not ask them. A `getTeamRoster` would be core
|
||||
offering to read back the module's own answer — which the module already holds.
|
||||
|
||||
All three are fire-and-forget and never reject, because they are called from inside
|
||||
game-event handlers and a storage problem of core's must not become the module's control
|
||||
flow. Correctness comes from reconciliation either way.
|
||||
|
||||
### The six event kinds
|
||||
|
||||
`team.created` · `team.disbanded` · `team.member.added` · `team.member.removed` ·
|
||||
`team.leader.added` · `team.leader.removed`
|
||||
|
||||
Six rather than four because **leadership is its own authority path**: a leadership change
|
||||
has to be expressible without pretending someone joined or left.
|
||||
|
||||
**`team.created` and `team.disbanded` only ask for a reconciliation.** Core will not invent
|
||||
a Team from a delta — it would have no name, no roster and no leaders — and will not archive
|
||||
one from a delta either, because an archive driven by a message that may simply have been
|
||||
repeated is destruction on no evidence.
|
||||
|
||||
### The activity feed
|
||||
|
||||
Each item carries an already-**rendered** `summary`, which core stores verbatim. Core cannot
|
||||
phrase "gained 15,000 gold" for a game whose vocabulary it does not know, and a core that
|
||||
templated it would have re-acquired exactly the semantics the module system exists to
|
||||
remove.
|
||||
|
||||
`visibility` defaults to `'members'` — **fail closed**. The module chooses it per item; core
|
||||
enforces it on read.
|
||||
|
||||
A `dedupeKey` collision is a **successful no-op**, which is what makes a sidecar reconnect
|
||||
backfill safe to replay.
|
||||
|
||||
## Untrusted game data becomes a public page
|
||||
|
||||
This is the sharpest edge in the whole subsystem: a group name chosen by a player becomes a
|
||||
page on a public website.
|
||||
|
||||
So game-sourced names go through **reserved-name screening**, and game-sourced overrides
|
||||
through an **approval gate**. Neither is optional, and neither is something a module can
|
||||
waive.
|
||||
|
||||
## What is deliberately out of scope
|
||||
|
||||
Multi-module namespacing, Team hierarchies and alliances, cross-Team messaging, and
|
||||
platform-only Teams with no game backing.
|
||||
|
||||
**Matrix is research, not a roadmap item.** Of the five capabilities a shared interface
|
||||
would name, a Matrix implementation could honestly provide two — it has no
|
||||
channel-with-overwrites, no role object, no voice channel, and no slash-command
|
||||
registration. The settled outcome was a *capability contract*, not an integration.
|
||||
|
||||
## Canonical document
|
||||
|
||||
[`TEAMS.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/TEAMS.md)
|
||||
is normative — Part 1 for the invariants, Part 2 for the core, Parts 3–4 for pages and the
|
||||
activity feed.
|
||||
131
src/content/docs/docs/architecture/the-bridge.mdx
Normal file
@@ -0,0 +1,131 @@
|
||||
---
|
||||
title: The bridge
|
||||
description: How a game server reaches the website without ever being reachable itself — the sidecar, the loopback socket, and the rules that keep the game running.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
The bridge exists to answer one question safely: **how does a private game server's live
|
||||
state reach a public website?**
|
||||
|
||||
The answer is a **sidecar** — a small service that owns the connection to the game and the
|
||||
durable copy of what the game said. It is not optional, and the reasons are worth
|
||||
understanding before you build one for another game.
|
||||
|
||||
## The shape
|
||||
|
||||
```
|
||||
ServUO shard ──dials out──▶ uo-link sidecar ──HTTP + WS──▶ website
|
||||
(C# plugin) 127.0.0.1:7788 (Rust) bearer + version (module)
|
||||
newline JSON
|
||||
▲ │
|
||||
└──────── the game opens NO port ──────┘
|
||||
```
|
||||
|
||||
Three properties fall out of that diagram, and each is a rule rather than an
|
||||
implementation detail.
|
||||
|
||||
## 1. The game dials out
|
||||
|
||||
**The sidecar is the listener. The game connects to it.** The shard opens no port at all,
|
||||
and nothing on the internet can reach it even in principle.
|
||||
|
||||
This inverts the intuitive design — you would expect the thing with the data to serve it —
|
||||
and the inversion is the whole security argument. Only the sidecar is exposed, and only the
|
||||
website's backend talks to the sidecar.
|
||||
|
||||
The transport is deliberately boring: **newline-delimited JSON, one object per line**, over
|
||||
loopback TCP.
|
||||
|
||||
## 2. A wedged sidecar must never stall the game
|
||||
|
||||
This is the constraint the plugin is built around.
|
||||
|
||||
On the C# side, `Emit()` **enqueues onto a bounded, drop-oldest queue and returns
|
||||
immediately**. It never touches the socket from the game's core thread. Every world read
|
||||
happens on the core thread; a dedicated writer thread drains the queue.
|
||||
|
||||
<Aside type="caution" title="Dropping events beats pausing the game">
|
||||
If the queue fills, the oldest events are discarded. That is the correct trade: a game
|
||||
server that stutters because a logging sidecar is slow is a broken game server, and no
|
||||
website feature is worth a lag spike.
|
||||
|
||||
Design your own plugin the same way. The game thread must never block on I/O — not on a
|
||||
socket, not on a lock held by a writer, not on a DNS lookup.
|
||||
</Aside>
|
||||
|
||||
Inbound commands get the mirror rule: **every inbound handler marshals to the core thread
|
||||
before touching world state.**
|
||||
|
||||
## 3. The sidecar persists before it forwards
|
||||
|
||||
The sidecar owns a durable store. It is not a proxy that translates and forgets — if the
|
||||
website is down, the game's events are still recorded, and a reconnecting website catches
|
||||
up.
|
||||
|
||||
This is what "a *thin* sidecar" means in the Integration Kit: thin in *logic*, not thin in
|
||||
responsibility. The sidecar is a **dumb forwarder** — it makes no access-control decisions
|
||||
and holds no policy. Access control and the admin-toggleable visibility scope live on the
|
||||
**website**, where an administrator can see and change them.
|
||||
|
||||
## Two ways in
|
||||
|
||||
**Live events** arrive over an outbound **WebSocket** and are routed by the module's ingest
|
||||
dispatcher. Kinds are handled differently by nature: state-changing kinds update tables,
|
||||
notable kinds append to an events log, and high-frequency kinds only update state rather
|
||||
than accumulating history.
|
||||
|
||||
**Point-in-time reads and commands** go over **REST**, through a client that never throws.
|
||||
|
||||
Every call carries `Authorization: Bearer <token>` and an `X-UOLink-Version` header. **A
|
||||
protocol mismatch fails fast with `409`** rather than being mis-parsed — see [Protocol
|
||||
versions](/docs/architecture/protocol-versions/).
|
||||
|
||||
## What the shard can say
|
||||
|
||||
The catalog spans sessions and identity, character state, economy and commerce, housing and
|
||||
IDOC, combat and PvP, progression, cheat detection and staff audit, and server lifecycle.
|
||||
A representative line looks like:
|
||||
|
||||
```json
|
||||
{"t":1752,"kind":"vendor.sale",
|
||||
"buyer":{"serial":"0x1A2B","acct":"PerryAdimn"},
|
||||
"owner":{"serial":"0x33C1","acct":"Feng"},
|
||||
"item":{"serial":"0x4001A2","type":"Longsword","amount":1},
|
||||
"price":75000,"commission":3750}
|
||||
```
|
||||
|
||||
The full catalog is [Event catalog](/docs/reference/event-catalog/).
|
||||
|
||||
## Two design details worth stealing
|
||||
|
||||
**`server.hello` is per-connection, not per-boot.** The sidecar restarts independently of
|
||||
the game, so anything it needs up front must be re-sent on **every** connect. An earlier
|
||||
draft emitted a "started" event once at boot; a sidecar that came up second never received
|
||||
it and had no idea which shard it was attached to.
|
||||
|
||||
It carries a `bootId` — a GUID generated at server start, stable across sidecar reconnects
|
||||
and changed on every game restart. That is how the sidecar tells *"I reconnected"* (keep
|
||||
cached state) from *"the game restarted"* (discard it).
|
||||
|
||||
**Rosters are sets, not signatures.** Guild membership is compared as a set rather than
|
||||
folded into a checksum, because a sum can collide: one member joining and another leaving
|
||||
between two sweeps offset each other, and the guild reads as unchanged. A set can also be
|
||||
*differenced*, which is what makes per-member leave events possible for a game that raises
|
||||
no event for leaving.
|
||||
|
||||
On a guild's **first** sweep there is no prior set, so nothing is reported as leaving — an
|
||||
unknown roster becoming known is not 155 people leaving at once.
|
||||
|
||||
## Building one for another game
|
||||
|
||||
The bridge is not UO-specific in shape, only in vocabulary. Chapters 3 and 4 of [the
|
||||
Integration Kit](/docs/modules/the-integration-kit/) cover the sidecar and the game-side
|
||||
plugin, and they are the two parts where the mistakes are most expensive.
|
||||
|
||||
## Canonical documents
|
||||
|
||||
[`link/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md)
|
||||
§5 and §7 are the data catalog and the wire protocol;
|
||||
[`link/INTEGRATION.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/INTEGRATION.md)
|
||||
is the integration guide. Both are normative; this page is not.
|
||||
140
src/content/docs/docs/getting-started/connect-a-game-server.mdx
Normal file
@@ -0,0 +1,140 @@
|
||||
---
|
||||
title: Connect a game server
|
||||
description: The installer binary on the shard host — what it deploys, what it asks, and the four values it prints for the website.
|
||||
---
|
||||
|
||||
import platform from '../../../../data/platform.json';
|
||||
import { Aside, Steps } from '@astrojs/starlight/components';
|
||||
|
||||
This is the second of the two installs, and it happens on the machine that runs your game
|
||||
server. One binary deploys the plugin, installs the sidecar, registers its service, and
|
||||
prints four values for you to paste into the website.
|
||||
|
||||
It never contacts your website, and it never starts or stops your shard.
|
||||
|
||||
## What gets deployed
|
||||
|
||||
| # | Component | Where it goes |
|
||||
|---|---|---|
|
||||
| 1 | **The plugin overlay** — C# source ServUO compiles at boot | into your ServUO tree |
|
||||
| 2 | **The uo-link sidecar** — a small Rust service | a system directory, plus a service |
|
||||
| 3 | **A record of the run** | `install.json`, with per-file hashes and backups |
|
||||
|
||||
```
|
||||
ServUO shard ──loopback TCP 127.0.0.1:7788──► uo-link sidecar ──HTTP + WebSocket──► website
|
||||
```
|
||||
|
||||
The shard **dials out**. It never listens for the website and is never reachable from the
|
||||
internet; only the sidecar is exposed, and only to your site.
|
||||
|
||||
## Install
|
||||
|
||||
<Steps>
|
||||
|
||||
1. **Download the binary for your OS, and `SHA256SUMS`**, from the
|
||||
[installer releases page](https://gitea.whitlocktech.com/RunicGateway/installer/releases)
|
||||
({platform.releases.installer}).
|
||||
|
||||
Releases are **unsigned** — there is no code-signing certificate, so that checksum file
|
||||
is the whole trust anchor. Check it:
|
||||
|
||||
```bash
|
||||
sha256sum -c SHA256SUMS --ignore-missing
|
||||
chmod +x runicgateway-installer-linux-x86_64
|
||||
```
|
||||
|
||||
On Windows, `(Get-FileHash .\runicgateway-installer-windows-x86_64.exe -Algorithm SHA256).Hash`
|
||||
and compare. Windows will also show a SmartScreen prompt on first run, for the same
|
||||
reason.
|
||||
|
||||
2. **Stop the shard.** `ServUO.exe` locks `Scripts.dll` and rewrites `Saves/` on exit, so
|
||||
the installer refuses to deploy under a running server.
|
||||
|
||||
3. **Run it, elevated.**
|
||||
|
||||
```bash
|
||||
sudo ./runicgateway-installer-linux-x86_64 install
|
||||
```
|
||||
|
||||
Add `--verify` first if you want to see every change it would make and write nothing.
|
||||
|
||||
It asks four things: your ServUO root, whether to apply the optional patch tier, the
|
||||
hostname your website should use to reach this machine, and your site's URL (used only
|
||||
to print a link at the end).
|
||||
|
||||
4. **Read the summary.** It reports the overlay sync file by file, the sidecar binary and
|
||||
its verified hash, the config and database paths, and the service state. Then it says
|
||||
what you must do next — restart ServUO yourself, because it will not do that for you.
|
||||
|
||||
</Steps>
|
||||
|
||||
<Aside type="note" title="It installs a bundle, not “latest of each”">
|
||||
The three components version independently but must agree on one wire protocol, so what it
|
||||
resolves is a **bundle**: an exact, protocol-checked pair of sidecar and overlay versions
|
||||
({platform.bundle.tag} today — sidecar {platform.bundle.sidecar}, overlay {platform.bundle.overlay}).
|
||||
`--bundle <tag>` pins an exact past combination, so a reinstall in six months reproduces
|
||||
today's install rather than tomorrow's.
|
||||
</Aside>
|
||||
|
||||
## The patch tier is optional
|
||||
|
||||
Most of the plugin is *added* files, which is why the base install is a safe copy. Two
|
||||
features need edits to stock ServUO sources, and those are opt-in, off unless you say yes,
|
||||
and refused where the target lines are not stock. Skipping the tier costs you vendor-sale
|
||||
events and in-game moderation audit forwarding; everything else works.
|
||||
|
||||
The tier is written and tested against stock ServUO {platform.bundle.servuoMin}. On any
|
||||
other version it is unsupported and untested, and the prompt makes you answer past a
|
||||
warning.
|
||||
|
||||
## Paste the four values into the site
|
||||
|
||||
A successful run ends by printing the one step it cannot do for you:
|
||||
|
||||
```
|
||||
Base URL http://shard.example.com:8080
|
||||
WebSocket URL ws://shard.example.com:8080/ws
|
||||
Protocol version 4
|
||||
Auth token 4f9c… (also in sidecar.toml)
|
||||
```
|
||||
|
||||
Every value comes from asking the installed sidecar itself, so it cannot drift from what
|
||||
the service actually runs.
|
||||
|
||||
On the site, sign in as an administrator and open **Shard (uo-link)** in the admin
|
||||
sidebar — `/admin/uo/link`. Tick *Enable the shard integration*, paste **Base URL**,
|
||||
**WebSocket URL**, **Auth token** and **Protocol**, and save. The ingest client restarts
|
||||
immediately.
|
||||
|
||||
<Aside type="caution" title="Installer v0.1.0 prints an older path for that screen">
|
||||
v0.1.0 prints `…/admin/shard`. Since the shard screens became part of the `uo` module — and
|
||||
a module owns one path segment wherever it appears — the screen moved to
|
||||
**`/admin/uo/link`**.
|
||||
|
||||
The old path does not fail visibly: the site has no route for it, so it sends you to the
|
||||
dashboard, and that looks like the link worked. The four values you were just told to paste
|
||||
then have nowhere to go. Use the sidebar, or the path above.
|
||||
|
||||
Fixed in **v0.1.1**, which prints the real path. Only matters if you are running the older
|
||||
binary.
|
||||
</Aside>
|
||||
|
||||
The token is encrypted at rest and **never returned to any client** — losing it means
|
||||
reading it back from `sidecar.toml` on the shard host, not from the website.
|
||||
|
||||
## If the website is on a different machine
|
||||
|
||||
The sidecar binds `127.0.0.1:8080`, reachable only from the shard host. If the site runs
|
||||
elsewhere, widen the bind and then narrow the access:
|
||||
|
||||
1. Set `[web] bind` in `sidecar.toml` to `0.0.0.0:8080` and restart the service.
|
||||
2. **Firewall that port to your website's address only.** The auth token is always on, but
|
||||
it travels as a plain bearer token — the sidecar speaks HTTP, not HTTPS.
|
||||
3. If the two hosts are not on a trusted network, put the sidecar behind a TLS reverse
|
||||
proxy or a VPN link and give the website the `https://` / `wss://` URLs.
|
||||
|
||||
Leave `[shard] bind` on `127.0.0.1:7788`. That socket accepts *inbound commands to the
|
||||
game*, and being loopback-only is what makes that safe.
|
||||
|
||||
Next: [Verify the whole stack](/docs/getting-started/verify-the-whole-stack/) — because a
|
||||
successful file copy is not a working bridge.
|
||||
81
src/content/docs/docs/getting-started/first-run.mdx
Normal file
@@ -0,0 +1,81 @@
|
||||
---
|
||||
title: First run
|
||||
description: Signing in as the first admin, what the site does before anyone visits, and the switch from maintenance to live.
|
||||
---
|
||||
|
||||
import { Aside, Steps } from '@astrojs/starlight/components';
|
||||
import Screenshot from '../../../../components/Screenshot.astro';
|
||||
|
||||
The site is up and nobody can see it yet. That is the intended state: a new deployment
|
||||
**starts in maintenance mode**, showing visitors a "coming soon" page while the admin panel
|
||||
stays reachable.
|
||||
|
||||
## Sign in
|
||||
|
||||
<Steps>
|
||||
|
||||
1. **Open `/admin/login`** — not `/`. The public site and the admin panel have separate
|
||||
sign-in screens, and in maintenance mode the public one is behind the coming-soon page.
|
||||
|
||||
2. **Use `ADMIN_USERNAME` and `ADMIN_PASSWORD` from your `.env`.**
|
||||
|
||||
That account was created on the first boot, and only because the `users` table was
|
||||
empty. The variables do nothing on later boots, so you can blank them once you are in.
|
||||
|
||||
3. **Set up two-factor**, under **Account** at the bottom of the sidebar. Optional,
|
||||
per-account, and the right moment is now rather than after the site is public.
|
||||
|
||||
</Steps>
|
||||
|
||||
<Aside type="caution" title="If the login screen rejects a password you are sure about">
|
||||
Login is rate-limited and backs off after repeated failures from one address, and the
|
||||
bot-scoring layer can ban an address outright. Both are working as designed. Give it a
|
||||
minute, and see [Authentication](/docs/administration/authentication/) for what the
|
||||
**Web Bot Activity** screen shows and how to lift a ban.
|
||||
</Aside>
|
||||
|
||||
<Screenshot id="admin-dashboard" />
|
||||
|
||||
## What is already there
|
||||
|
||||
The first boot seeds a working site rather than an empty one:
|
||||
|
||||
- **A wiki with eight pages**, arranged in sections — Guides, World & Lore, Systems &
|
||||
Gameplay, Community & Rules — as a skeleton to write into, not as content to keep.
|
||||
- **Post categories**: News, Five on Friday, Newsletter, Screenshots.
|
||||
- **A public navigation** covering those, the wiki and an About page.
|
||||
- **A portal hero** with placeholder copy that names no game.
|
||||
|
||||
None of it mentions a specific game, because core does not know about one. That arrives
|
||||
with a [module](/docs/getting-started/install-a-game-module/).
|
||||
|
||||
## The three things to set before going live
|
||||
|
||||
All three are on **Settings**:
|
||||
|
||||
| Setting | Why now |
|
||||
|---|---|
|
||||
| **Site title** | Overrides `BRAND_NAME` for the page title, the header and link previews. |
|
||||
| **Contact email** | Where the contact form delivers. Until email is configured, the form falls back to a `mailto:` link to this address — so an unset one means a contact form that goes nowhere. |
|
||||
| **Player registration** | **Off by default**: nobody can create an account. Choose password, SSO, both, or leave it off and invite people individually from **Invites**. |
|
||||
|
||||
The maintenance message and the homepage teaser are on the same screen, and both are worth
|
||||
a minute before anyone reads them.
|
||||
|
||||
## Switch to live
|
||||
|
||||
**Dashboard → Switch to Live.** The public site opens immediately; nothing else changes.
|
||||
|
||||
You can flip back at any time, and an admin who is signed in can preview the live site
|
||||
while the rest of the world still sees the maintenance page — so there is no need to go
|
||||
live in order to check your work.
|
||||
|
||||
<Aside type="note" title="Going live is not the same as being reachable">
|
||||
Live mode only decides what visitors are shown. Whether anyone can reach the site at all is
|
||||
your DNS, TLS and reverse proxy — see
|
||||
[Maintenance and upgrades](/docs/administration/maintenance-and-upgrades/).
|
||||
</Aside>
|
||||
|
||||
Next: [Install a game module](/docs/getting-started/install-a-game-module/), or skip
|
||||
straight to [Administration](/docs/administration/configuration/) if this deployment is a
|
||||
community site with no game server behind it.
|
||||
@@ -0,0 +1,93 @@
|
||||
---
|
||||
title: Install a game module
|
||||
description: Everything game-specific is a module. Installing one, what it adds, and the restart that makes it live.
|
||||
---
|
||||
|
||||
import platform from '../../../../data/platform.json';
|
||||
import { Aside, Steps } from '@astrojs/starlight/components';
|
||||
|
||||
Core knows nothing about any game. Every game-specific screen — shard status, the map
|
||||
atlas, the player marketplace, character sheets — comes from a **module**, a directory on a
|
||||
mounted volume that the server loads at start.
|
||||
|
||||
Today there is one: **`uo`**, for ServUO shards, published as
|
||||
[`Module-uo`](https://gitea.whitlocktech.com/RunicGateway/Module-uo) ({platform.releases['Module-uo']}).
|
||||
|
||||
## Install it
|
||||
|
||||
<Steps>
|
||||
|
||||
1. **Open Admin → Modules.**
|
||||
|
||||
2. **Paste the URL of a release's install manifest** into *Release install-manifest URL*
|
||||
and press **Install**.
|
||||
|
||||
For the current `uo` release that is the `module-uo-<version>.json` asset on
|
||||
[its releases page](https://gitea.whitlocktech.com/RunicGateway/Module-uo/releases).
|
||||
The site downloads the bundle, checks it against the `sha256` the manifest declares, and
|
||||
unpacks it onto the modules volume.
|
||||
|
||||
There is no catalog to browse, deliberately: a catalog would make core's release cadence
|
||||
decide which modules are allowed to exist.
|
||||
|
||||
3. **Restart when it asks.** A banner appears — *Modules are read from disk when the server
|
||||
starts* — with a **Restart the server** button. The row reads *Restart to start* until
|
||||
you do.
|
||||
|
||||
The button exits the process and lets your supervisor bring it back; on the Compose
|
||||
deployment from [Install the site](/docs/getting-started/install-the-site/), that is
|
||||
`restart: unless-stopped` doing its job. `docker compose restart app` is exactly
|
||||
equivalent.
|
||||
|
||||
4. **Confirm it started.** The module's row should read *Started*, and its screens should
|
||||
have appeared in the navigation.
|
||||
|
||||
</Steps>
|
||||
|
||||
<Aside type="note" title="Only listed hosts may be installed from">
|
||||
Installing a module runs its code inside your server, so the URL must be HTTPS and its host
|
||||
must be in the allowlist at the bottom of the same screen — re-checked on every redirect.
|
||||
It is seeded with `gitea.whitlocktech.com`, and an empty list forbids every install.
|
||||
</Aside>
|
||||
|
||||
## What the `uo` module adds
|
||||
|
||||
Watch the log at the restart and you will see exactly what it mounted:
|
||||
|
||||
```
|
||||
[uo] registered routes: public:/shard,/atlas admin:/shard,/uo-link player:/shard
|
||||
[modules] schema ensured for module "uo"
|
||||
[modules] module "uo" started
|
||||
```
|
||||
|
||||
Its capabilities are {platform.moduleUoCapabilities.join(', ')} — the shard console, the
|
||||
map atlas, the player-vendor marketplace, city governors, guilds, houses and IDOCs, champion
|
||||
boards, and the cliloc strings that make item names readable.
|
||||
|
||||
A module owns **one path segment** wherever it appears, so its pages live under `/uo/…`,
|
||||
`/admin/uo/…` and `/player/uo/…`. That boundary is visible in the URL on purpose.
|
||||
|
||||
<Aside type="caution" title="A module with no game server behind it is empty, not broken">
|
||||
Installing `uo` does not connect anything. Its screens exist and report the shard as
|
||||
offline until you
|
||||
[connect a game server](/docs/getting-started/connect-a-game-server/) — which is the same
|
||||
thing the public site does when the shard goes down, and is designed to be unremarkable.
|
||||
</Aside>
|
||||
|
||||
## The declarative alternative
|
||||
|
||||
A host whose Compose file is version-controlled can skip the panel entirely: set `MODULES`
|
||||
in `.env`, one entry per module, `<id>@<version>=<install manifest URL>`. The container
|
||||
resolves that set at every start.
|
||||
|
||||
A module already unpacked at the declared version is left alone **without a single network
|
||||
call**, so a restart with no route to the internet comes up unchanged. A failure is logged
|
||||
and shown in Admin → Modules, and never stops the site from starting.
|
||||
|
||||
The two surfaces agree on a rule worth knowing: **the variable owns what is on the volume,
|
||||
the admin panel owns whether a module runs.** A module you disable in the panel stays
|
||||
disabled even though its files are put back at the next start.
|
||||
|
||||
More on both in [Managing modules](/docs/administration/managing-modules/).
|
||||
|
||||
Next: [Connect a game server](/docs/getting-started/connect-a-game-server/).
|
||||
140
src/content/docs/docs/getting-started/install-the-site.mdx
Normal file
@@ -0,0 +1,140 @@
|
||||
---
|
||||
title: Install the site
|
||||
description: A complete Docker Compose deployment you can copy from this page — two files, two commands.
|
||||
---
|
||||
|
||||
import { Aside, Code, Steps } from '@astrojs/starlight/components';
|
||||
import { compose, env, omittedServices } from '../../../../data/quickstart.mjs';
|
||||
|
||||
export const envText = env.map((e) => `${e.key}=${e.value}`).join('\n');
|
||||
export const fillLines = env.filter((e) => e.fill).map((e) => `${e.key}=${e.value}`);
|
||||
|
||||
The site is a Docker deployment: a MariaDB container, the prebuilt application image, and
|
||||
two files you write. Nothing is compiled on your host, and there is no repository to clone
|
||||
— everything you need is on this page.
|
||||
|
||||
<Steps>
|
||||
|
||||
1. **Make a directory for the deployment.**
|
||||
|
||||
Everything below is relative to it, and the bind mounts want to exist before the
|
||||
containers do — Docker creates a missing mount source as `root`, and the container user
|
||||
then cannot write it.
|
||||
|
||||
```bash
|
||||
mkdir -p runic-gateway/logs runic-gateway/brand runic-gateway/modules
|
||||
cd runic-gateway
|
||||
```
|
||||
|
||||
2. **Write `docker-compose.yml`.**
|
||||
|
||||
<Code code={compose} lang="yaml" title="docker-compose.yml" />
|
||||
|
||||
This file only ever *pulls*. There is no `build:` anywhere in it, which is deliberate:
|
||||
a production host should not be able to build an image by accident.
|
||||
|
||||
3. **Write `.env` beside it.**
|
||||
|
||||
Every highlighted line must be changed before this is a real deployment. The secrets
|
||||
want to be long random strings — `openssl rand -base64 36` three times is enough.
|
||||
|
||||
<Code code={envText} lang="ini" title=".env" mark={fillLines} />
|
||||
|
||||
4. **Pull and start.**
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The database comes up first; the app waits for its health check, creates its schema,
|
||||
seeds defaults, creates your first admin, and starts listening.
|
||||
|
||||
5. **Check that it is up.**
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:3000/api/health
|
||||
```
|
||||
|
||||
```json
|
||||
{"status":"ok"}
|
||||
```
|
||||
|
||||
If that answers, the site is running. Go to
|
||||
[First run](/docs/getting-started/first-run/).
|
||||
|
||||
</Steps>
|
||||
|
||||
## What you just deployed
|
||||
|
||||
```
|
||||
localhost:3000 ──► app (the website: API + the built React client, one process)
|
||||
│
|
||||
└──► db (MariaDB, no host port — only the app can reach it)
|
||||
```
|
||||
|
||||
Four host directories and two volumes hold everything that survives a container:
|
||||
|
||||
| Path | What is in it |
|
||||
|---|---|
|
||||
| `./logs/` | `app.log`, readable from the host without `docker exec` |
|
||||
| `./modules/` | Installed [modules](/docs/getting-started/install-a-game-module/). A bind mount, so placing one by hand is a supported install |
|
||||
| `./brand/` | Your logo, hero and favicon, if you replace the defaults ([Branding and theming](/docs/administration/branding-and-theming/)) |
|
||||
| `dbdata` volume | The database |
|
||||
| `uploads` volume | Everything uploaded through the site |
|
||||
|
||||
<Aside type="caution" title="`restart: unless-stopped` is load-bearing">
|
||||
It is not boilerplate. Installing a module needs a restart, and the admin panel offers a
|
||||
button for it — that button exits the process and lets the supervisor bring it back. On a
|
||||
deployment with nothing supervising the process, the button takes the site down and leaves
|
||||
it down. Docker Compose is the supervisor here, and this line is what makes it one.
|
||||
</Aside>
|
||||
|
||||
## Two variables worth reading twice
|
||||
|
||||
**`SECRET_ENC_KEY`** encrypts secrets at rest — OAuth client secrets, the Discord bot
|
||||
token, the shard's auth token. In production the server **refuses to start** without it.
|
||||
Changing it later does not re-encrypt anything: what was stored under the old key can no
|
||||
longer be read, and every stored secret has to be entered again.
|
||||
|
||||
**`BOT_INTERNAL_KEY`** authenticates the internal channel between the site and the Discord
|
||||
bot. The server also refuses to start in production if it is blank, left at a placeholder,
|
||||
or shorter than 16 characters — even when, as here, you are not running the bot yet.
|
||||
|
||||
<Aside type="note" title="Both of those are set once, before the first boot">
|
||||
They are not "fill in later" values. The first boot is when your admin account and the
|
||||
site's defaults are written, and it will not happen at all until both are set.
|
||||
</Aside>
|
||||
|
||||
## What this quickstart leaves out
|
||||
|
||||
The project's shipped Compose file has two more services. Neither is needed to boot, and
|
||||
each is introduced where it is configured:
|
||||
|
||||
<ul>
|
||||
{Object.entries(omittedServices).map(([name, why]) => (
|
||||
<li key={name}><strong><code>{name}</code></strong> — {why}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
It also leaves out the branding, logging and session variables, which have working
|
||||
defaults and their own admin screens. The full file and the full environment reference are
|
||||
in the [website repository](https://gitea.whitlocktech.com/RunicGateway/website).
|
||||
|
||||
## Behind a reverse proxy
|
||||
|
||||
Not required to get started, and required before anyone else uses the site. Two settings
|
||||
here are what make it correct:
|
||||
|
||||
- **`TRUST_PROXY=1`** tells the app to read the client's address from `X-Forwarded-For`.
|
||||
Rate limiting, login backoff and the bot-scoring IP bans are all only as accurate as
|
||||
that. Set it to the number of proxies in front of the app, or pin it to the proxy's
|
||||
address; a blanket `true` is rejected on purpose, because it would let anyone spoof
|
||||
their address by sending a header.
|
||||
- **`COOKIE_SECURE=auto`** issues a `Secure` session cookie when the request arrives over
|
||||
HTTPS and a plain one otherwise, so logging in works both through the proxy and directly
|
||||
on the LAN while you are setting up.
|
||||
|
||||
Point the proxy at port 3000. Do not forward `INTERNAL_PORT` (3001) — it is the
|
||||
server-to-bot channel, it is deliberately not published by the Compose file, and it must
|
||||
never be reachable from outside.
|
||||
60
src/content/docs/docs/getting-started/requirements.mdx
Normal file
@@ -0,0 +1,60 @@
|
||||
---
|
||||
title: Requirements
|
||||
description: What you need on the website host, and what you need on the game server host, before you begin.
|
||||
---
|
||||
|
||||
import platform from '../../../../data/platform.json';
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
Two hosts, two lists. They can be the same machine, but they are separate deployments and
|
||||
have nothing in common except the four values you will paste between them.
|
||||
|
||||
## The website host
|
||||
|
||||
| Requirement | Detail |
|
||||
|---|---|
|
||||
| **Docker** with Compose v2 | `docker compose version` should print v2.x. The site ships as prebuilt images and pulls them; nothing is built on your host. |
|
||||
| **Outbound HTTPS** to `gitea.whitlocktech.com` | To pull the images, and later to install a module. Nothing inbound is required for the install itself. |
|
||||
| **~2 GB of disk to start** | Two images, a MariaDB volume, and an uploads volume. Uploads grow with what your community posts. |
|
||||
| **A hostname and TLS, eventually** | Not needed to boot — you can reach it on `http://localhost:3000` first. Needed before anyone else uses it: see [Maintenance and upgrades](/docs/administration/maintenance-and-upgrades/) for the reverse-proxy notes. |
|
||||
|
||||
There is no separate database to install: MariaDB comes up as a container beside the app,
|
||||
and the schema is created on first boot.
|
||||
|
||||
<Aside type="note" title="Windows and macOS are fine for trying it">
|
||||
The images are Linux containers, so Docker Desktop runs them. For a deployment other people
|
||||
depend on, a Linux host is the shape everything else assumes — the log paths, the bind
|
||||
mounts and the reverse-proxy notes all read that way.
|
||||
</Aside>
|
||||
|
||||
## The game server host
|
||||
|
||||
Only if you are connecting a game server. Today that means a ServUO shard, which is what
|
||||
the [`uo` module](/docs/getting-started/install-a-game-module/) and the installer support.
|
||||
|
||||
| Requirement | Detail |
|
||||
|---|---|
|
||||
| **A working ServUO install** | It must currently boot and compile scripts cleanly. The installer deploys onto a healthy shard; it does not repair a broken one. |
|
||||
| **ServUO {platform.bundle.servuoMin}** *(patch tier only)* | The base install works on any reasonably current ServUO. The optional patch tier is written and tested against stock {platform.bundle.servuoMin}; on any other version it is unsupported, and skipping it still leaves you with a working bridge. |
|
||||
| **The shard stopped** | `ServUO.exe` locks `Scripts.dll` and rewrites `Saves/` on exit. The installer refuses to deploy under a running shard. |
|
||||
| **Administrator / root** | It writes into system directories and registers a service. |
|
||||
| **Outbound HTTPS** | To fetch the bundle and its two artifacts. No Gitea account and no git client are needed. |
|
||||
| **The sidecar on the same host as the shard** | The shard connects to `127.0.0.1:7788`. Splitting them is not supported — that loopback socket *is* the trust boundary for inbound commands. |
|
||||
|
||||
<Aside type="caution" title="Back up before the shard install">
|
||||
The overlay overwrites `Scripts/Scripts.csproj`, a stock file, and the optional patch tier
|
||||
edits stock sources. A copy of `Scripts/` and `Config/` costs nothing and is the difference
|
||||
between an experiment and a gamble. The installer keeps its own backups too — see
|
||||
[Connect a game server](/docs/getting-started/connect-a-game-server/).
|
||||
</Aside>
|
||||
|
||||
## What you do not need
|
||||
|
||||
- **A Gitea account.** Everything the installers fetch is a public release asset.
|
||||
- **A build toolchain.** Not on either host. The site pulls images; the module arrives as a
|
||||
verified tarball; the shard plugin is C# source that ServUO itself compiles at boot.
|
||||
- **An inbound port on the game host** — for the *game*. The shard never listens for the
|
||||
website. If the website runs on a different machine from the shard, the **sidecar** needs
|
||||
to be reachable by the website, and that is the one hole you will open deliberately.
|
||||
|
||||
Next: [Install the site](/docs/getting-started/install-the-site/).
|
||||
103
src/content/docs/docs/getting-started/verify-the-whole-stack.mdx
Normal file
@@ -0,0 +1,103 @@
|
||||
---
|
||||
title: Verify the whole stack
|
||||
description: Four checks, one per link in the chain, that distinguish "files copied" from "the bridge works".
|
||||
---
|
||||
|
||||
import { Aside, Steps } from '@astrojs/starlight/components';
|
||||
|
||||
A successful install is not a working bridge, and the failure is quiet in a specific way:
|
||||
**ServUO shells out to `dotnet build`, prints the output, ignores the exit code, and
|
||||
reloads the existing `Scripts.dll`.** A broken script build looks exactly like a clean boot.
|
||||
|
||||
So verify each link in the chain, in order. Each check tells you which one to fix.
|
||||
|
||||
<Steps>
|
||||
|
||||
1. **The plugin compiled — watch the boot output.**
|
||||
|
||||
Start your shard the way you always do. You want the build to succeed *and* the bridge
|
||||
to announce itself:
|
||||
|
||||
```
|
||||
Core: Compiling scripts...
|
||||
Build succeeded.
|
||||
[Bridge] enabled=True endpoint=127.0.0.1:7788 queueCap=10000 …
|
||||
```
|
||||
|
||||
If you scrolled past it, force the question:
|
||||
|
||||
```bash
|
||||
dotnet build Scripts/Scripts.csproj -c Release -p:Platform=x64 # must be 0 errors
|
||||
```
|
||||
|
||||
2. **The shard is connected — ask it in game.**
|
||||
|
||||
As an administrator:
|
||||
|
||||
```
|
||||
[bridge status
|
||||
```
|
||||
|
||||
It reports `connected=True depth=0 sent=… dropped=0`. `connected=False` means the shard
|
||||
cannot reach the sidecar. `dropped` climbing means the sidecar is wedged and the shard
|
||||
is shedding events rather than stalling — which is what it is designed to do, and why a
|
||||
broken bridge never freezes your game.
|
||||
|
||||
`[bridge reload` re-reads `Bridge.cfg` without a restart; `[bridge sweepnow` forces one
|
||||
pass of every stream.
|
||||
|
||||
3. **The sidecar is healthy — ask it over HTTP.**
|
||||
|
||||
`/health` needs no auth, so it is safe to curl on the shard host:
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:8080/health
|
||||
```
|
||||
|
||||
```json
|
||||
{"status":"ok","protocol":4,"plugin_connected":true,"database":"ok","uptime":"2m"}
|
||||
```
|
||||
|
||||
**`plugin_connected: true` is the one that matters.** It is the only value in this whole
|
||||
sequence that distinguishes "files copied" from "the bridge works".
|
||||
|
||||
4. **The website is ingesting — look at the shard screen.**
|
||||
|
||||
On the site, open **Shard (uo-link)** (`/admin/uo/link`). The header should read
|
||||
**Connected**, with *Shard link: up* and *WS ingest: online*, and the live feed at the
|
||||
bottom should start showing events within seconds rather than sitting on
|
||||
*Waiting for shard events…*.
|
||||
|
||||
Then check the public side: the shard status page should stop reporting the game as
|
||||
offline.
|
||||
|
||||
</Steps>
|
||||
|
||||
## When one of them fails
|
||||
|
||||
| What you see | What it means |
|
||||
|---|---|
|
||||
| Shard boots clean, nothing reaches the site | The classic silent failure — a stale `Scripts.dll`. Run the `dotnet build` line above and read the errors. |
|
||||
| `[bridge` is not a command | The plugin did not compile, or the bridge is disabled in `Bridge.cfg`. |
|
||||
| `connected=False` | The sidecar is not listening on `127.0.0.1:7788`. Check the service, and that `[shard] bind` matches `Host`/`Port` in `Bridge.cfg`. |
|
||||
| `/health` is fine locally, the site says offline | The website cannot reach port 8080 — bind address or firewall. The site is *designed* to render normally with the shard down, so this fails quietly. |
|
||||
| The site logs `409` from the sidecar | Protocol mismatch. Set the Protocol field to what `/health` reports rather than guessing; the sidecar rejects rather than mis-parsing. |
|
||||
| `401` from the sidecar | Wrong or missing token. Read the live one back with `uo-link-sidecar --print-config`; do not retype it from a screenshot. |
|
||||
|
||||
<Aside type="note" title="`runicgateway doctor` answers most of this in one command">
|
||||
Run on the shard host, it checks the install record, the ServUO tree, every overlay file
|
||||
hash, the patch tier, the sidecar, its service, `/health`, and that the sidecar and overlay
|
||||
agree on a protocol. Its output is the first thing anyone helping you will ask for. It
|
||||
exits non-zero when a check failed, so a monitoring system can run it too.
|
||||
</Aside>
|
||||
|
||||
## What "working" looks like a week later
|
||||
|
||||
- The public shard page shows live status, and the admin dashboard shows events arriving.
|
||||
- `dropped` in `[bridge status` stays at zero. A climbing number means the sidecar is
|
||||
wedged, not that the shard is unhealthy.
|
||||
- `doctor` is still green after a shard update — that is what catches an overlay file
|
||||
reverted by hand or by a ServUO upgrade.
|
||||
|
||||
You have finished the installation path. From here,
|
||||
[Administration](/docs/administration/configuration/) covers running the site day to day.
|
||||
@@ -13,13 +13,6 @@ network-facing component, and only the website's backend is allowed to talk to i
|
||||
website degrades gracefully when the game is down, and sensitive events never reach the
|
||||
public event stream.
|
||||
|
||||
:::note[This documentation is being written in phases]
|
||||
The scaffold, theme and sidebar are in place. The pages themselves land in phases 7 and 8,
|
||||
starting with the installation path — which is the priority of the whole project, because
|
||||
the repositories treat the site and the shard as separate deployments and nothing today
|
||||
presents them as one sequence.
|
||||
:::
|
||||
|
||||
## What the platform is on today
|
||||
|
||||
<table>
|
||||
@@ -46,10 +39,30 @@ two independent deployments.
|
||||
2. **The shard side** is the installer binary, run on the game server's host. It sets up
|
||||
the plugin overlay and the sidecar, and it never contacts the website.
|
||||
|
||||
They meet at four values pasted into **Admin → Shard**, and at protocol {platform.protocol},
|
||||
which both sides check before they will pair.
|
||||
They meet at four values pasted into the module's shard screen, and at protocol
|
||||
{platform.protocol}, which both sides check before they will pair.
|
||||
|
||||
## Where to go next
|
||||
You can stop after the first one. A site with no game server attached is a complete
|
||||
community website — news, wiki, pages, Teams, forums, accounts and moderation are all core,
|
||||
and none of them knows a game exists. The second install is what fills the game screens.
|
||||
|
||||
## Start here
|
||||
|
||||
The seven pages of **Getting started** are that sequence, in order, and each one says what
|
||||
you should expect to see before you move on:
|
||||
|
||||
1. [Requirements](/docs/getting-started/requirements/) — what you need on both hosts
|
||||
2. [Install the site](/docs/getting-started/install-the-site/) — Docker Compose, pull-only
|
||||
3. [First run](/docs/getting-started/first-run/) — the first admin, and maintenance → live
|
||||
4. [Install a game module](/docs/getting-started/install-a-game-module/) — what makes the game screens exist
|
||||
5. [Connect a game server](/docs/getting-started/connect-a-game-server/) — the installer, on the shard host
|
||||
6. [Verify the whole stack](/docs/getting-started/verify-the-whole-stack/) — proving it works, rather than assuming
|
||||
|
||||
Then **Administration** covers running it: configuration, branding, content, users,
|
||||
authentication, Teams, moderation, notifications, modules, the shard connection, upgrades,
|
||||
and what to do when something is wrong.
|
||||
|
||||
## Where the truth lives
|
||||
|
||||
The canonical, normative documents live in the
|
||||
[`docs` repository](https://gitea.whitlocktech.com/RunicGateway/docs) and always win over
|
||||
|
||||
161
src/content/docs/docs/modules/building-a-module.mdx
Normal file
@@ -0,0 +1,161 @@
|
||||
---
|
||||
title: Building a module
|
||||
description: The repository layout, the server half, and the client build — including the three things about bundling that everyone gets wrong once.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
Start from [the Integration Kit's template](/docs/modules/the-integration-kit/) rather than
|
||||
an empty directory. This page explains what the template is doing and why, so that when you
|
||||
change something you know what you are changing.
|
||||
|
||||
## The layout
|
||||
|
||||
One repository, both halves, versioned together:
|
||||
|
||||
```
|
||||
module.json id, version, coreApi, mounts, extensions
|
||||
server/index.js the entry point — exports register(ctx, api)
|
||||
server/db/schema.sql idempotent fragment, replayed every boot
|
||||
server/db/purge.sql destructive; only ever run by an explicit purge
|
||||
server/router/ routers and controllers
|
||||
server/model/ *.model.js (logic) + *.db.js (SQL) pairs
|
||||
client/src/entry.jsx registers routes, nav, providers
|
||||
client/src/shim/ the shared-dependency shims — see below
|
||||
client/dist/entry.js PREBUILT chunk, published by your CI
|
||||
```
|
||||
|
||||
`client/dist/` is committed by your **release**, not by hand — the operator never builds,
|
||||
so the built chunk has to be in the bundle.
|
||||
|
||||
## The server half
|
||||
|
||||
`server/index.js` exports one function, called once during core's require phase:
|
||||
|
||||
```js
|
||||
module.exports = function register(ctx, api) {
|
||||
const log = ctx.log('examplegame')
|
||||
|
||||
api.registerRoutes({
|
||||
public: { '/world': worldRouter(ctx) },
|
||||
})
|
||||
|
||||
api.onBoot(async (ctx) => {
|
||||
// anything that needs a live database goes HERE, not above
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
Follow core's own layering — `router → controller → model → db`, with `.model.js` (logic)
|
||||
and `.db.js` (SQL) pairs, and raw parameterised queries. There is no ORM anywhere in this
|
||||
project, and a module that introduces one is a module nobody else can read.
|
||||
|
||||
### The rule CI enforces
|
||||
|
||||
**Zero `require`/`import` may reach outside your own directory.** Not "few". Zero.
|
||||
|
||||
```bash
|
||||
npm run check:imports --prefix server
|
||||
```
|
||||
|
||||
If you need something from core that `ctx` does not offer, that is a gap in the contract —
|
||||
raise it, so the surface grows deliberately. Reaching into core's internals is how a module
|
||||
breaks on a refactor it had no part in.
|
||||
|
||||
## The client half
|
||||
|
||||
Your chunk is built with Vite in **library mode**, emitting one unhashed `dist/entry.js`.
|
||||
Unhashed deliberately: `module.json` names that file, and a hashed name would have to be
|
||||
discovered at runtime. Core answers the caching question instead, serving it `no-cache`.
|
||||
|
||||
Then three things about the bundling, each of which has already cost somebody a day.
|
||||
|
||||
### 1. Aliases replace `external` — they do not accompany it
|
||||
|
||||
This is the one that looks most like it should work.
|
||||
|
||||
Rollup asks `external` **before** Vite's alias resolver runs, so a specifier listed there is
|
||||
marked external and **never aliased**. The chunk then ships bare `import 'react'`
|
||||
specifiers, which a browser cannot resolve without an import map — and an import map has to
|
||||
be inline, which `script-src 'self'` forbids.
|
||||
|
||||
The first real module shipped with both, **built cleanly**, and emitted exactly that chunk.
|
||||
|
||||
```js
|
||||
rollupOptions: { external: [] }, // deliberately empty
|
||||
```
|
||||
|
||||
Alias only. Nothing in `external`. (`output.globals` does not rescue this either — it covers
|
||||
iife/umd and does nothing for an ES module.)
|
||||
|
||||
### 2. Use the array form of `resolve.alias`, with anchored regexes
|
||||
|
||||
Vite's **object** form does *prefix* matching, so a `react` key also rewrites
|
||||
`react/jsx-runtime` — silently, to the wrong shim. The chunk then fails at its first element
|
||||
with a message about `jsx` not being a function, which points nowhere near the cause.
|
||||
|
||||
```js
|
||||
alias: SHARED.map(({ specifier, shim }) => ({
|
||||
find: new RegExp(`^${escape(specifier)}$`),
|
||||
replacement: shim,
|
||||
}))
|
||||
```
|
||||
|
||||
`^react$` and `^react/jsx-runtime$` cannot collide.
|
||||
|
||||
### 3. Assert at resolution time, not by grepping the output
|
||||
|
||||
The risk is a missed alias welding a **second React** into your chunk. That loads fine and
|
||||
then throws about an invalid hook call somewhere unrelated.
|
||||
|
||||
The template fails the build if any shared package resolves into `node_modules`. Two details
|
||||
of how it does that are not interchangeable:
|
||||
|
||||
- It hooks **`transform`, not `load`**. `load` is first-wins, so an earlier plugin returning
|
||||
the module's contents means the guard is never called. Written against `load`, it sat in
|
||||
the build doing nothing while a deliberately-broken alias produced a green build with
|
||||
react-router welded in.
|
||||
- The list of packages that may not be bundled is stated **independently** of the alias
|
||||
list. Deriving one from the other means deleting an alias also deletes the guard against
|
||||
what that alias prevented.
|
||||
|
||||
<Aside type="caution" title="Why shims rather than plain externals">
|
||||
Each shared dependency is aliased to a two-line module re-exporting from `window.__rg`.
|
||||
|
||||
The **named** re-exports matter: `import { useState } from 'react'` compiles to a named
|
||||
import, and a shim with only a default export fails at link time in the browser with a
|
||||
message about the binding — not about the shim.
|
||||
|
||||
Route every shim through one file that reads `window.__rg` and throws a useful error when
|
||||
it is missing. Otherwise the first symptom of a core ordering fault is
|
||||
`Cannot read properties of undefined (reading 'react')` thrown from a file called
|
||||
`react.js`, which reads like *your* bundling is wrong when it is the opposite.
|
||||
</Aside>
|
||||
|
||||
Verify with:
|
||||
|
||||
```bash
|
||||
npm run build --prefix client # build BEFORE the tests — two of them read the chunk
|
||||
npm run check:externals --prefix client
|
||||
```
|
||||
|
||||
## Registering the client half
|
||||
|
||||
```js
|
||||
const { registry } = window.__rg
|
||||
|
||||
registry.registerRoutes(ID, {
|
||||
public: [{ path: 'world', element: <WorldStatus /> }],
|
||||
admin: [{ path: 'link', element: <Admin /> }],
|
||||
})
|
||||
registry.registerNav(ID, { … })
|
||||
```
|
||||
|
||||
Paths are **relative to your module's segment** — `path: 'link'` under `admin` becomes
|
||||
`/admin/<id>/link`. Check `window.__rg.version` against your `coreApi` range and refuse to
|
||||
register on a mismatch.
|
||||
|
||||
## Then
|
||||
|
||||
[Testing and release](/docs/modules/testing-and-release/) covers CI, the checks, and
|
||||
publishing the bundle and its manifest.
|
||||
115
src/content/docs/docs/modules/installing-modules.mdx
Normal file
@@ -0,0 +1,115 @@
|
||||
---
|
||||
title: Installing modules
|
||||
description: How a module reaches a deployment — the install manifest, the two surfaces that can install one, and which of them wins.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
There is no catalog, and there is no marketplace. A module is installed by **naming the
|
||||
URL of a release's install manifest**.
|
||||
|
||||
That is a design decision rather than an unfinished feature: a catalog would make core's
|
||||
release cadence decide which modules exist, and the whole point of the module system is
|
||||
that it does not.
|
||||
|
||||
<Aside type="note" title="Doing this once, as an operator?">
|
||||
[Install a game module](/docs/getting-started/install-a-game-module/) walks the happy path,
|
||||
and [Managing modules](/docs/administration/managing-modules/) covers the screen
|
||||
afterwards. This page is about how distribution works, for people publishing one.
|
||||
</Aside>
|
||||
|
||||
## What a release publishes
|
||||
|
||||
Two artifacts:
|
||||
|
||||
- **`<id>-<version>.tar.gz`** — the bundle: `module.json`, the server half, the prebuilt
|
||||
client chunk, and the SQL fragments.
|
||||
- **An install manifest** — small JSON carrying the bundle's URL and its **`sha256`**.
|
||||
|
||||
The manifest URL is the thing an operator pastes. The bundle is downloaded, **verified
|
||||
against the `sha256`**, and unpacked into `modules/<id>/` on the mounted volume.
|
||||
|
||||
Nothing is compiled at any point in that sequence.
|
||||
|
||||
## The two surfaces
|
||||
|
||||
Both write the same `installed_modules` row, and neither needs a build step.
|
||||
|
||||
### The admin panel
|
||||
|
||||
Paste the manifest URL, press Install, then **restart** — a button on the same screen, not
|
||||
an instruction to go and restart the container. It runs the lifecycle shutdown and exits,
|
||||
and the supervisor declared in the shipped Compose file brings the process back.
|
||||
|
||||
That is why `restart: unless-stopped` is called out as load-bearing on [Install the
|
||||
site](/docs/getting-started/install-the-site/). Without a supervisor, that button takes the
|
||||
site down and leaves it down.
|
||||
|
||||
### The `MODULES` environment variable
|
||||
|
||||
For hosts managed by Compose rather than by clicking. Each entry is:
|
||||
|
||||
```
|
||||
<id>@<version>=<install manifest URL>
|
||||
```
|
||||
|
||||
Resolution runs **inside the server process**, before the volume is scanned — which is what
|
||||
lets it write the same provenance columns a panel install writes. A module already unpacked
|
||||
at the declared version is a no-op that makes **no network call at all**.
|
||||
|
||||
### By hand
|
||||
|
||||
`./modules` is a bind mount, deliberately rather than a named volume, so placing a module
|
||||
directory there yourself is a **supported install**. A named volume would have routed that
|
||||
through `docker cp`.
|
||||
|
||||
The image's own copy of `modules/` is excluded by `.dockerignore`, so a module sitting in a
|
||||
builder's working tree can never ship inside an image.
|
||||
|
||||
## Which surface wins
|
||||
|
||||
They govern different things, and the split is worth memorising:
|
||||
|
||||
- **The declaration owns what is on the volume.**
|
||||
- **The row owns whether a module runs.**
|
||||
|
||||
So uninstalling a declared module from the admin panel **returns its files at the next
|
||||
start and leaves it disabled**. The files come back because `MODULES` still declares them;
|
||||
it stays off because the row says so. That is the intended outcome, not a bug — but it
|
||||
surprises people who expect the panel to be the last word.
|
||||
|
||||
## Upgrades
|
||||
|
||||
Paste the new release's manifest URL and install over the top. The bundle is verified,
|
||||
unpacked over the old one, and takes effect at the restart.
|
||||
|
||||
An upgrade **deliberately leaves the state alone** — upgrading an enabled module must not
|
||||
silently switch it off, and re-installing a disabled one must not silently switch it on.
|
||||
|
||||
<Aside type="caution" title="Check the API range first">
|
||||
A module declares which core API versions it accepts. If a release needs a newer core than
|
||||
your image provides, upgrade the site first — see [The module
|
||||
manifest](/docs/modules/the-module-manifest/) for how that range is checked, and
|
||||
[Maintenance and upgrades](/docs/administration/maintenance-and-upgrades/) for the site
|
||||
half.
|
||||
</Aside>
|
||||
|
||||
## Removal
|
||||
|
||||
Covered in full on [Managing modules](/docs/administration/managing-modules/); the shape
|
||||
matters here because it constrains what you ship.
|
||||
|
||||
**Uninstall** is non-destructive: the row goes to `disabled`, the directory is removed, and
|
||||
the module's **tables and data are retained**.
|
||||
|
||||
**Purge** is separate, explicit, and destructive — it runs your `purge.sql`. It is offered
|
||||
in two places, and both are while the file is still on disk: as a standalone action on an
|
||||
installed module, and as an opt-in checkbox in the uninstall dialog.
|
||||
|
||||
That second placement exists because of a real ordering trap: **`purge.sql` lives inside the
|
||||
directory uninstall deletes**, so "purge afterwards" was never actually possible — it would
|
||||
have left a disabled row whose Purge button had nothing to run.
|
||||
|
||||
The consequence, accepted and stated: an operator who uninstalls without ticking the box
|
||||
keeps the tables, and getting rid of them later means reinstalling the module first. Write
|
||||
`purge.sql` on the assumption it may be run long after anyone remembers what it drops.
|
||||
103
src/content/docs/docs/modules/module-lifecycle.mdx
Normal file
@@ -0,0 +1,103 @@
|
||||
---
|
||||
title: Module lifecycle
|
||||
description: What core does to your module on boot, in what order, and what happens when any step of it throws.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
The five states are on [Managing modules](/docs/administration/managing-modules/), from the
|
||||
operator's side. This is the same machine from inside the module — what core calls, when,
|
||||
and what it does with a throw.
|
||||
|
||||
## The scan
|
||||
|
||||
The loader reads `modules/*/module.json` from the filesystem **synchronously, at require
|
||||
time**. The database is not consulted: what is on the volume determines what mounts.
|
||||
|
||||
`MODULES_DIR` defaults to `<repo>/modules`, and Compose sets it to `/app/modules`. **A
|
||||
missing modules directory is not an error** — "no modules installed" is the normal state of
|
||||
bare core, and the loader must not make the mount mandatory to boot.
|
||||
|
||||
Modules load **alphabetically by `id`**, deterministically. There is no dependency
|
||||
resolution between modules, and alphabetical order is the honest way of saying so: any
|
||||
other order would imply a precedence nobody is computing. Do not build a module that needs
|
||||
to load before or after another one.
|
||||
|
||||
## Validation, in order
|
||||
|
||||
Each step runs against your module. A failure at any step is **your module's failure and
|
||||
nobody else's**.
|
||||
|
||||
1. `module.json` parses, has no unknown keys, and its `id` matches the directory name.
|
||||
2. `coreApi` is satisfied by core's `MODULE_API_VERSION`.
|
||||
3. Declared `mounts` prefixes are well-formed and collide with nothing.
|
||||
4. Declared `extensions` slots all exist.
|
||||
5. `schema` and `purge` files exist and are readable, and their table names are namespaced
|
||||
or allowlisted.
|
||||
6. `require()` of your server entry succeeds and exports a function.
|
||||
7. `register(ctx, api)` returns without throwing, **and registers exactly what
|
||||
`module.json` declared**.
|
||||
|
||||
Step 7 is worth reading twice. The manifest is not documentation of what you register — it
|
||||
is a claim core holds you to. Registering something you did not declare fails, and so does
|
||||
declaring something you do not register.
|
||||
|
||||
<Aside type="note" title="Collision detection probes the live routers">
|
||||
Step 3 asks the actual tier routers whether a prefix is taken, rather than consulting a
|
||||
list of core's prefixes. A hardcoded table was tried and was already one prefix stale by
|
||||
the time it was written.
|
||||
|
||||
Mounting is also a **second pass** over the modules that survived validation, not part of
|
||||
the scan loop — otherwise the first module's layers would already be on the router while
|
||||
the second was validated, and the second would be told it collided with *core*, naming the
|
||||
wrong culprit.
|
||||
</Aside>
|
||||
|
||||
## Then the module runs
|
||||
|
||||
For each module that passed:
|
||||
|
||||
1. **Schema replay** — your `schema.sql` fragment is applied. It must be idempotent; it runs
|
||||
on every boot.
|
||||
2. **Routes and registrations** mount.
|
||||
3. **`onBoot(ctx)`** is called, if you export one. This is where long-lived work belongs:
|
||||
opening a stream, starting a poller, connecting to something.
|
||||
|
||||
On shutdown, **`onShutdown()`** is called. Disabling a module from the panel dispatches it
|
||||
too, so the module actually stops — releases its sockets, closes its streams — rather than
|
||||
merely becoming unreachable.
|
||||
|
||||
Enabling is deliberately **not** the mirror image: there is no `onBoot` re-dispatch, so the
|
||||
panel offers a restart instead. If your `onBoot` is expensive or stateful, that asymmetry is
|
||||
in your favour.
|
||||
|
||||
## Failure is contained, by construction
|
||||
|
||||
**A module that fails to load never takes the site down.**
|
||||
|
||||
The loader try/catches the module's **entire** lifecycle — require, validation, registration,
|
||||
schema replay, `onBoot` — not merely failures that surface after a router object was
|
||||
returned. Any failure at any point marks that module `startup_failed`, records the reason,
|
||||
and the site comes up with that module's routes and navigation absent.
|
||||
|
||||
Two consequences to design around:
|
||||
|
||||
- **A failed module is retried on every restart.** There is no backoff and no quarantine.
|
||||
A deterministically broken module re-records its failure each boot, which is the honest
|
||||
thing for it to do.
|
||||
- **`disabled` is the only state a boot leaves alone.** Every other non-disabled module is
|
||||
reset to `enabled` at boot and then recorded as `started` or `startup_failed`. Disabling
|
||||
is an operator's decision rather than an outcome, so it survives restarts untouched.
|
||||
|
||||
<Aside type="caution" title="Fail loudly and early">
|
||||
Because failure is contained, a broken module is easy to *not notice* — the site comes up
|
||||
fine and one section is missing. Validate your own configuration in `register()` or
|
||||
`onBoot()` and throw with a message naming what is wrong. `startup_failed` with a good
|
||||
reason is a far better outcome than a module that starts and then quietly does nothing.
|
||||
</Aside>
|
||||
|
||||
## Where the loader is specified
|
||||
|
||||
[`MODULE_API.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md)
|
||||
Part 4 is the normative account of everything on this page, including the exact position of
|
||||
the `load()` call in `app.js` and why it is load-bearing in both directions.
|
||||
104
src/content/docs/docs/modules/testing-and-release.mdx
Normal file
@@ -0,0 +1,104 @@
|
||||
---
|
||||
title: Testing and release
|
||||
description: The checks a module should run before it ships, what a release artifact actually is, and how the version that ships gets decided.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
## The checks
|
||||
|
||||
Four, and each exists because something got past review without it.
|
||||
|
||||
```bash
|
||||
npm run check:imports --prefix server # zero imports leave the module directory
|
||||
npm run build --prefix client # build FIRST — two tests read the chunk
|
||||
npm run check:externals --prefix client # no shared dependency welded into the chunk
|
||||
npm test --prefix server && npm test --prefix client
|
||||
```
|
||||
|
||||
**`check:imports`** enforces [the zero-internal-imports
|
||||
rule](/docs/modules/building-a-module/#the-rule-ci-enforces). It is the mechanical form of
|
||||
the module boundary — without it, the boundary is a convention, and conventions lose.
|
||||
|
||||
**`check:externals`** is the one that catches a chunk shipping bare `import 'react'`
|
||||
specifiers, or a second React welded in. Both build cleanly. Neither works in a browser.
|
||||
|
||||
<Aside type="caution" title="Build before you test">
|
||||
Two client tests read the built chunk. Run them against a stale `dist/` and they will
|
||||
happily pass on last week's output.
|
||||
</Aside>
|
||||
|
||||
Also worth running your OpenAPI fragment check if you publish one — the filename is fixed
|
||||
at `swagger-fragment.json` in the bundle root, so a module cannot point core at some other
|
||||
file.
|
||||
|
||||
## What a release artifact is
|
||||
|
||||
**Not source.** An operator never builds anything, and that constraint shapes everything
|
||||
here.
|
||||
|
||||
A release is **the directory core's loader expects to find at `modules/<id>/`, already
|
||||
assembled** — the prebuilt client chunk, any runtime dependency installed, the schema
|
||||
fragment, the OpenAPI fragment — packed exactly as it will be unpacked.
|
||||
|
||||
Two artifacts ship:
|
||||
|
||||
- `<id>-<version>.tar.gz`
|
||||
- an **install manifest** carrying that tarball's URL and its `sha256`
|
||||
|
||||
The admin install downloads the tarball, verifies the hash, and unpacks it. **Nothing runs
|
||||
`npm` on the way.**
|
||||
|
||||
## The version that ships is the tag
|
||||
|
||||
The template derives the next version from conventional-commit subjects since the newest
|
||||
`v*` tag:
|
||||
|
||||
| Commits since the last tag | Result |
|
||||
|---|---|
|
||||
| `feat!:` or `BREAKING CHANGE` | major |
|
||||
| `feat:` | minor |
|
||||
| `fix:` / `perf:` | patch |
|
||||
| Nothing releasable | **no release is cut** |
|
||||
| First ever run, no tag | releases what `module.json` declares |
|
||||
|
||||
Your committed `module.json` version is a **floor and a starting point, not a record of the
|
||||
last release**. Name a version there above the newest tag and that version is what releases
|
||||
— which is still the natural way to say "this one is a minor" when a `coreApi` bump forces
|
||||
the question.
|
||||
|
||||
<Aside type="note" title="Why derived rather than declared">
|
||||
The obvious alternative is to let `module.json`'s version decide: you already have that
|
||||
number, and two sources for one number is how they drift.
|
||||
|
||||
This project's reference module shipped that way and moved off it. The cost of a declared
|
||||
version is paid on **every** release, and the drift it prevents is something review catches
|
||||
anyway — a week of merged work there produced no bundle at all, because none of it happened
|
||||
to touch that line.
|
||||
</Aside>
|
||||
|
||||
## Pin the core you build against
|
||||
|
||||
Keep a `ci/core-ref.json` naming the exact core commit your module is written against, and
|
||||
have CI assert your declared `coreApi` still holds against that core's
|
||||
`MODULE_API_VERSION`.
|
||||
|
||||
Moving that sha is the moment someone re-reads what changed. It is the same mechanism [the
|
||||
Integration Kit uses](/docs/modules/the-integration-kit/#the-pin-that-forces-a-re-read), and
|
||||
the reason a contract bump upstream becomes a visible decision in your repository rather
|
||||
than a silent one.
|
||||
|
||||
## Before you tag
|
||||
|
||||
A short list, all of it learned rather than invented:
|
||||
|
||||
- **The module boots on a real deployment**, not just in tests. [Failure is
|
||||
contained](/docs/modules/module-lifecycle/#failure-is-contained-by-construction), so a
|
||||
broken module is easy to not notice — the site comes up and one section is missing.
|
||||
- **`schema.sql` is genuinely idempotent.** It runs on every boot, not once.
|
||||
- **`purge.sql` still makes sense to someone who has forgotten your module**, because
|
||||
[that is who will run it](/docs/modules/installing-modules/#removal).
|
||||
- **Your `coreApi` range covers the oldest core you actually test against**, not just the
|
||||
newest one you have.
|
||||
- **Every capability string you publish is one you intend to keep.** Something outside your
|
||||
repository is branching on them.
|
||||
107
src/content/docs/docs/modules/the-integration-kit.mdx
Normal file
@@ -0,0 +1,107 @@
|
||||
---
|
||||
title: The Integration Kit
|
||||
description: The instruction book for putting a different game on the platform — four chapters, a buildable template, and an honest account of its status.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
The [Integration
|
||||
Kit](https://gitea.whitlocktech.com/RunicGateway/Integration-kit) is a separate repository
|
||||
whose entire job is teaching someone **outside this project** how to put a different game on
|
||||
the platform.
|
||||
|
||||
<Aside type="caution" title="The kit describes itself as a draft, and so do we">
|
||||
In its own words: *the kit is finished when someone outside this project builds a working
|
||||
module for a new game by following it alone, without reading core's source. That has not
|
||||
happened yet.*
|
||||
|
||||
We are not going to describe it as finished before that happens. If you are the person who
|
||||
tries it, the places you get stuck are the most valuable thing the repository can receive —
|
||||
[open an issue](https://gitea.whitlocktech.com/RunicGateway/Integration-kit/issues) saying
|
||||
where you left the kit and what you did next.
|
||||
</Aside>
|
||||
|
||||
## What it covers
|
||||
|
||||
Three things, because the reasons live in the joins between them:
|
||||
|
||||
```
|
||||
your game server ──dials out──▶ your sidecar ──HTTP + WS──▶ website core
|
||||
(plugin: bounded queue, (owns the socket, (loads your module,
|
||||
writer thread) persists, then forwards) serves the pages)
|
||||
```
|
||||
|
||||
| Part | What it is |
|
||||
|---|---|
|
||||
| **The website module** | A bundle core loads at boot. The bulk of the work, and the only part every module needs |
|
||||
| **The sidecar** | A small service owning the connection to your game server, and the durable copy of what the game said. **Not optional** |
|
||||
| **The game-side plugin** | Whatever runs inside your game and feeds the sidecar, without ever letting the sidecar stall the game |
|
||||
|
||||
## The four chapters
|
||||
|
||||
| # | Chapter | What it covers |
|
||||
|---|---|---|
|
||||
| 1 | Your first module in twenty minutes | Copy the template, rename it, build it, install it, see a page. No theory |
|
||||
| 2 | The website module | `module.json`, `register(ctx, api)`, the schema fragment, the client chunk, packaging, and what a module must never do |
|
||||
| 3 | The sidecar | Why the website never talks to a game server, what "persist before you forward" means, and what a *thin* sidecar is |
|
||||
| 4 | The game-side plugin | The least code and the highest stakes: never block the game thread |
|
||||
|
||||
Before any of them, the kit points at the [Rust dry
|
||||
run](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/modules/rust-dryrun.md)
|
||||
— a complete module designed on paper for a second game, and the shortest honest picture of
|
||||
the whole job.
|
||||
|
||||
## The template is built, not just quoted
|
||||
|
||||
Chapters 1 and 2 quote `template/`, a real module that CI builds against a pinned core. The
|
||||
code in those chapters is **a tree that is proved rather than prose that looks like one**.
|
||||
|
||||
Chapters 3 and 4 cite `uo-link` and `servuo-plugins` by file and identifier rather than by
|
||||
line number, deliberately: those repositories move for their own reasons, and a line number
|
||||
in a book is wrong the moment they do.
|
||||
|
||||
## The kit never re-specifies a contract
|
||||
|
||||
This is its governing rule, and it is the same one this site follows.
|
||||
|
||||
> Nothing in these chapters is normative. Where a chapter and one of these documents
|
||||
> disagree, the document is right and the chapter has a bug.
|
||||
|
||||
| Authority | For |
|
||||
|---|---|
|
||||
| `MODULE_API.md` | Everything a module may do |
|
||||
| `MODULE_SYSTEM.md` | Why the module system is shaped this way, and how a module is installed and removed |
|
||||
| `link/PLAN.md` + `INTEGRATION.md` | The game ↔ sidecar wire protocol, as one real sidecar implements it |
|
||||
|
||||
The chapters teach the order to do things in, the reasoning, and **the mistakes that cost
|
||||
this project time**.
|
||||
|
||||
## The pin that forces a re-read
|
||||
|
||||
`ci/core-ref.json` pins the exact core commit the kit is written against, and CI asserts
|
||||
that the version `template/module.json` declares **equals** that core's
|
||||
`MODULE_API_VERSION`.
|
||||
|
||||
Equality, not "satisfies". That is the mechanism, not a bug: a contract bump in the website
|
||||
repository is *meant* to turn the kit red, so that someone re-reads the chapters before the
|
||||
pin moves.
|
||||
|
||||
<Aside type="note" title="It has already earned its keep">
|
||||
Writing the chapters against 1.6.0 found that core's inverted-slot fills named three of
|
||||
`module-uo`'s slots **literally** — so the mechanism worked for that one module and silently
|
||||
did nothing for any other game, producing an empty page with nothing logged.
|
||||
|
||||
That is exactly the class of defect a book written for an audience outside this org exists
|
||||
to catch, and it was fixed in core before the pin moved.
|
||||
</Aside>
|
||||
|
||||
## Running its checks
|
||||
|
||||
Dependency-free Node scripts, from the repository root — which is also how a reader runs
|
||||
them:
|
||||
|
||||
```bash
|
||||
node scripts/checkLinks.js # every relative link resolves; no commit permalinks
|
||||
node scripts/checkRenameSites.js # the rename checklist matches the template tree
|
||||
node scripts/checkChapterPaths.js # every path a chapter names in backticks still exists
|
||||
```
|
||||
171
src/content/docs/docs/modules/the-module-api.mdx
Normal file
@@ -0,0 +1,171 @@
|
||||
---
|
||||
title: The module API
|
||||
description: The two arguments core hands your module — what you can reach, what you can register, and the rules that govern both.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
Your server entry point exports one function:
|
||||
|
||||
```js
|
||||
module.exports = function register(ctx, api) { /* … */ }
|
||||
```
|
||||
|
||||
`ctx` is what core lends you. `api` is what you register with it. Everything crossing the
|
||||
module boundary goes through one of the two.
|
||||
|
||||
The contract version is **`MODULE_API_VERSION`**, currently **1.6.0**, and your manifest's
|
||||
[`coreApi` range](/docs/modules/the-module-manifest/#coreapi-and-what-a-range-means) is
|
||||
checked against it before your code is required.
|
||||
|
||||
## The entry point runs early
|
||||
|
||||
`register()` is called **once, synchronously, during core's require phase — not after the
|
||||
database is up.**
|
||||
|
||||
It must not `await`, must not touch the database, and must not throw for a reason a retry
|
||||
would fix. Everything needing a live database belongs in `onBoot`.
|
||||
|
||||
<Aside type="caution" title="This constraint is not stylistic">
|
||||
Core's route-manifest and OpenAPI generators both require the app with the connection pool
|
||||
pointed at a dead port. A module that queried at registration time would hang both.
|
||||
</Aside>
|
||||
|
||||
## `ctx` — what you can reach
|
||||
|
||||
Every member exists because a real module needed it. The surface is grown from demonstrated
|
||||
need, never speculation.
|
||||
|
||||
| Member | What it gives you |
|
||||
|---|---|
|
||||
| `ctx.express`, `ctx.validator` | Core's own `express` and `express-validator` namespaces |
|
||||
| `ctx.db.query`, `ctx.db.pool` | Parameterised SQL, and the pool for streaming work |
|
||||
| `ctx.log(namespace)` | `error` / `warn` / `info` / `debug`, each `(msg, meta?)` |
|
||||
| `ctx.settings` | `get`, `set`, `getInstanceName` |
|
||||
| `ctx.auth.getUserFromRequest(req)` | `{ id, username, role }` or `null` |
|
||||
| `ctx.push.publish` | Notification fan-out |
|
||||
| `ctx.secretBox` | `encrypt` / `decrypt` for secrets at rest |
|
||||
| `ctx.middleware` | `requireAuth`, `requireRole`, `siteMode`, `validate`, `noindex`, `rateLimit`, `accountChangeLimiter` |
|
||||
| `ctx.uploads` | `upload`, `UPLOAD_DIR`, `MIME_EXT` |
|
||||
| `ctx.posts` | `listAll`, `getById`, `linkAnnounceJob`, `markAnnounced` |
|
||||
| `ctx.paths.moduleRoot` | Absolute path to your own directory |
|
||||
| `ctx.activity.log` | The admin audit trail |
|
||||
| `ctx.users.getById` | Read a user |
|
||||
| `ctx.site.baseUrl` | Absolute base URL, no trailing slash |
|
||||
| `ctx.moduleId` | Your id, from the manifest |
|
||||
| `ctx.teams` | `publish`, `reconcile`, `activity.push` — see below |
|
||||
|
||||
`ctx` is frozen one level deep before you get it. That is a guard against accident, not
|
||||
against a hostile module — the boundary is organisational, [not a security
|
||||
boundary](/docs/modules/the-module-system/#the-boundary-is-not-a-sandbox).
|
||||
|
||||
### Three narrowings worth knowing
|
||||
|
||||
Core deliberately hands you **less** than the underlying utility exports.
|
||||
|
||||
- **`ctx.auth` is one function.** The full facade can mint sessions; minting is core's job.
|
||||
A module that needs an identity needs to *read* one.
|
||||
- **`ctx.settings` is three functions**, not the model's 24 — most of those are registration
|
||||
and app-links policy that is core's business.
|
||||
- **`ctx.posts` is four functions.** `create` / `update` / `remove` are the CMS, and the CMS
|
||||
is not a module's.
|
||||
|
||||
<Aside type="note" title="Why `ctx.express` has to exist">
|
||||
A module lives at `modules/<id>/`, outside `server/`, so Node's resolver never reaches
|
||||
core's `node_modules` and a plain `require('express')` simply fails. Even where it
|
||||
resolved, a second express in the process means a second `Router` prototype. Core owns one
|
||||
express, exactly as it owns one React.
|
||||
</Aside>
|
||||
|
||||
### `ctx.teams` is push-only, on purpose
|
||||
|
||||
There is no reader. A module **answers** questions about Teams; it does not ask them. Every
|
||||
Team table is core-internal, and a `getTeamRoster` would be core offering to read back the
|
||||
module's own answer — which the module already holds.
|
||||
|
||||
All three members are fire-and-forget and never reject, because they are called from inside
|
||||
game-event handlers and a storage problem of core's must not become your control flow.
|
||||
|
||||
See [Teams architecture](/docs/architecture/teams-architecture/) for the whole shape.
|
||||
|
||||
## `api` — what you register
|
||||
|
||||
```js
|
||||
api.registerRoutes({ public: {…}, admin: {…}, player: {…} })
|
||||
api.registerExtension(slot, router)
|
||||
api.registerNotificationStreams(streams)
|
||||
api.registerAnnounceLeg({ leg, label, dispatch, classify })
|
||||
api.registerPostHook({ onSaved, onDeleted })
|
||||
api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders })
|
||||
api.registerSlashCommands([{ name, description, options, access, handler }])
|
||||
api.onBoot(async (ctx) => {})
|
||||
api.onShutdown(async () => {})
|
||||
```
|
||||
|
||||
Every call is synchronous, and **calling one twice is an error** rather than a
|
||||
last-one-wins overwrite.
|
||||
|
||||
### Everything stages; nothing commits until you are known good
|
||||
|
||||
A claim's *shape* is checked at the call, so a malformed one throws with your own stack.
|
||||
Whether a name is *taken* can only be answered once the whole batch is in, and is checked
|
||||
when the loader commits.
|
||||
|
||||
The consequence is the one that matters: a module that registers two streams and then
|
||||
throws **has left nothing behind**. A half-registered catalog would be worse than a missing
|
||||
one — it is a subscribable stream that nothing will ever publish to.
|
||||
|
||||
### `registerRoutes` and the tier gate
|
||||
|
||||
One `express.Router()` per prefix per tier. The keys must match `module.json`'s `mounts`
|
||||
exactly, and prefixes are one segment — no nesting, no parameters.
|
||||
|
||||
**The tier gate is already applied.** A router registered under `admin` sits behind
|
||||
`noindex, isLoggedIn, requireRole('admin','editor','moderator')`; under `player`, behind
|
||||
`noindex, requireAuth`; under `public`, behind nothing, by design.
|
||||
|
||||
Add per-route gates on top of that. **Never re-implement the tier gate** — a module that
|
||||
rolls its own is a module whose access rules drift from core's.
|
||||
|
||||
Your router is mounted *inside* the tier, so it structurally cannot reach above its prefix.
|
||||
|
||||
## The client half
|
||||
|
||||
The client contract is its own thing. Core populates a global before it renders, and
|
||||
freezes it afterwards:
|
||||
|
||||
```js
|
||||
window.__rg = {
|
||||
version, // MODULE_API_VERSION — the same number as the server's
|
||||
react, // the React namespace
|
||||
reactDom, // react-dom/client
|
||||
router, // react-router-dom namespace
|
||||
jsxRuntime, // react/jsx-runtime
|
||||
registry, // routes, nav, feature providers, slots
|
||||
ui, // the shared component kit
|
||||
api, // the request primitive
|
||||
}
|
||||
```
|
||||
|
||||
Your chunk declares `react`, `react-dom` and `react-router-dom` as **externals** resolving
|
||||
to that global — a global rather than an import map precisely because an import map must be
|
||||
inline and `script-src 'self'` forbids inline script.
|
||||
|
||||
**`jsxRuntime` is not decoration.** Your bundler compiles every `.jsx` file to imports from
|
||||
`react/jsx-runtime` under the modern automatic runtime, and those must resolve to *core's*
|
||||
React like everything else. Without it on the global you would have to build with
|
||||
`jsxRuntime: 'classic'`; with it, you use the default your tooling already assumes.
|
||||
|
||||
**`version` is there so your entry can check it.** A module entry compares
|
||||
`window.__rg.version` against its own `coreApi` range and refuses to register on a
|
||||
mismatch, logging once — the client-side twin of the boot-time check.
|
||||
|
||||
You register routes, navigation and feature providers through `registry`. See [Building a
|
||||
module](/docs/modules/building-a-module/).
|
||||
|
||||
## The contract itself
|
||||
|
||||
[`MODULE_API.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md)
|
||||
is normative and complete — Part 2 for the server contract, Part 3 for the client, Part 4
|
||||
for the loader's obligations and Part 5 for how they are enforced. This page is a map of
|
||||
it, not a substitute.
|
||||
114
src/content/docs/docs/modules/the-module-manifest.mdx
Normal file
@@ -0,0 +1,114 @@
|
||||
---
|
||||
title: The module manifest
|
||||
description: Every key in module.json, what the loader does with each, and why a typo is a boot failure rather than an inert setting.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
`module.json` sits at the root of your bundle. The loader reads it synchronously, before
|
||||
anything else about your module runs.
|
||||
|
||||
**Unknown top-level keys are rejected, not ignored.** A misspelled key is a loud failure
|
||||
rather than a silently-inert setting — which is the right trade when the alternative is a
|
||||
module that boots and mysteriously does half its job.
|
||||
|
||||
## A complete manifest
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uo",
|
||||
"name": "Ultima Online",
|
||||
"version": "1.0.0",
|
||||
"coreApi": "^1.0.0",
|
||||
"server": "server/index.js",
|
||||
"client": { "entry": "client/dist/entry.js" },
|
||||
"schema": "server/db/schema.sql",
|
||||
"purge": "server/db/purge.sql",
|
||||
"mounts": {
|
||||
"public": ["/shard", "/atlas"],
|
||||
"admin": ["/shard", "/uo-link"],
|
||||
"player": ["/shard"]
|
||||
},
|
||||
"extensions": ["admin.users.detail"],
|
||||
"capabilities": ["shard", "atlas", "market"]
|
||||
}
|
||||
```
|
||||
|
||||
## The keys
|
||||
|
||||
| Key | Required | Meaning |
|
||||
|---|---|---|
|
||||
| `id` | yes | `^[a-z][a-z0-9-]{1,31}$`. The directory name, the `installed_modules` key, the URL segment, and the client registry key — all at once. **Must equal the directory it was read from.** |
|
||||
| `name` | yes | Human label for the admin Modules screen |
|
||||
| `version` | yes | Semver. Recorded on install; shown on failure |
|
||||
| `coreApi` | yes | Semver **range**, checked against core's `MODULE_API_VERSION` |
|
||||
| `server` | no | Server entry point, relative to the module root. Absent means a client-only module |
|
||||
| `client.entry` | no | The prebuilt ESM chunk, **in a subdirectory** — the directory it sits in is what gets served. Absent means a server-only module; present-but-empty is rejected, because it claims a client half and delivers none |
|
||||
| `schema` | no | Idempotent SQL fragment, replayed every boot |
|
||||
| `purge` | no | Destructive teardown. **Required if `schema` is present** |
|
||||
| `mounts` | no | Declared route prefixes per tier |
|
||||
| `extensions` | no | Core extension slots this module mounts into |
|
||||
| `capabilities` | no | Opaque strings published to clients for feature detection |
|
||||
|
||||
## `mounts` is a claim, not a description
|
||||
|
||||
The loader compares your declaration against what your module **actually registers**, and
|
||||
rejects a mismatch in either direction. Declaring a prefix you never mount fails; mounting
|
||||
one you never declared fails too.
|
||||
|
||||
Prefixes are validated against `^/[a-z0-9][a-z0-9-]*$`, and the keys must match what you
|
||||
register exactly.
|
||||
|
||||
<Aside type="caution" title="These are API prefixes, not page URLs">
|
||||
`mounts` governs your **server** routes. Your SPA pages are registered separately by the
|
||||
client half, and *those* are namespaced under your module id.
|
||||
|
||||
That is why `module-uo` declares `admin: ["/shard", "/uo-link"]` while its admin screen
|
||||
lives at `/admin/uo/link`. Two different mechanisms, and [the module
|
||||
system](/docs/modules/the-module-system/) explains why the split is deliberate.
|
||||
</Aside>
|
||||
|
||||
## `capabilities` is for feature detection
|
||||
|
||||
Opaque strings, published by `GET /api/v1/public/modules` — and **only while the module is
|
||||
`started`**. Clients like the SPA and the Android app read them to decide what to show.
|
||||
|
||||
They are not permissions and not mount prefixes. Keep them stable: something outside your
|
||||
repository is branching on them.
|
||||
|
||||
## `coreApi` and what a range means
|
||||
|
||||
Core exports a single semver string, currently **1.6.0**. Your range is checked at boot,
|
||||
before your code is required.
|
||||
|
||||
A **minor** bump adds members without removing any or changing a signature, so `^1.3.0`
|
||||
keeps resolving against 1.6.0 — which is exactly why `module-uo` still declares `^1.3.0`
|
||||
and runs fine.
|
||||
|
||||
Use a caret range against the oldest core you actually support and test against. Pinning
|
||||
exactly buys nothing and strands you on the next additive release.
|
||||
|
||||
<Aside type="note" title="Not the same number as the protocol version">
|
||||
`coreApi` versions the **website module contract**. `PROTOCOL_VERSION` versions the **shard
|
||||
wire** and says nothing about a website module. See [Protocol
|
||||
versions](/docs/architecture/protocol-versions/).
|
||||
</Aside>
|
||||
|
||||
## Schema and purge
|
||||
|
||||
`schema` runs on **every boot**, so it must be idempotent — `CREATE TABLE IF NOT EXISTS`,
|
||||
and additive migrations written so a replay is harmless. Table names must be namespaced or
|
||||
allowlisted; the loader checks.
|
||||
|
||||
`purge` is required whenever `schema` is present, because a module that can create tables
|
||||
must offer a way to remove them. It is only ever run by an explicit purge — never as part
|
||||
of an uninstall.
|
||||
|
||||
Remember [where `purge.sql` lives](/docs/modules/installing-modules/#removal): inside the
|
||||
directory an uninstall deletes. Write it to be run by someone who no longer remembers what
|
||||
your module created.
|
||||
|
||||
## The full specification
|
||||
|
||||
[`MODULE_API.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md)
|
||||
§2.1 is normative for the manifest, and §2.6 for the schema fragments.
|
||||
103
src/content/docs/docs/modules/the-module-system.mdx
Normal file
@@ -0,0 +1,103 @@
|
||||
---
|
||||
title: The module system
|
||||
description: What a module is, why the platform is built this way, and the one rule about URLs that catches everybody once.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
Runic Gateway's core knows nothing about any particular game. Everything that makes the
|
||||
site a *Ultima Online* site — the shard status, the atlas, the market, the guild pages —
|
||||
lives in a **module**, installed onto a running deployment.
|
||||
|
||||
This section is the builder's track. If you only want to install one, that is
|
||||
[Install a game module](/docs/getting-started/install-a-game-module/) and
|
||||
[Managing modules](/docs/administration/managing-modules/).
|
||||
|
||||
## What a module is
|
||||
|
||||
One repository producing one bundle, with a server half and a client half that version
|
||||
together — so a route and the screen that calls it can never be mismatched.
|
||||
|
||||
A module owns:
|
||||
|
||||
- **Its routes**, server and client
|
||||
- **Its schema**, as a fragment core replays on boot
|
||||
- **Its navigation entries**, interleaved into core's groups rather than parked in a
|
||||
section of their own
|
||||
- **Its vocabulary** — the words a player of *that* game expects
|
||||
|
||||
Core owns the account, the session, the roles, the posts, the uploads, notifications and
|
||||
Teams. A module reaches all of that through a defined surface, [the module
|
||||
API](/docs/modules/the-module-api/).
|
||||
|
||||
## Why it is built this way
|
||||
|
||||
Three constraints had to hold at the same time, and between them they determined almost
|
||||
everything else:
|
||||
|
||||
1. **Production is a prebuilt, pull-only image.** Operators do not build. There is no
|
||||
compile step anywhere in installing a module.
|
||||
2. **Modules live on a mounted volume**, not inside the image — a bind mount of
|
||||
`./modules`. That is what lets a module be added to an image that knows nothing about
|
||||
it.
|
||||
3. **`script-src 'self'`.** The content-security policy forbids inline script, which rules
|
||||
out an import map and is why core shares React on a global instead. See [Building a
|
||||
module](/docs/modules/building-a-module/).
|
||||
|
||||
Install and uninstall need a **restart** — never a rebuild.
|
||||
|
||||
<Aside type="note" title="One active module per deployment">
|
||||
Multi-module deployments are deliberately out of scope. `module_id` columns exist so the
|
||||
idea stays later-friendly, but nothing exercises them, and no one should design around
|
||||
them today.
|
||||
</Aside>
|
||||
|
||||
## The boundary is not a sandbox
|
||||
|
||||
A module runs **in the same Node process, with full access**. Say that plainly, because
|
||||
the word "module" invites the opposite assumption.
|
||||
|
||||
The boundary is a **code-organisation and distribution boundary, not a security
|
||||
boundary**. For a self-hosted operator installing software they chose, that is the same
|
||||
trust category as running its schema fragment — which they are also doing.
|
||||
|
||||
What the boundary buys is that modules talk to core through a *defined* surface, so a core
|
||||
refactor cannot silently break a module. That rule is enforced mechanically rather than by
|
||||
review: **a module must run with zero `require`/`import` reaching outside its own
|
||||
directory**, and CI checks it. A gap in the surface extends the surface; it is never
|
||||
worked around with a deeper import.
|
||||
|
||||
## The URL rule, and its one exception
|
||||
|
||||
**A module owns one path segment wherever it appears.** For a module with id `uo`:
|
||||
|
||||
| Surface | Path |
|
||||
|---|---|
|
||||
| Public pages | `/uo/shard`, `/uo/atlas`, `/uo/market` |
|
||||
| Admin pages | `/admin/uo/link`, `/admin/uo/visibility` |
|
||||
| Player pages | `/player/uo/…` |
|
||||
|
||||
**API routes are the exception, and keep their exact paths.** The shard admin API is still
|
||||
`/api/v1/admin/shard/*`, not `/api/v1/admin/uo/shard/*`. This is why the Android app and
|
||||
the Discord bot needed no API changes at the cutover.
|
||||
|
||||
<Aside type="caution" title="This distinction has already cost real time">
|
||||
The installer printed `<site>/admin/shard` — the pre-module path — well after the screen
|
||||
had moved to `/admin/uo/link`. It was not caught quickly because **the old path does not
|
||||
404**: the SPA has no route for it, so it redirects to the dashboard and looks like it
|
||||
worked.
|
||||
|
||||
If you are moving an existing surface into a module, the SPA paths change and the API paths
|
||||
do not. Grep for both.
|
||||
</Aside>
|
||||
|
||||
Old paths are **not** redirected. That was a deliberate call — a visible boundary in the URL
|
||||
rather than a hidden one — taken while the platform had no public deployments to break.
|
||||
|
||||
## Where the design of record lives
|
||||
|
||||
This page summarises. The normative document is
|
||||
[`MODULE_SYSTEM.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_SYSTEM.md),
|
||||
and the contract itself is
|
||||
[`MODULE_API.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md).
|
||||
Where this site and those documents disagree, they are right and this is a bug.
|
||||
73
src/content/docs/docs/reference/bridge-cfg.mdx
Normal file
@@ -0,0 +1,73 @@
|
||||
---
|
||||
title: Bridge.cfg
|
||||
description: Every key the in-game plugin reads — the connection, the sweep intervals, the feature switches and the caps that keep untrusted game data bounded.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
import { bridgeCfg } from '../../../../data/reference.mjs';
|
||||
|
||||
`Config/Bridge.cfg` in the ServUO tree configures the plugin — what it connects to, how
|
||||
often it sweeps the world, and which features it publishes.
|
||||
|
||||
[The installer](/docs/reference/installer-cli/) puts it there. Editing it is a shard
|
||||
operator's job, not a builder's.
|
||||
|
||||
## How to read this file
|
||||
|
||||
Three kinds of key, and they carry very different risk:
|
||||
|
||||
- **Connection** — where the sidecar is, and how much the plugin may buffer.
|
||||
- **Sweep intervals** — how often the plugin walks part of the world. **These are the
|
||||
performance dial.** Every sweep runs on the game's core thread, so shortening one costs
|
||||
the game, not the sidecar.
|
||||
- **Caps and switches** — feature toggles, and the bounds on anything a player can
|
||||
influence.
|
||||
|
||||
<Aside type="caution" title="The caps are a security control, not tuning">
|
||||
`TownCrierMaxLineLength`, `NewsMaxBodyLength`, `AccountNameMaxLength` and their siblings
|
||||
bound data that crosses between a public website and a game world in both directions.
|
||||
|
||||
`AdminWriteEnabled` is **off by default**, and it is the switch that decides whether the
|
||||
website may write to the game at all. Turn it on deliberately, having read
|
||||
[`ADMIN_CONTROLS.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/ADMIN_CONTROLS.md).
|
||||
</Aside>
|
||||
|
||||
## Every key
|
||||
|
||||
{Object.entries(bridgeCfg).map(([group, keys]) => (
|
||||
<div key={group}>
|
||||
<h3>{group}</h3>
|
||||
<table>
|
||||
<thead><tr><th>Key</th><th>What it is for</th></tr></thead>
|
||||
<tbody>
|
||||
{Object.entries(keys).map(([name, why]) => (
|
||||
<tr key={name}><td><code>{name}</code></td><td>{why}</td></tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
))}
|
||||
|
||||
This list is checked against the shipped `Bridge.cfg` on every build, so a key added by a
|
||||
protocol change turns this page red rather than going undocumented.
|
||||
|
||||
## Two that deserve their own note
|
||||
|
||||
**`QueueCap`** bounds the drop-oldest queue between the game and the writer thread. When it
|
||||
fills, the **oldest events are discarded** — which is the correct behaviour, because the
|
||||
alternative is a game server that stutters when a sidecar is slow. Raising it buys tolerance
|
||||
for longer sidecar outages at the cost of memory; it never buys correctness.
|
||||
|
||||
**`GuildRosterMembersPerLine`** exists because a roster is the only fat frame this bridge
|
||||
emits — a real 155-member guild measured about 10.8 KB. Rosters are **split** across lines
|
||||
rather than sent oversized. See [The bridge](/docs/architecture/the-bridge/).
|
||||
|
||||
## Canonical documents
|
||||
|
||||
The shipped
|
||||
[`Bridge.cfg`](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/src/branch/main/overlay/Config/Bridge.cfg)
|
||||
is the authority;
|
||||
[`link/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md)
|
||||
§10 documents the config keys and
|
||||
[`SHARD_PREREQS.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/SHARD_PREREQS.md)
|
||||
covers what a shard needs before any of this works.
|
||||
70
src/content/docs/docs/reference/canonical-documents.mdx
Normal file
@@ -0,0 +1,70 @@
|
||||
---
|
||||
title: Canonical documents
|
||||
description: Where the normative specifications live — the documents that win whenever this site disagrees with them.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
import { canonicalDocs } from '../../../../data/reference.mjs';
|
||||
|
||||
Everything on this site is a **summary**. These are the documents it summarises, and where
|
||||
the two disagree, **they are right and this site has a bug**.
|
||||
|
||||
<Aside type="note" title="Why say that so bluntly">
|
||||
A documentation site that quietly re-specifies a contract becomes a second source of truth,
|
||||
and second sources of truth drift. Every page here links out for exactly this reason, and
|
||||
this page is the index of what it links to.
|
||||
|
||||
If you find a disagreement, it is worth reporting — it means a check is missing.
|
||||
</Aside>
|
||||
|
||||
## The documents
|
||||
|
||||
<table>
|
||||
<thead><tr><th>Document</th><th>Answers</th></tr></thead>
|
||||
<tbody>
|
||||
{Object.entries(canonicalDocs).map(([docPath, why]) => (
|
||||
<tr key={docPath}>
|
||||
<td>
|
||||
<a href={`https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/${docPath}`}>
|
||||
<code>{docPath}</code>
|
||||
</a>
|
||||
</td>
|
||||
<td>{why}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
Every path above is checked to still exist on every build, so a document that is renamed or
|
||||
moved turns this page red rather than leaving a dead link.
|
||||
|
||||
## Which document answers which question
|
||||
|
||||
- **"May a module do this?"** → `MODULE_API.md`. It is the contract, and it is the only thing
|
||||
that can answer yes.
|
||||
- **"Why is the module system like this?"** → `MODULE_SYSTEM.md`.
|
||||
- **"What does this API return?"** → your own deployment's `/api/docs`, then
|
||||
`BACKEND_DESIGN.md` §4.
|
||||
- **"What can the shard send?"** → `link/PLAN.md` §5, and `v4.md` for the current protocol.
|
||||
- **"Who may see this?"** → `SHARD_VISIBILITY.md` for the administrator's view,
|
||||
`modules/uo/API.md` §4 for the specification.
|
||||
- **"How do I set a shard up?"** → `installer/INSTALL.md`.
|
||||
|
||||
## Where they live
|
||||
|
||||
All of them are in
|
||||
[`RunicGateway/docs`](https://gitea.whitlocktech.com/RunicGateway/docs), which is Markdown
|
||||
only and versioned independently of the code it describes.
|
||||
|
||||
**A code change is not complete until `docs` reflects it.** That is a rule in the
|
||||
project's own contributor guidance, not an aspiration — a change to behaviour, protocol,
|
||||
endpoints, schema, configuration or the deployment model requires a matching edit there.
|
||||
|
||||
## Two things that are not in `docs`
|
||||
|
||||
**The Integration Kit** is its own repository, because its audience is outside this project
|
||||
and it teaches rather than specifies. See [The Integration
|
||||
Kit](/docs/modules/the-integration-kit/).
|
||||
|
||||
**The OpenAPI specification** is generated and committed in `website` itself, because it is
|
||||
derived from the routes rather than written alongside them.
|
||||
66
src/content/docs/docs/reference/environment-variables.mdx
Normal file
@@ -0,0 +1,66 @@
|
||||
---
|
||||
title: Environment variables
|
||||
description: Every variable the site reads, what each is for, and the four it refuses to start without.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
import { envVars } from '../../../../data/reference.mjs';
|
||||
|
||||
Every variable in `website`'s root `.env.example` — **the file a Compose deployment actually
|
||||
reads**, which is not the same file local development copies.
|
||||
|
||||
This list is checked against that file on every build, in both directions: a variable that
|
||||
disappears upstream fails, and a variable added upstream that is missing here fails too.
|
||||
|
||||
<Aside type="caution" title="Four are refused at boot in production">
|
||||
`SECRET_ENC_KEY` and `BOT_INTERNAL_KEY` are required in production and the server **will not
|
||||
start** without them — `BOT_INTERNAL_KEY` even on a deployment running no Discord bot.
|
||||
`JWT_SECRET` and the `DB_*` group are required everywhere.
|
||||
|
||||
The first boot is also when your admin account is written, so `ADMIN_USERNAME` and
|
||||
`ADMIN_PASSWORD` are set-once-before-first-boot values, not fill-in-later ones.
|
||||
</Aside>
|
||||
|
||||
## Every variable
|
||||
|
||||
<table>
|
||||
<thead><tr><th>Variable</th><th>What it is for</th></tr></thead>
|
||||
<tbody>
|
||||
{Object.entries(envVars).map(([name, why]) => (
|
||||
<tr key={name}><td><code>{name}</code></td><td>{why}</td></tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## The three worth reading twice
|
||||
|
||||
**`SECRET_ENC_KEY`** encrypts secrets at rest — OAuth client secrets, the Discord bot token,
|
||||
the shard's auth token. Changing it does **not** re-encrypt anything: what was stored under
|
||||
the old key can no longer be read, and every stored secret has to be entered again.
|
||||
|
||||
**`COOKIE_SECURE=auto`** decides `Secure` per request, which is what lets one deployment
|
||||
work both over HTTPS through a proxy and over plain HTTP on a LAN address. Forcing it either
|
||||
way breaks one of those.
|
||||
|
||||
**`TRUST_PROXY`** is required behind a reverse proxy for secure cookies, real client IPs and
|
||||
rate limiting to work at all. Without it, every request appears to come from the proxy — so
|
||||
rate limiting and IP bans apply to your whole user base at once.
|
||||
|
||||
## Where to set them
|
||||
|
||||
A first install is [Install the site](/docs/getting-started/install-the-site/), which prints
|
||||
a complete `.env` alongside its Compose file. Afterwards,
|
||||
[Configuration](/docs/administration/configuration/) covers what is env-configured and what
|
||||
is not.
|
||||
|
||||
**Most settings are not here.** Branding, navigation, theming and the shard connection are
|
||||
**admin-managed and live in the database**, deliberately — so changing them does not mean
|
||||
redeploying a container.
|
||||
|
||||
## Canonical source
|
||||
|
||||
`website`'s
|
||||
[`.env.example`](https://gitea.whitlocktech.com/RunicGateway/website/src/branch/main/.env.example)
|
||||
is the authority, and
|
||||
[`BACKEND_DESIGN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/BACKEND_DESIGN.md)
|
||||
§8 covers deployment.
|
||||
104
src/content/docs/docs/reference/event-catalog.mdx
Normal file
@@ -0,0 +1,104 @@
|
||||
---
|
||||
title: Event catalog
|
||||
description: What a game server can tell the website, how those events are grouped, and the five-rung ladder that decides who may see each one.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
import { visibilityLadder } from '../../../../data/reference.mjs';
|
||||
|
||||
The events a shard emits, and the mechanism that decides who may see them.
|
||||
|
||||
The exact wire shapes are in the protocol specification and are **not** restated here — a
|
||||
copy of a wire format is a copy that will be wrong after the next bump. This page is the map
|
||||
and the security model.
|
||||
|
||||
## What the shard can say
|
||||
|
||||
Nine groups, from the data catalog:
|
||||
|
||||
| Group | Covers |
|
||||
|---|---|
|
||||
| Session & identity | Logins, logouts, account linking |
|
||||
| Character state | Vitals, stats, skills, position |
|
||||
| Economy & commerce | Gold movement, vendor sales, supply totals |
|
||||
| Housing / IDOC | Decay stages, ownership, coordinates |
|
||||
| Combat, death, PvP | Kills, deaths, notable fights |
|
||||
| Progression & activity | Skill gains, points, leaderboards |
|
||||
| Cheat detection & staff audit | Fastwalk and friends; staff property edits |
|
||||
| Lifecycle | `server.hello`, shutdown, crash |
|
||||
| Known gaps | Things ServUO offers no clean hook for |
|
||||
|
||||
A representative line:
|
||||
|
||||
```json
|
||||
{"t":1752,"kind":"cheat.fastwalk","serial":"0x1A2B","acct":"PerryAdimn"}
|
||||
```
|
||||
|
||||
Note that one. **Cheat and audit events exist, and they are exactly what must never reach a
|
||||
public page.**
|
||||
|
||||
## How events are handled
|
||||
|
||||
Not all alike, and the difference is deliberate:
|
||||
|
||||
- **State-changing kinds** update tables. The current state is what a page renders.
|
||||
- **Notable kinds** additionally append to an events log, because a history is worth
|
||||
keeping.
|
||||
- **High-frequency kinds** only update state. Accumulating history for something that fires
|
||||
constantly buys nothing and costs a table that grows forever.
|
||||
|
||||
## The visibility ladder
|
||||
|
||||
Five rungs, in order, least privileged first:
|
||||
|
||||
<ol>
|
||||
{visibilityLadder.map((rung) => (<li key={rung}><code>{rung}</code></li>))}
|
||||
</ol>
|
||||
|
||||
Every feature declares the rung it is visible from, and individual **fields** can require a
|
||||
higher rung than the feature that carries them — a character's presence may be public while
|
||||
its *location* is staff-only.
|
||||
|
||||
This list and its **order** are checked against the module that enforces it on every build.
|
||||
Order matters as much as membership: reasoning about "staff and above" depends on the rungs
|
||||
being in the right sequence.
|
||||
|
||||
<Aside type="caution" title="This is a security boundary, not a filter">
|
||||
It is applied in **three** places — at routes, at SSE subscribe time, and at the navigation.
|
||||
All three, because a surface filtered in only two of them leaks through the third.
|
||||
|
||||
Defaults **fail closed**: an unresolvable viewer is anonymous, not privileged, and a feature
|
||||
with no configuration is not public by accident.
|
||||
</Aside>
|
||||
|
||||
## Two SSE channels
|
||||
|
||||
Ingested events fan out to browsers over two streams:
|
||||
|
||||
- a **public** stream, carrying only allowlisted kinds;
|
||||
- an **admin** stream, which additionally carries staff audit, cheat detection and login
|
||||
attempts with IP addresses.
|
||||
|
||||
**The catalog is the module's; the boundary is core's.** A module declares which of its kinds
|
||||
are public-safe, and core enforces the split — a sensitive kind cannot reach the public
|
||||
channel.
|
||||
|
||||
A viewer's rung is resolved **once, when the stream opens, and frozen for its life**. A
|
||||
long-lived connection must not silently gain privilege because the session changed
|
||||
underneath it. Configuration changes, by contrast, *do* take effect live.
|
||||
|
||||
## Administering it
|
||||
|
||||
[The shard connection](/docs/administration/the-shard-connection/) covers the admin screens,
|
||||
and the visibility ladder is administrator-configurable per feature and per field.
|
||||
|
||||
## Canonical documents
|
||||
|
||||
[`link/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md)
|
||||
§5 is the data catalog and §7 the wire protocol;
|
||||
[`link/v4.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v4.md)
|
||||
is the current protocol;
|
||||
[`SHARD_VISIBILITY.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/SHARD_VISIBILITY.md)
|
||||
is the administrator's guide to the ladder, and
|
||||
[`modules/uo/API.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/modules/uo/API.md)
|
||||
§4 specifies it.
|
||||
85
src/content/docs/docs/reference/http-api.mdx
Normal file
@@ -0,0 +1,85 @@
|
||||
---
|
||||
title: HTTP API
|
||||
description: How the site's API is organised, where the live specification is, and the gate each tier sits behind.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
The site's backend API is **OpenAPI 3.0**, and the specification is generated from the routes
|
||||
themselves rather than maintained beside them.
|
||||
|
||||
<Aside type="note" title="Your own deployment serves the authoritative copy">
|
||||
Every route, parameter and response shape is at **`/api/docs`** on your site, generated from
|
||||
the code that is actually running — including any module you have installed.
|
||||
|
||||
That is the copy to trust. This page is a map of how it is organised; it does not restate
|
||||
the routes, and a reference section that tried to would be wrong within a week.
|
||||
</Aside>
|
||||
|
||||
## The tiers
|
||||
|
||||
Every route lives under `/api/v1/<tier>/`, and **the tier decides the gate**.
|
||||
|
||||
| Tier | Routes | Sits behind |
|
||||
|---|---|---|
|
||||
| `admin` | ~93 | `noindex`, `isLoggedIn`, `requireRole('admin','editor','moderator')` |
|
||||
| `auth` | ~38 | Public by necessity; heavily rate-limited and bot-scored |
|
||||
| `player` | ~24 | `noindex`, `requireAuth` — role-agnostic self-service |
|
||||
| `public` | ~19 | Nothing, by design |
|
||||
| `settings` | 2 | `requireAuth` + `noindex`, no role gate |
|
||||
|
||||
Plus two outside the versioned surface: **`/api/health`** and **`/api/csp-report`**.
|
||||
|
||||
Those two are deliberately not under `/api/v1`. A browser learns the CSP report path from the
|
||||
policy header rather than from a client build, so it is not part of the versioned client
|
||||
contract.
|
||||
|
||||
## Two things the tier table implies
|
||||
|
||||
**`player` is role-agnostic.** It is self-service for whoever is signed in, gated on
|
||||
`requireAuth` alone and never on "is not staff". Staff are a *superset* of players — an
|
||||
administrator has characters too, and a `player` route that excluded them would 403 an admin
|
||||
off their own account.
|
||||
|
||||
**A module's routes inherit their tier's gate** and add their own on top. A module never
|
||||
re-implements the tier gate; see [The module
|
||||
API](/docs/modules/the-module-api/#registerroutes-and-the-tier-gate).
|
||||
|
||||
## Authentication
|
||||
|
||||
Three ways in, [one session model](/docs/architecture/authentication-architecture/):
|
||||
|
||||
- **Cookie** — `httpOnly` JWT, for the browser.
|
||||
- **Bearer** — short access tokens plus rotated, hashed, revocable refresh tokens, for the
|
||||
native app.
|
||||
- **SSO** — OAuth2/OIDC with PKCE, and **link-only**: an external identity must already be
|
||||
attached to an existing account.
|
||||
|
||||
Admin roles are **re-validated against the database on every request**, so a demoted user
|
||||
loses access immediately rather than at token expiry.
|
||||
|
||||
## The sidecar's API is a different thing
|
||||
|
||||
The uo-link sidecar exposes its own small REST and WebSocket surface, reached **only** by the
|
||||
website's backend. It carries `X-UOLink-Version` and answers `409` on a protocol mismatch.
|
||||
|
||||
It is not part of this API and is not served from your site. See [The
|
||||
bridge](/docs/architecture/the-bridge/).
|
||||
|
||||
## Keeping the spec current
|
||||
|
||||
For contributors: the specification is generated from `#swagger.*` annotations next to each
|
||||
route, and the output is committed.
|
||||
|
||||
```bash
|
||||
cd website/server && npm run swagger
|
||||
```
|
||||
|
||||
A route that is not in the specification is not finished. Modules publish their own
|
||||
fragment, at a fixed filename in the bundle root, so a module's routes appear in the same
|
||||
documentation as core's.
|
||||
|
||||
## Canonical document
|
||||
|
||||
[`BACKEND_DESIGN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/BACKEND_DESIGN.md)
|
||||
§4 is the API contract, including §4.0's authoritative route list.
|
||||
85
src/content/docs/docs/reference/installer-cli.mdx
Normal file
@@ -0,0 +1,85 @@
|
||||
---
|
||||
title: Installer CLI
|
||||
description: The four commands the installer offers, what each does to a host, and the environment variable that makes a full run safe to rehearse.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
import { installerCommands } from '../../../../data/reference.mjs';
|
||||
|
||||
The installer is one binary per operating system that deploys the **shard side only**. It
|
||||
never contacts the website.
|
||||
|
||||
Downloads and the walkthrough are [Connect a game
|
||||
server](/docs/getting-started/connect-a-game-server/). This page is the command surface.
|
||||
|
||||
## The commands
|
||||
|
||||
<table>
|
||||
<thead><tr><th>Command</th><th>What it does</th></tr></thead>
|
||||
<tbody>
|
||||
{Object.entries(installerCommands).map(([name, why]) => (
|
||||
<tr key={name}><td><code>{name.toLowerCase()}</code></td><td>{why}</td></tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
`doctor`, `update` and `uninstall` are the day-two commands.
|
||||
|
||||
## Rehearsing a run
|
||||
|
||||
Two mechanisms, and they answer different questions.
|
||||
|
||||
```bash
|
||||
runicgateway-installer install --servuo /path/to/ServUO --verify
|
||||
```
|
||||
|
||||
**`--verify` writes nothing.** It reports what would change — the diff against the ServUO
|
||||
tree — which is the right thing to run first against a shard that has players on it.
|
||||
|
||||
```bash
|
||||
RUNICGATEWAY_STATE_DIR=/tmp/rehearsal runicgateway-installer install --servuo …
|
||||
```
|
||||
|
||||
**`RUNICGATEWAY_STATE_DIR` relocates everything the installer writes** — state, data, and
|
||||
the sidecar binary — *and suppresses service registration*. That is how a full run is
|
||||
exercised without root, and it is what the project's own tests use.
|
||||
|
||||
## What an install actually does
|
||||
|
||||
1. Resolves a **bundle** — an exact, protocol-checked sidecar and overlay pair published by
|
||||
CI. Never "latest of each"; see [Protocol
|
||||
versions](/docs/architecture/protocol-versions/).
|
||||
2. Syncs the plugin overlay into the ServUO tree, backing up whatever it is about to
|
||||
overwrite.
|
||||
3. Offers the opt-in patch tier.
|
||||
4. Installs the sidecar and registers its service.
|
||||
5. Prints four values to paste into the site's shard screen.
|
||||
|
||||
<Aside type="caution" title="Installer v0.1.0 prints an older path in step 5">
|
||||
It names `<site>/admin/shard`. The screen moved to **`/admin/uo/link`** when the shard
|
||||
surface became part of the `uo` module. Fixed in v0.1.1.
|
||||
</Aside>
|
||||
|
||||
## Platforms
|
||||
|
||||
Linux `x86_64`, Linux `aarch64`, and Windows `x86_64`.
|
||||
|
||||
**macOS and Windows-on-ARM are deliberately absent**: the game server and the sidecar must
|
||||
share a host, and no ServUO host is either.
|
||||
|
||||
Releases are **unsigned**, and `SHA256SUMS` is the trust anchor — verify before running.
|
||||
Windows will show a SmartScreen prompt, which is expected for an unsigned binary.
|
||||
|
||||
<Aside type="note" title="Why the library target is called `rgdeploy`">
|
||||
Windows UAC refuses to launch an unsigned executable whose name contains `install`
|
||||
(`os error 740`), and Cargo names test harnesses after their target. It is deliberate, and
|
||||
not something to tidy up.
|
||||
</Aside>
|
||||
|
||||
## Canonical documents
|
||||
|
||||
[`INSTALL.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/INSTALL.md)
|
||||
is the operator guide — including Appendix A, hand deployment, for hosts that cannot run the
|
||||
binary — and
|
||||
[`PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/PLAN.md)
|
||||
is the design of record.
|
||||