diff --git a/.gitea/workflows/pr-checks.yml b/.gitea/workflows/pr-checks.yml index 9d8fefd..44e4e6c 100644 --- a/.gitea/workflows/pr-checks.yml +++ b/.gitea/workflows/pr-checks.yml @@ -99,3 +99,19 @@ jobs: env: GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: npm run check:facts + + - name: Quickstart against website main + # PLAN.md §12, phase 7 (D35). /docs/getting-started/install-the-site/ prints a + # Compose file and an environment file the reader copies without leaving the page, + # which is the one place this site knowingly keeps a copy of another repo's file. + # + # So the copy is checked in BOTH directions: every value it states must match + # website's own docker-compose.yml and .env.example on main, and every service and + # variable THEY have must be either included or listed as deliberately omitted with + # a reason. A new variable upstream turns this repo red until someone decides + # whether a first install needs it — the same intent as the facts check above. + # + # Same token, and for the same reason: it reads another repository in the org. + env: + GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: npm run check:quickstart diff --git a/PLAN.md b/PLAN.md index 500befb..5fb5d14 100644 --- a/PLAN.md +++ b/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 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-three**: +somewhere other than the thing it decided. The count of record is **thirty-seven**: | # | Where | What it settled | |---|---|---| @@ -245,6 +245,7 @@ somewhere other than the thing it decided. The count of record is **thirty-three | D20–D25 | §10, "How phase 4 built the marketing pages" | `/features/` as the same list with detail, `/architecture/` as reasons not reference, the absences as data, the two absorbed scope items, `needsModule`, the demo deep links | | D26–D29 | §8, "How phase 5 built the app and the beta" | The screenshot slot reserved for phase 9, the demo as the tester target, `/beta` handling its own POST, equal billing for the APK and the beta | | D30–D33 | §9, "How phase 6 built the legal pages" | One logging hop and no edge provider, eighteen or older, no governing-law clause, the Data Safety notes as a generated document | +| D34–D37 | §10, "How phase 7 built the documentation journey" | One PR for all twenty pages, a self-contained install quickstart with a drift check, every admin screen walked before it was described, a thirteenth Administration page for content | --- @@ -821,7 +822,7 @@ Getting started What is Runic Gateway? · Requirements · Install the site · Verify the whole stack Administration Configuration · Branding and theming · Navigation and pages · - Users and roles · Authentication · Teams · Moderation · + Content · Users and roles · Authentication · Teams · Moderation · Notifications and email · Managing modules · The shard connection · Maintenance and upgrades · Troubleshooting @@ -836,7 +837,7 @@ Reference Environment variables · Installer CLI · sidecar.toml · Bridge.cfg · HTTP API · Event catalog · Canonical documents ``` -Roughly 37 pages. Every Reference page is a **navigable summary plus a link to the canonical +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 document** — never a re-specification, per §1. ### The installation path @@ -851,13 +852,81 @@ website; the website is a separate Docker deployment. 3. First run — first admin, maintenance → live 4. Install a game module — admin panel, `MODULES` env, or by hand 5. Connect a game server — the installer binary on the shard host (ServUO-specific today) -6. Paste the four values into Admin → Shard — **protocol 4**, per §2 +6. Paste the four values into **Shard (uo-link)**, `/admin/uo/link` — **protocol 4**, per §2. + (Not `/admin/shard`: the screen belongs to the module now, and the installer still prints the + old path — see "How phase 7 built the documentation journey" below) 7. Verify — `[bridge status` in game, `/health` reporting `plugin_connected: true`, then `doctor` 8. Configure authentication and integrations Each step states what the operator should expect to see, and links the failure modes to Troubleshooting. +### How phase 7 built the documentation journey + +Four decisions taken before a page was written (org lead, 2026-08-24), and three things the live +site disproved while it was being written. + +**D34 — one PR for all twenty pages.** Twenty, not nineteen: see D37. The alternative on the table +was splitting Getting started from Administration so the installation path could land first; the +org lead kept the phase whole, as every phase before it has been. + +**D35 — the install page is self-contained.** `/docs/getting-started/install-the-site/` prints a +complete Compose file and a complete `.env` that an operator copies without going to another +repository first. §1 argues at length against exactly this — it is a second copy of somebody else's +file, free to rot — so the copy is not trusted, it is checked. `src/data/quickstart.mjs` holds both +files and the page renders them; `scripts/checkQuickstart.mjs` re-reads `website`'s own +`docker-compose.yml` and `.env.example` from `main` over the Gitea API and fails the build on any +disagreement, in **both** directions: + +- every value the quickstart states must match upstream's; +- every service and variable upstream has must be **either included or listed as deliberately + omitted, with a reason**, so a new variable in `.env.example` turns this repo red until someone + decides whether a first install needs it; +- and an entry in either omission list that upstream no longer has fails too, so the lists cannot + rot into permanent exemptions. + +Same mechanism and same intent as `checkFacts.mjs`. It caught two stale entries on its first run — +`TOTP_ISSUER` and `MODULES`, which are commented *suggestions* upstream rather than keys — which is +the check earning its place before the page had shipped. + +**D36 — every Administration screen was walked before it was described.** Not read from source: +opened, in a browser, on a real deployment. The rig was the quickstart itself — the exact two files +from D35, against the published image — so one run proved the install page and produced the +screenshots' worth of detail the admin pages needed. Three of the four defects below came from that +walk, and no check could have found any of them. + +**D37 — a thirteenth Administration page.** §10's planned twelve named no home for Posts, Pages, +Wiki, Activity, Invites, the Hero editor or Web Bot Activity, all of which are real admin nav rows. +Rather than mirror the panel one page per row — which would organise the docs by the app's menu, +against this section's own principle — content authoring became one page, **Content**, and the +other four folded into the page that already owned their subject: Invites into Users and roles, the +Hero editor into Branding and theming, Web Bot Activity into Authentication. + +**What the live deployment disproved.** + +- **The documented Compose deploy does not boot.** `SECRET_ENC_KEY` is required in production — + `utils/secretBox.js` throws at require time, so the container crash-loops before it listens — and + it is **missing from website's root `.env.example`**, the file Compose actually reads. It is + present in `server/.env.example`, which is the file local development copies, which is why this + has never bitten anyone in dev. The quickstart carries it, declared as an upstream omission so the + check fails the day it is fixed. +- **The installer points operators at a screen that no longer exists.** It prints + `/admin/shard`, and INSTALL.md §5 repeats it. Since the module-system cutover a module owns + one path segment, and the screen is **`/admin/uo/link`**, labelled *Shard (uo-link)*. Both the + binary (`installer` `main:src/sidecar.rs`) and the guide are stale; the journey documents the real + path and says the printed one does not resolve. +- **The admin "Restart the server" button opens a `window.confirm`.** Its text is the honest + warning that a deployment with no supervisor does not come back — which is exactly why + `restart: unless-stopped` is called out as load-bearing on the install page rather than left as + boilerplate. + +**And the fourth defect, the one only a look found — three phases running.** The `.env` block's +prose says *every highlighted line must be changed*, and `mark` given the variable **names** +highlighted the names alone, leaving the values a reader has to replace unmarked. The build passed, +every check passed, and the page was quietly wrong about its own highlighting. Marking the whole +`KEY=value` string fixed it. See phase 4 (cascade), phase 5 (literal backticks) +and phase 6 (the card void) for the same lesson. + --- ## 11. Visual direction @@ -952,6 +1021,12 @@ a mechanism rather than diligence: links (D25) — lifting the pattern out of `applyBrand.mjs` and running it against the stock markup, so a template and a script that share no code cannot drift apart. Both are invisible in a stock build, which is exactly why they need a check rather than a look. +- **`scripts/checkQuickstart.mjs`** — added in phase 7 for D35. The install page prints a Compose + file and an `.env` verbatim, which is the one place this site knowingly copies another repo's + file; this re-reads `website` `main:docker-compose.yml` and `main:.env.example` and fails on any + disagreement. Two-directional, like `PLANNED_ROUTES`: a value that drifts fails, **and** a service + or variable that appears upstream fails until it is either included or recorded as deliberately + omitted with a reason. Its own first run found two stale entries. - **`scripts/checkTokens.mjs`** — no colour literal outside the token file (§7). - `astro check` plus a production build, in CI on every PR. @@ -968,11 +1043,11 @@ a mechanism rather than diligence: | **4** | Marketing: `/features/`, `/architecture/`, `/modules/`, `/integrations/`, **and `/community/`** — plus `checkLinks.mjs`, the capability `detail` lines, `notBuilt.mjs` and the demo deep links. See D20–D25 | | **5** | The app and the beta: `/app/`, `/beta/`, the signup handler, the SQLite store, rate limiting, the export CLI (§8). **Also the repository's first `node --test` suite**, and phase 9 inherits an emulator pass (D26) | | **6** | Legal: `/privacy/`, `/terms/`, footer links, and the Play Data Safety notes (§9) | -| **7** | Docs — the journey: Getting started (7) + Administration (12). **The installation path is the priority of the whole project** | +| **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) | | **9** | Screenshots (D4): stand up the local review stack, seed presentable content, capture the admin panel, Teams, forums, marketplace, spawn atlas and shard console; build the screenshot components. **Plus an emulator pass against the same seeded stack** to fill `/app/`'s reserved slot (D26) | | **10** | Polish: responsive, accessibility, SEO/OpenGraph/sitemap/robots, full-text search, CSP headers | -| **11** | Validation: `astro check`, production build, **all five check scripts** (tokens, brand, links, facts, types), mobile layout verified in a real browser, a signup walked end to end | +| **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 | | **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 diff --git a/package-lock.json b/package-lock.json index 5aea845..8ba171e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,8 @@ "devDependencies": { "@astrojs/check": "^0.9.10", "opentype.js": "^2.0.0", - "typescript": "^6.0.3" + "typescript": "^6.0.3", + "yaml": "^2.8.1" }, "engines": { "node": ">=22" diff --git a/package.json b/package.json index 27334d4..b38a2f1 100644 --- a/package.json +++ b/package.json @@ -19,11 +19,12 @@ "check:brand": "node scripts/checkBrand.mjs", "check:links": "node scripts/checkLinks.mjs", "check:datasafety": "node scripts/playDataSafety.mjs --check", + "check:quickstart": "node scripts/checkQuickstart.mjs", "play:datasafety": "node scripts/playDataSafety.mjs", "beta": "node scripts/beta.mjs", "test": "node --test test/beta.test.mjs test/legal.test.mjs", "brand:assets": "node scripts/buildBrandAssets.mjs", - "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" + "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" }, "dependencies": { "@astrojs/node": "^11.1.4", @@ -37,6 +38,7 @@ "devDependencies": { "@astrojs/check": "^0.9.10", "opentype.js": "^2.0.0", + "yaml": "^2.8.1", "typescript": "^6.0.3" } } diff --git a/scripts/checkQuickstart.mjs b/scripts/checkQuickstart.mjs new file mode 100644 index 0000000..d824222 --- /dev/null +++ b/scripts/checkQuickstart.mjs @@ -0,0 +1,216 @@ +#!/usr/bin/env node +/** + * checkQuickstart.mjs — PLAN.md §12, added in phase 7 for D35. + * + * The org lead chose a self-contained quickstart: `/docs/getting-started/install-the-site/` + * prints a Compose file and an environment file the reader can copy without going to + * another repository first. That is the one place this site knowingly keeps a copy of + * somebody else's file, and §1 is a long argument about why copies rot. + * + * So the copy is checked rather than trusted. Every service, image, published port, mount + * and environment key in `src/data/quickstart.mjs` is re-read from `website`'s own + * `docker-compose.yml` and `.env.example` on `main`, over the Gitea API — never from a + * working tree, per §1's process rule — and any disagreement fails the build. + * + * It checks in BOTH directions, which is the property that keeps it honest: + * + * - every value the quickstart states must match upstream's; + * - every service and variable upstream has must be either included or listed as + * deliberately omitted, WITH a reason. A new variable in `.env.example` therefore turns + * this repo red until someone decides whether a first install needs it — the same + * intent as checkFacts.mjs and the Integration Kit's checkCoreApi.js; + * - and an entry in either omission list that upstream no longer has fails too, so the + * lists cannot rot into permanent exemptions. + * + * GITEA_TOKEN= node scripts/checkQuickstart.mjs + * + * Anonymous raw fetches fail on this instance, so the token is required. A check that + * silently skips itself is worse than no check. + */ + +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; +import { parse as parseYaml } from 'yaml'; + +import { + compose, + services, + omittedServices, + env, + envOmitted, + notInUpstreamEnvExample, +} from '../src/data/quickstart.mjs'; + +const ROOT = fileURLToPath(new URL('..', import.meta.url)); +const platform = JSON.parse(readFileSync(path.join(ROOT, 'src/data/platform.json'), 'utf8')); + +const BASE = platform.gitea.base; +const ORG = platform.gitea.org; +const TOKEN = process.env.GITEA_TOKEN?.trim(); + +const failures = []; +const checked = []; + +const ok = (what) => checked.push(what); +const fail = (what, detail) => failures.push({ what, detail }); + +/** Same raw-file accessor checkFacts.mjs uses, and for the same reason. */ +async function raw(repo, filePath, ref) { + const url = `${BASE}/api/v1/repos/${ORG}/${repo}/raw/${filePath}?ref=${encodeURIComponent(ref)}`; + const res = await fetch(url, { headers: { Authorization: `token ${TOKEN}` } }); + if (!res.ok) throw new Error(`${res.status} ${res.statusText} for ${url}`); + return res.text(); +} + +/** + * `KEY=value` lines from a dotenv file. Commented-out suggestions (`# MODULES=…`) are NOT + * keys: they are prose about a variable, and treating them as declared would make the + * omission list argue with documentation rather than with configuration. + */ +function envKeys(text) { + const out = new Map(); + for (const line of text.split(/\r?\n/)) { + const m = line.match(/^([A-Z][A-Z0-9_]*)=(.*)$/); + if (m) out.set(m[1], m[2].replace(/\s+#.*$/, '').trim()); + } + return out; +} + +/** Published host:container port pairs, as written. */ +const portsOf = (svc) => (svc.ports ?? []).map(String); + +/** Container-side paths of every volume entry, which is what a reader's site depends on. */ +const mountTargets = (svc) => (svc.volumes ?? []).map((v) => String(v).split(':')[1]); + +async function run() { + if (!TOKEN) { + console.error('checkQuickstart: GITEA_TOKEN is not set. This check cannot run anonymously.'); + process.exit(2); + } + + const upstreamComposeText = await raw('website', 'docker-compose.yml', 'main'); + const upstreamEnvText = await raw('website', '.env.example', 'main'); + + const upstream = parseYaml(upstreamComposeText); + const ours = parseYaml(compose); + + if (!upstream?.services) throw new Error('website main:docker-compose.yml has no services block — the file shape changed.'); + + // ── 1. The services we ship ─────────────────────────────────────────────── + for (const name of services) { + const mine = ours.services?.[name]; + const theirs = upstream.services?.[name]; + if (!mine) { fail(`service ${name}`, 'declared in quickstart.mjs but absent from its own compose text'); continue; } + if (!theirs) { fail(`service ${name}`, 'no longer exists in website main:docker-compose.yml'); continue; } + + if (String(mine.image) !== String(theirs.image)) { + fail(`service ${name}: image`, `quickstart "${mine.image}" vs upstream "${theirs.image}"`); + } else ok(`service ${name}: image`); + + const minePorts = portsOf(mine).join(', '); + const theirPorts = portsOf(theirs).join(', '); + if (minePorts !== theirPorts) { + fail(`service ${name}: ports`, `quickstart [${minePorts}] vs upstream [${theirPorts}]`); + } else ok(`service ${name}: ports`); + + // Every mount we keep must land where upstream lands it. Upstream may have mounts we + // dropped (the schema bind, which needs a checkout); dropping one is safe, moving one + // is not. + for (const target of mountTargets(mine)) { + if (!mountTargets(theirs).includes(target)) { + fail(`service ${name}: mount ${target}`, 'upstream mounts nothing at that container path'); + } else ok(`service ${name}: mount ${target}`); + } + + for (const [key, value] of Object.entries(mine.environment ?? {})) { + const theirValue = theirs.environment?.[key]; + if (theirValue === undefined) { + fail(`service ${name}: ${key}`, 'upstream no longer sets it in the compose file'); + } else if (String(theirValue) !== String(value)) { + fail(`service ${name}: ${key}`, `quickstart "${value}" vs upstream "${theirValue}"`); + } else ok(`service ${name}: ${key}`); + } + } + + // ── 2. The services we left out, and any that appeared ──────────────────── + const upstreamServiceNames = Object.keys(upstream.services); + for (const [name, reason] of Object.entries(omittedServices)) { + if (!upstreamServiceNames.includes(name)) { + fail(`omitted service ${name}`, 'upstream no longer has this service — drop it from omittedServices'); + } else if (!reason?.trim()) { + fail(`omitted service ${name}`, 'listed without a reason'); + } else ok(`omitted service ${name}`); + } + for (const name of upstreamServiceNames) { + if (!services.includes(name) && !(name in omittedServices)) { + fail(`service ${name}`, 'is new in website main:docker-compose.yml — include it in the quickstart or record why not'); + } + } + + // ── 3. The environment file ─────────────────────────────────────────────── + const theirEnv = envKeys(upstreamEnvText); + const mineEnv = new Map(env.map((e) => [e.key, e])); + + for (const entry of env) { + const theirValue = theirEnv.get(entry.key); + const excused = notInUpstreamEnvExample[entry.key]; + + if (theirValue === undefined) { + if (excused) { + ok(`env ${entry.key} (absent upstream, declared: ${excused})`); + } else { + fail(`env ${entry.key}`, 'not in website main:.env.example — either it is gone, or it needs a reason in notInUpstreamEnvExample'); + } + continue; + } + + if (excused) { + fail( + `env ${entry.key}`, + 'is now in website main:.env.example — remove it from notInUpstreamEnvExample, and re-read the prose that describes it as missing', + ); + continue; + } + + // A value an operator is told to replace is a placeholder on both sides; comparing two + // placeholders would only ever assert that two people picked the same filler words. + if (!entry.fill && theirValue !== String(entry.value)) { + fail(`env ${entry.key}`, `quickstart "${entry.value}" vs upstream "${theirValue}"`); + } else ok(`env ${entry.key}`); + } + + for (const [key, reason] of Object.entries(envOmitted)) { + if (!theirEnv.has(key)) { + fail(`omitted env ${key}`, 'upstream .env.example no longer sets it — drop it from envOmitted'); + } else if (!reason?.trim()) { + fail(`omitted env ${key}`, 'listed without a reason'); + } else ok(`omitted env ${key}`); + } + + for (const key of theirEnv.keys()) { + if (!mineEnv.has(key) && !(key in envOmitted)) { + fail(`env ${key}`, 'is new in website main:.env.example — add it to the quickstart or record why a first install does not need it'); + } + } + + // ── Report ──────────────────────────────────────────────────────────────── + if (failures.length === 0) { + console.log(`checkQuickstart: ${checked.length} checks passed against website main.`); + return; + } + + console.error(`checkQuickstart: ${failures.length} disagreement(s) with website main:\n`); + for (const f of failures) console.error(` ✗ ${f.what}\n ${f.detail}`); + console.error( + '\nThe quickstart on /docs/getting-started/install-the-site/ is a copy of website\'s own\n' + + 'deployment files (D35). Either update src/data/quickstart.mjs to match, or record the\n' + + 'difference with a reason. Do not "fix" the check.', + ); + process.exit(1); +} + +run().catch((err) => { + console.error(`checkQuickstart: ${err.message}`); + process.exit(1); +}); diff --git a/src/config/sidebar.mjs b/src/config/sidebar.mjs index 101575c..7d5ec67 100644 --- a/src/config/sidebar.mjs +++ b/src/config/sidebar.mjs @@ -16,7 +16,33 @@ export const docsSidebar = [ { label: 'Getting started', - items: [{ label: 'What is Runic Gateway?', slug: 'docs' }], + items: [ + { label: 'What is Runic Gateway?', slug: 'docs' }, + { label: 'Requirements', slug: 'docs/getting-started/requirements' }, + { label: 'Install the site', slug: 'docs/getting-started/install-the-site' }, + { label: 'First run', slug: 'docs/getting-started/first-run' }, + { label: 'Install a game module', slug: 'docs/getting-started/install-a-game-module' }, + { label: 'Connect a game server', slug: 'docs/getting-started/connect-a-game-server' }, + { label: 'Verify the whole stack', slug: 'docs/getting-started/verify-the-whole-stack' }, + ], + }, + { + label: 'Administration', + items: [ + { label: 'Configuration', slug: 'docs/administration/configuration' }, + { label: 'Branding and theming', slug: 'docs/administration/branding-and-theming' }, + { label: 'Navigation and pages', slug: 'docs/administration/navigation-and-pages' }, + { label: 'Content', slug: 'docs/administration/content' }, + { label: 'Users and roles', slug: 'docs/administration/users-and-roles' }, + { label: 'Authentication', slug: 'docs/administration/authentication' }, + { label: 'Teams', slug: 'docs/administration/teams' }, + { label: 'Moderation', slug: 'docs/administration/moderation' }, + { label: 'Notifications and email', slug: 'docs/administration/notifications-and-email' }, + { label: 'Managing modules', slug: 'docs/administration/managing-modules' }, + { label: 'The shard connection', slug: 'docs/administration/the-shard-connection' }, + { label: 'Maintenance and upgrades', slug: 'docs/administration/maintenance-and-upgrades' }, + { label: 'Troubleshooting', slug: 'docs/administration/troubleshooting' }, + ], }, ]; diff --git a/src/content/docs/docs/administration/authentication.mdx b/src/content/docs/docs/administration/authentication.mdx new file mode 100644 index 0000000..1df190d --- /dev/null +++ b/src/content/docs/docs/administration/authentication.mdx @@ -0,0 +1,82 @@ +--- +title: Authentication +description: Local accounts and two-factor, SSO providers and the link-only policy, and the layer that keeps automated traffic out. +--- + +import { Aside } from '@astrojs/starlight/components'; + +**Admin → Authentication** has four tabs: Local Accounts, Google, Discord and Custom +Providers. One session model sits behind all of them — a web cookie, a mobile bearer token +and an SSO sign-in all produce the same session. + +## Local accounts + +Username and password sign-in is **always enabled and cannot be turned off**. It is how you +manage accounts and how SSO identities get linked in the first place, so there is no +configuration on this tab beyond that statement. + +**Two-factor** is a per-account, opt-in TOTP code, set up by each person under **Account** +in the sidebar. Nobody can enable it on someone else's behalf, and staff accounts are the +ones worth insisting on. + +## SSO providers + +Google and Discord each need a client ID and secret from that provider's developer console; +Custom Providers takes any OAuth2/OIDC issuer. Secrets are encrypted at rest with +`SECRET_ENC_KEY` and are never returned to any client. + + + +Configuring Google here also unlocks **email delivery**, which reuses the same OAuth client +— see [Notifications and email](/docs/administration/notifications-and-email/). + +## Trusted devices + +A second factor that asks on every sign-in on the same laptop trains people to click +through it. A device can be remembered after a successful two-factor challenge, and the +trust rides the browser's own cookie jar — including the in-app browser tab the Android app +opens for SSO, which is why signing in there does not ask again. + +Trust is per device and revocable, and it survives signing out: signing out ends a session, +not the statement that this machine is yours. + +## What keeps the automated traffic out + +Four layers, all on by default: + +- **Rate limiting and backoff** on the login routes, so a password guess costs time. +- **A honeypot field** that a human never fills in and a naive bot always does. +- **Bot scoring**, which accumulates points against an address for behaviour no human + produces, and bans it automatically past a threshold. +- **IP bans** from that scoring. + +**Admin → Web Bot Activity** shows the live state: currently banned addresses with their +score and expiry, and the recent events with the reason, path and points that produced +them. It is deliberately **read-only apart from an emergency unban** — there is nothing to +tune here, and the panel exists so that a legitimate user locked out by their office's +shared address can be let back in. + + + +## Getting locked out + +Two situations worth knowing before they happen at three in the morning: + +- **Your address is banned.** Restart the app container — the in-memory state goes with it. +- **You lost your second factor.** Use one of the recovery codes issued when you enabled + it. If those are gone too, another administrator opens **Users → View** on your account + and presses **Reset two-factor**, which turns TOTP off, revokes your trusted devices and + clears your recovery codes so a password sign-in works again. That is the practical + argument for a site never having exactly one admin. + +The same screen lists an account's trusted devices and revokes them individually or all at +once — the right response to a lost or stolen laptop, and something to reach for before +resetting the whole second factor. diff --git a/src/content/docs/docs/administration/branding-and-theming.mdx b/src/content/docs/docs/administration/branding-and-theming.mdx new file mode 100644 index 0000000..a967bac --- /dev/null +++ b/src/content/docs/docs/administration/branding-and-theming.mdx @@ -0,0 +1,85 @@ +--- +title: Branding and theming +description: Colours, fonts and corners from the Appearance screen; logo, hero and favicon from a mounted directory; the portal hero from its own editor. +--- + +import { Aside } from '@astrojs/starlight/components'; + +One prebuilt image runs as any community's site. Nothing about your identity is compiled +in — it is a theme row in the database, three image files on a mount, and a few environment +variables for the values that must exist before the database does. + +## Appearance + +**Admin → Appearance** themes the public site, the admin panel and the player portal +together. + +**Presets** — *Runic Gateway*, *Modern*, *Fantasy*, *Custom* — set a whole palette at once. +Anything you set below a preset overrides it field by field, and a colour you never set +keeps following the preset. That is the useful property: pick the preset closest to what +you want, change the two colours that are wrong, and the rest still moves with it. + +| Group | What is in it | +|---|---| +| **Colors** | Background, deep background, panel top and bottom, accent, bright accent, ink/headings, body text | +| **Fonts** | Body serif, display/headings, interface sans — each with a "follow the preset" default | +| **Corners & depth** | Radius for pills and buttons, flat panels, cards, inputs; and card shadow | + +Two things the screen tells you that are easy to miss: + +- **Live and maintenance status colours are never themed.** Green has to keep meaning live. +- **The accent reaches the mobile app and the Discord bot**, both of which theme themselves + from this site's public branding. Changing it here changes them. + +## Brand assets + +The same screen uploads three images, and each applies as soon as the upload finishes — +there is nothing to save. + +| Asset | Where it shows | Limit | +|---|---|---| +| **Logo** | Site header, admin sidebar, player portal, and link previews when a page is shared | 1 MB | +| **Hero image** | Behind the portal hero, unless the hero editor has its own background | 8 MB | +| **Favicon** | The browser tab. PNG only; 32×32 or 64×64 works everywhere | 512 KB | + +Underneath, these are files on the `./brand` bind mount from +[Install the site](/docs/getting-started/install-the-site/), pointed at by `BRAND_LOGO`, +`BRAND_HERO` and `BRAND_FAVICON`. An upload writes there; so does copying a file in by +hand. Both are supported, and the mount is why replacing a logo never means rebuilding an +image. + + + +## The text that comes from the environment + +A few identity values are read before the database is available — the server templates them +into `index.html` at boot so that link previews and the tab title are right on the very +first request: + +`BRAND_NAME`, `BRAND_SHORT_NAME`, `BRAND_TAGLINE`, `BRAND_DESCRIPTION`, +`BRAND_ACCENT_COLOR`, `BRAND_URL`, `BRAND_CONTACT_EMAIL`. + +Where an admin-editable setting exists for the same thing — site title, contact email — the +**setting wins**. The variable is the value a fresh deployment starts from. + +## The portal hero + +**Admin → Hero Editor** composes the front page's hero directly: drag elements to place +them, drag the corner handle to resize (text scales with the box), Delete removes the +selected one. The palette adds text, buttons, the moon, a badge or an image. + +Its own background image and overlay darkness are set at the bottom of the editor, and a +background set here **wins over** the Appearance screen's hero image. + +Work is not live until you press **Publish**; **Preview** opens it in a new tab, and +**Revert to live** throws away an unpublished draft. Until anything is published at all, +the portal renders the shipped hero with the homepage teaser from +[Settings](/docs/administration/configuration/) underneath it. + + diff --git a/src/content/docs/docs/administration/configuration.mdx b/src/content/docs/docs/administration/configuration.mdx new file mode 100644 index 0000000..a101bda --- /dev/null +++ b/src/content/docs/docs/administration/configuration.mdx @@ -0,0 +1,99 @@ +--- +title: Configuration +description: What is set in the environment file, what is set in the admin panel, and why the split is where it is. +--- + +import { Aside } from '@astrojs/starlight/components'; + +Two places hold configuration, and the line between them is not arbitrary. + +| | Environment (`.env`) | Admin panel | +|---|---|---| +| **What** | How the process runs: ports, database, secrets, proxy trust, log level | How the site behaves: titles, registration, forums, integrations | +| **Changing it** | Edit the file, `docker compose up -d` | Save the form; effective immediately | +| **Who** | Whoever has the host | Whoever has an admin account | +| **Where it lives** | A file on the host | The database | + +The rule behind the split: **anything that needs a restart or a shell is environment; +anything an administrator should be able to change without either is in the panel.** That +is why the Discord bot token, the OAuth client secrets and the shard's auth token are *not* +environment variables — they are entered in the panel and stored encrypted. + +## Settings + +**Admin → Settings**, the screen most of a new deployment's decisions live on. + +| Field | What it does | +|---|---| +| **Site title** | Overrides `BRAND_NAME` in the page title, the header and link previews. | +| **Homepage teaser** | Rich text under the hero heading, when no custom hero layout is published. | +| **Maintenance message** | What visitors see while the site is in maintenance mode. | +| **Status message** | A short line for announcements — a maintenance window, an outage. | +| **Contact email** | Where the contact form delivers, and the address it falls back to as a `mailto:` link while email is unconfigured. | +| **Player registration** | Disabled, password, SSO, or both. **Off by default.** | + +### Player registration is off until you turn it on + +A new site accepts no self-registration at all. The three ways to let people in: + +- **Password** — a normal sign-up form. +- **SSO** — sign-up through a linked provider, which needs a provider configured first. +- **Invites** — leave registration off entirely and issue invitations from + **Admin → Invites**. See [Users and roles](/docs/administration/users-and-roles/). + +## Team forums + +The same screen carries the forum switches, because they are site-wide policy rather than +per-Team settings: + +- **Enable team forums** — off by default. Switching them off hides them completely (every + forum route answers *not found*) but **deletes nothing**: threads, posts, access grants + and notification preferences all survive and come back exactly as they were. +- **Images in forum posts** — disabled, remote URLs only, or uploads to your server. + Enabling uploads means content stored on infrastructure you are responsible for, and the + screen says so at some length before you can agree to it. +- **Post edit window** — how long an author may edit their own post. Staff are not bound by + it. Zero makes posts permanent once written; some bound is what stops a post being + rewritten out from under someone quoting it. + +## Email + +Configured on the same screen and covered in +[Notifications and email](/docs/administration/notifications-and-email/): it is Gmail over +OAuth2, it reuses the Google authentication client, and it must be set up on the +[Authentication](/docs/administration/authentication/) page first. + + + +## The environment file, in three groups + +You wrote these in [Install the site](/docs/getting-started/install-the-site/); this is +what they mean when you come back to them. + +**Identity and process** — `NODE_ENV`, `PORT`, `INTERNAL_PORT`, `IMAGE_TAG`. `INTERNAL_PORT` +is the server-to-bot channel and must never be published or proxied. + +**Data and secrets** — the `DB_*` group, `JWT_SECRET`, `SECRET_ENC_KEY`, `BOT_INTERNAL_KEY`. +The last two are required in production, and `SECRET_ENC_KEY` is the key everything else +encrypted at rest is keyed by: change it and the stored secrets become unreadable. + +**Behaviour at the edge** — `TRUST_PROXY`, `COOKIE_SECURE`, `COOKIE_NAME`, +`JWT_EXPIRES_IN`. `COOKIE_NAME` is worth one warning: changing it on a live site logs +everybody out. + + + +## Branding is data, not configuration + +The `BRAND_*` variables and the `/brand` mount are how one prebuilt image runs as any +community's site. They get their own page: +[Branding and theming](/docs/administration/branding-and-theming/). diff --git a/src/content/docs/docs/administration/content.mdx b/src/content/docs/docs/administration/content.mdx new file mode 100644 index 0000000..478d115 --- /dev/null +++ b/src/content/docs/docs/administration/content.mdx @@ -0,0 +1,62 @@ +--- +title: Content +description: Posts and their categories, the wiki and its sections, and the activity log that records who changed what. +--- + +import { Aside } from '@astrojs/starlight/components'; + +Three content surfaces, one for each shape of writing a community does. + +| Surface | For | Lives at | +|---|---|---| +| **Posts** | Dated writing: news, the newsletter, screenshots | `/site/news` and friends | +| **Pages** | Standing pages: About, Rules, Donate — see [Navigation and pages](/docs/administration/navigation-and-pages/) | its own slug | +| **Wiki** | Reference the community maintains: guides, lore, systems | `/wiki` | + +## Posts + +**Admin → Posts**, filtered by category. A new deployment seeds four: + +- **News** — the default, and the one wired to announcements. +- **Five on Friday** — a recurring short-form format. +- **Newsletter** — longer, periodic. +- **Screenshots** — image posts. + +Each post is a draft until it is published, and the Posts list shows status and date at a +glance. + + + +## The wiki + +**Admin → Wiki** lists every page with its section and status, and **Manage sections** +edits the grouping itself. A new site starts with eight pages in four sections — Guides, +World & Lore, Systems & Gameplay, Community & Rules — as a skeleton to write into. + +They are placeholders. None of them describes your game, and leaving them published means +publishing an empty guide to it; either write them or unpublish them before you go live. + +## Who may write what + +Roles decide it, and the split is the useful part: + +- **Editor** — the content roles. Posts, pages, wiki, and the activity log. +- **Moderator** — moderation and Teams, not content authoring. +- **Admin** — everything, including the system screens. + +Full table in [Users and roles](/docs/administration/users-and-roles/). + +## The activity log + +**Admin → Activity** records what staff did: the action, a detail line, who did it, from +which address, and when. Module installs, logins, content changes and moderation all land +here. + +Two things it is good for beyond curiosity: reconstructing what changed just before +something broke, and confirming that an account which should not have done something did +not. It is a record, not a workflow — nothing is actioned from this screen. diff --git a/src/content/docs/docs/administration/maintenance-and-upgrades.mdx b/src/content/docs/docs/administration/maintenance-and-upgrades.mdx new file mode 100644 index 0000000..ec64545 --- /dev/null +++ b/src/content/docs/docs/administration/maintenance-and-upgrades.mdx @@ -0,0 +1,125 @@ +--- +title: Maintenance and upgrades +description: Upgrading the image, pinning a build, what to back up and how, where the logs are, and the reverse proxy. +--- + +import { Aside, Steps } from '@astrojs/starlight/components'; + +## Upgrading the site + +```bash +docker compose pull +docker compose up -d +``` + +That is the whole routine. The image carries the server and the built client together; +schema changes are applied on boot, and installed modules are on a volume the upgrade does +not touch. + +**Pin a build when you want a deploy you can reproduce.** `IMAGE_TAG` defaults to `latest`; +every merge also publishes `sha-<7>`, so + +```bash +IMAGE_TAG=sha-042a151 docker compose pull && docker compose up -d +``` + +deploys an exact build, and putting that value in `.env` makes it the one this host runs +until you change it. Rolling back is the same command with the previous tag — with one +caveat that decides whether it works. + + + +## Back up before you upgrade + +Two volumes and one directory hold everything that cannot be re-downloaded: the database, +the uploads, and `./modules`. + + + +1. **Dump the database.** From the deployment directory, while the stack is up: + + ```bash + docker compose exec -T db sh -c \ + 'mariadb-dump -u root -p"$MARIADB_ROOT_PASSWORD" --single-transaction --routines runic_gateway' \ + > backup-$(date +%F).sql + ``` + + `--single-transaction` is what makes it consistent without locking the site. + +2. **Copy the uploads volume.** + + ```bash + docker run --rm -v _uploads:/from -v "$PWD":/to alpine \ + tar czf /to/uploads-$(date +%F).tgz -C /from . + ``` + + The volume is named after the directory Compose runs in — `docker volume ls` shows the + exact names. + +3. **Keep `./modules`, `./brand` and your two files.** They are ordinary host directories; + whatever backs up the rest of the host covers them. + + + +Restoring the database is the same command inverted — `mariadb … < backup.sql` — into a +stack whose image is the one the dump came from. + +## Logs + +`./logs/app.log` on the host, because the Compose file bind-mounts it there. `docker compose +logs -f app` shows the same stream live. + +`LOG_LEVEL` sets console verbosity and `FILE_LOG_LEVEL` the file's — the file keeps the +fuller record on purpose. Nothing rotates them for you. + +## Restarting + +`docker compose restart app` is the ordinary restart, and it is what the admin panel's +**Restart the server** button amounts to. Restarts are needed after installing, enabling or +uninstalling a module, and are harmless otherwise. + +`docker compose down` stops everything and keeps the data. **`docker compose down -v` also +deletes the volumes** — the database and every upload. There is no undo. + +## The reverse proxy + +The app publishes port 3000 and binds all interfaces, so any proxy that can reach the host +can serve it. Two settings make it correct rather than merely working, both covered in +[Install the site](/docs/getting-started/install-the-site/): `TRUST_PROXY`, so the address +your rate limiting and IP bans act on is the visitor's rather than the proxy's, and +`COOKIE_SECURE=auto`. + +Three rules for whatever proxy you use: + +- **Forward only 3000.** `INTERNAL_PORT` (3001) is the server-to-bot channel and must never + be reachable from outside; the Compose file deliberately does not publish it. +- **Deny `/api/v1/internal` at the proxy** as well. Belt and braces: that route no longer + rides the public listener, and an explicit deny costs nothing. +- **Terminate TLS at the proxy.** The app speaks HTTP; it is not meant to hold a + certificate. + +## Upgrading the shard side + +A different deployment on a different host, and it moves on its own schedule: + +```bash +sudo runicgateway update # re-resolves the bundle; --verify to see it first +sudo runicgateway doctor # confirm afterwards +``` + +`update` replaces the sidecar and restarts its service, re-syncs the overlay, and tells you +when ServUO needs restarting — it never restarts your shard itself. Because it resolves a +**bundle**, the sidecar and the plugin move together and cannot end up disagreeing about the +protocol. + + diff --git a/src/content/docs/docs/administration/managing-modules.mdx b/src/content/docs/docs/administration/managing-modules.mdx new file mode 100644 index 0000000..3654a34 --- /dev/null +++ b/src/content/docs/docs/administration/managing-modules.mdx @@ -0,0 +1,104 @@ +--- +title: Managing modules +description: The five states a module can be in, installing and upgrading, disable versus uninstall versus purge, and what to do when one fails to start. +--- + +import { Aside } from '@astrojs/starlight/components'; + +Installing your first module is [Getting started](/docs/getting-started/install-a-game-module/). +This is what the screen means afterwards. + +## The five states + +`installed → enabled → started`, with `disabled` and `startup_failed` as recoverable +states. + +| State | Means | +|---|---| +| **Installed** | Files are on the volume; it mounts at the next restart | +| **Enabled** | Allowed to run, and about to be loaded. Every boot resets each non-disabled module to this, then records the outcome | +| **Started** | Running: routes mounted, schema applied | +| **Disabled** | An operator switched it off. Its routes answer *not found* | +| **Startup failed** | It tried and could not. The site came up without it | + +**A module that fails to load never takes the site down.** Failure is caught across the +whole lifecycle — require, schema, routes, registration, boot hook — and the site starts +with that module's routes and navigation absent, and the reason recorded on this screen. + +Two consequences of how boots work: + +- **A failed module is retried on every restart.** Fix the underlying cause and restart; you + do not need to touch the panel. A deterministically broken module re-records its failure + each boot, which is the honest thing for it to do. +- **Disabled is the only state a boot leaves alone.** Disabling is an operator's decision + rather than an outcome, so it survives restarts untouched. + +## Upgrading + +Paste the new release's install-manifest URL and press Install. The bundle is verified +against its `sha256`, unpacked over the old one, and takes effect at the restart. + +An upgrade **deliberately leaves the state alone**: upgrading an enabled module must not +silently switch it off, and re-installing a disabled one must not silently switch it on. + + + +## Disable, uninstall, purge + +Three different actions, in increasing order of destruction. + +**Disable** flips the row and dispatches that module's shutdown hook, so it actually stops +— releases its sockets, closes its streams — rather than merely becoming unreachable. Enable +is deliberately not the mirror image: there is no boot hook re-dispatch, so enabling offers +a restart. + +**Uninstall** is non-destructive by default: the row goes to `disabled`, the directory is +removed, and **the module's tables and data are retained**. + +**Purge** runs the module's own `purge.sql` and destroys its data. It is never implied by +an uninstall, and it is offered in two places — as a standalone action on an installed +module, and as an opt-in checkbox in the uninstall dialog. + + + +## Where modules may be installed from + +The allowlist at the bottom of the screen. Installing a module runs its code inside your +server, so only listed hosts are permitted, over HTTPS, re-checked on every redirect. An +empty list forbids every install. + +`MODULE_SOURCE_HOSTS` seeds this list on a site's first boot and is ignored afterwards — +the setting is authoritative, so a redeploy cannot silently undo your choice. + +## The declarative path + +`MODULES` in `.env` declares the set this deployment runs, resolved at every container +start, each entry `@=`. + +The division of ownership is the thing to remember: **the variable owns what is on the +volume; the panel owns whether a module runs.** Uninstall a declared module from the panel +and its files come back at the next start — disabled. + +A module already unpacked at the declared version is a no-op that makes **no network call +at all**, so a restart with no route to the internet comes up unchanged. A version that +cannot be fetched is logged, shown on this screen, and never stops the site starting. + +## Placing one by hand + +Unpacking a module tarball into `./modules//` and restarting is a supported install — +it is why that path is a bind mount rather than a named volume. The row it produces has no +provenance columns, because nothing downloaded it. + + diff --git a/src/content/docs/docs/administration/moderation.mdx b/src/content/docs/docs/administration/moderation.mdx new file mode 100644 index 0000000..d3e4070 --- /dev/null +++ b/src/content/docs/docs/administration/moderation.mdx @@ -0,0 +1,68 @@ +--- +title: Moderation +description: Three screens that do three different jobs — Discord moderation, content reports, and appeals against a sanction. +--- + +import { Aside } from '@astrojs/starlight/components'; + +The **Moderation** group in the sidebar holds three screens that are easy to confuse and do +not overlap. + +| Screen | Is about | Comes from | +|---|---|---| +| **Moderation** | Your **Discord** guild — bans, kicks, mutes, warnings, joins, leaves, filter and spam hits | the bot, captured live | +| **Reports** | **Team forum content** members have reported | the site | +| **Appeals** | Sanctions people are asking you to reverse | the site | + +## Moderation (Discord) + +Counts across a window you choose — 24 hours, 7 days, 30 days — for bans, kicks, mutes, +warnings, joins, leaves, filter hits and spam hits, with a filterable list of recent +actions and a tabbed event log (members, filter hits, spam hits). + +Everything here arrives from the Discord bot, so a site with no bot configured shows zeros +and empty lists rather than an error. Setting the bot up is +[Notifications and email](/docs/administration/notifications-and-email/). + +**Look up** takes you to a per-user view when you are investigating one account rather than +browsing the window. + +## Reports + +Reports raised by members about Team forum content. Two design decisions show through in +how this screen behaves: + +- **They come to site staff, and a Team's own leaders never see them.** A leader moderates + their own forum, so a report *about a leader* has to reach someone above them. +- **Handling a report records a decision about the report.** It does not touch the content: + hiding or removing a post is done in the forum, or as a sanction against the account. + +The filters are *Open*, *Reviewing*, *Actioned*, *Dismissed* and *All*, and the count of +open reports sits at the top so the screen is glanceable. + + + +## Appeals + +An appeal is a request to reverse a sanction, filtered by *Open*, *Pending*, *Under +review*, *Approved*, *Denied*, *Withdrawn* or *All*. Each row carries the target, the +action being appealed, the appeal itself, who submitted it, its age and whether a reversal +happened. + +Two things worth building a habit around: + +- **Age is the column that matters.** An appeal that nobody has looked at for three weeks + is a worse outcome than a denial. +- **The decision is recorded either way.** Approving an appeal records the reversal, so the + history explains itself later without anyone having to remember. + +## What is recorded, and where + +Every staff action lands in **Admin → Activity** — who did what, from which address, when. +That log is the thing to read when reconstructing a disputed decision, and it is a record +rather than a workflow: nothing is actioned from it. See +[Content](/docs/administration/content/). diff --git a/src/content/docs/docs/administration/navigation-and-pages.mdx b/src/content/docs/docs/administration/navigation-and-pages.mdx new file mode 100644 index 0000000..6f6cc92 --- /dev/null +++ b/src/content/docs/docs/administration/navigation-and-pages.mdx @@ -0,0 +1,49 @@ +--- +title: Navigation and pages +description: Renaming, reordering and hiding navigation entries in three navs, and composing standalone pages from blocks. +--- + +import { Aside } from '@astrojs/starlight/components'; + +## Navigation + +**Admin → Navigation** edits three separate navigations — **Public site**, **Admin** and +**Player portal** — each with the same tools: rename an entry, reorder it, hide it, group +entries into a dropdown section, or add a link of your own. + +A fresh site's public nav is the seeded one: the portal, News, Screenshots, Five on Friday, +Newsletter, the wiki, and About. Until you change anything, the nav "renders exactly as +coded" — there is no stored copy to drift from the code. + +Two properties are worth understanding before you rely on this screen. + +**It advertises; it does not authorise.** Renaming or hiding an entry changes what is +listed, never what exists or who may reach it. Hiding *Wiki* does not close the wiki. Access +is decided by roles and by a module's visibility settings, and this screen "can never show +anyone a link their role, or the visibility settings of an installed module, would hide". + +**You only edit what you can see.** Entries hidden from *you* — by your role, or by a +module's visibility rules — are not listed, and they keep whatever setting they already +had. So an administrator's view of this screen is not necessarily the whole nav, and +editing it cannot damage the parts you cannot see. + + + +**Reset to default** discards your customisation for that nav and goes back to the coded +one. It is per-nav, not global. + +## Pages + +**Admin → Pages** composes standalone pages from blocks. A published page is live at its +slug — `/about`, `/rules`, `/donate` — and a draft is visible only to staff. + +This is the right tool for content that is not news and not a wiki article: the pages a +navigation entry points at. A page you create is not linked from anywhere until you add it +in **Navigation** — deliberately, because the two are separate decisions. + +For everything else — news posts, the newsletter, screenshots, the wiki — see +[Content](/docs/administration/content/). diff --git a/src/content/docs/docs/administration/notifications-and-email.mdx b/src/content/docs/docs/administration/notifications-and-email.mdx new file mode 100644 index 0000000..59bb1de --- /dev/null +++ b/src/content/docs/docs/administration/notifications-and-email.mdx @@ -0,0 +1,91 @@ +--- +title: Notifications and email +description: Email over Gmail OAuth2, the announcement pipeline and its legs, the Discord bot, and opt-in push to the mobile app. +--- + +import { Aside } from '@astrojs/starlight/components'; + +Four separate delivery paths, each optional, each off until you configure it. A site that +configures none of them still works — it just never reaches anyone who is not looking at +it. + +## Email + +**Admin → Settings → Email delivery.** The site sends contact-form messages (and test +messages) through **Gmail over OAuth2**, delivered to the *Contact email* setting. + +It reuses the **Google authentication client**, so the order is fixed: configure Google on +the [Authentication](/docs/administration/authentication/) page first, then press **Connect +Gmail** here. Until then the panel reads *Unconfigured* and says exactly that. + +The refresh token it stores is encrypted at rest like every other secret. + + + +## Announcements + +Publishing a **news** post fans it out to every registered delivery leg. The dispatcher is +an in-process poller, tuned by `ANNOUNCE_POLL_MS` (15 seconds by default), and the links in +an announcement are built from `APP_BASE_URL` — so set that in production or the links point +at the wrong host. + +Which legs exist depends on what has registered one: + +- **Discord `#news`** is core's, and needs the bot below. +- **A module may add its own.** The `uo` module adds an in-game town crier, so a news post + is announced to players who are logged into the game and never visit the site. + +A leg brings its own settings with it — the town crier's duration is a module setting, not +a core one — which is why they are documented with the module rather than here. + +## The Discord bot + +**Admin → Discord Bot**: enable it, give it the guild (server) ID and the bot token, and +save. The token is stored **encrypted in the database** and is never an environment +variable. + +The bot is a separate container. On the quickstart deployment from +[Install the site](/docs/getting-started/install-the-site/) it is not running at all, and +the panel says so — *bot unreachable* is the honest state of a site that never started one, +not a failure. Add the `bot` service from the project's shipped Compose file when you want +it. + +What it does once connected: posts announcements, captures the moderation events on the +[Moderation](/docs/administration/moderation/) screen, serves slash commands, and — if you +switch them on — the Team notification bridge and per-Team voice channels from +[Teams](/docs/administration/teams/). + +## Push notifications + +Opt-in push to the Android app, over a **self-hosted ntfy relay** — the `ntfy` service in +the project's Compose file, plus `NTFY_BASE_URL` and friends. + +Two properties matter for what you have to trust: + +- **The relay only ever carries a content-free tickle.** The message says something + happened; the app then fetches the actual content from the site over its own + authenticated connection. So the relay never sees notification text. +- **A device may only register an endpoint on an allowed origin**, derived from + `NTFY_BASE_URL`. That is what stops a device pointing your server at somebody else's. + +Without `NTFY_PUBLIC_URL` / `NTFY_ALLOWED_ORIGINS`, the app simply shows push as +unavailable for your instance — nothing breaks. + +## Who receives what + +The per-person side of this lives in the player portal, not the admin panel: each member +chooses which Team and forum notifications they want, and how. Two defaults are worth +knowing because they are not symmetrical: + +- **Push is opt-out** once a device is registered. +- **Email is opt-in.** + + diff --git a/src/content/docs/docs/administration/teams.mdx b/src/content/docs/docs/administration/teams.mdx new file mode 100644 index 0000000..1a1e75d --- /dev/null +++ b/src/content/docs/docs/administration/teams.mdx @@ -0,0 +1,77 @@ +--- +title: Teams +description: Core owns the Team machinery and cannot create a Team. What that means in practice, and what the admin screen controls. +--- + +import { Aside } from '@astrojs/starlight/components'; + +Teams are a core platform primitive: membership, roles, forums, notifications, moderation +and the Discord integrations are all core's, and none of it knows what a Team *is* in your +game. + +**Core cannot create a Team.** Teams arrive from the installed module — with the `uo` +module, they are the shard's guilds. On a deployment with no module, the Team machinery is +present and permanently empty. That is not a bug to work around; it is the contract that +lets the same forum, notification and moderation code serve any game. + + + +## The projection, and why it can be stale + +**Admin → Teams** shows a sync panel per module: last attempt, last success, consecutive +failures and the last error, with **Sync now** and **Resync now**. + +The wording on that panel is exact and worth reading: + +> Core has never had an answer it could trust. What is shown below is not a confirmed empty +> shard. + +An empty list therefore means one of two very different things — there are no Teams, or +nobody could ask. The panel tells you which, and a *last success: never* with a *last +error* of `no uo-link configured` is the second. Fix +[the shard connection](/docs/administration/the-shard-connection/) and sync again. + +## Forums + +Team forums are switched on site-wide in **Settings**, along with whether images are +allowed and how long an author may edit a post — see +[Configuration](/docs/administration/configuration/). + +Two rules are structural rather than settings: + +- **A Team's leaders moderate their own forum.** That is the point of a Team forum. +- **Reports about that forum do not go to them.** They go to site staff, because a report + about a leader has to reach someone above them. See + [Moderation](/docs/administration/moderation/). + +## The Discord bridges + +Two integrations, both optional, both configured from **Admin → Teams**. + +**Notification bridge** — sends Team notifications to a Discord channel: a default for +every Team, overridable per Team. A message is sent once and never retried; the bridge is a +courtesy, and nothing on the site depends on it arriving. With nothing configured, no Team +event leaves the site. + +**Voice channels** — gives each Team a Discord voice channel of its own, with access +granted by a per-Team role, so a Team's members can see and join theirs and nobody else +can. It needs the bot reachable, and members need a linked Discord account and guild +membership. + +Its three settings deserve a thought each: + +| Setting | What it decides | +|---|---| +| **Minimum members** | How large a Team must be to get a channel. Every active member counts, linked account or not. | +| **Grace window (days)** | How long a Team keeps its channel after it stops qualifying. A Team that recovers inside the window keeps the same channel; zero removes it on the next pass. | +| **Staff roles** | Roles that can see and join every Team's channel. Guild administrators already can, so this is for staff who are not administrators. | + + diff --git a/src/content/docs/docs/administration/the-shard-connection.mdx b/src/content/docs/docs/administration/the-shard-connection.mdx new file mode 100644 index 0000000..b42b942 --- /dev/null +++ b/src/content/docs/docs/administration/the-shard-connection.mdx @@ -0,0 +1,98 @@ +--- +title: The shard connection +description: The module's shard screen — connection settings, what the status line means, game-account creation, the town crier, and what reaches the public. +--- + +import platform from '../../../../data/platform.json'; +import { Aside } from '@astrojs/starlight/components'; + +With the `uo` module installed, **Shard (uo-link)** appears in the admin sidebar at +`/admin/uo/link`. It is the site's half of the bridge: the connection to the sidecar, and +the controls that ride on it. + +Setting it up for the first time is +[Connect a game server](/docs/getting-started/connect-a-game-server/). + +## Connection + +Four fields, all four printed by the installer, plus the switch that turns the integration +on: + +| Field | Value | +|---|---| +| **Base URL (REST)** | `http://:8080` — point-in-time queries | +| **WebSocket URL (feed)** | `ws://:8080/ws` — the live event feed | +| **Auth token** | The sidecar's token | +| **Protocol** | {platform.protocol} today | + +Saving restarts the ingest client, so a change takes effect immediately. + +**The token is write-only.** It is encrypted at rest and never returned to any client, so +the field is blank when you come back to the screen — losing it means reading it back from +`sidecar.toml` on the shard host, not from the website. + +## Reading the status line + +The header carries the connection state, *Shard link*, *WS ingest*, *Reconnects* and *SSE +clients*. Together they say **which** link is broken: + +| Reading | Means | +|---|---| +| Disconnected, shard link down | The site cannot reach the sidecar at all — URL, firewall, or the service is not running | +| Connected, but shard link down | The sidecar is up and the *game* is not talking to it | +| Reconnects climbing | An unstable path between site and sidecar | +| Live feed silent, everything else green | The bridge is fine and the shard is quiet | + +A `409` in the logs is a protocol mismatch — set the Protocol field to what the sidecar's +`/health` reports rather than guessing; it rejects rather than mis-parsing. A `401` is the +token. + + + +## Game-account creation + +Whether players can create a **game** account (for the game client) from the website. The +game server's own `SignupMode` in `Bridge.cfg` has to agree. + +| Mode | Behaviour | +|---|---| +| **Disabled** | Players may only link an account that already exists | +| **Website** | The site creates game accounts | +| **Hybrid** | Site or in-game — the recommended setting | +| **Game only** | Created in the game client; the site only links | + +With creation enabled, a *Create a game account* form appears in the player portal and +after an invite is accepted. + +## Town crier + +Broadcast a message every in-game town crier announces until it expires: an id, one or more +lines, and a duration in seconds. Re-posting the same id **replaces** that message, and +**Remove by id** takes it down early. + +The id is the useful part — give a recurring announcement a stable one and you can update or +withdraw it without waiting for it to expire. + +## What reaches the public + +Events from the shard fan out over two separate streams, and the split is a security +boundary rather than a preference: + +- **The public stream** carries an allowlist of event kinds. +- **The admin stream** adds staff audit events, cheat detection, login attempts and IP + addresses. + +The live feed at the bottom of this screen is the admin one — everything, as it arrives. +Treat it accordingly: it is the screen you do not put in a screenshot. + + diff --git a/src/content/docs/docs/administration/troubleshooting.mdx b/src/content/docs/docs/administration/troubleshooting.mdx new file mode 100644 index 0000000..01d4024 --- /dev/null +++ b/src/content/docs/docs/administration/troubleshooting.mdx @@ -0,0 +1,118 @@ +--- +title: Troubleshooting +description: The failures a deployment actually hits, what each one looks like, and the fix. +--- + +import { Aside } from '@astrojs/starlight/components'; + +Grouped by where the problem is, because the first useful question is always *which half is +broken*. + +## The site will not start + +Read the log first — `docker compose logs app` — because the server says exactly why. + +| What the log says | What it means | +|---|---| +| `SECRET_ENC_KEY must be set in production` | The key that encrypts stored secrets is missing. Set it in `.env` and start again. The container crash-loops until you do. | +| A `BOT_INTERNAL_KEY` complaint | Blank, still a placeholder, or shorter than 16 characters. Required in production even when the bot is not running. | +| A database connection error, repeatedly | The app came up before the database was ready, or `DB_*` is wrong. The Compose file's health check handles the first case; check the values for the second. | +| Nothing at all, container restarting | The image did not pull. `docker compose pull` on its own shows the error. | + + + +## Nobody can sign in + +- **Your address is rate-limited or bot-banned.** Both are working as designed. Check + **Admin → Web Bot Activity** from another network, or restart the app container — the + scoring state is in memory and resets with it. +- **The password is right and the form still fails.** Check the log for the actual status: + a `429` is the rate limiter, a `403` is usually the honeypot, and a `401` really is the + password. +- **SSO returns to the login page.** SSO is link-only: an identity that is not already + linked to an account cannot sign in, and that is the expected outcome rather than a + misconfiguration. Link it from the account screen first. +- **Two-factor is lost.** Recovery codes, or another admin's **Reset two-factor** on + **Users → View**. See [Authentication](/docs/administration/authentication/). + +## A module will not start + +**Admin → Modules** names the stage and the reason. The usual three: + +| Reason | Fix | +|---|---| +| `module directory not present on the volume` | The row exists and the files do not — someone deleted the directory by hand. Reinstall, or remove the row with an uninstall. | +| A schema failure | The module's schema fragment could not be applied. The log carries the SQL error. | +| A version refusal | The module wants a newer core API than this image. Upgrade the site. | + +Whatever the reason, **the site is up and the module's routes are absent** — that is by +design, and it is why a broken module is an inconvenience rather than an outage. Fix the +cause and restart: failed modules are retried on every boot. + +**The install button rejects a URL.** The host must be in the allowlist on the same screen, +and the URL must be HTTPS. An empty allowlist forbids every install. + +**The install succeeds and nothing appears.** It needs a restart. The banner says so, and +the row reads *Restart to start* until then. + +## The Restart button did not bring the site back + +The button exits the process and relies on a supervisor to start it again. If your +deployment has nothing supervising it — `npm start` in a terminal, a container without +`restart:` — the site stays down until you start it yourself. Compose with +`restart: unless-stopped` is the supported shape. + +## The game screens are empty or say offline + +Work outwards from the game, and stop at the first check that fails. + +1. **In game:** `[bridge status` — `connected=False` means the shard cannot reach the + sidecar. +2. **On the shard host:** `curl -s http://127.0.0.1:8080/health` — `plugin_connected: true` + is the value that matters. +3. **On the shard host:** `runicgateway doctor` — checks the install record, every overlay + file hash, the service, and that the sidecar and overlay agree on a protocol. +4. **On the site:** the [shard connection screen](/docs/administration/the-shard-connection/) + — its four indicators say which link is broken. + +Two log lines with specific meanings: **`409`** is a protocol mismatch (set the Protocol +field to what `/health` reports), and **`401`** is the auth token (read the live one back +with `uo-link-sidecar --print-config`; do not retype it from a screenshot). + + + +## Teams are missing + +Check the sync panel on **Admin → Teams** before anything else: *last success: never* with +`no uo-link configured` means the shard connection, not the Team machinery. And on a site +with **no module installed**, an empty Team list is correct and final — core cannot create +a Team. See [Teams](/docs/administration/teams/). + +## Email and announcements never arrive + +- **The contact form opens a mail client.** Email delivery is not connected; that is the + documented fallback. Connect Gmail in **Settings → Email delivery** — after configuring + the Google provider, which it reuses. +- **A published post announced nothing.** The Discord bot is a separate container. If the + Discord Bot screen says *bot unreachable*, it is not running. +- **A missed announcement does not come back.** Nothing retries; the post itself is still + on the site. + +## Uploads and modules fail with permission errors + +Docker created a bind-mount source that the container user cannot write — usually because +the directory was deleted and recreated by Docker as `root`. `chown 1000:1000 modules` (or +`logs`, or `brand`) on the host fixes it. Do not delete those directories. + +## When you need to ask for help + +Bring three things: the relevant lines from `docker compose logs app`, the output of +`runicgateway doctor` if a game server is involved, and what you changed last. The +[community page](/community/) has where to ask. diff --git a/src/content/docs/docs/administration/users-and-roles.mdx b/src/content/docs/docs/administration/users-and-roles.mdx new file mode 100644 index 0000000..cc44efd --- /dev/null +++ b/src/content/docs/docs/administration/users-and-roles.mdx @@ -0,0 +1,67 @@ +--- +title: Users and roles +description: The four roles and what each one reaches, creating accounts, and inviting people to a site that is not open for registration. +--- + +import { Aside } from '@astrojs/starlight/components'; + +## The four roles + +| Role | Reaches | +|---|---| +| **Player** | The player portal: their own profile, their own characters and game account links, their Teams, forum access, notification preferences | +| **Moderator** | Everything a player has, plus Moderation, Appeals, Reports and the Teams admin screen | +| **Editor** | Everything a player has, plus Posts, Pages, Wiki and the Activity log | +| **Admin** | All of it, including Users, Invites, Settings, Modules, Appearance, Navigation, Authentication and the module's own admin screens | + + + +Admin routes are re-validated against the database on **every request**, not just at sign-in. +Demoting an account takes effect at once — the open session does not keep its access until +it expires. + +## Creating an account + +**Admin → Users → + Add user** creates one directly: username, password, role, and it is +active immediately. That is the right path for staff, and for the handful of accounts you +create yourself. + +The list shows each account's role, status and last login, with **View** and **Edit** on +every row. + +## Invites + +**Admin → Invites** is the way to let a specific person in when self-registration is off — +which is how every deployment starts. + +Enter an email, pick the access level (player, moderator, editor or admin), and either +**create and email** the invitation or generate a link to share yourself. The table tracks +status, expiry and creation date, so an unaccepted invite is visible rather than forgotten. + +This is worth preferring over creating accounts by hand for real people: the recipient sets +their own password, and you never handle it. + +## Opening registration + +When you do want a public sign-up, that is **Settings → Player registration**: password, +SSO, or both. See [Configuration](/docs/administration/configuration/). + +Before opening it, know what is protecting the door: rate limiting, login backoff, a +honeypot, bot scoring and automatic IP bans — all covered in +[Authentication](/docs/administration/authentication/), along with two-factor and the SSO +policy that an external identity can only ever sign in to an account it is already linked +to. + +## Status, and why deleting is the last resort + +Editing an account sets its **status** as well as its role: *active*, *disabled*, *banned* +or *pending*. Disabled and banned both stop the account being used; the difference is what +you are recording — an account switched off versus an account sanctioned. + +Prefer either to the **Delete** button. Content, moderation history and Team membership all +reference the account, and a disabled one keeps those records readable while a deleted one +leaves the history to explain itself. diff --git a/src/content/docs/docs/getting-started/connect-a-game-server.mdx b/src/content/docs/docs/getting-started/connect-a-game-server.mdx new file mode 100644 index 0000000..f431c23 --- /dev/null +++ b/src/content/docs/docs/getting-started/connect-a-game-server.mdx @@ -0,0 +1,133 @@ +--- +title: Connect a game server +description: The installer binary on the shard host — what it deploys, what it asks, and the four values it prints for the website. +--- + +import platform from '../../../../data/platform.json'; +import { Aside, Steps } from '@astrojs/starlight/components'; + +This is the second of the two installs, and it happens on the machine that runs your game +server. One binary deploys the plugin, installs the sidecar, registers its service, and +prints four values for you to paste into the website. + +It never contacts your website, and it never starts or stops your shard. + +## What gets deployed + +| # | Component | Where it goes | +|---|---|---| +| 1 | **The plugin overlay** — C# source ServUO compiles at boot | into your ServUO tree | +| 2 | **The uo-link sidecar** — a small Rust service | a system directory, plus a service | +| 3 | **A record of the run** | `install.json`, with per-file hashes and backups | + +``` +ServUO shard ──loopback TCP 127.0.0.1:7788──► uo-link sidecar ──HTTP + WebSocket──► website +``` + +The shard **dials out**. It never listens for the website and is never reachable from the +internet; only the sidecar is exposed, and only to your site. + +## Install + + + +1. **Download the binary for your OS, and `SHA256SUMS`**, from the + [installer releases page](https://gitea.whitlocktech.com/RunicGateway/installer/releases) + ({platform.releases.installer}). + + Releases are **unsigned** — there is no code-signing certificate, so that checksum file + is the whole trust anchor. Check it: + + ```bash + sha256sum -c SHA256SUMS --ignore-missing + chmod +x runicgateway-installer-linux-x86_64 + ``` + + On Windows, `(Get-FileHash .\runicgateway-installer-windows-x86_64.exe -Algorithm SHA256).Hash` + and compare. Windows will also show a SmartScreen prompt on first run, for the same + reason. + +2. **Stop the shard.** `ServUO.exe` locks `Scripts.dll` and rewrites `Saves/` on exit, so + the installer refuses to deploy under a running server. + +3. **Run it, elevated.** + + ```bash + sudo ./runicgateway-installer-linux-x86_64 install + ``` + + Add `--verify` first if you want to see every change it would make and write nothing. + + It asks four things: your ServUO root, whether to apply the optional patch tier, the + hostname your website should use to reach this machine, and your site's URL (used only + to print a link at the end). + +4. **Read the summary.** It reports the overlay sync file by file, the sidecar binary and + its verified hash, the config and database paths, and the service state. Then it says + what you must do next — restart ServUO yourself, because it will not do that for you. + + + + + +## The patch tier is optional + +Most of the plugin is *added* files, which is why the base install is a safe copy. Two +features need edits to stock ServUO sources, and those are opt-in, off unless you say yes, +and refused where the target lines are not stock. Skipping the tier costs you vendor-sale +events and in-game moderation audit forwarding; everything else works. + +The tier is written and tested against stock ServUO {platform.bundle.servuoMin}. On any +other version it is unsupported and untested, and the prompt makes you answer past a +warning. + +## Paste the four values into the site + +A successful run ends by printing the one step it cannot do for you: + +``` + Base URL http://shard.example.com:8080 + WebSocket URL ws://shard.example.com:8080/ws + Protocol version 4 + Auth token 4f9c… (also in sidecar.toml) +``` + +Every value comes from asking the installed sidecar itself, so it cannot drift from what +the service actually runs. + +On the site, sign in as an administrator and open **Shard (uo-link)** in the admin +sidebar — `/admin/uo/link`. Tick *Enable the shard integration*, paste **Base URL**, +**WebSocket URL**, **Auth token** and **Protocol**, and save. The ingest client restarts +immediately. + + + +The token is encrypted at rest and **never returned to any client** — losing it means +reading it back from `sidecar.toml` on the shard host, not from the website. + +## If the website is on a different machine + +The sidecar binds `127.0.0.1:8080`, reachable only from the shard host. If the site runs +elsewhere, widen the bind and then narrow the access: + +1. Set `[web] bind` in `sidecar.toml` to `0.0.0.0:8080` and restart the service. +2. **Firewall that port to your website's address only.** The auth token is always on, but + it travels as a plain bearer token — the sidecar speaks HTTP, not HTTPS. +3. If the two hosts are not on a trusted network, put the sidecar behind a TLS reverse + proxy or a VPN link and give the website the `https://` / `wss://` URLs. + +Leave `[shard] bind` on `127.0.0.1:7788`. That socket accepts *inbound commands to the +game*, and being loopback-only is what makes that safe. + +Next: [Verify the whole stack](/docs/getting-started/verify-the-whole-stack/) — because a +successful file copy is not a working bridge. diff --git a/src/content/docs/docs/getting-started/first-run.mdx b/src/content/docs/docs/getting-started/first-run.mdx new file mode 100644 index 0000000..54cc450 --- /dev/null +++ b/src/content/docs/docs/getting-started/first-run.mdx @@ -0,0 +1,78 @@ +--- +title: First run +description: Signing in as the first admin, what the site does before anyone visits, and the switch from maintenance to live. +--- + +import { Aside, Steps } from '@astrojs/starlight/components'; + +The site is up and nobody can see it yet. That is the intended state: a new deployment +**starts in maintenance mode**, showing visitors a "coming soon" page while the admin panel +stays reachable. + +## Sign in + + + +1. **Open `/admin/login`** — not `/`. The public site and the admin panel have separate + sign-in screens, and in maintenance mode the public one is behind the coming-soon page. + +2. **Use `ADMIN_USERNAME` and `ADMIN_PASSWORD` from your `.env`.** + + That account was created on the first boot, and only because the `users` table was + empty. The variables do nothing on later boots, so you can blank them once you are in. + +3. **Set up two-factor**, under **Account** at the bottom of the sidebar. Optional, + per-account, and the right moment is now rather than after the site is public. + + + + + +## What is already there + +The first boot seeds a working site rather than an empty one: + +- **A wiki with eight pages**, arranged in sections — Guides, World & Lore, Systems & + Gameplay, Community & Rules — as a skeleton to write into, not as content to keep. +- **Post categories**: News, Five on Friday, Newsletter, Screenshots. +- **A public navigation** covering those, the wiki and an About page. +- **A portal hero** with placeholder copy that names no game. + +None of it mentions a specific game, because core does not know about one. That arrives +with a [module](/docs/getting-started/install-a-game-module/). + +## The three things to set before going live + +All three are on **Settings**: + +| Setting | Why now | +|---|---| +| **Site title** | Overrides `BRAND_NAME` for the page title, the header and link previews. | +| **Contact email** | Where the contact form delivers. Until email is configured, the form falls back to a `mailto:` link to this address — so an unset one means a contact form that goes nowhere. | +| **Player registration** | **Off by default**: nobody can create an account. Choose password, SSO, both, or leave it off and invite people individually from **Invites**. | + +The maintenance message and the homepage teaser are on the same screen, and both are worth +a minute before anyone reads them. + +## Switch to live + +**Dashboard → Switch to Live.** The public site opens immediately; nothing else changes. + +You can flip back at any time, and an admin who is signed in can preview the live site +while the rest of the world still sees the maintenance page — so there is no need to go +live in order to check your work. + + + +Next: [Install a game module](/docs/getting-started/install-a-game-module/), or skip +straight to [Administration](/docs/administration/configuration/) if this deployment is a +community site with no game server behind it. diff --git a/src/content/docs/docs/getting-started/install-a-game-module.mdx b/src/content/docs/docs/getting-started/install-a-game-module.mdx new file mode 100644 index 0000000..840100e --- /dev/null +++ b/src/content/docs/docs/getting-started/install-a-game-module.mdx @@ -0,0 +1,93 @@ +--- +title: Install a game module +description: Everything game-specific is a module. Installing one, what it adds, and the restart that makes it live. +--- + +import platform from '../../../../data/platform.json'; +import { Aside, Steps } from '@astrojs/starlight/components'; + +Core knows nothing about any game. Every game-specific screen — shard status, the map +atlas, the player marketplace, character sheets — comes from a **module**, a directory on a +mounted volume that the server loads at start. + +Today there is one: **`uo`**, for ServUO shards, published as +[`Module-uo`](https://gitea.whitlocktech.com/RunicGateway/Module-uo) ({platform.releases['Module-uo']}). + +## Install it + + + +1. **Open Admin → Modules.** + +2. **Paste the URL of a release's install manifest** into *Release install-manifest URL* + and press **Install**. + + For the current `uo` release that is the `module-uo-.json` asset on + [its releases page](https://gitea.whitlocktech.com/RunicGateway/Module-uo/releases). + The site downloads the bundle, checks it against the `sha256` the manifest declares, and + unpacks it onto the modules volume. + + There is no catalog to browse, deliberately: a catalog would make core's release cadence + decide which modules are allowed to exist. + +3. **Restart when it asks.** A banner appears — *Modules are read from disk when the server + starts* — with a **Restart the server** button. The row reads *Restart to start* until + you do. + + The button exits the process and lets your supervisor bring it back; on the Compose + deployment from [Install the site](/docs/getting-started/install-the-site/), that is + `restart: unless-stopped` doing its job. `docker compose restart app` is exactly + equivalent. + +4. **Confirm it started.** The module's row should read *Started*, and its screens should + have appeared in the navigation. + + + + + +## What the `uo` module adds + +Watch the log at the restart and you will see exactly what it mounted: + +``` +[uo] registered routes: public:/shard,/atlas admin:/shard,/uo-link player:/shard +[modules] schema ensured for module "uo" +[modules] module "uo" started +``` + +Its capabilities are {platform.moduleUoCapabilities.join(', ')} — the shard console, the +map atlas, the player-vendor marketplace, city governors, guilds, houses and IDOCs, champion +boards, and the cliloc strings that make item names readable. + +A module owns **one path segment** wherever it appears, so its pages live under `/uo/…`, +`/admin/uo/…` and `/player/uo/…`. That boundary is visible in the URL on purpose. + + + +## The declarative alternative + +A host whose Compose file is version-controlled can skip the panel entirely: set `MODULES` +in `.env`, one entry per module, `@=`. The container +resolves that set at every start. + +A module already unpacked at the declared version is left alone **without a single network +call**, so a restart with no route to the internet comes up unchanged. A failure is logged +and shown in Admin → Modules, and never stops the site from starting. + +The two surfaces agree on a rule worth knowing: **the variable owns what is on the volume, +the admin panel owns whether a module runs.** A module you disable in the panel stays +disabled even though its files are put back at the next start. + +More on both in [Managing modules](/docs/administration/managing-modules/). + +Next: [Connect a game server](/docs/getting-started/connect-a-game-server/). diff --git a/src/content/docs/docs/getting-started/install-the-site.mdx b/src/content/docs/docs/getting-started/install-the-site.mdx new file mode 100644 index 0000000..f19aa2e --- /dev/null +++ b/src/content/docs/docs/getting-started/install-the-site.mdx @@ -0,0 +1,140 @@ +--- +title: Install the site +description: A complete Docker Compose deployment you can copy from this page — two files, two commands. +--- + +import { Aside, Code, Steps } from '@astrojs/starlight/components'; +import { compose, env, omittedServices } from '../../../../data/quickstart.mjs'; + +export const envText = env.map((e) => `${e.key}=${e.value}`).join('\n'); +export const fillLines = env.filter((e) => e.fill).map((e) => `${e.key}=${e.value}`); + +The site is a Docker deployment: a MariaDB container, the prebuilt application image, and +two files you write. Nothing is compiled on your host, and there is no repository to clone +— everything you need is on this page. + + + +1. **Make a directory for the deployment.** + + Everything below is relative to it, and the bind mounts want to exist before the + containers do — Docker creates a missing mount source as `root`, and the container user + then cannot write it. + + ```bash + mkdir -p runic-gateway/logs runic-gateway/brand runic-gateway/modules + cd runic-gateway + ``` + +2. **Write `docker-compose.yml`.** + + + + This file only ever *pulls*. There is no `build:` anywhere in it, which is deliberate: + a production host should not be able to build an image by accident. + +3. **Write `.env` beside it.** + + Every highlighted line must be changed before this is a real deployment. The secrets + want to be long random strings — `openssl rand -base64 36` three times is enough. + + + +4. **Pull and start.** + + ```bash + docker compose pull + docker compose up -d + ``` + + The database comes up first; the app waits for its health check, creates its schema, + seeds defaults, creates your first admin, and starts listening. + +5. **Check that it is up.** + + ```bash + curl -s http://localhost:3000/api/health + ``` + + ```json + {"status":"ok"} + ``` + + If that answers, the site is running. Go to + [First run](/docs/getting-started/first-run/). + + + +## What you just deployed + +``` +localhost:3000 ──► app (the website: API + the built React client, one process) + │ + └──► db (MariaDB, no host port — only the app can reach it) +``` + +Four host directories and two volumes hold everything that survives a container: + +| Path | What is in it | +|---|---| +| `./logs/` | `app.log`, readable from the host without `docker exec` | +| `./modules/` | Installed [modules](/docs/getting-started/install-a-game-module/). A bind mount, so placing one by hand is a supported install | +| `./brand/` | Your logo, hero and favicon, if you replace the defaults ([Branding and theming](/docs/administration/branding-and-theming/)) | +| `dbdata` volume | The database | +| `uploads` volume | Everything uploaded through the site | + + + +## Two variables worth reading twice + +**`SECRET_ENC_KEY`** encrypts secrets at rest — OAuth client secrets, the Discord bot +token, the shard's auth token. In production the server **refuses to start** without it. +Changing it later does not re-encrypt anything: what was stored under the old key can no +longer be read, and every stored secret has to be entered again. + +**`BOT_INTERNAL_KEY`** authenticates the internal channel between the site and the Discord +bot. The server also refuses to start in production if it is blank, left at a placeholder, +or shorter than 16 characters — even when, as here, you are not running the bot yet. + + + +## What this quickstart leaves out + +The project's shipped Compose file has two more services. Neither is needed to boot, and +each is introduced where it is configured: + +
    + {Object.entries(omittedServices).map(([name, why]) => ( +
  • {name} — {why}
  • + ))} +
+ +It also leaves out the branding, logging and session variables, which have working +defaults and their own admin screens. The full file and the full environment reference are +in the [website repository](https://gitea.whitlocktech.com/RunicGateway/website). + +## Behind a reverse proxy + +Not required to get started, and required before anyone else uses the site. Two settings +here are what make it correct: + +- **`TRUST_PROXY=1`** tells the app to read the client's address from `X-Forwarded-For`. + Rate limiting, login backoff and the bot-scoring IP bans are all only as accurate as + that. Set it to the number of proxies in front of the app, or pin it to the proxy's + address; a blanket `true` is rejected on purpose, because it would let anyone spoof + their address by sending a header. +- **`COOKIE_SECURE=auto`** issues a `Secure` session cookie when the request arrives over + HTTPS and a plain one otherwise, so logging in works both through the proxy and directly + on the LAN while you are setting up. + +Point the proxy at port 3000. Do not forward `INTERNAL_PORT` (3001) — it is the +server-to-bot channel, it is deliberately not published by the Compose file, and it must +never be reachable from outside. diff --git a/src/content/docs/docs/getting-started/requirements.mdx b/src/content/docs/docs/getting-started/requirements.mdx new file mode 100644 index 0000000..233510f --- /dev/null +++ b/src/content/docs/docs/getting-started/requirements.mdx @@ -0,0 +1,60 @@ +--- +title: Requirements +description: What you need on the website host, and what you need on the game server host, before you begin. +--- + +import platform from '../../../../data/platform.json'; +import { Aside } from '@astrojs/starlight/components'; + +Two hosts, two lists. They can be the same machine, but they are separate deployments and +have nothing in common except the four values you will paste between them. + +## The website host + +| Requirement | Detail | +|---|---| +| **Docker** with Compose v2 | `docker compose version` should print v2.x. The site ships as prebuilt images and pulls them; nothing is built on your host. | +| **Outbound HTTPS** to `gitea.whitlocktech.com` | To pull the images, and later to install a module. Nothing inbound is required for the install itself. | +| **~2 GB of disk to start** | Two images, a MariaDB volume, and an uploads volume. Uploads grow with what your community posts. | +| **A hostname and TLS, eventually** | Not needed to boot — you can reach it on `http://localhost:3000` first. Needed before anyone else uses it: see [Maintenance and upgrades](/docs/administration/maintenance-and-upgrades/) for the reverse-proxy notes. | + +There is no separate database to install: MariaDB comes up as a container beside the app, +and the schema is created on first boot. + + + +## The game server host + +Only if you are connecting a game server. Today that means a ServUO shard, which is what +the [`uo` module](/docs/getting-started/install-a-game-module/) and the installer support. + +| Requirement | Detail | +|---|---| +| **A working ServUO install** | It must currently boot and compile scripts cleanly. The installer deploys onto a healthy shard; it does not repair a broken one. | +| **ServUO {platform.bundle.servuoMin}** *(patch tier only)* | The base install works on any reasonably current ServUO. The optional patch tier is written and tested against stock {platform.bundle.servuoMin}; on any other version it is unsupported, and skipping it still leaves you with a working bridge. | +| **The shard stopped** | `ServUO.exe` locks `Scripts.dll` and rewrites `Saves/` on exit. The installer refuses to deploy under a running shard. | +| **Administrator / root** | It writes into system directories and registers a service. | +| **Outbound HTTPS** | To fetch the bundle and its two artifacts. No Gitea account and no git client are needed. | +| **The sidecar on the same host as the shard** | The shard connects to `127.0.0.1:7788`. Splitting them is not supported — that loopback socket *is* the trust boundary for inbound commands. | + + + +## What you do not need + +- **A Gitea account.** Everything the installers fetch is a public release asset. +- **A build toolchain.** Not on either host. The site pulls images; the module arrives as a + verified tarball; the shard plugin is C# source that ServUO itself compiles at boot. +- **An inbound port on the game host** — for the *game*. The shard never listens for the + website. If the website runs on a different machine from the shard, the **sidecar** needs + to be reachable by the website, and that is the one hole you will open deliberately. + +Next: [Install the site](/docs/getting-started/install-the-site/). diff --git a/src/content/docs/docs/getting-started/verify-the-whole-stack.mdx b/src/content/docs/docs/getting-started/verify-the-whole-stack.mdx new file mode 100644 index 0000000..e7fd204 --- /dev/null +++ b/src/content/docs/docs/getting-started/verify-the-whole-stack.mdx @@ -0,0 +1,103 @@ +--- +title: Verify the whole stack +description: Four checks, one per link in the chain, that distinguish "files copied" from "the bridge works". +--- + +import { Aside, Steps } from '@astrojs/starlight/components'; + +A successful install is not a working bridge, and the failure is quiet in a specific way: +**ServUO shells out to `dotnet build`, prints the output, ignores the exit code, and +reloads the existing `Scripts.dll`.** A broken script build looks exactly like a clean boot. + +So verify each link in the chain, in order. Each check tells you which one to fix. + + + +1. **The plugin compiled — watch the boot output.** + + Start your shard the way you always do. You want the build to succeed *and* the bridge + to announce itself: + + ``` + Core: Compiling scripts... + Build succeeded. + [Bridge] enabled=True endpoint=127.0.0.1:7788 queueCap=10000 … + ``` + + If you scrolled past it, force the question: + + ```bash + dotnet build Scripts/Scripts.csproj -c Release -p:Platform=x64 # must be 0 errors + ``` + +2. **The shard is connected — ask it in game.** + + As an administrator: + + ``` + [bridge status + ``` + + It reports `connected=True depth=0 sent=… dropped=0`. `connected=False` means the shard + cannot reach the sidecar. `dropped` climbing means the sidecar is wedged and the shard + is shedding events rather than stalling — which is what it is designed to do, and why a + broken bridge never freezes your game. + + `[bridge reload` re-reads `Bridge.cfg` without a restart; `[bridge sweepnow` forces one + pass of every stream. + +3. **The sidecar is healthy — ask it over HTTP.** + + `/health` needs no auth, so it is safe to curl on the shard host: + + ```bash + curl -s http://127.0.0.1:8080/health + ``` + + ```json + {"status":"ok","protocol":4,"plugin_connected":true,"database":"ok","uptime":"2m"} + ``` + + **`plugin_connected: true` is the one that matters.** It is the only value in this whole + sequence that distinguishes "files copied" from "the bridge works". + +4. **The website is ingesting — look at the shard screen.** + + On the site, open **Shard (uo-link)** (`/admin/uo/link`). The header should read + **Connected**, with *Shard link: up* and *WS ingest: online*, and the live feed at the + bottom should start showing events within seconds rather than sitting on + *Waiting for shard events…*. + + Then check the public side: the shard status page should stop reporting the game as + offline. + + + +## When one of them fails + +| What you see | What it means | +|---|---| +| Shard boots clean, nothing reaches the site | The classic silent failure — a stale `Scripts.dll`. Run the `dotnet build` line above and read the errors. | +| `[bridge` is not a command | The plugin did not compile, or the bridge is disabled in `Bridge.cfg`. | +| `connected=False` | The sidecar is not listening on `127.0.0.1:7788`. Check the service, and that `[shard] bind` matches `Host`/`Port` in `Bridge.cfg`. | +| `/health` is fine locally, the site says offline | The website cannot reach port 8080 — bind address or firewall. The site is *designed* to render normally with the shard down, so this fails quietly. | +| The site logs `409` from the sidecar | Protocol mismatch. Set the Protocol field to what `/health` reports rather than guessing; the sidecar rejects rather than mis-parsing. | +| `401` from the sidecar | Wrong or missing token. Read the live one back with `uo-link-sidecar --print-config`; do not retype it from a screenshot. | + + + +## What "working" looks like a week later + +- The public shard page shows live status, and the admin dashboard shows events arriving. +- `dropped` in `[bridge status` stays at zero. A climbing number means the sidecar is + wedged, not that the shard is unhealthy. +- `doctor` is still green after a shard update — that is what catches an overlay file + reverted by hand or by a ServUO upgrade. + +You have finished the installation path. From here, +[Administration](/docs/administration/configuration/) covers running the site day to day. diff --git a/src/content/docs/docs/index.mdx b/src/content/docs/docs/index.mdx index 0e7ab5d..f44712d 100644 --- a/src/content/docs/docs/index.mdx +++ b/src/content/docs/docs/index.mdx @@ -13,13 +13,6 @@ network-facing component, and only the website's backend is allowed to talk to i website degrades gracefully when the game is down, and sensitive events never reach the public event stream. -:::note[This documentation is being written in phases] -The scaffold, theme and sidebar are in place. The pages themselves land in phases 7 and 8, -starting with the installation path — which is the priority of the whole project, because -the repositories treat the site and the shard as separate deployments and nothing today -presents them as one sequence. -::: - ## What the platform is on today @@ -46,10 +39,30 @@ two independent deployments. 2. **The shard side** is the installer binary, run on the game server's host. It sets up the plugin overlay and the sidecar, and it never contacts the website. -They meet at four values pasted into **Admin → Shard**, and at protocol {platform.protocol}, -which both sides check before they will pair. +They meet at four values pasted into the module's shard screen, and at protocol +{platform.protocol}, which both sides check before they will pair. -## Where to go next +You can stop after the first one. A site with no game server attached is a complete +community website — news, wiki, pages, Teams, forums, accounts and moderation are all core, +and none of them knows a game exists. The second install is what fills the game screens. + +## Start here + +The seven pages of **Getting started** are that sequence, in order, and each one says what +you should expect to see before you move on: + +1. [Requirements](/docs/getting-started/requirements/) — what you need on both hosts +2. [Install the site](/docs/getting-started/install-the-site/) — Docker Compose, pull-only +3. [First run](/docs/getting-started/first-run/) — the first admin, and maintenance → live +4. [Install a game module](/docs/getting-started/install-a-game-module/) — what makes the game screens exist +5. [Connect a game server](/docs/getting-started/connect-a-game-server/) — the installer, on the shard host +6. [Verify the whole stack](/docs/getting-started/verify-the-whole-stack/) — proving it works, rather than assuming + +Then **Administration** covers running it: configuration, branding, content, users, +authentication, Teams, moderation, notifications, modules, the shard connection, upgrades, +and what to do when something is wrong. + +## Where the truth lives The canonical, normative documents live in the [`docs` repository](https://gitea.whitlocktech.com/RunicGateway/docs) and always win over diff --git a/src/data/quickstart.mjs b/src/data/quickstart.mjs new file mode 100644 index 0000000..18d683c --- /dev/null +++ b/src/data/quickstart.mjs @@ -0,0 +1,160 @@ +/** + * quickstart.mjs — the self-contained site deployment (D35, PLAN.md §10). + * + * The org lead chose a quickstart an operator can copy without leaving the page: the + * Compose file and the environment file below are complete enough to boot a site, and + * `/docs/getting-started/install-the-site/` renders them verbatim. + * + * That decision creates the artifact §1 spends its whole length warning about — a second + * copy of somebody else's file, free to drift. `scripts/checkQuickstart.mjs` is the price + * of it: every service, image, port, mount and variable below is re-read from `website`'s + * own `docker-compose.yml` and `.env.example` on `main`, over the Gitea API, and any + * disagreement fails the build. Same mechanism and same intent as `checkFacts.mjs`. + * + * WHAT THIS FILE IS NOT. It is not a smaller compose file that the project supports as an + * alternative. It is the shipped one with the parts an operator does not need on day one + * left out, and the page says so: `bot` and `ntfy` are real services, documented where + * they are configured, and the reader is pointed at the full file for them. + */ + +/** + * Services the quickstart ships, and — for the check — what each one must still agree with + * upstream about. `omitted` records the services deliberately left out, because a NEW + * service appearing upstream should make someone decide, rather than pass silently. + */ +export const services = ['db', 'app']; +export const omittedServices = { + ntfy: 'Push notifications for the Android app. Nothing needs it to boot, and it wants a public URL a first install does not have yet.', + bot: 'The Discord bot. It is configured from the admin panel once the site is up, so it is introduced on the integrations page rather than here.', +}; + +/** + * The Compose file, exactly as the page prints it. + * + * Three differences from upstream's, all deliberate and all asserted by the check: + * - `bot` and `ntfy` are absent (above). + * - `db` does not bind-mount `./server/db/schema.sql`. That mount is a checkout-relative + * path, and this quickstart has no checkout; the server ensures its own schema on boot, + * which is what actually creates the tables in every deployment. + * - the `MODULES` comment block is reduced to one line pointing at the module page. + */ +export const compose = `services: + db: + image: mariadb:11 + restart: unless-stopped + environment: + MARIADB_DATABASE: \${DB_NAME} + MARIADB_USER: \${DB_USER} + MARIADB_PASSWORD: \${DB_PASSWORD} + MARIADB_ROOT_PASSWORD: \${DB_ROOT_PASSWORD} + volumes: + - dbdata:/var/lib/mysql + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 10s + timeout: 5s + retries: 10 + + app: + image: gitea.whitlocktech.com/runicgateway/website-app:\${IMAGE_TAG:-latest} + restart: unless-stopped + env_file: .env + environment: + DB_HOST: db + UPLOAD_DIR: /app/uploads + LOG_DIR: /app/logs + MODULES_DIR: /app/modules + depends_on: + db: + condition: service_healthy + volumes: + - uploads:/app/uploads + - ./logs:/app/logs + - ./brand:/app/brand:ro + - ./modules:/app/modules + ports: + - "3000:3000" + +volumes: + dbdata: + uploads: +`; + +/** + * The environment file, as the page prints it. `fill` marks the lines an operator must + * change before this is a real deployment — the page highlights exactly these. + */ +export const env = [ + { key: 'IMAGE_TAG', value: 'latest' }, + + { key: 'NODE_ENV', value: 'production' }, + { key: 'PORT', value: '3000' }, + { key: 'INTERNAL_PORT', value: '3001' }, + + { key: 'DB_HOST', value: 'db' }, + { key: 'DB_PORT', value: '3306' }, + { key: 'DB_NAME', value: 'runic_gateway' }, + { key: 'DB_USER', value: 'runic' }, + { key: 'DB_PASSWORD', value: 'change-me-db-password', fill: true }, + { key: 'DB_ROOT_PASSWORD', value: 'change-me-root-password', fill: true }, + + { key: 'JWT_SECRET', value: 'change-me-to-a-long-random-string', fill: true }, + { key: 'SECRET_ENC_KEY', value: 'change-me-to-another-long-random-string', fill: true }, + { key: 'COOKIE_SECURE', value: 'auto' }, + { key: 'TRUST_PROXY', value: '1' }, + + { key: 'ADMIN_USERNAME', value: 'admin', fill: true }, + { key: 'ADMIN_PASSWORD', value: 'change-me-before-first-boot', fill: true }, + + { key: 'BOT_INTERNAL_KEY', value: 'change-me-to-a-third-long-random-string', fill: true }, +]; + +/** + * `SECRET_ENC_KEY` is in this quickstart and NOT in upstream's `.env.example`, which is why + * it needs a declaration rather than passing quietly. + * + * Found by booting this exact file against the published image (phase 7): the server calls + * `resolveKey()` in `utils/secretBox.js` at require time and throws + * `SECRET_ENC_KEY must be set in production`, so the container crash-loops before it ever + * listens. It is documented in `server/.env.example` — the file local development copies — + * and missing from the root `.env.example` that Compose actually reads. + * + * The check treats the omission as upstream's bug, not as licence: it fails the moment the + * variable appears in `.env.example`, so this note cannot outlive the defect it describes. + */ +export const notInUpstreamEnvExample = { + SECRET_ENC_KEY: + "the app refuses to start in production without it (utils/secretBox.js), but website's root .env.example does not list it", +}; + +/** + * Variables upstream's `.env.example` carries that the quickstart leaves out, each with the + * reason. The check requires this list plus the keys above to account for EVERY key in + * `.env.example`: when website adds a variable, this repo goes red and someone decides + * whether a first install needs it. That failure is the feature. + */ +export const envOmitted = { + UPLOAD_DIR: 'set in the Compose file, where the volume that makes it meaningful is', + LOG_LEVEL: 'logging defaults are fine until there is something to debug', + FILE_LOG_LEVEL: 'as above', + LOG_TO_FILE: 'as above', + LOG_DIR: 'set in the Compose file, beside its bind mount', + LOG_FILE: 'as above', + BRAND_NAME: 'branding is its own admin screen and its own page', + BRAND_SHORT_NAME: 'as above', + BRAND_TAGLINE: 'as above', + BRAND_DESCRIPTION: 'as above', + BRAND_CONTACT_EMAIL: 'as above', + BRAND_URL: 'as above', + BRAND_ACCENT_COLOR: 'as above', + BRAND_LOGO: 'as above', + BRAND_HERO: 'as above', + BRAND_FAVICON: 'as above', + JWT_EXPIRES_IN: 'the default session length is a decision for later, not for boot', + COOKIE_NAME: 'changing it logs everyone out; not a first-install decision', + DEBUG_TRUST_PROXY: 'a diagnostic, and a noisy one', + TOTP_CHALLENGE_TTL: 'the default is right', + CLIENT_ORIGIN: 'only needed when the client is served from a different origin, which a Compose deployment does not do', + BOT_INTERNAL_URL: 'points at the bot service, which this quickstart does not run', + NTFY_BASE_URL: 'push notifications need the ntfy service, which this quickstart does not run', +};