feat(demo): list the demo's sign-ins on /beta and link into it per game #42
10
DEPLOY.md
10
DEPLOY.md
@@ -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
57
PLAN.md
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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 ' +
|
||||
|
||||
@@ -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 ' +
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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
124
src/lib/demoLogins.mjs
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
“coming soon”. 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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
208
test/demo.test.mjs
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user