The entry point becomes real: five mount prefixes, the admin.users.detail extension slot, the shard push catalog, the town-crier announce leg and both lifecycle hooks. module.json declares all of it and the loader checks the declaration against what register() actually registers, in both directions. The URLs are byte-identical to the ones core served before the extraction. That is the whole point of moving the code and not the paths: the shipped Android app calls POST /api/v1/admin/shard/kick and the Discord bot reads /api/v1/public/shard/*, and neither knows a module answers now. Require order is load-bearing and the requires are inside register() because of it. Every ported file reaches core through ./core, whose members resolve ctx when called -- but a router does `const express = core.express` at ITS file scope, which runs the moment it is required. Hoisting these to the top of the file breaks the module with an error about ctx being missing, from a file that never mentions it. boot.js takes the eight UO call sites out of core's server.js. One behavioural change, deliberate: uoLinkSocket.start() and the sidecar health probe used to run AFTER the listener bound and now run before it, because onBoot does. start() returns as soon as the reconnecting client is armed, but the probe is a real HTTP call, so it is fired and NOT awaited -- an unreachable sidecar must not hold the site closed. Reporting that the bridge is down is diagnostics; being up is not a precondition for serving a page. router/rateLimits.js builds the market limiter through ctx.middleware.rateLimit, core's factory. The policy is the module's -- only the module knows what its endpoints cost -- and the plumbing is core's, so there is one express-rate-limit in the process and one place a breach is logged. Co-Authored-By: Claude <noreply@anthropic.com>
130 lines
5.8 KiB
JavaScript
130 lines
5.8 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, activity } = require('../../core')
|
|
const shardLinks = require('../../model/shardLinks/shardLinks.model')
|
|
const shardState = require('../../model/shardState/shardState.model')
|
|
const uoLinkClient = require('../../utils/uoLinkClient')
|
|
const { salesForAccounts } = require('../../utils/shardSales')
|
|
|
|
const log = require('../../core').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 }
|