ACCOUNT_DELETION.md §4.4 (AD10, AD11). api.onUserDeleting queues every linked
account in the new shard_pending_unlinks table, then calls the sidecar's
DELETE /link/{account} (actor web:account-deletion) and drops the mirror on 200/404.
A shard that is away leaves the row pending, retried on WS reconnect and on a live
server.hello; a protected account (403) is refused and listed on Admin -> Shard as
still linked on the shard. A live mob.login without the deleted user's webId clears
the row. coreApi ^1.20.0; core-ref pinned to website#232's merge (7bd93b8).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
223 lines
9.5 KiB
JavaScript
223 lines
9.5 KiB
JavaScript
// The entry point's contract with core (MODULE_API.md §2.2).
|
|
//
|
|
// Slice 0 registers nothing, so there is very little behaviour to assert — and
|
|
// the rules that DO apply are the ones that would otherwise be discovered on an
|
|
// operator's install: registering synchronously, never awaiting, never touching
|
|
// a database, never mutating what it was handed. Those hold for every slice
|
|
// after this one too, which is why they are tested against the entry point
|
|
// rather than against whatever it happens to register today.
|
|
|
|
const test = require('node:test')
|
|
const assert = require('node:assert')
|
|
|
|
const register = require('../index')
|
|
const { fakeCtx, fakeApi } = require('./_fakes')
|
|
|
|
test('exports a single register function', () => {
|
|
assert.strictEqual(typeof register, 'function')
|
|
})
|
|
|
|
test('registers synchronously and returns nothing to await', () => {
|
|
const result = register(fakeCtx(), fakeApi())
|
|
// Not `assert.strictEqual(result, undefined)` alone: a module that returned a
|
|
// promise would be a module whose registration core silently never waits for.
|
|
assert.ok(!result || typeof result.then !== 'function', 'register() must not return a thenable')
|
|
})
|
|
|
|
test('touches no database at registration time', () => {
|
|
const ctx = fakeCtx()
|
|
register(ctx, fakeApi())
|
|
assert.deepStrictEqual(ctx.db.query.calls, [], 'register() queried the database')
|
|
})
|
|
|
|
test('registers exactly what module.json declares', () => {
|
|
// The loader compares these two in BOTH directions and rejects a mismatch
|
|
// either way, so a prefix registered without being declared and a prefix
|
|
// declared without being registered are both module-breaking. Asserting
|
|
// against the manifest rather than a literal list means the test cannot drift
|
|
// from the file core actually reads.
|
|
const api = fakeApi()
|
|
register(fakeCtx(), api)
|
|
|
|
const manifest = require('../../module.json')
|
|
for (const tier of ['public', 'admin', 'player']) {
|
|
assert.deepStrictEqual(
|
|
Object.keys(api.record.routes[tier]).sort(),
|
|
[...manifest.mounts[tier]].sort(),
|
|
`${tier} mounts disagree with module.json`,
|
|
)
|
|
for (const router of Object.values(api.record.routes[tier])) {
|
|
assert.strictEqual(typeof router, 'function', `${tier} router is not a router`)
|
|
}
|
|
}
|
|
|
|
assert.deepStrictEqual(api.record.extensions.map((e) => e.slot), manifest.extensions)
|
|
assert.deepStrictEqual(api.record.legs.map((l) => l.leg), ['towncrier'])
|
|
|
|
// The event contract (MODULE_API 1.10.0, EVENTS_PLAN.md Phase 9). Asserted
|
|
// here rather than only in the actions' own suite because registration is the
|
|
// half that can silently not happen: a declaration file nothing calls is a
|
|
// deployment whose event authors simply never see the verbs, with no error
|
|
// anywhere.
|
|
assert.deepStrictEqual(
|
|
api.record.eventActions.map((a) => a.id).sort(),
|
|
[
|
|
'uo.boss.spawn',
|
|
'uo.broadcast',
|
|
'uo.creature.spawn',
|
|
'uo.decor.place',
|
|
'uo.gate.open',
|
|
'uo.item.grant',
|
|
'uo.news.post',
|
|
'uo.npc.place',
|
|
'uo.participation.collect',
|
|
'uo.participation.open',
|
|
'uo.towncrier.post',
|
|
'uo.world.save',
|
|
],
|
|
)
|
|
// Phase 12a's five are all the MODULE's dimensions, never core's (org lead,
|
|
// 2026-09-07): core meters whatever a module declares and knows nothing about
|
|
// Ultima Online. Asserted as an ordered list because the order is the order
|
|
// an author meets them in a cap meter.
|
|
assert.deepStrictEqual(api.record.eventBudgets.map((b) => b.id), [
|
|
'uo.broadcasts',
|
|
'uo.creatures',
|
|
'uo.bosses',
|
|
'uo.npcs',
|
|
'uo.decor',
|
|
'uo.gate.minutes',
|
|
'uo.rewards',
|
|
])
|
|
// Phase 11b. One key, because ServUO has almost no others: of the 158 non-Bridge
|
|
// `Config.Get` call sites in `Scripts/`, roughly eight are read live, and a lease
|
|
// on any of the rest applies cleanly and does nothing.
|
|
// Phase 12b adds five TARGETED leases beside it -- a key that names a capability
|
|
// over many things, with the target supplied per step. Four spawner properties
|
|
// (`MaxCount`, not the `Amount` EVENTS_PLAN.md named: there is no such property
|
|
// on ServUO 57.4) and the seasonal status, which is a three-value enum over eight
|
|
// events rather than the nine-value one section G described.
|
|
assert.deepStrictEqual(api.record.eventLeases.map((l) => l.id), [
|
|
'uo.playercaps.skillcap',
|
|
'uo.spawner.maxcount',
|
|
'uo.spawner.mindelay',
|
|
'uo.spawner.maxdelay',
|
|
'uo.spawner.running',
|
|
'uo.seasonal.status',
|
|
])
|
|
// Only the targeted ones declare a target, and every one of them names a source:
|
|
// a target field with no list behind it is the free-text box the option-source
|
|
// contract exists to replace.
|
|
for (const lease of api.record.eventLeases) {
|
|
if (lease.id === 'uo.playercaps.skillcap') {
|
|
assert.strictEqual(lease.target, undefined, 'a config lease has no target')
|
|
continue
|
|
}
|
|
assert.ok(lease.target && lease.target.label, `${lease.id} has no target label`)
|
|
assert.ok(lease.target.source, `${lease.id} has no target source`)
|
|
}
|
|
assert.deepStrictEqual(
|
|
api.record.eventOptionSources.map((s) => s.id).sort(),
|
|
[
|
|
'uo.options.creatures',
|
|
'uo.options.decor',
|
|
'uo.options.items',
|
|
'uo.options.landmarks',
|
|
'uo.options.regions',
|
|
'uo.options.seasonal',
|
|
'uo.options.spawners',
|
|
],
|
|
)
|
|
assert.ok(api.record.streams.length > 0)
|
|
assert.strictEqual(typeof api.record.hooks.onBoot, 'function')
|
|
assert.strictEqual(typeof api.record.hooks.onShutdown, 'function')
|
|
// MODULE_API 1.20.0: a deleted account's game links are unlinked on the shard.
|
|
assert.strictEqual(api.record.hooks.onUserDeleting, require('../utils/accountDeletion').onUserDeleting)
|
|
})
|
|
|
|
test('every registered stream is namespaced or grandfathered', () => {
|
|
// Core rejects a stream id that carries neither this module's prefix nor a
|
|
// §6.5 grandfathered name. The seven legacy ids are stored in
|
|
// `notification_subs` and read by a shipped Android client, so they are
|
|
// allowlisted rather than renamed — but a NEW id must be namespaced, and this
|
|
// is where that is caught before an install refuses to load the module.
|
|
// Copied from core's loader (LEGACY_STREAM_IDS), deliberately rather than
|
|
// imported — this repo has no dependency on core's source, and a copy that
|
|
// drifts is caught by the module failing to load, which is the failure this
|
|
// test exists to move earlier.
|
|
const GRANDFATHERED = new Set([
|
|
'server.status', 'idoc.warning', 'champ.start', 'governor.election',
|
|
'vendor.sale', 'house.idoc', 'account.login',
|
|
])
|
|
const api = fakeApi()
|
|
register(fakeCtx(), api)
|
|
for (const s of api.record.streams) {
|
|
assert.ok(
|
|
s.id.startsWith('uo.') || GRANDFATHERED.has(s.id),
|
|
`stream "${s.id}" is neither namespaced "uo." nor grandfathered`,
|
|
)
|
|
assert.ok(s.label && s.description, `stream "${s.id}" is missing its wire shape`)
|
|
assert.strictEqual(typeof s.personal, 'boolean')
|
|
assert.strictEqual(typeof s.requiresLinkedAccount, 'boolean')
|
|
}
|
|
})
|
|
|
|
test('registers a Team provider with all three methods', () => {
|
|
// Core requires all three: a provider that could list Teams but not their
|
|
// members would leave core holding Teams it can never populate, which is not
|
|
// the same as a call that fails. Asserted here so a refactor that drops one
|
|
// fails in this suite rather than at load on an operator's install.
|
|
const api = fakeApi()
|
|
register(fakeCtx(), api)
|
|
|
|
const provider = api.record.teamProvider
|
|
assert.ok(provider, 'a UO guild is a Team; something has to answer for them')
|
|
for (const method of ['getTeams', 'getTeamMembers', 'getTeamLeaders']) {
|
|
assert.strictEqual(typeof provider[method], 'function', `${method} is missing`)
|
|
}
|
|
})
|
|
|
|
test('registration does not call the provider, or touch the database', async () => {
|
|
// register() runs while core's app.js is still being required, with the pool
|
|
// pointed at a dead port — routeManifest.js and swagger.js both depend on that.
|
|
// Registration is a CLAIM; core does not ask anything until it reconciles,
|
|
// which is after onBoot.
|
|
const ctx = fakeCtx()
|
|
let queried = false
|
|
const frozen = Object.freeze({ ...ctx, db: Object.freeze({ query: async () => { queried = true; return [] } }) })
|
|
const api = fakeApi()
|
|
|
|
register(frozen, api)
|
|
assert.equal(queried, false, 'a query at registration time would hang the manifest and the spec build')
|
|
})
|
|
|
|
test('takes a frozen ctx and does not try to write to it', () => {
|
|
const ctx = fakeCtx()
|
|
assert.ok(Object.isFrozen(ctx))
|
|
// Core freezes one level deep; a module that assigned to ctx would throw here
|
|
// in strict mode and fail silently outside it. Either way it must not.
|
|
assert.doesNotThrow(() => register(ctx, fakeApi()))
|
|
})
|
|
|
|
test('logs through ctx.log, never through console', () => {
|
|
const ctx = fakeCtx()
|
|
register(ctx, fakeApi())
|
|
assert.strictEqual(ctx.logs.length, 1, 'expected exactly one logger to be taken')
|
|
const { log } = ctx.logs[0]
|
|
assert.strictEqual(log.info.calls.length, 1)
|
|
assert.strictEqual(log.info.calls[0][0], 'registered')
|
|
})
|
|
|
|
test('carries no hidden state between calls', () => {
|
|
// Core calls register() exactly once, and the `once()` guard that enforces
|
|
// that lives in core's `api` — not here. What this asserts is the module's
|
|
// own half of it: registering into a second `api` produces the same result as
|
|
// the first, so nothing is memoised at file scope where a re-register would
|
|
// silently do less than it appears to.
|
|
const first = fakeApi()
|
|
const second = fakeApi()
|
|
register(fakeCtx(), first)
|
|
register(fakeCtx(), second)
|
|
assert.deepStrictEqual(second.record, first.record)
|
|
})
|