The module's half of the org lead's correction: Teams is the contract, guilds are the presentation, and the presentation is this module's. Adds `/uo/guilds/:id` — the detail view the board never had — with the roster from this module's OWN board, which is the same data it answers core's Team provider from. Reading core's projection of our own answer back would be a round trip through a staler copy of it. The page declares `uo.guild.detail` and core fills it with the Team activity feed. That is the one part of this page core cannot hand over: only core can resolve whether the viewer is inside the Team, and the public/members split on that feed is a security boundary. The guild is named in OUR terms — core maps its own Team from the module id and the external id — so this module never holds core's row id or slug. `TeamOverviewStrip` is deleted with the core Team page it filled. `team.member.row` is not declared here either: the useful thing to put in a roster row is a link to the character behind it, and nothing core could supply identifies one. `GET /public/shard/guilds/:id` backs the page, gated and projected through the same `guilds` feature as the board — so an operator who raises that audience raises this too, and the locked acct/webId fields never survive below admin. A roster is where those appear in bulk, which makes this the endpoint where getting the projection wrong would matter most. Co-Authored-By: Claude <noreply@anthropic.com>
233 lines
13 KiB
JavaScript
233 lines
13 KiB
JavaScript
// ── This module's own API bindings ─────────────────────────────────────────
|
|
//
|
|
// Core hands out the request PRIMITIVE and nothing above it (MODULE_API.md
|
|
// §3.5): same-origin `/api/v1`, cookies included, JSON in and out, `ApiError` on
|
|
// a non-2xx. The paths are ours, because the routes at the other end are ours —
|
|
// `server/router/**` in this repo serves every one of them.
|
|
//
|
|
// This file is the client half of the pair that moved in slice 1, and the two
|
|
// halves are checked against each other by nothing but review, so the ordering
|
|
// below mirrors the router tree deliberately: public, then admin, then player.
|
|
//
|
|
// **The URLs are unchanged from the ones core used to call.** MODULE_SYSTEM.md
|
|
// §1.2 freezes the API surface across the extraction — the shipped Android app
|
|
// calls `/api/v1/admin/shard/kick` and six of its neighbours — so what moved is
|
|
// which repo declares them, never what they are. Only the SPA route paths
|
|
// changed (`/uo/*`, `/admin/uo/*`, `/player/uo/*`), and those are not API URLs.
|
|
|
|
import rg from './core.js'
|
|
|
|
const { request: req, BASE } = rg.api
|
|
|
|
/** Prefix a non-empty query string with "?" — core's `withQs`, which is not in the kit. */
|
|
const withQs = (s) => (s ? `?${s}` : '')
|
|
|
|
// ── public: live shard data (uo-link) ──────────────────────────────────────
|
|
// Token-free, same-origin reads backed by the ingested feed plus a cached live
|
|
// character round-trip.
|
|
export const 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)
|
|
return req(`/public/shard/feed${withQs(qs.toString())}`)
|
|
},
|
|
economy: (limit) => req(`/public/shard/economy${withQs(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'),
|
|
guild: (id) => req(`/public/shard/guilds/${encodeURIComponent(id)}`),
|
|
governors: () => req('/public/shard/governors'),
|
|
governorHistory: (city, limit) =>
|
|
req(`/public/shard/governors/${encodeURIComponent(city)}/history${withQs(limit ? `limit=${limit}` : '')}`),
|
|
presence: () => req('/public/shard/presence'),
|
|
houses: () => req('/public/shard/houses'),
|
|
// Protocol 3.0: the shard's published ruleset. Resolves to null when the shard
|
|
// has never published one — a real answer, not an error.
|
|
ruleset: () => req('/public/shard/ruleset'),
|
|
// Protocol 3.0: points/loyalty leaderboards, one board per point system.
|
|
// `pointsBoard` 404s for a system the shard has never published.
|
|
points: () => req('/public/shard/points'),
|
|
pointsBoard: (system) => req(`/public/shard/points/${encodeURIComponent(system)}`),
|
|
// Protocol 3.0: the player-vendor marketplace. Rate-limited server-side, so
|
|
// the page debounces its search box rather than firing per keystroke.
|
|
market: (opts = {}) => {
|
|
const qs = new URLSearchParams()
|
|
if (opts.q) qs.set('q', opts.q)
|
|
if (opts.minPrice != null && opts.minPrice !== '') qs.set('minPrice', opts.minPrice)
|
|
if (opts.maxPrice != null && opts.maxPrice !== '') qs.set('maxPrice', opts.maxPrice)
|
|
if (opts.itemId != null && opts.itemId !== '') qs.set('itemId', opts.itemId)
|
|
if (opts.map) qs.set('map', opts.map)
|
|
if (opts.region) qs.set('region', opts.region)
|
|
if (opts.sort) qs.set('sort', opts.sort)
|
|
if (opts.limit) qs.set('limit', opts.limit)
|
|
if (opts.offset) qs.set('offset', opts.offset)
|
|
return req(`/public/shard/market${withQs(qs.toString())}`)
|
|
},
|
|
marketMeta: () => req('/public/shard/market/meta'),
|
|
marketVendor: (serial, opts = {}) => {
|
|
const qs = new URLSearchParams()
|
|
if (opts.limit) qs.set('limit', opts.limit)
|
|
if (opts.offset) qs.set('offset', opts.offset)
|
|
return req(`/public/shard/market/vendors/${encodeURIComponent(serial)}${withQs(qs.toString())}`)
|
|
},
|
|
// Which shard surfaces this caller may reach, plus the audience rung they
|
|
// resolved to. Drives nav so we never render a link that would 403 — and, as
|
|
// of slice 3, also carries `gameAccountSignup`: whether this site offers
|
|
// game-account creation at all (see server/router/public/shard.controller.js).
|
|
features: () => req('/public/shard/features'),
|
|
}
|
|
|
|
// ── public: the spawn atlas (Protocol 3.0 Part C) ──────────────────────────
|
|
// Static shard CONTENT, parsed from the shard's own ServUO tree — deliberately
|
|
// not under /shard, because nothing here depends on the sidecar and the pages
|
|
// stay populated while the shard is offline.
|
|
export const atlas = {
|
|
creatures: (opts = {}) => {
|
|
const qs = new URLSearchParams()
|
|
if (opts.q) qs.set('q', opts.q)
|
|
if (opts.facet) qs.set('facet', opts.facet)
|
|
if (opts.limit) qs.set('limit', opts.limit)
|
|
if (opts.offset) qs.set('offset', opts.offset)
|
|
return req(`/public/atlas/creatures${withQs(qs.toString())}`)
|
|
},
|
|
creature: (slug, opts = {}) => {
|
|
const qs = new URLSearchParams()
|
|
if (opts.facet) qs.set('facet', opts.facet)
|
|
if (opts.points) qs.set('points', opts.points)
|
|
return req(`/public/atlas/creatures/${encodeURIComponent(slug)}${withQs(qs.toString())}`)
|
|
},
|
|
regions: (opts = {}) => {
|
|
const qs = new URLSearchParams()
|
|
if (opts.facet) qs.set('facet', opts.facet)
|
|
if (opts.q) qs.set('q', opts.q)
|
|
return req(`/public/atlas/regions${withQs(qs.toString())}`)
|
|
},
|
|
landmarks: (opts = {}) => {
|
|
const qs = new URLSearchParams()
|
|
if (opts.facet) qs.set('facet', opts.facet)
|
|
if (opts.q) qs.set('q', opts.q)
|
|
return req(`/public/atlas/landmarks${withQs(qs.toString())}`)
|
|
},
|
|
// The CONFIGURED altar roster, not the live board — see `shard.champs()` for
|
|
// "which spawn is on level 3 right now".
|
|
champions: (facet) =>
|
|
req(`/public/atlas/champions${withQs(facet ? `facet=${encodeURIComponent(facet)}` : '')}`),
|
|
meta: () => req('/public/atlas/meta'),
|
|
}
|
|
|
|
// ── admin ──────────────────────────────────────────────────────────────────
|
|
export const admin = {
|
|
// The account/character/house reads a staff member makes across the whole shard.
|
|
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'),
|
|
houses: () => req('/admin/shard/houses'), // full registry (admin/moderator)
|
|
createAccount: (account, password) =>
|
|
req('/admin/shard/account', { method: 'POST', body: { account, password } }),
|
|
},
|
|
|
|
// The sidecar's own configuration and the town crier it drives.
|
|
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' }),
|
|
|
|
// Whether this site offers game-account creation, and in which direction.
|
|
// Core's Site Settings used to carry this; it is ours as of slice 3, because
|
|
// "the game server's own SignupMode must agree" is not a sentence core can own.
|
|
getSignupMode: () => req('/admin/uo-link/signup-mode'),
|
|
saveSignupMode: (mode) => req('/admin/uo-link/signup-mode', { method: 'PUT', body: { mode } }),
|
|
|
|
// Per-feature shard visibility: who may see which shard surface, and which
|
|
// sensitive fields within it. Admin only — it decides what ANONYMOUS visitors
|
|
// get. acct/webId are admin-only always and the API rejects any attempt to
|
|
// configure them.
|
|
getShardVisibility: () => req('/admin/shard/visibility'),
|
|
saveShardVisibility: (features) => req('/admin/shard/visibility', { method: 'PUT', body: { features } }),
|
|
|
|
// The atlas re-derives itself from the ServUO tree on every boot; these are for
|
|
// applying a map change without a restart, and for the approve/reject decision
|
|
// on a refresh that would remove a facet.
|
|
atlas: {
|
|
status: () => req('/admin/shard/atlas'),
|
|
import: (force = false) => req('/admin/shard/atlas/import', { method: 'POST', body: { force } }),
|
|
approve: () => req('/admin/shard/atlas/approve', { method: 'POST', body: {} }),
|
|
reject: () => req('/admin/shard/atlas/reject', { method: 'POST', body: {} }),
|
|
setPath: (path) => req('/admin/shard/atlas/path', { method: 'PUT', body: { path } }),
|
|
},
|
|
|
|
// 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${withQs(limit ? `limit=${limit}` : '')}`),
|
|
},
|
|
|
|
/**
|
|
* One user's shard presence, for the `admin.users.detail` extension slot.
|
|
*
|
|
* A factory rather than a flat namespace because every call is scoped to the
|
|
* user whose page this is. The three that are NOT — roster, vendors, char —
|
|
* are keyed by an account or a serial the scoped calls just returned, and they
|
|
* are the same routes `admin.shard` uses; they are repeated here so the slot's
|
|
* components take one `scope` object and never reach for a second one.
|
|
*/
|
|
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`),
|
|
unlink: (account) => req(`/admin/users/${id}/shard/link/${encodeURIComponent(account)}`, { method: 'DELETE' }),
|
|
}),
|
|
}
|
|
|
|
// ── player self-service ────────────────────────────────────────────────────
|
|
// Mirrors `admin.shard`, self-scoped: the server derives the caller from the
|
|
// session and never takes an account id from the client.
|
|
export const player = {
|
|
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'),
|
|
houses: () => req('/player/shard/houses'), // the caller's own houses
|
|
createAccount: (account, password) =>
|
|
req('/player/shard/account', { method: 'POST', body: { account, password } }),
|
|
},
|
|
}
|
|
|
|
// ── SSE endpoints ──────────────────────────────────────────────────────────
|
|
// Full paths including `/api/v1`, because `request` is fetch-only and an
|
|
// EventSource builds its own URL. `BASE` is core's — it owns where the API is
|
|
// mounted, and a module hardcoding `/api/v1` would be asserting something about
|
|
// core that core has not promised (MODULE_API.md §3.5).
|
|
//
|
|
// The admin stream carries every kind, including audit and cheat detection, and
|
|
// needs the staff session cookie.
|
|
export const shardStreamUrl = `${BASE}/public/shard/stream`
|
|
export const adminShardStreamUrl = `${BASE}/admin/uo-link/stream`
|
|
|
|
export const api = { shard, atlas, admin, player, shardStreamUrl, adminShardStreamUrl }
|
|
|
|
export default api
|