feat(demo): the public demo's module-side locks and seed hook
All checks were successful
PR Checks / client-build (pull_request) Successful in 54s
PR Checks / server-tests (pull_request) Successful in 1m0s
PR Checks / frozen-manifest (pull_request) Successful in 1m19s

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:
2026-10-26 03:40:30 -05:00
parent c4729315cd
commit 7a0d0842ba
16 changed files with 809 additions and 14 deletions

View File

@@ -33,6 +33,7 @@
const core = require('../../core')
const configEdit = require('../../configEdit')
const demo = require('../../demo')
const db = require('../../model/config/config.db')
const model = require('../../model/config/config.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.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. */
@@ -98,6 +124,10 @@ async function readFile(req, res) {
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)
if (!server) return undefined
@@ -116,7 +146,8 @@ async function readFile(req, res) {
}
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' })
}
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)
if (!server) return undefined
@@ -151,6 +192,7 @@ async function writeFile(req, res) {
const onDisk = current.data
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)
// 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)) {
return res.status(409).json({
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. */
async function history(req, res) {
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) {
log.error('failed to read the configuration history', { error: err.message })
return res.status(500).json({ message: 'Failed to read the configuration history' })
@@ -305,6 +349,7 @@ async function writeStatus(req, res) {
try {
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 (demo.isDemo() && isBridgeWrite(write)) return refuseBridge(res)
return res.json({ write })
} 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
* fail a save. Returns the row's id, or null when it could not be written.