The org lead's answers of 2026-10-23: demoLocks in module.json, settings open, the rig open but reset each cycle through Pterodactyl, content open with uploads locked hard, the Oxide rig as the demo's, a stand-in Steam link, every 2 hours, files in website/, latest versions, and every lock at the server route level. Adds the rig reset (§7.1-7.2): the site's first sync imports what is on the rig (D198, D244), so the rig is restored from a baseline archive before the site relinks, and that first import is the reset. Adds the hard upload rule (any multipart body refused). Three follow-ups: F1 console-command steps, F2 "latest" across the bundle, F3 reading of DM2. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
19 KiB
Demo mode — demo.runicgateway.com
Status: design. The org lead answered §9 Q1-Q9 on 2026-10-23 (recorded as decisions DM1-DM10
in §9); three follow-up questions in §9.2 remain. Nothing is built.
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 maps the code to a new error kind with the same wording; today it would show the generic "Something went wrong", which is misleading for a reviewer.
Reads are never locked. 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": "POST", "path": "/admin/rust/config/:serverId/file" }
]
Paths use the same :param syntax as the module's routers and must sit under the module's own
mounts, which the loader checks the same way it checks mounts. A module without the field is
unaffected. It is a new manifest field, so MODULE_API_VERSION gets a minor bump,
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 for a session that is not the caller's |
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 |
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 |
| Plugin config files | POST /admin/rust/config/:serverId/file |
Writes a plugin's config file on the rig's disk |
Settings stay open (DM2). Theme, navigation and the other PUT /admin/settings values can be
changed and come back at the wipe. Brand images are uploads, so they fall under the upload lock.
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.
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 are the exception that needs a rule of their own (§9.2 F1).
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.
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 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 } while the flag is on. 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 …". The app shows the same strip on Home. Both read resetsAt, so neither
needs to know the schedule.
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.
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/ is not in the baseline. Plugin versions are the deployment's business (§9.2
F2), not the wipe's.
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 saves the world (server.save over the console), stops the server, POST …/files/compresses
the three directories into demo-baseline.tar.gz, and starts the server again. Re-run it whenever
the default set should change.
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.
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>(DM9, §9.2 F2).- Wait for the app's healthcheck.
docker compose exec app npm run seed:demo— one core script that, from a fixture file in the deploy directory:- creates the four accounts (§4) and
demo-player's stand-in Steam link (DM6); - creates the Rust server row with the rig's sidecar URL and token taken from
.env, so the link survives every wipe without being stored in the fixture; - writes the seeded site content: news posts, wiki pages, the demo events and a team;
- stamps
resetsAtfor the banner.
- creates the four accounts (§4) and
- The module's sync connects when the rig finishes booting, and its first import adopts the baseline's permissions and NPCs (§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.
8. Where it lives
| Piece | Repo |
|---|---|
DEMO_MODE lock, banner field, seed:demo script |
website (core) |
The Rust module's demoLocks, and the console-command rule (§9.2 F1) |
Module-Rust |
DEMO_LOCKED error kind and the banner in the app |
Android-app |
deploy/demo/ (compose file, .env.example, seed fixture) and .gitea/workflows/demo-wipe.yml + demo-baseline.yml |
website (DM8) |
.env (JWT secret, encryption key, the rig's sidecar 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 and open questions
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). See F3 |
| 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) |
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 Follow-up questions
F1 — Console-command steps on the demo.
Example: demo-admin adds a console-command step with quit to an event and starts it. The Rust
server stops and stays down until the next wipe, up to two hours, for every visitor. Other commands
do the same more quietly: oxide.unload RunicGateway cuts the site off from the rig, and
kickall and ban affect anyone playing. The command box is free text with no allowlist today
(Rust §16).
- (a) Recommended: on the demo, the module accepts a command step or command reward only if its
command is on a short allowlist, for example
say,env.time,weather.*andserver.time. The module checks it when the event is saved and when the step runs, so it is a server-side rule (DM10). Everything else in events stays open. - (b) Refuse command steps and command rewards entirely on the demo.
- (c) Leave them open; the wipe is the cleanup, and the rig may be down until it.
F2 — "Latest" across the website, the module and the rig. Example: Module-Rust ships a release that needs protocol 15 while the rig's bridge plugin still speaks 14. At the next wipe, the demo pulls the new module, and the site refuses the rig until someone updates the plugins.
- (a) Recommended: the demo follows the latest Rust bundle, the protocol-checked set of
module, sidecar and plugins that CI already publishes. When the bundle changes, the wipe also
puts the bundle's plugins and sidecar onto the rig through the same Pterodactyl file API. The
website image always follows
main. - (b) Latest module at every wipe, and the rig's plugins updated by hand when a release needs it.
F3 — DM2, read correctly? "Stay open" is read as: the settings (theme, colours, navigation, site texts) stay open, and every other Tier A row stays locked, including site mode, modules, SSO, email, Discord, the demo accounts' credentials and 2FA, and the rig link. Is that right?
For the org lead to set up (not questions): a Pterodactyl subuser with access to rust-oxide
only (file and power permissions), and its client key as the PTERODACTYL_CLIENT_KEY secret on
website (§7.2). Also confirm that the demo-site host can reach the Pterodactyl panel and the
rig's sidecar over the network.
10. Build order, once §9.2 is answered
- Core (
website): theDEMO_MODElock (deny list, themultipart/form-datarule, the demo-account and other-session checks),demoLocksloading, the banner field,seed:demo,noindex, the web banner and toast,MODULE_APIminor bump, and the OpenAPI note that a route may answerDEMO_LOCKED. Tests for every lock. One PR, plusMODULE_API.mdand this doc. - Module (
Module-Rust):demoLocksinmodule.json, and the F1 rule with its tests. One PR. - Kit (
Integration-kit): thedemoLocksparagraph and thecore-ref.jsonre-pin. One PR. - App (
Android-app): theDEMO_LOCKEDerror kind and the banner on Home. One PR. - Deployment (
website):deploy/demo/,demo-wipe.ymlanddemo-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. One PR. - Play: the sign-in details in the Play Console point at
play-review; runicgateway.com/betapublishes the logins.