Some checks failed
PR checks / checks (pull_request) Failing after 2m16s
The public demo (demo.runicgateway.com) is live, so the site's demo slot can be switched on. Two decisions of record, PLAN.md §15: - D82: /beta reads the demo's GET /api/v1/public/settings demo.logins on the server, caches it for a minute, and lists the published sign-ins with the next wipe. If the demo does not answer, /beta links to its sign-in page instead. Gate two reads as in place. - D83: a deep link into a game module's pages carries data-demo-game, and applyBrand.mjs fills it only for games in the new mounted demoGames list. The Rust features gain links into /rust and /rust/servers/demo. UO's stay hidden on a Rust demo. The public-demo not-built entry is removed, because its resolvedBy was met. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
463 lines
20 KiB
JavaScript
463 lines
20 KiB
JavaScript
/**
|
||
* capabilities.mjs — the five capability groups of PLAN.md §10, as data.
|
||
*
|
||
* ---------------------------------------------------------------------------------------
|
||
* WHY THIS IS DATA AND NOT MARKUP
|
||
* ---------------------------------------------------------------------------------------
|
||
* The homepage names these groups, `/features/` expands them, and `/modules/` explains the
|
||
* core/module split they encode. Three pages listing the same capabilities in three
|
||
* hand-maintained lists is how a site ends up advertising something that was removed, which
|
||
* §1 forbids. One list, read by all three.
|
||
*
|
||
* Phase 4 added the `detail` line rather than writing `/features/` as prose (D20). The two
|
||
* pages are then one list rendered twice — `/` takes the label, `/features/` takes the
|
||
* label and the detail — and they cannot disagree about what exists, only about how much
|
||
* they say. `assertDetailCoverage()` below is what stops the next capability being added to
|
||
* the homepage without an argument to go with it.
|
||
*
|
||
* ---------------------------------------------------------------------------------------
|
||
* THE ONE GROUP CORE DOES NOT SUPPLY, IN CORE'S WORDS
|
||
* ---------------------------------------------------------------------------------------
|
||
* "What a game module adds" is the only group core does not supply — it comes from
|
||
* whichever module is installed. Until phase 17 it was "Game intelligence" and listed the
|
||
* Ultima Online module's features in that game's words, on the homepage and `/features/`.
|
||
* Two modules exist now, and D63 keeps a game's vocabulary on its own pages, so the group
|
||
* says what BOTH modules add, in core's words, and each game's real list lives in
|
||
* `src/data/games/` and on its page under `/modules/` (D75).
|
||
*
|
||
* The capability check moved with the lists: `assertGameCoverage()` in `games/index.mjs`
|
||
* holds each game's features to the capabilities its module declares, in both directions.
|
||
*
|
||
* ---------------------------------------------------------------------------------------
|
||
* `needsModule` — THE THIRD STATE, WHICH PHASE 3 DID NOT HAVE
|
||
* ---------------------------------------------------------------------------------------
|
||
* A group is `moduleSupplied` or it is not, and phase 3 shipped the Community group saying
|
||
* "everything here works on a deployment with no game module installed at all". Writing the
|
||
* `/features/` detail for Teams is what exposed that as false, and the tree says so plainly
|
||
* on `main`: `teams.module_id` is `NOT NULL`, there is no create route anywhere under
|
||
* `/api/v1/admin/teams`, and `teamSync` is gated on `teamProvider.providerModuleId()`.
|
||
*
|
||
* The truth is neither of the two states the file had. Core owns the whole Team machinery —
|
||
* the tables, the roster resolver, the forums, the notification streams, the Discord bridge,
|
||
* the voice channels, the activity feed and `/admin/teams` — and cannot *originate* a Team.
|
||
* They arrive from the installed module, which is exactly the point: core does not own the
|
||
* word for a Team, so each module calls them what its game calls them and builds the pages
|
||
* on the same primitive.
|
||
*
|
||
* So `needsModule` marks an item that is core machinery a module has to populate. On a bare
|
||
* core it is present, correct and permanently empty. `/` renders the requalified group
|
||
* summary; `/features/` renders the marker and says why (D24).
|
||
*
|
||
* ---------------------------------------------------------------------------------------
|
||
* `demoPath` — DEEP LINKS THAT ONLY EXIST WHEN A DEMO DOES
|
||
* ---------------------------------------------------------------------------------------
|
||
* §15/D12 kept the public demo out of scope while requiring the site to gain one by way of
|
||
* a line in a mounted `brand.json`. `brand.json`'s own comment promised `/features/` a
|
||
* "per-capability affordance" that had never been defined; D25 defines it as a deep link
|
||
* per capability that has a stable public route, appended to the mounted `demoUrl` by
|
||
* `applyBrand.mjs` at boot and hidden by the same `[data-demo-url='']` rule as the
|
||
* homepage's slot.
|
||
*
|
||
* Only some capabilities have one, and that asymmetry is honest rather than unfinished:
|
||
* character sheets are reachable only by the account they belong to, and a Team forum lives
|
||
* behind a Team id no static page can know. Paths are read from the real route tables —
|
||
* core's `client/src/App.jsx` and `module-uo`'s `client/src/entry.jsx` on `main` — never
|
||
* guessed. A module's public pages are namespaced under its own id, so a route into the
|
||
* Ultima Online module is `/uo/…`. A link into a module's pages names that game
|
||
* (`demoGame` here, the `game` prop of `GameFeatures` for the lists in `games/`), and it
|
||
* shows only when the mounted `demoGames` lists it (D83): the demo that exists runs Rust,
|
||
* so the Teams item's `/uo/guilds` stays hidden there and Rust's clans link stands in.
|
||
*/
|
||
|
||
import { games } from './games/index.mjs';
|
||
|
||
/**
|
||
* The shape of a capability, written out because TypeScript otherwise infers it per group
|
||
* from whichever fields that group's items happen to use — and then `/features/` cannot
|
||
* read `demoPath` off an Administration item, because no Administration item has one.
|
||
* `astro check` catches that, correctly: the union of five literal shapes is not the shape
|
||
* the page is written against.
|
||
*
|
||
* @typedef {object} Capability
|
||
* @property {string} label What it is called, on every page that lists it.
|
||
* @property {string} detail The argument for it. `/features/` only; see D20.
|
||
* @property {string[]} [caps] Module capability slugs, on a game's own items (`games/`) only.
|
||
* @property {boolean} [needsModule] Core machinery a module has to populate (D24).
|
||
* @property {string} [demoPath] A stable public route, deep-linked into a demo (D25).
|
||
* @property {string} [demoGame] The game module whose pages `demoPath` is in, if any (D83).
|
||
*
|
||
* @typedef {object} CapabilityGroup
|
||
* @property {string} id
|
||
* @property {string} title
|
||
* @property {string} summary
|
||
* @property {boolean} moduleSupplied
|
||
* @property {Capability[]} items
|
||
*/
|
||
|
||
/**
|
||
* Community — core machinery. Everything here ships with the site itself and none of it
|
||
* knows what game you run; two of the six still need a module to put anything in them,
|
||
* which is what `needsModule` says.
|
||
*
|
||
* @type {CapabilityGroup}
|
||
*/
|
||
const community = {
|
||
id: 'community',
|
||
/**
|
||
* Core, not module-supplied. Stated on every group rather than only on the one that is
|
||
* true, so the shape of a group is uniform — the homepage reads this field on all five,
|
||
* and an inferred union that carries it on one member is an error waiting for the next
|
||
* template that touches it.
|
||
*/
|
||
moduleSupplied: false,
|
||
title: 'Community',
|
||
summary:
|
||
'The site your players actually use, none of which knows what game you run — though ' +
|
||
'Teams arrive from the installed module rather than being created here.',
|
||
items: [
|
||
{
|
||
label: 'Teams',
|
||
needsModule: true,
|
||
demoPath: '/uo/guilds',
|
||
demoGame: 'uo',
|
||
detail:
|
||
'A roster, a leader, a private forum, its own notification streams and a Discord ' +
|
||
'voice channel, all hanging off one group. Core owns every part of that machinery ' +
|
||
'and deliberately cannot create a Team: they arrive from the installed module, ' +
|
||
'which is how a group the game already has becomes a Team on the site — and why ' +
|
||
'each game keeps its own name for one without core learning a new word.',
|
||
},
|
||
{
|
||
label: 'Team forums',
|
||
needsModule: true,
|
||
detail:
|
||
'Announcements, discussion threads and replies, with an edit window, post ' +
|
||
'moderation, and abuse reports a member can raise without going through staff ' +
|
||
'first. Forums are an admin switch for the whole deployment, and image uploads ' +
|
||
'stay off until someone deliberately turns them on.',
|
||
},
|
||
{
|
||
label: 'Notifications',
|
||
detail:
|
||
'On the site, by push and by email, chosen per notification by each person rather ' +
|
||
'than per person by you. The on-site inbox arrives by default and can be switched ' +
|
||
'off; push and email only ever arrive if they were asked for.',
|
||
},
|
||
{
|
||
label: 'Event calendar',
|
||
demoPath: '/site/events',
|
||
detail:
|
||
'What is scheduled, what is happening now, what finished recently, and the ' +
|
||
'results afterwards — with arcs, so a three-part story reads as one thing rather ' +
|
||
'than three unrelated entries. A run that was cancelled says so instead of ' +
|
||
'quietly vanishing. Core owns the whole calendar and can run an event on its ' +
|
||
'own; what an event may do inside a game world comes from the installed module.',
|
||
},
|
||
{
|
||
label: 'Wiki',
|
||
demoPath: '/wiki',
|
||
detail:
|
||
'For the things that outlive a news post — rules, guides, the lore nobody wants to ' +
|
||
'retype in chat. Written in the admin panel, published on the public site.',
|
||
},
|
||
{
|
||
label: 'News and newsletter',
|
||
demoPath: '/site/news',
|
||
detail:
|
||
'Four kinds of post — news, five-on-friday, newsletter issues and screenshots — ' +
|
||
'plus CMS pages and a page builder for everything that is not a post at all.',
|
||
},
|
||
{
|
||
label: 'Player self-service',
|
||
detail:
|
||
'An account area every signed-in person gets, whatever their role: their profile, ' +
|
||
'their linked game accounts, their own characters, their devices and sessions. ' +
|
||
'Staff are players too, so it is the same area for everyone.',
|
||
},
|
||
],
|
||
};
|
||
|
||
/**
|
||
* What a game module adds — module-supplied, and written in core's words (D63, D75). Each
|
||
* item is something BOTH modules supply, each in its own game's way; the game's own list,
|
||
* in its own words, is on its page under `/modules/`, which `/features/` and the homepage
|
||
* link to. No `caps` here: they belong to a game's own items in `src/data/games/`, where
|
||
* `assertGameCoverage()` checks them.
|
||
*
|
||
* @type {CapabilityGroup}
|
||
*/
|
||
const gameModule = {
|
||
id: 'game-module',
|
||
title: 'What a game module adds',
|
||
moduleSupplied: true,
|
||
summary:
|
||
'Supplied by the installed game module, not by the core site. Two modules exist, for ' +
|
||
'Ultima Online and for Rust, and each adds its own set from a live game server — these ' +
|
||
'are the parts they share.',
|
||
items: [
|
||
{
|
||
label: 'Live server status',
|
||
detail:
|
||
'Whether each game server is up, who is on it, and how long ago the site last heard ' +
|
||
'from it. When the game is down the site is the thing that says so — it does not go ' +
|
||
'down with it.',
|
||
},
|
||
{
|
||
label: 'A live feed of the world',
|
||
detail:
|
||
'What is happening in the game, as it happens. Every kind of event passes an ' +
|
||
'allowlist the module holds before it can reach a public page; staff read a second ' +
|
||
'stream carrying the rest.',
|
||
},
|
||
{
|
||
label: 'Leaderboards',
|
||
detail:
|
||
'The numbers the game already keeps, published without anyone exporting a ' +
|
||
'spreadsheet on a Sunday — and kept by the site after the game has moved on.',
|
||
},
|
||
{
|
||
label: 'Maps of the world',
|
||
detail:
|
||
'Where things are, drawn from the server rather than from someone else’s idea of the ' +
|
||
'game: one module maps what spawns where, the other shows what is moving now.',
|
||
},
|
||
{
|
||
label: 'Teams, from the game',
|
||
detail:
|
||
'The groups players already form in the game arrive as Teams on the site, with the ' +
|
||
'forum, notifications and voice channel core attaches to one.',
|
||
},
|
||
{
|
||
label: 'Players’ own game accounts',
|
||
detail:
|
||
'A player links their game identity to their site account and sees their own side ' +
|
||
'of the game on it. Who may see another player at all is the operator’s choice.',
|
||
},
|
||
{
|
||
label: 'Game steps for scheduled events',
|
||
detail:
|
||
'What an event may do inside the world comes from the module: core runs the ' +
|
||
'calendar, the budget and the undo, and the module brings the steps.',
|
||
},
|
||
],
|
||
};
|
||
|
||
/** @type {CapabilityGroup} */
|
||
const administration = {
|
||
id: 'administration',
|
||
moduleSupplied: false,
|
||
title: 'Administration',
|
||
summary: 'Running the place, with a record of who did what.',
|
||
items: [
|
||
{
|
||
label: 'Roles and permissions',
|
||
detail:
|
||
'Admin, moderator and player. Admin access is re-checked against the database on ' +
|
||
'every request rather than trusted from whatever the session was issued with, so ' +
|
||
'demoting someone takes effect on their next click and not at their next login.',
|
||
},
|
||
{
|
||
label: 'Moderation and appeals',
|
||
detail:
|
||
'Decisions carry a written reason, and the person on the receiving end has a ' +
|
||
'documented way to answer rather than a direct message to whoever is awake.',
|
||
},
|
||
{
|
||
label: 'Content reports',
|
||
detail:
|
||
'Anything a member writes can be reported by another member, into a staff queue ' +
|
||
'with the context attached.',
|
||
},
|
||
{
|
||
label: 'Append-only audit log',
|
||
detail:
|
||
'Staff actions are recorded, and nothing in the panel can edit or delete the ' +
|
||
'record afterwards. That is worth having on the day you need to prove what did ' +
|
||
'not happen.',
|
||
},
|
||
{
|
||
label: 'Bot scoring and IP bans',
|
||
detail:
|
||
'Login attempts are scored on behaviour rather than on a puzzle a real person has ' +
|
||
'to solve, and a bad enough score bans the address by itself. The panel is a read ' +
|
||
'view with an emergency unban, deliberately — it is not somewhere to tune a ' +
|
||
'threshold at three in the morning.',
|
||
},
|
||
{
|
||
label: 'Scheduled world events',
|
||
detail:
|
||
'Author an event as phases and steps, publish a version, put it on the calendar ' +
|
||
'and let it run unattended — with a dry run first that prices the whole plan ' +
|
||
'against this deployment’s caps. Every action arrives switched off, every run ' +
|
||
'has a per-run budget enforced in the database rather than in a role check, and ' +
|
||
'everything an event creates or borrows is written to a ledger so the undo is ' +
|
||
'generated rather than authored. Pause, resume, skip a step or cancel with ' +
|
||
'cleanup, all logged with the person who did it.',
|
||
},
|
||
{
|
||
label: 'Module management',
|
||
detail:
|
||
'Install, disable, uninstall and purge a module from the panel. Uninstalling keeps ' +
|
||
'the data and reinstalling picks it up where it was; deleting it is a separate, ' +
|
||
'opt-in choice.',
|
||
},
|
||
{
|
||
label: 'The game-server connection',
|
||
detail:
|
||
"The bridge's address, token and protocol version live in the panel rather than in " +
|
||
'an environment file, so connecting a server is not a redeploy. The token is ' +
|
||
'encrypted at rest and write-only in the API — it is never returned to any client, ' +
|
||
'including yours.',
|
||
},
|
||
],
|
||
};
|
||
|
||
/** @type {CapabilityGroup} */
|
||
const integration = {
|
||
id: 'integration',
|
||
moduleSupplied: false,
|
||
title: 'Integration',
|
||
summary: 'The seams that let other things reach in — and one game reach out.',
|
||
items: [
|
||
{
|
||
label: 'Modules',
|
||
detail:
|
||
'The whole game-specific half of a deployment is an installable module: routes, ' +
|
||
'screens, tables and nav rows, versioned against a declared core API. Installing ' +
|
||
'one is a paste in the admin panel or a line in your environment, never a build.',
|
||
},
|
||
{
|
||
label: 'The sidecar bridge',
|
||
detail:
|
||
'A small service beside the game server, speaking a versioned wire protocol to the ' +
|
||
'site and a loopback socket to the game. It is the only part of the bridge anything ' +
|
||
'can reach over a network, and the game never listens at all.',
|
||
},
|
||
{
|
||
label: 'Discord: slash commands, notifications, voice',
|
||
detail:
|
||
'A bot for the Discord server you already have. Commands answer from your site, ' +
|
||
'notifications bridge into channels, and a Team can be granted a voice channel ' +
|
||
'that maintains its own membership.',
|
||
},
|
||
{
|
||
label: 'Mobile and push',
|
||
detail:
|
||
'A native Android app against the same documented API the website uses, with push ' +
|
||
'delivered through your own ntfy server rather than a vendor in the middle.',
|
||
},
|
||
{
|
||
label: 'SSO over OAuth2 / OIDC',
|
||
detail:
|
||
'Google, Discord, or any OIDC provider you run. Link-only by policy: an external ' +
|
||
'identity has to be attached to an account that already exists, and signing in ' +
|
||
'with one never creates a user.',
|
||
},
|
||
],
|
||
};
|
||
|
||
/** @type {CapabilityGroup} */
|
||
const infrastructure = {
|
||
id: 'infrastructure',
|
||
moduleSupplied: false,
|
||
title: 'Infrastructure',
|
||
summary: 'How it runs, and who it answers to.',
|
||
items: [
|
||
{
|
||
label: 'Self-hosted, start to finish',
|
||
detail:
|
||
'There is no hosted tier and no account with us. Every part of this runs on ' +
|
||
'hardware you control, which is the only arrangement under which the rest of the ' +
|
||
'claims on this page mean anything.',
|
||
},
|
||
{
|
||
label: 'Docker, with prebuilt pull-only images',
|
||
detail:
|
||
'Compose up, compose down. Images are pulled rather than built, so nothing ' +
|
||
'compiles on your server and an upgrade is a pull and a restart.',
|
||
},
|
||
{
|
||
label: 'Branding as data, not a rebuild',
|
||
detail:
|
||
'Name, colours, logo and contact address are a mounted file. The same image runs ' +
|
||
'as any community — including this site, which is built the same way.',
|
||
},
|
||
{
|
||
label: 'OpenAPI 3.0 for the whole API',
|
||
detail:
|
||
'The spec ships with the server and an installed module merges its own routes into ' +
|
||
'it, so the API you build against is the API that is actually running.',
|
||
},
|
||
],
|
||
};
|
||
|
||
export const capabilityGroups = [
|
||
community,
|
||
gameModule,
|
||
administration,
|
||
integration,
|
||
infrastructure,
|
||
];
|
||
|
||
/**
|
||
* One group by id, or a build failure naming the id that was asked for.
|
||
*
|
||
* `capabilityGroups.find(...)` returns `CapabilityGroup | undefined`, so every page that
|
||
* wants one group has to either handle an impossible undefined or assert past it — and the
|
||
* assertion is what would eventually ship a blank section after somebody renamed an id.
|
||
* Failing here instead means a renamed group is caught by the first page that reads it.
|
||
*
|
||
* @param {string} id
|
||
* @returns {CapabilityGroup}
|
||
*/
|
||
export function capabilityGroup(id) {
|
||
const group = capabilityGroups.find((candidate) => candidate.id === id);
|
||
if (group) return group;
|
||
|
||
throw new Error(
|
||
`src/data/capabilities.mjs has no group with id "${id}", but a page asked for it.\n` +
|
||
`Known ids: ${capabilityGroups.map((candidate) => candidate.id).join(', ')}.\n`
|
||
);
|
||
}
|
||
|
||
/*
|
||
* The capability check that lived here — "Game intelligence" against the module's declared
|
||
* capabilities — moved to `assertGameCoverage()` in `games/index.mjs` in phase 17, with the
|
||
* lists it checks.
|
||
*/
|
||
|
||
/**
|
||
* Fails the build when a capability has no `detail`.
|
||
*
|
||
* The homepage renders labels, so a capability added with nothing else still looks correct
|
||
* there — and `/features/` would silently render a heading with no argument under it. The
|
||
* asymmetry between the two renderings is the whole of D20, and this is what keeps the
|
||
* thinner one from being the only one anybody notices.
|
||
*
|
||
* Called from `/features/` for the same reason `assertGameCoverage` is called from the
|
||
* homepage: the build error should name the page that would have shipped wrong. It also
|
||
* reads each game's own list, which its page under `/modules/` renders with the detail too.
|
||
*/
|
||
export function assertDetailCoverage() {
|
||
const missing = [];
|
||
for (const group of capabilityGroups) {
|
||
for (const item of group.items) {
|
||
if (!item.detail?.trim()) missing.push(`${group.title} → ${item.label}`);
|
||
}
|
||
}
|
||
for (const game of games) {
|
||
for (const item of game.features) {
|
||
if (!item.detail?.trim()) missing.push(`${game.name} → ${item.label}`);
|
||
}
|
||
}
|
||
|
||
if (!missing.length) return;
|
||
|
||
throw new Error(
|
||
`src/data/capabilities.mjs has ${missing.length} capabilit${missing.length === 1 ? 'y' : 'ies'} with no detail:\n` +
|
||
missing.map((entry) => ` - ${entry}`).join('\n') +
|
||
`\n\n/features/ renders the detail line (D20). A capability without one is a heading\n` +
|
||
`with nothing under it — write the sentence, or take the capability off the list.\n`
|
||
);
|
||
}
|