feat(demo): list the demo's sign-ins on /beta and link into it per game #42

Merged
whitlocktech merged 1 commits from feat/demo-live into main 2026-10-29 05:12:43 -05:00
18 changed files with 650 additions and 63 deletions

View File

@@ -244,7 +244,7 @@ recolours the site and leaves every logo alone. `PLAN.md` §7 is the full accoun
```
brand/
brand.json Site name, tagline, contact address, Discord invite, Gitea org,
demo URL, Play opt-in URL
demo URL and the games it runs, Play opt-in URL
logo.png ONE raster. Every size the site asks for — header at three pixel
ratios, install icons, apple-touch, favicons, a real .ico — is
derived from it on demand
@@ -264,6 +264,14 @@ mounted site name has to reach forty-nine pages that were rendered before the fi
day the Play closed test opens, pasting the URL into `brand/brand.json` puts a working link on the
confirmation screen on the very next request.
**The public demo** is two fields. `demoUrl` shows the homepage's "See it running" button and the
core features' links into it; `demoGames` lists the game modules it runs (`["rust"]` for
`demo.runicgateway.com`), and only those games' links show. Both need the restart. `/beta` also
reads `demoUrl` live: it fetches the demo's published sign-ins from
`<demoUrl>/api/v1/public/settings` and lists them, so the container must be able to reach the
demo's public address. If it cannot, `/beta` links to the demo's sign-in page instead and logs
`[beta] could not read the demo's sign-ins`.
To see which source answered a given asset:
```bash

57
PLAN.md
View File

@@ -232,12 +232,12 @@ Taken by the org lead (Colby Whitlock) on 2026-08-19. Recorded so they are not r
| **D9** | **No analytics.** No tracking scripts, no third-party requests, no cookie banner. | Reverse-proxy access logs are the only traffic data. |
| **D10** | **Support routes to Discord and Gitea issues.** Invite: `https://discord.gg/t2Jav8yT4g`. | §10. Discord is the front door; Gitea takes issues from anyone with an account, which today is nobody outside the org — §14, N3. |
| **D11** | **Keep the existing emblem.** `runic-emblem.png` is the mark on both the website and the Android launcher icon; the site adopts it rather than drawing a new one. | §11. Revision 1's "draw a new geometric mark" recommendation is withdrawn. |
| **D12** | **A public demo instance is planned but out of scope today** — a Proxmox VM running the full stack including ServUO, with restricted settings and an hourly automatic reset. | §15. The IA reserves the slot so it lands later without a restructure. |
| **D12** | **A public demo instance is planned but out of scope today** — a Proxmox VM running the full stack including ServUO, with restricted settings and an hourly automatic reset. | §15. The IA reserves the slot so it lands later without a restructure. **Built 2026-10-27** as `demo.runicgateway.com`, on Rust with a two-hour reset; the site reaches it through D82 and D83 (§15). |
| **D13** | **Publish the existing address.** No mailbox has been created at the domain and the org lead elected not to wait for one: `whitlocktech@gmail.com` is the published contact wherever the site needs one — `/privacy`, `/terms`, `/community`, the Play listing — and `docs/SECURITY.md` keeps the address it already carries. | Taken 2026-08-19, superseding §14 N2 as a blocker. The address lives **only** in `brand.json` (§7), never in prose, so moving to `privacy@`/`security@` later is a file edit and a restart. Phase 0's PR 0.10 is withdrawn, not deferred. |
**Decisions after D13 are recorded where they were taken**, in the section describing the phase that
raised them, rather than appended here — a decision is only re-litigated when its reasoning is
somewhere other than the thing it decided. The count of record is **seventy-nine**:
somewhere other than the thing it decided. The count of record is **eighty-three**:
| # | Where | What it settled |
|---|---|---|
@@ -261,6 +261,8 @@ somewhere other than the thing it decided. The count of record is **seventy-nine
| D70–D71 | §16, "How phase 15 built it" | The Rust group is fifteen pages, one per admin screen where it can be; the Rust event kinds are checked against the module's own classification |
| D72–D74 | §16, "How phase 16 built it" | The RunicNPC subgroup is eight pages with a shared profile-field reference; the API check compares signatures and every data shape's field names; the site's API page carries the current API only, with no decision numbers and no version history |
| D75–D79 | §16, "How phase 17 built it" | Core marketing pages describe what a game module adds in core's words and link to each game's page; the homepage keeps its game screenshot, labelled; `/app/` describes the Rust screens, released the same day; `/runicnpc/` is in the header; `checkTerms.mjs` gains a pass over the built pages |
| D80–D81 | §16, "How phase 18 built it" | A name in a screenshot is a role; the Rust captures' players are mock data |
| D82–D83 | §15, "How the site reaches it" | `/beta` lists the demo's sign-ins as the demo publishes them; a demo deep link names its game and shows only for the games the mounted `demoGames` lists |
---
@@ -1807,9 +1809,56 @@ whether or not N3 is actioned; only one sentence changes.
---
## 15. Planned, not in scope: the demo instance
## 15. The demo instance
D12. Recorded now so the IA reserves room for it and nothing has to be restructured later.
D12. Recorded on 2026-08-19 so the IA reserved room for it and nothing had to be restructured
later. **It exists now:** `https://demo.runicgateway.com`, built in `website` and live since
2026-10-27. Its design of record is `docs/website/DEMO_MODE.md` (DM1-DM29), not this section, and
it came out different from the plan below in three ways that matter to this site:
- **It runs Rust, not Ultima Online.** The site is the normal `website` image with the Rust module,
linked to the org's Oxide test server. So the `/uo/…` deep links D25 wrote have nothing to open
there.
- **It resets every two hours, not hourly, and not by reverting a VM.** A scheduled workflow
restores the game server from a baseline, recreates the site's database, and seeds it again
(`DEMO_MODE.md` §7). The banner gives the next reset in UTC.
- **Its sign-ins are generated on its host.** The passwords exist only in the demo's own `.env`
and in what the demo publishes: with `DEMO_MODE` on, `GET /api/v1/public/settings` answers
`demo.logins`, which is what its sign-in page lists.
### How the site reaches it (2026-10-28)
`DEMO_MODE.md` §10 step 6 says "runicgateway.com `/beta` publishes the logins". The org lead took
two decisions to do that:
**D82 — `/beta` lists the demo's sign-ins as the demo publishes them.** With `demoUrl` mounted,
`/beta` (already rendered per request) reads the demo's `GET /api/v1/public/settings` on the
server, caches the answer for a minute, and lists each `demo.logins` row with the next wipe
time. A copy pasted into `brand.json` was the alternative, and it is one regenerated `.env` away
from publishing passwords that do not work. The read is this server's, not the visitor's, so D9
and the CSP are unchanged. It never fails the page: the demo is down for a few minutes at every
wipe, and then (or after 1.5 seconds without an answer) `/beta` links to the demo's own sign-in
page instead, and asks again 15 seconds later. Every field is shape- and length-checked before
it is rendered. Gate two ("somewhere to point it") reads as in place, with the demo's host name
to type into the app's first screen. `lib/demoLogins.mjs` holds it; `test/demo.test.mjs` tests it.
**D83 — a demo deep link names its game, and shows only for the games the demo runs.** A link
into a game module's pages carries `data-demo-game` after `data-demo-path`. A new mounted list,
`demoGames` (stock `[]`), names the modules the demo runs, and `applyBrand.mjs` fills a game's
links only for a game listed there. Core's links (`/wiki`, `/site/events`, `/site/news`) carry no
game and follow `demoUrl` alone. The Rust features gain links into the demo's server page
(`/rust`, and `/rust/servers/demo` with its `?tab=` for the feed, leaderboard, online, map,
wipes and clans). `demo` is the server's id, fixed by the demo's seed fixture. UO's links stay
in the markup and stay hidden, so a UO demo later is one more entry in the list. `checkBrand.mjs`
holds `demoGames` to the games in `games/index.mjs` and checks that `DEEP_LINK` reads the game.
The `public-demo` entry in `notBuilt.mjs` is gone, because its `resolvedBy` was met.
**What the operator mounts:** `"demoUrl": "https://demo.runicgateway.com"` and
`"demoGames": ["rust"]` in the site's `brand.json`, then a restart. `/beta` follows `demoUrl` on
the next request; the prerendered pages follow both at the restart.
### The plan as written (2026-08-19)
**Shape:** a VM on the org lead's Proxmox host running the full stack — website, `module-uo`, the
uo-link sidecar and a real ServUO shard — so an evaluator clicks one link and sees the actual

View File

@@ -23,13 +23,22 @@
"giteaOrg": "https://gitea.whitlocktech.com/RunicGateway",
"$comment_demo": [
"§15 / D12. A public demo instance is planned and out of scope today. The homepage's",
"'See it running' slot and /features/'s per-capability affordance render only when this",
"is a non-empty URL, so the site gains a working demo by way of one line in a mounted",
"file — no rebuild, consistent with §7."
"§15 / D12. The public demo's address, e.g. https://demo.runicgateway.com. The",
"homepage's 'See it running' slot and /features/'s per-capability affordance render only",
"when this is a non-empty URL, so the site gains a working demo by way of one line in a",
"mounted file — no rebuild, consistent with §7. /beta also reads the demo's published",
"sign-ins from it, on the next request (D82)."
],
"demoUrl": "",
"$comment_demoGames": [
"D83. The game modules the demo at demoUrl runs, by module id (\"rust\", \"uo\"). A",
"'See it running' link into a game's own pages shows only for a game listed here, so",
"a Rust demo never links to a /uo/ page it does not have. Core's links (the wiki, the",
"event calendar, news) follow demoUrl alone. Read at the next restart, like demoUrl."
],
"demoGames": [],
"$comment_beta": [
"PLAN.md §8 / D27. The Google Play closed-test opt-in URL. Empty until the track",
"exists, and /beta renders a waiting state rather than a broken link while it is.",

View File

@@ -29,7 +29,7 @@
"check:csp": "node scripts/checkCsp.mjs",
"play:datasafety": "node scripts/playDataSafety.mjs",
"beta": "node scripts/beta.mjs",
"test": "node --test test/beta.test.mjs test/legal.test.mjs test/footer.test.mjs",
"test": "node --test test/beta.test.mjs test/legal.test.mjs test/footer.test.mjs test/demo.test.mjs",
"brand:assets": "node scripts/buildBrandAssets.mjs",
"screens:capture": "node scripts/captureScreens.mjs",
"csp:hashes": "node scripts/checkCsp.mjs --reset && astro build && node scripts/checkCsp.mjs --write && astro build && node scripts/checkCsp.mjs",

View File

@@ -193,17 +193,38 @@ if (demoFrom !== demoTo) {
* because `global.css` hides `[data-demo-url='']` — the visibility rule stays one rule for
* both kinds of link, and only the `href` differs.
*/
const DEEP_LINK = /href="[^"]*" data-demo-url="[^"]*" data-demo-path="([^"]*)"/g;
const DEEP_LINK = /href="[^"]*" data-demo-url="[^"]*" data-demo-path="([^"]*)"(?: data-demo-game="([^"]*)")?/g;
const deepLinkTo = (demoPath) => {
const href = demoTo ? `${demoTo.replace(/\/+$/, '')}${demoPath}` : '';
/**
* Which games the demo runs (D83). A deep link into a game module's pages carries that
* game's id in `data-demo-game`, and it is filled only when the mounted `demoGames` names
* it: the demo is a Rust deployment, and a `/uo/market` link into it is a 404. Core's own
* links (`/wiki`, `/site/events`) carry no game and follow `demoUrl` alone, because every
* deployment has them.
*
* An absent or malformed list means no game's links, not every game's. A demo whose games
* nobody named is one whose game links cannot be known to work.
*/
const gamesOf = (value) => (Array.isArray(value) ? value.filter((id) => typeof id === 'string') : []);
const gamesFrom = gamesOf(previous.demoGames);
const gamesTo = gamesOf(resolved.demoGames);
const gamesChanged = gamesFrom.join(',') !== gamesTo.join(',');
if ('demoGames' in mounted && !Array.isArray(mounted.demoGames)) {
console.warn('[brand] the mounted "demoGames" is not a list of game ids — no game links will show.');
}
const deepLinkTo = (demoPath, game) => {
const root = !game || gamesTo.includes(game) ? demoTo : '';
const href = root ? `${root.replace(/\/+$/, '')}${demoPath}` : '';
return (
`href="${escapeHtml(href)}" data-demo-url="${escapeHtml(demoTo)}" ` +
`data-demo-path="${demoPath}"`
`href="${escapeHtml(href)}" data-demo-url="${escapeHtml(root)}" ` +
`data-demo-path="${demoPath}"` +
(game === undefined ? '' : ` data-demo-game="${game}"`)
);
};
if (!replacements.length) {
if (!replacements.length && !gamesChanged) {
console.log('[brand] mount matches what is already applied; nothing to rewrite.');
process.exit(0);
}
@@ -303,8 +324,8 @@ for (const file of walk(CLIENT)) {
// After the literal swaps, never before: the plain-slot replacement also matches the
// first two attributes of a deep link, so it runs first and this pass corrects the
// `href` it just wrote. Recomputing rather than replacing is what makes that safe.
after = after.replace(DEEP_LINK, (whole, demoPath) => {
const rebuilt = deepLinkTo(demoPath);
after = after.replace(DEEP_LINK, (whole, demoPath, game) => {
const rebuilt = deepLinkTo(demoPath, game);
if (rebuilt !== whole) counts.set('demoDeep', counts.get('demoDeep') + 1);
return rebuilt;
});
@@ -330,6 +351,9 @@ if (counts.get('demoDeep')) {
` ${'demoUrl deep'.padEnd(14)} ${demoTo ? `linked -> ${demoTo}/…` : 'links hidden'} (${counts.get('demoDeep')}x)`
);
}
if (gamesChanged) {
console.log(` ${'demoGames'.padEnd(14)} ${gamesTo.length ? gamesTo.join(', ') : 'no game links'}`);
}
if (inlineCollisions.length) {
console.error(

View File

@@ -32,6 +32,7 @@ import path from 'node:path';
import sharp from 'sharp';
import { classify } from '../src/lib/brandAssets.mjs';
import { games } from '../src/data/games/index.mjs';
const ROOT = fileURLToPath(new URL('..', import.meta.url));
const DEFAULTS = path.join(ROOT, 'brand-default');
@@ -157,6 +158,20 @@ if (brand) {
}
}
// D83: the one list-valued field. Each entry is a game module's id, checked against the
// games this site describes, so a typo is a failed build rather than a demo whose game
// links silently never appear.
const gameIds = games.map((game) => game.key);
if (!Array.isArray(brand.demoGames)) {
fail('brand.json is missing the list field "demoGames" (an empty list is the stock value).');
} else {
for (const id of brand.demoGames) {
if (!gameIds.includes(id)) {
fail(`brand.json's "demoGames" names ${JSON.stringify(id)}, which is not one of ${gameIds.join(', ')}.`);
}
}
}
/**
* Kept in step with `TEXT_FIELDS` in `applyBrand.mjs` by reading that file rather than by
* restating the list. A field added there and forgotten here would be unchecked; a field
@@ -348,6 +363,23 @@ if (!deepPattern) {
);
}
// A game's link (D83) must also be read WITH its game. A pattern that matched the first
// three attributes and dropped the fourth would treat a /uo/ link as a core one and fill
// it on every demo, which is the 404 the attribute exists to prevent.
const gameSample = `${sample} data-demo-game="rust"`;
let gameRead = null;
try {
gameRead = new RegExp(deepPattern[1]).exec(gameSample)?.[2] ?? null;
} catch {
// Reported above.
}
if (gameRead !== 'rust') {
fail(
`applyBrand.mjs's DEEP_LINK does not read the game out of \`${gameSample}\`.\n` +
' Game links would follow demoUrl alone and point into modules the demo may not run.'
);
}
const deepStrays = [];
let deepLinks = 0;

View File

@@ -12,14 +12,19 @@ import type { Capability } from '../data/capabilities.mjs';
* A feature with a `demoPath` gets the per-capability demo link of D25. The markup is the
* exact contract `scripts/checkBrand.mjs` enforces and `applyBrand.mjs` rewrites at boot —
* see the note in `/features/` before touching it. A stock build hides every one.
*
* `game` is the module's id, written as `data-demo-game` straight after the path: these
* links are into that module's pages, so they show only on a demo whose mounted
* `demoGames` names it (D83).
*/
interface Props {
title: string;
lede?: string;
game: string;
items: Capability[];
}
const { title, lede, items } = Astro.props;
const { title, lede, game, items } = Astro.props;
---
<section class="page section feats">
@@ -35,7 +40,7 @@ const { title, lede, items } = Astro.props;
{item.demoPath && (
<a
class="demo-link"
href="" data-demo-url="" data-demo-path={item.demoPath}
href="" data-demo-url="" data-demo-path={item.demoPath} data-demo-game={game}
rel="noopener noreferrer"
>
See it running

View File

@@ -32,9 +32,9 @@
* 2. SOMEWHERE TO POINT IT. `ConnectScreen.kt` on `Android-app` `main` is blunt about
* this — "nothing else in the app runs until a valid Runic Gateway site is entered and
* validated". An installed app with no deployment behind it is a text field. D27 makes
* the public demo (§15) the tester target rather than naming a private shard, which
* means the beta opens when the demo VM does. `notBuilt.mjs` already carries that
* absence; this page renders the demo link from `demoUrl` when there is one.
* the public demo (§15) the tester target rather than naming a private shard. The demo
* went up on 2026-10-27; with `demoUrl` mounted, `/beta` marks this gate in place and
* lists the demo's sign-ins as the demo publishes them (D82, `lib/demoLogins.mjs`).
*
* Neither gate is a reason not to collect addresses now — the list is what makes the first
* batch possible on day one — but a page that hid them would be advertising a beta that
@@ -180,8 +180,7 @@ export const requirements = [
body:
'The app ships pointed at nothing. Its first screen asks for the address of a site ' +
'running this platform and validates it before anything else in the app will run — ' +
'so a tester needs either their own deployment or the public demo, which is the ' +
'second of the two things this beta is waiting on.',
'so a tester needs either their own deployment or the public demo.',
},
];

View File

@@ -51,7 +51,7 @@
* ---------------------------------------------------------------------------------------
* `demoPath` — DEEP LINKS THAT ONLY EXIST WHEN A DEMO DOES
* ---------------------------------------------------------------------------------------
* §15/D12 keeps the public demo out of scope while requiring the site to gain one by way of
* §15/D12 kept the public demo out of scope while requiring the site to gain one by way of
* a line in a mounted `brand.json`. `brand.json`'s own comment promised `/features/` a
* "per-capability affordance" that had never been defined; D25 defines it as a deep link
* per capability that has a stable public route, appended to the mounted `demoUrl` by
@@ -63,8 +63,10 @@
* behind a Team id no static page can know. Paths are read from the real route tables —
* core's `client/src/App.jsx` and `module-uo`'s `client/src/entry.jsx` on `main` — never
* guessed. A module's public pages are namespaced under its own id, so a route into the
* Ultima Online module is `/uo/…`: the demo §15 plans runs that game, which is why the
* Teams item points there and the module-supplied items in `games/` carry their own.
* Ultima Online module is `/uo/…`. A link into a module's pages names that game
* (`demoGame` here, the `game` prop of `GameFeatures` for the lists in `games/`), and it
* shows only when the mounted `demoGames` lists it (D83): the demo that exists runs Rust,
* so the Teams item's `/uo/guilds` stays hidden there and Rust's clans link stands in.
*/
import { games } from './games/index.mjs';
@@ -82,6 +84,7 @@ import { games } from './games/index.mjs';
* @property {string[]} [caps] Module capability slugs, on a game's own items (`games/`) only.
* @property {boolean} [needsModule] Core machinery a module has to populate (D24).
* @property {string} [demoPath] A stable public route, deep-linked into a demo (D25).
* @property {string} [demoGame] The game module whose pages `demoPath` is in, if any (D83).
*
* @typedef {object} CapabilityGroup
* @property {string} id
@@ -116,6 +119,7 @@ const community = {
label: 'Teams',
needsModule: true,
demoPath: '/uo/guilds',
demoGame: 'uo',
detail:
'A roster, a leader, a private forum, its own notification streams and a Discord ' +
'voice channel, all hanging off one group. Core owns every part of that machinery ' +

View File

@@ -16,7 +16,12 @@
* The staff items carry no `caps`: a capability is what a client feature-detects on, and
* the admin screens are not something any client detects.
*
* No `demoPath` anywhere: the demo instance planned in §15 runs Ultima Online.
* `demoPath` is a public route in Module-Rust's own `client/src/entry.jsx` (`/rust`, and
* `/rust/servers/:id` with its `?tab=` search parameter, which `ServerDetail.jsx` reads).
* The public demo runs Rust (§15), and `demo` is its server's id, fixed by
* `website/deploy/demo/seed.json` (`modules.rust.servers`) and recreated at every wipe.
* The links show only while the mounted `demoGames` lists `rust` (D83). The staff items
* carry none: every admin page needs a sign-in, and D25's links are to public routes.
*/
/** @typedef {import('../capabilities.mjs').Capability} Capability */
@@ -26,6 +31,7 @@ export const features = [
{
label: 'Every server, live',
caps: ['rust', 'servers'],
demoPath: '/rust',
detail:
'Every Rust server the community runs, as it last reported itself: up or down, who is ' +
'on, which map, and when the next wipe is. One sidecar serves one server, so a ' +
@@ -35,6 +41,7 @@ export const features = [
{
label: 'The kill feed',
caps: ['killfeed'],
demoPath: '/rust/servers/demo',
detail:
'Kills, chat, comings and goings, RunicNPC bosses, and the world itself — cargo, ' +
'helicopters, airdrops — as they happen. Every kind passes a default-deny allowlist ' +
@@ -44,6 +51,7 @@ export const features = [
{
label: 'Leaderboards, per wipe',
caps: ['leaderboard'],
demoPath: '/rust/servers/demo?tab=leaderboard',
detail:
'Kills, deaths, NPC kills, structures and time played, for one wipe or all-time — with ' +
'the chat titles players have earned. The record survives the wipe: the game forgets, ' +
@@ -52,6 +60,7 @@ export const features = [
{
label: 'Who is online',
caps: ['presence'],
demoPath: '/rust/servers/demo?tab=online',
detail:
'Who is on right now, and who is asleep. Nothing names a player to the public by ' +
'default: how far a name reaches — staff, signed-in players, everyone — is the ' +
@@ -60,6 +69,7 @@ export const features = [
{
label: 'Wipes',
caps: ['wipes'],
demoPath: '/rust/servers/demo?tab=wipes',
detail:
'Every wipe the site has seen, and the next one once the server has a wipe schedule — ' +
'the monthly forced wipe or a rule of your own.',
@@ -67,6 +77,7 @@ export const features = [
{
label: 'The live map',
caps: ['map'],
demoPath: '/rust/servers/demo?tab=map',
detail:
'The server’s own map picture with layers on it: monuments and world events, the ' +
'zones and NPCs your events placed, players, bases as positions only, and a player’s ' +
@@ -82,6 +93,7 @@ export const features = [
},
{
label: 'Clans are Teams',
demoPath: '/rust/servers/demo?tab=clans',
detail:
'Each clan on a server is a Team on the site: a page, a feed for its members and ' +
'core’s Team notifications. Who may see a clan’s roster is set alongside who may see ' +

View File

@@ -17,7 +17,8 @@
* ids and are resolved against the shard's own string table). Coverage, not a bijection.
*
* `demoPath` is a stable public route on the module's own pages, deep-linked into a demo
* when one is mounted (D25). Paths are read from `module-uo`'s `client/src/entry.jsx`.
* when one is mounted (D25) and the mounted `demoGames` lists `uo` (D83) — which the public
* demo, a Rust deployment, does not. Paths are read from `module-uo`'s `client/src/entry.jsx`.
*/
/** @typedef {import('../capabilities.mjs').Capability} Capability */

View File

@@ -117,23 +117,9 @@ export const notBuilt = [
resolvedBy: 'A game server that runs there.',
link: { href: `${GITEA}/docs/src/branch/main/installer/INSTALL.md`, label: 'The operator guide' },
},
{
id: 'public-demo',
// Phase 5 added `app` and `beta`, and that is not tidying. D27 makes the demo the
// deployment a beta tester connects to, so on those two pages this stopped being a
// thing the site lacks and became the thing the beta is waiting for. An absence that
// blocks a call to action has to be on the page carrying that call to action.
scope: ['features', 'app', 'beta'],
title: 'A public demo you can click through',
body:
'Planned and out of scope today: a virtual machine running the whole stack including ' +
'a game server, with settings locked down and an hourly reset. Until it exists this ' +
'site does not link to one, and there is no screenshot here of something that is not ' +
'running somewhere.',
resolvedBy:
'The machine being stood up. The site is already built to gain it by way of one line ' +
'in a configuration file, rather than a rebuild.',
},
// `public-demo` stood here until 2026-10-28. Its `resolvedBy` was met when
// demo.runicgateway.com went up (docs/website/DEMO_MODE.md), and the site reaches it through
// the mounted `demoUrl`, as the entry promised (D82, D83).
/* ---------------------------------------------------------------------------------------
THE ANDROID CLIENT (phase 5)

124
src/lib/demoLogins.mjs Normal file
View File

@@ -0,0 +1,124 @@
/**
* demoLogins.mjs — the public demo's sign-ins, read from the demo itself (D82).
*
* ---------------------------------------------------------------------------------------
* WHY THE PASSWORDS ARE NOT IN THIS REPOSITORY
* ---------------------------------------------------------------------------------------
* The demo's accounts are recreated at every wipe from passwords generated on the demo's own
* host, so the only copy that is right by definition is the one the demo serves. It already
* publishes them: with `DEMO_MODE` on, `GET /api/v1/public/settings` answers
* `demo: { resetsAt, logins: [{ username, password, role }] }`, which is what its own
* sign-in page lists (`docs/website/DEMO_MODE.md` §4 and §6). `/beta` reads the same answer.
* A copy pasted into a mounted file would be one regenerated `.env` away from a page
* publishing passwords that no longer work.
*
* ---------------------------------------------------------------------------------------
* WHAT THIS DOES NOT CHANGE
* ---------------------------------------------------------------------------------------
* The fetch is made by this server, never by the browser, so D9 and §6's CSP stand: the
* visitor's page loads nothing from another origin. Only `/beta` calls it, and `/beta`
* already renders per request.
*
* It never throws and never holds a page up for long. The demo is down for a few minutes at
* every wipe, and a sign-up page must not fail with it: a failed or slow read answers `null`,
* the page points at the demo's own sign-in page instead, and the failure is cached briefly
* so a down demo is not asked again on every request.
*
* Everything read is checked before it is used. The demo is ours, but it is a public admin
* playground, and a string from it goes into this page's markup. Astro escapes it, and the
* length and shape limits below keep a strange answer from becoming a strange page.
*/
const OK_TTL_MS = 60_000;
const FAIL_TTL_MS = 15_000;
const TIMEOUT_MS = 1_500;
const MAX_LOGINS = 8;
const MAX_FIELD = 64;
let cache = { key: '', until: 0, value: null };
let pending = null;
const isShortString = (value) => typeof value === 'string' && value.length > 0 && value.length <= MAX_FIELD;
/**
* The demo's answer reduced to what `/beta` shows, or `null` when it is not the shape
* `DEMO_MODE.md` §6 describes. Exported for the tests.
*
* @param {unknown} body The parsed JSON of `GET /api/v1/public/settings`.
* @returns {{ logins: { username: string, password: string, role: string }[], resetsAt: string | null } | null}
*/
export function readDemo(body) {
const demo = body && typeof body === 'object' ? body.demo : null;
if (!demo || typeof demo !== 'object' || !Array.isArray(demo.logins)) return null;
const logins = demo.logins
.filter((l) => l && isShortString(l.username) && isShortString(l.password) && isShortString(l.role))
.slice(0, MAX_LOGINS)
.map(({ username, password, role }) => ({ username, password, role }));
if (!logins.length) return null;
const at = typeof demo.resetsAt === 'string' ? new Date(demo.resetsAt) : null;
const resetsAt = at && !Number.isNaN(at.getTime()) ? at.toISOString() : null;
return { logins, resetsAt };
}
/**
* "22:00 UTC" — in UTC and not converted, matching the demo's own banner (§6).
* @param {string | null} iso
*/
export function resetTime(iso) {
return iso ? `${iso.slice(11, 16)} UTC` : null;
}
/**
* The sign-ins the demo at `demoUrl` publishes now, cached for a minute.
*
* @param {string} demoUrl The mounted `brand.json` `demoUrl`.
* @param {{ fetchImpl?: typeof fetch, now?: () => number }} [options] For the tests.
* @returns {Promise<ReturnType<typeof readDemo>>}
*/
export async function demoLogins(demoUrl, { fetchImpl = fetch, now = Date.now } = {}) {
let base;
try {
base = new URL(demoUrl);
} catch {
return null;
}
if (base.protocol !== 'https:' && base.protocol !== 'http:') return null;
const key = base.origin + base.pathname.replace(/\/+$/, '');
if (cache.key === key && now() < cache.until) return cache.value;
if (pending?.key === key) return pending.promise;
const promise = (async () => {
let value = null;
try {
const response = await fetchImpl(`${key}/api/v1/public/settings`, {
headers: { accept: 'application/json' },
redirect: 'error',
signal: AbortSignal.timeout(TIMEOUT_MS),
});
if (response.ok) value = readDemo(await response.json());
if (!value) console.warn(`[beta] the demo at ${key} published no sign-ins (HTTP ${response.status}).`);
} catch (error) {
console.warn(`[beta] could not read the demo's sign-ins from ${key}: ${error.message}`);
}
cache = { key, until: now() + (value ? OK_TTL_MS : FAIL_TTL_MS), value };
return value;
})();
pending = { key, promise };
try {
return await promise;
} finally {
if (pending?.promise === promise) pending = null;
}
}
/** Forget the cache. For the tests. */
export function resetDemoLoginsCache() {
cache = { key: '', until: 0, value: null };
pending = null;
}

View File

@@ -4,6 +4,7 @@ import PageHeader from '../components/PageHeader.astro';
import NotBuilt from '../components/NotBuilt.astro';
import { liveBrand } from '../lib/brand.mjs';
import { demoLogins, resetTime } from '../lib/demoLogins.mjs';
import { isFull, liveCount } from '../lib/betaStore.mjs';
import { issueFormToken, OUTCOME, submit, isSuccess } from '../lib/betaSignup.mjs';
import { CONSENT_TEXT, fields, limits, playPolicy, requirements } from '../data/beta.mjs';
@@ -35,9 +36,9 @@ import { CONSENT_TEXT, fields, limits, playPolicy, requirements } from '../data/
* The beta cannot start yet for two independent reasons — no Play track, and nowhere for a
* tester to point the app (see `beta.mjs` for both in full). The page collects addresses
* anyway, because the list is what makes the first batch possible on day one, and says
* plainly that it is a list rather than a queue that is moving. The demo is rendered
* through `NotBuilt` so the absence appears in the same shape it takes everywhere else on
* the site rather than as an apology invented for this page.
* plainly that it is a list rather than a queue that is moving. Gate two closed when the
* public demo went up (2026-10-27): with `demoUrl` mounted it says so, and the page lists
* the demo's sign-ins as the demo itself publishes them (D82).
*
* ---------------------------------------------------------------------------------------
* WHAT THE SUCCESS SCREEN SHOWS, AND WHY IT CAN SHOW IT
@@ -53,6 +54,22 @@ export const prerender = false;
const brand = liveBrand();
/**
* The public demo, which closes gate two (D27): the deployment a tester points the app at.
* Its sign-ins are read from the demo on this request (D82, `demoLogins.mjs`), so they are
* whatever the demo's own sign-in page lists. `null` with a `demoUrl` set means the demo did
* not answer, and the page sends people to that sign-in page instead.
*/
let demoHost = '';
try {
demoHost = brand.demoUrl ? new URL(brand.demoUrl).host : '';
} catch {
console.warn(`[beta] the mounted demoUrl is not a URL: ${JSON.stringify(brand.demoUrl)}`);
}
const demoBase = demoHost ? brand.demoUrl.replace(/\/+$/, '') : '';
const demo = demoBase ? await demoLogins(demoBase) : null;
const demoReset = resetTime(demo?.resetsAt ?? null);
/**
* A POST is a submission; anything else is somebody arriving. `Astro.request.formData()`
* parses both `application/x-www-form-urlencoded` and `multipart/form-data`, and this form
@@ -193,7 +210,7 @@ const formToken = issueFormToken();
test. This is the list of people who want a place on it.
</p>
<p>
It has not opened yet, and the two reasons are below rather than behind a
It has not opened yet, and what it is waiting on is below rather than behind a
&ldquo;coming soon&rdquo;. Adding your address now means you are in the first batch
rather than hearing about it afterwards.
</p>
@@ -249,8 +266,11 @@ const formToken = issueFormToken();
<section class="page section">
<h2 class="beta-h2">What it is waiting on</h2>
<p class="prose beta-lede">
Two things, neither of which is a date. Both are visible from outside, so there is no
reason to be vague about them.
{demoBase
? 'Two things, neither of which is a date, and the second is in place. Both are ' +
'visible from outside, so there is no reason to be vague about them.'
: 'Two things, neither of which is a date. Both are visible from outside, so there ' +
'is no reason to be vague about them.'}
</p>
<ol class="beta-gates">
@@ -271,22 +291,85 @@ const formToken = issueFormToken();
</li>
<li class="panel beta-gate">
<p class="eyebrow">Gate two</p>
<p class="eyebrow">{demoBase ? 'Gate two · in place' : 'Gate two'}</p>
<h3>Somewhere to point it</h3>
<p>
The app is a client and ships pointed at nothing — its first screen asks for the
address of a site running this platform. So a tester needs a deployment, and the
public demo is the one being built for that. Until it is running, a place on the
test would be a place to install an app with nothing behind it.
</p>
{
demoBase ? (
<p>
The app is a client and ships pointed at nothing — its first screen asks for the
address of a site running this platform. The public demo is that site: a real
deployment with a real game server behind it, open to anyone with the sign-ins{' '}
<a href="#demo">below</a>.
</p>
) : (
<p>
The app is a client and ships pointed at nothing — its first screen asks for the
address of a site running this platform. So a tester needs a deployment, and
without a public one to point at, a place on the test would be a place to
install an app with nothing behind it.
</p>
)
}
<p class="beta-gate__foot">
If you already run a Runic Gateway deployment, this gate does not apply to you —
say so in Discord.
If you already run a Runic Gateway deployment, you can point the app at that
instead — say so in Discord.
</p>
</li>
</ol>
</section>
{
demoBase && (
<section class="page section" id="demo">
<h2 class="beta-h2">The demo</h2>
<p class="prose beta-lede">
Enter <strong>{demoHost}</strong> on the app's first screen, or{' '}
<a href={demoBase} rel="noopener noreferrer">open it in a browser</a>. Every account
below is shared with everyone else trying it, and the site is wiped back to the same
starting point every few hours, so nothing you change there lasts.
</p>
{demo ? (
<div class="panel beta-demo">
<table class="beta-demo__logins">
<caption>Sign-ins, as the demo publishes them right now</caption>
<thead>
<tr>
<th scope="col">Account</th>
<th scope="col">Password</th>
<th scope="col">Role</th>
</tr>
</thead>
<tbody>
{demo.logins.map((login) => (
<tr>
<td><code>{login.username}</code></td>
<td><code>{login.password}</code></td>
<td>{login.role}</td>
</tr>
))}
</tbody>
</table>
{demoReset && (
<p class="beta-demo__foot">
Next wipe at {demoReset}. Anything you change before then goes with it.
</p>
)}
</div>
) : (
<div class="panel beta-notice beta-notice--warn">
<h3>The demo did not answer just now.</h3>
<p>
It is offline for a few minutes at every wipe. Its sign-ins are listed on{' '}
<a href={`${demoBase}/account/login`} rel="noopener noreferrer">its own sign-in page</a>
{' '}once it is back.
</p>
</div>
)}
</section>
)
}
<section class="page section">
<h2 class="beta-h2">What a tester needs</h2>
<ul class="beta-reqs">
@@ -524,6 +607,46 @@ const formToken = issueFormToken();
font-size: 0.86rem;
}
/* The demo's sign-ins (D82). A plain table: three columns a person copies from, so the
passwords are code and nothing about them is decorated. */
.beta-demo {
margin-top: 1.25rem;
overflow-x: auto;
}
.beta-demo__logins {
width: 100%;
border-collapse: collapse;
}
.beta-demo__logins caption {
margin-bottom: 0.65rem;
color: var(--dim);
font-size: 0.86rem;
text-align: left;
}
.beta-demo__logins th,
.beta-demo__logins td {
padding: 0.55rem 0.75rem 0.55rem 0;
border-bottom: 1px solid var(--line-soft);
text-align: left;
}
.beta-demo__logins th {
color: var(--head);
font-weight: 600;
}
.beta-demo__logins td {
color: var(--text);
}
.beta-demo__foot {
margin: 0.85rem 0 0;
color: var(--muted);
}
/* The form and the counter. The counter is deliberately narrow and secondary — it is
context for the decision, not the reason to make it. */
.beta-formwrap {

View File

@@ -133,7 +133,7 @@ const description =
{item.demoPath && (
<a
class="demo-link"
href="" data-demo-url="" data-demo-path={item.demoPath}
href="" data-demo-url="" data-demo-path={item.demoPath} data-demo-game={item.demoGame}
rel="noopener noreferrer"
>
See it running

View File

@@ -92,6 +92,7 @@ const description =
</section>
<GameFeatures
game="rust"
title="What players see"
lede="Every public surface is the operator's to widen: nothing names a player to the public by default."
items={features}
@@ -107,6 +108,7 @@ const description =
</section>
<GameFeatures
game="rust"
title="What staff run from the site"
lede="Seven admin screens, and a Rust section on each user's page."
items={staffFeatures}

View File

@@ -87,6 +87,7 @@ const description =
</section>
<GameFeatures
game="uo"
title="What it adds"
lede="Everything here is published from the live shard, and every public surface passes the visibility settings first — each one is the operator's to switch on."
items={features}

208
test/demo.test.mjs Normal file
View File

@@ -0,0 +1,208 @@
/**
* The public demo's two hooks into this site (D82, D83).
*
* `demoLogins.mjs` decides what `/beta` prints from an answer it does not control, so its
* shape checks and its failure path are tested here rather than trusted. `applyBrand.mjs`
* decides which "See it running" links point anywhere, and the failure it guards against
* (a Rust demo linked to `/uo/` pages) is invisible in a build that has no demo mounted, so
* it is run here against a scratch build that does.
*/
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { after, beforeEach, describe, it } from 'node:test';
import { demoLogins, readDemo, resetDemoLoginsCache, resetTime } from '../src/lib/demoLogins.mjs';
const ROOT = fileURLToPath(new URL('..', import.meta.url));
const LOGINS = [
{ username: 'demo-admin', password: 'pw-a', role: 'admin' },
{ username: 'demo-player', password: 'pw-p', role: 'player' },
];
const answer = (body, status = 200) => async () => ({
ok: status >= 200 && status < 300,
status,
json: async () => body,
});
describe('readDemo', () => {
it('keeps the logins and normalises the reset time', () => {
const read = readDemo({ demo: { resetsAt: '2026-10-28T22:00:00.000Z', logins: LOGINS } });
assert.deepEqual(read, { logins: LOGINS, resetsAt: '2026-10-28T22:00:00.000Z' });
assert.equal(resetTime(read.resetsAt), '22:00 UTC');
});
it('answers null for a site that is not in demo mode', () => {
assert.equal(readDemo({ site_mode: 'live' }), null);
assert.equal(readDemo(null), null);
assert.equal(readDemo({ demo: { logins: [] } }), null);
});
it('drops malformed rows, extra fields and an unreadable time', () => {
const read = readDemo({
demo: {
resetsAt: 'soon',
logins: [
{ username: 'demo-admin', password: 'pw-a', role: 'admin', token: 'x' },
{ username: 'x'.repeat(65), password: 'pw', role: 'admin' },
{ username: 'demo-player', password: 42, role: 'player' },
'demo-moderator',
],
},
});
assert.deepEqual(read, { logins: [LOGINS[0]], resetsAt: null });
assert.equal(resetTime(read.resetsAt), null);
});
it('shows at most eight', () => {
const many = Array.from({ length: 12 }, (_, i) => ({ username: `u${i}`, password: 'p', role: 'player' }));
assert.equal(readDemo({ demo: { logins: many } }).logins.length, 8);
});
});
describe('demoLogins', () => {
beforeEach(() => resetDemoLoginsCache());
it('reads the public settings route under the demo URL, without its trailing slash', async () => {
let asked = null;
const fetchImpl = async (url) => {
asked = url;
return answer({ demo: { logins: LOGINS } })();
};
const read = await demoLogins('https://demo.example.test/', { fetchImpl });
assert.equal(asked, 'https://demo.example.test/api/v1/public/settings');
assert.deepEqual(read.logins, LOGINS);
});
it('caches an answer for a minute', async () => {
let calls = 0;
let clock = 1_000;
const fetchImpl = async () => {
calls++;
return answer({ demo: { logins: LOGINS } })();
};
const options = { fetchImpl, now: () => clock };
await demoLogins('https://demo.example.test', options);
clock += 59_000;
await demoLogins('https://demo.example.test', options);
assert.equal(calls, 1);
clock += 2_000;
await demoLogins('https://demo.example.test', options);
assert.equal(calls, 2);
});
it('answers null when the demo is down, and asks again sooner', async () => {
let calls = 0;
let clock = 1_000;
const fetchImpl = async () => {
calls++;
throw new Error('connect ECONNREFUSED');
};
const options = { fetchImpl, now: () => clock };
assert.equal(await demoLogins('https://demo.example.test', options), null);
clock += 10_000;
assert.equal(await demoLogins('https://demo.example.test', options), null);
assert.equal(calls, 1);
clock += 6_000;
await demoLogins('https://demo.example.test', options);
assert.equal(calls, 2);
});
it('answers null for an error status and for a non-web URL', async () => {
assert.equal(await demoLogins('https://demo.example.test', { fetchImpl: answer({}, 502) }), null);
resetDemoLoginsCache();
let called = false;
const fetchImpl = async () => {
called = true;
return answer({ demo: { logins: LOGINS } })();
};
assert.equal(await demoLogins('file:///etc/passwd', { fetchImpl }), null);
assert.equal(await demoLogins('not a url', { fetchImpl }), null);
assert.equal(called, false);
});
it('shares one request between renders that arrive together', async () => {
let calls = 0;
const fetchImpl = async () => {
calls++;
await new Promise((resolve) => setTimeout(resolve, 20));
return answer({ demo: { logins: LOGINS } })();
};
const [a, b] = await Promise.all([
demoLogins('https://demo.example.test', { fetchImpl }),
demoLogins('https://demo.example.test', { fetchImpl }),
]);
assert.equal(calls, 1);
assert.deepEqual(a, b);
});
});
describe('applyBrand: which demo links point anywhere (D83)', () => {
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-demo-brand-'));
const dist = path.join(scratch, 'dist');
const mount = path.join(scratch, 'brand');
const page = path.join(dist, 'client', 'index.html');
// The four shapes a build writes: the homepage slot, a core deep link, and a link into
// each game's pages. Same attribute order as the templates; checkBrand.mjs holds them to it.
const STOCK =
'<a class="btn demo-cta" href="" data-demo-url="">See it running</a>\n' +
'<a class="demo-link" href="" data-demo-url="" data-demo-path="/wiki" rel="noopener">w</a>\n' +
'<a class="demo-link" href="" data-demo-url="" data-demo-path="/rust/servers/demo?tab=map" data-demo-game="rust" rel="noopener">r</a>\n' +
'<a class="demo-link" href="" data-demo-url="" data-demo-path="/uo/market" data-demo-game="uo" rel="noopener">u</a>\n';
fs.mkdirSync(path.dirname(page), { recursive: true });
fs.mkdirSync(mount, { recursive: true });
fs.writeFileSync(page, STOCK);
const apply = (brand) => {
fs.writeFileSync(path.join(mount, 'brand.json'), JSON.stringify(brand));
execFileSync(process.execPath, [path.join(ROOT, 'scripts/applyBrand.mjs')], {
env: {
...process.env,
BRAND_DIST: dist,
BRAND_DIR: mount,
BRAND_DEFAULT_DIR: path.join(ROOT, 'brand-default'),
},
stdio: 'pipe',
});
return fs.readFileSync(page, 'utf8');
};
const hrefOf = (html, marker) => html.split('\n').find((line) => line.includes(marker)).match(/href="([^"]*)"/)[1];
after(() => fs.rmSync(scratch, { recursive: true, force: true }));
it('fills the slot, core links and the listed game only', () => {
const html = apply({ demoUrl: 'https://demo.example.test', demoGames: ['rust'] });
assert.equal(hrefOf(html, 'demo-cta'), 'https://demo.example.test');
assert.equal(hrefOf(html, '/wiki'), 'https://demo.example.test/wiki');
assert.equal(hrefOf(html, 'tab=map'), 'https://demo.example.test/rust/servers/demo?tab=map');
assert.equal(hrefOf(html, '/uo/market'), '');
assert.match(html, /data-demo-path="\/uo\/market" data-demo-game="uo"/);
});
it('hides a game link again when only demoGames changes', () => {
const html = apply({ demoUrl: 'https://demo.example.test', demoGames: [] });
assert.equal(hrefOf(html, 'tab=map'), '');
assert.equal(hrefOf(html, '/wiki'), 'https://demo.example.test/wiki');
});
it('treats a malformed demoGames as no games', () => {
apply({ demoUrl: 'https://demo.example.test', demoGames: ['rust'] });
const html = apply({ demoUrl: 'https://demo.example.test', demoGames: 'rust' });
assert.equal(hrefOf(html, 'tab=map'), '');
});
it('returns to the stock markup when the demo is unmounted', () => {
apply({ demoUrl: 'https://demo.example.test', demoGames: ['rust', 'uo'] });
const html = apply({});
assert.equal(html, STOCK);
});
});