Files
Module-uo/client/src/api.js
wtclaude dda0e32dd3
Some checks failed
PR Checks / client-build (pull_request) Successful in 16s
PR Checks / frozen-manifest (pull_request) Failing after 33s
PR Checks / server-tests (pull_request) Successful in 8m46s
feat(guilds): a guild detail page, and the slot core puts the feed in
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>
2026-08-17 20:58:30 -05:00

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