Phase 2 PR 4 of docs/website/MODULE_SYSTEM.md §2.7. Adds server/src/modules/registries.js and moves core's own notification streams, announce leg and users-detail routes behind it, so the three seams §1.8 and §1.9 named are exercised on every boot before any module depends on them. Registering is validate-then-commit per registrant: the loader stages what a module claims and the second pass commits it, so a module that throws halfway through register() — or fails checkDeclared after it — leaves nothing behind. That is the registry-side twin of PR 2's second-pass mount rule. Four decisions, all the recommended option: - announce legs became a child table. `announce_job_legs` replaces the towncrier_*/discord_* column groups, so the leg set is data: core registers `discord`, module-uo will register `towncrier`, and a module cannot ALTER a core table to add its own. Backfill is guarded on information_schema (a SELECT of a dropped column is a parse error, not a runtime one) and the columns go with DROP COLUMN IF EXISTS. Verified against the live dev DB: three legacy jobs migrated faithfully, three replays, no duplicates. - `mapEvent` dropped from registerNotificationStreams. §1.8 already inverts the push path so a module owns fromShardEvent and calls core's publish() with a stream id it resolved; a second mapping mechanism was a leftover. The public safety filter, the kinds it reads and the streams it protects now live in one file and move together. - core registers through the same staging area a module uses, via an explicit registries.registerCore() in app.js before modules.load(). - core's six /admin/users/:id/shard/* paths now go through the `admin.users.detail` slot, and getUser moved back to admin.controller.js. Found on the way, and the reason two build tools changed: - scripts/routeManifest.js could not decode a parameterised mount. Its unwinder expected `(?:([^\/]+?))`; express 4.22 emits `(?:\/([^/]+?))` with the separator inside the group. The branch had never run. It threw rather than guessing, which is what it is for. - swagger-autogen cannot follow a route into an extension slot — the slot's router is created by declareSlot() and filled later, so there is no literal mount for a static parse. Regenerating deleted 407 lines and printed `Swagger-autogen: Success`, the spike's exact failure (MODULE_API.md §7.4). swagger/slotSpecs.js generates a fragment per filled slot and re-roots it at the prefix the router actually hangs at in the live app — read from the express stack via routeManifest's own mountPath, so the manifest and the spec cannot disagree. swagger/mergeSpec.js is the merge helper core owes for module fragments anyway (§6.1a), proved here against core's own slot first. 884 tests pass (856 before). routes.manifest.json is unchanged at 229 routes. The OpenAPI spec diff is two lines of intent: the retry endpoint's summary, and its `leg` no longer being a fixed enum. Co-Authored-By: Claude <noreply@anthropic.com>
239 lines
11 KiB
JavaScript
239 lines
11 KiB
JavaScript
// Point the DB at a closed port before requiring anything that builds the pool —
|
||
// these tests inject fake models, so no real query should ever run.
|
||
process.env.DB_HOST = '127.0.0.1'
|
||
process.env.DB_PORT = '59999'
|
||
|
||
const { test, after } = require('node:test')
|
||
const assert = require('node:assert/strict')
|
||
|
||
const { mapShardEvent, createTracker } = require('../src/config/shardStreams')
|
||
const pushDispatch = require('../src/utils/pushDispatch')
|
||
// fromShardEvent moved out of pushDispatch in PR 4: core publishes, the shard
|
||
// side resolves an event to a stream and an owner (MODULE_SYSTEM.md §1.8).
|
||
const shardPush = require('../src/utils/shardPush')
|
||
const db = require('../src/utils/db')
|
||
|
||
after(() => db.close())
|
||
|
||
// Run body with env keys set, then restore prior values.
|
||
function withEnv(vars, fn) {
|
||
const prior = {}
|
||
for (const [k, v] of Object.entries(vars)) {
|
||
prior[k] = process.env[k]
|
||
if (v === undefined) delete process.env[k]
|
||
else process.env[k] = v
|
||
}
|
||
try {
|
||
return fn()
|
||
} finally {
|
||
for (const [k, v] of Object.entries(prior)) {
|
||
if (v === undefined) delete process.env[k]
|
||
else process.env[k] = v
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── mapShardEvent ───────────────────────────────────────────────────────────
|
||
|
||
test('server.hello / shutdown / crashed map to the public server.status stream', () => {
|
||
const t = createTracker()
|
||
assert.deepEqual(mapShardEvent({ kind: 'server.hello', bootId: 'b1' }, t), [
|
||
{ streamId: 'server.status', ref: 'up:b1' },
|
||
])
|
||
assert.deepEqual(mapShardEvent({ kind: 'server.shutdown' }, t), [{ streamId: 'server.status', ref: 'down' }])
|
||
assert.deepEqual(mapShardEvent({ kind: 'server.crashed' }, t), [{ streamId: 'server.status', ref: 'down' }])
|
||
})
|
||
|
||
test('house.decay INTO idoc yields the public idoc.warning AND the owner-keyed house.idoc', () => {
|
||
const t = createTracker()
|
||
const out = mapShardEvent({ kind: 'house.decay', to: 'IDOC', serial: '0x40', ownerAcct: 'bob' }, t)
|
||
assert.deepEqual(out, [
|
||
{ streamId: 'idoc.warning', ref: '0x40' },
|
||
{ streamId: 'house.idoc', ref: '0x40', ownerAccount: 'bob' },
|
||
])
|
||
// A non-IDOC decay stage produces nothing.
|
||
assert.deepEqual(mapShardEvent({ kind: 'house.decay', to: 'Fairly', serial: '0x41' }, t), [])
|
||
})
|
||
|
||
test('champ.update fires champ.start only on the inactive→active transition', () => {
|
||
const t = createTracker()
|
||
// First sight active → start.
|
||
assert.deepEqual(mapShardEvent({ kind: 'champ.update', serial: 'c1', active: true }, t), [
|
||
{ streamId: 'champ.start', ref: 'c1' },
|
||
])
|
||
// Still active → no re-fire.
|
||
assert.deepEqual(mapShardEvent({ kind: 'champ.update', serial: 'c1', active: true }, t), [])
|
||
// Goes inactive, then active again → fires again.
|
||
assert.deepEqual(mapShardEvent({ kind: 'champ.update', serial: 'c1', active: false }, t), [])
|
||
assert.deepEqual(mapShardEvent({ kind: 'champ.update', serial: 'c1', active: true }, t), [
|
||
{ streamId: 'champ.start', ref: 'c1' },
|
||
])
|
||
})
|
||
|
||
test('city.update fires governor.election only on a real governor change, never on first sight', () => {
|
||
const t = createTracker()
|
||
// First sight of the city → no election (could be a reconnect snapshot).
|
||
assert.deepEqual(mapShardEvent({ kind: 'city.update', city: 'Britain', governor: { serial: '0x1' } }, t), [])
|
||
// Same governor → nothing.
|
||
assert.deepEqual(mapShardEvent({ kind: 'city.update', city: 'Britain', governor: { serial: '0x1' } }, t), [])
|
||
// New governor → election.
|
||
assert.deepEqual(mapShardEvent({ kind: 'city.update', city: 'Britain', governor: { serial: '0x2' } }, t), [
|
||
{ streamId: 'governor.election', ref: 'Britain' },
|
||
])
|
||
})
|
||
|
||
test('personal streams are owner-keyed and sensitive kinds never yield a public target', () => {
|
||
const t = createTracker()
|
||
const sale = mapShardEvent({ kind: 'vendor.sale', ownerAcct: 'bob', t: 7 }, t)
|
||
assert.deepEqual(sale, [{ streamId: 'vendor.sale', ref: '7', ownerAccount: 'bob' }])
|
||
|
||
const login = mapShardEvent({ kind: 'account.login.attempt', acct: 'bob', ip: '1.2.3.4', t: 9 }, t)
|
||
assert.deepEqual(login, [{ streamId: 'account.login', ref: '9', ownerAccount: 'bob' }])
|
||
|
||
// Every personal target carries an ownerAccount (never a bare public push).
|
||
for (const target of [...sale, ...login]) assert.ok(target.ownerAccount, 'personal target must be owner-keyed')
|
||
|
||
// A truly sensitive, unmapped kind produces nothing at all.
|
||
assert.deepEqual(mapShardEvent({ kind: 'cheat.fastwalk', acct: 'bob' }, t), [])
|
||
assert.deepEqual(mapShardEvent({ kind: 'admin.audit', actor: 'staff' }, t), [])
|
||
})
|
||
|
||
// ── isAllowedEndpoint (SSRF guard) ──────────────────────────────────────────
|
||
|
||
test('isAllowedEndpoint pins the configured ntfy origin and rejects everything else', () => {
|
||
withEnv({ NTFY_BASE_URL: 'https://ntfy.example.com', NTFY_ALLOWED_ORIGINS: undefined }, () => {
|
||
assert.equal(pushDispatch.isAllowedEndpoint('https://ntfy.example.com/UPabc'), true)
|
||
assert.equal(pushDispatch.isAllowedEndpoint('https://evil.example.com/x'), false) // wrong origin
|
||
assert.equal(pushDispatch.isAllowedEndpoint('http://ntfy.example.com/x'), false) // not https
|
||
assert.equal(pushDispatch.isAllowedEndpoint('https://127.0.0.1/x'), false) // loopback
|
||
assert.equal(pushDispatch.isAllowedEndpoint('not a url'), false)
|
||
})
|
||
})
|
||
|
||
test('isAllowedEndpoint (no allow-set) permits any public https host but blocks private/loopback/http', () => {
|
||
withEnv({ NTFY_BASE_URL: undefined, NTFY_ALLOWED_ORIGINS: undefined }, () => {
|
||
assert.equal(pushDispatch.isAllowedEndpoint('https://relay.somehost.net/UPabc'), true)
|
||
assert.equal(pushDispatch.isAllowedEndpoint('http://relay.somehost.net/x'), false)
|
||
assert.equal(pushDispatch.isAllowedEndpoint('https://10.0.0.5/x'), false)
|
||
assert.equal(pushDispatch.isAllowedEndpoint('https://192.168.1.10/x'), false)
|
||
assert.equal(pushDispatch.isAllowedEndpoint('https://localhost/x'), false)
|
||
})
|
||
})
|
||
|
||
// ── publish ─────────────────────────────────────────────────────────────────
|
||
|
||
function captureFetch() {
|
||
const calls = []
|
||
return {
|
||
calls,
|
||
fetchImpl: async (url, opts) => {
|
||
calls.push({ url, opts })
|
||
return { ok: true, status: 200 }
|
||
},
|
||
}
|
||
}
|
||
|
||
test('publish sends a content-free tickle to public subscribers', async () => {
|
||
await withEnv({ NTFY_BASE_URL: undefined, NTFY_ALLOWED_ORIGINS: undefined }, async () => {
|
||
const { calls, fetchImpl } = captureFetch()
|
||
const streamCalls = []
|
||
const pushDevices = {
|
||
endpointsForStream: async (s) => {
|
||
streamCalls.push(s)
|
||
return [{ endpoint: 'https://relay.test/UPa', transport: 'unifiedpush' }]
|
||
},
|
||
endpointsForUserStream: async () => [],
|
||
}
|
||
await pushDispatch.publish('news.post', { ref: '5' }, { pushDevices, fetchImpl })
|
||
assert.deepEqual(streamCalls, ['news.post'])
|
||
assert.equal(calls.length, 1)
|
||
assert.equal(calls[0].url, 'https://relay.test/UPa')
|
||
assert.equal(calls[0].opts.method, 'POST')
|
||
assert.deepEqual(JSON.parse(calls[0].opts.body), { stream: 'news.post', ref: '5' })
|
||
})
|
||
})
|
||
|
||
test('publish (personal) targets only the owner’s subscribed devices', async () => {
|
||
await withEnv({ NTFY_BASE_URL: undefined, NTFY_ALLOWED_ORIGINS: undefined }, async () => {
|
||
const { calls, fetchImpl } = captureFetch()
|
||
const userStreamCalls = []
|
||
const pushDevices = {
|
||
endpointsForStream: async () => {
|
||
throw new Error('public path must not be used for a personal publish')
|
||
},
|
||
endpointsForUserStream: async (userId, s) => {
|
||
userStreamCalls.push([userId, s])
|
||
return [{ endpoint: 'https://relay.test/UPb' }]
|
||
},
|
||
}
|
||
await pushDispatch.publish('vendor.sale', { ref: '9', ownerUserId: 42 }, { pushDevices, fetchImpl })
|
||
assert.deepEqual(userStreamCalls, [[42, 'vendor.sale']])
|
||
assert.equal(calls.length, 1)
|
||
})
|
||
})
|
||
|
||
test('publish skips endpoints that fail the SSRF guard', async () => {
|
||
await withEnv({ NTFY_BASE_URL: undefined, NTFY_ALLOWED_ORIGINS: undefined }, async () => {
|
||
const { calls, fetchImpl } = captureFetch()
|
||
const pushDevices = {
|
||
endpointsForStream: async () => [
|
||
{ endpoint: 'http://relay.test/insecure' }, // not https → skipped
|
||
{ endpoint: 'https://10.0.0.9/private' }, // private → skipped
|
||
{ endpoint: 'https://relay.test/ok' }, // delivered
|
||
],
|
||
endpointsForUserStream: async () => [],
|
||
}
|
||
await pushDispatch.publish('server.status', { ref: 'down' }, { pushDevices, fetchImpl })
|
||
assert.equal(calls.length, 1)
|
||
assert.equal(calls[0].url, 'https://relay.test/ok')
|
||
})
|
||
})
|
||
|
||
// ── fromShardEvent (owner resolution) ───────────────────────────────────────
|
||
|
||
test('fromShardEvent resolves a personal event to the owning user, or drops it if unlinked', async () => {
|
||
await withEnv({ NTFY_BASE_URL: undefined, NTFY_ALLOWED_ORIGINS: undefined }, async () => {
|
||
const { calls, fetchImpl } = captureFetch()
|
||
const userStreamCalls = []
|
||
const shardLinks = { getByAccount: async (acct) => (acct === 'mine' ? { userId: 42 } : null) }
|
||
const pushDevices = {
|
||
endpointsForStream: async () => [],
|
||
endpointsForUserStream: async (userId, s) => {
|
||
userStreamCalls.push([userId, s])
|
||
return [{ endpoint: 'https://relay.test/UPc' }]
|
||
},
|
||
}
|
||
const deps = { shardLinks, pushDevices, fetchImpl, tracker: createTracker() }
|
||
|
||
await shardPush.fromShardEvent({ kind: 'vendor.sale', ownerAcct: 'mine', t: 1 }, deps)
|
||
assert.deepEqual(userStreamCalls, [[42, 'vendor.sale']])
|
||
assert.equal(calls.length, 1)
|
||
|
||
// Unlinked account → nobody to notify → no publish.
|
||
userStreamCalls.length = 0
|
||
calls.length = 0
|
||
await shardPush.fromShardEvent({ kind: 'vendor.sale', ownerAcct: 'stranger', t: 2 }, deps)
|
||
assert.equal(userStreamCalls.length, 0)
|
||
assert.equal(calls.length, 0)
|
||
})
|
||
})
|
||
|
||
test('fromShardEvent fans a public shard event to the stream’s subscribers', async () => {
|
||
await withEnv({ NTFY_BASE_URL: undefined, NTFY_ALLOWED_ORIGINS: undefined }, async () => {
|
||
const { fetchImpl } = captureFetch()
|
||
const publicCalls = []
|
||
const pushDevices = {
|
||
endpointsForStream: async (s) => {
|
||
publicCalls.push(s)
|
||
return []
|
||
},
|
||
endpointsForUserStream: async () => [],
|
||
}
|
||
await shardPush.fromShardEvent(
|
||
{ kind: 'server.hello', bootId: 'b1' },
|
||
{ pushDevices, fetchImpl, tracker: createTracker() },
|
||
)
|
||
assert.deepEqual(publicCalls, ['server.status'])
|
||
})
|
||
})
|