docs(journey): phase 7 — the installation path and administration
All checks were successful
PR checks / checks (pull_request) Successful in 9m25s

Twenty documentation pages: Getting started (7) and Administration (13), the
journey no existing document owns end to end because the repositories are
organised by component and an operator is not.

Four decisions of record, taken before anything was written (D34–D37, PLAN.md
§10 "How phase 7 built the documentation journey"):

- D34 one PR for all twenty pages.
- D35 the install page is SELF-CONTAINED: it prints a complete Compose file and
  a complete .env that an operator copies without visiting another repository.
  That is a copy of somebody else's file, so it is checked rather than trusted —
  scripts/checkQuickstart.mjs re-reads website main:docker-compose.yml and
  main:.env.example over the Gitea API and fails on any disagreement, in both
  directions: 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 first run found two stale entries.
- D36 every Administration screen was walked on a real deployment before it was
  described — the rig being the quickstart itself, against the published image,
  so one run proved the install page and produced the detail the admin pages
  needed.
- D37 a thirteenth Administration page, Content, so that every admin nav row has
  a home without organising the docs by the app's menu.

What the live deployment disproved, all three now documented:

- The documented Compose deploy does not boot. SECRET_ENC_KEY is required in
  production (utils/secretBox.js throws at require time) and is missing from
  website's ROOT .env.example — the file Compose reads. It is present in
  server/.env.example, which is why dev never hits it. 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
  <site>/admin/shard, and INSTALL.md §5 repeats it, but since the module cutover
  the screen is /admin/uo/link. Both the binary and the guide are stale.
- The admin Restart button opens a window.confirm whose text is the honest
  warning that a deployment with no supervisor does not come back — which is why
  `restart: unless-stopped` is called out as load-bearing rather than left as
  boilerplate.

And the defect only a look found, three phases running: the .env block's prose
promised that every highlighted line must be changed, while `mark` given the
variable names highlighted the names alone and left the values unmarked. Every
check passed on a page that was wrong about its own highlighting.

verify green: 890 internal links, 52 branch links, 19 facts, 59 quickstart
checks, 0 astro-check errors.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-24 08:35:03 -05:00
parent 971fa9c032
commit f499f2b72b
27 changed files with 2260 additions and 19 deletions

View File

@@ -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' },
],
},
];

View File

@@ -0,0 +1,82 @@
---
title: Authentication
description: Local accounts and two-factor, SSO providers and the link-only policy, and the layer that keeps automated traffic out.
---
import { Aside } from '@astrojs/starlight/components';
**Admin → Authentication** has four tabs: Local Accounts, Google, Discord and Custom
Providers. One session model sits behind all of them — a web cookie, a mobile bearer token
and an SSO sign-in all produce the same session.
## Local accounts
Username and password sign-in is **always enabled and cannot be turned off**. It is how you
manage accounts and how SSO identities get linked in the first place, so there is no
configuration on this tab beyond that statement.
**Two-factor** is a per-account, opt-in TOTP code, set up by each person under **Account**
in the sidebar. Nobody can enable it on someone else's behalf, and staff accounts are the
ones worth insisting on.
## SSO providers
Google and Discord each need a client ID and secret from that provider's developer console;
Custom Providers takes any OAuth2/OIDC issuer. Secrets are encrypted at rest with
`SECRET_ENC_KEY` and are never returned to any client.
<Aside type="caution" title="SSO is link-only, by policy">
An external identity can only sign in to an account it is **already linked to**. Signing in
with Google does not create an account, ever. People link a provider themselves from their
own account screen, and that link is what grants the access — so a stranger with a Google
account is still a stranger.
</Aside>
Configuring Google here also unlocks **email delivery**, which reuses the same OAuth client
— see [Notifications and email](/docs/administration/notifications-and-email/).
## Trusted devices
A second factor that asks on every sign-in on the same laptop trains people to click
through it. A device can be remembered after a successful two-factor challenge, and the
trust rides the browser's own cookie jar — including the in-app browser tab the Android app
opens for SSO, which is why signing in there does not ask again.
Trust is per device and revocable, and it survives signing out: signing out ends a session,
not the statement that this machine is yours.
## What keeps the automated traffic out
Four layers, all on by default:
- **Rate limiting and backoff** on the login routes, so a password guess costs time.
- **A honeypot field** that a human never fills in and a naive bot always does.
- **Bot scoring**, which accumulates points against an address for behaviour no human
produces, and bans it automatically past a threshold.
- **IP bans** from that scoring.
**Admin → Web Bot Activity** shows the live state: currently banned addresses with their
score and expiry, and the recent events with the reason, path and points that produced
them. It is deliberately **read-only apart from an emergency unban** — there is nothing to
tune here, and the panel exists so that a legitimate user locked out by their office's
shared address can be let back in.
<Aside type="note" title="The scoring state is in memory, and resets when the server restarts">
So a restart clears every automatic ban. That is a reasonable escape hatch when you have
locked yourself out, and a reason not to treat this list as a permanent record.
</Aside>
## Getting locked out
Two situations worth knowing before they happen at three in the morning:
- **Your address is banned.** Restart the app container — the in-memory state goes with it.
- **You lost your second factor.** Use one of the recovery codes issued when you enabled
it. If those are gone too, another administrator opens **Users → View** on your account
and presses **Reset two-factor**, which turns TOTP off, revokes your trusted devices and
clears your recovery codes so a password sign-in works again. That is the practical
argument for a site never having exactly one admin.
The same screen lists an account's trusted devices and revokes them individually or all at
once — the right response to a lost or stolen laptop, and something to reach for before
resetting the whole second factor.

View File

@@ -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.
<Aside type="note" title="The “powered by Runic Gateway” mark in the footer is not yours to theme">
It is the project's badge rather than your instance's, and it does not change with the
theme.
</Aside>
## The text that comes from the environment
A few identity values are read before the database is available — the server templates them
into `index.html` at boot so that link previews and the tab title are right on the very
first request:
`BRAND_NAME`, `BRAND_SHORT_NAME`, `BRAND_TAGLINE`, `BRAND_DESCRIPTION`,
`BRAND_ACCENT_COLOR`, `BRAND_URL`, `BRAND_CONTACT_EMAIL`.
Where an admin-editable setting exists for the same thing — site title, contact email — the
**setting wins**. The variable is the value a fresh deployment starts from.
## The portal hero
**Admin → Hero Editor** composes the front page's hero directly: drag elements to place
them, drag the corner handle to resize (text scales with the box), Delete removes the
selected one. The palette adds text, buttons, the moon, a badge or an image.
Its own background image and overlay darkness are set at the bottom of the editor, and a
background set here **wins over** the Appearance screen's hero image.
Work is not live until you press **Publish**; **Preview** opens it in a new tab, and
**Revert to live** throws away an unpublished draft. Until anything is published at all,
the portal renders the shipped hero with the homepage teaser from
[Settings](/docs/administration/configuration/) underneath it.
<Aside type="caution" title="Check a hero on a phone before publishing it">
The editor is a canvas, and a layout that reads well at desktop width can put text over a
face or off the edge on a narrow screen. Preview it there.
</Aside>

View File

@@ -0,0 +1,99 @@
---
title: Configuration
description: What is set in the environment file, what is set in the admin panel, and why the split is where it is.
---
import { Aside } from '@astrojs/starlight/components';
Two places hold configuration, and the line between them is not arbitrary.
| | Environment (`.env`) | Admin panel |
|---|---|---|
| **What** | How the process runs: ports, database, secrets, proxy trust, log level | How the site behaves: titles, registration, forums, integrations |
| **Changing it** | Edit the file, `docker compose up -d` | Save the form; effective immediately |
| **Who** | Whoever has the host | Whoever has an admin account |
| **Where it lives** | A file on the host | The database |
The rule behind the split: **anything that needs a restart or a shell is environment;
anything an administrator should be able to change without either is in the panel.** That
is why the Discord bot token, the OAuth client secrets and the shard's auth token are *not*
environment variables — they are entered in the panel and stored encrypted.
## Settings
**Admin → Settings**, the screen most of a new deployment's decisions live on.
| Field | What it does |
|---|---|
| **Site title** | Overrides `BRAND_NAME` in the page title, the header and link previews. |
| **Homepage teaser** | Rich text under the hero heading, when no custom hero layout is published. |
| **Maintenance message** | What visitors see while the site is in maintenance mode. |
| **Status message** | A short line for announcements — a maintenance window, an outage. |
| **Contact email** | Where the contact form delivers, and the address it falls back to as a `mailto:` link while email is unconfigured. |
| **Player registration** | Disabled, password, SSO, or both. **Off by default.** |
### Player registration is off until you turn it on
A new site accepts no self-registration at all. The three ways to let people in:
- **Password** — a normal sign-up form.
- **SSO** — sign-up through a linked provider, which needs a provider configured first.
- **Invites** — leave registration off entirely and issue invitations from
**Admin → Invites**. See [Users and roles](/docs/administration/users-and-roles/).
## Team forums
The same screen carries the forum switches, because they are site-wide policy rather than
per-Team settings:
- **Enable team forums** — off by default. Switching them off hides them completely (every
forum route answers *not found*) but **deletes nothing**: threads, posts, access grants
and notification preferences all survive and come back exactly as they were.
- **Images in forum posts** — disabled, remote URLs only, or uploads to your server.
Enabling uploads means content stored on infrastructure you are responsible for, and the
screen says so at some length before you can agree to it.
- **Post edit window** — how long an author may edit their own post. Staff are not bound by
it. Zero makes posts permanent once written; some bound is what stops a post being
rewritten out from under someone quoting it.
## Email
Configured on the same screen and covered in
[Notifications and email](/docs/administration/notifications-and-email/): it is Gmail over
OAuth2, it reuses the Google authentication client, and it must be set up on the
[Authentication](/docs/administration/authentication/) page first.
<Aside type="note" title="Until email is connected, the contact form is a mailto: link">
That is a deliberate fallback rather than a failure — but it does mean the *Contact email*
setting is doing real work on a site that has never configured delivery, and an unset one
leaves a contact form that goes nowhere.
</Aside>
## The environment file, in three groups
You wrote these in [Install the site](/docs/getting-started/install-the-site/); this is
what they mean when you come back to them.
**Identity and process** — `NODE_ENV`, `PORT`, `INTERNAL_PORT`, `IMAGE_TAG`. `INTERNAL_PORT`
is the server-to-bot channel and must never be published or proxied.
**Data and secrets** — the `DB_*` group, `JWT_SECRET`, `SECRET_ENC_KEY`, `BOT_INTERNAL_KEY`.
The last two are required in production, and `SECRET_ENC_KEY` is the key everything else
encrypted at rest is keyed by: change it and the stored secrets become unreadable.
**Behaviour at the edge** — `TRUST_PROXY`, `COOKIE_SECURE`, `COOKIE_NAME`,
`JWT_EXPIRES_IN`. `COOKIE_NAME` is worth one warning: changing it on a live site logs
everybody out.
<Aside type="caution" title="`MODULE_SOURCE_HOSTS` is bootstrap only">
It seeds the module install allowlist the first time a site boots without one. After that
the **setting** is authoritative and is edited in Admin → Modules — changing the variable on
an existing deployment does nothing, deliberately, so a redeploy cannot silently undo an
administrator's choice.
</Aside>
## Branding is data, not configuration
The `BRAND_*` variables and the `/brand` mount are how one prebuilt image runs as any
community's site. They get their own page:
[Branding and theming](/docs/administration/branding-and-theming/).

View File

@@ -0,0 +1,62 @@
---
title: Content
description: Posts and their categories, the wiki and its sections, and the activity log that records who changed what.
---
import { Aside } from '@astrojs/starlight/components';
Three content surfaces, one for each shape of writing a community does.
| Surface | For | Lives at |
|---|---|---|
| **Posts** | Dated writing: news, the newsletter, screenshots | `/site/news` and friends |
| **Pages** | Standing pages: About, Rules, Donate — see [Navigation and pages](/docs/administration/navigation-and-pages/) | its own slug |
| **Wiki** | Reference the community maintains: guides, lore, systems | `/wiki` |
## Posts
**Admin → Posts**, filtered by category. A new deployment seeds four:
- **News** — the default, and the one wired to announcements.
- **Five on Friday** — a recurring short-form format.
- **Newsletter** — longer, periodic.
- **Screenshots** — image posts.
Each post is a draft until it is published, and the Posts list shows status and date at a
glance.
<Aside type="caution" title="Publishing a news post announces it">
Publishing is what triggers the announcement pipeline — the Discord `#news` leg, and any leg
an installed module adds, such as the `uo` module's in-game town crier. It fires on
publication, so an accidental publish is an accidental announcement. See
[Notifications and email](/docs/administration/notifications-and-email/).
</Aside>
## The wiki
**Admin → Wiki** lists every page with its section and status, and **Manage sections**
edits the grouping itself. A new site starts with eight pages in four sections — Guides,
World & Lore, Systems & Gameplay, Community & Rules — as a skeleton to write into.
They are placeholders. None of them describes your game, and leaving them published means
publishing an empty guide to it; either write them or unpublish them before you go live.
## Who may write what
Roles decide it, and the split is the useful part:
- **Editor** — the content roles. Posts, pages, wiki, and the activity log.
- **Moderator** — moderation and Teams, not content authoring.
- **Admin** — everything, including the system screens.
Full table in [Users and roles](/docs/administration/users-and-roles/).
## The activity log
**Admin → Activity** records what staff did: the action, a detail line, who did it, from
which address, and when. Module installs, logins, content changes and moderation all land
here.
Two things it is good for beyond curiosity: reconstructing what changed just before
something broke, and confirming that an account which should not have done something did
not. It is a record, not a workflow — nothing is actioned from this screen.

View File

@@ -0,0 +1,125 @@
---
title: Maintenance and upgrades
description: Upgrading the image, pinning a build, what to back up and how, where the logs are, and the reverse proxy.
---
import { Aside, Steps } from '@astrojs/starlight/components';
## Upgrading the site
```bash
docker compose pull
docker compose up -d
```
That is the whole routine. The image carries the server and the built client together;
schema changes are applied on boot, and installed modules are on a volume the upgrade does
not touch.
**Pin a build when you want a deploy you can reproduce.** `IMAGE_TAG` defaults to `latest`;
every merge also publishes `sha-<7>`, so
```bash
IMAGE_TAG=sha-042a151 docker compose pull && docker compose up -d
```
deploys an exact build, and putting that value in `.env` makes it the one this host runs
until you change it. Rolling back is the same command with the previous tag — with one
caveat that decides whether it works.
<Aside type="caution" title="A rollback is only safe if the schema did not move">
Upgrades apply schema changes on boot; nothing un-applies them. Rolling the image back to a
build that predates a schema change leaves the older code looking at a newer database.
Restore the backup you took first, or stay forward.
</Aside>
## Back up before you upgrade
Two volumes and one directory hold everything that cannot be re-downloaded: the database,
the uploads, and `./modules`.
<Steps>
1. **Dump the database.** From the deployment directory, while the stack is up:
```bash
docker compose exec -T db sh -c \
'mariadb-dump -u root -p"$MARIADB_ROOT_PASSWORD" --single-transaction --routines runic_gateway' \
> backup-$(date +%F).sql
```
`--single-transaction` is what makes it consistent without locking the site.
2. **Copy the uploads volume.**
```bash
docker run --rm -v <deployment>_uploads:/from -v "$PWD":/to alpine \
tar czf /to/uploads-$(date +%F).tgz -C /from .
```
The volume is named after the directory Compose runs in — `docker volume ls` shows the
exact names.
3. **Keep `./modules`, `./brand` and your two files.** They are ordinary host directories;
whatever backs up the rest of the host covers them.
</Steps>
Restoring the database is the same command inverted — `mariadb … < backup.sql` — into a
stack whose image is the one the dump came from.
## Logs
`./logs/app.log` on the host, because the Compose file bind-mounts it there. `docker compose
logs -f app` shows the same stream live.
`LOG_LEVEL` sets console verbosity and `FILE_LOG_LEVEL` the file's — the file keeps the
fuller record on purpose. Nothing rotates them for you.
## Restarting
`docker compose restart app` is the ordinary restart, and it is what the admin panel's
**Restart the server** button amounts to. Restarts are needed after installing, enabling or
uninstalling a module, and are harmless otherwise.
`docker compose down` stops everything and keeps the data. **`docker compose down -v` also
deletes the volumes** — the database and every upload. There is no undo.
## The reverse proxy
The app publishes port 3000 and binds all interfaces, so any proxy that can reach the host
can serve it. Two settings make it correct rather than merely working, both covered in
[Install the site](/docs/getting-started/install-the-site/): `TRUST_PROXY`, so the address
your rate limiting and IP bans act on is the visitor's rather than the proxy's, and
`COOKIE_SECURE=auto`.
Three rules for whatever proxy you use:
- **Forward only 3000.** `INTERNAL_PORT` (3001) is the server-to-bot channel and must never
be reachable from outside; the Compose file deliberately does not publish it.
- **Deny `/api/v1/internal` at the proxy** as well. Belt and braces: that route no longer
rides the public listener, and an explicit deny costs nothing.
- **Terminate TLS at the proxy.** The app speaks HTTP; it is not meant to hold a
certificate.
## Upgrading the shard side
A different deployment on a different host, and it moves on its own schedule:
```bash
sudo runicgateway update # re-resolves the bundle; --verify to see it first
sudo runicgateway doctor # confirm afterwards
```
`update` replaces the sidecar and restarts its service, re-syncs the overlay, and tells you
when ServUO needs restarting — it never restarts your shard itself. Because it resolves a
**bundle**, the sidecar and the plugin move together and cannot end up disagreeing about the
protocol.
<Aside type="note" title="Update the two sides in either order, but verify after each">
They are independent deployments joined by a version-checked contract: a mismatch is
rejected with a `409` rather than mis-parsed. So the worst case is a bridge that refuses to
pair until both sides are current — visible on
[the shard connection screen](/docs/administration/the-shard-connection/), and not silent
corruption.
</Aside>

View File

@@ -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.
<Aside type="caution" title="Check the Module API version before upgrading">
A module declares which core API versions it accepts. If a module release requires a newer
core than your image, upgrade the site first — see
[Maintenance and upgrades](/docs/administration/maintenance-and-upgrades/).
</Aside>
## Disable, uninstall, purge
Three different actions, in increasing order of destruction.
**Disable** flips the row and dispatches that module's shutdown hook, so it actually stops
— releases its sockets, closes its streams — rather than merely becoming unreachable. Enable
is deliberately not the mirror image: there is no boot hook re-dispatch, so enabling offers
a restart.
**Uninstall** is non-destructive by default: the row goes to `disabled`, the directory is
removed, and **the module's tables and data are retained**.
**Purge** runs the module's own `purge.sql` and destroys its data. It is never implied by
an uninstall, and it is offered in two places — as a standalone action on an installed
module, and as an opt-in checkbox in the uninstall dialog.
<Aside type="caution" title="Purge only works while the files are still there">
`purge.sql` lives inside the directory an uninstall deletes. Uninstalling without ticking
the box keeps the tables, and getting rid of them later means **reinstalling the module
first**. Decide at the uninstall, not afterwards.
</Aside>
## Where modules may be installed from
The allowlist at the bottom of the screen. Installing a module runs its code inside your
server, so only listed hosts are permitted, over HTTPS, re-checked on every redirect. An
empty list forbids every install.
`MODULE_SOURCE_HOSTS` seeds this list on a site's first boot and is ignored afterwards —
the setting is authoritative, so a redeploy cannot silently undo your choice.
## The declarative path
`MODULES` in `.env` declares the set this deployment runs, resolved at every container
start, each entry `<id>@<version>=<install manifest URL>`.
The division of ownership is the thing to remember: **the variable owns what is on the
volume; the panel owns whether a module runs.** Uninstall a declared module from the panel
and its files come back at the next start — disabled.
A module already unpacked at the declared version is a no-op that makes **no network call
at all**, so a restart with no route to the internet comes up unchanged. A version that
cannot be fetched is logged, shown on this screen, and never stops the site starting.
## Placing one by hand
Unpacking a module tarball into `./modules/<id>/` and restarting is a supported install —
it is why that path is a bind mount rather than a named volume. The row it produces has no
provenance columns, because nothing downloaded it.
<Aside type="note" title="Do not delete the `modules` directory itself">
Docker recreates a missing bind-mount source as `root`, and the container user can then no
longer write it — which breaks installing from the panel. If that happens,
`chown 1000:1000 modules` on the host.
</Aside>

View File

@@ -0,0 +1,68 @@
---
title: Moderation
description: Three screens that do three different jobs — Discord moderation, content reports, and appeals against a sanction.
---
import { Aside } from '@astrojs/starlight/components';
The **Moderation** group in the sidebar holds three screens that are easy to confuse and do
not overlap.
| Screen | Is about | Comes from |
|---|---|---|
| **Moderation** | Your **Discord** guild — bans, kicks, mutes, warnings, joins, leaves, filter and spam hits | the bot, captured live |
| **Reports** | **Team forum content** members have reported | the site |
| **Appeals** | Sanctions people are asking you to reverse | the site |
## Moderation (Discord)
Counts across a window you choose — 24 hours, 7 days, 30 days — for bans, kicks, mutes,
warnings, joins, leaves, filter hits and spam hits, with a filterable list of recent
actions and a tabbed event log (members, filter hits, spam hits).
Everything here arrives from the Discord bot, so a site with no bot configured shows zeros
and empty lists rather than an error. Setting the bot up is
[Notifications and email](/docs/administration/notifications-and-email/).
**Look up** takes you to a per-user view when you are investigating one account rather than
browsing the window.
## Reports
Reports raised by members about Team forum content. Two design decisions show through in
how this screen behaves:
- **They come to site staff, and a Team's own leaders never see them.** A leader moderates
their own forum, so a report *about a leader* has to reach someone above them.
- **Handling a report records a decision about the report.** It does not touch the content:
hiding or removing a post is done in the forum, or as a sanction against the account.
The filters are *Open*, *Reviewing*, *Actioned*, *Dismissed* and *All*, and the count of
open reports sits at the top so the screen is glanceable.
<Aside type="note" title="Dismissing is a real outcome, not a failure to act">
A report that was not a problem should be dismissed rather than left open — an open queue
that never empties stops being read, and the reporter's next report is the one that
matters.
</Aside>
## Appeals
An appeal is a request to reverse a sanction, filtered by *Open*, *Pending*, *Under
review*, *Approved*, *Denied*, *Withdrawn* or *All*. Each row carries the target, the
action being appealed, the appeal itself, who submitted it, its age and whether a reversal
happened.
Two things worth building a habit around:
- **Age is the column that matters.** An appeal that nobody has looked at for three weeks
is a worse outcome than a denial.
- **The decision is recorded either way.** Approving an appeal records the reversal, so the
history explains itself later without anyone having to remember.
## What is recorded, and where
Every staff action lands in **Admin → Activity** — who did what, from which address, when.
That log is the thing to read when reconstructing a disputed decision, and it is a record
rather than a workflow: nothing is actioned from it. See
[Content](/docs/administration/content/).

View File

@@ -0,0 +1,49 @@
---
title: Navigation and pages
description: Renaming, reordering and hiding navigation entries in three navs, and composing standalone pages from blocks.
---
import { Aside } from '@astrojs/starlight/components';
## Navigation
**Admin → Navigation** edits three separate navigations — **Public site**, **Admin** and
**Player portal** — each with the same tools: rename an entry, reorder it, hide it, group
entries into a dropdown section, or add a link of your own.
A fresh site's public nav is the seeded one: the portal, News, Screenshots, Five on Friday,
Newsletter, the wiki, and About. Until you change anything, the nav "renders exactly as
coded" — there is no stored copy to drift from the code.
Two properties are worth understanding before you rely on this screen.
**It advertises; it does not authorise.** Renaming or hiding an entry changes what is
listed, never what exists or who may reach it. Hiding *Wiki* does not close the wiki. Access
is decided by roles and by a module's visibility settings, and this screen "can never show
anyone a link their role, or the visibility settings of an installed module, would hide".
**You only edit what you can see.** Entries hidden from *you* — by your role, or by a
module's visibility rules — are not listed, and they keep whatever setting they already
had. So an administrator's view of this screen is not necessarily the whole nav, and
editing it cannot damage the parts you cannot see.
<Aside type="note" title="A module's pages appear here like anything else">
An installed module adds its own entries, and they can be renamed, reordered, grouped and
hidden exactly like core's. What you cannot do is *reach past* the module's own visibility
settings — those are set with the module, not here.
</Aside>
**Reset to default** discards your customisation for that nav and goes back to the coded
one. It is per-nav, not global.
## Pages
**Admin → Pages** composes standalone pages from blocks. A published page is live at its
slug — `/about`, `/rules`, `/donate` — and a draft is visible only to staff.
This is the right tool for content that is not news and not a wiki article: the pages a
navigation entry points at. A page you create is not linked from anywhere until you add it
in **Navigation** — deliberately, because the two are separate decisions.
For everything else — news posts, the newsletter, screenshots, the wiki — see
[Content](/docs/administration/content/).

View File

@@ -0,0 +1,91 @@
---
title: Notifications and email
description: Email over Gmail OAuth2, the announcement pipeline and its legs, the Discord bot, and opt-in push to the mobile app.
---
import { Aside } from '@astrojs/starlight/components';
Four separate delivery paths, each optional, each off until you configure it. A site that
configures none of them still works — it just never reaches anyone who is not looking at
it.
## Email
**Admin → Settings → Email delivery.** The site sends contact-form messages (and test
messages) through **Gmail over OAuth2**, delivered to the *Contact email* setting.
It reuses the **Google authentication client**, so the order is fixed: configure Google on
the [Authentication](/docs/administration/authentication/) page first, then press **Connect
Gmail** here. Until then the panel reads *Unconfigured* and says exactly that.
The refresh token it stores is encrypted at rest like every other secret.
<Aside type="note" title="There is no SMTP option">
Gmail over OAuth2 is the only supported delivery path today. Until it is connected, the
contact form falls back to a `mailto:` link to the contact address — which works, and puts
the message in the visitor's own mail client rather than in your logs.
</Aside>
## Announcements
Publishing a **news** post fans it out to every registered delivery leg. The dispatcher is
an in-process poller, tuned by `ANNOUNCE_POLL_MS` (15 seconds by default), and the links in
an announcement are built from `APP_BASE_URL` — so set that in production or the links point
at the wrong host.
Which legs exist depends on what has registered one:
- **Discord `#news`** is core's, and needs the bot below.
- **A module may add its own.** The `uo` module adds an in-game town crier, so a news post
is announced to players who are logged into the game and never visit the site.
A leg brings its own settings with it — the town crier's duration is a module setting, not
a core one — which is why they are documented with the module rather than here.
## The Discord bot
**Admin → Discord Bot**: enable it, give it the guild (server) ID and the bot token, and
save. The token is stored **encrypted in the database** and is never an environment
variable.
The bot is a separate container. On the quickstart deployment from
[Install the site](/docs/getting-started/install-the-site/) it is not running at all, and
the panel says so — *bot unreachable* is the honest state of a site that never started one,
not a failure. Add the `bot` service from the project's shipped Compose file when you want
it.
What it does once connected: posts announcements, captures the moderation events on the
[Moderation](/docs/administration/moderation/) screen, serves slash commands, and — if you
switch them on — the Team notification bridge and per-Team voice channels from
[Teams](/docs/administration/teams/).
## Push notifications
Opt-in push to the Android app, over a **self-hosted ntfy relay** — the `ntfy` service in
the project's Compose file, plus `NTFY_BASE_URL` and friends.
Two properties matter for what you have to trust:
- **The relay only ever carries a content-free tickle.** The message says something
happened; the app then fetches the actual content from the site over its own
authenticated connection. So the relay never sees notification text.
- **A device may only register an endpoint on an allowed origin**, derived from
`NTFY_BASE_URL`. That is what stops a device pointing your server at somebody else's.
Without `NTFY_PUBLIC_URL` / `NTFY_ALLOWED_ORIGINS`, the app simply shows push as
unavailable for your instance — nothing breaks.
## Who receives what
The per-person side of this lives in the player portal, not the admin panel: each member
chooses which Team and forum notifications they want, and how. Two defaults are worth
knowing because they are not symmetrical:
- **Push is opt-out** once a device is registered.
- **Email is opt-in.**
<Aside type="caution" title="Nothing here retries">
The announcement dispatcher sends once, and the Team notification bridge states plainly that
a message is sent once and not retried. If Discord is down when a post is published, that
announcement is gone — the post is still on the site, which is the thing that matters.
</Aside>

View File

@@ -0,0 +1,77 @@
---
title: Teams
description: Core owns the Team machinery and cannot create a Team. What that means in practice, and what the admin screen controls.
---
import { Aside } from '@astrojs/starlight/components';
Teams are a core platform primitive: membership, roles, forums, notifications, moderation
and the Discord integrations are all core's, and none of it knows what a Team *is* in your
game.
**Core cannot create a Team.** Teams arrive from the installed module — with the `uo`
module, they are the shard's guilds. On a deployment with no module, the Team machinery is
present and permanently empty. That is not a bug to work around; it is the contract that
lets the same forum, notification and moderation code serve any game.
<Aside type="note" title="What that means when you are looking at an empty screen">
*No Teams in the projection yet* on a site with no module installed is the correct and
final state. Install a module, connect its game server, and Teams appear as that module
reconciles them.
</Aside>
## The projection, and why it can be stale
**Admin → Teams** shows a sync panel per module: last attempt, last success, consecutive
failures and the last error, with **Sync now** and **Resync now**.
The wording on that panel is exact and worth reading:
> Core has never had an answer it could trust. What is shown below is not a confirmed empty
> shard.
An empty list therefore means one of two very different things — there are no Teams, or
nobody could ask. The panel tells you which, and a *last success: never* with a *last
error* of `no uo-link configured` is the second. Fix
[the shard connection](/docs/administration/the-shard-connection/) and sync again.
## Forums
Team forums are switched on site-wide in **Settings**, along with whether images are
allowed and how long an author may edit a post — see
[Configuration](/docs/administration/configuration/).
Two rules are structural rather than settings:
- **A Team's leaders moderate their own forum.** That is the point of a Team forum.
- **Reports about that forum do not go to them.** They go to site staff, because a report
about a leader has to reach someone above them. See
[Moderation](/docs/administration/moderation/).
## The Discord bridges
Two integrations, both optional, both configured from **Admin → Teams**.
**Notification bridge** — sends Team notifications to a Discord channel: a default for
every Team, overridable per Team. A message is sent once and never retried; the bridge is a
courtesy, and nothing on the site depends on it arriving. With nothing configured, no Team
event leaves the site.
**Voice channels** — gives each Team a Discord voice channel of its own, with access
granted by a per-Team role, so a Team's members can see and join theirs and nobody else
can. It needs the bot reachable, and members need a linked Discord account and guild
membership.
Its three settings deserve a thought each:
| Setting | What it decides |
|---|---|
| **Minimum members** | How large a Team must be to get a channel. Every active member counts, linked account or not. |
| **Grace window (days)** | How long a Team keeps its channel after it stops qualifying. A Team that recovers inside the window keeps the same channel; zero removes it on the next pass. |
| **Staff roles** | Roles that can see and join every Team's channel. Guild administrators already can, so this is for staff who are not administrators. |
<Aside type="caution" title="Voice channels are a per-guild ceiling, not a per-Team one">
Discord's role and channel limits apply to the whole guild, so a site with many small Teams
can exhaust them. The minimum-members setting is the lever that keeps the count sane, and
it is easier to raise it before provisioning than to unpick channels afterwards.
</Aside>

View File

@@ -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://<shard host>:8080` — point-in-time queries |
| **WebSocket URL (feed)** | `ws://<shard host>:8080/ws` — the live event feed |
| **Auth token** | The sidecar's token |
| **Protocol** | {platform.protocol} today |
Saving restarts the ingest client, so a change takes effect immediately.
**The token is write-only.** It is encrypted at rest and never returned to any client, so
the field is blank when you come back to the screen — losing it means reading it back from
`sidecar.toml` on the shard host, not from the website.
## Reading the status line
The header carries the connection state, *Shard link*, *WS ingest*, *Reconnects* and *SSE
clients*. Together they say **which** link is broken:
| Reading | Means |
|---|---|
| Disconnected, shard link down | The site cannot reach the sidecar at all — URL, firewall, or the service is not running |
| Connected, but shard link down | The sidecar is up and the *game* is not talking to it |
| Reconnects climbing | An unstable path between site and sidecar |
| Live feed silent, everything else green | The bridge is fine and the shard is quiet |
A `409` in the logs is a protocol mismatch — set the Protocol field to what the sidecar's
`/health` reports rather than guessing; it rejects rather than mis-parsing. A `401` is the
token.
<Aside type="note" title="The site is designed to look normal while this is broken">
Every read through the sidecar returns a result rather than throwing, so the public site
renders with the shard shown offline. That is deliberate graceful degradation, and it is
also why a broken bridge can go unnoticed — this screen, or `runicgateway doctor` on the
shard host, is how you find out.
</Aside>
## Game-account creation
Whether players can create a **game** account (for the game client) from the website. The
game server's own `SignupMode` in `Bridge.cfg` has to agree.
| Mode | Behaviour |
|---|---|
| **Disabled** | Players may only link an account that already exists |
| **Website** | The site creates game accounts |
| **Hybrid** | Site or in-game — the recommended setting |
| **Game only** | Created in the game client; the site only links |
With creation enabled, a *Create a game account* form appears in the player portal and
after an invite is accepted.
## Town crier
Broadcast a message every in-game town crier announces until it expires: an id, one or more
lines, and a duration in seconds. Re-posting the same id **replaces** that message, and
**Remove by id** takes it down early.
The id is the useful part — give a recurring announcement a stable one and you can update or
withdraw it without waiting for it to expire.
## What reaches the public
Events from the shard fan out over two separate streams, and the split is a security
boundary rather than a preference:
- **The public stream** carries an allowlist of event kinds.
- **The admin stream** adds staff audit events, cheat detection, login attempts and IP
addresses.
The live feed at the bottom of this screen is the admin one — everything, as it arrives.
Treat it accordingly: it is the screen you do not put in a screenshot.
<Aside type="caution" title="Visibility is decided on the website, not on the sidecar">
The sidecar is a dumb forwarder. What is public, what is staff-only and what is off is
decided on the site, so changing your mind is a settings change rather than a shard
redeploy — and it also means an unreviewed default is a decision you have made by not
making it.
</Aside>

View File

@@ -0,0 +1,118 @@
---
title: Troubleshooting
description: The failures a deployment actually hits, what each one looks like, and the fix.
---
import { Aside } from '@astrojs/starlight/components';
Grouped by where the problem is, because the first useful question is always *which half is
broken*.
## The site will not start
Read the log first — `docker compose logs app` — because the server says exactly why.
| What the log says | What it means |
|---|---|
| `SECRET_ENC_KEY must be set in production` | The key that encrypts stored secrets is missing. Set it in `.env` and start again. The container crash-loops until you do. |
| A `BOT_INTERNAL_KEY` complaint | Blank, still a placeholder, or shorter than 16 characters. Required in production even when the bot is not running. |
| A database connection error, repeatedly | The app came up before the database was ready, or `DB_*` is wrong. The Compose file's health check handles the first case; check the values for the second. |
| Nothing at all, container restarting | The image did not pull. `docker compose pull` on its own shows the error. |
<Aside type="caution" title="Both of those key errors happen on the FIRST boot, not later">
They are checked at require time, before the server listens. A deployment that has ever
served a request has both of them set.
</Aside>
## Nobody can sign in
- **Your address is rate-limited or bot-banned.** Both are working as designed. Check
**Admin → Web Bot Activity** from another network, or restart the app container — the
scoring state is in memory and resets with it.
- **The password is right and the form still fails.** Check the log for the actual status:
a `429` is the rate limiter, a `403` is usually the honeypot, and a `401` really is the
password.
- **SSO returns to the login page.** SSO is link-only: an identity that is not already
linked to an account cannot sign in, and that is the expected outcome rather than a
misconfiguration. Link it from the account screen first.
- **Two-factor is lost.** Recovery codes, or another admin's **Reset two-factor** on
**Users → View**. See [Authentication](/docs/administration/authentication/).
## A module will not start
**Admin → Modules** names the stage and the reason. The usual three:
| Reason | Fix |
|---|---|
| `module directory not present on the volume` | The row exists and the files do not — someone deleted the directory by hand. Reinstall, or remove the row with an uninstall. |
| A schema failure | The module's schema fragment could not be applied. The log carries the SQL error. |
| A version refusal | The module wants a newer core API than this image. Upgrade the site. |
Whatever the reason, **the site is up and the module's routes are absent** — that is by
design, and it is why a broken module is an inconvenience rather than an outage. Fix the
cause and restart: failed modules are retried on every boot.
**The install button rejects a URL.** The host must be in the allowlist on the same screen,
and the URL must be HTTPS. An empty allowlist forbids every install.
**The install succeeds and nothing appears.** It needs a restart. The banner says so, and
the row reads *Restart to start* until then.
## The Restart button did not bring the site back
The button exits the process and relies on a supervisor to start it again. If your
deployment has nothing supervising it — `npm start` in a terminal, a container without
`restart:` — the site stays down until you start it yourself. Compose with
`restart: unless-stopped` is the supported shape.
## The game screens are empty or say offline
Work outwards from the game, and stop at the first check that fails.
1. **In game:** `[bridge status` — `connected=False` means the shard cannot reach the
sidecar.
2. **On the shard host:** `curl -s http://127.0.0.1:8080/health` — `plugin_connected: true`
is the value that matters.
3. **On the shard host:** `runicgateway doctor` — checks the install record, every overlay
file hash, the service, and that the sidecar and overlay agree on a protocol.
4. **On the site:** the [shard connection screen](/docs/administration/the-shard-connection/)
— its four indicators say which link is broken.
Two log lines with specific meanings: **`409`** is a protocol mismatch (set the Protocol
field to what `/health` reports), and **`401`** is the auth token (read the live one back
with `uo-link-sidecar --print-config`; do not retype it from a screenshot).
<Aside type="note" title="“Nothing changed and it stopped working” usually means a ServUO update">
An update to the server tree can revert `Scripts.csproj`, at which point the plugin sits in
the tree and never compiles — and ServUO ignores the script build's exit code, so the boot
looks clean. `doctor` catches it by comparing file hashes against the install record.
</Aside>
## Teams are missing
Check the sync panel on **Admin → Teams** before anything else: *last success: never* with
`no uo-link configured` means the shard connection, not the Team machinery. And on a site
with **no module installed**, an empty Team list is correct and final — core cannot create
a Team. See [Teams](/docs/administration/teams/).
## Email and announcements never arrive
- **The contact form opens a mail client.** Email delivery is not connected; that is the
documented fallback. Connect Gmail in **Settings → Email delivery** — after configuring
the Google provider, which it reuses.
- **A published post announced nothing.** The Discord bot is a separate container. If the
Discord Bot screen says *bot unreachable*, it is not running.
- **A missed announcement does not come back.** Nothing retries; the post itself is still
on the site.
## Uploads and modules fail with permission errors
Docker created a bind-mount source that the container user cannot write — usually because
the directory was deleted and recreated by Docker as `root`. `chown 1000:1000 modules` (or
`logs`, or `brand`) on the host fixes it. Do not delete those directories.
## When you need to ask for help
Bring three things: the relevant lines from `docker compose logs app`, the output of
`runicgateway doctor` if a game server is involved, and what you changed last. The
[community page](/community/) has where to ask.

View File

@@ -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 |
<Aside type="note" title="Staff are players too">
Every self-service screen in the player portal is role-agnostic: it serves whoever is signed
in. An administrator has characters and Teams like anyone else, and reaches them through the
same portal. Nothing about being staff removes the player half of an account.
</Aside>
Admin routes are re-validated against the database on **every request**, not just at sign-in.
Demoting an account takes effect at once — the open session does not keep its access until
it expires.
## 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.

View File

@@ -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
<Steps>
1. **Download the binary for your OS, and `SHA256SUMS`**, from the
[installer releases page](https://gitea.whitlocktech.com/RunicGateway/installer/releases)
({platform.releases.installer}).
Releases are **unsigned** — there is no code-signing certificate, so that checksum file
is the whole trust anchor. Check it:
```bash
sha256sum -c SHA256SUMS --ignore-missing
chmod +x runicgateway-installer-linux-x86_64
```
On Windows, `(Get-FileHash .\runicgateway-installer-windows-x86_64.exe -Algorithm SHA256).Hash`
and compare. Windows will also show a SmartScreen prompt on first run, for the same
reason.
2. **Stop the shard.** `ServUO.exe` locks `Scripts.dll` and rewrites `Saves/` on exit, so
the installer refuses to deploy under a running server.
3. **Run it, elevated.**
```bash
sudo ./runicgateway-installer-linux-x86_64 install
```
Add `--verify` first if you want to see every change it would make and write nothing.
It asks four things: your ServUO root, whether to apply the optional patch tier, the
hostname your website should use to reach this machine, and your site's URL (used only
to print a link at the end).
4. **Read the summary.** It reports the overlay sync file by file, the sidecar binary and
its verified hash, the config and database paths, and the service state. Then it says
what you must do next — restart ServUO yourself, because it will not do that for you.
</Steps>
<Aside type="note" title="It installs a bundle, not “latest of each”">
The three components version independently but must agree on one wire protocol, so what it
resolves is a **bundle**: an exact, protocol-checked pair of sidecar and overlay versions
({platform.bundle.tag} today — sidecar {platform.bundle.sidecar}, overlay {platform.bundle.overlay}).
`--bundle <tag>` pins an exact past combination, so a reinstall in six months reproduces
today's install rather than tomorrow's.
</Aside>
## The patch tier is optional
Most of the plugin is *added* files, which is why the base install is a safe copy. Two
features need edits to stock ServUO sources, and those are opt-in, off unless you say yes,
and refused where the target lines are not stock. Skipping the tier costs you vendor-sale
events and in-game moderation audit forwarding; everything else works.
The tier is written and tested against stock ServUO {platform.bundle.servuoMin}. On any
other version it is unsupported and untested, and the prompt makes you answer past a
warning.
## Paste the four values into the site
A successful run ends by printing the one step it cannot do for you:
```
Base URL http://shard.example.com:8080
WebSocket URL ws://shard.example.com:8080/ws
Protocol version 4
Auth token 4f9c… (also in sidecar.toml)
```
Every value comes from asking the installed sidecar itself, so it cannot drift from what
the service actually runs.
On the site, sign in as an administrator and open **Shard (uo-link)** in the admin
sidebar — `/admin/uo/link`. Tick *Enable the shard integration*, paste **Base URL**,
**WebSocket URL**, **Auth token** and **Protocol**, and save. The ingest client restarts
immediately.
<Aside type="caution" title="The installer prints an older path for that screen">
It prints `…/admin/shard`. Since the shard screens became part of the `uo` module, a module
owns one path segment and the screen moved to **`/admin/uo/link`**. Use the sidebar, or
that path; the printed link no longer resolves.
</Aside>
The token is encrypted at rest and **never returned to any client** — losing it means
reading it back from `sidecar.toml` on the shard host, not from the website.
## If the website is on a different machine
The sidecar binds `127.0.0.1:8080`, reachable only from the shard host. If the site runs
elsewhere, widen the bind and then narrow the access:
1. Set `[web] bind` in `sidecar.toml` to `0.0.0.0:8080` and restart the service.
2. **Firewall that port to your website's address only.** The auth token is always on, but
it travels as a plain bearer token — the sidecar speaks HTTP, not HTTPS.
3. If the two hosts are not on a trusted network, put the sidecar behind a TLS reverse
proxy or a VPN link and give the website the `https://` / `wss://` URLs.
Leave `[shard] bind` on `127.0.0.1:7788`. That socket accepts *inbound commands to the
game*, and being loopback-only is what makes that safe.
Next: [Verify the whole stack](/docs/getting-started/verify-the-whole-stack/) — because a
successful file copy is not a working bridge.

View File

@@ -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
<Steps>
1. **Open `/admin/login`** — not `/`. The public site and the admin panel have separate
sign-in screens, and in maintenance mode the public one is behind the coming-soon page.
2. **Use `ADMIN_USERNAME` and `ADMIN_PASSWORD` from your `.env`.**
That account was created on the first boot, and only because the `users` table was
empty. The variables do nothing on later boots, so you can blank them once you are in.
3. **Set up two-factor**, under **Account** at the bottom of the sidebar. Optional,
per-account, and the right moment is now rather than after the site is public.
</Steps>
<Aside type="caution" title="If the login screen rejects a password you are sure about">
Login is rate-limited and backs off after repeated failures from one address, and the
bot-scoring layer can ban an address outright. Both are working as designed. Give it a
minute, and see [Authentication](/docs/administration/authentication/) for what the
**Web Bot Activity** screen shows and how to lift a ban.
</Aside>
## What is already there
The first boot seeds a working site rather than an empty one:
- **A wiki with eight pages**, arranged in sections — Guides, World & Lore, Systems &
Gameplay, Community & Rules — as a skeleton to write into, not as content to keep.
- **Post categories**: News, Five on Friday, Newsletter, Screenshots.
- **A public navigation** covering those, the wiki and an About page.
- **A portal hero** with placeholder copy that names no game.
None of it mentions a specific game, because core does not know about one. That arrives
with a [module](/docs/getting-started/install-a-game-module/).
## The three things to set before going live
All three are on **Settings**:
| Setting | Why now |
|---|---|
| **Site title** | Overrides `BRAND_NAME` for the page title, the header and link previews. |
| **Contact email** | Where the contact form delivers. Until email is configured, the form falls back to a `mailto:` link to this address — so an unset one means a contact form that goes nowhere. |
| **Player registration** | **Off by default**: nobody can create an account. Choose password, SSO, both, or leave it off and invite people individually from **Invites**. |
The maintenance message and the homepage teaser are on the same screen, and both are worth
a minute before anyone reads them.
## Switch to live
**Dashboard → Switch to Live.** The public site opens immediately; nothing else changes.
You can flip back at any time, and an admin who is signed in can preview the live site
while the rest of the world still sees the maintenance page — so there is no need to go
live in order to check your work.
<Aside type="note" title="Going live is not the same as being reachable">
Live mode only decides what visitors are shown. Whether anyone can reach the site at all is
your DNS, TLS and reverse proxy — see
[Maintenance and upgrades](/docs/administration/maintenance-and-upgrades/).
</Aside>
Next: [Install a game module](/docs/getting-started/install-a-game-module/), or skip
straight to [Administration](/docs/administration/configuration/) if this deployment is a
community site with no game server behind it.

View File

@@ -0,0 +1,93 @@
---
title: Install a game module
description: Everything game-specific is a module. Installing one, what it adds, and the restart that makes it live.
---
import platform from '../../../../data/platform.json';
import { Aside, Steps } from '@astrojs/starlight/components';
Core knows nothing about any game. Every game-specific screen — shard status, the map
atlas, the player marketplace, character sheets — comes from a **module**, a directory on a
mounted volume that the server loads at start.
Today there is one: **`uo`**, for ServUO shards, published as
[`Module-uo`](https://gitea.whitlocktech.com/RunicGateway/Module-uo) ({platform.releases['Module-uo']}).
## Install it
<Steps>
1. **Open Admin → Modules.**
2. **Paste the URL of a release's install manifest** into *Release install-manifest URL*
and press **Install**.
For the current `uo` release that is the `module-uo-<version>.json` asset on
[its releases page](https://gitea.whitlocktech.com/RunicGateway/Module-uo/releases).
The site downloads the bundle, checks it against the `sha256` the manifest declares, and
unpacks it onto the modules volume.
There is no catalog to browse, deliberately: a catalog would make core's release cadence
decide which modules are allowed to exist.
3. **Restart when it asks.** A banner appears — *Modules are read from disk when the server
starts* — with a **Restart the server** button. The row reads *Restart to start* until
you do.
The button exits the process and lets your supervisor bring it back; on the Compose
deployment from [Install the site](/docs/getting-started/install-the-site/), that is
`restart: unless-stopped` doing its job. `docker compose restart app` is exactly
equivalent.
4. **Confirm it started.** The module's row should read *Started*, and its screens should
have appeared in the navigation.
</Steps>
<Aside type="note" title="Only listed hosts may be installed from">
Installing a module runs its code inside your server, so the URL must be HTTPS and its host
must be in the allowlist at the bottom of the same screen — re-checked on every redirect.
It is seeded with `gitea.whitlocktech.com`, and an empty list forbids every install.
</Aside>
## What the `uo` module adds
Watch the log at the restart and you will see exactly what it mounted:
```
[uo] registered routes: public:/shard,/atlas admin:/shard,/uo-link player:/shard
[modules] schema ensured for module "uo"
[modules] module "uo" started
```
Its capabilities are {platform.moduleUoCapabilities.join(', ')} — the shard console, the
map atlas, the player-vendor marketplace, city governors, guilds, houses and IDOCs, champion
boards, and the cliloc strings that make item names readable.
A module owns **one path segment** wherever it appears, so its pages live under `/uo/…`,
`/admin/uo/…` and `/player/uo/…`. That boundary is visible in the URL on purpose.
<Aside type="caution" title="A module with no game server behind it is empty, not broken">
Installing `uo` does not connect anything. Its screens exist and report the shard as
offline until you
[connect a game server](/docs/getting-started/connect-a-game-server/) — which is the same
thing the public site does when the shard goes down, and is designed to be unremarkable.
</Aside>
## The declarative alternative
A host whose Compose file is version-controlled can skip the panel entirely: set `MODULES`
in `.env`, one entry per module, `<id>@<version>=<install manifest URL>`. The container
resolves that set at every start.
A module already unpacked at the declared version is left alone **without a single network
call**, so a restart with no route to the internet comes up unchanged. A failure is logged
and shown in Admin → Modules, and never stops the site from starting.
The two surfaces agree on a rule worth knowing: **the variable owns what is on the volume,
the admin panel owns whether a module runs.** A module you disable in the panel stays
disabled even though its files are put back at the next start.
More on both in [Managing modules](/docs/administration/managing-modules/).
Next: [Connect a game server](/docs/getting-started/connect-a-game-server/).

View File

@@ -0,0 +1,140 @@
---
title: Install the site
description: A complete Docker Compose deployment you can copy from this page — two files, two commands.
---
import { Aside, Code, Steps } from '@astrojs/starlight/components';
import { compose, env, omittedServices } from '../../../../data/quickstart.mjs';
export const envText = env.map((e) => `${e.key}=${e.value}`).join('\n');
export const fillLines = env.filter((e) => e.fill).map((e) => `${e.key}=${e.value}`);
The site is a Docker deployment: a MariaDB container, the prebuilt application image, and
two files you write. Nothing is compiled on your host, and there is no repository to clone
— everything you need is on this page.
<Steps>
1. **Make a directory for the deployment.**
Everything below is relative to it, and the bind mounts want to exist before the
containers do — Docker creates a missing mount source as `root`, and the container user
then cannot write it.
```bash
mkdir -p runic-gateway/logs runic-gateway/brand runic-gateway/modules
cd runic-gateway
```
2. **Write `docker-compose.yml`.**
<Code code={compose} lang="yaml" title="docker-compose.yml" />
This file only ever *pulls*. There is no `build:` anywhere in it, which is deliberate:
a production host should not be able to build an image by accident.
3. **Write `.env` beside it.**
Every highlighted line must be changed before this is a real deployment. The secrets
want to be long random strings — `openssl rand -base64 36` three times is enough.
<Code code={envText} lang="ini" title=".env" mark={fillLines} />
4. **Pull and start.**
```bash
docker compose pull
docker compose up -d
```
The database comes up first; the app waits for its health check, creates its schema,
seeds defaults, creates your first admin, and starts listening.
5. **Check that it is up.**
```bash
curl -s http://localhost:3000/api/health
```
```json
{"status":"ok"}
```
If that answers, the site is running. Go to
[First run](/docs/getting-started/first-run/).
</Steps>
## What you just deployed
```
localhost:3000 ──► app (the website: API + the built React client, one process)
└──► db (MariaDB, no host port — only the app can reach it)
```
Four host directories and two volumes hold everything that survives a container:
| Path | What is in it |
|---|---|
| `./logs/` | `app.log`, readable from the host without `docker exec` |
| `./modules/` | Installed [modules](/docs/getting-started/install-a-game-module/). A bind mount, so placing one by hand is a supported install |
| `./brand/` | Your logo, hero and favicon, if you replace the defaults ([Branding and theming](/docs/administration/branding-and-theming/)) |
| `dbdata` volume | The database |
| `uploads` volume | Everything uploaded through the site |
<Aside type="caution" title="`restart: unless-stopped` is load-bearing">
It is not boilerplate. Installing a module needs a restart, and the admin panel offers a
button for it — that button exits the process and lets the supervisor bring it back. On a
deployment with nothing supervising the process, the button takes the site down and leaves
it down. Docker Compose is the supervisor here, and this line is what makes it one.
</Aside>
## Two variables worth reading twice
**`SECRET_ENC_KEY`** encrypts secrets at rest — OAuth client secrets, the Discord bot
token, the shard's auth token. In production the server **refuses to start** without it.
Changing it later does not re-encrypt anything: what was stored under the old key can no
longer be read, and every stored secret has to be entered again.
**`BOT_INTERNAL_KEY`** authenticates the internal channel between the site and the Discord
bot. The server also refuses to start in production if it is blank, left at a placeholder,
or shorter than 16 characters — even when, as here, you are not running the bot yet.
<Aside type="note" title="Both of those are set once, before the first boot">
They are not "fill in later" values. The first boot is when your admin account and the
site's defaults are written, and it will not happen at all until both are set.
</Aside>
## What this quickstart leaves out
The project's shipped Compose file has two more services. Neither is needed to boot, and
each is introduced where it is configured:
<ul>
{Object.entries(omittedServices).map(([name, why]) => (
<li key={name}><strong><code>{name}</code></strong> — {why}</li>
))}
</ul>
It also leaves out the branding, logging and session variables, which have working
defaults and their own admin screens. The full file and the full environment reference are
in the [website repository](https://gitea.whitlocktech.com/RunicGateway/website).
## Behind a reverse proxy
Not required to get started, and required before anyone else uses the site. Two settings
here are what make it correct:
- **`TRUST_PROXY=1`** tells the app to read the client's address from `X-Forwarded-For`.
Rate limiting, login backoff and the bot-scoring IP bans are all only as accurate as
that. Set it to the number of proxies in front of the app, or pin it to the proxy's
address; a blanket `true` is rejected on purpose, because it would let anyone spoof
their address by sending a header.
- **`COOKIE_SECURE=auto`** issues a `Secure` session cookie when the request arrives over
HTTPS and a plain one otherwise, so logging in works both through the proxy and directly
on the LAN while you are setting up.
Point the proxy at port 3000. Do not forward `INTERNAL_PORT` (3001) — it is the
server-to-bot channel, it is deliberately not published by the Compose file, and it must
never be reachable from outside.

View File

@@ -0,0 +1,60 @@
---
title: Requirements
description: What you need on the website host, and what you need on the game server host, before you begin.
---
import platform from '../../../../data/platform.json';
import { Aside } from '@astrojs/starlight/components';
Two hosts, two lists. They can be the same machine, but they are separate deployments and
have nothing in common except the four values you will paste between them.
## The website host
| Requirement | Detail |
|---|---|
| **Docker** with Compose v2 | `docker compose version` should print v2.x. The site ships as prebuilt images and pulls them; nothing is built on your host. |
| **Outbound HTTPS** to `gitea.whitlocktech.com` | To pull the images, and later to install a module. Nothing inbound is required for the install itself. |
| **~2 GB of disk to start** | Two images, a MariaDB volume, and an uploads volume. Uploads grow with what your community posts. |
| **A hostname and TLS, eventually** | Not needed to boot — you can reach it on `http://localhost:3000` first. Needed before anyone else uses it: see [Maintenance and upgrades](/docs/administration/maintenance-and-upgrades/) for the reverse-proxy notes. |
There is no separate database to install: MariaDB comes up as a container beside the app,
and the schema is created on first boot.
<Aside type="note" title="Windows and macOS are fine for trying it">
The images are Linux containers, so Docker Desktop runs them. For a deployment other people
depend on, a Linux host is the shape everything else assumes — the log paths, the bind
mounts and the reverse-proxy notes all read that way.
</Aside>
## The game server host
Only if you are connecting a game server. Today that means a ServUO shard, which is what
the [`uo` module](/docs/getting-started/install-a-game-module/) and the installer support.
| Requirement | Detail |
|---|---|
| **A working ServUO install** | It must currently boot and compile scripts cleanly. The installer deploys onto a healthy shard; it does not repair a broken one. |
| **ServUO {platform.bundle.servuoMin}** *(patch tier only)* | The base install works on any reasonably current ServUO. The optional patch tier is written and tested against stock {platform.bundle.servuoMin}; on any other version it is unsupported, and skipping it still leaves you with a working bridge. |
| **The shard stopped** | `ServUO.exe` locks `Scripts.dll` and rewrites `Saves/` on exit. The installer refuses to deploy under a running shard. |
| **Administrator / root** | It writes into system directories and registers a service. |
| **Outbound HTTPS** | To fetch the bundle and its two artifacts. No Gitea account and no git client are needed. |
| **The sidecar on the same host as the shard** | The shard connects to `127.0.0.1:7788`. Splitting them is not supported — that loopback socket *is* the trust boundary for inbound commands. |
<Aside type="caution" title="Back up before the shard install">
The overlay overwrites `Scripts/Scripts.csproj`, a stock file, and the optional patch tier
edits stock sources. A copy of `Scripts/` and `Config/` costs nothing and is the difference
between an experiment and a gamble. The installer keeps its own backups too — see
[Connect a game server](/docs/getting-started/connect-a-game-server/).
</Aside>
## What you do not need
- **A Gitea account.** Everything the installers fetch is a public release asset.
- **A build toolchain.** Not on either host. The site pulls images; the module arrives as a
verified tarball; the shard plugin is C# source that ServUO itself compiles at boot.
- **An inbound port on the game host** — for the *game*. The shard never listens for the
website. If the website runs on a different machine from the shard, the **sidecar** needs
to be reachable by the website, and that is the one hole you will open deliberately.
Next: [Install the site](/docs/getting-started/install-the-site/).

View File

@@ -0,0 +1,103 @@
---
title: Verify the whole stack
description: Four checks, one per link in the chain, that distinguish "files copied" from "the bridge works".
---
import { Aside, Steps } from '@astrojs/starlight/components';
A successful install is not a working bridge, and the failure is quiet in a specific way:
**ServUO shells out to `dotnet build`, prints the output, ignores the exit code, and
reloads the existing `Scripts.dll`.** A broken script build looks exactly like a clean boot.
So verify each link in the chain, in order. Each check tells you which one to fix.
<Steps>
1. **The plugin compiled — watch the boot output.**
Start your shard the way you always do. You want the build to succeed *and* the bridge
to announce itself:
```
Core: Compiling scripts...
Build succeeded.
[Bridge] enabled=True endpoint=127.0.0.1:7788 queueCap=10000 …
```
If you scrolled past it, force the question:
```bash
dotnet build Scripts/Scripts.csproj -c Release -p:Platform=x64 # must be 0 errors
```
2. **The shard is connected — ask it in game.**
As an administrator:
```
[bridge status
```
It reports `connected=True depth=0 sent=… dropped=0`. `connected=False` means the shard
cannot reach the sidecar. `dropped` climbing means the sidecar is wedged and the shard
is shedding events rather than stalling — which is what it is designed to do, and why a
broken bridge never freezes your game.
`[bridge reload` re-reads `Bridge.cfg` without a restart; `[bridge sweepnow` forces one
pass of every stream.
3. **The sidecar is healthy — ask it over HTTP.**
`/health` needs no auth, so it is safe to curl on the shard host:
```bash
curl -s http://127.0.0.1:8080/health
```
```json
{"status":"ok","protocol":4,"plugin_connected":true,"database":"ok","uptime":"2m"}
```
**`plugin_connected: true` is the one that matters.** It is the only value in this whole
sequence that distinguishes "files copied" from "the bridge works".
4. **The website is ingesting — look at the shard screen.**
On the site, open **Shard (uo-link)** (`/admin/uo/link`). The header should read
**Connected**, with *Shard link: up* and *WS ingest: online*, and the live feed at the
bottom should start showing events within seconds rather than sitting on
*Waiting for shard events…*.
Then check the public side: the shard status page should stop reporting the game as
offline.
</Steps>
## When one of them fails
| What you see | What it means |
|---|---|
| Shard boots clean, nothing reaches the site | The classic silent failure — a stale `Scripts.dll`. Run the `dotnet build` line above and read the errors. |
| `[bridge` is not a command | The plugin did not compile, or the bridge is disabled in `Bridge.cfg`. |
| `connected=False` | The sidecar is not listening on `127.0.0.1:7788`. Check the service, and that `[shard] bind` matches `Host`/`Port` in `Bridge.cfg`. |
| `/health` is fine locally, the site says offline | The website cannot reach port 8080 — bind address or firewall. The site is *designed* to render normally with the shard down, so this fails quietly. |
| The site logs `409` from the sidecar | Protocol mismatch. Set the Protocol field to what `/health` reports rather than guessing; the sidecar rejects rather than mis-parsing. |
| `401` from the sidecar | Wrong or missing token. Read the live one back with `uo-link-sidecar --print-config`; do not retype it from a screenshot. |
<Aside type="note" title="`runicgateway doctor` answers most of this in one command">
Run on the shard host, it checks the install record, the ServUO tree, every overlay file
hash, the patch tier, the sidecar, its service, `/health`, and that the sidecar and overlay
agree on a protocol. Its output is the first thing anyone helping you will ask for. It
exits non-zero when a check failed, so a monitoring system can run it too.
</Aside>
## What "working" looks like a week later
- The public shard page shows live status, and the admin dashboard shows events arriving.
- `dropped` in `[bridge status` stays at zero. A climbing number means the sidecar is
wedged, not that the shard is unhealthy.
- `doctor` is still green after a shard update — that is what catches an overlay file
reverted by hand or by a ServUO upgrade.
You have finished the installation path. From here,
[Administration](/docs/administration/configuration/) covers running the site day to day.

View File

@@ -13,13 +13,6 @@ network-facing component, and only the website's backend is allowed to talk to i
website degrades gracefully when the game is down, and sensitive events never reach the
public event stream.
:::note[This documentation is being written in phases]
The scaffold, theme and sidebar are in place. The pages themselves land in phases 7 and 8,
starting with the installation path — which is the priority of the whole project, because
the repositories treat the site and the shard as separate deployments and nothing today
presents them as one sequence.
:::
## What the platform is on today
<table>
@@ -46,10 +39,30 @@ two independent deployments.
2. **The shard side** is the installer binary, run on the game server's host. It sets up
the plugin overlay and the sidecar, and it never contacts the website.
They meet at four values pasted into **Admin → Shard**, and at protocol {platform.protocol},
which both sides check before they will pair.
They meet at four values pasted into the module's shard screen, and at protocol
{platform.protocol}, which both sides check before they will pair.
## Where to go next
You can stop after the first one. A site with no game server attached is a complete
community website — news, wiki, pages, Teams, forums, accounts and moderation are all core,
and none of them knows a game exists. The second install is what fills the game screens.
## Start here
The seven pages of **Getting started** are that sequence, in order, and each one says what
you should expect to see before you move on:
1. [Requirements](/docs/getting-started/requirements/) — what you need on both hosts
2. [Install the site](/docs/getting-started/install-the-site/) — Docker Compose, pull-only
3. [First run](/docs/getting-started/first-run/) — the first admin, and maintenance → live
4. [Install a game module](/docs/getting-started/install-a-game-module/) — what makes the game screens exist
5. [Connect a game server](/docs/getting-started/connect-a-game-server/) — the installer, on the shard host
6. [Verify the whole stack](/docs/getting-started/verify-the-whole-stack/) — proving it works, rather than assuming
Then **Administration** covers running it: configuration, branding, content, users,
authentication, Teams, moderation, notifications, modules, the shard connection, upgrades,
and what to do when something is wrong.
## Where the truth lives
The canonical, normative documents live in the
[`docs` repository](https://gitea.whitlocktech.com/RunicGateway/docs) and always win over

160
src/data/quickstart.mjs Normal file
View File

@@ -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',
};