// 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 }