Compare commits
23 Commits
edge
...
47a79d841c
| Author | SHA1 | Date | |
|---|---|---|---|
| 47a79d841c | |||
| 62eaa7693d | |||
| 0f05098d8f | |||
| 8ec7f399a7 | |||
| 38aef5964e | |||
| 9408fee3c1 | |||
| c18250eb07 | |||
| 61feca50a8 | |||
| 7aafd0dd74 | |||
| 3b9998539f | |||
| 3c1c174337 | |||
| 247323ff05 | |||
| d48d8c473c | |||
| 2a6e307f16 | |||
| c7a24a186c | |||
| c87563bc32 | |||
| 69998ec548 | |||
| cee4ab8aea | |||
| c6f53a1509 | |||
| 4ad746c43e | |||
| b5e0d01a91 | |||
| 8012a457fc | |||
| 6890a74c7c |
42
.gitea/workflows/demo-baseline.yml
Normal file
42
.gitea/workflows/demo-baseline.yml
Normal file
@@ -0,0 +1,42 @@
|
||||
# Capture the demo rig's baseline (docs/website/DEMO_MODE.md §7.2). Run by hand,
|
||||
# after setting the demo default set up through the demo site: the permission
|
||||
# groups, a few NPCs, a clan for the demo's Team. It stops the rig (a power stop
|
||||
# runs `quit`, which saves the world, DM22), compresses oxide/data, oxide/config
|
||||
# and server/<identity> into demo-baseline.tar.gz at the server root, and starts
|
||||
# it again. Every wipe from then on restores that archive.
|
||||
#
|
||||
# Run it right after a wipe has finished and the default set is in place, not
|
||||
# while visitors are mid-change: whatever is on the rig is what every later
|
||||
# cycle returns to.
|
||||
|
||||
name: Demo baseline
|
||||
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
|
||||
concurrency:
|
||||
group: demo-rig
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
DEMO_DIR: ${{ vars.DEMO_DIR || '/opt/demo.runicgateway.com' }}
|
||||
PTERODACTYL_URL: ${{ vars.PTERODACTYL_URL || 'http://192.168.0.12' }}
|
||||
PTERODACTYL_SERVER: ${{ vars.PTERODACTYL_SERVER || '6eb9d621' }}
|
||||
NODE_IMAGE: node:22.23.4-alpine
|
||||
|
||||
jobs:
|
||||
baseline:
|
||||
runs-on: demo-site
|
||||
if: github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- name: Check out the deploy files
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Capture the baseline
|
||||
env:
|
||||
PTERODACTYL_CLIENT_KEY: ${{ secrets.PTERODACTYL_CLIENT_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker run --rm -v "$PWD/deploy/demo:/demo:ro" \
|
||||
-e PTERODACTYL_URL -e PTERODACTYL_SERVER -e PTERODACTYL_CLIENT_KEY \
|
||||
"$NODE_IMAGE" node /demo/rig.mjs baseline
|
||||
117
.gitea/workflows/demo-wipe.yml
Normal file
117
.gitea/workflows/demo-wipe.yml
Normal file
@@ -0,0 +1,117 @@
|
||||
# The public demo's wipe (docs/website/DEMO_MODE.md §7). Every 2 hours on the
|
||||
# hour, UTC (DM7), it puts demo.runicgateway.com and its Rust server back to the
|
||||
# demo's starting point:
|
||||
#
|
||||
# 1. versions the latest Rust bundle and the newest Module-Rust release on the
|
||||
# same protocol (§7.5, DM12)
|
||||
# 2. the rig FIRST: kill, write the new bundle if it changed, restore
|
||||
# demo-baseline.tar.gz, start (§7.2). A fresh site imports what is
|
||||
# on the rig, so the rig must be back before the site talks to it (§7.1)
|
||||
# 3. the site down -v, pull, up, seed:demo (§7.3)
|
||||
# 4. smoke the site answers, then the rig reports in (bounded)
|
||||
#
|
||||
# A redeploy is the same path: a push to main that changes deploy/demo/ runs it,
|
||||
# and so does workflow_dispatch (§7.4).
|
||||
#
|
||||
# Runs on the `demo-site` runner, in host mode, on the demo host itself. The host
|
||||
# needs Docker with Compose, and node for actions/checkout; everything else runs
|
||||
# in containers. One-time host setup: deploy/demo/README.md.
|
||||
#
|
||||
# Secret: PTERODACTYL_CLIENT_KEY the demo subuser's client key (file + power)
|
||||
# Variables: PTERODACTYL_URL, PTERODACTYL_SERVER (defaults below), DEMO_DIR
|
||||
|
||||
name: Demo wipe
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 */2 * * *'
|
||||
workflow_dispatch: {}
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'deploy/demo/**'
|
||||
- '.gitea/workflows/demo-wipe.yml'
|
||||
|
||||
# A wipe and a baseline capture must never overlap: both stop the rig.
|
||||
concurrency:
|
||||
group: demo-rig
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
DEMO_DIR: ${{ vars.DEMO_DIR || '/opt/demo.runicgateway.com' }}
|
||||
PTERODACTYL_URL: ${{ vars.PTERODACTYL_URL || 'http://192.168.0.12' }}
|
||||
PTERODACTYL_SERVER: ${{ vars.PTERODACTYL_SERVER || '6eb9d621' }}
|
||||
NODE_IMAGE: node:22.23.4-alpine
|
||||
|
||||
jobs:
|
||||
wipe:
|
||||
runs-on: demo-site
|
||||
if: github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- name: Check out the deploy files
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install the deploy files
|
||||
# Everything but .env, which only the host holds, and state/, which the
|
||||
# wipe itself writes.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -f "$DEMO_DIR/.env" || { echo "::error::$DEMO_DIR/.env is missing; see deploy/demo/README.md"; exit 1; }
|
||||
mkdir -p "$DEMO_DIR/state" "$DEMO_DIR/logs" "$DEMO_DIR/modules"
|
||||
# The app runs as uid 1000 and unpacks the declared module into ./modules.
|
||||
# The runner is not root, so a host chown fails; a container is root and
|
||||
# the runner may start one. A failure here stops the run: without it the
|
||||
# app comes up healthy with no module and the seed fails much later.
|
||||
docker run --rm -v "$DEMO_DIR:/demo" "$NODE_IMAGE" chown -R 1000:1000 /demo/modules /demo/logs
|
||||
cp deploy/demo/docker-compose.yml deploy/demo/seed.json deploy/demo/rig.mjs "$DEMO_DIR/"
|
||||
|
||||
- name: Decide the versions
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker run --rm -v "$DEMO_DIR:/demo" -e STATE_DIR=/demo/state "$NODE_IMAGE" node /demo/rig.mjs plan
|
||||
|
||||
- name: Restore the rig
|
||||
id: rig
|
||||
# The site is wiped even when this fails: a demo left un-wiped is worse
|
||||
# than one whose rig kept a visitor's changes for a cycle. The last step
|
||||
# fails the run so the failure is seen.
|
||||
continue-on-error: true
|
||||
env:
|
||||
PTERODACTYL_CLIENT_KEY: ${{ secrets.PTERODACTYL_CLIENT_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker run --rm -v "$DEMO_DIR:/demo" -e STATE_DIR=/demo/state \
|
||||
-e PTERODACTYL_URL -e PTERODACTYL_SERVER -e PTERODACTYL_CLIENT_KEY \
|
||||
"$NODE_IMAGE" node /demo/rig.mjs restore
|
||||
|
||||
- name: Wipe and reseed the site
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd "$DEMO_DIR"
|
||||
docker compose pull
|
||||
docker compose down -v --remove-orphans
|
||||
docker compose up -d
|
||||
for attempt in $(seq 1 60); do
|
||||
STATUS="$(docker inspect -f '{{.State.Health.Status}}' rg-demo-app-1 2>/dev/null || echo starting)"
|
||||
[ "$STATUS" = healthy ] && break
|
||||
sleep 5
|
||||
done
|
||||
if [ "$STATUS" != healthy ]; then
|
||||
echo "::error::the app did not become healthy within 300s"
|
||||
docker compose logs --tail 100 app
|
||||
exit 1
|
||||
fi
|
||||
docker compose exec -T app npm run --silent --prefix server seed:demo -- /app/deploy/demo/seed.json
|
||||
docker compose ps
|
||||
|
||||
- name: Smoke check
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker run --rm --network rg-demo_default -v "$DEMO_DIR:/demo" \
|
||||
-e SITE_URL=http://app:3000 "$NODE_IMAGE" node /demo/rig.mjs smoke
|
||||
|
||||
- name: Report the rig restore
|
||||
if: steps.rig.outcome == 'failure'
|
||||
run: |
|
||||
echo "::error::the rig restore failed (see its step); the site was wiped and reseeded anyway"
|
||||
exit 1
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22.23.4
|
||||
cache: npm
|
||||
cache-dependency-path: server/package-lock.json
|
||||
- name: Check core names no module identifier
|
||||
@@ -90,7 +90,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22.23.4
|
||||
cache: npm
|
||||
cache-dependency-path: client/package-lock.json
|
||||
- name: Install client deps
|
||||
@@ -113,7 +113,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22.23.4
|
||||
cache: npm
|
||||
cache-dependency-path: bot/package-lock.json
|
||||
- name: Install bot deps
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:20-alpine
|
||||
FROM node:22.23.4-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -147,7 +147,7 @@ flowchart TB
|
||||
|
||||
| Layer | Tech |
|
||||
|---|---|
|
||||
| Backend | Node.js 20+, Express 4, `mariadb` driver (parameterized SQL, no ORM) |
|
||||
| Backend | Node.js 22 (images pin 22.23.4), Express 4, `mariadb` driver (parameterized SQL, no ORM) |
|
||||
| Auth | Session service over JWT: httpOnly cookie (web) + bearer access/refresh tokens (mobile), bcrypt hashing, optional TOTP 2FA (`speakeasy` + `qrcode`), pluggable OAuth2/OIDC SSO (built-in Google & Discord + generic) |
|
||||
| Database | MariaDB 11 (own container) |
|
||||
| Frontend | React 18, Vite 5, React Router 6 |
|
||||
@@ -196,7 +196,8 @@ website/
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Node.js 20+** and npm (Node 22/24 are fine).
|
||||
- **Node.js 22.23.4+** (or 24.22.1+) and npm. Older builds carry the Node2Shell Brotli bug
|
||||
(CVE-2026-111497), and Node 20 is past end of life.
|
||||
- **Docker Desktop** (for MariaDB, and for the full Compose deploy).
|
||||
|
||||
---
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:20-alpine
|
||||
FROM node:22.23.4-alpine
|
||||
|
||||
WORKDIR /app/bot
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Routes, Route, Navigate, Outlet } from 'react-router-dom'
|
||||
import { AuthProvider } from './contexts/AuthContext.jsx'
|
||||
import { SiteProvider } from './contexts/SiteContext.jsx'
|
||||
import MaintenanceGate from './components/MaintenanceGate.jsx'
|
||||
import DemoBanner from './components/DemoBanner.jsx'
|
||||
import DemoToast from './components/DemoToast.jsx'
|
||||
import RequireAuth from './components/RequireAuth.jsx'
|
||||
import RequirePlayer from './components/RequirePlayer.jsx'
|
||||
import RoleGate from './components/RoleGate.jsx'
|
||||
@@ -90,6 +92,10 @@ export default function App() {
|
||||
the provider hooks are called once for the whole app rather than
|
||||
once per screen. */}
|
||||
<ModuleFeaturesProvider>
|
||||
{/* The public demo's strip and refusal toast (DEMO_MODE.md §3.1, §6).
|
||||
Both render nothing unless the server says this is the demo. */}
|
||||
<DemoBanner />
|
||||
<DemoToast />
|
||||
<Routes>
|
||||
{/* Landing hero — always public, even in maintenance mode. The hero is
|
||||
itself the pre-launch "coming soon" page, so it sits outside the
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// Thin fetch wrapper. Always sends cookies (httpOnly JWT) and talks to the
|
||||
// same-origin API (/api/v1) — proxied to the Express server in dev.
|
||||
import { reportIfDemoLocked } from '../lib/demoNotice.js'
|
||||
|
||||
const BASE = '/api/v1'
|
||||
|
||||
// Prefix a non-empty query string with "?" (and nothing when it is empty), so
|
||||
@@ -28,6 +30,9 @@ async function req(path, { method = 'GET', body, headers, raw } = {}) {
|
||||
const text = await res.text()
|
||||
const data = text ? safeParse(text) : null
|
||||
if (!res.ok) {
|
||||
// The public demo's lock (DEMO_MODE.md §3.1): shown once, as a toast, for
|
||||
// every caller. The ApiError below still goes to the form as usual.
|
||||
reportIfDemoLocked(data)
|
||||
const message = (data && data.message) || res.statusText || 'Request failed'
|
||||
throw new ApiError(res.status, message, data)
|
||||
}
|
||||
|
||||
47
client/src/components/DemoBanner.jsx
Normal file
47
client/src/components/DemoBanner.jsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useSite } from '../contexts/SiteContext.jsx'
|
||||
import { formatResetTime } from '../lib/demoNotice.js'
|
||||
|
||||
// The public demo's strip, on every page (docs/website/DEMO_MODE.md §6).
|
||||
// Renders nothing unless GET /public/settings carries a `demo` block, which the
|
||||
// server sends only with DEMO_MODE on — so a real shard never draws it, and the
|
||||
// banner needs no schedule of its own: `resetsAt` is stamped by every wipe.
|
||||
export default function DemoBanner() {
|
||||
const { settings } = useSite()
|
||||
const demo = settings.demo
|
||||
if (!demo) return null
|
||||
|
||||
const at = demo.resetsAt ? formatResetTime(demo.resetsAt) : null
|
||||
const names = (demo.logins || []).map((l) => l.username)
|
||||
|
||||
return (
|
||||
<div
|
||||
role="note"
|
||||
className="sans"
|
||||
style={{
|
||||
padding: '7px 16px',
|
||||
textAlign: 'center',
|
||||
fontSize: '0.82rem',
|
||||
lineHeight: 1.45,
|
||||
color: 'var(--head)',
|
||||
background: 'var(--bg-deep)',
|
||||
borderBottom: '1px solid var(--accent)',
|
||||
}}
|
||||
>
|
||||
<strong style={{ color: 'var(--accent)' }}>Demo site</strong>
|
||||
{' — anything you change is wiped '}
|
||||
{at ? `at ${at}` : 'every two hours'}.
|
||||
{names.length > 0 && (
|
||||
<>
|
||||
{' Sign-ins: '}
|
||||
{names.join(' / ')}
|
||||
{' — passwords on the '}
|
||||
<Link to="/account/login" style={{ color: 'var(--accent)' }}>
|
||||
sign-in page
|
||||
</Link>
|
||||
.
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
51
client/src/components/DemoLogins.jsx
Normal file
51
client/src/components/DemoLogins.jsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useSite } from '../contexts/SiteContext.jsx'
|
||||
|
||||
// The published sign-ins, on the sign-in pages of the public demo
|
||||
// (DEMO_MODE.md §4). They are public by design — printed here and on
|
||||
// runicgateway.com/beta — so "Use" fills the form rather than making anyone
|
||||
// copy a password. Renders nothing off the demo.
|
||||
export default function DemoLogins({ onPick }) {
|
||||
const { settings } = useSite()
|
||||
const logins = settings.demo?.logins || []
|
||||
if (logins.length === 0) return null
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="Demo sign-ins"
|
||||
className="sans"
|
||||
style={{
|
||||
marginTop: 18,
|
||||
padding: '14px 16px',
|
||||
border: '1px solid var(--line)',
|
||||
borderRadius: 10,
|
||||
fontSize: '0.84rem',
|
||||
// Opaque: the admin sign-in page draws it over the hero image.
|
||||
background: 'var(--bg-deep)',
|
||||
}}
|
||||
>
|
||||
<p style={{ margin: '0 0 10px', color: 'var(--head)' }}>
|
||||
<strong>Demo sign-ins</strong> <span style={{ color: 'var(--dim)' }}>— shared by everyone, reset every wipe</span>
|
||||
</p>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<tbody>
|
||||
{logins.map((l) => (
|
||||
<tr key={l.username}>
|
||||
<td style={{ padding: '5px 0', verticalAlign: 'middle' }}>
|
||||
<div style={{ color: 'var(--head)', whiteSpace: 'nowrap' }}>{l.username}</div>
|
||||
<div style={{ color: 'var(--dim)', fontFamily: 'monospace', fontSize: '0.78rem', wordBreak: 'break-all' }}>
|
||||
{l.password}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '5px 8px', color: 'var(--dim)', verticalAlign: 'middle' }}>{l.role}</td>
|
||||
<td style={{ padding: '5px 0', textAlign: 'right', verticalAlign: 'middle' }}>
|
||||
<button type="button" className="btn btn-ghost" style={{ padding: '3px 10px', fontSize: '0.8rem' }} onClick={() => onPick(l.username, l.password)}>
|
||||
Use
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
47
client/src/components/DemoToast.jsx
Normal file
47
client/src/components/DemoToast.jsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { onDemoLocked } from '../lib/demoNotice.js'
|
||||
|
||||
// The one toast for a write the public demo refused (DEMO_MODE.md §3.1). Fed
|
||||
// by api/client.js through lib/demoNotice.js, so every screen — and every
|
||||
// installed module's screens — gets it without handling the code itself. On a
|
||||
// real shard nothing is ever refused that way, so it never appears.
|
||||
const SHOW_MS = 5000
|
||||
|
||||
export default function DemoToast() {
|
||||
const [message, setMessage] = useState(null)
|
||||
|
||||
useEffect(() => onDemoLocked((text) => setMessage(text)), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!message) return undefined
|
||||
const t = setTimeout(() => setMessage(null), SHOW_MS)
|
||||
return () => clearTimeout(t)
|
||||
}, [message])
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
style={{ position: 'fixed', left: 16, right: 16, bottom: 20, zIndex: 1000, display: 'flex', justifyContent: 'center', pointerEvents: 'none' }}
|
||||
>
|
||||
{message && (
|
||||
<div
|
||||
className="sans"
|
||||
style={{
|
||||
maxWidth: 480,
|
||||
padding: '11px 16px',
|
||||
borderRadius: 10,
|
||||
fontSize: '0.88rem',
|
||||
color: 'var(--head)',
|
||||
background: 'var(--bg-deep)',
|
||||
border: '1px solid var(--accent)',
|
||||
boxShadow: '0 12px 32px rgba(0,0,0,0.45)',
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
57
client/src/lib/demoNotice.js
Normal file
57
client/src/lib/demoNotice.js
Normal file
@@ -0,0 +1,57 @@
|
||||
// The public demo's refusal (docs/website/DEMO_MODE.md §3.1).
|
||||
//
|
||||
// With DEMO_MODE on, the server answers a locked write `403 { code:
|
||||
// 'DEMO_LOCKED', message }`. Every request goes through api/client.js's req(),
|
||||
// installed modules' included, so req() reports the refusal here and the one
|
||||
// toast (components/DemoToast.jsx) shows it — wherever the action was, without
|
||||
// each form learning the code. The form still gets its ApiError as usual.
|
||||
//
|
||||
// The lock is the server's (DM10). Nothing here decides what is allowed; it
|
||||
// only explains what was refused.
|
||||
|
||||
export const DEMO_LOCKED = 'DEMO_LOCKED'
|
||||
|
||||
const listeners = new Set()
|
||||
|
||||
/** Subscribe to refusals. Returns the unsubscribe function. */
|
||||
export function onDemoLocked(fn) {
|
||||
listeners.add(fn)
|
||||
return () => listeners.delete(fn)
|
||||
}
|
||||
|
||||
/** Report a refusal, if this response body is one. Returns whether it was. */
|
||||
export function reportIfDemoLocked(body) {
|
||||
if (!body || typeof body !== 'object' || body.code !== DEMO_LOCKED) return false
|
||||
const message = typeof body.message === 'string' && body.message ? body.message : 'This is turned off on the demo site.'
|
||||
for (const fn of listeners) {
|
||||
try {
|
||||
fn(message)
|
||||
} catch {
|
||||
// A broken listener must not turn a refusal into a different error.
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Was this ApiError the demo's refusal? For the few screens that turn a 403
|
||||
* into a sentence of their own ("only an administrator can…"), which would
|
||||
* otherwise blame the role of somebody who is signed in as an admin.
|
||||
*/
|
||||
export function isDemoLocked(err) {
|
||||
return Boolean(err && err.body && err.body.code === DEMO_LOCKED)
|
||||
}
|
||||
|
||||
/**
|
||||
* "14:00 UTC" for the banner. The wipe runs on the hour in UTC (DM7), so the
|
||||
* banner says UTC rather than converting — a converted time in a reviewer's
|
||||
* zone reads as a different schedule from the one published on /beta.
|
||||
*/
|
||||
export function formatResetTime(iso) {
|
||||
const ms = Date.parse(iso)
|
||||
if (Number.isNaN(ms)) return null
|
||||
const d = new Date(ms)
|
||||
const hh = String(d.getUTCHours()).padStart(2, '0')
|
||||
const mm = String(d.getUTCMinutes()).padStart(2, '0')
|
||||
return `${hh}:${mm} UTC`
|
||||
}
|
||||
@@ -41,7 +41,7 @@ export function relativeTime(when, now = Date.now()) {
|
||||
* Distinct from `teamAdmin.freshnessOf`, which is worded for an operator
|
||||
* debugging a sync. A visitor needs one sentence about whether what they are
|
||||
* looking at is current, and specifically must never be shown an unconfirmed
|
||||
* empty projection as though it were a confirmed empty shard.
|
||||
* empty projection as though it were a confirmed empty one.
|
||||
*/
|
||||
export function freshnessNote(sync = {}, now = Date.now()) {
|
||||
// Nothing supplies Teams here, so there is nothing to be stale ABOUT. A
|
||||
@@ -57,7 +57,7 @@ export function freshnessNote(sync = {}, now = Date.now()) {
|
||||
* Group feed items into days, newest first, preserving order within a day (§4.3).
|
||||
*
|
||||
* Keyed by local calendar date rather than by a UTC slice: "yesterday" is a
|
||||
* property of where the reader is sitting, and a shard's evening raid landing at
|
||||
* property of where the reader is sitting, and a game server's evening raid landing at
|
||||
* 00:30 UTC belongs on the day the players experienced it.
|
||||
*/
|
||||
export function groupByDay(items = [], locale = undefined) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//
|
||||
// Plain JS with tests, following lib/moduleAdmin.js. The reason it is worth
|
||||
// splitting here specifically: this screen's job is to tell an operator the
|
||||
// difference between "the shard has no Teams" and "core has not been able to ask
|
||||
// difference between "the game server has no Teams" and "core has not been able to ask
|
||||
// for two hours", and those two produce almost the same page. Getting that
|
||||
// wording right is logic, not markup.
|
||||
|
||||
@@ -31,7 +31,7 @@ export function freshnessOf(sync = {}) {
|
||||
return {
|
||||
tone: TONE.bad,
|
||||
label: 'Never synced',
|
||||
detail: 'Core has never had an answer it could trust. What is shown below is not a confirmed empty shard.',
|
||||
detail: 'Core has never had an answer it could trust. What is shown below does not confirm that the game server has no Teams.',
|
||||
}
|
||||
}
|
||||
if (sync.stale) {
|
||||
|
||||
@@ -11,6 +11,13 @@
|
||||
// that the two files can drift, so a test asserts they agree
|
||||
// (client/test/moduleRegistry.test.js) rather than trusting a bump to remember
|
||||
// both.
|
||||
// 1.19.0 — an event action may declare `demoRefuses(params)` (DEMO_MODE.md §3.4,
|
||||
// DM18): the demo refuses a step at save. Server-side only; the refusal reaches
|
||||
// the browser as an ordinary DEMO_LOCKED answer. This file bumps for the reason at the top.
|
||||
// 1.18.0 — the public demo (DEMO_MODE.md §3.2): `demoLocks` in module.json,
|
||||
// `ctx.demoMode`, `api.onDemoSeed`. Server-side; the client half is core's own
|
||||
// banner and refusal toast, which an installed module's requests reach through
|
||||
// the shared request primitive. This file bumps for the reason at the top.
|
||||
// 1.17.0 — a module may take part in linking a sign-in identity (STEAM_SIGNIN.md
|
||||
// §4): `ctx.identities.list`, `api.onIdentityLinking`, `api.onIdentityLinked`.
|
||||
// Server-side; the client half is core's own account pages showing Steam. This
|
||||
@@ -98,4 +105,4 @@
|
||||
// but the two halves state ONE version: a module declares a single coreApi range
|
||||
// and is served one chunk, so a client that claimed 1.0.0 while the server
|
||||
// answered 1.1.0 would be two answers to one question.
|
||||
export const MODULE_API_VERSION = '1.17.0'
|
||||
export const MODULE_API_VERSION = '1.19.0'
|
||||
|
||||
@@ -4,6 +4,7 @@ import MoonDot from '../../components/MoonDot.jsx'
|
||||
import BrandLogo from '../../components/BrandLogo.jsx'
|
||||
import ProviderIcon from '../../components/ProviderIcon.jsx'
|
||||
import TrustLimitModal from '../../components/security/TrustLimitModal.jsx'
|
||||
import DemoLogins from '../../components/DemoLogins.jsx'
|
||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||
import { api } from '../../api/client.js'
|
||||
@@ -342,6 +343,14 @@ export default function AdminLogin() {
|
||||
Protected area — not indexed. Sessions expire after 1 day.
|
||||
</p>
|
||||
</form>
|
||||
{stage === 'creds' && (
|
||||
<DemoLogins
|
||||
onPick={(u, p) => {
|
||||
setUsername(u)
|
||||
setPassword(p)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<p style={{ textAlign: 'center', margin: '20px 0 0' }}>
|
||||
<Link to="/" className="sans" style={{ color: 'var(--accent)', fontSize: '0.84rem', textDecoration: 'none' }}>
|
||||
← Back to site
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { useAsync } from '../../../lib/useAsync.js'
|
||||
import { ago, dateTime } from '../../../lib/format.js'
|
||||
import { api } from '../../../api/client.js'
|
||||
import { isDemoLocked } from '../../../lib/demoNotice.js'
|
||||
import { useSite } from '../../../contexts/SiteContext.jsx'
|
||||
import { useAuth } from '../../../contexts/AuthContext.jsx'
|
||||
|
||||
@@ -49,11 +50,10 @@ export default function Dashboard() {
|
||||
await refreshSite()
|
||||
reload()
|
||||
} catch (err) {
|
||||
setModeError(
|
||||
err.status === 403
|
||||
? 'Only an administrator can change the site mode.'
|
||||
: 'Could not change the site mode. Try again.',
|
||||
)
|
||||
let message = 'Could not change the site mode. Try again.'
|
||||
if (isDemoLocked(err)) message = err.message
|
||||
else if (err.status === 403) message = 'Only an administrator can change the site mode.'
|
||||
setModeError(message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import TeamVoice from './TeamVoice.jsx'
|
||||
// Three panels, in the order an operator needs them:
|
||||
//
|
||||
// 1. **Sync state**, verbatim, including the last error. The screen's first job
|
||||
// is to make "the shard has no Teams" and "core has not been able to ask for
|
||||
// is to make "the game server has no Teams" and "core has not been able to ask for
|
||||
// two hours" impossible to confuse — they render almost identically
|
||||
// otherwise, and one is fine while the other is an outage.
|
||||
// 2. **The review queue** — Teams auto-hidden because their name matched the
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
||||
import { Link, useNavigate, useLocation } from 'react-router-dom'
|
||||
import ProviderIcon from '../../components/ProviderIcon.jsx'
|
||||
import TrustLimitModal from '../../components/security/TrustLimitModal.jsx'
|
||||
import DemoLogins from '../../components/DemoLogins.jsx'
|
||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||
import { api } from '../../api/client.js'
|
||||
import PlayerShell, { honeypotStyle } from './PlayerShell.jsx'
|
||||
@@ -272,6 +273,15 @@ export default function PlayerLogin() {
|
||||
)}
|
||||
</form>
|
||||
|
||||
{stage === 'creds' && (
|
||||
<DemoLogins
|
||||
onPick={(u, p) => {
|
||||
setUsername(u)
|
||||
setPassword(p)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{trustLimit && (
|
||||
<TrustLimitModal
|
||||
devices={trustLimit.devices}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Link, useNavigate } from 'react-router-dom'
|
||||
import ProviderIcon from '../../components/ProviderIcon.jsx'
|
||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||
import { api } from '../../api/client.js'
|
||||
import { isDemoLocked } from '../../lib/demoNotice.js'
|
||||
import PlayerShell, { honeypotStyle } from './PlayerShell.jsx'
|
||||
|
||||
export default function PlayerRegister() {
|
||||
@@ -54,6 +55,7 @@ export default function PlayerRegister() {
|
||||
navigate('/player', { replace: true })
|
||||
} catch (err) {
|
||||
if (err.status === 409) setError('That username is already taken.')
|
||||
else if (isDemoLocked(err)) setError(err.message)
|
||||
else if (err.status === 403) setError('Registration is not open right now.')
|
||||
else if (err.status === 400) setError(err.message || 'Please check your details and try again.')
|
||||
else setError('Could not create your account right now.')
|
||||
|
||||
71
client/test/demoNotice.test.js
Normal file
71
client/test/demoNotice.test.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import { test, afterEach } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { api } from '../src/api/client.js'
|
||||
import { onDemoLocked, reportIfDemoLocked, formatResetTime, isDemoLocked } from '../src/lib/demoNotice.js'
|
||||
|
||||
// The public demo's refusal reaches the one toast from req() itself, so every
|
||||
// screen and every installed module gets it without handling the code — and
|
||||
// the caller still gets its ApiError, so a form's own error line is unchanged.
|
||||
|
||||
const realFetch = global.fetch
|
||||
afterEach(() => {
|
||||
global.fetch = realFetch
|
||||
})
|
||||
|
||||
function replyOnce(status, body) {
|
||||
global.fetch = async () => ({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
statusText: 'Forbidden',
|
||||
text: async () => JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
test('a DEMO_LOCKED answer is reported once and still thrown to the caller', async () => {
|
||||
const heard = []
|
||||
const off = onDemoLocked((m) => heard.push(m))
|
||||
replyOnce(403, { code: 'DEMO_LOCKED', message: 'This is turned off on the demo site.' })
|
||||
await assert.rejects(() => api.publicSettings(), (err) => err.status === 403 && err.message === 'This is turned off on the demo site.')
|
||||
off()
|
||||
assert.deepEqual(heard, ['This is turned off on the demo site.'])
|
||||
})
|
||||
|
||||
test('any other refusal is not a demo refusal', async () => {
|
||||
const heard = []
|
||||
const off = onDemoLocked((m) => heard.push(m))
|
||||
replyOnce(403, { message: 'Admin role required' })
|
||||
await assert.rejects(() => api.publicSettings())
|
||||
off()
|
||||
assert.deepEqual(heard, [])
|
||||
})
|
||||
|
||||
test('reportIfDemoLocked recognises the code alone and survives a broken listener', () => {
|
||||
const off1 = onDemoLocked(() => {
|
||||
throw new Error('boom')
|
||||
})
|
||||
const heard = []
|
||||
const off2 = onDemoLocked((m) => heard.push(m))
|
||||
assert.equal(reportIfDemoLocked({ code: 'DEMO_LOCKED' }), true)
|
||||
assert.equal(reportIfDemoLocked({ code: 'OTHER' }), false)
|
||||
assert.equal(reportIfDemoLocked('text'), false)
|
||||
assert.equal(reportIfDemoLocked(null), false)
|
||||
off1()
|
||||
off2()
|
||||
assert.deepEqual(heard, ['This is turned off on the demo site.'])
|
||||
})
|
||||
|
||||
test('isDemoLocked tells the demo refusal from a role 403', async () => {
|
||||
replyOnce(403, { code: 'DEMO_LOCKED', message: 'This is turned off on the demo site.' })
|
||||
const locked = await api.publicSettings().catch((e) => e)
|
||||
replyOnce(403, { message: 'Admin role required' })
|
||||
const role = await api.publicSettings().catch((e) => e)
|
||||
assert.equal(isDemoLocked(locked), true)
|
||||
assert.equal(isDemoLocked(role), false)
|
||||
assert.equal(isDemoLocked(null), false)
|
||||
})
|
||||
|
||||
test('the reset time is said in UTC, as the wipe runs', () => {
|
||||
assert.equal(formatResetTime('2026-10-24T14:00:00.000Z'), '14:00 UTC')
|
||||
assert.equal(formatResetTime('2026-10-24T09:05:00+02:00'), '07:05 UTC')
|
||||
assert.equal(formatResetTime('nope'), null)
|
||||
})
|
||||
@@ -19,7 +19,7 @@ test('a deployment with no provider is not stale, it is uninvolved', () => {
|
||||
assert.equal(freshnessNote({ configured: false }, NOW), null)
|
||||
})
|
||||
|
||||
test('never synced is a warning, and never reads as a confirmed empty shard', () => {
|
||||
test('never synced is a warning, and never reads as a confirmed empty roster', () => {
|
||||
const note = freshnessNote({ configured: true, lastSyncAt: null }, NOW)
|
||||
assert.equal(note.tone, 'warn')
|
||||
assert.match(note.text, /Not yet confirmed/)
|
||||
|
||||
@@ -21,13 +21,13 @@ test('no provider is idle, not a fault', () => {
|
||||
assert.match(f.label, /No Team provider/)
|
||||
})
|
||||
|
||||
test('never synced is reported as never synced, not as an empty shard', () => {
|
||||
test('never synced is reported as never synced, not as a game server with no Teams', () => {
|
||||
// The failure this prevents: an empty projection core has never confirmed,
|
||||
// rendered as though the game genuinely has no Teams.
|
||||
const f = freshnessOf({ configured: true, lastSyncAt: null })
|
||||
assert.equal(f.tone, TONE.bad)
|
||||
assert.equal(f.label, 'Never synced')
|
||||
assert.match(f.detail, /not a confirmed empty shard/)
|
||||
assert.match(f.detail, /does not confirm that the game server has no Teams/)
|
||||
})
|
||||
|
||||
test('stale says how old it is', () => {
|
||||
|
||||
61
deploy/demo/.env.example
Normal file
61
deploy/demo/.env.example
Normal file
@@ -0,0 +1,61 @@
|
||||
# ─── The demo site's environment (docs/website/DEMO_MODE.md) ───
|
||||
# Copy to /opt/demo.runicgateway.com/.env on the `demo-site` host and fill in.
|
||||
# NEVER commit the real file. The wipe workflow never writes it; it only reads it.
|
||||
|
||||
IMAGE_TAG=latest
|
||||
NODE_ENV=production
|
||||
PORT=3000
|
||||
# The host port the external reverse proxy forwards demo.runicgateway.com to.
|
||||
DEMO_HOST_PORT=3000
|
||||
# How often the wipe runs, for the banner's "wiped at" time. Must match the cron
|
||||
# in .gitea/workflows/demo-wipe.yml (every 2 hours on the hour, UTC, DM7).
|
||||
DEMO_RESET_EVERY_HOURS=2
|
||||
|
||||
# Branding
|
||||
BRAND_NAME=Runic Gateway Demo
|
||||
BRAND_SHORT_NAME=RG Demo
|
||||
BRAND_URL=https://demo.runicgateway.com
|
||||
CLIENT_ORIGIN=https://demo.runicgateway.com
|
||||
|
||||
# Database. Recreated empty at every wipe; the passwords only need to be stable.
|
||||
DB_HOST=db
|
||||
DB_PORT=3306
|
||||
DB_NAME=runic_gateway
|
||||
DB_USER=runic
|
||||
DB_PASSWORD=
|
||||
DB_ROOT_PASSWORD=
|
||||
|
||||
# Long random strings, e.g. `openssl rand -hex 32`. SECRET_ENC_KEY is required in
|
||||
# production. Every stored secret is re-seeded at each wipe, so rotating either
|
||||
# one only signs people out.
|
||||
JWT_SECRET=
|
||||
SECRET_ENC_KEY=
|
||||
COOKIE_SECURE=auto
|
||||
# The guard on the app's unpublished internal port. The demo runs no bot, but the
|
||||
# app refuses to start in production without it. Another long random string.
|
||||
BOT_INTERNAL_KEY=
|
||||
# One proxy hop: the external reverse proxy.
|
||||
TRUST_PROXY=1
|
||||
|
||||
# Leave ADMIN_USERNAME unset: the boot seed must not create a fifth admin. The four
|
||||
# demo accounts come from seed.json (§4).
|
||||
ADMIN_USERNAME=
|
||||
|
||||
# Where modules may be installed from. A fresh database seeds the allowlist from
|
||||
# this, and the declared module (state/modules.env) installs under it.
|
||||
MODULE_SOURCE_HOSTS=gitea.whitlocktech.com
|
||||
|
||||
# ── seed.json reads these through "$env:NAME" ──
|
||||
# The published logins (§4) are not secrets, but they live here so the repo does
|
||||
# not pin them. play-review's is NOT published; it goes in the Play Console only.
|
||||
DEMO_ADMIN_PASSWORD=
|
||||
DEMO_MODERATOR_PASSWORD=
|
||||
DEMO_PLAYER_PASSWORD=
|
||||
PLAY_REVIEW_PASSWORD=
|
||||
# A stand-in Steam id that has played on the rig, so demo-player's Rust screens
|
||||
# have data (DM6).
|
||||
DEMO_PLAYER_STEAM_ID=
|
||||
# The rig's sidecar web API (rust-oxide's last allocation) and its web token: the
|
||||
# RUSTLINK_WEB_TOKEN in the rig's startup command, or rust-link/sidecar.toml.
|
||||
DEMO_RIG_SIDECAR_URL=http://192.168.0.12:21004
|
||||
DEMO_RIG_SIDECAR_TOKEN=
|
||||
53
deploy/demo/README.md
Normal file
53
deploy/demo/README.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# deploy/demo — demo.runicgateway.com
|
||||
|
||||
The public demo site: the normal image with `DEMO_MODE=true`, wiped every two hours together
|
||||
with its Rust server. Design of record: `docs/website/DEMO_MODE.md`.
|
||||
|
||||
| File | What it is |
|
||||
| --- | --- |
|
||||
| `docker-compose.yml` | The app and its database. No bot and no ntfy: the demo sends nothing outward (§5) |
|
||||
| `.env.example` | Every variable the host's `.env` needs, with what each one is for |
|
||||
| `seed.json` | The fixture `seed:demo` writes after every wipe: the four logins, settings, content, and the Rust module's server link and Steam link (§7.3). Passwords and credentials are `"$env:NAME"` references to `.env` |
|
||||
| `rig.mjs` | Versions (`plan`), the rig restore (`restore`), the baseline capture (`baseline`) and the smoke check (`smoke`), against the Pterodactyl client API (§7.2, §7.5) |
|
||||
|
||||
Two workflows drive it, both on the `demo-site` runner:
|
||||
|
||||
- **`demo-wipe.yml`**: every 2 hours on the hour (UTC), on a push to `main` that changes this
|
||||
directory, and by hand. Versions, then the rig, then the site, then a smoke check.
|
||||
- **`demo-baseline.yml`**: by hand only. It captures the rig's current state as
|
||||
`demo-baseline.tar.gz`, which every wipe restores.
|
||||
|
||||
## One-time host setup
|
||||
|
||||
1. The `demo-site` runner in **host mode** on the demo host, with Docker and Compose, and `node`
|
||||
on the `PATH` (for `actions/checkout`). The host must reach `gitea.whitlocktech.com`, the
|
||||
Pterodactyl panel and the rig's sidecar port.
|
||||
2. `sudo mkdir -p /opt/demo.runicgateway.com`, owned by the runner's user (or the runner runs as
|
||||
root). To use another path, set the `DEMO_DIR` Actions variable.
|
||||
3. Copy `.env.example` to `/opt/demo.runicgateway.com/.env` and fill every blank. Generate
|
||||
secrets on the host, e.g. `openssl rand -hex 32`. The file never leaves the host.
|
||||
4. Log the host in to the registry if the images are not public: `docker login gitea.whitlocktech.com`.
|
||||
5. Point the external reverse proxy at `<host>:DEMO_HOST_PORT` (default 3000).
|
||||
6. Run **Demo wipe** by hand. The first run finds no baseline, so it leaves the rig as it is and
|
||||
starts it.
|
||||
|
||||
Then set the demo default set up through the site: the permission groups, a few NPCs, a clan. Run
|
||||
**Demo baseline** while nobody else is changing anything. Every later wipe returns the rig to that.
|
||||
|
||||
The panel URL and the rig's server identifier default to `http://192.168.0.12` and `6eb9d621`
|
||||
(`rust-oxide`). Override them with the `PTERODACTYL_URL` and `PTERODACTYL_SERVER` Actions
|
||||
variables. `PTERODACTYL_CLIENT_KEY` is the demo subuser's key, an Actions secret.
|
||||
|
||||
## What the wipe keeps on the host
|
||||
|
||||
`state/` in the deploy directory:
|
||||
|
||||
- `bundle.json`: the Rust bundle last written onto the rig. A new bundle is written only when
|
||||
this differs.
|
||||
- `modules.env`: `MODULES=rust@<version>=<manifest>`, the newest Module-Rust release on the bundle's
|
||||
protocol. When no release matches a new bundle yet, the wipe keeps this file and the rig's
|
||||
bundle as they were and warns (§7.5).
|
||||
- `plan.json`: this cycle's decision, from `plan` to `restore`.
|
||||
|
||||
`modules/` keeps the unpacked module between wipes. `down -v` drops the database and the uploads,
|
||||
never this directory.
|
||||
70
deploy/demo/docker-compose.yml
Normal file
70
deploy/demo/docker-compose.yml
Normal file
@@ -0,0 +1,70 @@
|
||||
# The public demo, demo.runicgateway.com (docs/website/DEMO_MODE.md).
|
||||
#
|
||||
# The same image as every other deployment, switched by DEMO_MODE=true (§2). Only
|
||||
# the app and its database: no bot and no ntfy, because the demo sends nothing
|
||||
# outward (§5). The wipe workflow (.gitea/workflows/demo-wipe.yml) copies this
|
||||
# file into the deploy directory on the `demo-site` host and runs it from there;
|
||||
# the host's own `.env` beside it holds every secret.
|
||||
#
|
||||
# The project name is fixed so the network is `rg-demo_default` whatever the
|
||||
# directory is called: the workflow's smoke check joins it to reach the app.
|
||||
name: rg-demo
|
||||
|
||||
services:
|
||||
db:
|
||||
image: mariadb:11
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MARIADB_DATABASE: ${DB_NAME}
|
||||
MARIADB_USER: ${DB_USER}
|
||||
MARIADB_PASSWORD: ${DB_PASSWORD}
|
||||
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
|
||||
volumes:
|
||||
# Dropped at every wipe (`down -v`) with the uploads volume: that is the wipe.
|
||||
- dbdata:/var/lib/mysql
|
||||
healthcheck:
|
||||
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
app:
|
||||
image: gitea.whitlocktech.com/runicgateway/website-app:${IMAGE_TAG:-latest}
|
||||
restart: unless-stopped
|
||||
# modules.env is written by the wipe (rig.mjs plan): MODULES=rust@<version>=…,
|
||||
# the newest Module-Rust release on the bundle's protocol (§7.5). Kept out of
|
||||
# .env so the workflow never edits the file holding the secrets.
|
||||
env_file:
|
||||
- .env
|
||||
- state/modules.env
|
||||
environment:
|
||||
DEMO_MODE: "true"
|
||||
DB_HOST: db
|
||||
UPLOAD_DIR: /app/uploads
|
||||
LOG_DIR: /app/logs
|
||||
MODULES_DIR: /app/modules
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- uploads:/app/uploads
|
||||
- ./logs:/app/logs
|
||||
# A bind mount, not a volume, so `down -v` keeps the unpacked module and a
|
||||
# wipe does not download it again. Must be writable by uid 1000.
|
||||
- ./modules:/app/modules
|
||||
# The seed fixture (§7.3), read by `seed:demo` after every wipe.
|
||||
- ./seed.json:/app/deploy/demo/seed.json:ro
|
||||
# Busybox wget is in the Alpine image; `/status` answers once the server listens.
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:3000/api/v1/public/status"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
start_period: 30s
|
||||
retries: 30
|
||||
# The external reverse proxy (DNS + HTTPS) forwards here.
|
||||
ports:
|
||||
- "${DEMO_HOST_PORT:-3000}:3000"
|
||||
|
||||
volumes:
|
||||
dbdata:
|
||||
uploads:
|
||||
372
deploy/demo/rig.mjs
Normal file
372
deploy/demo/rig.mjs
Normal file
@@ -0,0 +1,372 @@
|
||||
// ── The demo's rig and versions (docs/website/DEMO_MODE.md §7.2, §7.5) ──────
|
||||
//
|
||||
// One script, four commands, run by the demo workflows on the `demo-site` host
|
||||
// inside a stock Node container (so the host needs Docker and nothing else):
|
||||
//
|
||||
// node rig.mjs plan decide the versions for this cycle (§7.5)
|
||||
// node rig.mjs restore put the rig back to the baseline, updating its plugins
|
||||
// and sidecar first when the bundle changed (§7.2)
|
||||
// node rig.mjs baseline capture the rig's current state as the baseline (§7.2)
|
||||
// node rig.mjs smoke wait for the site, then for the rig to report in (§7.3.6)
|
||||
//
|
||||
// State lives in $STATE_DIR (the deploy directory's `state/`):
|
||||
// bundle.json the bundle last written onto the rig
|
||||
// modules.env MODULES=rust@<version>=<manifest>, read by docker-compose.yml
|
||||
// plan.json this cycle's decision, written by `plan`, read by `restore`
|
||||
//
|
||||
// Environment:
|
||||
// PTERODACTYL_URL the panel, e.g. http://192.168.0.12
|
||||
// PTERODACTYL_SERVER the rig's short identifier, e.g. e6758c06
|
||||
// PTERODACTYL_CLIENT_KEY the demo subuser's client key (file + power only)
|
||||
// PTERODACTYL_IDENTITY optional: the folder under /server holding the save;
|
||||
// found by listing /server when unset
|
||||
// GITEA_URL default https://gitea.whitlocktech.com
|
||||
// SITE_URL for `smoke`, e.g. http://app:3000
|
||||
// SMOKE_TIMEOUT_SECONDS for `smoke`, default 900 (a Rust boot is ~9 minutes)
|
||||
//
|
||||
// Dependency-free on purpose: Node's own fetch and crypto, and the image's tar.
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
|
||||
const GITEA = (process.env.GITEA_URL || 'https://gitea.whitlocktech.com').replace(/\/+$/, '')
|
||||
const STATE_DIR = process.env.STATE_DIR || path.join(path.dirname(fileURLToPath(import.meta.url)), 'state')
|
||||
const BASELINE = 'demo-baseline.tar.gz'
|
||||
const MODULE_REPO = 'RunicGateway/Module-Rust'
|
||||
const BUNDLE_PATH = 'RunicGateway/installer/contents/v2/rust/current.json?ref=bundles'
|
||||
|
||||
const log = (...a) => console.log(...a)
|
||||
const warn = (msg) => console.log(`::warning::${msg}`)
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
||||
|
||||
function need(name) {
|
||||
const v = process.env[name]
|
||||
if (!v) throw new Error(`${name} is not set`)
|
||||
return v
|
||||
}
|
||||
|
||||
const statePath = (f) => path.join(STATE_DIR, f)
|
||||
function readJson(file, fallback = null) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(file, 'utf8'))
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
function writeFile(file, text) {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true })
|
||||
fs.writeFileSync(file, text)
|
||||
}
|
||||
|
||||
// ── Gitea ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// The JSON API, never /raw/: /raw/ is CDN-cached for hours on this instance, and
|
||||
// a stale current.json would pin the demo to an old bundle without a word.
|
||||
async function gitea(apiPath) {
|
||||
const res = await fetch(`${GITEA}/api/v1/repos/${apiPath}`, { headers: { Accept: 'application/json' } })
|
||||
if (!res.ok) throw new Error(`GET ${apiPath}: HTTP ${res.status}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
async function currentBundle() {
|
||||
const file = await gitea(BUNDLE_PATH)
|
||||
return JSON.parse(Buffer.from(file.content, 'base64').toString('utf8'))
|
||||
}
|
||||
|
||||
async function download(url, sha256) {
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) throw new Error(`GET ${url}: HTTP ${res.status}`)
|
||||
const buf = Buffer.from(await res.arrayBuffer())
|
||||
if (sha256) {
|
||||
const got = createHash('sha256').update(buf).digest('hex')
|
||||
if (got !== sha256) throw new Error(`${url}: sha256 ${got} does not match the bundle's ${sha256}`)
|
||||
}
|
||||
return buf
|
||||
}
|
||||
|
||||
const semver = (v) => String(v).replace(/^v/, '').split('.').map((n) => Number.parseInt(n, 10) || 0)
|
||||
function newerFirst(a, b) {
|
||||
const x = semver(a)
|
||||
const y = semver(b)
|
||||
for (let i = 0; i < 3; i += 1) if (x[i] !== y[i]) return y[i] - x[i]
|
||||
return 0
|
||||
}
|
||||
|
||||
/** The newest Module-Rust release whose install manifest says `protocol` (DM12). */
|
||||
async function moduleFor(protocol) {
|
||||
const releases = (await gitea(`${MODULE_REPO}/releases?limit=50`))
|
||||
.filter((r) => !r.draft && !r.prerelease)
|
||||
.sort((a, b) => newerFirst(a.tag_name, b.tag_name))
|
||||
for (const r of releases) {
|
||||
const asset = (r.assets || []).find((a) => /^module-rust-.+\.json$/.test(a.name))
|
||||
if (!asset) continue
|
||||
const res = await fetch(asset.browser_download_url)
|
||||
if (!res.ok) continue
|
||||
const manifest = await res.json()
|
||||
// Releases from before §7.5 carry no `protocol`; they never match.
|
||||
if (manifest.protocol === protocol) {
|
||||
return { version: manifest.version, manifestUrl: asset.browser_download_url }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ── Pterodactyl (client API: files + power only) ─────────────────────────
|
||||
|
||||
function ptero() {
|
||||
const base = `${need('PTERODACTYL_URL').replace(/\/+$/, '')}/api/client/servers/${need('PTERODACTYL_SERVER')}`
|
||||
const key = need('PTERODACTYL_CLIENT_KEY')
|
||||
|
||||
async function call(method, sub, { json, body, type, raw = false } = {}) {
|
||||
const headers = { Authorization: `Bearer ${key}`, Accept: 'application/json' }
|
||||
let payload
|
||||
if (json !== undefined) {
|
||||
headers['Content-Type'] = 'application/json'
|
||||
payload = JSON.stringify(json)
|
||||
} else if (body !== undefined) {
|
||||
headers['Content-Type'] = type || 'application/octet-stream'
|
||||
payload = body
|
||||
}
|
||||
const res = await fetch(`${base}${sub}`, { method, headers, body: payload })
|
||||
const text = await res.text()
|
||||
if (!res.ok) throw new Error(`${method} ${sub}: HTTP ${res.status} ${text.slice(0, 300)}`)
|
||||
if (raw) return text
|
||||
return text ? JSON.parse(text) : null
|
||||
}
|
||||
|
||||
return {
|
||||
state: async () => (await call('GET', '/resources')).attributes.current_state,
|
||||
power: (signal) => call('POST', '/power', { json: { signal } }),
|
||||
list: async (dir) =>
|
||||
(await call('GET', `/files/list?directory=${encodeURIComponent(dir)}`)).data.map((f) => f.attributes),
|
||||
remove: (files) => call('POST', '/files/delete', { json: { root: '/', files } }),
|
||||
decompress: (file) => call('POST', '/files/decompress', { json: { root: '/', file } }),
|
||||
compress: async (files) => (await call('POST', '/files/compress', { json: { root: '/', files } })).attributes,
|
||||
rename: (from, to) => call('PUT', '/files/rename', { json: { root: '/', files: [{ from, to }] } }),
|
||||
read: (file) => call('GET', `/files/contents?file=${encodeURIComponent(file)}`, { raw: true }),
|
||||
write: (file, body, type) => call('POST', `/files/write?file=${encodeURIComponent(file)}`, { body, type }),
|
||||
// One file per call: a chmod naming two applies only the first, silently.
|
||||
chmod: (dir, file, mode) => call('POST', '/files/chmod', { json: { root: dir, files: [{ file, mode }] } }),
|
||||
}
|
||||
}
|
||||
|
||||
async function waitFor(p, wanted, seconds) {
|
||||
const until = Date.now() + seconds * 1000
|
||||
for (;;) {
|
||||
const s = await p.state()
|
||||
if (s === wanted) return
|
||||
if (Date.now() > until) throw new Error(`the rig is "${s}" after ${seconds}s, expected "${wanted}"`)
|
||||
await sleep(5000)
|
||||
}
|
||||
}
|
||||
|
||||
/** The folder under /server holding the world save and cfg/. */
|
||||
async function identity(p) {
|
||||
if (process.env.PTERODACTYL_IDENTITY) return process.env.PTERODACTYL_IDENTITY
|
||||
const dirs = (await p.list('/server')).filter((f) => !f.is_file).map((f) => f.name)
|
||||
if (dirs.length !== 1) {
|
||||
throw new Error(`cannot tell the server identity: /server holds ${JSON.stringify(dirs)}; set PTERODACTYL_IDENTITY`)
|
||||
}
|
||||
return dirs[0]
|
||||
}
|
||||
|
||||
const baselineDirs = (id) => ['oxide/data', 'oxide/config', `server/${id}`]
|
||||
|
||||
// ── plan ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function plan() {
|
||||
const bundle = await currentBundle()
|
||||
const applied = readJson(statePath('bundle.json'))
|
||||
const previousModules = fs.existsSync(statePath('modules.env'))
|
||||
log(`bundle ${bundle.bundle} (protocol ${bundle.protocol}); on the rig: ${applied ? applied.bundle : 'none recorded'}`)
|
||||
|
||||
const module = await moduleFor(bundle.protocol)
|
||||
if (!module) {
|
||||
// §7.5: a half-released protocol bump never takes the demo down.
|
||||
if (!previousModules) {
|
||||
throw new Error(`no Module-Rust release speaks protocol ${bundle.protocol}, and there is no previous set to keep`)
|
||||
}
|
||||
warn(`no Module-Rust release speaks protocol ${bundle.protocol} yet; keeping the previous bundle and module`)
|
||||
writeFile(statePath('plan.json'), JSON.stringify({ bundle: null }, null, 2))
|
||||
return
|
||||
}
|
||||
|
||||
log(`module rust ${module.version} (protocol ${bundle.protocol})`)
|
||||
writeFile(statePath('modules.env'), `MODULES=rust@${module.version}=${module.manifestUrl}\n`)
|
||||
const changed = !applied || applied.bundle !== bundle.bundle
|
||||
writeFile(statePath('plan.json'), JSON.stringify({ bundle: changed ? bundle : null }, null, 2))
|
||||
log(changed ? 'the rig gets the new bundle at this wipe' : 'the rig is already on this bundle')
|
||||
}
|
||||
|
||||
// ── restore ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function writeBundle(p, bundle) {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'bundle-'))
|
||||
try {
|
||||
const plugins = []
|
||||
for (const part of [bundle.payload, bundle.npc].filter(Boolean)) {
|
||||
const tarball = path.join(tmp, part.asset.name)
|
||||
fs.writeFileSync(tarball, await download(part.asset.url, part.asset.sha256))
|
||||
const out = path.join(tmp, `${part.asset.name}.d`)
|
||||
fs.mkdirSync(out)
|
||||
execFileSync('tar', ['-xzf', tarball, '-C', out])
|
||||
for (const dir of fs.readdirSync(out)) {
|
||||
for (const f of fs.readdirSync(path.join(out, dir))) {
|
||||
if (f.endsWith('.cs')) plugins.push({ name: f, text: fs.readFileSync(path.join(out, dir, f)) })
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!plugins.length) throw new Error(`bundle ${bundle.bundle} carries no plugin files`)
|
||||
|
||||
const sidecar = bundle.sidecar.assets['linux-x86_64']
|
||||
const binary = await download(sidecar.url, sidecar.sha256)
|
||||
const launcher = await download(bundle.sidecar.launcher.url, bundle.sidecar.launcher.sha256)
|
||||
|
||||
for (const f of plugins) {
|
||||
await p.write(`/oxide/plugins/${f.name}`, f.text, 'text/plain')
|
||||
log(` wrote oxide/plugins/${f.name}`)
|
||||
}
|
||||
await p.write('/rust-link/rust-link-sidecar', binary)
|
||||
await p.write('/rust-link/with-sidecar.sh', launcher, 'text/plain')
|
||||
await p.chmod('/rust-link', 'rust-link-sidecar', '755')
|
||||
await p.chmod('/rust-link', 'with-sidecar.sh', '755')
|
||||
log(` wrote the sidecar ${bundle.sidecar.version} and its launcher`)
|
||||
|
||||
// The rig's own record of what it runs, in the shape the rig push writes, so
|
||||
// anyone reading the rig sees the bundle the wipe put there.
|
||||
const prior = JSON.parse((await p.read('/rust-link/bundle.json').catch(() => null)) || '{}')
|
||||
const record = {
|
||||
bundle: bundle.bundle,
|
||||
protocol: bundle.protocol,
|
||||
framework: prior.framework || 'oxide',
|
||||
installed: new Date().toISOString().replace(/\.\d+Z$/, 'Z'),
|
||||
sidecar: { tag: bundle.sidecar.tag },
|
||||
plugin: { tag: bundle.payload.tag, commit: bundle.payload.commit },
|
||||
...(bundle.npc ? { runicnpc: { tag: bundle.npc.tag, api: bundle.npc.api } } : {}),
|
||||
}
|
||||
await p.write('/rust-link/bundle.json', `${JSON.stringify(record, null, 2)}\n`, 'text/plain')
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
async function restore() {
|
||||
const p = ptero()
|
||||
const decided = readJson(statePath('plan.json'))
|
||||
if (!decided) throw new Error('no plan.json; run `plan` first')
|
||||
|
||||
// 1. Kill: no save, the world is about to be replaced.
|
||||
if ((await p.state()) !== 'offline') {
|
||||
await p.power('kill')
|
||||
await waitFor(p, 'offline', 120)
|
||||
}
|
||||
log('the rig is stopped')
|
||||
|
||||
// §7.5: the bundle goes on while the server is stopped.
|
||||
if (decided.bundle) {
|
||||
log(`writing bundle ${decided.bundle.bundle} onto the rig`)
|
||||
await writeBundle(p, decided.bundle)
|
||||
writeFile(
|
||||
statePath('bundle.json'),
|
||||
JSON.stringify({ bundle: decided.bundle.bundle, protocol: decided.bundle.protocol, applied: new Date().toISOString() }, null, 2),
|
||||
)
|
||||
}
|
||||
|
||||
// 2-3. Delete the three directories and unpack the baseline over them.
|
||||
const root = (await p.list('/')).map((f) => f.name)
|
||||
if (root.includes(BASELINE)) {
|
||||
const id = await identity(p)
|
||||
// Only what exists: a visitor cannot delete these, but a half-finished
|
||||
// earlier run can have.
|
||||
const oxide = new Set((await p.list('/oxide')).map((f) => `oxide/${f.name}`))
|
||||
const dirs = baselineDirs(id).filter((d) => oxide.has(d) || d === `server/${id}`)
|
||||
await p.remove(dirs)
|
||||
log(` deleted ${dirs.join(', ')}`)
|
||||
await p.decompress(BASELINE)
|
||||
log(` unpacked ${BASELINE}`)
|
||||
} else {
|
||||
warn(`there is no ${BASELINE} on the rig yet; it starts as it is. Capture one with the "Demo baseline" workflow.`)
|
||||
}
|
||||
|
||||
// 4. Start. Rust takes minutes to boot; the site does not wait for it.
|
||||
await p.power('start')
|
||||
log('the rig is starting')
|
||||
}
|
||||
|
||||
// ── baseline ──────────────────────────────────────────────────────────────
|
||||
|
||||
async function baseline() {
|
||||
const p = ptero()
|
||||
// A power stop runs the egg's stop command, `quit`, which writes the save, so
|
||||
// the subuser needs no console permission (DM22).
|
||||
if ((await p.state()) !== 'offline') {
|
||||
await p.power('stop')
|
||||
await waitFor(p, 'offline', 300)
|
||||
}
|
||||
log('the rig is stopped and saved')
|
||||
|
||||
const id = await identity(p)
|
||||
const made = await p.compress(baselineDirs(id))
|
||||
log(` compressed ${baselineDirs(id).join(', ')} into ${made.name}`)
|
||||
if ((await p.list('/')).some((f) => f.name === BASELINE)) await p.remove([BASELINE])
|
||||
await p.rename(made.name, BASELINE)
|
||||
log(` saved as ${BASELINE} (${made.size} bytes)`)
|
||||
|
||||
await p.power('start')
|
||||
log('the rig is starting')
|
||||
}
|
||||
|
||||
// ── smoke ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function smoke() {
|
||||
const site = need('SITE_URL').replace(/\/+$/, '')
|
||||
const until = Date.now() + (Number(process.env.SMOKE_TIMEOUT_SECONDS) || 900) * 1000
|
||||
const get = async (p) => {
|
||||
try {
|
||||
const res = await fetch(`${site}${p}`)
|
||||
return res.ok ? await res.json() : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
while (!(await get('/api/v1/public/status'))) {
|
||||
if (Date.now() > until) throw new Error('the site never answered /api/v1/public/status')
|
||||
await sleep(5000)
|
||||
}
|
||||
log('the site answers')
|
||||
|
||||
for (;;) {
|
||||
const list = (await get('/api/v1/public/rust/servers'))?.servers || []
|
||||
if (list.length && list.every((s) => s.online)) {
|
||||
log(`the rig reports in: ${list.map((s) => s.name || s.id).join(', ')}`)
|
||||
return
|
||||
}
|
||||
if (Date.now() > until) {
|
||||
throw new Error(`the rig has not reported in (${JSON.stringify(list.map((s) => ({ id: s.id, online: s.online })))}); the site serves with it shown offline`)
|
||||
}
|
||||
await sleep(15000)
|
||||
}
|
||||
}
|
||||
|
||||
// ── main ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export { ptero, identity, baselineDirs, writeBundle, moduleFor, newerFirst, currentBundle, BASELINE }
|
||||
|
||||
// Run only when invoked, so a test can import the pieces without running one.
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
||||
const commands = { plan, restore, baseline, smoke }
|
||||
const command = commands[process.argv[2]]
|
||||
if (!command) {
|
||||
console.error(`usage: node rig.mjs ${Object.keys(commands).join('|')}`)
|
||||
process.exit(2)
|
||||
}
|
||||
command().catch((err) => {
|
||||
console.log(`::error::${err.message}`)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
158
deploy/demo/seed.json
Normal file
158
deploy/demo/seed.json
Normal file
@@ -0,0 +1,158 @@
|
||||
{
|
||||
"accounts": {
|
||||
"demo-admin": { "role": "admin", "password": "$env:DEMO_ADMIN_PASSWORD" },
|
||||
"demo-moderator": { "role": "moderator", "password": "$env:DEMO_MODERATOR_PASSWORD" },
|
||||
"demo-player": { "role": "player", "password": "$env:DEMO_PLAYER_PASSWORD" },
|
||||
"play-review": { "role": "admin", "password": "$env:PLAY_REVIEW_PASSWORD", "published": false }
|
||||
},
|
||||
"settings": {
|
||||
"site_title": "Runic Gateway Demo",
|
||||
"status_message": "A public demo. Look around, change things — every change is wiped every two hours."
|
||||
},
|
||||
"content": {
|
||||
"posts": [
|
||||
{
|
||||
"category": "news",
|
||||
"title": "Welcome to the Runic Gateway demo",
|
||||
"slug": "welcome-to-the-demo",
|
||||
"body": "<p>This is a live Runic Gateway site, linked to a real Rust server. Sign in with one of the logins on the sign-in page and look around: the admin panel, the moderation queue, a player's own pages.</p><p>You can change most things. A few would break the demo for the next visitor — uploads, the server link, deleting accounts — and those answer <em>This is turned off on the demo site</em>. Everything is put back every two hours.</p>"
|
||||
},
|
||||
{
|
||||
"category": "news",
|
||||
"title": "What the Rust module adds",
|
||||
"slug": "what-the-rust-module-adds",
|
||||
"body": "<p>The server list, the kill feed and the leaderboard come from the game as it happens. Signed in as an admin you can manage permission groups, place NPCs, edit plugin configs and build events that run on the server.</p>"
|
||||
}
|
||||
],
|
||||
"wiki": [
|
||||
{
|
||||
"slug": "about-this-demo",
|
||||
"title": "About this demo",
|
||||
"category": "guides",
|
||||
"published": true,
|
||||
"body": "<h2>The logins</h2><p>The sign-in page lists them, with a button that fills the form: an admin, a moderator and a player.</p><h2>The wipe</h2><p>The site and its Rust server go back to this starting point every two hours, on the hour, UTC. The banner says when the next one is. Anyone signed in is signed out by it.</p><h2>What is turned off</h2><p>Anything that would break the demo for the next visitor: uploads, the contact form, changing the shared logins, the site mode, the server link, and console commands that would stop the server.</p>"
|
||||
}
|
||||
],
|
||||
"events": [
|
||||
{
|
||||
"title": "Server greeting",
|
||||
"summary": "Says hello in the game chat. Run it from Admin → Events.",
|
||||
"timezone": "UTC",
|
||||
"spec": {
|
||||
"schedule": { "kind": "manual" },
|
||||
"phases": [
|
||||
{
|
||||
"key": "main",
|
||||
"label": "Greet",
|
||||
"steps": [
|
||||
{
|
||||
"actionId": "rust.command.run",
|
||||
"params": {
|
||||
"server": "demo",
|
||||
"commands": ["say Hello from the Runic Gateway demo site!"],
|
||||
"confirm": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"publish": true
|
||||
},
|
||||
{
|
||||
"title": "Treasure drop",
|
||||
"summary": "Starts a Dangerous Treasures event: a guarded, burning chest somewhere on the map. Run it from Admin → Events.",
|
||||
"timezone": "UTC",
|
||||
"spec": {
|
||||
"schedule": { "kind": "manual" },
|
||||
"phases": [
|
||||
{
|
||||
"key": "main",
|
||||
"label": "Drop the treasure",
|
||||
"steps": [
|
||||
{ "actionId": "rust.dt.start", "params": { "server": "demo", "level": 0 } }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"publish": true
|
||||
},
|
||||
{
|
||||
"title": "Payday",
|
||||
"summary": "Pays every linked player 500 coins through Economics. Run it from Admin → Events.",
|
||||
"timezone": "UTC",
|
||||
"spec": {
|
||||
"schedule": { "kind": "manual" },
|
||||
"phases": [
|
||||
{
|
||||
"key": "main",
|
||||
"label": "Pay",
|
||||
"steps": [
|
||||
{
|
||||
"actionId": "rust.coins.pay",
|
||||
"params": {
|
||||
"server": "demo",
|
||||
"amount": 500,
|
||||
"recipients": "linked",
|
||||
"message": "Payday! You received {amount} coins.",
|
||||
"delivery": "chat"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"publish": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"modules": {
|
||||
"rust": {
|
||||
"servers": [
|
||||
{
|
||||
"id": "demo",
|
||||
"name": "Demo server",
|
||||
"sidecarBaseUrl": "$env:DEMO_RIG_SIDECAR_URL",
|
||||
"sidecarToken": "$env:DEMO_RIG_SIDECAR_TOKEN"
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
{ "username": "demo-player", "steamId": "$env:DEMO_PLAYER_STEAM_ID", "serverId": "demo" }
|
||||
],
|
||||
"npcs": {
|
||||
"factions": [{ "a": "bandits", "b": "guards", "relation": "allied" }],
|
||||
"profiles": [
|
||||
{
|
||||
"name": "bandit",
|
||||
"servers": ["demo"],
|
||||
"body": {
|
||||
"kits": ["rnpc_raider"],
|
||||
"names": ["Bandit", "Bandit Scout"],
|
||||
"role": "roamer",
|
||||
"movement": { "mode": "wander", "radius": 20 },
|
||||
"health": 150,
|
||||
"faction": "bandits"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "guard",
|
||||
"servers": ["demo"],
|
||||
"body": {
|
||||
"kits": ["rnpc_campguard"],
|
||||
"names": ["Camp Guard"],
|
||||
"prefab": "scientistnpc_heavy",
|
||||
"role": "sentry",
|
||||
"health": 300,
|
||||
"faction": "guards",
|
||||
"kitUse": { "heal": true, "grenades": false, "melee": false, "rockets": false, "flamethrower": false }
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"chat": [
|
||||
{ "serverId": "demo", "group": "vip", "style": { "Title": "[VIP]", "TitleColor": "#ffaa00", "UsernameColor": "#ffaa00", "Priority": "10" } },
|
||||
{ "serverId": "demo", "group": "moderator", "style": { "Title": "[MOD]", "TitleColor": "#55ff55", "UsernameColor": "#55ff55", "Priority": "50" } }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
254
server/db/seedDemo.js
Normal file
254
server/db/seedDemo.js
Normal file
@@ -0,0 +1,254 @@
|
||||
// ── `npm run seed:demo` — the public demo's base (DEMO_MODE.md §7.3) ────────
|
||||
//
|
||||
// Run by the wipe workflow inside the app container, after `compose down -v` and
|
||||
// a fresh `up` have left an empty database with the schema in place:
|
||||
//
|
||||
// docker compose exec app npm run seed:demo -- /app/deploy/demo/seed.json
|
||||
//
|
||||
// It writes, from one fixture file:
|
||||
// 1. the four static logins (§4) — upserted, so a password a visitor somehow
|
||||
// changed is back within one cycle and a re-run is harmless;
|
||||
// 2. the site settings the demo starts from (live, registration disabled, and
|
||||
// whatever the fixture adds);
|
||||
// 3. the seeded content — posts, wiki pages, pages, events — on the first run
|
||||
// against a database only, so a re-run never duplicates it;
|
||||
// 4. each installed module's own rows, through the module's `onDemoSeed` hook
|
||||
// (API 1.18.0, DM15): core never writes a module's tables;
|
||||
// 5. `demo_state` — when the next wipe is due and the published logins, for
|
||||
// the banner and the sign-in page (§6).
|
||||
//
|
||||
// A string `"$env:NAME"` anywhere in the fixture is replaced with that variable,
|
||||
// so the rig's sidecar token lives in the host's .env and never in the repo. An
|
||||
// unset variable fails the run rather than seeding an empty credential.
|
||||
//
|
||||
// Refuses to run without DEMO_MODE=true: pointed at a real shard by mistake, it
|
||||
// would reset four accounts' passwords and flip the site live.
|
||||
|
||||
require('dotenv').config()
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const { isDemo, DEMO_ACCOUNTS } = require('../src/demo/mode')
|
||||
|
||||
const ROLES = ['admin', 'editor', 'moderator', 'player']
|
||||
|
||||
const log = require('../src/utils/logger')('seed-demo')
|
||||
|
||||
// ── Pure helpers (tested in test/seedDemo.test.js) ─────────────────────────
|
||||
|
||||
/** Replace every `"$env:NAME"` string, at any depth. Throws on an unset one. */
|
||||
function resolveEnv(value, env = process.env, where = 'fixture') {
|
||||
if (typeof value === 'string') {
|
||||
const m = /^\$env:([A-Z_][A-Z0-9_]*)$/.exec(value)
|
||||
if (!m) return value
|
||||
const got = env[m[1]]
|
||||
if (got === undefined || got === '') throw new Error(`${where}: environment variable ${m[1]} is not set`)
|
||||
return got
|
||||
}
|
||||
if (Array.isArray(value)) return value.map((v, i) => resolveEnv(v, env, `${where}[${i}]`))
|
||||
if (value && typeof value === 'object') {
|
||||
const out = {}
|
||||
for (const [k, v] of Object.entries(value)) out[k] = resolveEnv(v, env, `${where}.${k}`)
|
||||
return out
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* When the next wipe is due: the next whole multiple of `everyHours` past
|
||||
* midnight UTC, strictly after `now` — the schedule the wipe workflow's cron
|
||||
* runs on (DM7: every 2 hours on the hour). The seed runs a minute or two after
|
||||
* a wipe starts, so "strictly after" lands on the following slot.
|
||||
*/
|
||||
function nextResetAt(now = new Date(), everyHours = 2) {
|
||||
const step = everyHours * 60 * 60 * 1000
|
||||
const midnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
|
||||
const slots = Math.floor((now.getTime() - midnight) / step) + 1
|
||||
return new Date(midnight + slots * step)
|
||||
}
|
||||
|
||||
/** Check the fixture's shape before anything is written. Returns a list of problems. */
|
||||
function checkFixture(fixture) {
|
||||
const problems = []
|
||||
if (!fixture || typeof fixture !== 'object' || Array.isArray(fixture)) return ['the fixture must be a JSON object']
|
||||
const accounts = fixture.accounts || {}
|
||||
for (const name of DEMO_ACCOUNTS) {
|
||||
const a = accounts[name]
|
||||
if (!a) {
|
||||
problems.push(`accounts.${name} is missing`)
|
||||
continue
|
||||
}
|
||||
if (typeof a.password !== 'string' || a.password.length < 8) {
|
||||
problems.push(`accounts.${name}.password must be a string of at least 8 characters`)
|
||||
}
|
||||
if (!ROLES.includes(a.role)) problems.push(`accounts.${name}.role must be one of ${ROLES.join(', ')}`)
|
||||
}
|
||||
for (const name of Object.keys(accounts)) {
|
||||
if (!DEMO_ACCOUNTS.includes(name)) {
|
||||
problems.push(`accounts.${name} is not a demo account (the lock only knows ${DEMO_ACCOUNTS.join(', ')})`)
|
||||
}
|
||||
}
|
||||
if (fixture.settings !== undefined) {
|
||||
if (!fixture.settings || typeof fixture.settings !== 'object' || Array.isArray(fixture.settings)) {
|
||||
problems.push('settings must be an object')
|
||||
} else if ('demo_state' in fixture.settings) {
|
||||
problems.push('settings.demo_state is written by the seed itself')
|
||||
}
|
||||
}
|
||||
for (const key of ['posts', 'wiki', 'pages', 'events']) {
|
||||
const list = fixture.content?.[key]
|
||||
if (list !== undefined && !Array.isArray(list)) problems.push(`content.${key} must be an array`)
|
||||
}
|
||||
if (fixture.modules !== undefined && (typeof fixture.modules !== 'object' || Array.isArray(fixture.modules))) {
|
||||
problems.push('modules must be an object keyed by module id')
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// ── The run ────────────────────────────────────────────────────────────────
|
||||
|
||||
/* eslint-disable global-require */
|
||||
async function upsertAccounts(fixture) {
|
||||
const users = require('../src/model/users/users.model')
|
||||
const ids = {}
|
||||
for (const username of DEMO_ACCOUNTS) {
|
||||
const { password, role } = fixture.accounts[username]
|
||||
const existing = await users.getRawByUsername(username)
|
||||
let id
|
||||
if (existing) {
|
||||
id = existing.id
|
||||
// A password change bumps the session cutoff, which signs every visitor out
|
||||
// of this account — the right outcome at a wipe, and on a fresh database
|
||||
// (the normal case) there is nobody to sign out.
|
||||
await users.update(id, { password, role, status: 'active', emailVerified: true })
|
||||
await users.disableTotp(id)
|
||||
} else {
|
||||
const created = await users.createUser({ username, password, role, status: 'active', emailVerified: true })
|
||||
id = created.id
|
||||
}
|
||||
ids[username] = id
|
||||
log.info(`account ${username} (${role}) ready`, { id })
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
async function writeSettings(fixture) {
|
||||
const settings = require('../src/model/settings/settings.model')
|
||||
await settings.setMany({
|
||||
site_mode: 'live',
|
||||
site_mode_changed_at: new Date().toISOString(),
|
||||
site_mode_changed_by: 'seed:demo',
|
||||
// §4: registration is closed, and the route is locked too (DM16).
|
||||
[settings.REGISTRATION_KEY]: 'disabled',
|
||||
...(fixture.settings || {}),
|
||||
})
|
||||
}
|
||||
|
||||
async function writeContent(fixture, authorId) {
|
||||
const content = fixture.content || {}
|
||||
const posts = require('../src/model/posts/posts.model')
|
||||
const wiki = require('../src/model/wiki/wiki.model')
|
||||
const pages = require('../src/model/pages/pages.model')
|
||||
const events = require('../src/model/events/eventDefinitions.model')
|
||||
|
||||
for (const p of content.posts || []) {
|
||||
const category = posts.toDbCategory(p.category || 'news')
|
||||
if (!category) throw new Error(`post "${p.title}": unknown category "${p.category}"`)
|
||||
await posts.create({ ...p, category, published: p.published !== false, author_id: authorId })
|
||||
}
|
||||
for (const w of content.wiki || []) {
|
||||
const category = w.category ? await wiki.getCategoryBySlug(w.category) : null
|
||||
if (w.category && !category) throw new Error(`wiki "${w.slug}": unknown category "${w.category}"`)
|
||||
await wiki.create({ ...w, categoryId: category ? category.id : null, updatedBy: authorId })
|
||||
}
|
||||
for (const pg of content.pages || []) {
|
||||
await pages.create(pg, authorId)
|
||||
}
|
||||
for (const e of content.events || []) {
|
||||
const { publish, ...input } = e
|
||||
const made = await events.create(input, authorId, { role: 'admin' })
|
||||
if (!made.ok) throw new Error(`event "${input.name || input.title}": ${(made.errors || []).join('; ')}`)
|
||||
if (publish) {
|
||||
const pub = await events.publish(made.id, authorId)
|
||||
if (!pub.ok) throw new Error(`event "${input.name || input.title}" did not publish: ${(pub.errors || []).join('; ')}`)
|
||||
}
|
||||
}
|
||||
log.info('content written', {
|
||||
posts: (content.posts || []).length,
|
||||
wiki: (content.wiki || []).length,
|
||||
pages: (content.pages || []).length,
|
||||
events: (content.events || []).length,
|
||||
})
|
||||
}
|
||||
|
||||
async function seedModules(fixture, accounts) {
|
||||
const modules = require('../src/modules/loader')
|
||||
const hooks = modules.demoSeedHooks()
|
||||
const wanted = fixture.modules || {}
|
||||
for (const id of Object.keys(wanted)) {
|
||||
if (!hooks.some((h) => h.id === id)) {
|
||||
throw new Error(`modules.${id}: no installed module "${id}" takes a demo seed (onDemoSeed)`)
|
||||
}
|
||||
}
|
||||
for (const { id, hook } of hooks) {
|
||||
if (wanted[id] === undefined) continue
|
||||
// The module gets its part of the fixture and the demo accounts' ids, so it
|
||||
// can link one of them to a game identity without reading core's users table.
|
||||
await hook(wanted[id], { accounts: { ...accounts } })
|
||||
log.info(`module ${id} seeded`)
|
||||
}
|
||||
}
|
||||
|
||||
async function writeState(fixture, now = new Date()) {
|
||||
const settings = require('../src/model/settings/settings.model')
|
||||
const everyHours = Number(process.env.DEMO_RESET_EVERY_HOURS) || 2
|
||||
const logins = DEMO_ACCOUNTS
|
||||
.filter((name) => fixture.accounts[name].published !== false)
|
||||
.map((name) => ({ username: name, password: fixture.accounts[name].password, role: fixture.accounts[name].role }))
|
||||
const state = { resetsAt: nextResetAt(now, everyHours).toISOString(), logins }
|
||||
await settings.set(settings.DEMO_STATE_KEY, JSON.stringify(state))
|
||||
log.info('demo state stamped', { resetsAt: state.resetsAt, logins: logins.length })
|
||||
}
|
||||
|
||||
async function run(file) {
|
||||
if (!isDemo()) throw new Error('seed:demo runs only with DEMO_MODE=true')
|
||||
if (!file) throw new Error('usage: npm run seed:demo -- <fixture.json> (or set DEMO_SEED_FILE)')
|
||||
|
||||
const fixture = resolveEnv(JSON.parse(fs.readFileSync(path.resolve(file), 'utf8')))
|
||||
const problems = checkFixture(fixture)
|
||||
if (problems.length) throw new Error(`the fixture has problems:\n - ${problems.join('\n - ')}`)
|
||||
|
||||
// The app itself, without listening: this is what loads the installed modules
|
||||
// (and core's registries, which an event definition is validated against)
|
||||
// exactly as the server does. Nothing here calls onBoot, so no module starts
|
||||
// its workers in this process.
|
||||
require('../src/app')
|
||||
const settings = require('../src/model/settings/settings.model')
|
||||
|
||||
const firstRun = !(await settings.get(settings.DEMO_STATE_KEY))
|
||||
const accounts = await upsertAccounts(fixture)
|
||||
await writeSettings(fixture)
|
||||
if (firstRun) await writeContent(fixture, accounts['demo-admin'])
|
||||
else log.info('content already seeded on this database; left as is')
|
||||
await seedModules(fixture, accounts)
|
||||
await writeState(fixture)
|
||||
}
|
||||
/* eslint-enable global-require */
|
||||
|
||||
if (require.main === module) {
|
||||
;(async () => {
|
||||
const { close } = require('../src/utils/db') // eslint-disable-line global-require
|
||||
try {
|
||||
await run(process.argv[2] || process.env.DEMO_SEED_FILE)
|
||||
log.info('demo seeded')
|
||||
} catch (err) {
|
||||
log.error(`seed:demo failed: ${err.message}`)
|
||||
process.exitCode = 1
|
||||
} finally {
|
||||
await close()
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
module.exports = { resolveEnv, nextResetAt, checkFixture, run }
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"_comment": "Generated event-trigger inventory - the authoritative freeze of CORE's engagement contract (docs/website/ENGAGEMENT.md 4.3). Regenerate with `npm run engagement:manifest` in website/server. A renamed variable, a changed type or a widened ceiling breaks stored templates and rules, so the diff here is the review signal. A module ships its own copy in its bundle; this file never contains one.",
|
||||
"moduleApiVersion": "1.17.0",
|
||||
"moduleApiVersion": "1.19.0",
|
||||
"triggers": [
|
||||
{
|
||||
"id": "event.phase.changed",
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"start": "node src/server.js",
|
||||
"dev": "nodemon src/server.js",
|
||||
"seed": "node db/seed.js",
|
||||
"seed:demo": "node db/seedDemo.js",
|
||||
"swagger": "node swagger/swagger.js",
|
||||
"routes:manifest": "node scripts/routeManifest.js",
|
||||
"engagement:manifest": "node scripts/engagementManifest.js",
|
||||
|
||||
@@ -21,6 +21,8 @@ const createLogger = require('./utils/logger')
|
||||
const htmlShell = require('./utils/htmlShell')
|
||||
const { applyTrustProxy, trustProxyDebug } = require('./utils/trustProxy')
|
||||
const botScore = require('./middleware/botScore')
|
||||
const { isDemo } = require('./demo/mode')
|
||||
const demoLock = require('./demo/locks')
|
||||
|
||||
const httpLog = createLogger('http')
|
||||
const errLog = createLogger('error')
|
||||
@@ -84,6 +86,24 @@ app.use(morgan(accessFormat, { stream: { write: (line) => httpLog.info(line.trim
|
||||
app.use(express.json({ limit: '2mb' }))
|
||||
app.use(cookieParser())
|
||||
|
||||
// ── The public demo ───────────────────────────────────────────────────
|
||||
// DEMO_MODE=true turns this deployment into demo.runicgateway.com
|
||||
// (docs/website/DEMO_MODE.md). Unset, nothing here is mounted, so a real shard
|
||||
// runs none of it. After the body parser, because one lock reads the body of
|
||||
// PUT /admin/settings; after cookieParser, because the shared-login locks read
|
||||
// the session; ahead of `/api` and of the SPA's static files (robots.txt).
|
||||
// The module locks are asked per request: the loader runs further down.
|
||||
if (isDemo()) {
|
||||
app.use(demoLock.noindex)
|
||||
app.get('/robots.txt', (req, res) => {
|
||||
// #swagger.ignore = true
|
||||
demoLock.robots(req, res)
|
||||
})
|
||||
app.use(demoLock.refuseMultipart)
|
||||
app.use(demoLock.refuseDataUri)
|
||||
app.use('/api/v1', demoLock.createLock(() => modules.demoLocks()))
|
||||
}
|
||||
|
||||
// ── Paths ─────────────────────────────────────────────────────────────
|
||||
const SERVER_ROOT = path.join(__dirname, '..')
|
||||
const REPO_ROOT = path.join(SERVER_ROOT, '..')
|
||||
|
||||
246
server/src/demo/locks.js
Normal file
246
server/src/demo/locks.js
Normal file
@@ -0,0 +1,246 @@
|
||||
// ── The demo lock (docs/website/DEMO_MODE.md §3) ───────────────────────────
|
||||
//
|
||||
// With DEMO_MODE on, app.js mounts four middlewares from this file, and with it
|
||||
// off none of them is mounted at all, so a real shard runs none of this code.
|
||||
//
|
||||
// • `refuseMultipart` — any request with a multipart body, anywhere, before a
|
||||
// router sees it (§3.3 "Uploads — locked hard"). An upload route added later
|
||||
// is locked without anyone remembering to list it, and a module cannot open one.
|
||||
// • `lock` — each write request against core's list below plus every module's
|
||||
// declared `demoLocks`, at `/api/v1`, ahead of every router.
|
||||
// • `refuseDataUri` — any write whose JSON body carries a data: URI in any
|
||||
// string (DM25): a pasted image is an upload that did not use multipart.
|
||||
// • `noindex` + `robots` — nothing a visitor writes reaches a search engine.
|
||||
//
|
||||
// **The server is the lock (DM10).** Everything here is decided from the request
|
||||
// alone, so `curl`, an old app build and a hand-edited page are refused exactly
|
||||
// like a click. The client explains a refusal; it never enforces one.
|
||||
//
|
||||
// A refusal is always the same answer, so the web client and the app can each
|
||||
// recognise it by `code` and show one sentence for it.
|
||||
|
||||
const { compile, match } = require('./paths')
|
||||
const { isDemoUsername } = require('./mode')
|
||||
|
||||
const log = require('../utils/logger')('demo')
|
||||
|
||||
const CODE = 'DEMO_LOCKED'
|
||||
const MESSAGE = 'This is turned off on the demo site.'
|
||||
const WRITES = ['POST', 'PUT', 'PATCH', 'DELETE']
|
||||
|
||||
function refuse(res, extra = {}) {
|
||||
return res.status(403).json({ message: MESSAGE, code: CODE, ...extra })
|
||||
}
|
||||
|
||||
// ── Conditions ─────────────────────────────────────────────────────────────
|
||||
// A few rows lock a route only for some requests. Each condition is asked only
|
||||
// after the method and path have matched, so the DB is touched on a handful of
|
||||
// rare admin and self-service writes and on nothing else.
|
||||
|
||||
// Required lazily: this file is required by app.js, and the users model would
|
||||
// otherwise make the DB pool a startup dependency of the lock.
|
||||
/* eslint-disable global-require */
|
||||
const users = () => require('../model/users/users.model')
|
||||
const sessions = () => require('../auth/session.service')
|
||||
/* eslint-enable global-require */
|
||||
|
||||
async function isDemoUserId(id) {
|
||||
const n = Number(id)
|
||||
if (!Number.isInteger(n) || n < 1) return false
|
||||
const user = await users().getById(n)
|
||||
return Boolean(user && isDemoUsername(user.username))
|
||||
}
|
||||
|
||||
// `PUT /admin/users/:id` and its kin: an admin acting on one of the shared logins.
|
||||
const targetIsDemo = (req, params) => isDemoUserId(params.id)
|
||||
|
||||
// `/auth/me/*`: the caller signed in as one of the shared logins. The identity is
|
||||
// read the way requireAuth reads it, and the account row decides — not the
|
||||
// username claim in the token. No session is not a refusal: the route's own
|
||||
// requireAuth answers that with its usual 401.
|
||||
async function callerIsDemo(req) {
|
||||
const session = sessions().validateSession(req)
|
||||
return Boolean(session && (await isDemoUserId(session.userId)))
|
||||
}
|
||||
|
||||
// `PUT /admin/settings` stays open (DM2), but that one route writes arbitrary
|
||||
// keys, and a few keys are another door into something Tier A locks: `site_mode`
|
||||
// is what `PUT /admin/site-mode` writes, `module_source_hosts` what the Modules
|
||||
// screen's sources do, `teams_voice_*` the voice settings. `demo_*` is the
|
||||
// banner and the published logins, which the seed owns. `hero_layout` is the
|
||||
// live homepage hero (DM21): the editor's draft (`hero_layout_draft`) and its
|
||||
// Preview stay open, its Publish is refused.
|
||||
const PROTECTED_SETTINGS = [/^site_mode/, /^module_source_hosts$/, /^teams_voice_/, /^demo_/, /^hero_layout$/]
|
||||
|
||||
function protectedKeys(body) {
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) return []
|
||||
return Object.keys(body).filter((key) => PROTECTED_SETTINGS.some((re) => re.test(key)))
|
||||
}
|
||||
|
||||
// ── Core's list (§3.3) ─────────────────────────────────────────────────────
|
||||
// Paths are relative to /api/v1 and written as the routers declare them;
|
||||
// test/demoLocks.test.js holds every row against routes.manifest.json, so a
|
||||
// row that stops naming a real route fails the build rather than reading as a lock.
|
||||
|
||||
const CORE_LOCKS = [
|
||||
// Tier A — site configuration.
|
||||
{ methods: ['PUT'], path: '/admin/site-mode' },
|
||||
{ methods: WRITES, path: '/admin/modules/*' },
|
||||
{ methods: WRITES, path: '/admin/auth/providers/*' },
|
||||
{ methods: ['PUT'], path: '/admin/email/config' },
|
||||
{ methods: ['POST'], path: '/admin/email/test' },
|
||||
{ methods: ['POST'], path: '/admin/email/disconnect' },
|
||||
{ methods: ['PUT'], path: '/admin/discord-bot/config' },
|
||||
{ methods: WRITES, path: '/admin/teams/voice/*' },
|
||||
{ methods: ['PUT'], path: '/admin/settings', when: (req) => protectedKeys(req.body).length > 0 },
|
||||
|
||||
// Tier A — the shared logins, by an admin.
|
||||
{ methods: ['PUT', 'DELETE'], path: '/admin/users/:id', when: targetIsDemo },
|
||||
{ methods: ['DELETE'], path: '/admin/users/:id/trusted-devices', when: targetIsDemo },
|
||||
{ methods: ['DELETE'], path: '/admin/users/:id/trusted-devices/:deviceId', when: targetIsDemo },
|
||||
{ methods: ['POST'], path: '/admin/users/:id/mfa/reset', when: targetIsDemo },
|
||||
|
||||
// Tier A — the shared logins, by whoever is signed in as one.
|
||||
{ methods: ['PATCH'], path: '/auth/me/account/username', when: callerIsDemo },
|
||||
{ methods: ['PATCH'], path: '/auth/me/account/password', when: callerIsDemo },
|
||||
{ methods: ['PATCH'], path: '/auth/me/account/email', when: callerIsDemo },
|
||||
{ methods: ['POST'], path: '/auth/me/account/totp/setup', when: callerIsDemo },
|
||||
{ methods: ['POST'], path: '/auth/me/account/totp/enable', when: callerIsDemo },
|
||||
{ methods: ['POST'], path: '/auth/me/account/totp/disable', when: callerIsDemo },
|
||||
{ methods: ['POST'], path: '/auth/me/account/recovery-codes/generate', when: callerIsDemo },
|
||||
{ methods: ['DELETE'], path: '/auth/me/account/identities/:provider', when: callerIsDemo },
|
||||
// DM17: the whole route, because a mobile access token carries no refresh-row
|
||||
// id and "a session that is not the caller's" cannot be decided. Signing out
|
||||
// (POST /auth/mobile/logout) revokes the caller's own row and stays open.
|
||||
{ methods: ['DELETE'], path: '/auth/me/sessions/:id', when: callerIsDemo },
|
||||
|
||||
// Tier A — credentials for everyone.
|
||||
{ methods: ['POST'], path: '/auth/password/forgot' },
|
||||
{ methods: ['POST'], path: '/auth/password/reset/:token' },
|
||||
// DM16: settings are open, so `player_registration` can be switched on; the
|
||||
// route is what keeps registration closed (§4).
|
||||
{ methods: ['POST'], path: '/auth/register' },
|
||||
|
||||
// Tier C's one exception: the contact form mails a person.
|
||||
{ methods: ['POST'], path: '/public/contact' },
|
||||
|
||||
// Uploads. `refuseMultipart` already stops every one of these; they are
|
||||
// listed too so the table in §3.3 and this file say the same thing, and so a
|
||||
// non-multipart body sent to an upload route is refused the same way.
|
||||
{ methods: ['POST'], path: '/admin/uploads' },
|
||||
{ methods: ['POST'], path: '/admin/posts/upload' },
|
||||
{ methods: ['POST'], path: '/admin/settings/brand-asset/:slot' },
|
||||
{ methods: ['POST'], path: '/player/teams/:slug/forum/uploads' },
|
||||
].map((rule) => ({ ...rule, source: 'core', compiled: compile(rule.path) }))
|
||||
|
||||
/**
|
||||
* Build the lock middleware.
|
||||
*
|
||||
* @param {() => object[]} moduleLocks returns the installed modules' compiled
|
||||
* locks — asked per request, because the loader runs after app.js mounts this.
|
||||
*/
|
||||
function createLock(moduleLocks = () => []) {
|
||||
return async function lock(req, res, next) {
|
||||
if (!WRITES.includes(req.method)) return next()
|
||||
const rules = CORE_LOCKS.concat(moduleLocks())
|
||||
for (const rule of rules) {
|
||||
if (!rule.methods.includes(req.method)) continue
|
||||
const params = match(rule.compiled, req.path)
|
||||
if (!params) continue
|
||||
if (rule.when) {
|
||||
let applies
|
||||
try {
|
||||
applies = await rule.when(req, params)
|
||||
} catch (err) {
|
||||
// Fail closed: a lock that cannot decide is a lock.
|
||||
log.warn('lock condition failed; refusing', { path: rule.path, message: err.message })
|
||||
applies = true
|
||||
}
|
||||
if (!applies) continue
|
||||
}
|
||||
log.info('refused', { method: req.method, path: req.originalUrl, rule: rule.path, source: rule.source })
|
||||
const keys = rule.path === '/admin/settings' ? { keys: protectedKeys(req.body) } : {}
|
||||
return refuse(res, keys)
|
||||
}
|
||||
return next()
|
||||
}
|
||||
}
|
||||
|
||||
// Any multipart content type, not only form-data: nothing on the demo takes one,
|
||||
// and a narrower test is one more spelling to get wrong. The header alone
|
||||
// decides, so a multipart request with no body is refused too.
|
||||
function refuseMultipart(req, res, next) {
|
||||
const type = String(req.headers['content-type'] || '').trim().toLowerCase()
|
||||
if (!type.startsWith('multipart/')) return next()
|
||||
log.info('refused upload', { method: req.method, path: req.originalUrl })
|
||||
return refuse(res)
|
||||
}
|
||||
|
||||
// DM25: an image pasted into rich text travels inside the JSON body as a data:
|
||||
// URI, which is an upload by another name. Any string anywhere in a write's
|
||||
// body is checked, so a field added later, a setting or a module's route is
|
||||
// covered without being listed, as with multipart.
|
||||
//
|
||||
// A data: URI is `data:` (not the tail of a word like "metadata:") followed by
|
||||
// a comma, a `;`, or a type/subtype and then a comma. Prose such as
|
||||
// "Data: none, yet" does not match. Before testing, HTML character references
|
||||
// are decoded and tabs and newlines removed, because a browser does both when
|
||||
// it reads an attribute: `data:` and `da<newline>ta:` are still data: URIs.
|
||||
const DATA_URI = /(?<![a-z0-9+.-])data:\s*(?:[,;]|[a-z]+\/[a-z0-9.+-]+[^,<>"']{0,256},)/i
|
||||
|
||||
function browserReading(s) {
|
||||
return s
|
||||
.replace(/&#x([0-9a-f]+);?/gi, (m, hex) => safeChar(parseInt(hex, 16), m))
|
||||
.replace(/&#(\d+);?/g, (m, dec) => safeChar(parseInt(dec, 10), m))
|
||||
.replace(/:/gi, ':')
|
||||
.replace(/&(?:tab|newline);/gi, '')
|
||||
.replace(/[\t\n\r]/g, '')
|
||||
}
|
||||
|
||||
function safeChar(code, original) {
|
||||
return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : original
|
||||
}
|
||||
|
||||
function hasDataUri(body) {
|
||||
const stack = [body]
|
||||
while (stack.length) {
|
||||
const value = stack.pop()
|
||||
if (typeof value === 'string') {
|
||||
if (value.length > 5 && DATA_URI.test(browserReading(value))) return true
|
||||
} else if (value && typeof value === 'object') {
|
||||
for (const v of Object.values(value)) stack.push(v)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function refuseDataUri(req, res, next) {
|
||||
if (!WRITES.includes(req.method) || !hasDataUri(req.body)) return next()
|
||||
log.info('refused data: URI', { method: req.method, path: req.originalUrl })
|
||||
return refuse(res)
|
||||
}
|
||||
|
||||
function noindex(req, res, next) {
|
||||
res.set('X-Robots-Tag', 'noindex, nofollow')
|
||||
next()
|
||||
}
|
||||
|
||||
function robots(req, res) {
|
||||
res.type('text/plain').send('User-agent: *\nDisallow: /\n')
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CODE,
|
||||
MESSAGE,
|
||||
WRITES,
|
||||
CORE_LOCKS,
|
||||
PROTECTED_SETTINGS,
|
||||
createLock,
|
||||
refuseMultipart,
|
||||
refuseDataUri,
|
||||
hasDataUri,
|
||||
noindex,
|
||||
robots,
|
||||
refuse,
|
||||
protectedKeys,
|
||||
}
|
||||
26
server/src/demo/mode.js
Normal file
26
server/src/demo/mode.js
Normal file
@@ -0,0 +1,26 @@
|
||||
// ── Demo mode: the flag and the four shared accounts ───────────────────────
|
||||
//
|
||||
// `DEMO_MODE=true` on a deployment turns it into the public demo
|
||||
// (docs/website/DEMO_MODE.md). Read per call rather than once at require time,
|
||||
// so a test can flip it without re-requiring every file that asks — and so
|
||||
// there is exactly one spelling of "is this the demo" in the codebase.
|
||||
//
|
||||
// Only the literal string `true` turns it on. A stray `DEMO_MODE=1` or `yes` on
|
||||
// a real shard must not lock its admin panel, and the opposite mistake — a demo
|
||||
// that forgot the flag — is caught by `seed:demo`, which refuses to run without it.
|
||||
|
||||
function isDemo() {
|
||||
return process.env.DEMO_MODE === 'true'
|
||||
}
|
||||
|
||||
// The static logins (DEMO_MODE.md §4). Fixed here, in core, rather than read
|
||||
// from the seed fixture: the lock has to recognise them on every request, and a
|
||||
// fixture that dropped one would quietly unlock the account everybody shares.
|
||||
// The seed creates exactly these; their passwords come from the fixture.
|
||||
const DEMO_ACCOUNTS = Object.freeze(['demo-admin', 'demo-moderator', 'demo-player', 'play-review'])
|
||||
|
||||
function isDemoUsername(username) {
|
||||
return typeof username === 'string' && DEMO_ACCOUNTS.includes(username.toLowerCase())
|
||||
}
|
||||
|
||||
module.exports = { isDemo, DEMO_ACCOUNTS, isDemoUsername }
|
||||
70
server/src/demo/paths.js
Normal file
70
server/src/demo/paths.js
Normal file
@@ -0,0 +1,70 @@
|
||||
// ── Lock patterns: `:param` segments and a trailing `*` ────────────────────
|
||||
//
|
||||
// The demo's lock list names routes the way the routers declare them —
|
||||
// `/admin/users/:id` — so a reviewer can hold the list against the route
|
||||
// manifest line by line. A trailing `/*` means "this path and everything under
|
||||
// it", for the areas DEMO_MODE.md §3.3 locks as a whole ("every write under
|
||||
// /admin/modules").
|
||||
//
|
||||
// The matcher reproduces the two express 4 routing defaults that decide whether
|
||||
// a request reaches a handler at all: matching is case-insensitive, and a
|
||||
// trailing slash is optional. A lock that was stricter than the router about
|
||||
// either would let `PUT /Admin/Site-Mode/` through to a handler that answers it.
|
||||
|
||||
const SEGMENT = /^(?::[A-Za-z][A-Za-z0-9]*|[A-Za-z0-9._-]+)$/
|
||||
|
||||
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
|
||||
/**
|
||||
* Compile a lock pattern.
|
||||
*
|
||||
* @param {string} pattern e.g. `/admin/users/:id` or `/admin/modules/*`
|
||||
* @returns {{pattern: string, names: string[], re: RegExp}}
|
||||
* @throws on a pattern that is not absolute, has an empty segment, or puts `*`
|
||||
* anywhere but last — a lock that silently compiled to something else would
|
||||
* be worse than no lock, because it would read as one.
|
||||
*/
|
||||
function compile(pattern) {
|
||||
if (typeof pattern !== 'string' || !pattern.startsWith('/')) {
|
||||
throw new Error(`lock path "${pattern}" must start with /`)
|
||||
}
|
||||
const segs = pattern.slice(1).split('/')
|
||||
const names = []
|
||||
let source = ''
|
||||
segs.forEach((seg, i) => {
|
||||
if (seg === '*') {
|
||||
if (i !== segs.length - 1) throw new Error(`lock path "${pattern}": * must be the last segment`)
|
||||
source += '(?:/.*)?'
|
||||
return
|
||||
}
|
||||
if (!SEGMENT.test(seg)) throw new Error(`lock path "${pattern}": bad segment "${seg}"`)
|
||||
if (seg.startsWith(':')) {
|
||||
names.push(seg.slice(1))
|
||||
source += '/([^/]+)'
|
||||
} else {
|
||||
source += `/${escape(seg)}`
|
||||
}
|
||||
})
|
||||
return { pattern, names, re: new RegExp(`^${source}/?$`, 'i') }
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a request path against a compiled pattern.
|
||||
*
|
||||
* @returns {Record<string,string>|null} the decoded params, or null
|
||||
*/
|
||||
function match(compiled, path) {
|
||||
const m = compiled.re.exec(path)
|
||||
if (!m) return null
|
||||
const params = {}
|
||||
compiled.names.forEach((name, i) => {
|
||||
try {
|
||||
params[name] = decodeURIComponent(m[i + 1])
|
||||
} catch {
|
||||
params[name] = m[i + 1]
|
||||
}
|
||||
})
|
||||
return params
|
||||
}
|
||||
|
||||
module.exports = { compile, match }
|
||||
@@ -29,6 +29,8 @@ const spec = require('../../events/spec')
|
||||
const authorize = require('../../events/authorize')
|
||||
const verifier = require('../../events/verify')
|
||||
const registries = require('../../modules/registries')
|
||||
const { isDemo } = require('../../demo/mode')
|
||||
const { CODE: DEMO_LOCKED } = require('../../demo/locks')
|
||||
const { slugify, uniqueSlug } = require('../teams/teamSlug')
|
||||
const { cleanBody } = require('../../utils/sanitizeHtml')
|
||||
const log = require('../../utils/logger')('events')
|
||||
@@ -181,6 +183,8 @@ async function create(input, userId, { role = null } = {}) {
|
||||
if (!result.ok) return result
|
||||
const floor = checkRoleFloor(result.definition.spec, role)
|
||||
if (floor) return floor
|
||||
const refused = await checkDemoRefusals(result.definition.spec)
|
||||
if (refused) return refused
|
||||
const id = await db.insert({ ...result.definition, created_by: userId })
|
||||
return { ok: true, id, definition: await db.getById(id) }
|
||||
}
|
||||
@@ -203,6 +207,8 @@ async function save(id, input, userId, { role = null } = {}) {
|
||||
if (!result.ok) return result
|
||||
const floor = checkRoleFloor(result.definition.spec, role)
|
||||
if (floor) return floor
|
||||
const refused = await checkDemoRefusals(result.definition.spec)
|
||||
if (refused) return refused
|
||||
await db.update(id, { ...result.definition, updated_by: userId })
|
||||
return { ok: true, id, definition: await db.getById(id) }
|
||||
}
|
||||
@@ -228,6 +234,11 @@ async function publish(id, userId) {
|
||||
})
|
||||
if (!checked.ok) return { ok: false, status: 400, errors: checked.errors }
|
||||
|
||||
// Asked again here, as the registries are: a draft saved before the module's
|
||||
// list grew must not become a version that runs (DM18).
|
||||
const refused = await checkDemoRefusals(checked.spec)
|
||||
if (refused) return refused
|
||||
|
||||
const publishable = spec.publishable(checked.spec)
|
||||
if (!publishable.ok) {
|
||||
return {
|
||||
@@ -365,6 +376,37 @@ function checkRoleFloor(specValue, role) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The public demo's save-time refusal (MODULE_API 1.19.0, DEMO_MODE.md §3.4,
|
||||
* DM18): each step whose action declares `demoRefuses(params)` is asked, and
|
||||
* the first sentence back refuses the whole save `403 DEMO_LOCKED`. Null off
|
||||
* the demo, where nothing is asked.
|
||||
*
|
||||
* A hook that throws refuses too, with core's sentence: this is a lock, and a
|
||||
* lock that cannot decide fails closed, as the route lock does (§3.3).
|
||||
*/
|
||||
async function checkDemoRefusals(specValue) {
|
||||
if (!isDemo()) return null
|
||||
for (const phase of specValue?.phases || []) {
|
||||
for (const step of phase.steps || []) {
|
||||
const action = registries.eventAction(step.actionId)
|
||||
if (!action || !action.demoRefuses) continue
|
||||
let said
|
||||
try {
|
||||
said = await action.demoRefuses(step.params || {})
|
||||
} catch (err) {
|
||||
log.error('demoRefuses failed', { action: action.id, message: err.message })
|
||||
said = `"${action.label}" could not be checked, so it is turned off on the demo site.`
|
||||
}
|
||||
if (typeof said === 'string' && said.trim()) {
|
||||
const message = said.trim()
|
||||
return { ok: false, status: 403, code: DEMO_LOCKED, message, errors: [message] }
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Dry-run a definition, and record the pass when there is a version to record it
|
||||
* on (Phase 6).
|
||||
@@ -545,6 +587,7 @@ module.exports = {
|
||||
archive,
|
||||
verify,
|
||||
checkRoleFloor,
|
||||
checkDemoRefusals,
|
||||
isTimezone,
|
||||
MIN_GRACE_SECONDS,
|
||||
MAX_GRACE_SECONDS,
|
||||
|
||||
@@ -3,6 +3,7 @@ const brand = require('../../config/brand')
|
||||
const { parseJsonSetting } = require('../../utils/settingsJson')
|
||||
const { resolveThemeTokens } = require('../../utils/themeResolve')
|
||||
const { resolveBrandAssets } = require('../../utils/brandAssets')
|
||||
const { isDemo } = require('../../demo/mode')
|
||||
|
||||
// Keys safe to expose on the public site.
|
||||
const PUBLIC_KEYS = [
|
||||
@@ -215,9 +216,30 @@ async function getPublic() {
|
||||
// before doing so (the server would otherwise reject an unallowlisted one). The
|
||||
// custom-scheme callback works regardless of this flag.
|
||||
out.mobileAppLinks = String(all[MOBILE_APP_LINKS_KEY]) === 'true'
|
||||
// The public demo's banner and sign-in panel (DEMO_MODE.md §4, §6). Present
|
||||
// only with DEMO_MODE on, so a real shard's payload is unchanged. Written by
|
||||
// `seed:demo` at every wipe; the demo lock keeps `demo_*` out of PUT
|
||||
// /admin/settings, so a visitor cannot rewrite the published passwords.
|
||||
if (isDemo()) out.demo = demoBlock(all[DEMO_STATE_KEY])
|
||||
return out
|
||||
}
|
||||
|
||||
// `demo_state` is one JSON object: `{ resetsAt, logins: [{ username, password,
|
||||
// role }] }`. The passwords are public by design (§4) — the logins are printed
|
||||
// on the sign-in page and on runicgateway.com/beta. Read forgivingly: a missing
|
||||
// or broken row is a banner with no time and no logins, never a 500.
|
||||
const DEMO_STATE_KEY = 'demo_state'
|
||||
function demoBlock(raw) {
|
||||
const state = parseJsonSetting(raw) || {}
|
||||
const resetsAt = typeof state.resetsAt === 'string' && !Number.isNaN(Date.parse(state.resetsAt))
|
||||
? state.resetsAt
|
||||
: null
|
||||
const logins = (Array.isArray(state.logins) ? state.logins : [])
|
||||
.filter((l) => l && typeof l.username === 'string' && typeof l.password === 'string')
|
||||
.map((l) => ({ username: l.username, password: l.password, role: typeof l.role === 'string' ? l.role : null }))
|
||||
return { resetsAt, logins }
|
||||
}
|
||||
|
||||
/**
|
||||
* What the HTML shell needs, resolved exactly as getPublic() resolves it: the
|
||||
* effective favicon and logo, plus the theme token map for the boot <style>
|
||||
@@ -285,4 +307,5 @@ module.exports = {
|
||||
registrationFlags,
|
||||
MOBILE_APP_LINKS_KEY,
|
||||
isMobileAppLinksEnabled,
|
||||
DEMO_STATE_KEY,
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ async function syncStatus() {
|
||||
configured: true,
|
||||
lastSyncAt,
|
||||
// Never synced at all is stale: a page must not present an empty projection
|
||||
// as a confirmed empty shard.
|
||||
// as a confirmed empty one.
|
||||
stale: ageS > intervalS * STALE_INTERVALS,
|
||||
consecutiveFailures: state ? state.consecutive_failures : 0,
|
||||
}
|
||||
|
||||
@@ -41,6 +41,8 @@ const { MODULE_API_VERSION } = require('./version')
|
||||
const semver = require('./semver')
|
||||
const registries = require('./registries')
|
||||
const { splitStatements } = require('../utils/sqlStatements')
|
||||
const demoPaths = require('../demo/paths')
|
||||
const { isDemo } = require('../demo/mode')
|
||||
|
||||
const log = require('../utils/logger')('modules')
|
||||
|
||||
@@ -62,9 +64,15 @@ const TIERS = ['public', 'admin', 'player']
|
||||
|
||||
const MANIFEST_KEYS = new Set([
|
||||
'id', 'name', 'version', 'coreApi', 'server', 'client',
|
||||
'schema', 'purge', 'mounts', 'extensions', 'capabilities',
|
||||
'schema', 'purge', 'mounts', 'extensions', 'capabilities', 'demoLocks',
|
||||
])
|
||||
|
||||
// What a `demoLocks` entry may lock (API 1.18.0, DEMO_MODE.md §3.2): writes
|
||||
// only. Reads are never locked by declaration — the one read the demo refuses
|
||||
// (a plugin config that holds credentials) depends on WHICH file is asked for,
|
||||
// so the module decides it behind `ctx.demoMode` rather than by route.
|
||||
const DEMO_LOCK_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE']
|
||||
|
||||
// Extension slots are declared by core, at require time, in the router that owns
|
||||
// the resource (registries.declareSlot). The loader asks the registry which exist
|
||||
// rather than keeping a list, for the same reason the prefix check probes the
|
||||
@@ -338,6 +346,12 @@ function buildCtx(id, moduleRoot) {
|
||||
// configuration, so core answers it. A getter, not a captured string: the
|
||||
// value is read per call, so it cannot go stale against the env.
|
||||
site: { get baseUrl() { return (process.env.APP_BASE_URL || 'http://localhost:5173').replace(/\/+$/, '') } },
|
||||
// API 1.18.0 (DEMO_MODE.md §3.4, §3.5). True on the public demo. For the
|
||||
// locks a route list cannot express — a console command by its first word,
|
||||
// a config file by which plugin owns it — which the module enforces in its
|
||||
// own handlers. A boolean, read once: the flag is the deployment's, and a
|
||||
// deployment does not change what it is while it runs.
|
||||
demoMode: isDemo(),
|
||||
}
|
||||
// A guard against accident, not against a hostile module — the boundary is
|
||||
// organisational, not a security boundary (MODULE_SYSTEM.md §2.2).
|
||||
@@ -503,6 +517,12 @@ function buildApi(record) {
|
||||
// nothing to undo when a sign-in identity goes.
|
||||
onIdentityLinking: hook('onIdentityLinking'),
|
||||
onIdentityLinked: hook('onIdentityLinked'),
|
||||
// API 1.18.0 (DEMO_MODE.md §7.3, DM15). Core's `seed:demo` handing the
|
||||
// module its part of the seed fixture, so the module writes its own rows —
|
||||
// a game server link, a player's game link — through its own model. Core
|
||||
// never writes a module's tables. Called from the seed script only, never
|
||||
// by the running server, and only with DEMO_MODE on.
|
||||
onDemoSeed: hook('onDemoSeed'),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -741,6 +761,8 @@ function readManifest(dir, id, tierRouters) {
|
||||
if (!registries.hasSlot(slot)) fail('extensions', `unknown extension slot "${slot}"`)
|
||||
}
|
||||
|
||||
checkDemoLocks(manifest)
|
||||
|
||||
if (manifest.client !== undefined) {
|
||||
if (typeof manifest.client !== 'object' || manifest.client === null || Array.isArray(manifest.client)) {
|
||||
fail('manifest', 'client must be an object')
|
||||
@@ -761,6 +783,40 @@ function readManifest(dir, id, tierRouters) {
|
||||
return manifest
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate `demoLocks` (API 1.18.0, DEMO_MODE.md §3.2), on every boot and not
|
||||
* only on the demo: a module whose declaration is wrong should find out on the
|
||||
* developer's machine, not on the one deployment where it is read.
|
||||
*
|
||||
* Each entry is `{ method, path }`, the path relative to /api/v1 and under one
|
||||
* of the module's own declared mounts — a module may lock its own routes and
|
||||
* nobody else's, the same boundary `mounts` draws for serving them.
|
||||
*/
|
||||
function checkDemoLocks(manifest) {
|
||||
if (manifest.demoLocks === undefined) return
|
||||
if (!Array.isArray(manifest.demoLocks)) fail('manifest', 'demoLocks must be an array')
|
||||
for (const entry of manifest.demoLocks) {
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
||||
fail('manifest', 'each demoLocks entry must be an object')
|
||||
}
|
||||
for (const key of Object.keys(entry)) {
|
||||
if (key !== 'method' && key !== 'path') fail('manifest', `unknown key "demoLocks.${key}" in module.json`)
|
||||
}
|
||||
if (!DEMO_LOCK_METHODS.includes(entry.method)) {
|
||||
fail('manifest', `demoLocks method "${entry.method}" must be one of ${DEMO_LOCK_METHODS.join(', ')}`)
|
||||
}
|
||||
try {
|
||||
demoPaths.compile(entry.path)
|
||||
} catch (err) {
|
||||
fail('manifest', `demoLocks: ${err.message}`)
|
||||
}
|
||||
const [, tier, prefix] = entry.path.split('/')
|
||||
if (!(manifest.mounts?.[tier] || []).includes(`/${prefix}`)) {
|
||||
fail('mounts', `demoLocks path ${entry.path} is outside the module's mounts`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// What the module registered must equal what it declared — in both directions.
|
||||
function checkDeclared(record) {
|
||||
const declared = record.manifest.mounts || {}
|
||||
@@ -841,7 +897,9 @@ function load(tierRouters) {
|
||||
staged: registries.stage(id),
|
||||
tables: new Set(),
|
||||
called: new Set(),
|
||||
hooks: { onBoot: null, onShutdown: null, onIdentityLinking: null, onIdentityLinked: null },
|
||||
hooks: {
|
||||
onBoot: null, onShutdown: null, onIdentityLinking: null, onIdentityLinked: null, onDemoSeed: null,
|
||||
},
|
||||
client: null,
|
||||
ctx: null,
|
||||
state: 'installed',
|
||||
@@ -1128,6 +1186,55 @@ function identityHooks(name) {
|
||||
.map((r) => ({ id: r.id, hook: r.hooks[name] }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Every installed module's `demoLocks`, compiled, for the demo lock in
|
||||
* demo/locks.js (API 1.18.0).
|
||||
*
|
||||
* Every module whose manifest validated, whatever its state. A disabled or
|
||||
* failed module's routes already answer 404 or 503, so its locks change
|
||||
* nothing — and a lock that disappeared whenever its module was between states
|
||||
* is a lock with a gap in it. Empty before load() has run, rather than throwing:
|
||||
* the middleware is mounted before the loader and asks per request.
|
||||
*
|
||||
* @returns {{methods: string[], path: string, compiled: object, source: string}[]}
|
||||
*/
|
||||
let demoLockCache = null
|
||||
function demoLocks() {
|
||||
if (!loaded) return []
|
||||
// Manifests are read once, in load(), which is synchronous — so the answer
|
||||
// cannot change after the first request asks, and is compiled once.
|
||||
if (demoLockCache) return demoLockCache
|
||||
const out = []
|
||||
for (const record of modules.values()) {
|
||||
for (const entry of record.manifest?.demoLocks || []) {
|
||||
out.push({
|
||||
methods: [entry.method],
|
||||
path: entry.path,
|
||||
compiled: demoPaths.compile(entry.path),
|
||||
source: record.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
demoLockCache = out
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* The `onDemoSeed` hooks, for `npm run seed:demo` (API 1.18.0, DM15).
|
||||
*
|
||||
* `registered` or `started`: the seed is its own process, where nothing calls
|
||||
* onBoot, so its modules never get past `registered`. A module that failed to
|
||||
* load is not handed a fixture for tables it may not have.
|
||||
*
|
||||
* @returns {{id: string, hook: Function, ctx: object}[]}
|
||||
*/
|
||||
function demoSeedHooks() {
|
||||
assertLoaded('demoSeedHooks')
|
||||
return [...modules.values()]
|
||||
.filter((r) => (r.state === 'registered' || r.state === 'started') && r.hooks.onDemoSeed)
|
||||
.map((r) => ({ id: r.id, hook: r.hooks.onDemoSeed, ctx: r.ctx }))
|
||||
}
|
||||
|
||||
/**
|
||||
* One module's `onShutdown`, for stopping it on its own rather than at exit.
|
||||
*
|
||||
@@ -1254,6 +1361,8 @@ module.exports = {
|
||||
bootable,
|
||||
shutdownHooks,
|
||||
identityHooks,
|
||||
demoLocks,
|
||||
demoSeedHooks,
|
||||
stopHook,
|
||||
clientChunks,
|
||||
clientEntryUrls,
|
||||
|
||||
@@ -445,14 +445,14 @@ async function resolveAudience(id, params = {}) {
|
||||
/**
|
||||
* Every declaration WITHOUT its callables — what the admin catalog serves.
|
||||
*
|
||||
* `perform`, `revert`, `reconcile`, `cost` and `progress` are stripped for the same reason `resolve` is
|
||||
* `perform`, `revert`, `reconcile`, `cost`, `progress` and `demoRefuses` are stripped for the same reason `resolve` is
|
||||
* stripped from an audience and `handler` from a slash command: this is the
|
||||
* object that leaves the process, and the browser's whole relationship with an
|
||||
* action is naming one by id. §F's "a module registers actions server-side and
|
||||
* adds no routes for them" is only true if the functions never ride out.
|
||||
*/
|
||||
const allEventActions = () =>
|
||||
[...eventActions.values()].map(({ perform, revert, reconcile, cost, progress, ...rest }) => rest)
|
||||
[...eventActions.values()].map(({ perform, revert, reconcile, cost, progress, demoRefuses, ...rest }) => rest)
|
||||
|
||||
/** One declaration, callables included. The runner's lookup (Phase 2). */
|
||||
const eventAction = (id) => eventActions.get(id) || null
|
||||
@@ -1128,7 +1128,7 @@ function checkActionParam(actionId, entry, seen, call = 'registerEventActions')
|
||||
}
|
||||
|
||||
/**
|
||||
* `registerEventActions([{ id, label, risk, reversible, version, budgetMs, cost, params, perform, revert, reconcile, progress }])`.
|
||||
* `registerEventActions([{ id, label, risk, reversible, version, budgetMs, cost, params, perform, revert, reconcile, progress, demoRefuses }])`.
|
||||
*
|
||||
* A typed verb core may ask a registrant to carry out. Everything decidable from
|
||||
* the argument alone is decided here, at the call; the collision — is this id
|
||||
@@ -1211,6 +1211,13 @@ function checkEventActionShape(entry) {
|
||||
if (a.progress !== undefined && typeof a.progress !== 'function') {
|
||||
throw new Error(`registerEventActions: ${a.id} progress must be a function`)
|
||||
}
|
||||
// 1.19.0, optional: the public demo's save-time refusal (DEMO_MODE.md §3.4,
|
||||
// DM18). Asked only with DEMO_MODE on, by the definition save path; a
|
||||
// sentence back refuses the save `403 DEMO_LOCKED`. The module still checks
|
||||
// at run, because a definition can predate the list it is checked against.
|
||||
if (a.demoRefuses !== undefined && typeof a.demoRefuses !== 'function') {
|
||||
throw new Error(`registerEventActions: ${a.id} demoRefuses must be a function of its params`)
|
||||
}
|
||||
|
||||
const version = a.version === undefined ? 1 : a.version
|
||||
if (!Number.isInteger(version) || version < 1) {
|
||||
@@ -1244,6 +1251,7 @@ function checkEventActionShape(entry) {
|
||||
revert: a.revert || null,
|
||||
reconcile: a.reconcile || null,
|
||||
progress: a.progress || null,
|
||||
demoRefuses: a.demoRefuses || null,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,29 @@
|
||||
// Deliberately separate from PROTOCOL_VERSION (which versions the shard wire and
|
||||
// has nothing to say about a website module) and from any module's own version.
|
||||
|
||||
// 1.19.0 — the public demo refuses a step at SAVE (docs/website/DEMO_MODE.md
|
||||
// §3.4, DM18). An event action may declare `demoRefuses(params)`, returning a
|
||||
// sentence or null. With DEMO_MODE on, core asks it for every step when a
|
||||
// definition is created, saved or published, and a sentence refuses the whole
|
||||
// write `403 DEMO_LOCKED` with that sentence as `message`; a hook that throws
|
||||
// refuses too. Off the demo it is validated and never called. One optional
|
||||
// action member, so minor. Module-uo declares `^1.10.0` and declares none.
|
||||
// Module-Rust's console-command deny list is the first user.
|
||||
//
|
||||
// 1.18.0 — the public demo (docs/website/DEMO_MODE.md §3.2, §3.4, §7.3; website#222).
|
||||
// Three additions, each optional:
|
||||
// • `demoLocks` in module.json — `[{ method, path }]`, writes only, each path
|
||||
// under one of the module's own mounts. With DEMO_MODE on, core answers a
|
||||
// matching request `403 DEMO_LOCKED` before the module's handler runs; with
|
||||
// it off the list is validated and otherwise unread;
|
||||
// • `ctx.demoMode` — true on the demo, for the locks a route cannot express,
|
||||
// which the module enforces in its own handlers;
|
||||
// • `api.onDemoSeed(fn)` — `npm run seed:demo` hands the module its part of
|
||||
// the seed fixture (DM15), so it writes its own rows.
|
||||
// Additions only, so minor. Module-uo declares `^1.10.0` and uses none of them.
|
||||
// A module that DECLARES `demoLocks` needs `^1.18.0`, because older cores refuse
|
||||
// a manifest key they do not know.
|
||||
//
|
||||
// 1.17.0 — a module may take part in linking an external sign-in identity
|
||||
// (docs/website/STEAM_SIGNIN.md §4; Rust PLAN_REDESIGNS §15, D392 and D396).
|
||||
// Three additions, each optional:
|
||||
@@ -231,6 +254,6 @@
|
||||
// an admin action a module performs belongs in core's one audit log, the
|
||||
// extension slot needs the user its prefix names, and §2.7 forbids a module
|
||||
// reading core's `APP_BASE_URL` for itself. Additions only, so minor.
|
||||
const MODULE_API_VERSION = '1.17.0'
|
||||
const MODULE_API_VERSION = '1.19.0'
|
||||
|
||||
module.exports = { MODULE_API_VERSION }
|
||||
|
||||
@@ -42,6 +42,16 @@ const asId = (raw) => {
|
||||
return Number.isInteger(n) && n > 0 ? n : null
|
||||
}
|
||||
|
||||
/**
|
||||
* A refused save's body: the errors, plus `code` and `message` when the model
|
||||
* set them. The demo's step refusal does (DM18), so the client's one
|
||||
* `DEMO_LOCKED` toast fires as it does for a route lock.
|
||||
*/
|
||||
const refusal = (result) => ({
|
||||
errors: result.errors,
|
||||
...(result.code ? { code: result.code, message: result.message } : {}),
|
||||
})
|
||||
|
||||
/**
|
||||
* The shape a definition takes on the wire.
|
||||
*
|
||||
@@ -413,7 +423,7 @@ exports.listVersions = async (req, res) => {
|
||||
/** POST /api/v1/admin/events */
|
||||
exports.create = async (req, res) => {
|
||||
const result = await definitions.create(req.body, req.user.id, { role: req.user.role })
|
||||
if (!result.ok) return res.status(result.status || 400).json({ errors: result.errors })
|
||||
if (!result.ok) return res.status(result.status || 400).json(refusal(result))
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'event.definition.created',
|
||||
@@ -427,7 +437,7 @@ exports.update = async (req, res) => {
|
||||
const id = asId(req.params.id)
|
||||
if (!id) return res.status(400).json({ error: 'bad event id' })
|
||||
const result = await definitions.save(id, req.body, req.user.id, { role: req.user.role })
|
||||
if (!result.ok) return res.status(result.status || 400).json({ errors: result.errors })
|
||||
if (!result.ok) return res.status(result.status || 400).json(refusal(result))
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'event.definition.updated',
|
||||
@@ -441,7 +451,7 @@ exports.publish = async (req, res) => {
|
||||
const id = asId(req.params.id)
|
||||
if (!id) return res.status(400).json({ error: 'bad event id' })
|
||||
const result = await definitions.publish(id, req.user.id)
|
||||
if (!result.ok) return res.status(result.status || 400).json({ errors: result.errors })
|
||||
if (!result.ok) return res.status(result.status || 400).json(refusal(result))
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'event.definition.published',
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"info": {
|
||||
"title": "Runic Gateway API",
|
||||
"version": "1.0.0",
|
||||
"description": "REST API for the Runic Gateway website, wiki and admin panel.\n\nThis document is core. Installed modules add their own paths, tags and schemas to it at request time from the fragment each one ships, so `/api/docs.json` on a running instance describes more than `npm run swagger` generates here (docs/website/MODULE_API.md §6.1a).\n\n### Authentication\n- **Web / admin panel** uses an httpOnly session cookie (`rg_token`) issued by `POST /api/v1/auth/login` (plus `/login/totp` when 2FA is enabled).\n- **Native / mobile clients** use bearer access tokens from `POST /api/v1/auth/mobile/login`, refreshed via `/auth/mobile/refresh`.\n\nEndpoints under `/api/v1/admin/**` require a valid session; some are further restricted to the `admin` role (editors are limited to content)."
|
||||
"description": "REST API for the Runic Gateway website, wiki and admin panel.\n\nThis document is core. Installed modules add their own paths, tags and schemas to it at request time from the fragment each one ships, so `/api/docs.json` on a running instance describes more than `npm run swagger` generates here (docs/website/MODULE_API.md §6.1a).\n\n### Authentication\n- **Web / admin panel** uses an httpOnly session cookie (`rg_token`) issued by `POST /api/v1/auth/login` (plus `/login/totp` when 2FA is enabled).\n- **Native / mobile clients** use bearer access tokens from `POST /api/v1/auth/mobile/login`, refreshed via `/auth/mobile/refresh`.\n\nEndpoints under `/api/v1/admin/**` require a valid session; some are further restricted to the `admin` role (editors are limited to content).\n\n### The public demo\nA deployment run with `DEMO_MODE=true` (the public demo, docs/website/DEMO_MODE.md) refuses a fixed set of write routes, and any `multipart/*` request, before the route runs. **Any write route may then answer** `403` with the `DemoLocked` body (`code: \"DEMO_LOCKED\"`); clients recognise the refusal by `code`, not by status. Reads are never refused this way. With the flag unset no route answers it."
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
@@ -18544,6 +18544,119 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"DemoLocked": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "The public demo refused a write (DEMO_MODE.md §3). Only on a deployment with `DEMO_MODE=true`. `keys` is present on `PUT /admin/settings` and names the settings the demo keeps. `errors` is present when an event save or publish is refused because a step's action refused its params (MODULE_API 1.19.0 `demoRefuses`, DM18); `message` is then the module's sentence."
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "This is turned off on the demo site."
|
||||
}
|
||||
}
|
||||
},
|
||||
"code": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"enum": {
|
||||
"type": "array",
|
||||
"example": [
|
||||
"DEMO_LOCKED"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"keys": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "array"
|
||||
},
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"type": "array",
|
||||
"example": [
|
||||
"site_mode"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "array"
|
||||
},
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"type": "array",
|
||||
"example": [
|
||||
"\"quit\" is turned off on the demo site."
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": {
|
||||
"type": "array",
|
||||
"example": [
|
||||
"message",
|
||||
"code"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ValidationError": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -25600,6 +25713,111 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"demo": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Present only on the public demo (`DEMO_MODE=true`, DEMO_MODE.md §4, §6). `resetsAt` is when the next wipe is due, for the banner; `logins` are the published static sign-ins — public by design, not secrets. Both are written by `npm run seed:demo` at every wipe."
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"resetsAt": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"format": {
|
||||
"type": "string",
|
||||
"example": "date-time"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "2026-10-24T14:00:00.000Z"
|
||||
}
|
||||
}
|
||||
},
|
||||
"logins": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "array"
|
||||
},
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"username": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "demo-admin"
|
||||
}
|
||||
}
|
||||
},
|
||||
"password": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "demo-admin"
|
||||
}
|
||||
}
|
||||
},
|
||||
"role": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "admin"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -44,7 +44,13 @@ const doc = {
|
||||
'- **Native / mobile clients** use bearer access tokens from ' +
|
||||
'`POST /api/v1/auth/mobile/login`, refreshed via `/auth/mobile/refresh`.\n\n' +
|
||||
'Endpoints under `/api/v1/admin/**` require a valid session; some are further ' +
|
||||
'restricted to the `admin` role (editors are limited to content).',
|
||||
'restricted to the `admin` role (editors are limited to content).\n\n' +
|
||||
'### The public demo\n' +
|
||||
'A deployment run with `DEMO_MODE=true` (the public demo, docs/website/DEMO_MODE.md) ' +
|
||||
'refuses a fixed set of write routes, and any `multipart/*` request, before the ' +
|
||||
'route runs. **Any write route may then answer** `403` with the `DemoLocked` body ' +
|
||||
'(`code: "DEMO_LOCKED"`); clients recognise the refusal by `code`, not by status. ' +
|
||||
'Reads are never refused this way. With the flag unset no route answers it.',
|
||||
},
|
||||
servers: [
|
||||
{ url: '/', description: 'Same-origin (current host)' },
|
||||
@@ -100,6 +106,18 @@ const doc = {
|
||||
type: 'object',
|
||||
properties: { message: { type: 'string', example: 'Not found' } },
|
||||
},
|
||||
DemoLocked: {
|
||||
type: 'object',
|
||||
description:
|
||||
'The public demo refused a write (DEMO_MODE.md §3). Only on a deployment with `DEMO_MODE=true`. `keys` is present on `PUT /admin/settings` and names the settings the demo keeps. `errors` is present when an event save or publish is refused because a step\'s action refused its params (MODULE_API 1.19.0 `demoRefuses`, DM18); `message` is then the module\'s sentence.',
|
||||
properties: {
|
||||
message: { type: 'string', example: 'This is turned off on the demo site.' },
|
||||
code: { type: 'string', enum: ['DEMO_LOCKED'] },
|
||||
keys: { type: 'array', items: { type: 'string' }, example: ['site_mode'] },
|
||||
errors: { type: 'array', items: { type: 'string' }, example: ['"quit" is turned off on the demo site.'] },
|
||||
},
|
||||
required: ['message', 'code'],
|
||||
},
|
||||
ValidationError: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -1080,6 +1098,25 @@ const doc = {
|
||||
ntfyUrl: { type: 'string', nullable: true, example: 'https://ntfy.example.com' },
|
||||
},
|
||||
},
|
||||
demo: {
|
||||
type: 'object',
|
||||
description:
|
||||
'Present only on the public demo (`DEMO_MODE=true`, DEMO_MODE.md §4, §6). `resetsAt` is when the next wipe is due, for the banner; `logins` are the published static sign-ins — public by design, not secrets. Both are written by `npm run seed:demo` at every wipe.',
|
||||
properties: {
|
||||
resetsAt: { type: 'string', format: 'date-time', nullable: true, example: '2026-10-24T14:00:00.000Z' },
|
||||
logins: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
username: { type: 'string', example: 'demo-admin' },
|
||||
password: { type: 'string', example: 'demo-admin' },
|
||||
role: { type: 'string', nullable: true, example: 'admin' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
additionalProperties: true,
|
||||
},
|
||||
|
||||
86
server/test/demoApp.test.js
Normal file
86
server/test/demoApp.test.js
Normal file
@@ -0,0 +1,86 @@
|
||||
// ── The demo lock, wired into the real app (DEMO_MODE.md §3.1) ─────────────
|
||||
//
|
||||
// demoLocks.test.js proves each rule against a stand-in app. This file proves
|
||||
// the wiring: with DEMO_MODE=true, app.js mounts the lock ahead of every
|
||||
// router, so a locked route is refused before its handler — before even its
|
||||
// auth check — and an open one still reaches it. One process per flag value,
|
||||
// since app.js decides at require time: this file is the demo, and every other
|
||||
// test file that requires app.js is the normal deployment.
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
process.env.DEMO_MODE = 'true'
|
||||
process.env.MODULES_DIR = require('path').join(require('os').tmpdir(), 'rg-demo-app-no-modules')
|
||||
|
||||
const { test, after } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const app = require('../src/app')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
async function withServer(fn) {
|
||||
const server = await new Promise((resolve) => {
|
||||
const s = app.listen(0, '127.0.0.1', () => resolve(s))
|
||||
})
|
||||
try {
|
||||
return await fn(`http://127.0.0.1:${server.address().port}`)
|
||||
} finally {
|
||||
server.closeAllConnections()
|
||||
await new Promise((resolve) => server.close(resolve))
|
||||
}
|
||||
}
|
||||
|
||||
test('a locked route answers DEMO_LOCKED before its own auth check', async () => {
|
||||
await withServer(async (base) => {
|
||||
const res = await fetch(`${base}/api/v1/admin/site-mode`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mode: 'maintenance' }),
|
||||
})
|
||||
assert.equal(res.status, 403)
|
||||
assert.equal((await res.json()).code, 'DEMO_LOCKED')
|
||||
})
|
||||
})
|
||||
|
||||
test('an open route still reaches its handler, which answers as usual', async () => {
|
||||
await withServer(async (base) => {
|
||||
const res = await fetch(`${base}/api/v1/admin/posts`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: 'x' }),
|
||||
})
|
||||
assert.equal(res.status, 401) // requireAuth, not the lock
|
||||
})
|
||||
})
|
||||
|
||||
test('an upload is refused before multer or auth sees it', async () => {
|
||||
await withServer(async (base) => {
|
||||
const form = new FormData()
|
||||
form.append('image', new Blob(['x'], { type: 'image/png' }), 'x.png')
|
||||
const res = await fetch(`${base}/api/v1/admin/uploads`, { method: 'POST', body: form })
|
||||
assert.equal(res.status, 403)
|
||||
assert.equal((await res.json()).code, 'DEMO_LOCKED')
|
||||
})
|
||||
})
|
||||
|
||||
test('a pasted data: image is refused before auth sees it, on an open route', async () => {
|
||||
await withServer(async (base) => {
|
||||
const res = await fetch(`${base}/api/v1/admin/posts`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: 'x', body: '<p><img src="data:image/png;base64,iVBORw0KGgo="></p>' }),
|
||||
})
|
||||
assert.equal(res.status, 403)
|
||||
assert.equal((await res.json()).code, 'DEMO_LOCKED')
|
||||
})
|
||||
})
|
||||
|
||||
test('every response carries noindex, and robots.txt disallows everything', async () => {
|
||||
await withServer(async (base) => {
|
||||
const health = await fetch(`${base}/api/health`)
|
||||
assert.equal(health.headers.get('x-robots-tag'), 'noindex, nofollow')
|
||||
const robots = await fetch(`${base}/robots.txt`)
|
||||
assert.equal(await robots.text(), 'User-agent: *\nDisallow: /\n')
|
||||
})
|
||||
})
|
||||
37
server/test/demoAppOff.test.js
Normal file
37
server/test/demoAppOff.test.js
Normal file
@@ -0,0 +1,37 @@
|
||||
// ── Without DEMO_MODE, none of the demo is mounted (DEMO_MODE.md §2) ───────
|
||||
//
|
||||
// The other half of demoApp.test.js, in its own process because app.js reads
|
||||
// the flag at require time. A normal shard must answer exactly as it did before
|
||||
// demo mode existed: its own auth check, no noindex header, its own robots.txt.
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
delete process.env.DEMO_MODE
|
||||
process.env.MODULES_DIR = require('path').join(require('os').tmpdir(), 'rg-demo-app-no-modules')
|
||||
|
||||
const { test, after } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const app = require('../src/app')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
test('a route the demo locks answers its own 401 on a normal deployment', async () => {
|
||||
const server = await new Promise((resolve) => {
|
||||
const s = app.listen(0, '127.0.0.1', () => resolve(s))
|
||||
})
|
||||
try {
|
||||
const base = `http://127.0.0.1:${server.address().port}`
|
||||
const res = await fetch(`${base}/api/v1/admin/site-mode`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mode: 'maintenance' }),
|
||||
})
|
||||
assert.equal(res.status, 401)
|
||||
const health = await fetch(`${base}/api/health`)
|
||||
assert.equal(health.headers.get('x-robots-tag'), null)
|
||||
} finally {
|
||||
server.closeAllConnections()
|
||||
await new Promise((resolve) => server.close(resolve))
|
||||
}
|
||||
})
|
||||
378
server/test/demoLocks.test.js
Normal file
378
server/test/demoLocks.test.js
Normal file
@@ -0,0 +1,378 @@
|
||||
// ── The demo lock (docs/website/DEMO_MODE.md §3) ───────────────────────────
|
||||
//
|
||||
// Every row of §3.3 that core owns, the matcher's two express defaults, the
|
||||
// conditions on the shared-login rows, the settings keys that are another door
|
||||
// into a Tier A lock, the multipart rule, and the module locks. The whole-app
|
||||
// wiring — mounted with DEMO_MODE on, absent with it off — is demoApp.test.js.
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
const { test, afterEach, after } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const db = require('../src/utils/db')
|
||||
const users = require('../src/model/users/users.model')
|
||||
const sessionService = require('../src/auth/session.service')
|
||||
const { compile, match } = require('../src/demo/paths')
|
||||
const locks = require('../src/demo/locks')
|
||||
const { isDemo, isDemoUsername, DEMO_ACCOUNTS } = require('../src/demo/mode')
|
||||
const manifest = require('../routes.manifest.json')
|
||||
const { startApp } = require('./_helper')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
const originalGetById = users.getById
|
||||
const originalValidate = sessionService.validateSession
|
||||
afterEach(() => {
|
||||
users.getById = originalGetById
|
||||
sessionService.validateSession = originalValidate
|
||||
delete process.env.DEMO_MODE
|
||||
})
|
||||
|
||||
// id 1-4 are the shared logins, 9 is somebody an admin created on the demo.
|
||||
const ACCOUNTS = { 1: 'demo-admin', 2: 'demo-moderator', 3: 'demo-player', 4: 'play-review', 9: 'alice' }
|
||||
function stubUsers() {
|
||||
users.getById = async (id) => (ACCOUNTS[id] ? { id, username: ACCOUNTS[id], role: 'admin' } : null)
|
||||
}
|
||||
function signedInAs(userId) {
|
||||
sessionService.validateSession = () => (userId ? { userId, username: ACCOUNTS[userId], role: 'admin' } : null)
|
||||
}
|
||||
|
||||
async function withLock(moduleLocks, fn) {
|
||||
const app = await startApp((a) => {
|
||||
a.use(locks.refuseMultipart)
|
||||
a.use('/api/v1', locks.createLock(moduleLocks))
|
||||
a.all('*', (req, res) => res.json({ reached: true }))
|
||||
})
|
||||
try {
|
||||
return await fn((method, path, body, headers = {}) =>
|
||||
fetch(`${app.url}/api/v1${path}`, {
|
||||
method,
|
||||
headers: body === undefined ? headers : { 'Content-Type': 'application/json', ...headers },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
}))
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
}
|
||||
|
||||
async function expectLocked(res) {
|
||||
assert.equal(res.status, 403)
|
||||
const body = await res.json()
|
||||
assert.equal(body.code, 'DEMO_LOCKED')
|
||||
assert.equal(body.message, 'This is turned off on the demo site.')
|
||||
return body
|
||||
}
|
||||
async function expectReached(res) {
|
||||
assert.equal(res.status, 200)
|
||||
assert.deepEqual(await res.json(), { reached: true })
|
||||
}
|
||||
|
||||
// ── The flag and the accounts ──────────────────────────────────────────────
|
||||
|
||||
test('only DEMO_MODE=true is the demo', () => {
|
||||
for (const v of [undefined, '', '1', 'yes', 'TRUE', 'false']) {
|
||||
if (v === undefined) delete process.env.DEMO_MODE
|
||||
else process.env.DEMO_MODE = v
|
||||
assert.equal(isDemo(), false, String(v))
|
||||
}
|
||||
process.env.DEMO_MODE = 'true'
|
||||
assert.equal(isDemo(), true)
|
||||
})
|
||||
|
||||
test('the four shared logins are recognised whatever their case', () => {
|
||||
assert.deepEqual([...DEMO_ACCOUNTS], ['demo-admin', 'demo-moderator', 'demo-player', 'play-review'])
|
||||
assert.equal(isDemoUsername('Demo-Admin'), true)
|
||||
assert.equal(isDemoUsername('alice'), false)
|
||||
assert.equal(isDemoUsername(null), false)
|
||||
})
|
||||
|
||||
// ── The matcher ────────────────────────────────────────────────────────────
|
||||
|
||||
test('patterns match the way express routes: any case, optional trailing slash', () => {
|
||||
const p = compile('/admin/users/:id')
|
||||
assert.deepEqual(match(p, '/admin/users/7'), { id: '7' })
|
||||
assert.deepEqual(match(p, '/Admin/USERS/7/'), { id: '7' })
|
||||
assert.equal(match(p, '/admin/users'), null)
|
||||
assert.equal(match(p, '/admin/users/7/mfa/reset'), null)
|
||||
assert.deepEqual(match(compile('/a/:x'), '/a/b%20c'), { x: 'b c' })
|
||||
})
|
||||
|
||||
test('a trailing * is the path and everything under it', () => {
|
||||
const p = compile('/admin/modules/*')
|
||||
for (const path of ['/admin/modules', '/admin/modules/', '/admin/modules/rust/enable', '/admin/modules/sources']) {
|
||||
assert.ok(match(p, path), path)
|
||||
}
|
||||
assert.equal(match(p, '/admin/modulesx'), null)
|
||||
})
|
||||
|
||||
test('a pattern that would compile to something else is refused', () => {
|
||||
for (const bad of ['admin/x', '/admin//x', '/admin/*/x', '/admin/x y', '/admin/:']) {
|
||||
assert.throws(() => compile(bad), /lock path/, bad)
|
||||
}
|
||||
})
|
||||
|
||||
// ── Core's list against the real routes ────────────────────────────────────
|
||||
|
||||
test("every row of core's list names a route the server actually serves", () => {
|
||||
// A row that matched nothing would read as a lock and lock nothing. Held
|
||||
// against routes.manifest.json, which `npm run routes:manifest` keeps exact.
|
||||
const served = manifest.public
|
||||
.filter((r) => r.path.startsWith('/api/v1/'))
|
||||
.map((r) => ({ method: r.method, path: r.path.slice('/api/v1'.length) }))
|
||||
for (const rule of locks.CORE_LOCKS) {
|
||||
const hits = served.filter((r) => rule.methods.includes(r.method) && match(rule.compiled, r.path))
|
||||
assert.ok(hits.length > 0, `${rule.methods.join('/')} ${rule.path} matches no served route`)
|
||||
}
|
||||
})
|
||||
|
||||
test('every upload route in the manifest is locked by name as well as by the multipart rule', () => {
|
||||
const uploads = manifest.public.filter((r) => r.method === 'POST' && /upload|brand-asset/.test(r.path))
|
||||
assert.ok(uploads.length >= 4)
|
||||
for (const r of uploads) {
|
||||
const path = r.path.slice('/api/v1'.length)
|
||||
assert.ok(locks.CORE_LOCKS.some((l) => l.methods.includes('POST') && match(l.compiled, path)), path)
|
||||
}
|
||||
})
|
||||
|
||||
// ── Tier A, unconditional ──────────────────────────────────────────────────
|
||||
|
||||
test('the Tier A rows are refused, and reads of the same paths are not', async () => {
|
||||
await withLock(undefined, async (req) => {
|
||||
await expectLocked(await req('PUT', '/admin/site-mode', { mode: 'maintenance' }))
|
||||
await expectLocked(await req('PUT', '/Admin/Site-Mode/', { mode: 'maintenance' }))
|
||||
await expectLocked(await req('POST', '/admin/modules/rust/disable'))
|
||||
await expectLocked(await req('DELETE', '/admin/modules/rust'))
|
||||
await expectLocked(await req('POST', '/admin/modules/restart'))
|
||||
await expectLocked(await req('POST', '/admin/auth/providers', { name: 'x' }))
|
||||
await expectLocked(await req('PUT', '/admin/email/config', {}))
|
||||
await expectLocked(await req('POST', '/admin/email/test'))
|
||||
await expectLocked(await req('POST', '/admin/email/disconnect'))
|
||||
await expectLocked(await req('PUT', '/admin/discord-bot/config', {}))
|
||||
await expectLocked(await req('POST', '/admin/teams/voice/sync'))
|
||||
await expectLocked(await req('POST', '/auth/password/forgot', { email: 'a@b.c' }))
|
||||
await expectLocked(await req('POST', '/auth/password/reset/abc', { password: 'x' }))
|
||||
await expectLocked(await req('POST', '/auth/register', { username: 'x' }))
|
||||
await expectLocked(await req('POST', '/public/contact', { message: 'hi' }))
|
||||
await expectLocked(await req('POST', '/admin/uploads'))
|
||||
|
||||
await expectReached(await req('GET', '/admin/site-mode'))
|
||||
await expectReached(await req('GET', '/admin/modules'))
|
||||
await expectReached(await req('GET', '/admin/email/config'))
|
||||
await expectReached(await req('GET', '/auth/password/reset/abc'))
|
||||
})
|
||||
})
|
||||
|
||||
test('content, the rig and the rest of Tier C stay open', async () => {
|
||||
await withLock(undefined, async (req) => {
|
||||
await expectReached(await req('POST', '/admin/posts', { title: 'x' }))
|
||||
await expectReached(await req('PUT', '/admin/wiki/guides', { title: 'x' }))
|
||||
await expectReached(await req('POST', '/admin/events', {}))
|
||||
await expectReached(await req('POST', '/admin/invites', {}))
|
||||
await expectReached(await req('POST', '/admin/users', { username: 'bob' }))
|
||||
await expectReached(await req('POST', '/auth/login', { username: 'demo-admin' }))
|
||||
await expectReached(await req('POST', '/auth/mobile/logout', {}))
|
||||
})
|
||||
})
|
||||
|
||||
// ── Settings: open, except the keys that reach a Tier A lock ───────────────
|
||||
|
||||
test('PUT /admin/settings is open for theme and nav', async () => {
|
||||
await withLock(undefined, async (req) => {
|
||||
await expectReached(await req('PUT', '/admin/settings', { theme_visual: { preset: 'fantasy' }, nav_public: {} }))
|
||||
await expectReached(await req('PUT', '/admin/settings', { player_registration: 'password' }))
|
||||
})
|
||||
})
|
||||
|
||||
// DM21: the hero editor's autosave and Preview write only the draft; Publish
|
||||
// writes both, and the live key refuses the whole body.
|
||||
test('the hero draft is open, publishing the hero is refused', async () => {
|
||||
await withLock(undefined, async (req) => {
|
||||
await expectReached(await req('PUT', '/admin/settings', { hero_layout_draft: '{"v":1}' }))
|
||||
const body = await expectLocked(
|
||||
await req('PUT', '/admin/settings', { hero_layout: '{"v":1}', hero_layout_draft: '{"v":1}' }),
|
||||
)
|
||||
assert.deepEqual(body.keys, ['hero_layout'])
|
||||
})
|
||||
})
|
||||
|
||||
test('PUT /admin/settings naming a protected key is refused whole, with the keys named', async () => {
|
||||
await withLock(undefined, async (req) => {
|
||||
for (const key of ['site_mode', 'site_mode_changed_by', 'module_source_hosts', 'teams_voice_enabled', 'demo_state']) {
|
||||
const body = await expectLocked(await req('PUT', '/admin/settings', { theme_visual: {}, [key]: 'x' }))
|
||||
assert.deepEqual(body.keys, [key])
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ── The shared logins ──────────────────────────────────────────────────────
|
||||
|
||||
test('an admin cannot change, delete or reset a shared login, but can anybody else', async () => {
|
||||
stubUsers()
|
||||
await withLock(undefined, async (req) => {
|
||||
for (const id of [1, 2, 3, 4]) {
|
||||
await expectLocked(await req('PUT', `/admin/users/${id}`, { role: 'player' }))
|
||||
await expectLocked(await req('DELETE', `/admin/users/${id}`))
|
||||
await expectLocked(await req('DELETE', `/admin/users/${id}/trusted-devices`))
|
||||
await expectLocked(await req('DELETE', `/admin/users/${id}/trusted-devices/5`))
|
||||
await expectLocked(await req('POST', `/admin/users/${id}/mfa/reset`))
|
||||
}
|
||||
await expectReached(await req('PUT', '/admin/users/9', { role: 'player' }))
|
||||
await expectReached(await req('DELETE', '/admin/users/9'))
|
||||
await expectReached(await req('PUT', '/admin/users/404', { role: 'player' }))
|
||||
await expectReached(await req('PUT', '/admin/users/not-a-number', {}))
|
||||
})
|
||||
})
|
||||
|
||||
const SELF_ROWS = [
|
||||
['PATCH', '/auth/me/account/username'],
|
||||
['PATCH', '/auth/me/account/password'],
|
||||
['PATCH', '/auth/me/account/email'],
|
||||
['POST', '/auth/me/account/totp/setup'],
|
||||
['POST', '/auth/me/account/totp/enable'],
|
||||
['POST', '/auth/me/account/totp/disable'],
|
||||
['POST', '/auth/me/account/recovery-codes/generate'],
|
||||
['DELETE', '/auth/me/account/identities/steam'],
|
||||
['DELETE', '/auth/me/sessions/12'], // DM17
|
||||
]
|
||||
|
||||
test('signed in as a shared login, the account cannot be changed or other sessions revoked', async () => {
|
||||
stubUsers()
|
||||
signedInAs(4)
|
||||
await withLock(undefined, async (req) => {
|
||||
for (const [method, path] of SELF_ROWS) await expectLocked(await req(method, path, {}))
|
||||
// Self-service that cannot lock anyone else out stays open.
|
||||
await expectReached(await req('PUT', '/auth/me/notifications/channels', {}))
|
||||
})
|
||||
})
|
||||
|
||||
test('signed in as anyone else, or not at all, the same routes go to their handlers', async () => {
|
||||
stubUsers()
|
||||
signedInAs(9)
|
||||
await withLock(undefined, async (req) => {
|
||||
for (const [method, path] of SELF_ROWS) await expectReached(await req(method, path, {}))
|
||||
})
|
||||
signedInAs(null) // the route's own requireAuth answers 401
|
||||
await withLock(undefined, async (req) => {
|
||||
for (const [method, path] of SELF_ROWS) await expectReached(await req(method, path, {}))
|
||||
})
|
||||
})
|
||||
|
||||
test('a lock that cannot decide refuses', async () => {
|
||||
users.getById = async () => {
|
||||
throw new Error('pool down')
|
||||
}
|
||||
await withLock(undefined, async (req) => {
|
||||
await expectLocked(await req('PUT', '/admin/users/9', {}))
|
||||
})
|
||||
})
|
||||
|
||||
// ── Uploads ────────────────────────────────────────────────────────────────
|
||||
|
||||
test('any multipart request is refused before a router sees it, wherever it is sent', async () => {
|
||||
await withLock(undefined, async (req) => {
|
||||
const multipart = { 'Content-Type': 'multipart/form-data; boundary=x' }
|
||||
await expectLocked(await req('POST', '/admin/posts', undefined, multipart))
|
||||
await expectLocked(await req('POST', '/rust/anything', undefined, { 'Content-Type': 'Multipart/Mixed; boundary=y' }))
|
||||
await expectLocked(await req('GET', '/public/settings', undefined, multipart))
|
||||
await expectReached(await req('POST', '/admin/posts', { title: 'json is fine' }))
|
||||
})
|
||||
})
|
||||
|
||||
// DM25: a data: URI in any string of a write's body is an upload too.
|
||||
async function withDataUriLock(fn) {
|
||||
const app = await startApp((a) => {
|
||||
a.use(locks.refuseDataUri)
|
||||
a.all('*', (req, res) => res.json({ reached: true }))
|
||||
})
|
||||
try {
|
||||
return await fn((method, path, body) =>
|
||||
fetch(`${app.url}/api/v1${path}`, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}))
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
}
|
||||
|
||||
test('a data: URI anywhere in a write body is refused, on any route and any write method', async () => {
|
||||
await withDataUriLock(async (req) => {
|
||||
const img = '<p>hi</p><img src="data:image/png;base64,iVBORw0KGgo=">'
|
||||
await expectLocked(await req('POST', '/admin/posts', { title: 't', body: img }))
|
||||
await expectLocked(await req('PUT', '/admin/wiki/1', { content: img }))
|
||||
await expectLocked(await req('PATCH', '/rust/anything', { deep: [{ x: { y: [img] } }] }))
|
||||
await expectLocked(await req('PUT', '/admin/settings', { hero_layout_draft: JSON.stringify([{ src: 'data:image/webp;base64,UklG' }]) }))
|
||||
await expectLocked(await req('DELETE', '/player/teams/x/forum/1', { reason: img }))
|
||||
})
|
||||
})
|
||||
|
||||
test('every data: URI shape a browser would load is refused', async () => {
|
||||
const shapes = [
|
||||
'DATA:image/png;base64,AAAA',
|
||||
'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg"/>',
|
||||
'data:text/html;base64,PHNjcmlwdD4=',
|
||||
'data:,hello',
|
||||
'data:;base64,AAAA',
|
||||
'data: image/png;base64,AAAA',
|
||||
'<div style="background:url(data:image/gif;base64,R0lG)">',
|
||||
'<img src="data:image/png;base64,AAAA">',
|
||||
'<img src="data:image/png;base64,AAAA">',
|
||||
'<img src="data:image/png;base64,AAAA">',
|
||||
'<img src="da\nta:image/png;base64,AAAA">',
|
||||
'<img src="data:im\tage/png;base64,AAAA">',
|
||||
]
|
||||
for (const s of shapes) assert.equal(locks.hasDataUri({ body: s }), true, s)
|
||||
})
|
||||
|
||||
test('prose that mentions data, reads, and bodies without strings pass', async () => {
|
||||
for (const s of ['metadata: none', 'Data: none, yet', 'the data: it is fine', 'see https://example.com/data:x', 'mydata:image/png,x']) {
|
||||
assert.equal(locks.hasDataUri({ body: s }), false, s)
|
||||
}
|
||||
assert.equal(locks.hasDataUri(undefined), false)
|
||||
assert.equal(locks.hasDataUri({ n: 1, ok: true, list: [null, 2] }), false)
|
||||
await withDataUriLock(async (req) => {
|
||||
await expectReached(await req('POST', '/admin/posts', { title: 'json is fine', body: '<p>metadata: none</p>' }))
|
||||
})
|
||||
// A read is never refused, whatever it carries.
|
||||
const app = await startApp((a) => {
|
||||
a.use(locks.refuseDataUri)
|
||||
a.all('*', (req, res) => res.json({ reached: true }))
|
||||
})
|
||||
try {
|
||||
await expectReached(await fetch(`${app.url}/api/v1/public/settings?x=data:image/png;base64,AAAA`))
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
// ── Module locks ───────────────────────────────────────────────────────────
|
||||
|
||||
test("a module's declared locks are refused alongside core's", async () => {
|
||||
const declared = [{ methods: ['PUT'], path: '/admin/rust/servers/:id', compiled: compile('/admin/rust/servers/:id'), source: 'rust' }]
|
||||
await withLock(() => declared, async (req) => {
|
||||
await expectLocked(await req('PUT', '/admin/rust/servers/demo', {}))
|
||||
await expectReached(await req('GET', '/admin/rust/servers/demo'))
|
||||
await expectReached(await req('DELETE', '/admin/rust/servers/demo'))
|
||||
await expectReached(await req('POST', '/admin/rust/servers/demo/test'))
|
||||
})
|
||||
})
|
||||
|
||||
// ── Search engines ─────────────────────────────────────────────────────────
|
||||
|
||||
test('noindex and robots keep the demo out of search engines', async () => {
|
||||
const app = await startApp((a) => {
|
||||
a.use(locks.noindex)
|
||||
a.get('/robots.txt', locks.robots)
|
||||
a.get('/', (req, res) => res.send('ok'))
|
||||
})
|
||||
try {
|
||||
const page = await fetch(`${app.url}/`)
|
||||
assert.equal(page.headers.get('x-robots-tag'), 'noindex, nofollow')
|
||||
const robots = await fetch(`${app.url}/robots.txt`)
|
||||
assert.equal(await robots.text(), 'User-agent: *\nDisallow: /\n')
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
218
server/test/demoModule.test.js
Normal file
218
server/test/demoModule.test.js
Normal file
@@ -0,0 +1,218 @@
|
||||
// ── The demo's module contract (MODULE_API 1.18.0) and the seed's helpers ──
|
||||
//
|
||||
// `demoLocks` in module.json, `ctx.demoMode`, `api.onDemoSeed` (DM15), the
|
||||
// `demo` block on GET /public/settings, and the pure parts of `seed:demo`.
|
||||
// Each loader test builds a throwaway modules directory and re-requires the
|
||||
// loader, the way moduleLoader.test.js does and for the same reason.
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
const fs = require('fs')
|
||||
const os = require('os')
|
||||
const path = require('path')
|
||||
|
||||
const { test, afterEach, after } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const express = require('express')
|
||||
|
||||
const db = require('../src/utils/db')
|
||||
const registries = require('../src/modules/registries')
|
||||
const settingsDb = require('../src/model/settings/settings.db')
|
||||
const settings = require('../src/model/settings/settings.model')
|
||||
const { resolveEnv, nextResetAt, checkFixture } = require('../db/seedDemo')
|
||||
|
||||
after(() => db.close())
|
||||
afterEach(() => {
|
||||
delete process.env.DEMO_MODE
|
||||
})
|
||||
|
||||
const emptyTiers = () => ({ public: express.Router(), admin: express.Router(), player: express.Router() })
|
||||
|
||||
function loadOne(manifest, server = 'module.exports = (ctx, api) => { const r = ctx.express.Router(); api.registerRoutes({ admin: { "/rust": r } }) }') {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-demo-'))
|
||||
const dir = path.join(root, 'rust')
|
||||
fs.mkdirSync(dir)
|
||||
fs.writeFileSync(path.join(dir, 'module.json'), JSON.stringify({
|
||||
id: 'rust',
|
||||
name: 'Rust',
|
||||
version: '1.0.0',
|
||||
coreApi: '^1.18.0',
|
||||
server: 'index.js',
|
||||
mounts: { admin: ['/rust'] },
|
||||
...manifest,
|
||||
}))
|
||||
fs.writeFileSync(path.join(dir, 'index.js'), server)
|
||||
process.env.MODULES_DIR = root
|
||||
registries._reset()
|
||||
delete require.cache[require.resolve('../src/modules/loader')]
|
||||
// eslint-disable-next-line global-require
|
||||
const loader = require('../src/modules/loader')
|
||||
loader.load(emptyTiers())
|
||||
return loader
|
||||
}
|
||||
const stateOf = (loader) => loader.list().find((m) => m.id === 'rust')
|
||||
|
||||
// ── demoLocks ──────────────────────────────────────────────────────────────
|
||||
|
||||
test('declared demoLocks load and come back compiled', () => {
|
||||
const loader = loadOne({
|
||||
demoLocks: [
|
||||
{ method: 'PUT', path: '/admin/rust/servers/:id' },
|
||||
{ method: 'DELETE', path: '/admin/rust/servers/:id' },
|
||||
],
|
||||
})
|
||||
assert.equal(stateOf(loader).state, 'registered')
|
||||
const got = loader.demoLocks()
|
||||
assert.deepEqual(got.map((l) => [l.methods, l.path, l.source]), [
|
||||
[['PUT'], '/admin/rust/servers/:id', 'rust'],
|
||||
[['DELETE'], '/admin/rust/servers/:id', 'rust'],
|
||||
])
|
||||
assert.ok(got[0].compiled.re.test('/admin/rust/servers/demo'))
|
||||
})
|
||||
|
||||
test('a module without demoLocks declares none', () => {
|
||||
assert.deepEqual(loadOne({}).demoLocks(), [])
|
||||
})
|
||||
|
||||
test('a bad demoLocks entry fails the module at load, demo or not', () => {
|
||||
const cases = [
|
||||
[{ demoLocks: {} }, /demoLocks must be an array/],
|
||||
[{ demoLocks: ['PUT /x'] }, /must be an object/],
|
||||
[{ demoLocks: [{ method: 'GET', path: '/admin/rust/x' }] }, /must be one of POST, PUT, PATCH, DELETE/],
|
||||
[{ demoLocks: [{ method: 'PUT', path: '/admin/rust/x', why: 'y' }] }, /unknown key "demoLocks.why"/],
|
||||
[{ demoLocks: [{ method: 'PUT', path: '/admin/rust/*/x' }] }, /\* must be the last segment/],
|
||||
// Outside the module's own mounts: a module locks its routes and nobody else's.
|
||||
[{ demoLocks: [{ method: 'PUT', path: '/admin/site-mode' }] }, /outside the module's mounts/],
|
||||
[{ demoLocks: [{ method: 'PUT', path: '/public/rust/x' }] }, /outside the module's mounts/],
|
||||
[{ demoLocks: [{ method: 'PUT', path: '/admin/*' }] }, /outside the module's mounts/],
|
||||
]
|
||||
for (const [manifest, reason] of cases) {
|
||||
const loader = loadOne(manifest)
|
||||
const m = stateOf(loader)
|
||||
assert.equal(m.state, 'startup_failed', JSON.stringify(manifest))
|
||||
assert.match(m.reason, reason)
|
||||
}
|
||||
})
|
||||
|
||||
test('a module declaring ^1.18.0 and an empty demoLocks loads on this core', () => {
|
||||
const loader = loadOne({ coreApi: '^1.18.0', demoLocks: [] })
|
||||
assert.equal(stateOf(loader).state, 'registered')
|
||||
})
|
||||
|
||||
// ── ctx.demoMode ───────────────────────────────────────────────────────────
|
||||
|
||||
const PROBE = `module.exports = (ctx, api) => {
|
||||
global.__demoProbe = ctx.demoMode
|
||||
api.registerRoutes({ admin: { '/rust': ctx.express.Router() } })
|
||||
}`
|
||||
|
||||
test('ctx.demoMode is false on a normal deployment and true on the demo', () => {
|
||||
loadOne({}, PROBE)
|
||||
assert.equal(global.__demoProbe, false)
|
||||
process.env.DEMO_MODE = 'true'
|
||||
loadOne({}, PROBE)
|
||||
assert.equal(global.__demoProbe, true)
|
||||
delete global.__demoProbe
|
||||
})
|
||||
|
||||
// ── onDemoSeed ─────────────────────────────────────────────────────────────
|
||||
|
||||
test('onDemoSeed is collected for the seed and handed the fixture and account ids', async () => {
|
||||
const loader = loadOne({}, `module.exports = (ctx, api) => {
|
||||
api.registerRoutes({ admin: { '/rust': ctx.express.Router() } })
|
||||
api.onDemoSeed(async (data, { accounts }) => { global.__demoSeed = { data, accounts } })
|
||||
}`)
|
||||
const hooks = loader.demoSeedHooks()
|
||||
assert.deepEqual(hooks.map((h) => h.id), ['rust'])
|
||||
await hooks[0].hook({ servers: [] }, { accounts: { 'demo-player': 3 } })
|
||||
assert.deepEqual(global.__demoSeed, { data: { servers: [] }, accounts: { 'demo-player': 3 } })
|
||||
delete global.__demoSeed
|
||||
})
|
||||
|
||||
test('onDemoSeed twice is a registration error, like every other hook', () => {
|
||||
const loader = loadOne({}, `module.exports = (ctx, api) => {
|
||||
api.registerRoutes({ admin: { '/rust': ctx.express.Router() } })
|
||||
api.onDemoSeed(() => {})
|
||||
api.onDemoSeed(() => {})
|
||||
}`)
|
||||
assert.equal(stateOf(loader).state, 'startup_failed')
|
||||
assert.match(stateOf(loader).reason, /onDemoSeed\(\) called twice/)
|
||||
})
|
||||
|
||||
// ── GET /public/settings ───────────────────────────────────────────────────
|
||||
|
||||
const originalGetAll = settingsDb.getAll
|
||||
afterEach(() => {
|
||||
settingsDb.getAll = originalGetAll
|
||||
})
|
||||
|
||||
test('the demo block is present only on the demo, and read forgivingly', async () => {
|
||||
const state = {
|
||||
resetsAt: '2026-10-24T14:00:00.000Z',
|
||||
logins: [{ username: 'demo-admin', password: 'demo-admin-pass', role: 'admin' }, { username: 7 }],
|
||||
}
|
||||
settingsDb.getAll = async () => [{ key: 'demo_state', value: JSON.stringify(state) }]
|
||||
const normal = await settings.getPublic()
|
||||
assert.equal('demo' in normal, false)
|
||||
|
||||
process.env.DEMO_MODE = 'true'
|
||||
const demo = await settings.getPublic()
|
||||
assert.deepEqual(demo.demo, {
|
||||
resetsAt: '2026-10-24T14:00:00.000Z',
|
||||
logins: [{ username: 'demo-admin', password: 'demo-admin-pass', role: 'admin' }],
|
||||
})
|
||||
|
||||
settingsDb.getAll = async () => [{ key: 'demo_state', value: '{broken' }]
|
||||
assert.deepEqual((await settings.getPublic()).demo, { resetsAt: null, logins: [] })
|
||||
})
|
||||
|
||||
// ── seed:demo's pure helpers ───────────────────────────────────────────────
|
||||
|
||||
test('resolveEnv replaces $env: strings at any depth and refuses an unset one', () => {
|
||||
const env = { DEMO_RUST_SIDECAR_TOKEN: 'tok', DEMO_RUST_SIDECAR_URL: 'http://rig:7790' }
|
||||
assert.deepEqual(
|
||||
resolveEnv({ a: '$env:DEMO_RUST_SIDECAR_URL', b: [{ t: '$env:DEMO_RUST_SIDECAR_TOKEN' }], c: 'plain $env:X', d: 3 }, env),
|
||||
{ a: 'http://rig:7790', b: [{ t: 'tok' }], c: 'plain $env:X', d: 3 },
|
||||
)
|
||||
assert.throws(() => resolveEnv({ m: { t: '$env:NOPE' } }, env), /fixture\.m\.t: environment variable NOPE is not set/)
|
||||
assert.throws(() => resolveEnv('$env:EMPTY', { EMPTY: '' }), /EMPTY is not set/)
|
||||
})
|
||||
|
||||
test('nextResetAt is the next two-hour slot on the hour, UTC', () => {
|
||||
const at = (iso) => nextResetAt(new Date(iso), 2).toISOString()
|
||||
assert.equal(at('2026-10-24T14:02:10Z'), '2026-10-24T16:00:00.000Z')
|
||||
assert.equal(at('2026-10-24T13:59:59Z'), '2026-10-24T14:00:00.000Z')
|
||||
assert.equal(at('2026-10-24T14:00:00Z'), '2026-10-24T16:00:00.000Z')
|
||||
assert.equal(at('2026-10-24T23:30:00Z'), '2026-10-25T00:00:00.000Z')
|
||||
assert.equal(nextResetAt(new Date('2026-10-24T14:02:00Z'), 3).toISOString(), '2026-10-24T15:00:00.000Z')
|
||||
})
|
||||
|
||||
const GOOD = {
|
||||
accounts: {
|
||||
'demo-admin': { password: 'demo-admin-pass', role: 'admin' },
|
||||
'demo-moderator': { password: 'demo-moderator-pass', role: 'moderator' },
|
||||
'demo-player': { password: 'demo-player-pass', role: 'player' },
|
||||
'play-review': { password: 'play-review-pass', role: 'admin' },
|
||||
},
|
||||
}
|
||||
|
||||
test('checkFixture accepts the four accounts and refuses anything that would leave the lock wrong', () => {
|
||||
assert.deepEqual(checkFixture(GOOD), [])
|
||||
const { 'play-review': _gone, ...three } = GOOD.accounts
|
||||
assert.match(checkFixture({ accounts: three }).join('\n'), /accounts\.play-review is missing/)
|
||||
assert.match(checkFixture({ accounts: { ...GOOD.accounts, alice: { password: 'alicealice', role: 'admin' } } }).join('\n'),
|
||||
/accounts\.alice is not a demo account/)
|
||||
assert.match(checkFixture({ accounts: { ...GOOD.accounts, 'demo-admin': { password: 'short', role: 'admin' } } }).join('\n'),
|
||||
/at least 8 characters/)
|
||||
assert.match(checkFixture({ accounts: { ...GOOD.accounts, 'demo-admin': { password: 'longenough', role: 'root' } } }).join('\n'),
|
||||
/role must be one of/)
|
||||
assert.match(checkFixture({ ...GOOD, settings: { demo_state: '{}' } }).join('\n'), /written by the seed itself/)
|
||||
assert.match(checkFixture({ ...GOOD, content: { posts: {} } }).join('\n'), /content\.posts must be an array/)
|
||||
assert.match(checkFixture({ ...GOOD, modules: [] }).join('\n'), /modules must be an object/)
|
||||
})
|
||||
|
||||
test('seed:demo refuses to run off the demo', async () => {
|
||||
const { run } = require('../db/seedDemo') // eslint-disable-line global-require
|
||||
await assert.rejects(() => run('anything.json'), /only with DEMO_MODE=true/)
|
||||
})
|
||||
154
server/test/demoStepRefusal.test.js
Normal file
154
server/test/demoStepRefusal.test.js
Normal file
@@ -0,0 +1,154 @@
|
||||
// ── The demo refuses a step at save (MODULE_API 1.19.0, DEMO_MODE.md §3.4) ──
|
||||
//
|
||||
// DM18: an event action may declare `demoRefuses(params)`. With DEMO_MODE on,
|
||||
// the definition save path asks it for every step at create, save and publish,
|
||||
// and a sentence back refuses the write `403 DEMO_LOCKED`. Off the demo it is
|
||||
// never called. The definitions table is stubbed on the module object the model
|
||||
// holds; the pool points at a closed port.
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
const { test, beforeEach, afterEach, after } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const db = require('../src/utils/db')
|
||||
const registries = require('../src/modules/registries')
|
||||
const definitions = require('../src/model/events/eventDefinitions.model')
|
||||
const definitionsDb = require('../src/model/events/eventDefinitions.db')
|
||||
const controller = require('../src/router/v1/admin/events.controller')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
let asked
|
||||
|
||||
const register = (extra = {}) => {
|
||||
const api = registries.stage('demo')
|
||||
api.registerEventActions([{
|
||||
id: 'demo.console.run',
|
||||
label: 'Run console commands',
|
||||
risk: 'irreversible',
|
||||
reversible: 'none',
|
||||
params: [{ name: 'command', type: 'string', required: true, example: 'say hi' }],
|
||||
perform: async () => ({ ok: true }),
|
||||
demoRefuses: (params) => {
|
||||
asked.push(params)
|
||||
return params.command === 'quit' ? '"quit" is turned off on the demo site.' : null
|
||||
},
|
||||
...extra,
|
||||
}])
|
||||
registries.apply(api.staged)
|
||||
}
|
||||
|
||||
const input = (command) => ({
|
||||
title: 'Console night',
|
||||
spec: {
|
||||
schedule: { kind: 'manual' },
|
||||
phases: [{ key: 'main', label: 'Main', steps: [{ actionId: 'demo.console.run', params: { command } }] }],
|
||||
},
|
||||
})
|
||||
|
||||
const stubTable = (t, existing = null) => {
|
||||
const wrote = []
|
||||
t.mock.method(definitionsDb, 'list', async () => [])
|
||||
t.mock.method(definitionsDb, 'insert', async (row) => { wrote.push(row); return 7 })
|
||||
t.mock.method(definitionsDb, 'update', async (_id, row) => { wrote.push(row) })
|
||||
t.mock.method(definitionsDb, 'getById', async () => existing || { id: 7, state: 'draft', title: 'Console night', spec: input('say hi').spec })
|
||||
return wrote
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
asked = []
|
||||
registries._reset()
|
||||
registries.registerCore()
|
||||
})
|
||||
afterEach(() => {
|
||||
registries._reset()
|
||||
delete process.env.DEMO_MODE
|
||||
})
|
||||
|
||||
test('demoRefuses must be a function, and never rides out to the catalog', () => {
|
||||
assert.throws(() => register({ demoRefuses: 'quit' }), /demoRefuses must be a function/)
|
||||
registries._reset()
|
||||
register()
|
||||
assert.equal(typeof registries.eventAction('demo.console.run').demoRefuses, 'function')
|
||||
const served = registries.allEventActions().find((a) => a.id === 'demo.console.run')
|
||||
assert.equal('demoRefuses' in served, false)
|
||||
})
|
||||
|
||||
test('off the demo the hook is never asked, and a denied command saves', async (t) => {
|
||||
register()
|
||||
const wrote = stubTable(t)
|
||||
const made = await definitions.create(input('quit'), 1, { role: 'admin' })
|
||||
assert.equal(made.ok, true)
|
||||
assert.equal(wrote.length, 1)
|
||||
assert.deepEqual(asked, [])
|
||||
})
|
||||
|
||||
test('on the demo a create naming a denied command is refused 403 DEMO_LOCKED and writes nothing', async (t) => {
|
||||
process.env.DEMO_MODE = 'true'
|
||||
register()
|
||||
const wrote = stubTable(t)
|
||||
const made = await definitions.create(input('quit'), 1, { role: 'admin' })
|
||||
assert.deepEqual(made, {
|
||||
ok: false,
|
||||
status: 403,
|
||||
code: 'DEMO_LOCKED',
|
||||
message: '"quit" is turned off on the demo site.',
|
||||
errors: ['"quit" is turned off on the demo site.'],
|
||||
})
|
||||
assert.equal(wrote.length, 0)
|
||||
// The hook is handed the step's checked params.
|
||||
assert.deepEqual(asked, [{ command: 'quit' }])
|
||||
})
|
||||
|
||||
test('on the demo a command the hook allows saves', async (t) => {
|
||||
process.env.DEMO_MODE = 'true'
|
||||
register()
|
||||
const wrote = stubTable(t)
|
||||
assert.equal((await definitions.create(input('say hi'), 1, { role: 'admin' })).ok, true)
|
||||
assert.equal((await definitions.save(7, input('say hi'), 1, { role: 'admin' })).ok, true)
|
||||
assert.equal(wrote.length, 2)
|
||||
})
|
||||
|
||||
test('on the demo a save and a publish are refused too', async (t) => {
|
||||
process.env.DEMO_MODE = 'true'
|
||||
register()
|
||||
const wrote = stubTable(t, { id: 7, state: 'draft', title: 'Console night', spec: input('quit').spec })
|
||||
const saved = await definitions.save(7, input('quit'), 1, { role: 'admin' })
|
||||
assert.equal(saved.status, 403)
|
||||
assert.equal(saved.code, 'DEMO_LOCKED')
|
||||
// A draft written before the list grew: publish asks again.
|
||||
const published = await definitions.publish(7, 1)
|
||||
assert.equal(published.status, 403)
|
||||
assert.equal(published.code, 'DEMO_LOCKED')
|
||||
assert.equal(wrote.length, 0)
|
||||
})
|
||||
|
||||
test('a hook that throws refuses with core\'s sentence (fails closed)', async (t) => {
|
||||
process.env.DEMO_MODE = 'true'
|
||||
register({ demoRefuses: () => { throw new Error('boom') } })
|
||||
stubTable(t)
|
||||
const made = await definitions.create(input('say hi'), 1, { role: 'admin' })
|
||||
assert.equal(made.status, 403)
|
||||
assert.equal(made.code, 'DEMO_LOCKED')
|
||||
assert.match(made.message, /"Run console commands" could not be checked/)
|
||||
})
|
||||
|
||||
test('the controller answers the refusal with code and message, so the toast fires', async (t) => {
|
||||
process.env.DEMO_MODE = 'true'
|
||||
register()
|
||||
stubTable(t)
|
||||
let status
|
||||
let body
|
||||
const res = {
|
||||
status(s) { status = s; return this },
|
||||
json(b) { body = b; return this },
|
||||
}
|
||||
await controller.create({ body: input('quit'), user: { id: 1, role: 'admin' } }, res)
|
||||
assert.equal(status, 403)
|
||||
assert.deepEqual(body, {
|
||||
errors: ['"quit" is turned off on the demo site.'],
|
||||
code: 'DEMO_LOCKED',
|
||||
message: '"quit" is turned off on the demo site.',
|
||||
})
|
||||
})
|
||||
@@ -112,7 +112,7 @@ test('the version a module declares against satisfies ^1.10.0', () => {
|
||||
// 1.10.0 loading — Module-uo declares exactly `^1.10.0` — and this is the
|
||||
// assertion that says it does. The number itself is pinned in version.js.
|
||||
assert.ok(semver.satisfies(MODULE_API_VERSION, '^1.10.0'), MODULE_API_VERSION)
|
||||
assert.equal(MODULE_API_VERSION, '1.17.0')
|
||||
assert.equal(MODULE_API_VERSION, '1.19.0')
|
||||
})
|
||||
|
||||
test('a module registers actions, budgets, leases and option sources', () => {
|
||||
|
||||
@@ -550,8 +550,10 @@ test('ctx exposes exactly the documented surface, and is frozen', () => {
|
||||
// API 1.17.0 added `identities` (STEAM_SIGNIN.md §4): READ only, one provider
|
||||
// at a time. Linking stays core's, and a module takes part through the two
|
||||
// hooks on `api`, never by writing an identity itself.
|
||||
// API 1.18.0 added `demoMode` (DEMO_MODE.md §3.4): a boolean, for the locks
|
||||
// the module enforces in its own handlers.
|
||||
assert.deepEqual(probe.keys, [
|
||||
'activity', 'auth', 'db', 'events', 'express', 'identities', 'inbox', 'log', 'middleware', 'moduleId', 'paths',
|
||||
'activity', 'auth', 'db', 'demoMode', 'events', 'express', 'identities', 'inbox', 'log', 'middleware', 'moduleId', 'paths',
|
||||
'posts', 'push', 'secretBox', 'settings', 'site', 'teams', 'uploads', 'users', 'validator',
|
||||
])
|
||||
// is core's limiter FACTORY, not a limiter: a module states its own
|
||||
|
||||
Reference in New Issue
Block a user