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>
131 lines
5.9 KiB
JavaScript
131 lines
5.9 KiB
JavaScript
// ── Admin: a single user's shard (uo-link) footprint ──────────────────────────
|
|
//
|
|
// Backs the /admin/users/:id detail page. Every read is scoped to the target
|
|
// user's linked game accounts (from the local shard_account_links mirror): their
|
|
// vendor sales, houses, and currently-online characters. The live character
|
|
// rosters are fetched separately by the client through the existing admin-bypass
|
|
// /admin/shard/* endpoints, so nothing here round-trips the sidecar — these are
|
|
// fast, DB-backed reads. Admin-only (registered under adminOnly in the router).
|
|
|
|
const users = require('../../../model/users/users.model')
|
|
const shardLinks = require('../../../model/shardLinks/shardLinks.model')
|
|
const shardState = require('../../../model/shardState/shardState.model')
|
|
const uoLinkClient = require('../../../utils/uoLinkClient')
|
|
const activity = require('../../../model/activity/activity.model')
|
|
const { salesForAccounts } = require('../../../utils/shardSales')
|
|
|
|
const log = require('../../../utils/logger')('admin-user-shard')
|
|
|
|
// Resolve the target user's linked game accounts, or null if the user id is
|
|
// unknown (so the handler can 404 rather than silently returning an empty set).
|
|
async function accountsForUser(id) {
|
|
const user = await users.getById(id)
|
|
if (!user) return null
|
|
const links = await shardLinks.listForUser(id)
|
|
return { user, links, accounts: links.map((l) => l.account) }
|
|
}
|
|
|
|
// GET /admin/users/:id/shard/accounts — the user's linked game accounts.
|
|
async function listAccounts(req, res) {
|
|
try {
|
|
const ctx = await accountsForUser(Number(req.params.id))
|
|
if (!ctx) return res.status(404).json({ message: 'Not found' })
|
|
return res.json(ctx.links)
|
|
} catch (err) {
|
|
log.error('listAccounts', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// GET /admin/users/:id/shard/sales — recent vendor sales on the user's accounts.
|
|
async function getSales(req, res) {
|
|
try {
|
|
const ctx = await accountsForUser(Number(req.params.id))
|
|
if (!ctx) return res.status(404).json({ message: 'Not found' })
|
|
return res.json(await salesForAccounts(ctx.accounts))
|
|
} catch (err) {
|
|
log.error('getSales', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// GET /admin/users/:id/shard/houses — houses owned by the user's accounts.
|
|
async function getHouses(req, res) {
|
|
try {
|
|
const ctx = await accountsForUser(Number(req.params.id))
|
|
if (!ctx) return res.status(404).json({ message: 'Not found' })
|
|
return res.json(await shardState.listHousesForAccounts(ctx.accounts))
|
|
} catch (err) {
|
|
log.error('getHouses', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// GET /admin/users/:id/shard/online — the user's characters currently online.
|
|
async function getOnline(req, res) {
|
|
try {
|
|
const ctx = await accountsForUser(Number(req.params.id))
|
|
if (!ctx) return res.status(404).json({ message: 'Not found' })
|
|
return res.json(await shardState.listOnlineForAccounts(ctx.accounts))
|
|
} catch (err) {
|
|
log.error('getOnline', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// GET /admin/users/:id/shard/standing — the user's shard "standing" cross-links:
|
|
// city governorships they currently hold and guilds they lead. Both are reliable
|
|
// current-state lookups on the user's linked accounts.
|
|
async function getStanding(req, res) {
|
|
try {
|
|
const ctx = await accountsForUser(Number(req.params.id))
|
|
if (!ctx) return res.status(404).json({ message: 'Not found' })
|
|
const [governorOf, guildsLed] = await Promise.all([
|
|
shardState.listGovernorshipsForAccounts(ctx.accounts),
|
|
shardState.listGuildsLedForAccounts(ctx.accounts),
|
|
])
|
|
return res.json({ governorOf, guildsLed })
|
|
} catch (err) {
|
|
log.error('getStanding', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// DELETE /admin/users/:id/shard/link/:account — unlink a game account from this
|
|
// user, site-side. `actor` is stamped from the session (never the browser). On
|
|
// success the sidecar clears the WebsiteUserId tag on the shard and we drop the
|
|
// local mirror so attribution stops immediately.
|
|
async function unlinkAccount(req, res) {
|
|
const { account } = req.params
|
|
try {
|
|
const ctx = await accountsForUser(Number(req.params.id))
|
|
if (!ctx) return res.status(404).json({ message: 'Not found' })
|
|
// Only unlink an account actually linked to THIS user (avoid cross-user unlink).
|
|
if (!ctx.accounts.includes(account)) {
|
|
return res.status(404).json({ message: 'That account is not linked to this user.' })
|
|
}
|
|
const result = await uoLinkClient.unlinkAccount({ actor: req.user.username, account })
|
|
if (result.ok) {
|
|
await shardLinks.removeByAccount(account)
|
|
await activity.log({ req, userId: ctx.user.id, action: 'shard.account.unlink', detail: { account } })
|
|
log.info('game account unlinked', { account, userId: ctx.user.id, actor: req.user.username })
|
|
return res.json({ account, unlinked: true })
|
|
}
|
|
if (result.status === 403) return res.status(403).json({ message: 'That account is protected and cannot be unlinked.' })
|
|
if (result.status === 404) {
|
|
// Not linked on the shard — reconcile our mirror anyway so the two agree.
|
|
await shardLinks.removeByAccount(account)
|
|
return res.status(404).json({ message: 'That account is not linked.' })
|
|
}
|
|
if (result.status === 503 || result.status === 0) {
|
|
return res.status(503).json({ message: 'The game server is unavailable — try again shortly.' })
|
|
}
|
|
return res.status(502).json({ message: 'Could not reach the shard to unlink the account.' })
|
|
} catch (err) {
|
|
log.error('unlinkAccount', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
module.exports = { listAccounts, getSales, getHouses, getOnline, getStanding, unlinkAccount }
|