A site is one game, and the module contract already has singletons that assume it. registerTeamProvider holds one value per deployment, and a second module registering one fails that module's whole load. The loader scans alphabetically, so installing module-rust (which gains a Team provider in its phase 9) beside module-uo would have taken uo down, not rust. install() now refuses, with 409 and before the artifact is downloaded, any install whose id differs from a module already on the volume. An upgrade of the installed module is still accepted; to change game, remove the module first. Both install surfaces share this path, so a MODULES declaration naming two modules installs the first and reports the second as refused without failing the boot. "Installed" means what the loader would scan: a directory named with a module id that holds a module.json. An install's scratch directory and a swap's aside copy do not count. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
145 lines
9.2 KiB
JavaScript
145 lines
9.2 KiB
JavaScript
// Admin · Modules — install, enable, disable, uninstall, purge and restart.
|
||
//
|
||
// Mounted at /api/v1/admin/modules by admin/index.js, which has already applied
|
||
// `noindex, isLoggedIn, staffOnly`. Every route here re-gates to `admin`: an
|
||
// editor or moderator has no business installing code into the server process,
|
||
// and the group gate alone would let them.
|
||
//
|
||
// Route order matters in one place. `/restart` and `/sources` are declared
|
||
// BEFORE the `/:id/...` routes, because express matches in declaration order and
|
||
// a module whose id was `restart` would otherwise shadow — or be shadowed by —
|
||
// the literal path. The id pattern below makes that unreachable in practice; the
|
||
// ordering makes it unreachable by construction.
|
||
|
||
const express = require('express')
|
||
const { body, param, query } = require('express-validator')
|
||
|
||
const controller = require('./modules.controller')
|
||
const { requireRole } = require('../../../utils/auth')
|
||
const validate = require('../../../middleware/validate')
|
||
|
||
const modulesRouter = express.Router()
|
||
const adminOnly = requireRole('admin')
|
||
|
||
// The loader's own id rule (MODULE_API.md §2.1). Applied at the edge so a
|
||
// traversal-shaped id never reaches a path join, even though install.js checks
|
||
// it again — this one produces a 400 with a readable message, that one is the
|
||
// guarantee.
|
||
const ID = /^[a-z][a-z0-9-]{1,31}$/
|
||
|
||
modulesRouter.get(
|
||
'/',
|
||
// #swagger.tags = ['Admin · Modules']
|
||
// #swagger.summary = 'List installed modules, their live state, and the source allowlist'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.responses[200] = { description: 'Modules and the install source allowlist', content: { "application/json": { schema: { type: "object", properties: { modules: { type: "array", items: { type: "object", additionalProperties: true } }, sourceHosts: { type: "array", items: { type: "string" } } } } } } } */
|
||
adminOnly,
|
||
controller.list,
|
||
)
|
||
|
||
modulesRouter.post(
|
||
'/',
|
||
// #swagger.tags = ['Admin · Modules']
|
||
// #swagger.summary = 'Install or upgrade a module from a release install-manifest URL'
|
||
// #swagger.description = 'Downloads the artifact the manifest names, verifies its sha256, inspects the archive in full and unpacks it onto the modules volume. The module mounts on the next restart. A site runs ONE module: installing a module other than the one already on the volume is refused with 409 before anything is downloaded, and only an upgrade of the installed module is accepted.'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["url"], properties: { url: { type: "string", description: "https URL of the release install manifest, on an allowed host" } } } } } } */
|
||
/* #swagger.responses[201] = { description: 'Installed — restart to mount it', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||
/* #swagger.responses[400] = { description: 'The URL, the manifest, the hash or the archive was refused', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[409] = { description: 'A different module is already installed; a site runs one module', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[502] = { description: 'The source host could not be reached or answered badly', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
adminOnly,
|
||
body('url').isString().trim().isLength({ min: 1, max: 2048 }),
|
||
validate,
|
||
controller.create,
|
||
)
|
||
|
||
modulesRouter.put(
|
||
'/sources',
|
||
// #swagger.tags = ['Admin · Modules']
|
||
// #swagger.summary = 'Replace the allowlist of hosts modules may be installed from'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["hosts"], properties: { hosts: { type: "string", description: "Comma- or space-separated hostnames. An empty list forbids all installs." } } } } } } */
|
||
/* #swagger.responses[200] = { description: 'The new allowlist', content: { "application/json": { schema: { type: "object", properties: { sourceHosts: { type: "array", items: { type: "string" } } } } } } } */
|
||
/* #swagger.responses[400] = { description: 'One of the entries is not a hostname', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
adminOnly,
|
||
body('hosts').isString().isLength({ max: 2048 }),
|
||
validate,
|
||
controller.setSources,
|
||
)
|
||
|
||
modulesRouter.post(
|
||
'/restart',
|
||
// #swagger.tags = ['Admin · Modules']
|
||
// #swagger.summary = 'Restart the server process so module changes take effect'
|
||
// #swagger.description = 'Runs the same graceful shutdown a SIGTERM does. The process is brought back by the supervisor, which the shipped docker-compose.yml provides; a bare `npm start` will not come back.'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.responses[202] = { description: 'Shutting down', content: { "application/json": { schema: { type: "object", properties: { restarting: { type: "boolean" } } } } } } */
|
||
adminOnly,
|
||
controller.restart,
|
||
)
|
||
|
||
modulesRouter.post(
|
||
'/:id/enable',
|
||
// #swagger.tags = ['Admin · Modules']
|
||
// #swagger.summary = 'Enable a module (takes effect on the next restart)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Module id.' }
|
||
/* #swagger.responses[200] = { description: 'Enabled — restart to start it', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||
/* #swagger.responses[404] = { description: 'No such module', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
adminOnly,
|
||
param('id').matches(ID),
|
||
validate,
|
||
controller.enable,
|
||
)
|
||
|
||
modulesRouter.post(
|
||
'/:id/disable',
|
||
// #swagger.tags = ['Admin · Modules']
|
||
// #swagger.summary = 'Stop a module now — runs its onShutdown, then its routes answer 404'
|
||
// #swagger.description = 'The only module action that takes effect without a restart. Re-enabling needs one, because there is no onBoot re-dispatch.'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Module id.' }
|
||
/* #swagger.responses[200] = { description: 'Disabled', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||
/* #swagger.responses[404] = { description: 'No such module', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
adminOnly,
|
||
param('id').matches(ID),
|
||
validate,
|
||
controller.disable,
|
||
)
|
||
|
||
modulesRouter.post(
|
||
'/:id/purge',
|
||
// #swagger.tags = ['Admin · Modules']
|
||
// #swagger.summary = "Run a disabled module’s purge.sql, dropping its tables and data"
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Module id.' }
|
||
/* #swagger.responses[200] = { description: 'Purged', content: { "application/json": { schema: { type: "object", properties: { id: { type: "string" }, purged: { type: "integer" } } } } } } */
|
||
/* #swagger.responses[400] = { description: 'The module ships no purge.sql', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[409] = { description: 'The module must be disabled first', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
adminOnly,
|
||
param('id').matches(ID),
|
||
validate,
|
||
controller.purge,
|
||
)
|
||
|
||
modulesRouter.delete(
|
||
'/:id',
|
||
// #swagger.tags = ['Admin · Modules']
|
||
// #swagger.summary = 'Uninstall a module, optionally deleting its data too'
|
||
// #swagger.description = "Removes the module directory and leaves its row disabled. With purge=true it also runs purge.sql first — which is the only moment it can, since purge.sql lives inside the directory being deleted."
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Module id.' }
|
||
// #swagger.parameters['purge'] = { in: 'query', required: false, schema: { type: 'boolean' }, description: "Also run the module’s purge.sql and drop its row. Destructive and irreversible." }
|
||
/* #swagger.responses[200] = { description: 'Uninstalled — restart to unmount it', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||
/* #swagger.responses[400] = { description: 'Purge was asked for and the module ships no purge.sql', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[404] = { description: 'No such module', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
adminOnly,
|
||
param('id').matches(ID),
|
||
query('purge').optional().isIn(['true', 'false', '1', '0']),
|
||
validate,
|
||
controller.remove,
|
||
)
|
||
|
||
module.exports = modulesRouter
|