feat(server): the whole server half (phase 3, slice 1) #3

Merged
whitlocktech merged 3 commits from feature/module-extract-server into main 2026-08-11 21:05:59 +00:00
Member

Slice 1 of the Phase 3 extraction: 40 files, ~9,674 lines, 27 tables, 351 tests. Merges before its website counterpart, so edge serves these routes from core right up to the moment core drops them.

This must merge before website's deletion PR, per §2.7.1's merge order.

Why it is one PR

The server half does not divide — two contract rules compose. A mount prefix is claimed whole (/admin/shard is one 386-line router spanning atlas, clilocs, ops, visibility, market and links), and a model cannot be shared across the boundary, so it moves with the last route consuming it. Take the closure and every prefix is in it. Full reasoning in docs#135 §2.7.1. Three commits, readable in order: models+utils+schema, then routers+entry+hooks, then tests.

The port mechanism

server/core.js 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() and before any ctx exists. So every member resolves ctx when called, and nothing may be destructured off ctx at init either, because core is free to hand over a getter (ctx.site.baseUrl is one).

Require order is load-bearing. A router does const express = core.express at its own file scope, so core.init(ctx) must run before the first require under router/. Hoisting those to the top of index.js breaks the module with an error about ctx being missing, from a file that never mentions it.

What was vendored, and what deliberately was not

utils/excerpt.js is core's deriveExcerpt — nine lines of pure text handling. Core's sanitiser sitting next to it was not copied: a second copy of a security control diverges silently the moment either is fixed. announceLinks.js vendors legError/articleUrl the same way, but baseUrl could not be — core's reads APP_BASE_URL and §2.7 forbids that, so it comes off ctx.site.baseUrl.

One deliberate behaviour change

uoLinkSocket.start() and the sidecar health probe used to run after the listener bound; they run before it now, 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 the bridge is down is diagnostics, being up is not a precondition for serving a page.

Verified against a running core

Loads, mounts five prefixes, replays 35 statements, warms up, reaches started. /api/v1/public/shard/status and /atlas/meta answer 200 with real data (800 creatures, 6,455 spawners, live economy); admin and player answer 401 from core's tier gates; the extension slot answers at /admin/users/:id/shard/*. Core's own SPA renders the shard and atlas pages unchanged against the module-served API — no console errors, no CSP reports.

Zero URLs moved. Generating the manifest against core+module and diffing against the pre-extraction file: 228 before, 228 after, zero missing, zero added, and routes.guards identical across all 228.

One real port bug, caught by the integration run and not by tests

The atlas art map resolved ../../../db/data — correct when the file lived in core, pointing 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.

Needs core API 1.1.0

coreApi moves to ^1.1.0: ctx.activity.log, ctx.users.getById, ctx.site.baseUrl and ctx.middleware.rateLimit. ws is the module's one runtime dependency and ships inside the release tarball.


  • AI-assisted (Claude Code / Claude Opus 5)
Slice 1 of the Phase 3 extraction: **40 files, ~9,674 lines, 27 tables, 351 tests.** Merges before its `website` counterpart, so `edge` serves these routes from core right up to the moment core drops them. **This must merge before website's deletion PR**, per §2.7.1's merge order. ## Why it is one PR The server half does not divide — two contract rules compose. A mount prefix is claimed whole (`/admin/shard` is one 386-line router spanning atlas, clilocs, ops, visibility, market and links), and a model cannot be shared across the boundary, so it moves with the last route consuming it. Take the closure and every prefix is in it. Full reasoning in docs#135 §2.7.1. Three commits, readable in order: models+utils+schema, then routers+entry+hooks, then tests. ## The port mechanism `server/core.js` 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()` and before any `ctx` exists. So **every member resolves `ctx` when called**, and nothing may be destructured off `ctx` at init either, because core is free to hand over a getter (`ctx.site.baseUrl` is one). **Require order is load-bearing.** A router does `const express = core.express` at its own file scope, so `core.init(ctx)` must run before the first `require` under `router/`. Hoisting those to the top of `index.js` breaks the module with an error about `ctx` being missing, from a file that never mentions it. ## What was vendored, and what deliberately was not `utils/excerpt.js` is core's `deriveExcerpt` — nine lines of pure text handling. Core's **sanitiser** sitting next to it was not copied: a second copy of a security control diverges silently the moment either is fixed. `announceLinks.js` vendors `legError`/`articleUrl` the same way, but `baseUrl` could not be — core's reads `APP_BASE_URL` and §2.7 forbids that, so it comes off `ctx.site.baseUrl`. ## One deliberate behaviour change `uoLinkSocket.start()` and the sidecar health probe used to run *after* the listener bound; they run before it now, 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 the bridge is down is diagnostics, being up is not a precondition for serving a page. ## Verified against a running core Loads, mounts five prefixes, replays 35 statements, warms up, reaches `started`. `/api/v1/public/shard/status` and `/atlas/meta` answer 200 with real data (800 creatures, 6,455 spawners, live economy); admin and player answer 401 from core's tier gates; the extension slot answers at `/admin/users/:id/shard/*`. Core's own SPA renders the shard and atlas pages unchanged against the module-served API — no console errors, no CSP reports. **Zero URLs moved.** Generating the manifest against core+module and diffing against the pre-extraction file: 228 before, 228 after, zero missing, zero added, and `routes.guards` identical across all 228. ## One real port bug, caught by the integration run and not by tests The atlas art map resolved `../../../db/data` — correct when the file lived in core, pointing 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. ## Needs core API 1.1.0 `coreApi` moves to `^1.1.0`: `ctx.activity.log`, `ctx.users.getById`, `ctx.site.baseUrl` and `ctx.middleware.rateLimit`. `ws` is the module's one runtime dependency and ships inside the release tarball. --- - [x] AI-assisted (Claude Code / Claude Opus 5)
wtclaude added 3 commits 2026-08-11 17:09:02 +00:00
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>
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>
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
6b99d7e220
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>
whitlocktech merged commit 050a02c21d into main 2026-08-11 21:05:59 +00:00
whitlocktech deleted branch feature/module-extract-server 2026-08-11 21:06:00 +00:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: RunicGateway/Module-uo#3
No description provided.