diff --git a/README.md b/README.md index ef3a827..a4b9b1c 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ sidecar as a service, and hands you the values the website needs. | [SHARD_VISIBILITY.md](website/SHARD_VISIBILITY.md) | Who sees which shard data — the admin-configurable audience framework | | [TRUSTED_DEVICES_MFA.md](website/TRUSTED_DEVICES_MFA.md) | TOTP two-factor, trusted devices and recovery codes | | [STEAM_SIGNIN.md](website/STEAM_SIGNIN.md) | **Plan, built 2026-10-19** — Sign in with Steam as a built-in provider beside Google and Discord (OpenID 2.0), and a Steam id for modules | +| [DEMO_MODE.md](website/DEMO_MODE.md) | **Design, fully decided 2026-10-23** — the public `demo.runicgateway.com`: one flag that locks destructive actions, static logins, and a wipe every couple of hours (website#222) | | [MODERATION_APPEALS.md](website/MODERATION_APPEALS.md) | Moderation actions, content reports and the appeals flow | | [SPAWN_ATLAS.md](website/SPAWN_ATLAS.md) | The bestiary / spawn atlas: what the shard contains, parsed from the shard's own ServUO files — served over the bridge since Protocol 8, so no shared filesystem | | [CLILOCS.md](website/CLILOCS.md) | UO's id → name table, so items have names. The shard decompresses and serves it over the bridge; the desktop conversion it replaced is gone | diff --git a/website/DEMO_MODE.md b/website/DEMO_MODE.md new file mode 100644 index 0000000..b851b0a --- /dev/null +++ b/website/DEMO_MODE.md @@ -0,0 +1,407 @@ +# Demo mode — `demo.runicgateway.com` + +**Status:** design, **fully decided** 2026-10-23 (DM1-DM14, §9). Ready to build per §10. Nothing is +built yet. +**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: + +```json +"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 | +| 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` 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 | +| 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. 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 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. + +The module learns that it runs on the demo from a new read-only `ctx.demoMode` boolean. That is part +of the same `MODULE_API` minor bump as `demoLocks` (§3.2). 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 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 }` 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//` — 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/compress`es +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@=`, which the workflow writes (§7.5). +3. Wait for the app's healthcheck. +4. `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 `resetsAt` for the banner. +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@=` + 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) | + +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. +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. +3. **Kit** (`Integration-kit`): a paragraph on `demoLocks` and `ctx.demoMode`, and the + `core-ref.json` re-pin. One PR. +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.