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
140 lines
5.6 KiB
JavaScript
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,
|
|
}
|