b13ffd584f3fed59845486d8c18fae8fd1196c71
97 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| b13ffd584f |
feat(notifications): per-channel preferences and the delivery-channel registry (engagement Phase 3)
`notification_subscriptions` answers one question — which streams a user wants
PUSHED — because that is the only question the shipped Android client can ask.
This adds the general one: which subscribable ids, on which channel, in which
mode. The old table becomes the push projection of the new one and keeps its
exact wire shape, so the shipped APK needs no update and no delivery path is
touched.
What lands:
- `engagement/channels.js` — `registerDeliveryChannel` (ENGAGEMENT.md §3.1), the
declarative half only: id, label, `carriesContent`, `defaultMode`,
`supportsDigest`. `addressFor`/`render`/`deliver` wait for Phases 6 and 7, for
the reason `transports/index.js` deferred this file at all. `coreChannels.js`
declares push / email / inapp through the subsystem's one door.
- `notification_channel_prefs` + a replay-safe `INSERT IGNORE … SELECT` backfill,
copying the `announce_jobs → announce_job_legs` precedent.
- `GET · PUT /auth/me/notifications/channels`. The PUT is SPARSE — only the
`(id, channel)` pairs named are written — deliberately unlike the two whole-set
PUTs beside it. `off` is a mode rather than an omission, so this endpoint has
no empty-array case and the kotlinx DTO gotcha cannot arise here.
Three decisions the org lead settled before any code, and one corrects the
phase's own acceptance criterion: push's `defaultMode` is `off`, not `instant`.
The plan borrowed "push is opt-OUT" from `team_notification_prefs`, where no row
does mean notified — but stream subscriptions have never worked that way, so
`instant` would have projected the whole catalog into the legacy GET for every
existing user and switched every toggle on in the shipped app after an upgrade
nobody asked for. A test pins the legacy GET at `{streams:[]}` for a fresh user.
One thing not named by the phase, and it is a G24 consequence rather than scope
creep: a trigger ceilinged at `staff` can never reach a non-staff user, so
offering the toggle would be offering a dead control AND disclosing the event
exists — `uo.cheat.detected` would otherwise appear in every player's screen the
moment Phase 11 declared it. Filtered from the catalog and gated on write. That
gave the `staff` label its first consumer, now written down as
`ceilings.STAFF_CEILING_ROLES` (the admin tier's three, deliberately not
`teamGrants.STAFF_ROLES`, which answers a different question).
15 new tests; swagger, route manifest and guards regenerated. No web or app
surface — those are Phases 7 and 8, where a preference governs something visible.
Refs: docs/website/ENGAGEMENT.md Phase 3, §3.1, §4.5
Co-Authored-By: Claude <noreply@anthropic.com>
|
|||
| 563199a096 |
feat(modules): event triggers, audiences and the ceiling lattice (engagement Phase 2)
The contract half of the engagement system: a module (and core) can DECLARE an
event with a payload contract and fire it. Nothing delivers yet — `emit`
validates, logs and stops, and Phase 4 replaces that log line with the engine.
`api.registerEventTriggers` and `api.registerAudiences` ride the existing
stage()/apply() validate-then-commit discipline, so a registrant that throws
halfway leaves nothing behind. `ctx.events.emit` is fire-and-forget and binds
the owner from the calling module — a module fires its own triggers and no one
else's. `ctx.inbox.push` is present and throws until Phase 7, the shape 1.6.0
settled on for a member that arrives a phase late.
MODULE_API_VERSION 1.7.0 on both halves. Additions only; module-uo's
`coreApi: "^1.3.0"` still resolves.
Three design decisions, approved by the org lead before any code:
ONE NAMESPACE for trigger ids and notification-stream ids (ENGAGEMENT.md §7.2,
against the recommendation in the text). A trigger is a payload contract
attached to an id that may also carry a subscription toggle, so an id has
exactly one owner across both facets, checked in both directions. Core's five
trigger ids ARE its five stream ids, so the same-owner upgrade case is
exercised on every boot rather than only by a module. It keeps
notification_channel_prefs single-keyed in Phase 3, where two namespaces would
have forced a `kind` discriminator into its primary key.
Two knock-on effects appeared only once it was implemented. The id grammar had
to be RELAXED to admit `_` inside a segment — §4.3's own worked example is
`uo.house.idoc_warning`, and two grammars over one namespace would mean an id
legal as a trigger and illegal as the stream it is the same event as. And the
seven grandfathered `uo.*` ids had to share their legacy allowlist with
triggers, because under one namespace `idoc.warning` is a single id. The push
catalog is untouched either way: allStreams() still serves the stream facet
only, so the shipped Android client sees exactly what it saw before.
THE CEILING LATTICE (G24), which the plan named everywhere and defined nowhere.
It is containment, not size: everyone ⊃ authenticated ⊃ {subscribers, members,
staff, owner}, with the four leaves mutually incomparable. The flat total order
the plan's wording invites would let a `staff`-ceilinged trigger be given an
`owner` audience — a rule that mails cheat detection to the player it detected.
Fewer people is not less exposure. Two incomparable ceilings have no meet at
all, so a composition is refused rather than guessed; union-widens is the
intuitive implementation and it is the wrong one.
`kind: 'event' | 'scheduled'` is declarable now and no evaluator exists (§7.1
Q6). Registration accepts `scheduled` and emit refuses to fire one, so `kind`
means something from the moment it can be written rather than from the moment
it is honoured.
Also: `GET /admin/engagement/{triggers,audiences}`, served from the registries
rather than a table so an uninstalled module simply stops appearing;
`npm run engagement:manifest` plus its CI `--check`, the twin of the route
manifest, because renaming a variable breaks stored templates silently, at send
time, in mail someone already received.
Co-Authored-By: Claude <noreply@anthropic.com>
|
|||
| fbb4b0bd91 |
feat(auth): unique, changeable, verifiable email addresses (engagement Phase 1b)
Makes `users.email` unique, de-duplicates the addresses an upgrade will find, and builds the self-service change-and-verify flow that did not exist. The uniqueness index is on a generated `email_norm AS (LOWER(email)) STORED` column under `utf8mb4_bin`, NOT on `email` under a `_ci` collation as the plan specified. Every case-insensitive collation this server offers is also accent-insensitive: `josé@x.com` and `jose@x.com` compare equal, and those are two different mailboxes. The plan's index would have refused the second address forever and the de-duplication would have nulled a legitimate account's. A requested address is STAGED in `email_pending` and only a tokened link installs it, so a typo cannot silently redirect account-recovery mail. `isDuplicateUsername()` now distinguishes the two indexes. All five call sites branch on it; each answers differently on purpose, because a public form, an IdP callback, a half-completed invite and an admin screen do not owe the same person the same amount of truth. SSO reads the IdP's actual `email_verified`/`verified` claim instead of inferring verification from an address merely being present. Co-Authored-By: Claude <noreply@anthropic.com> |
|||
| 6e61146678 |
refactor(api): collapse /admin/account and /player/account onto /auth/me/account
Self-service account security had three URL surfaces onto one controller. All
three mounted the same `admin/account.controller.js` handlers; each of the three
router files carried a header comment apologising for the arrangement.
`/auth/me/account` was already a strict superset, which settles which to keep:
/admin/account 6 routes noindex, isLoggedIn, staffOnly
/player/account 8 routes noindex, requireAuth
/auth/me/account 10 routes noindex, requireAuth
Neither of the deleted surfaces carried recovery codes, and /admin/account
carried no username or password change at all — so client.js already called
/auth/me/account/recovery-codes/* for two operations on a screen it otherwise
served from /admin/account. The split was leaking before this change.
Gating is equivalent where it overlapped: /player and /auth/me apply identical
`noindex, requireAuth`, and `staffOnly` on /admin/account was strictly narrower
while buying nothing, since every handler is self-scoped to req.user.id. There
is no CSRF layer to differ.
- 14 routes deleted, 0 added, no handler changed.
- account.controller.js moves router/v1/admin/ -> router/v1/auth/, beside the
one router that still reaches it.
- Web client: 14 call sites move onto a root-level api.myAccount /
api.changeUsername / ... group, matching the /auth/me methods already there.
- Android app: no change. MeApi.kt was already 100% /auth/me/account/*.
- Two swagger tags, `Admin · Account` and `Player`, were declared only by the
deleted routes and go with them. The orphaned `AccountStatus` schema goes
too; `PlayerAccount` is re-described as the any-role /auth/me/account shape
(the name is kept so existing $refs resolve).
Breaking to the published OpenAPI surface, accepted deliberately: both consumers
are in this org, and deprecate-then-delete would leave the next phase deciding
whether to add routes to surfaces already marked for removal.
Verification: routes.manifest.json shows exactly 14 deletions and 0 additions.
The OpenAPI spec loses the same 14 paths with zero surviving path definitions
changed; its large textual diff is pure reordering, because removing the
first-mounted router shifts every later path. 1203 server tests, 288 client
tests, 53 bot tests green; check:modules, check:hosts and routes:manifest
--check all pass.
Design of record: docs/website/ENGAGEMENT.md Phase 1a. This lands ahead of
engagement Phase 1b, which adds a self-service email field — written once here
rather than three times.
Co-Authored-By: Claude <noreply@anthropic.com>
|
|||
| 47c8b37d45 |
feat(email): remove Gmail OAuth2, put SMTP behind a transport registry
Engagement Phase 1 (docs/website/ENGAGEMENT.md §1.2a, §3.1, §3.2). A subtraction and a replacement in one commit, because leaving the OAuth2 flow half-wired across a release is worse than either end state. Deleted, per the §1.2a inventory: GET /admin/email/connect/start and /connect/callback, the connectStart/connectCallback controllers with the email_oauth_tx signed cookie, the PKCE verifier and CSRF nonce plumbing, the https://mail.google.com/ scope, the borrowed `google` auth-providers client, the OAuth2 nodemailer transport with its smtp.gmail.com:465 literals, the refresh-token decrypt in the model, and the client's Connect Gmail button, redirect banner and six Gmail error strings. `provider` and `refresh_token_enc` stay as columns under the additive-only discipline, unread. Added: a mail transport registry (server/src/engagement/transports) with `smtp` as the sole registration. `credentialFields` is the single declaration the admin form renders, the sanitizer filters against, and the "is it secret" answer comes from, so adding a transport is a registration rather than four edits. email_config gains transport / credential_enc (one encrypted JSON blob, since the field list is the transport's to declare) / reply_to. All six call sites keep their exact failure contracts: the contact form's mailto fallback, the invite's copyable link, the reset's generic 200, and sendTeamNotification's never-throws. One deliberate behaviour change: `enabled` now gates every sender rather than only isConfigured() — the connect flow used to set it as a side effect, and with a credential form the toggle has to mean what it says. Send-test becomes the real verification. Under OAuth2 the sender came back from Google and was guaranteed to belong to the credential; operator-typed, it can be refused, so failures name the sender and the SPF/DMARC reason (§1.2a consequence 2). G22, the silent degradation: an upgraded deployment backfills to smtp with no credentials and every sink politely does nothing. The admin dashboard now warns when the deprecated Gmail token is present and no replacement credential is, so the one deployment this happens to is told. A fresh install has never had mail and is not nagged. Guardrails: new `npm run check:hosts` (§3.2 rule 4) with its own self-test, wired into pr-checks before the install; routes.manifest and routes.guards regenerated (-2 routes). Co-Authored-By: Claude <noreply@anthropic.com> |
|||
| f72c92ffbe |
fix(teams): the two defects the phase 9 rig walk found
Walked against real MariaDB, the real app, and a fake standing in for Discord
that mounts the bot's real internal routes — everything up to the Discord API
call was production code. 47 assertions, and it found two things every unit
test in the phase had passed over.
1. **Every query failed: two result columns named `team_id`.** `desiredTeams`
and `holdersWithoutClaim` both select `t.id AS team_id`, and the shared
column list added `i.team_id` beside it. The `mariadb` driver refuses a
result set with a repeated field name outright, so the pass died at its
first query with "Error in results, duplicate field name `team_id`" — on the
one code path every unit test stubs.
It was also the wrong column: `desiredTeams` LEFT JOINs, so `i.team_id` is
NULL for exactly the Teams that have no channel yet, which is the create
case. The two queries that do not join `teams` now ask for it by name.
The regression test checks the INTERPOLATED sql captured from a fake
`query`, not the source text — in the source the shared list is still a
`${COLUMNS}` placeholder, and a first attempt that read the file passed
happily with the bug reintroduced.
2. **"Sync now" said "Nothing was done" while it was doing it.** Saving the
settings with voice switched on asks for a pass. An operator who then
presses Sync now — the obvious next thing — hit `running` and got back
`ran: false, reason: "a pass is already running"`, which the panel renders
as nothing having happened, while the pass they triggered was busy creating
their channels. A pass in flight is now JOINED and its real outcome
returned, the same choice `teamSync.reconcileNow` makes for the same reason.
Tests: 1162 server (+2), 53 bot, 284 client.
Co-Authored-By: Claude <noreply@anthropic.com>
|
|||
| 61abb3ec89 |
feat(teams): phase 9 — one voice channel per Team, granted by a role
TEAMS.md §7.3. Each qualifying Team gets a Discord voice channel of its own
and a role that opens it, kept in step by a reconciler that rides the Team
reconcile it already depends on.
Access is a per-Team ROLE, always. §7.3 designed per-member overwrites with
escalation to a role above ~90 members; the org lead settled on roles always
(2026-08-18), which deletes `voice_overwrite_max`, the escalation and the
`mode` column — and moves the ceiling. Overwrites are capped per channel, so
the old shape's limit was "how big can one Team be"; roles are capped per
guild at 250, so the new one is "how many Teams can have voice at all". That
is a limit an operator must be told about before they hit it, so the panel
reports it and the pass refuses the create rather than letting Discord do it.
Three things §7.3 named that this codebase does not have, all settled by
asking the operator because nothing in the data model can answer:
- "the staff role" — there is no staff-role concept anywhere. Now a list of
role ids the admin designates; empty is a normal answer, since guild
administrators bypass overwrites and what is really missing is a way to
let NON-admin staff in.
- the parent category — §7.3 said the bot creates it and gave the id nowhere
to live (`team_integrations.team_id` is NOT NULL). The bot creates it and
the server stores the id in settings.
- whether the bot can act at all — nothing has ever checked. The operator
invites the bot by hand and no invite URL with a permission integer exists
in the tree, so a deployment can be one unticked box from every call
failing. A preflight is now a PRECONDITION to enabling (422), not a
per-Team error discovered afterwards.
Two more, decided rather than asked:
- the threshold counts every active member, not linked ones. §7.3 wrote
`voice_min_linked_members`; the operator is judging whether a Team is real,
and link state answers a different question.
- hidden Teams are never provisioned. A channel name is a game-sourced string
published outside the site, which is exactly §2.8's concern —
reservedNames.js already names "and eventually a Discord channel name" as a
surface it protects — so the screen that suppresses a Team's page suppresses
its channel, and a Team that becomes hidden takes the grace window.
Turning voice OFF tears nothing down: the pass suspends in both directions and
the panel offers per-row removal. A checkbox must not delete structure in
somebody's guild.
Fixes a phase 8 defect that blocks this phase's own artifact: `npm run swagger`
has been unable to run on `edge` at all. `param('teamId').custom((v) => ... ||
/^[0-9]+$/.test(v))` makes swagger-autogen's parser run away — a regex literal
followed directly by `.test(`. Hoisted to a const, as modules.router.js
already does. Underneath it, `teams.router.js` sits exactly at that parser's
per-file limit: at twenty `teamsRouter.*` statements it dies, at nineteen it
generates, and one more statement of ANY shape tips it — an unannotated route
does, and so does a bare `use`. So the voice routes are their own router file
mounted from `admin/index.js`, and teams.router.js keeps its nineteen.
Also breaks a require cycle this phase would have introduced:
teamSync -> teamVoiceSync -> teams.model -> teamSync left `teams.model` holding
the reconciler's exports object as it stood mid-load — the empty one, since
`module.exports = {…}` replaces rather than fills. The symptom is not in the
new code: it is `teamSync.intervalSeconds is not a function` thrown out of
`syncStatus()`, the freshness banner on every public Team page.
Tests: 1160 server (+40), 53 bot (+21), 284 client (+21). Swagger, routes
manifest and guards regenerated; the guard shape of the four new routes is
byte-identical to the existing admin-only ones.
Co-Authored-By: Claude <noreply@anthropic.com>
|
|||
| 11b4368b57 |
feat(teams): phase 8 — the notifications bridge, and the gate §7.2 could not check
The same Team event as §6, delivered a third time: push, email, and now a Discord channel the operator configured. Not a second pipeline — teamNotify.js already computed the recipient set once, so the bridge is a sink beside the two that were there. The design's gate has no data source. §7.2 bridges an event only if "its visibility is public, or its destination channel is configured for a members-only Team context". The four team.* streams carry no visibility; forum threads have no public/members column because a forum is members-only by construction; and core cannot see a Discord channel's permissions. So §7.2's own example config names exactly the two events that are never public. The gate is therefore an attributed operator acknowledgement, in the shape teams_forum_uploads_ack already uses. It is a precondition — 422, not a quiet drop at delivery — it is re-asked at delivery as well as at the save, and changing the channel clears it, because an acknowledgement is about a destination and cannot survive the destination changing underneath it. The design's DDL cannot hold its own default row: MariaDB coerces every PRIMARY KEY column to NOT NULL, so `team_id NULL` — the deployment-wide default every override overrides — is unrepresentable. Proved on a real MariaDB (error 1048). Replaced with a surrogate id, a generated team_key AS IFNULL(team_id, 0) in the unique key, and the foreign key the original had no room for. One-shot, not queued: "identical to announce and mod-reverse" names two different reliability models, and a Team notification is the moment it describes. Co-Authored-By: Claude <noreply@anthropic.com> |
|||
| cecd72915f |
feat(teams): the slash-command seam, and the first command through it
Phase 7 of TEAMS.md. `api.registerSlashCommands` stops throwing: a module registers a command's DEFINITION and its HANDLER together, the bot pulls the definitions over the internal listener and runs none of our code, and the handler executes here — forced by the bot container having no `modules` volume, and the right boundary anyway. Registration validates what Discord would reject as a batch (names, description lengths, the four option types, required-before-optional), because the bot registers the whole set in one PUT and a single bad entry costs every command including the bot's own. Commands are not namespaced under their owner — there is no dot in Discord's name grammar — so collisions are first-come with the holder named. The dispatcher is the access boundary: `linked` has no Discord equivalent, so the platform-side permission default can only ever be advertising. It resolves the actor by `auth_providers.kind` rather than the id slug, treats a banned account as unlinked, bounds a handler under the bot's own timeout, and keeps `ok` outside the envelope so a handler cannot forge it. Liveness is asked at both the pull and the dispatch. The registries have no removal path, so a module an operator disables at runtime would otherwise keep a live handler behind a command Discord still advertises. Co-Authored-By: Claude <noreply@anthropic.com> |
|||
| 13312d7fc3 |
fix(teams): make "replace the whole set" actually replace it
Found walking the live rig, which is the only place it could be found: every unit
test and the settings screen itself send every row, so the bug was invisible to
both.
`PUT /auth/me/notifications/teams` documents itself as replacing the whole set. It
did not — it wrote the entries it was given and left every other preference
standing. So `{"teams": []}` cleared nothing, which is precisely the body the route
requires the array for: the field is mandatory even when empty so that clearing
everything is expressible, and it was the one thing that did not work.
A Team the caller could have named and did not now returns to its defaults. RESET
rather than deleted, and the difference is `last_digest_at`: that column is the
digest worker's state and not a preference, so dropping the row with it would make
every visit to the settings screen re-open a day-wide digest window and mail
somebody a summary they had already read.
Walked again after the fix on the real database: the empty set clears, an entry
naming a Team the caller is not in is still dropped, and the digest stamp survives.
Co-Authored-By: Claude <noreply@anthropic.com>
|
|||
| 5fa88baa0a |
test(teams): the refusals, which is most of what a notification feature is
A notification feature is mostly things that correctly do NOT happen, and each of these is invisible until it goes wrong in production: a departed member and a revoked guest are not recipients; a mute subtracts per Team and leaves the user's other Teams alone; the author of a post never receives the notification about it; forums switched off silences the forum streams including the digest; a Team's first roster wakes nobody; a failed send does not stamp `last_digest_at`. Two real defects came out of writing them. `Number(null)` is 0 and 0 is an integer, so a null in a caller's id list survived `filter(Number.isInteger)` and rode into an IN clause as user id 0. No row has id 0, so it was harmless — which is exactly why it would never have been noticed. Fixed in all three places that filter ids. `recipientIds: db.recipientIds` in the model captured the function OBJECT at require time, so the layer below could never be substituted. That is not only untestable; it means the model was not really the seam it claimed to be. Wrapped so `db.x` resolves at call time. The registries catalog assertion is now an exact five-element list, so a shard-content stream creeping back into core's registration fails here rather than shipping. Co-Authored-By: Claude <noreply@anthropic.com> |
|||
| c970caee16 |
fix(teams): let a post-moderation mistake reach the model that explains it
Found on the live rig. `moderatePost` answers `pin` with «"pin" applies to a thread, not to a post» and an invented action with "Unknown moderation action" — the distinction exists because they are different mistakes and a caller who made the first one has a bug worth naming precisely. The route's validator listed only the four actions a post accepts, so `pin` never got there: it came back as a generic "Validation failed". The precise message was written, documented, unit-tested — and unreachable through the API, which is the worst of both, because the branch reads as live code and is only exercised by its own test. The validator now lists all eight and lets the model discriminate. Both answers are 400, neither is a security boundary, and widening the list is not removing it — an action outside the enum still stops at the validator, which the added route test asserts alongside the `pin` case. Nothing else the walk exercised needed changing. The whole phase 5 surface was driven against a real server, real MariaDB and real sessions across four identities — an ordinary member, a granted non-member guest, a Team leader and a staffer — plus a browser pass over the forum panel, the reports queue, the per-Team forum ledger and the settings screen. Notably confirmed live: a locked thread refuses replies from all four identities at 409; a hidden post renders for the leader and staff with Unhide and **no Edit control for anyone**; the report queue answers 200 to staff and 403 to the leader, the member and the guest alike; and turning the edit window down to 0 stops the author while leaving staff unbounded. Co-Authored-By: Claude <noreply@anthropic.com> |
|||
| 128de0ff2e |
test(teams): phase 5's server surface, and the negative property under it
1008 pass (972 before). The tests worth reading first are the ones that pin a
property no screen would look different without:
* **The edit window is decided on the server, twice.** One test proves the read
path stamps `canEdit` per post per viewer; another proves the WRITE path
re-derives it from `created_at` and refuses a stale edit even though the
client was told it could — because a time-bounded permission must not take its
clock from the party it bounds.
* **A locked thread refuses staff too**, asserted over member, leader and staff
in one loop, at 409 rather than 403: well-formed request, refusing state.
* **delete → restore is reversible for images.** Without the second half of the
pair a restored post returns its words and loses its pictures a retention
window later, silently — the test asserts both calls and that `hide` makes
neither.
* **Post moderation recomputes the thread's counters** rather than nudging them;
the test runs hide → unhide → hide, which is the cycle a delta gets wrong.
* **acceptance: nothing in the report model is reachable by a Team leader.** The
negative property is the whole point of §5.6 and negatives are what nobody
notices going, so it is asserted directly — the module's function surface is
pinned, and `queue`/`handle` are checked not to mention leadership at all. If
a leader-facing queue is ever wanted it is the org lead's decision, and this
test is what makes somebody ask.
* **A report never changes the content it is about**, proved by stubbing every
mutation the forum has to throw. If filing a report touched a status then
"report" would BE moderation, and the first person to work that out would have
found a way to hide anything on the site.
The test suite caught one real defect: `describeTarget` returned `undefined` for a
hard-deleted target, and `undefined` is dropped by JSON.stringify — so the
documented `target: null` would have reached clients as an absent key.
Two phase-4 tests were updated rather than added to, both because phase 5 changed
what they describe: `canPost` split into `canPost` (open a discussion, everyone)
and `canAnnounce` (leaders), and `discussion` is no longer a refused thread type.
Phase 5's four new player routes are added to acceptance criterion 2's list, so
"with the forum off every forum route 404s" keeps covering the whole surface.
Co-Authored-By: Claude <noreply@anthropic.com>
|
|||
| 5baada08ef |
fix(teams): four defects the live rig found in the forum
None of these could fail a unit test, and three of them break the feature for the operator rather than for the code. **The uploads acknowledgement was a one-way door.** A settings form sends every field it owns, so once `teams_forum_images` was `uploads`, every later save re-sent `uploads` — and the gate fired on the VALUE being present rather than on the mode being SELECTED. The operator could never change a forum setting again, and the thing they would reach for in a hurry, switching the forum off, was exactly what came back 400. The gate now passes when an acknowledgement for the version in force is already on record AND uploads is already the stored mode: there is no new consent to take. A transition INTO uploads still asks, and a reworded notice is still caught by assertSettingsWritable. **An uploaded image could never become a picture.** `uploads` mode hands the composer `/uploads/<name>.png`, the composer puts it in the body as text — the author never writes markup, which is the whole design — and the renderer only rewrites ANCHORS. The linkifier matched absolute http(s) URLs only, so the write path could not produce the anchor the read path looks for, even though `isEmbeddableImageUrl` had accepted those paths since the first commit. The two halves disagreed and only a real upload showed it. **The embed sat beside its link, not beneath it**, because an <img> is inline, and nothing capped a remote image to the column — one post from a host serving a 4000px file would have blown the layout out. Core now emits `class="forum-embed"` and the stylesheet owns both. A class rather than an inline style because the style would then have to survive the client's DOMPurify pass, and its CSS sanitiser is a larger thing to reason about than one class name. **The panel's buttons had no button styling.** `btn-ghost` is a MODIFIER — every other call site in this codebase pairs it with the base `btn` — so alone it contributed colours and no geometry, and the controls rendered as bare boxes. Small inline actions use `pill`, which is what the rest of the admin surface uses for exactly these. Same class of mistake as the Material one in the Android M12 phase: the modifier carries no base. Also: the post body now re-sanitises client-side like every other body-HTML surface on this site, with `ADD_ATTR: ['referrerpolicy']`. That argument is load-bearing — DOMPurify's default allowlist carries `loading` but not `referrerpolicy`, so a plain sanitize() call silently strips the one attribute limiting what a remote embed leaks to the host serving it, which is the privacy property the admin help text promises. Co-Authored-By: Claude <noreply@anthropic.com> |
|||
| 57286594e7 |
test(teams): the four acceptance criteria, and regenerate the API artifacts
Four tests are named "acceptance" and are Phase 4's criteria verbatim. Each names a property the code around it can lose without any screen looking different: 1. A granted, unlinked account reads the forum, is absent from the member rows, and is still refused external-platform eligibility. The membership projection is asserted byte-identical across a grant, which is what "non-contamination" means in practice. 2. With the switch off every forum route 404s AND nothing is read or written on the way there — a guard that 404s after loading the thread is one that still bumped a counter. 3. The stored HTML is byte-identical between `disabled` and `remote`; only the rendered output differs. That is the property the renderer-owned design exists to give, and it is what makes flipping the policy back a no-op rather than a migration. 4. Selecting `uploads` without a matching acknowledgement is refused server-side, with the admin checkbox bypassed. Plus the ones that are not criteria but are the same kind of claim: an author cannot smuggle an <img> or its attributes through in any mode, http and non-image URLs stay plain links, a leader cannot revoke a staff-issued grant, a demoted account stops protecting the grants it made, moderation records which authority was exercised, and a RIFF container that is not WebP is not accepted as one. Twelve new routes in the manifest, all annotated and in the OpenAPI spec. Co-Authored-By: Claude <noreply@anthropic.com> |
|||
| 03631d7d40 |
feat(teams): the roster's audience projection, and optionalAuth to resolve it
TEAMS.md §3.3, as the eighth member of MODULE_API 1.6.0 — amended in place per the org lead, on the rule Protocol 4 was given in phase 2: a contract owes a bump only once it has landed on `main`. Two questions meet on the roster and they belong to different owners. WHICH ROWS a viewer may see is the module's, because the audience rungs and their configuration live there and core does not know what a rung is. WHAT A ROW LOOKS LIKE stays core's. So `projectRoster` answers with member KEYS, not rows. §3.3 said rows, and rows would let a module widen what is published — handing back a `userId` core had withheld — leaving core's field guarantee resting on every module's good behaviour. Core asks which rows and re-normalises the answer through its own public shape, so a module can narrow and cannot widen. "The module declines" needed splitting before it could be implemented. No module at all and a module whose rungs could not be consulted are opposite situations: the first withholds nothing and must serve the roster whole, the second must serve none of it. The refusal carries `projects`, and only `projects: true` fails closed. Without the split, bare core serves an empty roster on every Team page. This is also the first public route whose CONTENT depends on identity, which needed a middleware core did not have. `attachSession` only decodes a token, so a banned account, a password change or a logout would have kept working against the private half of a feed until the JWT expired. `optionalAuth` runs requireAuth's full database re-validation and, on any failure, continues ANONYMOUSLY rather than rejecting — a caller whose session is no longer good sees the public view, which is what they are entitled to. `GET /public/teams/:slug/activity` lands here for the same reason: §2.11's route table had no activity endpoint though §4.3 describes a filtered feed. Paged, with the visibility resolved from the session and never from a parameter. Co-Authored-By: Claude <noreply@anthropic.com> |
|||
| aa332eda82 |
feat(teams): the activity feed, its two writers and its retention
TEAMS.md Part 4. `team_activity` takes items from two sources and treats them
identically on the read path: core writes its own membership and rename items
with source='core', and a module pushes game items through
`ctx.teams.activity.push`, which stops throwing and starts working.
Core writing here too is deliberate — the rendering path is exercised by core's
own content from day one, so the feed is never empty on a deployment whose
module pushes nothing.
Three rules shape the model:
- core never composes a summary. It arrives already rendered and is stored
verbatim; core cannot phrase "gained 15,000 gold" for a game whose
vocabulary it does not know.
- visibility fails closed. An item with no stated visibility is `members`.
- a push never throws at its call site. It is called from inside a game-event
handler, and a storage problem of core's must not become the module's
control flow.
Core emits four of the five kinds §4.2 names — `core.forum.thread` has nothing
to emit it until the forum lands in phase 4 — and emits none of them for a
Team's FIRST roster: importing a 155-member guild is one Team arriving, not 155
people joining, and a join per member would bury every real event under the
import and reach the row cap on day one.
Retention ships with the feed rather than after someone notices. A nightly
worker applies an age horizon and a per-Team row cap, both settings; either
alone has a hole, since age lets one busy guild write a million rows inside the
window and a cap keeps a dead Team's feed forever.
The sync now reads member ROWS rather than keys, replacing the `memberKeys`
call rather than adding to it: the feed needs each changing member's display
name and prior `is_leader`, and the upsert is about to overwrite both.
Co-Authored-By: Claude <noreply@anthropic.com>
|
|||
| cf2666e5bc |
feat(teams): the Team read API, the moderation routes, and Admin -> Teams
The eighteen routes of docs/website/TEAMS.md §2.11, their OpenAPI annotations,
and the staff screen that drives them.
Two rules shape the read model. Hidden means absent from every public surface --
the index, the lookup and the roster alike, and a hidden Team 404s
indistinguishably from one that does not exist, because "absent" includes not
confirming it is there. And staleness is surfaced rather than silent: every
public payload carries { configured, stale, lastSyncAt }, so a page can say how
recently the projection was confirmed instead of presenting stale data as
current.
The public roster withholds both the member key and the user id -- one is a
game-internal identifier, the other names a site account. `linked` answers the
only question a public page has without publishing which account. The module's
per-audience field projection is phase 3's; this is a conservative core one.
The §2.9 gate is enforced per REQUEST, not per route. A moderator may call all
eighteen; three of them mean something different when they do, and the server
decides from the role it re-validates on every request rather than from a token
claim. The client has no "file as request" argument to get wrong.
Found by booting the real server against the real database, and not by any test:
**the index and the by-slug lookup disagreed about what exists.** listPublic was
keyed on a registered team provider while findBySlug is not, so with no module
installed `/teams` returned an empty list while `/teams/:slug/members` served a
full roster -- the index denying a Team that direct URLs answered for in full.
The rows are core's and they outlive the module that filled them: an uninstalled
module leaves a projection that is unmaintained, not one that stopped existing,
and `configured: false` is how a client learns that. The read side no longer
takes the provider into account at all. There is now a test named for the
property.
Also verified live: the public routes answer anonymously, an unknown and a hidden
slug both 404, the player and admin tiers 401 an anonymous caller, a seeded
roster projects correctly, and the reconciler logs that it is staying idle with
no provider registered rather than failing a boot.
Process obligations, all done: #swagger.* annotations on every route, `npm run
swagger` regenerated (18 paths in the spec, no dangling $refs, and the schemas
they reference added), `npm run routes:manifest` regenerated -- additions only,
184 public routes -- and BACKEND_DESIGN.md updated across the schema section and
all three tier tables.
Admin -> Teams follows the ModulesAdmin precedent: everything that decides what a
row SAYS lives in lib/teamAdmin.js, which is plain JS with tests, and the view
renders it. That split earns itself here specifically -- the screen's job is to
make "the shard has no Teams" and "core has not been able to ask for two hours"
impossible to confuse, and those two produce the same empty table. The four
freshness states are named and tested for exactly that reason, and the last
provider error is shown verbatim rather than paraphrased.
The button labels follow the caller's role: a moderator sees "Request publish",
so the pending result is not a surprise. Hiding is offered to everyone with no
gate, matching the server.
Server 894 passed, client 206 passed, client build clean. 17 route tests, 20
client display tests.
Refs docs/website/TEAMS.md §2.11, Part 12 phase 2
Co-Authored-By: Claude <noreply@anthropic.com>
|
|||
| 8fe2e01466 |
feat(teams): reserved-name screening, auto-hide, and the admin-approval gate
The one place untrusted game data becomes a public page (docs/website/TEAMS.md
§2.8), and the gate on releasing it (§2.9).
A Team's name is written by a player, in the game, with no review, and this
platform turns it into a public page, a URL and eventually a Discord channel
name. Someone naming their guild "Admin" or "<Brand> Staff" gets an
official-looking page on the operator's own site for free.
Hide, never reject. Core cannot refuse a name -- the guild already exists in the
game and core is a mirror of it, not an authority over it. A match hides the Team
from public surfaces and files it in a review queue, and it keeps working
completely for its own members: their forum, their grants, their notifications.
The people in it are not being punished for a name their leader chose.
That asymmetry -- a false positive costs a human glance, a false negative costs
an impersonated staff page -- is what lets the matcher be conservative. It is not
licence to be sloppy the other way: a check that fires on "Badminton" gets
switched off, and then the real cost is paid in full. So matching is whole WORDS
after normalisation, never substrings, following the precedent
scripts/checkModuleIdentifiers.js set for exactly this reason.
Three matcher gaps found by writing the tests, all real impersonation vectors:
- "Guild of Moderators" did not match `moderator`. Only a trailing s off the
WHOLE term is stripped, so "Nomads" still does not match `mod`.
- "G.M." normalises to two single-letter words and matched nothing. A run of
two or more single-letter words is now also offered joined. Deliberately not
a whole-name condensation, which would re-admit substring matching.
- The multi-word condensed form was already handled and is what makes
"RunicGateway" match the two-word term -- the form an impersonator would
reach for, since it is what the Gitea org and every URL use.
Terms resolve at CHECK time, never baked in, so renaming a deployment protects
the new name without a redeploy. A failed settings read falls back to the static
role and project terms rather than to an empty list: screening fewer terms is
bad, screening none is the whole hole.
Re-screening runs on every reconcile, over names no human has ruled on. Names are
immutable per row, so it only ever changes an outcome when the TERM LIST changed
-- an operator adding one, or a rename -- which is exactly what a create-time-only
check would miss forever. `name_reviewed_at` is what makes a staff decision
sticky; without it an override would be undone every fifteen minutes.
The gate is scoped to three actions because they publish untrusted game-sourced
strings, and to nothing else. Ordinary forum grants, leadership overrides,
archives and forum moderation still apply immediately and are audited. A
moderator initiating one files a pending request; an admin applies at once.
Never four-eyes on admins: users.role defaults to admin and `npm run seed`
creates exactly one, so most deployments have precisely one and a second-approver
rule would wedge them with no way out.
Hiding is deliberately NOT gated. Publishing untrusted data needs a second pair
of eyes; withdrawing it needs to be possible at once, by whoever is on duty.
Two concurrency details worth the review: a decision moves the row out of
`pending` under a guard and applies its effect only if the row actually moved,
so two admins clicking approve cannot double-apply or overwrite each other's
record; and a JSON payload is parsed defensively, because the driver returns
JSON columns already parsed on some versions and as a string on others.
Screening is stubbed in the reconciler's own tests -- it is a separate unit, and
the real call reads settings, which this suite must never do against a live
database. That was caught the hard way: the suite went from 11s to hanging, and
the cause was the reconciler reaching a dead pool through the new call.
44 tests in the reconciler file (up from 39), 19 for the matcher, 25 for the
gate. Full suite 877 passed, 0 failed.
Refs docs/website/TEAMS.md §2.8, §2.9, Part 12 phase 2
Co-Authored-By: Claude <noreply@anthropic.com>
|
|||
| bfd844e8fb |
feat(teams): the four-path access resolver and staff leadership overrides
The four authority paths of docs/website/TEAMS.md §2.5, and the rule that they stay four: four tables answering four questions, and no resolver reads another path's table. 1. Is this account a member? module team_members 2. Does this account lead the Team? module team_members.is_leader + override 3. May it use the Team forum? CORE team_forum_grants OR path 1 4. May it get external access? CORE derived, nothing of its own The temptation this resists is collapsing 1 and 3 into one boolean. They answer different questions about different populations: a forum grant may name any Runic Gateway account, including one with no game identity at all -- that is the point of it, since letting an unlinked guildmate into a forum must not require a staff ticket. Reading "has forum access" as "is a member" would put that person on the public roster, into every membership count, and into the external-platform grant, which is where a modelling preference becomes an impersonation risk. Path 4 is deliberately blind to path 3, and the reason is written down so nobody "fixes" it: an integration cannot verify that an unlinked, forum-granted account corresponds to a real game member, so it must not hand that account a privilege on a platform where impersonation has consequences. A forum is a room on the operator's own site with a known moderator; a Discord role is an identity claim in someone else's space. Leadership overrides are applied ON TOP of the synced value at read time, never written into the projection. The sync owns that column and rewrites it every interval, so an override stored there would be undone fifteen minutes after staff set it -- which is the whole reason §2.5.1 is a separate table. The roster carries both the resolved answer and `is_leader_synced`, so an admin sees that a decision was made rather than being shown it as fact. Three tests are named INVARIANT rather than for behaviour, because what they protect is structural and a reasonable-looking refactor destroys it silently: a grant never writes the membership projection, a granted user is absent from the roster, and a grant does not confer external eligibility. None of those failures appears on a screen as a bug -- the first shows up as a stranger on a public roster, the second as a Discord role handed to an account nobody can tie to a real player. Every unit test here stubs the db layer, so the SQL itself was verified separately: all 44 statements across teams.db.js and teamAccess.db.js were run against MariaDB 11 with a throwaway module id and cleaned up after. That run also confirmed live what the reconciler's tests could only assert against a stub -- an upsert does not overwrite is_leader, a revoked grant frees the unique key for a new one while the ledger keeps both, and an archived team stays resolvable at its old slug while its external_id is free for the successor row. 19 tests. Full suite 828 passed, 0 failed. Refs docs/website/TEAMS.md §2.5, §2.5.1, §2.6, Part 12 phase 2 Co-Authored-By: Claude <noreply@anthropic.com> |
|||
| 92631347f9 |
feat(teams): the reconciler, its four refusal gates, and ctx.teams (API 1.6.0)
Core's projection of the module's Teams, kept in step (docs/website/TEAMS.md
§2.4), plus the two ctx members a module pushes through.
The four gates are the file, and each is invariant 1 in a different costume --
module unavailability is staleness, never emptiness:
1. getTeams() not ok -> record the failure, touch NOTHING, return.
2. ok but empty, core holds >=1 -> quarantine; apply only if the NEXT
authoritative answer, an interval later,
agrees.
3. getTeamMembers() not ok -> that Team's roster untouched and stale; the
other Teams sync normally.
4. ok but zero members, had some -> the same two-strikes quarantine, per Team.
Gates 2 and 4 exist because an authoritative-looking empty answer during a cold
start is the one failure indistinguishable from a real wipe. "Every Team on the
shard disbanded at once" costs one interval to confirm; getting it wrong empties
every roster on the site.
Events are an optimisation, never the source of truth. Member and leadership
deltas apply at once for a Team core already knows; team.created and
team.disbanded only ask for a run. §2.2 scopes archival to an authoritative full
list, so a repeated or spurious disband event costs a reconcile rather than a
Team -- and a Team invented from a delta would have no name, no roster and no
leaders anyway.
Two columns TEAMS.md did not contemplate, both on `teams`:
- roster_synced_at, because team_sync_state holds one row per MODULE and gate 3
leaves ONE Team behind while the others sync. Without a per-Team stamp that
Team's page would report the module's last success as its own -- exactly the
staleness the gate exists to surface.
- members_empty_since, gate 4's per-Team quarantine. The twin of
team_sync_state.pending_empty_since, which is per module and cannot express it.
One real bug found by its own test. The roster upsert was writing is_leader, so a
refused getTeamLeaders() left every member demoted -- the roster had already
written `leader: false` before the authoritative call was even made. §2.5 is
explicit that path 2 is answered by getTeamLeaders(), so is_leader is now set on
INSERT only (seeding a Team so it is not leaderless while that call fails) and
moved afterwards by setLeaders() alone. Two writers for one column was the whole
defect.
MODULE_API_VERSION 1.6.0 on both halves -- they state one contract and a module
declares one coreApi range. The number covers the whole Team surface per Part 11;
the members arrive by phase. registerTeamProvider, ctx.teams.publish and
ctx.teams.reconcile are live. ctx.teams.activity.push (§4, phase 3) and
api.registerSlashCommands (§7.1, phase 7) are present and THROW with a sentence
naming their phase, rather than being absent or silently accepting data into
tables that do not exist yet.
39 tests here, and the ctx surface guard in moduleLoader.test.js updated -- it
caught the addition, which is what it is for. Server 809 passed, client 192
passed, 0 failed.
Refs docs/website/TEAMS.md §2.2, §2.3, §2.4, Part 11, Part 12 phase 2
Co-Authored-By: Claude <noreply@anthropic.com>
|
|||
| 8b63ffc725 |
feat(modules): registerTeamProvider, and a call path that cannot answer "empty"
The registration a module uses to become the authoritative source of Teams
(docs/website/TEAMS.md §2.3), plus the wrapper core calls it through.
registerTeamProvider is the first registration where core CALLS THE MODULE and
waits for an answer. Every existing one is either the module claiming a mount or
core notifying it; the closest precedent is registerAnnounceLeg's dispatch, and
this is modelled on it rather than invented. It also holds a single value rather
than a map, unlike every other registry: Teams have one authoritative source by
construction, and two modules answering "what teams exist" would produce two
disjoint sets under one `teams` table with no rule for merging them. A second
registration is therefore a collision, named against the module that holds it.
teamProvider.js is where invariant 1 -- module unavailability is staleness,
never emptiness -- is actually enforced. It is deliberately generous about what
counts as a failure: a rejected promise, a synchronous throw, a timeout, a
non-object, a bare array, a missing `ok`, or a structurally malformed row all
leave as the same `{ ok: false }` a module would have sent on purpose. There is
no shape a broken provider can produce that arrives at the reconciler looking
like an authoritative empty list -- which is the entire argument for the
envelope, since a bare array has exactly one such shape and it is the one a
module returns while its sidecar is still connecting.
A malformed row fails the whole call rather than being dropped. Salvaging is the
dangerous option: one unreadable member quietly omitted from a roster is
indistinguishable, downstream, from that member having left, and the sync would
mark them departed on the strength of a broken payload. Refusing costs one stale
interval.
The deadline timer is unreffed as well as cleared. Clearing covers the case
where the race settles; it cannot cover a module promise that never settles at
all, where nothing exists to clear until the deadline fires. Caught by the test
file taking 10.2s to run 265ms of assertions -- the same class of bug as the
mariadb pool that used to hold the suite open (test/_setup.js). 292ms now.
28 tests. Full suite 770 passed, 0 failed.
Refs docs/website/TEAMS.md §2.3, Part 12 phase 2
Co-Authored-By: Claude <noreply@anthropic.com>
|
|||
| a4da1cc438 |
fix(modules): stop a module before purging its tables
Uninstall-with-purge ran purge.sql while the module was still started: the tables went, and the module kept serving and ingesting against a schema that no longer existed until lifecycle.stop() finished — up to the five-second hook budget. For module-uo that is the uo-link WebSocket writing shard events into dropped tables, and requests in flight answering 500 where a stopped module answers 404. Nothing required the old order. The comment justified it as "purge while the SQL is still readable", but removeDir is the only step that touches the filesystem, so purge.sql stays readable until after the stop. The 400 for a module that ships no purge.sql is now resolved before anything is stopped, so a refused request leaves the module exactly as it found it. Found while proving Phase 4's acceptance criterion 2 against the real module-uo v0.3.0 release on an empty database (MODULE_SYSTEM.md §2.7.2). 742 server tests (+1); routes.manifest.json and swagger-output.json byte-identical. AI disclosure: this contribution was AI-assisted (Claude Code). Co-Authored-By: Claude <noreply@anthropic.com> |
|||
| ec2b530be7 |
test(login): stop the backoff-guard test racing its own one-second lock
A single recordFailure() locks for BASE_MS * 2 ** 0 — exactly one second — and the test then does a real HTTP round trip against it. On CI that round trip took 1,456 ms and the guard correctly answered 200, failing the run for a reason that has nothing to do with what the test is about. Five failures lock for sixteen seconds. The subject is the guard's answer while locked out, which is unchanged. Co-Authored-By: Claude <noreply@anthropic.com> |
|||
| 9b16f39a52 |
feat(modules): the declarative Docker path (phase 4, slice 3)
MODULES declares the module set a deployment runs, one entry per module as
`<id>@<version>=<install manifest URL>`, and the container arrives at it by
itself (MODULE_SYSTEM.md §2.7.2 decision 4). A module already unpacked at the
declared version is a no-op that makes NO network call, so a restart with the
network down comes up unchanged; anything else goes through install.js — same
allowlist, same sha256, same inspect-then-extract — and install() now takes an
`expect: {id, version}` so a URL resolving to another module or version is
refused while it is still only a manifest.
Resolution runs inside start(), between the seed and the require of app.js: the
seed is where the host allowlist setting comes from, and the require is what
scans the volume. That buys it the database, so a compose-installed module gets
the same provenance columns an admin install writes.
A failure is logged and carried, never fatal — an unreachable release host must
not take the site down. The declaration owns what is on the volume; the row owns
whether a module runs, so uninstalling a declared module returns its files at
the next start and leaves it disabled. The admin list gains that as a fourth
source (declared / declaredVersion / declaredError), because a declared module
that failed to resolve has no row, no directory and nothing mounted.
Deferring the app require moved core's schema ahead of the volume scan, and the
module schema-fragment replay was wired to core's schema — so every installed
module silently got no tables. Invisible to the suite (each one stubs the loader
or the pool) and to a smoke on a database that already had the tables; found by
booting against an empty one. ensureSchema() now takes `replayModules: false`
for the one caller that scans later, server.js replays them itself after the
require, and a bootOrder test pins the five steps in the only order they work in.
741 server tests (+18), 187 client (+5); manifest unchanged at 166 public + 2
internal, OpenAPI byte-identical.
Co-Authored-By: Claude <noreply@anthropic.com>
|
|||
| 732927a6bb |
fix(modules): three defects a real install exposed (phase 4, slice 1)
Standing the slice-2 screen up against a live server and installing the
published module-uo v0.3.0 through it found three things, none of which any
unit test in this repo could have caught. Two of them are older than this
phase.
1. The boot refresh nulled every install's provenance
--------------------------------------------------------
`installed_modules.source` and `.sha256` exist so the admin panel can say
where a module came from. They never survived a restart.
`lifecycle.boot()` re-records every scanned module with no source and no
sha256 -- correctly, because a scan finds a directory and never where it came
from -- and `upsert` assigned both columns unconditionally. So an install's
provenance lasted exactly until the restart that install asked for, and the
screen then described a module installed from a URL as "placed on the volume
by hand". Verified live: install, restart, provenance gone.
Nothing could have caught it before now. Phase 4 wrote the first non-null
value these columns had ever had, so lifecycle.js's comment asserting that
"recordInstalled leaves what it is not given" described an intention rather
than the statement below it -- and modules.model.test.js's fake reproduced
the defect faithfully, assigning unconditionally just like the SQL.
Fixed with COALESCE(VALUES(col), col): a value overwrites, a NULL leaves what
is there. The fake now matches, and two tests pin both directions -- a boot
refresh must not wipe it, and a re-install from a new URL must still replace
it, or the column would become write-once and an upgrade would for ever show
where the first version came from.
2. The restart killed the server on Windows instead of stopping it
------------------------------------------------------------------
The route called `process.kill(process.pid, 'SIGTERM')` to reach server.js's
graceful-shutdown handler. That works on Linux. **Windows has no POSIX
signals, and Node documents SIGTERM there as unconditional termination of the
target process** -- so on a Windows host the restart killed the server
outright: no module onShutdown, no listener close, no pool close, no log
flush. Observed exactly that: the process was gone and the shutdown handler
had logged nothing at all.
`process.on('SIGTERM', ...)` is an ordinary EventEmitter listener, so
`process.emit('SIGTERM')` reaches the same handler on every platform without
involving the OS. One shutdown path, still; it just gets there by an event.
Deployment is Linux containers and would never have shown this. Development
is not, and neither is the smoke that found it.
The test was worse than useless: it stubbed `process.kill` and asserted it
had been called with SIGTERM, which is precisely the call whose MEANING
differs by platform. It now waits for the SIGTERM EVENT -- what server.js is
actually subscribed to -- so a pass here means the handler would run.
3. `present()` did not publish the running version
--------------------------------------------------
An upgrade writes new files and a new row while the old code stays loaded, so
the row's version is a promise about the next boot rather than a description
of this one. Adds `liveVersion` from the loader beside `liveState`, so the
screen can tell the two apart instead of reporting the new version as running.
723 server tests (+2), manifest and OpenAPI both unchanged.
Co-Authored-By: Claude <noreply@anthropic.com>
|
|||
| b30e82cde2 |
feat(modules): install, uninstall, purge and restart (phase 4, slice 1)
The consumer half of a release module-uo's CI has been publishing since
phase 3 closed. Before this, core had the installed_modules provenance
columns and no code that could ever fill them: nothing fetched, verified,
unpacked, removed or purged anything, and there was no admin route at all.
Adds modules/archive.js, modules/install.js, schema.runPurge(),
lifecycle.stop(), loader.stopHook(), and /api/v1/admin/modules with eight
routes. 797 server tests (+76), manifest 158 -> 166 + 2 internal, OpenAPI
gains 8 operations and loses nothing.
Reject, never sanitise
----------------------
The download is the easy part: an https-only allowlist re-checked on every
redirect hop, a declared sha256 compared against the bytes that arrived, and
a byte cap. Unpacking is where the archive chooses the filenames, and core
writes into a directory bind-mounted from the host, so an escape is not
confined to the container.
archive.js inspects the whole archive before a byte is unpacked and refuses
absolute and drive-absolute paths, `..` segments, NUL bytes, backslashes,
anything that is not a regular file or a directory, more than one top-level
entry, and anything over the entry or byte caps. Refusing symlinks and
hardlinks outright is what keeps this off the majority of node-tar's
published advisories rather than depending on the library to contain them.
That two-pass shape is load-bearing, and it was measured rather than assumed:
extracting an archive whose fourth member escapes upward throws under
node-tar 7.5.22 -- and leaves the first three members on disk. The loader
scans that directory at require time on the next boot, so a half-unpacked
module is a module. Everything therefore happens in a scratch directory that
is removed on any failure, and the move into place is the last step.
`tar` is pinned to ^7.5.22 rather than the ^6 that installs by default: 6.x
is flagged critical, and reading the advisory list is what the file's header
now says out loud -- almost all of it is hardlink or symlink traversal and
PAX header interpretation differentials, which is exactly this feature's
threat model.
Two things the plan had wrong
-----------------------------
The bundle's top-level directory is `module-uo-<version>`, not the module id
-- so "the top-level name must equal the id" was checked against nothing real.
The extractor strips that level instead, because its name belongs to whoever
published the bundle and the directory it lands in is core's. What is checked
instead is the unpacked module.json: a manifest promising `uo` and delivering
something else is refused rather than installed under the name it promised.
And purge cannot be a follow-up action (decision 5): purge.sql lives inside
the directory uninstall deletes. It is offered in the uninstall flow and as a
standalone action on a still-installed module, and the standalone one refuses
unless the module is already disabled -- dropping tables under something that
is still serving leaves it answering out of a world that no longer exists.
Disable now means stopped
-------------------------
lifecycle.stop() dispatches that one module's onShutdown before flipping the
guard, so a module an operator switches off actually releases its sockets and
closes its streams instead of merely becoming unreachable. The hook runs
first and the state moves after it, because while onShutdown runs the module
is still `started` and that is the only state in which its routes and the
world it is tearing down agree. A hook that throws does not stop the disable
-- the opposite of the boot path's rule, and deliberately.
Enable is not its mirror and there is no start(id) beside it. There is no
onBoot re-dispatch and the hooks were never promised re-entrant, so enable
moves the row and the restart route starts it. A test pins that enable does
not touch the loader, because "fixing" it is a one-line change that would put
a module with closed sockets back on the nav.
Restart raises SIGTERM against its own process rather than calling the
shutdown path directly, so server.js's handler stays the one graceful-shutdown
path and this route cannot drift from it.
The allowlist bootstraps from MODULE_SOURCE_HOSTS into a settings row and is
admin-managed after that (decision 6); seedDefault is INSERT IGNORE, so
changing the variable on an existing deployment is a no-op by design. An empty
list forbids every install rather than allowing every host -- the safe
direction for a value someone might blank by accident.
Verified against the real v0.3.0 release
----------------------------------------
Not a fixture: fetched the published install manifest over the real Gitea
host and its redirect chain, verified the sha256, inspected and unpacked the
252,517-byte artifact to 82 files, and then booted core against the result --
the module registered its five mounts, seven streams and eight capabilities
and resolved its client chunk, with no scratch directory left behind.
Two defects this slice's own tooling caught, both of which had already been
written down as classes:
- the controller destructured runPurge at require time, capturing the
function rather than the module, which made the one dependency whose
ORDER matters the one that could not be substituted;
- two swagger annotations carried an apostrophe inside a quoted string,
dropped silently by swagger-autogen before slice 5 taught it to fail loudly.
Co-Authored-By: Claude <noreply@anthropic.com>
|
|||
| adff20be7b |
feat(modules): merge module OpenAPI fragments into /api/docs.json (phase 3, slice 5)
Core's half of the slice that closes phase 3. Two things: the request-time
fragment merge core has owed since phase 1, and the last of core's UO copy.
**The merge (MODULE_API.md §6.1a).** `swagger-output.json` is core's own routes
and cannot be anything else — it is generated on a developer's machine and
committed, so it must come out the same regardless of what they had checked out,
and a module arrives on a volume long after the image was built. Module routes
therefore reach the document at request time, from the `swagger-fragment.json`
each module ships: `swagger/docsSpec.js` merges the fragments of STARTED modules
over the committed spec, cached on a new loader state version and rebuilt when a
module's state moves.
Until now neither half existed. `swagger/mergeSpec.js` named the request-time
caller in its header and that caller was never written, so the 72 routes
module-uo serves were in no OpenAPI spec at all — core's standing rule ("never
ship a route that isn't in the spec") broken by the extraction rather than by a
route.
Core wins every key collision, `swagger-output.json` is never mutated (it is a
require()d JSON module — one in-place merge would be permanent AND cumulative),
and a fragment that is missing or unreadable costs that module its paths and
nothing else. The Swagger UI is now built per request for the same reason the
JSON is: bound once at require time it would show core's routes for the life of
the process while /api/docs.json showed the merged set.
**The last of core's UO copy** (slice 4 deferred it; §5.2's check reads code, not
prose, so none of this was caught):
- 31 UO schemas and 4 UO tags in `swagger/swagger.js`, describing routes core has
not served since slice 1 — 578 lines. They moved to module-uo, namespaced
`Uo…`, and arrive back through the merge on an instance that installs it.
- `info.description` said "a private Ultima Online shard".
- README.md's 48 UO mentions, including the architecture diagram and the whole
`## Shard integration (uo-link)` section, now `## Modules`.
- `TOWNCRIER_DURATION_SEC` and `UOLINK_*` in the two `.env.example`s: read by the
module, not by core, and documented in the module's README instead.
**Two dropped annotations, and the reason nobody knew.** swagger-autogen reports
an annotation it cannot parse and then prints Success in green, having skipped
it. `npm run swagger` now captures its diagnostics and fails — which immediately
found `POST /api/v1/admin/invites` and `POST /api/v1/auth/invite/:token/accept`
documented with an EMPTY request body, both since the day they were written.
Fixing the tag list also cleared five tags used by routes but never declared
(`Admin · Email`, `Admin · Invites`, `Admin · Moderation`, `Admin · Pages`,
`Auth · Me`) — the same defect class, in the other direction.
- 646 server tests (+9), 157 client tests unchanged
- routes.manifest.json unchanged (158 public + 2 internal); check:modules clean
- swagger-output.json: 128 paths, 69 schemas, 0 orphan tags, 0 orphan schemas
- verified against a real boot with module-uo installed: 197 merged paths
(128 core + 69 module), all four module tags, 31 Uo schemas, no dangling $refs,
/api/docs renders the module's operations with zero console errors
Refs: docs/website/MODULE_API.md §2.8, §6.1a; MODULE_SYSTEM.md §2.7.1
Co-Authored-By: Claude <noreply@anthropic.com>
|
|||
| 0c4eacfa4a |
refactor(modules)!: de-UO core's copy, and enforce it (phase 3, slice 4)
Phase 3's acceptance criterion 1, made real. Three things, one review: **The dead bindings.** `client/src/api/client.js` still carried ~190 lines of UO namespaces — `shard`, `atlas`, the two SSE URLs, `admin.shard/shardOps/atlas/ userShard`, the uo-link and town-crier calls, `player.shard` — with zero core consumers since slice 3 deleted the views. module-uo vendors its own bindings. The five assertions core's `apiClient.test.js` made about those URLs moved with them (Module-uo#5); the encoding test that used `governorHistory` now uses a core route. **The copy.** Core is the platform, not one game's site, so its words are game-neutral now: `About`, `Screenshots`, `Website`'s cards, `Status` (which was never about a game server at all — it reports site mode), `Wiki`, `SiteFooter`, the default hero, `brand.js`'s tagline and description, the seeded wiki categories, and two user-visible NavEditor strings that named a module's admin screen by its proper name. Which game an instance is for is the operator's to say — BRAND_* vars, the hero editor, CMS pages — and every real instance already does: `.env.uomysticmoon.example` sets both brand strings explicitly, so nothing live changes wording. Wiki page SLUGS are untouched: `seedDefault*` only inserts what is absent, so renaming one adds a duplicate page to every install. Also gone: an orphan comment block in `schema.sql` describing the spawn-atlas tables slice 1 took away, and the two settings rows core seeded for a module (`game_account_signup`, `uo_link_protocol_3_migrated`). The second was a live defect — see Module-uo#5, which takes ownership of both and repairs the one-shot migration core's ordering had disabled. **The check.** `scripts/checkModuleIdentifiers.js` + `npm run check:modules`, first step of the server-tests job because it needs no dependencies. It reads CODE, not prose — file names, import specifiers, route path literals, declared identifiers and property names — per §5.2, so core's English may still say "shard" where saying it is worth more than the word costs. Two things it gets right only because getting them wrong was tried first: it matches WHOLE WORDS (a substring pass flags `defaultImage`, which contains "ultIma", four times in this repo), and it strips comments and string bodies in one character walk (a comment contains quotes, a string contains `//`) — the `checkImports.js` lesson. It has its own 17-test suite, because a boundary check that silently stops checking is worse than none. The three §6.5 grandfathering allowlists are exempt by name, and an exemption that stops matching fails the build rather than lingering. BREAKING CHANGE: core no longer seeds `game_account_signup` or `uo_link_protocol_3_migrated`; module-uo's schema fragment does. An install running core without module-uo keeps whatever rows it already has and gains no new ones — nothing in core reads either key. Deferred to slice 5, deliberately: README.md's 48 UO mentions, including a `## Shard integration (uo-link)` section and the architecture diagram. That is documentation, which §5.2 does not cover, and it belongs with the phase-closing docs pass rather than half-done here. Co-Authored-By: Claude <noreply@anthropic.com> |
|||
| f7d27f7a06 |
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> |
|||
| f5e6025dcc |
test: re-point core's suite at what core still owns
25 of 82 test files left with the module. Three that core keeps needed splitting rather than moving, and the split is the boundary in each case. announceJobs.test.js keeps the announce PIPELINE -- the shared backoff schedule, the parent-status rollup, core's Discord leg -- and loses the town-crier text building and classification, which are a module's leg. pushDispatch.test.js keeps the SSRF guard and publish() delivering a content-free tickle, and loses mapShardEvent and the shard fan-out, which are a module's catalog. playerRouteAccess.test.js is the one worth explaining. It guards a real past bug -- an admin 403'd off their own characters -- and it did so through /player/shard/accounts, which is now module-owned. The guarantee it protects is CORE's, though: /player/* is role-agnostic self-service, staff are a superset of players. So it stays here and asserts that through /player/appeals, a core route with the same gate. Moving it would have left core with no test of its own tier rule, which is precisely what regressed once before. The remaining updates are core's own tests catching up: ctx has four more members, registerCore now registers only what core owns (one stream, one leg, no filled slot), and the extension-slot test asks for the DECLARED slot's router rather than the filled one, since core declares it and a module fills it. The gated-surface floor drops from >100 to >50 -- it is there so a filter matching nothing fails loudly, not to track core's exact route count. 616 core tests and 160 client tests pass; the module's own suite is 351. Co-Authored-By: Claude <noreply@anthropic.com> |
|||
| f50541f374 |
feat(modules): ctx additions and the post-hook registry (API 1.1.0)
Everything the extraction needed from core that ctx did not already offer. Additions only, so minor. ctx.activity.log, because an admin action a module performs has to land in core's one audit log or the trail has a hole exactly where a module operates the game -- a module keeping its own log would be a second place to look, which in practice means a place nobody looks. Write-only; reading the log is the admin panel's job and it spans every actor. ctx.users.getById, one function for one caller: the admin.users.detail slot router needs the user its prefix names. ctx.site.baseUrl, because a module has to build absolute links and §2.7 forbids it reading core's APP_BASE_URL -- a getter, not a captured string, so it cannot go stale against the env. ctx.middleware.rateLimit is core's makeLimiter, plus accountChangeLimiter handed over whole. The split is deliberate: a module states its own window and cap because it knows what its endpoints cost, and takes the plumbing from core so there is one express-rate-limit in the process and one place a breach is logged. accountChangeLimiter is shared policy -- core's /auth/me and /player/account sit behind the same counter -- so a module's account-change route has to land IN it rather than beside it. marketLimiter was UO policy living in core's file and leaves with the route it guards. registerPostHook is the fourth registry, and the last thing binding core to the module. Core's post controller called newsGump.syncPost directly: core's CMS naming a UO file. It now publishes what it already knows and a subscriber decides what to do with it. Not folded into registerAnnounceLeg, which fires on the same transition, because a leg is a one-shot DELIVERY with retry and classification while a post hook maintains idempotent STATE, runs on delete as well as save, and refreshes silently on an edit. Also fixes a real loader defect the extraction exposed: schema table names were matched against the RAW file, so a fragment whose header says "every CREATE TABLE carries IF NOT EXISTS" was rejected for a prefix violation on a table called `carries`. module-uo's fragment hit exactly that. Both scans now read split statements, which strip comments -- the same class of bug as a boundary check failing on its own documentation. Co-Authored-By: Claude <noreply@anthropic.com> |
|||
| e0927bc255 |
feat(modules): the client registry, window.__rg and the chunk's script injection
Phase 2, PR 7 of docs/website/MODULE_SYSTEM.md 2.7 — the client half's
delivery. A module's prebuilt chunk is served, injected, handed core's React
and its UI kit, and its routes are rendered by App.jsx. The registry is empty
on a bare core, so nothing an operator can see changes.
Client:
- modules/registry.js — registerRoutes/registerNav/registerFeatureProvider,
with the URL namespace written by core, never by the module
- modules/shared.js — window.__rg: React, react-dom/client, react-router-dom,
react/jsx-runtime, the registry, the seven-member UI kit and the request
primitive, frozen
- App.jsx reads routesFor for all three areas; nav consumption is PR 8
- main.jsx publishes the global, then mounts on DOMContentLoaded
Server:
- the loader validates client.entry and publishes clientChunks() and
clientEntryUrls(); an entry in the module root is rejected, because the
directory it sits in is what gets served
- app.js mounts each chunk at /modules/<id>/ behind the module's state guard
with no-cache; anything else under /modules is a 404, not the SPA shell
- htmlShell injects the tag before </body>, so core's bundle runs first
wherever a bundler puts it
Found by loading a real chunk in a browser, and fixed here: core mounted before
any module chunk had evaluated, because document.readyState during a deferred
script is 'interactive', not 'loading'. Every test passed against that build.
The smoke is written down in MODULE_API.md 7.7.
933 server tests (+23), 123 client tests (+14). routes.manifest.json unchanged
at 230 routes; the OpenAPI spec regenerates byte-identical.
Co-Authored-By: Claude <noreply@anthropic.com>
|
|||
| 291c30f6ff |
feat(modules): publish the installed-module list at /api/v1/public/modules
Phase 2, PR 6 of docs/website/MODULE_SYSTEM.md 2.7 — the first module-system
URL a client can see. The SPA and the Android app feature-detect against the
capabilities a module declares; the shape is settled in MODULE_API.md 2.9.
Four decisions, and what is absent from the payload is most of the design:
* started modules only. A module that is disabled or failed to load is
ABSENT, exactly as 4.4 already leaves its routes and its nav absent, so a
client renders a site without that capability rather than advertising one
that 503s.
* no state, failure_stage or failure_reason. Where a module broke belongs to
the admin Modules screen, and the reason is an exception string from inside
core — not anonymous-visitor business.
* no client chunk URL. htmlShell injects a script tag per started module
(3.1.3), so the browser is handed the tag rather than a URL to fetch. This
endpoint feature-detects; it does not load. MODULE_SYSTEM 2.6 step 4 is
amended to match (API 6.7).
* no siteMode gate and no database — the same class as /public/status and
/public/version, so a client can still feature-detect during maintenance.
It is a capability router of its own rather than a fifth singleton in
site.router.js, and that is load-bearing: the loader's prefix-collision probe
reads the live tier stack and skips root-mounted layers, because a use('/', ...)
matches every path. A route inside the root-mounted site router would be
invisible to it — mounting use('/modules', ...) is what makes "no module may
claim /modules" a rule the loader enforces.
910 tests pass (+9, every one on the boundary — what must NOT appear).
routes.manifest.json gains exactly the one route and routes.guards.json records
it with an empty gates list, which is itself the assertion that it is ungated.
Co-Authored-By: Claude <noreply@anthropic.com>
|
|||
| 32ed8e4411 |
fix(test): stop the suite reaching a real database, and make it exit
`npm test` never terminated. Twenty-two test files omitted the two lines that point the pool at a dead port, so utils/db.js -- which builds its mariadb pool at require time and calls dotenv.config() itself -- picked up server/.env and opened five live connections to the developer's MariaDB. The tests still passed, because they stub their models and never issue a query; the only symptoms were a process that never exited and five connections held for as long as it lived. Thirty stranded workers is 150 connections, which is the whole server's limit, and that is the "too many connections" this workspace has hit before. The convention was right and only ever as good as the next test file's memory of it, so it moves into the harness: test/_setup.js is loaded with --require by the npm script, ahead of the test file it hosts, which is the only moment early enough to matter. It pins the dead port -- dotenv does not overwrite an existing variable, so an explicit DB_PORT= still wins for anyone who wants a live database -- and closes the pool after the file's tests, so the process exits at once instead of waiting out the driver's connect retries. The per-file preambles stay: they keep `node --test test/one.test.js` safe on its own. Two supporting fixes: - db.close() is idempotent. pool.end() throws "pool is already closed" on a second call, and closing twice is now normal rather than exceptional -- the harness closes the pool for every file on top of the suites that close it themselves, and a SIGINT followed by a SIGTERM already reached the shutdown handler twice. - test/_helper.js's close() destroys open connections. server.close() only stops accepting and waits for existing connections to end, and node's global fetch keeps its sockets alive, so the listener outlived the test that created it -- invisible until now, because the pool was holding the process open anyway. announceJobs.test.js alone: 120s+ hang -> 0.35s. The whole suite now finishes in ~75s where it previously did not finish at all: 901 tests, 901 pass, verified three times on CI's exact platform (node:20 on Linux, via Docker). Co-Authored-By: Claude <noreply@anthropic.com> |
|||
| 21196466ed |
feat(modules): boot/shutdown hook dispatch and the installed_modules reconcile
Phase 2, PR 5 of docs/website/MODULE_SYSTEM.md 2.7. api.onBoot/api.onShutdown stop throwing, server.js gains one call on each side, and the 2.4 state machine finally runs against real outcomes -- which is what makes 4.5's `disabled` 404 leg reachable for the first time. Dispatch and reconcile live in src/modules/lifecycle.js rather than in the loader, for the reason the schema replay does: routeManifest.js and swagger.js both require app.js against a dead pool, so the loader may not reach the database. The two halves meet at exactly one function, loader.setState(), so the in-memory record the dispatch guard reads and the row the admin panel reads are moved together and cannot disagree. Four decisions, all recorded in MODULE_API.md 2.5 and 4.4: - The loader classifies its failures by 4.3 step, so failure_stage says where a module broke instead of being a column nothing ever filled. The four steps readManifest covers in one pass label themselves; the rest are inferred from how far load() had got, and an unlabelled throw is recorded against the step that was running rather than guessed at. - A row whose directory is gone is marked startup_failed rather than left claiming `enabled` -- the boot reset has just moved it there, and a row claiming to be enabled for a module that is not on the volume is the one state that is simply untrue. An uninstall leaves `disabled`, which the reset never touches, so this catches only a hand-deleted directory. - Core's eight UO boot call sites stay in server.js until Phase 3. Unlike a registered announce leg, a boot call site already has somewhere to live, so moving it now would be extraction done early in a phase whose exit criterion is that nothing changes. - onBoot gets no timeout. Shutdown races a SIGKILL and boot does not, and a slow onBoot delaying the listener is the contract's promise to a module that must warm up before it serves. The operator's switch wins over everything: a disabled module is guarded, not booted, and does not have its failure re-recorded, or an outcome would silently switch it back on next boot. Every database write in the reconcile is individually caught -- a row that will not update is worse reporting, never a failed boot. 900 tests pass (17 new). routes.manifest.json is unchanged at 229 routes and the OpenAPI spec regenerates byte-identical. Co-Authored-By: Claude <noreply@anthropic.com> |
|||
| 6195c76d61 |
feat(modules): the three de-entanglement registries, with core as the registrant
Phase 2 PR 4 of docs/website/MODULE_SYSTEM.md §2.7. Adds server/src/modules/registries.js and moves core's own notification streams, announce leg and users-detail routes behind it, so the three seams §1.8 and §1.9 named are exercised on every boot before any module depends on them. Registering is validate-then-commit per registrant: the loader stages what a module claims and the second pass commits it, so a module that throws halfway through register() — or fails checkDeclared after it — leaves nothing behind. That is the registry-side twin of PR 2's second-pass mount rule. Four decisions, all the recommended option: - announce legs became a child table. `announce_job_legs` replaces the towncrier_*/discord_* column groups, so the leg set is data: core registers `discord`, module-uo will register `towncrier`, and a module cannot ALTER a core table to add its own. Backfill is guarded on information_schema (a SELECT of a dropped column is a parse error, not a runtime one) and the columns go with DROP COLUMN IF EXISTS. Verified against the live dev DB: three legacy jobs migrated faithfully, three replays, no duplicates. - `mapEvent` dropped from registerNotificationStreams. §1.8 already inverts the push path so a module owns fromShardEvent and calls core's publish() with a stream id it resolved; a second mapping mechanism was a leftover. The public safety filter, the kinds it reads and the streams it protects now live in one file and move together. - core registers through the same staging area a module uses, via an explicit registries.registerCore() in app.js before modules.load(). - core's six /admin/users/:id/shard/* paths now go through the `admin.users.detail` slot, and getUser moved back to admin.controller.js. Found on the way, and the reason two build tools changed: - scripts/routeManifest.js could not decode a parameterised mount. Its unwinder expected `(?:([^\/]+?))`; express 4.22 emits `(?:\/([^/]+?))` with the separator inside the group. The branch had never run. It threw rather than guessing, which is what it is for. - swagger-autogen cannot follow a route into an extension slot — the slot's router is created by declareSlot() and filled later, so there is no literal mount for a static parse. Regenerating deleted 407 lines and printed `Swagger-autogen: Success`, the spike's exact failure (MODULE_API.md §7.4). swagger/slotSpecs.js generates a fragment per filled slot and re-roots it at the prefix the router actually hangs at in the live app — read from the express stack via routeManifest's own mountPath, so the manifest and the spec cannot disagree. swagger/mergeSpec.js is the merge helper core owes for module fragments anyway (§6.1a), proved here against core's own slot first. 884 tests pass (856 before). routes.manifest.json is unchanged at 229 routes. The OpenAPI spec diff is two lines of intent: the retry endpoint's summary, and its `leg` no longer being a fixed enum. Co-Authored-By: Claude <noreply@anthropic.com> |
|||
| 2892d01b24 |
feat(modules): replay module schema fragments after core's
Phase 2, PR 3 of docs/website/MODULE_SYSTEM.md 2.7. ensureSchema() now replays every installed module's schema fragment immediately after core's schema.sql, per MODULE_API.md 2.6. The work splits across two files on the line of whether a database is needed to know the answer. loader.js VALIDATES a fragment at load time, before anything is mounted, because every rule 2.6 states about the SQL is knowable by reading it; a module that breaks one never mounts (4.4, left column). modules/schema.js EXECUTES it, so the only failures there are the ones the database alone could report, and those are post-mount and answer 503 (4.4, right column). Validation is a leading-verb allowlist -- CREATE, ALTER, INSERT, UPDATE, the four core's own schema.sql uses -- rather than the DROP denylist 2.6 words it as. A fragment is replayed on every boot, so TRUNCATE and DELETE would empty a table at each restart and RENAME would fail at the second one; a denylist only ever bans what somebody thought of. A CREATE TABLE missing IF NOT EXISTS is rejected for the same reason: it works once and fails every boot after, which presents to an operator as a module that broke on restart. The splitter moves to utils/sqlStatements.js so core's schema and a fragment are split by literally the same code, which is what 2.6 promises. It is its own file rather than an export of utils/db.js because the loader validates fragments at require time and must not drag the mariadb pool into app.js's require chain. The replay sits outside ensureSchema's wait-for-the-database retry loop: a fragment that throws is one module's failure, not a signal the database is coming up, and retrying core's whole schema nine more times over one module's bad SQL would turn a 503'd module into a two-minute boot. Found while wiring it: db/seed.js calls ensureSchema() standalone for `npm run seed`, without ever requiring app.js, so the loader has not scanned and fragments()'s 7.6 throw would have broken seeding outright. The replay asks isLoaded() and logs the skip rather than swallowing it -- a booting server quietly getting no module tables is the thing 7.6 exists to prevent. Verification: - 856 server tests pass, 14 new. moduleSchema.test.js injects the query fn, so the exact statements and their order are asserted with the pool at a dead port like every other suite. - routes.manifest.json and routes.guards.json diffs are zero lines, 229 routes -- the phase 2 exit criterion. swagger-output.json regenerates byte-identical. - Run for real against the local MariaDB with two fixture modules: a good fragment created its table, applied its ALTER and seeded its row; a fragment whose SQL passes validation but the server rejects (`id NOTATYPE`) marked only that module startup_failed, its route answering 503 while the other answered 200; a second ensureSchema on the same database was a clean no-op. Co-Authored-By: Claude <noreply@anthropic.com> |
|||
| ec1ca7e794 |
feat(modules): the filesystem module loader
Phase 2 PR 2 of docs/website/MODULE_SYSTEM.md 2.7. Adds
server/src/modules/{loader,semver,version}.js: the synchronous scan of
MODULES_DIR, manifest validation, prefix and table-name collision
rejection, per-module try/catch and the mount into the three tier
routers behind the MODULE_API.md 4.5 dispatch guard.
Two decisions the contract left open, both now written up there:
- The load trigger is one explicit modules.load(tierRouters) call in
app.js, not a lazy scan (API 7.6). Accessors throw until it has run,
because "no modules installed" is a real answer a caller must not be
handed by accident.
- Whether core owns a prefix is asked of the live tier routers via
express's own layer.match(), skipping root-mounted layers, rather than
a hardcoded table -- the spike's was already stale when written
(API 4.3).
Mounting is a second pass after every module is validated. Doing it
inside the scan loop makes the first module's layers indistinguishable
from core's, so the second module claiming a taken prefix is told it
collided with core and the module-versus-module check is unreachable.
registerExtension/NotificationStreams/AnnounceLeg and onBoot/onShutdown
throw "not available until phase 2 PR 4/5" rather than no-op; an
accepting stub would let a module believe it had registered something.
No schema replay, no boot dispatch, no installed_modules reconcile --
those are PRs 3 and 5, and until PR 5 a record's state is in memory only.
No module ships on the volume, so nothing an operator or client can see
changes: 842 tests pass, routes.manifest.json is unchanged at 229 routes
and swagger-output.json regenerates byte-identical.
Co-Authored-By: Claude <noreply@anthropic.com>
|
|||
| 3add0063bf |
feat(modules): installed_modules and the module state machine
Phase 2 PR 1 of the module system (docs/website/MODULE_SYSTEM.md 2.7). The table and the state machine only: no loader, no routes, no boot wiring, so nothing an operator or a client can see changes and the route manifest diff is zero lines. The five states of 2.4 live in one `state` column: installed -> enabled -> started, with disabled and startup_failed as the recoverable ones. The row is a record of what happened, never the source of truth for what is mounted -- the loader scans the filesystem at require time, before the database is reachable (MODULE_API.md 4.1), which is what keeps routes.manifest.json generatable against a dead database. Two rules the model owns and the boot path will lean on: - Every boot resets each non-disabled row to `enabled` and clears its recorded failure, so a startup_failed module is retried on the next restart and a fixed one recovers with no admin-panel visit. `disabled` is the one operator decision rather than outcome, so it survives untouched -- and a disabled module's failure is a no-op, never a re-enable. - A failure carries the stage it happened at, and every non-failing transition clears it, so a running module can never show a stale reason. An illegal move throws instead of writing a row that misrepresents the state, except on the two boot-path softenings noted above, because one module's failure must never become everybody's. 22 model tests over an in-memory fake; the SQL and the DDL were round-tripped against a real MariaDB separately. Co-Authored-By: Claude <noreply@anthropic.com> |
|||
| b517d7b2df |
feat(theming): dropdown sections and added links in the public header
Phase 10 of docs/website/THEMING_AND_NAV.md, asked for before the edge -> main
cutover. An admin can now create dropdown sections in the public header, organise
the coded entries into them, and add links of their own.
This deliberately amends §7, which said the override layer "cannot introduce a
`to` that is not already in the hardcoded NAV array". That stays true of every
CODED entry; an admin may now also add a link, restricted to a same-origin path —
no scheme, no protocol-relative //host. A link carries no gate of its own and
needs none: the page behind it enforces its own access, so an added link
advertises a route and never grants one.
The invariant is kept structurally rather than by vigilance. Coded entries live
in an `items` map whose keys must be routes the base array declares, so that map
cannot invent a route; everything that CAN name an arbitrary path lives in
`links`, which is the one place the path rule is applied — on both the write and
the read path.
nav_public therefore grew a { items, sections, links } wrapper. A bare map still
reads as the items map, and a nav with no sections still stores one, so this
changed nothing for a nav that does not use it. Free to do now because nothing
has shipped; after the cutover it would have needed a migration.
The Public tab gets its own editor. A public section is an entry in the
top-level order that the admin created and can drag among the pills, unlike the
admin sidebar's four coded sections, where only membership moves — that is a tree
rather than a list of groups. Deleting a section returns its entries to the top
level rather than removing them, which is the one destructive act this screen
could otherwise commit.
The dropdown opens on click and never on hover, and its trigger is not a link: a
hover menu is unusable on touch, and a trigger that navigates means tapping to
open takes you somewhere instead. Escape closes and returns focus, an outside
press closes, navigating closes, and Arrow Up/Down walk the items.
pruneNav applies the shard-feature gate inside a section and drops one it leaves
empty, so a dropdown never opens onto nothing.
Also fixes a bug this surfaced in the phase 6-8 code: the save path judged "does
this route still exist?" against the palette — the base array already filtered to
what the editing admin can see — so on the public header a feature-gated row's
override could never be carried through and would have been silently reset.
Membership is now judged against the full coded nav while the rows still come
from the palette.
Co-Authored-By: Claude <noreply@anthropic.com>
|
|||
| 32a3ff104a |
feat(theming): wire the three navs and add the admin nav builder
Phases 6-8 of docs/website/THEMING_AND_NAV.md. The public header, the admin sidebar and the player portal now read their override row, and /admin/navigation writes them: rename, reorder by drag, hide, and — on the admin sidebar — move a row into another existing section. The merge always runs BEFORE the role and shard-feature filters in the layouts, which are unchanged and remain the boundary. An override is presentation: it cannot introduce a route, cannot touch a `roles` or `feature` gate, and a stored `hidden: false` on a gated item shows nobody anything. The design scoped these phases as client work, but the server had no way to store a nav row: updateSettings validates and stringifies theme_visual and brand_assets and lets everything else through, so a nav object would have been written as "[object Object]" and read as absent for ever. utils/navOverrides.js mirrors utils/brandAssets.js — strict on write with the offending key named, forgiving on read. It validates shape only; whether a `to` exists is settled client-side at merge time, because the base NAV arrays are client constants and a server-side copy would be a second source of truth that drifts. The nav editor cannot be hidden — its own toggle is disabled, the write path drops `hidden` on that one `to`, and AdminLayout strips it again before merging, which also covers a row edited straight in the database. Orders are written only when the sequence actually differs from the code's, and the comparison is restricted to the rows the editing admin can see, so renaming one item does not pin the position of every other one and a role- or feature-gated item missing from their palette is not mistaken for a reorder. Co-Authored-By: Claude <noreply@anthropic.com> |
|||
| 847cfd2d2b |
feat(theming): brand-asset overrides and a cached, settings-aware HTML shell
Phase 5 of docs/website/THEMING_AND_NAV.md: uploaded logo/hero/favicon overrides on top of the BRAND_* env defaults, delivered through an HTML shell that is no longer built once at boot. - utils/htmlShell.js owns the shell lifecycle: rendered lazily, cached per process, invalidated on a brand_assets/theme_visual write with a 5-minute TTL so other workers converge. A settings-read failure renders the env-only shell and caches that, so a DB outage is not a failing query per page view, and with no rows the output is byte-identical to what app.js served before. - POST /admin/settings/brand-asset/:slot uploads one asset and writes the row in the same call, so an upload never leaves an unreferenced file. It reuses the shared multer allowlist and only tightens it per slot: favicons are PNG-only and capped at 512 KB, logos at 1 MB, heroes at 8 MB. Refused files are unlinked before the response. - utils/brandAssets.js constrains a stored asset to a same-origin path under /uploads, /brand or /assets — these are the only settings values written straight into the page as a URL. Strict on write, forgiving on read. - The shell also carries the resolved theme as a <style id="theme-boot"> block, removing the first-paint flash phases 3-4 deferred; SiteContext drops that block once a successful settings fetch has been applied. - BrandLogo renders beside the MoonDot on all six shells and renders nothing when no logo is set, which is the shipped default. Co-Authored-By: Claude <noreply@anthropic.com> |
|||
| 3d6b2e23a7 |
feat(theming): server-resolved theme engine and admin appearance UI
Phases 3-4 of docs/website/THEMING_AND_NAV.md. Three presets, the curated font shortlist, and /admin/appearance to drive them. The design put the presets in theme.css as [data-theme] blocks. That does not work: SiteContext writes --accent as an inline style on <html>, which beats any attribute-selector block, so a preset's accent would have been painted over by BRAND_ACCENT_COLOR while getPublic().brand.accent -- the value the Android app themes itself from -- reported the other one. Presets now live in server/src/config/themePresets.js. themeResolve.js layers :root <- preset <- custom per field into a token map, getPublic() returns it as `theme`, and the client writes it onto <html>. One authority for the merge, and brand.accent is by construction the accent the site paints. theme.css's :root is untouched, so an instance with no row gets no theme block and renders as today. Also: presets carry the full 15-token palette (eight would have left Fantasy with blue-grey borders); the option catalog is served from GET /settings/theme/options so the form cannot offer what the server rejects; validation is strict on write and forgiving on read; and the Discord bot now fetches the effective accent instead of its boot-time env copy. Fixes a Phase 0 bug in passing: settings/nav.controller.js imported the logger factory rather than calling it, so a DB fault would have thrown a TypeError inside the catch instead of returning 500. Co-Authored-By: Claude <noreply@anthropic.com> |
|||
| ec0036ce6d |
feat(theming): settings-store, nav merge util and radius tokens
Phases 0-2 of docs/website/THEMING_AND_NAV.md. Groundwork only: no admin UI, no consumer wiring, and an instance that never touches the new settings keys renders exactly as it does today. Phase 0 - settings store: - settingsDb.remove() and DELETE /api/v1/admin/settings/:key, the "reset to default" primitive. Defaults for these keys live in BRAND_* env, theme.css and the hardcoded NAV arrays, so reset has to delete the row rather than store a copy of the default. Allowlisted to the five theming/nav keys plus hero_layout_draft, admin-only, idempotent. - GET /api/v1/settings/nav behind requireAuth with no role gate. AdminLayout renders for editors and moderators and PlayerPortalLayout for players, and none of them can read GET /admin/settings, so without this their nav override would silently never apply. - A fifth router group for it: /public is anonymous, /admin/settings is adminOnly, /player is self-scoped data. This is configuration that needs a login. - parseJsonSetting() in utils/settingsJson.js. settings.value is TEXT, so every JSON key arrives as a string; malformed or wrong-shaped reads as absent, never as an error and never half-applied. - theme_visual / brand_assets / nav_public join PUBLIC_KEYS; nav_admin and nav_player deliberately do not. Phase 1 - client/src/lib/navOverrides.js, the pure merge util. Presentation only: it can set label/order/hidden and (grouped navs) group, and nothing else. It cannot introduce a `to`, cannot touch roles/feature, and hidden:false cannot un-hide anything - the existing filters run afterward, unchanged, and remain the boundary. Phase 2 - promoted 23 border-radius literals in theme.css to four tokens at today's values (14x8px, 4x999px, 4x10px, 1x12px). The 7px/6px editor chrome and the two 50% circles stay literal. --shadow-card and --panel-grad were already tokens. Tests: 16 new server tests, 20 new client tests. The route-manifest guard now also asserts /settings/** sits behind requireAuth. Swagger and both route artifacts regenerated. Co-Authored-By: Claude <noreply@anthropic.com> |
|||
| 01a559792c |
fix(shard): answer with the instance name when the shard is unnamed
ServUO ships Server.cfg with `Name=My Shard`. An operator who never edited it publishes that verbatim, so the rules page read "My Shard" under a header carrying the real name. That value is the shard saying *unnamed* rather than naming anything, so the site now answers with its own. `settings.getInstanceName()` resolves `site_title || BRAND_NAME` — the same resolution `getPublic().brand.name` already uses, so an install that set only the site title can never show two different names on two pages. Bare `brand.name` would have been wrong for exactly that case. Substituted at INGEST rather than on read: world.ruleset is also broadcast live, and the same object is handed to the SSE fan-out, so a read-time fix would be undone by the next reconnect's frame. Matched case- and padding-insensitively but only as a whole value, so a shard genuinely called "My Shard Reborn" keeps its name. Fixes a second ruleset writer found on the way: uoLinkSocket.backfill() called shardState.setRuleset directly instead of going through the dispatcher as ingestEach does, so the boot/reconnect snapshot silently skipped this normalization. The two arrival orders have to produce the same stored frame. Also renders a placeholder row on an unscored leaderboard — the instance name with an em dash where a score goes, deliberately not shaped like an entry (no medal, no bar) because a placeholder that looked like a real standing would be a fabricated one. Presentation only; the API still sends an empty `top`. Verified live against the shard + sidecar: rules page and leaderboards on web and Android both correct. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U7CBg11prhLimL9iHSX1bP |
|||
| 8771a1cf6c |
feat(shard): the player-vendor marketplace
Protocol 3.0 §8, the website half. Ingests vendor.listing / vendor.listing.remove
into shard_vendors + shard_vendor_items, serves a searchable public API over
them, and ships /site/market and /site/market/vendors/:serial.
Three things the pages have to say out loud, all consequences of how the data is
gathered:
- The prices are NOT live. The shard sweeps vendors round-robin, so a shop can be
a full cycle behind. The banner is driven by the OLDEST vendor row, not the
newest — the one stale shop is the one that wastes somebody's trip.
- A shop can be truncated. `total` exceeding `count` means the shop holds more
than the shard publishes per frame; the vendor page says "showing 250 of 3,104"
rather than presenting a partial shop as complete.
- An item may have no name. On a shard with no cliloc table the honest render is
the item id, never an invented label.
## The pre-wired visibility rules, re-checked
Part A pre-wired market.ownerName and market.location before the frame existed,
and the sibling rule it pre-wired for leaderboards (`characterName`) turned out
to be INERT because projectValue matches literal JSON keys. Both market rules
were checked against the real frame this time:
- `ownerName` is a real key. Kept.
- `location` is a real key ONLY because the frame nests it. Flat map/x/y/region
would have made the rule match nothing — the same failure, one part later. It
is nested on the wire and on the read model so one rule hides the facet, the
coordinates, the region and the house together; five flat keys would be five
rules that drift apart.
- `ownerSerial` was ADDED. An admin who hides the owner's name and leaves a
serial that the leaderboards and guild boards resolve back to that same name
has not hidden anything.
Tests assert all three bite, on the stored read model AND on the raw frame —
the market's SSE stream is off by default but an admin can turn it on, and a rule
that worked on only one path is exactly the leak §3.6.1 records.
## Notable
- **No payload column on shard_vendors**, unlike shard_points_boards next door.
The board's top-N is a fixed-size list read whole; here the items ARE the
searchable rows, so they are normalized and nothing is left worth duplicating.
- **display_name is denormalized at ingest** (literal name preferred over the
cliloc — a player set it, so it is more specific). Resolving at query time
would put the cliloc table on the hot path and make search-by-name impossible.
Because the shard's diff sweep will not re-send an unchanged shop just because
the site learned what its items are called, a cliloc import now triggers a bulk
re-resolution — 50 ms per thousand rows, never throws.
- **updated_at is written explicitly** on every upsert. MariaDB does not fire ON
UPDATE CURRENT_TIMESTAMP when every column is written back unchanged, and a
shop re-published identically is still freshly confirmed — without this the
staleness banner would age a perfectly current shop forever.
- **LIKE wildcards in `q` are escaped.** `%` and `_` are LIKE metacharacters, not
SQL ones, so parameterization does not neutralize them: `?q=%` would otherwise
match every listing on the shard.
- **Rate-limited** (60/min/IP), the only limited public read. Every other public
GET is an indexed lookup of bounded size; this is a LIKE scan plus a COUNT over
the largest shard_* table, anonymous by default.
- Reconnect backfill pages /market, bounded by MARKET_SNAPSHOT_MAX = 5000 and
stopping on a short page as well as on `total`, so a concurrent sweep shrinking
the index cannot spin the walk.
## How it was tested
673 server tests pass (27 new). Client builds clean; swagger-output.json,
routes.manifest.json and routes.guards.json regenerated.
Verified full-stack against the live MariaDB and a real shard, not only units:
- 27 real vendors / 1,040 listings swept off the ServUO tree, through the Rust
sidecar, into the site — names resolving through the cliloc table ("longsword",
"katana"), real facets and regions in the filters.
- `?q=sword` 682, `?q=%` and `?q=_` **0** (the escape), map/region/price/sort
filters, paging, and the vendor detail route.
- Visibility live: fields gated to staff vanish for an anonymous caller while
shopName and price survive; audience=player 403s; enabled=0 404s; and
/shard/features correctly drops `market` so the nav hides it.
- Re-publishing a shop smaller leaves no orphan items; an identical re-publish
moves updated_at.
- The limiter fires (38x200 then 32x429 on a 70-request burst).
Not covered by an automated test: the two React pages are presentational and this
repo's client suite covers pure-logic modules only. They were driven against the
live API above, but not rendered in a DOM harness.
Co-Authored-By: Claude <noreply@anthropic.com>
|
|||
| bda031566a |
feat(shard): read clilocs from a source SET so shard items get names
Shards edit items and add new ones, and those carry cliloc ids no stock client
table has. Reading exactly one converted file meant an operator had to
re-export 5 MB every time they added one item — friction enough that the table
would simply go stale, which is the failure the spawn atlas was redesigned to
avoid in the first place.
So this mirrors spawnAtlasSource.readSources(): a BASE (the converted client
table) plus every operator-maintained overlay under `custom/`, all re-read on
every boot and hash-gated as a SET. Later sources win, so an overlay both adds
ids the client never had and overrides stock ones the shard re-purposed.
Adding, editing or removing any overlay counts as drift.
`custom/` is the one convention here that is ours rather than the shard's, and
deliberately so: ServUO has no server-side notion of a custom cliloc — they
live in the patched client a shard distributes, and nothing in the tree
declares them. There is nothing to discover. (An operator who does patch their
client cliloc needs no overlay: convert the patched file and the edits are in
the base.) Scale, measured on the live shard: its script tree references 16,434
cliloc ids and only 37 are absent from stock — tens against a 67k base, which
is why this is an overlay and not a second table.
The set brings back a hazard a single file did not have, and it gets the
atlas's answer. A corrupt source fails the parse loudly, but a source that has
VANISHED parses perfectly and imports a table quietly missing everything it
contributed — an unmounted volume is indistinguishable from a deliberate
deletion. So it is staged, not applied (`needsReview`), reported by both the
import and status(), and accepted with `{approve:true}`. That is a flag rather
than the atlas's approve/reject pair because the atlas stores a pending
decision SO THAT approving re-parses; here nothing is stored, so re-reading at
approval time is automatic.
Also reports a per-source breakdown (entries/added/overrode) on import and in
status, which is how an operator confirms an overlay took effect — "overrode: 0"
on a file meant to re-label stock items says it did not.
Two bugs this surfaced, both found by running a shard-style overlay rather than
by another stock-table fixture:
- displayText tidied punctuation unconditionally, so a custom
"Runic Gateway Sigil (v2)" rendered as "(v2". Stripping leftover brackets is
right after a placeholder is removed and wrong otherwise — the same condition
the `%` rule already had.
- CANDIDATE_NAMES did not include `clilocs.plain`, which is the exact filename
CLILOCS.md and the export tool's README tell operators to write. Pointing at
the directory they were told to create failed with NO_FILE.
Verified end to end against the live MariaDB and a real server boot: base-only
import, overlay adding one id and overriding another (per-source breakdown
correct), unchanged set as a no-op, an edited overlay re-importing and
withdrawing its override, a vanished overlay refused with the table intact,
status reporting missingSources, approve applying it, and a file-path
configuration still finding overlays beside it. All three resolve correctly
through the running server: shard-added, overridden and stock. 646 server tests
pass (16 new in clilocSource.test.js, 3 new in clilocParse.test.js); swagger,
routes.manifest.json and routes.guards.json regenerated.
Co-Authored-By: Claude <noreply@anthropic.com>
|
|||
| b61a4d6721 |
feat(shard): resolve cliloc names for items and reward titles
Protocol 3.0 §8.6 (docs/link/v3.md), the dependency order 5 was sequenced
behind. Items on the wire carry a LabelNumber, not a name — the bridge has
always sent it (char.profile.equipment.cliloc, reward titles as a cliloc
number in string form, and one per marketplace listing) but the site had no
table to resolve it against, so a character sheet could only render
`id 1023721` where the game renders "quarter staff".
The number was never the missing piece. The table was.
Sourced from a file the operator converts once from their own client, at a
path from the `cliloc_client_path` setting falling back to UO_CLIENT_PATH.
Nothing client-derived is committed: UO's strings are EA's, exactly as the
creature sprites are. A shard with nothing configured is fully supported —
names render as ids, as they did before.
The conversion step is not avoidable, and that is the substantive finding
here: every current client ships its cliloc files COMPRESSED (first DWORD's
high byte 0x8E, the Mythic container), and ServUO's own bundled
Ultima.StringList cannot read that either — so VendorSearch.GetItemName is
already inert on such a shard and the plugin could not supply names instead.
v3.md's original "read the client's Cliloc.enu" recommendation was therefore
not implementable as written, and its committed db/data/clilocs.json artifact
also predates the Part C corrections (no committed derived snapshots, nothing
EA-derived shipped). Replaced with the spawn-atlas pattern: parse on boot from
an operator-configured path, hash-gated, output gitignored.
- utils/clilocParse.js — pure parsers, fs-free so the suite runs in CI.
Accepts the plain binary layout and delimited text, sniffed by header rather
than extension. Rejects a compressed file BY NAME: without that check the
plain parser reads it as ~19k records of negative ids and 60 KB "strings"
before dying mid-file, and the resulting error names the wrong problem.
displayText() drops the ~1_val~ arguments the bridge never sends.
- utils/clilocSource.js — the fs layer. hashSource reports `compressed` so the
admin panel can flag an unconverted file WITHOUT parsing 5 MB per poll;
otherwise pointing at a client directory reports a healthy file with pending
drift ("ready to import") and the operator only finds out on failure.
- model/shardClilocs — refresh/status/lookup. All-or-nothing replace (DELETE,
not TRUNCATE — TRUNCATE is DDL in MariaDB and implicitly commits). Batched
server-side resolution behind a capped cache; never throws, because a cliloc
lookup is decoration on a character sheet.
- Deliberately NO staged-approval flow, unlike the atlas: the atlas escalates
facet loss because a half-copied tree and a real map change are
indistinguishable from inside the process, whereas a partial cliloc copy
makes the parser fail on a truncated record. The ambiguity the atlas must
escalate is one this parser simply detects.
- No public route. The table is never served AS a table: 67k rows would dwarf
any page using them, and the Android client consumes the same resolved JSON.
Two parser bugs found by building it, both now covered by tests: trimming a
text line before splitting ate the trailing separator on empty-text entries
and silently dropped 55,994 of 123,490 while still reporting success; and
Number('') is 0, not NaN, so a line starting with a separator imported as a
bogus cliloc 0.
Verified against the real client table (123,490 entries) and the live MariaDB:
import 663 ms, hash-gated boot no-op 14 ms, cold resolve 4.2 ms / warm 0.015 ms.
Binary and TSV imports converge on the same 67,496 rows with identical keys
(blank entries — half the table — are dropped at import). A file truncated to
half its length is refused with TRUNCATED and leaves the previous table
serving. Boot logs verified for both the import and the compressed-file
warning; neither blocks startup. All three admin routes exercised over HTTP
with a real session. 629 server tests pass; client builds clean; swagger,
routes.manifest.json and routes.guards.json regenerated.
Not covered by an automated test: the character sheet renders resolved names
in presentational React with no DOM test harness in this repo, and was not
rendered against a live linked-player profile — that needs a logged-in player
with a linked game account and a shard answering a profile RPC.
Co-Authored-By: Claude <noreply@anthropic.com>
|
|||
| 26094459ae |
feat(shard): ingest points.board and publish the leaderboards
Protocol 3.0 §7 (docs/link/v3.md). The shard publishes ~25 points/loyalty
leaderboards — Queen's Loyalty, Void Pool, the nine city loyalties, Clean Up
Britannia — and the site renders them, plus each character's own standings on
their sheet.
Server
- shard_points_boards: one row per system, keyed by the shard's PointsType
name. The top-N list stays inside `payload` — a fixed-size list read whole,
exactly like shard_governors.candidates. Normalizing into an entries table
buys nothing until something needs a per-character reverse lookup, and a
character's own standings already ride inside char.profile.
- shardIngest routes points.board to upsertPointsBoard and deliberately does
NOT log it: this is board state like guild.update, and the shard emits a
frame every time anyone's score moves a top ten.
- uoLinkSocket backfills /points through snapshot() with ingestEach rather
than a replace*: there is no points.remove and the system set is fixed, so
upserting IS the reconciliation, and a system the operator later excludes
keeps its last-known board rather than vanishing.
- GET /public/shard/points and /points/:system behind
requireFeature('leaderboards'), both projected per §3.6.1. :system is
constrained to an identifier before any query runs; 404 for a system never
published, distinct from a published board nobody has scored in (200, empty
top).
The leaderboards field rule now keys on `name`, not `characterName`
Part A pre-wired FEATURES.leaderboards.fields = { characterName: ... }, but
projectValue matches on the LITERAL JSON key and the wire key is `name`. As
written the rule was inert: an admin tightening character names would have got
no enforcement and no error — precisely the failure §3.6.1 records for the
flattened `ownerAcct` spelling. Fixed, with a test that fails if it is renamed
back, and the admin panel's FIELD_LABEL carries the meaning instead.
Client
- routes/public/Leaderboards.jsx at /site/leaderboards. A points.board frame
describes ONE system, so live frames merge over the fetched set by system
key rather than replacing it wholesale the way the ruleset does. Filter
matches board name, system key, or any ranked player — the last is what
makes it useful ("where do I appear?").
- A "Loyalty & Points" section in CharacterSheet.jsx, one edit serving both
PlayerCharacter and AdminCharacter.
- Both treat maxPoints: 0 as UNCAPPED and both fall back to humanising the
system key when nameString is null. Neither is defensive padding: on a real
shard uncapped and cliloc-only names are the majority case.
Verified end to end against the local MariaDB, the Rust sidecar, and the real
ServUO shard: backfill from /points, live SSE delivery (a board absent from the
initial fetch appearing without a reload, and an existing one updating in
place), REST reflecting the overwrite, and the gate at every rung — 200 by
default with names, names stripped but points kept at fieldRules name=staff, 403
plus dropped from /features at audience=staff, 404 when disabled. Page rendered
clean, no console errors beyond the pre-existing React Router v7 warnings.
605 server tests pass; routes.manifest.json, routes.guards.json and the OpenAPI
spec regenerated.
Co-Authored-By: Claude <noreply@anthropic.com>
|