- website/USER_SAFETY.md (new): report, block and hide on the website and in the app, S1-S5 decided by the org lead, five questions open. - android/PLAN.md: M8's three open items decided (keep the rating and add report/block/hide; conservative data safety; UnifiedPush on specialUse with restart on boot), the order to the cutover, and M21 (Teams and the team forum in the app, planned, not scheduled). - website/DEMO_MODE.md: DM32 and section 11, the demo content plan (two seed passes; moderation, Teams/forum/reports, event history, Rust stats). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
55 KiB
Demo mode — demo.runicgateway.com
Status: design fully decided 2026-10-23 (DM1-DM18, §9). §10 step 1 (core, MODULE_API
1.18.0) is built, and so are steps 2-5 (the module with its core hook MODULE_API 1.19.0, the
kit, the app, and the deployment, live since 2026-10-27). Step 6 is built on the site side
(2026-10-28) and waits on the Play Console.
Issue: RunicGateway/website#222. Why now: Google Play (../android/PLAN.md item 9,
M8) needs a public HTTPS site with working sign-ins for its reviewers and for the 12 testers who
must be opted in for 14 days before the app can apply for production.
1. What the demo is
A public Runic Gateway deployment at demo.runicgateway.com, linked to the Oxide Rust rig,
that anyone can sign into with published accounts. The org lead set three requirements on
2026-10-23:
- Static logins. Ready-made accounts (player, moderator, admin, and one for Google's reviewer) whose names and passwords are published.
- No destructive actions. Someone signed in as the public admin must not be able to break the site for the next visitor, or reach through it to damage the rig.
- An automatic wipe every couple of hours that brings the site back to a known base: the Rust module, the link to the rig, the demo accounts and the seeded content.
The demo-site Gitea Actions runner is where the demo is deployed and wiped. The runner is only the
host for those jobs; the design below covers what runs there.
2. Principle: the same image, switched by one flag
The demo runs the normal website-app image and the normal Rust module release. Nothing is forked.
A single env var on the deployment, DEMO_MODE=true, turns on three things:
- a lock on a fixed list of write routes (§3), answered before the route's own handler runs;
- a banner on every page and in the app saying this is a demo and when it next resets (§6);
- no outbound side effects that reach a real person (§5).
With the flag unset, none of the demo code runs, so a normal shard is unaffected.
3. The lock
3.1 How it works
A core middleware, mounted ahead of every router when DEMO_MODE is on, compares each write
request (POST/PUT/PATCH/DELETE) against a deny list of method + path patterns. A match
answers:
403 { "message": "This is turned off on the demo site.", "code": "DEMO_LOCKED" }
The web client shows the message as a toast wherever an action fails with that code. The Android
app does the same with a snackbar (DM19): an interceptor on its one HTTP client spots the code on
any 403 and reports the server's sentence, so a module's own wording (a refused console command)
reaches the screen as written. The screen still shows its own failure line, except where that line
blames the role ("You don't have permission for that action", on the admin screens), which says the
demo sentence instead. Load screens get a DEMO_LOCKED error kind, with no Retry button.
Reads are never locked, with one exception: the bridge's config (§3.5). Every page can be opened and every form filled in; only the save is refused. That keeps the demo useful as a tour of the admin panel.
The server is the lock, never the UI (DM10). Every lock is enforced at the route, so a request
sent with curl, an old app build, or a hand-edited page is refused exactly like a click. The UI may
explain a lock (the toast, the banner) but never hides or disables a control as the means of
locking it, and no client-side check is relied on.
Module routes are mounted by core but belong to the module, so the module declares its own locked
routes in module.json (DM1, §3.2).
3.2 The module's declaration
module.json gains an optional demoLocks list, read by core when DEMO_MODE is on:
"demoLocks": [
{ "method": "PUT", "path": "/admin/rust/servers/:id" },
{ "method": "DELETE", "path": "/admin/rust/servers/:id" },
{ "method": "DELETE", "path": "/player/rust/links/:steamId" }
]
Paths use the same :param syntax as the module's routers, with an optional trailing /* for a
path and everything under it. They must sit under the module's own mounts, which the loader checks
the same way it checks mounts. Only writes can be declared. The config editor's one locked read
(§3.5) depends on which file is asked for, so the module enforces it behind ctx.demoMode rather
than by route. A module without the field is unaffected. The field is validated on every boot, so a
wrong declaration fails on a developer's machine and not only on the demo. It is a new manifest field,
so MODULE_API_VERSION gets a minor bump to 1.18.0, MODULE_API.md documents it, and the
Integration Kit's module chapter gains a paragraph (its ci/core-ref.json pin then moves, as for any
API bump).
3.3 What is locked
Paths are relative to /api/v1. Tier A would break the site for the next visitor and is always
locked. Uploads are locked hard. Tier B (the rig) and Tier C (content) stay open: the
wipe puts them back (§7).
Tier A — always locked (core)
| Area | Routes | What a visitor could otherwise do |
|---|---|---|
| Site mode | PUT /admin/site-mode |
Put the site into maintenance; every non-admin is locked out |
| Modules | every write under /admin/modules (install, sources, restart, enable, disable, purge, delete) |
Disable or purge the Rust module; restart the server |
| SSO providers | writes under /admin/auth/providers |
Add a sign-in provider pointing anywhere |
PUT /admin/email/config, POST /admin/email/test, POST /admin/email/disconnect |
Connect a mail account; send mail from the site | |
| Discord bot | PUT /admin/discord-bot/config |
Point the bot at someone's server |
| Team voice | writes under /admin/teams/voice |
Configure or sync Discord voice channels |
| Demo accounts, by an admin | PUT/DELETE /admin/users/:id, DELETE …/trusted-devices…, POST …/mfa/reset when :id is a demo account |
Demote, disable or delete the account everyone signs in with |
| Own account, on a demo account | PATCH /auth/me/account/username, …/password, …/email; POST /auth/me/account/totp/setup, …/enable, …/disable; POST /auth/me/account/recovery-codes/generate; DELETE /auth/me/account/identities/:provider |
Change the shared password, or turn on 2FA so nobody else can sign in |
| Other people's sessions | DELETE /auth/me/sessions/:id on a demo account, whichever session it names (DM17) |
Sign every other visitor out of the shared account |
| Password reset | POST /auth/password/forgot, POST /auth/password/reset/:token |
Reset a demo account's password |
| Registration | POST /auth/register (DM16) |
Settings are open, so a visitor could switch player_registration on. The route is what keeps registration closed (§4) |
| Settings that reach a lock | PUT /admin/settings when the body names site_mode*, module_source_hosts, teams_voice_*, demo_* or hero_layout |
That route writes arbitrary keys, so these would get around the site-mode, Modules and team-voice rows above, rewrite the banner and the published logins, or publish the homepage hero (DM21). The answer names the keys (keys: [...]) |
Why DM17 locks the whole route. A mobile access token carries no id for its refresh-token row,
so the server cannot tell which row in GET /auth/me/sessions is the caller's own. Signing out is
POST /auth/mobile/logout, which revokes the caller's own row and stays open.
Tier A — always locked (Rust module)
| Area | Routes | Why |
|---|---|---|
| The link to the rig | PUT/DELETE /admin/rust/servers/:id |
Repoint or delete the sidecar link; the site goes dark |
| The bridge's config, read or written | GET/POST /admin/rust/config/:serverId/file, GET …/writes… for the RunicGateway plugin's files, which are also left out of GET …/files |
It holds the bridge's link to the sidecar and its credentials. Read, it would let someone talk to the sidecar directly over HTTP (DM14, §3.5) |
| Removing a Steam link (DM27) | DELETE /player/rust/links/:steamId (demoLocks), and staff's DELETE /admin/users/:id/rust/links/:steamId, which the module's handler refuses behind ctx.demoMode because it serves under core's users mount, where a module may not declare a lock |
demo-player's stand-in link is what its Rust pages, rewards and permission grants hang on; unlinked, the player tour is empty until the wipe |
Settings stay open (DM2). Theme, navigation and the other PUT /admin/settings values can be
changed and come back at the wipe, except the keys in the "Settings that reach a lock" row above.
Brand images are uploads, so they fall under the upload lock.
The hero editor previews but does not publish (DM21). Admin → Hero autosaves to
hero_layout_draft, and its Preview opens the homepage with that draft, so both keep working. Its
Publish writes hero_layout, the hero every visitor sees, and is refused; the editor's status line
and the toast give the demo sentence. Its image uploads fall under the upload lock. The demo admins
share one login, so they share one draft, and the wipe resets it.
Which accounts are demo accounts. The four usernames in §4 are fixed in core
(server/src/demo/mode.js), not read from the seed fixture: the lock checks them on every request,
and a fixture that dropped one would unlock the account everybody shares. The lock decides by the
account row in the database (users.getById), not by the username claim in the token. An account an
admin creates on the demo (POST /admin/users stays open) is an ordinary account. It is gone at the
next wipe.
Failing closed. A conditional row that cannot decide, because the database is down, refuses.
Uploads — locked hard (DM4)
| Route | What it stores |
|---|---|
POST /admin/uploads |
The media library |
POST /admin/posts/upload |
Images in posts |
POST /admin/settings/brand-asset/:slot |
Logo, hero, favicon |
POST /player/teams/:slug/forum/uploads |
Images in team forum posts |
"Hard" means a second rule beside the list: with DEMO_MODE on, core refuses any request whose
body is multipart/form-data, before a router sees it. An upload route added later is locked
without anyone remembering to list it, and a module cannot open one.
A pasted image is an upload too (DM25). An image pasted into rich text (a post, a wiki page, an
event description, a forum post, the hero draft) does not use multipart. It travels inside the
JSON body as a data: URI. So with DEMO_MODE on, core also refuses any write
(POST/PUT/PATCH/DELETE) whose JSON body carries a data: URI in any string, on any
route, core's or a module's, before a router sees it. As with multipart, nothing is listed, so a
rich-text field added later is covered without anyone remembering it. Every data: URI counts,
not only data:image/*. A match is data: that is not the end of a longer word (so "metadata:"
does not match), followed by a comma, a ;, or a type/subtype and then a comma. So prose such
as "Data: none, yet" saves. The string is first read as a browser reads an attribute: HTML
character references are decoded and tabs and newlines removed, so data: and a data:
split by a newline are still refused. Reads are never checked. (Off the demo, the server's HTML
sanitizer already drops data: from <img> on news, wiki and forum bodies, but not on every
rich-text field. The demo rule is the one rule that covers them all.)
Tier B — the rig: open, reset by the wipe (DM3). Permission writes (policy, grant, revoke, groups, members, drift actions, sync), RunicNPC writes (factions, profiles, placements, push, respawn), economics and permission grants to a linked player, the world actions events run, and map fetch/render all work. The rig starts every cycle from a demo default set of permissions, NPCs and events, and the wipe restores it (§7.1). Console-command steps stay open too, except for the commands on a deny list (§3.4, DM11).
Tier C — content: open (DM4). Posts, wiki, pages, events, teams and team forums, moderation
actions and invites work. The contact form is locked (POST /public/contact), because it sends mail
to a person. The demo is served with X-Robots-Tag: noindex and a matching robots.txt, so nothing
a visitor writes reaches a search engine.
3.4 The console-command deny list (DM11)
Console-command event steps and command rewards are a free-text box with no allowlist (Rust §16). On the demo, the Rust module refuses a command on this deny list, which holds the commands that would break the rig for every visitor until the next wipe. Everything else runs.
| Command (any of these spellings) | Why it is denied |
|---|---|
quit, restart |
Stops or restarts RustDedicated. The rig stays down, or the site loses it for minutes, until the wipe |
oxide.unload, oxide.reload (with any argument, * included) |
Unloads RunicGateway, which cuts the site off from the rig, or RunicNPC, Kits, ZoneManager and the rest, which the bridge needs |
rcon.* |
The panel's console runs over RCON, and the wipe's baseline capture uses that console |
fps.limit, server.tickrate |
Starves the server tick; the rig crawls for every visitor |
Matching. The check normalises the command before comparing: it trims it and lower-cases it,
strips a leading global. (Rust accepts global.quit), and maps the o. short form to oxide.
(o.unload). It compares the first word only, so the arguments cannot hide a denied command.
; does not split console commands in Rust (the §16 spike), so there is no second command to look
for.
Where it is enforced (DM10). In the Rust module's server code, at two points:
- when an event or reward holding the command is saved, answering
403 DEMO_LOCKEDwith the denied command named; - when the step runs, so an event saved before the list changed is still refused.
Saving an event is core's route, and core never asked a module about a step's params when it saved.
So the save-time check is a core hook (DM18, MODULE_API 1.19.0): an event action may declare
demoRefuses(params), which returns a sentence or null. With DEMO_MODE on, core asks it for every
step when a definition is created, saved or published, and the first sentence refuses the write
403 DEMO_LOCKED with that sentence as the message. A hook that throws refuses too. The run-time check
reads a new read-only ctx.demoMode boolean, part of the same MODULE_API minor bump as demoLocks
(§3.2, 1.18.0). The list lives in the module with tests, and a command added later is a module change.
Commands that only change the world or the players (server.writecfg, entity.*, env.*,
weather.*, say, kick, ban, oxide.grant) stay open. The baseline restore puts all of them
back (§7.2).
3.5 The plugin config editor (DM14)
Every plugin's config except the bridge's can be read and changed on the demo. A visitor can tune RunicNPC, Kits, Economics and the rest and watch the change take effect. Three things make that safe:
- The save reloads through the bridge, not the console. The bridge backs up the file, writes it, reloads the plugin that owns it, and puts the old file back if that plugin fails to load (D178, D179). The §3.4 deny list never sees this reload, and a config that breaks its plugin undoes itself at once.
- The wipe restores
oxide/config/from the baseline (§7.2), so a change lasts two hours at most. - The bridge's own config is out of reach. The editor's routes refuse every file the
RunicGatewayplugin owns, for reading as well as writing, and the file list leaves those files out. This is the one place where §3.1's "reads are never locked" gives way. The file holds the bridge's link to the sidecar and its credentials, and anyone holding them could talk to the sidecar directly over HTTP, outside every lock in this document.
Secrets in other plugins' configs. A plugin config can hold a webhook URL or an API key. The
editor already flags those fields by key name (SECRET_WORDS in configEdit.js), but today it
returns the full text and masks them only in the form, behind a reveal button. Under DM10 that
mask is not a lock. On the demo, the server therefore:
- redacts every flagged value in
GET …/fileand in the write history before it leaves the module, replacing it with a placeholder; - makes flagged fields read-only: a form save that edits one is refused;
- refuses raw-JSON saves (
textinstead ofedits), because the raw document a visitor sends back would hold the placeholders and overwrite the real values. Form saves splice only the fields that changed, so untouched secrets stay as they are on disk.
The link on the Servers screen. The module's admin view of a server never returns the sidecar
token; it returns hasToken. On the demo it also leaves out the sidecar's URL, so the screen
gives away nothing that helps someone reach the sidecar. Editing the link stays locked (§3.3).
All of this is enforced in the module's server code behind ctx.demoMode (DM10), with tests.
4. Static logins
| Account | Role | Notes |
|---|---|---|
demo-admin |
admin | The admin panel, read in full; writes per §3 |
demo-moderator |
moderator | Moderation queue, appeals |
demo-player |
player | Self-service; linked to a stand-in Steam ID that has played on the rig, so the Rust player screens have data (DM6) |
play-review |
admin | Google's reviewer. Same powers as demo-admin, but a separate account, so a reviewer is never signed out because someone else revoked a session on the shared one |
Usernames and passwords are published on the demo's sign-in page and on runicgateway.com /beta.
They are not secrets. The seed writes them into demo_state, GET /public/settings serves them as
demo.logins, and both sign-in pages (/account/login, /admin/login) list them with a Use
button that fills the form. A fixture account with "published": false is created but not listed. The seed recreates them at every wipe, so a password that somehow changed is
back within one cycle. Public registration is closed on the demo: there is no mail to verify an
address with (§5), and the static logins cover every role.
5. Outbound effects
| Channel | On the demo |
|---|---|
| Not configured. Registration closed, password reset locked, digest and engagement mail never send | |
| Push (ntfy) | Not deployed. The app's notification settings still save; nothing is delivered |
| Discord bot | Not deployed |
| The rig | Open, and restored to the demo default set at every wipe (§7.1) |
6. The banner
GET /api/v1/public/settings gains demo: { resetsAt, logins } while the flag is on, and only then.
The seed writes both into one settings row, demo_state, which PUT /admin/settings cannot touch
(§3.3). The web client shows a thin strip on every page: "Demo site — anything you change is wiped at
14:00 UTC. Sign-ins: demo-admin / demo-player … — passwords on the sign-in page". The app shows the same
strip on Home, but it does not list the passwords: its strip names the site's sign-in page instead
("passwords at demo.runicgateway.com/account/login"), DM20. Both read resetsAt, so neither needs to know the schedule. The time is said in UTC,
not converted, so it matches the schedule published on /beta.
The toast. Every web request goes through one request primitive (client/src/api/client.js),
installed modules' requests included. A DEMO_LOCKED answer is reported from there to one toast
(DemoToast), and the caller still gets its error as usual. So no screen has to learn the code.
7. The wipe
A scheduled workflow on the demo-site runner runs every 2 hours on the hour, UTC (DM7). It
resets the rig first and the site second, and the order matters.
7.1 Why the rig goes first
The site's first sync with a server imports what it finds there. A new site's first permission read adopts every grant and group on the rig (D198), and RunicNPC profiles are adopted before the first push (D244). So wiping only the site would bring back every change a visitor made on the rig, now owned by the fresh site. The rig has to be back at the demo default set before the new site first talks to it. Then that first import is the reset: the site adopts the default set, and the default set never has to be described twice.
Except for two parts of it (DM26, found on the first restored wipes, 2026-10-27). Permissions, groups and members come back through the import as described. NPC profiles and factions do not: the restored rig's RunicNPC says it is already managed, by the site before this one, so the new site adopts nothing (D244), and its first push sends an empty profile set. Group chat styles are never adopted by the import. These two come from the fixture instead (§7.3): the seed writes them into the site before its first sync, the site pushes them, and the baseline's NPC placements spawn again. The baseline still holds the placements, routes and everything else on the rig.
7.2 The rig: restore a baseline through Pterodactyl (DM3)
The demo rig is the existing rust-oxide Pterodactyl server (DM5). On it sits one archive,
demo-baseline.tar.gz, holding the demo default set:
oxide/data/— permissions and groups, RunicNPC profiles and placements, Economics balances, the bridge plugin's own data;oxide/config/— plugin configs, so even a config that changed some other way comes back;server/<identity>/— the world save andcfg/, so anything an event built or a console command wrote withserver.writecfgcomes back too.
oxide/plugins/ and the sidecar are not in the baseline. Their versions follow the Rust bundle
(§7.5, DM12), not the snapshot.
The workflow uses the Pterodactyl client API (files and power):
POST …/power {"signal":"kill"}— no save, since the world is about to be replaced.POST …/files/deletethe three directories.POST …/files/decompressdemo-baseline.tar.gzat the server root.POST …/power {"signal":"start"}. Rust takes a few minutes to boot; the site does not wait.
Capturing the baseline is a second, hand-run workflow (workflow_dispatch only): someone sets up
the default set through the demo site (the permission groups, a handful of NPCs, events), then runs
it. It stops the server with POST …/power {"signal":"stop"}, which runs the egg's stop command,
quit, and that writes the save, so the subuser needs no console permission (DM22). It then
POST …/files/compresses the three directories, renames the archive to demo-baseline.tar.gz, and
starts the server again. Re-run it whenever the default set should change.
The first wipe on a rig with no baseline yet leaves the rig as it is and only starts it, with a warning in the run. Set the default set up after that first wipe, then capture.
Checked against the panel (2026-10-26), on the offline egg-oxide test server: files/compress
returns the archive's name and size; files/decompress is synchronous (every entry is back when it
answers); files/write takes a raw binary body byte-exact, so the 8 MB sidecar goes up the same way
as a plugin file; one files/chmod per file.
The key. Pterodactyl client keys belong to a user and reach every server that user can see. The
demo gets its own Pterodactyl subuser, with access to rust-oxide only and the file and power
permissions only, and that subuser's client key is the demo-site workflow's secret
(PTERODACTYL_CLIENT_KEY). The panel URL and server identifier are plain variables
(PTERODACTYL_URL, PTERODACTYL_SERVER), defaulting to http://192.168.0.12 and 6eb9d621, the
current rust-oxide. Done: the org lead created the subuser and added the secret on 2026-10-23.
7.3 The site
-
docker compose down -v— drops the database and the uploads volume. -
docker compose pull && docker compose up -d— the app boots on the latestmainimage, creates the schema, and installs the Rust module fromMODULES=rust@<version>=<manifest>, which the workflow writes tostate/modules.env(§7.5). It installs under the allowlist a fresh database seeds fromMODULE_SOURCE_HOSTS, so the demo's.envsets that togitea.whitlocktech.com. -
Wait for the app's healthcheck (the demo compose file's own:
GET /api/v1/public/status). -
docker compose exec app npm run --prefix server seed:demo -- /app/deploy/demo/seed.json(the compose file mounts the fixture there;DEMO_SEED_FILEalso works) — one core script (server/db/seedDemo.js) that, from a fixture file in the deploy directory:- creates the four accounts (§4), or resets them if they exist: password, role, active, 2FA off;
- sets the site live with registration disabled, plus any
settingsthe fixture adds; - writes the seeded site content: news posts, wiki pages, pages and the demo events. It does
this only on the first run against a database, so a re-run never duplicates it. With the
events it switches on, in the events switchboard (EVENTS.md §K), every action their steps
use, uncapped. A fresh database starts every
changeandirreversibleaction switched off, so without this the seeded events could not run (DM28); - hands each installed module its
modules.<id>section through the module'sonDemoSeedhook (DM15,MODULE_API1.18.0). The Rust module writes its own server row, with the rig's sidecar URL and token, anddemo-player's stand-in Steam link (DM6), and the default set's NPC faction table, NPC profiles and group chat styles (DM26, §7.1). Core never writes a module's tables; - stamps
demo_state:resetsAt, the nextDEMO_RESET_EVERY_HOURSslot (default 2) on the hour UTC, and the published logins.
The fixture is JSON:
{ accounts, settings?, content?: { posts, wiki, pages, events }, modules? }. The Rust module's section is{ servers: [{ id, name, sidecarBaseUrl, sidecarToken, protocol?, enabled?, sortOrder? }], links: [{ username, steamId, name?, serverId? }], npcs?: { factions?: [{ a, b, relation }], profiles?: [{ name, servers?, allServers?, killsScope?, body }] }, chat?: [{ serverId, group, style }] }. A profile'sbodylists only what differs from RunicNPC's defaults, and is checked as the admin page checks one. Astylelists only the BetterChat fields that differ from the defaults. Its group is created on the site for that server if the import has not brought it in yet; the import then fills in the rig's title, rank and permissions. Any string"$env:NAME"in it is replaced with that variable from the host's.env, so the sidecar token is never in the repo. An unset variable fails the run instead of seeding an empty credential. The script checks the fixture before writing anything. It must name exactly the four accounts, each with a role and a password of 8 or more characters. It refuses to run at all withoutDEMO_MODE=true. The demo's.envleavesADMIN_USERNAMEunset, so the boot seed creates no fifth admin.There is no seeded team. Core's Teams are synced from the game (a Rust clan becomes a Team), so the demo's team comes from a clan in the rig's baseline (§7.2), not from the fixture.
-
The module's sync connects when the rig finishes booting. Its first import adopts the baseline's permissions, and its first NPC push sends the seeded profiles, which the baseline's placements spawn from (§7.1).
-
Smoke check, after a bounded wait:
GET /api/v1/public/statusanswers, and the Rust server reports a live connection. A rig that has not come back fails the run, and the site still serves with the server shown offline.
7.4 What a visitor sees
The site is down for about a minute; the rig for a few. Signed-in visitors, including app users, are signed out, because their sessions and refresh tokens were in the database.
The same workflow runs on workflow_dispatch and on a push to main that changes deploy/demo/,
so a redeploy is the same path as a wipe.
7.5 Versions: the latest Rust bundle (DM9, DM12)
"Latest" is decided per piece, so the three halves of the Rust link can never disagree on protocol:
| Piece | Follows |
|---|---|
| Website image | website-app:latest, the newest main |
| Rig plugins + sidecar | The Rust bundle's current.json (installer, branch bundles, v2/rust/). A bundle is the protocol-checked pair of Rust-Link and Rust-Plugins that CI already publishes |
| Rust module | The newest Module-Rust release whose protocol equals that bundle's protocol |
The Rust game link has no 409: a plugin on a different protocol would mis-parse, not be refused.
That is why the module is chosen by protocol, not by date.
What the wipe adds. Before §7.2's rig restore, the workflow reads current.json:
- The bundle changed since the last wipe. It downloads the bundle's plugin tarball and sidecar
binary, checks each
sha256, and writes them onto the rig through the same Pterodactyl file API while the server is stopped. That means the plugin files intooxide/plugins/, and the sidecar and itswith-sidecar.shlauncher where the egg expects them (rust-link/, eachchmod 755). It records the bundle instate/bundle.jsonon thedemo-sitehost, which is what "changed" compares against, and in the rig's ownrust-link/bundle.json, so the rig says what it runs. The plugin files are the bridge's tarball and RunicNPC's (payloadandnpc); the other plugins inoxide/plugins/(Kits, ZoneManager and the optional ones) are left alone. - Always. It picks the module release as above, and writes
MODULES=rust@<version>=<manifest>tostate/modules.envin the deploy directory before §7.3. The compose file reads it as a secondenv_file, so the workflow never edits the.envthat holds the secrets.
Module-Rust's release CI adds protocol (read from PROTOCOL_VERSION in
server/sidecarClient.js) to the install manifest it already publishes, so the choice needs no
guessing. Core's installer reads only the fields it names (modules/install.js), so the extra
field needs no core change.
If no module release matches the new bundle yet, the wipe keeps the previous bundle and module, finishes the reset on them, and warns in the run. A half-released protocol bump never takes the demo down.
8. Where it lives
| Piece | Repo |
|---|---|
DEMO_MODE lock, banner field, seed:demo script |
website (core) |
The Rust module's demoLocks, the console-command deny list (§3.4), the config-editor rules (§3.5), and protocol in its install manifest (§7.5) |
Module-Rust |
DEMO_LOCKED error kind and the banner in the app |
Android-app |
deploy/demo/ (compose file, .env.example, seed fixture seed.json, rig.mjs for the rig and the versions, README) and .gitea/workflows/demo-wipe.yml + demo-baseline.yml |
website (DM8) |
The deploy directory, /opt/demo.runicgateway.com (DM23): the copied deploy files, .env, state/ (bundle.json, modules.env, plan.json), modules/ |
The demo-site host |
.env (JWT secret, encryption key, BOT_INTERNAL_KEY, database passwords, the four demo passwords, the stand-in Steam id, the rig's sidecar URL and token) |
On the demo-site host only, never in a repo |
PTERODACTYL_CLIENT_KEY (the demo subuser's key) |
A Gitea Actions secret on website |
demo-baseline.tar.gz |
On the rig, at the Pterodactyl server root |
9. Decisions
9.1 Decisions (org lead, 2026-10-23)
| # | Question | Decision |
|---|---|---|
| DM1 | How the module declares its locked routes | A demoLocks list in module.json; MODULE_API minor bump (§3.2) |
| DM2 | Is anything on Tier A to stay open? | Settings stay open (theme, navigation and the rest of PUT /admin/settings); the rest of Tier A stays locked (§3.3). Confirmed as "settings only" (DM13) |
| DM3 | What may a visitor do to the rig? | Permissions, NPCs and events start from a demo default set, can be changed through the site, and are put back each cycle by a CI script that restores the rig through Pterodactyl (§7.2) |
| DM4 | May visitors publish content? | Yes. Uploads are locked hard (§3.3); the contact form is locked; the site is noindex |
| DM5 | Which rig? | The existing Oxide rig becomes the demo's. A new Oxide server is spun up for testing when a walk needs one |
| DM6 | A Steam link for demo-player? |
Yes, a stand-in Steam ID that has played on the rig |
| DM7 | Cadence | Every 2 hours |
| DM8 | Where the files live | website/deploy/demo/ and website/.gitea/workflows/ |
| DM9 | Version | Latest, at every wipe |
| DM10 | (the org lead, unprompted) | Every lock is at the server route level, never the UI level (§3.1) |
| DM11 | (F1) Console-command steps on the demo | A deny list of the commands that would break the rig (quit, oxide.unload RunicGateway and the like), not an allowlist; everything else runs (§3.4) |
| DM12 | (F2) What "latest" means across the Rust link | The latest Rust bundle for the rig's plugins and sidecar, the newest module release on the same protocol, and the website's main image; the wipe updates the rig when the bundle changes (§7.5) |
| DM13 | (F3) DM2 read correctly? | Yes: only the settings stay open; every other Tier A row stays locked |
| DM14 | (after DM13) May visitors change plugin configs? | Yes, every plugin except the bridge. The bridge's config can be neither read nor written, because it holds credentials that would let someone talk to the sidecar directly (§3.5) |
| DM15 | (build) How does the seed create rows in a module's tables (the Rust server link, demo-player's Steam link)? |
Through a new optional module hook, api.onDemoSeed(data, { accounts }), in the same MODULE_API minor as demoLocks. The module writes its own rows with its own model; core never writes a module's tables (§7.3) |
| DM16 | (build) Settings are open, so player_registration can be switched on. Lock registration? |
Yes: POST /auth/register is a Tier A lock. Invites stay open (§3.3) |
| DM17 | (build) The server cannot tell which mobile session is the caller's own. What does the other-sessions lock do? | DELETE /auth/me/sessions/:id is locked outright on a demo account; POST /auth/mobile/logout stays open (§3.3) |
| DM18 | (build, step 2) Saving an event is core's route, and core never asks a module about a step's params. How is a denied command refused at save? | A new core hook: an event action's optional demoRefuses(params), asked at create, save and publish on the demo, refusing 403 DEMO_LOCKED (§3.4). MODULE_API 1.19.0; Module-Rust then needs ^1.19.0 |
| DM19 | (build, step 4) Each app screen writes its own failure line, and the admin screens turn every 403 into "You don't have permission for that action". How does the app show a refusal? |
One app-wide snackbar with the server's sentence, like the web's toast, and the screens whose 403 line blames the role say the demo sentence instead. ErrorKind.DEMO_LOCKED for load screens (§3.1) |
| DM20 | (build, step 4) Should the app's sign-in screen list the logins, as the web's does? | No, the banner only. The Home strip names the site's sign-in page for the passwords; Google's reviewer gets play-review's from the Play Console (§6) |
| DM21 | (amendment, 2026-10-26) Settings are open, so the hero editor's Publish changes the homepage every visitor sees. What may a visitor do in the hero editor? | Edit and preview, never publish. hero_layout joins the protected settings keys; the draft and Preview stay open; uploads were already locked. Server lock only: Publish stays clickable and answers the demo sentence (§3.3) |
| DM22 | (build, step 5) The subuser has file and power permissions only, so server.save over the console is out of reach. How does the baseline capture save the world? |
A power stop, which runs the egg's quit and writes the save; no console permission (§7.2) |
| DM23 | (build, step 5) Where does the demo live on its host? | /opt/demo.runicgateway.com, the demo-site runner in host mode, like rgcom's /opt/runicgateway.com. The org lead writes .env from deploy/demo/.env.example |
| DM24 | (build, step 5) The module's demo code was on edge only, so no release could be installed. Cut over? |
Yes, now: Module-Rust edge → main releases v0.6.0, the first with protocol in its install manifest |
| DM25 | (amendment, 2026-10-27) Uploads are locked, but an image pasted into rich text is saved as a data: URI inside the JSON body. Block it? |
Yes, on every write and for every data: URI. Core refuses any write whose JSON body has a data: URI in any string, on any route, the same way the multipart rule needs no list (§3.3) |
| DM26 | (build, step 5, 2026-10-27) The restored rig is marked managed, so a fresh site adopts none of its NPC profiles and empties them on its first push; chat styles are never adopted. How does the default set bring them back? | Seed them. Module-Rust's onDemoSeed also takes npcs (factions, profiles) and chat (a style per group), written before the site's first sync (§7.1, §7.3). Patch release v0.6.1 |
| DM27 | (step-5 walk, W4, 2026-10-28) demo-player can remove its stand-in Steam link, and staff can remove it from Admin → Users. Lock it, or leave it to the wipe? |
Lock it, both ways. The player's route through demoLocks; the staff route in its handler, because it serves under core's users mount (§3.3). Module-Rust patch release |
| DM28 | (step-5 walk, W1, 2026-10-28) The seeded events' actions start switched off on a fresh database, so they fail when run. Fix? | Yes. seed:demo switches on every action a seeded event uses, uncapped, with the events (§7.3). Core only, no MODULE_API change |
| DM29 | (step-5 walk, W2, 2026-10-28) SECRET_WORDS also hides harmless fields such as "Allow Master Key" and "TC Auth Only". Narrow it? |
No, leave it as is |
| DM30 | (step 6, 2026-10-28) How does runicgateway.com /beta publish the logins? |
Live from the demo. /beta reads this site's GET /public/settings demo.logins on its server, caches it for a minute, and links to /account/login when the demo does not answer (runicgateway.com D82) |
| DM31 | (step 6) runicgateway.com's "See it running" links were UO routes, which 404 here. What do they do? | Per game. A deep link names its game, and that site's mounted demoGames (["rust"] for this demo) says which games' links show. Rust's point at /rust and /rust/servers/demo?tab=…, so that site now quotes the fixture's server id demo (runicgateway.com D83) |
| DM32 | (2026-10-29) The moderator tour shows empty queues, and other surfaces are empty too (§11). Which get seeded? | All four of §11's groups: moderation history, Teams with their forum and reports, event history with the calendar, and Rust player stats |
DNS and HTTPS for demo.runicgateway.com come from an external reverse proxy, which the org lead is
setting up. The app sits behind it on plain HTTP; core already trusts one proxy hop for
X-Forwarded-* by default (TRUST_PROXY), and the deploy PR checks secure cookies and client IPs
through that proxy.
9.2 Set up by the org lead
- Done 2026-10-23: the Pterodactyl subuser (access to
rust-oxideonly, file and power permissions) and its client key as thePTERODACTYL_CLIENT_KEYActions secret (§7.2). - In progress: DNS and HTTPS through the external reverse proxy.
- To confirm in the deploy phase: the
demo-sitehost reaches the Pterodactyl panel and the rig's sidecar over the network.
10. Build order
-
Core (
website): theDEMO_MODElock (the deny list, themultipart/form-datarule, the demo-account and other-session checks),demoLocksloading,ctx.demoMode, the banner field,seed:demo,noindex, the web banner and toast, theMODULE_APIminor bump, and the OpenAPI note that a route may answerDEMO_LOCKED. Tests for every lock. One PR, plusMODULE_API.mdand this doc. Built 2026-10-23 asMODULE_API1.18.0, withonDemoSeed(DM15), the registration lock (DM16), the protected settings keys (§3.3) and the published logins on the sign-in pages (§4). The code is inserver/src/demo/(mode.js,paths.js,locks.js),server/db/seedDemo.js, and the client'sDemoBanner,DemoToastandDemoLogins.test/demoLocks.test.jsholds every core row againstroutes.manifest.json, so a row that stops naming a real route fails the build. -
Module (
Module-Rust):demoLocksinmodule.json; behindctx.demoMode, the console-command deny list (§3.4) and the config-editor rules (§3.5); andprotocolin the release's install manifest (§7.5). Each with tests. One PR. The save-time half of the deny list needed a core hook first (DM18):demoRefusesinwebsite,MODULE_API1.19.0, its own PR ahead of the module's. Built 2026-10-26: corewebsite#224, moduleModule-Rust#62(coreApi^1.19.0). The module's rules are inserver/demo.js, and its seed hook isserver/demoSeed.js, which readsmodules.rust.serversandmodules.rust.links. A bridge path is recognised after canonicalising it (dropping.segments, trimming, lower-casing), so./RunicGateway.jsoncounts. The plugin nameRunicGatewayis a floor beside the catalogue'sself, so a catalogue that cannot be read never unlocks the file. A config that does not parse is withheld whole on the demo, because its secrets cannot be found to redact. -
Kit (
Integration-kit): a paragraph ondemoLocksandctx.demoMode, and thecore-ref.jsonre-pin. One PR. Built 2026-10-26 asIntegration-kit#17, pinned to the shawebsite#224merged as (1.19.0). Chapter 2 gains a section on a public demo site, which also coversonDemoSeed(DM15), and chapter 5 coversdemoRefuses(DM18). Both hooks were decided after this list was written. Module-Rust's own pin moved to the same sha inModule-Rust#63. -
App (
Android-app): theDEMO_LOCKEDerror kind and the banner on Home. One PR. Built 2026-10-26 asAndroid-app#61, intoedge, with the snackbar (DM19) and the banner only (DM20). The refusal is read incore/result/DemoLocked.ktand spotted bycore/net/DemoLockInterceptor; the strip isui/demo/DemoBanner. Checked on an emulator againstwebsitemainwithDEMO_MODE=trueandseed:demo: the strip, a shared login's username change refused, and the admin site-mode switch refused with the demo sentence in place of the permission line. -
Deployment (
website):deploy/demo/,demo-wipe.yml(with §7.5's bundle step) anddemo-baseline.yml; the first deploy ondemo-site; setting up the default set and capturing the baseline. Then a walk of every static login, every Tier A lock and the upload lock, in the browser and the app, plus one full wipe cycle that restores a changed permission, NPC and event; a denied console command refused at save and at run; and a plugin config changed and reloaded through the editor, with the bridge's config refused for reading and another plugin's secret field redacted; and the hero editor's draft previewed while its Publish is refused (DM21). One PR. Files built 2026-10-26 (DM22-DM24):deploy/demo/and the two workflows. The site half (compose,down -v/up, healthcheck,seed:demowith this fixture and the Rust module at 0.6.0) ran locally against an image built frommain;rig.mjs planandsmokeran against the real Gitea; the Pterodactyl file calls ran againstegg-oxide(§7.2). The first deploy, the baseline and the walk wait on the host's.envandModule-Rustv0.6.0. First wipes 2026-10-27: the host's.envwas written and v0.6.0 released. Wipe 358's rig half worked (bundle 2026.10.22 written, rig restarted), but the seed loaded no module: the runner is not root, so itschownofmodules/andlogs/failed silently. The wipe now does the chown in a root container. The same day, DM25 added thedata:URI rule (§3.3) to the walk's upload checks. Wipe 362 was the first green one. The default set was then built through the site asdemo-admin. It holdsvipand a newmoderatorgroup with chat titles,demo-playerinvip, bandit and guard NPC profiles with an allied faction pair, andguard-1at the bandit camp. It was captured as the baseline (run 366, 499 MB with the world save). The first three restored wipes (367-369) brought back the groups, permissions and members, but not the NPC profiles or the chat titles (DM26). Public URL:demo.runicgateway.comis served through Pangolin. Cloudflare's proxy in front of it made the app log Cloudflare's addresses as the visitors' IPs, under the defaultTRUST_PROXY=1. Cloudflare was set to DNS only, and the app now logs the real visitor. DM26 then seeded the NPC profiles and chat titles, and restored wipes 378 and 381 brought the whole default set back.The walk (2026-10-28), on
websitemain,Module-Rustv0.6.1 and Rust bundle 2026.10.22, with the rig on Rust build 26139947 (the public branch that day):- Every lock, through the API: all 87 checks passed. They cover each Tier A row for all three
published logins, the case and trailing-slash forms, the four upload routes, multipart sent to
an unlisted core route and to a module route, the
data:URI forms (plain,data:, split by a newline, in the hero draft, in a module write), both server-link locks, the bridge's config under four spellings for reading and writing, the file list and write history without the bridge, the Servers view without the sidecar URL, a redacted field, the raw-JSON save, a form edit of a flagged field, andnoindex. Prose containingData:still saves. - The deny list at save: 21 checks. Every §3.4 command was refused in a run step, under
each spelling (
global.,o., upper case, padding), in a teardown command, in a reward step and in an edit of an existing event. The open commands (say,server.writecfg,env.*,weather.*,oxide.grant,kick) all saved. - The deny list at run: this half cannot be reached on the live demo, because every save path
asks first. It was run on a local core instead, on the v0.6.1 release tarball: an event holding
quitwas saved and published withDEMO_MODEoff, the core was restarted with it on, and the run's step failed with the deny sentence before any command was sent. - In the browser, as
demo-player,demo-moderatoranddemo-admin: the strip, the logins and Use buttons, the toast and the inline line on a refused username change, the moderator's queues, and the hero editor saving and previewing its draft while Publish is refused. The config editor left the bridge out and showed a redacted field read-only. A config change to AutoDoors saved, reloaded the plugin on the rig and appeared in the history. The browser half ran against the host's LAN address, because Chrome on the walking machine would not open the public name, althoughcurland the emulator did. - In the app (
Android-appedge, debug build, public HTTPS address): the Home strip, sign-in asdemo-admin, and the maintenance switch refused with the inline line and the snackbar. - One wipe cycle: before the 08:00 UTC wipe the walk changed AutoDoors' config, took a
permission out of
vipand retitled it, deleted theguard-1placement, edited the greeting event, added to the hero draft and enabled an event action. After the wipe all of them were back as the default set has them, with all four NPCs alive. The site reseeded at 08:07 and the rig was fully back at 08:17.
Found by the walk (the org lead's answers, 2026-10-28):
- The seeded events could not run. A fresh database has every event action switched off, so Server greeting, Treasure drop and Payday showed as ready but failed when started ("not enabled on this deployment"). Fixed (DM28): the seed switches on the actions they use.
- The credential check over-flags.
SECRET_WORDSmatches words such askeyandauthinside longer names ("Allow Master Key", "TC Auth Only", a drone preset key), so on the demo those harmless fields are hidden and read-only. Left as is (DM29). play-reviewwas not walked: its password is only in the host's.env. The org lead read it there and handed it over, and it was walked with the re-check below.demo-playercould remove its stand-in Steam link, and staff could remove it for them. Locked (DM27).- The public URL did not open in Chrome on the walker's PC, while curl and the emulator reached it; the org lead opened it through the proxy without trouble, so it is local to that PC.
- Nothing seeds the moderation queues, so the moderator tour shows empty lists.
Re-checked after the fixes (2026-10-28, the 12:00 UTC wipe, Module-Rust v0.6.3, whose run tagged a v0.6.2 first and then failed to publish it):
play-review, 19/19. It is not among the published logins; it signs in as admin and reads settings, users, servers and events; its own username, password, email, two-factor, recovery codes and sessions are refused, as are the site's admin locks. The app's path works too: mobile sign-in, a bearer read, a refused lock and sign-out. Its web sign-in was checked through the API, which is the call the page makes.- Seeded events, W1. The three actions are on, uncapped, straight after the wipe, and Server greeting ran to completion.
- Steam link, W4.
demo-player's unlink and staff's unlink both answer403 DEMO_LOCKED, and the link stays.
- Every lock, through the API: all 87 checks passed. They cover each Tier A row for all three
published logins, the case and trailing-slash forms, the four upload routes, multipart sent to
an unlisted core route and to a module route, the
-
Play: the sign-in details in the Play Console point at
play-review; runicgateway.com/betapublishes the logins. Site side built 2026-10-28 inrunicgateway.com(DM30, DM31)./betalists the published logins and the next wipe as this site serves them, marks its "somewhere to point it" gate in place, and names the host to type into the app. The homepage,/features/and/modules/rust/link into the demo, and UO's links stay hidden. Against the live demo: three logins listed (play-reviewnot among them), the 22:00 UTC wipe shown, seven Rust links and three core links filled, and/uo/guildsleft empty. It goes live when that site's mountedbrand.jsongains"demoUrl": "https://demo.runicgateway.com"and"demoGames": ["rust"]and its container restarts. Play Console: the Sign in details declaration (formerly App access) is filled in but not saved: Yes, restricted, the name Admin account (demo site), the usernameplay-review, and instructions (enterdemo.runicgateway.comon the first screen, the two-hour reset, the refused locks). The org lead types the password, ticks the full-access attestation and saves. The closed track does not exist yet (Test and release shows Get started for closed and internal testing), so there is no opt-in URL forbetaOptInUrl, and/beta's first gate stays shut. -
Demo content (DM32, §11): the seed's two passes and the rig's clans. Planned 2026-10-29. One PR in
website(core seed, the wipe's second pass), one inModule-Rust(onDemoSeedstats and feed lines, intoedge), a new baseline, and a walk of every surface §11 names after the next wipe.
11. Demo content (DM32)
Why. A read-only count of the live demo on 2026-10-29 found these surfaces empty after a wipe:
every moderation queue (recent actions, filter hits, spam hits, member events, appeals, reports),
Teams (public, the player's own, staff's review queue), event runs and history, the public calendar
(the three seeded events are never scheduled), demo-player's inbox, and on the Rust side the
leaderboard, wipes, clans and the events feed. Only the four demo accounts exist. A reviewer touring
as demo-moderator finds nothing to moderate.
11.1 Two seed passes
Some of it can be written the moment the site is up. The rest depends on the rig: Teams are
module-authoritative and re-synced every 15 minutes (TEAMS.md), so a Team written straight
into core would be archived by the next sync, and the Rust stats need the wipe the rig is on. So:
- Pass 1,
seed:demo, as today: accounts, settings, content, events, and the new core rows below that need nothing from the rig. - Pass 2,
seed:demo --after-rig, a new step indemo-wipe.ymlafter the rig reports in and the first Teams sync has run: Team forum content, reports, Rust stats and feed lines. It waits for the Teams it names, with a time limit, and fails the wipe if they never appear, as the rig smoke check does.
The rig's wipe id comes from the restored world save, so it is the same on every wipe
(w-20261005T165054Z today). Pass 2 reads it from the server state rather than the fixture, so a
new baseline with a new map still works.
11.2 What each group seeds
- Moderation history (pass 1, core). Discord-shaped rows for six invented members: a few
mod_actions(warn, timeout, ban),warnings,filter_hitsandspam_hits, join and leavemember_events, staffmod_notes(one admin-only), and threeappeals: one pending to claim, one under review, one resolved. Times are relative to the seed, so the 24 h / 7 d / 30 d counts are never zero. There is no bot on the demo, so approving an appeal records the reversal as failed. That is the shipped behaviour when the bot is down, and the appeal still closes. - Teams, forum and reports. Two or three clans in the rig's baseline (the Clans plugin's
data, which already reports an empty board), with stand-in members and
demo-player's stand-in Steam ID in one, so sync brings them in as Teams. One clan's name contains a reserved term, so it lands in staff's review queue. Pass 2 then writes forum threads and posts in them, and two open reports. Once report, block and hide exist (USER_SAFETY.md), it also writes reports on chat lines. - Event history and the calendar (pass 1, core). Two finished runs of the seeded events, with
their steps, log, participants (stand-ins, and
demo-playerthrough its link) and published results, so the history and results pages fill anddemo-player's inbox has the results notification. The three seeded events are scheduled in the coming days, so the public calendar shows them. - Rust player stats (pass 2, Module-Rust
onDemoSeed). About ten stand-in players with wipe stats (kills, deaths, NPC kills, playtime, gather totals, weapon kills) on the rig's current wipe, an earlier wipe for the Wipes tab, and a day of feed lines (kills, joins, chat), so the leaderboard, profiles and feed are populated. The online list stays honest at zero.
11.3 Open questions
- Stand-in Steam IDs. Every 17-digit
7656119…number can belong to a real person, and these would appear on public leaderboards. Recommended: check each candidate against Steam's public profile page before using it, keep only IDs with no profile, and list them in the fixture with that check recorded.demo-player's existing stand-in gets the same check. - Invented Discord IDs. The moderation pages show Discord user ids. Recommended: synthetic ids from a block well below any issued since 2016, named in the fixture as demo data. Nothing on the demo links them to Discord.
- Names. Recommended: invented gamer tags ("Rusty_Pete", "Scrap Baron", …), never a real person's name, and nothing offensive even in the seeded filter hits, which show a placeholder word.