docs(builder): phase 8 — modules, architecture and reference #12
@@ -65,6 +65,19 @@ jobs:
|
|||||||
# the shorter form would pass locally and break only here.
|
# the shorter form would pass locally and break only here.
|
||||||
run: npm test
|
run: npm test
|
||||||
|
|
||||||
|
- name: Sidebar
|
||||||
|
# PLAN.md §12, phase 8. src/config/sidebar.mjs holds two trees — the one Starlight
|
||||||
|
# renders and the one §10 planned — and they must agree on groups, labels and
|
||||||
|
# ORDER. Order because the order of "Getting started" IS the installation path.
|
||||||
|
#
|
||||||
|
# While pages were being written the planned tree was a checklist; now that every
|
||||||
|
# page exists it is a hand-maintained second copy, and it had already drifted
|
||||||
|
# unnoticed (phase 7 added Content under D37 and never updated it). Nothing caught
|
||||||
|
# that because nothing read it.
|
||||||
|
#
|
||||||
|
# No token, no network, no build — so it runs early and fails fast.
|
||||||
|
run: npm run check:sidebar
|
||||||
|
|
||||||
- name: Production build
|
- name: Production build
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
@@ -115,3 +128,22 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
run: npm run check:quickstart
|
run: npm run check:quickstart
|
||||||
|
|
||||||
|
- name: Reference enumerations against their sources
|
||||||
|
# PLAN.md §12, phase 8. The Reference section names things — every environment
|
||||||
|
# variable, config key, installer command, visibility rung and canonical document.
|
||||||
|
# §1 forbids re-specifying a contract, and this is what makes writing the NAMES
|
||||||
|
# down safe anyway: each list is a SET comparison against the repository that owns
|
||||||
|
# it, in both directions.
|
||||||
|
#
|
||||||
|
# The second direction is the one that earns its keep. A reference page does not
|
||||||
|
# usually rot by describing something that vanished — it rots by quietly not
|
||||||
|
# mentioning the three things added since it was written.
|
||||||
|
#
|
||||||
|
# Descriptions are deliberately NOT checked; nothing here can know whether a
|
||||||
|
# one-line summary is still true, so it does not pretend to.
|
||||||
|
#
|
||||||
|
# Same token, and for the same reason: it reads five other repositories in the org.
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: npm run check:reference
|
||||||
|
|||||||
82
PLAN.md
82
PLAN.md
@@ -236,7 +236,7 @@ Taken by the org lead (Colby Whitlock) on 2026-08-19. Recorded so they are not r
|
|||||||
|
|
||||||
**Decisions after D13 are recorded where they were taken**, in the section describing the phase that
|
**Decisions after D13 are recorded where they were taken**, in the section describing the phase that
|
||||||
raised them, rather than appended here — a decision is only re-litigated when its reasoning is
|
raised them, rather than appended here — a decision is only re-litigated when its reasoning is
|
||||||
somewhere other than the thing it decided. The count of record is **thirty-seven**:
|
somewhere other than the thing it decided. The count of record is **forty-one**:
|
||||||
|
|
||||||
| # | Where | What it settled |
|
| # | Where | What it settled |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -246,6 +246,7 @@ somewhere other than the thing it decided. The count of record is **thirty-seven
|
|||||||
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -837,7 +838,7 @@ Reference Environment variables · Installer CLI · sidecar.toml ·
|
|||||||
Bridge.cfg · HTTP API · Event catalog · Canonical documents
|
Bridge.cfg · HTTP API · Event catalog · Canonical documents
|
||||||
```
|
```
|
||||||
|
|
||||||
Roughly 38 pages — 37 planned, plus the Content page D37 added in phase 7. 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.
|
document** — never a re-specification, per §1.
|
||||||
|
|
||||||
### The installation path
|
### The installation path
|
||||||
@@ -938,6 +939,79 @@ 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 11. Visual direction
|
## 11. Visual direction
|
||||||
|
|
||||||
**"Modern infrastructure software with an arcane identity."** Dark-first. Marketing pages are
|
**"Modern infrastructure software with an arcane identity."** Dark-first. Marketing pages are
|
||||||
@@ -1053,10 +1127,10 @@ a mechanism rather than diligence:
|
|||||||
| **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) |
|
| **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) |
|
| **6** | Legal: `/privacy/`, `/terms/`, footer links, and the Play Data Safety notes (§9) |
|
||||||
| **7** | Docs — the journey: Getting started (7) + Administration (**13**, per D37) — twenty pages in one PR (D34), with the install page self-contained and drift-checked (D35) and every admin screen walked before it was described (D36). **The installation path is the priority of the whole project** |
|
| **7** | Docs — the journey: Getting started (7) + Administration (**13**, per D37) — twenty pages in one PR (D34), with the install page self-contained and drift-checked (D35) and every admin screen walked before it was described (D36). **The installation path is the priority of the whole project** |
|
||||||
| **8** | Docs — builder and reference: Modules (8) + Architecture (5) + Reference (7) |
|
| **8** | Docs — builder and reference: Modules (8) + Architecture (5) + Reference (7) — twenty pages in one PR (D38), with Reference enumerating names and **checking every one of them** against its source (D39), and `plannedSidebar` becoming a checked invariant (D41) |
|
||||||
| **9** | Screenshots (D4): stand up the local review stack, seed presentable content, capture the admin panel, Teams, forums, marketplace, spawn atlas and shard console; build the screenshot components. **Plus an emulator pass against the same seeded stack** to fill `/app/`'s reserved slot (D26) |
|
| **9** | Screenshots (D4): stand up the local review stack, seed presentable content, capture the admin panel, Teams, forums, marketplace, spawn atlas and shard console; build the screenshot components. **Plus an emulator pass against the same seeded stack** to fill `/app/`'s reserved slot (D26) |
|
||||||
| **10** | Polish: responsive, accessibility, SEO/OpenGraph/sitemap/robots, full-text search, CSP headers |
|
| **10** | Polish: responsive, accessibility, SEO/OpenGraph/sitemap/robots, full-text search, CSP headers |
|
||||||
| **11** | Validation: `astro check`, production build, **all six check scripts** (tokens, brand, links, facts, quickstart, data safety), mobile layout verified in a real browser, a signup walked end to end |
|
| **11** | Validation: `astro check`, production build, **all eight check scripts** (tokens, brand, links, facts, quickstart, data safety, reference, sidebar), 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) |
|
| **12** | Delivery: Dockerfile, `docker-compose.yml` with both bind mounts documented, Gitea Actions workflow publishing to the registry, README, CONTRIBUTING with the AI-disclosure requirement, and an operator note covering DNS, TLS and the reverse proxy (D6) |
|
||||||
|
|
||||||
Phases 5 and 6 are deliberately adjacent and early: the beta cannot start without `/privacy`, and
|
Phases 5 and 6 are deliberately adjacent and early: the beta cannot start without `/privacy`, and
|
||||||
|
|||||||
@@ -20,11 +20,13 @@
|
|||||||
"check:links": "node scripts/checkLinks.mjs",
|
"check:links": "node scripts/checkLinks.mjs",
|
||||||
"check:datasafety": "node scripts/playDataSafety.mjs --check",
|
"check:datasafety": "node scripts/playDataSafety.mjs --check",
|
||||||
"check:quickstart": "node scripts/checkQuickstart.mjs",
|
"check:quickstart": "node scripts/checkQuickstart.mjs",
|
||||||
|
"check:reference": "node scripts/checkReference.mjs",
|
||||||
|
"check:sidebar": "node scripts/checkSidebar.mjs",
|
||||||
"play:datasafety": "node scripts/playDataSafety.mjs",
|
"play:datasafety": "node scripts/playDataSafety.mjs",
|
||||||
"beta": "node scripts/beta.mjs",
|
"beta": "node scripts/beta.mjs",
|
||||||
"test": "node --test test/beta.test.mjs test/legal.test.mjs",
|
"test": "node --test test/beta.test.mjs test/legal.test.mjs",
|
||||||
"brand:assets": "node scripts/buildBrandAssets.mjs",
|
"brand:assets": "node scripts/buildBrandAssets.mjs",
|
||||||
"verify": "npm run check:tokens && npm run check:brand && npm run check:datasafety && npm run check && npm test && npm run build && npm run check:links && npm run check:facts && npm run check:quickstart"
|
"verify": "npm run check:sidebar && 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": {
|
"dependencies": {
|
||||||
"@astrojs/node": "^11.1.4",
|
"@astrojs/node": "^11.1.4",
|
||||||
|
|||||||
191
scripts/checkReference.mjs
Normal file
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);
|
||||||
|
});
|
||||||
77
scripts/checkSidebar.mjs
Normal file
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);
|
||||||
|
}
|
||||||
@@ -44,12 +44,54 @@ export const docsSidebar = [
|
|||||||
{ label: 'Troubleshooting', slug: 'docs/administration/troubleshooting' },
|
{ label: 'Troubleshooting', slug: 'docs/administration/troubleshooting' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Modules',
|
||||||
|
items: [
|
||||||
|
{ label: 'The module system', slug: 'docs/modules/the-module-system' },
|
||||||
|
{ label: 'Installing modules', slug: 'docs/modules/installing-modules' },
|
||||||
|
{ label: 'Module lifecycle', slug: 'docs/modules/module-lifecycle' },
|
||||||
|
{ label: 'The module manifest', slug: 'docs/modules/the-module-manifest' },
|
||||||
|
{ label: 'The module API', slug: 'docs/modules/the-module-api' },
|
||||||
|
{ label: 'Building a module', slug: 'docs/modules/building-a-module' },
|
||||||
|
{ label: 'The Integration Kit', slug: 'docs/modules/the-integration-kit' },
|
||||||
|
{ label: 'Testing and release', slug: 'docs/modules/testing-and-release' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Architecture',
|
||||||
|
items: [
|
||||||
|
{ label: 'System architecture', slug: 'docs/architecture/system-architecture' },
|
||||||
|
{ label: 'The bridge', slug: 'docs/architecture/the-bridge' },
|
||||||
|
{ label: 'Authentication architecture', slug: 'docs/architecture/authentication-architecture' },
|
||||||
|
{ label: 'Teams architecture', slug: 'docs/architecture/teams-architecture' },
|
||||||
|
{ label: '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
|
* The tree §10 planned, kept as the record of what was intended — every page it names now
|
||||||
* checklist in the place they will be working. Not exported into the Starlight config —
|
* exists, as of phase 8.
|
||||||
* it names pages that do not exist yet.
|
*
|
||||||
|
* It was the phases 7/8 checklist, and a checklist with nothing left on it is no longer
|
||||||
|
* pulling its weight: it is a second copy of the tree above, maintained by hand, and it had
|
||||||
|
* already drifted once (phase 7 added `Content` under D37 and this list was not updated,
|
||||||
|
* which nothing caught because nothing reads it). `checkSidebar.mjs` now asserts the two
|
||||||
|
* agree, which is what makes keeping it safe.
|
||||||
|
*
|
||||||
|
* Not exported into the Starlight config.
|
||||||
*/
|
*/
|
||||||
export const plannedSidebar = {
|
export const plannedSidebar = {
|
||||||
'Getting started': [
|
'Getting started': [
|
||||||
@@ -65,6 +107,7 @@ export const plannedSidebar = {
|
|||||||
'Configuration',
|
'Configuration',
|
||||||
'Branding and theming',
|
'Branding and theming',
|
||||||
'Navigation and pages',
|
'Navigation and pages',
|
||||||
|
'Content',
|
||||||
'Users and roles',
|
'Users and roles',
|
||||||
'Authentication',
|
'Authentication',
|
||||||
'Teams',
|
'Teams',
|
||||||
|
|||||||
@@ -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
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
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
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
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.
|
||||||
161
src/content/docs/docs/modules/building-a-module.mdx
Normal file
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
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
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
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
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
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
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
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
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
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
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
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
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
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.
|
||||||
68
src/content/docs/docs/reference/sidecar-toml.mdx
Normal file
68
src/content/docs/docs/reference/sidecar-toml.mdx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
---
|
||||||
|
title: sidecar.toml
|
||||||
|
description: The sidecar's entire configuration — four keys — and why the file is generated rather than shipped.
|
||||||
|
---
|
||||||
|
|
||||||
|
import { Aside } from '@astrojs/starlight/components';
|
||||||
|
import { sidecarConfig } from '../../../../data/reference.mjs';
|
||||||
|
|
||||||
|
The uo-link sidecar's configuration. It is deliberately tiny: the sidecar is a **dumb
|
||||||
|
forwarder**, and policy lives on the website where an administrator can see it.
|
||||||
|
|
||||||
|
## The file is written, not shipped
|
||||||
|
|
||||||
|
The sidecar **writes `sidecar.toml` on first run**, including a generated auth token. There
|
||||||
|
is no committed sample that is authoritative, and nothing is compiled into the binary.
|
||||||
|
|
||||||
|
Point it elsewhere with `$UOLINK_CONFIG`.
|
||||||
|
|
||||||
|
<Aside type="caution" title="Authentication is always on">
|
||||||
|
A blank token is not "no authentication" — it is auto-generated and written back, so the web
|
||||||
|
surface is authenticated from first boot. There is no way to turn it off, which is the
|
||||||
|
correct default for the one component that is exposed.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
## The keys
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Key</th><th>What it is for</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{Object.entries(sidecarConfig).map(([name, why]) => (
|
||||||
|
<tr key={name}><td><code>{name}</code></td><td>{why}</td></tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
This list is checked against the sidecar's own config structs on every build, so a key added
|
||||||
|
upstream turns this page red rather than quietly going undocumented.
|
||||||
|
|
||||||
|
## What is *not* in here
|
||||||
|
|
||||||
|
Worth stating, because the absences are the design:
|
||||||
|
|
||||||
|
- **No allowlist, no audience rules, no visibility settings.** Those are the website's, and
|
||||||
|
admin-toggleable. The sidecar forwards; the site decides who may see what.
|
||||||
|
- **No website URL.** The website reaches the sidecar, not the other way round.
|
||||||
|
- **No protocol version.** It is compiled in, because a sidecar that could be *configured*
|
||||||
|
to claim a different protocol would defeat the check. See [Protocol
|
||||||
|
versions](/docs/architecture/protocol-versions/).
|
||||||
|
|
||||||
|
## Running it
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run # writes sidecar.toml on first run
|
||||||
|
RUST_LOG=debug cargo run # verbose, including heartbeats
|
||||||
|
```
|
||||||
|
|
||||||
|
Normally you do not run it by hand — [the installer](/docs/reference/installer-cli/)
|
||||||
|
installs it and registers its service.
|
||||||
|
|
||||||
|
## Canonical documents
|
||||||
|
|
||||||
|
The structs in
|
||||||
|
[`sidecar/src/config.rs`](https://gitea.whitlocktech.com/RunicGateway/link/src/branch/main/sidecar/src/config.rs)
|
||||||
|
are the authority;
|
||||||
|
[`link/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md)
|
||||||
|
is the design of record and
|
||||||
|
[`ADMIN_CONTROLS.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/ADMIN_CONTROLS.md)
|
||||||
|
covers what the site may command the game to do.
|
||||||
190
src/data/reference.mjs
Normal file
190
src/data/reference.mjs
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
/**
|
||||||
|
* The Reference section's enumerations.
|
||||||
|
*
|
||||||
|
* §1 says a Reference page is "a navigable summary plus a link to the canonical document —
|
||||||
|
* never a re-specification". This file is the line between those two things, and it is
|
||||||
|
* worth being explicit about where it falls:
|
||||||
|
*
|
||||||
|
* * The NAMES are here — every environment variable, every config key, every command,
|
||||||
|
* every event kind. A reference section that cannot answer "what variables are there?"
|
||||||
|
* without a click-through is a link farm.
|
||||||
|
* * The SEMANTICS are not. One terse line each, saying what a thing is FOR. Shapes,
|
||||||
|
* defaults that matter, interactions, and every "why" stay in the canonical document.
|
||||||
|
*
|
||||||
|
* Everything below is checked against its source by `scripts/checkReference.mjs`, in both
|
||||||
|
* directions — a name that disappears upstream fails, and a name that appears upstream and
|
||||||
|
* is missing here fails too. That is the whole reason it is safe to write names down at
|
||||||
|
* all: the enumeration cannot rot into fiction without turning the build red.
|
||||||
|
*
|
||||||
|
* Descriptions are NOT checked, and cannot be. They are the part a human has to keep
|
||||||
|
* honest, which is why they are kept short enough to re-read.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** `website` root `.env.example` — the file a Compose deployment actually reads. */
|
||||||
|
export const envVars = {
|
||||||
|
IMAGE_TAG: 'Which published image tag to run',
|
||||||
|
NODE_ENV: 'production or development — several refusals are production-only',
|
||||||
|
PORT: 'The port the app listens on',
|
||||||
|
INTERNAL_PORT: 'The internal-only listener, for the bot channel',
|
||||||
|
UPLOAD_DIR: 'Where uploads are written',
|
||||||
|
|
||||||
|
LOG_LEVEL: 'Console log level',
|
||||||
|
FILE_LOG_LEVEL: 'File log level, set separately',
|
||||||
|
LOG_TO_FILE: 'Whether to write a log file at all',
|
||||||
|
LOG_DIR: 'Directory for the log file',
|
||||||
|
LOG_FILE: 'Log file name',
|
||||||
|
|
||||||
|
BRAND_NAME: 'Site name — branding is data, not a build',
|
||||||
|
BRAND_SHORT_NAME: 'Short form, for tight spaces',
|
||||||
|
BRAND_TAGLINE: 'One line under the name',
|
||||||
|
BRAND_DESCRIPTION: 'Meta description',
|
||||||
|
BRAND_CONTACT_EMAIL: 'Published contact address',
|
||||||
|
BRAND_URL: 'Canonical public URL',
|
||||||
|
BRAND_ACCENT_COLOR: 'Accent colour',
|
||||||
|
BRAND_LOGO: 'Logo path',
|
||||||
|
BRAND_HERO: 'Hero image path',
|
||||||
|
BRAND_FAVICON: 'Favicon path',
|
||||||
|
|
||||||
|
DB_HOST: 'Database host',
|
||||||
|
DB_PORT: 'Database port',
|
||||||
|
DB_NAME: 'Database name',
|
||||||
|
DB_USER: 'Database user',
|
||||||
|
DB_PASSWORD: 'Database password',
|
||||||
|
DB_ROOT_PASSWORD: "The database container's root password",
|
||||||
|
|
||||||
|
JWT_SECRET: 'Signs session tokens. Rotating it logs everyone out',
|
||||||
|
SECRET_ENC_KEY:
|
||||||
|
'Encrypts secrets at rest. Required in production, and rotating it ORPHANS every stored secret',
|
||||||
|
JWT_EXPIRES_IN: 'Session lifetime',
|
||||||
|
COOKIE_SECURE: 'auto decides Secure per request, so HTTPS and LAN HTTP both work',
|
||||||
|
COOKIE_NAME: 'Session cookie name. Changing it invalidates existing sessions',
|
||||||
|
TRUST_PROXY: 'Needed behind a reverse proxy for secure cookies, real IPs and rate limiting',
|
||||||
|
DEBUG_TRUST_PROXY: 'Diagnostic for the above',
|
||||||
|
TOTP_CHALLENGE_TTL: 'How long a pending 2FA challenge is valid',
|
||||||
|
|
||||||
|
ADMIN_USERNAME: 'First admin, created only when no users exist',
|
||||||
|
ADMIN_PASSWORD: 'First admin password. Set it before the first boot, not after',
|
||||||
|
|
||||||
|
CLIENT_ORIGIN: 'Dev only — the Vite origin allowed through CORS',
|
||||||
|
|
||||||
|
BOT_INTERNAL_URL: 'Where the Discord bot listens',
|
||||||
|
BOT_INTERNAL_KEY:
|
||||||
|
'Authenticates the site↔bot channel. Required in production EVEN IF you run no bot',
|
||||||
|
|
||||||
|
NTFY_BASE_URL: 'Push notification relay base URL',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** `link/sidecar/src/config.rs` → the TOML the sidecar writes on first run. */
|
||||||
|
export const sidecarConfig = {
|
||||||
|
'shard.bind': 'Loopback address the game plugin dials out to',
|
||||||
|
'web.bind': 'Address the website reaches the sidecar on',
|
||||||
|
'web.auth_token': 'Shared secret the website must present. Generated on first run if blank',
|
||||||
|
'store.path': "The sidecar's own durable store",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** `installer` — `src/cli.rs`'s `Command`. */
|
||||||
|
export const installerCommands = {
|
||||||
|
Install: 'Set up the shard side: sync the overlay, install the sidecar, register its service',
|
||||||
|
Doctor: 'Diagnose an existing install',
|
||||||
|
Update: 'Move to a newer bundle',
|
||||||
|
Uninstall: 'Remove what install put there',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** `servuo-plugins/overlay/Config/Bridge.cfg` — the plugin's config, grouped for reading. */
|
||||||
|
export const bridgeCfg = {
|
||||||
|
Connection: {
|
||||||
|
Host: 'Sidecar address the shard dials out to',
|
||||||
|
Port: 'Sidecar port',
|
||||||
|
QueueCap: 'Bounded queue depth. Full means drop-oldest — never block the game',
|
||||||
|
PublicConnectAddress: 'Address players connect to, published to the site',
|
||||||
|
LinkUrl: 'Where in-game account linking sends a player',
|
||||||
|
},
|
||||||
|
Sweeps: {
|
||||||
|
StatSweepSeconds: 'Character stat sweep interval',
|
||||||
|
DecaySweepSeconds: 'House decay sweep',
|
||||||
|
EconomySweepSeconds: 'Economy totals sweep',
|
||||||
|
ChampSweepSeconds: 'Champion spawn sweep',
|
||||||
|
PageSweepSeconds: 'Staff page sweep',
|
||||||
|
GuildSweepSeconds: 'Guild roster sweep',
|
||||||
|
CitySweepSeconds: 'City / governor sweep',
|
||||||
|
PresenceSweepSeconds: 'Who is online',
|
||||||
|
HousingSweepSeconds: 'Housing sweep',
|
||||||
|
},
|
||||||
|
Guilds: {
|
||||||
|
GuildRosterMembersPerLine: 'Frame cap — a roster is split rather than sent oversized',
|
||||||
|
GuildRosterGuildsPerTick: 'How many guilds are swept per tick',
|
||||||
|
},
|
||||||
|
Points: {
|
||||||
|
PointsSweepSeconds: 'Points sweep interval',
|
||||||
|
PointsLeaderboardEnabled: 'Publish a leaderboard at all',
|
||||||
|
PointsTopN: 'Leaderboard length',
|
||||||
|
PointsSystems: 'Which point systems to include',
|
||||||
|
PointsProfileEnabled: 'Show points on a character profile',
|
||||||
|
PointsProfileRank: 'Show rank as well as total',
|
||||||
|
},
|
||||||
|
Market: {
|
||||||
|
MarketEnabled: 'Publish player vendor listings',
|
||||||
|
MarketSweepSeconds: 'Market sweep interval',
|
||||||
|
MarketSweepBatch: 'Vendors per sweep',
|
||||||
|
MarketMaxListings: 'Cap on listings published',
|
||||||
|
},
|
||||||
|
Ruleset: {
|
||||||
|
RulesetEnabled: 'Publish the shard ruleset',
|
||||||
|
RulesetIncludeSchedule: 'Include the event schedule with it',
|
||||||
|
},
|
||||||
|
'Town crier': {
|
||||||
|
TownCrierMaxLines: 'Lines per notice',
|
||||||
|
TownCrierMaxLineLength: 'Characters per line',
|
||||||
|
TownCrierMaxActive: 'Concurrent notices',
|
||||||
|
TownCrierMaxDurationSec: 'Longest a notice may run',
|
||||||
|
},
|
||||||
|
News: {
|
||||||
|
NewsMaxTitleLength: 'Title cap',
|
||||||
|
NewsMaxBodyLength: 'Body cap',
|
||||||
|
NewsMaxExternal: 'How many site posts are carried in-game',
|
||||||
|
NewsAnnounceDurationSec: 'How long an announcement shows',
|
||||||
|
},
|
||||||
|
'Admin commands': {
|
||||||
|
AdminWriteEnabled: 'Whether the site may write to the game at all. Off by default',
|
||||||
|
AdminAccessFloor: 'Minimum in-game access level for admin actions',
|
||||||
|
AdminBroadcastMaxLength: 'Broadcast cap',
|
||||||
|
AdminReasonMaxLength: 'Reason field cap',
|
||||||
|
AdminBanMaxDurationSec: 'Longest ban the site may set',
|
||||||
|
},
|
||||||
|
Accounts: {
|
||||||
|
SignupMode: 'How game accounts may be created',
|
||||||
|
AccountCreateEnabled: 'Allow creation at all',
|
||||||
|
RequireIpForCreate: 'Require a real client IP',
|
||||||
|
AccountNameMaxLength: 'Account name cap',
|
||||||
|
AccountPasswordMaxLength: 'Account password cap',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The five-rung visibility ladder, from `module-uo`'s `server/utils/shardVisibility.js`.
|
||||||
|
*
|
||||||
|
* This one is a SECURITY boundary, not a convenience filter, which is why it is enumerated
|
||||||
|
* rather than described: a reader needs to see the whole ladder at once to reason about it.
|
||||||
|
*/
|
||||||
|
export const visibilityLadder = ['anonymous', 'logged_in', 'player', 'staff', 'admin'];
|
||||||
|
|
||||||
|
/** Canonical documents, by the question each answers. Checked to still exist in `docs`. */
|
||||||
|
export const canonicalDocs = {
|
||||||
|
'website/ARCHITECTURE.md': 'How the website fits together — the canonical diagram',
|
||||||
|
'website/BACKEND_DESIGN.md': 'The API, schema and security contract',
|
||||||
|
'website/MODULE_SYSTEM.md': 'Why the module system is shaped this way',
|
||||||
|
'website/MODULE_API.md': 'Everything a module may do — the contract',
|
||||||
|
'website/TEAMS.md': 'Teams as a platform primitive',
|
||||||
|
'website/SHARD_VISIBILITY.md': 'The audience ladder, for administrators',
|
||||||
|
'website/THEMING_AND_NAV.md': 'Admin-configurable theme, assets and navigation',
|
||||||
|
'website/TRUSTED_DEVICES_MFA.md': 'Trusted devices and the second factor',
|
||||||
|
'link/PLAN.md': 'The sidecar design of record, the data catalog and the wire protocol',
|
||||||
|
'link/INTEGRATION.md': 'Integrating with the sidecar',
|
||||||
|
'link/v4.md': 'Protocol 4, and its cross-repository obligations',
|
||||||
|
'link/ADMIN_CONTROLS.md': 'What the site may command the game to do',
|
||||||
|
'installer/INSTALL.md': 'The operator guide for setting a shard up',
|
||||||
|
'installer/PLAN.md': "The installer's design of record",
|
||||||
|
'modules/rust-dryrun.md': 'A second module designed on paper, to test that the contract generalises',
|
||||||
|
'modules/uo/API.md': "module-uo's own API, including its audience rules",
|
||||||
|
'android/PLAN.md': 'The Android app',
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user