21 Commits

Author SHA1 Message Date
7f7d4578ce fix(uo-link): pin protocol 4, the version this build actually speaks
All checks were successful
PR Checks / client-build (pull_request) Successful in 24s
PR Checks / server-tests (pull_request) Successful in 28s
PR Checks / frozen-manifest (pull_request) Successful in 40s
The protocol-4 cutover moved `link`'s PROTOCOL_VERSION, the overlay's
`overlay.toml` and this module's ingest — `guild.roster` and `guild.leave`
landed with the Teams cutover — but left both of this module's pin sites at 3.

A fresh install therefore came up speaking 3 to a protocol-4 sidecar, and a
sidecar answers a stale client with `409 protocol version mismatch` rather than
mis-parsing it. The failure is total and silent: every REST read fails, the WS
closes on ws.hello, and the operator sees an empty marketplace, an empty guild
board and no shard status, with the cause only in the server log. It cleared
only when an admin edited the number by hand in Admin → Shard.

Found while standing up a demo deployment for the marketing site's screenshots.

- `DEFAULT_PROTOCOL` → 4 (the constant used before an admin has saved anything)
- the `uo_link_config.protocol` column default → 4, at both declaration sites
- a protocol-4 one-shot mirroring the protocol-3 one, guarded by its own marker
  so an operator who deliberately pins an older sidecar stays pinned, and
  written `protocol < 4` so an install that never took the protocol-3 migration
  is carried the whole way rather than one step
- three regression tests: the column default, the marker ordering, and the
  `< 4` predicate

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-24 19:17:51 -05:00
3c179e3338 fix(release): ship server/commands, and check that the bundle is complete
All checks were successful
PR Checks / client-build (pull_request) Successful in 16s
PR Checks / frozen-manifest (pull_request) Successful in 40s
PR Checks / server-tests (pull_request) Successful in 8m38s
v1.0.0 installed and then died on every boot:

  module "uo" failed to load — {"stage":"register","reason":"Cannot find
  module './commands/guild.command'"}

`server/commands/` arrived with the Teams cutover (2d1d91e, `/guild`). The
release assembles the tarball from an include list, that list was hardcoded in
release.yml, and it was never told about the new directory — so the bundle
shipped without it and the module was dead on the operator's box.

Nothing caught it, and that is the more interesting half. Every PR check runs
against the whole repo — `frozen-manifest` even installs the module into core by
tarring the entire tree — but a release is a SUBSET of the repo, and the subset
exists nowhere except the release. The pre-publish check in release.yml only
stats the paths `module.json` declares, and a file reached by a require inside
`register()` is named in none of them, so it passed on a bundle that could not
load.

The include list stays an include list — release.yml's header makes that case
and it still holds. What changes is that it is declared ONCE, in ci/bundle.json,
with two readers instead of one:

  • release.yml assembles from it (via jq) rather than from its own copy.
  • server/scripts/checkBundle.js asks, in PR checks, whether it still covers
    everything `server/index.js` reaches — following requires transitively and
    through function bodies, which is where index.js deliberately puts them.

And the release gains a real loadability check: `checkBundle.js --bundle` walks
the ASSEMBLED tree and asserts every relative require resolves inside it. Asked
of the artifact rather than the source, so it also catches a half-failed copy or
a list naming a path that has moved.

Requiring the entry point would not have worked as a check: index.js requires
inside `register()` because require order is load-bearing (`core.init(ctx)` must
run before anything under `router/`), so requiring it evaluates one line and
reports success on a bundle missing every router it has.

Both modes were verified against the real defect — each fails with `commands`
removed and passes with it present.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WnDSWzpUjw8t8C2hghysNz
2026-08-19 13:25:57 -05:00
466842c6f2 fix(guilds): do not offer linking where linking cannot reach
Some checks failed
PR Checks / server-tests (pull_request) Successful in 28s
PR Checks / client-build (pull_request) Successful in 17s
PR Checks / frozen-manifest (pull_request) Failing after 35s
Found on the live rig, with the shard's guild feature gated to staff: the
refusal still read "link your account — this shard shows guild information to
linked players". Signing in reaches `logged_in` and linking a game account
reaches `player`; `staff` and `admin` are roles an operator grants, and no
amount of linking earns them. Inviting someone to do something that changes
nothing is worse than plainly saying no.

Also drops the host name from the list embed's title. `ctx.site` carries a base
URL and no brand name, so naming the deployment there could only ever mean
printing its hostname into a title on the shard's own Discord server.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 19:08:22 -05:00
2d1d91e372 feat(guilds): /guild, the module's own chat command
The first command through `api.registerSlashCommands` (MODULE_API 1.6.0,
TEAMS.md §7.1). The definition and the handler both live here; the bot pulls the
definition and runs no line of this module.

`/guild` and not `/team`, deliberately. Core does not own the word for a Team —
that is what deleted its Team pages in phase 3 — so it does not publish the noun
in a channel either. Core ships the dispatcher and zero commands.

The audience rungs are re-resolved in the handler rather than assumed: a shard
that gates guilds to staff does not become public because the question arrived
over Discord. The provider's own staleness guard is honoured too, so a stale
board answers "not connected" instead of reporting what it still holds, and
`resolveUserId` is exported rather than copied so "linked" means here what it
means on the roster.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 18:53:45 -05:00
c57310c505 feat(guilds): a third place on the guild page, and where that page lives
All checks were successful
PR Checks / server-tests (pull_request) Successful in 23s
PR Checks / client-build (pull_request) Successful in 17s
PR Checks / frozen-manifest (pull_request) Successful in 34s
Two lines only core cannot supply for itself.

`uo.guild.header` is a third declared slot, at the top of the page, for core's
per-Team notification control. A third rather than a corner of the feed because a
slot holds one component and the first fill wins: the control is an action ON this
page and the other two are content IN it, and separate slots are what let this
module say so.

`pageUrlTemplate` tells core where a guild page actually is. Teams are a contract
primitive with no core surface — core owns the tables and the access rules, this
module owns the word "guild" and therefore the page — which leaves core unable to
write a link to one. A notification email that cannot take you to the thread it is
about is most of the way to useless. Core substitutes `{externalId}` and does
nothing else with it; a template naming its own host is refused at registration.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 14:35:36 -05:00
dda0e32dd3 feat(guilds): a guild detail page, and the slot core puts the feed in
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
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
d4aa5ade12 feat(teams): project rosters by audience rung, and add to the Team page
The module's half of TEAMS.md phase 3.

`projectRoster` is the optional fourth provider method and the only one core
calls on a request path. Core holds the roster and owns its public shape; the
question that is this module's is who is allowed to look, because the audience
rungs and their configuration live here.

The answer is all-or-nothing, which is the honest translation rather than a
shortcut: a rung is a property of the FEATURE, and there is no configuration in
which some members of a guild are public and others are not.

The refusal semantics INVERT here, and the tests say so. For the other three
methods a refusal means "change nothing" and an empty array would be
destructive. Core fails CLOSED on this one, so the dangerous answer is the
opposite — returning every key because the config could not be read would
publish a roster an operator gated to staff. Every path that cannot reach a
confident answer refuses, including the catch.

The anonymous case is answered directly rather than by handing `viewerLevel` a
synthetic request. Given one with no `req.user` it falls through to
`auth.getUserFromRequest`, which expects real cookies and throws on a fake — and
that throw would have become a refusal, so every anonymous visitor would have
been served an empty roster on a shard whose guilds are public. Caught by the
tests, not by reading.

`team.overview` gets a live population reading beside core's stored one. Core's
number comes from the last roster sync and is coarse by construction; this is
the `presence.online` feed this module already holds. It is explicitly not a
per-Team presence figure — the shard publishes a global aggregate and no
per-guild breakdown exists on the wire, so claiming one would be inventing a
number — and it renders nothing at all when it has nothing true to say.

`team.member.row` is left unfilled. The useful thing to put there is a link to
the character behind a row, and the props core can supply do not identify one:
the member key and the site account id are withheld from every public roster.
An empty cell beats a guess.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 20:16:22 -05:00
99d1ca25a7 feat(teams): ingest guild rank, and report every leader rather than one
Some checks failed
PR Checks / client-build (pull_request) Successful in 15s
PR Checks / server-tests (pull_request) Successful in 20s
PR Checks / frozen-manifest (pull_request) Failing after 34s
The module half of the Protocol 4 rank amendment (servuo-plugins, same wire
version -- Protocol 4 is unreleased on `edge`, so it is amended rather than
bumped).

`shard_guild_members` gains `rank`, `rank_cliloc` and `rank_name`. The provider
then answers the question it previously could not: `getTeamLeaders()` returns
EVERY member at rank 4, not just the board's single `leader_serial`. That
limitation was the whole reason the wire grew a per-member rank -- TEAMS.md §2.5
treats multiple leaders as the normal case and core has always supported them.

The board's `leader_serial` is folded in as a floor rather than replaced. It
comes from a different frame, so on a shard whose roster has not been re-emitted
since the amendment it is the only leadership signal there is, and moving to
ranks must not lose it.

## NULL rank is a real state, and it is load-bearing

The shard withholds the rank for a staff account, because ServUO's
`PlayerMobile.GuildRank` reports Leader for anyone at GameMaster or above
whatever their actual rank. Every layer here preserves that:

  - the ingest stores NULL rather than defaulting to 0, which would be a
    demotion this code invented;
  - `leader` requires an integer rank >= 4, so absence is never leadership;
  - the leaders query compares on `rank`, and NULL is excluded by the comparison.

Reading a missing rank as either 0 or "leader" would republish the exact lie the
shard went out of its way not to send.

## Rank labels

Three sources, in order: a custom rank's literal string, then the operator's
cliloc table, then the five standard names. The last exists because the cliloc
table is populated only if someone ran the client-file extraction, and a roster
on a shard that has not should still read "Warlord" rather than nothing. A
failing lookup falls back rather than failing the roster -- a label is decoration,
and losing it must not lose the data.

`rank` is backticked everywhere it is written, like `int` on shard_online: it is
reserved in MySQL 8 and merely a keyword in MariaDB, so it parses bare here and
must not be relied on to.

The schema fragment carries ALTERs as well as the CREATE. No production install
has this table -- it is new in an unreleased protocol -- but `edge` deployments do,
from the roster work that landed before the amendment, and CREATE TABLE IF NOT
EXISTS adds a table and never a column. Same gap the sidecar's own store hit when
`guilds.members` was added.

## Verification

The unit tests stub the db layer, so the round trip was proved separately: the
VERBATIM roster frame captured from the live ServUO run was fed through the real
ingest into MariaDB and then read back through the provider.

  stored:   0x1F5 rank=4  0x1F6 rank=3  0x1F7 rank=2  0x1F8 rank=1
            0x1F9 rank=NULL (the GameMaster)  0x2E0 rank=4
  provider: leaders = [0x1F5, 0x2E0]   <- two, which the board alone cannot express
            labels  = Leader / Warlord / Emissary / Member, with no cliloc table
            0x1F9   = not a leader, no label

9/9 checks. Suite 413 -> 421 tests, all passing.

Refs docs/link/v4.md §2.3, docs/website/TEAMS.md §2.5

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 17:42:32 -05:00
c6929c6bae fix(teams): take query from the core facade, not a core.db that does not exist
Some checks failed
PR Checks / client-build (pull_request) Successful in 23s
PR Checks / server-tests (pull_request) Successful in 29s
PR Checks / frozen-manifest (pull_request) Failing after 40s
The Team provider's db layer built its query helper as `core.db.query(...)`. The
facade has no `db` member -- every other *.db.js in this module destructures
`query` from it directly -- so every call threw `Cannot read properties of
undefined (reading 'query')`.

The failure mode is the bad part. That throw is caught by the provider's own
error handling and turned into `{ ok: false, reason: 'roster unreadable: …' }`,
which is a perfectly valid refusal -- so core would have accepted it, held the
projection it had, and reported staleness. A provider that answers correctly and
never returns data, forever, with nothing in any log louder than a warning.

Invisible to the unit tests because they stub every db function, so the helper
was never called. Found by running a real roster frame through the ingest and
then asking the provider what it saw, against the real database.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 17:41:22 -05:00
268449f2a6 feat(teams): answer core's Team provider from the guild board
Some checks failed
PR Checks / client-build (pull_request) Successful in 16s
PR Checks / server-tests (pull_request) Successful in 21s
PR Checks / frozen-manifest (pull_request) Failing after 44s
A UO guild is a Team. This registers module-uo as the authoritative source of
them (MODULE_API 1.6.0, docs/website/TEAMS.md §2.3) and answers the three
questions core asks, from the board and the roster Protocol 4 put there.

`externalId` is the persistent ServUO `Guild.Id`, which survives a rename -- so
core sees "an id whose name changed" and applies its rename rule rather than an
unrelated new guild appearing beside the old one. That mapping is this module's
to make: only the game knows what identity survives what.

The most important code here is the refusal guard, and it is deliberately
conservative. Core's contract is that module unavailability becomes staleness and
never emptiness, and this module is the only thing that can honour it -- an empty
array from here reads as an authoritative "there are none", and core archives
Teams and departs members from an authoritative answer. Three states refuse: no
uo-link configured, the integration disabled, and the socket not connected.

**The third is the one worth arguing about.** The board is durable and survives an
outage, so serving it while disconnected looks harmless. It is not: core cannot
tell a board five minutes stale from one five days stale, and a complete answer
licenses destruction. There is a test named for that.

A fourth refusal has no equivalent anywhere else: a guild whose roster has not
arrived. Protocol 4's roster comes on its own frames, separately from the
`guild.update` that creates the board row, so there is a real window where a
155-member guild has zero roster rows. The board's own `members` count is the only
thing that distinguishes "the roster is late" from "this guild is empty", and it
is checked -- with the count in the refusal message, because it is the evidence.
The other side is tested too: when the board says zero, an empty roster is the
truth and withholding it would freeze a disbanding guild's membership forever.

Two limitations, both honest and both in the code as comments:

  - **`rankLabel` is null.** The wire's roster member is the standard actor object
    (`serial`, `name`, `player`, `acct?`, `webId?`) and carries no guild rank.
    Inventing a label from the leader flag would be core displaying something this
    module made up.

  - **One leader, not several.** TEAMS.md §2.5 expects multiple leaders from
    `GuildRank.Rank >= 4` and core supports them, but Protocol 4 does not put rank
    on the wire, so the only leadership visible here is the board's single
    `leader_serial`. Raising it to the full set is a protocol change, not
    something this module can fix.

`online` comes from `shard_online` rather than the roster, which carries no
per-member presence and only a board-level count -- the same source the public
"who's online" surface already uses. `userId` prefers the roster's own `web_id`
(what the shard asserted at roster time) and falls back to the `shard_account_links`
join for a member whose row predates their link; resolving it here rather than in
core is the contract, since core reading `shard_account_links` would be core
naming a module's table.

`coreApi` stays `^1.3.0` -- 1.6.0 satisfies it, which is what makes the bump minor.

18 provider tests plus two on the entry point: that all three methods are
registered, and that registration performs no query. The second matters because
register() runs while core's app.js is still being required with the pool pointed
at a dead port, which both routeManifest.js and swagger.js depend on.

`fakeApi` gained `registerTeamProvider` with the same `once` rule core applies --
one provider per deployment, so a second registration has to fail here too rather
than passing a shape core rejects at load.

411 -> 413 tests, all passing.

Refs docs/website/TEAMS.md §2.3, Part 12 phase 2

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 15:31:58 -05:00
2fa4d87a40 feat(shard): ingest guild rosters and departures (protocol 4)
All checks were successful
PR Checks / client-build (pull_request) Successful in 15s
PR Checks / server-tests (pull_request) Successful in 19s
PR Checks / frozen-manifest (pull_request) Successful in 38s
Protocol 2 gave the guild board a member *count* and nothing else, so the Guilds
page could say a guild had 155 members but never who they were, and
findGuildForActor deliberately answered only for leaders because membership for
rank-and-file was not in the feed at all. Protocol 4 puts it there.

`shard_guild_members` holds one row per member per guild, keyed on
(guild_id, serial). `guild.roster` replaces a guild's rows; `guild.leave` removes
one. A guild.remove now clears the membership too, so a disbanded guild does not
leave orphaned rows behind.

The chunking needs explaining. A roster over the shard's per-frame cap arrives as
several frames carrying seq/more/total. The sidecar reassembles them for its own
GET /guilds board, but the live WebSocket feed and the /history backfill both
carry the individual frames — so this ingest sees them unreassembled.

It copes without buffering, because a table expresses what the sidecar's single
JSON column could not: the frame carrying seq 0 clears the guild first, and every
frame then upserts its own rows. Upsert rather than insert because the /history
backfill replays stored frames on every reconnect, and a redelivery has to be a
no-op rather than a duplicate-key error. The cost is a sub-second window during a
multi-frame update where the table holds part of a roster; buffering to close it
would duplicate the sidecar's reassembly for a projection that is already only as
fresh as a 60s sweep.

On visibility: both kinds are mapped to the existing `guilds` feature. Without
that mapping rule 2 fails an unmapped kind closed to admin-only, which would have
quietly kept rosters off the public page forever. Mapping them is safe because a
roster is the first frame carrying locked fields inside an ARRAY of actors rather
than one nested actor, and the projection walker already recurses into arrays and
matches acct/webId by suffix — so a member's account name is stripped below admin
by exactly the rule that already strips guild.leader.acct. There is a test for
that specifically, because the difference is a public page listing character names
versus one publishing 150 account names.

`acct`/`web_id` are still stored, since that is what lets a linked member be
matched to a site user; they are just never projected below admin.

guild.leave is appended to the event log, as the departure counterpart to
guild.join and for the same reason — it is what a "so-and-so left" feed reads.
guild.roster stays out: it is board state like guild.update, and it is the one fat
frame on the wire, so logging it would put a full membership snapshot into
shard_events on every membership change.

The PUBLIC_KINDS guard test caught the addition, which is what it is for; its
expected set now carries a v4 group alongside the v3 one.

Refs: docs/website/TEAMS.md Part 12 Phase 1

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 12:57:26 -05:00
044211fd41 fix(docs): repair the escaped apostrophes, and document this module's env vars
Some checks failed
PR Checks / client-build (pull_request) Successful in 14s
PR Checks / frozen-manifest (pull_request) Failing after 34s
PR Checks / server-tests (pull_request) Successful in 8m49s
Nineteen `#swagger` descriptions carried a `\'` inside a single-quoted string.
That is correct JavaScript and wrong here: swagger-autogen does not evaluate the
annotation as JS, so the backslash survives into the spec and Swagger UI renders
"the shard\'s published ruleset" to a reader. Replaced with a typographic
apostrophe, which the same files already use elsewhere.

Found by opening /api/docs in a browser against a real core with this module
installed — the fragment was valid JSON, the paths were right, every test passed,
and it was still wrong on screen. Nothing that reads the artifact can see this;
only reading the rendered page can.

Also documents the four environment variables this module reads
(UOLINK_BASE_URL / _WS_URL / _PROTOCOL, TOWNCRIER_DURATION_SEC). Core's
.env.example is dropping them in the paired website PR: they were never core's,
and a half-copy in two repos goes stale silently.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 22:57:00 -05:00
5cdcf0fbb6 feat(release): ship an OpenAPI fragment, a frozen manifest and a bundle (phase 3, slice 5)
All checks were successful
PR Checks / server-tests (pull_request) Successful in 19s
PR Checks / frozen-manifest (pull_request) Successful in 35s
PR Checks / client-build (pull_request) Successful in 8m49s
The three artifacts that make this module installable and checkable, closing
phase 3's extraction. Nothing about what the module serves changes: the same 72
URLs, the same behaviour.

**The OpenAPI fragment (MODULE_API.md §2.8, §6.1a) was never built, on either
side.** The 417 `#swagger` annotations came across in slice 1 and went nowhere,
and core's /api/docs.json merged nothing — so every route this module serves was
in no spec at all, which is core's standing rule ("never ship a route that isn't
in the spec") being broken by the extraction rather than by a route.

`server/scripts/swaggerFragment.js` generates it. The prefixes are DERIVED: the
script runs the module's own `register()` against a recording api and asks
`require.cache` which file each router came from, so a mount prefix exists in one
place — `server/index.js` — and not in a table beside it. The 31 schemas moved
here from core's swagger.js, namespaced `Uo…` because core wins every key
collision in the merge; `Error` and `ValidationError` stay referenced by core's
names, since they resolve in the merged document.

**The frozen route manifest (§5.3)** is derived too, and by subtraction: CI
clones core at the ref pinned in ci/core-ref.json, generates its manifest without
this module and then with it, and the difference is what this module serves. That
buys the half of §5.3 that matters most for free — a module that shadowed or
displaced one of core's routes shows up as a REMOVAL, not merely as an addition
elsewhere. The same job checks the fragment against ground truth: every route
must have an operation and every operation must be a route.

**The release workflow** publishes `module-uo-<version>.tar.gz` plus a manifest
carrying its sha256. The version is declared in module.json rather than computed
from commit subjects, and the workflow never writes to a branch — it tags and
publishes — so `main` needs no push exception. The bundle is assembled from an
include list, because an exclude list ships whatever it forgot.

Four annotation defects, inherited from core and never visible until something
generated a spec from these files: two `requestBody` literals a brace short (the
route documented with an empty body), and two descriptions whose inner quoting
swagger-autogen cannot survive — it re-quotes `"` and a backtick to `'` before
evaluating, so either inside a single-quoted description ends the string early
and the annotation is dropped. It reports each one and then prints Success in
green, so the generator now captures its diagnostics and makes them fatal.

Also fixed while writing it: passing one shared `doc` to swagger-autogen six
times. It renders components.schemas from an EXAMPLE object and writes the result
back into what it was handed, so each pass re-wrapped the last and the fragment
came out at 484 MB.

- 409 server tests (+21), 40 client tests unchanged
- swagger-fragment.json: 69 paths covering all 72 routes
- routes.manifest.json: 72 routes; core's own surface unchanged, 0 removals
- verified end to end by assembling the bundle exactly as CI will, unpacking it
  into a real core and regenerating the manifest

Refs: docs/website/MODULE_SYSTEM.md §2.7.1, MODULE_API.md §2.8, §5.3, §6.1a

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 22:40:21 -05:00
f7bb3d912e fix(db): own the two settings seeds, and repair the protocol-3 one-shot
Core seeded `game_account_signup` and `uo_link_protocol_3_migrated`, two keys
that name a game concept. That made core's schema declare a module's settings,
which is the structural half of what Phase 3 removes (MODULE_SYSTEM.md §2.7.1,
slice 4). Both INSERTs move here. The keys are deliberately unchanged: they are
live rows on every existing install and renaming one silently resets an
operator's choice to the default.

The marker is not just a tidy-up. It and the `UPDATE uo_link_config SET
protocol = 3` it makes one-shot were adjacent in core's schema.sql until slice 1
moved the UPDATE here and left the INSERT behind — and the two files do not run
together: core's schema is replayed in full before any module fragment. So the
marker existed before the UPDATE ever read it, the NOT EXISTS guard was false on
every boot of an upgraded install, and the migration could never fire. An
install carrying a protocol-2 row would have stayed pinned at 2 against a v3
sidecar, 409ing every REST call — the exact failure the migration prevents.
Latent rather than live: it bites only an install that first boots a
post-slice-1 build while already holding a uo_link_config row, and `edge` has
not cut over.

`schemaFragment.test.js` asserts the order, plus the fragment rules core
validates at load time (leading-verb allowlist, IF NOT EXISTS, grandfathered
table prefixes) — restated here for the same reason manifest.test.js restates
the manifest rules. Its statement splitter is a character walk, because a
comment in this file contains quotes.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 21:27:52 -05:00
493cf296ab fix(server): own game-account signup, and repair the gate slice 1 broke
`POST /player/shard/account` and its staff twin have answered 500 for every
caller since slice 1: the ported controller called
`settings.isGameAccountSignupEnabled()`, which is a member of core's settings
model and not of `ctx.settings` — three functions, deliberately. The call was
`undefined(...)`, the TypeError landed in the catch, and no test reached the
branch.

The gate now lives on the side that uses it (`utils/gameSignup.js`), which is
also where the policy belongs: the setting's own help text names Bridge.cfg and
says the shard's SignupMode must agree, and core cannot own a sentence about a
UO shard. The admin field moves to this module's Shard page and the derived
flag onto `/public/shard/features`, beside the visibility flags the same
callers already read.

The setting KEY is unchanged. Renaming `game_account_signup` would silently
reset every configured instance to `disabled` on upgrade, with players
reporting broken signup as the only clue — the same grandfathering as
`spawn_atlas_servuo_path` and the seven stream ids.

Both regression tests were shown to fail against the bug before it was fixed.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 18:00:37 -05:00
28f4b9afe2 feat(client): the whole client half (phase 3, slice 3)
The 35 files behind twelve public pages, seven admin views, two player views
and three core-page extensions, ported onto `window.__rg`. Every one of them
imports exactly the seven kit members plus `lib/format.js`, which is the
finding §2.7.1 predicted and this confirms.

`client/src/core.js` is the port mechanism, and unlike the server's it is a
plain read: `window.__rg` is published before any module chunk evaluates, so
there is no gap to defer around and a ported component keeps its ordinary
import shape. `client/src/api.js` rebuilds the UO namespaces over the request
primitive — same URLs, because §1.2 freezes the API surface.

SPA paths changed and API paths did not. `/site/shard` is `/uo/shard`, and the
admin paths lost their now-redundant `shard-` prefixes (`/admin/uo/ops`), a
clean break being the only moment that is free.

`shim/rg.js` becomes the single reader of the global, so the "core did not
publish its dependencies" message is reachable from whichever module the
bundler happens to touch first rather than from whichever one is imported
first — a guarantee that used to last until someone sorted the imports.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 18:00:25 -05:00
6b99d7e220 test(server): port core's UO suite onto the ctx harness
All checks were successful
PR Checks / client-build (pull_request) Successful in 17s
PR Checks / server-tests (pull_request) Successful in 8m47s
22 test files moved from core, plus the two that were split out of files core
keeps. 351 tests pass.

One change runs through every moved test, and it is the boundary rather than a
chore: core internals can no longer be stubbed by requiring them, because there
are none to require. `../utils/db` and `../model/settings` do not exist here.
What a test controls instead is the ctx core would have handed over, installed
once by test/_setup.js -- which is a better seam anyway, since it is exactly the
surface the contract promises and nothing wider.

The ctx _setup installs is deliberately unfrozen. Core freezes what it hands a
module and entry.test.js still asserts against a frozen one; but a test that
needs settings.get to return a path has to be able to say so.

Two tests changed SHAPE, and that is the boundary too. fromShardEvent used to
assert through publish() into pushDevices and a captured fetch -- which
endpoints were hit, how many requests went out. None of that is this module's
any more: publish is ctx.push.publish, and the device registry and the relay are
behind it. Reaching for them from here would be reaching past ctx. What remains
is what the module owns and is the part worth guarding: a game account resolves
to a website user, a personal target that resolves to nobody is dropped rather
than published, and a sensitive kind never reaches publish at all.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 12:07:15 -05:00
740a677f92 feat(server): register the routes, the slot, the leg and the boot hooks
The entry point becomes real: five mount prefixes, the admin.users.detail
extension slot, the shard push catalog, the town-crier announce leg and both
lifecycle hooks. module.json declares all of it and the loader checks the
declaration against what register() actually registers, in both directions.

The URLs are byte-identical to the ones core served before the extraction. That
is the whole point of moving the code and not the paths: the shipped Android app
calls POST /api/v1/admin/shard/kick and the Discord bot reads
/api/v1/public/shard/*, and neither knows a module answers now.

Require order is load-bearing and the requires are inside register() because of
it. Every ported file reaches core through ./core, whose members resolve ctx
when called -- but a router does `const express = core.express` at ITS file
scope, which runs the moment it is required. Hoisting these to the top of the
file breaks the module with an error about ctx being missing, from a file that
never mentions it.

boot.js takes the eight UO call sites out of core's server.js. One behavioural
change, deliberate: uoLinkSocket.start() and the sidecar health probe used to
run AFTER the listener bound and now run before it, because onBoot does. start()
returns as soon as the reconnecting client is armed, but the probe is a real
HTTP call, so it is fired and NOT awaited -- an unreachable sidecar must not
hold the site closed. Reporting that the bridge is down is diagnostics; being up
is not a precondition for serving a page.

router/rateLimits.js builds the market limiter through ctx.middleware.rateLimit,
core's factory. The policy is the module's -- only the module knows what its
endpoints cost -- and the plumbing is core's, so there is one express-rate-limit
in the process and one place a breach is logged.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 12:06:46 -05:00
fe3251a543 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>
2026-08-11 12:06:26 -05:00
47809854ef fix(module): ask Node whether a specifier is a builtin
All checks were successful
PR Checks / server-tests (pull_request) Successful in 12s
PR Checks / client-build (pull_request) Successful in 15s
The first CI run failed on `node:test`, in every test file, reported as the
module boundary being broken. It was not: `builtinModules` omits `test` on
Node 20 (CI) and includes it on Node 24 (local), so a list rebuilt from it
disagrees with itself across versions.

Use `isBuiltin`, which is Node's own answer, and treat the `node:` prefix as
sufficient on its own -- a prefixed specifier can never resolve to a package,
whatever the running version enumerates. Test covers both forms.

Also corrects this file's header: the client half's guard is no longer
`external` (it never worked), it is the Vite build's resolution-time check plus
checkExternals.js on the built chunk.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 01:36:57 -05:00
5d7668d5ea feat(module): the bundle skeleton (phase 3, slice 0)
Some checks failed
PR Checks / server-tests (pull_request) Failing after 10s
PR Checks / client-build (pull_request) Successful in 8m45s
The first real module. It registers nothing, deliberately: what slice 0 proves
is the delivery path itself, end to end, before a single UO file moves into it.

Server half: module.json, an entry point that takes (ctx, api) and registers
nothing, a test suite built on a fake ctx, and scripts/checkImports.js -- the
MODULE_API.md §5.1 boundary check. Client half: the Vite library build, four
shims re-exporting react / react-dom/client / react-router-dom / jsx-runtime
from window.__rg, an entry that verifies each is identity-equal to core's copy,
and scripts/checkExternals.js. 29 server tests, 9 client tests, both new.

Verified against a real core: the module loads, mounts its zero routes, runs to
`started`, and is published by /api/v1/public/modules. Its chunk serves from
the entry's directory with `Cache-Control: no-cache` while the module's server
source, module.json and package.json all 404. In Chrome, under the enforced
`script-src 'self'`, the chunk evaluates and reports all four shared
dependencies OK, with zero CSP reports and no console errors.

Three findings, each of which had produced a green build that was wrong.

MODULE_API.md §3.6 shows `external` alongside the aliases and they do not
compose. Rollup asks `external` BEFORE Vite's alias resolver runs, so a
specifier in both is marked external and never aliased -- the chunk then ships
bare `import "react"`, which no browser can resolve without an import map, and
CSP forbids one. Built cleanly and emitted exactly that; checkExternals caught
it. So: alias only, `external` empty, and vite.config.js grows a resolution-time
guard that fails the build if a shared dependency resolves into node_modules.

That guard was wrong twice before it worked. Written against Rollup's `load`
hook it never ran -- `load` is first-wins and an earlier plugin had already
claimed the module -- so a deliberately-broken alias produced a 24 kB chunk with
react-router welded in, and a green build. And its forbidden-package list was
derived from the alias list "so the two cannot disagree", which meant deleting
an alias also deleted the guard against what that alias prevented. It states the
contract now, and a test asserts the aliases stay inside it.

checkImports failed on its own documentation the first time it ran: the comment
naming require("../../etc/passwd") as an example of what to catch, and index.js
explaining why the module must never require("express"). A boundary check that
cannot survive being described is one people stop writing comments around. It
strips comments and template literals with a character walk rather than a
regexp, because a URL in a string contains a comment opener and a comment
contains quotes -- and it has its own test suite, since a check never shown to
fail is a check nobody knows the state of.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 01:31:37 -05:00