Replaces the two raw JSON boxes Phase 3 shipped as explicit placeholders: a
step's params are a form rendered from the action's own declaration, and a
phase's advance condition is the engagement condition builder. Adds the live cap
meter, the searchable option source's first consumer, and a start dialog
carrying the three fields the route has taken since Phase 10.
One route: POST /admin/events/price, admin+editor. A module's cost() runs on the
server and only there, so a meter has nothing to add up until something asks --
and the dry run is the wrong thing to ask on a debounce twice over: it dispatches
every step through the module and a pass against a version is RECORDED, which is
the stamp K's unattended-start gate reads. This dispatches nothing and records
nothing, and takes the spec in the body because the plan being priced is unsaved
between keystrokes.
A form gives way to JSON on the condition builder's own rule: a value the editor
cannot round-trip is SHOWN rather than silently rewritten. Dropping a param the
action does not declare and flattening `A and (B or C)` are the same mistake.
Two defects fixed in already-merged code:
* Creating an event has been impossible since Phase 6. `events/new` was added
beside `events/:id` and binds no param, and React Router ranks a static
segment above a dynamic one whatever the order -- so the editor was handed no
id and fetched /admin/events/undefined. Worse, the failure was invisible:
`!form` is true for every failed load, so the error state sat behind a
spinner that never stopped.
* 12b's searchable sources had no consumer. The server half shipped and the
only UI that reads a source never sent a term, so the 6,707-entry spawner
list was picked from a 2,000-entry truncation with nothing saying so.
Server: 2113 tests, 2024 pass, 0 fail (89 DB-skipped). Client: 380 pass, 0 fail.
routes:manifest and swagger regenerated -- one route added, none moved.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
715 lines
42 KiB
JavaScript
715 lines
42 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 is core's own binding surface and nothing else: every
|
|
// namespace in it belongs to a route core still serves. A module binds its own
|
|
// paths in its own chunk, against this primitive.
|
|
// `BASE` goes with it: a module that needs an EventSource URL cannot go through
|
|
// `req` (fetch-only) and must not hardcode `/api/v1`, which is core's choice of
|
|
// mount point and not a promise it has made.
|
|
export { req as request, BASE }
|
|
|
|
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' }),
|
|
// Self-service account security, role-agnostic under /auth/me/account. This is
|
|
// the ONLY surface for it: the /admin/account/* and /player/account/* copies
|
|
// were deleted (both were strictly smaller — neither carried recovery codes),
|
|
// which is why recovery codes below already lived here while the rest did not.
|
|
// The change endpoints re-issue the session cookie server-side, so the caller
|
|
// stays signed in.
|
|
myAccount: () => req('/auth/me/account'),
|
|
changeUsername: (username) =>
|
|
req('/auth/me/account/username', { method: 'PATCH', body: { username } }),
|
|
changePassword: (newPassword, currentPassword) =>
|
|
req('/auth/me/account/password', { method: 'PATCH', body: { newPassword, currentPassword } }),
|
|
// Email address (engagement Phase 1b). changeEmail STAGES the address — the
|
|
// account keeps its current one until the emailed link is opened — so the UI
|
|
// must show `email_pending` as pending, never as the address in force.
|
|
changeEmail: (email, currentPassword) =>
|
|
req('/auth/me/account/email', { method: 'PATCH', body: { email, currentPassword } }),
|
|
resendEmailVerification: () => req('/auth/me/account/email/resend', { method: 'POST' }),
|
|
cancelEmailChange: () => req('/auth/me/account/email/pending', { method: 'DELETE' }),
|
|
// The confirm half is public and token-gated — it is reached from a mailbox,
|
|
// often with no session, so it deliberately sits outside /auth/me.
|
|
lookupEmailVerification: (token) => req(`/auth/email/verify/${encodeURIComponent(token)}`),
|
|
confirmEmailVerification: (token) =>
|
|
req(`/auth/email/verify/${encodeURIComponent(token)}`, { method: 'POST' }),
|
|
totpSetup: () => req('/auth/me/account/totp/setup', { method: 'POST' }),
|
|
totpEnable: (code) => req('/auth/me/account/totp/enable', { method: 'POST', body: { code } }),
|
|
totpDisable: (code) => req('/auth/me/account/totp/disable', { method: 'POST', body: { code } }),
|
|
// Linked SSO identities (self-service). Linking starts at /auth/sso/:id/link.
|
|
myIdentities: () => req('/auth/me/account/identities'),
|
|
unlinkIdentity: (provider) =>
|
|
req(`/auth/me/account/identities/${encodeURIComponent(provider)}`, { 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'),
|
|
|
|
// ----- Teams (TEAMS.md §2.11, §4.3) -----
|
|
//
|
|
// Only the two calls CORE's own client makes. Core renders no Team pages — the
|
|
// vocabulary belongs to whichever module owns the surface — so the index, the
|
|
// roster and the player list are not here; a module that renders those calls
|
|
// the same public API from its own client.
|
|
//
|
|
// The lookup exists because a module names a Team in its own terms and core
|
|
// keys the feed by slug. Resolving that is core's job precisely so a module
|
|
// never has to hold core's identifiers.
|
|
teamByExternalId: (moduleId, externalId) =>
|
|
req(`/public/teams/by-external/${encodeURIComponent(moduleId)}/${encodeURIComponent(externalId)}`),
|
|
teamActivity: (slug, opts = {}) => {
|
|
const qs = new URLSearchParams()
|
|
if (opts.limit != null) qs.set('limit', String(opts.limit))
|
|
if (opts.offset != null) qs.set('offset', String(opts.offset))
|
|
return req(`/public/teams/${encodeURIComponent(slug)}/activity${withQs(qs.toString())}`)
|
|
},
|
|
// The Team FORUM, under /player because a participant may be a plain player and
|
|
// a leader is a player (TEAMS.md §2.11). Core's, for the same reason the feed is
|
|
// core's: only core resolves whether this viewer is inside the Team, and the
|
|
// member/guest split is a security boundary. The module renders the PLACE.
|
|
teamForumThreads: (slug) => req(`/player/teams/${encodeURIComponent(slug)}/forum/threads`),
|
|
teamForumThread: (slug, id) => req(`/player/teams/${encodeURIComponent(slug)}/forum/threads/${id}`),
|
|
teamForumPost: (slug, body) =>
|
|
req(`/player/teams/${encodeURIComponent(slug)}/forum/threads`, { method: 'POST', body }),
|
|
teamForumModerate: (slug, id, body) =>
|
|
req(`/player/teams/${encodeURIComponent(slug)}/forum/threads/${id}/moderate`, { method: 'POST', body }),
|
|
// Phase 5 ("5b"). A reply, an edit and post-level moderation are separate
|
|
// routes from their thread-level cousins rather than the same route with a
|
|
// target kind, because they answer to different rules: a reply is refused by a
|
|
// lock, an edit by a clock, and `pin`/`lock` mean nothing to a post at all.
|
|
teamForumReply: (slug, threadId, body) =>
|
|
req(`/player/teams/${encodeURIComponent(slug)}/forum/threads/${threadId}/posts`, { method: 'POST', body }),
|
|
teamForumEditPost: (slug, postId, body) =>
|
|
req(`/player/teams/${encodeURIComponent(slug)}/forum/posts/${postId}`, { method: 'PATCH', body }),
|
|
teamForumModeratePost: (slug, postId, body) =>
|
|
req(`/player/teams/${encodeURIComponent(slug)}/forum/posts/${postId}/moderate`, { method: 'POST', body }),
|
|
// The report goes to SITE STAFF, never to the Team's leaders — the whole point
|
|
// of it is a path that routes around a Team's own leadership (TEAMS.md §5.6).
|
|
// There is no leader-facing counterpart to this call and there should not be.
|
|
teamForumReport: (slug, body) =>
|
|
req(`/player/teams/${encodeURIComponent(slug)}/forum/report`, { method: 'POST', body }),
|
|
teamForumUpload: (slug, file) => {
|
|
const fd = new FormData()
|
|
fd.append('image', file)
|
|
return req(`/player/teams/${encodeURIComponent(slug)}/forum/uploads`, { method: 'POST', body: fd, raw: true })
|
|
},
|
|
teamGrantList: (slug) => req(`/player/teams/${encodeURIComponent(slug)}/grants`),
|
|
teamGrantAdd: (slug, body) =>
|
|
req(`/player/teams/${encodeURIComponent(slug)}/grants`, { method: 'POST', body }),
|
|
teamGrantRevoke: (slug, userId) =>
|
|
req(`/player/teams/${encodeURIComponent(slug)}/grants/${userId}`, { method: 'DELETE' }),
|
|
|
|
// ----- notifications (TEAMS.md Part 6) -----
|
|
//
|
|
// Under /auth/me rather than /player: these are role-agnostic self-service, the
|
|
// same rule that put the forum under /player rather than behind a staff gate.
|
|
// The streams catalog and the per-stream subscriptions were built for the app
|
|
// and had no web consumer at all until phase 6 gave them one.
|
|
notificationStreams: () => req('/auth/me/notifications/streams'),
|
|
notificationSubscriptions: () => req('/auth/me/notifications/subscriptions'),
|
|
// `streams` is always sent, empty array included — the endpoint requires the
|
|
// field, so clearing the last subscription must not become an absent key.
|
|
setNotificationSubscriptions: (streams) =>
|
|
req('/auth/me/notifications/subscriptions', { method: 'PUT', body: { streams } }),
|
|
// Per-channel preferences (ENGAGEMENT.md Phase 3). A SPARSE update: only the
|
|
// (id, channel) pairs sent are written, so a screen managing one channel need
|
|
// not know what the others hold. Shipped with no surface at all until Phase 7.
|
|
notificationChannelPrefs: () => req('/auth/me/notifications/channels'),
|
|
setNotificationChannelPrefs: (prefs) =>
|
|
req('/auth/me/notifications/channels', { method: 'PUT', body: { prefs } }),
|
|
// The in-app inbox (ENGAGEMENT.md Phase 7). `before` is a keyset cursor — the
|
|
// id of the last item on the previous page — not an offset: the list gains
|
|
// rows at the top while it is being read.
|
|
notifications: ({ limit, before, unread } = {}) => {
|
|
const qs = new URLSearchParams()
|
|
if (limit) qs.set('limit', String(limit))
|
|
if (before) qs.set('before', String(before))
|
|
if (unread) qs.set('unread', 'true')
|
|
return req(`/auth/me/notifications${withQs(qs.toString())}`)
|
|
},
|
|
notificationsUnreadCount: () => req('/auth/me/notifications/unread-count'),
|
|
markNotificationRead: (id) => req(`/auth/me/notifications/${id}/read`, { method: 'POST' }),
|
|
markAllNotificationsRead: () => req('/auth/me/notifications/read-all', { method: 'POST' }),
|
|
teamNotificationPrefs: () => req('/auth/me/notifications/teams'),
|
|
setTeamNotificationPrefs: (teams) =>
|
|
req('/auth/me/notifications/teams', { method: 'PUT', body: { teams } }),
|
|
// Unauthenticated, and the one write in the public tier: the caller is reading
|
|
// their mail, not signed in. Always resolves 200 whatever the token was.
|
|
unsubscribeTeam: (token) =>
|
|
req(`/public/teams/unsubscribe/${encodeURIComponent(token)}`, { method: 'POST' }),
|
|
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 }),
|
|
|
|
// ----- 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' }),
|
|
// Accounts whose address was cleared when addresses became unique (Phase 1b).
|
|
// They can still sign in but can receive no mail until they set a new one, so
|
|
// they are the list an operator has to work through.
|
|
emailDedupeReport: () => req('/admin/users/email-dedupe-report'),
|
|
acknowledgeEmailDedupeReport: () =>
|
|
req('/admin/users/email-dedupe-report/acknowledge', { method: 'POST' }),
|
|
// 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' }),
|
|
|
|
// Installed modules (MODULE_SYSTEM.md §2.7.2). `uninstallModule`'s purge flag
|
|
// is a query parameter rather than a body because it hangs off a DELETE, and
|
|
// it is spelled out at the call site rather than defaulted, so the
|
|
// destructive branch is never the one you get by forgetting an argument.
|
|
listModules: () => req('/admin/modules'),
|
|
installModule: (url) => req('/admin/modules', { method: 'POST', body: { url } }),
|
|
enableModule: (id) => req(`/admin/modules/${encodeURIComponent(id)}/enable`, { method: 'POST' }),
|
|
disableModule: (id) => req(`/admin/modules/${encodeURIComponent(id)}/disable`, { method: 'POST' }),
|
|
uninstallModule: (id, { purge } = {}) =>
|
|
req(`/admin/modules/${encodeURIComponent(id)}${purge ? '?purge=true' : ''}`, { method: 'DELETE' }),
|
|
purgeModule: (id) => req(`/admin/modules/${encodeURIComponent(id)}/purge`, { method: 'POST' }),
|
|
setModuleSources: (hosts) => req('/admin/modules/sources', { method: 'PUT', body: { hosts } }),
|
|
restartServer: () => req('/admin/modules/restart', { method: 'POST' }),
|
|
|
|
// Engagement (docs/website/ENGAGEMENT.md Phase 4b). The first three are the
|
|
// catalog — triggers, audiences and channels, all served from the registries
|
|
// rather than from tables, so an installed module's declarations appear here
|
|
// without a client release.
|
|
//
|
|
// `setEngagementRuleEnabled` is its own call rather than a `saveEngagementRule`
|
|
// with one field, because the route is its own route: turning a rule off must
|
|
// work on a rule the registries would now refuse, which is exactly the rule an
|
|
// operator most wants stopped.
|
|
//
|
|
// `previewEngagementReach` answers with a COUNT and never a list of people.
|
|
engagementTriggers: () => req('/admin/engagement/triggers'),
|
|
engagementAudiences: () => req('/admin/engagement/audiences'),
|
|
engagementChannels: () => req('/admin/engagement/channels'),
|
|
listEngagementRules: () => req('/admin/engagement/rules'),
|
|
createEngagementRule: (body) => req('/admin/engagement/rules', { method: 'POST', body }),
|
|
updateEngagementRule: (id, body) => req(`/admin/engagement/rules/${id}`, { method: 'PUT', body }),
|
|
setEngagementRuleEnabled: (id, enabled) =>
|
|
req(`/admin/engagement/rules/${id}/enabled`, { method: 'PATCH', body: { enabled } }),
|
|
deleteEngagementRule: (id) => req(`/admin/engagement/rules/${id}`, { method: 'DELETE' }),
|
|
listEngagementSegments: () => req('/admin/engagement/segments'),
|
|
createEngagementSegment: (body) => req('/admin/engagement/segments', { method: 'POST', body }),
|
|
updateEngagementSegment: (id, body) => req(`/admin/engagement/segments/${id}`, { method: 'PUT', body }),
|
|
deleteEngagementSegment: (id) => req(`/admin/engagement/segments/${id}`, { method: 'DELETE' }),
|
|
previewEngagementReach: ({ audience, audienceSegmentId, triggerId } = {}) => {
|
|
const qs = new URLSearchParams()
|
|
if (audienceSegmentId) qs.set('audienceSegmentId', String(audienceSegmentId))
|
|
else if (audience) qs.set('audience', audience)
|
|
if (triggerId) qs.set('triggerId', triggerId)
|
|
return req(`/admin/engagement/audience-preview${withQs(qs.toString())}`)
|
|
},
|
|
|
|
// Templates and the send log (engagement Phase 5b). `previewEngagementTemplate`
|
|
// and `testSendEngagementTemplate` are POSTs that write nothing: both act on
|
|
// the draft in the request, so the editor can show and send what is on screen
|
|
// rather than what was last saved.
|
|
listEngagementTemplates: () => req('/admin/engagement/templates'),
|
|
getEngagementTemplate: (id) => req(`/admin/engagement/templates/${id}`),
|
|
updateEngagementTemplate: (id, body) =>
|
|
req(`/admin/engagement/templates/${id}`, { method: 'PUT', body }),
|
|
duplicateEngagementTemplate: (id, body) =>
|
|
req(`/admin/engagement/templates/${id}/duplicate`, { method: 'POST', body }),
|
|
deleteEngagementTemplate: (id) => req(`/admin/engagement/templates/${id}`, { method: 'DELETE' }),
|
|
previewEngagementTemplate: (id, body) =>
|
|
req(`/admin/engagement/templates/${id}/preview`, { method: 'POST', body }),
|
|
testSendEngagementTemplate: (id, body) =>
|
|
req(`/admin/engagement/templates/${id}/test-send`, { method: 'POST', body }),
|
|
listEngagementSends: ({ limit, offset, triggerId, ruleId, userId, status } = {}) => {
|
|
const qs = new URLSearchParams()
|
|
if (limit) qs.set('limit', String(limit))
|
|
if (offset) qs.set('offset', String(offset))
|
|
if (triggerId) qs.set('triggerId', triggerId)
|
|
if (ruleId) qs.set('ruleId', String(ruleId))
|
|
if (userId) qs.set('userId', String(userId))
|
|
if (status) qs.set('status', status)
|
|
return req(`/admin/engagement/sends${withQs(qs.toString())}`)
|
|
},
|
|
|
|
// Suppressions (Phase 9). `unsuppressAddress` sends the address in the BODY
|
|
// of a DELETE rather than in the path, and that is not style: a path
|
|
// parameter lands in the access log, the browser history and every proxy in
|
|
// front of the deployment, and this one is a real person's address.
|
|
//
|
|
// **Phase 14 added the second form, and it is the one the row uses.** The
|
|
// list now returns each row's `address_hash`, so the Lift button on a row
|
|
// needs no address at all — the operator is looking at a mask and has never
|
|
// been told the address. `unsuppressAddress` stays for the address the
|
|
// operator types, which is the only way to reach a row that is not on the
|
|
// page in front of them.
|
|
listEngagementSuppressions: ({ limit, offset, reason, channel, search } = {}) => {
|
|
const qs = new URLSearchParams()
|
|
if (limit) qs.set('limit', String(limit))
|
|
if (offset) qs.set('offset', String(offset))
|
|
if (reason) qs.set('reason', reason)
|
|
if (channel) qs.set('channel', channel)
|
|
if (search) qs.set('search', search)
|
|
return req(`/admin/engagement/suppressions${withQs(qs.toString())}`)
|
|
},
|
|
suppressAddress: (address, detail) =>
|
|
req('/admin/engagement/suppressions', { method: 'POST', body: { address, detail } }),
|
|
unsuppressAddress: (address, channel) =>
|
|
req('/admin/engagement/suppressions', { method: 'DELETE', body: { address, channel } }),
|
|
unsuppressByHash: (hash, channel) => {
|
|
const qs = new URLSearchParams()
|
|
if (channel) qs.set('channel', channel)
|
|
return req(`/admin/engagement/suppressions/by-hash/${hash}${withQs(qs.toString())}`, {
|
|
method: 'DELETE',
|
|
})
|
|
},
|
|
|
|
// Retention (Phase 14). Three horizons, one screen; `engagement_suppressions`
|
|
// is not among them because a suppression does not expire.
|
|
getEngagementRetention: () => req('/admin/engagement/retention'),
|
|
setEngagementRetention: (body) =>
|
|
req('/admin/engagement/retention', { method: 'PUT', body }),
|
|
|
|
// Events (docs/website/EVENTS.md, Phase 3). Reads are staff-wide; authoring
|
|
// is admin+editor, publish and start are admin ONLY, and the six live
|
|
// controls are admin+moderator — the one gate in this feature wider than
|
|
// admin, because stopping a run at 2am is incident response and starting
|
|
// one is not (§N2). The buttons follow the same split, and the server
|
|
// re-checks every one of them.
|
|
listEvents: (state) => req(`/admin/events${state ? `?state=${encodeURIComponent(state)}` : ''}`),
|
|
getEvent: (id) => req(`/admin/events/${id}`),
|
|
createEvent: (body) => req('/admin/events', { method: 'POST', body }),
|
|
updateEvent: (id, body) => req(`/admin/events/${id}`, { method: 'PUT', body }),
|
|
publishEvent: (id) => req(`/admin/events/${id}/publish`, { method: 'POST' }),
|
|
archiveEvent: (id) => req(`/admin/events/${id}`, { method: 'DELETE' }),
|
|
listEventVersions: (id) => req(`/admin/events/${id}/versions`),
|
|
eventCatalog: () => req('/admin/events/catalog'),
|
|
// Phase 7. The values behind a param's `source` — resolved by the module that
|
|
// registered the source, on a request of its own rather than inside the
|
|
// catalog, because a source can be slow or down and must not take the whole
|
|
// editor with it. A refusal comes back 200 with `ok: false`, so this never
|
|
// throws for the case the screen is meant to render: the field degrades to
|
|
// free text with the reason beside it.
|
|
// Phase 12b made a source SEARCHABLE and Phase 13 is what asks. `q` is
|
|
// ignored, never refused, by a source that does not declare itself
|
|
// searchable — so passing it is always safe and the field decides whether
|
|
// it is a typeahead by reading `searchable` off the answer.
|
|
eventOptions: (sourceId, q) => {
|
|
const qs = q ? `?${new URLSearchParams({ q }).toString()}` : ''
|
|
return req(`/admin/events/catalog/options/${encodeURIComponent(sourceId)}${qs}`)
|
|
},
|
|
// Phase 6. The dry run is admin+editor: it dispatches nothing, and the author
|
|
// who wrote the definition is who should be able to price it against the caps
|
|
// before asking an admin to publish it. A report with findings comes back 200
|
|
// — the request succeeded, the plan has problems.
|
|
verifyEvent: (id) => req(`/admin/events/${id}/verify`, { method: 'POST' }),
|
|
// Phase 13's live cap meter, and NOT a lighter dry run — it dispatches
|
|
// nothing, so it knows nothing a module knows. It takes the spec in the
|
|
// body rather than an id because the plan it prices is the one in the
|
|
// author's hands, which is unsaved between keystrokes, and it records
|
|
// nothing, which is what makes it safe to call on a debounce.
|
|
priceEvent: (body) => req('/admin/events/price', { method: 'POST', body }),
|
|
// The switchboard, admin only in BOTH directions: reading which actions a
|
|
// deployment permits is as much configuration as writing it (§K). One action
|
|
// per write rather than the whole board, so an action that appeared between
|
|
// the read and the write cannot be overwritten with a default.
|
|
eventActions: () => req('/admin/events/actions'),
|
|
saveEventAction: (body) => req('/admin/events/actions', { method: 'PUT', body }),
|
|
eventSeries: () => req('/admin/events/series'),
|
|
// Series writes are admin+editor rather than admin: naming an arc is
|
|
// authoring, and §N2's narrow gate is about committing the deployment to a
|
|
// run. The delete is a real delete and answers with how many definitions it
|
|
// detached — `series_id` is ON DELETE SET NULL, so nothing is destroyed.
|
|
createEventSeries: (body) => req('/admin/events/series', { method: 'POST', body }),
|
|
updateEventSeries: (id, body) => req(`/admin/events/series/${id}`, { method: 'PUT', body }),
|
|
deleteEventSeries: (id) => req(`/admin/events/series/${id}`, { method: 'DELETE' }),
|
|
// The calendar. `from`/`to` are UTC instants the caller computes from the
|
|
// month it is showing, in the READER's zone — the server never guesses it.
|
|
// A `status` or `scope` filter suppresses projections, which is why the
|
|
// month view sends neither.
|
|
eventCalendar: ({ from, to, status, scope, seriesId } = {}) => {
|
|
const qs = new URLSearchParams({ from, to })
|
|
if (status) qs.set('status', status)
|
|
if (scope) qs.set('scope', scope)
|
|
if (seriesId) qs.set('seriesId', String(seriesId))
|
|
return req(`/admin/events/calendar?${qs.toString()}`)
|
|
},
|
|
startEventRun: (id, body) => req(`/admin/events/${id}/runs`, { method: 'POST', body }),
|
|
listEventRuns: ({ definitionId, status, limit } = {}) => {
|
|
const qs = new URLSearchParams()
|
|
if (definitionId) qs.set('definitionId', String(definitionId))
|
|
if (status) qs.set('status', status)
|
|
if (limit) qs.set('limit', String(limit))
|
|
const suffix = qs.toString()
|
|
return req(`/admin/events/runs${suffix ? `?${suffix}` : ''}`)
|
|
},
|
|
getEventRun: (runId) => req(`/admin/events/runs/${runId}`),
|
|
getEventRunLog: (runId, limit) =>
|
|
req(`/admin/events/runs/${runId}/log${limit ? `?limit=${Number(limit)}` : ''}`),
|
|
pauseEventRun: (runId, reason) =>
|
|
req(`/admin/events/runs/${runId}/pause`, { method: 'POST', body: { reason } }),
|
|
resumeEventRun: (runId) => req(`/admin/events/runs/${runId}/resume`, { method: 'POST' }),
|
|
// `cleanup` defaults to true server-side and has to be asked out of: EVENTS.md
|
|
// §L makes cancelling WITHOUT cleanup the separate, admin-only, logged action,
|
|
// so an absent flag means "give back what this run took".
|
|
cancelEventRun: (runId, reason, cleanup = true) =>
|
|
req(`/admin/events/runs/${runId}/cancel`, { method: 'POST', body: { reason, cleanup } }),
|
|
cleanupEventRun: (runId) => req(`/admin/events/runs/${runId}/cleanup`, { method: 'POST' }),
|
|
advanceEventRun: (runId, reason) =>
|
|
req(`/admin/events/runs/${runId}/advance`, { method: 'POST', body: { reason } }),
|
|
confirmEventStep: (runId, stepId, note) =>
|
|
req(`/admin/events/runs/${runId}/steps/${stepId}/confirm`, { method: 'POST', body: { note } }),
|
|
skipEventStep: (runId, stepId, reason) =>
|
|
req(`/admin/events/runs/${runId}/steps/${stepId}/skip`, { method: 'POST', body: { reason } }),
|
|
retryEventStep: (runId, stepId) =>
|
|
req(`/admin/events/runs/${runId}/steps/${stepId}/retry`, { method: 'POST' }),
|
|
|
|
// Teams (docs/website/TEAMS.md §2.11). Three of these mean something
|
|
// different depending on who calls them: for a moderator, unhide and
|
|
// setTeamDisplayName file a request and the response says `pending: true`.
|
|
// The caller does not choose — the server decides from the live role — so
|
|
// there is deliberately no "asRequest" argument to get wrong.
|
|
listTeams: () => req('/admin/teams'),
|
|
getTeam: (id) => req(`/admin/teams/${id}`),
|
|
resyncTeams: () => req('/admin/teams/resync', { method: 'POST' }),
|
|
archiveTeam: (id, reason) => req(`/admin/teams/${id}/archive`, { method: 'POST', body: { reason } }),
|
|
teamGrants: (id) => req(`/admin/teams/${id}/grants`),
|
|
hideTeam: (id, reason) => req(`/admin/teams/${id}/hide`, { method: 'POST', body: { reason } }),
|
|
unhideTeam: (id, reason) => req(`/admin/teams/${id}/unhide`, { method: 'POST', body: { reason } }),
|
|
setTeamDisplayName: (id, displayName, reason) =>
|
|
req(`/admin/teams/${id}/display-name`, { method: 'POST', body: { displayName, reason } }),
|
|
setTeamLeaderOverride: (id, body) =>
|
|
req(`/admin/teams/${id}/leader-override`, { method: 'POST', body }),
|
|
clearTeamLeaderOverride: (id, memberKey) =>
|
|
req(`/admin/teams/${id}/leader-override/${encodeURIComponent(memberKey)}`, { method: 'DELETE' }),
|
|
teamForumSettings: () => req('/admin/teams/forum/settings'),
|
|
// The notification bridge (TEAMS.md §7.2). Admin-only server-side, so a
|
|
// moderator's admin panel never renders the panel that calls these.
|
|
teamIntegrations: () => req('/admin/teams/integrations'),
|
|
saveTeamIntegration: (body) => req('/admin/teams/integrations', { method: 'PUT', body }),
|
|
deleteTeamIntegration: (teamId) =>
|
|
req(`/admin/teams/integrations/${teamId === null ? 'default' : teamId}`, { method: 'DELETE' }),
|
|
// Voice channels (TEAMS.md §7.3). Admin-only server-side, like the bridge.
|
|
teamVoice: () => req('/admin/teams/voice'),
|
|
saveTeamVoice: (body) => req('/admin/teams/voice', { method: 'PUT', body }),
|
|
teamVoicePass: () => req('/admin/teams/voice/sync', { method: 'POST' }),
|
|
removeTeamVoice: (teamId) => req(`/admin/teams/voice/${teamId}`, { method: 'DELETE' }),
|
|
teamForumUploads: (opts = {}) => {
|
|
const qs = new URLSearchParams()
|
|
if (opts.deleted) qs.set('deleted', '1')
|
|
return req(`/admin/teams/forum/uploads${withQs(qs.toString())}`)
|
|
},
|
|
teamForumModeration: (id) => req(`/admin/teams/${id}/forum/moderation`),
|
|
teamReviewQueue: () => req('/admin/teams/review'),
|
|
teamRequests: (status) => req(`/admin/teams/requests${status ? `?status=${status}` : ''}`),
|
|
decideTeamRequest: (id, status, note) =>
|
|
req(`/admin/teams/requests/${id}/decide`, { method: 'POST', body: { status, note } }),
|
|
|
|
// ----- moderation dashboard (admin + moderator) -----
|
|
modSummary: () => req('/admin/moderation/stats/summary'),
|
|
// The content-report queue (TEAMS.md §5.6). Under moderation rather than
|
|
// under Teams because a staffer working a queue should have one place to
|
|
// work, and a report about a forum post is the same job as a report about
|
|
// anything else — which is also why `targetType` is open-ended.
|
|
contentReports: (opts = {}) => {
|
|
const qs = new URLSearchParams()
|
|
if (opts.status) qs.set('status', opts.status)
|
|
if (opts.teamId) qs.set('teamId', String(opts.teamId))
|
|
return req(`/admin/moderation/reports${withQs(qs.toString())}`)
|
|
},
|
|
handleContentReport: (id, body) =>
|
|
req(`/admin/moderation/reports/${id}/handle`, { method: 'POST', body }),
|
|
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`),
|
|
|
|
// ----- 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 }),
|
|
|
|
// ----- Email delivery (admin only) -----
|
|
// The connect-flow call went with Gmail OAuth2 (ENGAGEMENT.md §1.2a); the
|
|
// config response now carries the transport catalog the form renders from.
|
|
getEmailConfig: () => req('/admin/email/config'),
|
|
saveEmailConfig: (data) => req('/admin/email/config', { method: 'PUT', body: data }),
|
|
testEmail: (to) => req('/admin/email/test', { method: 'POST', body: { to } }),
|
|
disconnectEmail: () => req('/admin/email/disconnect', { method: 'POST' }),
|
|
},
|
|
|
|
// ----- player self-service (role: 'player') -----
|
|
// Account security is NOT here — it is role-agnostic and lives at the root of
|
|
// this object, on /auth/me/account. What remains is genuinely player-scoped.
|
|
player: {
|
|
// ----- 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 }
|