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

@@ -174,6 +174,71 @@ function shapeFile(file, { self = null, maxDepth = 6 } = {}) {
}
}
/**
* A shaped file with every credential-shaped value hidden, for the public demo
* (DEMO_MODE.md §3.5, DM14). The form's mask is not a lock (DM10), so on the
* demo the value never leaves the module:
*
* - in `text`, each flagged string or number is spliced out for the
* placeholder, from the end backwards so the spans stay true;
* - in `fields`, a flagged field carries the placeholder and is `locked`,
* so the form shows it read-only, and the save refuses an edit to it anyway.
*
* "Flagged" is the editor's own reading (`configEdit.isSecretKey`, inherited by
* everything under a flagged key), so the demo hides exactly what the form masks.
* A file that does not parse cannot be read for its secrets, so its text is
* withheld whole: the lock fails closed.
*/
function redactSecrets(shaped, placeholder) {
if (!shaped) return shaped
let root
try {
root = configEdit.scan(shaped.text)
} catch {
return { ...shaped, text: '', fields: null, redacted: true, withheld: 'This file does not parse, so it is not shown on the demo site.' }
}
const spans = []
const visit = (node, secret) => {
if (node.type === 'object') {
for (const child of node.children) visit(child, secret || configEdit.isSecretKey(child.key))
} else if (node.type === 'array') {
for (const child of node.children) visit(child, secret)
} else if (secret && (node.type === 'number' || (node.type === 'string' && node.value !== ''))) {
spans.push(node)
}
}
visit(root, false)
let text = shaped.text
for (const node of [...spans].sort((a, b) => b.start - a.start)) {
text = text.slice(0, node.start) + JSON.stringify(placeholder) + text.slice(node.end)
}
const fields = Array.isArray(shaped.fields)
? shaped.fields.map((field) => {
if (!field.secret) return field
const leaf = field.type === 'number' || field.type === 'string'
const { raw, ...rest } = field
return {
...rest,
...(leaf && !(field.type === 'string' && field.value === '') ? { value: placeholder } : {}),
locked: true,
}
})
: shaped.fields
return { ...shaped, text, fields, redacted: true }
}
/** Whether any edit names a credential-shaped field, as `applyEdits` marks them. */
function editsTouchSecret(edits) {
return (edits || []).some(
(edit) => Array.isArray(edit && edit.pointer) && edit.pointer.some((step) => typeof step === 'string' && configEdit.isSecretKey(step)),
)
}
/**
* Which locked keys differ between two versions of a document.
*
@@ -290,6 +355,8 @@ module.exports = {
isPlausiblePath,
shapeCatalogue,
shapeFile,
redactSecrets,
editsTouchSecret,
pluginOf,
isBridgeConfig,
lockedKeysFor,