fix(modules): a site runs one module; the installer refuses a second
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
This commit is contained in:
@@ -17,6 +17,11 @@
|
||||
// through modules/install.js, the same fetch-verify-unpack path the admin panel
|
||||
// uses, under the same host allowlist.
|
||||
//
|
||||
// **A site runs one module** (org lead, 2026-09-23), and install.js enforces it
|
||||
// for this path too: a declaration naming a second module installs the first,
|
||||
// and the second is refused — logged and kept for the admin screen like any
|
||||
// other failed entry, never fatal to the boot.
|
||||
//
|
||||
// Three things this file deliberately does not do:
|
||||
//
|
||||
// - **It does not decide whether a module RUNS.** Resolution owns what is on
|
||||
|
||||
@@ -290,6 +290,28 @@ function isInstalled(id) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every module on the volume, by id — the directories the loader would scan.
|
||||
*
|
||||
* The loader's own rule, restated: a directory whose name is a module id and
|
||||
* which holds a `module.json`. That excludes an install's scratch directory
|
||||
* (`.install-*`) and a swap's aside copy (`<id>.replaced-*`), neither of which
|
||||
* is a module and both of which can briefly exist beside one.
|
||||
*/
|
||||
function installedIds() {
|
||||
let entries
|
||||
try {
|
||||
entries = fs.readdirSync(loader.dir(), { withFileTypes: true })
|
||||
} catch {
|
||||
return [] // no modules directory is the normal case for a bare core
|
||||
}
|
||||
return entries
|
||||
.filter((e) => e.isDirectory() && ID.test(e.name))
|
||||
.filter((e) => fs.existsSync(path.join(loader.dir(), e.name, 'module.json')))
|
||||
.map((e) => e.name)
|
||||
.sort()
|
||||
}
|
||||
|
||||
/**
|
||||
* The absolute path of a module's `purge.sql`, or null.
|
||||
*
|
||||
@@ -348,6 +370,25 @@ async function install({ url, hosts, expect = null, fetchImpl = fetch }) {
|
||||
)
|
||||
}
|
||||
|
||||
// One module per site (org lead, 2026-09-23). A site is one game, and the
|
||||
// contract has singletons that assume it: `registerTeamProvider` holds ONE
|
||||
// value per deployment, and a second module registering one fails its whole
|
||||
// load — with modules loaded alphabetically, installing `rust` beside `uo`
|
||||
// would have taken `uo` down, not `rust`. So an install is an UPGRADE of the
|
||||
// module already here, or it is refused before a byte is downloaded.
|
||||
//
|
||||
// Refused here rather than in the admin controller so the declared module set
|
||||
// (modules/declared.js) gets the same answer: an environment naming two
|
||||
// modules installs the first and is told why the second was not.
|
||||
const others = installedIds().filter((id) => id !== manifest.id)
|
||||
if (others.length) {
|
||||
throw new InstallError(
|
||||
`this site already runs the module "${others.join('", "')}", and a site runs one module. ` +
|
||||
`Upgrade it with its own release, or remove it before installing "${manifest.id}".`,
|
||||
{ status: 409 },
|
||||
)
|
||||
}
|
||||
|
||||
const target = moduleDir(manifest.id)
|
||||
const scratch = await fsp.mkdtemp(path.join(loader.dir(), `.install-${manifest.id}-`))
|
||||
const tarball = path.join(scratch, 'bundle.tar.gz')
|
||||
@@ -444,6 +485,7 @@ module.exports = {
|
||||
removeDir,
|
||||
moduleDir,
|
||||
isInstalled,
|
||||
installedIds,
|
||||
purgeFile,
|
||||
MAX_MANIFEST_BYTES,
|
||||
MAX_ARTIFACT_BYTES,
|
||||
|
||||
@@ -41,11 +41,12 @@ 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.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 }),
|
||||
|
||||
Reference in New Issue
Block a user