Phase 2, PR 7 of docs/website/MODULE_SYSTEM.md 2.7 — the client half's
delivery. A module's prebuilt chunk is served, injected, handed core's React
and its UI kit, and its routes are rendered by App.jsx. The registry is empty
on a bare core, so nothing an operator can see changes.
Client:
- modules/registry.js — registerRoutes/registerNav/registerFeatureProvider,
with the URL namespace written by core, never by the module
- modules/shared.js — window.__rg: React, react-dom/client, react-router-dom,
react/jsx-runtime, the registry, the seven-member UI kit and the request
primitive, frozen
- App.jsx reads routesFor for all three areas; nav consumption is PR 8
- main.jsx publishes the global, then mounts on DOMContentLoaded
Server:
- the loader validates client.entry and publishes clientChunks() and
clientEntryUrls(); an entry in the module root is rejected, because the
directory it sits in is what gets served
- app.js mounts each chunk at /modules/<id>/ behind the module's state guard
with no-cache; anything else under /modules is a 404, not the SPA shell
- htmlShell injects the tag before </body>, so core's bundle runs first
wherever a bundler puts it
Found by loading a real chunk in a browser, and fixed here: core mounted before
any module chunk had evaluated, because document.readyState during a deferred
script is 'interactive', not 'loading'. Every test passed against that build.
The smoke is written down in MODULE_API.md 7.7.
933 server tests (+23), 123 client tests (+14). routes.manifest.json unchanged
at 230 routes; the OpenAPI spec regenerates byte-identical.
Co-Authored-By: Claude <noreply@anthropic.com>
530 lines
28 KiB
JavaScript
530 lines
28 KiB
JavaScript
// Thin fetch wrapper. Always sends cookies (httpOnly JWT) and talks to the
|
|
// same-origin API (/api/v1) — proxied to the Express server in dev.
|
|
const BASE = '/api/v1'
|
|
|
|
// Prefix a non-empty query string with "?" (and nothing when it is empty), so
|
|
// callers can append it to a path without a dangling "?".
|
|
const withQs = (s) => (s ? `?${s}` : '')
|
|
|
|
class ApiError extends Error {
|
|
constructor(status, message, body) {
|
|
super(message)
|
|
this.status = status
|
|
this.body = body
|
|
}
|
|
}
|
|
|
|
async function req(path, { method = 'GET', body, headers, raw } = {}) {
|
|
const opts = { method, credentials: 'include', headers: { ...headers } }
|
|
if (body !== undefined) {
|
|
if (raw) {
|
|
opts.body = body // FormData — let the browser set the content-type
|
|
} else {
|
|
opts.headers['Content-Type'] = 'application/json'
|
|
opts.body = JSON.stringify(body)
|
|
}
|
|
}
|
|
const res = await fetch(BASE + path, opts)
|
|
const text = await res.text()
|
|
const data = text ? safeParse(text) : null
|
|
if (!res.ok) {
|
|
const message = (data && data.message) || res.statusText || 'Request failed'
|
|
throw new ApiError(res.status, message, data)
|
|
}
|
|
return data
|
|
}
|
|
|
|
function safeParse(text) {
|
|
try {
|
|
return JSON.parse(text)
|
|
} catch {
|
|
return text
|
|
}
|
|
}
|
|
|
|
// The request PRIMITIVE, exported for installed modules and handed to them on
|
|
// `window.__rg.api` (docs/website/MODULE_API.md §3.5). Core owns the fetch
|
|
// semantics — same-origin /api/v1, cookies included, JSON in and out, ApiError
|
|
// on a non-2xx — and nothing above them: a module owns the paths it calls,
|
|
// because it owns the routes at the other end.
|
|
//
|
|
// The `api` object below stays core's own binding surface. Its `atlas` and
|
|
// `shard` namespaces are module bindings that only still live here because
|
|
// Phase 3 has not moved them yet.
|
|
export { req as request }
|
|
|
|
export const api = {
|
|
// ----- auth -----
|
|
me: () => req('/auth/me'),
|
|
// `extra` carries the honeypot field (and any future login fields).
|
|
login: (username, password, extra = {}) =>
|
|
req('/auth/login', { method: 'POST', body: { username, password, ...extra } }),
|
|
// Public self-registration (player accounts). `extra` carries the honeypot +
|
|
// optional email. Returns { user } and sets the session cookie on success.
|
|
register: (username, password, extra = {}) =>
|
|
req('/auth/register', { method: 'POST', body: { username, password, ...extra } }),
|
|
// Email invites (public, token-gated accept).
|
|
getInvite: (token) => req(`/auth/invite/${encodeURIComponent(token)}`),
|
|
acceptInvite: (token, username, password, extra = {}) =>
|
|
req(`/auth/invite/${encodeURIComponent(token)}/accept`, { method: 'POST', body: { username, password, ...extra } }),
|
|
// Second factor for web login. `extra` carries the optional recoveryCode (an
|
|
// alternative to code) and the trustDevice/deviceName opt-in. On success the
|
|
// response may include { trustLimitReached, devices } when trust was requested
|
|
// but the device cap is reached.
|
|
loginTotp: (challenge, code, extra = {}) =>
|
|
req('/auth/login/totp', { method: 'POST', body: { challenge, code, ...extra } }),
|
|
// Self-service password reset (public, token-gated). forgot always resolves the
|
|
// same way whether or not the email exists (no enumeration); getPasswordReset
|
|
// validates a link (200 → { username }, 404 → invalid/expired); resetPassword
|
|
// sets the new password and revokes all sessions (the user then signs in fresh).
|
|
forgotPassword: (email) => req('/auth/password/forgot', { method: 'POST', body: { email } }),
|
|
getPasswordReset: (token) => req(`/auth/password/reset/${encodeURIComponent(token)}`),
|
|
resetPassword: (token, password) =>
|
|
req(`/auth/password/reset/${encodeURIComponent(token)}`, { method: 'POST', body: { password } }),
|
|
// Second factor for an SSO login (challenge is held in an httpOnly cookie set by
|
|
// the callback, so only the code is sent). `extra` carries the trustDevice/
|
|
// deviceName opt-in, same as the password path. Returns { user, returnTo } — plus
|
|
// { trustLimitReached, devices } when trust was asked for but the cap is reached.
|
|
ssoLoginTotp: (code, extra = {}) => req('/auth/sso/totp', { method: 'POST', body: { code, ...extra } }),
|
|
logout: () => req('/auth/logout', { method: 'POST' }),
|
|
// Public SSO provider discovery — drives the login-page provider buttons.
|
|
authProviders: () => req('/auth/providers'),
|
|
// Active mobile device sessions (role-agnostic self-service under /auth/me).
|
|
// List the active ones and revoke a single device by its session id.
|
|
mySessions: () => req('/auth/me/sessions'),
|
|
revokeMySession: (id) => req(`/auth/me/sessions/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
|
// Trusted devices (MFA "Trust this device"), role-agnostic under /auth/me. These
|
|
// are the browsers/apps allowed to skip the TOTP step at login (distinct from
|
|
// mySessions, which are live mobile login sessions).
|
|
myTrustedDevices: () => req('/auth/me/trusted-devices'),
|
|
trustThisDevice: (deviceName) =>
|
|
req('/auth/me/trusted-devices', { method: 'POST', body: { deviceName } }),
|
|
revokeTrustedDevice: (id) =>
|
|
req(`/auth/me/trusted-devices/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
|
revokeAllTrustedDevices: () => req('/auth/me/trusted-devices', { method: 'DELETE' }),
|
|
// Recovery (backup) codes. status → remaining count; generate → a fresh set,
|
|
// returned ONCE (password step-up for accounts that have a password).
|
|
recoveryCodesStatus: () => req('/auth/me/account/recovery-codes/status'),
|
|
generateRecoveryCodes: (currentPassword) =>
|
|
req('/auth/me/account/recovery-codes/generate', { method: 'POST', body: { currentPassword } }),
|
|
|
|
// ----- settings (any authenticated account) -----
|
|
// Nav overrides for the layouts the caller's own role renders, and the theme
|
|
// catalog the appearance form is built from. A fifth group, not part of
|
|
// /admin, because AdminLayout renders for editors and moderators too — see
|
|
// docs/website/THEMING_AND_NAV.md §4.2.
|
|
navSettings: () => req('/settings/nav'),
|
|
themeOptions: () => req('/settings/theme/options'),
|
|
|
|
// ----- public -----
|
|
publicSettings: () => req('/public/settings'),
|
|
status: () => req('/public/status'),
|
|
posts: (category) => req(`/public/posts/${category}`),
|
|
post: (category, idOrSlug) => req(`/public/posts/${category}/${idOrSlug}`),
|
|
wiki: (opts = {}) => {
|
|
const qs = new URLSearchParams()
|
|
if (opts.category) qs.set('category', opts.category)
|
|
if (opts.tag) qs.set('tag', opts.tag)
|
|
if (opts.q) qs.set('q', opts.q)
|
|
const s = qs.toString()
|
|
return req(`/public/wiki${withQs(s)}`)
|
|
},
|
|
wikiCategories: () => req('/public/wiki/categories'),
|
|
wikiTags: () => req('/public/wiki/tags'),
|
|
wikiPage: (slug) => req(`/public/wiki/${slug}`),
|
|
// CMS pages (block-based). Published-only for the public; a draft-preview link
|
|
// is fetched by id + token.
|
|
page: (slug) => req(`/public/pages/${slug}`),
|
|
pagePreview: (id, token) => req(`/public/pages/${id}/preview/${token}`),
|
|
contact: (payload) => req('/public/contact', { method: 'POST', body: payload }),
|
|
|
|
// ----- shard live data (uo-link) -----
|
|
// Token-free, same-origin reads backed by the ingested feed + a cached live
|
|
// character round-trip. shardStreamUrl is the SSE endpoint for useShardFeed.
|
|
shard: {
|
|
status: () => req('/public/shard/status'),
|
|
feed: (opts = {}) => {
|
|
const qs = new URLSearchParams()
|
|
if (opts.kind) qs.set('kind', opts.kind)
|
|
if (opts.limit) qs.set('limit', opts.limit)
|
|
const s = qs.toString()
|
|
return req(`/public/shard/feed${withQs(s)}`)
|
|
},
|
|
economy: (limit) => {
|
|
const q = limit ? `limit=${limit}` : ''
|
|
return req(`/public/shard/economy${withQs(q)}`)
|
|
},
|
|
online: () => req('/public/shard/online'),
|
|
idoc: () => req('/public/shard/idoc'),
|
|
champs: () => req('/public/shard/champs'),
|
|
// Protocol 2.0 boards.
|
|
guilds: () => req('/public/shard/guilds'),
|
|
governors: () => req('/public/shard/governors'),
|
|
governorHistory: (city, limit) => {
|
|
const q = limit ? `limit=${limit}` : ''
|
|
return req(`/public/shard/governors/${encodeURIComponent(city)}/history${withQs(q)}`)
|
|
},
|
|
presence: () => req('/public/shard/presence'),
|
|
houses: () => req('/public/shard/houses'),
|
|
// Protocol 3.0: the shard's published ruleset. Resolves to null when the
|
|
// shard has never published one — a real answer, not an error.
|
|
ruleset: () => req('/public/shard/ruleset'),
|
|
// Protocol 3.0: points/loyalty leaderboards, one board per point system.
|
|
// `board` 404s for a system the shard has never published.
|
|
points: () => req('/public/shard/points'),
|
|
pointsBoard: (system) => req(`/public/shard/points/${encodeURIComponent(system)}`),
|
|
// Protocol 3.0: the player-vendor marketplace. Rate-limited server-side, so
|
|
// the page debounces its search box rather than firing per keystroke.
|
|
market: (opts = {}) => {
|
|
const qs = new URLSearchParams()
|
|
if (opts.q) qs.set('q', opts.q)
|
|
if (opts.minPrice != null && opts.minPrice !== '') qs.set('minPrice', opts.minPrice)
|
|
if (opts.maxPrice != null && opts.maxPrice !== '') qs.set('maxPrice', opts.maxPrice)
|
|
if (opts.itemId != null && opts.itemId !== '') qs.set('itemId', opts.itemId)
|
|
if (opts.map) qs.set('map', opts.map)
|
|
if (opts.region) qs.set('region', opts.region)
|
|
if (opts.sort) qs.set('sort', opts.sort)
|
|
if (opts.limit) qs.set('limit', opts.limit)
|
|
if (opts.offset) qs.set('offset', opts.offset)
|
|
return req(`/public/shard/market${withQs(qs.toString())}`)
|
|
},
|
|
marketMeta: () => req('/public/shard/market/meta'),
|
|
marketVendor: (serial, opts = {}) => {
|
|
const qs = new URLSearchParams()
|
|
if (opts.limit) qs.set('limit', opts.limit)
|
|
if (opts.offset) qs.set('offset', opts.offset)
|
|
return req(`/public/shard/market/vendors/${encodeURIComponent(serial)}${withQs(qs.toString())}`)
|
|
},
|
|
// Which shard surfaces this caller may reach, plus the audience rung they
|
|
// resolved to. Drives nav so we never render a link that would 403.
|
|
features: () => req('/public/shard/features'),
|
|
},
|
|
|
|
// ----- spawn atlas (Protocol 3.0 Part C) -----
|
|
// Static shard CONTENT, parsed from the shard's own ServUO tree — deliberately
|
|
// not under /shard, because nothing here depends on the sidecar and the pages
|
|
// stay populated while the shard is offline.
|
|
atlas: {
|
|
creatures: (opts = {}) => {
|
|
const qs = new URLSearchParams()
|
|
if (opts.q) qs.set('q', opts.q)
|
|
if (opts.facet) qs.set('facet', opts.facet)
|
|
if (opts.limit) qs.set('limit', opts.limit)
|
|
if (opts.offset) qs.set('offset', opts.offset)
|
|
return req(`/public/atlas/creatures${withQs(qs.toString())}`)
|
|
},
|
|
creature: (slug, opts = {}) => {
|
|
const qs = new URLSearchParams()
|
|
if (opts.facet) qs.set('facet', opts.facet)
|
|
if (opts.points) qs.set('points', opts.points)
|
|
return req(`/public/atlas/creatures/${encodeURIComponent(slug)}${withQs(qs.toString())}`)
|
|
},
|
|
regions: (opts = {}) => {
|
|
const qs = new URLSearchParams()
|
|
if (opts.facet) qs.set('facet', opts.facet)
|
|
if (opts.q) qs.set('q', opts.q)
|
|
return req(`/public/atlas/regions${withQs(qs.toString())}`)
|
|
},
|
|
landmarks: (opts = {}) => {
|
|
const qs = new URLSearchParams()
|
|
if (opts.facet) qs.set('facet', opts.facet)
|
|
if (opts.q) qs.set('q', opts.q)
|
|
return req(`/public/atlas/landmarks${withQs(qs.toString())}`)
|
|
},
|
|
// The CONFIGURED altar roster, not the live board — see shard.champs() for
|
|
// "which spawn is on level 3 right now".
|
|
champions: (facet) => req(`/public/atlas/champions${withQs(facet ? `facet=${encodeURIComponent(facet)}` : '')}`),
|
|
meta: () => req('/public/atlas/meta'),
|
|
},
|
|
// Full paths (incl. /api/v1) for the browser EventSource — the req() wrapper is
|
|
// fetch-only, so SSE subscribers build the URL from here. The admin stream
|
|
// carries every kind (incl. audit/cheat) and needs the staff session cookie.
|
|
shardStreamUrl: `${BASE}/public/shard/stream`,
|
|
adminShardStreamUrl: `${BASE}/admin/uo-link/stream`,
|
|
|
|
// ----- admin -----
|
|
admin: {
|
|
dashboard: () => req('/admin/dashboard'),
|
|
setSiteMode: (mode) => req('/admin/site-mode', { method: 'PUT', body: { mode } }),
|
|
listPosts: (category) => {
|
|
const q = category ? `category=${category}` : ''
|
|
return req(`/admin/posts${withQs(q)}`)
|
|
},
|
|
getPost: (id) => req(`/admin/posts/${id}`),
|
|
createPost: (data) => req('/admin/posts', { method: 'POST', body: data }),
|
|
updatePost: (id, data) => req(`/admin/posts/${id}`, { method: 'PUT', body: data }),
|
|
deletePost: (id) => req(`/admin/posts/${id}`, { method: 'DELETE' }),
|
|
publishPost: (id, published) =>
|
|
req(`/admin/posts/${id}/publish`, { method: 'PATCH', body: { published } }),
|
|
// News announcement pipeline (town crier + Discord) status + per-leg retry.
|
|
getAnnounce: (id) => req(`/admin/posts/${id}/announce`),
|
|
retryAnnounceLeg: (id, leg) =>
|
|
req(`/admin/posts/${id}/announce/retry`, { method: 'POST', body: { leg } }),
|
|
uploadImage: (file) => {
|
|
const fd = new FormData()
|
|
fd.append('image', file)
|
|
return req('/admin/posts/upload', { method: 'POST', body: fd, raw: true })
|
|
},
|
|
// Generalized upload for rich-text editors → { url }.
|
|
upload: (file) => {
|
|
const fd = new FormData()
|
|
fd.append('image', file)
|
|
return req('/admin/uploads', { method: 'POST', body: fd, raw: true })
|
|
},
|
|
// ----- CMS pages (block-based page builder) -----
|
|
listPages: () => req('/admin/pages'),
|
|
getPage: (id) => req(`/admin/pages/${id}`),
|
|
createPage: (data) => req('/admin/pages', { method: 'POST', body: data }),
|
|
updatePage: (id, data) => req(`/admin/pages/${id}`, { method: 'PATCH', body: data }),
|
|
deletePage: (id) => req(`/admin/pages/${id}`, { method: 'DELETE' }),
|
|
unprotectPage: (id, password) =>
|
|
req(`/admin/pages/${id}/unprotect`, { method: 'POST', body: { password } }),
|
|
createPagePreview: (id) => req(`/admin/pages/${id}/preview`, { method: 'POST' }),
|
|
listWiki: (params = '') => req(`/admin/wiki${params}`),
|
|
getWiki: (slug) => req(`/admin/wiki/${slug}`),
|
|
createWiki: (data) => req('/admin/wiki', { method: 'POST', body: data }),
|
|
updateWiki: (slug, data) => req(`/admin/wiki/${slug}`, { method: 'PUT', body: data }),
|
|
publishWiki: (slug, published) =>
|
|
req(`/admin/wiki/${slug}/publish`, { method: 'PATCH', body: { published } }),
|
|
deleteWiki: (slug) => req(`/admin/wiki/${slug}`, { method: 'DELETE' }),
|
|
listWikiRevisions: (slug) => req(`/admin/wiki/${slug}/revisions`),
|
|
getWikiRevision: (slug, id) => req(`/admin/wiki/${slug}/revisions/${id}`),
|
|
restoreWikiRevision: (slug, id) =>
|
|
req(`/admin/wiki/${slug}/revisions/${id}/restore`, { method: 'POST' }),
|
|
listWikiTags: () => req('/admin/wiki/tags'),
|
|
listWikiCategories: () => req('/admin/wiki/categories'),
|
|
createWikiCategory: (data) => req('/admin/wiki/categories', { method: 'POST', body: data }),
|
|
updateWikiCategory: (id, data) =>
|
|
req(`/admin/wiki/categories/${id}`, { method: 'PUT', body: data }),
|
|
deleteWikiCategory: (id) => req(`/admin/wiki/categories/${id}`, { method: 'DELETE' }),
|
|
getSettings: () => req('/admin/settings'),
|
|
updateSettings: (obj) => req('/admin/settings', { method: 'PUT', body: obj }),
|
|
// Reset one setting to its default by deleting the row — the theming/nav
|
|
// keys and the hero draft only (the server holds the allowlist). Idempotent,
|
|
// so the caller need not know whether a row exists.
|
|
resetSetting: (key) => req(`/admin/settings/${encodeURIComponent(key)}`, { method: 'DELETE' }),
|
|
// Upload one brand asset (logo | hero | favicon) and set it as the override
|
|
// in the same call → { url, brand_assets }. A separate endpoint from the
|
|
// generic upload above because the server applies per-slot rules (favicons
|
|
// are PNG-only and capped small) and writes the settings row itself, so an
|
|
// upload never leaves a file nothing points at.
|
|
uploadBrandAsset: (slot, file) => {
|
|
const fd = new FormData()
|
|
fd.append('image', file)
|
|
return req(`/admin/settings/brand-asset/${encodeURIComponent(slot)}`, { method: 'POST', body: fd, raw: true })
|
|
},
|
|
activity: (limit = 50) => req(`/admin/activity?limit=${limit}`),
|
|
botActivity: () => req('/admin/bot-activity'),
|
|
unbanIp: (ip) => req('/admin/bot-activity/unban', { method: 'POST', body: { ip } }),
|
|
listUsers: () => req('/admin/users'),
|
|
getUser: (id) => req(`/admin/users/${id}`),
|
|
createUser: (data) => req('/admin/users', { method: 'POST', body: data }),
|
|
updateUser: (id, data) => req(`/admin/users/${id}`, { method: 'PUT', body: data }),
|
|
deleteUser: (id) => req(`/admin/users/${id}`, { method: 'DELETE' }),
|
|
// A user's trusted devices + MFA reset (admin only).
|
|
userTrustedDevices: (id) => req(`/admin/users/${id}/trusted-devices`),
|
|
revokeUserTrustedDevice: (id, deviceId) =>
|
|
req(`/admin/users/${id}/trusted-devices/${deviceId}`, { method: 'DELETE' }),
|
|
revokeAllUserTrustedDevices: (id) =>
|
|
req(`/admin/users/${id}/trusted-devices`, { method: 'DELETE' }),
|
|
resetUserMfa: (id) => req(`/admin/users/${id}/mfa/reset`, { method: 'POST' }),
|
|
// Email invites.
|
|
listInvites: () => req('/admin/invites'),
|
|
createInvite: (email, role, sendEmail = true) =>
|
|
req('/admin/invites', { method: 'POST', body: { email, role, sendEmail } }),
|
|
revokeInvite: (id) => req(`/admin/invites/${id}`, { method: 'DELETE' }),
|
|
// A single user's shard (uo-link) footprint, scoped to their linked accounts.
|
|
// accounts/sales/houses/online are user-scoped endpoints; roster/vendors/char
|
|
// reuse the admin-bypass /admin/shard/* endpoints (which already read any
|
|
// account) so the shared GameAccounts component works unchanged.
|
|
userShard: (id) => ({
|
|
accounts: () => req(`/admin/users/${id}/shard/accounts`),
|
|
roster: (account) => req(`/admin/shard/roster/${encodeURIComponent(account)}`),
|
|
vendors: (account) => req(`/admin/shard/vendors/${encodeURIComponent(account)}`),
|
|
char: (serial) => req(`/admin/shard/char/${encodeURIComponent(serial)}`),
|
|
sales: () => req(`/admin/users/${id}/shard/sales`),
|
|
houses: () => req(`/admin/users/${id}/shard/houses`),
|
|
online: () => req(`/admin/users/${id}/shard/online`),
|
|
standing: () => req(`/admin/users/${id}/shard/standing`),
|
|
unlink: (account) => req(`/admin/users/${id}/shard/link/${encodeURIComponent(account)}`, { method: 'DELETE' }),
|
|
}),
|
|
|
|
// ----- moderation dashboard (admin + moderator) -----
|
|
modSummary: () => req('/admin/moderation/stats/summary'),
|
|
modRecent: (params = {}) => {
|
|
const qs = new URLSearchParams()
|
|
if (params.type) qs.set('type', params.type)
|
|
if (params.limit) qs.set('limit', params.limit)
|
|
if (params.offset) qs.set('offset', params.offset)
|
|
const s = qs.toString()
|
|
return req(`/admin/moderation/recent${withQs(s)}`)
|
|
},
|
|
modSearch: (q) => req(`/admin/moderation/search?q=${encodeURIComponent(q)}`),
|
|
modMembers: (params = {}) => {
|
|
const qs = new URLSearchParams()
|
|
if (params.type) qs.set('type', params.type)
|
|
if (params.limit) qs.set('limit', params.limit)
|
|
if (params.offset) qs.set('offset', params.offset)
|
|
const s = qs.toString()
|
|
return req(`/admin/moderation/members${withQs(s)}`)
|
|
},
|
|
modFilterHits: (params = {}) => {
|
|
const qs = new URLSearchParams()
|
|
if (params.limit) qs.set('limit', params.limit)
|
|
if (params.offset) qs.set('offset', params.offset)
|
|
const s = qs.toString()
|
|
return req(`/admin/moderation/filter-hits${withQs(s)}`)
|
|
},
|
|
modSpamHits: (params = {}) => {
|
|
const qs = new URLSearchParams()
|
|
if (params.limit) qs.set('limit', params.limit)
|
|
if (params.offset) qs.set('offset', params.offset)
|
|
const s = qs.toString()
|
|
return req(`/admin/moderation/spam-hits${withQs(s)}`)
|
|
},
|
|
modUser: (discordId) => req(`/admin/moderation/user/${discordId}`),
|
|
modUserActions: (discordId, params = {}) => {
|
|
const qs = new URLSearchParams()
|
|
if (params.type) qs.set('type', params.type)
|
|
if (params.limit) qs.set('limit', params.limit)
|
|
if (params.offset) qs.set('offset', params.offset)
|
|
const s = qs.toString()
|
|
return req(`/admin/moderation/user/${discordId}/actions${withQs(s)}`)
|
|
},
|
|
modUserNotes: (discordId) => req(`/admin/moderation/user/${discordId}/notes`),
|
|
addModNote: (discordId, data) =>
|
|
req(`/admin/moderation/user/${discordId}/notes`, { method: 'POST', body: data }),
|
|
|
|
// ----- moderation appeals (admin + moderator) -----
|
|
getAppeals: (params = {}) => {
|
|
const qs = new URLSearchParams()
|
|
if (params.status) qs.set('status', params.status)
|
|
if (params.limit) qs.set('limit', params.limit)
|
|
if (params.offset) qs.set('offset', params.offset)
|
|
const s = qs.toString()
|
|
return req(`/admin/moderation/appeals${withQs(s)}`)
|
|
},
|
|
getAppeal: (id) => req(`/admin/moderation/appeals/${id}`),
|
|
claimAppeal: (id) => req(`/admin/moderation/appeals/${id}/claim`, { method: 'POST' }),
|
|
resolveAppeal: (id, data) =>
|
|
req(`/admin/moderation/appeals/${id}/resolve`, { method: 'POST', body: data }),
|
|
getUserAppeals: (discordId) => req(`/admin/moderation/user/${discordId}/appeals`),
|
|
|
|
// ----- account security (self-service 2FA) -----
|
|
getAccount: () => req('/admin/account'),
|
|
totpSetup: () => req('/admin/account/totp/setup', { method: 'POST' }),
|
|
totpEnable: (code) => req('/admin/account/totp/enable', { method: 'POST', body: { code } }),
|
|
totpDisable: (code) => req('/admin/account/totp/disable', { method: 'POST', body: { code } }),
|
|
|
|
// ----- linked SSO identities (self-service) -----
|
|
linkedIdentities: () => req('/admin/account/identities'),
|
|
unlinkIdentity: (provider) => req(`/admin/account/identities/${provider}`, { method: 'DELETE' }),
|
|
|
|
// ----- game account linking (self-service, staff) -----
|
|
shard: {
|
|
link: (code) => req('/admin/shard/link', { method: 'POST', body: { code } }),
|
|
accounts: () => req('/admin/shard/accounts'),
|
|
roster: (account) => req(`/admin/shard/roster/${encodeURIComponent(account)}`),
|
|
vendors: (account) => req(`/admin/shard/vendors/${encodeURIComponent(account)}`),
|
|
char: (serial) => req(`/admin/shard/char/${encodeURIComponent(serial)}`),
|
|
sales: () => req('/admin/shard/sales'),
|
|
houses: () => req('/admin/shard/houses'), // full registry (admin/moderator)
|
|
createAccount: (account, password) =>
|
|
req('/admin/shard/account', { method: 'POST', body: { account, password } }),
|
|
},
|
|
|
|
// ----- auth providers / SSO config (admin only) -----
|
|
listAuthProviders: () => req('/admin/auth/providers'),
|
|
createAuthProvider: (data) => req('/admin/auth/providers', { method: 'POST', body: data }),
|
|
updateAuthProvider: (id, data) => req(`/admin/auth/providers/${id}`, { method: 'PUT', body: data }),
|
|
deleteAuthProvider: (id) => req(`/admin/auth/providers/${id}`, { method: 'DELETE' }),
|
|
|
|
// ----- Discord bot control (admin only) -----
|
|
getDiscordBotConfig: () => req('/admin/discord-bot/config'),
|
|
saveDiscordBotConfig: (data) => req('/admin/discord-bot/config', { method: 'PUT', body: data }),
|
|
|
|
// ----- uo-link sidecar control (admin only) -----
|
|
getUoLinkConfig: () => req('/admin/uo-link/config'),
|
|
saveUoLinkConfig: (data) => req('/admin/uo-link/config', { method: 'PUT', body: data }),
|
|
postTownCrier: (data) => req('/admin/uo-link/towncrier', { method: 'POST', body: data }),
|
|
deleteTownCrier: (id) => req(`/admin/uo-link/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
|
// Per-feature shard visibility: who may see which shard surface, and which
|
|
// sensitive fields within it. Admin only — it decides what ANONYMOUS
|
|
// visitors get. acct/webId are admin-only always and the API rejects any
|
|
// attempt to configure them.
|
|
getShardVisibility: () => req('/admin/shard/visibility'),
|
|
saveShardVisibility: (features) =>
|
|
req('/admin/shard/visibility', { method: 'PUT', body: { features } }),
|
|
|
|
// ----- spawn atlas operation (admin only) -----
|
|
// The atlas re-derives itself from the ServUO tree on every boot; these are
|
|
// for applying a map change without a restart, and for the approve/reject
|
|
// decision on a refresh that would remove a facet.
|
|
atlas: {
|
|
status: () => req('/admin/shard/atlas'),
|
|
import: (force = false) => req('/admin/shard/atlas/import', { method: 'POST', body: { force } }),
|
|
approve: () => req('/admin/shard/atlas/approve', { method: 'POST', body: {} }),
|
|
reject: () => req('/admin/shard/atlas/reject', { method: 'POST', body: {} }),
|
|
setPath: (path) => req('/admin/shard/atlas/path', { method: 'PUT', body: { path } }),
|
|
},
|
|
|
|
// ----- in-game staff operations: write plane + support queue (admin/moderator) -----
|
|
// `actor` is stamped server-side from the session — never sent from here.
|
|
shardOps: {
|
|
kick: (data) => req('/admin/shard/kick', { method: 'POST', body: data }),
|
|
ban: (data) => req('/admin/shard/ban', { method: 'POST', body: data }),
|
|
unban: (account) => req('/admin/shard/unban', { method: 'POST', body: { account } }),
|
|
broadcast: (data) => req('/admin/shard/broadcast', { method: 'POST', body: data }),
|
|
pages: () => req('/admin/shard/pages'),
|
|
respondPage: (id, data) =>
|
|
req(`/admin/shard/pages/${encodeURIComponent(id)}/respond`, { method: 'POST', body: data }),
|
|
closePage: (id) => req(`/admin/shard/pages/${encodeURIComponent(id)}/close`, { method: 'POST' }),
|
|
audit: (limit) => req(`/admin/shard/audit${limit ? `?limit=${limit}` : ''}`),
|
|
},
|
|
|
|
// ----- Email delivery / Gmail OAuth2 (admin only) -----
|
|
getEmailConfig: () => req('/admin/email/config'),
|
|
saveEmailConfig: (data) => req('/admin/email/config', { method: 'PUT', body: data }),
|
|
emailConnectUrl: () => req('/admin/email/connect/start'),
|
|
testEmail: (to) => req('/admin/email/test', { method: 'POST', body: { to } }),
|
|
disconnectEmail: () => req('/admin/email/disconnect', { method: 'POST' }),
|
|
},
|
|
|
|
// ----- player self-service (role: 'player') -----
|
|
// Mirrors the admin account methods but self-scoped under /player. The change
|
|
// endpoints re-issue the session cookie server-side, so the caller stays signed in.
|
|
player: {
|
|
getAccount: () => req('/player/account'),
|
|
changeUsername: (username) =>
|
|
req('/player/account/username', { method: 'PATCH', body: { username } }),
|
|
changePassword: (newPassword, currentPassword) =>
|
|
req('/player/account/password', { method: 'PATCH', body: { newPassword, currentPassword } }),
|
|
totpSetup: () => req('/player/account/totp/setup', { method: 'POST' }),
|
|
totpEnable: (code) => req('/player/account/totp/enable', { method: 'POST', body: { code } }),
|
|
totpDisable: (code) => req('/player/account/totp/disable', { method: 'POST', body: { code } }),
|
|
linkedIdentities: () => req('/player/account/identities'),
|
|
unlinkIdentity: (provider) => req(`/player/account/identities/${provider}`, { method: 'DELETE' }),
|
|
|
|
// ----- game account linking (uo-link) -----
|
|
shard: {
|
|
link: (code) => req('/player/shard/link', { method: 'POST', body: { code } }),
|
|
accounts: () => req('/player/shard/accounts'),
|
|
roster: (account) => req(`/player/shard/roster/${encodeURIComponent(account)}`),
|
|
vendors: (account) => req(`/player/shard/vendors/${encodeURIComponent(account)}`),
|
|
char: (serial) => req(`/player/shard/char/${encodeURIComponent(serial)}`),
|
|
sales: () => req('/player/shard/sales'),
|
|
houses: () => req('/player/shard/houses'), // the caller's own houses
|
|
createAccount: (account, password) =>
|
|
req('/player/shard/account', { method: 'POST', body: { account, password } }),
|
|
},
|
|
|
|
// ----- moderation appeals (self-service) -----
|
|
getMyAppeals: () => req('/player/appeals'),
|
|
getEligibleAppeals: () => req('/player/appeals/eligible'),
|
|
submitAppeal: (data) => req('/player/appeals', { method: 'POST', body: data }),
|
|
withdrawAppeal: (id) => req(`/player/appeals/${id}/withdraw`, { method: 'POST' }),
|
|
},
|
|
}
|
|
|
|
export { ApiError }
|