Commit Graph

3 Commits

Author SHA1 Message Date
61d6bfaca2 feat(shard): ingest world.ruleset and publish it at /site/rules
Protocol 3.0 §5 (docs/link/v3.md). The shard publishes its own ruleset —
expansion, which optional systems are on, skill/stat caps, account and house
limits, champion scroll rules, the save/restart schedule — and the site renders
it, so the rules page cannot drift from how the shard actually plays.

Server
  - shard_ruleset: a singleton table (id = 1) holding the whole frame in
    `payload`, with `rev` and `expansion` hoisted. Nothing is normalized out:
    the frame is a flat description of config read as one page, and splitting it
    into columns would mean a schema change every time the shard grows a block.
  - shardIngest routes world.ruleset to setRuleset and deliberately does NOT
    log it — the shard re-emits the whole ruleset on every sidecar connect, so
    logging would append a duplicate row per reconnect, and server.hello already
    marks each of those.
  - uoLinkSocket backfills GET /ruleset explicitly rather than via snapshot(),
    which asserts an array; this covers the order where the sidecar was already
    up and holding the ruleset when we reconnected.
  - GET /public/shard/ruleset behind requireFeature('ruleset') and projected,
    per §3.6.1's rule that a shard read which doesn't project is a bug. `null`
    means the shard has never published one — a real answer, distinct from a
    published ruleset, and the page says so.

Client
  - routes/public/Rules.jsx at /site/rules, live via world.ruleset (a frame is a
    complete ruleset, not a delta, so the newest one wins outright). Caps are
    rendered from tenths — 7000 is 700.0, and showing the raw number would
    mislead. A systems key this build doesn't know still renders, humanised, so
    a newer plugin can't go invisible against an older client.
  - Nav entry gated on the `ruleset` feature, so it hides rather than 403s.

Verified end to end against the local MariaDB and a sidecar fed by a fake shard:
backfill snapshot, live SSE delivery of a changed ruleset, REST reflecting the
overwrite, an empty /feed (not logged), and the gate — 200 by default, 403 at
audience=staff (and dropped from /features so nav hides it), 404 when disabled.
Page rendered clean at all breakpoints checked, no console errors.

497 server tests pass; routes.manifest.json, routes.guards.json and the OpenAPI
spec regenerated.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 14:35:24 -05:00
f30ea66fce fix(shard): enforce visibility on the REST reads that bypassed it
Protocol 3.0 Part A follow-up, found by the live five-rung smoke test.

Part A implemented the visibility framework correctly on the SSE path
and on /guilds + /governors, but the remaining public REST reads never
called into it. The result was that one event was projected live and
served verbatim from history:

  * GET /public/shard/feed returned the stored payload as-is, so
    actor.acct and actor.webId were readable ANONYMOUSLY for every
    logged kind - player.death, player.murdered, mob.killed,
    quest.complete, skill.gain, fame/karma.change, mob.login/logout,
    guild.join. Broader than the guild-leader leak Part A set out to
    close, since it covers every player rather than board holders.

  * GET /public/shard/idoc returned ownerAcct - the house owner's game
    account - to anonymous callers.

  * The `houses` field rules (owner/price -> staff) were dead config:
    neither getIdoc nor getHouses projected, so an admin could set them
    in the panel and nothing happened.

  * /feed filtered on PUBLIC_KINDS, a module-load constant derived from
    the compiled DEFAULTS, so live audience changes did not reach it.
    With `guilds` moved to staff, /guilds 403'd while /feed happily
    served guild.join to anonymous.

Four fixes, all at the root rather than per-route:

1. Rule 1 now matches a field's MEANING, not one spelling. The wire
   nests actors (leader.acct) but the read models flatten them
   (shapeHouse -> ownerAcct, shapeGuild -> leaderWebId), and an
   exact-key check missed every flattened one. isLockedField() locks a
   key that is or ends in acct/webId, case-insensitively, so it fails
   closed for shapes not yet written. The admin PUT rejects those
   spellings too - `ownerAcct` is no longer configurable.

2. visibleKinds(level, config) resolves readable kinds from the LIVE
   config; getFeed uses it and projects each row against its own kind's
   feature. Deliberately independent of the `stream` flag, which governs
   SSE fan-out only - so market history stays readable with its firehose
   off. This makes the set a superset of PUBLIC_KINDS by exactly the two
   vendor kinds.

3. getIdoc/getHouses/getChamps/getPresence project, so every shard
   surface honours the same config.

4. shardEvents.db.list treats an EMPTY kinds array as "serve nothing".
   It previously fell through to the unfiltered query, so a fully-gated
   config would have dumped the whole event log, staff audit included.

Also fixes a bug introduced while wiring this up: projectValue recursed
into any object, so a Date column came back as {}. It now walks arrays
and plain objects only. The unit tests used JSON fixtures and could not
have caught it - the live /idoc read did.

Verified live against MariaDB + a stub sidecar, all five rungs: 13
routes x 5 rungs, defaults reproducing pre-v3 access exactly, zero
acct/webId below admin on any read, unmapped kinds (staff.command,
cheat.detect, login.attempt) reaching only admin on SSE, and audience /
enabled / stream changes taking effect live on an already-open stream.

Tests: 487 server (+9). Swagger regenerated; route manifest unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 10:49:55 -05:00
35e5269ec5 test(server): unit-test auth, invite, password-reset, and public controllers
Add controller-level unit tests (mock req/res, monkeypatched collaborators)
focused on security boundaries and decision logic the API must not regress:

- auth.controller: honeypot handling, non-enumerating generic-fail for every
  credential failure, inactive-account refusal, the TOTP challenge branch that
  must NOT issue a session, register-mode gating + dup-username 409, and logout
  that always clears the cookie and revokes the session (even on error).
- invite.controller: user created at the invite's PRESET role, and the lost
  double-accept race rolling back the just-created user.
- passwordReset.controller: identical generic 200 whether or not the email
  matched (incl. internal errors), per-account mail-failure isolation, the
  single-use consume race, and revoke-everywhere-on-reset with no auto-login.
- public.controller: staff-only draft visibility, token-gated page preview,
  wiki search precedence + unknown-filter handling, contact 502.
- shard.controller (public): the PUBLIC_KINDS feed allowlist and the public
  house view stripping owner/price — both leak-prevention boundaries.

Lifts: auth.controller 46%→94%, passwordReset 33%→93%,
public.controller 28%→65%, shard.controller 45%→68% line coverage;
server aggregate 63.5%→70.4%.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 00:33:01 -05:00