Protocol 3.0 §5 (docs/link/v3.md). The shard publishes its own ruleset —
expansion, which optional systems are on, skill/stat caps, account and house
limits, champion scroll rules, the save/restart schedule — and the site renders
it, so the rules page cannot drift from how the shard actually plays.
Server
- shard_ruleset: a singleton table (id = 1) holding the whole frame in
`payload`, with `rev` and `expansion` hoisted. Nothing is normalized out:
the frame is a flat description of config read as one page, and splitting it
into columns would mean a schema change every time the shard grows a block.
- shardIngest routes world.ruleset to setRuleset and deliberately does NOT
log it — the shard re-emits the whole ruleset on every sidecar connect, so
logging would append a duplicate row per reconnect, and server.hello already
marks each of those.
- uoLinkSocket backfills GET /ruleset explicitly rather than via snapshot(),
which asserts an array; this covers the order where the sidecar was already
up and holding the ruleset when we reconnected.
- GET /public/shard/ruleset behind requireFeature('ruleset') and projected,
per §3.6.1's rule that a shard read which doesn't project is a bug. `null`
means the shard has never published one — a real answer, distinct from a
published ruleset, and the page says so.
Client
- routes/public/Rules.jsx at /site/rules, live via world.ruleset (a frame is a
complete ruleset, not a delta, so the newest one wins outright). Caps are
rendered from tenths — 7000 is 700.0, and showing the raw number would
mislead. A systems key this build doesn't know still renders, humanised, so
a newer plugin can't go invisible against an older client.
- Nav entry gated on the `ruleset` feature, so it hides rather than 403s.
Verified end to end against the local MariaDB and a sidecar fed by a fake shard:
backfill snapshot, live SSE delivery of a changed ruleset, REST reflecting the
overwrite, an empty /feed (not logged), and the gate — 200 by default, 403 at
audience=staff (and dropped from /features so nav hides it), 404 when disabled.
Page rendered clean at all breakpoints checked, no console errors.
497 server tests pass; routes.manifest.json, routes.guards.json and the OpenAPI
spec regenerated.
Co-Authored-By: Claude <noreply@anthropic.com>
167 lines
11 KiB
JavaScript
167 lines
11 KiB
JavaScript
// Public · Shard — token-free, same-origin reads of the live shard. The
|
|
// status/feed/economy/idoc/champs/guilds/governors/presence/houses endpoints read
|
|
// the site's own ingested data; nothing here round-trips the sidecar per request.
|
|
//
|
|
// Mounted at /api/v1/public/shard by public/index.js. Deliberately NOT site-mode
|
|
// gated — shard status is useful (and wanted) while the site itself is in
|
|
// maintenance.
|
|
//
|
|
// **GET /shard/stream stays anonymous.** It is consumed by logged-out browser
|
|
// visitors *and* by the Android ShardStreamClient, neither of which sends an
|
|
// Authorization header; adding requireAuth here blacks out the public live boards
|
|
// on web and mobile. The sensitive kinds (staff audit, cheat detection, login
|
|
// attempts, IPs) are withheld by utils/shardBroadcast.js, not by a route gate —
|
|
// that per-frame filtering is the security boundary, not this file. /stream is
|
|
// deliberately NOT wrapped in requireFeature either: it spans every feature, and
|
|
// each frame is gated individually against the subscriber's rung.
|
|
//
|
|
// Every other route carries `requireFeature(<name>)` (utils/shardVisibility.js),
|
|
// which 404s when an admin has disabled the feature and 403s when the caller sits
|
|
// below its configured audience. Defaults reproduce pre-v3 behavior exactly, so
|
|
// these gates are inert until an admin changes something.
|
|
|
|
const express = require('express')
|
|
const { param, query } = require('express-validator')
|
|
|
|
const shard = require('./shard.controller')
|
|
const validate = require('../../../middleware/validate')
|
|
const { requireFeature } = require('../../../utils/shardVisibility')
|
|
|
|
const shardRouter = express.Router()
|
|
|
|
shardRouter.get(
|
|
'/status',
|
|
requireFeature('status'),
|
|
// #swagger.tags = ['Public · Shard']
|
|
// #swagger.summary = 'Shard connection state, online count and latest economy'
|
|
/* #swagger.responses[200] = { description: 'Shard status', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardStatus" } } } } */
|
|
shard.getStatus,
|
|
)
|
|
shardRouter.get(
|
|
'/feed',
|
|
requireFeature('activity'),
|
|
// #swagger.tags = ['Public · Shard']
|
|
// #swagger.summary = 'Recent notable shard events (from the ingested log)'
|
|
// #swagger.description = 'The stored-history twin of /shard/stream, and it reaches the same verdict: which kinds are returned is resolved against the caller\'s audience rung under the live visibility config, and each event\'s payload is field-projected against its own kind\'s feature. Kinds the caller may not read are omitted (an explicit ?kind= for one of them returns []), and acct/webId never appear below admin.'
|
|
// #swagger.parameters['kind'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Filter to a single event kind, e.g. vendor.sale. Returns [] if the caller may not read that kind.' }
|
|
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Max rows (default 100, max 1000).' }
|
|
/* #swagger.responses[200] = { description: 'Events, newest first', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardEvent" } } } } } */
|
|
query('kind').optional({ values: 'falsy' }).isString().isLength({ max: 48 }),
|
|
query('limit').optional().isInt({ min: 1, max: 1000 }),
|
|
validate,
|
|
shard.getFeed,
|
|
)
|
|
shardRouter.get(
|
|
'/economy',
|
|
requireFeature('status'),
|
|
// #swagger.tags = ['Public · Shard']
|
|
// #swagger.summary = 'Gold-supply time series (oldest → newest)'
|
|
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Max samples (default 100, max 1000).' }
|
|
/* #swagger.responses[200] = { description: 'Economy samples', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardEconomyPoint" } } } } } */
|
|
query('limit').optional().isInt({ min: 1, max: 1000 }),
|
|
validate,
|
|
shard.getEconomy,
|
|
)
|
|
shardRouter.get(
|
|
'/online',
|
|
requireFeature('presence'),
|
|
// #swagger.tags = ['Public · Shard']
|
|
// #swagger.summary = 'Staff online now (linked staff accounts; location is admin/moderator-only)'
|
|
/* #swagger.responses[200] = { description: 'Online players', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardOnlinePlayer" } } } } } */
|
|
shard.getOnline,
|
|
)
|
|
shardRouter.get(
|
|
'/idoc',
|
|
requireFeature('houses'),
|
|
// #swagger.tags = ['Public · Shard']
|
|
// #swagger.summary = 'Houses currently in danger (IDOC)'
|
|
// #swagger.description = 'Location-level board of the houses about to collapse. Owner identity and price are gated by the `houses` feature\'s field rules (default `staff`), and the owner\'s game account is admin-only always — so an anonymous caller sees name, region and coordinates only.'
|
|
/* #swagger.responses[200] = { description: 'IDOC houses', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */
|
|
shard.getIdoc,
|
|
)
|
|
shardRouter.get(
|
|
'/champs',
|
|
requireFeature('champs'),
|
|
// #swagger.tags = ['Public · Shard']
|
|
// #swagger.summary = 'Current champion-spawn board (all categories)'
|
|
// #swagger.description = 'The live board of every champion / mini-champ / sea-boss spawn. Update in place via the champ.update / champ.remove frames on /shard/stream.'
|
|
/* #swagger.responses[200] = { description: 'Champion spawns, ordered by name', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
|
shard.getChamps,
|
|
)
|
|
shardRouter.get(
|
|
'/guilds',
|
|
requireFeature('guilds'),
|
|
// #swagger.tags = ['Public · Shard']
|
|
// #swagger.summary = 'Current guild board (rosters, alliances, leaders)'
|
|
// #swagger.description = 'The live board of every guild. Update in place via the guild.update / guild.remove / guild.join frames on /shard/stream.'
|
|
/* #swagger.responses[200] = { description: 'Guilds, ordered by name', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
|
shard.getGuilds,
|
|
)
|
|
shardRouter.get(
|
|
'/governors',
|
|
requireFeature('governors'),
|
|
// #swagger.tags = ['Public · Shard']
|
|
// #swagger.summary = 'Current town-governor board (City Loyalty)'
|
|
// #swagger.description = 'One entry per city with its governor and election phase. Empty if the shard does not run the City Loyalty system. Live via city.update on /shard/stream.'
|
|
/* #swagger.responses[200] = { description: 'Cities, ordered by name', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
|
shard.getGovernors,
|
|
)
|
|
shardRouter.get(
|
|
'/governors/:city/history',
|
|
requireFeature('governors'),
|
|
// #swagger.tags = ['Public · Shard']
|
|
// #swagger.summary = 'Governor term history for a city'
|
|
// #swagger.parameters['city'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'City name, e.g. Britain.' }
|
|
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Max terms (default 100, max 500).' }
|
|
/* #swagger.responses[200] = { description: 'Terms, newest first', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
|
param('city').isString().isLength({ min: 1, max: 40 }),
|
|
query('limit').optional().isInt({ min: 1, max: 500 }),
|
|
validate,
|
|
shard.getGovernorHistory,
|
|
)
|
|
shardRouter.get(
|
|
'/presence',
|
|
requireFeature('presence'),
|
|
// #swagger.tags = ['Public · Shard']
|
|
// #swagger.summary = 'Online population aggregate (count + per-facet + per-region)'
|
|
// #swagger.description = 'The latest presence.online snapshot powering the "Players Online" widget. Live via presence.online on /shard/stream.'
|
|
/* #swagger.responses[200] = { description: 'Population snapshot', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
|
shard.getPresence,
|
|
)
|
|
shardRouter.get(
|
|
'/houses',
|
|
requireFeature('houses'),
|
|
// #swagger.tags = ['Public · Shard']
|
|
// #swagger.summary = 'House registry (owner, co-owners, price, decay)'
|
|
// #swagger.description = 'Every house seen via the house.update registry feed. `price` is the placement value, not a for-sale flag. Live via house.update / house.remove on /shard/stream.'
|
|
/* #swagger.responses[200] = { description: 'Houses, ordered by name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */
|
|
shard.getHouses,
|
|
)
|
|
shardRouter.get(
|
|
'/ruleset',
|
|
requireFeature('ruleset'),
|
|
// #swagger.tags = ['Public · Shard']
|
|
// #swagger.summary = 'The shard\'s published ruleset (expansion, systems, caps, limits)'
|
|
// #swagger.description = 'How this shard is actually configured, published by the shard itself as one world.ruleset frame: expansion, which optional systems are on, skill/stat caps, account and house limits, champion scroll rules and the save/restart schedule. Served from our own store, so it renders while the shard is down; live via world.ruleset on /shard/stream. Returns `null` if the shard has never published one (an older plugin, or Bridge.RulesetEnabled=false) — distinct from a published ruleset, and the page renders it differently.'
|
|
/* #swagger.responses[200] = { description: 'The ruleset, or null if never published', content: { "application/json": { schema: { type: "object", nullable: true, additionalProperties: true } } } } */
|
|
shard.getRuleset,
|
|
)
|
|
shardRouter.get(
|
|
'/features',
|
|
// #swagger.tags = ['Public · Shard']
|
|
// #swagger.summary = 'Shard features visible to the caller (drives client nav)'
|
|
// #swagger.description = 'The caller\'s audience rung plus the shard features they may reach, so a client can hide nav entries instead of rendering links that 403. Reports only what the caller can see — the list itself does not disclose gated features.'
|
|
/* #swagger.responses[200] = { description: 'Visible features', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardFeatures" } } } } */
|
|
shard.getFeatures,
|
|
)
|
|
shardRouter.get(
|
|
'/stream',
|
|
// #swagger.tags = ['Public · Shard']
|
|
// #swagger.summary = 'Live shard event stream (Server-Sent Events, filtered by audience)'
|
|
// #swagger.description = 'text/event-stream of live events. The caller\'s audience rung is resolved once at subscribe time and frozen for the connection; each frame is then gated on its feature and field-projected, so sensitive kinds and fields (staff audit, cheat detection, login attempts, IPs, acct/webId) never reach a caller below their configured rung.'
|
|
/* #swagger.responses[200] = { description: 'An SSE stream (Content-Type: text/event-stream).' } */
|
|
shard.stream,
|
|
)
|
|
|
|
module.exports = shardRouter
|