Protocol 3.0 order 3 (Part C), second of two website PRs. #112 built the data pipeline; this makes it reachable — six public routes, five admin ones, two public pages and an admin panel. Still website-only: no plugin, no sidecar, no new event kinds, no wire change. The API sits at /api/v1/public/atlas, not under /public/shard. Nothing here touches the sidecar, so the pages stay complete while the shard is down, and a /shard prefix would imply a dependency the atlas does not have. Unlike /shard/* it IS site-mode gated, like /posts and /wiki: a bestiary is site content. Every route carries requireFeature('atlas') and projects its response. The atlas feature declares no sensitive fields, so the projection is a no-op today — the call is there because v3.md 3.6.1's rule is that the FIRST field needing a gate should be covered by construction rather than by a retrofit. Two bugs the UI surfaced, both fixed here: Respawn delays were stored in the wrong unit, sometimes. XmlSpawner writes MinDelay/MaxDelay in minutes and switches to seconds only when a delay does not divide into whole minutes, flagging it per record with DelayInSec. A `5` means five minutes on one spawner and five seconds on the next, both plausible, and the pipeline stored the raw number. 170 of 6,455 stock spawners are second flagged. The parser normalises to seconds; the API and UI carry seconds. That exposed the hash gate as a trap. "Has the tree changed?" is the wrong question on its own: an install whose maps never change would have kept serving the old readings forever, because the only thing compared was the tree. PARSER_VERSION is now stored beside the source hashes and a mismatch counts as drift, so any future parse correction lands on the next boot. Also renamed the detail route's spawn-point array to `spawners` — it was `points`, which is the COUNT on the search route, so one key meant a number in one place and an array in the other. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U7CBg11prhLimL9iHSX1bP
471 lines
24 KiB
JavaScript
471 lines
24 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
|
|
}
|
|
}
|
|
|
|
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 } }),
|
|
|
|
// ----- 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'),
|
|
// 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 }),
|
|
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 }
|