Files
docs/website/DEMO_MODE.md
wtclaude f80c8cd939 docs(website): demo mode step 3 built (kit re-pin to 1.19.0)
DEMO_MODE.md §10 step 3 is Integration-kit#17. The kit pins the sha
website#224 merged as. Chapter 2 gains a section on a public demo site,
covering demoLocks, ctx.demoMode and onDemoSeed. Chapter 5 covers
demoRefuses. Module-Rust's pin moves to the same sha in Module-Rust#63.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-26 05:59:14 -05:00

32 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 is step 2 (the module, with its core hook MODULE_API 1.19.0, DM18); steps 3-6 are not. 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:

  1. Static logins. Ready-made accounts (player, moderator, admin, and one for Google's reviewer) whose names and passwords are published.
  2. 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.
  3. 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, 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" }
]

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
Email 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_* or demo_* That route writes arbitrary keys, so these would get around the site-mode, Modules and team-voice rows above, or rewrite the banner and the published logins. 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)

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.

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.

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_LOCKED with 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 RunicGateway plugin 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 …/file and 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 (text instead of edits), 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
Email 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. 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.

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 and cfg/, so anything an event built or a console command wrote with server.writecfg comes 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):

  1. POST …/power {"signal":"kill"} — no save, since the world is about to be replaced.
  2. POST …/files/delete the three directories.
  3. POST …/files/decompress demo-baseline.tar.gz at the server root.
  4. 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. Done: the org lead created the subuser and added the secret on 2026-10-23.

7.3 The site

  1. docker compose down -v — drops the database and the uploads volume.

  2. docker compose pull && docker compose up -d — the app boots on the latest main image, creates the schema, and installs the Rust module from MODULES=rust@<version>=<manifest>, which the workflow writes (§7.5).

  3. Wait for the app's healthcheck.

  4. docker compose exec app npm run seed:demo -- <fixture.json> (or DEMO_SEED_FILE) — 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 settings the 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;
    • hands each installed module its modules.<id> section through the module's onDemoSeed hook (DM15, MODULE_API 1.18.0). The Rust module writes its own server row, with the rig's sidecar URL and token, and demo-player's stand-in Steam link (DM6). Core never writes a module's tables;
    • stamps demo_state: resetsAt, the next DEMO_RESET_EVERY_HOURS slot (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? }] }. 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 without DEMO_MODE=true. The demo's .env leaves ADMIN_USERNAME unset, 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.

  5. The module's sync connects when the rig finishes booting, and its first import adopts the baseline's permissions and NPCs (§7.1).

  6. Smoke check, after a bounded wait: GET /api/v1/public/status answers, 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:

  1. 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 into oxide/plugins/, and the sidecar and its with-sidecar.sh launcher where the egg expects them. It records the bundle tag on the demo-site host.
  2. Always. It picks the module release as above, and writes MODULES=rust@<version>=<manifest> into the deploy directory's .env before §7.3.

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) 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

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

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-oxide only, file and power permissions) and its client key as the PTERODACTYL_CLIENT_KEY Actions secret (§7.2).
  • In progress: DNS and HTTPS through the external reverse proxy.
  • To confirm in the deploy phase: the demo-site host reaches the Pterodactyl panel and the rig's sidecar over the network.

10. Build order

  1. Core (website): the DEMO_MODE lock (the deny list, the multipart/form-data rule, the demo-account and other-session checks), demoLocks loading, ctx.demoMode, the banner field, seed:demo, noindex, the web banner and toast, the MODULE_API minor bump, and the OpenAPI note that a route may answer DEMO_LOCKED. Tests for every lock. One PR, plus MODULE_API.md and this doc. Built 2026-10-23 as MODULE_API 1.18.0, with onDemoSeed (DM15), the registration lock (DM16), the protected settings keys (§3.3) and the published logins on the sign-in pages (§4). The code is in server/src/demo/ (mode.js, paths.js, locks.js), server/db/seedDemo.js, and the client's DemoBanner, DemoToast and DemoLogins. test/demoLocks.test.js holds every core row against routes.manifest.json, so a row that stops naming a real route fails the build.
  2. Module (Module-Rust): demoLocks in module.json; behind ctx.demoMode, the console-command deny list (§3.4) and the config-editor rules (§3.5); and protocol in 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): demoRefuses in website, MODULE_API 1.19.0, its own PR ahead of the module's. Built 2026-10-26: core website#224, module Module-Rust#62 (coreApi ^1.19.0). The module's rules are in server/demo.js, and its seed hook is server/demoSeed.js, which reads modules.rust.servers and modules.rust.links. A bridge path is recognised after canonicalising it (dropping . segments, trimming, lower-casing), so ./RunicGateway.json counts. The plugin name RunicGateway is a floor beside the catalogue's self, 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.
  3. Kit (Integration-kit): a paragraph on demoLocks and ctx.demoMode, and the core-ref.json re-pin. One PR. Built 2026-10-26 as Integration-kit#17, pinned to the sha website#224 merged as (1.19.0). Chapter 2 gains a section on a public demo site, which also covers onDemoSeed (DM15), and chapter 5 covers demoRefuses (DM18). Both hooks were decided after this list was written. Module-Rust's own pin moved to the same sha in Module-Rust#63.
  4. App (Android-app): the DEMO_LOCKED error kind and the banner on Home. One PR.
  5. Deployment (website): deploy/demo/, demo-wipe.yml (with §7.5's bundle step) and demo-baseline.yml; the first deploy on demo-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. One PR.
  6. Play: the sign-in details in the Play Console point at play-review; runicgateway.com /beta publishes the logins.