Phase 2 PR 4 of docs/website/MODULE_SYSTEM.md §2.7. Adds server/src/modules/registries.js and moves core's own notification streams, announce leg and users-detail routes behind it, so the three seams §1.8 and §1.9 named are exercised on every boot before any module depends on them. Registering is validate-then-commit per registrant: the loader stages what a module claims and the second pass commits it, so a module that throws halfway through register() — or fails checkDeclared after it — leaves nothing behind. That is the registry-side twin of PR 2's second-pass mount rule. Four decisions, all the recommended option: - announce legs became a child table. `announce_job_legs` replaces the towncrier_*/discord_* column groups, so the leg set is data: core registers `discord`, module-uo will register `towncrier`, and a module cannot ALTER a core table to add its own. Backfill is guarded on information_schema (a SELECT of a dropped column is a parse error, not a runtime one) and the columns go with DROP COLUMN IF EXISTS. Verified against the live dev DB: three legacy jobs migrated faithfully, three replays, no duplicates. - `mapEvent` dropped from registerNotificationStreams. §1.8 already inverts the push path so a module owns fromShardEvent and calls core's publish() with a stream id it resolved; a second mapping mechanism was a leftover. The public safety filter, the kinds it reads and the streams it protects now live in one file and move together. - core registers through the same staging area a module uses, via an explicit registries.registerCore() in app.js before modules.load(). - core's six /admin/users/:id/shard/* paths now go through the `admin.users.detail` slot, and getUser moved back to admin.controller.js. Found on the way, and the reason two build tools changed: - scripts/routeManifest.js could not decode a parameterised mount. Its unwinder expected `(?:([^\/]+?))`; express 4.22 emits `(?:\/([^/]+?))` with the separator inside the group. The branch had never run. It threw rather than guessing, which is what it is for. - swagger-autogen cannot follow a route into an extension slot — the slot's router is created by declareSlot() and filled later, so there is no literal mount for a static parse. Regenerating deleted 407 lines and printed `Swagger-autogen: Success`, the spike's exact failure (MODULE_API.md §7.4). swagger/slotSpecs.js generates a fragment per filled slot and re-roots it at the prefix the router actually hangs at in the live app — read from the express stack via routeManifest's own mountPath, so the manifest and the spec cannot disagree. swagger/mergeSpec.js is the merge helper core owes for module fragments anyway (§6.1a), proved here against core's own slot first. 884 tests pass (856 before). routes.manifest.json is unchanged at 229 routes. The OpenAPI spec diff is two lines of intent: the retry endpoint's summary, and its `leg` no longer being a fixed enum. Co-Authored-By: Claude <noreply@anthropic.com>
112 lines
6.7 KiB
JavaScript
112 lines
6.7 KiB
JavaScript
// ── 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 express = require('express')
|
||
const { param } = require('express-validator')
|
||
|
||
const usersShard = require('./usersShard.controller')
|
||
const validate = require('../../../middleware/validate')
|
||
|
||
// 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
|