From 740a677f9269423e28e581ab78de871260f96655 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 11 Aug 2026 12:06:46 -0500 Subject: [PATCH] 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 --- module.json | 15 +- server/boot.js | 121 +++++ server/index.js | 92 +++- server/router/admin/shard.router.js | 387 ++++++++++++++++ server/router/admin/shardAtlas.controller.js | 117 +++++ .../router/admin/shardClilocs.controller.js | 106 +++++ server/router/admin/shardOps.controller.js | 171 +++++++ .../admin/shardVisibility.controller.js | 98 ++++ server/router/admin/uoLink.controller.js | 123 +++++ server/router/admin/uoLink.router.js | 100 +++++ server/router/admin/usersShard.controller.js | 129 ++++++ server/router/admin/usersShard.router.js | 113 +++++ server/router/player/shard.controller.js | 273 +++++++++++ server/router/player/shard.router.js | 125 ++++++ server/router/public/atlas.controller.js | 134 ++++++ server/router/public/atlas.router.js | 129 ++++++ server/router/public/shard.controller.js | 422 ++++++++++++++++++ server/router/public/shard.router.js | 255 +++++++++++ server/router/rateLimits.js | 46 ++ 19 files changed, 2930 insertions(+), 26 deletions(-) create mode 100644 server/boot.js create mode 100644 server/router/admin/shard.router.js create mode 100644 server/router/admin/shardAtlas.controller.js create mode 100644 server/router/admin/shardClilocs.controller.js create mode 100644 server/router/admin/shardOps.controller.js create mode 100644 server/router/admin/shardVisibility.controller.js create mode 100644 server/router/admin/uoLink.controller.js create mode 100644 server/router/admin/uoLink.router.js create mode 100644 server/router/admin/usersShard.controller.js create mode 100644 server/router/admin/usersShard.router.js create mode 100644 server/router/player/shard.controller.js create mode 100644 server/router/player/shard.router.js create mode 100644 server/router/public/atlas.controller.js create mode 100644 server/router/public/atlas.router.js create mode 100644 server/router/public/shard.controller.js create mode 100644 server/router/public/shard.router.js create mode 100644 server/router/rateLimits.js diff --git a/module.json b/module.json index ddad9a9..f7b76ca 100644 --- a/module.json +++ b/module.json @@ -1,8 +1,17 @@ { "id": "uo", "name": "Ultima Online", - "version": "0.1.0", - "coreApi": "^1.0.0", + "version": "0.2.0", + "coreApi": "^1.1.0", "server": "server/index.js", - "client": { "entry": "client/dist/entry.js" } + "client": { "entry": "client/dist/entry.js" }, + "schema": "server/db/schema.sql", + "purge": "server/db/purge.sql", + "mounts": { + "public": ["/shard", "/atlas"], + "admin": ["/shard", "/uo-link"], + "player": ["/shard"] + }, + "extensions": ["admin.users.detail"], + "capabilities": ["shard", "atlas", "market", "governors", "guilds", "houses", "champs", "cliloc"] } diff --git a/server/boot.js b/server/boot.js new file mode 100644 index 0000000..58bd97d --- /dev/null +++ b/server/boot.js @@ -0,0 +1,121 @@ +// ── onBoot / onShutdown ──────────────────────────────────────────────────── +// +// The eight UO call sites that used to sit in core's `server.js`. `register()` +// runs with no database (MODULE_API.md §2.2); everything here runs with one. +// +// Core dispatches `onBoot` after `ensureSchema` and the schema-fragment replay, +// and **before the HTTP listener binds** — so the tables these functions touch +// exist, and nothing is served until the warm-up finishes. That ordering is the +// contract's promise rather than an accident, and it is why `onBoot` has no +// timeout: a module that must not serve traffic until a cache is warm only gets +// that guarantee if the listener is still closed. +// +// **One behavioural change, and it is deliberate.** In core, `uoLinkSocket.start()` +// and the sidecar health probe ran AFTER the listener bound; here they run before +// it. `start()` returns as soon as the reconnecting client is armed, so that part +// is free — but the probe is a real HTTP call to the sidecar, and an unreachable +// sidecar must not hold the site closed. It is therefore fired and NOT awaited, +// with its own catch. Reporting whether the bridge is up is diagnostics; being up +// is not a precondition for serving a page, and the site is required to degrade +// gracefully when the shard is down. +// +// Everything here is best-effort by the same rule. A module whose `onBoot` +// throws is marked `startup_failed` and its routes answer 503 (§4.4), which is +// the right outcome for a broken module — but "the operator has not configured a +// ServUO path" is not a broken module, and neither is "the shard is offline". + +const core = require('./core') + +const uoLinkSocket = require('./utils/uoLinkSocket') +const uoLinkClient = require('./utils/uoLinkClient') +const uoLinkConfig = require('./model/uoLinkConfig/uoLinkConfig.model') +const shardBroadcast = require('./utils/shardBroadcast') +const shardAtlas = require('./model/shardAtlas/shardAtlas.model') +const shardClilocs = require('./model/shardClilocs/shardClilocs.model') +const shardMarket = require('./model/shardMarket/shardMarket.model') + +/** + * Best-effort startup probe of the uo-link sidecar. + * + * Logs whether it is reachable and warns loudly on a protocol mismatch — + * fail-fast visibility rather than silently mis-parsing a newer wire format. + * Never throws, and is never awaited by `onBoot`. + */ +async function checkUoLink() { + const log = core.logger('boot') + const config = await uoLinkConfig.getSafe() + if (!config.enabled) return + const health = await uoLinkClient.health() + if (!health.ok) { + log.warn('uo-link is enabled but the sidecar is unreachable at startup', { + baseUrl: config.baseUrl, + error: health.error || `status ${health.status}`, + }) + return + } + if (health.data && health.data.protocol && health.data.protocol !== config.protocol) { + log.error('uo-link PROTOCOL MISMATCH — pinned vs sidecar', { + pinned: config.protocol, + sidecar: health.data.protocol, + }) + } else { + log.info('uo-link sidecar reachable', { + pluginConnected: health.data && health.data.plugin_connected, + protocol: health.data && health.data.protocol, + }) + } +} + +async function onBoot() { + const log = core.logger('boot') + + // Re-derive the spawn atlas from the shard's own ServUO tree. The shard's maps + // change over its lifetime — facets get added, replaced or renamed — so the + // atlas is rebuilt on every boot rather than shipped as a snapshot that would + // silently go stale. Hash-gated, so an unchanged tree costs one read pass and + // no database write. + // + // Best-effort by contract: no configured path, an unreadable mount or a + // malformed file must never stop the site coming up. A refresh that would + // REMOVE a facet is staged for admin approval instead of being applied. + await shardAtlas.refreshOnBoot() + + // Refresh the cliloc table (UO's id → display-string map) from the file the + // operator converted out of their own client. Same contract as the atlas: + // hash-gated so an unchanged file costs one read, and best-effort so a missing + // or wrong-format file never stops the site coming up — it just means item + // names render as ids, which is what they did before the table existed. + const clilocResult = await shardClilocs.refreshOnBoot() + + // A cliloc import changes what item names RESOLVE to, and the marketplace + // stores those names denormalized (shard_vendor_items.display_name) so it can + // index and search them. The shard's market sweep will not re-send an unchanged + // shop just because the site learned what its items are called, so the backfill + // has to be pulled rather than waited for. Only after an actual import — the + // common boot is hash-gated to a no-op and must stay one. + if (clilocResult && clilocResult.status === 'imported') await shardMarket.refreshDisplayNames() + + // Start the uo-link WebSocket ingest client. Self-guards: it only actually + // connects when the admin has enabled the integration and saved a token, so + // this is a no-op on shards that haven't configured the sidecar. + try { + await uoLinkSocket.start() + } catch (err) { + log.warn('uo-link socket failed to start (continuing)', { error: err.message }) + } + + // Deliberately not awaited — see the header. An unreachable sidecar would + // otherwise hold the listener closed for the length of an HTTP timeout. + checkUoLink().catch((err) => log.warn('uo-link startup probe failed', { error: err.message })) +} + +async function onShutdown() { + // Core runs this FIRST in its signal handler, while everything it handed over + // still works — the pool is open, the push dispatcher is up, the SSE fan-out + // is live. It is the only chance to close cleanly, and it is budgeted, so a + // hook that will not let go costs five seconds rather than the whole shutdown. + uoLinkSocket.stop() // close the uo-link WS ingest client + shardBroadcast.closeAll() // end any open shard live-feed SSE streams +} + +module.exports = { onBoot, onShutdown, checkUoLink } diff --git a/server/index.js b/server/index.js index c23584d..f62a4b7 100644 --- a/server/index.js +++ b/server/index.js @@ -8,44 +8,90 @@ // 1. **No `await`, and no database.** `scripts/routeManifest.js` and // `swagger/swagger.js` both require core's `app.js` with the pool pointed // at a dead port, so a module that queried at registration time would hang -// both. Anything needing a live database belongs in `onBoot`. +// both. Everything needing a live database is in `onBoot`. // 2. **Never resolve what core owns.** This module lives at // `/modules/uo/`, outside `server/`, so Node's resolver never // reaches core's `node_modules` and `require('express')` fails outright. -// express and express-validator arrive on `ctx`; so do the database, the -// logger, the middleware and the rest of §2.3. +// express, express-validator, the database, the logger, the middleware and +// the rest of §2.3 arrive on `ctx` and are re-exported by `./core`. // 3. **Never reach into core's tree.** No relative path may escape this -// module's root. `scripts/checkImports.js` enforces that in CI (§5.1) -// rather than leaving it to review. +// module's root; `scripts/checkImports.js` enforces that in CI (§5.1). // -// Slice 0 of the Phase 3 extraction (MODULE_SYSTEM.md §2.7.1) deliberately -// registers NOTHING. The bundle exists, core discovers it, validates it, mounts -// its zero routes, serves its client chunk and reports it `started` — which is -// the whole delivery path proved end to end before a single UO file moves into -// it. Slice 1 brings the atlas; every slice after that adds registrations here -// and deletes the matching files from core. +// **Require order is load-bearing, and it is why the requires below are inside +// the function.** Every ported file reaches core through `./core`, whose members +// resolve `ctx` when called — but a router does `const express = core.express` at +// its own file scope, which runs the moment it is required. So `core.init(ctx)` +// has to happen before the first `require` of anything under `router/`. Hoisting +// these to the top of the file would break the module with an error about `ctx` +// being missing, from a file that never mentions it. Node caches modules, so +// requiring here costs nothing after the first call. + +const core = require('./core') /** * @param {object} ctx what core hands the module (MODULE_API.md §2.3), frozen * @param {object} api what the module registers (§2.4) */ module.exports = function register(ctx, api) { - const log = ctx.log() + core.init(ctx) - // Registrations land here, slice by slice: + /* eslint-disable global-require */ + const publicShard = require('./router/public/shard.router') + const publicAtlas = require('./router/public/atlas.router') + const adminShard = require('./router/admin/shard.router') + const adminUoLink = require('./router/admin/uoLink.router') + const playerShard = require('./router/player/shard.router') + const usersShardExtension = require('./router/admin/usersShard.router') + + const shardStreams = require('./config/shardStreams') + const townCrierLeg = require('./utils/shardAnnounce') + const boot = require('./boot') + /* eslint-enable global-require */ + + const log = core.logger() + + // The five prefixes, exactly the ones `module.json` declares — the loader + // compares the two and rejects a mismatch in either direction. Each router + // mounts INSIDE its tier, so it structurally cannot reach above its prefix, + // and the tier's own gate is already applied: `/admin` sits behind + // `noindex, isLoggedIn, requireRole(...)`, `/player` behind + // `noindex, requireAuth`, `/public` behind nothing by design. // - // api.registerRoutes({ public: {...}, admin: {...}, player: {...} }) - // api.registerExtension('admin.users.detail', usersShardRouter) - // api.registerNotificationStreams(streams) - // api.registerAnnounceLeg({ leg: 'towncrier', ... }) - // api.onBoot(async (ctx) => { ... }) - // api.onShutdown(async () => { ... }) + // The URLs these produce are byte-identical to the ones core served before the + // extraction (§1.2). 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 or needs to + // know that a module answers now. + api.registerRoutes({ + public: { '/shard': publicShard, '/atlas': publicAtlas }, + admin: { '/shard': adminShard, '/uo-link': adminUoLink }, + player: { '/shard': playerShard }, + }) + + // The six `/admin/users/:id/shard/*` URLs, which hang off a CORE resource and + // therefore cannot be a mount of our own (§1.9). Core declares the slot in + // `users.router.js` and we fill it; the router gets `req.params.id` from the + // parent via `mergeParams`. Core's own routes on the resource win any path + // conflict, which is correct — it owns the user. + api.registerExtension('admin.users.detail', usersShardExtension) + + // The push catalog and the news leg. Core kept the push infrastructure and the + // announce worker; what it never had was an opinion about *shard* streams or + // about talking to a town crier, and those are content (MODULE_SYSTEM.md §1.8). // - // `api` is referenced by this log line and nothing else yet, on purpose: an - // entry point that took `api` and never named it would read like an oversight - // rather than a stage of the extraction. + // Seven of these stream ids and the leg id `towncrier` are grandfathered + // (§6.5) — they are stored in `notification_subs` and `announce_job_legs.leg` + // and read by the shipped Android app, so a rename here is a data migration + // plus a client break rather than a tidy-up. + api.registerNotificationStreams(shardStreams.STREAMS) + api.registerAnnounceLeg(townCrierLeg.leg) + + api.onBoot(boot.onBoot) + api.onShutdown(boot.onShutdown) + log.info('registered', { version: require('../module.json').version, - registers: Object.keys(api).length, + routes: 'public:/shard,/atlas admin:/shard,/uo-link player:/shard', + streams: shardStreams.STREAMS.length, }) } diff --git a/server/router/admin/shard.router.js b/server/router/admin/shard.router.js new file mode 100644 index 0000000..dbb7fa2 --- /dev/null +++ b/server/router/admin/shard.router.js @@ -0,0 +1,387 @@ +// Admin · Shard — everything under /api/v1/admin/shard, in two tiers. +// +// Mounted at /api/v1/admin/shard by admin/index.js, which already applied +// `noindex, isLoggedIn, staffOnly`. Two capabilities share this prefix, and +// prefix ownership is the invariant the split preserves — so they share a file: +// +// 1. Self-service game-account linking (no extra gate). A staff member links +// and inspects their OWN in-game account exactly as a player does under +// /player/shard; the handlers are the very same `player/shard.controller` +// ones, keyed off req.user.id. These keep their `Admin · Account` swagger +// tag, which is why the tag disagrees with this filename. +// 2. Privileged live-shard operations and the help-page queue (`modAccess` — +// admin or moderator). `actor` is stamped server-side from the session in +// shardOps.controller.js; the request body never carries it. +// +// `modAccess` stays a per-route gate rather than a router-level `use`: it was +// per-route in admin.routes.js, and half the routes here must NOT have it. +// +// NOTE: /admin/shard/pages is the in-game help-page (support) queue. It is +// unrelated to /admin/pages, the CMS page builder. + +const core = require('../../core') + +const express = core.express +const { body, param } = core.validator + +const shardOps = require('./shardOps.controller') +const shardVisibility = require('./shardVisibility.controller') +const shardAtlas = require('./shardAtlas.controller') +const shardClilocs = require('./shardClilocs.controller') +const selfShard = require('../player/shard.controller') +const { requireRole, validate } = core.middleware + +const shardRouter = express.Router() + +// Moderator gate. Admins can do everything a moderator can. +const modAccess = requireRole('admin', 'moderator') +// Admin-only gate, for settings that decide what the PUBLIC sees. +const adminOnly = requireRole('admin') + +// ── Game account linking (self-service, any staff role) ─────────────── +// Staff link their OWN in-game account here, exactly like players do under +// /player/shard. The controller keys off req.user.id, so the same handlers work. +const SHARD_ACCOUNT_RE = /^[A-Za-z0-9_.-]{1,120}$/ +shardRouter.post( + '/link', + // #swagger.tags = ['Admin · Account'] + // #swagger.summary = 'Link an in-game account with a one-time code (self)' + // #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" } } } } */ + body('code').isString().trim().isLength({ min: 4, max: 32 }), + validate, + selfShard.link, +) +shardRouter.get( + '/accounts', + // #swagger.tags = ['Admin · Account'] + // #swagger.summary = 'List the caller’s linked game accounts (self)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardLink" } } } } } */ + selfShard.listAccounts, +) +shardRouter.get( + '/roster/:account', + // #swagger.tags = ['Admin · Account'] + // #swagger.summary = 'Character roster for an account (self; admins: any 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" } } } } */ + param('account').matches(SHARD_ACCOUNT_RE), + validate, + selfShard.roster, +) +shardRouter.get( + '/vendors/:account', + // #swagger.tags = ['Admin · Account'] + // #swagger.summary = 'Player vendors for an account (self; admins: any 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" } } } } */ + param('account').matches(SHARD_ACCOUNT_RE), + validate, + selfShard.vendors, +) +shardRouter.get( + '/char/:serial', + // #swagger.tags = ['Admin · Account'] + // #swagger.summary = 'Character sheet (self-linked characters; admins: any character)' + // #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" } } } } */ + param('serial').matches(/^0x[0-9a-fA-F]+$/), + validate, + selfShard.getChar, +) +shardRouter.get( + '/sales', + // #swagger.tags = ['Admin · Account'] + // #swagger.summary = 'Recent player-vendor sales for the caller’s linked accounts (self)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */ + selfShard.getSales, +) +shardRouter.post( + '/account', + // #swagger.tags = ['Admin · Account'] + // #swagger.summary = 'Create a game account and link it to the caller (staff self-service)' + // #swagger.description = 'Same as POST /player/shard/account but for a signed-in staff user — provisions a game account (own username + password) and links it. Gated by game_account_signup + the shard’s mode; the password is never stored or logged.' + // #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", additionalProperties: true } } } } */ + /* #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" } } } } */ + body('account').matches(/^[A-Za-z0-9][A-Za-z0-9_.-]{2,29}$/), + body('password').isString().isLength({ min: 8, max: 64 }), + validate, + selfShard.createGameAccount, +) + +// ── In-game staff operations (uo-link write plane + support queue) ───── +// Privileged live-shard actions and the help-page queue, open to moderators as +// well as admins (modAccess). `actor` is stamped server-side from the session in +// the controller — the body never carries it. See shardOps.controller.js. +shardRouter.post( + '/kick', + // #swagger.tags = ['Admin · Shard'] + // #swagger.summary = 'Kick every live session of an account (admin/moderator)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" }, serial: { type: "string" } } } } } } */ + /* #swagger.responses[200] = { description: 'Kicked', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ + /* #swagger.responses[403] = { description: 'Protected target or write plane disabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + modAccess, + body('account').optional({ values: 'falsy' }).matches(SHARD_ACCOUNT_RE), + body('serial').optional({ values: 'falsy' }).matches(/^0x[0-9a-fA-F]+$/), + validate, + shardOps.kick, +) +shardRouter.post( + '/ban', + // #swagger.tags = ['Admin · Shard'] + // #swagger.summary = 'Ban an account, timed or indefinite (admin/moderator)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" }, serial: { type: "string" }, durationSec: { type: "integer" }, reason: { type: "string" } } } } } } */ + /* #swagger.responses[200] = { description: 'Banned', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ + /* #swagger.responses[403] = { description: 'Protected target or write plane disabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + modAccess, + body('account').optional({ values: 'falsy' }).matches(SHARD_ACCOUNT_RE), + body('serial').optional({ values: 'falsy' }).matches(/^0x[0-9a-fA-F]+$/), + body('durationSec').optional().isInt({ min: 0, max: 315360000 }), + body('reason').optional({ values: 'falsy' }).isString().trim().isLength({ max: 500 }), + validate, + shardOps.ban, +) +shardRouter.post( + '/unban', + // #swagger.tags = ['Admin · Shard'] + // #swagger.summary = 'Clear an account ban (admin/moderator)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" } }, required: ["account"] } } } } */ + /* #swagger.responses[200] = { description: 'Unbanned', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ + modAccess, + body('account').matches(SHARD_ACCOUNT_RE), + validate, + shardOps.unban, +) +shardRouter.post( + '/broadcast', + // #swagger.tags = ['Admin · Shard'] + // #swagger.summary = 'Broadcast a system message to everyone online (admin/moderator)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { text: { type: "string" }, hue: { type: "integer" } }, required: ["text"] } } } } */ + /* #swagger.responses[200] = { description: 'Broadcast', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ + modAccess, + body('text').isString().trim().isLength({ min: 1, max: 300 }), + body('hue').optional().isInt({ min: 0, max: 3000 }), + validate, + shardOps.broadcast, +) +shardRouter.get( + '/pages', + // #swagger.tags = ['Admin · Shard'] + // #swagger.summary = 'Open help-page (support) queue (admin/moderator)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Open pages', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */ + modAccess, + shardOps.listPages, +) +shardRouter.post( + '/pages/:id/respond', + // #swagger.tags = ['Admin · Shard'] + // #swagger.summary = 'Reply to a help page, optionally closing it (admin/moderator)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Page id (sender serial).' } + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { message: { type: "string" }, close: { type: "boolean" } }, required: ["message"] } } } } */ + /* #swagger.responses[200] = { description: 'Responded', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ + /* #swagger.responses[404] = { description: 'Unknown page', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + modAccess, + param('id').matches(/^0x[0-9a-fA-F]+$/), + body('message').isString().trim().isLength({ min: 1, max: 500 }), + body('close').optional().isBoolean(), + validate, + shardOps.respondPage, +) +shardRouter.post( + '/pages/:id/close', + // #swagger.tags = ['Admin · Shard'] + // #swagger.summary = 'Resolve a help page without a reply (admin/moderator)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Page id (sender serial).' } + /* #swagger.responses[200] = { description: 'Closed', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ + modAccess, + param('id').matches(/^0x[0-9a-fA-F]+$/), + validate, + shardOps.closePage, +) +shardRouter.get( + '/audit', + // #swagger.tags = ['Admin · Shard'] + // #swagger.summary = 'Recent in-game moderation audit events (admin/moderator)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'admin.audit events, newest first', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardEvent" } } } } } */ + modAccess, + shardOps.listAudit, +) +shardRouter.get( + '/houses', + // #swagger.tags = ['Admin · Shard'] + // #swagger.summary = 'Full house registry — owner, price, decay (admin/moderator)' + // #swagger.description = 'The complete house registry. The public endpoint shows only IDOC houses with location; this staff view carries owner/price/co-owner/decay detail.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Houses, ordered by name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */ + modAccess, + shardOps.listHouses, +) + +// ── Spawn atlas (admin only) ────────────────────────────────────────── +// Operating the atlas import. Admin-only rather than moderator: it reads a path +// on the server's filesystem and replaces every atlas table, which is closer to +// a deploy action than to moderation. +// +// These routes sit under /admin/shard even though the public ones deliberately +// do NOT sit under /public/shard. That is not an inconsistency: the public split +// says "this data does not come from the sidecar", while the admin panel is +// simply part of shard administration and belongs beside the rest of it. +shardRouter.get( + '/atlas', + // #swagger.tags = ['Admin · Shard'] + // #swagger.summary = 'Spawn atlas status: path, drift, counts, pending review (admin only)' + // #swagger.description = 'Where the ServUO tree is, whether it can be read, whether its source files have drifted from the loaded atlas, and any refresh staged for approval. The public /atlas/meta route reports the game world only; the filesystem detail is here.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Atlas status', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasStatus" } } } } */ + /* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + adminOnly, + shardAtlas.getStatus, +) +shardRouter.post( + '/atlas/import', + // #swagger.tags = ['Admin · Shard'] + // #swagger.summary = 'Re-import the spawn atlas from the ServUO tree (admin only)' + // #swagger.description = 'Applies a map change without a restart. `force` reimports even when the source hashes match what is loaded. A refresh that would REMOVE a facet is still staged for approval rather than applied — that decision is never taken implicitly. An unreadable tree answers 200 with status "unavailable" rather than 500: the refresh contract reports outcomes instead of throwing, and the admin needs to be told what is wrong with the path.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: false, content: { "application/json": { schema: { type: "object", properties: { force: { type: "boolean", description: "Reimport even if the tree is unchanged." } } } } } } */ + /* #swagger.responses[200] = { description: 'What happened', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasRefreshResult" } } } } */ + adminOnly, + body('force').optional().isBoolean(), + validate, + shardAtlas.importAtlas, +) +shardRouter.post( + '/atlas/approve', + // #swagger.tags = ['Admin · Shard'] + // #swagger.summary = 'Approve a staged atlas refresh that removes a facet (admin only)' + // #swagger.description = 'Re-parses the tree and applies it, facet loss included. Only the decision was stored, never the parsed world, so what lands matches the tree at approval time — an operator who has since fixed a half-copied mount gets the corrected import.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'What happened', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasRefreshResult" } } } } */ + adminOnly, + shardAtlas.approve, +) +shardRouter.post( + '/atlas/reject', + // #swagger.tags = ['Admin · Shard'] + // #swagger.summary = 'Reject a staged atlas refresh (admin only)' + // #swagger.description = 'Keeps the current atlas and remembers the decision against those exact source hashes, so a declined refresh does not re-prompt on every restart. Changing the tree asks again.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Rejected', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasRefreshResult" } } } } */ + /* #swagger.responses[404] = { description: 'Nothing is awaiting review', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + adminOnly, + shardAtlas.reject, +) +shardRouter.put( + '/atlas/path', + // #swagger.tags = ['Admin · Shard'] + // #swagger.summary = 'Set the ServUO tree the atlas reads from (admin only)' + // #swagger.description = 'Persisted as a setting, which wins over the SERVUO_PATH deploy default so the mount can move without a redeploy. Blank clears it and the atlas is simply skipped on the next boot. Deliberately does not import as a side effect — the response carries the refreshed status so the panel can offer that as the next step.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["path"], properties: { path: { type: "string", description: "Absolute path to the ServUO server root. Blank disables the atlas." } } } } } } */ + /* #swagger.responses[200] = { description: 'Atlas status after the change', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasStatus" } } } } */ + adminOnly, + body('path').isString().isLength({ max: 512 }), + validate, + shardAtlas.setPath, +) + +// ── Cliloc table (admin only) ───────────────────────────────────────────── +// UO's id → display-string map, converted once by the operator from their own +// client (docs/website/CLILOCS.md). Sits beside the atlas for the same reason: +// it is static content derived from operator-supplied files rather than anything +// the sidecar sends, and operating it is shard administration. +// +// There is deliberately NO public counterpart. The table is never served as a +// table — 123k rows would dwarf any page that used it, and the Android client +// consumes the same already-resolved JSON. Names are applied server-side to the +// responses that need them. +shardRouter.get( + '/clilocs', + // #swagger.tags = ['Admin · Shard'] + // #swagger.summary = 'Cliloc table status: sources, drift, entry count (admin only)' + // #swagger.description = 'Where the cliloc sources are, whether they can be read, how many entries are loaded, and whether the files on disk have drifted from them. The table is built from a SET of sources — the converted client table plus every operator-maintained overlay under `custom/`, which is how shard-added and shard-edited items get names. `missingSources` lists any source that was loaded before and is now gone; an import refuses that without `approve`. A shard with nothing configured is a supported state — item names simply render as ids.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Cliloc status', content: { "application/json": { schema: { $ref: "#/components/schemas/ClilocStatus" } } } } */ + /* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + adminOnly, + shardClilocs.getStatus, +) +shardRouter.post( + '/clilocs/import', + // #swagger.tags = ['Admin · Shard'] + // #swagger.summary = 'Re-import the cliloc table from its source files (admin only)' + // #swagger.description = 'Applies a client patch, or a change to the shard\'s own overlay files, without a restart. `force` reimports even when the source hashes match what is loaded. `approve` accepts a refresh in which a previously-loaded source has VANISHED — refused by default, because an unmounted volume and a deliberate deletion are indistinguishable from the server, and the wrong guess silently drops every name that file contributed. A missing path — or the common mistake of pointing at the client\'s own COMPRESSED Cliloc.enu — answers 200 with status "unavailable" and the reason, rather than 500: the refresh contract reports outcomes instead of throwing, and the admin needs to be told which file to convert.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: false, content: { "application/json": { schema: { type: "object", properties: { force: { type: "boolean", description: "Reimport even if the sources are unchanged." }, approve: { type: "boolean", description: "Accept a refresh in which a previously-loaded source has vanished." } } } } } } */ + /* #swagger.responses[200] = { description: 'What happened', content: { "application/json": { schema: { $ref: "#/components/schemas/ClilocRefreshResult" } } } } */ + adminOnly, + body('force').optional().isBoolean(), + body('approve').optional().isBoolean(), + validate, + shardClilocs.importClilocs, +) +shardRouter.put( + '/clilocs/path', + // #swagger.tags = ['Admin · Shard'] + // #swagger.summary = 'Set the cliloc source the site reads from (admin only)' + // #swagger.description = 'Accepts either the converted base file itself or a directory to search. Overlays are read from a `custom/` directory beside it either way — pointing at a file does not forfeit them. Persisted as a setting, which wins over the UO_CLIENT_PATH deploy default so the mount can move without a redeploy. Blank clears it and resolution is skipped on the next boot. Deliberately does not import as a side effect — the response carries the refreshed status so the panel can offer that as the next step.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["path"], properties: { path: { type: "string", description: "Path to the converted cliloc file, or a directory containing one. Blank disables resolution." } } } } } } */ + /* #swagger.responses[200] = { description: 'Cliloc status after the change', content: { "application/json": { schema: { $ref: "#/components/schemas/ClilocStatus" } } } } */ + adminOnly, + body('path').isString().isLength({ max: 512 }), + validate, + shardClilocs.setPath, +) + +// ── Feature visibility (admin only) ─────────────────────────────────── +// Who can see which shard surface, and which sensitive fields within it. This +// decides what ANONYMOUS visitors get, so it sits above the moderator tier. +shardRouter.get( + '/visibility', + // #swagger.tags = ['Admin · Shard'] + // #swagger.summary = 'Get per-feature shard visibility config (admin only)' + // #swagger.description = 'The effective config (compiled defaults merged with stored overrides) plus the vocabulary the admin UI renders from: the audience ladder and the always-locked fields. Defaults reproduce pre-v3 behavior.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Visibility config', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardVisibilityConfig" } } } } */ + /* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + adminOnly, + shardVisibility.getVisibility, +) +shardRouter.put( + '/visibility', + // #swagger.tags = ['Admin · Shard'] + // #swagger.summary = 'Update per-feature shard visibility config (admin only)' + // #swagger.description = 'Patch one or more features. Unknown feature names, unknown rungs, and any attempt to configure a locked field (acct / webId — admin-only always) are rejected with 400 rather than silently dropped.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ShardVisibilityUpdate" } } } } */ + /* #swagger.responses[200] = { description: 'Updated config', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardVisibilityConfig" } } } } */ + /* #swagger.responses[400] = { description: 'Unknown feature, rung, or a locked field', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + adminOnly, + body('features').isObject(), + validate, + shardVisibility.putVisibility, +) + +module.exports = shardRouter diff --git a/server/router/admin/shardAtlas.controller.js b/server/router/admin/shardAtlas.controller.js new file mode 100644 index 0000000..b8b5a61 --- /dev/null +++ b/server/router/admin/shardAtlas.controller.js @@ -0,0 +1,117 @@ +// ── Admin · Spawn atlas ──────────────────────────────────────────────────── +// +// Operating the atlas import: where the ServUO tree is, whether it has drifted +// from what is loaded, and the approve/reject decision for a refresh that would +// remove a facet (docs/website/SPAWN_ATLAS.md). +// +// The policy lives in the model. This controller does three things and no more: +// it validates input, it maps a refresh RESULT onto an HTTP status, and it +// records the action in the admin activity log. +// +// **A refresh result is not an exception.** `shardAtlas.refresh()` reports +// `unavailable` / `failed` / `needsReview` rather than throwing, because the boot +// path must never be stopped by a bad tree. That contract is preserved here: an +// unreadable mount is a 200 carrying `status: 'unavailable'`, not a 500. The +// admin needs to be told what is wrong with their path, and a 500 says only +// "something broke". + +const atlas = require('../../model/shardAtlas/shardAtlas.model') +const { activity } = require('../../core') + +const log = require('../../core').logger('admin-shard-atlas') + +// GET /admin/shard/atlas — what is loaded, what the tree looks like, what is +// staged. Unlike the public /atlas/meta route this DOES carry the filesystem +// path and the drift flag: that is the whole point of the panel. +async function getStatus(req, res) { + try { + return res.json(await atlas.status()) + } catch (err) { + log.error('getStatus', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// POST /admin/shard/atlas/import — apply a map change without a restart. +// +// `force` reimports even when the source hashes match what is loaded (the escape +// hatch for "the database is wrong but the tree is not"). Facet loss is still +// staged rather than applied — approving is a separate, explicit act. +async function importAtlas(req, res) { + try { + const force = !!req.body?.force + const result = await atlas.refresh({ force }) + await activity.log({ + req, + action: 'shard.atlas.import', + detail: { force, status: result.status, counts: result.counts ?? null }, + }) + return res.json(result) + } catch (err) { + log.error('importAtlas', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// POST /admin/shard/atlas/approve — apply a staged refresh, facet loss and all. +// +// Re-parses the tree rather than applying something captured at boot: only the +// DECISION was stored, so what lands matches the tree as it is now. If the +// operator has since fixed a half-copied mount, the approved import is simply +// the corrected one — which is the desired outcome, not a surprise. +async function approve(req, res) { + try { + const result = await atlas.approvePending() + await activity.log({ + req, + action: 'shard.atlas.approve', + detail: { status: result.status, removed: result.removedFacets ?? null }, + }) + return res.json(result) + } catch (err) { + log.error('approveAtlas', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// POST /admin/shard/atlas/reject — keep the current atlas and remember the +// decision against those exact source hashes, so a declined refresh does not +// re-prompt on every restart. Changing the tree asks again. +async function reject(req, res) { + try { + const result = await atlas.rejectPending() + if (result.status === 'none') { + return res.status(404).json({ message: 'No refresh is awaiting review.' }) + } + await activity.log({ req, action: 'shard.atlas.reject', detail: {} }) + return res.json(result) + } catch (err) { + log.error('rejectAtlas', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// PUT /admin/shard/atlas/path — point the atlas at a different ServUO tree. +// +// Persisted as a setting, which wins over the SERVUO_PATH env default so an +// operator can move the mount without a redeploy. Blank clears it, which turns +// the atlas off (boot skips, the loaded atlas keeps serving) — that is a +// legitimate thing to want, so it is allowed rather than validated away. +// +// Deliberately does NOT import as a side effect: changing where the atlas reads +// from and reloading it are separate decisions, and an operator fixing a typo +// should not have a multi-thousand-row replace happen under them. The response +// carries the refreshed status so the panel can offer the import immediately. +async function setPath(req, res) { + try { + const value = String(req.body?.path ?? '').trim() + await atlas.setServuoPath(value, req.user?.id ?? null) + await activity.log({ req, action: 'shard.atlas.path', detail: { path: value } }) + return res.json(await atlas.status()) + } catch (err) { + log.error('setAtlasPath', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +module.exports = { getStatus, importAtlas, approve, reject, setPath } diff --git a/server/router/admin/shardClilocs.controller.js b/server/router/admin/shardClilocs.controller.js new file mode 100644 index 0000000..c35882a --- /dev/null +++ b/server/router/admin/shardClilocs.controller.js @@ -0,0 +1,106 @@ +// ── Admin · Cliloc table ─────────────────────────────────────────────────── +// +// Operating the cliloc import: where the converted cliloc file is, whether it +// has drifted from what is loaded, and a forced reimport after a client patch +// (docs/website/CLILOCS.md). +// +// The policy lives in the model. This controller does three things and no more: +// it validates input, it maps a refresh RESULT onto an HTTP status, and it +// records the action in the admin activity log. +// +// **A refresh result is not an exception.** `shardClilocs.refresh()` reports +// `unavailable` / `failed` rather than throwing, because the boot path must never +// be stopped by a bad file. That contract is preserved here: a missing file, or +// the single most likely operator mistake — pointing at the client's own +// COMPRESSED `Cliloc.enu` — is a 200 carrying `status: 'unavailable'` and the +// reason, not a 500. A 500 would say only "something broke"; the operator needs +// to be told which file to convert. + +const clilocs = require('../../model/shardClilocs/shardClilocs.model') +const market = require('../../model/shardMarket/shardMarket.model') +const { activity } = require('../../core') + +const log = require('../../core').logger('admin-shard-clilocs') + +// GET /admin/shard/clilocs — what is loaded, what the file looks like, whether +// they disagree. There is no public counterpart: the cliloc table is never +// served as a table, only applied to names the site already returns. +async function getStatus(req, res) { + try { + return res.json(await clilocs.status()) + } catch (err) { + log.error('getStatus', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// POST /admin/shard/clilocs/import — reload after a client patch or a change to +// the shard's own overlay files, without a restart. +// +// `force` reimports even when the source hashes match what is loaded (the escape +// hatch for "the database is wrong but the files are not"). +// +// `approve` accepts a refresh in which a previously-loaded source has VANISHED. +// That is refused by default because an unmounted volume and a deliberate +// deletion look identical from the server — the lighter cousin of the atlas's +// approve/reject flow, and the reason it can be a flag here rather than a +// pending table is that nothing is stored to approve: the import re-reads the +// files at approval time by construction. +async function importClilocs(req, res) { + try { + const force = !!req.body?.force + const approve = !!req.body?.approve + const result = await clilocs.refresh({ force, approve }) + + // The marketplace denormalizes resolved item names into + // shard_vendor_items.display_name, and the shard's market sweep will NOT + // re-send an unchanged shop just because the site learned what its items are + // called — so without this pass, an operator who imports clilocs after the + // first sweep keeps seeing item ids until every shop happens to change. + // Awaited (rather than fired and forgotten) so the panel's "imported" is + // honest about the names being live; the pass is a bounded walk of one table + // and never throws. + if (result.status === 'imported') await market.refreshDisplayNames() + + await activity.log({ + req, + action: 'shard.clilocs.import', + detail: { + force, + approve, + status: result.status, + count: result.count ?? null, + missingSources: result.missingSources ?? result.acceptedMissing ?? null, + }, + }) + return res.json(result) + } catch (err) { + log.error('importClilocs', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// PUT /admin/shard/clilocs/path — point the site at a different cliloc file. +// +// Persisted as a setting, which wins over the UO_CLIENT_PATH env default so an +// operator can move the mount without a redeploy. Blank clears it, which turns +// resolution off (boot skips, the loaded table keeps serving) — a legitimate +// thing to want, so it is allowed rather than validated away. +// +// Deliberately does NOT import as a side effect, for the same reason the atlas +// path does not: changing where the table reads from and reloading it are +// separate decisions. The response carries the refreshed status so the panel can +// offer the import immediately. +async function setPath(req, res) { + try { + const value = String(req.body?.path ?? '').trim() + await clilocs.setClientPath(value, req.user?.id ?? null) + await activity.log({ req, action: 'shard.clilocs.path', detail: { path: value } }) + return res.json(await clilocs.status()) + } catch (err) { + log.error('setClilocPath', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +module.exports = { getStatus, importClilocs, setPath } diff --git a/server/router/admin/shardOps.controller.js b/server/router/admin/shardOps.controller.js new file mode 100644 index 0000000..5cf8821 --- /dev/null +++ b/server/router/admin/shardOps.controller.js @@ -0,0 +1,171 @@ +// ── Admin: in-game staff operations (uo-link write plane + support queue) ──── +// +// The privileged "write plane" (§6 of the sidecar guide): kick / ban / unban / +// broadcast against the live shard, plus the help-page (support ticket) queue. +// Gated admin+moderator at the route (modAccess) — the sidecar trusts the +// loopback socket, so authorization is entirely the site's responsibility. +// +// SECURITY: `actor` (who is taking the action) is ALWAYS set here from the +// authenticated session (req.user.username), never from the request body, so an +// action can't be attributed to someone else. The shard records it in its console +// log, the ban's BanDealer tag, and the admin.audit event it echoes back. + +const uoLinkClient = require('../../utils/uoLinkClient') +const shardState = require('../../model/shardState/shardState.model') +const shardEvents = require('../../model/shardEvents/shardEvents.model') +const { activity } = require('../../core') + +const log = require('../../core').logger('admin-shard-ops') + +// Map a never-throw uoLinkClient result onto an HTTP response. `okData` shapes the +// success body. Mirrors the sidecar's documented status codes so the UI can tell a +// transient outage (503/504 — retry) from a real rejection (403/404). +function relay(res, result, okData) { + if (result.ok) return res.json(okData(result.data)) + switch (result.status) { + case 400: + return res.status(400).json({ message: (result.data && result.data.error) || 'The shard rejected that request.' }) + case 403: + return res.status(403).json({ + message: + (result.data && result.data.error) || + 'That action was refused — the target is protected, or the write plane is disabled on the shard.', + }) + case 404: + return res.status(404).json({ message: 'No such account or target on the shard.' }) + case 503: + case 504: + case 0: + return res.status(503).json({ message: 'The shard is unavailable right now — try again shortly.' }) + default: + return res.status(502).json({ message: 'Could not reach the shard.' }) + } +} + +// POST /admin/shard/kick — disconnect every live session of an account (or serial). +async function kick(req, res) { + const { account, serial } = req.body + const actor = req.user.username + try { + const result = await uoLinkClient.adminKick({ actor, account, serial }) + if (result.ok) await activity.log({ req, action: 'shard.kick', detail: { account, serial } }) + return relay(res, result, (d) => d || { ok: true }) + } catch (err) { + log.error('shardOps.kick', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// POST /admin/shard/ban — ban an account (works offline); durationSec 0/absent = indefinite. +async function ban(req, res) { + const { account, serial, durationSec, reason } = req.body + const actor = req.user.username + try { + const result = await uoLinkClient.adminBan({ actor, account, serial, durationSec, reason }) + if (result.ok) await activity.log({ req, action: 'shard.ban', detail: { account, serial, durationSec, reason } }) + return relay(res, result, (d) => d || { ok: true }) + } catch (err) { + log.error('shardOps.ban', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// POST /admin/shard/unban — clear an account's ban. +async function unban(req, res) { + const { account } = req.body + const actor = req.user.username + try { + const result = await uoLinkClient.adminUnban({ actor, account }) + if (result.ok) await activity.log({ req, action: 'shard.unban', detail: { account } }) + return relay(res, result, (d) => d || { ok: true }) + } catch (err) { + log.error('shardOps.unban', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// POST /admin/shard/broadcast — a system message to everyone online. +async function broadcast(req, res) { + const { text, hue } = req.body + const actor = req.user.username + try { + const result = await uoLinkClient.adminBroadcast({ actor, text, hue }) + if (result.ok) await activity.log({ req, action: 'shard.broadcast', detail: { text } }) + return relay(res, result, (d) => d || { ok: true }) + } catch (err) { + log.error('shardOps.broadcast', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// GET /admin/shard/pages — the open help-page (support) queue, from our store. +async function listPages(req, res) { + try { + return res.json(await shardState.listPages()) + } catch (err) { + log.error('shardOps.listPages', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// POST /admin/shard/pages/:id/respond — reply to a player (optionally close). +async function respondPage(req, res) { + const { id } = req.params + const { message, close } = req.body + try { + const result = await uoLinkClient.respondPage(id, { message, close: Boolean(close) }) + if (result.ok) { + await activity.log({ req, action: 'shard.page.respond', detail: { pageId: id, close: Boolean(close) } }) + // Close removes the page from the queue; reflect it locally at once (the + // page.closed event will confirm it, but the UI shouldn't wait a poll cycle). + if (close) await shardState.removePage(id).catch(() => {}) + } + return relay(res, result, (d) => d || { ok: true }) + } catch (err) { + log.error('shardOps.respondPage', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// POST /admin/shard/pages/:id/close — resolve a page without a reply. +async function closePage(req, res) { + const { id } = req.params + try { + const result = await uoLinkClient.closePage(id) + if (result.ok) { + await activity.log({ req, action: 'shard.page.close', detail: { pageId: id } }) + await shardState.removePage(id).catch(() => {}) + } + return relay(res, result, (d) => d || { ok: true }) + } catch (err) { + log.error('shardOps.closePage', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// GET /admin/shard/audit — recent moderation audit events (admin.audit), from the +// ingested event log. Seeds the live audit log the panel keeps current over SSE. +async function listAudit(req, res) { + try { + const limit = req.query.limit + return res.json(await shardEvents.list({ kind: 'admin.audit', limit })) + } catch (err) { + log.error('shardOps.listAudit', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// GET /admin/shard/houses — the FULL house registry (owner, price, co-owners, +// decay), staff-only (modAccess). The public /public/shard/houses shows only IDOC +// houses with location; this is the complete board, kept live for staff on the +// admin SSE channel (house.update / house.remove). +async function listHouses(req, res) { + try { + return res.json(await shardState.listHouses()) + } catch (err) { + log.error('shardOps.listHouses', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +module.exports = { kick, ban, unban, broadcast, listPages, respondPage, closePage, listAudit, listHouses } diff --git a/server/router/admin/shardVisibility.controller.js b/server/router/admin/shardVisibility.controller.js new file mode 100644 index 0000000..285f32b --- /dev/null +++ b/server/router/admin/shardVisibility.controller.js @@ -0,0 +1,98 @@ +// ── Admin · Shard visibility ─────────────────────────────────────────────── +// +// Read/write the per-feature audience config that gates every shard-derived +// surface. Admin-only: this decides what anonymous visitors can see, so it is +// not part of the moderator tier. +// +// The policy itself (the ladder, the feature catalog, which fields are locked) +// lives in utils/shardVisibility.js. This controller only validates input +// against that policy and persists it. + +const model = require('../../model/shardVisibility/shardVisibility.model') +const visibility = require('../../utils/shardVisibility') +const log = require('../../core').logger('admin-shard-visibility') + +// GET /admin/shard/visibility — the effective config (defaults merged with any +// stored overrides), plus the vocabulary the admin UI needs to render itself: +// the ladder, and which fields each feature exposes as configurable. +async function getVisibility(req, res) { + try { + const config = await visibility.getConfig() + return res.json({ + ladder: visibility.LADDER, + lockedFields: Object.keys(visibility.LOCKED_FIELDS), + defaults: visibility.compileDefaults(), + features: config, + }) + } catch (err) { + log.error('getVisibility', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// PUT /admin/shard/visibility — replace the settings for one or more features. +// Body: { features: { : { enabled, audience, stream, fieldRules } } } +// +// Rejects unknown feature names, unknown rungs, and any attempt to configure a +// locked field — a 400 rather than a silent drop, so an admin who tries to make +// `acct` public learns that it is not negotiable. +async function putVisibility(req, res) { + try { + const incoming = req.body?.features + if (!incoming || typeof incoming !== 'object' || Array.isArray(incoming)) { + return res.status(400).json({ message: 'features object required' }) + } + + const entries = [] + for (const [name, patch] of Object.entries(incoming)) { + if (!visibility.isFeature(name)) { + return res.status(400).json({ message: `Unknown feature: ${name}` }) + } + if (!patch || typeof patch !== 'object' || Array.isArray(patch)) { + return res.status(400).json({ message: `Invalid settings for ${name}` }) + } + if (patch.audience != null && !visibility.isLevel(patch.audience)) { + return res.status(400).json({ message: `Unknown audience for ${name}: ${patch.audience}` }) + } + + const fieldRules = {} + for (const [field, level] of Object.entries(patch.fieldRules || {})) { + // Matches flattened spellings too (`ownerAcct`, `leaderWebId`), so the + // rejection covers every way the field can be named rather than the two + // canonical keys. + if (visibility.isLockedField(field)) { + return res.status(400).json({ message: `Field '${field}' is admin-only and cannot be configured` }) + } + if (!visibility.isLevel(level)) { + return res.status(400).json({ message: `Unknown rung for ${name}.${field}: ${level}` }) + } + fieldRules[field] = level + } + + const current = (await visibility.getConfig())[name] + entries.push({ + feature: name, + enabled: patch.enabled == null ? current.enabled : !!patch.enabled, + audience: patch.audience ?? current.audience, + stream: patch.stream == null ? current.stream : !!patch.stream, + fieldRules, + updatedBy: req.user?.id ?? null, + }) + } + + for (const entry of entries) await model.upsert(entry) + visibility.invalidate() + + log.info('shard visibility updated', { + by: req.user?.id, + features: entries.map((e) => e.feature), + }) + + return res.json({ features: await visibility.getConfig() }) + } catch (err) { + log.error('putVisibility', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +module.exports = { getVisibility, putVisibility } diff --git a/server/router/admin/uoLink.controller.js b/server/router/admin/uoLink.controller.js new file mode 100644 index 0000000..4075448 --- /dev/null +++ b/server/router/admin/uoLink.controller.js @@ -0,0 +1,123 @@ +// ── Admin: uo-link sidecar control ───────────────────────────────────────── +// +// Configure the connection to the uo-link sidecar (base/ws URL, shared-secret +// token, protocol pin, enabled) and drive the town crier. SECURITY: the token +// is write-only over this API — stored encrypted, NEVER returned; responses +// expose only `hasToken` (same convention as the Discord bot token). Saving +// (re)starts the WS ingest client so a change takes effect with no redeploy. + +const uoLinkConfig = require('../../model/uoLinkConfig/uoLinkConfig.model') +const uoLinkClient = require('../../utils/uoLinkClient') +const uoLinkSocket = require('../../utils/uoLinkSocket') +const shardBroadcast = require('../../utils/shardBroadcast') +const { activity } = require('../../core') + +const log = require('../../core').logger('admin-uolink') + +// Assemble the masked config + live health + ingestion stats for the panel. +async function buildStatus() { + const config = await uoLinkConfig.getSafe() + const health = await uoLinkClient.health() + return { + ...config, + health: health.ok ? health.data : { ok: false, error: health.error || `status ${health.status}` }, + ingest: uoLinkSocket.getState(), + sse: shardBroadcast.stats(), + } +} + +// GET /admin/uo-link/config — masked config + live status + ingestion stats. +async function getConfig(req, res) { + try { + return res.json(await buildStatus()) + } catch (err) { + log.error('uoLink.getConfig', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// PUT /admin/uo-link/config — save connection settings + (re)start the socket. +async function saveConfig(req, res) { + const { baseUrl, wsUrl, token, protocol, enabled } = req.body + try { + const current = await uoLinkConfig.getSafe() + const willHaveToken = Boolean(token) || current.hasToken + if (enabled && !willHaveToken) { + return res.status(400).json({ message: 'An auth token is required before enabling.' }) + } + + await uoLinkConfig.save({ + baseUrl, + wsUrl, + token, + protocol: protocol !== undefined ? Number(protocol) : undefined, + enabled, + updatedBy: req.user.id, + }) + // Drop the client's cached config so the health check below uses the new values. + uoLinkClient.invalidateConfig() + + // (Re)start or stop the ingest socket to match the new enabled/URL/token. + const saved = await uoLinkConfig.getSafe() + if (saved.enabled && saved.hasToken) { + await uoLinkSocket.start() + } else { + uoLinkSocket.stop() + await uoLinkConfig.recordStatus({ status: 'disconnected', pluginConnected: false }) + } + + await activity.log({ req, action: 'uoLink.config.update', detail: { baseUrl: saved.baseUrl, enabled: saved.enabled } }) + log.info('uo-link config updated', { by: req.user.username, enabled: saved.enabled }) + return res.json(await buildStatus()) + } catch (err) { + log.error('uoLink.saveConfig', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// POST /admin/uo-link/towncrier — publish/replace a town-crier message. +async function postTownCrier(req, res) { + const { id, lines, durationSec } = req.body + try { + const result = await uoLinkClient.postTownCrier({ id, lines, durationSec }) + if (result.ok) { + await activity.log({ req, action: 'uoLink.towncrier.post', detail: { id } }) + return res.json(result.data || { ok: true, id }) + } + if (result.status === 400) return res.status(400).json({ message: 'The shard rejected that message (over the line/duration caps?).' }) + if (result.status === 503 || result.status === 0) { + return res.status(503).json({ message: 'The shard is unavailable right now.' }) + } + return res.status(502).json({ message: 'Could not reach the shard.' }) + } catch (err) { + log.error('uoLink.postTownCrier', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// DELETE /admin/uo-link/towncrier/:id — remove a town-crier message. +async function deleteTownCrier(req, res) { + const { id } = req.params + try { + const result = await uoLinkClient.deleteTownCrier(id) + if (result.ok) { + await activity.log({ req, action: 'uoLink.towncrier.delete', detail: { id } }) + return res.json(result.data || { ok: true, id }) + } + if (result.status === 404) return res.status(404).json({ message: 'No town-crier message with that id.' }) + if (result.status === 503 || result.status === 0) { + return res.status(503).json({ message: 'The shard is unavailable right now.' }) + } + return res.status(502).json({ message: 'Could not reach the shard.' }) + } catch (err) { + log.error('uoLink.deleteTownCrier', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// GET /admin/uo-link/stream — the full live feed (incl. audit/cheat), staff only. +function stream(req, res) { + shardBroadcast.subscribe(req, res, 'admin') +} + +module.exports = { getConfig, saveConfig, postTownCrier, deleteTownCrier, stream } diff --git a/server/router/admin/uoLink.router.js b/server/router/admin/uoLink.router.js new file mode 100644 index 0000000..8974733 --- /dev/null +++ b/server/router/admin/uoLink.router.js @@ -0,0 +1,100 @@ +// Admin · uo-link — the sidecar connection config, the town crier, and the +// staff SSE stream. +// +// Mounted at /api/v1/admin/uo-link by admin/index.js, which already applied +// `noindex, isLoggedIn, staffOnly`. This is where shard integration is +// configured: base/ws URL, bearer token, protocol version and the enabled +// toggle all live in the DB (uoLinkConfig), never in env. The token is +// write-only over this API (SECURITY note in uoLink.controller.js). +// +// /stream is the ADMIN SSE channel — it carries staff audit, cheat detection +// and login attempts on top of the public event kinds. The public/admin +// allowlist split in utils/shardIngest.js is a security boundary; the adminOnly +// gate below is its other half. +// +// The routes keep their `Admin · Shard` swagger tag: retagging is a real +// OpenAPI diff and does not belong in a route-move PR. +// +// Admin-only, and kept as a per-route gate rather than a router-level `use` so +// the middleware chain each route carries is unchanged by the move. + +const core = require('../../core') + +const express = core.express +const { body, param } = core.validator + +const uoLink = require('./uoLink.controller') +const { requireRole, validate } = core.middleware + +const uoLinkRouter = express.Router() +const adminOnly = requireRole('admin') + +uoLinkRouter.get( + '/config', + // #swagger.tags = ['Admin · Shard'] + // #swagger.summary = 'Get uo-link config + live status + ingestion stats (admin only)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Masked config, health and ingestion stats', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ + /* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + adminOnly, + uoLink.getConfig, +) +uoLinkRouter.put( + '/config', + // #swagger.tags = ['Admin · Shard'] + // #swagger.summary = 'Save uo-link connection config (admin only)' + // #swagger.description = 'token is write-only — omit/blank it to keep the existing one. Saving (re)starts the WS ingest client.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { baseUrl: { type: "string" }, wsUrl: { type: "string" }, token: { type: "string" }, protocol: { type: "integer" }, enabled: { type: "boolean" } } } } } } */ + /* #swagger.responses[200] = { description: 'Updated config + live status', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ + /* #swagger.responses[400] = { description: 'Validation error, or missing token while enabling', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + adminOnly, + body('baseUrl').optional({ values: 'falsy' }).isString().trim().isURL({ require_tld: false, protocols: ['http', 'https'] }), + body('wsUrl').optional({ values: 'falsy' }).isString().trim().isURL({ require_tld: false, protocols: ['ws', 'wss'] }), + body('token').optional({ values: 'falsy' }).isString().trim(), + body('protocol').optional().isInt({ min: 1, max: 99 }), + body('enabled').optional().isBoolean(), + validate, + uoLink.saveConfig, +) +uoLinkRouter.post( + '/towncrier', + // #swagger.tags = ['Admin · Shard'] + // #swagger.summary = 'Publish / replace a town-crier message (admin only)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TownCrierRequest" } } } } */ + /* #swagger.responses[200] = { description: 'Posted', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ + /* #swagger.responses[400] = { description: 'Rejected (over caps)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[503] = { description: 'Shard unavailable', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + adminOnly, + body('id').isString().trim().isLength({ min: 1, max: 64 }), + body('lines').isArray({ min: 1, max: 8 }), + body('lines.*').isString().isLength({ max: 200 }), + body('durationSec').optional().isInt({ min: 1, max: 86400 }), + validate, + uoLink.postTownCrier, +) +uoLinkRouter.delete( + '/towncrier/:id', + // #swagger.tags = ['Admin · Shard'] + // #swagger.summary = 'Remove a town-crier message (admin only)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Town-crier message id.' } + /* #swagger.responses[200] = { description: 'Removed', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ + /* #swagger.responses[404] = { description: 'Unknown id', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + adminOnly, + param('id').isString().trim().isLength({ min: 1, max: 64 }), + validate, + uoLink.deleteTownCrier, +) +uoLinkRouter.get( + '/stream', + // #swagger.tags = ['Admin · Shard'] + // #swagger.summary = 'Full live shard event stream incl. audit/cheat (SSE, admin only)' + /* #swagger.responses[200] = { description: 'An SSE stream (Content-Type: text/event-stream).' } */ + adminOnly, + uoLink.stream, +) + +module.exports = uoLinkRouter diff --git a/server/router/admin/usersShard.controller.js b/server/router/admin/usersShard.controller.js new file mode 100644 index 0000000..e408766 --- /dev/null +++ b/server/router/admin/usersShard.controller.js @@ -0,0 +1,129 @@ +// ── 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 } diff --git a/server/router/admin/usersShard.router.js b/server/router/admin/usersShard.router.js new file mode 100644 index 0000000..bf092e8 --- /dev/null +++ b/server/router/admin/usersShard.router.js @@ -0,0 +1,113 @@ +// ── The `admin.users.detail` extension slot's contents ───────────────────── +// +// MODULE-UO CONTENT, still living in core. MODULE_SYSTEM.md §1.9 named the +// fourth mount shape: module routes hanging off a CORE resource. These six paths +// are shard reads on `/admin/users/:id`, a user-management URL core owns, so +// they cannot move with a prefix and cannot stay where they are either. +// +// The resolution is an extension SLOT. `users.router.js` declares +// `admin.users.detail` and mounts its router at `/:id`; this file is what fills +// it, registered through modules/registries.js like a module would +// (registerCore() → `api.registerExtension('admin.users.detail', …)`). Phase 3 +// moves this file to module-uo and changes nothing else — the six URLs are +// identical either way, and core never learns what "shard" means. +// +// `mergeParams` comes from the slot's router, so `req.params.id` is the parent's +// user id. Core's own routes on the resource are declared BEFORE the slot is +// mounted, so core always wins a path conflict (MODULE_API.md §2.4). + +const core = require('../../core') + +const express = core.express +const { param } = core.validator + +const usersShard = require('./usersShard.controller') +const { validate } = core.middleware + +// Same shape the shard routes validate account names with. +const SHARD_ACCOUNT_RE = /^[A-Za-z0-9_.-]{1,120}$/ + +const shardRouter = express.Router({ mergeParams: true }) + +// Backs the /admin/users/:id detail page: a user's linked game accounts and, +// scoped to those accounts, their vendor sales / houses / online characters. +// Live character rosters are fetched by the client through /admin/shard/* (which +// already grants admins a bypass to any account), so no routes for them here. +shardRouter.get( + '/shard/accounts', + // #swagger.tags = ['Admin · Users'] + // #swagger.summary = 'A user’s linked game accounts (admin only)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } + /* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardLink" } } } } } */ + /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt(), + validate, + usersShard.listAccounts, +) +shardRouter.get( + '/shard/sales', + // #swagger.tags = ['Admin · Users'] + // #swagger.summary = 'Recent vendor sales on a user’s accounts (admin only)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } + /* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */ + /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt(), + validate, + usersShard.getSales, +) +shardRouter.get( + '/shard/houses', + // #swagger.tags = ['Admin · Users'] + // #swagger.summary = 'Houses owned by a user’s accounts (admin only)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } + /* #swagger.responses[200] = { description: 'Houses (IDOC first)', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */ + /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt(), + validate, + usersShard.getHouses, +) +shardRouter.get( + '/shard/online', + // #swagger.tags = ['Admin · Users'] + // #swagger.summary = 'A user’s characters currently online (admin only)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } + /* #swagger.responses[200] = { description: 'Online characters', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */ + /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt(), + validate, + usersShard.getOnline, +) +shardRouter.get( + '/shard/standing', + // #swagger.tags = ['Admin · Users'] + // #swagger.summary = 'A user’s shard standing — governorships held and guilds led (admin only)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } + /* #swagger.responses[200] = { description: 'Standing { governorOf, guildsLed }', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ + /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt(), + validate, + usersShard.getStanding, +) +shardRouter.delete( + '/shard/link/:account', + // #swagger.tags = ['Admin · Users'] + // #swagger.summary = 'Unlink a game account from this user (admin only)' + // #swagger.description = 'Severs a game account’s tie to the website user from the site side (sidecar DELETE /link/{account}) and drops the local mirror. actor is stamped from the session.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } + // #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Game account to unlink.' } + /* #swagger.responses[200] = { description: 'Unlinked', content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" }, unlinked: { type: "boolean" } } } } } } */ + /* #swagger.responses[403] = { description: 'Protected staff account (refused by shard)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[404] = { description: 'Not linked', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt(), + param('account').matches(SHARD_ACCOUNT_RE), + validate, + usersShard.unlinkAccount, +) + +module.exports = shardRouter diff --git a/server/router/player/shard.controller.js b/server/router/player/shard.controller.js new file mode 100644 index 0000000..90423a9 --- /dev/null +++ b/server/router/player/shard.controller.js @@ -0,0 +1,273 @@ +// ── Player: game-account linking + reads ─────────────────────────────────── +// +// The player-facing surface for the uo-link integration. A logged-in player +// runs [link in game, gets a one-time code, and enters it here — the server +// confirms it with the sidecar (which permanently tags the game account with the +// website user id) and mirrors the link locally. Roster/vendor reads are +// ownership-checked against that mirror so a player can only see accounts they +// have linked. The sidecar token stays server-side throughout. + +const uoLinkClient = require('../../utils/uoLinkClient') +const shardLinks = require('../../model/shardLinks/shardLinks.model') +const shardState = require('../../model/shardState/shardState.model') +const shardClilocs = require('../../model/shardClilocs/shardClilocs.model') +const { settings, activity } = require('../../core') +const { salesForAccounts } = require('../../utils/shardSales') + +const log = require('../../core').logger('player-shard') + +const SERIAL_RE = /^0x[0-9a-fA-F]+$/ + +/** + * Resolve the cliloc ids on a profile into display names. + * + * Items on the wire carry a `LabelNumber`, not a name — `BridgeProfile.WriteItem` + * sends `cliloc` on every equipment entry and `name` only for the minority of + * items a player has renamed. Reward titles are the same shape: the shard sends + * a cliloc number as a string, which the sheet previously had to SKIP because it + * had no way to turn it into words. + * + * Resolution happens here rather than in the browser because the table is ~123k + * rows: shipping it to render a dozen names would dwarf the page, and the + * Android client consumes this same JSON and would otherwise need its own copy. + * + * A shard with no cliloc table configured resolves nothing and the sheet renders + * ids exactly as it did before — this is decoration, and it is applied in the + * same best-effort block as the guild/governor cross-links. + */ +async function resolveProfileClilocs(profile) { + const wanted = [] + + const equipment = Array.isArray(profile.equipment) ? profile.equipment : [] + for (const item of equipment) { + if (Number.isInteger(item?.cliloc)) wanted.push(item.cliloc) + } + + // Reward titles arrive as strings that may be either a literal ("Knight of + // Trinsic") or a cliloc number in string form. Only the numeric ones need us. + const reward = Array.isArray(profile.titles?.reward) ? profile.titles.reward : [] + const rewardNumbers = reward.map((r) => (/^\d+$/.test(String(r)) ? Number(r) : null)) + for (const n of rewardNumbers) if (n !== null) wanted.push(n) + + if (wanted.length === 0) return + + const names = await shardClilocs.resolveMany(wanted) + if (names.size === 0) return + + for (const item of equipment) { + // A player-given name always wins over the type name: an item called "Bob's + // lucky axe" should not be relabelled "hatchet". + if (item?.name) continue + const resolved = names.get(item?.cliloc) + if (resolved) item.clilocName = resolved + } + + if (rewardNumbers.some((n) => n !== null)) { + profile.titles.rewardResolved = reward.map((raw, i) => { + const n = rewardNumbers[i] + return n === null ? String(raw) : names.get(n) ?? null + }) + } +} + +// Decorate a char.profile with cross-links from our own board data: the guild the +// character leads and any city governorship on its account, plus resolved cliloc +// names. Best-effort — a failure here never fails the profile (it's a nicety, +// not the sheet). +async function enrichCharProfile(profile) { + if (!profile) return profile + try { + const guild = await shardState.findGuildForActor({ serial: profile.serial, acct: profile.acct }) + if (guild) profile.guild = guild + if (profile.acct) { + const govs = await shardState.listGovernorshipsForAccounts([profile.acct]) + if (govs.length) profile.governorOf = govs.map((g) => g.city) + } + await resolveProfileClilocs(profile) + } catch (err) { + log.warn('enrichCharProfile failed', { serial: profile.serial, message: err.message }) + } + return profile +} + +// POST /player/shard/link — confirm an in-game link code. +async function link(req, res) { + const { code } = req.body + try { + const result = await uoLinkClient.confirmLink(code, req.user.id) + + if (result.ok && result.data && result.data.kind === 'link.ok') { + const account = result.data.account + await shardLinks.link({ account, userId: req.user.id, charName: result.data.char || null }) + await activity.log({ req, action: 'uoLink.account.link', detail: { account } }) + log.info('player linked game account', { user: req.user.username, account }) + return res.json({ linked: true, account }) + } + + // Sidecar reports bad/expired codes as 400 link.error or 404. + if (result.status === 400 || result.status === 404) { + return res.status(400).json({ message: 'That code is unknown or has expired. Run [link in game for a new one.' }) + } + if (result.status === 503 || result.status === 0) { + return res.status(503).json({ message: 'The shard is unavailable right now — try again shortly.' }) + } + return res.status(502).json({ message: 'Could not confirm the link with the shard.' }) + } catch (err) { + log.error('player.shard.link', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// GET /player/shard/accounts — the caller's linked game accounts. +async function listAccounts(req, res) { + try { + return res.json(await shardLinks.listForUser(req.user.id)) + } catch (err) { + log.error('player.shard.listAccounts', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// Admins may view any character's data; everyone else is limited to accounts +// they have personally linked. The same handlers back /player/shard (role +// `player`, never admin) and /admin/shard (staff), so this bypass only ever +// widens access for genuine admins. +const isAdmin = (req) => req.user && req.user.role === 'admin' + +// Shared ownership gate + live round-trip for roster/vendors. `fetcher` is the +// uoLinkClient method to call with the account. +async function ownedRoundTrip(req, res, fetcher, label) { + const { account } = req.params + try { + const owns = isAdmin(req) || (await shardLinks.ownsAccount(account, req.user.id)) + if (!owns) return res.status(403).json({ message: 'That account is not linked to your profile.' }) + + const result = await fetcher(account) + if (result.ok) return res.json(result.data) + if (result.status === 404) return res.status(404).json({ message: 'Not found.' }) + if (result.status === 503 || result.status === 0) { + return res.status(503).json({ message: 'The shard is unavailable right now — try again shortly.' }) + } + return res.status(502).json({ message: 'Could not reach the shard.' }) + } catch (err) { + log.error(`player.shard.${label}`, err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// GET /player/shard/roster/:account — characters on a linked account. +const roster = (req, res) => ownedRoundTrip(req, res, uoLinkClient.getRoster, 'roster') + +// GET /player/shard/vendors/:account — player vendors on a linked account. +const vendors = (req, res) => ownedRoundTrip(req, res, uoLinkClient.getVendors, 'vendors') + +// GET /player/shard/char/:serial — a character sheet, but ONLY if the character's +// account is linked to the caller. The sidecar returns the owning account in the +// profile, which we check against the caller's links before returning anything. +async function getChar(req, res) { + const { serial } = req.params + if (!SERIAL_RE.test(serial)) return res.status(400).json({ message: 'Invalid serial.' }) + try { + const result = await uoLinkClient.getCharBySerial(serial) + if (result.ok) { + // Admins see any character; others only characters on an account they linked. + if (!isAdmin(req)) { + const acct = result.data && result.data.acct + const owns = acct ? await shardLinks.ownsAccount(acct, req.user.id) : false + if (!owns) return res.status(403).json({ message: 'That character is not on an account linked to you.' }) + } + return res.json(await enrichCharProfile(result.data)) + } + if (result.status === 404) return res.status(404).json({ message: 'Character not found.' }) + if (result.status === 503 || result.status === 0) { + return res.status(503).json({ message: 'The game server is restarting — try again shortly.' }) + } + return res.status(502).json({ message: 'Could not reach the shard.' }) + } catch (err) { + log.error('player.shard.getChar', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// GET /player/shard/sales — recent player-vendor sales for the caller's linked +// accounts only (as seller/owner). Read from the site's own event log. +async function getSales(req, res) { + try { + const links = await shardLinks.listForUser(req.user.id) + const accounts = links.map((l) => l.account) + return res.json(await salesForAccounts(accounts)) + } catch (err) { + log.error('player.shard.getSales', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// GET /player/shard/houses — the caller's OWN houses (home status), scoped to +// their linked accounts. A player sees their own decay/IDOC standing; never +// anyone else's. Full detail is fine here — it's their property. +async function getHouses(req, res) { + try { + const links = await shardLinks.listForUser(req.user.id) + const accounts = links.map((l) => l.account) + return res.json(await shardState.listHousesForAccounts(accounts)) + } catch (err) { + log.error('player.shard.getHouses', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// Map a failed uoLinkClient.createAccount result to a user-facing HTTP response. +// The password is never echoed anywhere; only the mapped reason is returned. +function mapCreateAccountError(res, result) { + const reason = (result.data && result.data.reason) || '' + switch (result.status) { + case 409: + return res.status(409).json({ message: 'That account name is already taken.' }) + case 429: + return res.status(429).json({ message: 'The account limit for your network has been reached.' }) + case 403: + return res.status(403).json({ message: 'Game-account signups are not available on this shard right now.' }) + case 400: + return res.status(400).json({ message: reason || 'The account name or password was not accepted.' }) + case 503: + case 0: + return res.status(503).json({ message: 'The game server is unavailable — try again shortly.' }) + default: + return res.status(502).json({ message: 'Could not reach the shard to create the account.' }) + } +} + +// POST /player/shard/account — provision a GAME account for the signed-in website +// user and auto-link it (Protocol 2.0 hybrid). Used by self-serve signup and the +// invite-accept "create game account" step alike (both act as the signed-in user). +// actor + websiteUserId are stamped from the session; the browser IP (req.ip, +// trust-proxy configured) is forwarded for the shard's per-IP cap; the password is +// never logged. Gated by the game_account_signup setting AND the shard's own mode. +async function createGameAccount(req, res) { + const { account, password } = req.body + try { + if (!(await settings.isGameAccountSignupEnabled())) { + return res.status(403).json({ message: 'Game-account signup is not available right now.' }) + } + const result = await uoLinkClient.createAccount({ + actor: req.user.username, + account, + password, + websiteUserId: req.user.id, + ip: req.ip, + }) + if (result.ok) { + // Mirror the link locally so the portal lists the account immediately. + await shardLinks.link({ account, userId: req.user.id }) + await activity.log({ req, userId: req.user.id, action: 'shard.account.create', detail: { account } }) + log.info('game account created', { account, userId: req.user.id, ip: req.ip }) + return res.status(201).json({ account, linked: true }) + } + return mapCreateAccountError(res, result) + } catch (err) { + log.error('player.shard.createGameAccount', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +module.exports = { link, listAccounts, roster, vendors, getChar, getSales, getHouses, createGameAccount } diff --git a/server/router/player/shard.router.js b/server/router/player/shard.router.js new file mode 100644 index 0000000..8a5dd8c --- /dev/null +++ b/server/router/player/shard.router.js @@ -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 caller’s 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 caller’s 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 caller’s 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 caller’s own houses (home status)' + // #swagger.description = 'Houses owned by the caller’s linked accounts, with decay/IDOC status. Only the caller’s own houses — never anyone else’s.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'The caller’s houses', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */ + shard.getHouses, +) + +module.exports = shardRouter diff --git a/server/router/public/atlas.controller.js b/server/router/public/atlas.controller.js new file mode 100644 index 0000000..169fadc --- /dev/null +++ b/server/router/public/atlas.controller.js @@ -0,0 +1,134 @@ +// ── Public: the spawn atlas ──────────────────────────────────────────────── +// +// A browsable catalogue of what the shard CONTAINS — which creatures spawn, +// where, how many, and which champion altars are configured. Everything here is +// a plain indexed read of the tables the boot-time import fills from the shard's +// own ServUO tree (docs/website/SPAWN_ATLAS.md). +// +// Two properties separate this from /public/shard/*: +// +// • **Nothing touches the sidecar.** The atlas is static shard content, not +// live shard state, so these pages stay fully populated while the shard is +// down. That is why the routes are mounted at /public/atlas and are +// siteMode-gated like /posts and /wiki, rather than under /shard. +// • **The live champion feed is a different thing.** `/atlas/champions` is the +// configured roster ("there is an Unholy Terror altar in Deceit"); +// `/shard/champs` is the running state ("it is on level 3 right now"). +// +// Every response is still passed through `projectFeature` for the `atlas` +// feature. It declares no sensitive fields today, so the projection is a +// no-op — but v3.md §3.6.1's rule is that a read path returning shard data and +// not projecting is a bug, and the cost of honouring it is one call per handler +// rather than a retrofit the first time a field needs gating. + +const atlas = require('../../model/shardAtlas/shardAtlas.model') +const visibility = require('../../utils/shardVisibility') + +const log = require('../../core').logger('public-atlas') + +const FEATURE = 'atlas' + +// Query params arrive as strings; express-validator has already bounded them. +const int = (value, fallback) => { + const n = Number.parseInt(value, 10) + return Number.isFinite(n) ? n : fallback +} + +const str = (value) => (typeof value === 'string' ? value.trim() : '') + +// GET /public/atlas/creatures?q=&facet=&limit=&offset= +async function getCreatures(req, res) { + try { + const page = await atlas.searchCreatures({ + q: str(req.query.q), + facet: str(req.query.facet), + limit: int(req.query.limit, 50), + offset: int(req.query.offset, 0), + }) + return res.json(await visibility.project(FEATURE, page, req)) + } catch (err) { + log.error('atlas.getCreatures', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// GET /public/atlas/creatures/:slug — one creature, with the places it spawns. +// +// 404 means "no such creature in this atlas", which also covers "the atlas has +// never been imported" — an empty atlas has no slugs, and there is nothing more +// specific to say to an anonymous caller. +async function getCreature(req, res) { + try { + const creature = await atlas.getCreature(req.params.slug, { + facet: str(req.query.facet), + points: int(req.query.points, 200), + }) + if (!creature) return res.status(404).json({ message: 'Not Found' }) + return res.json(await visibility.project(FEATURE, creature, req)) + } catch (err) { + log.error('atlas.getCreature', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// GET /public/atlas/regions?facet=&q= +async function getRegions(req, res) { + try { + const regions = await atlas.listRegions({ + facet: str(req.query.facet), + q: str(req.query.q), + }) + return res.json(await visibility.project(FEATURE, regions, req)) + } catch (err) { + log.error('atlas.getRegions', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// GET /public/atlas/landmarks?facet=&q= +async function getLandmarks(req, res) { + try { + const landmarks = await atlas.listLandmarks({ + facet: str(req.query.facet), + q: str(req.query.q), + }) + return res.json(await visibility.project(FEATURE, landmarks, req)) + } catch (err) { + log.error('atlas.getLandmarks', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// GET /public/atlas/champions?facet= — the CONFIGURED altar roster. +async function getChampions(req, res) { + try { + const champions = await atlas.listChampions({ facet: str(req.query.facet) }) + return res.json(await visibility.project(FEATURE, champions, req)) + } catch (err) { + log.error('atlas.getChampions', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// GET /public/atlas/meta — what is loaded: facets, counts, when it was imported. +// +// Public-safe by construction: the model omits the ServUO path, the per-file +// hashes and the pending-refresh state, all of which describe the operator's +// filesystem rather than the game world. The admin status route carries those. +async function getMeta(req, res) { + try { + return res.json(await visibility.project(FEATURE, await atlas.publicMeta(), req)) + } catch (err) { + log.error('atlas.getMeta', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +module.exports = { + getCreatures, + getCreature, + getRegions, + getLandmarks, + getChampions, + getMeta, +} diff --git a/server/router/public/atlas.router.js b/server/router/public/atlas.router.js new file mode 100644 index 0000000..27c3258 --- /dev/null +++ b/server/router/public/atlas.router.js @@ -0,0 +1,129 @@ +// Public · Atlas — the spawn atlas / bestiary. Static shard CONTENT derived from +// the shard's own ServUO tree, not live shard state. +// +// Mounted at /api/v1/public/atlas by public/index.js. Two deliberate differences +// from the /public/shard routes next door (docs/link/v3.md §6): +// +// • **Not under /shard.** Nothing here round-trips the sidecar, and the pages +// stay fully populated while the shard is down. Mounting it under /shard +// would imply a dependency it does not have. +// • **siteMode-gated, like /posts and /wiki.** The shard routes are exempt +// because shard status is wanted *during* maintenance; a bestiary is site +// content and follows site content's rules. +// +// Every route also carries `requireFeature('atlas')` — 404 when an admin has +// disabled the feature, 403 when the caller sits below its configured audience. +// The default audience is `anonymous`, so these gates are inert until an admin +// changes something. + +const core = require('../../core') + +const express = core.express +const { param, query } = core.validator + +const atlas = require('./atlas.controller') +const { siteMode, validate } = core.middleware +const { requireFeature } = require('../../utils/shardVisibility') + +const atlasRouter = express.Router() + +// Facet names come from the shard's own files and are never validated against a +// list — nothing in the codebase names a facet (§6.1 R2). Only the length is +// bounded, and the query matches exactly, so an unknown name returns an empty +// result rather than an error. +const facetParam = query('facet').optional({ values: 'falsy' }).isString().isLength({ max: 40 }) + +atlasRouter.get( + '/creatures', + requireFeature('atlas'), + // #swagger.tags = ['Public · Atlas'] + // #swagger.summary = 'Search the bestiary (paginated)' + // #swagger.description = 'Every creature the shard spawns, most numerous first. `total` is how many can be alive at once across all spawners; `points` is how many spawners mention it; `facets` maps facet name to that creature\'s share on it. Static content parsed from the shard\'s ServUO tree — unaffected by the shard being offline.' + // #swagger.parameters['q'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Substring match on the creature name (max 60 chars).' } + // #swagger.parameters['facet'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to creatures spawning on this facet. Facet names come from the shard\'s own files; an unknown one returns an empty page.' } + // #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Page size, 1..100 (default 50).' } + // #swagger.parameters['offset'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Rows to skip (default 0).' } + /* #swagger.responses[200] = { description: 'A page of creatures plus the unpaginated total', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasCreaturePage" } } } } */ + /* #swagger.responses[403] = { description: 'The atlas feature is gated above this caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[404] = { description: 'The atlas feature is disabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + query('q').optional({ values: 'falsy' }).isString().isLength({ max: 60 }), + facetParam, + query('limit').optional().isInt({ min: 1, max: 100 }), + query('offset').optional().isInt({ min: 0, max: 100000 }), + validate, + siteMode, + atlas.getCreatures, +) +atlasRouter.get( + '/creatures/:slug', + requireFeature('atlas'), + // #swagger.tags = ['Public · Atlas'] + // #swagger.summary = 'One creature: where it spawns, and what spawns with it' + // #swagger.description = 'The answer the atlas exists to give. `places` is the aggregate — "lizardman → Shrines, Isamu-Jima, Yew" — resolved by point-in-rect against the shard\'s own region rectangles, falling back to the nearest landmark, else "Wilderness". `spawners` lists the individual spawn points (bounded; `spawnersTruncated` says when the list was cut), and `alsoHere` is what shares those spawners.' + // #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Creature slug, e.g. lizardman.' } + // #swagger.parameters['facet'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Restrict places and spawners to one facet.' } + // #swagger.parameters['points'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Max spawners to return, 1..1000 (default 200).' } + /* #swagger.responses[200] = { description: 'The creature', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasCreature" } } } } */ + /* #swagger.responses[404] = { description: 'No such creature in this atlas (or the feature is disabled)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('slug').isString().isLength({ min: 1, max: 120 }), + facetParam, + query('points').optional().isInt({ min: 1, max: 1000 }), + validate, + siteMode, + atlas.getCreature, +) +atlasRouter.get( + '/regions', + requireFeature('atlas'), + // #swagger.tags = ['Public · Atlas'] + // #swagger.summary = 'Named regions and their rectangles' + // #swagger.description = 'Flattened out of the shard\'s nested Regions.xml. `priority` and the rectangles are what placed each spawn point, kept so the placement can be re-derived rather than taken on trust.' + // #swagger.parameters['facet'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to one facet.' } + // #swagger.parameters['q'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Substring match on the region name.' } + /* #swagger.responses[200] = { description: 'Regions, by facet then name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/AtlasRegion" } } } } } */ + facetParam, + query('q').optional({ values: 'falsy' }).isString().isLength({ max: 60 }), + validate, + siteMode, + atlas.getRegions, +) +atlasRouter.get( + '/landmarks', + requireFeature('atlas'), + // #swagger.tags = ['Public · Atlas'] + // #swagger.summary = 'Points of interest (dungeon levels, town markers)' + // #swagger.description = 'From the shard\'s Data/Locations files. `group` is the innermost enclosing parent ("Covetous"), which is the label worth showing over the individual marker ("Level 1").' + // #swagger.parameters['facet'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to one facet.' } + // #swagger.parameters['q'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Substring match on the landmark name or its group.' } + /* #swagger.responses[200] = { description: 'Landmarks, by facet then group', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/AtlasLandmark" } } } } } */ + facetParam, + query('q').optional({ values: 'falsy' }).isString().isLength({ max: 60 }), + validate, + siteMode, + atlas.getLandmarks, +) +atlasRouter.get( + '/champions', + requireFeature('atlas'), + // #swagger.tags = ['Public · Atlas'] + // #swagger.summary = 'Configured champion altars (the roster, not the live board)' + // #swagger.description = 'Where the altars are and what each one summons — "there is an Unholy Terror altar in Deceit". `randomType` marks altars whose champion is drawn at activation. Do not conflate this with GET /public/shard/champs, which is the live sidecar-fed board ("it is on level 3 right now").' + // #swagger.parameters['facet'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to one facet.' } + /* #swagger.responses[200] = { description: 'Altars, by facet then name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/AtlasChampion" } } } } } */ + facetParam, + validate, + siteMode, + atlas.getChampions, +) +atlasRouter.get( + '/meta', + requireFeature('atlas'), + // #swagger.tags = ['Public · Atlas'] + // #swagger.summary = 'What atlas is loaded: facets, counts, when it was imported' + // #swagger.description = 'Drives the facet filter and the "parsed from the shard\'s own files on " line. Reports the game world only — the ServUO path, the per-file hashes and any pending refresh are operator detail and live on the admin status route.' + /* #swagger.responses[200] = { description: 'Atlas metadata', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasMeta" } } } } */ + siteMode, + atlas.getMeta, +) + +module.exports = atlasRouter diff --git a/server/router/public/shard.controller.js b/server/router/public/shard.controller.js new file mode 100644 index 0000000..7b70ad2 --- /dev/null +++ b/server/router/public/shard.controller.js @@ -0,0 +1,422 @@ +// ── Public: shard live data ──────────────────────────────────────────────── +// +// Same-origin, token-free read endpoints backed by the data the WS ingest +// pipeline persists (shard_online / shard_events / shard_economy / shard_houses) +// plus a live character round-trip to the sidecar. The browser never sees the +// sidecar URL or token — every sidecar call is server-side (uoLinkClient). +// +// The stored-data endpoints are cheap DB reads. The live /char endpoint hits the +// running shard, so it is briefly cached and degrades gracefully: a 503 (shard +// restarting) surfaces as a retry-able banner rather than an error. + +const shardEvents = require('../../model/shardEvents/shardEvents.model') +const shardState = require('../../model/shardState/shardState.model') +const shardMarket = require('../../model/shardMarket/shardMarket.model') +const uoLinkConfig = require('../../model/uoLinkConfig/uoLinkConfig.model') +const broadcast = require('../../utils/shardBroadcast') +const visibility = require('../../utils/shardVisibility') + +const log = require('../../core').logger('public-shard') + +// GET /public/shard/status — connection state + online count + latest economy. +async function getStatus(req, res) { + try { + const config = await uoLinkConfig.getSafe() + const [online, economy] = await Promise.all([ + shardState.onlineCount(), + shardState.latestEconomy(), + ]) + return res.json({ + enabled: config.enabled, + status: config.status, + pluginConnected: config.pluginConnected, + lastEventAt: config.lastEventAt, + onlineCount: online, + economy, + }) + } catch (err) { + log.error('shard.getStatus', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// GET /public/shard/feed?kind=&limit= — recent notable events from the log. +// +// This is the stored-history twin of the SSE stream, and it must reach the same +// verdict the stream does about the same event. Two things are therefore resolved +// against the LIVE config rather than the compiled defaults: +// +// • which kinds this viewer may read at all — `visibleKinds`, not the static +// PUBLIC_KINDS set (which is fixed at module load, so an admin moving +// `guilds` to `staff` would gate /guilds while /feed kept serving +// guild.join to anonymous callers), and +// • the payload itself, projected per event against ITS OWN kind's feature — +// the rows are a mix of features, and without this the stored frames were +// returned verbatim, `acct`/`webId` and all, on an anonymous endpoint. +async function getFeed(req, res) { + try { + const config = await visibility.getConfig() + const level = req.viewerLevel || (await visibility.viewerLevel(req)) + const allowed = new Set(visibility.visibleKinds(level, config)) + + const { kind, limit } = req.query + // No readable kinds ⇒ nothing to serve. Returning early also keeps us clear + // of `list({ kinds: [] })`, which means "no filter", not "match nothing". + if (allowed.size === 0) return res.json([]) + + let events + if (kind) { + if (!allowed.has(kind)) return res.json([]) + events = await shardEvents.list({ kind, limit }) + } else { + events = await shardEvents.list({ kinds: [...allowed], limit }) + } + + return res.json( + events.map((ev) => ({ + ...ev, + payload: visibility.projectFeature( + visibility.KIND_FEATURE.get(ev.kind), + ev.payload, + level, + config, + ), + })), + ) + } catch (err) { + log.error('shard.getFeed', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// GET /public/shard/economy — gold-supply series, oldest → newest. +async function getEconomy(req, res) { + try { + return res.json(await shardState.listEconomy(req.query.limit)) + } catch (err) { + log.error('shard.getEconomy', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// GET /public/shard/online — players online now whose account is linked to a +// STAFF website user (admin/editor/moderator). Everyone sees that a staff member +// is online (name + serial); their in-game location (map + coordinates) is gated +// on the `presence` feature's `location` field rule, which defaults to `staff` +// — the same admin/moderator set this used to hardcode. Non-staff players are +// never listed. +async function canSeeStaffLocation(req) { + const config = await visibility.getConfig() + const required = config.presence?.fields?.location || 'staff' + const level = req.viewerLevel || (await visibility.viewerLevel(req)) + return visibility.meets(level, required) +} + +async function getOnline(req, res) { + try { + const rows = await shardState.listOnlineLinked() + const showLocation = await canSeeStaffLocation(req) + return res.json( + rows.map((r) => { + const entry = { serial: r.serial, name: r.name } + if (showLocation) { + entry.map = r.map + entry.x = r.x + entry.y = r.y + entry.z = r.z + } + return entry + }), + ) + } catch (err) { + log.error('shard.getOnline', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// GET /public/shard/idoc — houses currently in danger (stage IDOC). +// +// Projected: shapeHouse flattens the owner actor into `ownerSerial`/`ownerAcct`/ +// `ownerName`, so this endpoint used to hand an anonymous caller the house +// owner's GAME ACCOUNT NAME. The public IDOC board only ever needed name, region +// and location — which is all that survives projection below `staff`. +async function getIdoc(req, res) { + try { + return res.json(await visibility.project('houses', await shardState.listIdoc(), req)) + } catch (err) { + log.error('shard.getIdoc', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// GET /public/shard/champs — the current champion-spawn board (all categories). +// Served from our own store; live deltas (champ.update / champ.remove) arrive on +// the public SSE stream so the page can update in place. +async function getChamps(req, res) { + try { + return res.json(await visibility.project('champs', await shardState.listChamps(), req)) + } catch (err) { + log.error('shard.getChamps', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// GET /public/shard/guilds — the current guild board. Served from our store; +// live via guild.update / guild.remove / guild.join on the public SSE stream. +// +// Projected: the stored payload is the raw guild.update frame, whose `leader` +// actor carries `acct` and `webId`. Those are admin-only and were previously +// returned verbatim to anonymous callers. +async function getGuilds(req, res) { + try { + return res.json(await visibility.project('guilds', await shardState.listGuilds(), req)) + } catch (err) { + log.error('shard.getGuilds', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// GET /public/shard/governors — the current town-governor board (empty on shards +// without City Loyalty). Live via city.update on the public SSE stream. Projected +// for the same reason as getGuilds: `governor` / `governorElect` are actors. +async function getGovernors(req, res) { + try { + return res.json(await visibility.project('governors', await shardState.listGovernors(), req)) + } catch (err) { + log.error('shard.getGovernors', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// GET /public/shard/governors/:city/history — the term ledger for one city +// (look-back: "who were all the governors of Britain?"), newest first. +async function getGovernorHistory(req, res) { + try { + const terms = await shardState.listGovernorHistory(req.params.city, req.query.limit) + return res.json(await visibility.project('governors', terms, req)) + } catch (err) { + log.error('shard.getGovernorHistory', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// GET /public/shard/presence — the online-population aggregate (count + per-facet +// + per-region). Live via presence.online on the public SSE stream. +async function getPresence(req, res) { + try { + return res.json(await visibility.project('presence', await shardState.latestPresence(), req)) + } catch (err) { + log.error('shard.getPresence', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// GET /public/shard/houses — PUBLIC view: only houses in danger (IDOC), and only +// their location (name + region + map/coords). Owner, price, co-owners and decay +// detail are staff-only (see admin GET /admin/shard/houses). Live via house.decay +// on the public SSE stream. This is the "where are the falling houses" board. +async function getHouses(req, res) { + try { + const idoc = await shardState.listIdoc() + const publicHouses = idoc.map((h) => ({ + serial: h.serial, + name: h.name, + region: h.region, + map: h.map, + x: h.x, + y: h.y, + z: h.z, + isIdoc: true, + })) + // Already a hand-picked safe subset; projected anyway so an admin who + // tightens a `houses` field rule sees it honoured on every houses surface + // rather than on some of them. + return res.json(await visibility.project('houses', publicHouses, req)) + } catch (err) { + log.error('shard.getHouses', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// GET /public/shard/ruleset — the shard's published ruleset (Protocol 3.0): +// expansion, which optional systems are on, skill/stat caps, account and house +// limits, champion scroll rules, the save/restart schedule. Served from our own +// store, so it renders while the shard is down; live via world.ruleset on the +// public SSE stream. +// +// `null` means the shard has never published one (an old plugin, or +// Bridge.RulesetEnabled=false) — a real answer, distinct from a published +// ruleset, and the page says so rather than rendering an empty one. +// +// Projected like every other shard read (§3.6.1's rule: a read path that returns +// shard data and does not call projectFeature is a bug). The `connect` string is +// the one configurable field — an operator who published a connect address may +// still want it behind a login. +async function getRuleset(req, res) { + try { + const ruleset = await shardState.getRuleset() + if (!ruleset) return res.json(null) + return res.json(await visibility.project('ruleset', ruleset, req)) + } catch (err) { + log.error('shard.getRuleset', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// The shard keys boards by its own PointsType enum name (QueensLoyalty, +// CleanUpBritannia, …). Constrain the path param to that shape before it reaches +// the model: the column is VARCHAR(48), and an unbounded string here is a needless +// query on a value that can only ever be an identifier. +const SYSTEM_RE = /^[A-Za-z][A-Za-z0-9_]{0,47}$/ + +// GET /public/shard/points — every points/loyalty leaderboard the shard publishes. +// Served from our own store, so the page renders while the shard is down — which +// matters more here than for live state: these are standings accumulated over +// months, and blanking them during a restart would look like a data loss. +async function getPointsBoards(req, res) { + try { + const boards = await shardState.listPointsBoards() + return res.json(await visibility.project('leaderboards', boards, req)) + } catch (err) { + log.error('shard.getPointsBoards', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// GET /public/shard/points/:system — one system's board. +// +// 404 for a system the shard has never published, matching the sidecar: "no such +// board" and "a board nobody is on yet" are different answers. +async function getPointsBoard(req, res) { + const { system } = req.params + if (!SYSTEM_RE.test(system)) return res.status(400).json({ message: 'Invalid points system.' }) + try { + const board = await shardState.getPointsBoard(system) + if (!board) return res.status(404).json({ message: 'Unknown points system.' }) + return res.json(await visibility.project('leaderboards', board, req)) + } catch (err) { + log.error('shard.getPointsBoard', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// ── Marketplace (Protocol 3.0 vendor.listing) ────────────────────────────── +// +// The shard-wide player-vendor index. Served entirely from our own tables — the +// sidecar is never touched on this path — so shops stay browsable while the shard +// is down, labelled with how stale they may be. +// +// The staleness label is not decoration. The shard sweeps vendors round-robin, so +// a shop can legitimately be a full cycle behind; a page that implied live prices +// would send people to a vendor whose item sold twenty minutes ago. + +// The serial spelling the bridge uses everywhere: "0x" and hex. Constrained +// before it reaches the model, like SYSTEM_RE above. +const SERIAL_RE = /^0x[0-9A-Fa-f]{1,16}$/ + +const intParam = (value) => { + const n = Number.parseInt(value, 10) + return Number.isFinite(n) ? n : undefined +} + +// GET /public/shard/market — search the index. +// +// Returns LISTINGS, not vendors: "who sells a vanquishing kryss and for how much" +// is the question, and a vendor-shaped result would make every caller flatten the +// shops back out. +async function getMarket(req, res) { + try { + const page = await shardMarket.search({ + q: typeof req.query.q === 'string' ? req.query.q : '', + minPrice: intParam(req.query.minPrice), + maxPrice: intParam(req.query.maxPrice), + itemId: intParam(req.query.itemId), + map: typeof req.query.map === 'string' ? req.query.map : '', + region: typeof req.query.region === 'string' ? req.query.region : '', + sort: typeof req.query.sort === 'string' ? req.query.sort : 'price_asc', + limit: intParam(req.query.limit) ?? 50, + offset: intParam(req.query.offset) ?? 0, + }) + return res.json(await visibility.project('market', page, req)) + } catch (err) { + log.error('shard.getMarket', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// GET /public/shard/market/meta — index size, staleness, and the filter options +// (which facets and regions actually hold vendors). Separate from the search so +// the page can build its filters without running a query it will throw away. +async function getMarketMeta(req, res) { + try { + return res.json(await visibility.project('market', await shardMarket.meta(), req)) + } catch (err) { + log.error('shard.getMarketMeta', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// GET /public/shard/market/vendors/:serial — one shop and its listings. +// +// 404 for a serial the index has never seen, which also covers a vendor that has +// since been dismissed or hidden: to an anonymous caller "no such shop" is the +// only honest answer, and distinguishing the two would leak that a vendor exists +// but was hidden. +async function getMarketVendor(req, res) { + const { serial } = req.params + if (!SERIAL_RE.test(serial)) return res.status(400).json({ message: 'Invalid vendor serial.' }) + try { + const vendor = await shardMarket.getVendor(serial, { + limit: intParam(req.query.limit) ?? 250, + offset: intParam(req.query.offset) ?? 0, + }) + if (!vendor) return res.status(404).json({ message: 'Unknown vendor.' }) + return res.json(await visibility.project('market', vendor, req)) + } catch (err) { + log.error('shard.getMarketVendor', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// GET /public/shard/features — the shard features THIS caller can actually see, +// so the SPA (and the Android client) can hide nav entries instead of rendering +// links that 403. Deliberately reports only what the viewer may reach: the list +// itself must not disclose the existence of a feature they're gated out of. +async function getFeatures(req, res) { + try { + const config = await visibility.getConfig() + const level = await visibility.viewerLevel(req) + return res.json({ level, features: visibility.visibleFeatures(level, config) }) + } catch (err) { + log.error('shard.getFeatures', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// GET /public/shard/stream — live-event SSE channel. What arrives depends on the +// caller's audience rung, resolved once at subscribe time; see shardBroadcast.js. +function stream(req, res) { + return broadcast.subscribe(req, res, 'public') +} + +module.exports = { + getStatus, + getFeed, + getEconomy, + getOnline, + getIdoc, + getChamps, + getGuilds, + getGovernors, + getGovernorHistory, + getPresence, + getHouses, + getRuleset, + getPointsBoards, + getPointsBoard, + getMarket, + getMarketMeta, + getMarketVendor, + getFeatures, + stream, +} diff --git a/server/router/public/shard.router.js b/server/router/public/shard.router.js new file mode 100644 index 0000000..1ab07a9 --- /dev/null +++ b/server/router/public/shard.router.js @@ -0,0 +1,255 @@ +// Public · Shard — token-free, same-origin reads of the live shard. The +// status/feed/economy/idoc/champs/guilds/governors/presence/houses endpoints read +// the site's own ingested data; nothing here round-trips the sidecar per request. +// +// Mounted at /api/v1/public/shard by public/index.js. Deliberately NOT site-mode +// gated — shard status is useful (and wanted) while the site itself is in +// maintenance. +// +// **GET /shard/stream stays anonymous.** It is consumed by logged-out browser +// visitors *and* by the Android ShardStreamClient, neither of which sends an +// Authorization header; adding requireAuth here blacks out the public live boards +// on web and mobile. The sensitive kinds (staff audit, cheat detection, login +// attempts, IPs) are withheld by utils/shardBroadcast.js, not by a route gate — +// that per-frame filtering is the security boundary, not this file. /stream is +// deliberately NOT wrapped in requireFeature either: it spans every feature, and +// each frame is gated individually against the subscriber's rung. +// +// Every other route carries `requireFeature()` (utils/shardVisibility.js), +// which 404s when an admin has disabled the feature and 403s when the caller sits +// below its configured audience. Defaults reproduce pre-v3 behavior exactly, so +// these gates are inert until an admin changes something. + +const core = require('../../core') + +const express = core.express +const { param, query } = core.validator + +const shard = require('./shard.controller') +const { validate } = core.middleware +const { marketLimiter } = require('../rateLimits') +const { requireFeature } = require('../../utils/shardVisibility') + +const shardRouter = express.Router() + +shardRouter.get( + '/status', + requireFeature('status'), + // #swagger.tags = ['Public · Shard'] + // #swagger.summary = 'Shard connection state, online count and latest economy' + /* #swagger.responses[200] = { description: 'Shard status', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardStatus" } } } } */ + shard.getStatus, +) +shardRouter.get( + '/feed', + requireFeature('activity'), + // #swagger.tags = ['Public · Shard'] + // #swagger.summary = 'Recent notable shard events (from the ingested log)' + // #swagger.description = 'The stored-history twin of /shard/stream, and it reaches the same verdict: which kinds are returned is resolved against the caller\'s audience rung under the live visibility config, and each event\'s payload is field-projected against its own kind\'s feature. Kinds the caller may not read are omitted (an explicit ?kind= for one of them returns []), and acct/webId never appear below admin.' + // #swagger.parameters['kind'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Filter to a single event kind, e.g. vendor.sale. Returns [] if the caller may not read that kind.' } + // #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Max rows (default 100, max 1000).' } + /* #swagger.responses[200] = { description: 'Events, newest first', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardEvent" } } } } } */ + query('kind').optional({ values: 'falsy' }).isString().isLength({ max: 48 }), + query('limit').optional().isInt({ min: 1, max: 1000 }), + validate, + shard.getFeed, +) +shardRouter.get( + '/economy', + requireFeature('status'), + // #swagger.tags = ['Public · Shard'] + // #swagger.summary = 'Gold-supply time series (oldest → newest)' + // #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Max samples (default 100, max 1000).' } + /* #swagger.responses[200] = { description: 'Economy samples', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardEconomyPoint" } } } } } */ + query('limit').optional().isInt({ min: 1, max: 1000 }), + validate, + shard.getEconomy, +) +shardRouter.get( + '/online', + requireFeature('presence'), + // #swagger.tags = ['Public · Shard'] + // #swagger.summary = 'Staff online now (linked staff accounts; location is admin/moderator-only)' + /* #swagger.responses[200] = { description: 'Online players', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardOnlinePlayer" } } } } } */ + shard.getOnline, +) +shardRouter.get( + '/idoc', + requireFeature('houses'), + // #swagger.tags = ['Public · Shard'] + // #swagger.summary = 'Houses currently in danger (IDOC)' + // #swagger.description = 'Location-level board of the houses about to collapse. Owner identity and price are gated by the `houses` feature\'s field rules (default `staff`), and the owner\'s game account is admin-only always — so an anonymous caller sees name, region and coordinates only.' + /* #swagger.responses[200] = { description: 'IDOC houses', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */ + shard.getIdoc, +) +shardRouter.get( + '/champs', + requireFeature('champs'), + // #swagger.tags = ['Public · Shard'] + // #swagger.summary = 'Current champion-spawn board (all categories)' + // #swagger.description = 'The live board of every champion / mini-champ / sea-boss spawn. Update in place via the champ.update / champ.remove frames on /shard/stream.' + /* #swagger.responses[200] = { description: 'Champion spawns, ordered by name', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */ + shard.getChamps, +) +shardRouter.get( + '/guilds', + requireFeature('guilds'), + // #swagger.tags = ['Public · Shard'] + // #swagger.summary = 'Current guild board (rosters, alliances, leaders)' + // #swagger.description = 'The live board of every guild. Update in place via the guild.update / guild.remove / guild.join frames on /shard/stream.' + /* #swagger.responses[200] = { description: 'Guilds, ordered by name', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */ + shard.getGuilds, +) +shardRouter.get( + '/governors', + requireFeature('governors'), + // #swagger.tags = ['Public · Shard'] + // #swagger.summary = 'Current town-governor board (City Loyalty)' + // #swagger.description = 'One entry per city with its governor and election phase. Empty if the shard does not run the City Loyalty system. Live via city.update on /shard/stream.' + /* #swagger.responses[200] = { description: 'Cities, ordered by name', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */ + shard.getGovernors, +) +shardRouter.get( + '/governors/:city/history', + requireFeature('governors'), + // #swagger.tags = ['Public · Shard'] + // #swagger.summary = 'Governor term history for a city' + // #swagger.parameters['city'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'City name, e.g. Britain.' } + // #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Max terms (default 100, max 500).' } + /* #swagger.responses[200] = { description: 'Terms, newest first', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */ + param('city').isString().isLength({ min: 1, max: 40 }), + query('limit').optional().isInt({ min: 1, max: 500 }), + validate, + shard.getGovernorHistory, +) +shardRouter.get( + '/presence', + requireFeature('presence'), + // #swagger.tags = ['Public · Shard'] + // #swagger.summary = 'Online population aggregate (count + per-facet + per-region)' + // #swagger.description = 'The latest presence.online snapshot powering the "Players Online" widget. Live via presence.online on /shard/stream.' + /* #swagger.responses[200] = { description: 'Population snapshot', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ + shard.getPresence, +) +shardRouter.get( + '/houses', + requireFeature('houses'), + // #swagger.tags = ['Public · Shard'] + // #swagger.summary = 'House registry (owner, co-owners, price, decay)' + // #swagger.description = 'Every house seen via the house.update registry feed. `price` is the placement value, not a for-sale flag. Live via house.update / house.remove on /shard/stream.' + /* #swagger.responses[200] = { description: 'Houses, ordered by name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */ + shard.getHouses, +) +shardRouter.get( + '/ruleset', + requireFeature('ruleset'), + // #swagger.tags = ['Public · Shard'] + // #swagger.summary = 'The shard\'s published ruleset (expansion, systems, caps, limits)' + // #swagger.description = 'How this shard is actually configured, published by the shard itself as one world.ruleset frame: expansion, which optional systems are on, skill/stat caps, account and house limits, champion scroll rules and the save/restart schedule. Served from our own store, so it renders while the shard is down; live via world.ruleset on /shard/stream. Returns `null` if the shard has never published one (an older plugin, or Bridge.RulesetEnabled=false) — distinct from a published ruleset, and the page renders it differently.' + /* #swagger.responses[200] = { description: 'The ruleset, or null if never published', content: { "application/json": { schema: { type: "object", nullable: true, additionalProperties: true } } } } */ + shard.getRuleset, +) +shardRouter.get( + '/points', + requireFeature('leaderboards'), + // #swagger.tags = ['Public · Shard'] + // #swagger.summary = 'Points / loyalty leaderboards, one board per point system' + // #swagger.description = 'Every points/loyalty leaderboard the shard publishes (Queen\'s Loyalty, Void Pool, the nine city loyalties, Clean Up Britannia, …), each with its display name, max points, participant count and top N. Served from our own store, so it renders while the shard is down; live via points.board on /shard/stream. A board\'s display name may arrive as a literal (`nameString`) or a cliloc id (`nameNumber`) — resolve clilocs client-side.' + /* #swagger.responses[200] = { description: 'Boards, ordered by display name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardPointsBoard" } } } } } */ + shard.getPointsBoards, +) +shardRouter.get( + '/points/:system', + requireFeature('leaderboards'), + // #swagger.tags = ['Public · Shard'] + // #swagger.summary = 'One points system\'s leaderboard' + // #swagger.description = 'A single board by the shard\'s own PointsType name (e.g. `QueensLoyalty`, `CleanUpBritannia`). Returns 404 when the shard has never published that system — distinct from a published board that nobody has scored in yet, which returns 200 with an empty `top`.' + /* #swagger.parameters['system'] = { in: 'path', required: true, description: 'PointsType name, e.g. QueensLoyalty', schema: { type: 'string' } } */ + /* #swagger.responses[200] = { description: 'The board', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardPointsBoard" } } } } */ + /* #swagger.responses[400] = { description: 'Malformed system name' } */ + /* #swagger.responses[404] = { description: 'The shard has never published that system' } */ + shard.getPointsBoard, +) +// ── Marketplace ──────────────────────────────────────────────────────────── +// +// Rate-limited, unlike every other route in this file. These are the first +// genuinely expensive PUBLIC reads on the site — a LIKE scan plus a COUNT over +// what is typically the largest shard_* table, reachable with no session. +shardRouter.get( + '/market', + requireFeature('market'), + marketLimiter, + // #swagger.tags = ['Public · Shard'] + // #swagger.summary = 'Search the player-vendor marketplace' + // #swagger.description = 'Every priced listing on every player vendor the shard publishes — the same index the in-game Vendor Search gump reads, and it honours the same per-vendor opt-out, so a player who hid their shop in game is hidden here too. Results are LISTINGS, each carrying enough of its shop to be actionable. Served from the site\'s own tables (the sidecar is not touched), so it renders while the shard is down; `staleAt` is the oldest vendor row and the page must say how far behind the index can be — the shard sweeps vendors round-robin, so prices are inherently up to one full cycle old. Item names are resolved server-side against the cliloc table (docs/website/CLILOCS.md); on a shard that has not configured one, `displayName` is null and clients render the item id.' + // #swagger.parameters['q'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Substring match on the resolved item name or the item\'s own literal name (max 60 chars).' } + // #swagger.parameters['minPrice'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Lowest price to include.' } + // #swagger.parameters['maxPrice'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Highest price to include.' } + // #swagger.parameters['itemId'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Exact ItemID (art id) match, for "more like this".' } + // #swagger.parameters['map'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to one facet. Facet names come from the shard\'s own data; an unknown one returns an empty page.' } + // #swagger.parameters['region'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to one named region.' } + // #swagger.parameters['sort'] = { in: 'query', required: false, schema: { type: 'string', enum: ['price_asc','price_desc','recent'] }, description: 'Default price_asc. `recent` orders by when the shop was last seen.' } + // #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Page size, 1..100 (default 50).' } + // #swagger.parameters['offset'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Rows to skip (default 0).' } + /* #swagger.responses[200] = { description: 'A page of listings plus the unpaginated total and the staleness stamp', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardMarketPage" } } } } */ + /* #swagger.responses[403] = { description: 'The market feature is gated above this caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[404] = { description: 'The market feature is disabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[429] = { description: 'Rate limited', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + query('q').optional({ values: 'falsy' }).isString().isLength({ max: 60 }), + query('minPrice').optional({ values: 'falsy' }).isInt({ min: 0, max: 999999999 }), + query('maxPrice').optional({ values: 'falsy' }).isInt({ min: 0, max: 999999999 }), + query('itemId').optional({ values: 'falsy' }).isInt({ min: 0, max: 65535 }), + query('map').optional({ values: 'falsy' }).isString().isLength({ max: 40 }), + query('region').optional({ values: 'falsy' }).isString().isLength({ max: 80 }), + query('sort').optional({ values: 'falsy' }).isIn(['price_asc', 'price_desc', 'recent']), + query('limit').optional().isInt({ min: 1, max: 100 }), + query('offset').optional().isInt({ min: 0, max: 100000 }), + validate, + shard.getMarket, +) +shardRouter.get( + '/market/meta', + requireFeature('market'), + // #swagger.tags = ['Public · Shard'] + // #swagger.summary = 'Marketplace size, staleness and filter options' + // #swagger.description = 'How many vendors and listings the index holds, how stale it may be (`staleAt` = the oldest vendor row, `freshAt` = the newest), and which facets and regions actually hold vendors — so a client can build its filters without running a search it will discard.' + /* #swagger.responses[200] = { description: 'Marketplace metadata', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardMarketMeta" } } } } */ + shard.getMarketMeta, +) +shardRouter.get( + '/market/vendors/:serial', + requireFeature('market'), + marketLimiter, + // #swagger.tags = ['Public · Shard'] + // #swagger.summary = 'One player vendor and everything it is selling' + // #swagger.description = 'A single shop by its vendor serial, with its listings. `truncated` (and `total` exceeding `count`) means the shop holds more than the shard publishes per frame — a commodity reseller with thousands of stacks is a real thing, and the page says so rather than presenting a partial shop as complete. Returns 404 for a serial the index has never seen, which also covers a vendor since dismissed or hidden.' + /* #swagger.parameters['serial'] = { in: 'path', required: true, description: 'Vendor serial, e.g. 0x40001234', schema: { type: 'string' } } */ + // #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Listings to return, 1..500 (default 250).' } + // #swagger.parameters['offset'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Listings to skip (default 0).' } + /* #swagger.responses[200] = { description: 'The vendor', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardMarketVendor" } } } } */ + /* #swagger.responses[400] = { description: 'Malformed vendor serial' } */ + /* #swagger.responses[404] = { description: 'No such vendor in the index' } */ + param('serial').isString().isLength({ max: 20 }), + query('limit').optional().isInt({ min: 1, max: 500 }), + query('offset').optional().isInt({ min: 0, max: 100000 }), + validate, + shard.getMarketVendor, +) +shardRouter.get( + '/features', + // #swagger.tags = ['Public · Shard'] + // #swagger.summary = 'Shard features visible to the caller (drives client nav)' + // #swagger.description = 'The caller\'s audience rung plus the shard features they may reach, so a client can hide nav entries instead of rendering links that 403. Reports only what the caller can see — the list itself does not disclose gated features.' + /* #swagger.responses[200] = { description: 'Visible features', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardFeatures" } } } } */ + shard.getFeatures, +) +shardRouter.get( + '/stream', + // #swagger.tags = ['Public · Shard'] + // #swagger.summary = 'Live shard event stream (Server-Sent Events, filtered by audience)' + // #swagger.description = 'text/event-stream of live events. The caller\'s audience rung is resolved once at subscribe time and frozen for the connection; each frame is then gated on its feature and field-projected, so sensitive kinds and fields (staff audit, cheat detection, login attempts, IPs, acct/webId) never reach a caller below their configured rung.' + /* #swagger.responses[200] = { description: 'An SSE stream (Content-Type: text/event-stream).' } */ + shard.stream, +) + +module.exports = shardRouter diff --git a/server/router/rateLimits.js b/server/router/rateLimits.js new file mode 100644 index 0000000..57bdc39 --- /dev/null +++ b/server/router/rateLimits.js @@ -0,0 +1,46 @@ +// This module's own rate-limit policy, built on core's plumbing. +// +// `ctx.middleware.rateLimit` is core's `makeLimiter` (MODULE_API.md §2.3, API +// 1.1.0): the module states the window, the cap and the message, and core +// supplies the `express-rate-limit` instance, its store and the logging that +// records a breach. That division is the point. The policy is the module's — +// only the module knows what its endpoints cost — but there is one limiter +// library in the process and one place a breach is written down. A module that +// resolved `express-rate-limit` for itself would get a second store, and a limit +// enforced by two independent counters is not the limit either of them states. +// +// Built lazily, for the reason `core.js` explains: `core.middleware` resolves +// `ctx`, so touching it at require time would run before `register()`. The +// routers ask for these while they are being built, which is inside +// `register()`, and the result is memoised so a limiter is created once and the +// counter is not reset by a second call. + +const core = require('../core') + +let limiters = null + +function build() { + if (limiters) return limiters + limiters = { + // The player-vendor market search. The first genuinely expensive PUBLIC + // endpoint on the site: every call is a LIKE scan plus a COUNT over the + // listings table, which on a large shard is the biggest table there is, and + // it is anonymous by default. Generous for a human browsing shops (a typed + // search is debounced to one request, and paging is a click), tight enough + // that it cannot be used as a cheap way to load the database. + // + // This lived in core's `middleware/rateLimit.js` and is UO policy, so it + // came here with the route it guards. + marketLimiter: core.middleware.rateLimit({ + windowMs: 60 * 1000, + max: 60, + label: 'market', + message: 'Too many searches. Please slow down.', + }), + } + return limiters +} + +module.exports = { + get marketLimiter() { return build().marketLimiter }, +}