feat(modules): install, uninstall, purge and restart (phase 4, slice 1)
All checks were successful
PR Checks / bot-install (pull_request) Successful in 17s
PR Checks / client-build (pull_request) Successful in 26s
PR Checks / server-tests (pull_request) Successful in 33s

The consumer half of a release module-uo's CI has been publishing since
phase 3 closed. Before this, core had the installed_modules provenance
columns and no code that could ever fill them: nothing fetched, verified,
unpacked, removed or purged anything, and there was no admin route at all.

Adds modules/archive.js, modules/install.js, schema.runPurge(),
lifecycle.stop(), loader.stopHook(), and /api/v1/admin/modules with eight
routes. 797 server tests (+76), manifest 158 -> 166 + 2 internal, OpenAPI
gains 8 operations and loses nothing.

Reject, never sanitise
----------------------
The download is the easy part: an https-only allowlist re-checked on every
redirect hop, a declared sha256 compared against the bytes that arrived, and
a byte cap. Unpacking is where the archive chooses the filenames, and core
writes into a directory bind-mounted from the host, so an escape is not
confined to the container.

archive.js inspects the whole archive before a byte is unpacked and refuses
absolute and drive-absolute paths, `..` segments, NUL bytes, backslashes,
anything that is not a regular file or a directory, more than one top-level
entry, and anything over the entry or byte caps. Refusing symlinks and
hardlinks outright is what keeps this off the majority of node-tar's
published advisories rather than depending on the library to contain them.

That two-pass shape is load-bearing, and it was measured rather than assumed:
extracting an archive whose fourth member escapes upward throws under
node-tar 7.5.22 -- and leaves the first three members on disk. The loader
scans that directory at require time on the next boot, so a half-unpacked
module is a module. Everything therefore happens in a scratch directory that
is removed on any failure, and the move into place is the last step.

`tar` is pinned to ^7.5.22 rather than the ^6 that installs by default: 6.x
is flagged critical, and reading the advisory list is what the file's header
now says out loud -- almost all of it is hardlink or symlink traversal and
PAX header interpretation differentials, which is exactly this feature's
threat model.

Two things the plan had wrong
-----------------------------
The bundle's top-level directory is `module-uo-<version>`, not the module id
-- so "the top-level name must equal the id" was checked against nothing real.
The extractor strips that level instead, because its name belongs to whoever
published the bundle and the directory it lands in is core's. What is checked
instead is the unpacked module.json: a manifest promising `uo` and delivering
something else is refused rather than installed under the name it promised.

And purge cannot be a follow-up action (decision 5): purge.sql lives inside
the directory uninstall deletes. It is offered in the uninstall flow and as a
standalone action on a still-installed module, and the standalone one refuses
unless the module is already disabled -- dropping tables under something that
is still serving leaves it answering out of a world that no longer exists.

Disable now means stopped
-------------------------
lifecycle.stop() dispatches that one module's onShutdown before flipping the
guard, so a module an operator switches off actually releases its sockets and
closes its streams instead of merely becoming unreachable. The hook runs
first and the state moves after it, because while onShutdown runs the module
is still `started` and that is the only state in which its routes and the
world it is tearing down agree. A hook that throws does not stop the disable
-- the opposite of the boot path's rule, and deliberately.

Enable is not its mirror and there is no start(id) beside it. There is no
onBoot re-dispatch and the hooks were never promised re-entrant, so enable
moves the row and the restart route starts it. A test pins that enable does
not touch the loader, because "fixing" it is a one-line change that would put
a module with closed sockets back on the nav.

Restart raises SIGTERM against its own process rather than calling the
shutdown path directly, so server.js's handler stays the one graceful-shutdown
path and this route cannot drift from it.

The allowlist bootstraps from MODULE_SOURCE_HOSTS into a settings row and is
admin-managed after that (decision 6); seedDefault is INSERT IGNORE, so
changing the variable on an existing deployment is a no-op by design. An empty
list forbids every install rather than allowing every host -- the safe
direction for a value someone might blank by accident.

Verified against the real v0.3.0 release
----------------------------------------
Not a fixture: fetched the published install manifest over the real Gitea
host and its redirect chain, verified the sha256, inspected and unpacked the
252,517-byte artifact to 82 files, and then booted core against the result --
the module registered its five mounts, seven streams and eight capabilities
and resolved its client chunk, with no scratch directory left behind.

Two defects this slice's own tooling caught, both of which had already been
written down as classes:
  - the controller destructured runPurge at require time, capturing the
    function rather than the module, which made the one dependency whose
    ORDER matters the one that could not be substituted;
  - two swagger annotations carried an apostrophe inside a quoted string,
    dropped silently by swagger-autogen before slice 5 taught it to fail loudly.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-12 03:09:45 -05:00
parent 2cb549e9e5
commit b30e82cde2
20 changed files with 3381 additions and 3 deletions

View File

@@ -30,6 +30,7 @@ const pagesRouter = require('./pages.router')
const emailRouter = require('./email.router')
const discordBotRouter = require('./discordBot.router')
const settingsRouter = require('./settings.router')
const modulesRouter = require('./modules.router')
const dashboardRouter = require('./dashboard.router')
const adminRouter = express.Router()
@@ -73,6 +74,11 @@ adminRouter.use('/pages', pagesRouter)
adminRouter.use('/email', emailRouter)
adminRouter.use('/discord-bot', discordBotRouter)
adminRouter.use('/settings', settingsRouter)
// The Modules screen (Phase 4). Core's, not a module's — and it has to be
// core's: it is how a module gets onto the volume in the first place. Mounted
// here alongside the other configuration capabilities, and admin-only per route
// rather than at this line, so the gate sits next to what it is guarding.
adminRouter.use('/modules', modulesRouter)
// The two singletons that own no path segment of their own: GET /dashboard and
// PUT /site-mode. Mounted at the group root, last, exactly where the residual

View File

@@ -0,0 +1,379 @@
// ── Admin: installed modules ───────────────────────────────────────────────
//
// Phase 4, slice 1 of docs/website/MODULE_SYSTEM.md §2.7.2. Admin-only, and more
// so than anything else in this directory: installing a module puts JavaScript on
// the volume that core will `require` into its own process on the next boot. That
// is the feature — it is what "an operator never builds anything" means (§1.14) —
// but it is worth being plain that this controller is remote code execution with
// an audit trail, not a settings screen.
//
// What guards it, in the order an attacker would meet them:
//
// 1. `requireRole('admin')` on every route, on top of the group's staff gate.
// 2. An `https`-only host allowlist, re-checked on every redirect hop, so a
// pasted URL cannot be pointed at the compose network or a metadata service
// (install.js).
// 3. The sha256 the release published, compared against the bytes that arrived.
// 4. A full inspection of the archive before a byte of it is unpacked, and an
// unpack into a scratch directory that is only moved into place once the
// bundle has agreed with the manifest about what it is (archive.js).
// 5. Every action here writes to core's one audit log.
//
// The one thing this file cannot do is mount anything. §1.12 makes the volume the
// mounting source of truth, read once at require time, so install and uninstall
// take effect on the next boot — which is why `restart` is a route here rather
// than a sentence in a tooltip (decision 1).
const modules = require('../../../model/modules/modules.model')
const activity = require('../../../model/activity/activity.model')
const settings = require('../../../model/settings/settings.model')
const loader = require('../../../modules/loader')
const lifecycle = require('../../../modules/lifecycle')
const install = require('../../../modules/install')
// A namespace import, like every other require in this file, and not
// `const { runPurge } = …`: destructuring at require time captures the function
// rather than the module, which makes it the one dependency here that cannot be
// substituted. That matters because the two tests worth having about purge are
// about the ORDER it runs in relative to the directory being removed.
const schema = require('../../../modules/schema')
const log = require('../../../utils/logger')('admin-modules')
// The allowlist setting. Seeded from MODULE_SOURCE_HOSTS on first boot and
// admin-managed from then on (decision 6) — db/seed.js writes it once and never
// overwrites it, so changing the variable later does not silently reach in and
// undo an operator's choice.
const HOSTS_KEY = 'module_source_hosts'
// A hostname, not a URL: no scheme, no path, no port, no wildcard. Deliberately
// strict — every character allowed here is a character that can appear in the
// host of a URL this server will fetch and execute the contents of.
const HOSTNAME = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/
async function allowedHosts() {
return install.parseHosts(await settings.get(HOSTS_KEY))
}
/**
* One module, as the admin screen needs it.
*
* Three sources have to be reconciled, and which one answers which question is
* the whole of §2.4:
*
* - the ROW says what the operator decided and what the last boot recorded;
* - the LOADER says what is mounted and answering right now;
* - the VOLUME says whether there is still a directory there at all.
*
* They can legitimately disagree, and the screen has to show that rather than
* pick a winner. A row `enabled` with a loader state of `disabled` is a module
* the operator has just switched back on and which is waiting for a restart —
* exactly the case decision 3 creates, and it would be a lie to render it as
* either "running" or "off".
*/
function present(row, live, onVolume) {
return {
id: row ? row.id : live.id,
name: row ? row.name : live.name,
version: row ? row.version : live.version,
// What the database records.
state: row ? row.state : null,
failureStage: row ? row.failureStage : (live && live.stage) || null,
failureReason: row ? row.failureReason : (live && live.reason) || null,
source: row ? row.source : null,
sha256: row ? row.sha256 : null,
installedAt: row ? row.installedAt : null,
startedAt: row ? row.startedAt : null,
// What is actually mounted in this process, and what it is answering.
liveState: live ? live.state : null,
capabilities: live ? live.capabilities : [],
// What is on the volume.
onVolume,
canPurge: onVolume && Boolean(install.purgeFile(row ? row.id : live.id)),
}
}
// GET /admin/modules — every module core knows about, from all three sources,
// plus the source allowlist the install form needs.
async function list(req, res) {
try {
const rows = await modules.list()
// The loader throws rather than returning [] before load() has run (§7.6),
// and this controller is reachable from a process where that is true —
// `npm run seed` never gets here, but a test harness might.
const live = loader.isLoaded() ? loader.list() : []
const byId = new Map(live.map((m) => [m.id, m]))
const seen = new Set()
const out = []
for (const row of rows) {
seen.add(row.id)
out.push(present(row, byId.get(row.id) || null, install.isInstalled(row.id)))
}
// A directory on the volume that has no row yet — a hand-placed install
// before its first boot. It has to be listed, or the screen would show
// nothing for a module whose routes are already being served.
for (const m of live) {
if (!seen.has(m.id)) out.push(present(null, m, true))
}
return res.json({ modules: out, sourceHosts: await allowedHosts() })
} catch (err) {
log.error('list modules', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// POST /admin/modules — install (or upgrade) from an install-manifest URL.
async function create(req, res) {
const url = String(req.body.url || '').trim()
try {
const hosts = await allowedHosts()
const result = await install.install({ url, hosts })
// Provenance is written here and nowhere else: the boot reconcile records a
// module with NULL source/sha256 and leaves what it is not given, precisely
// so that a refresh cannot overwrite what an install knew (lifecycle.js).
const row = await modules.recordInstalled({
id: result.id,
name: result.name,
version: result.version,
source: result.source,
sha256: result.sha256,
})
await activity.log({
req,
userId: req.user.id,
action: 'module.install',
detail: { id: result.id, version: result.version, source: url, sha256: result.sha256, replaced: result.replaced },
})
log.warn('module installed — it will mount on the next restart', {
id: result.id,
version: result.version,
by: req.user.username,
})
return res.status(201).json({ module: row, restartRequired: true, replaced: result.replaced })
} catch (err) {
if (err.name === 'InstallError' || err.name === 'ArchiveError') {
// The operator pasted a URL and something about what came back was wrong.
// The message is the useful part and is written to be read by them.
log.warn('module install refused', { url, reason: err.message })
return res.status(err.status || 400).json({ message: err.message })
}
log.error('install module', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// POST /admin/modules/:id/enable — switch a module back on, for the next boot.
//
// Deliberately does NOT touch the loader's record. Disable ran the module's
// onShutdown (decision 3), and there is no onBoot re-dispatch to undo that: a
// module whose sockets were closed and timers cleared cannot be made to serve
// again by flipping a flag, and pretending otherwise would put it back on the
// nav with a torn-down world behind it. The row moves; the restart starts it.
async function enable(req, res) {
const { id } = req.params
try {
const row = await modules.enable(id)
if (!row) return res.status(404).json({ message: 'No such module.' })
await activity.log({ req, userId: req.user.id, action: 'module.enable', detail: { id } })
return res.json({ module: row, restartRequired: true })
} catch (err) {
if (err.name === 'ModuleStateError') return res.status(409).json({ message: err.message })
log.error('enable module', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// POST /admin/modules/:id/disable — stop it now.
//
// The one action on this screen that takes effect without a restart, and the
// reason it does is that it is the one an operator reaches for when something is
// going wrong. Its routes answer 404 from the moment this returns, and its
// onShutdown has already run.
async function disable(req, res) {
const { id } = req.params
try {
const current = await modules.get(id)
if (!current) return res.status(404).json({ message: 'No such module.' })
const { stopped, error } = await lifecycle.stop(id)
const row = await modules.get(id)
await activity.log({
req,
userId: req.user.id,
action: 'module.disable',
detail: { id, hookRan: stopped, hookError: error },
})
log.warn('module disabled by an operator', { id, hookRan: stopped, by: req.user.username })
// `shutdownError` is reported rather than swallowed: the module IS disabled
// either way, and an operator whose module could not close cleanly should be
// told so while they still have the logs to look at.
return res.json({ module: row, stopped, shutdownError: error })
} catch (err) {
log.error('disable module', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// DELETE /admin/modules/:id[?purge=true] — uninstall.
//
// Non-destructive by default (§2.5): the directory goes, the row stays
// `disabled`, and the module's tables and data are left alone.
//
// The purge option is here rather than as a follow-up action because it cannot
// be a follow-up action (decision 5): `purge.sql` is a file inside the directory
// this is about to delete, so after an uninstall there is nothing left to purge
// with. Ticking the box is the last moment the file exists.
//
// The order below is the whole of it, and each step depends on the one above:
// purge while the SQL is still readable, stop while the code is still loaded,
// then delete.
async function remove(req, res) {
const { id } = req.params
const purge = req.query.purge === 'true' || req.query.purge === '1'
try {
const current = await modules.get(id)
const onVolume = install.isInstalled(id)
if (!current && !onVolume) return res.status(404).json({ message: 'No such module.' })
let purged = null
if (purge) {
const file = install.purgeFile(id)
if (!file) {
return res.status(400).json({
message: 'This module ships no purge.sql, so its data cannot be deleted. Uninstall without purging instead.',
})
}
purged = await schema.runPurge(file)
}
// Stop it before its files vanish. A module whose directory is deleted out
// from under a running onShutdown is being asked to tear down a world whose
// code may already be half-unreadable — and its sockets would otherwise stay
// open until the restart, holding a connection on behalf of a module that no
// longer exists on disk.
await lifecycle.stop(id)
const removed = await install.removeDir(id)
// A purge leaves nothing: no directory, no tables, no data. Keeping a
// `disabled` row for that is a tombstone with nothing to offer and a Purge
// button that would fail. A plain uninstall keeps its row, which is what
// makes the retained data visible and reinstallable.
if (purge) await modules.remove(id)
await activity.log({
req,
userId: req.user.id,
action: purge ? 'module.purge' : 'module.uninstall',
detail: { id, purged, removed },
})
log.warn(`module ${purge ? 'uninstalled and purged' : 'uninstalled'}`, {
id,
statements: purged,
by: req.user.username,
})
return res.json({ id, removed, purged, restartRequired: true })
} catch (err) {
log.error('uninstall module', err)
return res.status(500).json({ message: err.message || 'Internal Server Error' })
}
}
// POST /admin/modules/:id/purge — drop a still-installed module's data.
//
// Refuses unless the module is already disabled, and that guard is the point:
// dropping the tables under a module that is still serving requests leaves it
// answering out of a world that no longer exists. Disabling first is one click
// and makes the destructive step happen against something that has stopped.
async function purge(req, res) {
const { id } = req.params
try {
const current = await modules.get(id)
if (!current) return res.status(404).json({ message: 'No such module.' })
if (current.state !== 'disabled') {
return res.status(409).json({
message: 'Disable this module before purging its data, so nothing is serving out of the tables being dropped.',
})
}
const file = install.purgeFile(id)
if (!file) {
return res.status(400).json({ message: 'This module ships no purge.sql, so its data cannot be deleted.' })
}
const statements = await schema.runPurge(file)
await activity.log({ req, userId: req.user.id, action: 'module.purge', detail: { id, statements } })
log.warn('module data purged', { id, statements, by: req.user.username })
return res.json({ id, purged: statements })
} catch (err) {
log.error('purge module', err)
return res.status(500).json({ message: err.message || 'Internal Server Error' })
}
}
// PUT /admin/modules/sources — the host allowlist.
async function setSources(req, res) {
const hosts = install.parseHosts(req.body.hosts)
const bad = hosts.find((h) => !HOSTNAME.test(h))
if (bad) return res.status(400).json({ message: `"${bad}" is not a valid hostname.` })
try {
const before = await allowedHosts()
await settings.set(HOSTS_KEY, hosts.join(','), req.user.id)
await activity.log({
req,
userId: req.user.id,
action: 'module.sources',
detail: { before, after: hosts },
})
log.warn('module source allowlist changed', { before, after: hosts, by: req.user.username })
return res.json({ sourceHosts: hosts })
} catch (err) {
log.error('set module sources', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// POST /admin/modules/restart — restart the server process.
//
// Decision 1. Install, uninstall and re-enable all only take effect at boot
// because §1.12 reads the volume at require time, and §2.4 promises recovery
// "with no shell access to the box" — which a banner saying "please restart your
// container" does not deliver.
//
// It raises SIGTERM against its own process rather than calling the shutdown
// path directly. server.js already has a handler that stops the modules, the
// workers and the listeners in the right order and closes the pool and the log
// file before exiting 0; reaching that through the signal means there is exactly
// one graceful-shutdown path and this route cannot drift from it.
//
// What brings the process BACK is the supervisor, not this. The shipped
// docker-compose.yml declares `restart: unless-stopped` on `app`, which restarts
// on a clean exit as well as a crash. A bare `npm start` does not come back, and
// the screen says so before it asks.
function restart(req, res) {
log.warn('restart requested from the admin panel', { by: req.user.username })
// Logged and answered first. Once the signal is raised the response has no
// listener left to flush through, so the operator would be told nothing.
res.status(202).json({ restarting: true })
activity
.log({ req, userId: req.user.id, action: 'module.restart', detail: {} })
.catch((err) => log.error('failed to record the restart in the audit log', err))
.finally(() => {
// A beat, so the 202 is on the wire. `unref` so this timer is not itself
// something keeping the process alive.
setTimeout(() => process.kill(process.pid, 'SIGTERM'), 250).unref()
})
}
module.exports = { list, create, enable, disable, remove, purge, setSources, restart, HOSTS_KEY }

View File

@@ -0,0 +1,143 @@
// 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.'
// #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[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 modules 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 modules 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