Commit Graph

32 Commits

Author SHA1 Message Date
065bec7ad8 feat(engagement): the email channel on the engine, and the Teams migration (engagement Phase 6)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 28s
PR Checks / client-build (pull_request) Successful in 29s
PR Checks / server-tests (pull_request) Successful in 11m9s
Email becomes a DeliveryChannel driven by rules, and the Team pipeline stops being
its own thing. `teamNotify.forumPost` now emits an event; a rule decides who is
mailed, through which template, and how often at most. One walk goes forum write
-> events.emit -> rule -> outbox -> worker -> email channel -> template -> SMTP.

Seven decisions settled by the org lead before any code:

  - email only moves; the push tickle and the Discord bridge stay direct calls
  - the EVENT carries its access-checked audience, and `members` resolves to it
  - the four Team rules are seeded DISABLED, with an admin banner and a note
  - team_notification_prefs stays, read by the engine as a scoped preference
  - the payload wins and a structural projection fills the gaps
  - the digest keeps computing at send time; only its state generalizes
  - an unsubscribe token turns off the channel it names, and nothing else

Three defects found while building it:

  - `email.button` never absolutized its href, while image and itemList both
    did. Every rule-driven CTA would have been a dead relative link, because a
    trigger's url variables are validated site-relative by construction.
  - Phase 4a enqueued digest-mode recipients for a drain that Phase 6 decided
    not to build. An outbox row snapshots the payload and so has none of the
    three properties the digest design exists for, including the security one.
  - the digest's send-log row carried no address_hash while the instant row
    beside it did, which would have made half the mail uncorrelatable in Phase 9.

Also: engagement_digest_state + a replay-safe backfill, engagement_outbox.scope_key,
a v2 unsubscribe token that still verifies v1 forever, and the canonical
/public/engagement/unsubscribe pair with the old /public/teams path kept
permanently — mail is not editable once sent.

Verified with 1464 server tests, 324 client tests, and a live rig (MariaDB +
Mailpit + a real Team) covering the instant mail, the digest, the generic
template, a pre-migration unsubscribe link and the backfill's replay-safety.

Docs: RunicGateway/docs#TBD

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 20:11:54 -05:00
2a56cbf22a feat(teams): fire the four events, and the routes that configure them
The roster sync tickles at most ONCE per stream per run, not once per member: a
tickle is content-free, so five people joining in one sweep is five identical
notifications and one piece of information. Suppressed on a Team's FIRST roster,
the same condition the activity feed uses and the half where it matters more —
importing a 155-member guild would otherwise wake every one of their phones.

Forum notifications fire from the CONTROLLER, not from the forum model. That file
takes an already-resolved access decision and reads no membership table by design;
the fan-out reads both to compute its recipients, so calling it from inside would
make the forum model transitively depend on exactly what its header says it must
not touch. The model returns a `notify` key the controller destructures out before
the response, so the API's answer to "did my post save" is unchanged.

`pageUrlTemplate` joins the team provider — the one thing phase 6 found that the
design of record had not anticipated. Phase 3 left core with no Team page and
therefore no way to LINK to one, so a notification email could name a Team and not
take you to it. It is data rather than a callback: a function would put a module
hook on the mail path to produce a string that never varies. Relative paths only,
and protocol-relative is refused with absolute.

The unsubscribe endpoint is the only write in the public tier and the only route
with no `siteMode` — the reader is in their mail client, and the mail went out
before the site went into maintenance. POST always answers 200, valid token or
forged: distinguishing them would be an oracle for which (user, Team) pairs exist.
GET redirects and acts on nothing, so a mail client's link scanner cannot mute
Teams nobody asked to leave.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 14:34:54 -05:00
5d9d10b245 refactor(teams)!: Teams is a contract, not a surface — invert the slots
All checks were successful
PR Checks / bot-install (pull_request) Successful in 20s
PR Checks / client-build (pull_request) Successful in 29s
PR Checks / server-tests (pull_request) Successful in 35s
Org lead's correction, and it changes what this phase ships.

TEAMS.md §3.1 and §3.5 put four public pages and three nav rows in core. They
should never have been core's. **Teams is the platform primitive that the API
contract exposes; the module builds the pages on top of it.** module-uo builds
guilds; the Rust module that comes next builds clans. Core does not own the word
for a Team, so a core page under a noun core invented would have sat beside
module-uo's existing /uo/guilds saying the same thing in the wrong vocabulary.

Removed: /teams, /teams/:slug, /teams/:slug/roster, /player/teams, the public
and portal nav rows, the `teams` feature flag and the core feature provider that
answered it. /admin/teams stays — an operator inspecting the primitive is
looking at the primitive.

Kept, and unchanged: the tables, the reconciler, the access resolver, the
activity feed, the retention prune, the whole public/player/admin API,
optionalAuth and the roster projection. That is the contract, and it is what
this phase was actually for.

**So the extension slots invert, which is a new direction in MODULE_API §3.7.**
`team.overview` and `team.member.row` assumed core rendered the page. In their
place `registry.declareModuleSlot(id, name)` lets a MODULE declare a place on
its own page and core fill it. Core fills `uo.guild.detail` with the Team
activity feed — the one part of that page core cannot hand over, because only
core can resolve whether the viewer is inside the Team and the public/members
split is a security boundary.

Three things about the inverted direction are load-bearing:

  - the name is namespaced under the declaring module and that is enforced, not
    conventional: it is the only thing keeping two modules off one name;
  - core's fills are applied at MOUNT rather than eagerly. Core's bundle
    evaluates before every module chunk, so when core registers a fill the slot
    does not exist yet — filling eagerly would silently do nothing;
  - a fill for a slot nobody declared is a no-op, never an error. The declaring
    module is simply not installed, which is the ordinary case. That is the
    opposite of §3.7, where an unknown slot throws, and the asymmetry is real:
    there, core declares first, so an unknown name is always a typo.

`Slot` becomes the eighth member of the shared UI kit, so a module renders the
place with core's own error boundary. It matters more here than anywhere else in
the kit: the thing being contained is core's content failing inside the module's
page.

`GET /public/teams/by-external/:moduleId/:externalId` is added because a module
names a Team in its own vocabulary and core keys the feed by slug. The module id
is matched rather than trusted — an external id is unique only within a module.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 20:58:07 -05:00
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>
2026-08-17 20:15:36 -05:00
cf2666e5bc feat(teams): the Team read API, the moderation routes, and Admin -> Teams
All checks were successful
PR Checks / bot-install (pull_request) Successful in 16s
PR Checks / client-build (pull_request) Successful in 24s
PR Checks / server-tests (pull_request) Successful in 8m56s
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>
2026-08-17 15:27:02 -05:00
39d731d87a refactor(modules)!: move the UO server half out to module-uo
40 files, ~9,674 lines, 27 of 68 tables. Core no longer contains anything that
knows what a shard is.

BREAKING for a deployment only in the sense that the module must be installed
for these URLs to answer -- no URL moved. routes.manifest.json goes 228 -> 158
public routes here, and the 70 that left reappear byte-identical when the module
is loaded: verified by generating the manifest against core+module and diffing
it against the pre-extraction file. Zero missing, zero added, and routes.guards
identical across all 228, so no auth gate moved either.

The five tier mounts are gone from public/admin/player index.js and are still
served: the loader mounts them onto the same routers after every core mount.
That ordering is also what keeps the prefixes unclaimable -- the collision check
asks the live router what core owns, so a second module claiming /shard is
rejected against the mounts actually present rather than against a list.

server.js loses its eight UO call sites to the module's onBoot/onShutdown.
schema.sql loses its 27 shard_*/uo_link_* statements; the two that FK into users
are why the fragment replays AFTER core's schema, and no core table ever
referenced a module table, which is what makes core still able to boot alone.

Verified against a running server with the module installed: it loads, mounts
five prefixes, replays 35 statements, warms up and reaches `started`; public
shard and atlas routes answer 200 with real data (800 creatures, 6,455
spawners), admin and player answer 401 from core's tier gates, and the extension
slot answers at /admin/users/:id/shard/*. Core's own SPA renders the shard and
atlas pages unchanged against the module-served API, with no console errors and
no CSP reports.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 12:08:03 -05:00
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>
2026-08-11 12:07:45 -05:00
291c30f6ff feat(modules): publish the installed-module list at /api/v1/public/modules
All checks were successful
PR Checks / client-build (pull_request) Successful in 26s
PR Checks / server-tests (pull_request) Successful in 1m33s
PR Checks / bot-install (pull_request) Successful in 8m45s
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>
2026-08-10 22:03:22 -05:00
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>
2026-07-29 09:51:50 -05:00
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>
2026-07-28 21:04:44 -05:00
7c769ea8fd feat(atlas): serve the spawn atlas and give operators a panel for it
Protocol 3.0 order 3 (Part C), second of two website PRs. #112 built the data
pipeline; this makes it reachable — six public routes, five admin ones, two
public pages and an admin panel. Still website-only: no plugin, no sidecar, no
new event kinds, no wire change.

The API sits at /api/v1/public/atlas, not under /public/shard. Nothing here
touches the sidecar, so the pages stay complete while the shard is down, and a
/shard prefix would imply a dependency the atlas does not have. Unlike /shard/*
it IS site-mode gated, like /posts and /wiki: a bestiary is site content.

Every route carries requireFeature('atlas') and projects its response. The atlas
feature declares no sensitive fields, so the projection is a no-op today — the
call is there because v3.md 3.6.1's rule is that the FIRST field needing a gate
should be covered by construction rather than by a retrofit.

Two bugs the UI surfaced, both fixed here:

Respawn delays were stored in the wrong unit, sometimes. XmlSpawner writes
MinDelay/MaxDelay in minutes and switches to seconds only when a delay does not
divide into whole minutes, flagging it per record with DelayInSec. A `5` means
five minutes on one spawner and five seconds on the next, both plausible, and
the pipeline stored the raw number. 170 of 6,455 stock spawners are second
flagged. The parser normalises to seconds; the API and UI carry seconds.

That exposed the hash gate as a trap. "Has the tree changed?" is the wrong
question on its own: an install whose maps never change would have kept serving
the old readings forever, because the only thing compared was the tree.
PARSER_VERSION is now stored beside the source hashes and a mismatch counts as
drift, so any future parse correction lands on the next boot.

Also renamed the detail route's spawn-point array to `spawners` — it was
`points`, which is the COUNT on the search route, so one key meant a number in
one place and an array in the other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U7CBg11prhLimL9iHSX1bP
2026-07-28 19:51:22 -05:00
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
f3450686e0 feat(shard): admin-configurable visibility for every shard surface
Protocol 3.0 Part A. Replaces the static PUBLIC_KINDS allowlist - which
was the entire public/admin boundary - with per-feature, per-field
audience control an admin owns from Admin -> Shard Visibility.

Closes a live leak. BridgeJson.Actor() writes acct and webId;
shapeGuild() returned the stored payload verbatim; GET
/api/v1/public/shard/guilds is anonymous. Guild leaders' game account
names and website user ids were readable by anyone, and the same path
existed for governors. Both are now projected.

The ladder is anonymous < logged_in < player < staff < admin, each rung
implying the ones below. Staff satisfy `player` without a linked account
(as /player/* already does); `editor` is a content role and gets no
shard privilege, since mapping it to staff would silently widen what
editors see.

Two invariants are code, not configuration, and both reject rather than
silently ignore:

  1. acct/webId are admin-only always - not configurable, discarded on
     read as well as rejected on write.
  2. A kind absent from KIND_FEATURE never reaches anyone below admin.
     Fail closed, so a shard emitting a new event degrades to staff-only
     rather than to public.

Enforcement is three points over one config: requireFeature() on routes
(404 disabled, 403 out-of-rung) plus field projection; per-connection
filtering on SSE, where a subscriber's rung is resolved once at subscribe
time and frozen so a long-open stream cannot gain privilege; and
/public/shard/features so the SPA hides links it cannot follow.

PUBLIC_KINDS still exists and is still exported (/feed filtering,
notificationStreams) but is now derived from the kind map, so the two
can no longer drift. Defaults reproduce pre-3.0 behavior exactly - a
test pins the derived set against the old allowlist.

Also fixes an SSE resource leak found while testing: a client dropped
because its write threw was removed from the bucket but its keepalive
interval was never cleared, firing forever on a dead socket. Both paths
now go through one drop().

Tests: 478 server (33 new across shardVisibility + shardBroadcast),
43 client. Route manifest and OpenAPI spec regenerated.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 10:04:48 -05:00
565a7d2c20 refactor(server): split public, player and residual auth into capability routers (PR 5)
All checks were successful
PR Checks / bot-install (pull_request) Successful in 17s
PR Checks / client-build (pull_request) Successful in 25s
PR Checks / server-tests (pull_request) Successful in 9m20s
The last split PR of docs/website/API_V2_PLAN.md § Phase 2. public.routes.js,
player.routes.js and auth.routes.js are deleted; each group is now a directory
whose index.js owns the group gate and the mount table and declares no routes.
Every one of the 200 manifest routes is now in a capability router.

  public/  posts (2) wiki (4) pages (2) shard (12) site (4, group root)
  player/  account (8) shard (8) appeals (4), behind noindex + requireAuth
  auth/    login (2) register (1) invite (2) password (3) session (2, root)

No URL moves. All four gates zero-diff: routes.manifest.json (200 public + 2
internal), routes.guards.json, swagger-output.json (198 operations), and
docs/website/api-route-inventory.json was already in sync. 434 tests green.

Notes on the non-mechanical parts:

- public/index.js and auth/index.js carry no group gate, deliberately, and say
  so. The public surface is anonymous by contract (logged-out SPA, Discord bot,
  Android ShardStreamClient on /public/shard/stream); /auth is where a caller
  becomes authenticated. player/index.js gates on requireAuth only, never
  requireRole('player') — staff are a superset of players.
- GET /auth/me has a mount-order dependency: use('/me', meRouter) matches the
  bare /me, so the request runs meRouter's noindex + requireAuth and falls
  through. session.router.js must stay mounted last. Verified by the
  counterfactual — mounting it first still 401s but drops X-Robots-Tag, which
  no manifest or guards file can see.
- loginGuards moved to auth/loginGuards.js (frozen) rather than being copied
  into the three routers that spread it; sso.routes.js drops its duplicate.
- The :param shadowing check was re-run in dispatch order against the built
  stack: 86 routes, 64 literal, none shadowed. /public/wiki/{categories,tags}
  ahead of /:slug is the only ordering-sensitive pair.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 20:52:14 -05:00
c35509e8b3 feat(public): type the brand block so mobile clients get typed theming
All checks were successful
PR Checks / client-build (pull_request) Successful in 9m29s
PR Checks / server-tests (pull_request) Successful in 10m30s
PR Checks / bot-install (pull_request) Successful in 9m21s
Branding is already returned by GET /public/settings (the `brand` block:
name/colors/logo/hero/favicon, per-shard from BRAND_*). §8.6 of the Android
plan asks to confirm it — this makes it a first-class part of the contract so
the app's OpenAPI codegen produces typed branding instead of an untyped map.

- Swagger: add Brand + PublicSettings schemas; /public/settings now references
  PublicSettings (was additionalProperties:true). Brand documents that asset
  fields may be site-relative paths (resolve against the base URL).
- test/publicBrand.test.js locks the brand theming contract the app depends on
  (all fields present; BRAND_* defaults; admin site_title/contact_email
  overrides; accentInt never leaked).

No behavior change to the response — it already carried `brand`; this types and
guards it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-19 11:58:01 -05:00
90c8eae20f feat(public): version/health surfacing for the app first-run probe
Expose a small backend identity/version descriptor (§8.4 of the Android plan)
so a client can positively recognize a Runic Gateway backend on first-run and
run a version-mismatch guard, instead of inferring from an incidental shape.

- New config/version.js: { service: 'runic-gateway', api: 'v1', server: <pkg> }.
- GET /public/status now includes a `version` block (the app already calls this
  on first-run, so it gets identity + version in one round trip).
- New GET /public/version: a lightweight, DB-free identity endpoint — the
  canonical target for the version guard and a cheap liveness check.
- Swagger: PublicVersion schema + version on PublicStatus; /version annotated.
- test/publicVersion.test.js covers the config shape and the DB-free 200.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-19 11:47:26 -05:00
aa2177715e fix(shard): restrict staff in-game location to admins/moderators
All checks were successful
PR Checks / client-build (pull_request) Successful in 9m23s
PR Checks / server-tests (pull_request) Successful in 10m33s
PR Checks / bot-install (pull_request) Successful in 9m18s
The public "Staff online" list on the Shard page exposed each staff
member's in-game location (map + coordinates) to everyone, including
logged-in players and unauthenticated visitors.

Location is now privileged data:
- Server: getOnline inspects the caller's role via getUserFromRequest
  (the same non-rejecting helper siteMode uses on public routes) and
  only includes map/x/y/z for admin/moderator callers. For everyone
  else the fields are omitted from the JSON entirely, so they can't be
  read from the network tab. serial + name (online status) still shown.
- Client: Shard.jsx gates the location span on the viewer's role from
  useAuth() (same pattern as RoleGate); non-privileged viewers see who
  is online but no location field is rendered.

Tests: publicShardOnline.test.js covers admin + moderator (location
included), player + unauthenticated + editor (location omitted).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XmHdsbnLzDMAVQkAoTQSBe
2026-07-18 21:18:52 -05:00
1629796235 feat(houses): tier house visibility — public IDOC-only, staff full, player own
Per request, split the single public house registry into three role-scoped views:

- Public /site/houses → only houses in DANGER (IDOC), by LOCATION (region + map/
  coords). No owner, price, co-owners or decay detail. Renamed "Houses in danger";
  kept live via the public house.decay feed. The full-registry deltas
  (house.update / house.remove — which carry owner/price) are REMOVED from the
  public SSE allowlist so they never reach the public channel.
- Staff full registry → new /admin/houses (admin + moderator, RoleGate + MOD_PATHS)
  backed by GET /admin/shard/houses (modAccess), with owner/price/co-owners/decay
  and search, kept live on the admin SSE channel.
- Player portal → "My houses" home-status section (own houses only, with decay/
  IDOC status) via GET /player/shard/houses, scoped to the caller's linked accounts.

Server tests green, client build clean, swagger regenerated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:50:37 -05:00
080478c4a1 feat(shard): ingest Protocol 2.0 boards — guilds, governors, presence, houses
Phase 1 of the Protocol 2.0/2.1 integration: the read/ingest backend for the four
new uo-link boards, following the established champs/pages pattern (ingest → our
MariaDB + snapshot-on-reconnect + public SSE + token-free public endpoint).

- Schema: shard_guilds, shard_governors, shard_governor_terms, shard_presence;
  extend shard_houses with the house.update registry columns (owner_name,
  co_owners, friends, price, decay, in_registry) so the decay-transition and
  registry feeds share one house row without clobbering each other.
- Ingest: route guild.update/remove, city.update, presence.online,
  house.update/remove; log guild.join (real-time joins feed); region.enter is
  broadcast-only. All new public kinds added to the SSE allowlist.
- Governor term history captured from day one: on every observed governor CHANGE
  the open term is closed and a new one opened, idempotent so backfill/duplicate
  city.update never spawn spurious terms. votes stays NULL (the feed carries only
  candidate count, not tallies) — we never fabricate vote numbers.
- Client + backfill: getGuilds/getGovernors/getHouses/getPresence; snapshot each
  board on every WS (re)connect, independently guarded so an empty/failed board
  (e.g. no City Loyalty) never wipes another.
- Public endpoints: /shard/{guilds,governors,governors/:city/history,presence,houses}.
- Tests: ingest routing for all new kinds + governor term-capture idempotency
  (15 new; full suite 179/179). Swagger regenerated.

Refs .plans/protocol2-integration.md (Phase 1).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 12:28:54 -05:00
c31553aeb6 feat(shard): admin write plane, help-page queue, and public champion board
All checks were successful
PR Checks / server-tests (pull_request) Successful in 10m20s
PR Checks / client-build (pull_request) Successful in 9m49s
PR Checks / bot-install (pull_request) Successful in 9m33s
Wire up the three uo-link sidecar surfaces that weren't integrated yet.

Champion spawns
- Ingest champ.update/champ.remove into a new shard_champs table (served from
  our own store, like online/houses); public /site/champs board with a nav link,
  live via the existing SSE feed (champ.* added to the public allowlist).

Staff write plane (admin + moderator)
- kick / ban / unban / broadcast via /admin/shard/*; actor is stamped server-side
  from the session, never the browser. Sidecar status codes mapped (403 disabled/
  protected, 404 unknown, 503/504 transient). admin.audit events are logged and
  surfaced at /admin/shard/audit.
- New admin "In-Game Ops" view (/admin/shard-ops), plus per-account Kick/Ban/Unban
  on the user-detail and character views (ShardAccountActions, self-gated to staff).

Help-page (support) queue
- Ingest page.new/updated/closed into a new shard_pages table; respond/close via
  /admin/shard/pages/*. Champ board and page queue are snapshotted from the
  sidecar's /champs and /pages on every WS (re)connect (guarded so a failed call
  never wipes local state).

Verified live end-to-end against MariaDB + the Rust sidecar + ServUO; unit tests
cover ingest routing (shardIngest.champsPages.test.js). Swagger regenerated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
2026-07-14 13:16:10 -05:00
c4245e3f6a Restrict public presence to staff + let admins view any character
Public "Online now" now lists only players whose game account is linked
to a STAFF website user (admin/editor/moderator) — linked players are no
longer exposed publicly with their name and location. listOnlineLinked
joins through to users and filters on role; the section is relabeled
"Staff online".

Character/roster/vendor reads gain an admin bypass: admins may view any
character's data, while players (and editor/moderator staff) stay limited
to accounts they have personally linked. The bypass lives in the shared
player controller and only ever widens access for genuine admins.

Also finalizes the uo-link character/vendor front end (player + admin
character sheets, VendorSales component, ShardChar removed) and
regenerates swagger-output.json.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kj5s1QCKobuFPYmqxjy1q
2026-07-11 09:15:18 -05:00
49d0c1bd11 Add shard activity feed + admin live feed; fix public-feed leak
Front ends for the rest of the sidecar data, plus a security fix the live data
surfaced.

- lib/shardEvents.js: shared describe()/category/label for every event kind
  (sales, deaths & PvP, skills, fame/karma, quests, world, and staff kinds).
- Public /site/shard/activity (ShardActivity): the full event log with category
  filter tabs and a live tail (history + SSE merged, de-duped). Linked from the
  Shard page. Shard page now reuses the shared describe().
- Admin: a "Live feed (all events)" panel on the Shard admin page subscribing to
  the admin SSE channel — shows every kind incl. audit/cheat/login attempts.
  useShardFeed generalized to take a stream url; api.adminShardStreamUrl added.

Security fix: GET /public/shard/feed now restricts to the public-safe kind
allowlist (shardEvents.list gains a `kinds` IN-filter). Previously it returned
whatever was logged — including audit.* / cheat.* / link.request. Those are
still stored for the admin channel but never served publicly (verified: a
public request for audit.command returns 0 rows).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3
2026-07-11 03:10:03 -05:00
fe6f93481b Add player portal + character-sheet front end (phase 4 follow-up)
Turns the raw shard endpoints into proper, navigable pages in the site's visual
language.

- components/CharacterSheet.jsx: reusable sheet — attribute tiles, vitals bars,
  resistances, skills (with bars), and equipment — styled with the shared
  panel/grid vocabulary.
- Player portal with a nav bar: PlayerPortalLayout (Characters / Account tabs +
  sign-out) wraps /player and /account. /player (PlayerCharacters) tells the
  logged-in player if they haven't linked a game account (with the [link code
  prompt) or, once linked, shows their characters grouped by account; each
  character opens its sheet at /player/char/:serial. Account security moved into
  the same shell (the buried "Game accounts" block was removed from it).
  Login/register now land on /player.
- Public: GET /public/shard/online (redacted name+serial+map) drives an
  "Online now" list on /site/shard that links to public character sheets at
  /site/shard/char/:serial (ShardChar). Swagger: ShardOnlinePlayer + regenerated.
- api.shard.online added.

Verified live against the running shard: Darrow's full sheet (STR 120, 58
skills, 3 equipment) renders through the browser-facing proxy; the online list
returns the live roster; player routes 401 without a session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3
2026-07-11 02:51:50 -05:00
523113f013 Add public shard read endpoints + live SSE stream (phase 2)
Curated, same-origin, token-free reads so the browser never sees the sidecar
URL or token:

- public/shard.controller.js:
  - GET /public/shard/status — connection state + online count + latest economy
    (from the site's ingested data).
  - GET /public/shard/feed?kind=&limit= — recent notable events from the log.
  - GET /public/shard/economy — gold-supply series (oldest → newest).
  - GET /public/shard/idoc — houses currently at IDOC.
  - GET /public/shard/char/:serial — live sheet round-trip via uoLinkClient,
    briefly cached; 503 (shard restarting) serves a stale cache or a retry
    banner rather than an error.
  - GET /public/shard/stream — public SSE channel (safe kinds only).
- Wired into public.routes.js with express-validator guards and #swagger
  annotations; new "Public · Shard" tag + ShardStatus/ShardEvent/
  ShardEconomyPoint/ShardHouse schemas; swagger-output.json regenerated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3
2026-07-11 02:11:28 -05:00
4d87c5f627 Add pages API: model, controller, routes, preview (steps 4/6/7/8)
Backend for the CMS page builder, all under the existing /api/v1:

- pages.model: authoritative save gate — validates blocks against the
  registry and sanitizes them on every create/update; maps rows to/from the
  grouped API shape (metadata / settings); slug validated + reserved-checked
  at create and immutable after; `protected` can be set true via PATCH but
  only cleared via the unprotect path; published_at stamped on first publish.
- sanitizeBlocks: post-validation normalizer (applies each block's sanitize,
  stamps version, defaults visible, recurses container slots).
- reservedSlugs: guards page slugs from shadowing named routes/API namespaces.
- Admin routes (staff-gated): GET/POST /pages, GET/PATCH/DELETE /pages/:id,
  POST /pages/:id/unprotect (password step-up, verified against the caller's
  own hash, never logged), POST /pages/:id/preview (1h token). Audit-logs
  create/publish/unpublish/protect/unprotect/delete.
- Public routes: GET /public/pages/:slug (published; staff see drafts; site-
  mode gated) and GET /public/pages/:id/preview/:token (ungated, token is the
  access control). Preview token primitives added to auth/token.js.
- Swagger annotations for all new endpoints.

Verified end-to-end: model integration test against the dev DB (sanitize,
invalid-block rejection, slug immutability, protected/unprotect, dup/reserved
slug, published_at) + authenticated HTTP smoke (201 create, 400 invalid
blocks, publish, public slug fetch, preview mint+fetch, 403 delete-protected,
401 wrong-password unprotect).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 20:48:31 -05:00
f8db61025b Audit and fix Swagger/OpenAPI accuracy; regenerate served spec
The route-level annotations were 100% present, but the committed/served
spec (swagger-output.json) was stale and several response schemas had
drifted from the controllers. This aligns the docs with actual behavior
and regenerates the spec.

Served spec was stale (64/67 operations). Regenerating picks up three
routes that were added after the last generation:
  - POST /api/v1/auth/sso/totp
  - GET  /api/v1/admin/discord-bot/config
  - PUT  /api/v1/admin/discord-bot/config
plus a stale /auth/logout summary.

Response-shape corrections (annotation now matches controller output):
  - Mutation endpoints do NOT return the generic { message } envelope.
    Deletes echo { id } / { slug }; toggles return { deleted },
    { unlinked }, { totp_enabled }, or { ip, removed }. Documented as-is
    via new DeletedId/DeletedSlug/DeletedFlag/UnlinkedFlag/TotpState/
    UnbanResult components. (The API is intentionally inconsistent here;
    recorded rather than normalized — see follow-up note.)
  - POST /account/totp/setup: otpauth_url -> otpauthUrl (TotpSetup)
  - PUT  /admin/site-mode: { mode } -> { site_mode, changed_at, changed_by }
  - GET  /account: full User -> AccountStatus (id/username/role/totp_enabled)
  - GET  /account/identities: add linked_at (LinkedIdentity)
  - GET  /public/status: add status_message (PublicStatus)
  - POST /auth/sso/totp: user is SafeUser, not full User
  - GET  /dashboard: description/shape corrected (posts+users, no wiki)

Schema completeness:
  - Provider (public discovery): { id, name, icon, loginUrl, priority },
    not { id, name, kind }
  - ProviderConfig: add hasSecret, builtin, health (ProviderHealth)
  - Post: add excerpt, author_id, published_at
  - MobileTokenResponse.expiresIn: duration string ("15m"), not integer

Config: declare the Admin · Discord Bot tag (was used but undeclared).

Auth model and the internal/external boundary were verified correct and
left unchanged: cookie + bearer are both accepted on session routes (dual
security annotations are accurate), and /internal/* runs on a separate
listener already excluded from the scan.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
2026-07-04 22:51:16 -05:00
a1f0675577 Add Swagger/OpenAPI API docs (swagger-ui + swagger-autogen)
Generate an OpenAPI 3.0 spec from route annotations and serve it with
Swagger UI so the full REST API is browsable and testable.

- Add swagger-ui-express (runtime) and swagger-autogen (dev) deps, plus
  an `npm run swagger` script.
- server/swagger/swagger.js: generator config with API metadata, servers,
  14 tag groups, cookie + bearer security schemes, and 28 reusable
  component schemas. Follows the Express mount chain from src/app.js so
  generated paths are fully-qualified (/api/v1/...).
- Annotate every route (auth, mobile, sso, public, admin, health) with
  #swagger tags/summaries/parameters/request bodies/security and the
  actual response codes each handler returns (400/401/403/404/409/429/
  302/502, multipart uploads).
- Serve Swagger UI at /api/docs and the raw spec at /api/docs.json,
  guarded so a missing spec disables docs instead of crashing.
- Commit the generated swagger-output.json so docs work with no build
  step; swagger-autogen stays dev-only and is not needed at runtime.
- README: new "API documentation (Swagger)" section plus tech-stack and
  project-structure entries.

Covers 51 paths / 64 operations. Existing test suite (83) still passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 15:28:13 -05:00
7bb992f58d Wiki Phase 4: full-text search + revision history
Final phase of the wiki upgrade (see WIKI_UPGRADE.md).

Schema (additive): wiki_revisions table (per-save content snapshots).
The FULLTEXT index on wiki_pages(title, body) shipped in Phase 1.

Search:
- MATCH ... AGAINST natural-language search over title + body, ordered by
  relevance
- public: GET /public/wiki?q= (published only); admin: GET /admin/wiki?q=
  (all statuses)
- public wiki index gains a search box; admin list gains a search field

Revision history:
- every create/update snapshots the page into wiki_revisions
- admin endpoints: list revisions, get one, and restore (restore overwrites
  the page, rebuilds links, and appends a new revision — history stays
  append-only); logged as wiki.revision.restore
- editor gains a History modal: revision list + word-level diff (jsdiff) of a
  chosen revision against the current page, with one-click restore

Verified end-to-end: search matches body and title; two edits produce three
revisions; diff renders added/removed words; restore reverts and records a new
revision. No console errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 15:49:05 -05:00
7c081ae749 Wiki Phase 3: internal links, backlinks, and tags
Connectivity phase of the wiki upgrade (see WIKI_UPGRADE.md).

Schema (additive new tables): wiki_tags, wiki_page_tags, wiki_links.

Internal links & backlinks:
- new wiki.links.js parses a saved body for /wiki/<slug> (and data-wiki-slug)
  targets; wiki_links is rebuilt on every save
- article shows a "Linked from" section (published backlinks) and renders
  links to non-existent pages as red links (server returns missing_links)
- editor gains an internal-link picker listing existing pages

Tags:
- pages accept a tags[] array; tags upsert on save, page tag-set is replaced,
  and orphaned tags are auto-pruned (on save and delete)
- public/admin list filter by ?tag=; /wiki/tags lists tags with published counts
- article shows tag chips; the index has a flat tag-filtered view; editor has a
  comma-separated tags field

Verified end-to-end: A->B backlink appears, red link detected, link index
rebuilds on edit, tag filtering + chips + pruning all work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 11:25:15 -05:00
b925114923 Wiki Phase 1: categories, drafts/publish, HTML sanitization
Foundation & safety phase of the wiki upgrade (see WIKI_UPGRADE.md).

Schema (additive, idempotent via ensureSchema):
- new wiki_categories table; wiki_pages gains category_id, excerpt,
  published, published_at, sort_order, and a FULLTEXT index
- migration ALTERs guarded with IF NOT EXISTS for existing databases
- seed reworked into 4 sections with the 8 starter pages assigned

Security:
- new utils/sanitizeHtml.js (sanitize-html allowlist); wiki bodies are
  sanitized on every save, and the article renders through DOMPurify
- strips <script>, event handlers (onerror), and javascript: URLs

Backend:
- public: published-only list with ?category filter + /wiki/categories
- admin: extended page CRUD, PATCH publish toggle, category CRUD;
  drafts visible to admin, hidden from public
- all writes logged to activity_log

Frontend:
- data-driven public wiki index (sections + real descriptions; removed
  hardcoded blurbs/Roman numerals) with ?category filtering
- article: category breadcrumb + sanitized render
- admin: Section/Status columns, draft/publish + section + excerpt in the
  editor, and a Manage sections modal

Verified end-to-end against MariaDB 11: migration clean, XSS neutralized,
drafts hidden, client builds, server boots.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 10:45:21 -05:00
eef79e2403 Initial commit: UOMysticmoon backend (Express + MariaDB + JWT)
- Layered API (router -> controller -> model -> db), serverlinkr pattern
- Public / auth / admin route groups; posts, wiki, settings, users, activity models
- JWT httpOnly-cookie auth (Secure auto-detected: LAN HTTP + Pangolin HTTPS)
- Site LIVE/MAINTENANCE mode with admin preview bypass
- Dual file+console logging (info/warn/error/debug) + HTTP access logs
- Docker Compose (app + MariaDB), schema.sql + seed, .env.example
- Verified end-to-end against MariaDB (27/27 smoke checks)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:58:32 -05:00