156 Commits

Author SHA1 Message Date
45fb4a3f15 Merge pull request 'docs(android): scope M11 — Protocol 3.0 shard parity for the app' (#74) from docs/android-v3-parity into edge
Reviewed-on: #74
2026-07-30 07:23:00 +00:00
5a091157d6 docs(android): scope M11 — Protocol 3.0 shard parity for the app
The v3 work added four shard features and an admin-configurable visibility
framework the Android client knows nothing about. v3.md §10 deferred the app
side as a follow-up; re-examining it before the cutover found the gap is wider
than nav hiding:

  - no consumer for any of ruleset / leaderboards / market / atlas,
  - no `points` block on the character sheet (§7.3),
  - no cliloc-resolved item names (§8.6), and
  - shard nav gated on session role alone, so an admin who disables a feature
    or raises its audience leaves the app rendering entries that 404/403 into a
    generic error where the web client hides them.

Scoped as PLAN.md §9 M11 in two PRs (the visibility rules + read-model adds,
then the four screens), with the traps a real shard exposes recorded inline:
uncapped `maxPoints: 0`, cliloc-named boards with a null `nameString`, skill
caps in tenths, the required market staleness banner, the market stream being
off by default, atlas delays in seconds, and `points`-count vs `spawners`-list.

edge → main is held until both land so web and app surface the same shard on
the same day. Neither PR is coupled to the merge order — on a pre-v3 website
every new route and /public/shard/features 404s and the app falls back to
today's behavior — so holding the cutover is a schedule decision, not a
technical dependency.

Also records two things verified as already correct, so they are not
re-derived: the app's SSE request rides the authenticated client (same audience
rung as the same account on web), and every shard DTO is nullable-with-defaults
(field projection cannot cause a decode failure).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-30 00:46:16 -05:00
3a1bbdd165 Merge pull request 'docs(link): the Protocol 3.0 cutover (v3.md order 6)' (#72) from docs/protocol-3-cutover into edge
Reviewed-on: #72
2026-07-30 03:03:10 +00:00
32def88c4e docs(link): fill in the cutover PR numbers
The order-6 row was written before the seven PRs existed. Same follow-up as the
cliloc row got.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 18:09:50 -05:00
71207cef16 docs(link): the Protocol 3.0 cutover (v3.md order 6)
INTEGRATION.md was written for the window that just closed -- it told integrators
the version had NOT been bumped yet and that a sidecar on `edge` reports 2 while
already carrying v3 kinds. That guidance is now wrong in the direction that
matters, so the version section states 3 (header, /health, ws.hello, the 409
example and the §8 worked example) and replaces the "until then" paragraph with
what a v2 integration actually has to do to upgrade: change the constant it
sends, and nothing else, because nothing that existed in v2 changed shape.

v3.md gains §4.1 for what the bump touches and, more importantly, WHY the
website's boot migration is gated on a marker row: schema.sql is re-run on every
boot and uo_link_config.protocol is admin-editable, so an ungated UPDATE would
silently un-pin an operator running an older sidecar. That is the one piece of
the cutover a reader could not infer from the code being one constant.

Progress tables: 5b done, 6 in review.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 18:04:00 -05:00
cdea1aa7cd Merge pull request 'docs(link): the player-vendor marketplace (Protocol 3.0 §8)' (#71) from docs/vendor-listing into edge
Reviewed-on: #71
2026-07-29 20:02:31 +00:00
6ce60a82c3 docs(link): the player-vendor marketplace (Protocol 3.0 §8)
Documents order 5b across the four repos, and records what building it changed
about §8 as designed.

- NEW website/MARKETPLACE.md — the operator guide: what the pages must say out
  loud and why, the privacy contract (the player's in-game Vendor Search toggle
  wins, and no admin setting overrides it), the Bridge.cfg knobs and how they
  trade against each other, and the measured sweep costs.
- INTEGRATION.md — catalog entry for vendor.listing / vendor.listing.remove with
  its six consumer gotchas, and the GET /market REST section (the sidecar's only
  paged read, and why it orders by serial rather than shop name).
- BACKEND_DESIGN.md — shard_vendors / shard_vendor_items, the routes, and the
  marketplace search as the only rate-limited public read.
- SHARD_VISIBILITY.md — why the market's fields default to Everyone (the in-game
  gump already shows exactly that set), why location is one setting covering
  four things, and why hiding the owner name without the owner id achieves
  nothing.
- PLAN.md — the amortized round-robin as the one sweep pattern the bridge did not
  previously have, and an update to §7's cliloc note: pushing name resolution to
  the plugin was never an option, because ServUO cannot read a modern client's
  compressed cliloc files either.
- v3.md §8.8 — the four things the build settled differently, chief among them
  that §8.1's FLAT location payload would have made Part A's pre-wired
  market.location rule inert, exactly like the characterName miss one part
  earlier.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 09:52:14 -05:00
70d49b7792 Merge pull request 'docs(link): the cliloc table, and why §8.6's recommendation was not implementable' (#70) from docs/cliloc-table into edge
Reviewed-on: #70
2026-07-29 11:58:26 +00:00
ee0c146d7a docs(link): cliloc overlays for shard-added and shard-edited items
Shards edit items and add new ones, carrying cliloc ids no stock client table
has. The cliloc table now reads a SET of sources rather than one file — a base
plus every operator-maintained overlay under `custom/`, hash-gated together and
re-read on every boot, exactly as §6 reads the ServUO tree. Matching docs for
website #115.

website/CLILOCS.md gains a "Shard-added and shard-edited items" section: the
directory layout, merge precedence, the per-source breakdown an operator uses
to confirm an overlay took effect, and why `custom/` is a convention we chose
rather than one discovered from the shard — 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. Scale recorded: the live shard's script tree
references 16,434 cliloc ids and only 37 are absent from stock, which is why
this is an overlay and not a second table.

"Why there is no staged-approval flow" is replaced by "Two ways a refresh is
refused", because the set brings back the hazard a single file did not have. A
corrupt source fails the parse loudly; a source that has VANISHED parses
perfectly and imports a table quietly missing everything it contributed. That
is the same ambiguity §6 stages a facet removal for, so it is staged here too
(`needsReview`, `{approve:true}` to accept) — as a flag rather than §6's
approve/reject pair, because the atlas stores a pending decision SO THAT
approving re-parses, and here nothing is stored.

Two more traps recorded in §8.6 (now five), both found by running a shard-style
overlay rather than another stock-table fixture:

- Tidying punctuation unconditionally corrupts real names — 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.
- Source labels must be forward-slashed and root-relative or the same directory
  fingerprints differently on Windows and Linux and every boot looks like a
  change — the identical bug §6 records.

BACKEND_DESIGN.md: the source set, the two refusals, and the updated admin
route notes (`approve`, `missingSources`, overlays found beside a file path).

Documentation only. Every claim was written after the behaviour was observed:
the counts come from the live shard's script tree, and the refusal/approve flow
and per-source breakdown are copied from real runs in website #115.

PROJECT_TREE.md files are deliberately untouched — CI-generated by the
sync-project-tree workflow.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 06:46:43 -05:00
e3aabf9e3e docs(link): fill in the cliloc PR numbers, correct the cliloc file count
The progress table and CLILOCS.md carried #TBD placeholders until the PRs
existed; they now point at website #115 and docs #70.

Also corrects "all four Cliloc.* files" to eight (chs, cht, deu, enu, esp,
fra, jpn, kor) in both v3.md §8.6 and CLILOCS.md — the compression marker was
checked against every one of them, and understating the sample weakens the
claim it supports.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 04:25:06 -05:00
be9f5019fa docs(link): the cliloc table, and why §8.6's recommendation was not implementable
Protocol 3.0 §8.6 resolved as its own website-only change, landing ahead of
§8 so the marketplace ships with real item names. Matching documentation for
website #TBD.

NEW website/CLILOCS.md — operator-facing: why the conversion step exists, how
to convert, how to configure the path, the refresh contract, what gets stored
and how names are applied.

link/v3.md §8.6 rewritten. Two things in the original recommendation turned
out to be wrong, and both are recorded because the reasoning generalises:

1. The committed db/data/clilocs.json artifact predates the Part C
   corrections (§6.1) and violates both — no committed snapshot of derived
   content, and nothing EA-derived ever shipped. UO's strings are EA's,
   exactly as the creature sprites are.
2. "scripts/buildClilocs.js reads the UO client's Cliloc.enu" is not
   possible. EVERY current client ships its cliloc files compressed (first
   DWORD's high byte 0x8E, the Mythic container); the plain layout is what
   those files looked like before that change, and parsing one as the other
   does not fail cleanly — it yields ~19k records with negative ids, 1,722
   distinct keys out of 19,508, one 62 KB "string", and a truncation
   somewhere in the middle. ServUO's own Ultima.StringList cannot read it
   either, so VendorSearch.GetItemName is already inert on such a shard and
   the work could not be pushed to the plugin.

That second point also retires an open question in §8.2: the warning never to
call GetItemName in the market sweep costs us nothing we could otherwise have
had, because the in-game Vendor Search gump has the same gap.

Three traps found by building it are recorded: StringList.SaveStringList
RE-COMPRESSES on save (its output is byte-identical to its compressed input,
because its purpose is round-tripping a file back into the client); trimming a
text line before splitting silently drops the ~half of a table that is empty
strings; and Number('') is 0, not NaN.

Also updated:
- Progress and §9 sequencing tables: order 5 split into 5a (this, website
  only) and 5b (the four-repo wire change).
- website/BACKEND_DESIGN.md — shard_clilocs / shard_cliloc_meta, the three
  admin routes, and why there is no staged-approval flow and no public route.
- link/INTEGRATION.md — the char.profile field note now says explicitly not to
  expect the shard to resolve clilocs, and points at CLILOCS.md.
- §10 documentation obligations list CLILOCS.md.

Documentation only. Every claim was written after the corresponding behaviour
was observed running: the compressed-format finding and the parse failures
come from the real client files on this machine, and the counts (123,490
parsed → 67,496 stored) and timings from importing them into the live MariaDB.

PROJECT_TREE.md files are deliberately untouched — they are CI-generated by
the sync-project-tree workflow and say so in their header.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 04:22:05 -05:00
f715323aa0 Merge pull request 'docs(link): points.board, the leaderboards API, and what a real shard changed' (#69) from feat/points-board into edge
Reviewed-on: #69
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-29 07:52:25 +00:00
8e857a9c8d docs(link): points.board, the leaderboards API, and what a real shard changed
Protocol 3.0 §7 lands across servuo-plugins, link and website; this is the
matching documentation.

INTEGRATION.md
  - points.board in the event catalog: one frame per system, never a delta, no
    points.remove (the shard's system set is fixed at startup). Four gotchas
    called out, all of them things a consumer gets wrong by default:
      1. maxPoints: 0 means UNCAPPED, not "zero allowed" — and on a real shard
         it is the COMMON case, so anything rendering points/maxPoints must
         special-case it or divide by zero on the happy path.
      2. nameString is usually null, with a cliloc in nameNumber — so
         humanising the system key is the primary display path, not a fallback.
      3. players counts players actually holding points, not table size: ten of
         the ~25 systems keep a zero-point row per character ever created, so
         the raw count would report the shard's whole census.
      4. Entries carry serial + name only, never acct/webId.
  - The char.profile `points` block, and why `rank` is absent by default.
  - GET /points and /points/:system, including why 404 (never published) and
    200-with-empty-top (published, nobody scored) are different answers.

v3.md
  - B/2 marked done in both the progress table and §9.
  - NEW §7.5, "what the run against a real shard changed" — the same record §6.1
    and §6.2 keep. Four corrections the plan could not have anticipated from
    reading PointsSystem.cs, the sharpest being that (long)double.MaxValue is an
    unchecked conversion yielding long.MinValue, which published
    "maxPoints": -9223372036854775808 on the first live sweep. Also records that
    GetEntry/GetPoints mutate the world on AutoAdd systems and so cannot be used
    in a read model, and the one deliberate deviation from §7.4: the visibility
    field rule must key on the wire's `name`, not the descriptive
    `characterName`, or it is silently inert.

BACKEND_DESIGN.md — shard_points_boards (including why the top-N list stays in
the payload and why listing orders by COALESCE(name, system)), plus the two new
public routes.

PLAN.md — 3.0 phasing brought current: the spawn atlas and points.board added to
what has shipped, and the Points* keys noted in the config-key paragraph.

PROJECT_TREE.md files are deliberately untouched — they are CI-generated and say
so.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 21:05:04 -05:00
64fb7edc3e Merge pull request 'docs(website): the spawn atlas API, the admin panel, and the delay-unit trap' (#68) from docs/spawn-atlas-api into edge
Reviewed-on: #68
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-29 00:53:54 +00:00
be7e1a69ce docs(website): the spawn atlas API, the admin panel, and the delay-unit trap
Docs half of website #113 (Protocol 3.0 Part C, second website PR).

Carries the atlas rewrite that missed #67: that PR merged before the "derive
from the tree on every boot" commit was pushed, so `edge` currently describes
the build/import-artifact design that was rejected in review, not what shipped
in website #112. It lands here.

New in SPAWN_ATLAS.md: the six public routes and five admin ones, and three
behaviours that read as bugs unless they are written down — an unreadable tree
answers 200 with status "unavailable" rather than 500 (refresh reports outcomes
so boot is never blocked by a bad tree, and the contract is preserved at the
API), setting the ServUO path deliberately does not import, and `points` is a
count while `spawners` is the list.

Also the delay-unit trap: XmlSpawner stores MinDelay/MaxDelay in minutes OR
seconds per record, decided by that record's own DelayInSec flag, so a `5` is
five minutes on one spawner and five seconds on the next. Both are plausible
respawn times, which is what makes it silent. 170 of 6,455 stock spawners are
second-flagged. And PARSER_VERSION, which exists because hashing the tree alone
would strand an install whose maps never change on whatever an older parser
derived.

BACKEND_DESIGN.md gains the routes, the router-map entry, and the parser-version
rule. v3.md marks Part C done and records in 6.3 what the API half found.

api-route-inventory.json refreshed from the live manifest — it had drifted to
200 routes before this PR (real count was 204) and is now 215.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U7CBg11prhLimL9iHSX1bP
2026-07-28 19:51:50 -05:00
1b7da860b5 Merge remote-tracking branch 'origin/edge' into docs/spawn-atlas 2026-07-28 16:47:54 -05:00
ff1c2064a5 docs(website): the atlas reads the shard's tree on every boot, not a snapshot
Follows the redesign in website #112. Two decisions from the original §6 were
rejected in review and replaced; the docs now describe what was actually built.

**The committed artifact is gone.** A shard's maps change over its life, so a
snapshot in the repo silently drifts from the world players actually see. The
ServUO tree is the single source of truth and the atlas is re-derived on every
server boot, hash-gated so an unchanged tree costs one read pass and no write.

**Nothing may name a facet.** The first implementation carried a lookup table of
the six stock UO facets. A shard may add facets, replace them outright, or rename
them when its maps are updated, and a built-in list mishandles all three
silently. Reconciliation is now by matching against the facet set discovered from
the shard's own data, with an unmatched name keeping its own rather than being
forced into a wrong bucket.

## Changes

- **`website/SPAWN_ATLAS.md`** rewritten: the two ideas that shape the design,
  how to configure the tree path, the boot flow as a decision tree, the
  approve/reject flow, and the code layout. The artwork policy is unchanged and
  still explicit — no art ever ships, operators extract their own from their own
  client files.
- **`link/v3.md` §6.1 (new)** records the two rejected decisions plus the two
  boot-path contracts. The old "what real data changed" list becomes §6.2. §6's
  now-superseded passages — the artifact bullet, the payload budget, the operator
  re-run story — are marked rather than deleted, so the reasoning stays legible.
- **`website/BACKEND_DESIGN.md`** documents `shard_atlas_pending` and the two
  contracts that make it safe: a facet removal is staged for a human, and the
  boot refresh can never block startup.

The two contracts are the part worth reviewing. Losing a facet is
indistinguishable at boot from a half-copied or mid-update tree, so it is staged
rather than applied; and no failure mode of the atlas — missing path, unreadable
mount, malformed file, database error — is allowed to stop the site coming up.

---

- [x] AI-assisted: written with **Claude Code** (Claude Opus 5), reviewed before opening.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U7CBg11prhLimL9iHSX1bP
2026-07-28 16:44:39 -05:00
10ae129b94 Merge pull request 'docs(website): record the spawn atlas pipeline and what real data changed' (#67) from docs/spawn-atlas into edge
Reviewed-on: #67
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-28 21:16:06 +00:00
3fb3f63f25 docs(website): record the spawn atlas pipeline and what real data changed
Protocol 3.0 order 3 (Part C), docs half of website #112. Part C is website-only
— no plugin, no sidecar, no new kinds, no wire change.

## New: website/SPAWN_ATLAS.md

The operator-facing reference: the build/import split and why it exists (build
needs a ServUO tree, import does not, and the container has the artifact but not
the tree), the re-run story, the artifact format, the placement transform, and
the three quirks in the source data that are silent when unhandled.

Also documents the artwork policy explicitly: **the project ships no creature
art and no extraction tooling.** Sprites live in the operator's own client
.mul/.uop files and are theirs, not ours to redistribute. `art` is nullable and
NULL on every fresh import; an operator who wants art extracts it themselves into
the gitignored uploads/atlas/ and maps slugs in a gitignored art map. Text-only
is the normal, supported state — not a degraded one.

## New: v3.md §6.1 — what the build against real data changed

Six corrections, kept as a diff rather than edited into §6 in place, because
each is a trap the next person would otherwise re-enter:

1. **Six facets, not thirteen.** Eodon.xml and the other named-area files carry
   TerMur/Trammel points; the facet comes from each record's `<Map>`.
2. **The XML dependency call resolved: hand-rolled, zero deps.** §6 left
   fast-xml-parser vs a tokenizer open.
3. **Facet names disagree between sources** — Locations says `Ter Mur`, `<Map>`
   says `TerMur`. Unreconciled the landmark fallback never fires there and every
   unregioned Ter Mur/Tokuno spawn silently reads "Wilderness".
4. **Spawn type tokens carry XmlSpawner directives** (`Fairy,{RND,4,8}`,
   `alchemist/z/-50`). Taken literally they invent creatures that do not exist
   and split real ones in two. 71 of 845 affected; 800 remain after stripping.
5. **The artifact is 1.41 MB, not "well under 1 MB"** — down from 4.40 MB via
   three encodings. Getting under 1 MB would mean dropping the spawner name.
6. **DELETE, not TRUNCATE** — TRUNCATE is DDL in MariaDB and implicitly commits,
   which would defeat the all-or-nothing reload the design asked for.

§6 also now records that Part C ships as two website PRs: the parsing half is
where the correctness risk lives and should not be reviewed inside a 10k-line
diff alongside routes and React.

## BACKEND_DESIGN.md

The seven atlas tables, the import-owned contract, the four column choices that
are traps (`spawn_range`/`grp` reserved words, DELETE vs TRUNCATE, explicit point
ids, plain INDEX not FULLTEXT), and the distinction between the configured
champion roster and the live champ.update feed.

PROJECT_TREE.md is left alone — it is auto-generated by the sync-project-tree
workflow.

---

- [x] AI-assisted: written with **Claude Code** (Claude Opus 5), reviewed before opening.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U7CBg11prhLimL9iHSX1bP
2026-07-28 16:13:30 -05:00
e9ecdc0ecb Merge pull request 'docs(link): record world.ruleset and mark Protocol 3.0 progress' (#66) from docs/world-ruleset into edge
Reviewed-on: #66
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-28 20:34:38 +00:00
09467c67b0 docs(link): link the world.ruleset PRs from the v3 progress table
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 14:41:54 -05:00
b0a2207c6a docs(link): record world.ruleset and mark Protocol 3.0 progress
Protocol 3.0 order 2 (v3.md §5) is built across all four repos; this is its
documentation half, plus the running progress record the plan was missing.

v3.md
  - A progress table at the top and a State column on §9's sequencing table, so
    "what has landed" is answerable without reading four git logs. Part A (order
    1) and world.ruleset (order 2) are marked done; the spawn atlas is next.
  - §5 gains the implementation notes worth keeping, chiefly: where a system's
    on/off state is DERIVED rather than configured, read the system's own static
    instead of inventing a .cfg key (Shadowguard has no Enabled key — it's the
    TOL expansion gate; Factions is `!ViceVsVirtueSystem.Enabled` by
    construction in stock ServUO). Also that the plugin CAN be compile-verified
    despite the "no standalone build" caveat, and how.

INTEGRATION.md
  - The world.ruleset catalog entry and GET /ruleset, with the two things
    consumers get wrong: caps are in TENTHS (1000 = 100.0), and `connect` exists
    only if the operator set Bridge.PublicConnectAddress — the shard's real
    listen address is never published.
  - §2 now says plainly that v3 has NOT been bumped yet and what that means:
    sidecars on `edge` report 2 while already carrying some v3 kinds, so do not
    infer feature availability from the version during this window.

PROTOCOL_2.md §10.4
  - The deferred "which PvP system does this shard run?" is answered (VvV on,
    Factions off — and mutually exclusive by construction), and world.systems is
    marked superseded by world.ruleset, which carries the systems block it asked
    for. No orphan kind is left behind.

BACKEND_DESIGN.md — the shard_ruleset table (why it is stored whole rather than
normalized, and why no row means null rather than {}) and the public route.

PROJECT_TREE.md is deliberately untouched: sync-project-tree regenerates it on
push to main, so it updates itself at the v3 cutover.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 14:39:53 -05:00
bd9718a859 Merge pull request 'docs(shard): record the REST projection gap the Part A smoke test found' (#65) from docs/shard-visibility-rest-projection into edge
Reviewed-on: #65
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-28 15:56:20 +00:00
4c0ceb1c41 chore(docs): drop an unrelated working-tree file committed by mistake
android/TRUSTED_DEVICES_APP_HANDOFF.md was untracked in the working tree
before this branch and was swept in by a `git add -A`. It is not part of
this change; untracked here and left on disk.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 10:53:01 -05:00
35ad440bad docs(shard): record the REST projection gap the Part A smoke test found
The live five-rung smoke test of the visibility framework found that Part
A enforced it on the SSE path and on /guilds + /governors, but not on the
remaining public REST reads - so one event was projected live and served
verbatim from stored history.

link/v3.md gains 3.6.1 with the full list (the anonymous acct/webId leak
on /feed, the flattened ownerAcct on /idoc, the dead `houses` field
rules, /feed ignoring live config, the empty-allowlist fall-through, and
the Date-to-{} projection bug), plus the rule it leaves behind: a read
path that returns shard data and does not project is a bug, and every new
Part B/C surface must gate its kind set on live config rather than on
PUBLIC_KINDS.

3.5 also corrected: the table is NOT seeded on boot. An absent row means
"use the compiled default", which keeps the defaults in one place instead
of duplicating them into a seeder that could drift.

BACKEND_DESIGN.md 6.5 records the same as a security contract: rule 1
locks a field by meaning rather than spelling; PUBLIC_KINDS is a
module-load constant and must not answer per-caller questions;
projectFeature walks arrays and plain objects only.

SHARD_VISIBILITY.md gets the admin-facing version - that stored history
answers the same way the live stream does, and that turning live updates
off stops the push, not the reading.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 10:52:13 -05:00
5cb77595aa Merge pull request 'docs(website): record the shard visibility framework' (#64) from docs/shard-visibility into edge
Reviewed-on: #64
2026-07-28 15:06:56 +00:00
8b4fc439ee Merge branch 'edge' into docs/shard-visibility 2026-07-28 15:06:39 +00:00
bf41105ec0 docs(website): record the shard visibility framework
Protocol 3.0 Part A. Admin-configurable, per-feature and per-field
audience control over every shard-derived surface, replacing the static
PUBLIC_KINDS allowlist that used to be the whole boundary.

- SHARD_VISIBILITY.md (new): the admin-facing guide - the ladder, what
  each of the ten features exposes, the defaults, the two rules that are
  code rather than configuration, and worked examples.
- BACKEND_DESIGN.md 6.5 (new): the same thing as a security contract -
  the ladder and how viewerLevel resolves it, the locked acct/webId rule,
  the fail-closed kind map, the asymmetric ladder fallbacks, and the
  three enforcement points. Plus the shard_feature_visibility schema, the
  /public/shard/features route, and the adminOnly tier on
  /admin/shard/visibility.

Defaults reproduce pre-3.0 behavior everywhere, with one deliberate
exception which is the leak Part A was written to close: guilds and
governors previously returned the raw stored payload, whose leader and
governor actors carry acct and webId, to anonymous callers.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 10:04:29 -05:00
7e8cbe1916 Merge pull request 'docs(link): add the Protocol 3.0 design' (#63) from docs/link-v3-plan into edge
Reviewed-on: #63
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-28 14:41:26 +00:00
6622afe4bd docs(link): add the Protocol 3.0 design
Surveys the live ServUO tree against everything the bridge already
surfaces and records the full gap list (17 items), then specs the four
features scoped for 3.0.

3.0 has three scope areas:

- A: the visibility framework. Admin-configurable, per-feature and
  per-field audience control over all ten shard-derived surfaces (the
  four new ones plus the six that already ship), on an
  anonymous -> logged_in -> player -> staff -> admin ladder. Every
  default reproduces today's behavior, so the retrofit is a no-op until
  an admin changes something. Two rules an admin cannot override: acct
  and webId are admin-only always, and an unmapped event kind is never
  broadcast below admin. This also fixes a verified leak - guild leader
  acct/webId are readable today on the anonymous /public/shard/guilds.
- B: three new wire streams - world.ruleset, points.board, and
  vendor.listing/vendor.listing.remove.
- C: the spawn atlas, built from static ServUO data files with no wire
  involvement.

Visibility lives entirely on the website; the sidecar stays a dumb
forwarder that defines no access parameters and advertises no
capabilities.

PROTOCOL_VERSION goes 2 -> 3 once, at the end: every part PRs into an
edge branch per repo, and the coordinated edge -> main merge is the
cutover. A schema migration moves uo_link_config.protocol so operators
don't have to.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 09:28:04 -05:00
b523336313 Merge pull request 'docs(website): record trusted-device support on the SSO login paths' (#62) from feat/sso-trusted-device into main
Reviewed-on: #62
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-28 06:06:58 +00:00
e4bec0caba docs(website): record trusted-device support on the SSO login paths
Doc side of website + Android-app feat/sso-trusted-device.

TRUSTED_DEVICES_MFA.md §6 gains an "SSO login paths" subsection: SSO is not
exempt from the second factor, and a trusted device skips it exactly as on the
password path (previously SSO consulted trust nowhere, so an external-identity
user was asked for a code on every sign-in). Documents the callback-side skip,
the new trustDevice/deviceName on POST /auth/sso/totp, and why recovery codes
stay password-login only.

Also writes down how this reaches the Android app, since it is not obvious: the
app's SSO runs in a Custom Tab that shares the system browser's cookie jar, so
the rg_trust cookie covers native SSO with no app change and no trust token in a
start URL (which would leak a secret into query strings and logs). The app's own
token is minted at /auth/mobile/sso/exchange instead — an authenticated
app→server call — so it never travels in the deep link, and the bridge row holds
only a boolean. Notes that one tick yields two independently-revocable rows.

§4 documents the new mobile_auth_sessions.trust_device column; BACKEND_DESIGN.md
gets the same column in its bridge table, the trust note on the /exchange row,
and a pointer from the bridge intro to the Custom Tab cookie model.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 01:01:49 -05:00
b2c27fb285 Merge pull request 'docs(website): record the uo-link client config-decrypt contract and the dashboard mixed tier' (#61) from fix/uolink-client-contract-and-sitemode-gate into main
Reviewed-on: #61
2026-07-28 05:31:52 +00:00
2b93529ef6 docs(website): record the uo-link client's config-decrypt contract and the dashboard's mixed tier
Two corrections found by a live smoke test of all 200 routes at every access
level (website PR: fix/uolink-client-throw-and-sitemode-gate).

ARCHITECTURE.md: the "uoLinkClient never throws" invariant was true of the HTTP
call but not of resolving the config, which decrypts the stored auth token and
throws when the ciphertext can't be authenticated (SECRET_ENC_KEY rotated, or a
DB dump restored under a different key). Spell out that this is now handled
inside the client, reported as { ok: false, error: 'uo-link config unreadable' }
with a distinct ERROR log, and that GET /admin/uo-link/config keeps working —
it is the screen an admin needs to re-enter the token and recover.

BACKEND_DESIGN.md: GET /dashboard is staff-wide while PUT /site-mode on the
same screen is adminOnly — the one place a single screen spans two tiers. Note
that the client must gate that control itself rather than relying on the route
gate that admitted the user to the page.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 00:23:22 -05:00
9f3f014f34 Merge pull request 'docs(website): record PR 5 — public, player and auth capability split' (#60) from docs/router-split-5 into main
Reviewed-on: #60
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-28 01:56:22 +00:00
257ed2166c docs(website): record PR 5 — public, player and auth capability split
The domain split is complete. API_V2_PLAN.md gains a "PR 5 — as landed"
section (route table, the four zero-diff gates, and the findings worth
carrying forward) and its status line and sequencing list are updated: only
the CSP enforce PR remains, blocked on soak data rather than on code.

BACKEND_DESIGN.md §2 replaces the auth.routes.js / public.routes.js entries
with the full per-capability tree for auth/, public/ and player/, and §4's
group headings now point at the index.js files. The /player prose names the
three routers behind the shared gate.

Findings recorded rather than left in the code alone:

- public/ and auth/ deliberately have no group gate — the obvious hardening
  edit to either is an outage.
- GET /auth/me depends on session.router.js being mounted last, because
  use('/me', meRouter) matches the bare /me and supplies its noindex header.
- Two root-mounted routers (public/site, auth/session) on the PR 4 dashboard
  precedent, safe only because neither declares router-level middleware.
- loginGuards is the PR's shared module, the counterpart to PR 3's
  imageUpload.js.
- Filename deviations from the target tree (posts not news, session.router.js
  added) and why public.controller.js was not split.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 20:52:29 -05:00
bab70a3f6f Merge pull request 'docs(tree): sync website/PROJECT_TREE.md' (#57) from chore/sync-website-tree into main
Reviewed-on: #57
2026-07-28 01:32:42 +00:00
9c0c8f902c Merge branch 'main' into chore/sync-website-tree 2026-07-28 01:32:33 +00:00
a3e3ca817e Merge pull request 'docs(website): record the PR 4 admin router split and the end of admin.routes.js' (#58) from docs/admin-router-split-4 into main
Reviewed-on: #58
2026-07-28 01:31:55 +00:00
45fce7cb9f docs(website): record the PR 4 admin router split and the end of admin.routes.js
Covers website PR 4, the last admin split PR: shard (16), uo-link (5), email
(6), discord-bot (2), settings (2) and dashboard/site-mode (2) leave the
residual file, which is deleted. The admin group is fully split.

API_V2_PLAN.md gains a "PR 4 — as landed" section recording the two decisions a
reviewer would otherwise have to reconstruct: dashboard.router.js is mounted at
the group root (the single relaxation of the mount-at-a-prefix rule, safe only
because it declares no router-level middleware), and /shard keeps two gate tiers
in one router because prefix ownership beats swagger-tag grouping. Sequencing
item 7 is marked landed; PR 5 (public/player/auth) is the only split PR left.

BACKEND_DESIGN.md §2 gets the six new routers in the folder tree and drops the
residual entry; §4's /admin preamble now describes the ops/config gates instead
of pointing at a file that no longer exists.

WIKI_UPGRADE.md's two links into admin.routes.js are repointed at wiki.router.js
and admin/imageUpload.js.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 20:02:49 -05:00
runic-docs-bot
2efc32c022 docs(tree): sync website/PROJECT_TREE.md from RunicGateway/website@812b895 [skip ci] 2026-07-28 00:36:40 +00:00
e892d80aa8 Merge pull request 'docs(tree): sync website/PROJECT_TREE.md' (#55) from chore/sync-website-tree into main
Reviewed-on: #55
2026-07-28 00:36:08 +00:00
43d01fde03 Merge branch 'main' into chore/sync-website-tree 2026-07-28 00:35:57 +00:00
9f6ad6888d Merge pull request 'docs(website): record the PR 3 admin router split (posts, uploads, wiki, pages)' (#56) from docs/admin-router-split-3 into main
Reviewed-on: #56
2026-07-28 00:28:24 +00:00
d515d42b7c docs(website): record the PR 3 admin router split (posts, uploads, wiki, pages)
Adds a "PR 3 — as landed" section to API_V2_PLAN.md and ticks the sequencing
list. 31 routes extracted, 33 left in admin.routes.js; all four zero-diff gates
came back clean and 434 server tests passed.

Findings carried forward:

- The residual 33 is exactly PR 4's list, so admin.routes.js is deleted by
  PR 4 rather than PR 5.
- First shared module in the split: the multer config, because POST
  /posts/upload and POST /uploads no longer live in the same file.
- POST /uploads keeps its Admin · Posts swagger tag — retagging is a real
  OpenAPI diff and does not belong in a route-move PR.
- The wiki router has load-bearing intra-file route order (/categories and
  /tags ahead of /:slug) that no gate can catch, because the manifest sorts
  its entries. Verified by introspecting the built router stack instead.

BACKEND_DESIGN.md §2 gets the four new routers plus imageUpload.js in the
folder tree, and §4 notes that the content capabilities add no gate beyond
staffOnly.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 19:24:58 -05:00
runic-docs-bot
c47c89c023 docs(tree): sync website/PROJECT_TREE.md from RunicGateway/website@4938432 [skip ci] 2026-07-28 00:12:16 +00:00
d034c6f673 Merge pull request 'docs(website): record the PR 2 admin router split (moderation, bot-activity, activity)' (#54) from docs/admin-router-split-2 into main
Reviewed-on: #54
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-28 00:11:13 +00:00
a4d03bd956 docs(website): record the PR 2 admin router split (moderation, bot-activity, activity)
Matches the code change in website: 18 more admin routes carved into
moderation.router.js (15), botActivity.router.js (2) and activity.router.js (1),
leaving 64 in the residual admin.routes.js.

API_V2_PLAN.md gains a "PR 2 — as landed" section recording the four zero-diff
gates and two decisions worth carrying into PRs 3-5:

  - /activity gets its own file rather than the target tree's plan to park it as
    a singleton inside dashboard.router.js — honouring the tree would have left
    one route in the residual file for two PRs, and it is a genuinely separate
    capability (the staff audit log, not the dashboard's stats overview and not
    the botScore middleware's ban state). PR 4 therefore mounts dashboard and
    site-mode only; the target tree is updated to match.
  - A gate moves to a router-level `use` only where it was already a *prefix*
    mount (moderation's modAccess). Bot-activity's per-route adminOnly stays
    per-route, because the per-route handler count is the only thing in
    routes.guards.json that would catch a dropped gate — requireRole(...) returns
    an anonymous arrow and never appears by name.

BACKEND_DESIGN.md §2 (folder structure) and §4 (the /admin contract preamble) are
updated for the new files and their gates. PROJECT_TREE.md is left alone — since
website#98 it is auto-generated by the sync-project-tree workflow.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 18:54:01 -05:00
7b699301e7 Merge pull request 'docs(tree): sync website/PROJECT_TREE.md' (#50) from chore/sync-website-tree into main
Reviewed-on: #50
2026-07-27 21:42:01 +00:00
runic-docs-bot
bd8adf1c54 docs(tree): sync website/PROJECT_TREE.md from RunicGateway/website@0e11e28 [skip ci] 2026-07-27 21:01:09 +00:00
fec3aa0d5d Merge pull request 'docs(website): record split PR 1 — admin users, account, invites, auth providers' (#53) from docs/admin-router-split-1 into main
Reviewed-on: #53
2026-07-27 20:59:05 +00:00
a7186e2fb9 Merge branch 'main' into docs/admin-router-split-1 2026-07-27 20:58:55 +00:00
b7244a24b0 docs(website): record split PR 1 — admin users, account, invites, auth providers
Documentation half of the first of five domain-split PRs (API_V2_PLAN.md § Phase 2).

BACKEND_DESIGN.md
- §2 folder structure: admin/ now shows index.js (shared gate + mount table) and
  the four capability routers with their route counts, prefixes and extra gates;
  admin.routes.js is labelled as the 82-route residual that goes away with PR 5.
- §4 /admin heading: was "admin.routes.js -> admin.controller.js", now points at
  admin/index.js and notes staffOnly, which the old heading omitted.
- The "planned change" note becomes "in progress" with what has landed.

API_V2_PLAN.md
- Status: planning -> in progress; PR 1 marked landed in the sequencing list.
- New "PR 1 — as landed" section: the route-count table (6+15+3+4+82 = 110) and
  three findings for PRs 2-5 — why the self-service /shard/* routes stay with the
  shard capability despite their Admin · Account tag, why a prefix mount must not
  be "simplified" to a pathless one (a bare use(gate) would then run for requests
  headed to later mounts), and that routes.guards.json came back zero-diff too.
- New section on the swagger path-normalization prerequisite and its consequence:
  with sorted path keys, a pure route move produces no spec diff, so the spec
  becomes a third zero-diff gate alongside the manifest and guards files.
- Correction to step 6: PROJECT_TREE.md is auto-generated by the sync-project-tree
  workflow since website#98 and must not be hand-edited in split PRs.

api-route-inventory.json is unchanged — verified still byte-identical to
server/routes.manifest.json (200 public + 2 internal), which is the point.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 15:55:37 -05:00
6cea1c24c4 Merge pull request 'docs(website): document the OpenAPI path-key normalization' (#52) from docs/swagger-normalize-paths into main
Reviewed-on: #52
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-27 20:52:09 +00:00
31c91fb307 docs(website): document the OpenAPI path-key normalization
Matches website PR "build(swagger): normalize and sort generated OpenAPI path
keys", which post-processes swagger-autogen's output ahead of the admin router
domain split (API_V2_PLAN.md § Phase 2).

- website-README.md § Regenerating the spec: why trailing slashes are stripped
  (a capability router mounted at /users declaring router.get('/') would document
  /api/v1/admin/users/, a URL no client calls) and why path keys are sorted.
- BACKEND_DESIGN.md § generated artifacts: note that both the route manifest and
  the spec are emitted sorted, so a diff in either is proportional to the change.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 15:50:01 -05:00
8c46af7e54 Merge pull request 'docs(website): correct the CSP delta to one directive and document the report sink' (#51) from feature/csp-report-only into main
Reviewed-on: #51
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-27 20:22:28 +00:00
9a3e1cc1e7 docs(website): correct the CSP delta to one directive and document the report sink
Companion to website "feat(security): soak the tightened CSP on report-only".

The plan's Phase 1 claimed a two-directive delta, one of which was adding
`form-action 'self'` as "currently absent". It was not absent. The directives
object in app.js does not list it, but the middleware runs `useDefaults: true`
and helmet's default set already supplies it, so production has been serving it
all along. The plan was written from the config rather than from the live
header; the correction, and how it was caught, are now recorded in place rather
than quietly fixed.

That leaves `frame-ancestors 'self'` -> `'none'` as the entire behavioural delta
of the phase. Worth noting that this is also the directive that most justifies a
soak: a frame-ancestors violation is reported by the browser of whoever framed
the site, so it is the only available way to discover a legitimate embed before
an enforcing policy breaks it.

Also documented:

  * POST /api/csp-report -- the same-origin sink report-to/report-uri point at,
    why it is same-origin, why it lives outside /api/v1, both wire formats, the
    Reporting-Endpoints header requirement, and the properties that make an
    unauthenticated public POST safe (always-204, caps, truncation, rate limit).
  * That the sink is scoped to the soak, so the enforce PR must decide
    explicitly whether to retire it or keep a report-to group on the enforced
    policy -- rather than leaving an orphan route behind.
  * The `[csp]` log tag in section 7.5 as the thing to watch during the soak,
    and what silence across one release means.
  * client/vite.config.js already sets `modulePreload: { polyfill: false }`, so
    the plan's inline-polyfill prerequisite was already satisfied.

api-route-inventory.json moves 199 -> 200 for the new route. That is the PR 0
freeze working as intended: the first manifest diff since the baseline is a
deliberate, reviewed one.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 15:19:43 -05:00
9ecfe610de Merge pull request 'docs(website): record the landed route manifest and resequence CSP after PR 0' (#49) from chore/route-manifest into main
Reviewed-on: #49
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-27 20:00:08 +00:00
b1a474a7eb docs(website): record the landed route manifest and resequence CSP after PR 0
Companion to website PR 0 (chore(server): freeze the URL surface with a
generated route manifest).

BACKEND_DESIGN.md gains § 4.0, naming the two generated artifacts that are
actually authoritative about the API and what each is authoritative *for*: the
manifest records which URLs exist (introspection-derived, reality), the Swagger
spec records what they mean (annotation-derived, intent). The prose tables in
§ 4 are orientation and can drift; those two files cannot. Also documents
routes.guards.json as a review aid that is explicitly not a contract.

API_V2_PLAN.md marks PR 0 shipped and records its two deviations. The optional
unauthenticated-status snapshot was tried and dropped exactly as that section
allowed — against the dead-port mariadb pool the tests use it sits on the acquire
timeout rather than failing fast — replaced by a deterministic assertion that
every /admin/** and /player/** route still carries requireAuth.
routes.guards.json is committed and staleness-checked even though a diff in it
is not a contract change, because an ungenerated review aid rots into a
misleading one.

The sequencing section is corrected: PR 0 now runs before the CSP pair. The CSP
report-only PR must stand up a POST /api/csp-report collector for `report-to` to
target, which is a new URL under /api/**; landing it first would have left PR 0
generating 200 routes against a 199-route baseline, destroying its own acceptance
test. With PR 0 first, the collector appears as a reviewed, deliberate +1 in the
manifest — the mechanism working as intended.

api-route-inventory.json is unchanged, which is the point: the generator
reproduced it byte-for-byte on first run.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 14:55:57 -05:00
40cd9375d7 Merge pull request 'docs(website): finalize the API plan — in-place router split, no /api/v2' (#48) from docs/api-plan-final into main
Reviewed-on: #48
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-27 19:41:11 +00:00
5de5e19445 docs(website): finalize the API plan — in-place router split, no /api/v2
The API v2 plan is revised down to the work that is actually justified: a CSP
hardening pass and an in-place domain split of the monolithic route wiring.

- Auth merge (httpOnly cookies -> bearer + rotating refresh for every client) is
  removed and re-filed as deferred behind trigger conditions. httpOnly+SameSite
  is the stronger model, session.service.js already unifies cookie and bearer,
  the SSO/PKCE transaction cookies survive any merge, and it dragged the admin
  SSE fetch/ReadableStream rewrite along as a dependency for no user-visible
  payoff. A revival must first spec refresh-token reuse detection and a rollback
  procedure.
- No parallel /api/v2. The URL surface is already grouped by capability, so each
  new router file mounts at the prefix it already owns and every URL stays
  byte-identical. No dual mount, no per-route migration, no v1 retirement; the
  SPA, Discord bot, and Android app are all untouched. API_V2_SKELETON.md is
  marked superseded (kept as the recipe if a versioned API is ever forced).
- The /api/mobile facade and app-version floor are deferred with the revival note
  that it starts as a one-line alias mount, not ~70 hand-written delegates. The
  M11 milestone is dropped from android/PLAN.md.
- Adds PR 0: a generated route manifest, so "every URL is unchanged" is proved by
  a zero-line diff rather than asserted in review. The baseline
  api-route-inventory.json (199 API routes + 2 internal) is committed here and is
  what PR 0's generator must reproduce byte-for-byte.
- Split sequenced as five grouped PRs; CSP fixed to report-only first, then
  enforce (the old plan contradicted itself), with the verified delta being just
  form-action 'self' and frame-ancestors 'none'.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 14:31:26 -05:00
81f7d58fbe Merge pull request 'docs(website): cross-component blast-radius review + /api/mobile facade (Phase 0)' (#47) from docs/api-v2-blast-radius-mobile-facade into main
Reviewed-on: #47
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-23 05:00:00 +00:00
3eafaef97e docs(website): add cross-component blast-radius review + /api/mobile facade (Phase 0)
Expand the API v2 plan to account for consumers beyond the browser and
insulate the Android app from version churn before the v2 work begins.

- Inventory the three v1 API consumers (browser, Android app, Discord bot)
  and map the cross-component contracts (site<->link, site<->mobile).
- Fix two concrete plan bugs: the public shard SSE stream must stay
  anonymous (logged-out browsers and the app's ShardStreamClient send no
  auth header), and the useShardFeed fetch-rewrite is admin-stream-only.
- Add "Phase 0 - the mobile facade": a version-agnostic /api/mobile
  namespace (a thin BFF delegating to current controllers behind pinned
  wire shapes), landed before v2 so the auth merge never touches the app.
- Note link/ is essentially out of scope (no PROTOCOL_VERSION bump), with
  the admin-stream allowlist split as the only shared seam.
- Resequence PRs (Phase 0 first) and gate v1 retirement on the pre-facade
  app fleet aging out via an app-version floor, not the web client.
- Add M11 to docs/android/PLAN.md: migrate the app to /api/mobile + ship
  the app-version floor, cross-referenced with the website plan's Phase 0.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-22 23:54:15 -05:00
4810830c8a Merge pull request 'docs(website): add /api/v2 skeleton (PR 1 scaffold)' (#46) from docs/api-v2-skeleton into main
Reviewed-on: #46
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 21:57:19 +00:00
bfb006c888 Merge branch 'main' into docs/api-v2-skeleton 2026-07-22 21:57:09 +00:00
2cb6f7a3b9 docs(website): add /api/v2 skeleton (PR 1 scaffold), link from plan
Companion doc to API_V2_PLAN.md describing PR 1: stand up router/v2/ empty but
wired next to a frozen /api/v1, with a trivial GET /api/v2/version to make the
mount testable and no behavior change. Includes the file tree, the api.router.js
/ v2.router.js wiring, empty capability-router stubs, and acceptance criteria.
Adds a forward link from the plan's PR-1 line to the skeleton doc.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 16:54:23 -05:00
cfd202b23e Merge pull request 'docs(website): add API v2 plan (auth merge, CSP hardening, domain split)' (#45) from docs/api-v2-plan into main
Reviewed-on: #45
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 21:52:04 +00:00
eb19556802 docs(website): add API v2 plan (auth merge, CSP hardening, domain split)
Plan for website API v2, sequenced in two phases behind a parallel /api/v2:

- Phase 1: retire httpOnly session cookies; unify web + mobile on the existing
  bearer access + rotating/revocable refresh model. Separates removable session
  cookies from the SSO/email transaction cookies that must stay. SSE moves to
  fetch-based streaming with Authorization: Bearer.
- Phase 1b: tighten the shipped CSP for the now-JS-held token (add form-action
  'self', frame-ancestors 'none'); self-host fonts + Trusted Types as follow-ups.
- Phase 2: break the monolithic route wiring (admin.routes.js, ~100 routes) into
  one router per business capability so the URL predicts the file.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 16:50:33 -05:00
c2f4866012 Merge pull request 'docs(tree): sync website/PROJECT_TREE.md' (#44) from chore/sync-website-tree into main
Reviewed-on: #44
2026-07-22 21:43:28 +00:00
runic-docs-bot
daf735f48f docs(tree): sync website/PROJECT_TREE.md from RunicGateway/website@cbe54fc [skip ci] 2026-07-22 21:31:42 +00:00
57395c51b1 Merge pull request 'docs(tree): sync link/PROJECT_TREE.md' (#42) from chore/sync-link-tree into main
Reviewed-on: #42
2026-07-22 21:27:34 +00:00
a458d4f594 Merge branch 'main' into chore/sync-link-tree 2026-07-22 21:27:11 +00:00
3e98a6c437 Merge pull request 'docs(tree): sync android/PROJECT_TREE.md' (#43) from chore/sync-android-tree into main
Reviewed-on: #43
2026-07-22 21:26:59 +00:00
runic-docs-bot
468c9b5541 docs(tree): sync android/PROJECT_TREE.md from RunicGateway/Android-app@f3da6ea [skip ci] 2026-07-22 21:25:02 +00:00
runic-docs-bot
7f9d63308b docs(tree): sync link/PROJECT_TREE.md from RunicGateway/link@7e4177c [skip ci] 2026-07-22 21:20:56 +00:00
d10aefa755 Merge pull request 'docs(tree): add per-repo PROJECT_TREE snapshots + index them' (#41) from docs/add-project-trees into main
Reviewed-on: #41
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 21:16:08 +00:00
3ae4d1c3db docs(tree): add per-repo PROJECT_TREE snapshots + index them
Add auto-generated project-tree snapshots for the website, link, and
Android-app repos under docs/<repo>/PROJECT_TREE.md, and link them from
the README (adding a previously-missing android/ section). These files
are maintained going forward by the sync-project-tree CI workflow in each
source repo, which opens a PR here whenever the tracked layout changes.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 16:14:52 -05:00
f773a13c23 Merge pull request 'docs(android): add test coverage plan to reach the 50% gate' (#40) from docs/android-coverage-plan into main
Reviewed-on: #40
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 19:30:04 +00:00
43d9782d2e docs(android): add test coverage plan to reach the 50% gate
Post-#26 the JaCoCo→Sonar wiring is live and coverage measures 16.4% on new
code — under the 50% gate. Add COVERAGE_PLAN.md: a bucketed analysis of the
gap (ViewModels 0.1%/1153 lines is the dominant lever; DTOs, repositories,
core utils next) and a phased plan — Phase 0 broadens coverage exclusions to
drop non-unit-testable UI/framework code, Phases 1–4 test DTOs, ViewModels,
repositories, and core. Includes a MainDispatcherRule harness + VM test
pattern and per-phase coverage projections (Phase 2 clears the gate at ~65%).
Cross-linked from PLAN.md §12.1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-22 14:27:39 -05:00
1e6710dd50 Merge pull request 'docs(android): record SonarQube coverage wiring and issue triage (§12.1)' (#39) from docs/android-sonar-coverage into main
Reviewed-on: #39
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 19:00:35 +00:00
a743b6000c docs(android): record SonarQube coverage wiring and issue triage (§12.1)
Add §12.1 documenting the Android-app SonarQube setup: the coverage gate
failed at 0% because the source-only scan received no JaCoCo report (a
reporting gap, not a testing gap). Records the JaCoCo wiring (jacoco plugin +
report task, sonar.coverage.jacoco.xmlReportPaths, pure-UI coverage
exclusions, a Gradle step in sonarqube.yml) and the 2026-07-22 triage:
3 smells fixed in code, 12 marked Won't Fix (snake_case DTO fields that
mirror the wire contract; idiomatic Compose/nav complexity).

Pairs with RunicGateway/Android-app chore/sonar-coverage-and-cleanup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-22 13:59:14 -05:00
2686ade632 Merge pull request 'docs(android): note the empty-subscriptions PUT serialization gotcha' (#38) from docs/notifications-empty-subscriptions-gotcha into main
Reviewed-on: #38
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 16:39:30 +00:00
2d2a68d4a7 docs(android): note the empty-subscriptions PUT serialization gotcha
Record the "can't turn off the last notification" class of bug in PLAN.md §11:
the PUT /auth/me/notifications/subscriptions validator requires `streams`, so an
empty set must serialize as {"streams":[]} not {}. kotlinx.serialization drops a
property equal to its default (encodeDefaults=false), so a request DTO field
defaulting to emptyList() gets omitted when empty and the server rejects it 400.
Generalized to any "replace the full set" PUT/POST whose empty value equals a DTO
default. Documents the fix in Android-app fix/notifications-empty-subscriptions.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 11:37:53 -05:00
7d7df6ac15 Merge pull request 'docs(ntfy): ntfy publishes a host port for the external reverse proxy' (#37) from fix/ntfy-published-port into main
Reviewed-on: #37
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 08:59:19 +00:00
0d5c0486dd docs(ntfy): ntfy publishes a host port for the external reverse proxy
Match the website change: the ntfy relay is reached through the public
reverse proxy, which runs outside the compose network and can only reach
a service via a published host port. Update the "no published host port /
internal-only publisher" claims in android/PLAN.md (§11 + §13) and
website/BACKEND_DESIGN.md to describe the published NTFY_HOST_PORT
(default 2586 -> ntfy:80), and note that both devices and the backend
publisher reach ntfy on the public origin.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 03:57:42 -05:00
0251df5bfe Merge pull request 'docs(backend): note the /player/* group is role-agnostic self-service' (#36) from docs/staff-player-self-service into main
Reviewed-on: #36
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 08:28:55 +00:00
30cfa0df1f docs(backend): note the /player/* group is role-agnostic self-service
Record that the whole /player/* router sits behind requireAuth only (not
requireRole('player')): staff are a superset of players, every handler is
self-scoped to the caller, and staff reach the identical handlers under
/admin/shard/*. This is why a staff account with linked characters gets
its "My characters" and personal notification streams on the mobile
client. Matches the code change in RunicGateway/website.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 02:18:22 -05:00
9fe7b75833 Merge pull request 'docs(android): full trusted-devices/MFA smoke-test walkthrough frames' (#35) from docs/trusted-devices-screenshots-walkthrough into main
Reviewed-on: #35
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 06:50:07 +00:00
1809f15456 docs(android): add full trusted-devices/MFA smoke-test walkthrough frames
Supplements the five curated highlights (docs#34) with the remaining
distinct frames from the same live smoke-test session, in flow order:
home/connected, signed-out + signed-in drawers, login + empty 2FA step,
account overview, recovery-codes pre-generate, and the untrust-all to
empty-list to TOTP-required-again sequence. Extends the screenshots
README with a walkthrough table.

Excludes the raws superseded by the five highlights and pure automation
artifacts (soft-keyboard popups, mid-transition spinners, duplicate Home
landings) that do not represent a distinct app state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-22 01:33:48 -05:00
7d09475c53 Merge pull request 'docs(android): trusted-devices/MFA live smoke-test screenshots' (#34) from docs/trusted-devices-screenshots into main
Reviewed-on: #34
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 06:27:37 +00:00
e34284561f @
docs(android): add trusted-devices/MFA live smoke-test screenshots

Live end-to-end smoke test of the Android trusted-device + recovery-code
feature (app PR RunicGateway/Android-app#23) against the local server +
MariaDB on an API 36 emulator. Adds android/screenshots/ with five
captures (login trust step, account Security section, Trusted Devices,
recovery-codes show-once, recovery-code login) and a README documenting
the verified flows — including that trust survives logout (password-only
re-login skipped TOTP) and untrust-all clears the local token.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
@
2026-07-22 01:23:58 -05:00
b64b310d67 Merge pull request 'docs(android): record trusted-devices app implementation in PLAN §4.1.1' (#33) from docs/trusted-devices-mfa-app into main
Reviewed-on: #33
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 06:05:28 +00:00
db983fbb8f @
docs(android): record trusted-devices app implementation in PLAN §4.1.1

Marks §4.1.1 implemented (app PR RunicGateway/Android-app
feature/trusted-devices-mfa) and captures two deliberate design
decisions from the build:

- The trust token DELIBERATELY SURVIVES logout (native analogue of the
  rg_trust cookie): it is only consulted at a fresh login, so clearing
  it on logout would make the feature a no-op. Kept in a separate,
  username-scoped encrypted store; cleared only on Settings→Server
  switch, untrust-all, or server-side revocation. Supersedes the earlier
  handoff note.
- The login-time trust cap is surfaced + resolved on the Trusted Devices
  screen rather than a blocking login modal, since native login already
  issued the session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
@
2026-07-22 00:42:45 -05:00
f495db572a Merge pull request 'docs: trusted devices & MFA improvements' (#32) from docs/trusted-devices-mfa into main
Reviewed-on: #32
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 04:50:48 +00:00
6e7da3acbe docs: trusted devices & MFA improvements (design + API/security/schema)
Add TRUSTED_DEVICES_MFA.md (the approved design/implementation plan) and fold
the feature into BACKEND_DESIGN §3 (trusted_devices + recovery_codes schema),
§4 (login/totp trust+recovery, /auth/me/trusted-devices*, recovery-codes*,
admin trusted-device + /mfa/reset routes), and §6 (trusted-device security
model + audit actions). Note the app-side trust/recovery flow in android PLAN §4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 23:39:05 -05:00
e82eac6d97 Merge pull request 'docs(website): add architecture diagram' (#31) from docs/website-architecture-diagram into main
Reviewed-on: #31
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 02:15:37 +00:00
80f75b1b4c docs(website): add architecture diagram
Add docs/website/ARCHITECTURE.md (canonical copy of the website architecture
Mermaid diagram, with fuller notes) and embed the same diagram in the
website-README mirror. Mirrors the diagram added to the website repo README.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 21:13:29 -05:00
eba08bc53d Merge pull request 'docs(android): M10 — native SSO fixes + staff operations scope' (#30) from docs/m10-native-sso-admin into main
Reviewed-on: #30
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-21 21:33:04 +00:00
e50c6dda2c docs(android): M10 — native SSO fixes + staff operations scope
Record the M10 plan and correct the admin-scope contract:

- §1: narrow the app's exclusion list. The operational admin subset is now
  IN scope for staff (moderation, support queue, dashboard/site-mode, content =
  news posts + wiki cats/tags). Only the hero/CMS block editor, Discord-bot
  config, uo-link config, and OAuth-provider setup remain excluded.
- §6.4: self-service stays role-agnostic under /auth/me/*, but the operational
  admin subset now calls /api/v1/admin/** directly, gated by a STAFF/ADMIN menu
  access level; bearer is accepted and role re-checked every request.
- §9: add milestone M10 covering the SSO discovery/reachability fixes (native
  buttons, no website fallback, encrypted pending PKCE, scrollable drawer, dev
  stub IdP) and the staff-operations screens.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 15:23:01 -05:00
c3062f9960 Merge pull request 'docs(website): add Discord bot test plan' (#29) from docs/bot-test-plan into main
Reviewed-on: #29
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-21 06:05:59 +00:00
027fe2cbc3 docs(website): add Discord bot test plan
Document the test plan for the bot/ workspace — the last of the three website
npm workspaces without a suite (server + client landed in website PR #86).
Records the shared node --test conventions (no jest/vitest/jsdom; DB pool at a
dead port; Discord objects hand-faked) and maps the meaningful bot behavior to
lock: the normalize/duration/spam pure logic, the invite/site-api/internal-key
single-collaborator units, the messageFilter pipeline (bypass-first decision
order + fixed-duration mute + best-effort recording), and the models with real
shaping logic. Includes the CI + SonarQube coverage wiring to mirror PR #86 and
a suggested phasing.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 01:01:34 -05:00
f97931f8c3 Merge pull request 'docs(android): note explicit network security config for cleartext posture' (#28) from fix/android-cleartext-doc into main
Reviewed-on: #28
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-21 04:42:16 +00:00
c1d99f943a docs(android): note explicit network security config for cleartext posture
Record that the app's HTTPS-only-in-release / HTTP-in-debug rule (ServerUrl,
allowInsecureHttp = BuildConfig.DEBUG) is backed at the platform socket layer
by an explicit network security config: main/release forbids all cleartext, a
debug override re-permits cleartext to loopback only. Matches the fix in
RunicGateway/Android-app (fix/manifest-cleartext-traffic).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 23:39:40 -05:00
f3fa48c932 Merge pull request 'docs(ci): document the SonarQube static-analysis setup' (#27) from docs/ci-sonarqube into main
Reviewed-on: #27
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-21 04:16:50 +00:00
2257df09eb docs(ci): document the SonarQube static-analysis setup
Adds docs/ci/SONARQUBE.md covering the non-blocking push-to-main scan
wired into website, link, and Android-app: server URL, per-repo project
keys/sources, the SONAR_TOKEN secret + SONAR_HOST_URL variable, and how
to onboard a new repo.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 23:13:54 -05:00
17f9207a17 Merge pull request 'docs(website): document the SPA Content-Security-Policy' (#26) from docs/csp-security-headers into main
Reviewed-on: #26
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-21 04:04:02 +00:00
28c5c228f7 docs(website): document the SPA Content-Security-Policy
Replace the vague "helmet with a CSP suited to the SPA" line with the actual
policy now implemented in server/src/app.js: per-directive sources and the
rationale for each non-'self' allowance (Google Fonts, inline React styles,
external/embedded images, same-origin REST+SSE), why upgrade-insecure-requests
is omitted, the scoped looser CSP for the /api/docs Swagger UI route, and the
X-Powered-By handling across the public and internal listeners.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-20 23:02:57 -05:00
6e0ff2a821 Merge pull request 'docs(android): spec Android App Links (assetlinks.json + build-time host)' (#25) from docs/app-links into main
Reviewed-on: #25
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-20 23:42:24 +00:00
0109df6963 Merge branch 'main' into docs/app-links 2026-07-20 23:42:13 +00:00
752793f6c3 docs(android): spec Android App Links (assetlinks.json + build-time host)
Promote APP_LINKS.md from a deferred design note into an implementation spec
matching the website `feat/mobile-app-links` and android `feat/app-links`
branches: the server-side `/.well-known/assetlinks.json` route + `mobile_app_links_enabled`
toggle + additive redirect-allowlist entry, and the app-side `autoVerify`
intent-filter driven by a build-time `appLinkHost` (a single multi-tenant APK
cannot autoVerify open-ended shard domains, so App Links are a white-label /
first-party build opt-in; the custom scheme stays the permanent fallback).

Update PLAN.md §9 (M9 follow-up) and the §14 open item, and the redirect-URI
allowlist section of website/BACKEND_DESIGN.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-20 18:37:04 -05:00
82d88f26ec Merge pull request 'docs(android): record the M9 Part 2 native-SSO app plan' (#24) from docs/m9-native-sso-part2 into main
Reviewed-on: #24
2026-07-20 22:56:27 +00:00
44544dc3bc docs(android): record the M9 Part 2 native-SSO app plan
Add the "M9 plan — native SSO login" block to android/PLAN.md: the two-part
(backend-first) split, the frozen Part-1 bridge contract the app codes against
(/auth/providers, /auth/mobile/sso/start Custom-Tab redirect, the code/error
callback deep link, /auth/mobile/sso/exchange), and the six Part-2 app work
items (PKCE+state, SsoAuthManager, SsoApi+DTOs, the callback intent-filter,
the login-screen provider list, and the JVM tests).

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-20 17:52:56 -05:00
b3fa93e9c4 Merge pull request 'docs(auth): design the mobile SSO authorization bridge (M9)' (#23) from docs/mobile-sso-bridge into main
Reviewed-on: #23
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-20 22:09:08 +00:00
1aba1ff93d docs(auth): design the mobile SSO authorization bridge (M9)
Record the plan before coding: native Android "Sign in with Google/Discord"
via a Mobile SSO Authorization Bridge that extends the existing /auth/sso/*
redirect flow and terminates in the existing mobile bearer tokens.

- BACKEND_DESIGN.md: mobile_auth_sessions / mobile_auth_codes schema, the
  /auth/mobile/sso/{start,exchange} contract, the two PKCE layers, state/CSRF,
  exact-match redirect-URI allowlist, TOTP parity, and the documented
  revocation-latency window.
- android/PLAN.md: promote §4.2's "possible later enhancement" to milestone M9
  (backend-first, mirroring M7); status note.
- android/APP_LINKS.md: new architecture note on the per-shard assetlinks.json
  / pairing multi-tenancy question (App Links deferred; custom scheme only now).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 17:01:57 -05:00
5a7bbc26fa Merge pull request 'docs: M7 Part 2 landed — embedded ntfy distributor + push.ntfyUrl' (#22) from docs/android-m7-part2-landed into main
Reviewed-on: #22
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-20 20:29:43 +00:00
71cb181152 docs(android): M7 Part 2 landed — embedded ntfy distributor + push.ntfyUrl
Flip M7 Part 2 to landed (Android-app#15) and record the two implementation
decisions: the direct-ntfy embedded distributor (no UnifiedPush library — the
plan's stated likely path; foreground-service SSE, no second app, no Google
Play Services), and the small additive push.ntfyUrl settings field the app
needs to discover the relay (website#79). Document push.ntfyUrl +
NTFY_PUBLIC_URL in BACKEND_DESIGN.md.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 15:27:55 -05:00
837b546f49 Merge pull request 'docs(android): plan M7 Part 2 — app UnifiedPush push notifications' (#21) from docs/android-m7-part2-plan into main
Reviewed-on: #21
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-20 19:58:55 +00:00
1dbdeb789e docs(android): plan M7 Part 2 (app UnifiedPush push notifications)
Part 1 backend landed (website#78 merged); flip its status to landed and
expand the M7 plan block into a detailed Part 2 (app) plan, grounded in the
merged /auth/me/devices + notifications contract.

Transport decision (per user): the app EMBEDS its own UnifiedPush
distributor — ntfy is only the relay server, no second app installed, no
Google Play Services. A foreground-service persistent ntfy connection
(reusing the ShardStreamClient pattern) subscribes to the app's own topic;
the registered endpoint is that topic URL. A PushTransport seam keeps the
future FCM Play flavor cheap.

Also records the ten Part-2 work items, resolves the §13 notification-tap
deep-link open item, and drops the now-decided distributor-strategy question.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 14:57:32 -05:00
9a8c083a1e Merge pull request 'docs: M7 push-notification backend contract + plan' (#20) from docs/android-m7-push into main
Reviewed-on: #20
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-20 15:24:36 +00:00
cb10cee6f1 docs: M7 push backend contract + status (website#78)
- BACKEND_DESIGN.md: push_devices + notification_subscriptions tables (§3), the
  /auth/me/devices* + /auth/me/notifications/* API rows (§4), a push-notification
  design + security section (content-free tickles, PUBLIC_KINDS split, owner-keyed
  personal streams, SSRF endpoint guard, untrusted-relay model), and the ntfy
  compose service in the deploy section (§8).
- PLAN.md: flip M7 Part 1 (backend + docs) to in-review — status line, §8 item 3,
  §9 M7.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 05:15:25 -05:00
a51017f4c4 docs(android): record M7 push-notifications plan (Part 1 backend + docs)
Capture the M7 backend/docs breakdown in PLAN.md before the code lands: two
event sources / one publisher, the stream catalog + PUBLIC_KINDS-gated mapping,
push_devices + notification_subscriptions tables, the /auth/me routes, the SSRF
endpoint guard, and the content-free-tickle ntfy service (no publish token).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 04:51:43 -05:00
6f9632f77b Merge pull request 'docs(android): record M6 (release mechanics) landed' (#19) from docs/android-m6-landed into main
Reviewed-on: #19
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-20 08:40:12 +00:00
f79c2fa5a9 docs(android): record M6 (release mechanics) landed
Mark M6 landed (RunicGateway/Android-app#11): signed-APK release plumbing
(R8 minify + resource shrink, release signingConfig from a gitignored keystore,
release.yml on a v* tag), the §3 version-mismatch guard, and the default brand
app icons (deep-indigo medallion). Record the decision to descope the optional
biometric app-lock from v1 (tokens already encrypted at rest) across §4.3, the
M3 note, §9, and §13.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 03:37:37 -05:00
8798a0ff79 Merge pull request 'docs(android): record M5 (design pass) landed' (#18) from docs/android-m5-landed into main
Reviewed-on: #18
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-20 08:03:55 +00:00
4eae12448a docs(android): record M5 (design pass) landed
Mark the M5 shard-website design pass complete in docs/android/PLAN.md,
matching RunicGateway/Android-app#10: dark-only theme (deep blue-black
surfaces, slate accent, Cinzel display face), reusable pill/label/card/meter
components, and retained per-shard brand-accent seeding — restyling the
working M1–M4 screens with no architecture, data-flow, endpoint, or DTO
change. Updates the status header, the §9 build-progress record, and the
milestone list (M0–M5 landed; M6 release hardening next).

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-20 01:56:27 -05:00
17320ac578 Merge pull request 'docs(android): summarize website frontend theme for Android client' (#17) from docs/android-m4-landed into main
Reviewed-on: #17
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-20 03:44:03 +00:00
a3a5985268 docs(android): summarize website frontend theme for Android client
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-19 22:41:45 -05:00
3a1d091a71 Merge pull request 'docs(android): record M4 (player self-service & game data) landed' (#16) from docs/android-m4-landed into main
Reviewed-on: #16
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-20 03:36:32 +00:00
cdc2bf580e docs(android): record M4 (player self-service & game data) landed
Marks M4 done in android/PLAN.md §9 build-progress and flips the top
status to "M0–M4 landed; M5 (design pass) next." Documents the account
self-service (/auth/me/account*), game-account linking, and text-only
own game-data screens shipped in RunicGateway/Android-app#9 — a pure
consumer of the existing bearer API, no backend/protocol change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-19 22:35:35 -05:00
da3be61c7f Merge pull request 'docs(android): record M2 (public shard) + M3 (auth) landed' (#15) from docs/android-m3-landed into main
Reviewed-on: #15
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-20 00:16:39 +00:00
ed53990679 Merge branch 'main' into docs/android-m3-landed 2026-07-20 00:16:28 +00:00
0d5743ec74 docs(android): record M3 (auth) landed, M4 next
Records the M3 functional auth pass in docs/android/PLAN.md: native
username/password (+TOTP) login, EncryptedSharedPreferences token storage,
the refresh-on-401 authenticator, /auth/me resume re-validation, the
declarative access-level menu + My Account, and the Custom-Tab hand-offs for
register / forgot-password / SSO. Also records the decision to defer the
optional biometric app-lock to M6 (tokens are already encrypted at rest).

No backend/API change accompanied M3 — the app is a pure consumer of the
existing mobile bearer + /auth/me surface — so no Swagger regeneration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-19 19:09:15 -05:00
07603691bb Merge pull request 'docs(android): record M2 (public shard + SSE) landed' (#14) from docs/android-m2-landed into main
Reviewed-on: #14
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-19 23:39:02 +00:00
90bc9a0dad docs(android): record M2 (public shard + SSE) landed
Mark M2 done in the build-progress section: the public shard widgets over
/public/shard/* (hub + champ/guild/governor/house boards) and the live SSE
feed with self-driven reconnect/backoff (Android-app#7). Update the status
line to "M0–M2 landed; M3 next". No API change — the app consumes the
existing public shard surface.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-19 18:36:17 -05:00
292cdb3274 Merge pull request 'docs(android): record M1 (connect & browse) landed' (#13) from docs/android-m1-progress into main
Reviewed-on: #13
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-19 22:12:43 +00:00
eda817e6f3 docs(android): record M1 (connect & browse) landed
Mark M0+M1 done in the build-progress header and next up M2. Summarize
the M1 functional pass (first-run connect, runtime base URL + host
interceptor, layered ApiResult stack, brand-seeded theming, public
content/wiki/pages/contact screens) and record the deliberate
hand-written-vs-openapi-generated API-client deviation from §2 and its
rationale (swagger-autogen schemas are meta-descriptive, not
codegen-clean). Tracks RunicGateway/Android-app#6.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-19 17:11:15 -05:00
061fbee8fc Merge pull request 'docs(android): mark M0 scaffold landed, M1 next' (#12) from docs/android-m0-status into main
Reviewed-on: #12
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-19 18:14:30 +00:00
78a455e3bb docs(android): mark M0 scaffold landed, M1 next
Update the PLAN.md status header now that the Android-app repo scaffold
(RunicGateway/Android-app#2) is in: Gradle+Compose+Hilt skeleton, version
catalog, and CI. Records M0 done and points at M1 (functional Kotlin pass).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-19 12:54:25 -05:00
e78c92850b Merge pull request 'docs: /public version+brand; mark PLAN §8 items 4 & 6 done' (#11) from docs/public-version-and-brand into main
Reviewed-on: #11
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-19 17:22:06 +00:00
874fcfd79d docs: document /public version+brand; mark PLAN §8 items 4 & 6 done
Counterpart to RunicGateway/website#77.

- BACKEND_DESIGN.md: /public table now documents the new GET /public/version
  (DB-free identity/version), the version block on /public/status, and the brand
  block on /public/settings (per-shard theming: name/accent/logo/hero/favicon).
- android/PLAN.md: mark §8 item 4 (version/health) and item 6 (branding) DONE and
  update the prerequisite-progress summary — all v1 prerequisites are now done;
  only push notifications (item 3) remains and is post-v1 (M7). App M0–M4 unblocked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-19 12:18:53 -05:00
d05b59316b Merge pull request 'docs: document /auth/me self surface; mark PLAN §8.1 done' (#10) from docs/auth-me-self-surface into main
Reviewed-on: #10
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-19 16:36:04 +00:00
63bce88bd7 docs: document /auth/me self surface; mark PLAN §8.1 done
Counterpart to RunicGateway/website#76 (role-agnostic /auth/me/* self surface).

- BACKEND_DESIGN.md: add the /auth/me/account* rows to the /auth API contract and
  a note that the surface reuses account.controller behind requireAuth (any role),
  so a client manages its own account without touching /admin.
- android/PLAN.md: mark §8 item 1 (role-agnostic self-service) DONE and update the
  prerequisite-progress summary; version/health (item 4) and branding (item 6)
  remain open, push (item 3) is post-v1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-19 04:56:55 -05:00
a3ede38c5d Merge pull request 'docs(android): mark the password-reset prerequisite done in PLAN' (#9) from docs/android-plan-prereq-status into main
Reviewed-on: #9
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-19 09:12:21 +00:00
7fa6eee9a9 docs(android): mark the password-reset prerequisite done in PLAN
§8 item 2 (password reset — the "build FIRST before app work" prerequisite)
shipped in RunicGateway/website#75 + docs#8. Mark it done, note the shipped
design (opaque token stored as a sha256 hash in password_resets, mirroring
user_invites, rather than a signed JWT), and record which §8 items remain open.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-19 04:10:12 -05:00
ee87ce0729 Merge pull request 'docs(backend): document the password-reset endpoints and table' (#8) from docs/password-reset into main
Reviewed-on: #8
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-19 09:01:33 +00:00
d87a45e914 docs(backend): document the password-reset endpoints and table
Add /auth/password/forgot and /auth/password/reset/:token to the API
contract and the password_resets table to the schema section, matching the
website change (RunicGateway/website feat/password-reset). Notes the
no-enumeration behaviour, single-use hashed-token model, and that the Android
app hands off to the web reset page (PLAN.md §4.2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-19 03:57:22 -05:00
099e5b0af4 Merge pull request 'docs(android): add design-pass workflow to app PLAN' (#7) from docs/android-design-pass-workflow into main
Reviewed-on: #7
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-19 08:36:23 +00:00
c59ff9270b docs(android): add design-pass workflow to app PLAN
Record the two-pass build process: functional Kotlin first (M0-M4), then a Claude Design pass producing the front-end design that Claude Code implements as the final UI (new M5). Add §2.1 documenting the workflow and reconcile §9 milestones (insert M5 design pass, renumber Polish/Push/Play to M6-M8) plus the milestone cross-references in §8, §11, §12, and §13.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-19 03:35:22 -05:00
033292504e Merge pull request 'docs(android): add Android app design plan' (#6) from docs/android-app-plan into main
Reviewed-on: #6
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-19 08:17:42 +00:00
8963269ff0 docs(android): add Android app design plan
Add docs/android/PLAN.md — the design contract for the RunicGateway/Android-app
repo (planning only, no app code yet).

Scope: native Kotlin + Jetpack Compose client of the website v1 API. Public
content + public shard widgets (incl. SSE), native username/password + TOTP
login, player self-service via a new role-agnostic /auth/me/* surface, and a
player's own shard/game data. Excludes every admin/management console (hero
editor, auth/provider admin, Discord bot, shard/uo-link ops).

Key decisions captured: stay on v1 (all additions are additive, no v2);
registration/invite/reset/SSO are website-handled hand-offs, not native screens;
password reset is built on the backend + web front end first; single shard per
install; minSdk 29; no telemetry and no offline cache in v1; text-only game data
(paperdoll is future); strings externalized from day one; push via a self-hosted
ntfy/UnifiedPush service with content-free tickles that keep the relay untrusted;
Gitea Actions build on ubuntu:latest with a signed-APK release.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-19 03:14:37 -05:00
e889700227 Merge pull request 'docs(website): moderation appeals + Discord reversal (Phase 6c/6d)' (#5) from docs/moderation-appeals into main
Reviewed-on: #5
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-19 03:41:11 +00:00
50244e5c2b docs(website): link moderation appeals doc from the feature index 2026-07-19 03:36:11 +00:00
4f0c282f3f docs(website): add moderation appeals + Discord reversal (Phase 6c/6d) 2026-07-19 03:34:26 +00:00
f1aa65cc17 Merge pull request 'docs(website): staff in-game location is admin/moderator-only' (#4) from fix/staff-location-visibility into main
Reviewed-on: #4
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-19 02:23:52 +00:00
8a3e37ae73 docs(website): staff in-game location is admin/moderator-only
The "What each audience sees" table said the public "Staff online" list
is shown "with name + map location". Location is now privileged: the
server includes map/coords only for admin/moderator callers and strips
them from the payload for players and the public. Update the wording to
match (RunicGateway/website#72).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XmHdsbnLzDMAVQkAoTQSBe
2026-07-18 21:21:20 -05:00
363eb810da Merge pull request 'chore: add open-source governance files (GPLv3 + contributing docs)' (#3) from chore/open-source-governance into main
Reviewed-on: #3
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-19 00:29:56 +00:00
42 changed files with 8403 additions and 31 deletions

View File

@@ -9,6 +9,8 @@ so they live in one place, independent of either codebase.
```
website/ docs from the shard website (Node/Express + MariaDB + React/Vite)
link/ docs from the ServUO bridge (C# plugin + Rust sidecar + Node WS)
android/ docs from the native Android client (Kotlin + Jetpack Compose)
ci/ cross-cutting CI/quality notes
```
### `website/`
@@ -17,18 +19,35 @@ link/ docs from the ServUO bridge (C# plugin + Rust sidecar + Node WS)
| [BACKEND_DESIGN.md](website/BACKEND_DESIGN.md) | API contract, DB schema, security model |
| [HERO_EDITOR.md](website/HERO_EDITOR.md) | Hero canvas editor feature spec |
| [WIKI_UPGRADE.md](website/WIKI_UPGRADE.md) | Wiki subsystem upgrade notes |
| [SHARD_VISIBILITY.md](website/SHARD_VISIBILITY.md) | Who sees which shard data — the admin-configurable audience framework |
| [SPAWN_ATLAS.md](website/SPAWN_ATLAS.md) | The bestiary / spawn atlas: what the shard contains, parsed from its own ServUO tree |
| [CLILOCS.md](website/CLILOCS.md) | UO's id → name table: converting one from your client so items have names |
| [MARKETPLACE.md](website/MARKETPLACE.md) | The player-vendor index: how it is gathered, what it costs, how to tune it |
| [website-README.md](website/website-README.md) | Snapshot of the website repo's README (setup/run reference) |
| [PROJECT_TREE.md](website/PROJECT_TREE.md) | Auto-generated snapshot of the repo's tracked file layout |
### `link/`
| Doc | What it covers |
|---|---|
| [INTEGRATION.md](link/INTEGRATION.md) | How the website integrates with the uo-link sidecar |
| [PROTOCOL_2.md](link/PROTOCOL_2.md) | Protocol 2.0 / 2.1 design |
| [v3.md](link/v3.md) | Protocol 3.0 design — shard content/standings streams + the visibility framework |
| [ADMIN_CONTROLS.md](link/ADMIN_CONTROLS.md) | Staff write-plane (kick/ban/broadcast, page queue) |
| [SHARD_PREREQS.md](link/SHARD_PREREQS.md) | Shard-side prerequisites for the bridge |
| [PLAN.md](link/PLAN.md) | uo-link build plan |
| [RESEARCH.md](link/RESEARCH.md) | Research notes |
| [link-README.md](link/link-README.md) | Snapshot of the link repo's README |
| [PROJECT_TREE.md](link/PROJECT_TREE.md) | Auto-generated snapshot of the repo's tracked file layout |
### `android/`
| Doc | What it covers |
|---|---|
| [PLAN.md](android/PLAN.md) | Android client build plan / milestones |
| [COVERAGE_PLAN.md](android/COVERAGE_PLAN.md) | Test-coverage rollout plan |
| [APP_LINKS.md](android/APP_LINKS.md) | Android App Links / deep-link setup |
| [theme-plan.md](android/theme-plan.md) | Theming plan |
| [TRUSTED_DEVICES_APP_HANDOFF.md](android/TRUSTED_DEVICES_APP_HANDOFF.md) | Trusted-devices app handoff notes |
| [PROJECT_TREE.md](android/PROJECT_TREE.md) | Auto-generated snapshot of the repo's tracked file layout |
## Provenance

196
android/APP_LINKS.md Normal file
View File

@@ -0,0 +1,196 @@
# Android App Links — implementation spec
Status: **implementation spec (M9 follow-up).** Stacks on the native SSO bridge (M9 Part 2):
the app already handles the **custom-scheme** callback `runicgateway://auth/callback`, and that stays
the permanent default and universal fallback. App Links are an **opt-in hardening** layered on top —
a verified `https://` callback that only the domain's real owner can claim.
Read alongside: the "Mobile SSO Authorization Bridge" section of
[`../website/BACKEND_DESIGN.md`](../website/BACKEND_DESIGN.md) (endpoints/tables/allowlist), and
[`PLAN.md`](./PLAN.md) §4.2 / §9 (the app milestone). This spec matches what ships on the
`feat/mobile-app-links` (website) and `feat/app-links` (android) branches.
---
## 1. The problem it solves
The mobile SSO bridge redirects the browser back to the app with a one-time code:
```
runicgateway://auth/callback?code=…&state=…
```
A **custom URI scheme** is fine for a self-hosted internal client, but it is not *owned* by anyone:
any other Android app can register an intent-filter for `runicgateway://auth/callback` and, if chosen
by the user, intercept the callback. The code is single-use, PKCE-bound (Layer B), and short-lived —
so an interceptor still cannot complete `/exchange` without the app's `code_verifier` — but a hijacked
callback is still a denial-of-service and a phishing surface we would rather close.
**Android App Links** (verified `https://` deep links) close it: the OS only routes an `https://`
link to an app that has proven, via a file served from *that domain*, that it owns the app. An
attacker cannot serve that file on a domain they do not control.
## 2. Why this is harder here than in a normal app
RunicGateway is **self-hosted per shard**. There is no single canonical domain — every shard owner
runs the website on **their own** domain (`play.exampleshard.com`, `uo.anothershard.net`, …). App
Links verification is **per-domain**: the domain must serve
```
https://<shard-domain>/.well-known/assetlinks.json
```
asserting the Android app's **package name** + **signing-certificate SHA-256 fingerprint**.
That is only half the problem. The other half is an Android platform constraint that decides the whole
shape of the app side:
> **`android:autoVerify` needs a *literal* host at build time.** An intent-filter's `<data android:host>`
> is a static string in the merged manifest; there is no "any host" or runtime host. A **single
> published multi-tenant APK therefore cannot autoVerify an open-ended set of shard domains** — the set
> is not known when the APK is built.
So App Links here are **not** a drop-in replacement for the custom scheme. They split into two pieces
that ship independently:
1. **Server (`assetlinks.json`) — shippable now, benefits any App-Links-capable build.** Every shard
can auto-serve its Digital Asset Links statement behind an admin toggle. This is a pure add and is
implemented on `feat/mobile-app-links`.
2. **App (`autoVerify` intent-filter) — a *build-time* opt-in.** Because the host must be baked in,
App Links are available to:
- a **white-label / first-party build** that bakes one shard's host (`-PappLinkHost=play.myshard.com`);
- a future **canonical relay domain** (`runicgateway.app`, PLAN §14 — *not yet secured*) that all
shards could bounce their final callback through, autoVerified by the generic build.
The **generic multi-tenant build bakes no host and stays custom-scheme-only** — correct and safe.
The custom scheme is never removed. It is the fallback on every build, for every shard, always.
## 3. Server design — `feat/mobile-app-links`
### 3.1 Auto-served `assetlinks.json`
- **Route:** `GET /.well-known/assetlinks.json`, served at the **web root** (outside `/api/v1`, before
the SPA catch-all) in `server/src/app.js`.
- **Gate:** the admin setting `mobile_app_links_enabled` (default **off**). Off ⇒ the route **404s** and
the app stays on the custom scheme for that shard. On ⇒ the shard opts into App Links.
- **Body:** the Digital Asset Links statement for the fixed package `com.runicgateway.app` and the
release signing cert SHA-256 fingerprint(s):
```json
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.runicgateway.app",
"sha256_cert_fingerprints": ["AB:CD:…"]
}
}
]
```
- **Fingerprint source:** env `MOBILE_APP_CERT_SHA256` — comma-separated (supports **cert rotation** and
a debug + release cert during testing). It is a **constant of the published app**, identical for every
shard, so it is a shipped/env default, not something each owner types. The package name is likewise
fixed (`MOBILE_APP_PACKAGE`, default `com.runicgateway.app`).
- **Enabled but no fingerprint configured ⇒ 404** (+ a one-time warn): serving a statement with no
fingerprint asserts nothing and would only mislead the verifier.
- Response is `application/json`, `Cache-Control: public, max-age=3600` (the Play verifier and the OS
re-fetch it; it changes only on a cert rotation).
### 3.2 Redirect-URI allowlist extension
`mobileSso.controller` validates the app's `redirect_uri` by **exact match** against
`MOBILE_AUTH_REDIRECT_URIS` (default `runicgateway://auth/callback`). App Links add exactly one more
acceptable value, and **only when the toggle is on**:
- When `mobile_app_links_enabled`, `/start` additionally accepts the **self-origin** HTTPS callback
`https://<request-host>/mobile/callback` (derived from the request/`APP_BASE_URL`, never from
attacker-controlled input). Still **exact match** — never a prefix match.
- The static custom-scheme allowlist is never narrowed; the HTTPS entry is *additive*.
- No new table or schema: the check reads the one boolean setting.
### 3.3 Public settings advertise the capability
`settings.getPublic()` gains `mobileAppLinks: <bool>` (mirrors the toggle) so a client can tell whether
a shard opted in before requesting an HTTPS `redirect_uri` (a white-label build uses it to avoid asking
for a callback the server would reject).
## 4. App design — `feat/app-links`
### 4.1 Build-time host (`appLinkHost`)
- Gradle property `appLinkHost` (default empty). Wired in `app/build.gradle.kts` into **both**:
- `BuildConfig.APP_LINK_HOST` — read by `SsoAuthManager` to decide the redirect;
- `manifestPlaceholders["appLinkHost"]` — substituted into the App Link intent-filter's host.
- **Default (generic build):** empty ⇒ `BuildConfig.APP_LINK_HOST = ""` and the placeholder falls back
to the reserved sentinel `runic-gateway.invalid` (RFC 6761 — never resolves), so the `autoVerify`
filter is **inert**: it matches no real link and verification simply never succeeds. No custom-scheme
behaviour changes.
- **White-label build:** `./gradlew assembleRelease -PappLinkHost=play.myshard.com` bakes that one host
into the filter and enables the HTTPS redirect for that host.
### 4.2 Manifest
A second intent-filter on `MainActivity`, alongside the unchanged custom-scheme one:
```xml
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https"
android:host="${appLinkHost}"
android:path="/mobile/callback" />
</intent-filter>
```
### 4.3 `SsoAuthManager` (pure Kotlin, unit-tested on the JVM)
- **Redirect selection in `buildStartUrl`:** request the HTTPS `redirect_uri`
`https://<pairedHost>/mobile/callback` **iff** `BuildConfig.APP_LINK_HOST` is non-blank *and* equals
the paired base-URL host (case-insensitive); otherwise the fixed custom-scheme `REDIRECT_URI`. A
white-label build that bakes the host is responsible for enabling the server toggle too (§3.2).
- **Verified-callback matcher + host-trust check:** a new `matchesAppLinkCallback(scheme, host, path)`
accepts only `scheme == https`, `path == /mobile/callback`, and **`host == the paired base-URL host`**.
The paired-host equality is defense-in-depth: even though `autoVerify` already means only a real,
opted-in shard domain can route here, the app still refuses any HTTPS callback whose host isn't the
shard it is currently paired to.
- The rest is unchanged: both matchers feed the *same* `complete(state, code, error)` → `/exchange` →
`SessionManager.onSignedIn`. There is no second auth path.
### 4.4 `MainActivity`
`handleSsoCallback` routes a VIEW intent through **`matchesCallback(...) || matchesAppLinkCallback(...)`**;
everything downstream (state check, exchange, sign-in) is shared. Custom-scheme and App Link callbacks
are indistinguishable past the edge.
## 5. Turning it on for a shard
1. Publish/point the app build at the shard host (`-PappLinkHost=<host>`) — or use the generic build and
leave App Links off.
2. Set `MOBILE_APP_CERT_SHA256` (release cert fingerprint) in the website env.
3. Admin → Shard/Settings: enable **App Links** (`mobile_app_links_enabled`).
4. Verify `https://<host>/.well-known/assetlinks.json` returns the statement; confirm Android verifies
(`adb shell pm get-app-links com.runicgateway.app`).
If any step is skipped the app transparently keeps using the custom scheme — nothing breaks.
## 6. Testing
- **Server (`node --test`):** route 404s when the toggle is off; 404s when on but no fingerprint;
returns the correct statement + content-type when on and configured; the redirect allowlist accepts
`https://<host>/mobile/callback` only when enabled and rejects it otherwise (custom scheme always
accepted).
- **App (JVM unit tests):** `matchesAppLinkCallback` accepts only https + `/mobile/callback` + the paired
host and rejects a foreign host / http / wrong path; `buildStartUrl` requests the HTTPS redirect only
when the baked host matches the paired host, else the custom scheme.
## 7. What does *not* change
- The bridge's server design (PKCE Layer A/B, single-use codes, `/start` + `/exchange`) is untouched;
App Links are *one more allowlist entry* + *one static file route*. That is the whole point of keeping
the allowlist exact-match and configurable from day one.
- The custom scheme remains on every build and is the permanent fallback.
- No change to `servuo-plugins/` — App Links are entirely a website ↔ app concern.

204
android/COVERAGE_PLAN.md Normal file
View File

@@ -0,0 +1,204 @@
# Android App — Test Coverage Plan
**Goal:** clear the SonarQube coverage quality gate (`new_coverage ≥ 50%`) for
`Runic-Gateway-Android-app`, and leave a durable unit-test culture behind it. Companion to
[`PLAN.md`](./PLAN.md) §12.1 (the JaCoCo wiring that made coverage measurable).
## 1. Current state (2026-07-22, post `Android-app#26`)
The JaCoCo→Sonar wiring is live on `main`, so coverage is now real — and the gate is **failing**:
| Metric | Value |
|---|---|
| Quality gate | **ERROR** (one condition) |
| `new_coverage` | **16.4%** (threshold ≥ 50%) |
| overall `coverage` | 16.3% |
| lines to cover | 3,188 |
| covered | 542 |
Everything else on the gate is green (reliability/security/maintainability **A**, duplication 0%).
Because this is the *first* measured version, Sonar's "new code" window is essentially the whole
codebase, so `new_coverage ≈ overall coverage` — to pass we need to roughly **triple** covered
lines, from 542 to ~1,600.
### Where the uncovered lines are
Bucketed from Sonar's per-file `uncovered_lines` (exclusions from #26 already applied, so `*Screen.kt`
is absent):
| Bucket | Files | Lines to cover | Covered % | Verdict |
|---|--:|--:|--:|---|
| **ViewModels** | 28 | 1,153 | **0.1%** | **Test** — the dominant lever; no ViewModel has any test |
| **DTOs** | 12 | 648 | 26.7% | **Test** — trivial (serialization); a pattern already exists |
| **Repositories** | 11 | 274 | 11.3% | **Test** — fake the API interface |
| **core/\* (logic)** | 21 | 365 | 56.2% | **Test** — top up the partially-covered ones |
| UI composables (`*Components.kt`, `BlockRenderer`, …) | 7 | 255 | 3.9% | **Exclude** — not JVM-unit-testable, and `*Screen.kt`'s exclusion missed them |
| core/push (Android services) | 4 | ~191 | ~0% | **Exclude** (or Robolectric later) — foreground service / notifications |
| core/auth `Encrypted*` stores | 3 | 76 | 0% | **Exclude** — Android Keystore / EncryptedSharedPreferences |
| framework glue (`di/`, `RunicGatewayApp`, `LocalAssetResolver`) | 3 | ~17 | 0% | **Exclude** |
**Two levers, applied together:** (a) stop *counting* code a JVM unit test physically cannot execute,
and (b) actually *test* the logic — ViewModels, DTOs, repositories, core utilities.
## 2. Strategy & projected math
Numbers below are line-coverage projections against Sonar's `lines_to_cover`. They are estimates, but
grounded in the current per-bucket totals.
### Phase 0 — Broaden coverage exclusions (no tests; ~½ day)
Move non-unit-testable code out of the **coverage** denominator (it stays in *analysis* — bugs and
smells are still reported). Extend `sonar.coverage.exclusions` in `sonar-project.properties`:
```properties
sonar.coverage.exclusions=\
app/src/main/java/**/ui/**/*Screen.kt,\
app/src/main/java/**/ui/**/*Screen*.kt,\
app/src/main/java/**/ui/**/*Components.kt,\
app/src/main/java/**/ui/page/BlockRenderer.kt,\
app/src/main/java/**/ui/components/**,\
app/src/main/java/**/ui/shard/FrameFields.kt,\
app/src/main/java/**/ui/theme/**,\
app/src/main/java/**/ui/LocalAssetResolver.kt,\
app/src/main/java/**/RunicApp.kt,\
app/src/main/java/**/MainActivity.kt,\
app/src/main/java/**/*Application.kt,\
app/src/main/java/**/RunicGatewayApp.kt,\
app/src/main/java/**/di/**,\
app/src/main/java/**/core/push/PushService.kt,\
app/src/main/java/**/core/push/PushManager.kt,\
app/src/main/java/**/core/push/PushNotifier.kt,\
app/src/main/java/**/core/push/NtfyStreamClient.kt,\
app/src/main/java/**/core/auth/Encrypted*.kt
```
> Verify each glob targets composable-only / framework-only files before committing (e.g. confirm
> `FrameFields.kt` holds no testable logic). Keep the *pure-logic* push files in coverage
> (`PushPreferences`, `PushTickle`, `NtfyTopic`, `PushStreams`) — they already have tests.
Effect: denominator ~3,188 → ~2,650; covered ~542 → ~532. **Coverage ≈ 20%.** (Removing ~540 lines
that were ~2% covered.)
### Phase 1 — DTO serialization tests (highest ROI; ~1 day) → ~34%
DTOs are `@Serializable` data classes; test them with kotlinx-serialization round-trips against
representative backend JSON. The pattern already exists (`AccountDtoTest`, `AuthDtoTest`,
`NotificationsDtoTest`, `PlayerShardDtoTest`, `ShardDtoTest`). Add/extend:
- **New:** `AdminDto` (111 uncov — biggest single file), `WikiDto` (51), `PublicDto` (32),
`PageDto` (16), `PostDto` (12), `ContactDto` (11), `SsoDto`, `PageDto`.
- **Extend to ~85%:** `PlayerShardDto` (30.8%), `ShardDto` (35.6%), `AccountDto` (50.7%),
`AuthDto` (47.4%).
Target DTOs to ~85%: **+~380 covered lines** → covered ~912 / ~2,650 ≈ **34%**.
### Phase 2 — ViewModel tests (the big one; ~34 days) → clears the gate
28 ViewModels, ~1,153 lines, currently 0%. This is where the gate is won. Requires a small test
harness (§3). Each test drives the VM with fake collaborators and asserts `UiState` transitions
(loading → success/error, form validation, actions).
Priority by uncovered lines:
1. `LoginViewModel` (116), `AccountViewModel` (104), `CharactersViewModel` (77),
`AdminContentViewModel` (74), `NotificationsViewModel` (66), `TrustedDevicesViewModel` (63),
`ShardViewModel` (59)
2. `HousesViewModel` (51), `GovernorsViewModel` (47), `AdminDashboardViewModel` (43),
`AdminSupportViewModel` (41), `ChampsViewModel` (40), `AdminModerationViewModel` (40),
`GuildsViewModel` (39), `RecoveryCodesViewModel` (38), `ConnectViewModel` (37),
`ContactViewModel` (36), `VendorsViewModel` (32), `AppViewModel` (29)
3. The small ones (`PostViewModel`, `NewsViewModel`, `WikiViewModel`, `CharacterViewModel`,
`MyHousesViewModel`, `WikiPageViewModel`, `PageViewModel`, `HomeViewModel`, `SessionViewModel`)
Target ViewModels to ~70%: **+~800 covered lines** → covered ~1,712 / ~2,650 ≈ **65%. ✅ Gate passes.**
> Phases 0 + 2 alone (skipping DTOs) already reach ~50.5% — but DTOs are cheap insurance and Phase 1
> lands first because it de-risks the harness work.
### Phase 3 — Repository tests (~12 days) → margin
Repositories map API `Response`/exceptions to `ApiResult`; test with a fake `*Api` interface (or
OkHttp `MockWebServer`). Priority: `AuthRepository` (89), `ConnectionRepository` (43),
`ShardRepository` (27), `AdminRepository` (21), then the small content/wiki/player repos. Target ~70%:
**+~160 lines** → buffer well above 50% and resilience as the new-code window narrows.
### Phase 4 — core/net + core/auth top-up (~½1 day) → durability
Fill the partially-covered utilities: `TokenAuthenticator` (31), `ShardStreamClient` (36),
`HostSelectionInterceptor` (9), `ApiResult` (6), `WebHandoff`, `WebsiteUrls`, `DeviceNameProvider`,
`ServerPreferences`, `AppConfig`.
### Trajectory
| After | Denominator | Covered | Coverage |
|---|--:|--:|--:|
| Today | 3,188 | 542 | 16.3% |
| Phase 0 (exclusions) | ~2,650 | ~532 | ~20% |
| Phase 1 (DTOs) | ~2,650 | ~912 | ~34% |
| **Phase 2 (ViewModels)** | ~2,650 | ~1,712 | **~65% ✅** |
| Phase 3 (repos) | ~2,650 | ~1,872 | ~71% |
| Phase 4 (core) | ~2,650 | ~2,000+ | ~75%+ |
## 3. Test infrastructure to add
The existing suite tests pure-logic classes only; ViewModel/coroutine testing needs a little scaffold.
`kotlinx-coroutines-test` is already a `testImplementation` dependency.
**`MainDispatcherRule`** (JUnit4) — swaps `Dispatchers.Main` (used by `viewModelScope`) for a test
dispatcher:
```kotlin
// app/src/test/java/com/runicgateway/app/util/MainDispatcherRule.kt
@OptIn(ExperimentalCoroutinesApi::class)
class MainDispatcherRule(
private val dispatcher: TestDispatcher = StandardTestDispatcher(),
) : TestWatcher() {
override fun starting(d: Description) = Dispatchers.setMain(dispatcher)
override fun finished(d: Description) = Dispatchers.resetMain()
}
```
**ViewModel test pattern** — hand-written fakes (matches the repo's existing no-mock convention; no new
dependency):
```kotlin
class LoginViewModelTest {
@get:Rule val mainDispatcher = MainDispatcherRule()
private class FakeAuthRepository(var result: LoginResult) : AuthRepository { /* stub the seam */ }
@Test fun `blank credentials surface INVALID_CREDENTIALS without a network call`() = runTest {
val vm = LoginViewModel(FakeAuthRepository(LoginResult.Success), /* … */)
vm.submit()
assertEquals(LoginError.INVALID_CREDENTIALS, vm.state.value.error)
}
}
```
- Assert on `viewModel.state.value` after `advanceUntilIdle()`; or collect the `StateFlow` in a
background `launch` when you need to see intermediate (loading) states.
- **Optional deps (decide once):** `mockk` would cut fake-writing for wide interfaces, and `turbine`
simplifies Flow assertions. Recommendation: **stay with hand fakes** to match convention; revisit
only if VM tests get boilerplate-heavy.
## 4. Execution notes
- CI already runs `./gradlew testDebugUnitTest jacocoTestReport` before the scan (`sonarqube.yml`),
so new tests count automatically on merge to `main`. Locally on this machine: JDK 21 needs
`-Pksp.incremental=false`.
- Sonar recomputes the gate on the post-merge scan; there's no way to fully confirm the number
pre-merge. Land phases as separate PRs (0, 1, 2, …) so coverage climbs visibly and reviews stay
small.
- `sonar.coverage.exclusions` removes files from **coverage only** — analysis still flags bugs/smells
in them, so excluding UI/framework code is safe.
- **Android-framework code deferred, not abandoned:** push services and `Encrypted*` stores are
excluded now; if we want them covered later, add Robolectric (`testImplementation`) and a
`RobolectricTestRunner` suite rather than instrumented tests, to keep it in the fast JVM `test`
source set the scan already consumes.
## 5. Definition of done
- `new_coverage ≥ 50%` and the SonarQube quality gate is **green**.
- `MainDispatcherRule` + a documented ViewModel test pattern exist and are reused.
- Coverage exclusions list only genuinely non-unit-testable files (UI composables, Android-framework
glue) — no ViewModel, repository, DTO, or pure core-logic file is excluded.

1172
android/PLAN.md Normal file

File diff suppressed because it is too large Load Diff

348
android/PROJECT_TREE.md Normal file
View File

@@ -0,0 +1,348 @@
# Android App — Project Tree
> **Auto-generated.** This file is maintained by the `sync-project-tree` CI workflow in
> the [`RunicGateway/Android-app`](https://gitea.whitlocktech.com/RunicGateway/Android-app) repository, which
> opens a pull request here whenever the tracked file layout on `main` changes. Do not edit
> by hand — changes will be overwritten by the next sync.
A snapshot of the tracked files in the repository (build output, dependencies, and other
git-ignored paths are excluded).
```text
android-app/
├── .gitea/
│ ├── scripts/
│ │ └── gen_tree.py
│ └── workflows/
│ ├── pr-checks.yml
│ ├── release.yml
│ ├── sonarqube.yml
│ └── sync-project-tree.yml
├── app/
│ ├── licenses/
│ │ └── Cinzel-OFL.txt
│ ├── src/
│ │ ├── debug/
│ │ │ └── res/
│ │ │ └── xml/
│ │ │ └── network_security_config.xml
│ │ ├── main/
│ │ │ ├── java/
│ │ │ │ └── com/
│ │ │ │ └── runicgateway/
│ │ │ │ └── app/
│ │ │ │ ├── core/
│ │ │ │ │ ├── auth/
│ │ │ │ │ │ ├── sso/
│ │ │ │ │ │ │ ├── EncryptedPendingSsoStore.kt
│ │ │ │ │ │ │ ├── PendingSsoStore.kt
│ │ │ │ │ │ │ ├── Pkce.kt
│ │ │ │ │ │ │ └── SsoAuthManager.kt
│ │ │ │ │ │ ├── DeviceNameProvider.kt
│ │ │ │ │ │ ├── EncryptedTokenStore.kt
│ │ │ │ │ │ ├── EncryptedTrustTokenStore.kt
│ │ │ │ │ │ ├── Session.kt
│ │ │ │ │ │ ├── SessionManager.kt
│ │ │ │ │ │ ├── TokenStore.kt
│ │ │ │ │ │ └── TrustTokenStore.kt
│ │ │ │ │ ├── net/
│ │ │ │ │ │ ├── AuthInterceptor.kt
│ │ │ │ │ │ ├── BaseUrlHolder.kt
│ │ │ │ │ │ ├── HostSelectionInterceptor.kt
│ │ │ │ │ │ ├── Http.kt
│ │ │ │ │ │ ├── ServerUrl.kt
│ │ │ │ │ │ ├── ShardStream.kt
│ │ │ │ │ │ ├── ShardStreamClient.kt
│ │ │ │ │ │ ├── ShardStreamEvent.kt
│ │ │ │ │ │ ├── TokenAuthenticator.kt
│ │ │ │ │ │ └── UserAgentInterceptor.kt
│ │ │ │ │ ├── prefs/
│ │ │ │ │ │ └── ServerPreferences.kt
│ │ │ │ │ ├── push/
│ │ │ │ │ │ ├── NtfyStreamClient.kt
│ │ │ │ │ │ ├── NtfyTopic.kt
│ │ │ │ │ │ ├── PushManager.kt
│ │ │ │ │ │ ├── PushNotifier.kt
│ │ │ │ │ │ ├── PushPreferences.kt
│ │ │ │ │ │ ├── PushService.kt
│ │ │ │ │ │ ├── PushStreams.kt
│ │ │ │ │ │ └── PushTickle.kt
│ │ │ │ │ ├── result/
│ │ │ │ │ │ └── ApiResult.kt
│ │ │ │ │ ├── web/
│ │ │ │ │ │ ├── WebHandoff.kt
│ │ │ │ │ │ └── WebsiteUrls.kt
│ │ │ │ │ └── AppConfig.kt
│ │ │ │ ├── data/
│ │ │ │ │ ├── api/
│ │ │ │ │ │ ├── dto/
│ │ │ │ │ │ │ ├── AccountDto.kt
│ │ │ │ │ │ │ ├── AdminDto.kt
│ │ │ │ │ │ │ ├── AuthDto.kt
│ │ │ │ │ │ │ ├── ContactDto.kt
│ │ │ │ │ │ │ ├── NotificationsDto.kt
│ │ │ │ │ │ │ ├── PageDto.kt
│ │ │ │ │ │ │ ├── PlayerShardDto.kt
│ │ │ │ │ │ │ ├── PostDto.kt
│ │ │ │ │ │ │ ├── PublicDto.kt
│ │ │ │ │ │ │ ├── ShardDto.kt
│ │ │ │ │ │ │ ├── SsoDto.kt
│ │ │ │ │ │ │ └── WikiDto.kt
│ │ │ │ │ │ ├── AdminApi.kt
│ │ │ │ │ │ ├── AuthApi.kt
│ │ │ │ │ │ ├── AuthRefreshApi.kt
│ │ │ │ │ │ ├── MeApi.kt
│ │ │ │ │ │ ├── NotificationsApi.kt
│ │ │ │ │ │ ├── PlayerShardApi.kt
│ │ │ │ │ │ ├── PublicApi.kt
│ │ │ │ │ │ └── SsoApi.kt
│ │ │ │ │ └── repository/
│ │ │ │ │ ├── AccountRepository.kt
│ │ │ │ │ ├── AdminRepository.kt
│ │ │ │ │ ├── AuthRepository.kt
│ │ │ │ │ ├── ConnectionRepository.kt
│ │ │ │ │ ├── ContactRepository.kt
│ │ │ │ │ ├── ContentRepository.kt
│ │ │ │ │ ├── NotificationsRepository.kt
│ │ │ │ │ ├── PlayerShardRepository.kt
│ │ │ │ │ ├── SettingsRepository.kt
│ │ │ │ │ ├── ShardRepository.kt
│ │ │ │ │ └── WikiRepository.kt
│ │ │ │ ├── di/
│ │ │ │ │ ├── AppModule.kt
│ │ │ │ │ ├── NetworkModule.kt
│ │ │ │ │ └── StorageModule.kt
│ │ │ │ ├── ui/
│ │ │ │ │ ├── admin/
│ │ │ │ │ │ ├── AdminContentScreen.kt
│ │ │ │ │ │ ├── AdminContentViewModel.kt
│ │ │ │ │ │ ├── AdminDashboardScreen.kt
│ │ │ │ │ │ ├── AdminDashboardViewModel.kt
│ │ │ │ │ │ ├── AdminModerationScreen.kt
│ │ │ │ │ │ ├── AdminModerationViewModel.kt
│ │ │ │ │ │ ├── AdminSupportScreen.kt
│ │ │ │ │ │ └── AdminSupportViewModel.kt
│ │ │ │ │ ├── auth/
│ │ │ │ │ │ ├── AccountScreen.kt
│ │ │ │ │ │ ├── AccountViewModel.kt
│ │ │ │ │ │ ├── LoginScreen.kt
│ │ │ │ │ │ ├── LoginViewModel.kt
│ │ │ │ │ │ ├── RecoveryCodesScreen.kt
│ │ │ │ │ │ ├── RecoveryCodesViewModel.kt
│ │ │ │ │ │ ├── TrustedDevicesScreen.kt
│ │ │ │ │ │ └── TrustedDevicesViewModel.kt
│ │ │ │ │ ├── components/
│ │ │ │ │ │ ├── HtmlText.kt
│ │ │ │ │ │ ├── StateViews.kt
│ │ │ │ │ │ └── ThemeComponents.kt
│ │ │ │ │ ├── connect/
│ │ │ │ │ │ ├── ConnectScreen.kt
│ │ │ │ │ │ └── ConnectViewModel.kt
│ │ │ │ │ ├── contact/
│ │ │ │ │ │ ├── ContactScreen.kt
│ │ │ │ │ │ └── ContactViewModel.kt
│ │ │ │ │ ├── home/
│ │ │ │ │ │ ├── HomeScreen.kt
│ │ │ │ │ │ └── HomeViewModel.kt
│ │ │ │ │ ├── navigation/
│ │ │ │ │ │ ├── Menu.kt
│ │ │ │ │ │ └── Routes.kt
│ │ │ │ │ ├── news/
│ │ │ │ │ │ ├── NewsScreen.kt
│ │ │ │ │ │ ├── NewsViewModel.kt
│ │ │ │ │ │ ├── PostScreen.kt
│ │ │ │ │ │ └── PostViewModel.kt
│ │ │ │ │ ├── notifications/
│ │ │ │ │ │ ├── NotificationsScreen.kt
│ │ │ │ │ │ └── NotificationsViewModel.kt
│ │ │ │ │ ├── page/
│ │ │ │ │ │ ├── BlockRenderer.kt
│ │ │ │ │ │ ├── PageScreen.kt
│ │ │ │ │ │ └── PageViewModel.kt
│ │ │ │ │ ├── player/
│ │ │ │ │ │ ├── CharacterSheetScreen.kt
│ │ │ │ │ │ ├── CharactersScreen.kt
│ │ │ │ │ │ ├── CharactersViewModel.kt
│ │ │ │ │ │ ├── CharacterViewModel.kt
│ │ │ │ │ │ ├── MyHousesScreen.kt
│ │ │ │ │ │ ├── MyHousesViewModel.kt
│ │ │ │ │ │ ├── VendorsScreen.kt
│ │ │ │ │ │ └── VendorsViewModel.kt
│ │ │ │ │ ├── session/
│ │ │ │ │ │ └── SessionViewModel.kt
│ │ │ │ │ ├── shard/
│ │ │ │ │ │ ├── ChampsScreen.kt
│ │ │ │ │ │ ├── ChampsViewModel.kt
│ │ │ │ │ │ ├── FrameFields.kt
│ │ │ │ │ │ ├── GovernorsScreen.kt
│ │ │ │ │ │ ├── GovernorsViewModel.kt
│ │ │ │ │ │ ├── GuildsScreen.kt
│ │ │ │ │ │ ├── GuildsViewModel.kt
│ │ │ │ │ │ ├── HousesScreen.kt
│ │ │ │ │ │ ├── HousesViewModel.kt
│ │ │ │ │ │ ├── LiveBoard.kt
│ │ │ │ │ │ ├── ShardComponents.kt
│ │ │ │ │ │ ├── ShardEventText.kt
│ │ │ │ │ │ ├── ShardScreen.kt
│ │ │ │ │ │ └── ShardViewModel.kt
│ │ │ │ │ ├── theme/
│ │ │ │ │ │ ├── BrandColor.kt
│ │ │ │ │ │ ├── Color.kt
│ │ │ │ │ │ ├── Font.kt
│ │ │ │ │ │ ├── Theme.kt
│ │ │ │ │ │ └── Type.kt
│ │ │ │ │ ├── wiki/
│ │ │ │ │ │ ├── WikiPageScreen.kt
│ │ │ │ │ │ ├── WikiPageViewModel.kt
│ │ │ │ │ │ ├── WikiScreen.kt
│ │ │ │ │ │ └── WikiViewModel.kt
│ │ │ │ │ ├── AppViewModel.kt
│ │ │ │ │ ├── LocalAssetResolver.kt
│ │ │ │ │ ├── RunicApp.kt
│ │ │ │ │ └── UiState.kt
│ │ │ │ ├── MainActivity.kt
│ │ │ │ └── RunicGatewayApp.kt
│ │ │ ├── res/
│ │ │ │ ├── drawable/
│ │ │ │ │ └── ic_launcher_background.xml
│ │ │ │ ├── drawable-anydpi/
│ │ │ │ │ └── ic_stat_name.xml
│ │ │ │ ├── drawable-hdpi/
│ │ │ │ │ └── ic_stat_name.png
│ │ │ │ ├── drawable-mdpi/
│ │ │ │ │ └── ic_stat_name.png
│ │ │ │ ├── drawable-xhdpi/
│ │ │ │ │ └── ic_stat_name.png
│ │ │ │ ├── drawable-xxhdpi/
│ │ │ │ │ └── ic_stat_name.png
│ │ │ │ ├── font/
│ │ │ │ │ └── cinzel_variable.ttf
│ │ │ │ ├── mipmap-anydpi-v26/
│ │ │ │ │ ├── ic_launcher.xml
│ │ │ │ │ └── ic_launcher_round.xml
│ │ │ │ ├── mipmap-hdpi/
│ │ │ │ │ ├── ic_launcher.webp
│ │ │ │ │ ├── ic_launcher_foreground.webp
│ │ │ │ │ └── ic_launcher_round.webp
│ │ │ │ ├── mipmap-mdpi/
│ │ │ │ │ ├── ic_launcher.webp
│ │ │ │ │ ├── ic_launcher_foreground.webp
│ │ │ │ │ └── ic_launcher_round.webp
│ │ │ │ ├── mipmap-xhdpi/
│ │ │ │ │ ├── ic_launcher.webp
│ │ │ │ │ ├── ic_launcher_foreground.webp
│ │ │ │ │ └── ic_launcher_round.webp
│ │ │ │ ├── mipmap-xxhdpi/
│ │ │ │ │ ├── ic_launcher.webp
│ │ │ │ │ ├── ic_launcher_foreground.webp
│ │ │ │ │ └── ic_launcher_round.webp
│ │ │ │ ├── mipmap-xxxhdpi/
│ │ │ │ │ ├── ic_launcher.webp
│ │ │ │ │ ├── ic_launcher_foreground.webp
│ │ │ │ │ └── ic_launcher_round.webp
│ │ │ │ ├── values/
│ │ │ │ │ ├── colors.xml
│ │ │ │ │ ├── strings.xml
│ │ │ │ │ └── themes.xml
│ │ │ │ └── xml/
│ │ │ │ ├── backup_rules.xml
│ │ │ │ ├── data_extraction_rules.xml
│ │ │ │ └── network_security_config.xml
│ │ │ ├── AndroidManifest.xml
│ │ │ └── ic_launcher-playstore.png
│ │ └── test/
│ │ └── java/
│ │ └── com/
│ │ └── runicgateway/
│ │ └── app/
│ │ ├── core/
│ │ │ ├── auth/
│ │ │ │ ├── sso/
│ │ │ │ │ ├── PkceTest.kt
│ │ │ │ │ └── SsoAuthManagerTest.kt
│ │ │ │ └── SessionManagerTest.kt
│ │ │ ├── net/
│ │ │ │ ├── HostRewriteTest.kt
│ │ │ │ ├── ServerUrlTest.kt
│ │ │ │ └── ShardStreamClientTest.kt
│ │ │ ├── push/
│ │ │ │ ├── NtfyTopicTest.kt
│ │ │ │ └── PushTickleTest.kt
│ │ │ └── result/
│ │ │ ├── ApiResultExtrasTest.kt
│ │ │ └── ApiResultTest.kt
│ │ ├── data/
│ │ │ ├── api/
│ │ │ │ ├── dto/
│ │ │ │ │ ├── AccountDtoTest.kt
│ │ │ │ │ ├── AdminDtoTest.kt
│ │ │ │ │ ├── AuthDtoTest.kt
│ │ │ │ │ ├── AuthRequestDtoTest.kt
│ │ │ │ │ ├── ContentDtoTest.kt
│ │ │ │ │ ├── NotificationsDtoTest.kt
│ │ │ │ │ ├── PlayerGameDataDtoTest.kt
│ │ │ │ │ ├── PlayerShardDtoTest.kt
│ │ │ │ │ ├── PublicDtoTest.kt
│ │ │ │ │ ├── ShardBoardDtoTest.kt
│ │ │ │ │ ├── ShardDtoTest.kt
│ │ │ │ │ ├── SsoDtoTest.kt
│ │ │ │ │ └── WikiDtoTest.kt
│ │ │ │ └── fake/
│ │ │ │ ├── FakeAdminApi.kt
│ │ │ │ ├── FakePlayerShardApi.kt
│ │ │ │ ├── FakePublicApi.kt
│ │ │ │ └── FakeShardStream.kt
│ │ │ └── repository/
│ │ │ ├── AccountTrustedDevicesTest.kt
│ │ │ └── ConnectionVersionGuardTest.kt
│ │ ├── ui/
│ │ │ ├── admin/
│ │ │ │ ├── AdminContentViewModelTest.kt
│ │ │ │ ├── AdminDashboardViewModelTest.kt
│ │ │ │ ├── AdminModerationViewModelTest.kt
│ │ │ │ └── AdminSupportViewModelTest.kt
│ │ │ ├── contact/
│ │ │ │ └── ContactViewModelTest.kt
│ │ │ ├── navigation/
│ │ │ │ └── MenuAccessTest.kt
│ │ │ ├── notifications/
│ │ │ │ └── NotificationRoutingTest.kt
│ │ │ ├── player/
│ │ │ │ ├── CharacterSheetHelpersTest.kt
│ │ │ │ ├── CharactersViewModelTest.kt
│ │ │ │ └── PlayerViewModelTest.kt
│ │ │ ├── shard/
│ │ │ │ ├── FrameFieldsTest.kt
│ │ │ │ ├── LiveBoardTest.kt
│ │ │ │ ├── ShardBoardViewModelTest.kt
│ │ │ │ └── ShardEventTextTest.kt
│ │ │ ├── theme/
│ │ │ │ └── BrandColorTest.kt
│ │ │ ├── ContentViewModelTest.kt
│ │ │ └── UiStateTest.kt
│ │ ├── util/
│ │ │ ├── FakeApiSupport.kt
│ │ │ └── MainDispatcherRule.kt
│ │ └── ScaffoldSanityTest.kt
│ ├── build.gradle.kts
│ └── proguard-rules.pro
├── gradle/
│ ├── wrapper/
│ │ ├── gradle-wrapper.jar
│ │ └── gradle-wrapper.properties
│ └── libs.versions.toml
├── .gitattributes
├── .gitignore
├── build.gradle.kts
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── CONTRIBUTORS.md
├── gradle.properties
├── gradlew
├── gradlew.bat
├── LICENSE.md
├── README.md
├── SECURITY.md
├── settings.gradle.kts
└── sonar-project.properties
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

View File

@@ -0,0 +1,49 @@
# Android app — trusted devices & recovery codes (live smoke test)
Screenshots from a live end-to-end smoke test of the trusted-device + MFA feature on
the Android client (app PR `RunicGateway/Android-app#23`), captured against the local
Node server (`127.0.0.1:3000`) and a `uomysticmoon` MariaDB, on an API 36 emulator.
See `../PLAN.md §4.1.1` for the design and `../../website/TRUSTED_DEVICES_MFA.md` for
the canonical contract.
| # | Screenshot | Shows |
|---|---|---|
| 1 | [`01-login-2fa-trust-device.png`](01-login-2fa-trust-device.png) | The `401 { totpRequired }` login step: the **authentication code** field, the **"Use a recovery code instead"** toggle, and the **"Trust this device (skip codes for 30 days)"** checkbox (ticked). |
| 2 | [`02-account-security-section.png`](02-account-security-section.png) | The new **Security** section on the account screen linking to Trusted devices and Recovery codes. |
| 3 | [`03-trusted-devices.png`](03-trusted-devices.png) | The **Trusted Devices** screen listing this device (`Google sdk_gphone64_x86_64` — the `device_name` sent at login) with revoke / trust-this-device / untrust-all. |
| 4 | [`04-recovery-codes-show-once.png`](04-recovery-codes-show-once.png) | The **Recovery Codes** screen after a password-stepped regenerate: the one-time batch shown once with copy / share, and the updated remaining count. |
| 5 | [`05-recovery-code-login.png`](05-recovery-code-login.png) | Signing in with a **single-use recovery code** instead of the authenticator code. |
## Verified flows (all passed)
1. **2FA login + "Trust this device"**`200`, trust token stored; server logged `device trusted`.
2. **Trust survives logout** — after signing out, a **password-only** sign-in skipped the
TOTP step entirely (server: a clean `200` with **no** preceding `401 totpRequired`).
This is the headline behaviour: the trust token is *only* consulted at a fresh login,
so it must outlive logout (see PLAN §4.1.1).
3. **Trusted Devices** — list, and the device's `last_used` stamp advancing after the
trust-skip login.
4. **Untrust all** — cleared the server rows **and** the local token; the next
password-only sign-in correctly required the TOTP step again (server: `401`).
5. **Recovery codes** — generate (password step-up, shown once) and a successful
**recovery-code login** (server: `mobile login via recovery code``200`).
## Full step-by-step walkthrough
The five images above are the curated highlights. These `walkthrough-*` frames are the
rest of the same smoke-test session, in flow order, for a complete record. (Pure
automation-artifact frames — soft-keyboard popups, mid-transition spinners, and
duplicate Home landings — are omitted; the raws that the highlights above supersede are
not repeated here.)
| # | Screenshot | Shows |
|---|---|---|
| 1 | [`walkthrough-01-home-connected.png`](walkthrough-01-home-connected.png) | Home with the shard **Online** (already connected to the local server), signed out. |
| 2 | [`walkthrough-02-drawer-signed-out.png`](walkthrough-02-drawer-signed-out.png) | Navigation drawer while signed out — **Sign in** entry. |
| 3 | [`walkthrough-03-login-screen.png`](walkthrough-03-login-screen.png) | The native login screen (no SSO providers configured in dev). |
| 4 | [`walkthrough-04-login-2fa-step.png`](walkthrough-04-login-2fa-step.png) | The `401 { totpRequired }` step with the fields empty — the **"Use a recovery code instead"** toggle and **"Trust this device"** checkbox before entry (companion to highlight #1, which shows them filled). |
| 5 | [`walkthrough-05-drawer-signed-in.png`](walkthrough-05-drawer-signed-in.png) | Drawer once signed in — **My account**, Notifications, player groups, **Sign out**. |
| 6 | [`walkthrough-06-account-overview.png`](walkthrough-06-account-overview.png) | Top of the account screen: identity, username/password, **two-factor ENABLED**. |
| 7 | [`walkthrough-07-recovery-codes-before-generate.png`](walkthrough-07-recovery-codes-before-generate.png) | Recovery Codes screen before generating — **0 codes remaining** + the password-step-up form. |
| 8 | [`walkthrough-08-trusted-devices-empty-after-untrust.png`](walkthrough-08-trusted-devices-empty-after-untrust.png) | Trusted Devices after **Untrust all** — "All devices untrusted." and the empty state. |
| 9 | [`walkthrough-09-login-2fa-required-after-untrust.png`](walkthrough-09-login-2fa-required-after-untrust.png) | The next sign-in **re-prompting for the TOTP step** — proof that untrust-all cleared the local trust token. |

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

98
android/theme-plan.md Normal file
View File

@@ -0,0 +1,98 @@
# Android theme plan — mirroring the website frontend
This is a summary of the **website frontend theme** (source of truth:
`website/client/src/styles/theme.css`, applied at runtime by
`website/client/src/contexts/SiteContext.jsx`) so the native Android client can
present a visually consistent brand. Where the web uses CSS custom properties,
the Android equivalent is a Compose `MaterialTheme` `ColorScheme` + `Typography`.
## Overall character
A **dark, moody, "arcane fantasy" theme** — deep blue-black backgrounds, muted
slate-blue accent, parchment-white text, and an engraved serif display face. It
reads like a leather-and-moonlight fantasy ledger, not a bright consumer app.
There is **no light mode** on the web; the app should ship dark-only to match.
## Color tokens
The web theme is a flat set of CSS variables under `:root`. Map them to Compose
as follows (hex is authoritative):
| Web token | Hex | Role | Compose slot (suggested) |
|-------------------|------------|----------------------------------------|-------------------------------|
| `--bg` | `#0e1318` | App background | `background` |
| `--bg-deep` | `#0b0f14` | Deepest surface / on-accent text | `surfaceDim` / `onPrimary` |
| `--panel-a` | `#192231` | Card gradient top | `surface` |
| `--panel-b` | `#141a21` | Card gradient bottom | `surfaceContainer` |
| `--panel-flat` | `#11161d` | Flat panels, toolbars | `surfaceContainerLow` |
| `--line` | `#2a3544` | Borders / dividers | `outline` |
| `--line-soft` | `#1d2733` | Subtle row dividers | `outlineVariant` |
| `--accent` | `#7f99bd` | **Primary accent** (brand-overridable) | `primary` |
| `--accent-bright` | `#cdd9e8` | Primary button fill, active states | `primaryContainer` / bright |
| `--ink` | `#eef3f8` | Highest-contrast text | `onBackground` |
| `--head` | `#e6edf6` | Headings | heading color |
| `--text` | `#c4cdd8` | Body prose | `onSurface` |
| `--muted` | `#aeb8c4` | Secondary text | `onSurfaceVariant` |
| `--dim` | `#6f7d8e` | Meta / captions / placeholders | dim / disabled text |
| `--blue` | `#13243c` | Accent hover/active background | `secondaryContainer` |
| `--mode-live` | `#5fb98a` | "Shard live" status (green) | success |
| `--mode-maint` | `#e6c26a` | "Maintenance" status (amber) | warning |
### Semantic / status colors (used in badges, diffs, moderation)
- **Success / published / live:** green `#5fb98a` (fills at ~1622% alpha, text `#7fd0a4`).
- **Warning / maintenance / moderation (kick/mute/warn):** amber `#e0b070` / `#e6c26a`.
- **Danger / ban / red-link / errors:** desaturated red `#d98b84` (borders `#6e3b38`).
- **Admin badge:** near-white `#d8e2ef` on `#3a4a5e`.
## Branding is data, not code
The `--accent` value is **overridden at runtime** per shard instance. On the web,
`SiteContext` reads `brand.accent` from the site settings API and sets the CSS
variable, so one build reskins for any shard. **The Android app should do the
same:** fetch the brand payload (name, `accent`, colors, logo/hero/favicon) from
the website API and derive the `primary` color at runtime rather than hardcoding
`#7f99bd`. Default to `#7f99bd` when the brand payload is absent/offline.
## Typography
Three font families, by role:
- **Display** (`--display`): **Cinzel**, falling back to Georgia serif — an
engraved Roman capitals face used for the logo, `h1`/`.h1`, and prose
`h2`/`h3`. Bundle Cinzel as an app font; this face carries the brand.
- **Serif body** (`--serif`): **Georgia / Times New Roman** — default body and
prose text; `line-height ≈ 1.6`.
- **Sans** (`--sans`): **Helvetica Neue / Arial** — UI chrome: buttons, pills,
form labels, table headers, badges, meta. Labels/eyebrows/kickers are
UPPERCASE with wide letter-spacing (`0.10.18em`) and small (0.680.86rem).
Heading scale is fluid on web (`h1` clamps ~2.43.6rem); pick fixed Material type
scale equivalents (e.g. display for `h1`, headline for `h2`, title for `h3`).
## Shape, elevation & motion
- **Corners:** cards/panels `1012px` radius; inputs/small elements `8px`;
pills and buttons are **fully rounded** (`999px` / capsule).
- **Cards:** vertical gradient `--panel-a → --panel-b`, 1px `--line` border, soft
drop shadow (`0 14px 34px rgba(0,0,0,0.3)`). On hover the web lifts `-3px` and
brightens the border to `--accent` — translate to a pressed/focused accent
border on Android.
- **Buttons:** primary = bright fill (`--accent-bright`) with dark text;
ghost/secondary = translucent dark fill with accent-on-hover border.
- **Motion:** short, subtle transitions (0.120.18s). Keep animations understated.
## Signature accents (nice-to-have)
- The **"moon"** motif: a radial-gradient sphere (`#eef3f8 → #9fb0c6 → #5d6e88`) —
a small brand flourish worth reproducing.
- Accent-tinted focus rings and left-border "note" callouts
(`border-left: 3px solid --accent` over a translucent `--blue` background).
## Implementation note for Compose
Define one `darkColorScheme(...)` from the table above, a `Typography` binding the
three families, and a `Shapes` set (`small = 8.dp`, `medium = 10.dp`, capsule for
buttons). Load `accent` from the brand API into a state holder and rebuild the
`primary` (and derived `primaryContainer`) at runtime so a shard's custom accent
flows through the whole UI — exactly as `SiteContext` does on the web.

59
ci/SONARQUBE.md Normal file
View File

@@ -0,0 +1,59 @@
# SonarQube static analysis
Each code repo in the Runic Gateway org reports static-analysis results to the
self-hosted **SonarQube** server for review. Analysis is **non-blocking**: it
runs on push to `main` (i.e. *after* merge), never on pull requests, so it never
gates a PR. It complements each repo's PR gate and release pipeline — it only
feeds the dashboard.
## Server
- **URL:** `https://sonar.whitlocktech.com`
- Each repo is a separate SonarQube project, keyed as below.
## Projects
| Repo | Project key | Sources analysed | Language |
|---|---|---|---|
| `website` | `runic-gateway-website` | `server/src`, `client/src`, `bot/src` | JS/TS |
| `link` | `runic-gateway-link` | `sidecar/src` | Rust |
| `Android-app` | `runic-gateway-android-app` | `app/src/main` | Kotlin |
## How it's wired
Each repo carries two files, identical in shape across repos:
- **`sonar-project.properties`** (repo root) — declares the project key, sources,
tests, and exclusions. The Sonar scanner reads this.
- **`.gitea/workflows/sonarqube.yml`** — a `SonarQube` workflow that, on push to
`main` (and via manual `workflow_dispatch`), checks out with full history
(`fetch-depth: 0`, needed for accurate blame + "new code") and runs
`sonarsource/sonarqube-scan-action@v4`.
The scan is **source-based** — it does not build the project or run a language
toolchain, so the workflows are lightweight (checkout + scan only). Richer
signals (Rust Clippy, Android Lint, JaCoCo coverage) are left as documented,
commented-out enrichment in each repo's `sonar-project.properties`; enable them
per repo when wanted.
## One-time setup per repo (Gitea UI → Repo → Settings → Actions)
Both are consumed by the scan action via `env:` in the workflow:
- **Secret `SONAR_TOKEN`** — a SonarQube *Analysis* token (My Account →
Security in SonarQube; project-scoped or global).
- **Variable `SONAR_HOST_URL`** — the SonarQube base URL reachable from the
self-hosted runner. Kept as a **variable, not committed**, so the internal
address stays out of git.
The self-hosted `ubuntu-latest` runner must be able to reach `SONAR_HOST_URL` on
the network. Nothing waits on the SonarQube Quality Gate, so a failing gate does
not fail the job — check the dashboard.
## Adding a new repo
1. Create the project in SonarQube; note its key.
2. Add `sonar-project.properties` (copy an existing repo's, adjust key + sources).
3. Add `.gitea/workflows/sonarqube.yml` (copy verbatim — it's language-agnostic).
4. Set the `SONAR_TOKEN` secret and `SONAR_HOST_URL` variable in the repo's
Gitea Actions settings.

View File

@@ -31,18 +31,32 @@ Missing or wrong token → **401** `{"error":"missing or invalid auth token"}`.
The wire protocol is versioned so a mismatch is caught immediately instead of failing weirdly.
- Every response carries an **`X-UOLink-Version: 2`** header.
- `GET /health` and the WebSocket `ws.hello` frame include `"protocol": 2`.
- **Optionally**, send `X-UOLink-Version: 2` on your requests. If it disagrees with the sidecar, the request is rejected **409 Conflict**:
- Every response carries an **`X-UOLink-Version: 3`** header.
- `GET /health` and the WebSocket `ws.hello` frame include `"protocol": 3`.
- **Optionally**, send `X-UOLink-Version: 3` on your requests. If it disagrees with the sidecar, the request is rejected **409 Conflict**:
```json
{ "error": "protocol version mismatch", "sidecar_protocol": 2, "client_protocol": "1" }
{ "error": "protocol version mismatch", "sidecar_protocol": 3, "client_protocol": "2" }
```
Pin the version you built against and compare it to the header (or `/health.protocol`) at startup.
**v2 (Protocol 2.0)** added the account-provisioning surface (§6.x: `POST /accounts/create`, `DELETE /link/{account}`) and the `account.*` events. Outbound event kinds are **additive** — a v1 client that ignores unknown kinds keeps working against the live feed — but the new *endpoints* require a v2 sidecar. If you send `X-UOLink-Version: 1`, calls to the new endpoints are refused with the 409 above.
**v3 (Protocol 3.0)** adds `world.ruleset`, `points.board` and `vendor.listing` /
`vendor.listing.remove`, with the `GET /ruleset`, `/points` and `/market` reads that serve them from
the sidecar's store. Same shape as the v2 bump: the event kinds are additive, so a v2 client that
ignores unknown kinds keeps working against the live feed, but the three new endpoints require a v3
sidecar. There is deliberately **no feature-negotiation array** — v3 implies all three kinds, so the
version number alone tells you what is available.
**Upgrading a v2 integration.** The bump is an operator-visible hard break in one direction only: a
client still declaring `2` gets a 409 on every protected route and, on the WebSocket, a closed
connection on the `ws.hello` mismatch. So update the pinned version at the same time you deploy the
v3 sidecar. Nothing that existed in v2 changed shape, so that is the whole migration — the website
does it with a one-shot boot migration of its `uo_link_config.protocol` row ([`v3.md`](v3.md) §4.1);
a third-party client changes the constant it sends.
---
## 3. Health
@@ -54,7 +68,7 @@ GET /health (no auth)
```json
{
"status": "ok", // "ok" when plugin connected AND db reachable, else "degraded"
"protocol": 1,
"protocol": 3,
"plugin_connected": true, // is the shard link up right now?
"database": "ok", // "ok" | "error"
"uptime": "3d 12h",
@@ -77,7 +91,7 @@ A push-only stream of game events as they happen. You do **not** send commands o
**On connect**, the first frame is:
```json
{ "kind": "ws.hello", "protocol": 1 }
{ "kind": "ws.hello", "protocol": 3 }
```
**Then** a continuous stream of event frames, each with at least `t` (epoch ms) and `kind`. Route on `kind`.
@@ -315,6 +329,198 @@ The house registry — one row per house, complementing the `house.decay` *trans
Render from `GET /houses` (§6) on connect, then keep live with these events.
#### Shard ruleset (Protocol 3.0)
How the shard is actually configured, published by the shard itself. **Not a sweep** — it changes only
when an operator edits `Config/*.cfg`, so it is emitted once per shard↔sidecar connect (and on
`[bridge reload`), exactly like `server.hello`.
| kind | fields | notes |
|------|--------|-------|
| `world.ruleset` | `rev`, `shard`, `expansion`, `connect?`, `systems`, `caps`, `housing`, `accounts`, `vetRewards`, `loot`, `vendors`, `champions?`, `treasureMaps`, `vvv?`, `store`, `schedule?` | The whole ruleset, always complete — **never a delta**, so the latest frame replaces the previous one outright. Every block except `shard`/`expansion` is optional and is **omitted when its system is off**, so absence means "not applicable here", not "unknown". |
`rev` is the shard's FNV-1a of the body: identical `rev` means the ruleset is unchanged and this frame
is just a reconnect re-send, so a consumer can skip the write. It is deliberately **not**
`String.GetHashCode()`, which is seeded per process and would change on every shard restart.
```json
{"kind":"world.ruleset","rev":"1a2b3c4d","shard":"UOMysticmoon","expansion":"EJ",
"systems":{"cityLoyalty":true,"vvv":true,"factions":false,"siege":false,"chat":true,
"store":true,"dailyRares":true,"honesty":true,"shadowguard":true,
"treasureMaps":true,"vetRewards":true,"testCenter":false},
"caps":{"skill":1000,"totalSkill":7000,"stat":225,"str":125,"dex":125,"int":125,
"strMax":150,"dexMax":150,"intMax":150},
"housing":{"accountHouseLimit":1},
"accounts":{"perIp":3,"charSlots":7,"autoCreate":true},
"vetRewards":{"enabled":true,"rewardIntervalDays":30},
"loot":{"feluccaLuckBonus":1000,"feluccaBudgetBonus":100,"feluccaMaxProps":11},
"vendors":{"restockDelayMinutes":60,"maxSell":500,"economyStockAmount":500},
"champions":{"powerScrolls":6,"statScrolls":16,"scrollChance":0.1,
"transcendenceChance":50.0,"rankThresholds":[5,10,13]},
"treasureMaps":{"enabled":true,"lootChance":0.01,"resetDays":30},
"vvv":{"enabled":true,"startSilver":2000,"enhancedRules":false},
"store":{"enabled":true,"currencyName":"Sovereigns"},
"schedule":{"autoSaveEnabled":true,"autoSaveFrequencyMinutes":15,"autoRestartEnabled":false},
"t":1752489280000}
```
**Two things consumers get wrong.**
1. **`caps.skill` and `caps.totalSkill` are in tenths**, the way ServUO stores them: `1000` is `100.0`
skill and `7000` is `700.0` total. Rendering the raw number is actively misleading. The other caps
(`stat`, `str`, …) are plain integers.
2. **`connect` is present only if the operator set `Bridge.PublicConnectAddress`.** The shard's real
listen address (`Server.cfg`) is never published; nor are `Staff.cfg`, `Email.cfg`, `DataPath.cfg`,
`Bridge.cfg`, `Compiler.cfg`, `Reports.cfg` or `Client.cfg`. The frame is built from an explicit
allowlist in `BridgeRuleset.cs` — `Config.Entries` is never enumerated, because that would sweep in
every key on the server.
Absent entirely if the shard runs `Bridge.RulesetEnabled=false` or an older plugin. Render from
`GET /ruleset` (§6) on connect, then keep live with this event.
This **supersedes the `world.systems` frame** sketched in [`PROTOCOL_2.md`](PROTOCOL_2.md) §10.4 and
never implemented; the `systems` block above is what that asked for.
#### Points / loyalty leaderboards (Protocol 3.0)
ServUO carries ~25 separate point currencies — Queen's Loyalty, Void Pool, Casino, Clean Up Britannia,
the nine city loyalties, Blackthorn, the Doom / Khaldun / Kotl treasure systems — every one a standing
players accumulate over months, and none of them visible outside an in-game gump before 3.0.
A diff sweep (default 300 s), **one frame per system** rather than one large frame for all of them,
matching `champ.update` / `guild.update`. A system is emitted only when its top N or its participant
count actually changes.
| kind | fields | notes |
|------|--------|-------|
| `points.board` | `system`, `nameString`, `nameNumber`, `maxPoints`, `showOnGump`, `players`, `top[]` | One system's complete board — **never a delta**. The latest frame for a `system` replaces the previous one outright. `top[]` entries are `{rank, serial, name, points}`. |
`system` is the shard's own `PointsType` enum name (`QueensLoyalty`, `CleanUpBritannia`, …) and is the
board's stable key. There is deliberately **no `points.remove`**: the set of systems is fixed at startup
by `PointsSystem.Configure`, so a system cannot disappear at runtime — the same argument `city.update`
makes for cities.
```json
{"kind":"points.board","system":"QueensLoyalty",
"nameString":"Queen's Loyalty","nameNumber":1114938,
"maxPoints":15000,"showOnGump":true,"players":842,
"top":[{"rank":1,"serial":"0x1A2B","name":"Darrow","points":29500},
{"rank":2,"serial":"0x1A2C","name":"Mireille","points":21000}],
"t":1752489280000}
```
**Four things consumers get wrong.**
1. **`maxPoints` of `0` means UNCAPPED, not "zero points allowed".** ServUO's idiom for an uncapped
system is `double.MaxValue` (`DespiseCrystals`, `ShameCrystals` and `VoidPool` all use it), which
the plugin normalises to `0` rather than emitting a nonsense integer. On a real shard **most
systems are uncapped**, so a UI that renders `points / maxPoints` must special-case this or it will
divide by zero on the common path.
2. **`nameString` is usually `null`.** The shard's `Name` is a `TextDefinition`, which may carry a
literal *or* a cliloc id, and in practice most systems use the cliloc — so `nameNumber` is set and
`nameString` is `null`. Resolve clilocs consumer-side; failing that, humanising the `system` key
("CleanUpBritannia" → "Clean Up Britannia") reads better than showing a bare number. This is the
same contract `titles.reward` already documents.
3. **`players` counts players who actually hold points**, not the size of the system's table. Ten of
the ~25 systems have `AutoAdd = true` and therefore keep a zero-point row for every character that
has ever logged in, so the raw table size would report the shard's entire character census as that
system's participants.
4. **Entries carry `serial` and `name` only — never `acct` or `webId`.** A board is the widest-audience
surface the bridge has, so the account name of every ranked player deliberately does not cross the
wire; resolve serial → site user from your own link mirror if you need it.
Absent entirely if the shard runs `Bridge.PointsLeaderboardEnabled=false` or an older plugin. Render
from `GET /points` (§6) on connect, then keep live with this event.
##### `char.profile` gains a `points` block
Read-model enrichment on the existing kind — there is **no** request kind for one character's points,
the same precedent `titles` set in [`PROTOCOL_2.md`](PROTOCOL_2.md) §10.3:
```json
"points":[{"system":"QueensLoyalty","nameString":"Queen's Loyalty","nameNumber":1114938,
"points":29500,"maxPoints":15000}]
```
Systems where the character has no entry, or an entry at zero, are **omitted** — otherwise every sheet
would carry ~25 zeroes. `maxPoints` follows the same `0 == uncapped` rule as the board.
`rank` is **absent by default** and appears only when the shard runs `Bridge.PointsProfileRank=true`:
a points lookup stops at the character's own row, but a rank must count every row that beats them, in
every system, on every profile build. Derive rank from `points.board` instead for anyone in the top N.
#### Player-vendor marketplace (Protocol 3.0)
The shard-wide shop index: every player vendor's shop name, owner, location and priced inventory —
the same set the in-game **Vendor Search** gump reads, published so a site can offer the same search
from outside the game.
An **amortized round-robin diff sweep**, not a snapshot RPC, and the distinction is load-bearing:
`rpc.rs::try_route` correlates a reply on the FIRST frame carrying a matching `reqId`, so a chunked
reply sharing one `reqId` would deliver chunk 1 to the HTTP caller and leak chunks 2..N onto the
broadcast feed. A whole-world snapshot could not fit in one frame inside the 10 s reply timeout
either. The per-account `vendor.snapshot` RPC (§5) is unaffected and still serves the player portal.
Each tick inventories at most `Bridge.MarketSweepBatch` vendors (default 25) starting from a
persistent cursor, so **per-tick cost is bounded independently of world size**; full coverage takes
`ceil(vendors / batch) × MarketSweepSeconds`. A vendor is emitted only when its contents, prices,
shop name or location actually change.
| kind | fields | notes |
|------|--------|-------|
| `vendor.listing` | `serial`, `shopName`, `ownerSerial`, `ownerName`, `location{}`, `count`, `total`, `truncated`, `items[]` | One vendor's complete shop — **never a delta**. The latest frame for a `serial` replaces the previous one outright. |
| `vendor.listing.remove` | `serial` | The shop is gone from the index: dismissed, expired, or its owner switched off the in-game Vendor Search flag. |
```json
{"kind":"vendor.listing","serial":"0x40001234",
"shopName":"Darrow's Bargains","ownerSerial":"0x1A2B","ownerName":"Darrow",
"location":{"map":"Trammel","x":1421,"y":1699,"z":0,
"region":"Britain","house":"Darrow's Villa"},
"count":2,"total":2,"truncated":false,
"items":[{"serial":"0x40012ABC","itemId":3922,"hue":0,"amount":1,
"price":25000,"name":null,"cliloc":1023721},
{"serial":"0x40012ABD","itemId":7026,"hue":1157,"amount":3,
"price":500,"name":"a shard sigil","cliloc":1041243}],
"t":1752489280000}
```
**Six things consumers get wrong.**
1. **`name` is `null` for nearly every item; `cliloc` is the real label.** Items carry a
`LabelNumber`, not a name. The plugin deliberately never calls `VendorSearch.GetItemName`, which
builds an `ObjectPropertyList`, serialises it and byte-parses the packet **per item** — a
multi-hundred-millisecond stall across a full pass. (It would not work anyway: every current
client ships its cliloc files compressed and ServUO's bundled `Ultima.StringList` cannot read
them, so the in-game gump has the same gap.) Resolve clilocs consumer-side; a non-null `name` is a
player-set literal and is strictly more specific, so **prefer it over the cliloc**.
2. **`location` is one nested object, and it may be absent entirely.** It is nested so that a
consumer gating vendor whereabouts gates one field rather than five that can drift apart — the
website's `market.location` rule removes the whole object. Treat a missing `location` as "not
published", not as an error.
3. **`truncated` means the shop holds more than the frame carries.** `count` is what was published,
`total` is what the shop actually holds, capped by `Bridge.MarketMaxListings` (default 250). A
commodity reseller with thousands of stacked resources is real and an uncapped frame for one is
measured in megabytes. Say "showing 250 of 3,104" rather than presenting a partial shop as
complete.
4. **`child: true` means the price buys the ENCLOSING CONTAINER.** ServUO prices a container as a
unit and everything inside inherits that price with no `VendorItem` of its own; `DoSearch`
surfaces the same flag. A UI that prints the container's price against each item inside it is
lying about the shard.
5. **Opted-out vendors are absent, and that is a privacy control.** `pv.VendorSearch` is the player's
own in-game toggle and the sweep honours it — hide your vendor in game and it is hidden here too.
The same goes for `Map.Internal` and a null backpack, matching `DoSearch`. Process
`vendor.listing.remove` promptly: it is how a player *revoking* that consent reaches you.
6. **Prices are inherently stale, by design.** The round-robin sweep means a shop can be a full cycle
behind. Any UI over this must say how old the data may be — the website derives it from the oldest
vendor row.
Entries carry `ownerSerial`/`ownerName` and **never `acct` or `webId`**, the same rule `points.board`
follows. Absent entirely if the shard runs `Bridge.MarketEnabled=false` or an older plugin. Render
from `GET /market` (§6) on connect, then keep live with these events — though note that a live
firehose of whole vendor inventories is the largest stream the bridge produces, and a consumer that
only needs a browsable index (as the website does) is better served by the REST read plus the
periodic re-sweep.
---
## 5. REST — read queries
@@ -354,7 +560,7 @@ Full character sheet: stats, all trained skills, worn equipment with flattened i
Field notes:
- `skills[].base` is trained value, `value` includes item/temp bonuses, `cap` is the cap. **Do not assume `base <= cap`** — GM characters can exceed it.
- `equipment[].mods` is a flattened map of every non-zero AOS attribute on the item (weapon or armor). Empty `{}` for plain items.
- Item names are usually **clilocs**, not strings: use `name` when present, otherwise resolve `cliloc` against a UO cliloc table on the site.
- Item names are usually **clilocs**, not strings: use `name` when present, otherwise resolve `cliloc` against a UO cliloc table on the site. **Do not expect the shard to resolve them for you** — on any modern client ServUO's own `Ultima.StringList` cannot read the client's compressed cliloc files, so `VendorSearch.GetItemName` returns `item.Name` and the in-game Vendor Search gump has the same gap. Building that table is a consumer-side job; the website's is described in [`website/CLILOCS.md`](../website/CLILOCS.md).
- `titles` (Protocol 2.0): `selected` is the index into `reward` currently displayed (`-1` if none). `fameKarma`/`skill` are computed display titles, omitted when the character has none. `reward` entries may be a **cliloc number as a string** or a literal string — resolve numeric ones against your cliloc table, same as item names.
- Errors: unknown account → **404** `{"kind":"bridge.error","reason":"unknown account"}`; bad slot → **404**/**400** similarly.
@@ -658,6 +864,72 @@ GET /houses
Every house's latest snapshot — owner→houses map. Served from the sidecar's projection, kept current by the `house.*` stream (§4). Ordered by name. Survives a sidecar restart.
### Shard ruleset (Protocol 3.0)
```
GET /ruleset
→ { "ruleset": {"kind":"world.ruleset","rev":"1a2b3c4d","shard":"UOMysticmoon",
"expansion":"EJ","systems":{...},"caps":{...},"accounts":{...}, ... } }
```
The shard's published ruleset (§4 for the full frame and its two gotchas). Served from the sidecar's
store, so it **answers while the shard is down** — a rules page that goes blank during a restart is
worse than one that is briefly stale. Keep it current with the `world.ruleset` stream.
`{"ruleset": null}` means the shard has never published one — an older plugin, or
`Bridge.RulesetEnabled=false`. That is a real answer distinct from a published ruleset, and worth
rendering differently ("not published yet") rather than as an empty ruleset.
### Points / loyalty leaderboards (Protocol 3.0)
```
GET /points
→ { "boards": [ {"kind":"points.board","system":"QueensLoyalty","nameString":"Queen's Loyalty",
"nameNumber":1114938,"maxPoints":15000,"showOnGump":true,"players":842,
"top":[{"rank":1,"serial":"0x1A2B","name":"Darrow","points":29500}, ...],"t":...}, ... ] }
GET /points/{system} # e.g. /points/QueensLoyalty
→ {"kind":"points.board","system":"QueensLoyalty", ... }
```
Every system's latest board, or one by its `PointsType` name (§4 for the frame and its four gotchas).
Served from the sidecar's projection, kept current by the `points.board` stream, ordered by display
name. Survives a sidecar restart — which matters more here than for live state, since these are
standings built over months and blanking them during a restart reads as data loss.
`GET /points/{system}` returns **404** for a system the shard has never published (an unknown name, or
one excluded by `Bridge.PointsSystems`). That is distinct from a published board nobody has scored in
yet, which is **200** with an empty `top[]` — and the two are worth rendering differently.
### Player-vendor marketplace (Protocol 3.0)
```
GET /market?limit=200&offset=0
→ { "vendors": [ {"kind":"vendor.listing","serial":"0x40001234",
"shopName":"Darrow's Bargains","ownerSerial":"0x1A2B","ownerName":"Darrow",
"location":{"map":"Trammel","x":1421,"y":1699,"z":0,
"region":"Britain","house":"Darrow's Villa"},
"count":2,"total":2,"truncated":false,"items":[ ... ],"t":...}, ... ],
"total": 137, "limit": 200, "offset": 0 }
```
Every vendor's latest shop, exactly as `vendor.listing` published it (§4 for the frame and its six
gotchas). Served from the sidecar's projection, so it answers while the shard is down.
**This is the only PAGED read the sidecar serves**, because it is the only board that can be a whole
world's inventory. `limit` is clamped to 1..1000 (default 200); `total` is returned so a caller knows
when to stop rather than paging until it sees a short page, which would race a concurrent sweep.
Ordering is by **serial**, not by shop name — a serial is stable while a shop name is renameable, so
a rename mid-walk cannot make a vendor skip or repeat a page.
The route is `/market` and deliberately **not** `/vendors`: `/vendors/{account}` next door is the
per-account RPC (§5), and two routes a prefix apart meaning "this player's shops" and "every shop on
the shard" is a trap nobody wins.
Frames are served **verbatim**, owner names and coordinates included. That is not an oversight: the
sidecar defines no audiences. Deciding who may see what is the consuming site's job — see
[`v3.md`](v3.md) §3 for how the website does it.
---
## 7. Status codes
@@ -683,7 +955,7 @@ Every house's latest snapshot — owner→houses map. Served from the sidecar's
A typical character page:
```js
const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "2" };
const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "3" };
// 1. render the roster
const roster = await fetch(`${BASE}/roster/${account}`, { headers: H }).then(r => r.json());

View File

@@ -284,6 +284,15 @@ Counts in `hello` are a live snapshot taken on the Core thread, not a cached val
`Item.Name` is frequently `null`; the display name is `LabelNumber`, a cliloc id. **There is no `Data/Cliloc.enu` in this repo**`BRIDGE_FINDINGS.md` §IV.4 is wrong about this. Cliloc data lives in the client install, which `DataPath` resolves to `D:\Games\Electronic Arts\Ultima Online Classic\`. Ship **both** `name` (when non-null) and `cliloc`, and resolve the number **on the website** against a cliloc map. That avoids a server-side dependency on the client directory.
**Update (3.0).** That recommendation held, and the reason it had to hold turned out to be stronger
than "avoids a dependency": **ServUO cannot resolve clilocs either.** Every current client ships its
`Cliloc.*` files compressed, and the bundled `Ultima.StringList` reads only the older plain layout —
so `VendorSearch.StringList` is null and `VendorSearch.GetItemName` returns `item.Name` on any modern
shard. The in-game Vendor Search gump has the same gap, which is why `vendor.listing` never calls it.
Pushing name resolution to the plugin was never an option. See [`v3.md`](v3.md) §8.6 and
`docs/website/CLILOCS.md` for how the site gets a table instead (the operator converts one from their
own client, once).
---
## 8. Corrections to `BRIDGE_FINDINGS.md`
@@ -315,6 +324,35 @@ Counts in `hello` are a live snapshot taken on the Core thread, not a cached val
7. **Core edit: `PlayerVendorSale`** (§6). Then the cheat-detection feed.
8. **Cheat signals.** `FastWalk`, `OnPropertyChanged` audit, vendor-sale anomaly detection in the sidecar.
**Beyond 1.0.** Phases above are the 1.0 read/event plane. Protocol 2.0's phasing (provisioning +
world-state boards) is [`PROTOCOL_2.md`](PROTOCOL_2.md) §13; Protocol 3.0's (visibility framework,
shard content and standings) is [`v3.md`](v3.md) §9, which also tracks what has landed. Shipped from
3.0 so far: **Part A** — the visibility framework — **`world.ruleset`** ([`v3.md`](v3.md) §5),
`BridgeRuleset.cs`, the first bridge stream that is neither an event subscription nor a sweep (it is
emitted once per connect, like `server.hello`, because shard config changes only when an operator
edits a file) — the **spawn atlas** ([`v3.md`](v3.md) §6), which is website-only and touches no wire
at all — and **`points.board`** ([`v3.md`](v3.md) §7), `BridgePoints.cs`, the loyalty/points
leaderboards. `BridgePoints` is the widest read the bridge performs: ten of ServUO's ~25 point systems
keep a row for every character ever created, so it selects the top N in a single bounded pass rather
than sorting, and runs on a deliberately slow 300 s interval.
Also shipped: **`vendor.listing`** ([`v3.md`](v3.md) §8), `BridgeMarket.cs`, the shard-wide
player-vendor index. It introduces the one sweep pattern the bridge did not previously have — an
**amortized round-robin**. Every other sweep walks its whole collection per tick, which is fine for
tens of houses or a fixed set of point systems and is not fine for a world of shops whose inventories
recurse into containers. `BridgeMarket` inventories at most `MarketSweepBatch` vendors per tick from
a persistent cursor, so the per-tick cost is bounded by the batch rather than by world size, and full
coverage takes `ceil(vendors / batch) x MarketSweepSeconds`. Measured at **15.4 ms** for a cold tick
of 25 vendors x 40 listings and **0.3 ms** in steady state (the per-vendor diff), on a shard of 209k
items / 43k mobiles. It is also the first stream to honour a per-player privacy toggle: ServUO's own
`PlayerVendor.VendorSearch` flag, so a shop hidden in game is hidden on the site.
That completes 3.0's feature work, so the last step is the version itself: `PROTOCOL_VERSION` **2 →
3** and the coordinated `edge``main` merge across all four repos ([`v3.md`](v3.md) §4 and §4.1).
The bump is deliberately the *only* thing that happens at that moment — v3 adds kinds and endpoints
but changes nothing that already existed in v2 — so the operator-visible break is limited to
re-pinning the version, which the website does for itself in a one-shot boot migration.
### Config keys (`Config/Bridge.cfg`)
```ini
@@ -328,6 +366,13 @@ EconomySweepSeconds=300
Read in `Configure()` via `Config.Get<T>("Bridge.<Key>", default)`. Key scope is the filename: `Bridge.cfg` + `StatSweepSeconds``Bridge.StatSweepSeconds`.
The set above is the 1.0 sample, not the current one — every later phase added keys (sweep intervals
for each board, the town-crier/news caps, the admin write plane, account provisioning, and 3.0's
`RulesetEnabled` / `PublicConnectAddress` / `RulesetIncludeSchedule`, and the `Points*` and `Market*`
blocks).
**`servuo-plugins/overlay/Config/Bridge.cfg`
is the authoritative, commented list**; `BridgeConfig.cs` holds the defaults.
---
## 11. Phase 1 acceptance

46
link/PROJECT_TREE.md Normal file
View File

@@ -0,0 +1,46 @@
# uo-link — Project Tree
> **Auto-generated.** This file is maintained by the `sync-project-tree` CI workflow in
> the [`RunicGateway/link`](https://gitea.whitlocktech.com/RunicGateway/link) repository, which
> opens a pull request here whenever the tracked file layout on `main` changes. Do not edit
> by hand — changes will be overwritten by the next sync.
A snapshot of the tracked files in the repository (build output, dependencies, and other
git-ignored paths are excluded).
```text
link/
├── .gitea/
│ ├── ISSUE_TEMPLATE/
│ │ ├── bug_report.md
│ │ ├── config.yaml
│ │ └── feature_request.md
│ ├── scripts/
│ │ └── gen_tree.py
│ ├── workflows/
│ │ ├── release.yml
│ │ ├── sonarqube.yml
│ │ └── sync-project-tree.yml
│ └── PULL_REQUEST_TEMPLATE.md
├── sidecar/
│ ├── src/
│ │ ├── config.rs
│ │ ├── main.rs
│ │ ├── rpc.rs
│ │ ├── shard.rs
│ │ ├── store.rs
│ │ └── web.rs
│ ├── .gitignore
│ ├── Cargo.lock
│ ├── Cargo.toml
│ ├── README.md
│ └── sidecar.toml.example
├── .gitignore
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── CONTRIBUTORS.md
├── LICENSE.md
├── README.md
├── SECURITY.md
└── sonar-project.properties
```

View File

@@ -285,6 +285,18 @@ City titles and faction/VvV merchant titles (`CityLoyaltySystem.ApplyCityTitle`,
### 10.4 Factions / Vice vs Virtue
> **Status update (Protocol 3.0, 2026-07-28).**
>
> - **The deferred question is answered.** This shard runs **Vice vs Virtue** (`VvV.cfg Enabled=True`);
> old Factions is off, and in stock ServUO that is not a coincidence —
> `Services/Factions/Core/Faction.cs` sets `Settings.Enabled = !ViceVsVirtueSystem.Enabled`, so the
> two are mutually exclusive by construction. The `vvv.standings` / `vvv.battle` streams below are
> therefore unblocked, but are **not** scoped for 3.0 (see [`v3.md`](v3.md) §2 row 5).
> - **`world.systems` is superseded by `world.ruleset`** ([`v3.md`](v3.md) §5), which shipped in 3.0.
> It was never implemented under this name. `world.ruleset` carries the same
> `systems{cityLoyalty, vvv, factions, …}` sub-object this section asked for, plus the rest of the
> shard's published ruleset, so no orphan kind is left behind. Do not implement `world.systems`.
**Which system is live is a shard decision — verify before building.** Two exist:
- **Old Factions** (`Scripts/Services/Factions`): `Faction.Commander` (leader, `Faction.cs:160`), `Faction.Election`, `Faction.Members` (`List<PlayerState>`), and faction-controlled **Towns** (`Town.cs` — each town has an owning faction, a sheriff, and finance). Config-gated and, on most modern shards, **off**.
@@ -300,7 +312,10 @@ City titles and faction/VvV merchant titles (`CityLoyaltySystem.ApplyCityTitle`,
{"kind":"vvv.standings","order":142000,"chaos":138500,"leaderSide":"Order"}
```
> Start by detecting which system is enabled at boot and streaming only that one; emit a one-time `world.systems` frame (what's on: cityLoyalty, vvv, factions) so the website renders the right panels instead of guessing.
> Start by detecting which system is enabled at boot and streaming only that one. ~~emit a one-time
> `world.systems` frame (what's on: cityLoyalty, vvv, factions) so the website renders the right panels
> instead of guessing.~~ — **superseded: `world.ruleset` already carries that `systems` block** (see the
> status note at the top of this section).
## 11. Further integration points — a menu to pick from

1014
link/v3.md Normal file

File diff suppressed because it is too large Load Diff

731
website/API_V2_PLAN.md Normal file
View File

@@ -0,0 +1,731 @@
# Website API — router domain split + CSP hardening
Status: **domain split complete** — PR 0 (route manifest), CSP report-only and split PRs 15 have all
landed; only the CSP enforce PR remains, and it is blocked on soak data rather than on code ·
Target repo: `website/` · Docs owner: this file + `BACKEND_DESIGN.md`
> **This file replaces the earlier "API v2" plan** (auth merge → CSP → domain split, with a parallel
> `/api/v2` mount and an `/api/mobile` facade). Three of those four pieces are **not being built**:
> the auth merge and the mobile facade are deferred with their reasoning recorded below, and the
> parallel-version scaffold in [API_V2_SKELETON.md](./API_V2_SKELETON.md) is superseded. The filename
> is kept so existing links resolve. What remains is genuinely useful work:
>
> 1. **CSP hardening** — small, independent, ships on its own cadence.
> 2. **The domain split** — `admin.routes.js` (1552 lines, 110 routes) broken into one router file per
> business capability, **in place, with every URL unchanged**. This is the actual driver.
---
## Why the auth merge is out
The original plan replaced httpOnly session cookies with a bearer JWT + rotating refresh token for
every client, so web and mobile would share one session model. Reasons that no longer hold up:
1. **The current model is the more secure one.** httpOnly + SameSite cookies are unreadable from JS
and carry CSRF protection by default. Every migration target is a sideways or backwards move:
- Refresh token in `localStorage` → any XSS becomes **persistent full account takeover**, not a
bounded access-token window. A short access TTL does not help; the attacker mints new pairs.
- Refresh token in an httpOnly cookie scoped to the refresh endpoint → safe, but that is
*cookies with extra steps*. It concedes the premise.
2. **"One session model everywhere" is already true where it matters.** `auth/session.service.js`
unifies cookie and bearer into a single session, and `auth/token.js` already extracts from either
`Cookie` or `Authorization: Bearer`. That abstraction is written, working, and paid for. The merge
would move complexity *out* of `extractToken` and *into* the SPA.
3. **The cookie codepath survives the merge anyway.** The SSO / email-connect redirect flow must keep
its short-lived httpOnly tx / PKCE-verifier / pending-TOTP cookies — the browser leaves for the
IdP and returns with no JS context. So the merge never actually delivered "cookies are gone."
4. **It carried the plan's most bug-prone work as a dependency.** The admin SSE rewrite
(`EventSource` → hand-rolled `fetch` + `ReadableStream` + SSE frame parser + reconnect/backoff +
refresh-on-401) existed *only* to serve the bearer model. Without the merge, the admin stream stays
on `EventSource` with `withCredentials` and that code is never written.
5. **It is a contract change with no user-visible payoff**, competing for the same review attention as
the domain split, which is the thing that actually hurts today.
### Dropped with it
- v2 bearer auth routes (`/api/v2/auth/{login,refresh,logout,login/totp}`).
- Deletion of the `setAuthCookie` / `clearAuthCookie` path.
- `rg_trust` cookie → `X-Trust-Token` header migration for web (the header stays available for native
clients via `extractTrustToken`, unchanged).
- `api/client.js` bearer + silent-refresh rewrite.
- `lib/useShardFeed.js` admin-stream fetch rewrite. **The admin SSE stream stays as-is.**
### Kept from it
- **CSP hardening** — now its own phase (below). It was justified as a compensating control for a
JS-held token; it is worth doing regardless, just no longer urgent.
- **The public/admin SSE allowlist split** — unchanged security boundary, unrelated to session model.
- **The tx-cookie carve-out reasoning** — recorded here so a future merge attempt doesn't rediscover it.
---
## Deferred: the auth merge
Not cancelled — parked behind trigger conditions. Revisit if **any** of these become true:
| Trigger | Why it changes the answer |
|---|---|
| The API becomes genuinely cross-origin (separate API host) | `SameSite` cookies stop being the easy path; bearer becomes the natural model. |
| Third-party or OAuth clients are introduced | Cookies don't serve clients you don't control. |
| Mobile and web session behavior diverge enough to cause real bugs | The unification argument gets teeth it currently lacks. |
If it is ever revived, two specs the original plan lacked must be written **first**:
- **Refresh-token reuse detection.** Rotation is only useful with it: replay of an already-consumed
refresh must revoke the entire token family, not just fail the one request.
- **Rollback procedure.** Once web clients have discarded their cookies, a bad deploy locks everyone
out. Needs a documented path back.
---
## Why there is no `/api/v2`
The domain split reorganizes router *files*. It does not need to move a single URL — because the URL
surface is **already grouped by capability**. Inventory taken from the live Express stack — 196
`/api/v1` routes, plus three outside it (`GET /api/health`, `GET /api/docs.json`,
`GET /.well-known/assetlinks.json`) and 2 on the internal port. Full machine-readable list:
[`api-route-inventory.json`](./api-route-inventory.json).
| Group | Routes | Second segment → capability |
|---|---|---|
| `/admin` | 110 | `shard` 16 · `moderation` 15 · `users` 15 · `wiki` 14 · `posts` 9 · `pages` 7 · `account` 6 · `email` 6 · `uo-link` 5 · `auth` 4 · `invites` 3 · `bot-activity` 2 · `discord-bot` 2 · `settings` 2 · `activity` 1 · `dashboard` 1 · `site-mode` 1 · `uploads` 1 |
| `/auth` | 42 | `me` 23 · `mobile` 5 · `sso` 4 · `password` 3 · `invite` 2 · `login` 2 · `logout` 1 · `providers` 1 · `register` 1 |
| `/public` | 24 | `shard` 12 · `wiki` 4 · `pages` 2 · `posts` 2 · `contact` 1 · `settings` 1 · `status` 1 · `version` 1 |
| `/player` | 20 | `account` 8 · `shard` 8 · `appeals` 4 |
Every capability already owns a URL prefix, so each new router file mounts at the prefix it already
owns and the emitted paths are **byte-identical**. No URL change means no contract change, and no
contract change means no reason to mount a parallel version.
Consequences of doing it in place:
- No `/api/v2`, no dual mount, no route-by-route migration, no v1-usage telemetry project, and no
v1-retirement sequence.
- `BASE = /api/v1` in `client/src/api/client.js` never changes. The Discord bot's `SITE_PUBLIC_URL`
never changes. The Android app is untouched.
- [API_V2_SKELETON.md](./API_V2_SKELETON.md) (the `router/v2/` scaffold) is **superseded and not
scheduled**. It is kept as the concrete recipe if a real contract break ever forces a versioned API.
**Tripwire:** if any endpoint turns out to *need* a new URL, that is a contract change, not a
refactor. List it explicitly, and reopen the versioning question before writing the code — do not
smuggle a URL change into a "mechanical" PR.
---
## Deferred: the `/api/mobile` facade and the app-version floor
The earlier plan's Phase 0 stood up a version-agnostic `/api/mobile` namespace and migrated the
Android app onto it, plus an app-version header and a server-side min-version floor.
**Why it was proposed:** the app hardcodes **69** distinct `api/v1/…` paths (`data/api/*.kt`,
`core/net/ShardStreamClient.kt`, `core/net/HostSelectionInterceptor.kt`,
`core/auth/sso/SsoAuthManager.kt`), has no version negotiation and no force-update, and installs in
the wild cannot be forced forward. Under a parallel-`/api/v2` plan that made the app the load-bearing
coupling: v1 could not be retired until the fleet aged out.
**Why it is deferred:** with the split done in place, no URL moves and nothing is being deleted — so
there is no fleet to sunset and no coupling to break. A facade would add ~70 permanently maintained
delegate routes plus a contract-test suite to solve a problem that does not currently exist. The
version floor was scoped to sunsetting the pre-facade fleet, so it goes with it.
**If it is ever revived** (the trigger is the mobile contract genuinely needing to diverge from web —
different response shapes, a mobile-only aggregation endpoint, a real breaking change):
- **Start with the alias mount, not a delegate layer:** `apiRouter.use('/mobile', v1Router)` gives the
app a stable, version-agnostic namespace with identical wiring, identical middleware and zero
per-route maintenance. Build hand-written delegates only for the routes that actually diverge.
- **A facade is a security surface, not a convenience alias.** Any hand-written route must carry the
*same* middleware chain as the route it mirrors (`requireAuth`, `staffOnly`/`adminOnly`, validators,
the public/admin SSE allowlist split). A re-exposed admin route missing `adminOnly` is privilege
escalation.
- **It needs contract tests.** The moment the app pins a namespace, its response shapes are a
committed contract; an internal refactor that changes a shape must fail a test before it ships to
installed apps.
- **The mobile SSE stream stays anonymous** under whatever path it gets — the app sends no
`Authorization` header.
---
## Phase 1 — CSP hardening (independent)
Previously bundled with the auth merge as a compensating control for a JS-held token. With no token in
JS, this is **defense in depth on its own merits** — cheap, worth doing, blocking nothing. It has no
dependency on the domain split and can ship at any time.
**Sequencing fix from the original plan:** the old version both "ships with the auth merge" and called
for a one-release report-only soak. Those contradict. Correct order is **report-only first, observe one
release, then enforce** — now trivially satisfiable since nothing waits on it.
The app already ships a tuned policy (`server/src/app.js`). Two directives are load-bearing and are
**already correct** — the job is to keep them that way:
- `script-src 'self'` — no `'unsafe-inline'` / `'unsafe-eval'`. Primary defense.
- `connect-src 'self'` — the exfiltration channel. Don't widen it unless the API genuinely becomes
cross-origin (which would also reopen the auth-merge question — see the trigger table).
`style-src 'unsafe-inline'` stays — it permits inline styling, not script execution, and React's
pervasive `style={{…}}` attributes can't be nonce'd. Not a meaningful hole. The `/api/docs` route keeps
its deliberately looser policy (swagger-ui injects an inline bootstrap script); that carve-out is
scoped to the one route and stays scoped.
Target enforced policy:
```
default-src 'self';
script-src 'self';
connect-src 'self';
img-src 'self' data: https:;
style-src 'self' 'unsafe-inline'; /* + fonts.googleapis.com only until fonts are self-hosted */
font-src 'self'; /* + fonts.gstatic.com only until fonts are self-hosted */
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
```
Delta vs. the policy in `server/src/app.js` today. This was written as two directives; on
implementation it turned out to be **one**:
- ~~**Add `form-action 'self'`** (currently absent)~~ — **it was not absent.** The directives object in
`app.js` does not list it, but the middleware is configured `useDefaults: true`, and helmet's default
set already supplies `form-action 'self'` — so the header served in production has carried it all
along. Verified by capturing the live `Content-Security-Policy` header from the running app rather
than reading the config, which is how the plan got this wrong. **No behavioural change here.** It is
now written out explicitly in `config/csp.js` anyway: a security directive should not depend on a
third-party library's defaults surviving its next major version.
- **Tighten `frame-ancestors`** `'self'``'none'` — nothing legitimately frames the site. **This is
the entire behavioural delta of the phase.**
- Unchanged: `default-src`, `script-src`, `connect-src`, `object-src 'none'`, `base-uri 'self'`, and
`img-src … https:` (external `BRAND_*` logo/hero and `<img>` in sanitized wiki/news bodies rely on
`https:`).
The soak is still worth running for that one directive, and arguably it is the directive that most
needs one: a `frame-ancestors` report is generated by the browser of *whoever framed the site*, so it
is the only way to discover that something legitimately embeds us before the enforcing policy breaks
it. Nothing else can tell us that.
**Rollout:** ship via `Content-Security-Policy-Report-Only` with `report-to` for one release, watch for
violations, then flip to enforce.
Before trusting `script-src 'self'`: Vite's build injects an inline modulepreload-polyfill `<script>`
into `dist/index.html`, which that directive blocks (harmless, but throws a violation).
**Already handled**`client/vite.config.js` sets `modulePreload: { polyfill: false }`, so the build
emits no inline bootstrap script. (The `renderIndexHtml` branding injection adds only `<meta>`/`<link>`
tags — no inline script, no nonce needed.)
### Where reports go
`report-to` needs somewhere to point, so the report-only PR stands up a same-origin sink:
**`POST /api/csp-report`** (`server/src/router/cspReport.controller.js`, wired in `app.js`). Same-origin
on purpose — violation reports describe attacks against this site and are not handed to a third-party
collector. It writes to the `csp` log tag and stores nothing.
It is mounted outside `/api/v1`, alongside `/api/health`: the browser learns the path from the policy
header, never from a client build, so it is not part of the versioned client contract. This is the
`+1` in the route manifest that made PR 0 go first (see § Sequencing).
Necessary properties, since it is an unauthenticated public `POST` (browsers send reports with no
session, and gating it would silence exactly the anonymous visitors worth hearing about):
- **Both wire formats.** `report-uri` (Firefox, Safari) sends `application/csp-report` with a single
hyphenated-key object; `report-to` (Chrome) sends `application/reports+json` with an array of
camelCase envelopes. Handling one silently drops half the browsers. Both directives are emitted, and
`report-to` additionally needs a `Reporting-Endpoints` response header or it is inert.
- **Always 204, even for junk.** A 4xx would make the global error handler write an ERROR line quoting
the attacker-supplied body — turning an open endpoint into a log-flood primitive. A browser cannot
act on an error from a report sink anyway.
- **Bounded everywhere:** 16 KB body cap, a per-IP rate limit, a fixed field allowlist, and every
logged field truncated (`script-sample` is attacker-influenced and can carry a whole inline script).
**Retiring it:** the sink exists for the soak. When the tightened policy flips to enforced and the
report-only twin is deleted, this endpoint goes with it — *unless* a `report-to` group is deliberately
kept on the enforced policy, which is a reasonable thing to want. Decide that in the enforce PR rather
than leaving an orphan route behind.
Tracked follow-ups (own PRs):
- **Self-host the Cinzel font** → drop `fonts.googleapis.com` from `style-src` and `fonts.gstatic.com`
from `font-src`, removing two third-party origins from the trust surface.
- **Trusted Types** — `require-trusted-types-for 'script'` + a `trusted-types` policy, report-only
first. Audit `dangerouslySetInnerHTML` + the `sanitizeHtml` render path first.
---
## Phase 2 — The domain split
**This is the reason the plan exists.** Everything else is supporting work.
**Rule:** one router file = one business capability; the URL names the domain; related endpoints live
together regardless of HTTP method; no generic `admin.routes.js` catch-all. Controllers are **already**
domain-split — this re-wires routes, not logic.
**Invariant:** each capability router mounts at the prefix it already owns, so the emitted URL set does
not change. Proved per PR by the route manifest (§ PR 0).
Target tree — derived from the inventory above, **inside `router/v1/`** (no `v2/` directory):
```
router/v1/
admin/
index.js # mounts the capability routers below under /admin, keeps the
# shared `noindex, isLoggedIn, staffOnly` gate in one place
users.router.js account.router.js invites.router.js
authProviders.router.js moderation.router.js botActivity.router.js
posts.router.js pages.router.js wiki.router.js
uploads.router.js shard.router.js uoLink.router.js
email.router.js discordBot.router.js settings.router.js
activity.router.js # the staff audit log — landed in PR 2, not with dashboard
dashboard.router.js # + the /site-mode singleton
auth/
login.router.js register.router.js password.router.js invite.router.js
sso.router.js mobile.router.js me.routes.js (already split, 23 routes)
public/
news.router.js (posts) pages.router.js wiki.router.js shard.router.js
site.router.js # status, settings, version, contact
player/
account.router.js shard.router.js appeals.router.js
internal/ (unchanged — stays on the unpublished port, never mounted publicly)
```
Steps:
1. **Land PR 0 (route manifest) first** — the mechanical proof that later PRs move no URL.
2. Carve `admin.routes.js` into the per-capability files above, each requiring its already-existing
controller. `admin/index.js` keeps the shared gate (`noindex, isLoggedIn, staffOnly`) and mounts
each capability router at its existing prefix; the `adminOnly` / `modAccess` gates move with the
routes that use them.
3. Split `public.routes.js`, `player.routes.js`, and the remaining `auth.routes.js` groups the same
way. `auth/me.routes.js` is already a separate file and stays.
4. Keep `/internal` off the public listener exactly as today (separate `internalApp.js` port).
5. Move each route's `#swagger.*` annotations **with** the route, then regenerate
(`cd website/server && npm run swagger`).
6. Update `BACKEND_DESIGN.md` §2 (folder structure) and §4 (API contract — it names
`admin.routes.js → admin.controller.js` and friends) as routers move, plus `PROJECT_TREE.md`.
Because the auth model is untouched and the URLs are frozen, each PR is a **pure mechanical refactor
with a green test suite and a zero-diff route manifest as its acceptance criteria** — which is what
makes grouped PRs actually reviewable.
> **Step 6 correction:** `PROJECT_TREE.md` is no longer hand-edited. Since website#98 it is
> auto-generated by the `sync-project-tree` CI workflow, which opens its own docs PR after a merge to
> `main`. Leave it alone in split PRs. `BACKEND_DESIGN.md` §2/§4 are still manual.
### PR 1 — as landed
`admin/index.js` owns the shared `noindex, isLoggedIn, staffOnly` gate and the mount table, and
declares no routes itself. The gate sits **ahead of every mount**, so a capability router extracted in
a later PR cannot silently ship without it. Route counts:
| Router | Routes | Prefix | Extra gate |
|---|---|---|---|
| `account.router.js` | 6 | `/admin/account` | none — self-service, an editor manages their own 2FA |
| `users.router.js` | 15 | `/admin/users` | `adminOnly` at router level |
| `invites.router.js` | 3 | `/admin/invites` | `adminOnly` per route |
| `authProviders.router.js` | 4 | `/admin/auth` (routes are `/providers[/:id]`) | `adminOnly` per route |
| `admin.routes.js` (residual) | 82 | group root, mounted last | unchanged |
6 + 15 + 3 + 4 + 82 = the 110 inventoried admin routes. None of the four prefixes appears in the
residual file, so nothing depends on mount ordering.
Three findings worth carrying into PRs 25:
- **The self-service `/shard/*` routes stay with `shard` (PR 4), despite their `Admin · Account`
swagger tag.** The invariant is *prefix ownership*, not tag agreement: one router owns `/shard`, so
splitting six routes off it by capability would mean two routers mounting under the same prefix and
an ordering hazard for no gain. Retag them in PR 4 if the tag still grates.
- **`usersRouter.use(adminOnly)` is exactly equivalent to the old `adminRouter.use('/users', adminOnly)`**
now that the router is mounted at `/users` — but only because it is mounted at a prefix. Under a
*pathless* mount, a bare `use(gate)` would run for every request passing through en route to a later
mount, 403-ing an editor on `/admin/posts`. Do not "simplify" a prefix mount away.
- **`routes.guards.json` came back zero-diff too**, not just the manifest — no route lost or gained a
gate. Worth checking both every time; the guards file is the one that would catch a dropped
`adminOnly` that the method+path freeze cannot see.
The OpenAPI spec was also byte-for-byte unchanged, which required a prerequisite fix — see below.
### PR 2 — as landed
`moderation`, `bot-activity` and `activity` — 18 routes, leaving 64 in the residual file.
| Router | Routes | Prefix | Extra gate |
|---|---|---|---|
| `moderation.router.js` | 15 | `/admin/moderation` | `modAccess` (admin + moderator) at router level |
| `botActivity.router.js` | 2 | `/admin/bot-activity` | `adminOnly` per route |
| `activity.router.js` | 1 | `/admin/activity` | none — staff-wide audit log |
| `admin.routes.js` (residual) | 64 | group root, mounted last | unchanged |
All four gates came back 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 server tests green.
Notes:
- **`/activity` gets its own file, deviating from the target tree above**, which parked it as a
singleton inside `dashboard.router.js`. It is folded into PR 2 by the sequencing list, and PR 4 is
where `dashboard` lands — so honouring the tree would have meant leaving one route in the residual
file for two PRs to satisfy a filename. It is also a genuinely separate capability: `/activity` is
the **staff audit log** (`activity.model.js`), while `/dashboard` is a stats overview and
`/bot-activity` is the botScore middleware's in-memory ban state. Three different things that read
alike. **PR 4 mounts `dashboard` and `site-mode` only.**
- **`modAccess` moved to a router-level `use`; `adminOnly` on bot-activity deliberately did not.**
Moderation was already gated by a *prefix* mount (`adminRouter.use('/moderation', modAccess)`), so
`moderationRouter.use(modAccess)` is the exact equivalent (the PR 1 `users` case). Bot-activity's
gate was per-route, and keeping it per-route is what holds the per-route handler count — the one
number in `routes.guards.json` that would catch a dropped `adminOnly`, since `requireRole(...)`
returns an anonymous arrow and never shows up by name. **Rule for PRs 35: move a gate to router
level only where it was already a prefix mount; otherwise leave it on the route.**
- **`modAccess` stays in the residual file** — the `/shard/*` in-game staff operations still use it
and do not move until PR 4. Its comment there was retargeted rather than deleted.
### PR 3 — as landed
`posts`, `uploads`, `wiki` and `pages` — 31 routes, leaving 33 in the residual file. The content tier,
and the first split PR where no gate moved at all: all four capabilities are editor-tier, so the shared
`staffOnly` in `admin/index.js` is their whole gate.
| Router | Routes | Prefix | Extra gate |
|---|---|---|---|
| `posts.router.js` | 9 | `/admin/posts` | none — editor tier |
| `uploads.router.js` | 1 | `/admin/uploads` | none — editor tier |
| `wiki.router.js` | 14 | `/admin/wiki` | none — editor tier |
| `pages.router.js` | 7 | `/admin/pages` | none — editor tier |
| `admin.routes.js` (residual) | 33 | group root, mounted last | unchanged |
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 server tests green.
Notes:
- **The residual 33 is exactly PR 4's list** — `shard` 16, `email` 6, `uo-link` 5, `settings` 2,
`discord-bot` 2, `dashboard` 1, `site-mode` 1. So `admin.routes.js` is deleted by PR 4, one PR
earlier than the sequencing list implies, and PR 5 touches only `public/*`, `player/*` and `auth/*`.
- **A shared module was unavoidable here, and it is the first one in the split.** The multer config
(upload dir, mimetype→extension allowlist, 8 MB cap) was defined inline in `admin.routes.js` and used
by *two* routes that this PR puts in different files: `POST /posts/upload` (→ `{image_url}`) and
`POST /uploads` (→ `{url}`). It moved to `admin/imageUpload.js` rather than being duplicated —
duplicating a security allowlist is how the two copies drift. It stays in `admin/` deliberately:
`UPLOAD_DIR` is resolved `__dirname`-relative, so relocating the file would silently repoint the
upload directory. Guard freshness is unaffected — multer's middleware is named `multerMiddleware`
wherever it is constructed, so `routes.guards.json` did not move.
- **`POST /uploads` keeps its `Admin · Posts` swagger tag**, which now disagrees with its filename. The
acceptance criterion is a byte-identical spec, so retagging is a real OpenAPI diff and does not
belong in a route-move PR. Same call as PR 1's `/shard/*` tag mismatch: fix tags in a PR that is
*about* tags.
- **The wiki router is the first one with load-bearing intra-file route order.** `/categories` and
`/tags` are literal paths that must stay ahead of `/:slug`, or `GET /admin/wiki/categories` gets
dispatched as a page whose slug is "categories". **The manifest cannot catch this — it sorts its
entries, so a reordering is invisible in all three gates.** It was verified separately by
introspecting the built router stack and asserting the last literal layer precedes the first `/:slug`
layer. Any future PR moving `/:slug`-style routes needs the same explicit check.
- **`/admin/pages` (CMS page builder) and `/admin/shard/pages` (in-game help-page queue) are unrelated
capabilities that read alike** — the latter stays with `shard` in PR 4. Same trap as PR 2's
`activity` / `dashboard` / `bot-activity` trio.
### PR 4 — as landed
`shard`, `uo-link`, `email`, `discord-bot`, `settings` and `dashboard`/`site-mode` — the whole residual
33. **`admin.routes.js` is deleted**, so the admin group is fully split and every one of its 110 routes
is declared in a capability router.
| Router | Routes | Prefix | Extra gate |
|---|---|---|---|
| `shard.router.js` | 16 | `/admin/shard` | none on the 7 self-service routes; `modAccess` per route on the 9 staff ops |
| `uoLink.router.js` | 5 | `/admin/uo-link` | `adminOnly` per route |
| `email.router.js` | 6 | `/admin/email` | `adminOnly` per route |
| `discordBot.router.js` | 2 | `/admin/discord-bot` | `adminOnly` per route |
| `settings.router.js` | 2 | `/admin/settings` | `adminOnly` per route |
| `dashboard.router.js` | 2 | **group root** (`/dashboard`, `/site-mode`) | `adminOnly` per route on `/site-mode` only |
| ~~`admin.routes.js`~~ | — | deleted | — |
16 + 5 + 6 + 2 + 2 + 2 = 33. 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 server tests green.
Notes:
- **`dashboard.router.js` is mounted at the group root, not a prefix — the one relaxation of the
"always mount at a prefix" rule, and it is deliberate.** `GET /dashboard` and `PUT /site-mode` own no
common path segment, so a prefix mount would mean two one-route files instead of the single file the
target tree calls for. It is safe **only** because the file declares no router-level middleware: a
bare `use(gate)` in a root-mounted router runs for every request passing through toward another
mount and would 403 an editor on an unrelated route (the PR 1 finding). The file says so in a
comment, because the next person to add a gate there is the one who needs to know.
- **`/shard` is the first prefix where two tiers share one router**, and it is why prefix ownership
beats swagger-tag grouping. The 7 self-service routes (`link`, `accounts`, `roster/:account`,
`vendors/:account`, `char/:serial`, `sales`, `POST account`) are tagged `Admin · Account`, run with
no gate beyond the shared `staffOnly`, and are served by the very same `player/shard.controller`
handlers as `/player/shard` — staff are a superset of players, and the controller keys off
`req.user.id`. The 9 in-game ops are tagged `Admin · Shard` and carry `modAccess`. Splitting them by
tag would put two routers under one prefix for no gain; instead one router owns `/shard` and gates
per route. The tag mismatch stays, on the PR 1 and PR 3 precedent: retagging is a real spec diff and
belongs in a PR that is about tags.
- **No gate moved to router level anywhere in this PR.** Every `adminOnly` in the residual file was
per-route, and `modAccess` on `/shard` must stay per-route because half that router must *not* have
it. This keeps the per-route handler count intact — the one number `routes.guards.json` can actually
check, since `requireRole(...)` returns an anonymous arrow.
- **The `/:param` shadowing check was run again and is clean**, since the manifest sorts and therefore
cannot see declaration order. Introspecting the built stack, all 110 admin routes and all 59 literal
admin paths dispatch to their own layer — nothing is captured first by a `:param` sibling. The
near-misses worth naming: `GET /shard/pages` (help-page queue) sits alongside
`POST /shard/pages/:id/respond|close`, and `POST /shard/towncrier` alongside
`DELETE /uo-link/towncrier/:id` — different depths and methods, so neither collides.
- **Deleting the file left dangling `see admin.routes.js` pointers**, which were repointed in the same
PR: `botActivity.controller.js``botActivity.router.js`, `moderation.controller.js`
`moderation.router.js`, `announceJobs.logic.js`'s town-crier cap mirror → `admin/uoLink.router.js`,
and the "route paths sit on the line *after* `adminRouter.get(`" rationale in
`scripts/routeManifest.js`, `README.md` and `pr-checks.yml` was generalized (it was never about that
one file).
- **`/admin/shard/pages` vs `/admin/pages` stayed separate**, as PR 3 flagged: the former is the
in-game help-page (support) queue and belongs to `shard`; the latter is the CMS page builder.
### PR 5 — as landed
`public`, `player` and the residual `auth` — 54 routes across three groups, the last split PR.
`public.routes.js`, `player.routes.js` and `auth.routes.js` are all **deleted**, so every one of the
200 routes in the manifest is now declared in a capability router and no monolithic route file
remains anywhere in `router/v1/`.
| Group | Router | Routes | Prefix | Extra gate |
|---|---|---|---|---|
| `public` | `posts.router.js` | 2 | `/public/posts` | none — `siteMode` per route |
| | `wiki.router.js` | 4 | `/public/wiki` | none — `siteMode` per route |
| | `pages.router.js` | 2 | `/public/pages` | none — `siteMode` per route except the preview |
| | `shard.router.js` | 12 | `/public/shard` | none — never `siteMode` gated |
| | `site.router.js` | 4 | **group root** (`/settings`, `/status`, `/version`, `/contact`) | none |
| `player` | `account.router.js` | 8 | `/player/account` | none beyond the group gate |
| | `shard.router.js` | 8 | `/player/shard` | none beyond the group gate |
| | `appeals.router.js` | 4 | `/player/appeals` | none beyond the group gate |
| `auth` | `login.router.js` | 2 | `/auth/login` | `loginGuards` per route |
| | `register.router.js` | 1 | `/auth/register` | `loginGuards` + `registerLimiter` |
| | `invite.router.js` | 2 | `/auth/invite` | `loginGuards` + `registerLimiter` on accept |
| | `password.router.js` | 3 | `/auth/password` | per-route reset limiters |
| | `session.router.js` | 2 | **group root** (`/logout`, `/me`) | per route |
24 + 20 + 10 = 54. `auth/`'s other 32 routes (`me` 23, `mobile` 5, `sso` 4) were already in their own
files and did not move. 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 server tests green.
Notes:
- **Each group is now a directory with an `index.js`**, matching `admin/`: `public/index.js`,
`player/index.js`, `auth/index.js` own the group gate (where there is one) and the mount table and
declare no routes. `v1.router.js` requires the directories. The four tests that imported the
deleted entry files were repointed.
- **Two of the three groups have no group gate, and that is the security-relevant fact about them.**
`player/index.js` carries `noindex, requireAuth` — authenticated, *any* role, because staff are a
superset of players. `public/index.js` and `auth/index.js` carry **nothing**, deliberately: the
public surface is anonymous by contract (logged-out SPA, Discord bot, and the Android
`ShardStreamClient` on `/public/shard/stream`, none of which send credentials), and `/auth` is where
an anonymous caller *becomes* authenticated. Both index files say so, because the obvious "hardening"
edit to either one is an outage.
- **`GET /auth/me` has a mount-order dependency, and it is the one genuinely non-obvious thing in this
PR.** `authRouter.use('/me', meRouter)` matches the bare path `/me`, not just `/me/*` — so a request
to `GET /auth/me` runs `meRouter`'s (and `notifRouter`'s) `noindex, requireAuth`, matches no route
inside either, and falls through to its own handler. `session.router.js` must therefore stay mounted
**last**. Verified by the counterfactual rather than by reading the mount table: moving the mount to
the top of `auth/index.js` still answers `401`, but the response loses its `X-Robots-Tag` header. No
gate file and neither manifest can see that — only a header assertion can.
- **Two root-mounted routers, on the PR 4 `dashboard.router.js` precedent.** `public/site.router.js`
(`/settings`, `/status`, `/version`, `/contact`) and `auth/session.router.js` (`/logout`, `/me`) hold
the routes that own no path segment. Both are safe at the root **only** because they declare no
router-level middleware — a bare `use(gate)` there runs for every request passing through toward
another mount. Both files say so.
- **`loginGuards` is the PR's one shared module**, the counterpart to PR 3's `imageUpload.js`. The
`[backoffGuard, slowLogin, loginLimiter]` array was defined inline in `auth.routes.js` and spread by
four routes that this PR puts in three different files — plus a fifth, already-duplicated copy in
`sso.routes.js`. It moved to `auth/loginGuards.js` and `sso.routes.js` now imports it too, so there
is one definition rather than five: duplicating a throttling stack is how the copies drift, and the
copy that drifts is the one that stops throttling. It is exported `Object.freeze`d — it is
module-level shared state, and a router that pushed onto it would silently add middleware to every
other login surface. Guard freshness is unaffected: the same three named functions, so
`routes.guards.json` did not move.
- **The `/:param` shadowing check was run again and is clean.** All 86 public/player/auth routes and
all 64 literal paths among them dispatch to their own layer. This was checked in *dispatch* order
against the built stack, since the manifest sorts and therefore cannot see declaration order. The
only ordering-sensitive pair is `GET /public/wiki/{categories,tags}` ahead of `/public/wiki/:slug`
the public twin of the `admin/wiki.router.js` trap PR 3 found, and `wiki.router.js` says so. The
`/public/pages` preview route also stays ahead of `/:slug`, though at a different depth.
- **Filename deviations from the target tree, both for prefix agreement.** The tree named the public
posts router `news.router.js`; it is `posts.router.js`, matching the `/posts` prefix it owns and its
`admin/posts.router.js` sibling. The tree also implied `sso.router.js` / `mobile.router.js`; those
files already exist as `sso.routes.js` / `mobile.routes.js` and were not renamed — they did not move
in this PR, and churning their names would add diff noise to a PR whose value is being reviewable.
- **`auth/session.router.js` is a deviation the target tree did not anticipate**, the same shape as
PR 2's `activity.router.js`. The tree listed `login.router.js` but had nowhere to put `/logout` and
`GET /me`, which own no prefix. Folding them into `login.router.js` would have forced *that* router
to the group root and given up prefix ownership for the four login routes; a separate root-mounted
singleton file keeps `/login` a real prefix mount.
- **Tag mismatches were left alone again**, on the PR 1 / PR 3 / PR 4 precedent: the acceptance
criterion is a byte-identical spec, so retagging belongs in a PR that is about tags.
- **`public.controller.js` was not split.** Unlike the admin controllers, it is still one file serving
settings/status/version/contact *and* posts/wiki/pages. The plan's rule is that these PRs re-wire
routes, not logic — splitting a controller is a separate change with a separate risk profile, and
bundling it would have cost this PR its "pure mechanical refactor" acceptance criteria.
### The swagger path-normalization prerequisite (landed before PR 1)
swagger-autogen builds a path by string-concatenating the mount prefix with the route argument, so a
capability router mounted at `/users` whose collection route is `router.get('/')` documents as
`/api/v1/admin/users/` — advertising a URL no client calls while dropping the one the SPA, the Android
app and the Discord bot all do. Express is indifferent; the published spec is not. It also emits path
keys in *router-traversal order*, so moving a route between files rewrote most of the ~5k-line
committed artifact even when the API was provably unchanged — burying the one line a reviewer needs.
Both are fixed once in `server/swagger/swagger.js`, which post-processes the generator's output to
strip trailing slashes and sort path keys (throwing on a collision rather than silently dropping an
operation). It shipped as its own PR ahead of PR 1, verified inert by the regenerated spec being
byte-for-byte the sorted form of the previously committed one — 198 operations, none added or removed.
`#swagger.path` was rejected as the fix: it bypasses the mount prefix, so every route would hardcode
its absolute path in a comment that silently lies the moment a mount moves.
**Consequence for PRs 25: the swagger diff is now a signal.** With sorting in place, a pure route
move produces *no* spec diff at all, so any diff there means an annotation actually changed. Treat
`swagger-output.json`, `routes.manifest.json` and `routes.guards.json` as three zero-diff gates.
### PR 0 — the route manifest (prerequisite of the first split PR) — **landed**
> **Status: shipped.** `server/scripts/routeManifest.js` + `npm run routes:manifest`,
> `server/routes.manifest.json` (199 public + 2 internal), `server/routes.guards.json`,
> `server/test/routeManifest.test.js`, and a `routes:manifest -- --check` step in
> `.gitea/workflows/pr-checks.yml`. No router file moved. **The generator reproduced
> `api-route-inventory.json` byte-for-byte on first run**, so the freeze is in effect and the
> committed baseline is confirmed accurate rather than merely asserted.
>
> Two deviations from the design below, both deliberate:
>
> - **The unauthenticated-status snapshot was tried and dropped**, exactly as this section allowed.
> Firing unauthenticated GETs at every manifest path against the dead-port mariadb pool the tests
> use does not fail fast — the pool sits on its acquire timeout, and a partial sweep had not
> finished after two minutes. A flaky two-minute gate is worse than none. What replaced it is
> cheap and deterministic: the test suite asserts from the introspected stack that every
> `/api/v1/admin/**` and `/api/v1/player/**` route still carries `requireAuth`.
> - **`routes.guards.json` is committed and staleness-checked**, though a diff in it is explicitly
> *not* a contract change. Left ungenerated it would rot into a misleading review aid within a
> release. The gate is on freshness; the meaning of a guards diff is still "read this", not
> "justify this". The generator drops app-level plumbing (helmet, morgan, the JSON parser, the bot
> guard) since it applies uniformly to all 199 routes and would bury the per-route gates.
"Every URL is unchanged" must be *proved by a diff*, not asserted in review. PR 0 lands the tool that
proves it, with no router file moved.
- **Generator:** `server/scripts/routeManifest.js`, wired as `npm run routes:manifest`. It requires
`src/app.js` (which exports the app and neither listens nor connects to the DB — `server.js` owns
those), walks `app._router.stack` recursively through mounted routers, reconstructs each full path
from the layer regexps, and writes a **sorted** array of
`{ "method": "GET", "path": "/api/v1/admin/users/:id" }` to `server/routes.manifest.json`.
`internalApp.js` is walked into a separate `internal` section so the unpublished port is inventoried
without being confused for public surface.
- **Scope it to the API surface, or it won't be deterministic.** Three mounts are *filesystem*
conditional: the SPA catch-all `GET *` (only when `client/dist/index.html` exists), the `/brand`
static mount, and `/api/docs*` (only when `swagger-output.json` is present — it is committed, so it
is stable). The manifest keeps only `/api/**`, `/.well-known/**`, and the internal app's routes, so
it does not change depending on whether CI built the client. Static mounts are not API contract.
- **The baseline already exists:** [`api-route-inventory.json`](./api-route-inventory.json) in this
directory is the frozen surface — **199 API routes** (110 of them `/api/v1/admin`) plus 2
internal at the time PR 0 was written. PR 0's generator must **reproduce this file byte-for-byte**;
that is PR 0's own acceptance test, and it means the freeze is already in effect before the first
router moves. *(It did, on first run. The file has since moved to **200** — the CSP report-only PR
added `POST /api/csp-report`, the first deliberate, reviewed manifest diff.)*
- **Runtime introspection, not source parsing.** It is authoritative about mounts, and the route paths
in `admin.routes.js` sit on the line *after* `adminRouter.get(`, which defeats naive greps.
- **Not `swagger-output.json`.** That is annotation-derived (only annotated routes appear) and churns
for unrelated reasons; it documents intent, the manifest records reality.
- **Frozen key: method + path only.** That is exactly the contract being preserved. Handler names are
useless as a guard check here — `requireRole(...)` returns an anonymous arrow, and router-level gates
like `adminRouter.use(noindex, isLoggedIn, staffOnly)` never appear in a route's own stack.
- **Guard coverage, separately.** The generator also emits a non-gated review aid: per route, the
handler count plus any *named* middleware collected along the mount chain. If a stable behavioral
check proves cheap, prefer it — a test that fires an **unauthenticated** request at every manifest
path and snapshots the status code catches a dropped `adminOnly` (403 → 200) in a way names cannot.
Try it in PR 0; if DB-touching public routes make it slow or noisy against the dead-port pool the
tests use, drop it rather than ship a flaky gate.
- **CI:** `.gitea/workflows/pr-checks.yml` runs `npm run routes:manifest` and
`git diff --exit-code server/routes.manifest.json`. A PR that moves a URL fails unless it
deliberately commits the new manifest — which puts the URL change in front of a reviewer instead of
letting it pass silently.
- **Published copy:** `docs/website/api-route-inventory.json` mirrors `server/routes.manifest.json` and
is refreshed in each split PR's mandatory docs edit. The markdown table above is orientation for a
human reader; **the manifest is the authoritative freeze.**
---
## Sequencing & PR breakdown
CSP and the split are independent; the only hard ordering is PR 0 before the first split PR.
**Resequenced during implementation: PR 0 ships first, before the CSP pair.** The CSP report-only PR
has to stand up a violation collector (`POST /api/csp-report`) for `report-to` to point at — which is
a new URL under `/api/**`. Landing it first would mean PR 0's generator emitting 200 routes against a
199-route committed baseline, so PR 0 could no longer prove itself by reproducing
`api-route-inventory.json` byte-for-byte. With PR 0 first, the collector shows up as a reviewed,
deliberate `+1` in the manifest — which is exactly the mechanism working as designed.
1. **PR 0 — route manifest.** Generator + CI check + committed baseline of today's surface. No routers moved. ✅ landed
2. **PR — CSP report-only.** Tightened policy behind `Content-Security-Policy-Report-Only` + `report-to`,
plus the report collector (manifest `+1` — the first deliberate, reviewed manifest diff). ✅ landed
3. **PR — CSP enforce.** One release later, assuming a clean violation report. **Blocked on real soak
data**, not on code: watch the `csp` log tag for `frame-ancestors` reports across one release before
flipping. Also decide there whether `/api/csp-report` is retired with the report-only twin or kept
as a `report-to` group on the enforced policy.
4. **PR 1 — admin:** `users`, `account`, `invites`, `auth` (providers). ✅ landed
5. **PR 2 — admin:** `moderation`, `bot-activity`, `activity`. ✅ landed
6. **PR 3 — admin (content):** `posts`, `pages`, `wiki`, `uploads`. ✅ landed
7. **PR 4 — admin (ops/config):** `shard`, `uo-link`, `email`, `discord-bot`, `settings`, `site-mode`,
`dashboard`. (`activity` went with PR 2 — see § PR 2 — as landed.) **This is the whole residual
file** — `admin.routes.js` is deleted here, not by PR 5. ✅ landed
8. **PR 5 — `public/*` + `player/*`** (and the residual `auth/*` grouping). ✅ landed — **the domain
split is complete.** The only remaining item in this plan is the CSP enforce PR (3), which is
blocked on soak data, not on code.
Each PR: **zero-line diff in `routes.manifest.json`**, server tests green
(`cd website/server && npm test`), Swagger regenerated, matching `docs/` edit, Conventional Commit,
AI-disclosure trailer, branch from a freshly-pulled `main`.
---
## Cross-component blast radius
Three independent clients consume the site's HTTP/SSE API, two of them in separate repos on separate
release cadences. **With the auth merge and the version bump both gone, the blast radius is empty**
no client's URLs or authentication change at all.
| Consumer | Repo (cadence) | Pinning | Impact under this plan |
|---|---|---|---|
| Browser SPA | `website/client` (lockstep) | `BASE = /api/v1` in `client/src/api/client.js` | **None.** Same URLs, same cookie session. |
| Android app | `android-app` (app-store cadence, un-updatable installs in the wild) | 69 hardcoded `api/v1/…` paths; SSE path in `ShardStreamClient.kt`; SSO in `SsoAuthManager.kt` | **None.** No repoint, no release required. |
| Discord bot | `website/bot` (separate deploy, env-configured) | `SITE_PUBLIC_URL` env → `/api/v1/public` | **None.** Anonymous public reads on unchanged paths. |
Standing constraints, unchanged:
- **The public SSE stream stays anonymous.** Consumed by logged-out browser visitors *and* the Android
`ShardStreamClient`, neither of which sends an `Authorization` header. Adding `requireAuth` blacks
out the public live boards on web and mobile. The single most likely regression in a careless
refactor is reflexively wrapping *both* shard streams in auth.
- **The admin SSE stream keeps its current cookie-based gating** (`isLoggedIn`, `EventSource` +
`withCredentials`) — the rewrite that would have changed this went out with the auth merge.
- **The public/admin allowlist split is a security boundary**, not an implementation detail. Preserve
it verbatim in `utils/shardBroadcast.js` / `utils/shardIngest.js` as routes move.
- **SSO / email-connect transaction cookies are load-bearing for web *and* native.** The Android SSO
flow opens a Custom Tab to the website's `/auth/…/sso/start` and rides the same server-side redirect
transaction and the same tx cookies. Nothing in this plan touches them; don't let a future "cookies
go away" push delete them.
- **`link/` is out of scope.** The sidecar contract (`uoLinkConfig`, `utils/uoLinkClient.js`,
`utils/shardIngest.js`, `X-UOLink-Version`) is a separate versioning axis. `PROTOCOL_VERSION` does
**not** bump for this work.
---
## Appendix: mapping from the previous plan
| Previous | Now |
|---|---|
| Phase 0 — `/api/mobile` facade + app-version floor | **Deferred.** See § Deferred: the `/api/mobile` facade |
| Phase 1 — auth merge | **Removed.** See § Why the auth merge is out and § Deferred: the auth merge |
| Phase 1b — CSP hardening (shipped with the auth merge) | **Phase 1**, standalone; report-only-first ordering fixed |
| Phase 2 — domain split under `router/v2/` | **Phase 2**, in place under `router/v1/`; now the primary driver |
| PR 1 — `/api/v2` scaffold ([API_V2_SKELETON.md](./API_V2_SKELETON.md)) | **Superseded**, kept as the recipe if a versioned API is ever forced |
| PR final — retire v1 | **Not applicable** — v1 is never replaced |

131
website/API_V2_SKELETON.md Normal file
View File

@@ -0,0 +1,131 @@
# Website API v2 — `/api/v2` Skeleton (PR 1)
> ## ⚠ Superseded — not scheduled
>
> The router domain split is being done **in place**, with every URL byte-identical, so there is no
> parallel version to stand up and this scaffold will not be built. See
> [API_V2_PLAN.md](./API_V2_PLAN.md) § Why there is no `/api/v2`.
>
> The file is kept, unedited below, as the concrete recipe **if** a real contract break ever forces a
> versioned API. Nothing here describes current or planned work.
Companion to [API_V2_PLAN.md](./API_V2_PLAN.md) — this is the concrete scaffold for **PR 1** in that
plan's sequencing. It stands up `/api/v2` **empty but wired**, next to a frozen `/api/v1`, with **no
behavior change**. Endpoints are filled in by the later PRs (auth merge, then the domain split).
## Scope
- Create the `router/v2/` tree of empty, domain-named routers.
- Mount `/v2` alongside `/v1` in `api.router.js`.
- Add a single trivial `GET /api/v2/version` so the mount is testable end-to-end.
- **Out of scope:** any real endpoint, any auth change, any controller edit. Those are PR 2+.
## File tree to create
```
website/server/src/router/v2/
v2.router.js # mounts the domain sub-routers; adds GET /version
admin/
index.js # mounts the admin capability routers under /admin
dashboard.router.js users.router.js moderation.router.js
content.router.js wiki.router.js shard.router.js
settings.router.js invites.router.js bot-activity.router.js
auth/
index.js login.router.js sso.router.js totp.router.js session.router.js
public/
index.js news.router.js wiki.router.js page.router.js shard.router.js
player/
index.js profile.router.js appeals.router.js shard.router.js
```
`internal/` is **not** part of v2's public tree — the internal routes stay on the separate,
unpublished port (`internalApp.js`), exactly as in v1. See `API_V2_PLAN.md` § Phase 2.
## Wiring
`api.router.js` gains the v2 mount next to v1:
```js
const v1Router = require('./v1/v1.router')
const v2Router = require('./v2/v2.router')
apiRouter.use('/v1', v1Router)
apiRouter.use('/v2', v2Router) // NEW — parallel version, migrate off v1 route-by-route
```
`v2.router.js` mounts each domain group and exposes the version ping:
```js
const express = require('express')
const v2Router = express.Router()
const adminRouter = require('./admin')
const authRouter = require('./auth')
const publicRouter = require('./public')
const playerRouter = require('./player')
// Cheap liveness/mount check so the parallel version is testable before any
// real endpoint exists. Returns the API major version, nothing sensitive.
v2Router.get('/version', (req, res) => res.json({ version: 2 }))
v2Router.use('/auth', authRouter)
v2Router.use('/public', publicRouter)
v2Router.use('/admin', adminRouter)
v2Router.use('/player', playerRouter)
// NOTE: /internal is intentionally NOT mounted here — same reason as v1.
module.exports = v2Router
```
Each capability router is an empty stub at this stage — a router that mounts cleanly and adds no
routes yet, so PR 2+ only has to add handlers, never re-wire:
```js
// router/v2/admin/dashboard.router.js
const express = require('express')
const router = express.Router()
// Routes added in the admin domain-split PR (see API_V2_PLAN.md § Phase 2).
module.exports = router
```
Each `index.js` mounts its group's capability routers under the URL that names them, e.g.:
```js
// router/v2/admin/index.js
const express = require('express')
const admin = express.Router()
admin.use('/dashboard', require('./dashboard.router'))
admin.use('/users', require('./users.router'))
admin.use('/moderation', require('./moderation.router'))
admin.use('/content', require('./content.router'))
admin.use('/wiki', require('./wiki.router'))
admin.use('/shard', require('./shard.router'))
admin.use('/settings', require('./settings.router'))
admin.use('/invites', require('./invites.router'))
admin.use('/bot-activity', require('./bot-activity.router'))
module.exports = admin
```
## Acceptance criteria
- Server boots with no error; every sub-router mounts.
- `GET /api/v2/version``200 { "version": 2 }`.
- `GET /api/v1/**` behavior is **byte-for-byte unchanged** — v1 is untouched.
- Existing server tests stay green (`cd website/server && npm test`).
- A new test asserts the `/api/v2/version` mount (smallest possible coverage of the wiring).
## Docs / spec
- Swagger regeneration is deferred until v2 has real routes (PR 2) — a lone `/version` ping doesn't
need an annotation. When PR 2 lands, add `#swagger.*` to the new routes and run `npm run swagger`.
- No `BACKEND_DESIGN.md` change here beyond noting the parallel `/api/v2` mount exists; the
route-map/security-contract edits land with the PRs that add real endpoints.
## Next
PR 2 fills the `auth/` routers with the bearer access + refresh flow and drops the session cookie —
see [API_V2_PLAN.md](./API_V2_PLAN.md) § Phase 1.

120
website/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,120 @@
# Website — Architecture
How the pieces of `RunicGateway/website` fit together. The React SPA and the native mobile app
talk to one Express backend (`router → controller → model → db`), which persists to MariaDB and
bridges to the live game world **only** through the **uo-link** sidecar. The ServUO shard itself is
never internet-facing — it dials out to the sidecar over loopback, and only the sidecar is exposed.
This is the canonical copy of the diagram; the same diagram is embedded in the website's
[`README.md`](https://gitea.whitlocktech.com/RunicGateway/website/src/branch/main/README.md#architecture).
See [BACKEND_DESIGN.md](BACKEND_DESIGN.md) for the full API / schema / security contract, and
[`docs/link/`](../link/) for the wire protocol between the sidecar and the shard.
```mermaid
flowchart TB
%% ---------- Clients ----------
subgraph clients["Clients"]
browser["Browser<br/>React + Vite SPA<br/>(public · wiki · admin)"]
mobile["Native mobile app<br/>(bearer tokens)"]
end
idp["SSO providers<br/>Google · Discord · custom OIDC"]
discord["Discord"]
%% ---------- Website (one repo) ----------
subgraph website["website/ &nbsp;— Node app (one repo)"]
direction TB
subgraph backend["server/ — Express backend"]
direction TB
mw["Middleware<br/>helmet · siteMode · noindex<br/>rateLimit · loginProtection · botScore · validate"]
router["Router /api/v1<br/>auth (web · mobile · sso) · public · admin"]
ctrl["Controllers"]
auth["Session layer (auth/)<br/>sessionService · JWT/cookie · bearer · SSO+PKCE"]
model["Models (.model + .db)<br/>raw parameterized SQL — no ORM"]
sse["SSE fan-out<br/>public stream (allowlist) · admin stream (sensitive)"]
subgraph shardutil["Shard integration (utils/)"]
ingest["shardIngest.js<br/>WS ingest dispatcher"]
restcli["uoLinkClient.js<br/>REST client (never throws)"]
end
secret["secretBox.js<br/>AES-256-GCM secrets at rest"]
end
bot["bot/<br/>Discord bot"]
end
db[("MariaDB<br/>users · posts · wiki · settings · activity<br/>mobileSessions · authProviders · userIdentities<br/>uoLinkConfig · shard_online/economy/houses/events")]
%% ---------- Shard side ----------
subgraph shardside["Game shard (never internet-facing)"]
direction TB
sidecar["uo-link sidecar<br/>(Rust) — the only bridge exposed"]
servuo["ServUO shard<br/>(C# plugin)"]
end
%% ---------- Edges ----------
browser <-->|"same-origin JSON + SSE (cookie)"| mw
mobile -->|"REST (bearer access/refresh)"| mw
browser -.->|"OAuth redirect + PKCE"| idp
auth -.->|"token exchange"| idp
mw --> router --> ctrl
ctrl --> auth
ctrl --> model
ctrl --> restcli
ctrl --> sse
auth --> model
model <--> db
auth -. reads/writes secrets .-> secret
restcli -. reads config/token .-> secret
ingest --> model
ingest --> sse
sse -->|"live events"| browser
bot -->|"messages"| discord
bot <--> db
restcli -->|"REST: /char /roster /economy /history · /link/confirm · /towncrier"| sidecar
sidecar -->|"WebSocket live event feed (bearer + X-UOLink-Version)"| ingest
servuo -->|"loopback TCP 127.0.0.1:7788<br/>newline-delimited JSON (shard dials out)"| sidecar
%% ---------- Styling ----------
classDef ext fill:#2d2233,stroke:#7a5c94,color:#e8dff0;
classDef store fill:#1f2d2a,stroke:#4c8c7d,color:#dff0ea;
classDef bridge fill:#2d2620,stroke:#94764c,color:#f0e6d8;
class idp,discord ext;
class db store;
class sidecar,servuo bridge;
```
## Notes on the diagram
- **One backend, layered.** Every request flows `middleware → router → controller → model → db`.
Web browsers authenticate with an httpOnly JWT cookie; the native app uses short-lived bearer
access tokens plus rotated, hashed, revocable refresh tokens; SSO (Google/Discord/OIDC) is
link-only and PKCE-guarded. All three surfaces resolve to the *same* session model via the session
layer, and admin routes are re-validated against the DB on every request.
- **The shard is never reachable.** The ServUO shard *dials out* over loopback TCP `127.0.0.1:7788`
(newline-delimited JSON) to the uo-link sidecar; only the sidecar is exposed, and only the backend
talks to it. Every backend→sidecar call carries `Authorization: Bearer <token>` and an
`X-UOLink-Version` header (a protocol mismatch fails fast with `409`). The REST client
(`uoLinkClient.js`) never throws — every call returns `{ ok, data, status }` — so the site degrades
gracefully when the shard is down. That guarantee covers **reading the config too**: resolving the
admin-managed config decrypts the stored auth token, which throws when the ciphertext can't be
authenticated (`SECRET_ENC_KEY` rotated, or a DB dump restored under a different key). This is
handled inside the client and reported as `{ ok: false, status: 0, error: 'uo-link config
unreadable' }` plus a distinct `ERROR`-level log, so a wrong key degrades the shard surface to
"unavailable" instead of 500ing it — and `GET /admin/uo-link/config` keeps working, which is the
screen an admin needs to re-enter the token and recover.
- **Two ways in from the sidecar.** Live game events arrive over an outbound **WebSocket** and are
routed by the `shardIngest.js` dispatcher (state-changing kinds update `shard_*` tables, notable
kinds append to `shard_events`, high-frequency kinds only update state). Point-in-time reads and
commands go over **REST** through `uoLinkClient.js`.
- **Sensitive events stay private.** Ingested events fan out to browsers over two **SSE** channels —
a public allowlist stream and an admin-only stream that additionally carries staff audit, cheat
detection, and login-attempt events. The allowlist split is a security boundary; sensitive kinds
can never leak onto the public channel.
- **Secrets at rest.** OAuth client secrets, the uo-link token, and the Gmail refresh token are
AES-256-GCM encrypted via `secretBox.js` (keyed by `SECRET_ENC_KEY`). The uo-link token is
write-only in the API — never returned to any client.

View File

@@ -29,6 +29,24 @@ Public contact email: **UOMysticmoon@gmail.com**
Skeleton from the spec, with a small number of justified additions marked **(+)**.
> **Complete.** The monolithic route files (`admin.routes.js` especially, originally 1552 lines /
> 110 routes) have been split into one router file per business capability — **in place, with every
> URL unchanged**. See [API_V2_PLAN.md](./API_V2_PLAN.md) § Phase 2.
>
> `users`, `account`, `invites`, `auth/providers` (PR 1, 28 routes), `moderation`, `bot-activity`,
> `activity` (PR 2, 18 routes), `posts`, `uploads`, `wiki`, `pages` (PR 3, 31 routes) and `shard`,
> `uo-link`, `email`, `discord-bot`, `settings`, `dashboard`/`site-mode` (PR 4, 33 routes) each live
> in their own router under `admin/`, behind `admin/index.js`. PR 5 did the same for `public/` (24),
> `player/` (20) and the residual `auth/` (10). **`admin.routes.js`, `public.routes.js`,
> `player.routes.js` and `auth.routes.js` are all deleted**; each group is now a directory whose
> `index.js` owns the group gate and the mount table and declares no routes of its own.
>
> "Every URL unchanged" is enforced mechanically, not by review: `server/scripts/routeManifest.js`
> (`npm run routes:manifest`) walks the live Express stack and writes the sorted
> `{ method, path }` freeze to `server/routes.manifest.json`, mirrored here as
> [api-route-inventory.json](./api-route-inventory.json). PR checks regenerate it and fail on any
> diff, so a split PR that moves a URL cannot merge silently. See § 4.0.
```
server/
.env.example
@@ -42,10 +60,108 @@ server/
router/
api.router.js mounts /v1
v1/
v1.router.js mounts /auth /public /admin
auth/ auth.routes.js + auth.controller.js
public/ public.routes.js + public.controller.js
admin/ admin.routes.js + admin.controller.js
v1.router.js mounts /auth /public /admin /player
auth/ index.js mounts the routers below; no group gate — /auth
is where an anonymous caller becomes
authenticated, so the authenticated parts gate
themselves. Mount order is load-bearing (see
session.router.js)
login.router.js (2) /auth/login + /login/totp — shared
loginGuards stack
register.router.js (1) /auth/register — honours the
player_registration setting
invite.router.js (2) /auth/invite/:token[/accept] — the
token is its own authority, so it
bypasses player_registration
password.router.js (3) /auth/password/forgot + reset/:token
session.router.js (2) POST /logout and GET /me — the two
singletons owning no path segment, so
mounted at the group root, LAST: the
/me sub-routers below also match the
bare /me and supply its noindex header
me.routes.js (23) /auth/me/account*, sessions, trusted
devices — router-level requireAuth
notifications.routes.js (3) /auth/me/devices*, notifications/*
mobile.routes.js + /auth/mobile/* — native bearer login
mobileSso.routes.js (5)
sso.routes.js (4) mounted PATHLESS: owns two prefixes,
/auth/providers and /auth/sso/*
loginGuards.js shared backoff/slow/limiter stack for
every credential-guessing surface
(not a router)
auth.controller.js + invite/passwordReset/sso/mobile controllers
public/ index.js mounts the routers below; **no group gate** —
this surface is anonymous by design (SPA
logged-out, Discord bot, Android ShardStream)
posts.router.js (2) /public/posts/:category[/:idOrSlug]
wiki.router.js (4) /public/wiki — /categories and /tags
MUST precede /:slug
pages.router.js (2) /public/pages — the draft-preview
route precedes /:slug and is
deliberately not site-mode gated
shard.router.js (14) /public/shard/* incl. the anonymous
SSE stream; never site-mode gated
atlas.router.js (6) /public/atlas/* — the spawn atlas.
NOT under /shard: nothing here
touches the sidecar, and unlike
/shard it IS site-mode gated
site.router.js (4) /settings /status /version /contact —
the group-root singletons; declares no
router-level middleware
public.controller.js + shard.controller.js
player/ index.js owns the shared `noindex, requireAuth` gate
(authenticated, ANY role — staff are a superset
of players) and the mount table
account.router.js (8) /player/account — credentials, TOTP,
linked identities; handlers shared
with /admin/account and /auth/me
shard.router.js (8) /player/shard — linking + own roster,
vendors, chars, sales, houses
appeals.router.js (4) /player/appeals
shard.controller.js + appeals.controller.js
admin/ index.js mounts the capability routers below at their
own prefixes; owns the shared
`noindex, isLoggedIn, staffOnly` gate and
declares no routes itself
account.router.js (6) /admin/account — self-service, no adminOnly
users.router.js (15) /admin/users — adminOnly
invites.router.js (3) /admin/invites — adminOnly
authProviders.router.js (4) /admin/auth — adminOnly
moderation.router.js (15) /admin/moderation — modAccess
(admin+moderator) at router level
botActivity.router.js (2) /admin/bot-activity — adminOnly
activity.router.js (1) /admin/activity — staff-wide
audit log, no extra gate
posts.router.js (9) /admin/posts — editor tier, no
gate beyond staffOnly
uploads.router.js (1) /admin/uploads — rich-text editor
image upload
wiki.router.js (14) /admin/wiki — pages, revisions,
categories, tags
pages.router.js (7) /admin/pages — CMS page builder
imageUpload.js shared multer config for the two
upload routes above (not a router)
shard.router.js (16) /admin/shard — 7 self-service
account-linking routes (no extra
gate, handlers shared with
/player/shard) + 9 in-game staff
ops on modAccess, per route
uoLink.router.js (5) /admin/uo-link — sidecar config,
town crier, admin SSE — adminOnly
email.router.js (6) /admin/email — Gmail OAuth2
delivery — adminOnly
discordBot.router.js (2) /admin/discord-bot — adminOnly
settings.router.js (2) /admin/settings — adminOnly
dashboard.router.js (2) GET /dashboard (staff-wide) and
PUT /site-mode (adminOnly) — the
two singletons owning no path
segment, so mounted at the group
root; declares no router-level
middleware, which is what makes a
root mount safe
admin.controller.js + the per-capability controllers
(already domain-split; the split PRs re-wire
routes, not logic)
model/
users/ users.model.js + users.db.js
posts/ posts.model.js + posts.db.js (news/five-on-friday/newsletter/screenshots)
@@ -142,6 +258,336 @@ Seeded keys: `site_mode` (default `maintenance`), `site_mode_changed_at`,
| ip | VARCHAR(45) NULL | from `req.ip` (needs `trust proxy`) |
| created_at | DATETIME DEFAULT CURRENT_TIMESTAMP | |
### password_resets — self-service reset links
| col | type | notes |
|---|---|---|
| id | INT PK AUTO_INCREMENT | |
| token_hash | CHAR(64) UNIQUE NOT NULL | sha256 hex of the opaque token; **plaintext never stored** |
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | the account this reset targets |
| status | ENUM('pending','used') DEFAULT 'pending' | single-use (atomic `markUsed`) |
| requested_ip | VARCHAR(64) NULL | who asked (audit only) |
| expires_at | DATETIME NOT NULL | ~1h TTL, enforced in the model on top of this |
| created_at / used_at | DATETIME | |
Same "store only the hash of an opaque token" pattern as `user_invites` / `mobile_refresh_tokens`.
A DB read never yields a usable reset link. See §4 `/auth/password/*`.
### push_devices — opt-in push endpoints (M7)
| col | type | notes |
|---|---|---|
| id | INT PK AUTO_INCREMENT | |
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | owner |
| transport | ENUM('unifiedpush','fcm') DEFAULT 'unifiedpush' | UnifiedPush for the sideloaded APK; FCM reserved for a later Play flavor |
| endpoint | VARCHAR(512) NOT NULL | the distributor URL the app's ntfy topic was handed (or an FCM token). Unguessable but **not a secret** — stored in the clear (unlike refresh tokens), because pushes are content-free tickles |
| platform | VARCHAR(40) NULL | free-form label, e.g. `android` |
| created_at / last_seen_at | DATETIME | |
`UNIQUE(user_id, endpoint)` — re-registering the same endpoint is an idempotent upsert.
### notification_subscriptions — which streams a user opted into (M7)
| col | type | notes |
|---|---|---|
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | |
| stream_id | VARCHAR(64) NOT NULL | an id from the catalog (`config/notificationStreams.js`), validated on write |
| created_at | DATETIME | |
`PRIMARY KEY(user_id, stream_id)`. Subscriptions are per-user (applied to every device); a PUT
replaces the whole set. Nothing is pushed unless the user subscribed.
### mobile_auth_sessions / mobile_auth_codes — mobile SSO bridge (M9)
Two short-lived, self-pruning tables that bridge a browser SSO redirect flow to a native client. They
carry the **app ↔ website** PKCE + CSRF state (a *second* PKCE layer, distinct from the website ↔ IdP
PKCE the `sso_tx` cookie already carries) and the one-time authorization code the app exchanges for
bearer tokens. Neither holds a secret in the clear — the PKCE `code_challenge` is a hash by
construction, and the authorization code is stored as a **sha256 hash only** (same pattern as
`user_invites` / `password_resets` / `mobile_refresh_tokens`).
`mobile_auth_sessions` — one row per `/auth/mobile/sso/start`:
| col | type | notes |
|---|---|---|
| id | INT PK AUTO_INCREMENT | |
| session_id | CHAR(36) UNIQUE | opaque uuid; carried inside the signed `sso_tx` (mode `mobile`) so the callback can find this row |
| provider | VARCHAR(40) NOT NULL | provider id validated enabled at `/start` |
| code_challenge | VARCHAR(255) NOT NULL | app-supplied PKCE S256 challenge (base64url); verified at `/exchange` |
| redirect_uri | VARCHAR(255) NOT NULL | the requested app callback — **exact-match** against the allowlist (never prefix) |
| state | VARCHAR(255) NOT NULL | app-generated opaque CSRF value, echoed on the callback for the app to verify |
| status | ENUM('pending','completed','consumed') DEFAULT 'pending' | `pending``completed` when the code is minted; `consumed` after a successful exchange |
| user_id | INT NULL FK→users(id) ON DELETE CASCADE | set once SSO resolves the account |
| trust_device | TINYINT(1) NOT NULL DEFAULT 0 | user ticked "trust this device" on the Custom Tab TOTP form. A **boolean only** — it tells `/exchange` to mint the app's own trust token; the token never rests here (only its sha256 reaches `trusted_devices`) |
| expires_at | DATETIME NOT NULL | short (~10 min — one redirect round-trip incl. TOTP) |
| created_at / used_at | DATETIME | `used_at` stamped at exchange |
`mobile_auth_codes` — one row per completed SSO callback (the code the app redeems):
| col | type | notes |
|---|---|---|
| id | INT PK AUTO_INCREMENT | |
| code_hash | CHAR(64) UNIQUE | sha256 hex of the opaque ≥128-bit code; the raw code never touches the DB |
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | the authenticated account |
| session_id | CHAR(36) NOT NULL | the owning `mobile_auth_sessions.session_id` (ties the code to its PKCE challenge) |
| expires_at | DATETIME NOT NULL | very short (~5 min) |
| used_at | DATETIME NULL | set on first successful exchange — **single use** (a reused code fails) |
| created_at | DATETIME | |
Both self-prune (indexed `expires_at`): a best-effort sweep runs at boot beside the existing
`revoked_sessions` prune, and each bridge write opportunistically deletes expired rows — so no cron
infra is added (same approach as `revoked_sessions`).
**`mobile_refresh_tokens` additions (M9).** Two nullable columns are added to support the device
list/revoke surface: `device_name VARCHAR(100) NULL` (a friendly label) and `last_used_at DATETIME
NULL` (bumped on each refresh). Existing rows get them via the schema's ALTER section; the token model
is otherwise unchanged.
### trusted_devices — MFA "Trust this device"
Lets a browser/app **skip the TOTP step** at login (never the password) for 30 days. Pattern-identical
to `mobile_refresh_tokens`: the opaque trust token lives client-side (the `rg_trust` httpOnly cookie on
web, `X-Trust-Token` / EncryptedSharedPreferences on native) and only its **sha256** hash is stored
(`token_hash CHAR(64) UNIQUE`) — sha256, not bcrypt, because a 256-bit random token is looked up **by
its hash** via the unique index (a per-row salt would break that). Columns mirror the mobile table
(`platform`, `device_name`, `device_hash`, `user_agent`, `created_at`, `last_used_at`, `expires_at`,
`revoked_at`). Capped at 10 rows/user **in application code — no silent pruning** (an over-cap trust is
refused so the client can prompt the user to revoke one first). Consulted only at the login/password
step, never at token refresh, and revoked wholesale on untrust / password change / password reset /
TOTP disable. See `docs/website/TRUSTED_DEVICES_MFA.md`.
### recovery_codes — single-use MFA backup codes
Generated at TOTP enrollment (10 at a time, shown to the user **once**) so a user who loses their
authenticator can complete login without an admin reset. `code_hash VARCHAR(72)` is a **bcrypt** hash
(not sha256): a recovery code is a human-typed, lower-entropy fallback credential — the closest
analogue to a password — and there is no hash-lookup constraint (verification fetches the user's ≤10
unused rows and `bcrypt.compare`s each, like password verification). `used_at` is the single-use
marker. Cleared wholesale on TOTP disable / password change / password reset.
### shard_ruleset — the shard's published ruleset (Protocol 3.0)
Singleton row (`id = 1`, CHECK-constrained) holding the latest `world.ruleset` frame: `rev`,
`expansion`, `payload` JSON (the whole frame), `t`, `updated_at`. The shard re-emits the complete
ruleset on every sidecar connect, so this is an **overwrite, not an append** — and the kind is
deliberately **not** in `LOGGED_KINDS`, since logging it would put a duplicate row in `shard_events`
on every reconnect while `server.hello` already marks each of those.
The frame is stored whole rather than normalized into columns: it is a flat description of server
config that is read as one page, so splitting it up would mean a schema change every time the shard
grows a new block. `rev` (the shard's FNV-1a of the body) and `expansion` are hoisted only because
they are cheap to display — the same payload-plus-hoisted-columns shape `shard_champs` uses.
**No row means the shard has never published one** (an older plugin, or `Bridge.RulesetEnabled=false`),
served as `null` rather than `{}`: "not published yet" and "published, everything off" are different
answers and the page renders them differently.
### shard_points_boards — points / loyalty leaderboards (Protocol 3.0)
One row per point system, keyed by the shard's own `PointsType` name (`QueensLoyalty`,
`CleanUpBritannia`, …). The shard carries ~25 of these, each a standing players build over months.
Columns: `system` (PK), `name`, `name_cliloc`, `max_points`, `players`, `show_on_gump`, `payload` JSON
(the whole `points.board` frame), `t`, `updated_at`.
**The top-N list stays inside `payload`** rather than being normalized into a `shard_points_entries`
table. It is a fixed-size list (10 by default) that is only ever read whole — exactly like
`shard_governors.candidates` — so normalizing buys nothing until something needs a per-character
reverse lookup, and a character's own standings already ride inside `char.profile` instead.
Board state, not events: `points.board` is **not** in `LOGGED_KINDS`, for the same reason
`guild.update` isn't. The shard emits a frame every time anyone's score moves a top ten, so logging
would grow `shard_events` without bound for something whose only interesting value is its latest
version. There is also **no delete path** — the shard's set of systems is fixed at startup, so there is
no `points.remove` to mirror.
Two values carry non-obvious meanings, both set by the plugin and both documented in
[`link/INTEGRATION.md`](../link/INTEGRATION.md) §4:
- **`max_points = 0` means uncapped**, and on a real shard that is the *common* case (ServUO's
uncapped idiom is `double.MaxValue`, which the plugin normalises to 0). Anything rendering
`points / max_points` must special-case it.
- **`name` is usually NULL**, with `name_cliloc` set instead — most systems name themselves with a
cliloc rather than a literal. Listing therefore orders by `COALESCE(name, system)`, so boards
awaiting cliloc resolution sort by their own key rather than clumping together under NULL.
### shard_vendors / shard_vendor_items — the player-vendor marketplace (Protocol 3.0)
The shard-wide shop index, fed by `vendor.listing` / `vendor.listing.remove`. One row per player
vendor and one per priced listing. Full operator detail in [`MARKETPLACE.md`](MARKETPLACE.md); the
design is `docs/link/v3.md` §8.
| Table | Shape |
|---|---|
| `shard_vendors` | `serial` (PK), `shop_name`, `owner_serial`, `owner_name`, `map`/`x`/`y`/`z`, `region`, `house`, `item_count`, `item_total`, `truncated`, `t`, `updated_at`. Indexes on owner, map, region and `updated_at`. |
| `shard_vendor_items` | `id` (PK), `vendor_serial`, `serial`, `item_id`, `hue`, `amount`, `price`, `name`, `cliloc`, `display_name`, `child`. Indexes on `vendor_serial`, `price`, `item_id`, `display_name`, and `(display_name, price)`. |
**Ingest is per-vendor and authoritative**: the frame is the whole shop, so ingest is
delete-then-insert of that vendor's listings inside one transaction. All-or-nothing matters
specifically because the two writes are "the shop" and "what is in it" — a failure between them
leaves a shop advertising an inventory it no longer has, which is visibly wrong and indistinguishable
from a genuinely empty shop. No foreign keys, consistent with every other `shard_*` table.
**There is deliberately no `payload` column**, unlike `shard_points_boards` directly above. The
board's top-N is a fixed-size list read whole, so it lives in JSON; here the items *are* the
searchable rows, so they are normalized and nothing is left worth duplicating. The sidecar keeps the
whole blob — outage resilience is its job, search is ours.
Market state, not events: neither kind is in `LOGGED_KINDS`, and this is the strongest case of the
three v3 kinds. One frame carries up to 250 listings and the sweep re-emits a shop on any price
change, so logging would turn `shard_events` into a price history nobody reads.
Two columns carry non-obvious meanings:
- **`item_count` vs `item_total`.** `item_count` is what the frame published; `item_total` is what
the shop actually holds. They differ when `truncated` — the shard caps listings per frame
(`Bridge.MarketMaxListings`, 250 by default), and a commodity reseller with thousands of stacks
genuinely exceeds it. Any UI must show both or it presents a partial shop as complete.
- **`display_name` is denormalized at ingest**, resolved from the item's literal `name` (preferred —
a player set it, so it is more specific) else its `cliloc` against `shard_clilocs`. 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 triggers a bulk re-resolution** of this column (after a boot
import and after an admin import; ~50 ms per thousand rows, never throws).
`updated_at` is written explicitly on every upsert rather than left to `ON UPDATE CURRENT_TIMESTAMP`,
which MariaDB does not fire when every column is written back unchanged. A shop re-published
identically is still *freshly confirmed*, and without this the staleness banner would age a perfectly
current shop forever.
### shard_feature_visibility — per-feature audience config (Protocol 3.0)
One row per shard feature: `feature` (PK), `enabled`, `audience` (a rung on the ladder in §6.5),
`stream` (whether the feature's kinds fan out over SSE at all), `field_rules` JSON (`{field: rung}`
for the sensitive fields only), `updated_by`, `updated_at`.
**An absent row means "use the compiled default", and the compiled defaults reproduce pre-3.0
behavior — so an empty table is a no-op and there is nothing to seed.** Stored rows are merged over
the defaults on read, which is also where the invariants are re-applied: a row naming an unknown
feature is ignored (a stale row must not resurrect a removed feature), an invalid rung falls back to
the default rather than failing open, and a rule touching a locked field (`acct` / `webId`) is
discarded. See §6.5.
### shard_spawn_* / shard_regions / shard_landmarks / shard_champion_spawns / shard_atlas_meta — the spawn atlas (Protocol 3.0)
Static shard **content**, not live shard state. Nothing here comes from the sidecar: the atlas is
derived from the shard's own ServUO tree, re-read on **every server boot** and hash-gated so an
unchanged tree costs one read pass and no write. Nothing is precomputed and committed — a shard's
maps change over its life, and a snapshot in the repo would silently drift from the world players
actually see. These tables stay populated whether the shard is up or not. Full operator detail in
[`SPAWN_ATLAS.md`](SPAWN_ATLAS.md); the design is `docs/link/v3.md` §6.
**No facet name appears anywhere in the code.** A shard may add facets, replace them, or rename them
when its maps are updated; the facet set is discovered from the tree, and the loose spellings in
`Data/Locations` are matched against it rather than looked up in a table.
| Table | Key columns |
|---|---|
| `shard_spawn_creatures` | `slug` PK, `name`, `total`, `points`, `facets` JSON, `art` NULL |
| `shard_spawn_points` | `id` PK, `facet`, `name`, `x`, `y`, `width`, `height`, `spawn_range`, `max_count`, `min_delay`, `max_delay`, `tod_start/end/mode`, `region`, `landmark`, `label` |
| `shard_spawn_point_types` | `(point_id, slug)` PK, `max_count` |
| `shard_regions` | `facet`, `name`, `type`, `priority`, `parent`, `rects` JSON |
| `shard_landmarks` | `facet`, `name`, `grp`, `x`, `y`, `z` |
| `shard_champion_spawns` | `slug` PK, `name`, `grp`, `type`, `random_type`, `facet`, `x`, `y`, `z`, `radius`, `label` |
| `shard_atlas_meta` | Singleton (`id = 1`), `payload` JSON (counts, a sha256 per source file, `parserVersion`), `imported_at` |
| `shard_atlas_pending` | Singleton (`id = 1`), `status` (`pending`/`rejected`), `payload` JSON, `detected_at` |
The first seven are **import-owned**: a refresh empties and reloads every one inside a single
transaction, so a failed reload leaves the previous atlas intact rather than a half-loaded world.
Nothing else writes to them, and nothing holds a foreign key to them — no FKs at all, consistent with
every other `shard_*` table.
**`shard_atlas_pending` is the security-relevant one.** A refresh that would REMOVE a facet is never
applied automatically: facet loss is indistinguishable at boot from a half-copied or mid-update tree,
so it is staged here for an admin to approve or reject, and **startup is never blocked by it**. Only
the decision is stored — source hashes plus the facet diff, a few KB — and approving re-parses the
tree, so a multi-megabyte blob never lands in the database and what gets applied matches the tree at
approval time. A rejection is remembered against those exact hashes so a declined refresh does not
re-prompt on every restart. Everything else (new facets, renamed regions, changed spawns) applies
immediately, since none of it can destroy data an operator would miss.
The boot refresh is **best-effort by contract**: no configured path, an unreadable mount, a malformed
file or a database error is caught and logged, and the site comes up serving whatever atlas it had.
The tree path comes from the `spawn_atlas_servuo_path` setting, falling back to `SERVUO_PATH`.
**A refresh re-derives when the tree changed OR the parser did.** `spawnAtlasSource.PARSER_VERSION`
is stored in `shard_atlas_meta` beside the source hashes and bumped whenever the parser produces
different data from identical files. Hashing the tree alone would strand an install whose maps never
change on whatever an older build derived — a corrected parse would ship and never reach the data.
Four column choices worth stating, because each one is a trap:
- **`spawn_range`, not `range`**, and **`grp`, not `group`** — both are reserved words.
- **`DELETE`, not `TRUNCATE`.** `TRUNCATE` is DDL in MariaDB and implicitly commits, which would
defeat the all-or-nothing reload. At ~7k rows the difference does not matter.
- **Point ids are assigned explicitly**, not left to `AUTO_INCREMENT`: the `shard_spawn_point_types`
rows need to know them, and `conn.batch()` reports no usable `insertId` for a multi-row insert.
- **Plain `INDEX` on `name`, deliberately not `FULLTEXT`.** ~800 creature rows makes a `LIKE` scan
free, and FULLTEXT's minimum token length would break searches for names like "orc".
`shard_champion_spawns` is the *configured* altar roster ("there is an Unholy Terror altar in
Deceit"). The live `champ.update` feed in `shard_champs` is the separate answer to "it is on level 3
right now". Both exist; they are not the same data.
**`shard_spawn_creatures.art` is always NULL on a fresh import.** The project ships no creature
artwork: sprites live in the operator's own client `.mul`/`.uop` files and are theirs, not ours to
redistribute. An operator supplies art via a gitignored map plus images under the (already
gitignored) `server/uploads/atlas/`. Text-only is the normal, supported state.
### shard_clilocs / shard_cliloc_meta — UO's localization table (Protocol 3.0)
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 with no table to resolve it against, the character sheet could only render
`id 1023721` where the game renders "quarter staff".
| Table | Shape |
|---|---|
| `shard_clilocs` | `number` INT PK, `flag`, `text` TEXT |
| `shard_cliloc_meta` | Singleton (`id = 1`), `payload` JSON (source file, sha256, count, `parserVersion`), `imported_at` |
Import-owned and all-or-nothing in one transaction, same contract as the atlas — including **`DELETE`,
not `TRUNCATE`**, for the same reason.
**Sourced from files the operator supplies**, 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. Full
design and operator guide: [`CLILOCS.md`](CLILOCS.md).
**It reads a SET of sources, not one file**, because shards edit items and add new ones and those
carry cliloc ids no stock client table has. A base (the converted client table) plus every overlay
under `custom/` are re-read on every boot and hash-gated **together**, exactly as the atlas re-reads
`Regions.xml` + `Locations/*.xml` + `Spawns/*.xml` + `ChampionSpawns.xml`. Later sources win, so an
overlay both adds ids and overrides stock ones, and adding one custom item never means re-exporting a
5 MB client file. Scale, measured on the live shard: its script tree references 16,434 cliloc ids and
only 37 are absent from stock — tens of entries against a 67k base, which is why this is an overlay
and not a second table.
The conversion step is not avoidable: **every current client ships its cliloc files compressed**
(first DWORD's high byte `0x8E`), and ServUO's own bundled `Ultima.StringList` cannot read that
either — so the shard cannot supply names on our behalf. The plain layout and a delimited text export
are both accepted, sniffed by header rather than extension.
Three decisions worth stating:
- **`text` is TEXT, not VARCHAR.** Long property descriptions reach 12 KB. The index that matters for
marketplace search is the denormalized `shard_vendor_items.display_name`, not this table.
- **Blank entries are dropped at import** — 123,490 parsed → **67,496** stored. Roughly half a cliloc
table is empty strings for ids the client reserves and never uses; a row that resolves to no name is
indistinguishable from no row at all, and dropping them makes the binary and text imports converge
on identical content.
- **Two refusals, one of them the atlas's.** A corrupt source fails the parse on a truncated record,
so it is caught outright and leaves the previous table serving. But a source that has **vanished**
parses perfectly and imports a table quietly missing everything it contributed — an unmounted volume
and a deliberate deletion are indistinguishable from here, which is precisely the ambiguity the
atlas stages a facet removal for. So it is escalated: `status: 'needsReview'`, nothing applied,
`missingSources` reported by both the import and `status()`, and an admin accepts it 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** the tree; here nothing is stored, so
re-reading at approval time is automatic.
**Resolution is server-side and there is 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 already-resolved
JSON. `resolveMany()` returns only ids that resolved to something displayable — placeholders like
`~1_val~` are stripped, since the bridge sends the id and never the property packet that carries the
arguments — and it never throws, because a cliloc lookup is decoration on a character sheet.
---
## 4. API contract
@@ -149,30 +595,234 @@ Seeded keys: `site_mode` (default `maintenance`), `site_mode_changed_at`,
Base path `/api/v1`. JSON in/out. Auth via httpOnly cookie (`isLoggedIn` reads it; also
accepts `Authorization: Bearer` for API testing).
### /auth (auth.routes.js → auth.controller.js)
### 4.0 The authoritative route list
The prose tables below are **orientation for a human reader** and can drift. Two generated artifacts
are authoritative, and they answer different questions:
| Artifact | Source of truth for | Generated by |
|---|---|---|
| `server/routes.manifest.json` — mirrored as [api-route-inventory.json](./api-route-inventory.json) | **What URLs exist.** 215 public routes + 2 on the internal listener, sorted, method + path only. | `npm run routes:manifest`, by walking the live Express stack |
| `server/swagger/swagger-output.json` — served at `/api/docs` | **What each route means.** Parameters, bodies, response codes, security. | `npm run swagger`, from `#swagger.*` annotations |
The split is deliberate: Swagger is annotation-derived, so an unannotated route is invisible in it and
it churns whenever a description is reworded — it documents *intent*. The manifest is introspection-
derived and records *reality*, which is why it, not Swagger, is the thing PR checks freeze
(`npm run routes:manifest -- --check`).
Both artifacts are emitted with **sorted** keys, so a diff in either is proportional to the change
rather than to how the routers happen to be traversed. `swagger.js` additionally strips trailing
slashes from generated path keys — see *Regenerating the spec* in the website README for why the
domain split makes that necessary.
Scope: the manifest keeps `/api/**` and `/.well-known/**` from the public app plus everything on the
internal listener. The SPA catch-all, `/uploads` and `/brand` are filesystem-conditional static
mounts — not API contract, and including them would make the output depend on whether CI had built
the client.
A third generated file, `server/routes.guards.json`, is a **review aid and not a contract**: per route,
the middleware handler count plus the *named* middleware on its mount chain. It exists because a
router-level `router.use(noindex, isLoggedIn, staffOnly)` gate never appears in an individual route's
own stack, so a capability router extracted without re-applying the gate would otherwise publish
authenticated endpoints silently. Names are a hint only — `requireRole(...)` returns an anonymous
arrow and cannot be observed — but a *missing* `requireAuth` is unambiguous, and the server test suite
asserts every `/admin/**` and `/player/**` route still carries it.
### /auth (auth/index.js → the capability routers in §2)
No group gate — `/auth` is where an anonymous caller becomes authenticated. The authenticated parts
gate themselves: `me.routes.js` and `notifications.routes.js` each apply `noindex, requireAuth` at
their own router level, and `/sso/:provider/link` carries `requireAuth` per route.
| Method | Path | Auth | Body | Purpose |
|---|---|---|---|---|
| POST | `/login` | — (rate-limited) | `{username,password}` | verify, set cookie, log `auth.login`, update `last_login_at` |
| POST | `/logout` | cookie | — | clear cookie |
| GET | `/me` | cookie | — | current user (no hash) or 401 — client bootstraps auth state |
| POST | `/login` | — (rate-limited) | `{username,password}` | verify, set cookie, log `auth.login`, update `last_login_at`. If the account has TOTP **and this browser is a trusted device** (a valid `rg_trust` cookie bound to the user), the TOTP step is **skipped** and a session is issued directly (logs `auth.login.trusted_device`). Otherwise a 2FA account returns `{totpRequired, challenge}`. |
| POST | `/login/totp` | — (rate-limited) | `{challenge, code? \| recoveryCode?, trustDevice?, deviceName?}` | complete 2FA with a TOTP **or** single-use recovery code. `trustDevice` sets the `rg_trust` cookie so future logins skip TOTP; at the device cap the session is still issued and the body carries `{trustLimitReached, devices}`. |
| POST | `/logout` | cookie | — | clear cookie (the `rg_trust` trust cookie deliberately **survives** logout) |
| GET | `/me` | cookie / bearer | — | current user (no hash) or 401 — client bootstraps auth state |
| POST | `/password/forgot` | — (rate-limited) | `{email}` | email a single-use, ~1h reset link to **every active account** on the address; **always** returns the same generic 200 (no account enumeration). Email is non-unique, so several accounts may each get a link naming their username. Logs `account.password.reset.request`. |
| GET | `/password/reset/:token` | — | — | validate a link → `{username}` for the form, else 404 (never distinguishes expired/used/never-existed) |
| POST | `/password/reset/:token` | — (rate-limited) | `{password}` | consume the single-use link, rotate the hash, and revoke **all** sessions (web cutoff + mobile refresh tokens). Does **not** sign the user in — they log in fresh (so a 2FA account still passes TOTP). Logs `account.password.reset.complete`. |
| GET | `/me/account` | cookie / bearer | — | full self account (`id, username, role, email, status, totp_enabled, has_password`) |
| PATCH | `/me/account/username` | cookie / bearer (rate-limited) | `{username}` | change own username; re-issues the caller's session |
| PATCH | `/me/account/password` | cookie / bearer (rate-limited) | `{newPassword, currentPassword?}` | change/set own password (current required unless the account has none); revokes other sessions, keeps the caller's |
| POST | `/me/account/totp/setup` · `…/enable` · `…/disable` | cookie / bearer | `{code}` on enable/disable | self 2FA enrollment (disable needs a valid current code, not a password). **enable** returns the one-time `recoveryCodes`; **disable** clears the user's trusted devices + recovery codes |
| GET | `/me/account/identities` · DELETE `…/:provider` | cookie / bearer | — | list / unlink own SSO identities |
| GET | `/me/trusted-devices` | cookie / bearer | — | list own active trusted devices (never tokens) |
| POST | `/me/trusted-devices` | cookie / bearer (rate-limited) | `{deviceName?}` | trust the current device; web gets an httpOnly `rg_trust` cookie, native gets `{trustToken}`. **409 `{error:'trusted_device_limit', devices}`** at the cap |
| DELETE | `/me/trusted-devices` · `…/:id` | cookie / bearer | — | untrust all / one (ownership-scoped) |
| GET | `/me/account/recovery-codes/status` | cookie / bearer | — | remaining unused code count (never the codes) |
| POST | `/me/account/recovery-codes/generate` | cookie / bearer (rate-limited, **password step-up**) | `{currentPassword?}` | regenerate the one-time recovery codes (returned once); refused when 2FA is off |
| POST | `/me/devices` | cookie / bearer | `{endpoint, transport?, platform?}` | register a push endpoint; **rejects a disallowed endpoint 400** (SSRF guard). Idempotent per (user, endpoint) |
| GET | `/me/devices` · DELETE `…/:id` | cookie / bearer | — | list / unregister own push devices |
| GET | `/me/notifications/streams` | cookie / bearer | — | the subscribable catalog (`personal`/`requiresLinkedAccount` flags) |
| GET · PUT | `/me/notifications/subscriptions` | cookie / bearer | `{streams:[id]}` on PUT | get / replace own opted-in streams (unknown ids dropped) |
No public `register`. First admin is bootstrapped by `seed.js` from env (see §6). Further
admins are created under `/admin/users`.
**Role-agnostic self-service (`/auth/me/*`).** The canonical "me" surface for **every** authenticated
role. It reuses the exact `account.controller` handlers as `/player/account/*` and `/admin/account/*`
(no logic duplication) behind `requireAuth` **only** — any active account, never a specific role. This
lets a client (the Android app) manage its own account through one surface without ever touching
`/admin` (docs/android/PLAN.md §6.4). The older `/player/account/*` + `/admin/account/*` routes stay
for web back-compat.
**The `/player/*` group is self-service, not player-only.** Staff are a **superset** of players — every
player ability plus their staff tools on top — so the whole group (`account.router.js`,
`shard.router.js`, `appeals.router.js`, mounted by `player/index.js`) sits behind the shared
`noindex, requireAuth` gate **only**, never `requireRole('player')`. Every handler is self-scoped to the caller by `req.user.id`, so an admin/editor/
moderator using it sees only their **own** linked accounts and characters (with the pre-existing
`isAdmin` bypass still letting a genuine admin read *any* character). Staff also reach the identical
self-scoped handlers under `/admin/shard/*` (same controller) for the web admin surface; the two are
interchangeable. This is why a staff account with linked game characters gets its "My characters" and
personal notification streams on the mobile client — the group no longer 403s a non-`player` role.
**Password reset.** Uses the same audited pattern as `user_invites`: an opaque 32-byte token
whose **sha256 hash only** is stored in `password_resets`, single-use and short-lived (~1h). It
also serves SSO-only accounts (null `password_hash`) as their "set an initial password" path. The
reset link points at the web front end (`/account/reset/:token`); the Android app hands off here
rather than shipping its own reset screen (docs/android/PLAN.md §4.2). First admin is bootstrapped
by `seed.js` from env (see §6); further staff are created under `/admin/users` or via email invites.
**Push notifications (M7, opt-in).** The app subscribes per stream (`/auth/me/notifications/*`) and
registers device endpoints (`/auth/me/devices`); nothing is pushed unless subscribed. Delivery is a
**content-free tickle**`{ stream, ref }`, no sensitive data — POSTed to each subscribed device's
self-hosted **ntfy** endpoint (`utils/pushDispatch`); the app wakes and pulls the real, ownership-
checked content over the authenticated API. Two producers fan out through the one publisher: the shard
ingest dispatcher (`utils/shardIngest`, beside the SSE broadcast) for shard-derived streams, and the
create/publish-post path for `news.post`. The stream catalog + event→stream mapping is
`config/notificationStreams.js`. Security invariants:
- **Same public/admin split as the SSE feed.** Public streams are drawn *only* from the SSE
`PUBLIC_KINDS` allowlist; a sensitive kind (audit/cheat/IP/login-attempt) can never produce a public
push.
- **Personal streams are owner-keyed.** `vendor.sale` / `house.idoc` / `account.login` are delivered
only to the *owning* user's devices, resolved via `shardLinks` (the same ownership check as
`/player/shard/*`).
- **SSRF guard.** A device `endpoint` is a client-supplied URL the server POSTs to, so registration and
every publish validate it is HTTPS, non-private/loopback, and (when configured) on the shard's ntfy
allow-set (`NTFY_BASE_URL` / `NTFY_ALLOWED_ORIGINS`).
- ntfy is treated as an **untrusted relay** — no per-user accounts, unguessable topics; an optional
`NTFY_PUBLISH_TOKEN` hardens backend→ntfy publishes but is not required. See docs/android/PLAN.md §11.
### Mobile SSO Authorization Bridge (`/auth/mobile/sso/*`, M9)
Native "Sign in with Google/Discord" for the Android app **without shipping any OAuth secret in the
app**. The website stays the identity authority: each shard owner's provider credentials live in
`auth_providers` (encrypted at rest) and are only ever used server-side. The bridge is a **new
consumer of the existing SSO + mobile-bearer machinery**, not a parallel auth path — it reuses the
`/auth/sso/:provider/*` redirect flow, the link-only + opt-in-provisioning policy, the TOTP gate, and
issues the **same** token pair as `/auth/mobile/login`.
The TOTP gate it reuses includes the **trusted-device skip** (see
`TRUSTED_DEVICES_MFA.md` §6). Because the app opens this flow in a Custom Tab, which shares the
system browser's cookie jar, the `rg_trust` cookie set on the TOTP form is presented back on the next
app sign-in — so "don't ask me again" works for native SSO without the app injecting a header into a
tab it does not control, and without a trust token ever appearing in a start URL.
| Method | Path | Auth | Body / Query | Purpose |
|---|---|---|---|---|
| GET | `/auth/providers` | — | — | **reused** discovery; the app renders provider buttons from this (never exposes secrets) |
| GET | `/auth/mobile/sso/start` | — (rate-limited per-IP + per-provider) | `?provider&code_challenge&state&redirect_uri` | validate provider enabled + `redirect_uri` **exact-match** allowlist; insert a `mobile_auth_sessions` row; create the existing `sso_tx` tagged `mode:'mobile'` carrying `session_id`; **302 to the IdP** (existing authorize URL) |
| GET | `/auth/sso/:provider/callback` | — (signed `sso_tx`) | `?code&state` | **existing** endpoint; a new branch when `tx.mode==='mobile'`: resolve the account (same policy as web login incl. TOTP), mint a single-use hashed authorization code into `mobile_auth_codes`, mark the session `completed`, and **302 to `redirect_uri?code=…&state=…`** (the app's original `state`) — **no cookie is set** |
| POST | `/auth/mobile/sso/exchange` | — (rate-limited per-IP) | `{code, code_verifier}` | validate the code exists / unexpired / unused (mark used) and `sha256(code_verifier)` matches the stored challenge → issue the existing mobile access + refresh pair (`createMobileSession`) → `{accessToken, refreshToken, expiresIn, user}`. When the session carries `trust_device`, also mint a `platform:'mobile'` trusted device and add `trustToken` — minted here, on an authenticated app→server call, so it never travels in the deep link. Best-effort: at the trusted-device cap the response simply omits it rather than failing the sign-in |
| POST | `/auth/mobile/refresh` | — | `{refreshToken}` | **reused** unchanged — rotate the pair |
| POST | `/auth/mobile/logout` | bearer | `{refreshToken?, all?}` | **reused** unchanged — revoke this (or all) refresh token(s) |
| GET | `/auth/me/sessions` · DELETE `…/:id` | cookie / bearer | — | list / revoke own **mobile sessions** (device_name, last_used_at, created_at) — the "Active Devices" surface (distinct from `/auth/me/devices`, which is push endpoints) |
**Two PKCE layers (do not conflate).**
- *Layer A (existing):* website ↔ IdP. The `code_verifier` is generated at `/start`, kept only in the
httpOnly `sso_tx` cookie, sent to the IdP token endpoint at the callback. Unchanged.
- *Layer B (new):* app ↔ website. The **app** generates `code_verifier`/`code_challenge`; the
challenge is stored in `mobile_auth_sessions` at `/start`; the verifier is presented at `/exchange`.
This is what stops an intercepted callback code from being redeemed by anyone but the real app.
**State / CSRF.** The app-generated `state` is stored at `/start`, echoed on the callback redirect,
and **verified by the app** before it calls `/exchange` — a CSRF guard independent of both PKCE
layers (a different app instance triggering `/start` cannot complete someone else's flow).
**Redirect-URI allowlist.** `/start` and the callback validate `redirect_uri` by **exact match**
against a configured allowlist (`MOBILE_AUTH_REDIRECT_URIS`, default the one fixed application-owned
callback `runicgateway://auth/callback`) — **never prefix match** (prefix matching on custom schemes
is a known open-redirect vector). Tokens are **never** placed in the callback URL — only the
short-lived authorization code.
*App Links (implemented).* When the admin toggle `mobile_app_links_enabled` is **on**, `/start` also
accepts the self-origin HTTPS callback `https://<request-host>/mobile/callback` — one *additive*
exact-match entry, derived from the request/`APP_BASE_URL` and never from client input; the
custom-scheme allowlist is never narrowed. The shard then auto-serves `GET
/.well-known/assetlinks.json` (fixed package `com.runicgateway.app` + `MOBILE_APP_CERT_SHA256`
fingerprints; 404 when the toggle is off or no fingerprint is configured), and
`settings.getPublic()` advertises `mobileAppLinks: <bool>`. These two things — one static file route
and one more allowlist entry — are the *entire* server surface App Links require. See
docs/android/APP_LINKS.md.
**TOTP through the bridge.** A 2FA account keeps full parity: the callback stages the existing
pending-TOTP cookie (now also carrying the bridge `session_id`) and bounces the Custom Tab through the
web TOTP form; on a correct code the completion mints the authorization code and deep-links back to
the app — it never mints a session cookie for a mobile flow.
**Revocation latency (documented tradeoff).** Revoking a refresh token (device revoke / logout) stops
future renewals but does **not** invalidate an already-issued access token until it expires — up to
the access-token lifetime (`MOBILE_ACCESS_TTL`, default 15 min) of continued access. This is an
accepted tradeoff given the short lifetime. If instant revocation is ever required, add an
access-token (jti) blocklist check on the `requireAuth` path — the same `revoked_sessions` mechanism
web sessions already use.
**Authorization code.** Cryptographically random, ≥128 bits, stored **hash-only**, single-use, short
expiry (~5 min); `/exchange` is rate-limited per-IP. The bridge tables self-prune (§3).
### /public (public/index.js → the capability routers in §2) — all GET except `/contact`, no auth
**No group gate, deliberately.** This surface is anonymous by design: the SPA renders it logged-out,
the Discord bot reads it with no credentials, and the Android `ShardStreamClient` consumes
`/public/shard/stream` without an `Authorization` header. Content visibility during maintenance comes
from the per-route **siteMode** middleware (§5), never from an auth gate.
### /public (public.routes.js → public.controller.js) — all GET, no auth
| Method | Path | Notes |
|---|---|---|
| GET | `/settings` | whitelisted public keys only (mode, maintenance_message, status_message, homepage_teaser, contact_email, site_title) |
| GET | `/status` | status message + current mode |
| GET | `/settings` | whitelisted public keys, derived `registration`/`gameAccountSignup` flags, the per-shard **`brand`** block (name, `accent` color, logo/hero/favicon) a client themes itself from — one image runs as any shard, asset fields may be site-relative paths (resolve against the base URL) — and a **`push`** block `{ ntfyUrl }` (M7): the client-facing ntfy relay URL the app's embedded distributor registers its device topic against, from `NTFY_PUBLIC_URL` / first `NTFY_ALLOWED_ORIGINS` (never the internal `NTFY_BASE_URL`); `null` when push isn't configured for the shard. |
| GET | `/status` | status message + current mode, **plus a `version` block** (`{ service:'runic-gateway', api, server }`) so a client first-run probe recognizes the backend and can run a version-mismatch guard |
| GET | `/version` | lightweight, **DB-free** backend identity/version (`{ service, api, server }`) — the canonical target for the version guard and a cheap liveness check |
| GET | `/posts/:category` | published only; `category` ∈ news\|five-on-friday\|newsletter\|screenshots |
| GET | `/posts/:category/:idOrSlug` | single published post |
| GET | `/wiki` | list of pages (slug + title) |
| GET | `/wiki/:slug` | single page |
| POST | `/contact` | (rate-limited) send mail via SMTP; if unconfigured, respond `{fallback:"mailto", email}` |
| GET | `/shard/ruleset` | the shard's own published ruleset (Protocol 3.0 `world.ruleset`): expansion, which optional systems are on, skill/stat caps, account and house limits, champion scroll rules, the save/restart schedule. Served from `shard_ruleset`, so it renders while the shard is down; live via `world.ruleset` on `/shard/stream`. Behind `requireFeature('ruleset')`. **`null`** means the shard has never published one — a real answer, distinct from a published ruleset. `caps.skill` / `caps.totalSkill` are in **tenths** (1000 = 100.0). |
| GET | `/shard/points` | every points/loyalty leaderboard the shard publishes (Protocol 3.0 `points.board`) — Queen's Loyalty, Void Pool, the nine city loyalties, Clean Up Britannia, … Served from `shard_points_boards`, so it renders while the shard is down; live via `points.board` on `/shard/stream`. Behind `requireFeature('leaderboards')`, ordered by display name. **`maxPoints: 0` means uncapped** (the common case), and `nameString` is usually `null` with `nameNumber` holding a cliloc — resolve client-side or humanise the `system` key. |
| GET | `/shard/points/:system` | one board by the shard's `PointsType` name (e.g. `QueensLoyalty`); `:system` must match `/^[A-Za-z][A-Za-z0-9_]{0,47}$/` or **400** before any query runs. **404** = the shard has never published that system, which is distinct from a published board nobody has scored in yet (**200** with an empty `top`). |
| GET | `/shard/market?q=&minPrice=&maxPrice=&itemId=&map=&region=&sort=&limit=&offset=` | search the player-vendor marketplace (Protocol 3.0 `vendor.listing`). Returns **listings**, not vendors — "who sells X and for how much" is the question, and a vendor-shaped result would make every caller flatten the shops back out. Served from `shard_vendors` + `shard_vendor_items`, so it renders while the shard is down. Behind `requireFeature('market')` **and rate-limited** — the first genuinely expensive public read on the site (a `LIKE` scan plus a `COUNT` over what is typically the largest `shard_*` table, reachable with no session). `sort ∈ {price_asc, price_desc, recent}`. `q` matches the resolved display name **or** the item's literal name, with `%`/`_` escaped: they are `LIKE` metacharacters, not SQL ones, so parameterization alone would let `?q=%` match every listing on the shard. Every response repeats `staleAt` (the oldest vendor row) because the shard sweeps round-robin — a banner that ages with the results it labels, not one fetched once. |
| GET | `/shard/market/meta` | index size, staleness (`staleAt`/`freshAt`) and which facets and regions actually hold vendors, so a client builds its filters without running a search it will discard. |
| GET | `/shard/market/vendors/:serial` | one shop and its listings; `:serial` must match `/^0x[0-9A-Fa-f]{1,16}$/` or **400** before any query runs. **404** = a serial the index has never seen, which also covers a vendor since dismissed or hidden — to an anonymous caller those are the same answer, and distinguishing them would leak that a hidden vendor exists. `truncated` (with `total` exceeding `count`) means the shop holds more than the shard publishes per frame. |
| GET | `/shard/features` | the shard features **this caller** may reach plus the audience rung they resolved to (§6.5), so a client hides nav it can't follow. Reports only what the caller can see — the list itself never discloses a gated feature. Consumed by the SPA header and (pending) the Android nav. |
| GET | `/atlas/creatures?q=&facet=&limit=&offset=` | the bestiary, most numerous first, with an unpaginated `total`. Static content parsed from the shard's ServUO tree — **not** sidecar-backed, which is why the atlas sits outside `/shard`, and unlike `/shard/*` it **is** site-mode gated. Behind `requireFeature('atlas')`. `?facet=` is matched exactly and never validated against a list (no facet name exists in the code); the filter is an `EXISTS` over the points rather than a JSON path or `JSON_SEARCH` built from caller input, whose `%`/`_` wildcards would make `?facet=%` match everything. |
| GET | `/atlas/creatures/:slug` | one creature: `places` (the point-in-rect aggregate — "lizardman → Shrines, Isamu-Jima, Yew"), `spawners` (the bounded raw list, with `spawnersTruncated`), `alsoHere`. **`points` is a COUNT and `spawners` is the LIST** — named apart so one key never means a number on one route and an array on another. `minDelay`/`maxDelay` are in **seconds**, normalised at parse time from the source's per-record minutes-or-seconds. 404 = no such creature in this atlas. |
| GET | `/atlas/regions?facet=&q=` | named regions and the rectangles that placed each spawner |
| GET | `/atlas/landmarks?facet=&q=` | points of interest, labelled by `group` ("Covetous", not "Level 1") |
| GET | `/atlas/champions?facet=` | the **configured** altar roster. Not `/shard/champs`, which is the live board. |
| GET | `/atlas/meta` | facets, counts and when the atlas was parsed. Game-world facts only — the ServUO path, source hashes and any pending refresh are operator detail and live on the admin route. |
Public content GETs pass through the **siteMode** gate (§5).
### /admin (admin.routes.js → admin.controller.js) — all behind `isLoggedIn` + `noindex`
### /admin (admin/index.js → the capability routers in §2) — all behind `isLoggedIn` + `noindex` + `staffOnly`
`admin/index.js` applies the shared gate and mounts each capability router at the prefix it owns;
`users`, `invites`, `auth/providers` and `bot-activity` add `adminOnly` on top, and `moderation` adds
`modAccess` (admin + moderator, so editors are excluded). The content capabilities — `posts`,
`uploads`, `wiki`, `pages` — add nothing: managing content is the editor tier's job, so `staffOnly` is
the whole gate. The ops/config capabilities — `uo-link`, `email`, `discord-bot`, `settings`, and
`PUT /site-mode` — are `adminOnly`; `shard` is the one mixed prefix, where self-service account
linking carries no extra gate and the in-game staff operations carry `modAccess`. There is no residual
file: every admin route is declared in a capability router.
`GET`/`PUT /admin/shard/visibility` are the third tier on that mixed prefix: **`adminOnly`**, because
they decide what *anonymous* visitors can see (§6.5). They sit above `modAccess` deliberately — a
moderator can ban a player but cannot decide what the public internet reads.
`GET /dashboard` and `PUT /site-mode` are the one place where a **single screen spans two tiers**: the
dashboard is staff-wide, but the site-mode toggle on it is `adminOnly`. The client must therefore gate
that control on its own (`Dashboard.jsx` renders it only for `role === 'admin'`) rather than relying on
the route gate that admitted them to the page — the same rule the sidebar follows, so a non-admin is
never shown a control that would 403. The URLs below are unaffected by which
file a route sits in — that is the property the route manifest freezes.
| Method | Path | Purpose |
|---|---|---|
| GET | `/dashboard` | current mode, last change time + who, content counts, recent activity |
@@ -189,6 +839,16 @@ Public content GETs pass through the **siteMode** gate (§5).
| GET | `/settings` · PUT `/settings` | read all / update `{key:value,...}` |
| GET | `/activity?limit=&offset=` | paginated activity log |
| GET | `/users` · POST `/users` · PUT `/users/:id` · DELETE `/users/:id` | user mgmt (can't delete self / last admin; password hashed on write) |
| GET | `/users/:id/trusted-devices` | list a user's active trusted devices (never tokens) |
| DELETE | `/users/:id/trusted-devices` · `…/:deviceId` | revoke all / one of a user's trusted devices (logs `admin.trusted_device.revoke[_all]`) |
| POST | `/users/:id/mfa/reset` | recover a locked-out user: disable TOTP + revoke all trusted devices + clear recovery codes (logs `admin.user.totp.reset`) |
| GET | `/shard/atlas` | spawn-atlas status (`adminOnly`): the ServUO path, whether the tree is readable, whether it has drifted from what is loaded, counts, facets, and any refresh staged for review. The public `/atlas/meta` reports the game world only; the filesystem detail is here. |
| POST | `/shard/atlas/import` | re-import without restarting; `{force}` ignores the hash gate. **An unreadable tree answers 200 with `status:"unavailable"`, not 500**`refresh()` reports outcomes rather than throwing (the boot path must never be blocked by a bad tree) and that contract is preserved at the API. |
| POST | `/shard/atlas/approve` · `/shard/atlas/reject` | answer a refresh staged because it would REMOVE a facet. Approving **re-parses** the tree, so what lands matches it at approval time; rejecting is remembered against those source hashes so it does not re-prompt every restart. 404 when nothing is staged. |
| PUT | `/shard/atlas/path` | point the atlas at a different tree (persisted as `spawn_atlas_servuo_path`, which wins over `SERVUO_PATH`). Blank clears it. Deliberately **does not import** — moving the mount and reloading the world are separate decisions — and returns fresh status so the panel can offer the import next. |
| GET | `/shard/clilocs` | cliloc-table status (`adminOnly`): every source found now (base first, then `custom/` overlays in merge order), what each contributed at the last import, readability, drift across the set, the entry count, and `missingSources`. `configured:false` is a supported state — item names then render as ids. No public counterpart: the table is never served *as* a table. |
| POST | `/shard/clilocs/import` | reload after a client patch or an overlay edit; `{force}` ignores the hash gate, `{approve}` accepts a **vanished** source (refused by default — see the table notes above). **A missing path — or the likely mistake of pointing at the client's own COMPRESSED `Cliloc.enu` — answers 200 with `status:"unavailable"` and a `code`, not 500.** `COMPRESSED` is called out by name: a 500 would say only "something broke", and the operator needs to be told which file to convert. |
| PUT | `/shard/clilocs/path` | point the site at a different cliloc base file or directory (persisted as `cliloc_client_path`, which wins over `UO_CLIENT_PATH`). Overlays are read from `custom/` beside it either way. Blank clears it. Deliberately **does not import**, same reasoning as the atlas path. |
Every admin write logs to `activity_log`.
@@ -219,17 +879,128 @@ who"; `activity_log` provides the history feed.
## 6. Auth & security
- **JWT** signed with `JWT_SECRET`, `expiresIn=JWT_EXPIRES_IN` (default `1d`); payload `{id,username,role}`.
- **Cookie**: `httpOnly`, `sameSite=Lax`, `path=/`, and **`secure` decided per-request** (`COOKIE_SECURE=auto``secure: req.secure`). This is the key to dual access: the cookie is `Secure` when reached through Pangolin (HTTPS, `X-Forwarded-Proto: https`) but **not** `Secure` when reached directly over the LAN IP on plain HTTP — so login works in both. `COOKIE_SECURE=true|false` can force it. Requires `trust proxy` (below). `localhost:5173` (Vite) and `localhost:3000` are same-site, so the cookie flows in dev too.
- **Cookie**: `httpOnly`, `sameSite=Lax`, `path=/`, and **`secure` decided per-request** (`COOKIE_SECURE=auto``secure: req.secure`).
- **Trusted-device MFA.** A second, separate httpOnly cookie (`rg_trust`, default 30d) — opaque, sha256-hashed server-side in `trusted_devices` — lets a browser/app **skip the TOTP step** (never the password) on future logins. It is a server-side, per-row-revocable record (never a JWT claim), so the stateless session JWT is unchanged and trust stays revocable. It only ever gates the **second factor**; it deliberately outlives logout, and is cleared on untrust / password change / password reset / TOTP disable. **Recovery codes** (bcrypt, single-use) are the 2FA-lockout fallback. All admin trusted-device/MFA actions and the self actions (`auth.login.trusted_device`, `account.trusted_device.*`, `account.recovery_code*`, `admin.trusted_device.*`, `admin.user.totp.reset`) are audit-logged. See `docs/website/TRUSTED_DEVICES_MFA.md`. This is the key to dual access: the cookie is `Secure` when reached through Pangolin (HTTPS, `X-Forwarded-Proto: https`) but **not** `Secure` when reached directly over the LAN IP on plain HTTP — so login works in both. `COOKIE_SECURE=true|false` can force it. Requires `trust proxy` (below). `localhost:5173` (Vite) and `localhost:3000` are same-site, so the cookie flows in dev too.
- **bcrypt** hashing (cost 10+); plaintext passwords never stored, logged, or returned.
- **Rate limiting** (`express-rate-limit`) on `/auth/login` and `/public/contact`.
- **Rate limiting** (`express-rate-limit`) on `/auth/login`, `/public/contact`, and — the only limited
*read*`/public/shard/market` and `/public/shard/market/vendors/:serial` (60/min/IP). Every other
public read is an indexed lookup of bounded size; the marketplace search is a `LIKE` scan plus a
`COUNT` over the largest `shard_*` table, anonymous by default, so it is the one public GET that is
worth money to serve.
- **Validation** (`express-validator`) on all writes; centralized error handler.
- **helmet** with a CSP suited to the SPA (self + inline styles as needed; image sources for uploads/hero).
- **helmet** with a Content-Security-Policy tuned for the built React SPA. The policies now live in
**`server/src/config/csp.js`** (`app.js` only wires them up):
`default-src 'self'`; `script-src 'self'` (the Vite build emits only external module chunks — the
inline module-preload polyfill is disabled in `client/vite.config.js` to keep this valid);
`style-src 'self' 'unsafe-inline' https://fonts.googleapis.com` (React's pervasive inline
`style={{…}}` attributes can't be nonce'd, plus the Google Fonts stylesheet); `font-src 'self'
https://fonts.gstatic.com` (Cinzel); `img-src 'self' data: https:` (same-origin uploads, plus
external https images embedded in wiki/news bodies or `BRAND_*` logo/hero/favicon); `connect-src
'self'` (REST + SSE are same-origin); `frame-ancestors 'self'`; `object-src 'none'`; `base-uri
'self'`; `form-action 'self'` (blocks an injected `<form action="https://evil">` from POSTing
credentials off-origin — an exfil path `connect-src` does not cover; it was always emitted via
helmet's `useDefaults` and is now pinned explicitly so it cannot vanish under a helmet upgrade).
`upgrade-insecure-requests` is intentionally **not** set (TLS terminates at the proxy, there
are no mixed-content subresources, and it would break a local `npm start` over plain http). The
`/api/docs` Swagger UI route gets a **looser** policy that additionally allows inline script/style,
since swagger-ui-express injects an inline bootstrap. helmet also strips `X-Powered-By`; the two
internal-only listeners (`internalApp.js`, `bot/src/app.js`) disable it explicitly too.
- **A second, tightened policy ships alongside on `Content-Security-Policy-Report-Only`** for one
release before it replaces the enforced one (`docs/website/API_V2_PLAN.md` § Phase 1). It is derived
from the enforced policy so the two cannot drift, and differs by exactly one directive:
`frame-ancestors 'self'`**`'none'`**. Serving both headers at once means the live policy keeps
protecting users while anything the tightened version would break arrives as a report rather than as
a broken page — and for `frame-ancestors` specifically, a report from the browser of whoever framed
the site is the only way to learn that something does.
- **`POST /api/csp-report`** is the same-origin violation sink that `report-to` / `report-uri` point at
(`report-to` additionally requires the `Reporting-Endpoints` response header, which is set alongside).
Same-origin on purpose: reports describe attacks against this site and are not handed to a
third-party collector. It parses **both** wire formats (`application/csp-report` from Firefox/Safari,
`application/reports+json` from Chrome's Reporting API — handling one drops half the browsers),
writes to the `csp` log tag and **stores nothing**. Necessarily unauthenticated (browsers send
reports with no session), so it is bounded on every axis: 16 KB body cap, per-IP rate limit, fixed
field allowlist, every logged field truncated, and **always 204 — even for malformed input**, since a
4xx would make the global error handler log the attacker-supplied body and turn an open endpoint into
a log-flood primitive. Mounted outside `/api/v1` next to `/api/health`: the browser learns the path
from the policy header, never from a client build, so it is not part of the versioned client
contract.
- **Admin not indexed**: `X-Robots-Tag: noindex, nofollow` on `/api/v1/admin` and the admin SPA routes; `robots.txt` disallows `/admin`.
- **No directory browsing** (express.static doesn't list; no `serve-index`).
- **No hardcoded credentials**: first admin via `seed.js` reading `ADMIN_USERNAME`/`ADMIN_PASSWORD` from env (created only if no users exist); `.env` git-ignored, `.env.example` committed.
- **`app.set('trust proxy', 1)`** so secure cookies, `req.ip`, and rate-limiting work behind Pangolin.
- **CORS**: same-origin in prod (SPA served by Express). Dev only: allow `CLIENT_ORIGIN` (Vite, `http://localhost:5173`) with `credentials:true`.
### 6.5 Shard visibility — the audience boundary (Protocol 3.0)
Every shard-derived surface is gated by an **admin-configurable, per-feature and per-field** audience
setting. This **replaces** the static `PUBLIC_KINDS` allowlist that used to be the whole boundary.
Policy lives in `utils/shardVisibility.js`; rows live in `shard_feature_visibility`; the admin surface
is `GET`/`PUT /admin/shard/visibility` (`adminOnly`). Admin-facing guide:
[`SHARD_VISIBILITY.md`](SHARD_VISIBILITY.md). Design: [`../link/v3.md`](../link/v3.md) §3.
**The ladder.** `anonymous < logged_in < player < staff < admin`, each rung implying the ones below.
`viewerLevel(req)` resolves it: no session ⇒ `anonymous`; authenticated ⇒ `logged_in`; authenticated
with a linked game account ⇒ `player`; moderator ⇒ `staff`; admin ⇒ `admin`. **Staff satisfy the
`player` rung without a linked account** (consistent with `/player/*` being role-agnostic).
**`editor` gets no shard privilege** — it is a content role, and mapping it to `staff` would silently
widen what editors see.
**Two invariants that are code, not configuration.** Both are enforced server-side and both reject
rather than silently ignore:
1. **`acct` and `webId` are admin-only, always.** They are not exposed as configurable fields, and a
stored row attempting to loosen them is discarded on read as well as rejected on write. A character
name is visible in game; the account behind it and the website user it links to are not.
The lock is on the field's **meaning, not one spelling**: `isLockedField(key)` matches a key that
*is* or *ends in* `acct`/`webId`, case-insensitively, so the flattened forms the read models emit
(`shapeHouse``ownerAcct`, `shapeGuild``leaderWebId`) are covered too. An exact-key check was
the original implementation and it let `GET /public/shard/idoc` serve `ownerAcct` anonymously.
2. **A kind absent from `KIND_FEATURE` is never broadcast below `admin`.** Fail closed. This is what
keeps the kind map a security boundary rather than a convenience filter, and it means a shard that
starts emitting an unknown event degrades to staff-only, never to public.
**Fail-closed everywhere else too.** An unreadable visibility config withholds every public frame; a
DB failure falls back to the compiled defaults (pre-3.0 behavior), not to open; an unresolvable viewer
subscribes as `anonymous`. The ladder comparison uses **asymmetric** fallbacks by design — an unknown
*viewer* level floors to the bottom rung and an unknown *requirement* ceils to admin, so an
unrecognised value loses on both sides. (A single shared fallback cannot do that: whichever direction
it picks, it fails open on one side.)
**Three enforcement points, one config:**
| Where | Mechanism |
|---|---|
| Routes | `requireFeature(name)`**404** when the feature is disabled (don't leak that it exists), **403** when the caller is below its audience. `projectFeature` then strips out-of-rung fields from the body. |
| SSE (`utils/shardBroadcast.js`) | Per-connection filtering. A subscriber's rung is resolved **once at subscribe time and frozen** for that connection, so a long-lived stream can't gain privilege; each frame is then mapped kind→feature, gated, and field-projected per viewer. Two subscribers can legitimately receive different versions of one event, or one of them nothing. |
| Nav | `GET /public/shard/features` returns only what the caller may reach, so the SPA never renders a link that would 403. Presentation only. |
Config reads are cached ~5s, so admin changes take effect within seconds **including on already-open
streams**. `PUBLIC_KINDS` still exists and is still exported (`notificationStreams.js`) but is now
**derived** from the kind map rather than hand-maintained, so the two cannot drift.
**`PUBLIC_KINDS` is a module-load constant and must not be used to answer "may this caller read this
kind?"** — it is computed from the compiled *defaults*, so it cannot see an admin's changes. Use
`visibleKinds(level, config)`, which resolves against the live config. `/feed` uses it; it originally
used `PUBLIC_KINDS` and consequently kept serving `guild.join` to anonymous callers after an admin had
moved `guilds` to `staff`. `visibleKinds` deliberately ignores the `stream` flag: that governs SSE
fan-out only, so a feature whose live firehose ships off (market) stays readable from stored history.
**Every read path that returns shard data must call `projectFeature`.** The stored-history endpoints
are not exempt — `/feed` returns the same events the stream does, and returning them unprojected
reopens on the REST side exactly what the stream closes. Relatedly, `shardEvents.db.list` treats an
**empty** `kinds` array as "serve nothing", never "no filter"; the fall-through it used to take would
have turned a fully-gated config into a dump of the entire event log.
`projectFeature` walks **arrays and plain objects only**. A `Date`, `Buffer` or other class instance
is passed through as a value — rebuilding one key-by-key yields `{}`, which is the difference between
the pure-JSON wire frames and the DB-backed read models whose rows carry real `Date` columns.
**Defaults reproduce pre-3.0 behavior exactly**, so installing the framework is a no-op until an admin
changes something — with deliberate exceptions, which are the leaks it was written to close.
`/public/shard/guilds`, `/public/shard/governors` and `/public/shard/feed` previously returned the raw
stored payload, whose actors carry `acct` and `webId`; `/public/shard/idoc` returned the flattened
`ownerAcct`. All are now stripped for every caller below admin.
---
## 7. Email
@@ -248,7 +1019,11 @@ instead. Errors never leak credentials.
`utils/logger.js` — a small dependency-free logger with **two transports, console + file**,
and four levels (`error`/`warn`/`info`/`debug`). Each line is timestamped and tagged by
subsystem (`[server]`, `[http]`, `[db]`, `[auth]`, `[admin]`, `[ratelimit]`, …).
subsystem (`[server]`, `[http]`, `[db]`, `[auth]`, `[admin]`, `[ratelimit]`, `[csp]`, …).
> During the CSP report-only soak, `[csp]` is the tag to watch: a `csp violation` warn line with
> `directive: frame-ancestors` means something really does frame the site and the enforce PR would
> break it. Silence across one release is the green light to flip.
- **Console**: color on a TTY, plain in Docker; verbosity = `LOG_LEVEL` (default `info`).
- **File**: plain text appended to `LOG_DIR/LOG_FILE` (default `<server>/logs/app.log`,
@@ -270,7 +1045,15 @@ subsystem (`[server]`, `[http]`, `[db]`, `[auth]`, `[admin]`, `[ratelimit]`, …
- `app`: builds the Dockerfile (installs client+server, builds Vite, serves via Express),
`env_file: .env`, `DB_HOST=db`, `depends_on: db (healthy)`, volume `uploads:/app/uploads`,
`ports: "3000:3000"`**binds 0.0.0.0** (no `127.0.0.1:` prefix) so Pangolin reaches it.
- Volumes: `dbdata`, `uploads`.
- `ntfy` (M7): pinned upstream `binwiederhier/ntfy` image, declarative config only
(`./ntfy/server.yml` mounted `:ro` + `NTFY_BASE_URL`), volume `ntfydata:/var/lib/ntfy`,
**publishes `:80` on a host port** (`${NTFY_HOST_PORT:-2586}:80`, binds 0.0.0.0) so Pangolin — which
runs outside the compose network — can forward the notification subdomain to it, the same reason
`app` publishes `3000`. Both devices (SSE subscribe) and the backend publisher (POSTing tickles to
registered device endpoints) reach ntfy on that public origin. Anonymous read-write to unguessable
topics (no accounts to provision) — safe because pushes are content-free tickles. Bringing the stack
up provisions a working push relay with **zero interactive setup**.
- Volumes: `dbdata`, `uploads`, `ntfydata`.
Express listens on `0.0.0.0:${PORT||3000}`. Pangolin terminates TLS and proxies to `app`.
@@ -292,6 +1075,19 @@ ADMIN_USERNAME=
ADMIN_PASSWORD=
# Email: configured in Admin → Settings → Email (Gmail OAuth2), not via env
CLIENT_ORIGIN=http://localhost:5173
# Push (M7): the ntfy relay URL — also the backend's SSRF allow-set for device
# endpoints. NTFY_ALLOWED_ORIGINS / NTFY_PUBLISH_TOKEN are optional.
NTFY_BASE_URL=https://ntfy.example.com
# The client-facing ntfy URL surfaced to the app via /public/settings.push.ntfyUrl
# (the app registers its topic endpoint here). Defaults to the first
# NTFY_ALLOWED_ORIGINS entry; set explicitly when the public URL differs from the
# internal NTFY_BASE_URL. Without it (and without NTFY_ALLOWED_ORIGINS) the app
# shows push as unavailable for the shard.
NTFY_PUBLIC_URL=https://ntfy.example.com
NTFY_ALLOWED_ORIGINS=https://ntfy.example.com
# Host port the ntfy container publishes :80 on (default 2586); the reverse proxy
# forwards the notification subdomain to host:NTFY_HOST_PORT. Change on a conflict.
NTFY_HOST_PORT=2586
```
`.gitignore`: `node_modules/`, `.env`, `_reference/`, `client/dist/`, `uploads/`.

296
website/CLILOCS.md Normal file
View File

@@ -0,0 +1,296 @@
# Cliloc table (item and title names)
**Status:** Complete on `edge` — website [#115](https://gitea.whitlocktech.com/RunicGateway/website/pulls/115), docs [#70](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/70).
**Design:** [`docs/link/v3.md` §8.6](../link/v3.md) — Protocol 3.0, the dependency Part B/3 was sequenced behind.
A "cliloc" is UO's localization table: an integer id mapped to a display string.
**Items on the wire carry a `LabelNumber`, not a name.** The bridge has always
sent that number — `char.profile.equipment` has a `cliloc` field, reward titles
arrive as a cliloc number in string form, and every marketplace listing carries
one — but the site had no table to look it up in, 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.
## Why the operator has to convert the file
This is the awkward part, and it is not avoidable:
**Every current UO client ships its cliloc files compressed.** All eight
`Cliloc.*` files in a modern client (`chs`, `cht`, `deu`, `enu`, `esp`, `fra`,
`jpn`, `kor`) begin with a DWORD whose high byte is `0x8E` — the "Mythic"
compressed container. The plain layout this site parses is what those files
looked like *before* that change.
Decompressing it means an inverse-BWT coder with a frequency header — a few
hundred lines of bit-level work whose failure mode is plausible-looking garbage
rather than an error. The site has no business carrying that at runtime.
Two facts make the alternatives worse, not better:
- **ServUO cannot read it either.** Its bundled `Ultima.StringList` implements
only the plain layout, so on a modern client `VendorSearch.StringList` is null
and `VendorSearch.GetItemName` returns `item.Name` — usually nothing. The
shard cannot supply names on our behalf; the in-game Vendor Search gump has the
same gap.
- **Nothing client-derived may be committed.** UO's strings are EA's. The repo
ships no string table for the same reason it ships no artwork and no map
snapshot — see [`SPAWN_ATLAS.md`](SPAWN_ATLAS.md).
So the conversion happens **once, on the operator's machine, against their own
client**, and the site reads the result from a path it is given. A shard that
never does this is in a fully supported state: names render as ids, exactly as
they did before the table existed.
## Converting
Either format below is accepted; the site sniffs which one it was handed.
| Format | Fidelity | Notes |
|---|---|---|
| **Plain binary** (recommended) | Exact | 6-byte header, then `{int32 number, byte flag, uint16 length, UTF-8}` records |
| Delimited text | Loses leading/trailing whitespace | `number<TAB\|,\|;>text` per line; a header row, blank lines and `#` comments are ignored |
The whitespace caveat is real but cosmetic: ~1,300 of the 123,490 entries in a
stock `Cliloc.enu` are label prefixes like `"max = "` whose trailing space is
meaningful when the client concatenates a value onto them. Nothing on this site
concatenates, and every consumer passes through `displayText()`, which trims.
### Using the bundled tool
`server/tools/cliloc-export/` is a small .NET console app that drives
[UOFiddler](https://github.com/polserver/UOFiddler)'s `Ultima.dll` — the
decompressor that already exists and is already maintained — and writes the plain
format. It loads that DLL **reflectively** so it compiles against any SDK, and it
writes the records by hand because UOFiddler's own `SaveStringList` *re-compresses*
on save (its purpose is round-tripping a file back into the client, so its output
is byte-identical to its input — a trap worth knowing about).
```bash
cd website/server/tools/cliloc-export
dotnet build -c Release
# binary (recommended)
dotnet run -- "<UOFiddler>/Ultima.dll" "<UO client>/Cliloc.enu" /srv/uo-data/clilocs.plain
# or tab-delimited
dotnet run -- "<UOFiddler>/Ultima.dll" "<UO client>/Cliloc.enu" /srv/uo-data/clilocs.tsv --tsv
```
A UOFiddler GUI export works equally well — anything producing one of the two
shapes above is fine.
## Shard-added and shard-edited items
**Shards edit items and add new ones**, and those carry cliloc ids no stock
client table has. The table is therefore built from a **set** of sources, all
re-read on every boot and hash-gated together — the same shape as the spawn
atlas, which reads `Regions.xml` + `Locations/*.xml` + `Spawns/*.xml` +
`ChampionSpawns.xml` and merges them:
```
<cliloc path>/
clilocs.plain ← base: the converted client table
custom/
01-uomysticmoon.tsv ← overlays: shard additions and overrides
02-events.tsv
```
Overlays use the same delimited-text format, are read in **sorted order**, and
**later sources win** — so an overlay both *adds* ids the client never had and
*overrides* stock ones the shard has re-purposed. Any `.tsv`, `.csv`, `.txt`,
`.enu` or `.plain` file in `custom/` is picked up; anything else (a `README.md`,
say) is ignored.
Adding, editing or removing any overlay counts as drift, so a new custom item
needs only a file edit and a restart — or the admin panel's Import button.
**Adding one item never means re-exporting a 5 MB client file.**
The import result reports what each source contributed, which is how you confirm
an overlay took effect — `overrode: 0` on a file meant to re-label stock items
says it did not:
```json
"sources": [
{ "label": "clilocs.plain", "kind": "base", "entries": 123490, "added": 123490, "overrode": 0 },
{ "label": "custom/uomysticmoon.tsv", "kind": "custom", "entries": 2, "added": 1, "overrode": 1 }
]
```
**Why a convention rather than discovery.** Everywhere else this pipeline follows
the shard's own files, but **ServUO has no server-side notion of a custom
cliloc** — they live in the patched client a shard distributes to its players,
and nothing in the tree declares them. There is nothing to discover, so `custom/`
is the one thing here that is our convention rather than the shard's. (An
operator who *does* patch their client cliloc needs no overlay at all: convert
the patched file and their edits are simply in the base.)
Measured on the live shard for scale: its script tree references **16,434** cliloc
ids and only **37** are absent from the stock client table — tens of entries
against a 67k base, which is what makes an overlay the right shape rather than a
second full table.
## Configuring the path
Two ways to point at the sources, the setting winning over the environment:
| Source | Notes |
|---|---|
| `cliloc_client_path` setting | Admin-editable (Admin → Shard); takes effect on the next refresh without a redeploy |
| `UO_CLIENT_PATH` env var | The deploy-time default, since the path usually describes a mount the deployment sets up |
The value may be **the base file itself or a directory to search**, because both
are natural answers to "where is it". Overlays are read from a `custom/`
directory beside the base **either way** — pointing at a file does not forfeit
them.
A directory is searched case-insensitively (the client writes `Cliloc.enu` on
Windows; the site usually runs on Linux) for, in order: `clilocs.tsv`,
`clilocs.csv`, `clilocs.plain`, `cliloc.plain`, `cliloc.plain.enu`,
`cliloc.enu.plain`, `clilocs.txt`, `cliloc.enu`.
That ordering puts explicitly-converted names first on purpose. Pointing the
setting straight at an unconverted client directory finds `cliloc.enu`, which is
compressed — and the site says so by name rather than failing obscurely:
```
status: unavailable
code: COMPRESSED
reason: This is a compressed (Mythic-format) cliloc file, which the site cannot
read. Convert it to the plain format first — see docs/website/CLILOCS.md.
```
## Refresh contract
Identical in shape to the spawn atlas, and for the same reasons:
- **It never blocks startup.** No path, an unreadable file, a wrong-format file,
a database error — all caught and logged. The site comes up either way.
- **Hash-gated.** The boot path hashes the file and skips the parse entirely when
it matches what is loaded, which is every restart that did not follow a client
patch. Measured on a stock table: **14 ms** for the no-op, **663 ms** for a full
parse and replace.
- **A `PARSER_VERSION` bump also counts as drift**, so a corrected parse reaches
an install whose client never patches.
### Two ways a refresh is refused
**A corrupt file** — the realistic failure for any single source — makes the
parser fail on a truncated record rather than yield a plausible-but-short table,
so it is caught outright. Verified: a file truncated to half its length reports
```
code: TRUNCATED
reason: Truncated record header at byte 2486759 (74909 entries read)
```
and the rows already loaded are untouched. A malformed overlay names the file it
came from (`custom/broken.tsv: No cliloc entries found…`), because "which of my
six overlay files is broken" is otherwise a guessing game.
**A source that has VANISHED** is the hazard a single file did not have. It
parses perfectly and imports a table quietly missing everything that file
contributed — and an unmounted volume looks exactly like a deliberate deletion
from here. This is the same ambiguity the atlas stages a facet removal for, so it
is escalated rather than applied:
```
status: needsReview
reason: 1 previously-loaded cliloc source(s) are missing;
the existing table is unchanged
missingSources: ["custom/uomysticmoon.tsv"]
```
`status()` reports `missingSources` too, so the panel can show it before anyone
clicks Import. An admin accepts it by re-running the import with
`{ "approve": true }`.
**Why that is a flag and not the atlas's approve/reject pair.** The atlas stores
a pending decision in its own table so that approving *re-parses the tree*, which
is what keeps a multi-megabyte blob out of the database and makes the applied
result match the tree at approval time. Here nothing is stored, so re-reading at
approval time is automatic — the decision is a single boolean on the import an
admin was already going to run.
## What gets stored
| | |
|---|---|
| Parsed from a stock `Cliloc.enu` | **123,490** entries |
| Of those, empty strings | **55,994** (ids the client reserves and never uses) |
| Stored in `shard_clilocs` | **67,496** |
Blank entries are dropped at import. A row resolving to no name is
indistinguishable from no row at all to every caller, and dropping them makes the
binary and text imports converge on **identical** content — the binary format
carries the blanks explicitly and a text export may or may not, depending on the
tool. Verified: both formats import to the same 67,496 rows with the same keys.
`text` is `TEXT`, not `VARCHAR`: the long property descriptions reach 12 KB, and
silently truncating them would be worse than storing them. The index that matters
for marketplace search is on the denormalized `shard_vendor_items.display_name`,
not here.
## How names are applied
**Resolution happens server-side.** The table is never served *as* a table and
there is no public route for it. Two reasons: 67k rows would dwarf any page that
used them, and the Android client consumes the same JSON and would otherwise need
its own copy.
`resolveMany()` takes a batch of ids and returns a `Map` holding only those that
resolved to something displayable, so "no such id" and "id with no usable name"
collapse into one branch at the call site. It never throws — a cliloc lookup is
decoration on someone's character sheet, and a database blip must not fail the
sheet. A capped in-process cache fronts it; measured cold **4.2 ms**, warm
**0.015 ms**.
### `displayText()`
Cliloc strings interpolate arguments the client pulls from an item's property
list — `~1_val~`, `~2_NAME~`. **We never have those**: the bridge sends the id,
not the packet. So a name carrying them is reduced to what is actually knowable.
| Raw | Displayed |
|---|---|
| `quarter staff` | `quarter staff` |
| `cold damage ~1_val~%` | `cold damage` |
| `[~1_stuff~]` | *(nothing — the whole string was the argument)* |
| `50%` | `50%` |
| `Runic Gateway Sigil (v2)` | `Runic Gateway Sigil (v2)` |
**Punctuation is only tidied when a placeholder was actually removed.** The
trailing `%` in row two is the unit belonging to the number we never had, and the
brackets in row three only ever wrapped the argument — but a string with no
placeholder has no such debris, and trimming it anyway corrupts real names. Rows
four and five are the ones that caught it: a shard's custom
`"Runic Gateway Sigil (v2)"` rendered as `"(v2"` while the bracket trim was
unconditional.
### Consumers
- **Character sheet equipment.** `enrichCharProfile` attaches `clilocName` to each
item. A player-given `name` always wins — "Bob's lucky axe" must not be
relabelled "hatchet" — and the client re-states that precedence.
- **Reward titles.** `titles.rewardResolved` is a parallel array with the numeric
entries turned into words (`null` where nothing resolved). The sheet used to
*skip* numeric reward titles entirely, having no way to render them.
- **Marketplace listings** (Protocol 3.0 §8) denormalize the resolved name into
`shard_vendor_items.display_name` so search can index it.
## Admin surface
All admin-only, alongside the atlas under Admin → Shard:
| Route | Purpose |
|---|---|
| `GET /api/v1/admin/shard/clilocs` | Sources found, what each contributed at the last import, readability, drift, entry count, `missingSources` |
| `POST /api/v1/admin/shard/clilocs/import` | Reload after a client patch or an overlay edit; `{ "force": true }` reimports an unchanged set, `{ "approve": true }` accepts a vanished source |
| `PUT /api/v1/admin/shard/clilocs/path` | Set the path; blank disables resolution |
A refresh **result is not an exception**: a missing file, or the likely mistake of
pointing at the client's own compressed `Cliloc.enu`, answers `200` with
`status: "unavailable"` and a reason. A `500` would say only "something broke";
the operator needs to be told which file to convert. Setting the path
deliberately does **not** import as a side effect — the response carries the
refreshed status so the panel can offer that as the next step.

167
website/MARKETPLACE.md Normal file
View File

@@ -0,0 +1,167 @@
# Marketplace — the player-vendor index
**Status:** On `edge` — servuo-plugins [#5](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/5), link [#19](https://gitea.whitlocktech.com/RunicGateway/link/pulls/19), website [#116](https://gitea.whitlocktech.com/RunicGateway/website/pulls/116).
**Design:** [`docs/link/v3.md` §8](../link/v3.md) — Protocol 3.0 Part B/3.
**Depends on:** [`CLILOCS.md`](CLILOCS.md) — without a cliloc table, listings render as item ids.
The marketplace is a searchable index of every player vendor on the shard: what
each shop is selling, for how much, and where it is standing. It is the same set
the in-game **Vendor Search** gump reads, offered from outside the game — so a
player can find the vanquishing kryss they want before logging in, and someone
who does not play at all can see that the economy exists.
Page: `/site/market`, plus `/site/market/vendors/:serial` for one shop.
## Three things the pages must say out loud
Everything below follows from how the data is gathered, and each has a visible
consequence the UI is required to surface.
**1. The prices are not live.** The shard sweeps vendors **round-robin** — at
most `Bridge.MarketSweepBatch` shops per tick — so a given shop can be a full
cycle behind. The page carries a *"prices last refreshed N minutes ago"* banner
driven by the **oldest** vendor row, not the newest: the one stale shop is the
one that wastes somebody's trip.
**2. A shop can be truncated.** `Bridge.MarketMaxListings` (250 by default) caps
how many listings one frame carries. A commodity reseller with thousands of
stacked resources is a real thing, and an uncapped frame for one is measured in
megabytes. Over the cap the shop reports `truncated`, and the vendor page says
*"showing 250 of 3,104 — this shop holds more than the shard publishes"* rather
than presenting a partial shop as complete.
**3. An item may have no name.** Items on the wire carry a cliloc id, not a name.
On a shard whose operator has not converted a cliloc table
([`CLILOCS.md`](CLILOCS.md)) the honest render is the item id — never an invented
label, which would be indistinguishable from a real one.
## Privacy: the player's own toggle wins
Only vendors whose owner left the in-game **Vendor Search** flag ON are ever sent
to the site. A player who hides their shop in game is hidden here too, and no
admin setting overrides that. When they hide one that was already indexed, the
shard emits `vendor.listing.remove` and the row is deleted — so revoking consent
takes effect, it does not merely stop refreshing.
Shop name, owner character name and location default to **Everyone**, because the
stock Vendor Search gump already shows exactly that set to any player in game.
They remain admin-configurable; see [`SHARD_VISIBILITY.md`](SHARD_VISIBILITY.md).
Account names and website user ids never cross the wire at all.
## How it is put together
```
ServUO uo-link sidecar website
────── ─────────────── ───────
BridgeMarket.cs vendors table shard_vendors
round-robin sweep ──────► (whole frame blob) ──────► shard_vendor_items
per-vendor diff GET /market (paged) + display_name
vendor.listing resolved at ingest
vendor.listing.remove
```
**The shard side** walks at most `MarketSweepBatch` vendors per tick from a
persistent cursor, diffs each against what it last published, and emits a whole
frame for any shop that moved. Per-tick cost is therefore bounded by the batch,
not by how many vendors the world holds — full coverage takes
`ceil(vendors / batch) × MarketSweepSeconds`.
**The sidecar** stores each frame whole and serves `GET /market`, its only paged
read. It normalizes nothing and defines no audiences: it is a dumb forwarder, and
search is the website's job.
**The website** splits each frame into a vendor row and its listings, replacing
that vendor's whole listing set inside one transaction (the frame is
authoritative for that vendor, never a delta). Item names are resolved against
the cliloc table **on the way in** and stored denormalized, which is what makes
search-by-name possible and keeps the cliloc table off the hot path.
## Operating it
Everything is in `Config/Bridge.cfg` on the shard. There is nothing to configure
on the website.
| Setting | Default | What it does |
|---|---|---|
| `MarketEnabled` | `true` | Master switch. Off publishes nothing; the page shows an empty index. |
| `MarketSweepSeconds` | `60` | Tick interval. |
| `MarketSweepBatch` | `25` | Vendors inventoried per tick. Clamped 1..500. |
| `MarketMaxListings` | `250` | Per-shop listing cap, after which `truncated`. Clamped 1..5000. |
**Faster coverage vs. per-tick cost.** Lowering `MarketSweepSeconds` or raising
`MarketSweepBatch` both refresh the index sooner and both cost more per tick.
The expensive part is the item walk, which recurses into every container a vendor
is selling — so a shard of big shops should raise the interval rather than the
batch.
`[bridge status` reports the sweep, including `lastMs` and `maxMs`:
```
market(enabled=True sweeps=42 scanned=108 emitted=27 removed=0 skipped=0
truncated=0 tracked=27 vendors=27 cursor=2 batch=25 lastMs=0.31 maxMs=15.40)
```
A tick over **50 ms** prints a rate-limited warning naming the knob:
```
[Bridge] market sweep took 82.4 ms (budget 50 ms) - lower Bridge.MarketSweepBatch (now 25) if this persists
```
Measured on a shard with 27 vendors × 40 listings (209k items, 43k mobiles):
**15.4 ms** for the first cold tick of 25 vendors, **0.3 ms** in steady state —
the diff is what makes an unchanged world nearly free. Note the arithmetic: 25
*full* shops at the 250-listing cap is 6,250 items ≈ 95 ms, over budget. Real
shops hold tens, which is why 25 is the default and why the warning exists.
`[bridge sweepnow` runs one tick immediately; `[bridge reload` re-reads the
settings above without a restart.
## Names arriving late
Item names come from the cliloc table, and the market sweep will **not** re-send
an unchanged shop just because the site learned what its items are called. So a
cliloc import triggers a bulk re-resolution of every stored listing — otherwise
an operator who configures clilocs after the first sweep would see item ids until
every shop happened to change on its own. It runs after a boot import and after
an admin import, takes ~50 ms per thousand listings, and never throws: a failure
leaves names exactly as they were.
## API
All under `/api/v1/public/shard`, gated by the `market` feature and
**rate-limited** — these are the first genuinely expensive public reads on the
site (a `LIKE` scan plus a `COUNT` over what is typically the largest `shard_*`
table, reachable with no session).
| Route | What |
|---|---|
| `GET /market` | Search. Returns **listings**, not vendors — "who sells X and for how much" is the question. `?q=&minPrice=&maxPrice=&itemId=&map=&region=&sort=&limit=&offset=`, `sort ∈ {price_asc, price_desc, recent}`. |
| `GET /market/meta` | Index size, staleness, and which facets and regions actually hold vendors — so a client builds its filters without running a search it will discard. |
| `GET /market/vendors/:serial` | One shop and its listings. **404** for a serial the index has never seen, which also covers a vendor since dismissed or hidden — to an anonymous caller those are the same answer. |
`q` matches the resolved display name **or** the item's own literal name, because
an item with a player-set name (most of what is worth searching for on a
player-run shard) may carry a generic cliloc. `%` and `_` in a query are escaped:
they are `LIKE` metacharacters, not SQL ones, so parameterization alone would let
a search for `%` match every listing on the shard.
Full schemas are in the OpenAPI spec (`ShardMarketPage`, `ShardMarketVendor`,
`ShardMarketMeta`, `ShardMarketListing`, `ShardMarketLocation`).
## Tables
`shard_vendors` (one row per shop) and `shard_vendor_items` (one row per priced
listing). Both are ingest-owned; nothing else writes to them. No foreign keys,
in keeping with every other `shard_*` table — the ingest transaction is what
keeps them consistent, and an FK would turn a malformed frame into a failed write
rather than a dropped row.
There is deliberately **no `payload` column** on `shard_vendors`, unlike the
points board next door. The board's top-N is a fixed-size list read whole, so it
lives in JSON; here the items *are* the searchable rows, so they are normalized
and there is nothing left worth duplicating. The sidecar keeps the whole blob,
because outage resilience is its job.
`shard_vendor_items.display_name` is denormalized and indexed (alone, and
composite with `price` for "cheapest matching X"). See "Names arriving late"
above for how it is kept current.

View File

@@ -0,0 +1,128 @@
# Runic Gateway Website — Moderation Appeals (Phase 6c/6d)
> Website feature branch: **`feature/moderation-appeals`**. Builds on the moderation
> dashboard (Phase 6a/6b) and the Discord bot's `mod_actions` log. Companion to
> [website-README.md](website-README.md) (overview) and
> [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (base API contract).
## 1. Overview
A player whose linked Discord identity was **banned** or **muted** — an action
recorded in the bot's `mod_actions` log — can open an **appeal** from the player
portal and track its status. Staff (**admin** or **moderator** role) work the
appeal from an **appeals queue** in the admin moderation section: claim it, then
resolve it **approved** or **denied** with a written staff response.
When staff **approve** a ban/mute appeal, the website makes a best-effort call to
the Discord bot's internal API to actually lift the ban / clear the timeout in
Discord, and the bot posts a mod-log embed ("Appeal approved"). This is
**best-effort**: if the bot is unreachable the appeal still resolves as approved,
the reversal is recorded as failed, and staff can reverse the sanction manually in
Discord.
Only **ban** and **mute** actions are appealable — the sanctions that have an
ongoing effect. Warnings/kicks and similar one-shot actions are not.
## 2. Ownership & eligibility
- **`appeals` is a server-owned table** — only the website reads/writes it. It
references the bot-owned `mod_actions` log by a plain id column
(`mod_action_id`); there is **no hard cross-owner foreign key** between the two
databases, so the reference is validated in application code (same pattern as
the rest of the uo-link / bot integration, where the two services never share a
live FK).
- **Eligibility** — the appellant must be a **logged-in player** whose linked
Discord identity (`user_identities`, `provider = 'discord'`) matches the
`mod_actions` row's target. A player cannot open an appeal for someone else's
action, and an unlinked player has nothing eligible to appeal.
- **One active appeal per action** — only one `pending` / `under_review` appeal is
allowed for a given `mod_action_id` at a time; a second attempt while one is
already open is rejected.
## 3. Appeal lifecycle
```
pending ──▶ under_review ──▶ approved
└─▶ denied
pending ──▶ withdrawn
under_review ──▶ withdrawn
```
- **`pending`** — submitted by the player, not yet claimed.
- **`under_review`** — claimed by a staffer (the claiming admin/moderator is
stamped on the row).
- **`approved`** / **`denied`** — resolved by staff with an optional
`staff_response`. Approving a ban/mute appeal triggers the Phase 6d reversal
(§5).
- **`withdrawn`** — the player pulled the appeal back before it was resolved.
`reversal_status` (only meaningful on an approved ban/mute appeal) is one of
`none` (not attempted / not applicable), `done`, or `failed`. No new
`mod_actions` row is written for a reversal — it modifies the *original* action's
standing rather than logging a new one.
## 4. API — player (role: `player`)
Base `/api/v1/player/appeals`.
| Method | Path | Purpose |
|---|---|---|
| GET | `/player/appeals` | The caller's own appeals. |
| GET | `/player/appeals/eligible` | The caller's ban/mute actions with no active appeal (empty if they have no linked Discord identity). |
| POST | `/player/appeals` | Open an appeal — `{ mod_action_id, submitted_text }`. `403` if the action isn't the caller's, `400` if the action isn't a ban/mute, `409` if one is already open for it. |
| POST | `/player/appeals/:id/withdraw` | Withdraw an appeal that hasn't been resolved yet. |
## 5. API — staff (role: `admin` or `moderator`)
Base `/api/v1/admin/moderation/appeals`, alongside the existing moderation
section.
| Method | Path | Purpose |
|---|---|---|
| GET | `/admin/moderation/appeals?status=&limit=&offset=` | The queue. Defaults to `pending` + `under_review`; pass `status=all` or a specific status to filter. |
| GET | `/admin/moderation/appeals/:id` | One appeal. |
| POST | `/admin/moderation/appeals/:id/claim` | `pending``under_review`, stamping the claiming staffer. |
| POST | `/admin/moderation/appeals/:id/resolve` | `{ status: 'approved' \| 'denied', staff_response? }`. On an approved ban/mute, triggers the Discord reversal (§6). |
| GET | `/admin/moderation/user/:discordId/appeals` | A user's appeals — shown as a tab on the per-user moderation history page. |
`resolve` returns a `reversal` object describing what happened:
```jsonc
{
"reversal": {
"attempted": true,
"ok": true,
"reversal_status": "done", // "none" | "done" | "failed"
"bot_status": 200,
"error": null
}
}
```
## 6. Auto-reversal (Phase 6d)
On `resolve` with `status: 'approved'` against a ban/mute appeal, the website
calls the Discord bot's internal API:
```
POST /internal/mod-reverse
```
— gated by the same shared-secret scheme as the existing `/internal/announce`
call. The bot lifts the ban / clears the timeout for the target and posts an
"Appeal approved" embed to its mod log.
The call is **best-effort**: the appeal resolution itself always completes
(the appeal is marked `approved` and the staff response is saved) regardless of
whether the bot answers. If the bot is down or the call otherwise fails,
`reversal_status` is recorded as `failed` and staff are expected to reverse the
sanction by hand in Discord; the `reversal` object in the `resolve` response
surfaces `ok: false` and an `error` so the UI can flag it. Denied appeals never
attempt a reversal.
---
See [website-README.md](website-README.md) for the moderation dashboard's place
in the wider site, and [BACKEND_DESIGN.md](BACKEND_DESIGN.md) for the base API
conventions (auth, error shapes, response codes) these endpoints follow.

555
website/PROJECT_TREE.md Normal file
View File

@@ -0,0 +1,555 @@
# Website — Project Tree
> **Auto-generated.** This file is maintained by the `sync-project-tree` CI workflow in
> the [`RunicGateway/website`](https://gitea.whitlocktech.com/RunicGateway/website) repository, which
> opens a pull request here whenever the tracked file layout on `main` changes. Do not edit
> by hand — changes will be overwritten by the next sync.
A snapshot of the tracked files in the repository (build output, dependencies, and other
git-ignored paths are excluded).
```text
website/
├── .claude/
│ └── launch.json
├── .gitea/
│ ├── ISSUE_TEMPLATE/
│ │ ├── bug_report.md
│ │ ├── config.yaml
│ │ └── feature_request.md
│ ├── scripts/
│ │ └── gen_tree.py
│ ├── workflows/
│ │ ├── build-images.yml
│ │ ├── pr-checks.yml
│ │ ├── sonarqube.yml
│ │ └── sync-project-tree.yml
│ └── PULL_REQUEST_TEMPLATE.md
├── bot/
│ ├── src/
│ │ ├── discord/
│ │ │ ├── commands/
│ │ │ │ ├── announce.command.js
│ │ │ │ ├── autorole.command.js
│ │ │ │ ├── ban.command.js
│ │ │ │ ├── filter.command.js
│ │ │ │ ├── filterallow.command.js
│ │ │ │ ├── index.js
│ │ │ │ ├── invite.command.js
│ │ │ │ ├── kick.command.js
│ │ │ │ ├── modlog.command.js
│ │ │ │ ├── mute.command.js
│ │ │ │ ├── news.command.js
│ │ │ │ ├── ping.command.js
│ │ │ │ ├── role.command.js
│ │ │ │ ├── rolemenu.command.js
│ │ │ │ ├── roles.command.js
│ │ │ │ ├── schedule.command.js
│ │ │ │ ├── warn.command.js
│ │ │ │ ├── warnings.command.js
│ │ │ │ └── wiki.command.js
│ │ │ ├── discordManager.js
│ │ │ ├── guildMemberAdd.js
│ │ │ ├── guildMemberRemove.js
│ │ │ ├── inviteTracker.js
│ │ │ ├── messageFilter.js
│ │ │ ├── modLog.js
│ │ │ ├── newsAnnounce.js
│ │ │ └── roleMenuHandler.js
│ │ ├── filter/
│ │ │ ├── filterCache.js
│ │ │ ├── inviteFilter.js
│ │ │ ├── normalize.js
│ │ │ └── spamFilter.js
│ │ ├── internal/
│ │ │ ├── internal.controller.js
│ │ │ ├── internal.routes.js
│ │ │ └── requireInternalKey.js
│ │ ├── invites/
│ │ │ ├── inviteRotator.js
│ │ │ └── inviteScheduler.js
│ │ ├── model/
│ │ │ ├── filterAllowlist.js
│ │ │ ├── filterHits.js
│ │ │ ├── filterWords.js
│ │ │ ├── guildConfig.js
│ │ │ ├── inviteLog.js
│ │ │ ├── memberEvents.js
│ │ │ ├── roleMenus.js
│ │ │ ├── scheduledMessages.js
│ │ │ ├── spamHits.js
│ │ │ ├── tempRoles.js
│ │ │ └── warnings.js
│ │ ├── roles/
│ │ │ └── tempRoleSweeper.js
│ │ ├── scheduler/
│ │ │ └── scheduler.js
│ │ ├── site/
│ │ │ └── siteApiClient.js
│ │ ├── utils/
│ │ │ ├── duration.js
│ │ │ └── logger.js
│ │ ├── app.js
│ │ ├── bootstrap.js
│ │ ├── brand.js
│ │ ├── db.js
│ │ └── server.js
│ ├── .env.example
│ ├── .gitignore
│ ├── Dockerfile
│ ├── package-lock.json
│ └── package.json
├── brand/
│ └── README.md
├── client/
│ ├── public/
│ │ ├── assets/
│ │ │ └── img/
│ │ │ ├── favicon.ico
│ │ │ ├── hero-moon.png
│ │ │ ├── runic-emblem.png
│ │ │ └── uomysticmoon-main-hero.png
│ │ └── robots.txt
│ ├── src/
│ │ ├── api/
│ │ │ └── client.js
│ │ ├── blocks/
│ │ │ ├── types/
│ │ │ │ ├── cta.jsx
│ │ │ │ ├── divider.jsx
│ │ │ │ ├── heading.jsx
│ │ │ │ ├── image.jsx
│ │ │ │ ├── quote.jsx
│ │ │ │ ├── richText.jsx
│ │ │ │ └── twoColumn.jsx
│ │ │ ├── BlockRenderer.jsx
│ │ │ ├── editorKit.jsx
│ │ │ ├── index.js
│ │ │ └── registry.js
│ │ ├── components/
│ │ │ ├── security/
│ │ │ │ ├── RecoveryCodesDisplay.jsx
│ │ │ │ ├── RecoveryCodesPanel.jsx
│ │ │ │ ├── TrustedDevicesPanel.jsx
│ │ │ │ └── TrustLimitModal.jsx
│ │ │ ├── CharacterSheet.jsx
│ │ │ ├── CharacterStats.jsx
│ │ │ ├── CreateGameAccountForm.jsx
│ │ │ ├── GameAccounts.jsx
│ │ │ ├── HeroElement.jsx
│ │ │ ├── MaintenanceGate.jsx
│ │ │ ├── Modal.jsx
│ │ │ ├── MoonDot.jsx
│ │ │ ├── PageHeader.jsx
│ │ │ ├── PageState.jsx
│ │ │ ├── PlayersOnline.jsx
│ │ │ ├── ProviderIcon.jsx
│ │ │ ├── PublicLayout.jsx
│ │ │ ├── RequireAuth.jsx
│ │ │ ├── RequirePlayer.jsx
│ │ │ ├── RichTextEditor.jsx
│ │ │ ├── RoleGate.jsx
│ │ │ ├── ShardAccountActions.jsx
│ │ │ ├── SiteFooter.jsx
│ │ │ ├── SiteHeader.jsx
│ │ │ └── VendorSales.jsx
│ │ ├── contexts/
│ │ │ ├── AuthContext.jsx
│ │ │ └── SiteContext.jsx
│ │ ├── data/
│ │ │ ├── cityCrests.js
│ │ │ └── regionBuckets.js
│ │ ├── lib/
│ │ │ ├── format.js
│ │ │ ├── heroLayout.js
│ │ │ ├── shardEvents.js
│ │ │ ├── useAsync.js
│ │ │ └── useShardFeed.js
│ │ ├── routes/
│ │ │ ├── admin/
│ │ │ │ ├── views/
│ │ │ │ │ ├── AccountAdmin.jsx
│ │ │ │ │ ├── ActivityAdmin.jsx
│ │ │ │ │ ├── AdminCharacter.jsx
│ │ │ │ │ ├── AdminCharacters.jsx
│ │ │ │ │ ├── Appeals.jsx
│ │ │ │ │ ├── AuthProvidersAdmin.jsx
│ │ │ │ │ ├── BotActivityAdmin.jsx
│ │ │ │ │ ├── Dashboard.jsx
│ │ │ │ │ ├── DiscordBotAdmin.jsx
│ │ │ │ │ ├── EmailDelivery.jsx
│ │ │ │ │ ├── HeroEditor.jsx
│ │ │ │ │ ├── HousesAdmin.jsx
│ │ │ │ │ ├── InvitesAdmin.jsx
│ │ │ │ │ ├── Moderation.jsx
│ │ │ │ │ ├── ModerationUser.jsx
│ │ │ │ │ ├── PageBuilder.jsx
│ │ │ │ │ ├── PagesAdmin.jsx
│ │ │ │ │ ├── PostEditor.jsx
│ │ │ │ │ ├── PostsAdmin.jsx
│ │ │ │ │ ├── SettingsAdmin.jsx
│ │ │ │ │ ├── ShardAdmin.jsx
│ │ │ │ │ ├── ShardOps.jsx
│ │ │ │ │ ├── UserDetail.jsx
│ │ │ │ │ ├── UserEditor.jsx
│ │ │ │ │ ├── UsersAdmin.jsx
│ │ │ │ │ ├── WikiAdmin.jsx
│ │ │ │ │ ├── WikiCategories.jsx
│ │ │ │ │ ├── WikiEditor.jsx
│ │ │ │ │ └── WikiHistory.jsx
│ │ │ │ ├── AdminLayout.jsx
│ │ │ │ └── AdminLogin.jsx
│ │ │ ├── player/
│ │ │ │ ├── AcceptInvite.jsx
│ │ │ │ ├── ForgotPassword.jsx
│ │ │ │ ├── PlayerAccount.jsx
│ │ │ │ ├── PlayerAppeals.jsx
│ │ │ │ ├── PlayerCharacter.jsx
│ │ │ │ ├── PlayerCharacters.jsx
│ │ │ │ ├── PlayerLogin.jsx
│ │ │ │ ├── PlayerPortalLayout.jsx
│ │ │ │ ├── PlayerRegister.jsx
│ │ │ │ ├── PlayerShell.jsx
│ │ │ │ └── ResetPassword.jsx
│ │ │ ├── public/
│ │ │ │ ├── About.jsx
│ │ │ │ ├── ChampSpawns.jsx
│ │ │ │ ├── CmsPage.jsx
│ │ │ │ ├── FiveOnFriday.jsx
│ │ │ │ ├── Governors.jsx
│ │ │ │ ├── Guilds.jsx
│ │ │ │ ├── Houses.jsx
│ │ │ │ ├── Maintenance.jsx
│ │ │ │ ├── News.jsx
│ │ │ │ ├── Newsletter.jsx
│ │ │ │ ├── NewsletterIssue.jsx
│ │ │ │ ├── Portal.jsx
│ │ │ │ ├── Screenshots.jsx
│ │ │ │ ├── Shard.jsx
│ │ │ │ ├── ShardActivity.jsx
│ │ │ │ ├── Status.jsx
│ │ │ │ └── Website.jsx
│ │ │ └── wiki/
│ │ │ ├── Wiki.jsx
│ │ │ └── WikiArticle.jsx
│ │ ├── styles/
│ │ │ └── theme.css
│ │ ├── App.jsx
│ │ └── main.jsx
│ ├── test/
│ │ ├── apiClient.test.js
│ │ ├── format.test.js
│ │ ├── heroLayout.test.js
│ │ ├── regionBuckets.test.js
│ │ └── shardEvents.test.js
│ ├── index.html
│ ├── package-lock.json
│ ├── package.json
│ └── vite.config.js
├── ntfy/
│ └── server.yml
├── scripts/
│ ├── dev/
│ │ ├── README.md
│ │ ├── seed-sso-provider.js
│ │ ├── sso-bridge-smoketest.js
│ │ └── stub-idp.js
│ └── sonar-test-reporter.mjs
├── server/
│ ├── db/
│ │ ├── schema.sql
│ │ └── seed.js
│ ├── scripts/
│ │ └── routeManifest.js
│ ├── src/
│ │ ├── auth/
│ │ │ ├── providers/
│ │ │ │ ├── base.provider.js
│ │ │ │ ├── discord.provider.js
│ │ │ │ ├── genericOidc.provider.js
│ │ │ │ ├── google.provider.js
│ │ │ │ ├── local.provider.js
│ │ │ │ ├── oauth2.provider.js
│ │ │ │ └── registry.js
│ │ │ ├── session.middleware.js
│ │ │ ├── session.service.js
│ │ │ ├── ssoState.js
│ │ │ ├── token.js
│ │ │ └── usernamePolicy.js
│ │ ├── blocks/
│ │ │ ├── types/
│ │ │ │ ├── cta.js
│ │ │ │ ├── divider.js
│ │ │ │ ├── heading.js
│ │ │ │ ├── image.js
│ │ │ │ ├── quote.js
│ │ │ │ ├── richText.js
│ │ │ │ └── twoColumn.js
│ │ │ ├── index.js
│ │ │ ├── propHelpers.js
│ │ │ ├── registry.js
│ │ │ ├── sanitizeBlocks.js
│ │ │ └── validateBlocks.js
│ │ ├── config/
│ │ │ ├── brand.js
│ │ │ ├── csp.js
│ │ │ ├── notificationStreams.js
│ │ │ └── version.js
│ │ ├── middleware/
│ │ │ ├── botScore.js
│ │ │ ├── loginProtection.js
│ │ │ ├── noindex.js
│ │ │ ├── rateLimit.js
│ │ │ ├── requireInternalKey.js
│ │ │ ├── siteMode.js
│ │ │ └── validate.js
│ │ ├── model/
│ │ │ ├── activity/
│ │ │ │ ├── activity.db.js
│ │ │ │ └── activity.model.js
│ │ │ ├── announceJobs/
│ │ │ │ ├── announceJobs.db.js
│ │ │ │ ├── announceJobs.logic.js
│ │ │ │ └── announceJobs.model.js
│ │ │ ├── appeals/
│ │ │ │ ├── appeals.db.js
│ │ │ │ ├── appeals.model.js
│ │ │ │ └── appeals.pure.js
│ │ │ ├── authProviders/
│ │ │ │ ├── authProviders.db.js
│ │ │ │ └── authProviders.model.js
│ │ │ ├── botConfig/
│ │ │ │ ├── botConfig.db.js
│ │ │ │ └── botConfig.model.js
│ │ │ ├── emailConfig/
│ │ │ │ ├── emailConfig.db.js
│ │ │ │ └── emailConfig.model.js
│ │ │ ├── invites/
│ │ │ │ ├── invites.db.js
│ │ │ │ └── invites.model.js
│ │ │ ├── mobileAuthBridge/
│ │ │ │ ├── mobileAuthBridge.db.js
│ │ │ │ └── mobileAuthBridge.model.js
│ │ │ ├── mobileSessions/
│ │ │ │ ├── mobileSessions.db.js
│ │ │ │ └── mobileSessions.model.js
│ │ │ ├── moderation/
│ │ │ │ ├── moderation.db.js
│ │ │ │ ├── moderation.model.js
│ │ │ │ └── moderation.pure.js
│ │ │ ├── modNotes/
│ │ │ │ ├── modNotes.db.js
│ │ │ │ └── modNotes.model.js
│ │ │ ├── notificationSubs/
│ │ │ │ ├── notificationSubs.db.js
│ │ │ │ └── notificationSubs.model.js
│ │ │ ├── pages/
│ │ │ │ ├── pages.db.js
│ │ │ │ ├── pages.model.js
│ │ │ │ └── reservedSlugs.js
│ │ │ ├── passwordResets/
│ │ │ │ ├── passwordResets.db.js
│ │ │ │ └── passwordResets.model.js
│ │ │ ├── posts/
│ │ │ │ ├── posts.db.js
│ │ │ │ └── posts.model.js
│ │ │ ├── pushDevices/
│ │ │ │ ├── pushDevices.db.js
│ │ │ │ └── pushDevices.model.js
│ │ │ ├── recoveryCodes/
│ │ │ │ ├── recoveryCodes.db.js
│ │ │ │ └── recoveryCodes.model.js
│ │ │ ├── revokedSessions/
│ │ │ │ ├── revokedSessions.db.js
│ │ │ │ └── revokedSessions.model.js
│ │ │ ├── settings/
│ │ │ │ ├── settings.db.js
│ │ │ │ └── settings.model.js
│ │ │ ├── shardEvents/
│ │ │ │ ├── shardEvents.db.js
│ │ │ │ └── shardEvents.model.js
│ │ │ ├── shardLinks/
│ │ │ │ ├── shardLinks.db.js
│ │ │ │ └── shardLinks.model.js
│ │ │ ├── shardState/
│ │ │ │ ├── shardState.db.js
│ │ │ │ └── shardState.model.js
│ │ │ ├── trustedDevices/
│ │ │ │ ├── trustedDevices.db.js
│ │ │ │ └── trustedDevices.model.js
│ │ │ ├── uoLinkConfig/
│ │ │ │ ├── uoLinkConfig.db.js
│ │ │ │ └── uoLinkConfig.model.js
│ │ │ ├── userIdentities/
│ │ │ │ ├── userIdentities.db.js
│ │ │ │ └── userIdentities.model.js
│ │ │ ├── users/
│ │ │ │ ├── users.db.js
│ │ │ │ └── users.model.js
│ │ │ ├── wiki/
│ │ │ │ ├── wiki.db.js
│ │ │ │ ├── wiki.links.js
│ │ │ │ └── wiki.model.js
│ │ │ └── singletonConfigDb.js
│ │ ├── router/
│ │ │ ├── v1/
│ │ │ │ ├── admin/
│ │ │ │ │ ├── account.controller.js
│ │ │ │ │ ├── account.router.js
│ │ │ │ │ ├── activity.router.js
│ │ │ │ │ ├── admin.controller.js
│ │ │ │ │ ├── admin.routes.js
│ │ │ │ │ ├── authProviders.controller.js
│ │ │ │ │ ├── authProviders.router.js
│ │ │ │ │ ├── botActivity.controller.js
│ │ │ │ │ ├── botActivity.router.js
│ │ │ │ │ ├── discordBot.controller.js
│ │ │ │ │ ├── emailConfig.controller.js
│ │ │ │ │ ├── imageUpload.js
│ │ │ │ │ ├── index.js
│ │ │ │ │ ├── invites.controller.js
│ │ │ │ │ ├── invites.router.js
│ │ │ │ │ ├── moderation.controller.js
│ │ │ │ │ ├── moderation.router.js
│ │ │ │ │ ├── pages.controller.js
│ │ │ │ │ ├── pages.router.js
│ │ │ │ │ ├── posts.router.js
│ │ │ │ │ ├── shardOps.controller.js
│ │ │ │ │ ├── uoLink.controller.js
│ │ │ │ │ ├── uploads.router.js
│ │ │ │ │ ├── users.router.js
│ │ │ │ │ ├── usersShard.controller.js
│ │ │ │ │ └── wiki.router.js
│ │ │ │ ├── auth/
│ │ │ │ │ ├── auth.controller.js
│ │ │ │ │ ├── auth.routes.js
│ │ │ │ │ ├── invite.controller.js
│ │ │ │ │ ├── me.routes.js
│ │ │ │ │ ├── mobile.controller.js
│ │ │ │ │ ├── mobile.routes.js
│ │ │ │ │ ├── mobileSso.controller.js
│ │ │ │ │ ├── mobileSso.routes.js
│ │ │ │ │ ├── notifications.controller.js
│ │ │ │ │ ├── notifications.routes.js
│ │ │ │ │ ├── passwordReset.controller.js
│ │ │ │ │ ├── sso.controller.js
│ │ │ │ │ ├── sso.routes.js
│ │ │ │ │ └── trustDevice.helper.js
│ │ │ │ ├── internal/
│ │ │ │ │ ├── internal.controller.js
│ │ │ │ │ └── internal.routes.js
│ │ │ │ ├── player/
│ │ │ │ │ ├── appeals.controller.js
│ │ │ │ │ ├── player.routes.js
│ │ │ │ │ └── shard.controller.js
│ │ │ │ ├── public/
│ │ │ │ │ ├── public.controller.js
│ │ │ │ │ ├── public.routes.js
│ │ │ │ │ └── shard.controller.js
│ │ │ │ └── v1.router.js
│ │ │ ├── api.router.js
│ │ │ ├── cspReport.controller.js
│ │ │ └── wellKnown.controller.js
│ │ ├── utils/
│ │ │ ├── announceWorker.js
│ │ │ ├── auth.js
│ │ │ ├── botInternalClient.js
│ │ │ ├── botInternalKey.js
│ │ │ ├── db.js
│ │ │ ├── logger.js
│ │ │ ├── mailer.js
│ │ │ ├── newsGump.js
│ │ │ ├── pushDispatch.js
│ │ │ ├── sanitizeHtml.js
│ │ │ ├── secretBox.js
│ │ │ ├── shardBroadcast.js
│ │ │ ├── shardIngest.js
│ │ │ ├── shardSales.js
│ │ │ ├── totp.js
│ │ │ ├── trustProxy.js
│ │ │ ├── uoLinkClient.js
│ │ │ └── uoLinkSocket.js
│ │ ├── app.js
│ │ ├── internalApp.js
│ │ └── server.js
│ ├── swagger/
│ │ ├── swagger-output.json
│ │ └── swagger.js
│ ├── test/
│ │ ├── _helper.js
│ │ ├── adminTrustedDevices.test.js
│ │ ├── adminUserShard.test.js
│ │ ├── announceJobs.test.js
│ │ ├── appeals.pure.test.js
│ │ ├── appeals.test.js
│ │ ├── appLinks.test.js
│ │ ├── authController.test.js
│ │ ├── authMe.test.js
│ │ ├── authTrustedDevice.test.js
│ │ ├── botInternalKey.test.js
│ │ ├── botScore.test.js
│ │ ├── csp.test.js
│ │ ├── emailConfig.model.test.js
│ │ ├── honeypot.test.js
│ │ ├── inviteController.test.js
│ │ ├── invites.test.js
│ │ ├── loginProtection.test.js
│ │ ├── mailer.test.js
│ │ ├── mobileAuthBridge.model.test.js
│ │ ├── mobileDeviceSessions.test.js
│ │ ├── mobileSession.test.js
│ │ ├── mobileSsoBridge.test.js
│ │ ├── moderation.model.test.js
│ │ ├── moderation.test.js
│ │ ├── newsGump.test.js
│ │ ├── notificationsRoutes.test.js
│ │ ├── pages.model.test.js
│ │ ├── passwordResetController.test.js
│ │ ├── passwordResets.test.js
│ │ ├── playerAccounts.test.js
│ │ ├── playerRouteAccess.test.js
│ │ ├── providers.test.js
│ │ ├── publicBrand.test.js
│ │ ├── publicController.test.js
│ │ ├── publicShardOnline.test.js
│ │ ├── publicVersion.test.js
│ │ ├── pushDispatch.test.js
│ │ ├── recoveryCodes.test.js
│ │ ├── registry.test.js
│ │ ├── requireInternalKey.test.js
│ │ ├── routeManifest.test.js
│ │ ├── secretBox.test.js
│ │ ├── selfTrustedDevices.test.js
│ │ ├── session.test.js
│ │ ├── shardControllerPublic.test.js
│ │ ├── shardIngest.champsPages.test.js
│ │ ├── shardIngest.protocol2.test.js
│ │ ├── shardState.governorTerms.test.js
│ │ ├── shardState.model.test.js
│ │ ├── ssoCallback.test.js
│ │ ├── ssoState.test.js
│ │ ├── totp.test.js
│ │ ├── trustedDevices.test.js
│ │ ├── trustProxy.test.js
│ │ └── usernamePolicy.test.js
│ ├── .env.example
│ ├── package-lock.json
│ ├── package.json
│ ├── routes.guards.json
│ └── routes.manifest.json
├── .dockerignore
├── .env.example
├── .env.uomysticmoon.example
├── .gitignore
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── CONTRIBUTORS.md
├── docker-compose.dev.yml
├── docker-compose.yml
├── Dockerfile
├── LICENSE.md
├── package.json
├── README.md
├── SECURITY.md
└── sonar-project.properties
```

158
website/SHARD_VISIBILITY.md Normal file
View File

@@ -0,0 +1,158 @@
# Shard visibility — who sees which shard data
**Status:** Built (Protocol 3.0 Part A). Admin → Shard Visibility.
**Audience:** shard owners and admins.
**Companion to** [`../link/v3.md`](../link/v3.md) §3 (the design) and
[`BACKEND_DESIGN.md`](BACKEND_DESIGN.md) §6 (the security contract).
The website surfaces a lot of live shard data. What your players, your staff and the anonymous
internet may each see is **yours to decide**, per feature, from Admin → Shard Visibility.
Nothing changes until you change it: every setting ships at the value that reproduces how the site
behaved before this panel existed.
---
## 1. The audience ladder
Five rungs. Each one includes everyone below it.
| Rung | In the UI | Who that is |
|---|---|---|
| `anonymous` | **Everyone** | Anyone at all, signed in or not. |
| `logged_in` | **Signed in** | Any registered account, whether or not they've linked a game account. |
| `player` | **Linked players** | Accounts with a linked in-game account. **Staff always qualify**, linked or not. |
| `staff` | **Staff** | Admins and moderators. |
| `admin` | **Admins only** | Admins. |
Two notes that surprise people:
- **`editor` is a content role, not a shard role.** Editors write news and wiki pages; they get no
shard privilege from that. An editor is treated by link status like any other member. This matches
the rest of the site, where shard staff powers are admin-or-moderator.
- **Staff satisfy `player` without linking.** Otherwise an admin would be locked out of surfaces
they'd gated to players, which is how the `/player/*` routes already behave.
## 2. What you can set per feature
**Enabled.** Off means gone. The feature's pages return “not found”, not “forbidden” — a disabled
feature doesn't advertise that it exists.
**Who can see it.** The minimum rung, from the ladder above.
**Live updates.** Whether this feature pushes changes to open pages in real time. Turning it off
doesn't break the page; it just refreshes on load instead of updating in place.
**Sensitive fields.** Some features expose a field that deserves its own rung — you can publish the
board while holding back one column. See the table in §3.
## 3. The features, and their defaults
| Feature | What it exposes | Default | Sensitive fields |
|---|---|---|---|
| **Shard status** | Connection state, online count, gold-supply series | Everyone | — |
| **Activity feed** | Deaths, kills, skill gains, quests, logins | Everyone | — |
| **Champion spawns** | The live champion / mini-champ / sea-boss board | Everyone | — |
| **Guilds** | Guild rosters, alliances, leaders | Everyone | — |
| **Town governors** | City Loyalty governors, elections, term history | Everyone | — |
| **Houses / IDOC** | Houses in danger | Everyone | House owner → Staff · House price → Staff |
| **Players online** | Population aggregate, staff-online widget | Everyone | In-game location → Staff |
| **Shard rules** | Skill/stat caps, house limits, vet rewards, the ruleset | Everyone | Connect address → Everyone |
| **Spawn atlas** | Bestiary and spawn locations (static content) | Everyone | — |
| **Leaderboards** | Point and loyalty standings | Everyone | Character names → Everyone |
| **Marketplace** | The shard-wide player-vendor index | Everyone, **live updates off** | Vendor owner name → Everyone · Vendor owner character id → Everyone · In-game location → Everyone |
**Why the marketplace ships with live updates off.** A live feed of every vendor's full inventory
would be the single largest thing the site sends. No page needs it — the marketplace is a search over
stored data with a “prices last refreshed N minutes ago” stamp. Turn it on only if you want it.
**Why the marketplace's fields default to Everyone.** A vendor's shop name, its owner's character
name and where it is standing are *already* visible to every player in game: the stock Vendor Search
gump surfaces exactly that set to anyone who opens it. Publishing them on the site is not a new
disclosure. They stay configurable because a shard may still prefer to keep its economy behind a
login — and because "already public in game" is a judgement about your shard, not ours.
**Location is one setting covering four things.** Hiding it removes the facet, the coordinates, the
region *and* the house name together. That is deliberate: those are four ways of saying the same
thing, and a setting that hid the coordinates while publishing the house name would not have hidden
anything.
**Hiding the owner name also hides the owner character id.** They are separate settings so you can
be explicit, but leaving the id published while hiding the name achieves nothing — the leaderboards
and guild boards resolve that same id back to a character name. Set both.
**What hiding a vendor cannot do.** Only vendors whose owner left the in-game *Vendor Search* flag ON
are ever sent to the site, so a player who hides their shop in game is hidden here too — and no
setting on this page can override that. It works the other way as well: these settings control who
sees the index, not whether players can find each other's shops in game.
**Why house owner/price default to Staff.** The public Houses page has always been a "where are the
falling houses" board — location only. Owner and price are the staff view. That split is preserved.
## 4. What you cannot change
Two rules are enforced in code and are not settings. Attempting to set them returns an error rather
than silently ignoring you.
**1. Game account names and website user ids are admin-only, always.**
`acct` and `webId` never appear below the admin rung on any surface. A character *name* is visible in
game to anyone standing next to them; the **account** behind it is not, and neither is the website
user it's linked to. Publishing those would disclose something the shard itself doesn't, and would
tie a player's in-game identity to their forum identity without their consent.
This rule matches the *meaning* of a field, not one spelling of it. Some responses nest the player
who owns a record (`leader.acct`); others flatten it into the row (`ownerAcct`, `leaderWebId`,
`governorAcct`). Every one of those is locked, and the admin API refuses to configure any of them —
so a new response shape can't quietly reopen the hole by naming the field differently.
**2. Unknown event kinds are never broadcast below admin.**
The live stream maps each event kind to a feature. A kind with no mapping — a new event from a shard
plugin the site doesn't know yet, say — goes to admins only. It fails closed. This is what keeps the
stream safe by default when the shard starts sending something new: the worst case is that staff see
it and players don't, never the reverse.
## 5. How it's enforced
Three places, one config:
- **Page and API requests** are checked before the handler runs, and the response is then stripped of
any field above the caller's rung.
- **The live stream** resolves a viewer's rung once, when they connect, and freezes it for that
connection — a long-open page can't gain privilege because something changed underneath it. Each
event is then gated and stripped per viewer, so two people watching the same page can legitimately
receive different versions of the same event, or one of them nothing.
- **Navigation** hides links a viewer can't follow, so they don't hit a wall. This is presentation
only — the gate is server-side either way.
**Stored history answers the same way the live stream does.** The activity feed reads from the event
log rather than the live stream, but it resolves the *same* question against the *same* config: which
kinds you may read, and which fields survive. So moving a feature up a rung hides it from the history
as well as the stream — there is no back door where yesterday's copy of an event is more revealing
than today's.
One deliberate asymmetry: turning **live updates** off for a feature stops the push, not the reading.
The marketplace ships this way — its history and its pages are public, only the firehose is off.
Changes take effect within about five seconds, **including on streams that are already open**. You
don't need to restart anything.
If the database is briefly unreachable, the site falls back to the built-in defaults — the pre-v3
behavior — rather than to "everything is public".
## 6. Worked examples
**"I want a private shard — nothing public until people register."**
Set every feature to **Signed in**. Anonymous visitors still get the site itself; the shard data
disappears from the nav.
**"Publish the market, but don't tie vendors to players."**
Marketplace → Everyone, with **Vendor owner name** → Staff. Prices, items and locations stay public;
who owns each vendor doesn't.
**"Leaderboards for members only."**
Leaderboards → **Linked players**. Anyone who's linked a game account sees the standings; drive-by
visitors don't.
**"Let players see house owners."**
Houses → Everyone, **House owner** → Linked players. Note this is a real disclosure: house ownership
is visible in game, but the website makes it searchable in a way the game doesn't.

354
website/SPAWN_ATLAS.md Normal file
View File

@@ -0,0 +1,354 @@
# Spawn atlas
**Status:** Complete on `edge` — data pipeline in website [#112](https://gitea.whitlocktech.com/RunicGateway/website/pulls/112), API + pages in website [#113](https://gitea.whitlocktech.com/RunicGateway/website/pulls/113).
**Design:** [`docs/link/v3.md` §6](../link/v3.md) — Protocol 3.0 Part C.
The spawn atlas is a browsable catalogue of what the shard *contains*: which
creatures spawn, where, how many, and which champion altars are configured. It
answers "where do I find a lizardman?" with **"Shrines, Yew, Isamu-Jima"** rather
than with a list of raw coordinates.
## Two things that shape the whole design
**The shard's ServUO tree is the single source of truth.** Nothing is
precomputed and committed to the repository. A shard's maps change over its
lifetime — facets get added, replaced, or renamed — and a snapshot in the repo
would silently drift from the world players actually see. The atlas is therefore
re-derived from the tree **on every server boot**.
**Facets are not a fixed list.** Nothing in the codebase names Felucca, Trammel,
or any other stock facet. The facet set is whatever the shard's own files
declare, discovered at parse time. A shard running entirely custom maps gets
exactly the same treatment as a stock one, with no code change.
## What it is not
The atlas is **static shard content, not live shard state.**
- It does **not** come from the sidecar. Nothing here touches the bridge, and
there is no event kind, no wire change and no `PROTOCOL_VERSION` bump for it.
Part C is website-only.
- It stays fully populated while the shard is down.
- Its champion table (`shard_champion_spawns`) is the *configured roster*
"there is an Unholy Terror altar in Deceit". The live `champ.update` feed in
`shard_champs` is the separate, sidecar-fed answer to "it is on level 3 right
now". Both exist; do not conflate them.
Routes live at `/api/v1/public/atlas`, deliberately **not** under `/shard`,
because `/shard/*` means sidecar-dependent.
## Configuring the tree
The website needs to be able to *read* the ServUO tree — same host, a bind mount,
or a shared volume. Two ways to point at it, the setting winning over the
environment:
| Source | Notes |
|---|---|
| `spawn_atlas_servuo_path` setting | Admin-editable; changes take effect on the next refresh without a redeploy |
| `SERVUO_PATH` env var | The deploy-time default, since the path usually describes a mount the deployment sets up |
With neither set the atlas is simply skipped — the site runs normally without
one.
## The boot path
On every start the server hashes the source files and compares them against what
is loaded. Unchanged (the normal case on a restart) costs one read pass, ~120 ms,
and no database write. A real change costs a ~400 ms parse and a reload.
Two contracts govern it:
**1. It never blocks startup.** No configured path, an unreadable mount, a
malformed file, a database error — every one is caught and logged, and the site
comes up serving whatever atlas it already had.
**2. A facet disappearing is never applied automatically.** Losing a facet looks
exactly like a half-copied or mid-update tree, and boot cannot tell that apart
from a real map change. That refresh is *staged* for a human instead. Everything
else — new facets, renamed regions, changed spawns — applies immediately, since
none of it can destroy something an operator would miss.
```
boot
└─ path configured? no ──▶ skip
└─ tree readable? no ──▶ warn, carry on
└─ hashes changed? no ──▶ done (nothing parsed)
└─ parse
└─ a facet would be removed?
no ──▶ import
yes ──▶ stage for admin review; atlas unchanged
```
### Approving or rejecting a staged refresh
Only the *decision* is stored, never the parsed world — a few KB of source hashes
plus the facet diff. Approving **re-parses** the tree, so what lands matches the
tree at approval time rather than at boot, and a multi-megabyte blob never sits
in the database.
A rejection is remembered against those exact source hashes, so a declined
refresh does not re-prompt on every restart. Change the tree and the hashes
differ, which asks again.
From **Admin → Spawn Atlas**, or from the CLI:
```bash
cd website/server
npm run atlas:import -- --status # what is loaded, and what is pending
npm run atlas:import -- --approve # apply the staged refresh
npm run atlas:import -- --reject # keep the current atlas, dismiss it
```
## The CLI
The server refreshes itself on boot, so this is for applying a map change
*without* a restart, and for the approve/reject flow above.
```bash
npm run atlas:import # import if the tree differs
npm run atlas:import -- --servuo <path> # override the path for this run
npm run atlas:import -- --force # reimport even if unchanged
```
`--servuo` is a per-run override and deliberately does **not** persist — changing
where the atlas permanently reads from is an admin action, not a side effect of a
one-off import.
## Sources
| File | Count (stock ServUO 57.4) | Used for |
|---|---|---|
| `Spawns/*.xml` | 13 files, ~10.5 MB | Every spawner: location, size, delays, time-of-day, creature types |
| `Data/Regions.xml` | 129 KB, nested | Named regions and their rectangles |
| `Data/Locations/*.xml` | 6 files | Landmarks (dungeon levels, town markers) |
| `Config/ChampionSpawns.xml` | 4.8 KB | Configured champion altars |
**A stock tree has 13 spawn files but only 6 facets.** `Eodon.xml`,
`GravewaterLake.xml`, `TreasuresOfKotl.xml` and the other named-area files hold
TerMur/Trammel points. The facet always comes from each record's own `<Map>`,
never from the file name.
## How a coordinate becomes a place name
This is the transform the atlas exists for, in `resolveRegion()`:
1. The highest-`priority` named region whose rectangle contains the point. Ties
break toward the **smallest** rect, so a specific room wins over the
dungeon-wide rect enclosing it.
2. Otherwise the nearest landmark within the landmark radius (200 tiles by
default), labelled by its **group** ("Covetous"), not its individual marker
("Level 1").
3. Otherwise `"Wilderness"`.
The radius cap in step 2 is what keeps step 3 reachable. Without it the nearest
landmark is always *some* landmark however far away, and open countryside gets
labelled with a dungeon on the far side of the map.
Against stock ServUO this resolves **83.2%** of points (5,369 of 6,455): 3,681 by
region, 1,688 by landmark, 1,086 Wilderness.
## Three quirks in the source data
Each of these is silent if unhandled — the atlas still builds, it is just wrong.
**Facet names disagree between sources.** `Data/Locations/*.xml` spells them
`Ter Mur` and `Tokuno Islands`, while `<Map>` and `<Facet name>` say `TerMur` and
`Tokuno`. Unreconciled, the landmark bucket is keyed differently from the points
looking it up, so the fallback never fires and every unregioned spawn on those
facets reads "Wilderness".
This is reconciled **by matching, not by a lookup table** — there is no list of
facet names anywhere. `facetKey()` collapses spelling differences (lowercase,
alphanumerics only), and `resolveFacetName()` matches a loose spelling against
the canonical set discovered from the shard's own spawn and region data, by exact
key then by prefix in either direction. A name matching nothing keeps its own
name: forcing a wrong match would file a real custom facet's landmarks under the
wrong facet, which is worse than leaving it alone.
**Spawn type tokens carry XmlSpawner directives.** The `<Objects2>` type is not
always a bare class name:
```
Fairy,{RND,4,8} alchemist/z/-50 Agralem/Name/Agralem
GargishRouser,1 greatape,true GargishRefugee/hue/34532
```
Taken literally these invent creatures that do not exist *and* split real ones in
two, because `Fairy` and `Fairy,{RND,4,8}` slug apart into separate entries. 71 of
845 were affected. Everything from the first `/` or `,` is stripped, leaving 800
real creatures.
**Case is inconsistent across files.** The same creature is `Lizardman` in one
file and `lizardman` in another. Slugging collapses them correctly, but the
display name is chosen deterministically — most common spelling wins, ties break
to the more capitalised form, then alphabetically — because otherwise it would
depend on file read order and change on an unrelated restart.
## Tables
All are **import-owned**: a refresh empties and reloads them in one transaction,
so a failed reload leaves the previous atlas intact rather than a half-loaded
world. Nothing else writes to them and nothing holds a foreign key to them — no
FKs at all, consistent with every other `shard_*` table. Full column listings in
[`BACKEND_DESIGN.md`](BACKEND_DESIGN.md).
| Table | Rows (stock) | Notes |
|---|---|---|
| `shard_spawn_creatures` | 800 | `slug` PK; `total` = sum of each type's own max; nullable `art` |
| `shard_spawn_points` | 6,455 | `spawn_range`, since `range` is reserved in MariaDB |
| `shard_spawn_point_types` | 23,927 | The many-to-many; one spawner commonly carries six types |
| `shard_regions` | 387 | Flattened out of the nesting; `rects` JSON |
| `shard_landmarks` | 558 | `grp`, since `group` is reserved in SQL |
| `shard_champion_spawns` | 25 | Configured altars, not the live feed |
| `shard_atlas_meta` | 1 | Singleton; source hashes, for the change check |
| `shard_atlas_pending` | 01 | Singleton; a staged refresh awaiting admin review |
`shard_spawn_creatures.name` carries a plain `INDEX`, deliberately **not
`FULLTEXT`**: ~800 rows makes a `LIKE` scan free, and FULLTEXT's minimum token
length would break searches for names like "orc".
The reload uses `DELETE`, not `TRUNCATE``TRUNCATE` is DDL in MariaDB and would
implicitly commit, defeating the all-or-nothing guarantee. Point ids are assigned
explicitly rather than left to `AUTO_INCREMENT`, because the join rows need them
and `conn.batch()` reports no usable `insertId`.
## Artwork — operator-supplied, never shipped
**This project ships no creature art and no extraction tooling, and never will.**
UO sprites live in the operator's own client `.mul`/`.uop` files. They are the
operator's, not ours to redistribute.
The atlas is fully functional as text. `shard_spawn_creatures.art` is nullable
and is NULL on every fresh import; pages render without images, which is the
normal and supported state, not a degraded one.
An operator who wants art:
1. Extracts it from **their own** client files (UOFiddler, ClassicUO tooling, or
any art extractor).
2. Drops the images under `server/uploads/atlas/`.
3. Copies `server/db/data/spawnAtlas.art.example.json` to `spawnAtlas.art.json`
and maps creature slugs to file names.
4. Restarts, or runs `npm run atlas:import -- --force`.
Both `spawnAtlas.art.json` and `server/uploads/` are gitignored, so neither the
map nor the images can be committed by accident.
## Code layout
| File | Role |
|---|---|
| `src/utils/spawnAtlasParse.js` | **Pure and fs-free** parsers, so CI covers them with no ServUO tree. Zero dependencies. |
| `src/utils/spawnAtlasSource.js` | The only thing that reads a ServUO tree; shared by the boot path and the CLI |
| `src/model/shardAtlas/shardAtlas.db.js` | The one-transaction replace |
| `src/model/shardAtlas/shardAtlas.model.js` | The refresh decision, staging, approve/reject |
| `scripts/importSpawnAtlas.js` | Thin CLI over the model |
Parsing notes:
- `Regions.xml`, `Locations/*.xml` and `ChampionSpawns.xml` genuinely nest, and
get a small hand-rolled **subset** tokenizer — elements, attributes,
self-closing tags, comments, the XML declaration, CDATA, and the five
predefined entities plus numeric refs. It is not a general-purpose XML parser
and must not be reused as one.
- The ~10.5 MB of `Spawns/*.xml` never touches that tokenizer. Those records are
flat, so they get a streaming regex sweep instead; a DOM would allocate a node
per element across ~40 fields on every record to keep 14 of them. **Do not put
the Points files through a DOM parser.**
- `<Objects2>` is `Type:MX=n:SB=…` segments joined by `:OBJ=`. Split on `:OBJ=`
*first* — a naive `split(':')` shreds it. A single Trammel point carries six
types.
- **Respawn delays are stored in two different units, per record.** XmlSpawner
writes `MinDelay`/`MaxDelay` in minutes, and switches to seconds only when a
spawner's delay does not divide into whole minutes — flagging that with
`DelayInSec` on the same record. A `5` therefore means five *minutes* on one
spawner and five *seconds* on the next, and both are plausible respawn times,
so a reader assuming either unit is silently wrong about the other. Stock
ServUO 57.4 has ~170 second-flagged spawners out of 6,455. The parser
normalises everything to **seconds**; the API and UI carry seconds throughout.
### The parser version
`spawnAtlasSource.js` exports `PARSER_VERSION`, stored in `shard_atlas_meta`
alongside the source hashes and bumped whenever the parser derives **different
data from identical files** — a fixed misreading, a new field, a changed unit.
A refresh re-derives when the tree changed **or** the parser did. Hashing the
tree alone would be a trap: an install whose maps never change would keep serving
whatever an older build derived, indefinitely, and a deploy that corrects the
parse would never reach the data. A version mismatch counts as drift, so the
correction lands on the next boot without an operator having to know it happened.
## The API
Everything is served from MariaDB. Nothing on this path touches the sidecar, so
the pages stay complete while the shard is down — which is why the routes sit at
`/api/v1/public/atlas` and **not** under `/public/shard`, where a prefix means
"sidecar-dependent". Unlike `/shard/*`, they *are* `siteMode`-gated, like
`/posts` and `/wiki`: a bestiary is site content and follows site content's rules.
Every route carries `requireFeature('atlas')`**404** when an admin has
disabled the feature (its pages must not reveal that it exists) and **403** when
the caller sits below its configured audience. The default is `anonymous`, so the
gates are inert until an admin changes something. Responses are field-projected
like every other shard read; `atlas` declares no sensitive fields today, and the
projection call is there so the first one that does is covered by construction
rather than by a retrofit ([`v3.md` §3.6.1](../link/v3.md)).
| Route | Answers |
|---|---|
| `GET /atlas/creatures?q=&facet=&limit=&offset=` | The bestiary, most numerous first, paginated with an unpaginated `total` |
| `GET /atlas/creatures/:slug?facet=&points=` | One creature: `places`, `spawners`, `alsoHere` |
| `GET /atlas/regions?facet=&q=` | Named regions and their rectangles |
| `GET /atlas/landmarks?facet=&q=` | Points of interest, labelled by `group` |
| `GET /atlas/champions?facet=` | The configured altar roster |
| `GET /atlas/meta` | Facets, counts and when the atlas was parsed |
Two shapes worth knowing:
- **`places` is the aggregate the atlas exists for.** "Lizardman → Shrines,
Isamu-Jima, Yew", grouped in SQL rather than by summing 6,455 point rows in
Node. `spawners` is the raw list underneath it, bounded, with
`spawnersTruncated` saying when it was cut.
- **`points` is a COUNT, `spawners` is the LIST.** The two are named apart
deliberately: the same key meaning a number on the search route and an array on
the detail route is the kind of thing a client only discovers in production.
`GET /atlas/meta` reports the **game world only**. The ServUO path, the per-file
hashes and any pending refresh describe the operator's filesystem, and live on
the admin route instead.
A facet is never validated against a list — nothing in the codebase names one.
`?facet=` is length-bounded and matched exactly, so an unknown name returns an
empty result rather than an error. The filter is an `EXISTS` over the points and
deliberately not a JSON path or `JSON_SEARCH` built from caller input: that
function treats `%` and `_` as wildcards, which would make `?facet=%` match
everything.
## The admin panel
**Admin → Spawn Atlas** (`/admin/shard-atlas`, admin-only — it reads a path on
the server's filesystem and replaces every atlas table, which is closer to a
deploy action than to moderation).
| Route | Does |
|---|---|
| `GET /admin/shard/atlas` | Status: path, readable, drift, counts, facets, pending |
| `POST /admin/shard/atlas/import` | Import now; `{ force: true }` ignores the hash gate |
| `POST /admin/shard/atlas/approve` | Apply a staged refresh, facet loss and all |
| `POST /admin/shard/atlas/reject` | Keep the current atlas; remember the decision |
| `PUT /admin/shard/atlas/path` | Point the atlas at a different tree |
Three behaviours that are deliberate:
- **An unreadable tree is a 200, not a 500.** `refresh()` reports outcomes rather
than throwing, because the boot path must never be stopped by a bad tree, and
that contract is preserved at the API. The panel says *"The tree could not be
read: …"*; a 500 would say only that something broke.
- **Setting the path does not import.** Moving the mount and reloading the world
are separate decisions, and an operator fixing a typo should not have a
multi-thousand-row replace happen under them. The response carries fresh status
so the panel can offer the import as the next step.
- **Every action is written to the admin activity log** (`shard.atlas.import` /
`.approve` / `.reject` / `.path`).

View File

@@ -0,0 +1,259 @@
# Trusted Devices & MFA Improvements — Design & Implementation Plan
> Reference plan for the trusted-device + MFA hardening work. Approved 2026-07-21.
> This document is the contract the implementation builds against; keep it in sync
> with `BACKEND_DESIGN.md` (§3 schema, §4 API, §6 security) as code lands.
## 1. Goal & scope
Reduce 2FA friction without weakening the second-factor boundary, and close the
2FA-lockout gap. Four deliverables:
1. **Trusted devices** — an opt-in "Trust this device" that lets a browser or the
Android app **skip the TOTP step** (never the password) on future logins for a
fixed window.
2. **Recovery / backup codes** — single-use codes generated at 2FA enrollment so a
user who loses their authenticator can self-recover instead of needing an admin
reset.
3. **Admin-managed revocation** — staff can view and revoke a user's trusted
devices and reset their MFA, with full audit logging (**backend endpoints _and_
admin front-end screens**).
4. **Step-up (password) for sensitive operations** — reusing the existing
`currentPassword`-verification pattern; disabling TOTP keeps its stronger
current-TOTP-code requirement.
Touches `website/` (server + client), `docs/`, and `android-app/` (plan only in
this pass). **No** `link/` or `servuo-plugins/` change — no wire-protocol impact.
### Approved decisions
| Decision | Value |
|---|---|
| Trust duration | **30 days** (matches mobile refresh-token lifetime) |
| Roles eligible | **All roles** (no staff carve-out) |
| Opt-in model | **Explicit "Trust this device" checkbox, default off** |
| Recovery codes | **10 codes**, shown **once**, single-use |
| Trusted-device cap | **10 per user, no silent pruning** (see §5) |
| Trust-token hashing | **sha256** |
| Recovery-code hashing | **bcrypt** (cost 10) |
## 2. Current state (starting point)
- **One session service** (`server/src/auth/session.service.js`) backs web (JWT
`httpOnly` cookie, 1d) and mobile (15m access JWT + 30d opaque refresh token).
`requireAuth` accepts either via `token.extractToken()`.
- **TOTP** is opt-in per user (`users.totp_secret` / `totp_enabled`), demanded on
**every** login. Web uses a staged 5-min `stage:'totp'` challenge; mobile uses a
single-request `401 { totpRequired }`. **No recovery codes** exist today.
- **Device tracking exists only on mobile** (`mobile_refresh_tokens` rows with
`device_name` / `device_hash` / `user_agent` / `last_used_at`). Web JWTs are
stateless with no per-session row.
- **Revocation is mature:** `revoked_sessions` (jti denylist) + `tokens_valid_after`
(per-user cutoff) for web; per-token rows + `revokeAllForUser` for mobile.
- **No trusted-device or step-up concept exists anywhere.**
## 3. Hashing rationale
The repo already splits hashing by secret entropy, and this plan follows it:
- **sha256** — every high-entropy machine-generated opaque token
(`mobile_refresh_tokens`, `mobile_auth_codes`, `user_invites`, `password_resets`,
SSO PKCE). **Trusted-device tokens use sha256:** they are 256-bit random values
(nothing to brute-force) looked up **by a `token_hash UNIQUE` index**, which
requires a deterministic hash — bcrypt's per-row salt would break the lookup and
truncates input at 72 bytes.
- **bcrypt (`bcryptjs`, cost 10)** — the repo uses it only for **passwords**, the
one human-chosen low-entropy secret. **Recovery codes use bcrypt:** they are a
human-typed, lower-entropy fallback credential that grants a login (the closest
analogue to a password), and there is no hash-lookup constraint — we fetch the
identified user's ≤10 code rows and `bcrypt.compare` each, exactly like password
verification.
## 4. Database (additive, idempotent — matches `schema.sql` style)
### `trusted_devices`
Pattern-identical to `mobile_refresh_tokens`; stores only the token hash.
| Column | Type | Notes |
|---|---|---|
| id | INT PK AUTO_INCREMENT | |
| user_id | INT NOT NULL | FK → users, `ON DELETE CASCADE` |
| token_hash | CHAR(64) NOT NULL UNIQUE | sha256 hex of the opaque trust token |
| platform | ENUM('web','mobile') NOT NULL DEFAULT 'web' | |
| device_name | VARCHAR(100) NULL | friendly label |
| device_hash | VARCHAR(32) NULL | best-effort UA+IP, **display only** |
| user_agent | VARCHAR(255) NULL | |
| created_at | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | |
| last_used_at | DATETIME NULL | stamped when trust is honored at login |
| expires_at | DATETIME NOT NULL | created_at + 30d |
| revoked_at | DATETIME NULL | |
Indices: `idx_td_user (user_id)`, `idx_td_expires (expires_at)`.
### `mobile_auth_sessions.trust_device` (SSO bridge)
| Column | Type | Notes |
|---|---|---|
| trust_device | TINYINT(1) NOT NULL DEFAULT 0 | user ticked "trust this device" on the Custom Tab TOTP form |
A **boolean only**. It records the user's choice so `POST /auth/mobile/sso/exchange`
knows to mint the app's own trust token over that authenticated app→server call; the
token itself is never written here (only its sha256 reaches `trusted_devices`). Set
only while the session is still `pending` and unexpired, for the same reason
`completeSession` is guarded — a replayed TOTP post must not re-arm a consumed
session.
### `recovery_codes`
| Column | Type | Notes |
|---|---|---|
| id | INT PK AUTO_INCREMENT | |
| user_id | INT NOT NULL | FK → users, `ON DELETE CASCADE` |
| code_hash | VARCHAR(72) NOT NULL | **bcrypt** hash of one code |
| used_at | DATETIME NULL | single-use marker |
| created_at | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | |
Index: `idx_rc_user (user_id)`.
No new `users` column: password change/reset and TOTP-disable **bulk-revoke**
`trusted_devices` rows and **delete** `recovery_codes` (consistent with
`revokeAllForUser`), so no "trust epoch" column is needed.
## 5. Trusted-device cap — no silent pruning
Cap = **10**. A shared `assertUnderTrustCap(userId)` guards both entry points (the
login/TOTP trust path and the authenticated "trust this device" path). On the 11th
attempt the backend **refuses to create the row** and returns
`409 { error: 'trusted_device_limit', devices: [...] }`. Login itself still
succeeds — only the trust marker is withheld. The web client then renders a modal
**in the same visual pattern as the TOTP entry flow** that:
1. shows the existing trusted devices,
2. requires revoking ≥1 before continuing,
3. completes via `POST /auth/me/trusted-devices` (trust current device), and
4. offers **Cancel**, which returns without creating any trust entry.
## 6. API additions
### Auth (login paths)
- `POST /auth/login` — after password verify, if a valid unrevoked `rg_trust`
cookie matches a live `trusted_devices` row for this user → **skip TOTP**, issue
the session, log `auth.login.trusted_device`, stamp `last_used_at`. Otherwise
unchanged (`{ totpRequired, challenge }`).
- `POST /auth/login/totp` — gains optional `trustDevice` + `deviceName`, and
accepts a **recovery code** as an alternative to the TOTP code (single-use). On
success with `trustDevice`, mint the opaque trust token, set the `rg_trust`
cookie, insert the row (subject to the cap → `409` signal).
- `POST /auth/mobile/login` — gains `trustDevice` / `recoveryCode`; returns a
`trustToken` the app stores in EncryptedSharedPreferences and replays on a later
login to skip TOTP. Same cap behavior.
#### SSO login paths
SSO is **not** exempt: an account with TOTP on is asked for a code after a
Google/Discord sign-in exactly as it is after a password one, and a trusted device
skips that code exactly the same way. (Originally SSO consulted trust nowhere, so a
user who signed in with an external identity was asked for a code on *every* sign-in
no matter how many times they had ticked "trust this device".)
- `GET /auth/sso/:provider/callback` — once the account is resolved and before a
TOTP challenge is staged, resolve the presented trust (cookie, or `X-Trust-Token`)
and, if it belongs to **this** user, skip the code, stamp `last_used_at`, and log
`auth.login.trusted_device`. The first factor is the IdP authentication that just
succeeded, so this is the same posture as the password path. A store error falls
through to the challenge — fail **closed** to asking for the code.
- `POST /auth/sso/totp` — gains optional `trustDevice` + `deviceName`, mints the
trust and sets the `rg_trust` cookie on success. At the cap the sign-in still
completes and the response carries `{ trustLimitReached, devices }`, matching
`POST /auth/login/totp`. Recovery codes remain password-login only: this step
verifies an authenticator code against the staged challenge.
**How this reaches the Android app.** The app's SSO runs in a Custom Tab, which
shares the system browser's cookie jar, so both halves land in the same place: the
`rg_trust` cookie set on the Custom Tab TOTP form is presented back on the *next*
app SSO sign-in and skips the code — no app change, and no trust token smuggled
through a start URL where it would leak into query strings and logs.
To cover the app's **native** password login on the same device as well, ticking
the box also sets `mobile_auth_sessions.trust_device` (a boolean — never the
token), and `POST /auth/mobile/sso/exchange` then mints a `platform: 'mobile'`
trust and returns `{ trustToken }` in its JSON body. Minting at exchange time is
deliberate: it is an authenticated app→server call, so the raw token never travels
in the deep link and never rests in the bridge row. One tick therefore produces two
independently-revocable rows (the browser and the app) — which is honest, since they
are two distinct credentials on one device. If the user is at the cap, the exchange
simply returns no token; it never turns a successful sign-in into an error.
### Self-service (`/auth/me/*`, `requireAuth`, any role)
- `GET /auth/me/trusted-devices` — list active trusted devices (never tokens).
- `POST /auth/me/trusted-devices` — trust the current browser/device (cap-checked).
- `DELETE /auth/me/trusted-devices/:id` — revoke one (ownership-scoped).
- `DELETE /auth/me/trusted-devices` — revoke all ("untrust everywhere").
- `POST /auth/me/account/recovery-codes/generate`**password step-up required**;
returns the codes **once**.
- `GET /auth/me/account/recovery-codes/status` — remaining count only.
### Admin (`requireRole('admin')`)
- `GET /admin/users/:id/trusted-devices` — list a user's trusted devices.
- `DELETE /admin/users/:id/trusted-devices/:deviceId` — revoke one.
- `DELETE /admin/users/:id/trusted-devices` — revoke all.
- MFA reset control (revoke trust + disable TOTP + clear recovery codes).
## 7. Cookie / refresh / JWT interaction
- New **`rg_trust`** cookie: `httpOnly`, `sameSite=Lax`, `secure` per-request
(reuse `cookieSecure`), `path=/`, `maxAge` 30d, opaque 256-bit base64url,
sha256-hashed server-side. **Separate from the session cookie and deliberately
survives logout** (so the next login skips 2FA); only untrust / password-change /
TOTP-disable revoke it.
- **JWTs stay stateless and unchanged** — trust is a server-side cookie+row, never
a JWT claim, so it remains revocable.
- **Refresh flow untouched** — trust is consulted only at the login/password step,
never at token refresh; the two stores stay independent.
## 8. Security & invalidation
- Trust **only ever gates the second factor**; password is always required.
- Recovery-code entry reuses the login brute-force stack (backoff + bot scoring +
rate limits); recovery codes are single-use.
- **Password change/reset and TOTP-disable clear trust and recovery codes.**
- **Audit logging** via existing `activity.log` / `activity_log`:
`auth.login.trusted_device`, `account.trusted_device.add` / `.revoke` /
`.revoke_all`, `account.recovery_codes.generate`, `account.recovery_code.consume`,
and admin `admin.trusted_device.revoke` / `.revoke_all`, `admin.user.totp.reset`
— each with actor, target user, and device id in `detail`.
## 9. Backwards compatibility
Fully additive. With no `rg_trust` cookie the behavior is exactly today's (TOTP
every login). Recovery codes exist only for users who generate them. No existing
session or login flow changes shape. New tables via `CREATE TABLE IF NOT EXISTS`
and columns via `ALTER TABLE … ADD COLUMN IF NOT EXISTS`.
## 10. Implementation roadmap
1. **Schema + models**`trusted_devices` (sha256, cap-checked) + `recovery_codes`
(bcrypt); `.db.js` / `.model.js` pairs mirroring `mobileSessions`.
2. **Session service** — trust-token mint/sha256/verify + recovery-code
generate/bcrypt-verify/consume helpers (pure, DB-free); shared
`assertUnderTrustCap()`.
3. **Web login** — trust-cookie skip in `/auth/login`; `trustDevice` / recovery
handling + cap `409` in `/auth/login/totp`; set/clear `rg_trust`.
4. **Mobile login**`trustDevice` / `trustToken` / `recoveryCode`, same cap.
5. **Self-service + admin backend**`/auth/me/trusted-devices*` + recovery-code
endpoints; `/admin/users/:id/trusted-devices*` + MFA reset (all admin-gated).
6. **Invalidation wiring** — password change/reset & TOTP-disable revoke trust +
delete recovery codes.
7. **Web client UI** — "Trust this device" checkbox; cap-reached TOTP-styled modal
(revoke-to-continue / cancel); user Trusted Devices + Recovery Codes screens.
8. **Admin front-end UI** — admin Trusted Devices management & revocation screens
(per-user list, revoke one / revoke all, MFA reset), wired to step 5.
9. **Android** — record the app-side trust/recovery flow in
`docs/android/PLAN.md`; app implementation sequenced after the backend lands.
10. **OpenAPI + docs**`#swagger.*` on every new/modified route + regenerate
`server/swagger/swagger-output.json`; update `BACKEND_DESIGN.md` §3/§4/§6.
11. **Automated tests** — trusted-device login skip (valid / missing / expired /
revoked), token mint+hash, recovery-code single-use consume + wrong-code
backoff, cap `409` behavior, revocation (self + admin), invalidation on
password-change / TOTP-disable, and permission checks (admin routes reject
non-admins; self routes ownership-scoped); web client pure-logic tests; Android
JVM DTO/repository tests.

View File

@@ -54,7 +54,7 @@ auth model (admin/editor — no new roles, no public contributions).
| Schema | flat `wiki_pages(slug,title,body,updated_by,timestamps)` | [server/db/schema.sql:31](server/db/schema.sql) |
| Model | thin CRUD by slug | [server/src/model/wiki/wiki.db.js](server/src/model/wiki/wiki.db.js), [wiki.model.js](server/src/model/wiki/wiki.model.js) |
| Public API | `GET /public/wiki`, `GET /public/wiki/:slug` | [public.controller.js:53](server/src/router/v1/public/public.controller.js) |
| Admin API | `GET/POST/PUT/DELETE /admin/wiki[...]` | [admin.controller.js:163](server/src/router/v1/admin/admin.controller.js), [admin.routes.js:68](server/src/router/v1/admin/admin.routes.js) |
| Admin API | `GET/POST/PUT/DELETE /admin/wiki[...]` | [admin.controller.js:163](server/src/router/v1/admin/admin.controller.js), [wiki.router.js](server/src/router/v1/admin/wiki.router.js) |
| Public UI | card grid (hardcoded blurbs + Roman numerals), article w/ auto-TOC | [Wiki.jsx](client/src/routes/wiki/Wiki.jsx), [WikiArticle.jsx](client/src/routes/wiki/WikiArticle.jsx) |
| Admin UI | raw-HTML `<textarea>` modal | [WikiAdmin.jsx](client/src/routes/admin/views/WikiAdmin.jsx), [WikiEditor.jsx](client/src/routes/admin/views/WikiEditor.jsx) |
| API client | `api.wiki`, `api.admin.*Wiki` | [client/src/api/client.js:52](client/src/api/client.js) |
@@ -196,7 +196,7 @@ centralized error handler unchanged. **Every write logs to `activity_log`**
### 4.4 Image uploads
Generalize the existing screenshot upload (multer config in [admin.routes.js:17](server/src/router/v1/admin/admin.routes.js))
Generalize the existing screenshot upload (multer config now in [admin/imageUpload.js](server/src/router/v1/admin/imageUpload.js))
into a shared `POST /admin/uploads` returning `{ url: "/uploads/<file>" }`, reused by both
the post editor and the wiki editor. Same size/mime limits. No new storage —
served from the existing `uploads/` volume.

View File

@@ -0,0 +1,875 @@
{
"$comment": "Generated route inventory - the authoritative freeze of the URL surface. Regenerate with `npm run routes:manifest` in website/server; a domain-split PR must produce a zero-line diff here.",
"public": [
{
"method": "GET",
"path": "/.well-known/assetlinks.json"
},
{
"method": "POST",
"path": "/api/csp-report"
},
{
"method": "GET",
"path": "/api/docs.json"
},
{
"method": "GET",
"path": "/api/health"
},
{
"method": "GET",
"path": "/api/v1/admin/account"
},
{
"method": "GET",
"path": "/api/v1/admin/account/identities"
},
{
"method": "DELETE",
"path": "/api/v1/admin/account/identities/:provider"
},
{
"method": "POST",
"path": "/api/v1/admin/account/totp/disable"
},
{
"method": "POST",
"path": "/api/v1/admin/account/totp/enable"
},
{
"method": "POST",
"path": "/api/v1/admin/account/totp/setup"
},
{
"method": "GET",
"path": "/api/v1/admin/activity"
},
{
"method": "GET",
"path": "/api/v1/admin/auth/providers"
},
{
"method": "POST",
"path": "/api/v1/admin/auth/providers"
},
{
"method": "DELETE",
"path": "/api/v1/admin/auth/providers/:id"
},
{
"method": "PUT",
"path": "/api/v1/admin/auth/providers/:id"
},
{
"method": "GET",
"path": "/api/v1/admin/bot-activity"
},
{
"method": "POST",
"path": "/api/v1/admin/bot-activity/unban"
},
{
"method": "GET",
"path": "/api/v1/admin/dashboard"
},
{
"method": "GET",
"path": "/api/v1/admin/discord-bot/config"
},
{
"method": "PUT",
"path": "/api/v1/admin/discord-bot/config"
},
{
"method": "GET",
"path": "/api/v1/admin/email/config"
},
{
"method": "PUT",
"path": "/api/v1/admin/email/config"
},
{
"method": "GET",
"path": "/api/v1/admin/email/connect/callback"
},
{
"method": "GET",
"path": "/api/v1/admin/email/connect/start"
},
{
"method": "POST",
"path": "/api/v1/admin/email/disconnect"
},
{
"method": "POST",
"path": "/api/v1/admin/email/test"
},
{
"method": "GET",
"path": "/api/v1/admin/invites"
},
{
"method": "POST",
"path": "/api/v1/admin/invites"
},
{
"method": "DELETE",
"path": "/api/v1/admin/invites/:id"
},
{
"method": "GET",
"path": "/api/v1/admin/moderation/appeals"
},
{
"method": "GET",
"path": "/api/v1/admin/moderation/appeals/:id"
},
{
"method": "POST",
"path": "/api/v1/admin/moderation/appeals/:id/claim"
},
{
"method": "POST",
"path": "/api/v1/admin/moderation/appeals/:id/resolve"
},
{
"method": "GET",
"path": "/api/v1/admin/moderation/filter-hits"
},
{
"method": "GET",
"path": "/api/v1/admin/moderation/members"
},
{
"method": "GET",
"path": "/api/v1/admin/moderation/recent"
},
{
"method": "GET",
"path": "/api/v1/admin/moderation/search"
},
{
"method": "GET",
"path": "/api/v1/admin/moderation/spam-hits"
},
{
"method": "GET",
"path": "/api/v1/admin/moderation/stats/summary"
},
{
"method": "GET",
"path": "/api/v1/admin/moderation/user/:discordId"
},
{
"method": "GET",
"path": "/api/v1/admin/moderation/user/:discordId/actions"
},
{
"method": "GET",
"path": "/api/v1/admin/moderation/user/:discordId/appeals"
},
{
"method": "GET",
"path": "/api/v1/admin/moderation/user/:discordId/notes"
},
{
"method": "POST",
"path": "/api/v1/admin/moderation/user/:discordId/notes"
},
{
"method": "GET",
"path": "/api/v1/admin/pages"
},
{
"method": "POST",
"path": "/api/v1/admin/pages"
},
{
"method": "DELETE",
"path": "/api/v1/admin/pages/:id"
},
{
"method": "GET",
"path": "/api/v1/admin/pages/:id"
},
{
"method": "PATCH",
"path": "/api/v1/admin/pages/:id"
},
{
"method": "POST",
"path": "/api/v1/admin/pages/:id/preview"
},
{
"method": "POST",
"path": "/api/v1/admin/pages/:id/unprotect"
},
{
"method": "GET",
"path": "/api/v1/admin/posts"
},
{
"method": "POST",
"path": "/api/v1/admin/posts"
},
{
"method": "DELETE",
"path": "/api/v1/admin/posts/:id"
},
{
"method": "GET",
"path": "/api/v1/admin/posts/:id"
},
{
"method": "PUT",
"path": "/api/v1/admin/posts/:id"
},
{
"method": "GET",
"path": "/api/v1/admin/posts/:id/announce"
},
{
"method": "POST",
"path": "/api/v1/admin/posts/:id/announce/retry"
},
{
"method": "PATCH",
"path": "/api/v1/admin/posts/:id/publish"
},
{
"method": "POST",
"path": "/api/v1/admin/posts/upload"
},
{
"method": "GET",
"path": "/api/v1/admin/settings"
},
{
"method": "PUT",
"path": "/api/v1/admin/settings"
},
{
"method": "POST",
"path": "/api/v1/admin/shard/account"
},
{
"method": "GET",
"path": "/api/v1/admin/shard/accounts"
},
{
"method": "GET",
"path": "/api/v1/admin/shard/atlas"
},
{
"method": "POST",
"path": "/api/v1/admin/shard/atlas/approve"
},
{
"method": "POST",
"path": "/api/v1/admin/shard/atlas/import"
},
{
"method": "PUT",
"path": "/api/v1/admin/shard/atlas/path"
},
{
"method": "POST",
"path": "/api/v1/admin/shard/atlas/reject"
},
{
"method": "GET",
"path": "/api/v1/admin/shard/audit"
},
{
"method": "POST",
"path": "/api/v1/admin/shard/ban"
},
{
"method": "POST",
"path": "/api/v1/admin/shard/broadcast"
},
{
"method": "GET",
"path": "/api/v1/admin/shard/char/:serial"
},
{
"method": "GET",
"path": "/api/v1/admin/shard/houses"
},
{
"method": "POST",
"path": "/api/v1/admin/shard/kick"
},
{
"method": "POST",
"path": "/api/v1/admin/shard/link"
},
{
"method": "GET",
"path": "/api/v1/admin/shard/pages"
},
{
"method": "POST",
"path": "/api/v1/admin/shard/pages/:id/close"
},
{
"method": "POST",
"path": "/api/v1/admin/shard/pages/:id/respond"
},
{
"method": "GET",
"path": "/api/v1/admin/shard/roster/:account"
},
{
"method": "GET",
"path": "/api/v1/admin/shard/sales"
},
{
"method": "POST",
"path": "/api/v1/admin/shard/unban"
},
{
"method": "GET",
"path": "/api/v1/admin/shard/vendors/:account"
},
{
"method": "GET",
"path": "/api/v1/admin/shard/visibility"
},
{
"method": "PUT",
"path": "/api/v1/admin/shard/visibility"
},
{
"method": "PUT",
"path": "/api/v1/admin/site-mode"
},
{
"method": "GET",
"path": "/api/v1/admin/uo-link/config"
},
{
"method": "PUT",
"path": "/api/v1/admin/uo-link/config"
},
{
"method": "GET",
"path": "/api/v1/admin/uo-link/stream"
},
{
"method": "POST",
"path": "/api/v1/admin/uo-link/towncrier"
},
{
"method": "DELETE",
"path": "/api/v1/admin/uo-link/towncrier/:id"
},
{
"method": "POST",
"path": "/api/v1/admin/uploads"
},
{
"method": "GET",
"path": "/api/v1/admin/users"
},
{
"method": "POST",
"path": "/api/v1/admin/users"
},
{
"method": "DELETE",
"path": "/api/v1/admin/users/:id"
},
{
"method": "GET",
"path": "/api/v1/admin/users/:id"
},
{
"method": "PUT",
"path": "/api/v1/admin/users/:id"
},
{
"method": "POST",
"path": "/api/v1/admin/users/:id/mfa/reset"
},
{
"method": "GET",
"path": "/api/v1/admin/users/:id/shard/accounts"
},
{
"method": "GET",
"path": "/api/v1/admin/users/:id/shard/houses"
},
{
"method": "DELETE",
"path": "/api/v1/admin/users/:id/shard/link/:account"
},
{
"method": "GET",
"path": "/api/v1/admin/users/:id/shard/online"
},
{
"method": "GET",
"path": "/api/v1/admin/users/:id/shard/sales"
},
{
"method": "GET",
"path": "/api/v1/admin/users/:id/shard/standing"
},
{
"method": "DELETE",
"path": "/api/v1/admin/users/:id/trusted-devices"
},
{
"method": "GET",
"path": "/api/v1/admin/users/:id/trusted-devices"
},
{
"method": "DELETE",
"path": "/api/v1/admin/users/:id/trusted-devices/:deviceId"
},
{
"method": "GET",
"path": "/api/v1/admin/wiki"
},
{
"method": "POST",
"path": "/api/v1/admin/wiki"
},
{
"method": "DELETE",
"path": "/api/v1/admin/wiki/:slug"
},
{
"method": "GET",
"path": "/api/v1/admin/wiki/:slug"
},
{
"method": "PUT",
"path": "/api/v1/admin/wiki/:slug"
},
{
"method": "PATCH",
"path": "/api/v1/admin/wiki/:slug/publish"
},
{
"method": "GET",
"path": "/api/v1/admin/wiki/:slug/revisions"
},
{
"method": "GET",
"path": "/api/v1/admin/wiki/:slug/revisions/:id"
},
{
"method": "POST",
"path": "/api/v1/admin/wiki/:slug/revisions/:id/restore"
},
{
"method": "GET",
"path": "/api/v1/admin/wiki/categories"
},
{
"method": "POST",
"path": "/api/v1/admin/wiki/categories"
},
{
"method": "DELETE",
"path": "/api/v1/admin/wiki/categories/:id"
},
{
"method": "PUT",
"path": "/api/v1/admin/wiki/categories/:id"
},
{
"method": "GET",
"path": "/api/v1/admin/wiki/tags"
},
{
"method": "GET",
"path": "/api/v1/auth/invite/:token"
},
{
"method": "POST",
"path": "/api/v1/auth/invite/:token/accept"
},
{
"method": "POST",
"path": "/api/v1/auth/login"
},
{
"method": "POST",
"path": "/api/v1/auth/login/totp"
},
{
"method": "POST",
"path": "/api/v1/auth/logout"
},
{
"method": "GET",
"path": "/api/v1/auth/me"
},
{
"method": "GET",
"path": "/api/v1/auth/me/account"
},
{
"method": "GET",
"path": "/api/v1/auth/me/account/identities"
},
{
"method": "DELETE",
"path": "/api/v1/auth/me/account/identities/:provider"
},
{
"method": "PATCH",
"path": "/api/v1/auth/me/account/password"
},
{
"method": "POST",
"path": "/api/v1/auth/me/account/recovery-codes/generate"
},
{
"method": "GET",
"path": "/api/v1/auth/me/account/recovery-codes/status"
},
{
"method": "POST",
"path": "/api/v1/auth/me/account/totp/disable"
},
{
"method": "POST",
"path": "/api/v1/auth/me/account/totp/enable"
},
{
"method": "POST",
"path": "/api/v1/auth/me/account/totp/setup"
},
{
"method": "PATCH",
"path": "/api/v1/auth/me/account/username"
},
{
"method": "GET",
"path": "/api/v1/auth/me/devices"
},
{
"method": "POST",
"path": "/api/v1/auth/me/devices"
},
{
"method": "DELETE",
"path": "/api/v1/auth/me/devices/:id"
},
{
"method": "GET",
"path": "/api/v1/auth/me/notifications/streams"
},
{
"method": "GET",
"path": "/api/v1/auth/me/notifications/subscriptions"
},
{
"method": "PUT",
"path": "/api/v1/auth/me/notifications/subscriptions"
},
{
"method": "GET",
"path": "/api/v1/auth/me/sessions"
},
{
"method": "DELETE",
"path": "/api/v1/auth/me/sessions/:id"
},
{
"method": "DELETE",
"path": "/api/v1/auth/me/trusted-devices"
},
{
"method": "GET",
"path": "/api/v1/auth/me/trusted-devices"
},
{
"method": "POST",
"path": "/api/v1/auth/me/trusted-devices"
},
{
"method": "DELETE",
"path": "/api/v1/auth/me/trusted-devices/:id"
},
{
"method": "POST",
"path": "/api/v1/auth/mobile/login"
},
{
"method": "POST",
"path": "/api/v1/auth/mobile/logout"
},
{
"method": "POST",
"path": "/api/v1/auth/mobile/refresh"
},
{
"method": "POST",
"path": "/api/v1/auth/mobile/sso/exchange"
},
{
"method": "GET",
"path": "/api/v1/auth/mobile/sso/start"
},
{
"method": "POST",
"path": "/api/v1/auth/password/forgot"
},
{
"method": "GET",
"path": "/api/v1/auth/password/reset/:token"
},
{
"method": "POST",
"path": "/api/v1/auth/password/reset/:token"
},
{
"method": "GET",
"path": "/api/v1/auth/providers"
},
{
"method": "POST",
"path": "/api/v1/auth/register"
},
{
"method": "GET",
"path": "/api/v1/auth/sso/:provider/callback"
},
{
"method": "GET",
"path": "/api/v1/auth/sso/:provider/link"
},
{
"method": "GET",
"path": "/api/v1/auth/sso/:provider/start"
},
{
"method": "POST",
"path": "/api/v1/auth/sso/totp"
},
{
"method": "GET",
"path": "/api/v1/player/account"
},
{
"method": "GET",
"path": "/api/v1/player/account/identities"
},
{
"method": "DELETE",
"path": "/api/v1/player/account/identities/:provider"
},
{
"method": "PATCH",
"path": "/api/v1/player/account/password"
},
{
"method": "POST",
"path": "/api/v1/player/account/totp/disable"
},
{
"method": "POST",
"path": "/api/v1/player/account/totp/enable"
},
{
"method": "POST",
"path": "/api/v1/player/account/totp/setup"
},
{
"method": "PATCH",
"path": "/api/v1/player/account/username"
},
{
"method": "GET",
"path": "/api/v1/player/appeals"
},
{
"method": "POST",
"path": "/api/v1/player/appeals"
},
{
"method": "POST",
"path": "/api/v1/player/appeals/:id/withdraw"
},
{
"method": "GET",
"path": "/api/v1/player/appeals/eligible"
},
{
"method": "POST",
"path": "/api/v1/player/shard/account"
},
{
"method": "GET",
"path": "/api/v1/player/shard/accounts"
},
{
"method": "GET",
"path": "/api/v1/player/shard/char/:serial"
},
{
"method": "GET",
"path": "/api/v1/player/shard/houses"
},
{
"method": "POST",
"path": "/api/v1/player/shard/link"
},
{
"method": "GET",
"path": "/api/v1/player/shard/roster/:account"
},
{
"method": "GET",
"path": "/api/v1/player/shard/sales"
},
{
"method": "GET",
"path": "/api/v1/player/shard/vendors/:account"
},
{
"method": "GET",
"path": "/api/v1/public/atlas/champions"
},
{
"method": "GET",
"path": "/api/v1/public/atlas/creatures"
},
{
"method": "GET",
"path": "/api/v1/public/atlas/creatures/:slug"
},
{
"method": "GET",
"path": "/api/v1/public/atlas/landmarks"
},
{
"method": "GET",
"path": "/api/v1/public/atlas/meta"
},
{
"method": "GET",
"path": "/api/v1/public/atlas/regions"
},
{
"method": "POST",
"path": "/api/v1/public/contact"
},
{
"method": "GET",
"path": "/api/v1/public/pages/:id/preview/:token"
},
{
"method": "GET",
"path": "/api/v1/public/pages/:slug"
},
{
"method": "GET",
"path": "/api/v1/public/posts/:category"
},
{
"method": "GET",
"path": "/api/v1/public/posts/:category/:idOrSlug"
},
{
"method": "GET",
"path": "/api/v1/public/settings"
},
{
"method": "GET",
"path": "/api/v1/public/shard/champs"
},
{
"method": "GET",
"path": "/api/v1/public/shard/economy"
},
{
"method": "GET",
"path": "/api/v1/public/shard/features"
},
{
"method": "GET",
"path": "/api/v1/public/shard/feed"
},
{
"method": "GET",
"path": "/api/v1/public/shard/governors"
},
{
"method": "GET",
"path": "/api/v1/public/shard/governors/:city/history"
},
{
"method": "GET",
"path": "/api/v1/public/shard/guilds"
},
{
"method": "GET",
"path": "/api/v1/public/shard/houses"
},
{
"method": "GET",
"path": "/api/v1/public/shard/idoc"
},
{
"method": "GET",
"path": "/api/v1/public/shard/online"
},
{
"method": "GET",
"path": "/api/v1/public/shard/presence"
},
{
"method": "GET",
"path": "/api/v1/public/shard/ruleset"
},
{
"method": "GET",
"path": "/api/v1/public/shard/status"
},
{
"method": "GET",
"path": "/api/v1/public/shard/stream"
},
{
"method": "GET",
"path": "/api/v1/public/status"
},
{
"method": "GET",
"path": "/api/v1/public/version"
},
{
"method": "GET",
"path": "/api/v1/public/wiki"
},
{
"method": "GET",
"path": "/api/v1/public/wiki/:slug"
},
{
"method": "GET",
"path": "/api/v1/public/wiki/categories"
},
{
"method": "GET",
"path": "/api/v1/public/wiki/tags"
}
],
"internal": [
{
"method": "GET",
"path": "/health"
},
{
"method": "GET",
"path": "/internal/bot-config"
}
]
}

154
website/test-plan.md Normal file
View File

@@ -0,0 +1,154 @@
# Runic Gateway Website — Test Plan (Discord bot)
> Companion to [website-README.md](website-README.md) (overview) and
> [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (server API/schema/security contract).
> Establishes the test plan for the **`bot/` workspace** (the Discord bot), the
> last of the three `website` npm workspaces without a suite. The server and
> client suites already exist (website PR #86); this doc closes the bot gap and
> records the shared conventions so all three stay consistent.
## 1. Goal & philosophy
Cover **meaningful bot behavior** — the moderation/filter decisions a future
change could silently break — not a coverage number. The bot's value is in *what
it decides to delete, warn, mute, or let through*; a good test reads "given this
message + config, what action does the bot take?", never "did this function call
that function?".
The whole `website` repo tests on **Node's built-in runner (`node --test`)** with
**zero external test dependencies** — no jest, no vitest, no jsdom. Unit tests
run without a database or a live Discord gateway: the DB pool is pointed at a dead
port and every collaborator (`db.query`, a model, a Discord `message`/`client`) is
replaced with an in-memory fake. This mirrors the server suite exactly (the bot is
CommonJS, like the server — `require`/`module.exports` — so the same patterns
apply verbatim).
## 2. Current state
| Workspace | Runner | Status |
|---|---|---|
| `server/` | `node --test` | **Exists** — models, controllers, auth/session, shard ingest. |
| `client/` | `node --test` (pure-logic ESM) | **Exists** (PR #86) — `lib/`, `api/`, `data/`. |
| `bot/` | — | **This plan** — no runner or tests yet. |
The 46% SonarQube aggregate is dragged down by the bot (and the client's
un-unit-testable React components) reading as 0% covered. Standing up the bot
suite lifts the real number and, more importantly, locks the filter/moderation
rules.
## 3. Harness
Add a test script to `bot/package.json` (mirrors server/client):
```json
"scripts": { "test": "node --test" }
```
Conventions, identical to `server/test/`:
- **No DB.** Set `process.env.DB_HOST = '127.0.0.1'` / `DB_PORT = '59999'` at the
top of any test whose module transitively `require`s `../db`, so the pool is
built against a dead port and a stray query fails fast instead of hanging.
Models are exercised by monkeypatching `db.query` (or the model method the unit
under test calls) with an in-memory fake; restore it in `afterEach`.
- **No Discord.** The `discord.js` `Client`, `Message`, `GuildMember`, and
`Interaction` objects are hand-rolled fakes carrying only the fields the unit
reads (e.g. `message.mentions.users.size`, `message.member.roles.cache`,
`message.client.fetchInvite`). Never construct a real client or open a gateway
connection.
- **Tests live in `bot/test/*.test.js`.** One file per module under test.
## 4. Units to cover
Ordered high-value first. Each row names the module, the behavior worth locking,
and the seam a test drives it through.
### 4.1 Pure logic (no mocks beyond inputs)
| Module | Behaviors to lock | Notes |
|---|---|---|
| `filter/normalize.js` | leetspeak folding (`b4d``bad`, `@ss``ass`), 3+-repeat collapse (`sooooo``so`), case-fold; `matches()` is word-boundary anchored (so `bad` does not fire inside `badminton`); `findMatch()` returns the first `{word, severity}` row or `null`; the *documented gaps* stay gaps (spaced-out `b a d` is **not** caught). | The core obfuscation-resistance contract. Pin the gaps too, so a future tightening is a deliberate, test-visible change. |
| `utils/duration.js` | `"30s"/"10m"/"2h"/"1d"` → correct ms; whitespace tolerated; garbage/empty/unknown-unit → `null`; `MAX_TIMEOUT_MS` is 28 days (the Discord cap callers clamp to). | Feeds `/mute` and temp-role expiry — a wrong parse mutes for the wrong span. |
| `filter/spamFilter.js` | `isRateLimited` trips on the 6th message inside the 5 s window and is per-`guild:user`; it has a **side effect** (records the timestamp) so it must be called once; `isMassMention` counts users **+** roles against the threshold; `isMassEmoji` counts custom `<:name:id>` and unicode pictographs. | Drive time with a stubbed `Date.now` (or accept the real clock and use tight windows). The module-load `setInterval(...).unref()` sweep must not keep the runner alive — `.unref()` already handles this; assert no leak by letting the suite exit. |
### 4.2 Logic with a single mocked collaborator
| Module | Behaviors to lock | Seam / mock |
|---|---|---|
| `filter/inviteFilter.js` | returns the **code** (not a bool) of the first *foreign* invite; an invite resolving to the current guild is allowed; an invite that **fails to resolve** (expired/invalid) is treated as foreign (fail-closed); no invite in the message → `null`. | Fake `message.client.fetchInvite(code)` — resolve with `{ guild: { id } }` for local/foreign, or throw for unresolvable. Set `message.guildId` + `message.content`. |
| `site/siteApiClient.js` | never throws on a network/timeout/HTTP failure — always returns the `{ ok, ... }` shape so a command never breaks when the site is down: `{ ok: true, data }` on success, `{ ok: false, error }` on failure, and the distinct `{ ok: false, maintenance: true, message }` for the site's 503 maintenance response. (Public read client — **no** shared secret; the keyed channel is `botInternalClient.js`.) | Stub `global.fetch` (resolve various statuses/bodies, reject/timeout via `AbortController`). Same non-throwing contract the server's `uoLinkClient` follows. |
| `internal/requireInternalKey.js` | `401` on a missing/wrong key; `next()` on the configured key; **fail-closed** when `BOT_INTERNAL_KEY` is empty (never matches); timing-safe compare (equal-length + `crypto.timingSafeEqual`, no early-exit length leak). | Call the middleware with a fake `req` (`req.get('X-Internal-Key')`) + `res`/`next` spies; mirrors `server/test/requireInternalKey.test.js`. |
### 4.3 The filter pipeline (orchestration)
`discord/messageFilter.js` is the highest-value integration seam — it composes
all of §4.14.2. Lock the **decision order and the actions**, with every
collaborator faked:
- **Bypass wins first:** an allow-listed channel, or a member holding an
allow-listed role, short-circuits the whole pipeline (`isBypassed`) — no
delete, no hit recorded.
- **Order:** invite link → banned word → spam (rate-limit → mass-mention →
mass-emoji). `detectSpam` must evaluate `isRateLimited` **first and exactly
once** (it has the timestamp side effect).
- **Actions:** invite/spam always delete + warn (no severity tiers); a
word-filter hit applies the action for its severity; filter-triggered mutes use
the fixed `FILTER_MUTE_SECONDS` (600 s).
- **Best-effort recording never breaks moderation:** `recordFilterHit` /
`recordSpamHit` throwing must be swallowed — the delete/warn still happens.
Mocks: `filterCache` (in-memory `allowChannels`/`allowRoles` Sets + words), a fake
`message` (content, author, member roles, `delete()`, `guildId`), and spies on
`warnings`/`filterHits`/`spamHits`/`modLog` to assert what was called.
### 4.4 Models (`bot/model/*.js`)
Thin `db.query` wrappers (e.g. `warnings.js`, `filterWords.js`, `filterHits.js`,
`spamHits.js`, `memberEvents.js`, `guildConfig.js`, `scheduledMessages.js`,
`tempRoles.js`). Cover only those with **shaping/branching logic** (a query that
maps rows, applies an "active/not-expired" predicate, or upserts) by asserting the
**SQL params** passed to a fake `db.query` and the shape returned. Skip pure
one-line inserts with no logic — testing those only proves the mock.
## 5. Explicitly out of scope
- **`discord.js` internals / the live gateway** — not ours to test; never open a
real connection.
- **Thin command wrappers** (`discord/commands/*.command.js`) that only validate
args and call a Discord API + a model — cover their *logic* (arg parsing,
duration clamping) if any, but not the Discord round-trip.
- **`node-cron` scheduling itself** — test the job's callback logic, not that cron
fires.
## 6. CI & coverage wiring
Mirror what PR #86 did for the client:
- **`.gitea/workflows/pr-checks.yml`** — add a `bot-tests` job (or a
`Run bot tests` step) that runs `npm test --prefix bot`, gating PRs into `main`.
- **`.gitea/workflows/sonarqube.yml`** — generate a bot LCOV from the repo root so
`SF:` paths resolve to `bot/src/...`:
```
node --test --experimental-test-coverage \
--test-reporter=lcov --test-reporter-destination=bot/coverage/lcov.info \
bot/test/*.test.js
```
- **`sonar-project.properties`** — add `bot/test` to `sonar.tests`, the glob to
`sonar.test.inclusions`, `bot/coverage/lcov.info` to
`sonar.javascript.lcov.reportPaths`, and `bot/coverage/**` to `sonar.exclusions`.
(`bot/src` is already in `sonar.sources`.)
Bot units need no `npm ci` to run when they import only relative files + Node
built-ins; a test that stubs `global.fetch` or fakes `db` needs no `discord.js`
either. Keep the pool pointed at a dead port so the run is hermetic.
## 7. Suggested phasing
1. **Harness + pure logic** — `test` script, then `normalize`, `duration`,
`spamFilter`. Fast, zero mocks, immediate value.
2. **Single-collaborator units** — `inviteFilter`, `siteApiClient`,
`requireInternalKey`.
3. **Pipeline** — `messageFilter` decision order + actions (the payoff test).
4. **Models with logic**, then the **CI/Sonar wiring** so it all counts.

View File

@@ -10,6 +10,7 @@ A full-stack app in one repo:
- **Frontend** — React + Vite single-page app (public site, wiki, and the admin panel), dark "gothic" theme (Cinzel + Georgia).
- **Deploy** — Docker Compose (app + MariaDB) behind a Pangolin reverse proxy. Express serves the built SPA in production.
- **Shard link** — a live bridge to the in-game ServUO shard through the **uo-link** sidecar ([RunicGateway/link](https://gitea.whitlocktech.com/RunicGateway/link)): the site ingests a live event feed and makes server-side REST calls to show shard status, economy, staff presence, IDOCs, live activity, and per-character sheets. See [Shard integration (uo-link)](#shard-integration-uo-link).
- **Moderation appeals** — a player whose linked Discord identity was banned or muted (per the bot's `mod_actions` log) can open an appeal from the player portal; staff claim and resolve appeals from an admin queue, and approving a ban/mute appeal best-effort reverses it in Discord automatically. See [MODERATION_APPEALS.md](MODERATION_APPEALS.md).
The design reference is [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (API contract, schema, security).
@@ -17,6 +18,7 @@ The design reference is [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (API contract, sc
## Contents
- [Architecture](#architecture)
- [Tech stack](#tech-stack)
- [Project structure](#project-structure)
- [Prerequisites](#prerequisites)
@@ -36,6 +38,103 @@ The design reference is [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (API contract, sc
---
## Architecture
How the pieces fit together — the React SPA and native app talk to one Express backend
(`router → controller → model → db`), which persists to MariaDB and bridges to the live
game world only through the **uo-link** sidecar. The shard itself is never internet-facing.
See [ARCHITECTURE.md](ARCHITECTURE.md) for the fuller write-up.
```mermaid
flowchart TB
%% ---------- Clients ----------
subgraph clients["Clients"]
browser["Browser<br/>React + Vite SPA<br/>(public · wiki · admin)"]
mobile["Native mobile app<br/>(bearer tokens)"]
end
idp["SSO providers<br/>Google · Discord · custom OIDC"]
discord["Discord"]
%% ---------- Website (one repo) ----------
subgraph website["website/ &nbsp;— Node app (one repo)"]
direction TB
subgraph backend["server/ — Express backend"]
direction TB
mw["Middleware<br/>helmet · siteMode · noindex<br/>rateLimit · loginProtection · botScore · validate"]
router["Router /api/v1<br/>auth (web · mobile · sso) · public · admin"]
ctrl["Controllers"]
auth["Session layer (auth/)<br/>sessionService · JWT/cookie · bearer · SSO+PKCE"]
model["Models (.model + .db)<br/>raw parameterized SQL — no ORM"]
sse["SSE fan-out<br/>public stream (allowlist) · admin stream (sensitive)"]
subgraph shardutil["Shard integration (utils/)"]
ingest["shardIngest.js<br/>WS ingest dispatcher"]
restcli["uoLinkClient.js<br/>REST client (never throws)"]
end
secret["secretBox.js<br/>AES-256-GCM secrets at rest"]
end
bot["bot/<br/>Discord bot"]
end
db[("MariaDB<br/>users · posts · wiki · settings · activity<br/>mobileSessions · authProviders · userIdentities<br/>uoLinkConfig · shard_online/economy/houses/events")]
%% ---------- Shard side ----------
subgraph shardside["Game shard (never internet-facing)"]
direction TB
sidecar["uo-link sidecar<br/>(Rust) — the only bridge exposed"]
servuo["ServUO shard<br/>(C# plugin)"]
end
%% ---------- Edges ----------
browser <-->|"same-origin JSON + SSE (cookie)"| mw
mobile -->|"REST (bearer access/refresh)"| mw
browser -.->|"OAuth redirect + PKCE"| idp
auth -.->|"token exchange"| idp
mw --> router --> ctrl
ctrl --> auth
ctrl --> model
ctrl --> restcli
ctrl --> sse
auth --> model
model <--> db
auth -. reads/writes secrets .-> secret
restcli -. reads config/token .-> secret
ingest --> model
ingest --> sse
sse -->|"live events"| browser
bot -->|"messages"| discord
bot <--> db
restcli -->|"REST: /char /roster /economy /history · /link/confirm · /towncrier"| sidecar
sidecar -->|"WebSocket live event feed (bearer + X-UOLink-Version)"| ingest
servuo -->|"loopback TCP 127.0.0.1:7788<br/>newline-delimited JSON (shard dials out)"| sidecar
%% ---------- Styling ----------
classDef ext fill:#2d2233,stroke:#7a5c94,color:#e8dff0;
classDef store fill:#1f2d2a,stroke:#4c8c7d,color:#dff0ea;
classDef bridge fill:#2d2620,stroke:#94764c,color:#f0e6d8;
class idp,discord ext;
class db store;
class sidecar,servuo bridge;
```
- **One backend, layered.** Every request flows `middleware → router → controller → model → db`.
Web browsers authenticate with an httpOnly JWT cookie; the native app uses short-lived bearer
access tokens plus rotated refresh tokens; SSO (Google/Discord/OIDC) is link-only and PKCE-guarded.
All three surfaces produce the *same* session via the session layer.
- **The shard is never reachable.** The ServUO shard *dials out* over loopback TCP to the uo-link
sidecar; only the sidecar is exposed, and only the backend talks to it. The REST client
(`uoLinkClient.js`) never throws, so the site degrades gracefully when the shard is down.
- **Sensitive events stay private.** Ingested game events fan out to browsers over two SSE channels —
a public allowlist stream and an admin-only stream that adds staff audit / cheat / login events.
---
## Tech stack
| Layer | Tech |
@@ -268,6 +367,18 @@ cd server
npm run swagger # → server/swagger/swagger-output.json
```
`swagger.js` post-processes the generator's output in two ways before writing it:
- **Trailing slashes are stripped from path keys.** swagger-autogen builds a path by
string-concatenating the mount prefix with the route argument, so a capability router mounted at
`/users` whose collection route is `router.get('/')` would document as `/api/v1/admin/users/`
a URL no client calls, while dropping the one they all do. Express is indifferent (non-strict
routing treats the two as one route), but the published spec is a contract.
- **Path keys are sorted.** The generator emits them in router-traversal order, so moving a route
between files rewrote most of this ~5k-line committed artifact even when the API was provably
unchanged. Sorting keeps the diff proportional to the change. OpenAPI attaches no meaning to path
order, and `scripts/routeManifest.js` already sorts for the same reason.
If the generated spec is missing, the server logs a warning and simply disables `/api/docs` (it does
not crash).
@@ -331,7 +442,7 @@ character**; players and editor/moderator staff are limited to their own linked
| Surface | Endpoints | Who | Data |
|---|---|---|---|
| **Public** | `/api/v1/public/shard/*` (`status`, `feed`, `economy`, `online`, `idoc`, `stream`) | anyone | Shard up/down, gold-supply series, IDOC houses, a curated live feed, and **"Staff online"** — only players whose account is linked to a **staff** user (admin/editor/moderator), shown with name + map location. Linked *players* are never listed publicly; no vitals or account are exposed. |
| **Public** | `/api/v1/public/shard/*` (`status`, `feed`, `economy`, `online`, `idoc`, `stream`) | anyone | Shard up/down, gold-supply series, IDOC houses, a curated live feed, and **"Staff online"** — only players whose account is linked to a **staff** user (admin/editor/moderator), shown by name. Their in-game **map location is only included for admin/moderator viewers** — for players and the public it is stripped from the payload entirely (server-enforced, not just hidden in the UI). Linked *players* are never listed publicly; no vitals or account are exposed. |
| **Player** | `/api/v1/player/shard/*` (`link`, `accounts`, `roster/:account`, `vendors/:account`, `char/:serial`, `sales`) | logged-in player | Their own linked accounts: character rosters, character sheets, player-vendor snapshots, and recent vendor sales. |
| **Admin** | `/api/v1/admin/shard/*` (self-linking, same as player) · `/api/v1/admin/uo-link/*` (`config`, `towncrier`, `stream`) | staff / admin | Staff link their own accounts like players; **admins** additionally read *any* character's data, edit the sidecar connection config, publish/remove **town-crier** messages, and subscribe to the full event stream (incl. audit/cheat). |