// 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'), // 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'), }, // 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 } }), // ----- 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 }