Compare commits
1 Commits
feat/publi
...
fix/one-mo
| Author | SHA1 | Date | |
|---|---|---|---|
| e4f088e90b |
@@ -103,18 +103,9 @@ function phaseLabel(spec, phaseId) {
|
||||
return (phase && (phase.label || phase.id)) || null
|
||||
}
|
||||
|
||||
/**
|
||||
* One calendar entry, from a materialised run.
|
||||
*
|
||||
* **`runId` is published because the event page already publishes it** on every
|
||||
* occurrence, and `?run=` takes it. The calendar was the one public shape that
|
||||
* named a run without saying which, so a client holding a run id from elsewhere
|
||||
* (a module's map marker) had no way to find its event but to fetch every event
|
||||
* page. A projection has none: nothing is committed to it.
|
||||
*/
|
||||
/** One calendar entry, from a materialised run. */
|
||||
const publicRunEntry = (run) => ({
|
||||
kind: 'run',
|
||||
runId: run.id,
|
||||
title: run.definition_title,
|
||||
slug: run.definition_slug,
|
||||
seriesName: run.series_name || null,
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
@@ -26295,23 +26305,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"runId": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
},
|
||||
"example": {
|
||||
"type": "number",
|
||||
"example": 3692
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Runs only: the run this entry is, the same id `PublicEventOccurrence.runId` carries and `/events/{slug}?run=` takes. A projected entry has none, because nothing is committed to it."
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -1247,12 +1247,6 @@ const doc = {
|
||||
'One calendar entry. `kind` says which of two things it is: a `run` is a materialised occurrence, a `projected` entry is arithmetic past the materialisation horizon — a forecast with nothing committed to it, which a client should draw as such.',
|
||||
properties: {
|
||||
kind: { type: 'string', enum: ['run', 'projected'], example: 'run' },
|
||||
runId: {
|
||||
type: 'integer',
|
||||
example: 3692,
|
||||
description:
|
||||
'Runs only: the run this entry is, the same id `PublicEventOccurrence.runId` carries and `/events/{slug}?run=` takes. A projected entry has none, because nothing is committed to it.',
|
||||
},
|
||||
title: { type: 'string', example: 'The Yew Invasion' },
|
||||
slug: { type: 'string', example: 'the-yew-invasion' },
|
||||
seriesName: { type: 'string', nullable: true, example: 'The Yew Campaign' },
|
||||
|
||||
@@ -164,32 +164,10 @@ test('a calendar entry carries no operational field at all', async () => {
|
||||
// The whole security property of this file, asserted positively: the entry has
|
||||
// exactly these keys and gaining one is a deliberate act.
|
||||
assert.deepEqual(Object.keys(entry).sort(), [
|
||||
'kind', 'live', 'runId', 'scheduledFor', 'seriesName', 'seriesSlug', 'slug', 'status', 'timezone',
|
||||
'title',
|
||||
'kind', 'live', 'scheduledFor', 'seriesName', 'seriesSlug', 'slug', 'status', 'timezone', 'title',
|
||||
])
|
||||
})
|
||||
|
||||
test('a run entry names its run, and a projection names none', async () => {
|
||||
// Rust phase 15, D125: a map marker carries core's run id, and the app finds
|
||||
// the event it belongs to from this calendar. The id is the one the event page
|
||||
// already publishes on each occurrence.
|
||||
store.definitions[0].spec = {
|
||||
...SPEC,
|
||||
schedule: { kind: 'weekly', days: ['saturday'], time: '00:00' },
|
||||
}
|
||||
const result = await publicModel.calendar({ from: '2026-08-28', to: '2026-09-15', now: NOW })
|
||||
const runs = result.entries.filter((e) => e.kind === 'run')
|
||||
const projected = result.entries.filter((e) => e.kind === 'projected')
|
||||
assert.equal(runs.length, 1)
|
||||
assert.equal(runs[0].runId, store.runs[0].id)
|
||||
assert.ok(projected.length > 0, 'the weekly schedule must forecast past the one run')
|
||||
for (const entry of projected) assert.equal('runId' in entry, false)
|
||||
|
||||
const page = await publicModel.event('the-yew-invasion')
|
||||
const occurrences = [page.event.current, page.event.next, ...page.event.upcoming, ...page.event.past]
|
||||
assert.ok(occurrences.some((o) => o && o.runId === runs[0].runId))
|
||||
})
|
||||
|
||||
test('the default window reaches back as well as forward', async () => {
|
||||
// §I: this route is "upcoming, live and recent". The default used to start at
|
||||
// `now`, which left no room for the third word — an event that finished an hour
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user