feat(demo): the public demo's module-side locks and seed hook
All checks were successful
PR Checks / client-build (pull_request) Successful in 54s
PR Checks / server-tests (pull_request) Successful in 1m0s
PR Checks / frozen-manifest (pull_request) Successful in 1m19s

website/DEMO_MODE.md §3.2, §3.4, §3.5, §7.3 and §7.5 (step 2 of §10).
Nothing changes off the demo; everything below sits behind ctx.demoMode.

- demoLocks in module.json: PUT and DELETE /admin/rust/servers/:id.
- The console-command deny list (DM11): quit, restart, oxide.unload,
  oxide.reload, rcon.*, fps.limit and server.tickrate, by first word,
  with global. stripped and o. read as oxide. Refused at save through
  core's demoRefuses (MODULE_API 1.19.0, DM18) and again at run, with
  retry: false, before the rig is asked. Teardown commands too.
- The config editor (DM14): the bridge's files are left out of the
  list and refused for reading, writing and in the write history, by
  name (canonicalised) and by the catalogue's self. Other plugins'
  credential fields are redacted in the text and the form and made
  read-only; raw-JSON saves are refused; a file that does not parse is
  withheld. The admin server list leaves out the sidecar URL.
- onDemoSeed (DM15): upserts the rig's server row with an encrypted
  token and demo-player's stand-in Steam link.
- The release's install manifest carries protocol, read from
  PROTOCOL_VERSION, for the wipe's bundle match (DM12).

coreApi is ^1.19.0; ci/core-ref.json pins website#224's head.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-10-26 03:40:30 -05:00
parent c4729315cd
commit 7a0d0842ba
16 changed files with 809 additions and 14 deletions

View File

@@ -384,6 +384,22 @@ jobs:
SHA="$(sha256sum "dist/module-rust-${VERSION}.tar.gz" | cut -d' ' -f1)"
SIZE="$(stat -c%s "dist/module-rust-${VERSION}.tar.gz")"
# The wire protocol this release speaks, for the public demo's wipe
# (website/DEMO_MODE.md §7.5, DM12). The wipe picks the newest release
# whose `protocol` EQUALS the Rust bundle's. The Rust link has no 409,
# so a plugin on another protocol would mis-parse rather than be
# refused. Read from the one declaration (`server/sidecarClient.js`);
# a release that cannot find it fails here rather than ship a manifest
# the wipe would skip. Core's installer reads only the fields it names
# (modules/install.js), so the extra key changes nothing there.
PROTOCOL="$(node -e '
const src = require("fs").readFileSync("server/sidecarClient.js", "utf8");
const m = /^const PROTOCOL_VERSION = (\d+)$/m.exec(src);
if (!m) { console.error("no PROTOCOL_VERSION in server/sidecarClient.js"); process.exit(1); }
console.log(m[1]);
')"
echo "protocol ${PROTOCOL}"
# The install manifest. Same shape as the installer's bundle JSON — a
# per-asset sha256 fetched over HTTPS, no signatures — because that is
# the model this project already has and a second one would be a second
@@ -396,9 +412,10 @@ jobs:
--arg artifact "module-rust-${VERSION}.tar.gz" \
--arg sha256 "$SHA" \
--argjson size "$SIZE" \
--argjson protocol "$PROTOCOL" \
--arg url "https://${GITEA_HOST}/${REPO}/releases/download/v${VERSION}/module-rust-${VERSION}.tar.gz" \
'{schema:1, id:$id, name:$name, version:$version, coreApi:$coreApi,
artifact:$artifact, url:$url, sha256:$sha256, size:$size}' \
artifact:$artifact, url:$url, sha256:$sha256, size:$size, protocol:$protocol}' \
> "dist/module-rust-${VERSION}.json"
echo "${SHA} module-rust-${VERSION}.tar.gz" > dist/SHA256SUMS

View File

@@ -34,6 +34,8 @@
"core.js",
"dangerousTreasures.js",
"db",
"demo.js",
"demoSeed.js",
"dynamicPvp.js",
"eventCommands.js",
"engagement",

View File

@@ -1,6 +1,6 @@
{
"$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/rust and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves, because a mount prefix is a string in server/index.js and a documented path is a string in a JSON file, and whether those name the same URL is a fact about a running core. It also answers the blind spot phase 1 had to check by hand: core mounts several routes at the TIER ROOT (/status, /version), which the loader's collision probe cannot see, so /rust being free is asserted here by a core rather than by a reading. Pinned rather than tracking a branch on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together. This module needs MODULE_API 1.10.0 (module.json's coreApi is ^1.10.0), which the Event System cutover put on `main` — so unlike Module-uo, which spent the Event System window pinned to `edge`, this repo starts pinned to `main` and should stay there unless it comes to depend on a contract member that has not shipped yet. **2026-09-27, protocol 13 step 2:** this module calls ctx.events.expired (MODULE_API 1.11.0, PLAN_FIXES D183), so coreApi is ^1.11.0. It was pinned to website#209's branch head while that PR was open — a 1.10.0 core refuses to load it — and is back on `main` at the sha #209 merged as. **2026-10-12, protocol 14 (PLAN_REDESIGNS §2):** this module registers an event template and declares param durations, a lease value example and option notes (MODULE_API 1.13.0), so coreApi is ^1.13.0. It was pinned to website#212's branch head while that PR was open, and is back on `main` at the sha #212 merged as (the same tree). **2026-10-12, PLAN_REDESIGNS §12 (permission rewards):** this module declares a param with multiple: true (MODULE_API 1.14.0), so coreApi is ^1.14.0. It was pinned to website#213's branch head while that PR was open, and is back on `main` at the sha #213 merged as (the same tree). **2026-10-15, PLAN_REDESIGNS §14.3.4 (Dangerous Treasures):** the Start a Dangerous Treasures event step answers DT's warm-up with retryAfterMs (MODULE_API 1.15.0, D371), so coreApi is ^1.15.0, pinned to `main` at the sha website#215 merged as. **2026-10-19, Steam sign-in (PLAN_REDESIGNS §15, D392/D396):** this module registers onIdentityLinking and onIdentityLinked (MODULE_API 1.17.0), so coreApi is ^1.17.0. Pinned to the head of website feat/steam-signin while that PR is open — a 1.16.0 core refuses to load it — and is now on `edge` at the sha website#218 merged as. That is `edge`, not `main`, because #218 went into `edge` for the in-progress cutover. **2026-10-22, the cutover (PLAN_LAST_FIXES stage 6):** website#220 put `edge` on `main`, so this is back on `main` at the sha #220 merged as. That tree adds the last fixes' core half (website#219, no MODULE_API bump), whose only route changes are core's own event routes, so routes.manifest.json does not change.",
"$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/rust and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves, because a mount prefix is a string in server/index.js and a documented path is a string in a JSON file, and whether those name the same URL is a fact about a running core. It also answers the blind spot phase 1 had to check by hand: core mounts several routes at the TIER ROOT (/status, /version), which the loader's collision probe cannot see, so /rust being free is asserted here by a core rather than by a reading. Pinned rather than tracking a branch on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together. This module needs MODULE_API 1.10.0 (module.json's coreApi is ^1.10.0), which the Event System cutover put on `main` — so unlike Module-uo, which spent the Event System window pinned to `edge`, this repo starts pinned to `main` and should stay there unless it comes to depend on a contract member that has not shipped yet. **2026-09-27, protocol 13 step 2:** this module calls ctx.events.expired (MODULE_API 1.11.0, PLAN_FIXES D183), so coreApi is ^1.11.0. It was pinned to website#209's branch head while that PR was open — a 1.10.0 core refuses to load it — and is back on `main` at the sha #209 merged as. **2026-10-12, protocol 14 (PLAN_REDESIGNS §2):** this module registers an event template and declares param durations, a lease value example and option notes (MODULE_API 1.13.0), so coreApi is ^1.13.0. It was pinned to website#212's branch head while that PR was open, and is back on `main` at the sha #212 merged as (the same tree). **2026-10-12, PLAN_REDESIGNS §12 (permission rewards):** this module declares a param with multiple: true (MODULE_API 1.14.0), so coreApi is ^1.14.0. It was pinned to website#213's branch head while that PR was open, and is back on `main` at the sha #213 merged as (the same tree). **2026-10-15, PLAN_REDESIGNS §14.3.4 (Dangerous Treasures):** the Start a Dangerous Treasures event step answers DT's warm-up with retryAfterMs (MODULE_API 1.15.0, D371), so coreApi is ^1.15.0, pinned to `main` at the sha website#215 merged as. **2026-10-19, Steam sign-in (PLAN_REDESIGNS §15, D392/D396):** this module registers onIdentityLinking and onIdentityLinked (MODULE_API 1.17.0), so coreApi is ^1.17.0. Pinned to the head of website feat/steam-signin while that PR is open — a 1.16.0 core refuses to load it — and is now on `edge` at the sha website#218 merged as. That is `edge`, not `main`, because #218 went into `edge` for the in-progress cutover. **2026-10-22, the cutover (PLAN_LAST_FIXES stage 6):** website#220 put `edge` on `main`, so this is back on `main` at the sha #220 merged as. That tree adds the last fixes' core half (website#219, no MODULE_API bump), whose only route changes are core's own event routes, so routes.manifest.json does not change. **2026-10-26, the public demo (DEMO_MODE.md §3.4, §3.5, DM18):** this module declares demoLocks, reads ctx.demoMode, registers onDemoSeed (MODULE_API 1.18.0) and gives its two console-command actions demoRefuses (1.19.0), so coreApi is ^1.19.0. Pinned to the head of website feature/demo-step-refusal (website#224) while that PR is open — a 1.18.0 core never asks demoRefuses — and moves to the sha #224 merges as.",
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
"ref": "6890a74c7c4ad5690509e8c163e477d7f7b399e7",
"refName": "main @ website#220 merge (the cutover), MODULE_API 1.17.0"
"ref": "cee4ab8aeab4dd218724ae9f35b7bc87e97e91aa",
"refName": "website feature/demo-step-refusal @ website#224 head, MODULE_API 1.19.0"
}

View File

@@ -130,7 +130,7 @@ function formFrom(server) {
isNew: false,
id: server.id,
name: server.name,
sidecarBaseUrl: server.sidecarBaseUrl,
sidecarBaseUrl: server.sidecarBaseUrl || '',
sidecarToken: '',
enabled: server.enabled,
sortOrder: server.sortOrder,
@@ -264,7 +264,7 @@ export default function ServerSettings() {
</span>
</div>
<div className="dim" style={{ fontSize: '0.76rem', marginTop: 4 }}>
{s.sidecarBaseUrl} · {s.hasToken ? 'token stored' : 'no token'} · protocol {s.protocol}
{s.sidecarBaseUrl || 'sidecar address hidden'} · {s.hasToken ? 'token stored' : 'no token'} · protocol {s.protocol}
{s.sidecarProtocol != null && s.sidecarProtocol !== s.protocol ? ` (sidecar speaks ${s.sidecarProtocol})` : ''}
{s.lastSeenAt ? ` · last seen ${ago(s.lastSeenAt)}` : ''}
</div>

View File

@@ -1,6 +1,6 @@
{
"_comment": "Generated freeze of module-rust's engagement contract (docs/modules/rust/PLAN.md §25). Regenerate with `npm run engagement:manifest` in server/. A renamed variable, a changed type or a widened ceiling breaks stored templates and rules, so the diff here is the review signal.",
"coreApi": "^1.17.0",
"coreApi": "^1.19.0",
"triggers": [
{
"id": "rust.base.destroyed",

View File

@@ -2,7 +2,7 @@
"id": "rust",
"name": "Rust",
"version": "0.1.0",
"coreApi": "^1.17.0",
"coreApi": "^1.19.0",
"server": "server/index.js",
"client": { "entry": "client/dist/entry.js" },
"schema": "server/db/schema.sql",
@@ -13,5 +13,9 @@
"player": ["/rust"]
},
"extensions": ["admin.users.detail"],
"capabilities": ["rust", "servers", "killfeed", "leaderboard", "presence", "wipes", "identity", "map"]
"capabilities": ["rust", "servers", "killfeed", "leaderboard", "presence", "wipes", "identity", "map"],
"demoLocks": [
{ "method": "PUT", "path": "/admin/rust/servers/:id" },
{ "method": "DELETE", "path": "/admin/rust/servers/:id" }
]
}

View File

@@ -186,4 +186,9 @@ module.exports = {
// origin to be relative to. A getter over core's getter, so an operator who
// changes `APP_BASE_URL` is read at the call, never captured at load.
get baseUrl() { return need().site.baseUrl },
// Whether this is the public demo (MODULE_API 1.18.0, DEMO_MODE.md §3.4, §3.5).
// Read through `demo.js`, which holds every lock it switches on. Only a real
// `true` counts, so a core that predates the member reads as "not the demo".
get demoMode() { return need().demoMode === true },
}

135
server/demo.js Normal file
View File

@@ -0,0 +1,135 @@
// ── The public demo's module-side locks (DEMO_MODE.md §3.4, §3.5) ──────────
//
// Core locks this module's two link routes by `demoLocks` in module.json. What a
// route list cannot express lives here, behind `ctx.demoMode` (MODULE_API
// 1.18.0), and every handler that enforces one of these asks this file:
//
// the console-command deny list (§3.4, DM11): the commands that would break
// the rig for every visitor until the next wipe. Asked at save, through
// core's `demoRefuses` hook (1.19.0, DM18), and again at run;
// the bridge's own config (§3.5, DM14): neither readable nor writable, because
// it holds the bridge's link to the sidecar and its credentials;
// secrets in every other config: redacted on the way out, read-only on the
// way in, and no raw-JSON saves.
//
// Off the demo nothing here refuses anything. The server is the lock (DM10):
// the UI may explain a refusal, but no screen is relied on to make one.
const core = require('./core')
const CODE = 'DEMO_LOCKED'
const MESSAGE = 'This is turned off on the demo site.'
/** Whether this is the public demo. */
function isDemo() {
return core.demoMode
}
/** The demo's refusal, in core's shape, so core's one toast shows it. */
function refuse(res, message = MESSAGE, extra = {}) {
return res.status(403).json({ message, code: CODE, ...extra })
}
// ── The console-command deny list (§3.4, DM11) ─────────────────────────────
/** Denied by first word, exactly. */
const DENIED = new Set(['quit', 'restart', 'oxide.unload', 'oxide.reload', 'fps.limit', 'server.tickrate'])
/** Denied by prefix: every RCON command. */
const DENIED_PREFIXES = ['rcon.']
/**
* The command a line runs, as the deny list compares it: the first word,
* lower-cased, with a leading `global.` stripped (Rust accepts `global.quit`)
* and the `o.` short form spelled `oxide.` (`o.unload`). Only the first word,
* so arguments cannot hide a denied command; `;` does not split a Rust console
* line (the §16 spike), so there is no second command to look for.
*/
function commandWord(line) {
let word = String(line === undefined || line === null ? '' : line).trim().split(/\s+/)[0].toLowerCase()
if (word.startsWith('global.')) word = word.slice('global.'.length)
if (word.startsWith('o.')) word = `oxide.${word.slice('o.'.length)}`
return word
}
/** Whether one console line is denied on the demo, by its first word. */
function isDeniedCommand(line) {
const word = commandWord(line)
if (!word) return false
return DENIED.has(word) || DENIED_PREFIXES.some((prefix) => word.startsWith(prefix))
}
/**
* The refusal sentence for a list of console lines, or null when none is
* denied — or when this is not the demo. Names the line as the author typed it.
*/
function deniedCommand(lines) {
if (!isDemo()) return null
const items = Array.isArray(lines) ? lines : lines === undefined || lines === null ? [] : [lines]
const hit = items.find((line) => isDeniedCommand(line))
return hit === undefined ? null : `"${String(hit).trim()}" is turned off on the demo site.`
}
// ── The bridge's own config (§3.5, DM14) ───────────────────────────────────
/**
* The bridge plugin's name. The catalogue's `self` is the plugin's own word for
* it and is used when it is there; this is the floor, so a catalogue that could
* not be read never unlocks the file (the lock fails closed).
*/
const BRIDGE = 'RunicGateway'
/**
* A config path in the form the host will resolve it to, for the bridge test
* only: `.` segments dropped, each segment trimmed of the spaces and trailing
* dots Windows ignores, and lower-cased. `./RunicGateway.json` and
* `RunicGateway /x.json` are the bridge's files on some host, so they are the
* bridge's files here.
*/
function canonicalSegments(path) {
return String(path || '')
.split(/[\\/]+/)
// A `.` segment trims to nothing and is dropped with the empty ones.
.map((segment) => segment.trim().replace(/[. ]+$/, ''))
.filter(Boolean)
.map((segment) => segment.toLowerCase())
}
/**
* Whether a config path belongs to the bridge plugin: its first segment, or its
* file name at the root, is the bridge's name. `self` is the catalogue's.
*/
function isBridgePath(path, self) {
const names = new Set([BRIDGE.toLowerCase()])
if (self) names.add(String(self).toLowerCase())
const parts = canonicalSegments(path)
if (!parts.length) return false
const first = parts.length > 1 ? parts[0] : parts[0].replace(/\.json$/, '')
return names.has(first)
}
/** Whether a plugin name (a catalogue group, an audit row's plugin) is the bridge. */
function isBridgePlugin(plugin, self) {
const name = String(plugin || '').toLowerCase()
return name === BRIDGE.toLowerCase() || (self ? name === String(self).toLowerCase() : false)
}
// ── Secrets in every other config (§3.5) ───────────────────────────────────
/** What a redacted value reads as, in the text and in the form. */
const REDACTED = '(hidden on the demo site)'
module.exports = {
CODE,
MESSAGE,
BRIDGE,
REDACTED,
isDemo,
refuse,
commandWord,
isDeniedCommand,
deniedCommand,
canonicalSegments,
isBridgePath,
isBridgePlugin,
}

97
server/demoSeed.js Normal file
View File

@@ -0,0 +1,97 @@
// ── The public demo's seed: this module's rows (MODULE_API 1.18.0, DM15) ────
//
// `npm run seed:demo` hands each module its `modules.<id>` section of the
// fixture, with every `"$env:NAME"` already replaced, and the demo accounts' ids
// (DEMO_MODE.md §7.3). This module writes two kinds of row from it, through its
// own models; core never writes them:
//
// servers the link to the rig: the sidecar URL and token come from the host's
// .env, so the token is never in the repo
// links demo-player's stand-in Steam id (DM6), so the player screens have
// somebody to show
//
// { "servers": [{ "id": "main", "name": "Demo", "sidecarBaseUrl": "$env:RUST_SIDECAR_URL",
// "sidecarToken": "$env:RUST_SIDECAR_TOKEN" }],
// "links": [{ "username": "demo-player", "steamId": "7656119…", "serverId": "main" }] }
//
// **It runs on every seed, so it upserts.** Core seeds content on a database's
// first run only, but calls this every time. Everything is checked before
// anything is written, and a problem throws, which fails the seed.
const linksDb = require('./model/links/links.db')
const servers = require('./model/servers/servers.model')
const serversDb = require('./model/servers/servers.db')
const sidecar = require('./sidecarClient')
const core = require('./core')
const log = core.logger('demo')
const SERVER_ID = /^[a-z0-9][a-z0-9-]{0,63}$/
const STEAM_ID = /^7656119\d{10}$/
/** Every problem with the section, as sentences. Empty when it is good to write. */
function checkSection(data, accounts = {}) {
const problems = []
if (!data || typeof data !== 'object' || Array.isArray(data)) return ['modules.rust must be an object']
const list = (key) => (data[key] === undefined ? [] : data[key])
for (const key of ['servers', 'links']) {
if (data[key] !== undefined && !Array.isArray(data[key])) problems.push(`modules.rust.${key} must be an array`)
}
if (problems.length) return problems
list('servers').forEach((s, i) => {
const at = `modules.rust.servers[${i}]`
if (!s || typeof s !== 'object') return problems.push(`${at} must be an object`)
if (!SERVER_ID.test(String(s.id || ''))) problems.push(`${at}.id must be lowercase letters, digits and hyphens`)
if (typeof s.name !== 'string' || !s.name.trim()) problems.push(`${at}.name is required`)
if (!/^https?:\/\//.test(String(s.sidecarBaseUrl || ''))) problems.push(`${at}.sidecarBaseUrl must be an http(s) URL`)
if (typeof s.sidecarToken !== 'string' || !s.sidecarToken) problems.push(`${at}.sidecarToken is required`)
return undefined
})
list('links').forEach((l, i) => {
const at = `modules.rust.links[${i}]`
if (!l || typeof l !== 'object') return problems.push(`${at} must be an object`)
if (!accounts[l.username]) problems.push(`${at}.username must be one of the demo accounts`)
if (!STEAM_ID.test(String(l.steamId || ''))) problems.push(`${at}.steamId must be a 17-digit Steam id`)
if (l.serverId !== undefined && !SERVER_ID.test(String(l.serverId))) problems.push(`${at}.serverId must be a server id`)
return undefined
})
return problems
}
/** The hook core calls: `onDemoSeed(data, { accounts })`. */
async function onDemoSeed(data, { accounts = {} } = {}) {
const problems = checkSection(data, accounts)
if (problems.length) throw new Error(`the Rust demo seed has problems: ${problems.join('; ')}`)
for (const s of data.servers || []) {
await serversDb.upsertServer({
id: s.id,
name: s.name.trim(),
sidecarBaseUrl: s.sidecarBaseUrl,
sidecarTokenEnc: servers.encryptToken(s.sidecarToken),
protocol: s.protocol === undefined ? sidecar.PROTOCOL_VERSION : Number(s.protocol),
enabled: s.enabled === undefined ? true : Boolean(s.enabled),
sortOrder: s.sortOrder === undefined ? 0 : Number(s.sortOrder),
})
log.info('demo server ready', { server: s.id })
}
// A link is a plain insert by design (D23), so the upsert is spelled out here:
// the right owner already holds it, or it is moved to them. Only the seed may
// move a link, and only to a demo account.
for (const l of data.links || []) {
const userId = accounts[l.username]
const steamId = String(l.steamId)
const existing = await linksDb.getBySteamId(steamId)
if (existing && Number(existing.userId) === Number(userId)) continue
if (existing) await linksDb.removeBySteamId(steamId)
await linksDb.insert({ steamId, userId, name: l.name || null, serverId: l.serverId || null })
log.info('demo link ready', { username: l.username })
}
}
module.exports = { checkSection, onDemoSeed }

View File

@@ -41,6 +41,7 @@ const core = require('./core')
const { serverFor, transportError, pluginError } = require('./eventLeases')
const { BUDGET_MS } = require('./eventWorld')
const rewards = require('./eventRewards')
const demo = require('./demo')
const log = core.logger('commands')
@@ -115,6 +116,9 @@ function checkConfirm(params, sentence) {
/** The first command that names a placeholder, or null: only a reward has someone to fill one with. */
const withPlaceholder = (lines) => lines.find((line) => PLACEHOLDER.test(line)) || null
/** A `multiple` param as a list: a bare value is a list of one, nothing is none. */
const asList = (raw) => (raw === undefined || raw === null || raw === '' ? [] : Array.isArray(raw) ? raw : [raw])
/** A command list's price: at least one, so the switchboard sees the dimension. */
function pricedCommands(raw) {
const checked = checkCommands(raw, { required: false })
@@ -190,6 +194,11 @@ async function checkRun(params) {
const teardown = checkCommands(params.teardown, { field: 'teardown commands', required: false })
if (!teardown.ok) return { ok: false, retry: false, error: teardown.error }
// The demo's deny list (DEMO_MODE.md §3.4), at run: core asked at save
// (demoRefuses), but a definition can predate the list.
const denied = demo.deniedCommand([...commands.value, ...teardown.value])
if (denied) return { ok: false, retry: false, error: denied }
const placeholder = withPlaceholder([...commands.value, ...teardown.value])
if (placeholder) {
return { ok: false, retry: false, error: `{steamid} and {name} are filled in only by Reward by console command, and "${placeholder}" has one` }
@@ -324,6 +333,10 @@ const commandRun = {
revert: revertRun,
// The demo's deny list at save (MODULE_API 1.19.0, DM18). Core asks only on the
// demo; null lets the step through.
demoRefuses: (p) => demo.deniedCommand([...asList(p.commands), ...asList(p.teardown)]),
// The site cannot ask a server whether a command's effect is still there, so
// a teardown not yet run is always in force: "I do not know" is never "gone".
async reconcile({ resources }) {
@@ -397,10 +410,15 @@ const commandReward = {
cost: (p) => ({ 'rust.commands': pricedCommands(p.commands) * pricedPeople(p) }),
demoRefuses: (p) => demo.deniedCommand(asList(p.commands)),
async perform({ runId, stepId, idempotencyKey, params, verify }) {
const commands = checkCommands(params.commands)
if (!commands.ok) return { ok: false, retry: false, error: commands.error }
const denied = demo.deniedCommand(commands.value)
if (denied) return { ok: false, retry: false, error: denied }
const confirm = checkConfirm(params, REWARD_SURE)
if (!confirm.ok) return { ok: false, retry: false, error: confirm.error }

View File

@@ -168,6 +168,13 @@ module.exports = function register(ctx, api) {
api.onIdentityLinking(links.checkSteamSignin)
api.onIdentityLinked(links.linkFromSteamSignin)
// The public demo's seed (MODULE_API 1.18.0, DM15): the rig's server row and
// demo-player's Steam link, written by this module from its part of the
// fixture. Called only by `npm run seed:demo` on a DEMO_MODE deployment. The
// demo's other locks are `demoLocks` in module.json and `demo.js`.
const demoSeed = require('./demoSeed') // eslint-disable-line global-require
api.onDemoSeed(demoSeed.onDemoSeed)
// The leases (PLAN.md §27, protocol 8): what an event may BORROW on a server
// and must give back. Core's `core.lease` is the verb; these are the values it
// may name and the four callables each ships. Every lease is targeted and the

View File

@@ -174,6 +174,71 @@ function shapeFile(file, { self = null, maxDepth = 6 } = {}) {
}
}
/**
* A shaped file with every credential-shaped value hidden, for the public demo
* (DEMO_MODE.md §3.5, DM14). The form's mask is not a lock (DM10), so on the
* demo the value never leaves the module:
*
* - in `text`, each flagged string or number is spliced out for the
* placeholder, from the end backwards so the spans stay true;
* - in `fields`, a flagged field carries the placeholder and is `locked`,
* so the form shows it read-only, and the save refuses an edit to it anyway.
*
* "Flagged" is the editor's own reading (`configEdit.isSecretKey`, inherited by
* everything under a flagged key), so the demo hides exactly what the form masks.
* A file that does not parse cannot be read for its secrets, so its text is
* withheld whole: the lock fails closed.
*/
function redactSecrets(shaped, placeholder) {
if (!shaped) return shaped
let root
try {
root = configEdit.scan(shaped.text)
} catch {
return { ...shaped, text: '', fields: null, redacted: true, withheld: 'This file does not parse, so it is not shown on the demo site.' }
}
const spans = []
const visit = (node, secret) => {
if (node.type === 'object') {
for (const child of node.children) visit(child, secret || configEdit.isSecretKey(child.key))
} else if (node.type === 'array') {
for (const child of node.children) visit(child, secret)
} else if (secret && (node.type === 'number' || (node.type === 'string' && node.value !== ''))) {
spans.push(node)
}
}
visit(root, false)
let text = shaped.text
for (const node of [...spans].sort((a, b) => b.start - a.start)) {
text = text.slice(0, node.start) + JSON.stringify(placeholder) + text.slice(node.end)
}
const fields = Array.isArray(shaped.fields)
? shaped.fields.map((field) => {
if (!field.secret) return field
const leaf = field.type === 'number' || field.type === 'string'
const { raw, ...rest } = field
return {
...rest,
...(leaf && !(field.type === 'string' && field.value === '') ? { value: placeholder } : {}),
locked: true,
}
})
: shaped.fields
return { ...shaped, text, fields, redacted: true }
}
/** Whether any edit names a credential-shaped field, as `applyEdits` marks them. */
function editsTouchSecret(edits) {
return (edits || []).some(
(edit) => Array.isArray(edit && edit.pointer) && edit.pointer.some((step) => typeof step === 'string' && configEdit.isSecretKey(step)),
)
}
/**
* Which locked keys differ between two versions of a document.
*
@@ -290,6 +355,8 @@ module.exports = {
isPlausiblePath,
shapeCatalogue,
shapeFile,
redactSecrets,
editsTouchSecret,
pluginOf,
isBridgeConfig,
lockedKeysFor,

View File

@@ -203,7 +203,9 @@ async function listForAdmin(now = Date.now()) {
// The public shape first, so the admin-only fields below cannot be
// overwritten by a key the public shape happens to share.
...shapePublic(row, state, now),
sidecarBaseUrl: row.sidecarBaseUrl,
// Left out on the public demo, so the screen gives nothing away that helps
// someone reach the sidecar (DEMO_MODE.md §3.5); editing the link is locked.
...(core.demoMode ? {} : { sidecarBaseUrl: row.sidecarBaseUrl }),
hasToken: Boolean(row.sidecarTokenEnc),
protocol: Number(row.protocol),
enabled: Boolean(row.enabled),

View File

@@ -33,6 +33,7 @@
const core = require('../../core')
const configEdit = require('../../configEdit')
const demo = require('../../demo')
const db = require('../../model/config/config.db')
const model = require('../../model/config/config.model')
const servers = require('../../model/servers/servers.model')
@@ -87,7 +88,32 @@ async function listFiles(req, res) {
return res.status(502).json({ message: refusalMessage(reply.data) })
}
return res.json(model.shapeCatalogue(reply.data))
return res.json(forDemo(model.shapeCatalogue(reply.data)))
}
/**
* The catalogue without the bridge's own files, on the demo (DEMO_MODE.md §3.5,
* DM14): they hold its link to the sidecar, so they are not listed, read or
* written. Off the demo the catalogue is untouched.
*/
function forDemo(catalogue) {
if (!demo.isDemo() || !catalogue) return catalogue
return {
...catalogue,
plugins: catalogue.plugins
.filter((group) => !demo.isBridgePlugin(group.plugin, catalogue.self))
.map((group) => ({ ...group, files: group.files.filter((file) => !demo.isBridgePath(file.path, catalogue.self)) })),
}
}
/** A shaped file as the caller may see it: secrets hidden on the demo. */
function visible(shaped) {
return demo.isDemo() ? model.redactSecrets(shaped, demo.REDACTED) : shaped
}
/** The demo's refusal for the bridge's config, in one place. */
function refuseBridge(res) {
return demo.refuse(res, 'The bridge plugin’s configuration is not shown on the demo site.')
}
/** One file: its text, and the reading of it the form is drawn from. */
@@ -98,6 +124,10 @@ async function readFile(req, res) {
return res.status(400).json({ message: 'That is not a configuration path' })
}
// Before the host is asked, by the bridge's own name; and again below, by the
// name its catalogue gives, in case the two ever differ.
if (demo.isDemo() && demo.isBridgePath(path, null)) return refuseBridge(res)
const server = await serverOr404(req, res)
if (!server) return undefined
@@ -116,7 +146,8 @@ async function readFile(req, res) {
}
const self = catalogueReply.ok && catalogueReply.data ? catalogueReply.data.self : null
return res.json(model.shapeFile(fileReply.data, { self }))
if (demo.isDemo() && demo.isBridgePath(path, self)) return refuseBridge(res)
return res.json(visible(model.shapeFile(fileReply.data, { self })))
}
/**
@@ -135,6 +166,16 @@ async function writeFile(req, res) {
return res.status(400).json({ message: 'That is not a configuration path' })
}
if (demo.isDemo()) {
if (demo.isBridgePath(path, null)) return refuseBridge(res)
// A raw document sent back from the demo holds the placeholders, and would
// write them over the real values. The form splices only what changed.
if (tier === 'raw') return demo.refuse(res, 'Raw JSON saves are turned off on the demo site. Use the form.')
if (model.editsTouchSecret(req.body.edits)) {
return demo.refuse(res, 'Credential fields are read-only on the demo site.')
}
}
const server = await serverOr404(req, res)
if (!server) return undefined
@@ -151,6 +192,7 @@ async function writeFile(req, res) {
const onDisk = current.data
const self = catalogue.ok && catalogue.data ? catalogue.data.self : null
if (demo.isDemo() && demo.isBridgePath(path, self)) return refuseBridge(res)
const locked = model.lockedKeysFor(path, self)
// The browser's version against what is on the host right now. The plugin
@@ -160,7 +202,7 @@ async function writeFile(req, res) {
if (String(req.body.version || '') !== String(onDisk.version)) {
return res.status(409).json({
message: 'That file changed on the server since you opened it',
current: model.shapeFile(onDisk, { self }),
current: visible(model.shapeFile(onDisk, { self })),
})
}
@@ -287,7 +329,9 @@ async function writeFile(req, res) {
/** What has been written to this server's configuration lately, and by whom. */
async function history(req, res) {
try {
return res.json({ writes: await db.recentWrites(req.params.serverId, req.query.limit) })
const writes = await db.recentWrites(req.params.serverId, req.query.limit)
// The bridge's own writes are left out on the demo, as its files are (§3.5).
return res.json({ writes: demo.isDemo() ? writes.filter((w) => !isBridgeWrite(w)) : writes })
} catch (err) {
log.error('failed to read the configuration history', { error: err.message })
return res.status(500).json({ message: 'Failed to read the configuration history' })
@@ -305,6 +349,7 @@ async function writeStatus(req, res) {
try {
const write = await db.getWrite(req.params.serverId, Number(req.params.writeId))
if (!write) return res.status(404).json({ message: 'No such configuration write' })
if (demo.isDemo() && isBridgeWrite(write)) return refuseBridge(res)
return res.json({ write })
} catch (err) {
@@ -313,6 +358,11 @@ async function writeStatus(req, res) {
}
}
/** Whether an audit row is a write to the bridge's own config. */
function isBridgeWrite(write) {
return demo.isBridgePlugin(write.plugin, null) || demo.isBridgePath(write.path, null)
}
/**
* One audit row, plus the activity entry core owns. Never lets a logging failure
* fail a save. Returns the row's id, or null when it could not be written.

View File

@@ -104,6 +104,8 @@ function fakeCtx(overrides = {}) {
accountChangeLimiter: (req, res, next) => next(),
},
site: { baseUrl: 'http://localhost:5173' },
// MODULE_API 1.18.0: false unless a suite says it is the public demo.
demoMode: false,
...rest,
}
// Non-enumerable, and that is not tidiness. Core freezes every object value on
@@ -169,6 +171,8 @@ function fakeApi() {
// MODULE_API 1.17.0 (Steam sign-in, PLAN_REDESIGNS §15).
onIdentityLinking(fn) { once('onIdentityLinking'); record.hooks.onIdentityLinking = fn },
onIdentityLinked(fn) { once('onIdentityLinked'); record.hooks.onIdentityLinked = fn },
// MODULE_API 1.18.0 (the public demo's seed, DM15).
onDemoSeed(fn) { once('onDemoSeed'); record.hooks.onDemoSeed = fn },
}
api.record = record
return api

387
server/test/demo.test.js Normal file
View File

@@ -0,0 +1,387 @@
// ── The public demo's module-side locks (website/DEMO_MODE.md §3.4, §3.5, §7.3) ──
//
// Everything a route list cannot lock, behind `ctx.demoMode` (MODULE_API
// 1.18.0): the console-command deny list (DM11) at save through core's
// `demoRefuses` (1.19.0, DM18) and at run; the bridge's config, neither read nor
// written (DM14); secrets in every other config, redacted and read-only; the
// sidecar URL left off the Servers view; and the seed hook (DM15). Each lock is
// held twice: on the demo it refuses, and off it nothing changes.
const test = require('node:test')
const assert = require('node:assert')
const { fakeCtx } = require('./_fakes')
function withCore({ demo = true, select = () => [] } = {}) {
const queries = []
require('../core')._reset()
require('../core').init(
fakeCtx({
demoMode: demo,
db: {
query: (sql, params) => {
const flat = sql.trim().replace(/\s+/g, ' ')
queries.push({ sql: flat, params })
const verb = flat.split(' ')[0].toUpperCase()
if (verb === 'SELECT') return Promise.resolve(select(flat, params))
return Promise.resolve({ affectedRows: 1, insertId: 1 })
},
pool: {},
},
}),
)
return queries
}
function fakeRes() {
const res = { statusCode: 200, body: null }
res.status = (code) => {
res.statusCode = code
return res
}
res.json = (body) => {
res.body = body
return res
}
return res
}
// ── The console-command deny list (§3.4) ───────────────────────────────────
test('the deny list compares the first word, normalised', () => {
withCore()
const demo = require('../demo')
for (const line of [
'quit', 'QUIT', ' quit ', 'global.quit', 'restart 60 "bye"',
'oxide.unload RunicGateway', 'o.unload RunicNPC', 'oxide.reload *', 'o.reload Kits', 'global.oxide.reload x',
'rcon.password hunter2', 'RCON.web 0', 'fps.limit 5', 'server.tickrate 1',
]) {
assert.equal(demo.isDeniedCommand(line), true, line)
}
for (const line of [
'say hello', 'server.writecfg', 'oxide.grant group default kits.use', 'kick someone', 'rbe 1',
'quitter', 'restarts', 'oxide.load Foo', 'entity.spawn bear', 'weather.rain 1', '',
]) {
assert.equal(demo.isDeniedCommand(line), false, line)
}
assert.equal(demo.commandWord('Global.O.Unload X'), 'oxide.unload')
})
test('the deny list refuses nothing off the demo', () => {
withCore({ demo: false })
assert.equal(require('../demo').deniedCommand(['quit']), null)
})
test('both command actions refuse a denied command at save, through demoRefuses', () => {
withCore()
const { ACTIONS } = require('../eventCommands')
const run = ACTIONS.find((a) => a.id === 'rust.command.run')
const reward = ACTIONS.find((a) => a.id === 'rust.command.reward')
assert.equal(run.demoRefuses({ commands: ['say hi', 'quit'] }), '"quit" is turned off on the demo site.')
// A teardown runs at the end of the event, so it is checked as well.
assert.equal(run.demoRefuses({ commands: ['rbe 1'], teardown: ['o.unload RunicGateway'] }), '"o.unload RunicGateway" is turned off on the demo site.')
assert.equal(run.demoRefuses({ commands: 'say hi' }), null)
assert.equal(reward.demoRefuses({ commands: ['sr add {steamid} 100', 'rcon.password x'] }), '"rcon.password x" is turned off on the demo site.')
assert.equal(reward.demoRefuses({ commands: ['sr add {steamid} 100'] }), null)
})
test('a denied command is refused at run as well, permanently, before the rig is asked', async () => {
withCore()
const sidecar = require('../sidecarClient')
let asked = 0
sidecar.commandsRun = async () => { asked += 1; return { ok: true, data: { kind: 'command.ran', ran: 1 } } }
const { ACTIONS } = require('../eventCommands')
const run = ACTIONS.find((a) => a.id === 'rust.command.run')
const reward = ACTIONS.find((a) => a.id === 'rust.command.reward')
const ran = await run.perform({ runId: 1, stepId: 's', idempotencyKey: 'k', params: { server: 'main', commands: ['quit'], confirm: true } })
assert.deepEqual(ran, { ok: false, retry: false, error: '"quit" is turned off on the demo site.' })
const rewarded = await reward.perform({ runId: 1, stepId: 's', idempotencyKey: 'k', params: { server: 'main', recipients: 'linked', commands: ['restart'], confirm: true } })
assert.deepEqual(rewarded, { ok: false, retry: false, error: '"restart" is turned off on the demo site.' })
assert.equal(asked, 0)
})
// ── The bridge's own config (§3.5) ─────────────────────────────────────────
test('a bridge path is recognised however it is spelled', () => {
withCore()
const demo = require('../demo')
for (const p of ['RunicGateway.json', './RunicGateway.json', 'runicgateway.json', 'RunicGateway/extra.json', ' RunicGateway.json ', 'RunicGateway./x.json', 'Bridge.json']) {
assert.equal(demo.isBridgePath(p, 'Bridge'), true, p)
}
for (const p of ['ZoneManager.json', 'Kits/RunicGateway.json', 'RunicGatewayExtra.json', '@data/RaidableBases/Profiles/a.json']) {
assert.equal(demo.isBridgePath(p, 'Bridge'), false, p)
}
})
const SECRET_FILE = `{
"Discord Webhook": "https://discord.example/hook/abc",
"Api Key": 123456789,
"Empty Token": "",
"Use Auth": true,
"Credentials": { "User": "admin", "List": ["one", "two"] },
"Gather": { "Wood": 1.0 },
"Enabled": true
}`
function stubSidecar({ file = SECRET_FILE, self = 'RunicGateway' } = {}) {
const sidecar = require('../sidecarClient')
const calls = []
sidecar.configFile = async (server, asked) => {
calls.push(['read', asked])
return { ok: true, status: 'ok', data: { kind: 'config.file', path: asked, text: file, version: `v-${asked}`, bytes: file.length } }
}
sidecar.configFiles = async () => {
calls.push(['list'])
return {
ok: true,
status: 'ok',
data: {
kind: 'config.catalogue',
root: '/home/container/oxide/config',
self,
files: [
{ path: 'Discord.json', bytes: 40, editable: true, plugin: 'Discord' },
{ path: 'RunicGateway.json', bytes: 90, editable: true, plugin: 'RunicGateway' },
],
plugins: [{ name: 'Discord', title: 'Discord', version: '1.0.0' }, { name: 'RunicGateway', title: 'Runic Gateway', version: '1.0.0' }],
},
}
}
sidecar.configWrite = async (server, body) => {
calls.push(['write', body])
return { ok: true, status: 'ok', data: { kind: 'config.report', ok: true, reloaded: true, files: [{ path: body.files[0].path, version: 'v-after' }] } }
}
return calls
}
function stubServer() {
const row = { id: 'main', name: 'Main', sidecarBaseUrl: 'http://x', sidecarTokenEnc: null, protocol: 14 }
require('../model/servers/servers.db').getServer = async () => row
require('../model/servers/servers.model').withToken = () => ({ id: row.id, baseUrl: row.sidecarBaseUrl, token: 't' })
}
const controller = () => require('../router/admin/config.controller')
test('the file list leaves the bridge out on the demo, and keeps it off the demo', async () => {
withCore()
stubSidecar()
stubServer()
const res = fakeRes()
await controller().listFiles({ params: { serverId: 'main' }, query: {} }, res)
assert.deepEqual(res.body.plugins.map((p) => p.plugin), ['Discord'])
withCore({ demo: false })
const off = fakeRes()
await controller().listFiles({ params: { serverId: 'main' }, query: {} }, off)
assert.deepEqual(off.body.plugins.map((p) => p.plugin), ['Discord', 'RunicGateway'])
})
test('the bridge\'s config cannot be read on the demo, and the host is never asked for it', async () => {
withCore()
const calls = stubSidecar()
stubServer()
for (const path of ['RunicGateway.json', './RunicGateway.json']) {
const res = fakeRes()
await controller().readFile({ params: { serverId: 'main' }, query: { path } }, res)
assert.equal(res.statusCode, 403, path)
assert.equal(res.body.code, 'DEMO_LOCKED')
}
assert.deepEqual(calls, [])
// Named by the catalogue's own word for the plugin, when that differs.
const renamed = stubSidecar({ self: 'Bridge' })
const res = fakeRes()
await controller().readFile({ params: { serverId: 'main' }, query: { path: 'Bridge.json' } }, res)
assert.equal(res.statusCode, 403)
assert.ok(renamed.some(([kind]) => kind === 'list'))
})
test('another plugin\'s secrets are redacted in the text and the form, and made read-only', async () => {
withCore()
stubSidecar()
stubServer()
const res = fakeRes()
await controller().readFile({ params: { serverId: 'main' }, query: { path: 'Discord.json' } }, res)
assert.equal(res.statusCode, 200)
const { text, fields } = res.body
assert.doesNotMatch(text, /discord\.example|123456789|"admin"|"one"/)
assert.match(text, /"Discord Webhook": "\(hidden on the demo site\)"/)
// Still JSON, and the values that are not credentials are untouched.
const parsed = JSON.parse(text)
assert.equal(parsed['Empty Token'], '')
assert.equal(parsed['Use Auth'], true)
assert.equal(parsed.Gather.Wood, 1)
assert.match(text, /"Wood": 1\.0/)
const byPath = Object.fromEntries(fields.map((f) => [f.path, f]))
assert.equal(byPath['Discord Webhook'].value, '(hidden on the demo site)')
assert.equal(byPath['Discord Webhook'].locked, true)
assert.equal(byPath['Api Key'].value, '(hidden on the demo site)')
assert.equal('raw' in byPath['Api Key'], false)
assert.equal(byPath['Credentials.List[0]'].value, '(hidden on the demo site)')
assert.equal(byPath['Gather.Wood'].locked, false)
assert.equal(res.body.redacted, true)
})
test('off the demo the file is returned as it is', async () => {
withCore({ demo: false })
stubSidecar()
stubServer()
const res = fakeRes()
await controller().readFile({ params: { serverId: 'main' }, query: { path: 'Discord.json' } }, res)
assert.equal(res.body.text, SECRET_FILE)
assert.equal(res.body.redacted, undefined)
})
test('a file that does not parse is withheld whole on the demo', () => {
withCore()
const model = require('../model/config/config.model')
const shaped = model.shapeFile({ path: 'Broken.json', version: 'v', text: '{ "Api Key": "abc", ' })
const out = model.redactSecrets(shaped, '(hidden)')
assert.equal(out.text, '')
assert.match(out.withheld, /does not parse/)
})
test('on the demo a save refuses the bridge, raw JSON and a credential field, before the host is asked', async () => {
withCore()
const calls = stubSidecar()
stubServer()
const save = async (body) => {
const res = fakeRes()
await controller().writeFile({ params: { serverId: 'main' }, body: { version: 'v-Discord.json', ...body } }, res)
return res
}
const bridge = await save({ path: 'RunicGateway.json', edits: [{ pointer: ['QueueCap'], raw: '1' }] })
assert.equal(bridge.statusCode, 403)
const raw = await save({ path: 'Discord.json', text: SECRET_FILE })
assert.equal(raw.statusCode, 403)
assert.match(raw.body.message, /Raw JSON saves/)
const secret = await save({ path: 'Discord.json', edits: [{ pointer: ['Discord Webhook'], value: 'https://evil' }] })
assert.equal(secret.statusCode, 403)
const nested = await save({ path: 'Discord.json', edits: [{ pointer: ['Credentials', 'User'], value: 'x' }] })
assert.equal(nested.statusCode, 403)
for (const r of [bridge, raw, secret, nested]) assert.equal(r.body.code, 'DEMO_LOCKED')
assert.deepEqual(calls, [])
})
test('on the demo a form edit to an ordinary field saves, and leaves the secrets on disk alone', async () => {
withCore()
const calls = stubSidecar()
stubServer()
const res = fakeRes()
await controller().writeFile(
{ params: { serverId: 'main' }, body: { path: 'Discord.json', version: 'v-Discord.json', edits: [{ pointer: ['Enabled'], value: false }] } },
res,
)
assert.equal(res.statusCode, 200)
const [, body] = calls.find(([kind]) => kind === 'write')
assert.match(body.files[0].text, /discord\.example\/hook\/abc/)
assert.match(body.files[0].text, /"Enabled": false/)
})
test('a conflict\'s current file is redacted too', async () => {
withCore()
stubSidecar()
stubServer()
const res = fakeRes()
await controller().writeFile(
{ params: { serverId: 'main' }, body: { path: 'Discord.json', version: 'v-stale', edits: [{ pointer: ['Enabled'], value: false }] } },
res,
)
assert.equal(res.statusCode, 409)
assert.doesNotMatch(res.body.current.text, /discord\.example/)
})
test('the write history leaves the bridge\'s writes out, and one of them cannot be read', async () => {
const rows = [
{ id: 2, serverId: 'main', path: 'RunicGateway.json', plugin: 'RunicGateway', outcome: 'applied', changes: null },
{ id: 1, serverId: 'main', path: 'Discord.json', plugin: 'Discord', outcome: 'applied', changes: null },
]
withCore({ select: (sql, params) => (sql.includes('WHERE server_id = ? AND id = ?') ? rows.filter((r) => r.id === params[1]) : rows) })
const res = fakeRes()
await controller().history({ params: { serverId: 'main' }, query: {} }, res)
assert.deepEqual(res.body.writes.map((w) => w.id), [1])
const one = fakeRes()
const db = require('../model/config/config.db')
db.getWrite = async () => rows[0]
await controller().writeStatus({ params: { serverId: 'main', writeId: '2' } }, one)
assert.equal(one.statusCode, 403)
assert.equal(one.body.code, 'DEMO_LOCKED')
})
// ── The Servers view (§3.5) ────────────────────────────────────────────────
test('the admin server list leaves the sidecar URL out on the demo, and keeps hasToken', async () => {
const row = { id: 'main', name: 'Main', sidecarBaseUrl: 'http://10.0.0.5:7790', sidecarTokenEnc: 'enc:t', protocol: 14, enabled: 1, sortOrder: 0 }
for (const demoMode of [true, false]) {
withCore({ demo: demoMode })
const db = require('../model/servers/servers.db')
db.listServers = async () => [row]
db.listState = async () => []
const [shaped] = await require('../model/servers/servers.model').listForAdmin()
assert.equal('sidecarBaseUrl' in shaped, !demoMode)
assert.equal(shaped.hasToken, true)
assert.equal(JSON.stringify(shaped).includes('10.0.0.5'), !demoMode)
}
})
// ── The seed hook (§7.3, DM15) ─────────────────────────────────────────────
const SECTION = {
servers: [{ id: 'main', name: 'Demo rig', sidecarBaseUrl: 'http://rig:7790', sidecarToken: 'tok' }],
links: [{ username: 'demo-player', steamId: '76561190000000001', serverId: 'main' }],
}
test('the seed section is checked whole before anything is written', async () => {
const queries = withCore()
const seed = require('../demoSeed')
const accounts = { 'demo-player': 3 }
assert.deepEqual(seed.checkSection(SECTION, accounts), [])
const problems = seed.checkSection({
servers: [{ id: 'Main', name: '', sidecarBaseUrl: 'rig', sidecarToken: '' }],
links: [{ username: 'someone', steamId: '123', serverId: 'Other' }],
}, accounts)
assert.equal(problems.length, 7)
await assert.rejects(seed.onDemoSeed({ servers: 'x' }, { accounts }), /must be an array/)
assert.deepEqual(queries, [])
})
test('the seed upserts the server with an encrypted token, and links demo-player', async () => {
const queries = withCore()
await require('../demoSeed').onDemoSeed(SECTION, { accounts: { 'demo-player': 3 } })
const upsert = queries.find((q) => q.sql.startsWith('INSERT INTO rust_servers'))
assert.match(upsert.sql, /ON DUPLICATE KEY UPDATE/)
assert.deepEqual(upsert.params.slice(0, 4), ['main', 'Demo rig', 'http://rig:7790', 'enc:tok'])
const link = queries.find((q) => q.sql.startsWith('INSERT INTO rust_account_links'))
assert.deepEqual(link.params, ['76561190000000001', 3, null, 'main'])
})
test('a re-seed leaves a link the right owner holds, and moves one held by someone else', async () => {
let owner = 3
const queries = withCore({ select: (sql) => (sql.includes('rust_account_links') ? [{ steamId: '76561190000000001', userId: owner }] : []) })
const seed = require('../demoSeed')
await seed.onDemoSeed({ links: SECTION.links }, { accounts: { 'demo-player': 3 } })
assert.equal(queries.some((q) => /^(INSERT|DELETE)/.test(q.sql)), false)
owner = 9
await seed.onDemoSeed({ links: SECTION.links }, { accounts: { 'demo-player': 3 } })
const writes = queries.filter((q) => /^(INSERT|DELETE)/.test(q.sql)).map((q) => q.sql.split(' ')[0])
assert.deepEqual(writes, ['DELETE', 'INSERT'])
})
// ── The manifest (§3.2) ────────────────────────────────────────────────────
test('module.json locks the two link routes and needs a core that asks demoRefuses', () => {
const manifest = require('../../module.json')
assert.deepEqual(manifest.demoLocks, [
{ method: 'PUT', path: '/admin/rust/servers/:id' },
{ method: 'DELETE', path: '/admin/rust/servers/:id' },
])
assert.equal(manifest.coreApi, '^1.19.0')
})