7 Commits

Author SHA1 Message Date
1b692bf624 Merge pull request 'chore(modules): bump MODULE_API_VERSION to 1.4.0 — the sidecar rule' (#147) from chore/module-api-1.4.0 into edge
Reviewed-on: #147
2026-08-12 14:41:26 +00:00
5410e7e0b3 chore(modules): bump MODULE_API_VERSION to 1.4.0 — the sidecar rule
All checks were successful
PR Checks / client-build (pull_request) Successful in 25s
PR Checks / server-tests (pull_request) Successful in 29s
PR Checks / bot-install (pull_request) Successful in 8m45s
Phase 5 decision 4 (MODULE_SYSTEM.md §2.11.1): a module does not open a
connection to a game server from the website process. It talks to a sidecar,
which owns the durable copy of the game's state.

No member was added, removed or changed — the surface is identical to 1.3.0.
Minor rather than major because module-uo's `coreApi: "^1.3.0"` still resolves
and module-uo already complies, but a module written against 1.3.0 could
satisfy every member and still be built the wrong way round, which is what this
number now says.

The rule itself is MODULE_API.md §2.7 (docs, separate PR) and is the one
prohibition there with no CI behind it: an outbound socket is not statically
detectable the way an internal require is (§5.1).

742 server + 187 client tests pass; routes.manifest.json and swagger-output.json
regenerate byte-identical.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-12 09:37:47 -05:00
c3120ea3da Merge pull request 'fix(modules): stop a module before purging its tables' (#146) from fix/module-uninstall-stop-before-purge into edge
Reviewed-on: #146
2026-08-12 14:18:47 +00:00
a4da1cc438 fix(modules): stop a module before purging its tables
All checks were successful
PR Checks / bot-install (pull_request) Successful in 20s
PR Checks / client-build (pull_request) Successful in 28s
PR Checks / server-tests (pull_request) Successful in 33s
Uninstall-with-purge ran purge.sql while the module was still started: the
tables went, and the module kept serving and ingesting against a schema that no
longer existed until lifecycle.stop() finished — up to the five-second hook
budget. For module-uo that is the uo-link WebSocket writing shard events into
dropped tables, and requests in flight answering 500 where a stopped module
answers 404.

Nothing required the old order. The comment justified it as "purge while the SQL
is still readable", but removeDir is the only step that touches the filesystem,
so purge.sql stays readable until after the stop. The 400 for a module that
ships no purge.sql is now resolved before anything is stopped, so a refused
request leaves the module exactly as it found it.

Found while proving Phase 4's acceptance criterion 2 against the real
module-uo v0.3.0 release on an empty database (MODULE_SYSTEM.md §2.7.2).

742 server tests (+1); routes.manifest.json and swagger-output.json byte-identical.

AI disclosure: this contribution was AI-assisted (Claude Code).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-12 09:05:21 -05:00
12df79430f Merge pull request 'test(login): stop the backoff-guard test racing its own one-second lock' (#145) from fix/login-backoff-flake into edge
Reviewed-on: #145
2026-08-12 13:39:50 +00:00
ec2b530be7 test(login): stop the backoff-guard test racing its own one-second lock
All checks were successful
PR Checks / bot-install (pull_request) Successful in 15s
PR Checks / client-build (pull_request) Successful in 23s
PR Checks / server-tests (pull_request) Successful in 8m57s
A single recordFailure() locks for BASE_MS * 2 ** 0 — exactly one second — and
the test then does a real HTTP round trip against it. On CI that round trip took
1,456 ms and the guard correctly answered 200, failing the run for a reason that
has nothing to do with what the test is about.

Five failures lock for sixteen seconds. The subject is the guard's answer while
locked out, which is unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-12 08:16:59 -05:00
8bc09d8b53 Merge pull request 'feat(modules): the declarative Docker path (phase 4, slice 3)' (#144) from feature/module-docker-path into edge
Reviewed-on: #144
2026-08-12 13:02:23 +00:00
5 changed files with 80 additions and 23 deletions

View File

@@ -11,6 +11,10 @@
// that the two files can drift, so a test asserts they agree
// (client/test/moduleRegistry.test.js) rather than trusting a bump to remember
// both.
// 1.4.0 — a rule, not a member: §2.7 forbids a module opening a connection to a
// game server from the website process (it talks to a sidecar, which owns the
// durable copy). Nothing on window.__rg changed and nothing on the server's ctx
// changed either; this half bumps because the two halves state ONE version.
// 1.3.0 — three additions, all from Phase 3 slice 3 needing them: a nav item may
// carry an `icon` component (§3.3), core declares a third slot
// `player.invite.accepted` (§3.7), and `window.__rg.api` gained `BASE`, which
@@ -26,4 +30,4 @@
// but the two halves state ONE version: a module declares a single coreApi range
// and is served one chunk, so a client that claimed 1.0.0 while the server
// answered 1.1.0 would be two answers to one question.
export const MODULE_API_VERSION = '1.3.0'
export const MODULE_API_VERSION = '1.4.0'

View File

@@ -9,6 +9,15 @@
// Deliberately separate from PROTOCOL_VERSION (which versions the shard wire and
// has nothing to say about a website module) and from any module's own version.
// 1.4.0 — no member changed. §2.7 gained one prohibition: a module does not open
// a connection to a game server from the website process; it talks to a sidecar,
// which owns the durable copy of the game's state. Minor rather than major
// because the SURFACE is identical to 1.3.0 — module-uo's `coreApi: "^1.3.0"`
// still resolves, and it already complies — but a module written against 1.3.0
// could satisfy every member and still be built the wrong way round, which is
// what this number now says. The one §2.7 rule with no CI behind it: an outbound
// socket is not statically detectable the way an internal require is.
//
// 1.3.0 — three CLIENT additions from Phase 3 slice 3: a nav item may carry an
// `icon`, core declares a `player.invite.accepted` slot, and `window.__rg.api`
// gained `BASE` (which §3.5 always specified and shared.js never published).
@@ -26,6 +35,6 @@
// an admin action a module performs belongs in core's one audit log, the
// extension slot needs the user its prefix names, and §2.7 forbids a module
// reading core's `APP_BASE_URL` for itself. Additions only, so minor.
const MODULE_API_VERSION = '1.3.0'
const MODULE_API_VERSION = '1.4.0'
module.exports = { MODULE_API_VERSION }

View File

@@ -265,9 +265,13 @@ async function disable(req, res) {
// 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.
// The order below is the whole of it: locate the purge file, STOP, purge, then
// delete. Only the last step is destructive to the filesystem, so the file stays
// readable the whole way through — which is why stopping comes first. Dropping a
// module's tables while it is still started leaves it serving and ingesting
// against a schema that no longer exists: module-uo's uo-link WebSocket keeps
// writing shard events for as long as its onShutdown takes to run, and requests
// in flight answer 500 where a stopped module answers 404.
async function remove(req, res) {
const { id } = req.params
const purge = req.query.purge === 'true' || req.query.purge === '1'
@@ -276,23 +280,26 @@ async function remove(req, res) {
const onVolume = install.isInstalled(id)
if (!current && !onVolume) return res.status(404).json({ message: 'No such module.' })
let purged = null
// Resolved before anything happens, because this branch is a 400: a request
// that is going to be refused must not stop the module on its way out.
let purgeSql = null
if (purge) {
const file = install.purgeFile(id)
if (!file) {
purgeSql = install.purgeFile(id)
if (!purgeSql) {
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.
// Stop it before its tables and then 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 purged = purgeSql ? await schema.runPurge(purgeSql) : null
const removed = await install.removeDir(id)
// A purge leaves nothing: no directory, no tables, no data. Keeping a

View File

@@ -11,9 +11,11 @@
// - **enable must not touch the loader.** Disable ran the module's onShutdown;
// there is no onBoot re-dispatch, so flipping the record back would put a
// module with closed sockets and cleared timers back on the nav.
// - **purge must run before the directory is removed.** purge.sql lives inside
// that directory. Reorder those two lines and the feature silently stops
// working, with a 200 and no data deleted.
// - **uninstall's four steps have exactly one valid order**: stop, purge,
// remove the directory, remove the row. purge.sql lives inside that
// directory, so purging after the delete silently does nothing with a 200 —
// and purging before the stop drops the tables under a module that is still
// running. See the test itself for why neither is visible in a response.
//
// Point the DB at a closed port BEFORE requiring anything that builds the pool.
process.env.DB_HOST = '127.0.0.1'
@@ -333,10 +335,20 @@ test('a shutdown hook that failed is reported rather than swallowed', async () =
// ── uninstall and purge ────────────────────────────────────────────────────
test('uninstall purges BEFORE it removes the directory', async () => {
// The ordering that makes decision 5 work at all: purge.sql is a file inside
// the directory being deleted. Swap these two and the endpoint still answers
// 200 and deletes nothing.
test('uninstall stops the module, THEN purges, THEN removes the directory', async () => {
// Two orderings pinned by one assertion, because both are one line away from
// being wrong and neither failure is visible in the response:
//
// - purge BEFORE removeDir, or decision 5 stops working entirely — purge.sql
// is a file inside the directory being deleted, and the endpoint would
// still answer 200 having deleted nothing.
// - stop BEFORE purge, or the tables are dropped under a module that is
// still started. It keeps serving and ingesting against a schema that no
// longer exists for as long as its onShutdown takes (module-uo's uo-link
// WebSocket keeps writing shard events), and requests in flight answer 500
// where a stopped module answers 404. Nothing about the old order was
// required: purge.sql stays readable until removeDir, which is the only
// step that touches the filesystem.
const order = []
modules.get = async (id) => ({ id, state: 'started' })
install.isInstalled = () => true
@@ -349,12 +361,31 @@ test('uninstall purges BEFORE it removes the directory', async () => {
const res = mockRes()
await ctrl.remove(req({ params: { id: 'uo' }, query: { purge: 'true' } }), res)
assert.deepEqual(order, ['purge', 'stop', 'removeDir', 'removeRow'])
assert.deepEqual(order, ['stop', 'purge', 'removeDir', 'removeRow'])
assert.equal(res.body.purged, 12)
assert.equal(res.body.restartRequired, true)
assert.equal(logged[0].action, 'module.purge')
})
test('a refused purge does not stop the module on its way out', async () => {
// The 400 branch below is resolved BEFORE anything is stopped. A request that
// is going to be refused must leave the module exactly as it found it —
// otherwise "you cannot delete this module's data" would silently take the
// module down as a side effect of saying no.
const order = []
modules.get = async (id) => ({ id, state: 'started' })
install.isInstalled = () => true
install.purgeFile = () => null
lifecycle.stop = async () => { order.push('stop'); return { stopped: true, error: null } }
install.removeDir = async () => { order.push('removeDir'); return true }
const res = mockRes()
await ctrl.remove(req({ params: { id: 'uo' }, query: { purge: 'true' } }), res)
assert.equal(res.statusCode, 400)
assert.deepEqual(order, [])
})
test('a plain uninstall keeps the row and does not purge', async () => {
const order = []
modules.get = async (id) => ({ id, state: 'started' })

View File

@@ -60,7 +60,13 @@ test('backoffGuard returns a generic 429 while locked out', async () => {
a.post('/login', lp.backoffGuard, (req, res) => res.json({ ok: true }))
})
try {
lp.recordFailure('203.0.113.40') // lock the test client IP
// Lock the test client IP. FIVE failures, not one: the lock is
// `BASE_MS * 2 ** (count - 1)`, so a single failure locks for exactly one
// second and this test then races the round trip. It lost that race on CI
// (200 instead of 429, request arriving 1,456 ms after the lock). Five
// failures lock for sixteen seconds, which is not a race. What is under
// test is the guard's ANSWER while locked out, and that is unchanged.
for (let i = 0; i < 5; i += 1) lp.recordFailure('203.0.113.40')
const res = await fetch(`${app.url}/login`, {
method: 'POST',
headers: { 'X-Forwarded-For': '203.0.113.40' },