990a50b491d5b608e0ba1c4ea173ccb6a20a11ca
3 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| 268449f2a6 |
feat(teams): answer core's Team provider from the guild board
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>
|
|||
| 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> |
|||
| 5d7668d5ea |
feat(module): the bundle skeleton (phase 3, slice 0)
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>
|