Phase 2 PR 4 of docs/website/MODULE_SYSTEM.md §2.7. Adds server/src/modules/registries.js and moves core's own notification streams, announce leg and users-detail routes behind it, so the three seams §1.8 and §1.9 named are exercised on every boot before any module depends on them. Registering is validate-then-commit per registrant: the loader stages what a module claims and the second pass commits it, so a module that throws halfway through register() — or fails checkDeclared after it — leaves nothing behind. That is the registry-side twin of PR 2's second-pass mount rule. Four decisions, all the recommended option: - announce legs became a child table. `announce_job_legs` replaces the towncrier_*/discord_* column groups, so the leg set is data: core registers `discord`, module-uo will register `towncrier`, and a module cannot ALTER a core table to add its own. Backfill is guarded on information_schema (a SELECT of a dropped column is a parse error, not a runtime one) and the columns go with DROP COLUMN IF EXISTS. Verified against the live dev DB: three legacy jobs migrated faithfully, three replays, no duplicates. - `mapEvent` dropped from registerNotificationStreams. §1.8 already inverts the push path so a module owns fromShardEvent and calls core's publish() with a stream id it resolved; a second mapping mechanism was a leftover. The public safety filter, the kinds it reads and the streams it protects now live in one file and move together. - core registers through the same staging area a module uses, via an explicit registries.registerCore() in app.js before modules.load(). - core's six /admin/users/:id/shard/* paths now go through the `admin.users.detail` slot, and getUser moved back to admin.controller.js. Found on the way, and the reason two build tools changed: - scripts/routeManifest.js could not decode a parameterised mount. Its unwinder expected `(?:([^\/]+?))`; express 4.22 emits `(?:\/([^/]+?))` with the separator inside the group. The branch had never run. It threw rather than guessing, which is what it is for. - swagger-autogen cannot follow a route into an extension slot — the slot's router is created by declareSlot() and filled later, so there is no literal mount for a static parse. Regenerating deleted 407 lines and printed `Swagger-autogen: Success`, the spike's exact failure (MODULE_API.md §7.4). swagger/slotSpecs.js generates a fragment per filled slot and re-roots it at the prefix the router actually hangs at in the live app — read from the express stack via routeManifest's own mountPath, so the manifest and the spec cannot disagree. swagger/mergeSpec.js is the merge helper core owes for module fragments anyway (§6.1a), proved here against core's own slot first. 884 tests pass (856 before). routes.manifest.json is unchanged at 229 routes. The OpenAPI spec diff is two lines of intent: the retry endpoint's summary, and its `leg` no longer being a fixed enum. Co-Authored-By: Claude <noreply@anthropic.com>
254 lines
12 KiB
JavaScript
254 lines
12 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 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())
|
|
|
|
// ── 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. The spec
|
|
// is generated from route annotations by `npm run swagger` (server/swagger/).
|
|
// Loaded lazily and guarded so a missing spec never crashes the server.
|
|
try {
|
|
// eslint-disable-next-line global-require
|
|
const swaggerSpec = require('../swagger/swagger-output.json')
|
|
app.get('/api/docs.json', (req, res) => {
|
|
// #swagger.ignore = true
|
|
res.json(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,
|
|
},
|
|
})
|
|
app.use('/api/docs', swaggerCsp, swaggerUi.serve, swaggerUi.setup(swaggerSpec, {
|
|
customSiteTitle: `${brand.name} API docs`,
|
|
swaggerOptions: { persistAuthorization: true },
|
|
}))
|
|
} 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).
|
|
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' }))
|
|
|
|
// ── /.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
|