Phase 3: surface the new board data on existing character/user pages. - Character sheet: render the char.profile titles block (fame/karma + skill + selected reward title; numeric clilocs skipped since the site has no cliloc table yet), plus "Guildmaster" and "Governor of <city>" chips. - Char profile enrichment (player/admin /shard/char/:serial, one shared path): attach guild + governorOf from our own boards. Guild is LEADERSHIP-ONLY — it's verifiable from current board state, whereas guessing membership from stale guild.join events risks showing a wrong guild, so we return null instead. - Admin user detail (/admin/users/:id): new "Standing" section (governorships held + guilds led) via GET /users/:id/shard/standing; Houses rows now show the registry fields (decay level, placement price, co-owner/friend counts) already returned by listHousesForAccounts. Server 179/179, client build clean, swagger regenerated. Refs .plans/protocol2-integration.md (Phase 3). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
321 lines
16 KiB
JavaScript
321 lines
16 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'
|
|
|
|
class ApiError extends Error {
|
|
constructor(status, message, body) {
|
|
super(message)
|
|
this.status = status
|
|
this.body = body
|
|
}
|
|
}
|
|
|
|
async function req(path, { method = 'GET', body, headers, raw } = {}) {
|
|
const opts = { method, credentials: 'include', headers: { ...headers } }
|
|
if (body !== undefined) {
|
|
if (raw) {
|
|
opts.body = body // FormData — let the browser set the content-type
|
|
} else {
|
|
opts.headers['Content-Type'] = 'application/json'
|
|
opts.body = JSON.stringify(body)
|
|
}
|
|
}
|
|
const res = await fetch(BASE + path, opts)
|
|
const text = await res.text()
|
|
const data = text ? safeParse(text) : null
|
|
if (!res.ok) {
|
|
const message = (data && data.message) || res.statusText || 'Request failed'
|
|
throw new ApiError(res.status, message, data)
|
|
}
|
|
return data
|
|
}
|
|
|
|
function safeParse(text) {
|
|
try {
|
|
return JSON.parse(text)
|
|
} catch {
|
|
return text
|
|
}
|
|
}
|
|
|
|
export const api = {
|
|
// ----- auth -----
|
|
me: () => req('/auth/me'),
|
|
// `extra` carries the honeypot field (and any future login fields).
|
|
login: (username, password, extra = {}) =>
|
|
req('/auth/login', { method: 'POST', body: { username, password, ...extra } }),
|
|
// Public self-registration (player accounts). `extra` carries the honeypot +
|
|
// optional email. Returns { user } and sets the session cookie on success.
|
|
register: (username, password, extra = {}) =>
|
|
req('/auth/register', { method: 'POST', body: { username, password, ...extra } }),
|
|
loginTotp: (challenge, code) =>
|
|
req('/auth/login/totp', { method: 'POST', body: { challenge, code } }),
|
|
// Second factor for an SSO login (challenge is held in an httpOnly cookie set by
|
|
// the callback, so only the code is sent). Returns { user, returnTo }.
|
|
ssoLoginTotp: (code) => req('/auth/sso/totp', { method: 'POST', body: { code } }),
|
|
logout: () => req('/auth/logout', { method: 'POST' }),
|
|
// Public SSO provider discovery — drives the login-page provider buttons.
|
|
authProviders: () => req('/auth/providers'),
|
|
|
|
// ----- 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${s ? `?${s}` : ''}`)
|
|
},
|
|
wikiCategories: () => req('/public/wiki/categories'),
|
|
wikiTags: () => req('/public/wiki/tags'),
|
|
wikiPage: (slug) => req(`/public/wiki/${slug}`),
|
|
// CMS pages (block-based). Published-only for the public; a draft-preview link
|
|
// is fetched by id + token.
|
|
page: (slug) => req(`/public/pages/${slug}`),
|
|
pagePreview: (id, token) => req(`/public/pages/${id}/preview/${token}`),
|
|
contact: (payload) => req('/public/contact', { method: 'POST', body: payload }),
|
|
|
|
// ----- shard live data (uo-link) -----
|
|
// Token-free, same-origin reads backed by the ingested feed + a cached live
|
|
// character round-trip. shardStreamUrl is the SSE endpoint for useShardFeed.
|
|
shard: {
|
|
status: () => req('/public/shard/status'),
|
|
feed: (opts = {}) => {
|
|
const qs = new URLSearchParams()
|
|
if (opts.kind) qs.set('kind', opts.kind)
|
|
if (opts.limit) qs.set('limit', opts.limit)
|
|
const s = qs.toString()
|
|
return req(`/public/shard/feed${s ? `?${s}` : ''}`)
|
|
},
|
|
economy: (limit) => req(`/public/shard/economy${limit ? `?limit=${limit}` : ''}`),
|
|
online: () => req('/public/shard/online'),
|
|
idoc: () => req('/public/shard/idoc'),
|
|
champs: () => req('/public/shard/champs'),
|
|
// Protocol 2.0 boards.
|
|
guilds: () => req('/public/shard/guilds'),
|
|
governors: () => req('/public/shard/governors'),
|
|
governorHistory: (city, limit) =>
|
|
req(`/public/shard/governors/${encodeURIComponent(city)}/history${limit ? `?limit=${limit}` : ''}`),
|
|
presence: () => req('/public/shard/presence'),
|
|
houses: () => req('/public/shard/houses'),
|
|
},
|
|
// Full paths (incl. /api/v1) for the browser EventSource — the req() wrapper is
|
|
// fetch-only, so SSE subscribers build the URL from here. The admin stream
|
|
// carries every kind (incl. audit/cheat) and needs the staff session cookie.
|
|
shardStreamUrl: `${BASE}/public/shard/stream`,
|
|
adminShardStreamUrl: `${BASE}/admin/uo-link/stream`,
|
|
|
|
// ----- admin -----
|
|
admin: {
|
|
dashboard: () => req('/admin/dashboard'),
|
|
setSiteMode: (mode) => req('/admin/site-mode', { method: 'PUT', body: { mode } }),
|
|
listPosts: (category) => req(`/admin/posts${category ? `?category=${category}` : ''}`),
|
|
getPost: (id) => req(`/admin/posts/${id}`),
|
|
createPost: (data) => req('/admin/posts', { method: 'POST', body: data }),
|
|
updatePost: (id, data) => req(`/admin/posts/${id}`, { method: 'PUT', body: data }),
|
|
deletePost: (id) => req(`/admin/posts/${id}`, { method: 'DELETE' }),
|
|
publishPost: (id, published) =>
|
|
req(`/admin/posts/${id}/publish`, { method: 'PATCH', body: { published } }),
|
|
// News announcement pipeline (town crier + Discord) status + per-leg retry.
|
|
getAnnounce: (id) => req(`/admin/posts/${id}/announce`),
|
|
retryAnnounceLeg: (id, leg) =>
|
|
req(`/admin/posts/${id}/announce/retry`, { method: 'POST', body: { leg } }),
|
|
uploadImage: (file) => {
|
|
const fd = new FormData()
|
|
fd.append('image', file)
|
|
return req('/admin/posts/upload', { method: 'POST', body: fd, raw: true })
|
|
},
|
|
// Generalized upload for rich-text editors → { url }.
|
|
upload: (file) => {
|
|
const fd = new FormData()
|
|
fd.append('image', file)
|
|
return req('/admin/uploads', { method: 'POST', body: fd, raw: true })
|
|
},
|
|
// ----- CMS pages (block-based page builder) -----
|
|
listPages: () => req('/admin/pages'),
|
|
getPage: (id) => req(`/admin/pages/${id}`),
|
|
createPage: (data) => req('/admin/pages', { method: 'POST', body: data }),
|
|
updatePage: (id, data) => req(`/admin/pages/${id}`, { method: 'PATCH', body: data }),
|
|
deletePage: (id) => req(`/admin/pages/${id}`, { method: 'DELETE' }),
|
|
unprotectPage: (id, password) =>
|
|
req(`/admin/pages/${id}/unprotect`, { method: 'POST', body: { password } }),
|
|
createPagePreview: (id) => req(`/admin/pages/${id}/preview`, { method: 'POST' }),
|
|
listWiki: (params = '') => req(`/admin/wiki${params}`),
|
|
getWiki: (slug) => req(`/admin/wiki/${slug}`),
|
|
createWiki: (data) => req('/admin/wiki', { method: 'POST', body: data }),
|
|
updateWiki: (slug, data) => req(`/admin/wiki/${slug}`, { method: 'PUT', body: data }),
|
|
publishWiki: (slug, published) =>
|
|
req(`/admin/wiki/${slug}/publish`, { method: 'PATCH', body: { published } }),
|
|
deleteWiki: (slug) => req(`/admin/wiki/${slug}`, { method: 'DELETE' }),
|
|
listWikiRevisions: (slug) => req(`/admin/wiki/${slug}/revisions`),
|
|
getWikiRevision: (slug, id) => req(`/admin/wiki/${slug}/revisions/${id}`),
|
|
restoreWikiRevision: (slug, id) =>
|
|
req(`/admin/wiki/${slug}/revisions/${id}/restore`, { method: 'POST' }),
|
|
listWikiTags: () => req('/admin/wiki/tags'),
|
|
listWikiCategories: () => req('/admin/wiki/categories'),
|
|
createWikiCategory: (data) => req('/admin/wiki/categories', { method: 'POST', body: data }),
|
|
updateWikiCategory: (id, data) =>
|
|
req(`/admin/wiki/categories/${id}`, { method: 'PUT', body: data }),
|
|
deleteWikiCategory: (id) => req(`/admin/wiki/categories/${id}`, { method: 'DELETE' }),
|
|
getSettings: () => req('/admin/settings'),
|
|
updateSettings: (obj) => req('/admin/settings', { method: 'PUT', body: obj }),
|
|
activity: (limit = 50) => req(`/admin/activity?limit=${limit}`),
|
|
botActivity: () => req('/admin/bot-activity'),
|
|
unbanIp: (ip) => req('/admin/bot-activity/unban', { method: 'POST', body: { ip } }),
|
|
listUsers: () => req('/admin/users'),
|
|
getUser: (id) => req(`/admin/users/${id}`),
|
|
createUser: (data) => req('/admin/users', { method: 'POST', body: data }),
|
|
updateUser: (id, data) => req(`/admin/users/${id}`, { method: 'PUT', body: data }),
|
|
deleteUser: (id) => req(`/admin/users/${id}`, { method: 'DELETE' }),
|
|
// A single user's shard (uo-link) footprint, scoped to their linked accounts.
|
|
// accounts/sales/houses/online are user-scoped endpoints; roster/vendors/char
|
|
// reuse the admin-bypass /admin/shard/* endpoints (which already read any
|
|
// account) so the shared GameAccounts component works unchanged.
|
|
userShard: (id) => ({
|
|
accounts: () => req(`/admin/users/${id}/shard/accounts`),
|
|
roster: (account) => req(`/admin/shard/roster/${encodeURIComponent(account)}`),
|
|
vendors: (account) => req(`/admin/shard/vendors/${encodeURIComponent(account)}`),
|
|
char: (serial) => req(`/admin/shard/char/${encodeURIComponent(serial)}`),
|
|
sales: () => req(`/admin/users/${id}/shard/sales`),
|
|
houses: () => req(`/admin/users/${id}/shard/houses`),
|
|
online: () => req(`/admin/users/${id}/shard/online`),
|
|
standing: () => req(`/admin/users/${id}/shard/standing`),
|
|
}),
|
|
|
|
// ----- moderation dashboard (admin + moderator) -----
|
|
modSummary: () => req('/admin/moderation/stats/summary'),
|
|
modRecent: (params = {}) => {
|
|
const qs = new URLSearchParams()
|
|
if (params.type) qs.set('type', params.type)
|
|
if (params.limit) qs.set('limit', params.limit)
|
|
if (params.offset) qs.set('offset', params.offset)
|
|
const s = qs.toString()
|
|
return req(`/admin/moderation/recent${s ? `?${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${s ? `?${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${s ? `?${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${s ? `?${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${s ? `?${s}` : ''}`)
|
|
},
|
|
modUserNotes: (discordId) => req(`/admin/moderation/user/${discordId}/notes`),
|
|
addModNote: (discordId, data) =>
|
|
req(`/admin/moderation/user/${discordId}/notes`, { method: 'POST', body: data }),
|
|
|
|
// ----- account security (self-service 2FA) -----
|
|
getAccount: () => req('/admin/account'),
|
|
totpSetup: () => req('/admin/account/totp/setup', { method: 'POST' }),
|
|
totpEnable: (code) => req('/admin/account/totp/enable', { method: 'POST', body: { code } }),
|
|
totpDisable: (code) => req('/admin/account/totp/disable', { method: 'POST', body: { code } }),
|
|
|
|
// ----- linked SSO identities (self-service) -----
|
|
linkedIdentities: () => req('/admin/account/identities'),
|
|
unlinkIdentity: (provider) => req(`/admin/account/identities/${provider}`, { method: 'DELETE' }),
|
|
|
|
// ----- game account linking (self-service, staff) -----
|
|
shard: {
|
|
link: (code) => req('/admin/shard/link', { method: 'POST', body: { code } }),
|
|
accounts: () => req('/admin/shard/accounts'),
|
|
roster: (account) => req(`/admin/shard/roster/${encodeURIComponent(account)}`),
|
|
vendors: (account) => req(`/admin/shard/vendors/${encodeURIComponent(account)}`),
|
|
char: (serial) => req(`/admin/shard/char/${encodeURIComponent(serial)}`),
|
|
sales: () => req('/admin/shard/sales'),
|
|
},
|
|
|
|
// ----- auth providers / SSO config (admin only) -----
|
|
listAuthProviders: () => req('/admin/auth/providers'),
|
|
createAuthProvider: (data) => req('/admin/auth/providers', { method: 'POST', body: data }),
|
|
updateAuthProvider: (id, data) => req(`/admin/auth/providers/${id}`, { method: 'PUT', body: data }),
|
|
deleteAuthProvider: (id) => req(`/admin/auth/providers/${id}`, { method: 'DELETE' }),
|
|
|
|
// ----- Discord bot control (admin only) -----
|
|
getDiscordBotConfig: () => req('/admin/discord-bot/config'),
|
|
saveDiscordBotConfig: (data) => req('/admin/discord-bot/config', { method: 'PUT', body: data }),
|
|
|
|
// ----- uo-link sidecar control (admin only) -----
|
|
getUoLinkConfig: () => req('/admin/uo-link/config'),
|
|
saveUoLinkConfig: (data) => req('/admin/uo-link/config', { method: 'PUT', body: data }),
|
|
postTownCrier: (data) => req('/admin/uo-link/towncrier', { method: 'POST', body: data }),
|
|
deleteTownCrier: (id) => req(`/admin/uo-link/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
|
|
|
// ----- in-game staff operations: write plane + support queue (admin/moderator) -----
|
|
// `actor` is stamped server-side from the session — never sent from here.
|
|
shardOps: {
|
|
kick: (data) => req('/admin/shard/kick', { method: 'POST', body: data }),
|
|
ban: (data) => req('/admin/shard/ban', { method: 'POST', body: data }),
|
|
unban: (account) => req('/admin/shard/unban', { method: 'POST', body: { account } }),
|
|
broadcast: (data) => req('/admin/shard/broadcast', { method: 'POST', body: data }),
|
|
pages: () => req('/admin/shard/pages'),
|
|
respondPage: (id, data) =>
|
|
req(`/admin/shard/pages/${encodeURIComponent(id)}/respond`, { method: 'POST', body: data }),
|
|
closePage: (id) => req(`/admin/shard/pages/${encodeURIComponent(id)}/close`, { method: 'POST' }),
|
|
audit: (limit) => req(`/admin/shard/audit${limit ? `?limit=${limit}` : ''}`),
|
|
},
|
|
|
|
// ----- Email delivery / Gmail OAuth2 (admin only) -----
|
|
getEmailConfig: () => req('/admin/email/config'),
|
|
saveEmailConfig: (data) => req('/admin/email/config', { method: 'PUT', body: data }),
|
|
emailConnectUrl: () => req('/admin/email/connect/start'),
|
|
testEmail: (to) => req('/admin/email/test', { method: 'POST', body: { to } }),
|
|
disconnectEmail: () => req('/admin/email/disconnect', { method: 'POST' }),
|
|
},
|
|
|
|
// ----- player self-service (role: 'player') -----
|
|
// Mirrors the admin account methods but self-scoped under /player. The change
|
|
// endpoints re-issue the session cookie server-side, so the caller stays signed in.
|
|
player: {
|
|
getAccount: () => req('/player/account'),
|
|
changeUsername: (username) =>
|
|
req('/player/account/username', { method: 'PATCH', body: { username } }),
|
|
changePassword: (newPassword, currentPassword) =>
|
|
req('/player/account/password', { method: 'PATCH', body: { newPassword, currentPassword } }),
|
|
totpSetup: () => req('/player/account/totp/setup', { method: 'POST' }),
|
|
totpEnable: (code) => req('/player/account/totp/enable', { method: 'POST', body: { code } }),
|
|
totpDisable: (code) => req('/player/account/totp/disable', { method: 'POST', body: { code } }),
|
|
linkedIdentities: () => req('/player/account/identities'),
|
|
unlinkIdentity: (provider) => req(`/player/account/identities/${provider}`, { method: 'DELETE' }),
|
|
|
|
// ----- game account linking (uo-link) -----
|
|
shard: {
|
|
link: (code) => req('/player/shard/link', { method: 'POST', body: { code } }),
|
|
accounts: () => req('/player/shard/accounts'),
|
|
roster: (account) => req(`/player/shard/roster/${encodeURIComponent(account)}`),
|
|
vendors: (account) => req(`/player/shard/vendors/${encodeURIComponent(account)}`),
|
|
char: (serial) => req(`/player/shard/char/${encodeURIComponent(serial)}`),
|
|
sales: () => req('/player/shard/sales'),
|
|
},
|
|
},
|
|
}
|
|
|
|
export { ApiError }
|