refactor(client): delete the UO client half (phase 3, slice 3)

35 files and 5,332 lines out — twelve public pages, seven admin views, two
player views, eight components, the two `data/` leaves and the three `lib/`
ones, plus the two tests that came with them. §2.7.1's estimate of 51 files /
~3,700 lines was measured differently and is corrected in the docs PR.

The seams core keeps, each smaller than what it replaced:

Nine rows leave the public header and six leave the admin sidebar, and both
lists are now free of `feature` gates and of `IconShard`. `moduleTitle` already
handled a module page's heading, so the six TITLES entries and the
`/admin/characters` branch of `sectionTitle` simply go.

`/player` had `PlayerCharacters` as its index — a UO page — and rather than name
a replacement or invent a landing screen it now resolves to the first row of the
portal nav this viewer can reach (`firstDestinationFor`, beside
`allowedPathsFor` and reading the BASE nav for the same reason: an override is
presentation and where everybody lands is behaviour). With the module installed
that is still Characters, so a player's first screen after signing in does not
change. Deliberately generic and deliberately not in the portal layout — the
admin index is the same question with a hardcoded answer, and if the two
logged-in areas ever become one this is what serves both.

`game_account_signup` goes with the rest of core's UO prose: the mode list, the
derived public flag, the validation and a Site Settings field whose help text
named Bridge.cfg. The row itself is untouched and module-uo reads it through
ctx.settings — the data stays, the semantics move.

KNOWN BREAK, accepted by the org lead: the shipped Android app reads
`gameAccountSignup` off `/public/settings` (PublicDto.kt:80). The field has a
`= false` default so nothing crashes; the app silently stops offering
game-account creation until it reads the module's `/public/shard/features`
instead. Out of scope here, recorded in the Android plan, and it lands well
before this workstream's cutover reaches `main`.

620 server + 161 client tests. Manifest 158 public + 2 internal, unchanged;
routes.guards unchanged. The OpenAPI spec loses exactly one property, and only
because it was hand-written in swagger.js — regeneration alone would have left
the spec documenting a field core no longer returns.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-11 18:39:55 -05:00
parent 5b5006c365
commit f7d27f7a06
50 changed files with 230 additions and 5629 deletions

View File

@@ -55,27 +55,6 @@ function registrationFlags(mode) {
}
}
// Game-account signup (Protocol 2.0). The admin picks who mints game accounts:
// disabled — the site never offers game-account creation (link-only).
// website — the site is the authority (offer creation; pair with the shard in
// website mode + AutoCreateAccounts=false).
// hybrid — either side may create (the site offers creation).
// game — the game server is the authority; the site does NOT offer creation.
// The site OFFERS creation only for 'website'/'hybrid'; the shard's own SignupMode
// (Bridge.cfg) still has the final say and may 403 a call regardless.
const GAME_SIGNUP_KEY = 'game_account_signup'
const GAME_SIGNUP_MODES = ['disabled', 'website', 'hybrid', 'game']
const GAME_SIGNUP_OFFER = ['website', 'hybrid']
async function getGameSignupMode() {
const v = await settingsDb.get(GAME_SIGNUP_KEY)
return GAME_SIGNUP_MODES.includes(v) ? v : 'disabled'
}
async function isGameAccountSignupEnabled() {
return GAME_SIGNUP_OFFER.includes(await getGameSignupMode())
}
// Android App Links opt-in (M9 follow-up). When on, the shard auto-serves
// /.well-known/assetlinks.json and the mobile SSO bridge additionally accepts the
// self-origin https://<host>/mobile/callback redirect. Stored as the string
@@ -146,10 +125,12 @@ async function getPublic() {
// page show/hide the password form and SSO buttons.
const mode = REGISTRATION_MODES.includes(all[REGISTRATION_KEY]) ? all[REGISTRATION_KEY] : 'disabled'
out.registration = registrationFlags(mode)
// Whether the site offers game-account creation (the shard's own mode still has
// the final say when the call is made). Lets the portal show/hide the form.
const gsMode = GAME_SIGNUP_MODES.includes(all[GAME_SIGNUP_KEY]) ? all[GAME_SIGNUP_KEY] : 'disabled'
out.gameAccountSignup = GAME_SIGNUP_OFFER.includes(gsMode)
// `gameAccountSignup` was derived here until Phase 3 slice 3. It said whether
// the site offers GAME-account creation, which is a question about a shard —
// its own SignupMode has to agree — so it left with the rest of core's UO
// prose. The `game_account_signup` row is unchanged and module-uo reads it
// through ctx.settings; the derived flag is on its `/public/shard/features`.
//
// The effective CSS custom properties for the admin's theme, or absent when
// no theme_visual row exists (or nothing in it was usable). The SPA writes
// these onto <html>; absence means it writes nothing and theme.css's :root
@@ -264,10 +245,6 @@ module.exports = {
REGISTRATION_MODES,
getRegistrationMode,
registrationFlags,
GAME_SIGNUP_KEY,
GAME_SIGNUP_MODES,
getGameSignupMode,
isGameAccountSignupEnabled,
MOBILE_APP_LINKS_KEY,
isMobileAppLinksEnabled,
}

View File

@@ -520,12 +520,12 @@ async function updateSettings(req, res) {
) {
return res.status(400).json({ message: 'Invalid player_registration value' })
}
if (
settings.GAME_SIGNUP_KEY in updates &&
!settings.GAME_SIGNUP_MODES.includes(updates[settings.GAME_SIGNUP_KEY])
) {
return res.status(400).json({ message: 'Invalid game_account_signup value' })
}
// `game_account_signup` was validated here until Phase 3 slice 3. The key and
// its stored value are unchanged, but the four legal modes are a fact about a
// ServUO shard, so module-uo owns them and validates on its own route. This
// endpoint takes arbitrary keys either way, and an unrecognised value resolves
// to `disabled` on read — the module's gate fails closed, which is the right
// direction for "may this player mint a game account".
// App Links toggle is a boolean stored as a 'true'/'false' string; accept a real
// boolean or those two strings and normalize, reject anything else.
if (settings.MOBILE_APP_LINKS_KEY in updates) {

View File

@@ -14571,19 +14571,6 @@
}
}
},
"gameAccountSignup": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"example": {
"type": "boolean",
"example": false
}
}
},
"brand": {
"$ref": "#/components/schemas/Brand"
},

View File

@@ -796,7 +796,6 @@ const doc = {
type: 'object',
properties: { password: { type: 'boolean' }, sso: { type: 'boolean' } },
},
gameAccountSignup: { type: 'boolean', example: false },
brand: { $ref: '#/components/schemas/Brand' },
theme: {
type: 'object',

View File

@@ -0,0 +1,75 @@
// ── What core's settings no longer know about ──────────────────────────────
//
// Phase 3, slice 3. `game_account_signup` policy left core: the mode list, the
// derived public flag, the validation and the Site Settings field. The row in
// the `settings` table is untouched and module-uo reads it through
// `ctx.settings`, which is the whole point — the DATA stays, the SEMANTICS move.
//
// This file exists because the deletion passed 616 tests without one of them
// noticing. Nothing asserted the shape of `getPublic()`, so a field could leave
// the public settings payload — which the SPA, the Android app and the Discord
// bot all read — and no suite would say a word. That is worth a test in the
// direction of "and it is gone", but it is much more worth one in the direction
// of "and nothing else went with it".
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, after, afterEach } = require('node:test')
const assert = require('node:assert/strict')
const settings = require('../src/model/settings/settings.model')
const settingsDb = require('../src/model/settings/settings.db')
const db = require('../src/utils/db')
after(() => db.close())
const originals = { getAll: settingsDb.getAll, get: settingsDb.get }
afterEach(() => Object.assign(settingsDb, originals))
// Both readers, because the two halves of this test read different ones:
// `getPublic` folds `getAll`, and a module's `ctx.settings.get` is `get`.
const withRows = (rows) => {
settingsDb.getAll = async () => Object.entries(rows).map(([key, value]) => ({ key, value }))
settingsDb.get = async (key) => (key in rows ? rows[key] : null)
}
test('the public settings payload no longer carries gameAccountSignup', async () => {
// Configured as it would be on a live instance that had the feature on. The
// row is still there and still readable — it is simply not core's to interpret.
withRows({ site_title: 'Test', game_account_signup: 'hybrid' })
const out = await settings.getPublic()
assert.equal('gameAccountSignup' in out, false)
})
test('and the raw row is still there for the module to read', async () => {
// The load-bearing half. Renaming or dropping the key would silently reset
// every configured instance to `disabled`, with players reporting that signup
// stopped working as the only clue.
withRows({ game_account_signup: 'hybrid' })
assert.equal(await settings.get('game_account_signup'), 'hybrid')
})
test('the rest of the public payload is unchanged', async () => {
// The assertion the deletion actually needed: three clients read this object
// and none of them are in this repo. Registration flags are the near
// neighbour — same shape, same file, same derived-from-a-mode pattern — and
// the one most likely to be taken along by an over-eager edit.
withRows({
site_title: 'Test',
player_registration: 'both',
game_account_signup: 'hybrid',
})
const out = await settings.getPublic()
assert.deepEqual(out.registration, { password: true, sso: true })
assert.equal(out.site_title, 'Test')
})
test('core no longer exports the game-signup policy', () => {
// A leftover export is a leftover consumer waiting to happen, and the module
// has its own copy now. `ctx.settings` is three functions and never included
// these, so nothing outside core could have been using them.
for (const name of ['GAME_SIGNUP_KEY', 'GAME_SIGNUP_MODES', 'getGameSignupMode', 'isGameAccountSignupEnabled']) {
assert.equal(name in settings, false, `settings still exports ${name}`)
}
})