Phase 5a gave templates a table, a renderer and nine seeded rows; nothing could
change one. This is the screen that lets an operator change one without being able
to break the mail the system depends on — plus the two screens Q4 promised Phase 5:
Triggers (read-only, from the registries) and the Send Log, which closes G15.
The shape follows from one fact: a mail body is rendered by the SERVER, so the
preview is too, and framed rather than redrawn in React. A client-side renderer
would be a second implementation of the one artifact that matters, agreeing with
the send path on the day it was written and drifting from the first Outlook fix on.
Settled with the org lead before any code: a shipped default is edited IN PLACE
(`protected` blocks deletion and nothing else, `customized = 1` keeps the edit);
duplicate is the only way to a new template; `renderByKey` now requires
`published`; a test send is logged under a synthetic `core.admin.test-send`; and a
template a rule points at refuses deletion with a 409 naming the rules.
Three things the plan did not know, found by building it:
- The undeclared-variable check cannot be a token scan. `email.itemList.variable`
holds a BARE name, so a digest pointed at `itmes` would have saved clean and
arrived empty. Blocks now declare `variables(props)`; the editor makes that
field a select over the trigger's list variables so the typo is unavailable.
- A duplicate that drops `seed_key` loses its variable palette, so duplicating
`notify.event` would have been refused for the tokens it was copied with — the
one action §4.6.2 offers, refusing itself. The copy inherits it; `customized`
is what the seeder actually reads.
- `validateEmailBlocks` returns `{ valid, errors }`, not an array, and the first
version tested it with `.length` — so block validation never ran at all.
Also fixes a Phase 4a defect the live walk found, with the org lead's approval: a
rule's template key was checked against a pattern with no dot in it, so no rule
could name any template that exists — §4.6.2's whole duplicate-and-point-a-rule-at-it
workflow was unreachable. Both models now read one pattern.
Verified against the running stack: real multipart mail into a mailpit catcher
including an unsaved draft, the draft/published arms both ways through the real
mailer path, every refusal, and the end-to-end duplicate → rule → 409 walk.
Server 1428 tests green, client 324.
Co-Authored-By: Claude <noreply@anthropic.com>
564 lines
32 KiB
JavaScript
564 lines
32 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 } }),
|
|
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())}`)
|
|
},
|
|
|
|
// 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 }
|