feat(server): register the routes, the slot, the leg and the boot hooks

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>
This commit is contained in:
2026-08-11 12:06:46 -05:00
committed by Claude
parent fe3251a543
commit 740a677f92
19 changed files with 2930 additions and 26 deletions

View File

@@ -0,0 +1,125 @@
// Player · Shard — game-account linking and the caller's own roster / vendors /
// characters / sales / houses, ownership-checked against the local link mirror.
//
// Mounted at /api/v1/player/shard by player/index.js, which already applied
// `noindex, requireAuth`. No extra gate: every handler is self-scoped to
// req.user.id.
//
// These are the *same* handlers (player/shard.controller) that admin/shard.router.js
// serves under /admin/shard for the seven self-service routes — staff are a
// superset of players, and the controller keys off req.user.id either way. Two
// URL surfaces, one implementation.
const core = require('../../core')
const express = core.express
const { body, param } = core.validator
const shard = require('./shard.controller')
const { validate, accountChangeLimiter } = core.middleware
const shardRouter = express.Router()
// Link an in-game account with a one-time code from [link, then read the
// account's roster / vendors (ownership-checked against the local link mirror).
const ACCOUNT_RE = /^[A-Za-z0-9_.-]{1,120}$/
shardRouter.post(
'/link',
// #swagger.tags = ['Player · Shard']
// #swagger.summary = 'Link an in-game account with a one-time code'
// #swagger.description = 'The player runs [link in game to get a code, then submits it here. The server confirms it with the sidecar and mirrors the link.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ShardLinkRequest" } } } } */
/* #swagger.responses[200] = { description: 'Linked', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardLinkResult" } } } } */
/* #swagger.responses[400] = { description: 'Unknown or expired code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[503] = { description: 'Shard unavailable — retry', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
body('code').isString().trim().isLength({ min: 4, max: 32 }),
validate,
shard.link,
)
shardRouter.post(
'/account',
// #swagger.tags = ['Player · Shard']
// #swagger.summary = 'Create a game account (hybrid signup) and link it to the caller'
// #swagger.description = 'Provisions a new game account with its own username + password and auto-links it to the signed-in website user. Available only when game_account_signup is enabled and the shard accepts website signups. The password is hashed on the shard and never stored or logged by the site.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["account","password"], properties: { account: { type: "string" }, password: { type: "string" } } } } } */
/* #swagger.responses[201] = { description: 'Account created and linked', content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" }, linked: { type: "boolean" } } } } } } */
/* #swagger.responses[400] = { description: 'Validation error or rejected name/password', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
/* #swagger.responses[403] = { description: 'Game-account signup unavailable (site or shard)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[409] = { description: 'Account name already taken', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[429] = { description: 'Per-IP account cap reached', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[503] = { description: 'Shard unavailable — retry', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
accountChangeLimiter,
body('account').matches(/^[A-Za-z0-9][A-Za-z0-9_.-]{2,29}$/),
body('password').isString().isLength({ min: 8, max: 64 }),
validate,
shard.createGameAccount,
)
shardRouter.get(
'/accounts',
// #swagger.tags = ['Player · Shard']
// #swagger.summary = 'List the callers linked game accounts'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardLink" } } } } } */
shard.listAccounts,
)
shardRouter.get(
'/roster/:account',
// #swagger.tags = ['Player · Shard']
// #swagger.summary = 'Character roster for a linked account'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'A game account linked to the caller.' }
/* #swagger.responses[200] = { description: 'Account roster', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[403] = { description: 'Account not linked to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[503] = { description: 'Shard unavailable — retry', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('account').matches(ACCOUNT_RE),
validate,
shard.roster,
)
shardRouter.get(
'/vendors/:account',
// #swagger.tags = ['Player · Shard']
// #swagger.summary = 'Player vendors for a linked account'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'A game account linked to the caller.' }
/* #swagger.responses[200] = { description: 'Vendor snapshot', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[403] = { description: 'Account not linked to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[503] = { description: 'Shard unavailable — retry', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('account').matches(ACCOUNT_RE),
validate,
shard.vendors,
)
shardRouter.get(
'/char/:serial',
// #swagger.tags = ['Player · Shard']
// #swagger.summary = 'Character sheet — only for a character on the callers linked account'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['serial'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Mobile serial, e.g. 0x24C.' }
/* #swagger.responses[200] = { description: 'Character profile', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[403] = { description: 'Character not on an account linked to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[503] = { description: 'Shard unavailable — retry', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('serial').matches(/^0x[0-9a-fA-F]+$/),
validate,
shard.getChar,
)
shardRouter.get(
'/sales',
// #swagger.tags = ['Player · Shard']
// #swagger.summary = 'Recent player-vendor sales for the callers linked accounts'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */
shard.getSales,
)
shardRouter.get(
'/houses',
// #swagger.tags = ['Player · Shard']
// #swagger.summary = 'The callers own houses (home status)'
// #swagger.description = 'Houses owned by the callers linked accounts, with decay/IDOC status. Only the callers own houses — never anyone elses.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The callers houses', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */
shard.getHouses,
)
module.exports = shardRouter