From 8e5d2f5f4edab9466428b69c4c0430c350a11059 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 27 Oct 2026 13:13:54 -0500 Subject: [PATCH 1/2] docs(website): DM25 data: URI lock on the demo + first-wipe notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DEMO_MODE.md §3.3 gets the data: URI rule (a pasted image is an upload), §9.1 records DM25, and §10 step 5 records the first wipes: the rig half worked, and the seed loaded no module because the runner's chown of modules/ and logs/ failed silently. BACKEND_DESIGN.md §5.1 mirrors the rule. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- website/BACKEND_DESIGN.md | 5 ++++- website/DEMO_MODE.md | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) 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..910b57b 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 @@ -456,6 +470,7 @@ 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) | 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 +532,10 @@ 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. 6. **Play**: the sign-in details in the Play Console point at `play-review`; runicgateway.com `/beta` publishes the logins. From 5855bcc5edc37b2f3417a7fa654f1606c77667d7 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 27 Oct 2026 20:17:31 -0500 Subject: [PATCH 2/2] docs(website): DM26, NPCs and chat titles come from the demo seed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first restored wipes brought the default set's permissions back but not its NPC profiles (the restored rig is marked managed, so a fresh site adopts none and its first push empties them) or its chat titles (never adopted). §7.1 records the exception, §7.3 the fixture's new npcs and chat keys, step 5 of §7.3 the NPC push, §9.1 DM26, and §10 step 5 the default set, the baseline, the restored wipes and the public URL (Cloudflare set to DNS only so the app logs the real visitor). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- website/DEMO_MODE.md | 33 ++++++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/website/DEMO_MODE.md b/website/DEMO_MODE.md index 910b57b..9a50a45 100644 --- a/website/DEMO_MODE.md +++ b/website/DEMO_MODE.md @@ -301,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, @@ -361,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 @@ -377,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. @@ -471,6 +486,7 @@ down. | 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 @@ -536,6 +552,13 @@ through that proxy. 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. + 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.