diff --git a/DEPLOY.md b/DEPLOY.md index f81b57b..13c3e1b 100644 --- a/DEPLOY.md +++ b/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 +`/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 diff --git a/PLAN.md b/PLAN.md index cd3d769..2eaa9b9 100644 --- a/PLAN.md +++ b/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 diff --git a/brand-default/brand.json b/brand-default/brand.json index a44f89e..303a78e 100644 --- a/brand-default/brand.json +++ b/brand-default/brand.json @@ -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.", diff --git a/package.json b/package.json index de797a0..23150d8 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/applyBrand.mjs b/scripts/applyBrand.mjs index e5a7a82..ac2b5f9 100644 --- a/scripts/applyBrand.mjs +++ b/scripts/applyBrand.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( diff --git a/scripts/checkBrand.mjs b/scripts/checkBrand.mjs index 726dfad..b53133e 100644 --- a/scripts/checkBrand.mjs +++ b/scripts/checkBrand.mjs @@ -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; diff --git a/src/components/GameFeatures.astro b/src/components/GameFeatures.astro index 8503300..35d33f9 100644 --- a/src/components/GameFeatures.astro +++ b/src/components/GameFeatures.astro @@ -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; ---
@@ -35,7 +40,7 @@ const { title, lede, items } = Astro.props; {item.demoPath && ( See it running diff --git a/src/data/beta.mjs b/src/data/beta.mjs index 493225d..54c0ded 100644 --- a/src/data/beta.mjs +++ b/src/data/beta.mjs @@ -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.', }, ]; diff --git a/src/data/capabilities.mjs b/src/data/capabilities.mjs index b1c28a7..547883d 100644 --- a/src/data/capabilities.mjs +++ b/src/data/capabilities.mjs @@ -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 ' + diff --git a/src/data/games/rust.mjs b/src/data/games/rust.mjs index f710a45..fe63726 100644 --- a/src/data/games/rust.mjs +++ b/src/data/games/rust.mjs @@ -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 ' + diff --git a/src/data/games/uo.mjs b/src/data/games/uo.mjs index 117af0b..18609fc 100644 --- a/src/data/games/uo.mjs +++ b/src/data/games/uo.mjs @@ -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 */ diff --git a/src/data/notBuilt.mjs b/src/data/notBuilt.mjs index 15c4f9d..1068155 100644 --- a/src/data/notBuilt.mjs +++ b/src/data/notBuilt.mjs @@ -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) diff --git a/src/lib/demoLogins.mjs b/src/lib/demoLogins.mjs new file mode 100644 index 0000000..5146f47 --- /dev/null +++ b/src/lib/demoLogins.mjs @@ -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>} + */ +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; +} diff --git a/src/pages/beta.astro b/src/pages/beta.astro index 5cc3312..2d1cfd8 100644 --- a/src/pages/beta.astro +++ b/src/pages/beta.astro @@ -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.

- 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.

@@ -249,8 +266,11 @@ const formToken = issueFormToken();

What it is waiting on

- 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.'}

    @@ -271,22 +291,85 @@ const formToken = issueFormToken();
  1. -

    Gate two

    +

    {demoBase ? 'Gate two · in place' : 'Gate two'}

    Somewhere to point it

    -

    - 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. -

    + { + demoBase ? ( +

    + 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{' '} + below. +

    + ) : ( +

    + 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. +

    + ) + }

    - 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.

+ { + demoBase && ( +
+

The demo

+

+ Enter {demoHost} on the app's first screen, or{' '} + open it in a browser. 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. +

+ + {demo ? ( +
+ + + + + + + + + + + {demo.logins.map((login) => ( + + + + + + ))} + +
Sign-ins, as the demo publishes them right now
AccountPasswordRole
{login.username}{login.password}{login.role}
+ {demoReset && ( +

+ Next wipe at {demoReset}. Anything you change before then goes with it. +

+ )} +
+ ) : ( +
+

The demo did not answer just now.

+

+ It is offline for a few minutes at every wipe. Its sign-ins are listed on{' '} + its own sign-in page + {' '}once it is back. +

+
+ )} +
+ ) + } +

What a tester needs

    @@ -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 { diff --git a/src/pages/features.astro b/src/pages/features.astro index 47c2a67..b86fe2c 100644 --- a/src/pages/features.astro +++ b/src/pages/features.astro @@ -133,7 +133,7 @@ const description = {item.demoPath && ( See it running diff --git a/src/pages/modules/rust.astro b/src/pages/modules/rust.astro index bda83be..6cd1bd1 100644 --- a/src/pages/modules/rust.astro +++ b/src/pages/modules/rust.astro @@ -92,6 +92,7 @@ const description =
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 = + 'See it running\n' + + 'w\n' + + 'r\n' + + 'u\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); + }); +});