feat(demo): DEMO_MODE lock, seed:demo and the demo banner (MODULE_API 1.18.0)
Core half of the public demo (docs/website/DEMO_MODE.md §10 step 1, website#222).
Nothing here runs unless DEMO_MODE=true.
- server/src/demo/: the lock at /api/v1, ahead of every router. It covers every
Tier A row of §3.3, the shared-login conditions, POST /auth/register (DM16),
DELETE /auth/me/sessions/:id on a demo account (DM17), and the PUT
/admin/settings keys that reach a Tier A lock. Any multipart request is refused
app-wide. noindex and robots.txt. A condition that cannot decide refuses.
- Loader: demoLocks in module.json (writes only, under the module's own mounts),
ctx.demoMode, and api.onDemoSeed (DM15). MODULE_API 1.18.0 on both halves.
- GET /public/settings carries demo: { resetsAt, logins } on the demo only.
- npm run seed:demo -- <fixture>: upserts the four accounts, sets the site live
with registration disabled, seeds content on the first run only, calls each
module's onDemoSeed, and stamps demo_state. It resolves $env: strings and
refuses to run off the demo.
- Client: the banner, one DEMO_LOCKED toast fed from the request primitive (so
module screens get it too), and the published logins on both sign-in pages.
Dashboard and register say the demo message instead of blaming a role.
- OpenAPI: DemoLocked schema, the demo block, and a note that any write may
answer DEMO_LOCKED on the demo.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
@@ -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,23 @@ 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('/api/v1', demoLock.createLock(() => modules.demoLocks()))
|
||||
}
|
||||
|
||||
// ── Paths ─────────────────────────────────────────────────────────────
|
||||
const SERVER_ROOT = path.join(__dirname, '..')
|
||||
const REPO_ROOT = path.join(SERVER_ROOT, '..')
|
||||
|
||||
196
server/src/demo/locks.js
Normal file
196
server/src/demo/locks.js
Normal file
@@ -0,0 +1,196 @@
|
||||
// ── The demo lock (docs/website/DEMO_MODE.md §3) ───────────────────────────
|
||||
//
|
||||
// With DEMO_MODE on, app.js mounts three 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.
|
||||
// • `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.
|
||||
const PROTECTED_SETTINGS = [/^site_mode/, /^module_source_hosts$/, /^teams_voice_/, /^demo_/]
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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,
|
||||
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 }
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -9,6 +9,20 @@
|
||||
// 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.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 +245,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.18.0'
|
||||
|
||||
module.exports = { MODULE_API_VERSION }
|
||||
|
||||
Reference in New Issue
Block a user