Merge pull request 'release: the public demo's module-side locks and seed hook (v0.6.0, MODULE_API 1.19.0)' (#64) from edge into main
All checks were successful
Release / release (push) Successful in 45s
All checks were successful
Release / release (push) Successful in 45s
Reviewed-on: #64
This commit is contained in:
@@ -384,6 +384,22 @@ jobs:
|
|||||||
SHA="$(sha256sum "dist/module-rust-${VERSION}.tar.gz" | cut -d' ' -f1)"
|
SHA="$(sha256sum "dist/module-rust-${VERSION}.tar.gz" | cut -d' ' -f1)"
|
||||||
SIZE="$(stat -c%s "dist/module-rust-${VERSION}.tar.gz")"
|
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
|
# The install manifest. Same shape as the installer's bundle JSON — a
|
||||||
# per-asset sha256 fetched over HTTPS, no signatures — because that is
|
# 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
|
# 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 artifact "module-rust-${VERSION}.tar.gz" \
|
||||||
--arg sha256 "$SHA" \
|
--arg sha256 "$SHA" \
|
||||||
--argjson size "$SIZE" \
|
--argjson size "$SIZE" \
|
||||||
|
--argjson protocol "$PROTOCOL" \
|
||||||
--arg url "https://${GITEA_HOST}/${REPO}/releases/download/v${VERSION}/module-rust-${VERSION}.tar.gz" \
|
--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,
|
'{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"
|
> "dist/module-rust-${VERSION}.json"
|
||||||
|
|
||||||
echo "${SHA} module-rust-${VERSION}.tar.gz" > dist/SHA256SUMS
|
echo "${SHA} module-rust-${VERSION}.tar.gz" > dist/SHA256SUMS
|
||||||
|
|||||||
@@ -34,6 +34,8 @@
|
|||||||
"core.js",
|
"core.js",
|
||||||
"dangerousTreasures.js",
|
"dangerousTreasures.js",
|
||||||
"db",
|
"db",
|
||||||
|
"demo.js",
|
||||||
|
"demoSeed.js",
|
||||||
"dynamicPvp.js",
|
"dynamicPvp.js",
|
||||||
"eventCommands.js",
|
"eventCommands.js",
|
||||||
"engagement",
|
"engagement",
|
||||||
|
|||||||
@@ -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. It was pinned to the head of website feature/demo-step-refusal (website#224) while that PR was open — a 1.18.0 core never asks demoRefuses — and is back on `main` at the sha #224 merged as (the same tree, so routes.manifest.json does not change).",
|
||||||
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
|
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
|
||||||
"ref": "6890a74c7c4ad5690509e8c163e477d7f7b399e7",
|
"ref": "69998ec54854d46681def92985a2b299ee3582a6",
|
||||||
"refName": "main @ website#220 merge (the cutover), MODULE_API 1.17.0"
|
"refName": "website main @ website#224 merge, MODULE_API 1.19.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ function formFrom(server) {
|
|||||||
isNew: false,
|
isNew: false,
|
||||||
id: server.id,
|
id: server.id,
|
||||||
name: server.name,
|
name: server.name,
|
||||||
sidecarBaseUrl: server.sidecarBaseUrl,
|
sidecarBaseUrl: server.sidecarBaseUrl || '',
|
||||||
sidecarToken: '',
|
sidecarToken: '',
|
||||||
enabled: server.enabled,
|
enabled: server.enabled,
|
||||||
sortOrder: server.sortOrder,
|
sortOrder: server.sortOrder,
|
||||||
@@ -264,7 +264,7 @@ export default function ServerSettings() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="dim" style={{ fontSize: '0.76rem', marginTop: 4 }}>
|
<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.sidecarProtocol != null && s.sidecarProtocol !== s.protocol ? ` (sidecar speaks ${s.sidecarProtocol})` : ''}
|
||||||
{s.lastSeenAt ? ` · last seen ${ago(s.lastSeenAt)}` : ''}
|
{s.lastSeenAt ? ` · last seen ${ago(s.lastSeenAt)}` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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.",
|
"_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": [
|
"triggers": [
|
||||||
{
|
{
|
||||||
"id": "rust.base.destroyed",
|
"id": "rust.base.destroyed",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"id": "rust",
|
"id": "rust",
|
||||||
"name": "Rust",
|
"name": "Rust",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"coreApi": "^1.17.0",
|
"coreApi": "^1.19.0",
|
||||||
"server": "server/index.js",
|
"server": "server/index.js",
|
||||||
"client": { "entry": "client/dist/entry.js" },
|
"client": { "entry": "client/dist/entry.js" },
|
||||||
"schema": "server/db/schema.sql",
|
"schema": "server/db/schema.sql",
|
||||||
@@ -13,5 +13,9 @@
|
|||||||
"player": ["/rust"]
|
"player": ["/rust"]
|
||||||
},
|
},
|
||||||
"extensions": ["admin.users.detail"],
|
"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" }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -186,4 +186,9 @@ module.exports = {
|
|||||||
// origin to be relative to. A getter over core's getter, so an operator who
|
// 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.
|
// changes `APP_BASE_URL` is read at the call, never captured at load.
|
||||||
get baseUrl() { return need().site.baseUrl },
|
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
135
server/demo.js
Normal 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
97
server/demoSeed.js
Normal 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 }
|
||||||
@@ -41,6 +41,7 @@ const core = require('./core')
|
|||||||
const { serverFor, transportError, pluginError } = require('./eventLeases')
|
const { serverFor, transportError, pluginError } = require('./eventLeases')
|
||||||
const { BUDGET_MS } = require('./eventWorld')
|
const { BUDGET_MS } = require('./eventWorld')
|
||||||
const rewards = require('./eventRewards')
|
const rewards = require('./eventRewards')
|
||||||
|
const demo = require('./demo')
|
||||||
|
|
||||||
const log = core.logger('commands')
|
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. */
|
/** 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
|
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. */
|
/** A command list's price: at least one, so the switchboard sees the dimension. */
|
||||||
function pricedCommands(raw) {
|
function pricedCommands(raw) {
|
||||||
const checked = checkCommands(raw, { required: false })
|
const checked = checkCommands(raw, { required: false })
|
||||||
@@ -190,6 +194,11 @@ async function checkRun(params) {
|
|||||||
const teardown = checkCommands(params.teardown, { field: 'teardown commands', required: false })
|
const teardown = checkCommands(params.teardown, { field: 'teardown commands', required: false })
|
||||||
if (!teardown.ok) return { ok: false, retry: false, error: teardown.error }
|
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])
|
const placeholder = withPlaceholder([...commands.value, ...teardown.value])
|
||||||
if (placeholder) {
|
if (placeholder) {
|
||||||
return { ok: false, retry: false, error: `{steamid} and {name} are filled in only by Reward by console command, and "${placeholder}" has one` }
|
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,
|
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
|
// 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".
|
// a teardown not yet run is always in force: "I do not know" is never "gone".
|
||||||
async reconcile({ resources }) {
|
async reconcile({ resources }) {
|
||||||
@@ -397,10 +410,15 @@ const commandReward = {
|
|||||||
|
|
||||||
cost: (p) => ({ 'rust.commands': pricedCommands(p.commands) * pricedPeople(p) }),
|
cost: (p) => ({ 'rust.commands': pricedCommands(p.commands) * pricedPeople(p) }),
|
||||||
|
|
||||||
|
demoRefuses: (p) => demo.deniedCommand(asList(p.commands)),
|
||||||
|
|
||||||
async perform({ runId, stepId, idempotencyKey, params, verify }) {
|
async perform({ runId, stepId, idempotencyKey, params, verify }) {
|
||||||
const commands = checkCommands(params.commands)
|
const commands = checkCommands(params.commands)
|
||||||
if (!commands.ok) return { ok: false, retry: false, error: commands.error }
|
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)
|
const confirm = checkConfirm(params, REWARD_SURE)
|
||||||
if (!confirm.ok) return { ok: false, retry: false, error: confirm.error }
|
if (!confirm.ok) return { ok: false, retry: false, error: confirm.error }
|
||||||
|
|
||||||
|
|||||||
@@ -168,6 +168,13 @@ module.exports = function register(ctx, api) {
|
|||||||
api.onIdentityLinking(links.checkSteamSignin)
|
api.onIdentityLinking(links.checkSteamSignin)
|
||||||
api.onIdentityLinked(links.linkFromSteamSignin)
|
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
|
// 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
|
// 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
|
// may name and the four callables each ships. Every lease is targeted and the
|
||||||
|
|||||||
@@ -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.
|
* Which locked keys differ between two versions of a document.
|
||||||
*
|
*
|
||||||
@@ -290,6 +355,8 @@ module.exports = {
|
|||||||
isPlausiblePath,
|
isPlausiblePath,
|
||||||
shapeCatalogue,
|
shapeCatalogue,
|
||||||
shapeFile,
|
shapeFile,
|
||||||
|
redactSecrets,
|
||||||
|
editsTouchSecret,
|
||||||
pluginOf,
|
pluginOf,
|
||||||
isBridgeConfig,
|
isBridgeConfig,
|
||||||
lockedKeysFor,
|
lockedKeysFor,
|
||||||
|
|||||||
@@ -203,7 +203,9 @@ async function listForAdmin(now = Date.now()) {
|
|||||||
// The public shape first, so the admin-only fields below cannot be
|
// The public shape first, so the admin-only fields below cannot be
|
||||||
// overwritten by a key the public shape happens to share.
|
// overwritten by a key the public shape happens to share.
|
||||||
...shapePublic(row, state, now),
|
...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),
|
hasToken: Boolean(row.sidecarTokenEnc),
|
||||||
protocol: Number(row.protocol),
|
protocol: Number(row.protocol),
|
||||||
enabled: Boolean(row.enabled),
|
enabled: Boolean(row.enabled),
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
const core = require('../../core')
|
const core = require('../../core')
|
||||||
|
|
||||||
const configEdit = require('../../configEdit')
|
const configEdit = require('../../configEdit')
|
||||||
|
const demo = require('../../demo')
|
||||||
const db = require('../../model/config/config.db')
|
const db = require('../../model/config/config.db')
|
||||||
const model = require('../../model/config/config.model')
|
const model = require('../../model/config/config.model')
|
||||||
const servers = require('../../model/servers/servers.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.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. */
|
/** 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' })
|
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)
|
const server = await serverOr404(req, res)
|
||||||
if (!server) return undefined
|
if (!server) return undefined
|
||||||
|
|
||||||
@@ -116,7 +146,8 @@ async function readFile(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const self = catalogueReply.ok && catalogueReply.data ? catalogueReply.data.self : null
|
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' })
|
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)
|
const server = await serverOr404(req, res)
|
||||||
if (!server) return undefined
|
if (!server) return undefined
|
||||||
|
|
||||||
@@ -151,6 +192,7 @@ async function writeFile(req, res) {
|
|||||||
|
|
||||||
const onDisk = current.data
|
const onDisk = current.data
|
||||||
const self = catalogue.ok && catalogue.data ? catalogue.data.self : null
|
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)
|
const locked = model.lockedKeysFor(path, self)
|
||||||
|
|
||||||
// The browser's version against what is on the host right now. The plugin
|
// 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)) {
|
if (String(req.body.version || '') !== String(onDisk.version)) {
|
||||||
return res.status(409).json({
|
return res.status(409).json({
|
||||||
message: 'That file changed on the server since you opened it',
|
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. */
|
/** What has been written to this server's configuration lately, and by whom. */
|
||||||
async function history(req, res) {
|
async function history(req, res) {
|
||||||
try {
|
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) {
|
} catch (err) {
|
||||||
log.error('failed to read the configuration history', { error: err.message })
|
log.error('failed to read the configuration history', { error: err.message })
|
||||||
return res.status(500).json({ message: 'Failed to read the configuration history' })
|
return res.status(500).json({ message: 'Failed to read the configuration history' })
|
||||||
@@ -305,6 +349,7 @@ async function writeStatus(req, res) {
|
|||||||
try {
|
try {
|
||||||
const write = await db.getWrite(req.params.serverId, Number(req.params.writeId))
|
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 (!write) return res.status(404).json({ message: 'No such configuration write' })
|
||||||
|
if (demo.isDemo() && isBridgeWrite(write)) return refuseBridge(res)
|
||||||
|
|
||||||
return res.json({ write })
|
return res.json({ write })
|
||||||
} catch (err) {
|
} 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
|
* 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.
|
* fail a save. Returns the row's id, or null when it could not be written.
|
||||||
|
|||||||
@@ -104,6 +104,8 @@ function fakeCtx(overrides = {}) {
|
|||||||
accountChangeLimiter: (req, res, next) => next(),
|
accountChangeLimiter: (req, res, next) => next(),
|
||||||
},
|
},
|
||||||
site: { baseUrl: 'http://localhost:5173' },
|
site: { baseUrl: 'http://localhost:5173' },
|
||||||
|
// MODULE_API 1.18.0: false unless a suite says it is the public demo.
|
||||||
|
demoMode: false,
|
||||||
...rest,
|
...rest,
|
||||||
}
|
}
|
||||||
// Non-enumerable, and that is not tidiness. Core freezes every object value on
|
// 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).
|
// MODULE_API 1.17.0 (Steam sign-in, PLAN_REDESIGNS §15).
|
||||||
onIdentityLinking(fn) { once('onIdentityLinking'); record.hooks.onIdentityLinking = fn },
|
onIdentityLinking(fn) { once('onIdentityLinking'); record.hooks.onIdentityLinking = fn },
|
||||||
onIdentityLinked(fn) { once('onIdentityLinked'); record.hooks.onIdentityLinked = 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
|
api.record = record
|
||||||
return api
|
return api
|
||||||
|
|||||||
387
server/test/demo.test.js
Normal file
387
server/test/demo.test.js
Normal 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')
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user