Phase 5 is the Android app's leg of this module's read path (R10), and it gates its Rust navigation on one capability string the way `module-uo`'s five shard rows gate on `shard`. There was no such string here: the five this module declared all name a SURFACE, and core flattens every started module's capabilities into one list, so `servers` is a word another module could declare tomorrow and silently reveal these screens on a site that does not run Rust. `rust` is the string only this module can mean. It is asserted against `module.json`'s own `id` rather than a literal, so the two cannot drift. The README says why it is not redundant with `id`: `id` is a mount prefix, and MODULE_API.md §2.9 forbids a client inferring a route from a capability. Gating on `id` would quietly make those the same thing. Decided by the org lead as D16, 2026-09-16. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
168 lines
7.9 KiB
JavaScript
168 lines
7.9 KiB
JavaScript
// ── The registration handshake ────────────────────────────────────────────
|
||
//
|
||
// The one suite every module should have, whatever else it does. Core validates
|
||
// all of this at boot and refuses to mount a module that fails — so testing it
|
||
// here is the difference between finding out in half a second and finding out on
|
||
// an operator's install.
|
||
|
||
const test = require('node:test')
|
||
const assert = require('node:assert')
|
||
|
||
const { fakeCtx, fakeApi } = require('./_fakes')
|
||
const manifest = require('../../module.json')
|
||
|
||
/** A fresh registration. `core.js` holds a module-level `ctx`, so reset it. */
|
||
function register(ctx = fakeCtx()) {
|
||
require('../core')._reset()
|
||
const api = fakeApi()
|
||
require('../index')(ctx, api)
|
||
return { api, ctx }
|
||
}
|
||
|
||
test('registers exactly the mounts module.json declares', () => {
|
||
const { api } = register()
|
||
|
||
// Core compares these two and rejects a mismatch in EITHER direction: a prefix
|
||
// declared and never registered is as fatal as a route registered and never
|
||
// declared. Asserting against the manifest rather than against a literal is
|
||
// what keeps the test true after a prefix is added.
|
||
assert.deepStrictEqual(
|
||
Object.keys(api.record.routes).sort(),
|
||
Object.keys(manifest.mounts).sort(),
|
||
)
|
||
for (const [tier, prefixes] of Object.entries(manifest.mounts)) {
|
||
assert.deepStrictEqual(Object.keys(api.record.routes[tier]).sort(), [...prefixes].sort())
|
||
}
|
||
})
|
||
|
||
test('all three tiers are mounted (R14)', () => {
|
||
const { api } = register()
|
||
|
||
// Not the assertion above restated. That one says the manifest and the code
|
||
// agree; this one says WHICH answer they agree on, so that deleting a tier from
|
||
// both halves at once still fails. R14 puts this module on all three from the
|
||
// start precisely so that a later phase adding a player surface does not have
|
||
// to move an address clients are already calling.
|
||
assert.deepStrictEqual(Object.keys(api.record.routes).sort(), ['admin', 'player', 'public'])
|
||
for (const tier of ['admin', 'player', 'public']) {
|
||
assert.deepStrictEqual(Object.keys(api.record.routes[tier]), ['/rust'])
|
||
}
|
||
})
|
||
|
||
test('every registered mount is a real express router', () => {
|
||
const { api } = register()
|
||
for (const byPrefix of Object.values(api.record.routes)) {
|
||
for (const [prefix, router] of Object.entries(byPrefix)) {
|
||
assert.strictEqual(typeof router, 'function', `${prefix} is not a router`)
|
||
assert.ok(router.stack, `${prefix} has no middleware stack`)
|
||
}
|
||
}
|
||
})
|
||
|
||
test('prefixes are one segment, lowercase, no parameters', () => {
|
||
// §2.4's rule, restated where a typo is cheap to find. Core enforces it, and a
|
||
// module that fails it does not mount at all.
|
||
for (const prefixes of Object.values(manifest.mounts)) {
|
||
for (const prefix of prefixes) {
|
||
assert.match(prefix, /^\/[a-z0-9][a-z0-9-]*$/, `illegal mount prefix ${prefix}`)
|
||
}
|
||
}
|
||
})
|
||
|
||
test('registration touches no database and awaits nothing', () => {
|
||
const ctx = fakeCtx()
|
||
register(ctx)
|
||
|
||
// §2.2's first rule. Core requires `app.js` with the pool pointed at a dead
|
||
// port in two build tools, so a query here would hang both — and the symptom is
|
||
// a build that never finishes rather than an error naming this module.
|
||
assert.deepStrictEqual(ctx.db.query.calls, [])
|
||
})
|
||
|
||
test('registers both lifecycle hooks', () => {
|
||
const { api } = register()
|
||
assert.strictEqual(typeof api.record.hooks.onBoot, 'function')
|
||
assert.strictEqual(typeof api.record.hooks.onShutdown, 'function')
|
||
})
|
||
|
||
test('the manifest declares what the loader requires', () => {
|
||
assert.match(manifest.id, /^[a-z][a-z0-9-]{1,31}$/)
|
||
assert.match(manifest.version, /^\d+\.\d+\.\d+/)
|
||
assert.ok(manifest.coreApi, 'coreApi is required — it is the version check')
|
||
// Declaring a schema without a purge is refused: a module that can create
|
||
// tables and cannot drop them leaves an operator with orphaned data.
|
||
if (manifest.schema) assert.ok(manifest.purge, 'a schema fragment requires a purge file')
|
||
// The chunk must be in a SUBDIRECTORY — the directory it sits in is what core
|
||
// serves, so an entry in the module root would publish the whole module.
|
||
if (manifest.client) assert.ok(manifest.client.entry.includes('/'), 'client.entry must be in a subdirectory')
|
||
})
|
||
|
||
test('the manifest declares no extension slot it does not fill', () => {
|
||
const { api } = register()
|
||
|
||
// §11.3 of the plan reads `extensions` as "declared, and held against reality
|
||
// by the loader". Only the first half is true: the loader checks that a named
|
||
// slot EXISTS (`registries.hasSlot`) and never checks that the module went on
|
||
// to fill it — `checkDeclared` covers `mounts` alone. So a declaration with
|
||
// nothing behind it loads cleanly and means nothing, which is exactly why this
|
||
// module does not write one until it has an extension to register.
|
||
//
|
||
// The other half of that correction: `admin.users.detail` is the ONLY server
|
||
// slot core declares. `site.footer.status` is a CLIENT slot and is registered
|
||
// from the chunk — naming it here would fail the load with
|
||
// `unknown extension slot "site.footer.status"`.
|
||
const declared = manifest.extensions || []
|
||
const filled = api.record.extensions.map((e) => e.slot)
|
||
assert.deepStrictEqual([...declared].sort(), [...filled].sort())
|
||
})
|
||
|
||
test('nothing is registered that has nothing behind it yet', () => {
|
||
const { api } = register()
|
||
|
||
// The phase-1 statement, written down so that removing it is deliberate. A
|
||
// declared trigger nothing emits and a declared slot nothing fills are both
|
||
// surfaces an operator can configure and then wait on — worse than an absent
|
||
// one, because the absence is visible. Each of these arrives with the phase
|
||
// that has something real to put in it, and this assertion is what that phase
|
||
// deletes.
|
||
assert.strictEqual(api.record.teamProvider, null)
|
||
assert.strictEqual(api.record.triggers, null)
|
||
assert.strictEqual(api.record.audiences, null)
|
||
assert.strictEqual(api.record.engagementSeeds, null)
|
||
assert.strictEqual(api.record.streams, null)
|
||
assert.strictEqual(api.record.eventBudgets, null)
|
||
assert.strictEqual(api.record.eventOptionSources, null)
|
||
assert.strictEqual(api.record.eventLeases, null)
|
||
assert.strictEqual(api.record.eventActions, null)
|
||
})
|
||
|
||
test('the module’s protocol version agrees with the manifest it ships beside', () => {
|
||
const sidecar = require('../sidecarClient')
|
||
|
||
// The wire version is declared in three repos — here, `PROTOCOL_VERSION` in
|
||
// the sidecar, and `overlay.toml` in the plugin overlay — and nothing in one
|
||
// repo can check the other two. What CAN be checked is that this repo says one
|
||
// thing: the number the client sends is the number an operator sees on a
|
||
// freshly created server row, so a bump that edits one and not the other
|
||
// configures every new server against a version the client does not speak.
|
||
assert.strictEqual(typeof sidecar.PROTOCOL_VERSION, 'number')
|
||
assert.ok(sidecar.PROTOCOL_VERSION >= 1)
|
||
})
|
||
|
||
test('an identity capability is declared, and it is the module id (phase 5, D16)', () => {
|
||
// Core flattens every started module's capabilities into ONE list, so a client
|
||
// asking "is this module installed" needs a string only this module can
|
||
// declare. `servers` is not that string — it names a surface, and another
|
||
// module could name it too — which is the whole reason this one exists beside
|
||
// the five surface words.
|
||
//
|
||
// It is asserted against `manifest.id` rather than against the literal "rust"
|
||
// so that the two cannot drift: the day the id changes, the capability a
|
||
// client gates a whole navigation group on has to change with it.
|
||
assert.ok(
|
||
manifest.capabilities.includes(manifest.id),
|
||
`module.json must declare "${manifest.id}" as a capability — it is the only string a client can` +
|
||
' use to tell this module apart from any other, and the Android app gates its Rust rows on it',
|
||
)
|
||
})
|