feat(server): port the UO models, utils and schema fragment

The data half of the extraction: 8 model directories, 13 utils, the shard
stream catalog and the 27-table schema fragment with its purge.

server/core.js is what makes the port a one-line import change per file rather
than a signature change per function. Ported code requires its dependencies at
file scope -- `const { query } = require('../../core')` -- which runs before
register() has been called and before any ctx exists. So every member is a
stable function that resolves ctx when CALLED, and nothing may be destructured
off ctx at init either, because core is free to hand over a getter.

Two helpers are vendored rather than taken from ctx, and the line between them
is the point. utils/excerpt.js is core's deriveExcerpt -- nine lines of pure
text handling. Core's sanitiser next to it was NOT copied: a second copy of a
security control diverges silently the moment either is fixed. announceLinks.js
vendors legError and articleUrl the same way, but baseUrl could not be: core's
reads APP_BASE_URL, and §2.7 forbids a module reading core's environment, so it
comes off ctx.site.baseUrl.

The schema fragment is core's 27 shard_*/uo_link_* statements, verbs CREATE,
ALTER and UPDATE only, every CREATE TABLE guarded. Two of its tables carry a
foreign key INTO users, which is allowed and is why the replay order matters --
core's schema is in place before this runs. The reverse never occurs and must
not: it would make core unable to boot without a module installed.

One real port bug caught by the integration run, not by tests: the atlas art
map resolved `../../../db/data`, which pointed at core's tree when this file
lived there and points outside server/ now. A path that happens to resolve is
exactly what survives a green suite, because the absent-file branch returns {}
and looks like the normal case.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-11 12:06:26 -05:00
committed by Claude
parent 47809854ef
commit fe3251a543
40 changed files with 7967 additions and 3 deletions

View File

@@ -0,0 +1,172 @@
// ── Shard-derived push streams + event → stream mapping ────────────────────
//
// MODULE-UO CONTENT, still living in core. MODULE_SYSTEM.md §1.8 named
// config/notificationStreams.js as one of the three genuinely entangled files:
// most of its catalog and all of `mapShardEvent` are shard-derived, and it reads
// `PUBLIC_KINDS` out of utils/shardBroadcast. PR 4 split it — core's one stream
// is config/coreStreams.js, and everything shard-shaped is here, in a file that
// moves to module-uo whole in Phase 3. Nothing in core imports it except
// modules/registries.js's registerCore(), which is the one line Phase 3 deletes.
//
// Two families:
// • public / opt-in — no linked game account required; delivered to every
// subscriber. Drawn ONLY from the SSE public allowlist
// (utils/shardBroadcast PUBLIC_KINDS) — a sensitive kind
// can never produce a public push.
// • personal / owner-keyed — require a linked game account; delivered ONLY to
// the owning user's devices (resolved from the event's
// game account via shardLinks), never fanned out publicly.
//
// The payload the relay ever carries is a CONTENT-FREE tickle ({ stream, ref });
// `ref` is an opaque hint (serial / city / timestamp) the app uses to pull the
// real, ownership-checked content over the authenticated API. So even a leaked
// ntfy topic reveals nothing (docs/android/PLAN.md §11).
const { PUBLIC_KINDS } = require('../utils/shardBroadcast')
const STREAMS = [
{
id: 'server.status',
label: 'Server up / down',
description: 'The shard comes online or goes offline.',
personal: false,
requiresLinkedAccount: false,
},
{
id: 'idoc.warning',
label: 'IDOC warnings',
description: 'A house falls into its final (IDOC) decay stage.',
personal: false,
requiresLinkedAccount: false,
},
{
id: 'champ.start',
label: 'Champion spawn starts',
description: 'A champion spawn becomes active.',
personal: false,
requiresLinkedAccount: false,
},
{
id: 'governor.election',
label: 'Governor elections',
description: 'A town elects a new governor.',
personal: false,
requiresLinkedAccount: false,
},
{
id: 'vendor.sale',
label: 'Your vendor sold an item',
description: 'One of your player vendors made a sale.',
personal: true,
requiresLinkedAccount: true,
},
{
id: 'house.idoc',
label: 'Your house entered IDOC',
description: 'One of your houses fell into its final decay stage.',
personal: true,
requiresLinkedAccount: true,
},
{
id: 'account.login',
label: 'A login to your account',
description: 'An authentication attempt against your game account.',
personal: true,
requiresLinkedAccount: true,
},
]
// The owner-keyed subset, needed by mapShardEvent's public-safety filter below.
// Derived from this file's own catalog rather than read back out of the registry:
// the filter is about THESE streams, and a module must not be able to weaken it
// by registering something that happens to share an id.
const PERSONAL_STREAMS = new Set(STREAMS.filter((s) => s.personal).map((s) => s.id))
// Per-process transition state so full-state upserts (champ.update / city.update
// are upserts, not discrete "started"/"elected" events — see docs/link
// PROTOCOL_2 §383) only fire once, on an actual transition. Injectable so tests
// pass a fresh tracker; a module-level default backs the live dispatcher.
function createTracker() {
return { champActive: new Map(), cityGovernor: new Map() }
}
const defaultTracker = createTracker()
// Per-kind mappers, each pushing 0+ targets onto `out` (and updating `tracker`
// for the upsert-transition kinds). Split out of mapShardEvent so that function
// stays a trivial dispatch + the public-safety filter.
const serverStatusUp = (event, tracker, out) =>
out.push({ streamId: 'server.status', ref: `up:${event.bootId || ''}` })
const serverStatusDown = (event, tracker, out) => out.push({ streamId: 'server.status', ref: 'down' })
const EVENT_MAPPERS = {
'server.hello': serverStatusUp,
'server.shutdown': serverStatusDown,
'server.crashed': serverStatusDown,
'house.decay': (event, tracker, out) => {
if (String(event.to).toUpperCase() !== 'IDOC') return
const ref = String(event.serial ?? '')
out.push({ streamId: 'idoc.warning', ref }) // public — location only
if (event.ownerAcct) {
out.push({ streamId: 'house.idoc', ref, ownerAccount: event.ownerAcct }) // personal
}
},
'champ.update': (event, tracker, out) => {
const { serial } = event
if (serial == null) return
const wasActive = tracker.champActive.get(serial) === true
const isActive = event.active === true
tracker.champActive.set(serial, isActive)
if (isActive && !wasActive) out.push({ streamId: 'champ.start', ref: String(serial) })
},
'champ.remove': (event, tracker) => {
if (event.serial != null) tracker.champActive.delete(event.serial)
},
'city.update': (event, tracker, out) => {
const { city } = event
if (!city) return
const gov = event.governor && event.governor.serial != null ? String(event.governor.serial) : null
const prev = tracker.cityGovernor.get(city)
tracker.cityGovernor.set(city, gov)
// Only a real transition to a new governor, and never on first sight
// (prev === undefined) so a reconnect snapshot isn't read as an election.
if (prev !== undefined && gov && gov !== prev) {
out.push({ streamId: 'governor.election', ref: String(city) })
}
},
'vendor.sale': (event, tracker, out) => {
if (event.ownerAcct) {
out.push({ streamId: 'vendor.sale', ref: String(event.t ?? ''), ownerAccount: event.ownerAcct })
}
},
'account.login.attempt': (event, tracker, out) => {
if (event.acct) {
out.push({ streamId: 'account.login', ref: String(event.t ?? ''), ownerAccount: event.acct })
}
},
}
// Map one shard event → an array of targets ({ streamId, ref, ownerAccount? }).
// May yield 0, 1, or 2 targets (an owner house.decay produces both the public
// idoc.warning and the personal house.idoc). Pure given `tracker`.
function mapShardEvent(event, tracker = defaultTracker) {
if (!event || typeof event.kind !== 'string') return []
const kind = event.kind
const out = []
const mapper = EVENT_MAPPERS[kind]
if (mapper) mapper(event, tracker, out)
// Defense in depth: a PUBLIC (non-personal) target may only ride a public-safe
// kind. Personal targets are owner-keyed and delivered solely to the owner, so
// they are exempt from the public allowlist (that is the whole point of the
// owner-keyed split). This guarantees a sensitive kind can never leak publicly
// even if a future mapping case is added carelessly.
//
// This filter, the kinds it reads and the streams it protects now all live in
// one file and move together — the reason PR 4 dropped the contract's
// `mapEvent` half rather than leaving the mapping in core and the catalog in a
// module (MODULE_API.md §2.4).
return out.filter((t) => (PERSONAL_STREAMS.has(t.streamId) ? true : PUBLIC_KINDS.has(kind)))
}
module.exports = { STREAMS, mapShardEvent, createTracker, PERSONAL_STREAMS }