feat(demo): the public demo's module-side locks and seed hook
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:
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,
|
||||
}
|
||||
Reference in New Issue
Block a user