4 Commits

Author SHA1 Message Date
e4f088e90b 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
2026-09-23 01:26:52 -05:00
702ab89ae2 Merge pull request 'fix(admin): stop a long action name printing over the activity row beside it' (#203) from fix/activity-action-overflow into main
All checks were successful
sync-project-tree / sync (push) Successful in 13s
Build container images / build (push) Successful in 1m29s
Build container images / deploy (push) Successful in 46s
SonarQube / analysis (push) Successful in 9m11s
Reviewed-on: #203
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-09-21 22:24:29 +00:00
9e1f591b25 fix(admin): stop a long action name printing over the activity row beside it
All checks were successful
PR Checks / client-build (pull_request) Successful in 57s
PR Checks / server-tests (pull_request) Successful in 6m9s
PR Checks / bot-tests (pull_request) Successful in 8m3s
The dashboard renders an activity row action in a fixed `width: 110` span with
`flex: none` and no overflow handling, so a name wider than that overflows its
box and prints on top of the detail text next to it.

Core own actions all fit. A module one need not: `module-rust` writes
`rust.account.unlink.staff` when staff sever a player Steam link, and it
overlapped `steamId: …` on a live dashboard. `module-uo` `uoLink.account.link`
is already close to the edge.

`minWidth` instead of `width` keeps the column aligned for every short name and
lets a longer one push the detail right rather than sit under it. One property,
verified in a browser with both rows on screen.

Found while walking module-rust phase 6; raised here rather than worked around
there, because a module may legitimately name an action and shortening one
module names only moves the ceiling to the next one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
2026-09-21 09:10:43 -05:00
efa9db7330 Merge pull request 'chore(tools): delete the cliloc converter the Asset Bridge replaced (Asset Bridge cutover, 2 of 5)' (#202) from edge into main
All checks were successful
sync-project-tree / sync (push) Successful in 34s
Build container images / build (push) Successful in 22s
Build container images / deploy (push) Successful in 38s
SonarQube / analysis (push) Successful in 9m11s
Reviewed-on: #202
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-09-15 10:20:24 +00:00
6 changed files with 139 additions and 3 deletions

View File

@@ -166,7 +166,12 @@ export default function Dashboard() {
className="sans"
style={{ display: 'flex', gap: 14, alignItems: 'center', padding: '13px 18px', borderBottom: '1px solid var(--line-soft)', fontSize: '0.86rem' }}
>
<span style={{ flex: 'none', color: 'var(--accent)', fontSize: '0.66rem', fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', width: 110, fontFamily: 'ui-monospace,Menlo,monospace' }}>
{/* `minWidth` rather than `width`: the column still lines up for core's
own short action names, and a longer one — a module's namespaced
action, say `rust.account.unlink.staff` — grows the box instead of
overflowing it and printing on top of the detail beside it. Found
on a live dashboard with a module installed. */}
<span style={{ flex: 'none', color: 'var(--accent)', fontSize: '0.66rem', fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', minWidth: 110, fontFamily: 'ui-monospace,Menlo,monospace' }}>
{a.action}
</span>
<span style={{ flex: 1, color: 'var(--text)' }}>{formatDetail(a)}</span>

View File

@@ -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

View File

@@ -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,

View File

@@ -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 }),

View File

@@ -7211,7 +7211,7 @@
"Admin · Modules"
],
"summary": "Install or upgrade a module from a release install-manifest URL",
"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.",
"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.",
"responses": {
"201": {
"description": "Installed — restart to mount it",
@@ -7234,6 +7234,16 @@
}
}
},
"409": {
"description": "A different module is already installed; a site runs one module",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "Internal Server Error"
},

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', () => {