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
338 lines
16 KiB
JavaScript
338 lines
16 KiB
JavaScript
const express = require('express')
|
|
const path = require('path')
|
|
const fs = require('fs')
|
|
const cors = require('cors')
|
|
const helmet = require('helmet')
|
|
const morgan = require('morgan')
|
|
const cookieParser = require('cookie-parser')
|
|
require('dotenv').config()
|
|
|
|
const swaggerUi = require('swagger-ui-express')
|
|
|
|
const apiRouter = require('./router/api.router')
|
|
const modules = require('./modules/loader')
|
|
const registries = require('./modules/registries')
|
|
const wellKnown = require('./router/wellKnown.controller')
|
|
const cspReport = require('./router/cspReport.controller')
|
|
const brand = require('./config/brand')
|
|
const csp = require('./config/csp')
|
|
const { cspReportLimiter } = require('./middleware/rateLimit')
|
|
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')
|
|
|
|
const app = express()
|
|
|
|
// Behind Pangolin: trust the forwarding proxy so req.secure (cookie flag) and
|
|
// req.ip (activity log, rate limiting, backoff, bot-ban) reflect the real client
|
|
// from X-Forwarded-*. Configurable via TRUST_PROXY; defaults to a single hop and
|
|
// never a blanket `true` (which would let clients spoof their IP). Must run
|
|
// before any middleware that reads req.ip.
|
|
applyTrustProxy(app)
|
|
|
|
// Optional trust-proxy diagnostics (off unless DEBUG_TRUST_PROXY is set). Before
|
|
// the bot guard so it logs scanner/junk source IPs too.
|
|
app.use(trustProxyDebug)
|
|
|
|
// Bot / scanner guard — mounted first (before helmet/routing) so banned IPs and
|
|
// obvious scanner probes are 404'd immediately without reaching real handlers.
|
|
app.use(botScore.guard)
|
|
|
|
// Security headers, including a Content-Security-Policy tuned for the built React
|
|
// SPA. The policies themselves (and the reasoning behind every non-'self' allowance)
|
|
// live in config/csp.js. The interactive API docs at /api/docs get their own looser
|
|
// policy below.
|
|
app.use(
|
|
helmet({
|
|
contentSecurityPolicy: { useDefaults: true, directives: csp.enforced },
|
|
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
|
}),
|
|
)
|
|
|
|
// The tightened policy rides alongside on Content-Security-Policy-Report-Only for one
|
|
// release, then replaces the enforced one (docs/website/API_V2_PLAN.md § Phase 1).
|
|
// Both headers are served at once on purpose: the live policy keeps protecting users
|
|
// while anything the tightened version would have broken shows up as a report at
|
|
// /api/csp-report instead of as a broken page. Reports are same-origin — they
|
|
// describe attacks on this site and are not handed to a third party.
|
|
app.use(csp.reportingEndpoints)
|
|
app.use(
|
|
helmet.contentSecurityPolicy({
|
|
useDefaults: true,
|
|
reportOnly: true,
|
|
directives: csp.reportOnly,
|
|
}),
|
|
)
|
|
|
|
// CORS only when a separate client origin is configured (local Vite dev). In
|
|
// production the SPA is same-origin, so no CORS is needed.
|
|
if (process.env.CLIENT_ORIGIN) {
|
|
app.use(cors({ origin: process.env.CLIENT_ORIGIN, credentials: true }))
|
|
}
|
|
|
|
// Access logs: real client IP (via trust proxy), the authenticated admin (if any),
|
|
// method, URL, status, response time, and size. Bodies/credentials are never logged.
|
|
morgan.token('user', (req) => (req.user && req.user.username) || '-')
|
|
const accessFormat =
|
|
':remote-addr :user :method :url :status :response-time ms - :res[content-length] bytes'
|
|
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, '..')
|
|
const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(SERVER_ROOT, 'uploads')
|
|
const CLIENT_DIST = path.join(REPO_ROOT, 'client', 'dist')
|
|
const BRAND_DIR = process.env.BRAND_DIR || path.join(REPO_ROOT, 'brand')
|
|
fs.mkdirSync(UPLOAD_DIR, { recursive: true })
|
|
|
|
// Escape user/brand text for safe interpolation into the HTML shell.
|
|
const htmlEscape = (s) =>
|
|
String(s).replace(
|
|
/[&<>"']/g,
|
|
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]),
|
|
)
|
|
|
|
// Uploaded images — always served, even during maintenance. Force nosniff so a
|
|
// stored file is never interpreted as anything other than its declared type
|
|
// (defense in depth alongside helmet's global X-Content-Type-Options, and in
|
|
// case that global config is ever changed).
|
|
app.use(
|
|
'/uploads',
|
|
express.static(UPLOAD_DIR, {
|
|
setHeaders: (res) => res.set('X-Content-Type-Options', 'nosniff'),
|
|
}),
|
|
)
|
|
|
|
// ── API docs (Swagger UI) ─────────────────────────────────────────────
|
|
// Interactive OpenAPI docs at /api/docs, raw spec at /api/docs.json. Core's own
|
|
// routes are generated from their annotations by `npm run swagger`
|
|
// (server/swagger/) and committed; an installed module's routes cannot be —
|
|
// swagger-autogen is static analysis and a module arrives on the volume after the
|
|
// image was built — so each module ships its own fragment and they are merged
|
|
// HERE, per request, over core's committed spec (docs/website/MODULE_API.md §6.1a).
|
|
// Loaded lazily and guarded so a missing spec never crashes the server.
|
|
try {
|
|
/* eslint-disable global-require */
|
|
const swaggerSpec = require('../swagger/swagger-output.json')
|
|
const { docsSpec } = require('../swagger/docsSpec')
|
|
/* eslint-enable global-require */
|
|
|
|
app.get('/api/docs.json', (req, res) => {
|
|
// #swagger.ignore = true
|
|
res.json(docsSpec(swaggerSpec))
|
|
})
|
|
// swagger-ui-express injects an inline bootstrap script and inline styles, which
|
|
// the global 'self'-only script-src would block — relax CSP for this route only.
|
|
const swaggerCsp = helmet.contentSecurityPolicy({
|
|
useDefaults: true,
|
|
directives: {
|
|
'script-src': ["'self'", "'unsafe-inline'"],
|
|
'style-src': ["'self'", "'unsafe-inline'"],
|
|
'img-src': ["'self'", 'data:', 'https:'],
|
|
'upgrade-insecure-requests': null,
|
|
},
|
|
})
|
|
// `setup()` is called PER REQUEST rather than once here, because the document it
|
|
// renders is not fixed at boot: a module reaching `started` (or failing to) adds
|
|
// or removes paths, and a UI bound to the spec as it looked while app.js was
|
|
// still being required would show core's routes for the life of the process
|
|
// while /api/docs.json showed the merged set. `docsSpec` is cached on the
|
|
// loader's state version, so the repeated call costs a comparison.
|
|
const swaggerOpts = {
|
|
customSiteTitle: `${brand.name} API docs`,
|
|
swaggerOptions: { persistAuthorization: true },
|
|
}
|
|
app.use('/api/docs', swaggerCsp, swaggerUi.serve, (req, res, next) =>
|
|
swaggerUi.setup(docsSpec(swaggerSpec), swaggerOpts)(req, res, next))
|
|
} catch (err) {
|
|
errLog.error('Swagger spec not found — run `npm run swagger` to generate it. API docs disabled.', {
|
|
message: err.message,
|
|
})
|
|
}
|
|
|
|
// ── API ───────────────────────────────────────────────────────────────
|
|
app.get(
|
|
'/api/health',
|
|
// #swagger.tags = ['Health']
|
|
// #swagger.summary = 'Liveness probe'
|
|
/* #swagger.responses[200] = { description: 'Service is up', content: { "application/json": { schema: { type: "object", properties: { status: { type: "string", example: "ok" } } } } } } */
|
|
(req, res) => res.json({ status: 'ok' }),
|
|
)
|
|
// CSP violation sink. Mounted here, ahead of the /api 404, and outside /api/v1: it is
|
|
// not part of the versioned client contract — it exists for the browser, which learns
|
|
// the path from the policy header, never from a client build.
|
|
app.post(csp.REPORT_PATH, cspReportLimiter, ...cspReport.parsers, cspReport.receive)
|
|
|
|
app.use('/api', apiRouter)
|
|
|
|
// ── Installed modules ─────────────────────────────────────────────────
|
|
// Discover, validate and mount whatever is on the modules volume
|
|
// (docs/website/MODULE_API.md Part 4). One explicit call, here and nowhere else:
|
|
// the loader has no lazy self-scan, so there is exactly one place that decides
|
|
// when modules are discovered, and reading the module list before this line is
|
|
// an error rather than a silent empty answer (§7.6).
|
|
//
|
|
// Position is load-bearing, in both directions. It is AFTER `/api` is mounted,
|
|
// so every core prefix is already on the tier routers when the collision check
|
|
// asks them what core owns — and so first-match-wins means a module physically
|
|
// cannot shadow a core route. It is BEFORE the `/api` 404 below, so a module
|
|
// route reaches its handler instead of the catch-all.
|
|
//
|
|
// The three requires resolve from cache to the very routers v1.router.js
|
|
// mounted; this is a reference to them, not a second copy.
|
|
//
|
|
// registerCore() first, and for the same reason the loader runs after `/api`: a
|
|
// module's collision checks are asked against what is ALREADY registered, so
|
|
// core's streams, its announce leg and its extension-slot fill have to be there
|
|
// before the first module registers anything (MODULE_SYSTEM.md §1.8).
|
|
// The engagement subsystem's own door, which is what brings core's mail
|
|
// transports and its three delivery channels into existence (ENGAGEMENT.md
|
|
// §3.1). Requiring `engagement/channels` or `engagement/transports` directly gets
|
|
// the empty registry — populating it is deliberately a side effect of this one
|
|
// require, so there is exactly one place either can be registered from. It runs
|
|
// beside registerCore() and before the loader for the same reason: a preference
|
|
// read or a mail send must never find a half-populated registry.
|
|
require('./engagement')
|
|
|
|
registries.registerCore()
|
|
modules.load({
|
|
public: require('./router/v1/public'),
|
|
admin: require('./router/v1/admin'),
|
|
player: require('./router/v1/player'),
|
|
})
|
|
|
|
app.use('/api', (req, res) => res.status(404).json({ message: 'Not found' }))
|
|
|
|
// Installed modules' prebuilt client chunks, at /modules/<id>/ — same-origin, so
|
|
// `script-src 'self'` admits them with no nonce and no inline script
|
|
// (docs/website/MODULE_API.md §3.1). Three properties, each load-bearing:
|
|
//
|
|
// • The static root is the directory the ENTRY sits in, never the module root.
|
|
// One express.static over a module root would publish its server source, its
|
|
// module.json and its schema fragment; the loader rejects an entry that would
|
|
// make those the same directory.
|
|
// • Behind the module's own state guard, so a failed module's chunk is 503 and
|
|
// a disabled one's is 404 — the same answers its API gives, for the same
|
|
// reason: the browser should not be running the client half of something the
|
|
// server half has stopped serving.
|
|
// • `fallthrough: false`, so a missing file is a 404 here rather than falling
|
|
// through to the SPA catch-all and answering a `<script src>` with the index
|
|
// shell, which the browser then rejects on its MIME type instead.
|
|
//
|
|
// Vite's library build emits an unhashed `entry.js`, so `no-cache` (revalidate,
|
|
// not "do not store") is what stops an upgraded module serving yesterday's chunk
|
|
// out of the disk cache.
|
|
for (const chunk of modules.clientChunks()) {
|
|
app.use(
|
|
chunk.url,
|
|
chunk.guard,
|
|
express.static(chunk.dir, {
|
|
fallthrough: false,
|
|
setHeaders: (res) => {
|
|
res.set('Cache-Control', 'no-cache')
|
|
res.set('X-Content-Type-Options', 'nosniff')
|
|
},
|
|
}),
|
|
)
|
|
}
|
|
|
|
// Everything else under /modules is a 404, not the SPA shell. The namespace
|
|
// belongs to installed modules' chunks — an unknown module id or a file a module
|
|
// does not ship is a missing file, and answering a `<script src>` with an HTML
|
|
// page turns that into a MIME-type refusal in the console with a 200 in the
|
|
// network tab. It also keeps the namespace's boundary a fact of the app rather
|
|
// than of whichever catch-all happens to be mounted after it.
|
|
app.use('/modules', (req, res) => res.status(404).json({ message: 'Not found' }))
|
|
|
|
// ── /.well-known ──────────────────────────────────────────────────────
|
|
// Android App Links verification file at the web root (M9 follow-up). Mounted
|
|
// before the SPA catch-all so it returns JSON, not the index shell. 404s unless
|
|
// the admin has enabled App Links for this shard (docs/android/APP_LINKS.md).
|
|
app.get('/.well-known/assetlinks.json', wellKnown.assetlinks)
|
|
|
|
// ── Client SPA ────────────────────────────────────────────────────────
|
|
// Serve the built React app if present; otherwise show a placeholder so the
|
|
// server is usable API-only before the frontend phase.
|
|
// Brand assets (logo/hero/favicon) from a mounted directory, used when BRAND_*
|
|
// paths point at /brand/*. Optional — the defaults live under the SPA's /assets,
|
|
// so this only matters for a custom mount.
|
|
if (fs.existsSync(BRAND_DIR)) {
|
|
app.use(
|
|
'/brand',
|
|
express.static(BRAND_DIR, {
|
|
setHeaders: (res) => res.set('X-Content-Type-Options', 'nosniff'),
|
|
}),
|
|
)
|
|
}
|
|
|
|
if (fs.existsSync(path.join(CLIENT_DIST, 'index.html'))) {
|
|
// Serve a branded copy of the index.html shell for every SPA route; assets keep
|
|
// their own cache-friendly static handler.
|
|
//
|
|
// The shell is templated from BRAND_* env *and* the admin's brand_assets /
|
|
// theme_visual rows, so it is rendered lazily and cached rather than built once
|
|
// at boot: see utils/htmlShell.js for the caching, the invalidation and why a
|
|
// DB fault still serves a page.
|
|
htmlShell.init(fs.readFileSync(path.join(CLIENT_DIST, 'index.html'), 'utf8'))
|
|
app.use(express.static(CLIENT_DIST, { index: false }))
|
|
app.get('*', async (req, res, next) => {
|
|
// htmlShell.get() swallows a settings-read failure itself; the try is for
|
|
// anything unforeseen, since an async handler that rejects in Express 4
|
|
// hangs the request instead of reaching the error handler below.
|
|
try {
|
|
res.type('html').send(await htmlShell.get())
|
|
} catch (err) {
|
|
next(err)
|
|
}
|
|
})
|
|
} else {
|
|
app.get('*', (req, res) =>
|
|
res
|
|
.type('html')
|
|
.send(
|
|
`<h1>${htmlEscape(brand.name)} API</h1><p>The web client has not been built yet. ` +
|
|
'The API is available under <code>/api/v1</code>.</p>',
|
|
),
|
|
)
|
|
}
|
|
|
|
// ── Error handler ─────────────────────────────────────────────────────
|
|
// eslint-disable-next-line no-unused-vars
|
|
app.use((err, req, res, next) => {
|
|
const status = err.status || (err.name === 'MulterError' ? 400 : 500)
|
|
// Log the stack for server faults; client (4xx) errors stay terse.
|
|
errLog.error(
|
|
`${req.method} ${req.originalUrl} -> ${status} ${err.message}`,
|
|
status >= 500 ? { stack: err.stack } : undefined,
|
|
)
|
|
res.status(status).json({ message: err.message || 'Internal Server Error' })
|
|
})
|
|
|
|
module.exports = app
|