ACCOUNT_DELETION.md §4.4 (AD10, AD11). api.onUserDeleting queues every linked
account in the new shard_pending_unlinks table, then calls the sidecar's
DELETE /link/{account} (actor web:account-deletion) and drops the mirror on 200/404.
A shard that is away leaves the row pending, retried on WS reconnect and on a live
server.hello; a protected account (403) is refused and listed on Admin -> Shard as
still linked on the shard. A live mob.login without the deleted user's webId clears
the row. coreApi ^1.20.0; core-ref pinned to website#232's merge (7bd93b8).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
139 lines
8.6 KiB
JavaScript
139 lines
8.6 KiB
JavaScript
// 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 gameSignup = require('../../utils/gameSignup')
|
||
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.description = 'pendingUnlinks lists the game accounts a website account deletion left linked on the shard: state pending (the shard was away; retried when the bridge reconnects) or refused (a protected staff account; shard staff clear it by hand). A row goes once the shard reports a login on the account without the deleted user id.'
|
||
/* #swagger.responses[200] = { description: 'Masked config, health, ingestion stats and pendingUnlinks', 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,
|
||
)
|
||
|
||
// ── Game-account signup mode ───────────────────────────────────────────────
|
||
//
|
||
// New in slice 3, and new only in the sense that the field moved: core's Site
|
||
// Settings has carried `game_account_signup` since long before the extraction,
|
||
// and its help text has always been about a game server. The setting key and its
|
||
// stored value are unchanged, so an existing instance keeps its configured mode.
|
||
uoLinkRouter.get(
|
||
'/signup-mode',
|
||
// #swagger.tags = ['Admin · Shard']
|
||
// #swagger.summary = 'Get the game-account signup mode (admin only)'
|
||
// #swagger.description = 'Whether the site offers game-account creation, and in which direction. The shard’s own SignupMode (Bridge.cfg) must agree: website/hybrid accept site-created accounts, game refuses them.'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.responses[200] = { description: 'The configured mode and the legal values', content: { "application/json": { schema: { type: "object", properties: { mode: { type: "string" }, modes: { type: "array", items: { type: "string" } } } } } } } */
|
||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
adminOnly,
|
||
uoLink.getSignupMode,
|
||
)
|
||
uoLinkRouter.put(
|
||
'/signup-mode',
|
||
// #swagger.tags = ['Admin · Shard']
|
||
// #swagger.summary = 'Set the game-account signup mode (admin only)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["mode"], properties: { mode: { type: "string", enum: ["disabled","website","hybrid","game"] } } } } } } */
|
||
/* #swagger.responses[200] = { description: 'The saved mode', content: { "application/json": { schema: { type: "object", properties: { mode: { type: "string" } } } } } } */
|
||
/* #swagger.responses[400] = { description: 'Unknown mode', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
adminOnly,
|
||
// Validated here as well as in gameSignup.setMode: the list is the same list,
|
||
// and the difference is the answer. A rejected value must be a 400 naming the
|
||
// field, not a 500 from a thrown Error the controller could only guess about.
|
||
body('mode').isIn(gameSignup.MODES),
|
||
validate,
|
||
uoLink.saveSignupMode,
|
||
)
|
||
|
||
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/UoTownCrierRequest" } } } } */
|
||
/* #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
|