diff --git a/website/BACKEND_DESIGN.md b/website/BACKEND_DESIGN.md
index fc90fa5..8ed257f 100644
--- a/website/BACKEND_DESIGN.md
+++ b/website/BACKEND_DESIGN.md
@@ -1834,7 +1834,10 @@ it.
A sentence back refuses the write `403 { code: "DEMO_LOCKED", message, errors }` with that sentence; a
hook that throws refuses too.
- **Uploads.** Any request with a `multipart/*` content type is refused app-wide, before a router or
- multer sees it.
+ multer sees it. So is any write whose JSON body has a `data:` URI in any string (DM25): a pasted
+ image is an upload that did not use multipart. The string is read as a browser reads an attribute
+ (character references decoded, tabs and newlines removed); "metadata:" and prose such as
+ "Data: none, yet" pass. Matching rules are in DEMO_MODE.md §3.3.
- **Search engines.** Every response carries `X-Robots-Tag: noindex, nofollow`. `/robots.txt` is
`Disallow: /`, served ahead of the SPA's static file.
- **`GET /public/settings`** gains `demo: { resetsAt, logins }`, from the `demo_state` settings row.
diff --git a/website/DEMO_MODE.md b/website/DEMO_MODE.md
index 2646ab5..9a50a45 100644
--- a/website/DEMO_MODE.md
+++ b/website/DEMO_MODE.md
@@ -151,6 +151,20 @@ next wipe.
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 `
` 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
@@ -287,6 +301,14 @@ now owned by the fresh site. The rig has to be back at the demo default set **be
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,
@@ -347,13 +369,19 @@ current `rust-oxide`. **Done:** the org lead created the subuser and added the s
this only on the first run against a database, so a re-run never duplicates it;
- hands each installed module its `modules.` 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;
+ and token, and `demo-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 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? }] }`.
+ enabled?, sortOrder? }], links: [{ username, steamId, name?, serverId? }], npcs?: { factions?:
+ [{ a, b, relation }], profiles?: [{ name, servers?, allServers?, killsScope?, body }] }, chat?:
+ [{ serverId, group, style }] }`. A profile's `body` lists only what differs from RunicNPC's defaults,
+ and is checked as the admin page checks one. A `style` lists 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
@@ -363,8 +391,9 @@ current `rust-oxide`. **Done:** the org lead created the subuser and added the s
**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).
+5. 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).
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.
@@ -456,6 +485,8 @@ down.
| 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 |
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
@@ -517,5 +548,17 @@ through that proxy.
module at 0.6.0) ran locally against an image built from `main`; `rig.mjs plan` and `smoke` ran
against the real Gitea; the Pterodactyl file calls ran against `egg-oxide` (§7.2). The first
deploy, the baseline and the walk wait on the host's `.env` and `Module-Rust` v0.6.0.
+ **First wipes 2026-10-27:** the host's `.env` was 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 its `chown` of `modules/` and `logs/` failed silently. The wipe now does
+ the chown in a root container. The same day, DM25 added the `data:` 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 as `demo-admin`. It holds `vip` and a new `moderator` group with chat titles, `demo-player` in
+ `vip`, bandit and guard NPC profiles with an allied faction pair, and `guard-1` at 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.com` is served through Pangolin. Cloudflare's
+ proxy in front of it made the app log Cloudflare's addresses as the visitors' IPs, under the
+ default `TRUST_PROXY=1`. Cloudflare was set to DNS only, and the app now logs the real visitor.
6. **Play**: the sign-in details in the Play Console point at `play-review`;
runicgateway.com `/beta` publishes the logins.