// ── 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, }