fix(modules): a site runs one module; the installer refuses a second
All checks were successful
PR Checks / client-build (pull_request) Successful in 33s
PR Checks / bot-tests (pull_request) Successful in 34s
PR Checks / server-tests (pull_request) Successful in 13m28s

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:
2026-09-23 01:26:52 -05:00
parent 702ab89ae2
commit e4f088e90b
5 changed files with 133 additions and 2 deletions

View File

@@ -395,6 +395,79 @@ test('a failed upgrade leaves the previous version in place', async () => {
assert.deepEqual(fs.readdirSync(tmpRoot), ['uo'])
})
// ── One module per site ────────────────────────────────────────────────────
/** A second, different module's manifest and artifact, served beside the first. */
function otherModuleRoutes(id = 'rust') {
const tarball = bundle({ id })
const artifact = `https://releases.example.com/mod/${id}-1.0.0.tar.gz`
const url = `https://releases.example.com/mod/${id}-1.0.0.json`
return {
url,
routes: {
[url]: manifestFor(tarball, { id, name: id, artifact: `${id}-1.0.0.tar.gz`, url: artifact }),
[artifact]: tarball,
},
artifact,
}
}
test('a second, different module is refused before anything is downloaded', async () => {
const first = goodRoutes()
await install.install({ url: MANIFEST_URL, hosts: HOSTS, fetchImpl: fakeFetch(first.routes) })
const other = otherModuleRoutes('rust')
const fetchImpl = fakeFetch(other.routes)
await assert.rejects(
() => install.install({ url: other.url, hosts: HOSTS, fetchImpl }),
(err) => {
assert.equal(err.name, 'InstallError')
// 409: nothing is wrong with the URL; the SITE is not in a state to take it.
assert.equal(err.status, 409)
assert.match(err.message, /already runs the module "uo"/)
assert.match(err.message, /remove it before installing "rust"/)
return true
},
)
// Refused on the manifest alone: the artifact was never fetched, and the
// volume holds exactly what it held before.
assert.ok(!fetchImpl.seen.includes(other.artifact), 'the artifact was not downloaded')
assert.deepEqual(fs.readdirSync(tmpRoot), ['uo'])
})
test('the same module is still an upgrade, and removing it frees the site for another', async () => {
const first = goodRoutes()
await install.install({ url: MANIFEST_URL, hosts: HOSTS, fetchImpl: fakeFetch(first.routes) })
// An upgrade of what is installed is exactly what the rule allows.
const second = goodRoutes({ version: '2.0.0', manifest: { version: '2.0.0' } })
second.routes[MANIFEST_URL] = manifestFor(second.tarball, { version: '2.0.0' })
const upgraded = await install.install({ url: MANIFEST_URL, hosts: HOSTS, fetchImpl: fakeFetch(second.routes) })
assert.equal(upgraded.replaced, true)
// And once it is gone, the site takes a different one.
await install.removeDir('uo')
const other = otherModuleRoutes('rust')
const result = await install.install({ url: other.url, hosts: HOSTS, fetchImpl: fakeFetch(other.routes) })
assert.equal(result.id, 'rust')
assert.deepEqual(install.installedIds(), ['rust'])
})
test('what counts as installed is what the loader would scan', () => {
// A real module, an install's scratch directory, a swap's aside copy and a
// directory with no module.json. Only the first is a module.
fs.mkdirSync(path.join(tmpRoot, 'uo'))
fs.writeFileSync(path.join(tmpRoot, 'uo', 'module.json'), '{}')
fs.mkdirSync(path.join(tmpRoot, '.install-rust-abc'))
fs.writeFileSync(path.join(tmpRoot, '.install-rust-abc', 'module.json'), '{}')
fs.mkdirSync(path.join(tmpRoot, 'uo.replaced-123'))
fs.writeFileSync(path.join(tmpRoot, 'uo.replaced-123', 'module.json'), '{}')
fs.mkdirSync(path.join(tmpRoot, 'notes'))
assert.deepEqual(install.installedIds(), ['uo'])
})
// ── The volume ─────────────────────────────────────────────────────────────
test('moduleDir refuses an id that is not one', () => {