Files
Module-Rust/server/demo.js
wtclaude fb7511d700
All checks were successful
PR Checks / server-tests (pull_request) Successful in 54s
PR Checks / client-build (pull_request) Successful in 1m5s
PR Checks / frozen-manifest (pull_request) Successful in 1m36s
fix(demo): lock removing a Steam link on the demo (DM27)
The step-5 walk found demo-player could remove its stand-in Steam link from
its Rust page, and staff could remove it from Admin -> Users. That link is
what the player tour's Rust pages, rewards and grants hang on.

- module.json demoLocks gains DELETE /player/rust/links/:steamId.
- The staff route, DELETE /admin/users/:id/rust/links/:steamId, serves under
  core's users mount, which a module's demoLocks may not name, so its handler
  refuses with DEMO_LOCKED behind ctx.demoMode before the link is touched.

Off the demo both behave as before. Checked on a real core with the demo
seed: both answer 403 DEMO_LOCKED and the link stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-28 04:44:28 -05:00

140 lines
5.6 KiB
JavaScript

// ── The public demo's module-side locks (DEMO_MODE.md §3.4, §3.5) ──────────
//
// Core locks this module's two rig-link routes and the player's Steam unlink 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 staff Steam unlink on Admin → Users (step-5 walk, W4): it serves under
// core's users mount, which a module's demoLocks may not name, so its handler
// refuses instead. The demo player's seeded link stays put either way;
// 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,
}