ENGAGEMENT.md Phase 9, closing gap G16. Two mechanisms decide that somebody in a
rule's audience does not get the mail, and they sit at deliberately different
points in the pipeline.
`engagement_suppressions` is checked at DELIVERY: an outbox row can sit through a
rule's `delay_seconds` grace window and an address can bounce inside it, so the
only correct check is the one taken immediately before the transport call — which
is also what produces the `status='suppressed'` row with no transport call at all.
The Phase 1b verification gate is applied at ENQUEUE, through a new optional
`registerDeliveryChannel({ eligible })` that only `email` declares. Filtering the
shared audience would have silenced the wrong sink: a rule spanning email and
in-app must still put an item in an unverified user's inbox. The excluded counts
reach `summary.ineligible` and the admin reach preview, which until now reported
an audience size that was never the number of people who would be mailed.
`bounceClassify.js` is the only thing that may write a `bounce` row, and it is
deliberately NOT `mailer.PERMANENT_CODES`. That set answers "is retrying
pointless?" and contains EAUTH and 554 — an auth failure and a relay-wide policy
refusal, neither of which is a fact about the recipient. Reusing it would mean one
stale SMTP password suppressing every address the worker touched, silently. The
classifier reads the RFC 3463 enhanced status first, falls back to a phrase match
only past a veto list and only for 550/551/553, and does not suppress anything it
is unsure about.
Scope is engagement rules only: resets, invites, verification and the contact form
still attempt, matching the posture passwordReset.controller.js already stated.
Found on the live rig, against a real MariaDB and a real SMTP conversation: a hard
bounce was being recorded as `failed`, so the Send Log's "Bounced" filter — a
status `engagement_sends` has carried since §4.5 — matched nothing and always
would have. It is now its own outcome; the outbox row stays `failed`, since that
ENUM has no `bounced` and a bounced row is one that finished unsuccessfully.
`address_masked` is this phase's one addition to §4.5's DDL. A hash-only table
cannot be operated — an operator cannot tell three typos from a whole domain
refusing mail — and the domain survives while the local part is destroyed, so the
column can never be read back as an address book.
- schema: `engagement_suppressions` (+ `address_masked`, `created_by`)
- `GET/POST/DELETE /api/v1/admin/engagement/suppressions`, and Admin → Engagement
→ Suppressions, the only way out of the list
- `sendNotification` returns `smtp: { code, responseCode, response }`
- 26 new tests; swagger, routes manifest and guards regenerated
Docs: RunicGateway/docs#191.
Co-Authored-By: Claude <noreply@anthropic.com>
602 lines
34 KiB
JavaScript
602 lines
34 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. The list
|
|
// never returns a hash to use instead.
|
|
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 } }),
|
|
|
|
// 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 }
|