236 Commits

Author SHA1 Message Date
5103b74a9d Merge pull request 'feat(shard)!: Protocol 3.0 cutover — visibility framework, spawn atlas, marketplace' (#118) from edge into main
All checks were successful
sync-project-tree / sync (push) Successful in 16s
Build container images / build (push) Successful in 1m12s
Build container images / deploy (push) Successful in 39s
SonarQube / analysis (push) Successful in 3m58s
Reviewed-on: #118
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-01 07:19:31 +00:00
c91fd128bf Merge pull request 'fix(shard): answer with the instance name when the shard is unnamed' (#119) from fix/ruleset-shard-name into edge
All checks were successful
PR Checks / bot-install (pull_request) Successful in 27s
PR Checks / client-build (pull_request) Successful in 35s
PR Checks / server-tests (pull_request) Successful in 1m43s
Reviewed-on: #119
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-01 06:04:19 +00:00
01a559792c fix(shard): answer with the instance name when the shard is unnamed
ServUO ships Server.cfg with `Name=My Shard`. An operator who never edited it
publishes that verbatim, so the rules page read "My Shard" under a header
carrying the real name. That value is the shard saying *unnamed* rather than
naming anything, so the site now answers with its own.

`settings.getInstanceName()` resolves `site_title || BRAND_NAME` — the same
resolution `getPublic().brand.name` already uses, so an install that set only
the site title can never show two different names on two pages. Bare
`brand.name` would have been wrong for exactly that case.

Substituted at INGEST rather than on read: world.ruleset is also broadcast
live, and the same object is handed to the SSE fan-out, so a read-time fix
would be undone by the next reconnect's frame. Matched case- and
padding-insensitively but only as a whole value, so a shard genuinely called
"My Shard Reborn" keeps its name.

Fixes a second ruleset writer found on the way: uoLinkSocket.backfill() called
shardState.setRuleset directly instead of going through the dispatcher as
ingestEach does, so the boot/reconnect snapshot silently skipped this
normalization. The two arrival orders have to produce the same stored frame.

Also renders a placeholder row on an unscored leaderboard — the instance name
with an em dash where a score goes, deliberately not shaped like an entry (no
medal, no bar) because a placeholder that looked like a real standing would be
a fabricated one. Presentation only; the API still sends an empty `top`.

Verified live against the shard + sidecar: rules page and leaderboards on web
and Android both correct.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U7CBg11prhLimL9iHSX1bP
2026-08-01 00:58:21 -05:00
e50fab241f Merge pull request 'feat(shard)!: declare wire protocol 3' (#117) from chore/protocol-3-cutover into edge
All checks were successful
PR Checks / bot-install (pull_request) Successful in 18s
PR Checks / client-build (pull_request) Successful in 25s
PR Checks / server-tests (pull_request) Successful in 1m34s
Reviewed-on: #117
2026-07-30 03:02:20 +00:00
779a304173 feat(shard)!: declare wire protocol 3
The site's declared version is the admin-set uo_link_config.protocol column, so
the sidecar's PROTOCOL_VERSION 2 -> 3 bump has to be matched here or every REST
call 409s and uoLinkSocket closes the WS on the ws.hello mismatch. Five places
carry the number and all five move together: the column default, the model's
DEFAULT_PROTOCOL (what a site with nothing saved yet declares), the two
`config.protocol || 1` fallbacks in uoLinkClient/uoLinkSocket -- unreachable
today, but an unset value quietly sending 1 is exactly the confusing 409 the
version check exists to prevent -- the admin form's initial value, and the
documented env default.

The boot migration is the only subtle part. schema.sql is re-run on EVERY boot,
and `protocol` is admin-editable, so a bare UPDATE would silently un-pin an
operator who had deliberately pinned an older sidecar in Admin -> Shard. It is
therefore gated on a marker row in `settings`, written after the UPDATE: the
first boot on this build migrates, every later boot is a no-op. `protocol < 3`
rather than `= 2` picks up an install still on the old default of 1, which could
not have been talking to a v2 sidecar anyway. A fresh install has no row to
update and just gets the marker plus the new column default.

Verified against the local MariaDB through ensureSchema (the production path):
2 -> 3 with the marker written and the column default now 3; pinned back to 2 by
hand, re-ran, and it STAYED 2 -- the one-shot property holds. 673 server tests,
47 client tests, client build green.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 18:03:48 -05:00
c6c0c257dd Merge pull request 'feat(shard): the player-vendor marketplace' (#116) from feat/vendor-listing into edge
All checks were successful
PR Checks / bot-install (pull_request) Successful in 29s
PR Checks / client-build (pull_request) Successful in 35s
PR Checks / server-tests (pull_request) Successful in 1m45s
Reviewed-on: #116
2026-07-29 20:03:22 +00:00
8771a1cf6c feat(shard): the player-vendor marketplace
Protocol 3.0 §8, the website half. Ingests vendor.listing / vendor.listing.remove
into shard_vendors + shard_vendor_items, serves a searchable public API over
them, and ships /site/market and /site/market/vendors/:serial.

Three things the pages have to say out loud, all consequences of how the data is
gathered:

- The prices are NOT live. The shard sweeps vendors round-robin, so a shop can be
  a full cycle behind. The banner is driven by the OLDEST vendor row, not the
  newest — the one stale shop is the one that wastes somebody's trip.
- A shop can be truncated. `total` exceeding `count` means the shop holds more
  than the shard publishes per frame; the vendor page says "showing 250 of 3,104"
  rather than presenting a partial shop as complete.
- An item may have no name. On a shard with no cliloc table the honest render is
  the item id, never an invented label.

## The pre-wired visibility rules, re-checked

Part A pre-wired market.ownerName and market.location before the frame existed,
and the sibling rule it pre-wired for leaderboards (`characterName`) turned out
to be INERT because projectValue matches literal JSON keys. Both market rules
were checked against the real frame this time:

- `ownerName` is a real key. Kept.
- `location` is a real key ONLY because the frame nests it. Flat map/x/y/region
  would have made the rule match nothing — the same failure, one part later. It
  is nested on the wire and on the read model so one rule hides the facet, the
  coordinates, the region and the house together; five flat keys would be five
  rules that drift apart.
- `ownerSerial` was ADDED. An admin who hides the owner's name and leaves a
  serial that the leaderboards and guild boards resolve back to that same name
  has not hidden anything.

Tests assert all three bite, on the stored read model AND on the raw frame —
the market's SSE stream is off by default but an admin can turn it on, and a rule
that worked on only one path is exactly the leak §3.6.1 records.

## Notable

- **No payload column on shard_vendors**, unlike shard_points_boards next door.
  The board's top-N is a fixed-size list read whole; here the items ARE the
  searchable rows, so they are normalized and nothing is left worth duplicating.
- **display_name is denormalized at ingest** (literal name preferred over the
  cliloc — a player set it, so it is more specific). Resolving at query time
  would put the cliloc table on the hot path and make search-by-name impossible.
  Because the shard's diff sweep will not re-send an unchanged shop just because
  the site learned what its items are called, a cliloc import now triggers a bulk
  re-resolution — 50 ms per thousand rows, never throws.
- **updated_at is written explicitly** on every upsert. MariaDB does not fire ON
  UPDATE CURRENT_TIMESTAMP when every column is written back unchanged, and a
  shop re-published identically is still freshly confirmed — without this the
  staleness banner would age a perfectly current shop forever.
- **LIKE wildcards in `q` are escaped.** `%` and `_` are LIKE metacharacters, not
  SQL ones, so parameterization does not neutralize them: `?q=%` would otherwise
  match every listing on the shard.
- **Rate-limited** (60/min/IP), the only limited public read. Every other public
  GET is an indexed lookup of bounded size; this is a LIKE scan plus a COUNT over
  the largest shard_* table, anonymous by default.
- Reconnect backfill pages /market, bounded by MARKET_SNAPSHOT_MAX = 5000 and
  stopping on a short page as well as on `total`, so a concurrent sweep shrinking
  the index cannot spin the walk.

## How it was tested

673 server tests pass (27 new). Client builds clean; swagger-output.json,
routes.manifest.json and routes.guards.json regenerated.

Verified full-stack against the live MariaDB and a real shard, not only units:

- 27 real vendors / 1,040 listings swept off the ServUO tree, through the Rust
  sidecar, into the site — names resolving through the cliloc table ("longsword",
  "katana"), real facets and regions in the filters.
- `?q=sword` 682, `?q=%` and `?q=_` **0** (the escape), map/region/price/sort
  filters, paging, and the vendor detail route.
- Visibility live: fields gated to staff vanish for an anonymous caller while
  shopName and price survive; audience=player 403s; enabled=0 404s; and
  /shard/features correctly drops `market` so the nav hides it.
- Re-publishing a shop smaller leaves no orphan items; an identical re-publish
  moves updated_at.
- The limiter fires (38x200 then 32x429 on a 70-request burst).

Not covered by an automated test: the two React pages are presentational and this
repo's client suite covers pure-logic modules only. They were driven against the
live API above, but not rendered in a DOM harness.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 09:51:50 -05:00
8da658f223 Merge pull request 'feat(shard): resolve cliloc names for items and reward titles' (#115) from feat/cliloc-table into edge
Reviewed-on: #115
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-29 11:58:54 +00:00
bda031566a feat(shard): read clilocs from a source SET so shard items get names
Shards edit items and add new ones, and those carry cliloc ids no stock client
table has. Reading exactly one converted file meant an operator had to
re-export 5 MB every time they added one item — friction enough that the table
would simply go stale, which is the failure the spawn atlas was redesigned to
avoid in the first place.

So this mirrors spawnAtlasSource.readSources(): a BASE (the converted client
table) plus every operator-maintained overlay under `custom/`, all re-read on
every boot and hash-gated as a SET. Later sources win, so an overlay both adds
ids the client never had and overrides stock ones the shard re-purposed.
Adding, editing or removing any overlay counts as drift.

`custom/` is the one convention here that is ours rather than the shard's, and
deliberately so: ServUO has no server-side notion of a custom cliloc — they
live in the patched client a shard distributes, and nothing in the tree
declares them. There is nothing to discover. (An operator who does patch their
client cliloc needs no overlay: convert the patched file and the edits are in
the base.) Scale, measured on the live shard: its script tree references 16,434
cliloc ids and only 37 are absent from stock — tens against a 67k base, which
is why this is an overlay and not a second table.

The set brings back a hazard a single file did not have, and it gets the
atlas's answer. A corrupt source fails the parse loudly, but a source that has
VANISHED parses perfectly and imports a table quietly missing everything it
contributed — an unmounted volume is indistinguishable from a deliberate
deletion. So it is staged, not applied (`needsReview`), reported by both the
import and status(), and accepted with `{approve:true}`. That is a flag rather
than the atlas's approve/reject pair because the atlas stores a pending
decision SO THAT approving re-parses; here nothing is stored, so re-reading at
approval time is automatic.

Also reports a per-source breakdown (entries/added/overrode) on import and in
status, which is how an operator confirms an overlay took effect — "overrode: 0"
on a file meant to re-label stock items says it did not.

Two bugs this surfaced, both found by running a shard-style overlay rather than
by another stock-table fixture:

- displayText tidied punctuation unconditionally, so a custom
  "Runic Gateway Sigil (v2)" rendered as "(v2". Stripping leftover brackets is
  right after a placeholder is removed and wrong otherwise — the same condition
  the `%` rule already had.
- CANDIDATE_NAMES did not include `clilocs.plain`, which is the exact filename
  CLILOCS.md and the export tool's README tell operators to write. Pointing at
  the directory they were told to create failed with NO_FILE.

Verified end to end against the live MariaDB and a real server boot: base-only
import, overlay adding one id and overriding another (per-source breakdown
correct), unchanged set as a no-op, an edited overlay re-importing and
withdrawing its override, a vanished overlay refused with the table intact,
status reporting missingSources, approve applying it, and a file-path
configuration still finding overlays beside it. All three resolve correctly
through the running server: shard-added, overridden and stock. 646 server tests
pass (16 new in clilocSource.test.js, 3 new in clilocParse.test.js); swagger,
routes.manifest.json and routes.guards.json regenerated.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 06:46:12 -05:00
b61a4d6721 feat(shard): resolve cliloc names for items and reward titles
Protocol 3.0 §8.6 (docs/link/v3.md), the dependency order 5 was sequenced
behind. Items on the wire carry a LabelNumber, not a name — the bridge has
always sent it (char.profile.equipment.cliloc, reward titles as a cliloc
number in string form, and one per marketplace listing) but the site had no
table to resolve it against, so a character sheet could only render
`id 1023721` where the game renders "quarter staff".

The number was never the missing piece. The table was.

Sourced from a file the operator converts once from their own client, at a
path from the `cliloc_client_path` setting falling back to UO_CLIENT_PATH.
Nothing client-derived is committed: UO's strings are EA's, exactly as the
creature sprites are. A shard with nothing configured is fully supported —
names render as ids, as they did before.

The conversion step is not avoidable, and that is the substantive finding
here: every current client ships its cliloc files COMPRESSED (first DWORD's
high byte 0x8E, the Mythic container), and ServUO's own bundled
Ultima.StringList cannot read that either — so VendorSearch.GetItemName is
already inert on such a shard and the plugin could not supply names instead.
v3.md's original "read the client's Cliloc.enu" recommendation was therefore
not implementable as written, and its committed db/data/clilocs.json artifact
also predates the Part C corrections (no committed derived snapshots, nothing
EA-derived shipped). Replaced with the spawn-atlas pattern: parse on boot from
an operator-configured path, hash-gated, output gitignored.

- utils/clilocParse.js — pure parsers, fs-free so the suite runs in CI.
  Accepts the plain binary layout and delimited text, sniffed by header rather
  than extension. Rejects a compressed file BY NAME: without that check the
  plain parser reads it as ~19k records of negative ids and 60 KB "strings"
  before dying mid-file, and the resulting error names the wrong problem.
  displayText() drops the ~1_val~ arguments the bridge never sends.
- utils/clilocSource.js — the fs layer. hashSource reports `compressed` so the
  admin panel can flag an unconverted file WITHOUT parsing 5 MB per poll;
  otherwise pointing at a client directory reports a healthy file with pending
  drift ("ready to import") and the operator only finds out on failure.
- model/shardClilocs — refresh/status/lookup. All-or-nothing replace (DELETE,
  not TRUNCATE — TRUNCATE is DDL in MariaDB and implicitly commits). Batched
  server-side resolution behind a capped cache; never throws, because a cliloc
  lookup is decoration on a character sheet.
- Deliberately NO staged-approval flow, unlike the atlas: the atlas escalates
  facet loss because a half-copied tree and a real map change are
  indistinguishable from inside the process, whereas a partial cliloc copy
  makes the parser fail on a truncated record. The ambiguity the atlas must
  escalate is one this parser simply detects.
- No public route. The table is never served AS a table: 67k rows would dwarf
  any page using them, and the Android client consumes the same resolved JSON.

Two parser bugs found by building it, both now covered by tests: trimming a
text line before splitting ate the trailing separator on empty-text entries
and silently dropped 55,994 of 123,490 while still reporting success; and
Number('') is 0, not NaN, so a line starting with a separator imported as a
bogus cliloc 0.

Verified against the real client table (123,490 entries) and the live MariaDB:
import 663 ms, hash-gated boot no-op 14 ms, cold resolve 4.2 ms / warm 0.015 ms.
Binary and TSV imports converge on the same 67,496 rows with identical keys
(blank entries — half the table — are dropped at import). A file truncated to
half its length is refused with TRUNCATED and leaves the previous table
serving. Boot logs verified for both the import and the compressed-file
warning; neither blocks startup. All three admin routes exercised over HTTP
with a real session. 629 server tests pass; client builds clean; swagger,
routes.manifest.json and routes.guards.json regenerated.

Not covered by an automated test: the character sheet renders resolved names
in presentational React with no DOM test harness in this repo, and was not
rendered against a live linked-player profile — that needs a logged-in player
with a linked game account and a shard answering a profile RPC.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 04:21:38 -05:00
1e1a3d67c3 Merge pull request 'feat(shard): ingest points.board and publish the leaderboards' (#114) from feat/points-board into edge
Reviewed-on: #114
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-29 07:52:52 +00:00
26094459ae feat(shard): ingest points.board and publish the leaderboards
Protocol 3.0 §7 (docs/link/v3.md). The shard publishes ~25 points/loyalty
leaderboards — Queen's Loyalty, Void Pool, the nine city loyalties, Clean Up
Britannia — and the site renders them, plus each character's own standings on
their sheet.

Server
  - shard_points_boards: one row per system, keyed by the shard's PointsType
    name. The top-N list stays inside `payload` — a fixed-size list read whole,
    exactly like shard_governors.candidates. Normalizing into an entries table
    buys nothing until something needs a per-character reverse lookup, and a
    character's own standings already ride inside char.profile.
  - shardIngest routes points.board to upsertPointsBoard and deliberately does
    NOT log it: this is board state like guild.update, and the shard emits a
    frame every time anyone's score moves a top ten.
  - uoLinkSocket backfills /points through snapshot() with ingestEach rather
    than a replace*: there is no points.remove and the system set is fixed, so
    upserting IS the reconciliation, and a system the operator later excludes
    keeps its last-known board rather than vanishing.
  - GET /public/shard/points and /points/:system behind
    requireFeature('leaderboards'), both projected per §3.6.1. :system is
    constrained to an identifier before any query runs; 404 for a system never
    published, distinct from a published board nobody has scored in (200, empty
    top).

The leaderboards field rule now keys on `name`, not `characterName`
  Part A pre-wired FEATURES.leaderboards.fields = { characterName: ... }, but
  projectValue matches on the LITERAL JSON key and the wire key is `name`. As
  written the rule was inert: an admin tightening character names would have got
  no enforcement and no error — precisely the failure §3.6.1 records for the
  flattened `ownerAcct` spelling. Fixed, with a test that fails if it is renamed
  back, and the admin panel's FIELD_LABEL carries the meaning instead.

Client
  - routes/public/Leaderboards.jsx at /site/leaderboards. A points.board frame
    describes ONE system, so live frames merge over the fetched set by system
    key rather than replacing it wholesale the way the ruleset does. Filter
    matches board name, system key, or any ranked player — the last is what
    makes it useful ("where do I appear?").
  - A "Loyalty & Points" section in CharacterSheet.jsx, one edit serving both
    PlayerCharacter and AdminCharacter.
  - Both treat maxPoints: 0 as UNCAPPED and both fall back to humanising the
    system key when nameString is null. Neither is defensive padding: on a real
    shard uncapped and cliloc-only names are the majority case.

Verified end to end against the local MariaDB, the Rust sidecar, and the real
ServUO shard: backfill from /points, live SSE delivery (a board absent from the
initial fetch appearing without a reload, and an existing one updating in
place), REST reflecting the overwrite, and the gate at every rung — 200 by
default with names, names stripped but points kept at fieldRules name=staff, 403
plus dropped from /features at audience=staff, 404 when disabled. Page rendered
clean, no console errors beyond the pre-existing React Router v7 warnings.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 21:04:44 -05:00
bfa1db58c4 Merge pull request 'feat(atlas): serve the spawn atlas and give operators a panel for it' (#113) from feat/spawn-atlas-api into edge
Reviewed-on: #113
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-29 00:53:21 +00:00
7c769ea8fd feat(atlas): serve the spawn atlas and give operators a panel for it
Protocol 3.0 order 3 (Part C), second of two website PRs. #112 built the data
pipeline; this makes it reachable — six public routes, five admin ones, two
public pages and an admin panel. Still website-only: no plugin, no sidecar, no
new event kinds, no wire change.

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

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

Two bugs the UI surfaced, both fixed here:

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U7CBg11prhLimL9iHSX1bP
2026-07-28 19:51:22 -05:00
f3d084e046 Merge pull request 'feat(atlas): derive a spawn atlas from the shard tree on every boot' (#112) from feat/spawn-atlas-parse into edge
Reviewed-on: #112
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-28 21:51:28 +00:00
a4ef9d676d Merge remote-tracking branch 'origin/edge' into feat/spawn-atlas-parse 2026-07-28 16:45:56 -05:00
2801ec8f4d refactor(atlas): derive the atlas from the shard's tree on every boot
Replaces the committed-artifact design from the first commit. Two problems with
it, both raised in review:

**Facets are not a fixed list.** The first pass carried a hardcoded table of the
six stock UO facets to reconcile the spelling drift between sources. That is
wrong: a shard may add facets, replace them outright, or rename them when its
maps are updated, and a built-in list quietly mishandles all three. Nothing in
the atlas names a facet any more. The facet set is discovered from the tree —
spawn records and region definitions are the authority — and the loose spellings
in Data/Locations are matched against it by key and prefix. Custom facets get
identical treatment; the tests use `Sosaria` and `Underdark` precisely so a
stock-facet assumption cannot creep back in.

**A snapshot goes stale.** Maps change over a server's life, so a build-once
artifact silently drifts from the world players actually see. The tree is now
the single source of truth and the atlas is re-derived on every boot.

## What that changed

- **The committed artifact is gone** — 1.41 MB of generated JSON removed, along
  with `scripts/buildSpawnAtlas.js` and the whole encode/decode seam it needed
  (`encodePoint`/`readPoint`, the tuple encoding, the omitted-defaults scheme and
  their round-trip tests). Nothing to keep in sync, nothing to go stale.
- **NEW `src/utils/spawnAtlasSource.js`** — the only thing that touches a ServUO
  tree; shared by the boot path and the CLI. Parsers stay pure and fs-free.
- **NEW `src/model/shardAtlas/`** — `.db.js` (the one-transaction replace) and
  `.model.js` (the refresh decision).
- **`scripts/importSpawnAtlas.js`** is now a thin CLI over the model:
  `--servuo`, `--force`, `--approve`, `--reject`, `--status`. `atlas:build` is
  gone; `atlas:import` remains.
- Path comes from the `spawn_atlas_servuo_path` admin setting, falling back to
  `SERVUO_PATH`. The setting wins, matching how the rest of the shard
  integration is admin-managed rather than env-configured.

## Two contracts on the boot path

**It never blocks startup.** No 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. Verified by booting the real server with no path,
a broken path, and a good path.

**A facet disappearing is never applied automatically.** Losing a facet is the
signature of a half-copied or mid-update tree as much as of a real map change,
and boot cannot tell them apart. The refresh is staged in `shard_atlas_pending`
for an admin to approve or reject, and startup continues regardless. Additions
and every other change apply immediately, since none of them can destroy
something an operator would miss.

Only the decision is stored, not the parsed world: a few KB of source hashes and
the facet diff. Approving re-parses, so what gets applied matches the tree at
approval time rather than at boot. A rejection is remembered against those exact
hashes, so a declined refresh does not re-prompt on every restart — changing the
tree changes the hashes and asks again.

Hash-gated, so the common case (restart, maps unchanged) reads and hashes the
tree (~120 ms) and writes nothing. A real change costs a ~400 ms parse.

The admin approve/reject UI is part of the second PR, with the rest of the
routes and pages. Until then the CLI covers it.

## Verification

- **564 server tests pass**, 28 new in `spawnAtlas.source.test.js` covering the
  custom-facet build, the spelling reconciliation, hash gating, and every branch
  of the refresh decision — including that `refreshOnBoot` survives a database
  that throws on every call.
- End-to-end against the local MariaDB and the real ServUO tree: 6,455 points,
  800 creatures, 23,927 point/type rows, 387 regions, 558 landmarks, 25 altars,
  83.2% of points resolved to a place name.
- The facet gate exercised against a real tree copy with `malas.xml` removed:
  staged rather than applied, atlas untouched with all 293 Malas points intact,
  reject then stays quiet on re-run, approve applies and drops the facet.
- Booted the real server under all three source conditions; none blocked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U7CBg11prhLimL9iHSX1bP
2026-07-28 16:41:33 -05:00
353cce9f26 feat(atlas): parse a ServUO tree into a committed spawn atlas artifact
Protocol 3.0 order 3 (Part C), first of two website PRs. This half is the data
pipeline only — parsers, the build/import CLI, and the tables. No routes and no
client, so nothing is user-visible yet; the API and pages follow in PR 2.

Part C is website-only: no plugin, no sidecar, no new event kinds, no wire
change.

## Parsing

`src/utils/spawnAtlasParse.js` is pure and fs-free so CI covers it with no
ServUO tree. Zero new dependencies — `Regions.xml` genuinely nests, so it gets a
small hand-rolled subset tokenizer rather than a new XML package. The 10.5 MB of
`Spawns/*.xml` never touches it: those records are flat and get a streaming
regex sweep instead.

The high-value transform is point-in-rect placement — highest region priority
wins, ties break to the smaller rect, then a nearest-landmark fallback within
200 tiles, else "Wilderness". That is what turns "lizardman at 5411,1234" into
"Despise, Felucca", and it resolves 83.2% of points (5,369 of 6,455).

Three things the real data forced, none of which were in the design:

- **Only 6 facets, not 13.** `Eodon.xml`, `GravewaterLake.xml` and the other
  named-area files carry TerMur/Trammel points, so the facet comes from each
  record's own `<Map>` and the artifact shards 6 ways.
- **Facet names disagree across sources.** `Data/Locations/*.xml` spells them
  `Ter Mur` and `Tokuno Islands`; `<Map>` and `<Facet name>` say `TerMur` and
  `Tokuno`. Unreconciled this is silent — the landmark fallback simply never
  fires on those facets and every unregioned spawn there reads "Wilderness".
- **Spawn type tokens carry XmlSpawner directives**: `Fairy,{RND,4,8}`,
  `alchemist/z/-50`, `Agralem/Name/Agralem`. Taken literally these invent
  creatures that do not exist AND split real ones in two, since `Fairy` and
  `Fairy,{RND,4,8}` slug apart. 71 of 845 entries were affected; stripping at
  the first `/` or `,` leaves 800 clean ones.

## Artifact

`npm run atlas:build -- --servuo <path>` writes `db/data/spawnAtlas.*.json`:
6 facet shards + a compact index + a small indented `meta`. 1.41 MB committed,
down from 4.40 MB by dropping `facet` per record, omitting defaulted fields, and
tuple-encoding the ~24,000 type entries. `encodePoint()` and the importer's
`readPoint()` are exact inverses and are round-tripped in tests.

Display spelling is chosen deterministically (most common, ties to the
capitalised form) because the spawn files are inconsistent about case and the
name would otherwise depend on file read order — a spurious diff on every
unrelated rebuild.

## Import

`npm run atlas:import` needs no ServUO tree, which is the whole reason build and
import are separate: the container has the artifact but not the tree. It
reloads all six tables in one transaction (DELETE, not TRUNCATE, which is DDL
and would implicitly commit), so a failed import leaves the previous atlas
intact.

## No artwork, by design

The repo 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. `shard_spawn_creatures.art` is nullable and NULL on every fresh
import; an operator who wants art extracts it themselves, drops it under
`server/uploads/atlas/` (already gitignored) and maps slugs in a gitignored
`spawnAtlas.art.json`. Text-only is the normal, fully supported state.

## Verification

- **544 server tests pass**, 57 new across `spawnAtlas.parse.test.js` (the
  `:OBJ=` split, directive stripping, nested-region priority inheritance,
  half-open rects, the facet reconciliation, tokenizer edge cases) and
  `spawnAtlas.build.test.js` (aggregation, deterministic naming, and the
  encode/decode round trip).
- Built and imported for real against the local MariaDB and the ServUO tree at
  `C:\Users\colby\Desktop\ServUO`: 6,455 points, 800 creatures, 23,927
  point/type rows, 387 regions, 558 landmarks, 25 champion altars.
- "Where does a lizardman spawn?" answers Shrines / Isamu-Jima / Yew across
  Felucca, Trammel and Tokuno.

No routes changed, so the OpenAPI spec and route manifest are untouched.

---

- [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:07:30 -05:00
7b98f1a778 Merge pull request 'feat(shard): ingest world.ruleset and publish it at /site/rules' (#111) from feat/shard-ruleset into edge
Reviewed-on: #111
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-28 20:50:35 +00:00
61d6bfaca2 feat(shard): ingest world.ruleset and publish it at /site/rules
Protocol 3.0 §5 (docs/link/v3.md). The shard publishes its own ruleset —
expansion, which optional systems are on, skill/stat caps, account and house
limits, champion scroll rules, the save/restart schedule — and the site renders
it, so the rules page cannot drift from how the shard actually plays.

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 14:35:24 -05:00
6b1396dd2f Merge pull request 'fix(shard): enforce visibility on the REST reads that bypassed it' (#110) from fix/shard-visibility-rest-projection into edge
Reviewed-on: #110
2026-07-28 15:58:29 +00:00
f30ea66fce fix(shard): enforce visibility on the REST reads that bypassed it
Protocol 3.0 Part A follow-up, found by the live five-rung smoke test.

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 10:49:55 -05:00
cd56af3f12 Merge pull request 'feat(shard): admin-configurable visibility for every shard surface' (#109) from feat/shard-visibility-framework into edge
Reviewed-on: #109
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-28 15:08:12 +00:00
f3450686e0 feat(shard): admin-configurable visibility for every shard surface
Protocol 3.0 Part A. Replaces the static PUBLIC_KINDS allowlist - which
was the entire public/admin boundary - with per-feature, per-field
audience control an admin owns from Admin -> Shard Visibility.

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

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

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

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 10:04:48 -05:00
a3407ae654 Merge pull request 'feat(auth): honor and establish trusted devices on the SSO login paths' (#108) from feat/sso-trusted-device into main
All checks were successful
sync-project-tree / sync (push) Successful in -15s
Build container images / build (push) Successful in 1m37s
Build container images / deploy (push) Successful in 37s
SonarQube / analysis (push) Successful in 3m21s
Reviewed-on: #108
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-28 06:12:57 +00:00
620781b7bc feat(auth): honor and establish trusted devices on the SSO login paths
All checks were successful
PR Checks / bot-install (pull_request) Successful in 19s
PR Checks / client-build (pull_request) Successful in 25s
PR Checks / server-tests (pull_request) Successful in 9m21s
"Trust this device" did nothing for anyone who signs in with Google or Discord.
sso.controller went straight from needsTotp(user) to staging a pending-TOTP
challenge and never consulted resolveTrustedDevice, so an SSO user was asked for
a code on EVERY sign-in no matter how many times they had ticked the box — and
POST /auth/sso/totp accepted only `code`, so that step could not establish a
trust either. The password paths (web + native) were unaffected and already
worked; this closes the gap for SSO, on the website AND in the Android app.

Server:
- finishLogin and finishMobileLogin now run the same trusted-device check as
  auth.controller.login, via one shared helper: honor a trust that belongs to
  THIS user, stamp last_used_at, log auth.login.trusted_device. A store error
  falls through to the challenge — fail closed to asking for the code.
- POST /auth/sso/totp gains optional trustDevice + deviceName, sets the rg_trust
  cookie, and mirrors the password path's { trustLimitReached, devices } response
  at the cap (the sign-in still completes). Recovery codes stay password-only.

Android coverage, without leaking a secret into a URL:
- The app opens SSO in a Custom Tab, which shares the system browser's cookie
  jar, so the rg_trust cookie set on that TOTP form is presented back on the next
  app sign-in. That alone makes native SSO skip the code. Passing the app's token
  into the start URL was rejected — it would put a 256-bit secret in query
  strings, Referer headers and access logs.
- To also cover the app's NATIVE password login, ticking the box sets
  mobile_auth_sessions.trust_device (a boolean; never the token), and
  /auth/mobile/sso/exchange mints a platform:'mobile' trust and returns
  { trustToken }. Minting there keeps the raw token on an authenticated
  app→server call, out of the deep link and out of the bridge row. Best-effort:
  at the cap the response just omits it rather than failing a good sign-in.

Client: the trust checkbox is no longer hidden on the SSO second step, on both
the admin and player login screens. On the mobile bridge the deep-link redirect
takes priority over the cap prompt — the sign-in succeeded and the link is
single-use, so stalling there would strand the app.

Tests: 8 new cases in server/test/ssoTrustedDevice.test.js (verified to fail
against the pre-fix controller). Full suites green — server 445, client 43 —
and routes.manifest.json is a zero-line diff: no URL moved, only +2 handlers on
/auth/sso/totp in routes.guards.json for the two new validators. Swagger
regenerated. Verified live against the running server and real MariaDB: the TOTP
step issues rg_trust and persists the row, a subsequent SSO callback carrying it
skips the code, and an invalid trust is still challenged.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 01:01:12 -05:00
f6611231c4 Merge pull request 'fix(shard): stop an undecryptable uo-link token 500ing every live-shard route' (#107) from fix/uolink-client-throw-and-sitemode-gate into main
All checks were successful
sync-project-tree / sync (push) Successful in 11s
Build container images / build (push) Successful in 1m8s
Build container images / deploy (push) Successful in 35s
SonarQube / analysis (push) Successful in 2m32s
Reviewed-on: #107
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-28 05:33:33 +00:00
a6fd5659c4 fix(shard): stop an undecryptable uo-link token 500ing every live-shard route
All checks were successful
PR Checks / bot-install (pull_request) Successful in 28s
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 44s
`uoLinkClient.call()` resolved the uo-link config OUTSIDE its try/catch.
resolveConfig() decrypts the stored auth token, and secretBox.decrypt throws
when the ciphertext can't be authenticated — SECRET_ENC_KEY rotated, or a DB
dump restored into an environment keyed differently. That throw escaped the
client entirely, breaking its documented "never throws / always returns
{ ok, data, status }" contract and turning a misconfiguration into a 500 on
every route that does a live sidecar round-trip:

  GET /admin/uo-link/config
  GET /{admin,player}/shard/char/:serial
  GET /{admin,player}/shard/roster/:account
  GET /{admin,player}/shard/vendors/:account

Found by a live smoke test of all 200 routes at every access level. Public
shard routes were unaffected because they read the DB via getSafe(), which
never decrypts.

Move resolveConfig() inside the try so the failure returns the standard
{ ok: false } shape, and log it at ERROR with a distinct message: a wrong key
previously looked identical to "the shard is offline", with no clue why.
Those routes now degrade to 503, and GET /admin/uo-link/config returns 200
again — it is the screen an admin needs to re-enter the token and recover, so
having it 500 locked them out of the fix.

Also gate the admin Dashboard's site-mode toggle. PUT /admin/site-mode is
adminOnly, but the button rendered for every staff role, and toggle() had a
try/finally with no catch — so an editor clicking it got an unhandled promise
rejection and zero UI feedback. Gate the control on role === 'admin' (the rule
AdminLayout already documents: never show a non-admin a control that would 403)
and surface a message if the call is refused anyway.

Adds server/test/uoLinkClient.test.js, which fails against the unfixed client.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 00:22:40 -05:00
068844bfd9 Merge pull request 'refactor(server): split public, player and residual auth into capability routers (PR 5)' (#106) from refactor/router-split-5 into main
All checks were successful
sync-project-tree / sync (push) Successful in 12s
Build container images / build (push) Successful in 1m2s
Build container images / deploy (push) Successful in 39s
SonarQube / analysis (push) Successful in 2m35s
Reviewed-on: #106
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-28 02:04:57 +00:00
565a7d2c20 refactor(server): split public, player and residual auth into capability routers (PR 5)
All checks were successful
PR Checks / bot-install (pull_request) Successful in 17s
PR Checks / client-build (pull_request) Successful in 25s
PR Checks / server-tests (pull_request) Successful in 9m20s
The last split PR of docs/website/API_V2_PLAN.md § Phase 2. public.routes.js,
player.routes.js and auth.routes.js are deleted; each group is now a directory
whose index.js owns the group gate and the mount table and declares no routes.
Every one of the 200 manifest routes is now in a capability router.

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

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

Notes on the non-mechanical parts:

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 20:52:14 -05:00
3fcc64ab96 Merge pull request 'refactor(server): split admin shard, uo-link, email, discord-bot, settings and dashboard into capability routers (PR 4)' (#105) from refactor/admin-router-split-4 into main
All checks were successful
sync-project-tree / sync (push) Successful in -11s
Build container images / build (push) Successful in 1m21s
Build container images / deploy (push) Successful in 40s
SonarQube / analysis (push) Successful in 2m47s
Reviewed-on: #105
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-28 01:33:45 +00:00
8fd0d82580 refactor(server): split admin shard, uo-link, email, discord-bot, settings and dashboard into capability routers
All checks were successful
PR Checks / bot-install (pull_request) Successful in 17s
PR Checks / client-build (pull_request) Successful in 24s
PR Checks / server-tests (pull_request) Successful in 9m21s
PR 4 of the in-place admin router split (docs/website/API_V2_PLAN.md § Phase 2),
and the last admin one: it moves the entire residual 33 and DELETES
admin.routes.js. Every one of the 110 admin routes is now declared in a
capability router. No URL, gate or handler changes.

  shard.router.js      (16)  /admin/shard
  uoLink.router.js     ( 5)  /admin/uo-link
  email.router.js      ( 6)  /admin/email
  discordBot.router.js ( 2)  /admin/discord-bot
  settings.router.js   ( 2)  /admin/settings
  dashboard.router.js  ( 2)  GET /dashboard + PUT /site-mode, at the group root
  admin.routes.js            deleted, was 33

No gate moved to router level. 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 — which keeps the per-route handler count intact, the one
number routes.guards.json can actually check.

/shard is the first prefix where two tiers share one router: 7 self-service
account-linking routes (no extra gate, served by the same player/shard
controller handlers, tagged `Admin · Account`) alongside 9 in-game staff ops on
modAccess. Prefix ownership beats tag grouping — splitting by tag would put two
routers under one prefix for no gain. The tag mismatch stays; retagging is a
real spec diff and belongs in a PR about tags.

dashboard.router.js is the one router mounted at the group root rather than a
prefix: GET /dashboard and PUT /site-mode share no path segment. That is safe
only because the file declares no router-level middleware — a bare use(gate) in
a root-mounted router would run for every request passing through toward
another mount. The file carries a comment saying so.

Acceptance — all four gates zero-diff:
  routes.manifest.json    unchanged (200 public + 2 internal)
  routes.guards.json      unchanged (no route lost or gained a gate)
  swagger-output.json     unchanged (198 operations)
  api-route-inventory.json already in sync
plus 434 server tests green.

Verified separately, because no gate can catch it: introspecting the built
stack, all 59 literal admin paths still dispatch to their own layer — nothing
is captured first by a /:param sibling. The manifest sorts its entries, so
declaration order is invisible to it.

Also repoints the comments that referenced admin.routes.js by name
(botActivity/moderation controllers, the town-crier cap mirror in
announceJobs.logic.js) and generalizes the "the path is on the line after
router.get(" rationale in routeManifest.js, README.md and pr-checks.yml, which
was never about that one file.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 20:02:28 -05:00
812b895507 Merge pull request 'refactor(server): split admin posts, uploads, wiki and pages into capability routers (PR 3)' (#104) from refactor/admin-router-split-3 into main
All checks were successful
sync-project-tree / sync (push) Successful in 13s
Build container images / build (push) Successful in 51s
Build container images / deploy (push) Successful in 35s
SonarQube / analysis (push) Successful in 2m39s
Reviewed-on: #104
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-28 00:36:27 +00:00
00ad16858a refactor(server): split admin posts, uploads, wiki and pages into capability routers
All checks were successful
PR Checks / bot-install (pull_request) Successful in 16s
PR Checks / server-tests (pull_request) Successful in 37s
PR Checks / client-build (pull_request) Successful in 9m15s
PR 3 of the in-place admin router split (docs/website/API_V2_PLAN.md § Phase 2).
Moves the content tier out of the residual admin.routes.js into one router file
per capability, each mounted at the prefix it already owned. No URL, gate or
handler changes.

  posts.router.js     ( 9)  /admin/posts
  uploads.router.js   ( 1)  /admin/uploads
  wiki.router.js      (14)  /admin/wiki
  pages.router.js     ( 7)  /admin/pages
  admin.routes.js     (33)  residual, was 64

All four capabilities are editor tier, so no gate moved: the shared
`noindex, isLoggedIn, staffOnly` in admin/index.js is their whole gate.

The multer config moved to admin/imageUpload.js because the two routes that
share it (POST /posts/upload and POST /uploads) now live in different files;
duplicating a mimetype allowlist is how the two copies drift. It stays in
admin/ because UPLOAD_DIR is resolved relative to __dirname.

Acceptance — all four gates zero-diff:
  routes.manifest.json    unchanged (200 public + 2 internal)
  routes.guards.json      unchanged (no route lost or gained a gate)
  swagger-output.json     unchanged (198 operations)
  api-route-inventory.json already in sync
plus 434 server tests green.

Verified separately, because no gate can catch it: the wiki router's literal
/categories and /tags paths still precede /:slug in declaration order. The
manifest sorts its entries, so a reordering there would be invisible.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 19:24:07 -05:00
493843241e Merge pull request 'refactor(server): split admin moderation, bot-activity and activity into capability routers (PR 2)' (#103) from refactor/admin-router-split-2 into main
All checks were successful
sync-project-tree / sync (push) Successful in 16s
Build container images / build (push) Successful in 57s
Build container images / deploy (push) Successful in 35s
SonarQube / analysis (push) Successful in 2m40s
Reviewed-on: #103
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-28 00:12:02 +00:00
bd53a0b8a4 refactor(server): split admin moderation, bot-activity and activity into capability routers
All checks were successful
PR Checks / bot-install (pull_request) Successful in 24s
PR Checks / client-build (pull_request) Successful in 32s
PR Checks / server-tests (pull_request) Successful in 46s
PR 2 of the domain split (docs/website/API_V2_PLAN.md § Phase 2). Carves 18 more
routes out of admin.routes.js into one router file per business capability,
in place, with every URL unchanged:

  moderation.router.js   (15)  /admin/moderation    modAccess at router level
  botActivity.router.js   (2)  /admin/bot-activity  adminOnly per route
  activity.router.js      (1)  /admin/activity      staff-wide, no extra gate

The residual admin.routes.js drops from 82 routes to 64.

Moderation was already gated by a prefix mount (adminRouter.use('/moderation',
modAccess)), so moderationRouter.use(modAccess) is the exact equivalent now that
the router is mounted at a prefix. Bot-activity's adminOnly was per-route and is
deliberately kept per-route: that is what holds the per-route handler count in
routes.guards.json, the only signal that would catch a dropped gate, since
requireRole(...) returns an anonymous arrow and never appears by name.

/activity gets its own file rather than waiting for dashboard.router.js in PR 4
— it is the staff audit log, a different capability from the dashboard's stats
overview and from the botScore middleware's in-memory ban state.

Acceptance:
  - routes.manifest.json  zero-diff (200 public + 2 internal)
  - routes.guards.json    zero-diff
  - swagger-output.json   zero-diff (198 operations)
  - api-route-inventory.json already in sync
  - 434 server tests green
  - role gates verified identical to main by reading the requireRole role sets
    off the live Express stack for every moved route plus untouched controls

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 18:53:15 -05:00
0e11e28cca Merge pull request 'build(swagger): normalize and sort generated OpenAPI path keys' (#101) from build/swagger-normalize-paths into main
All checks were successful
sync-project-tree / sync (push) Successful in 12s
Build container images / build (push) Successful in 53s
Build container images / deploy (push) Successful in 37s
SonarQube / analysis (push) Successful in 2m38s
Reviewed-on: #101
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-27 21:00:57 +00:00
f7c98b8ba3 Merge pull request 'refactor(server): split admin users, account, invites and auth providers into capability routers' (#102) from refactor/admin-router-split-1 into build/swagger-normalize-paths
All checks were successful
PR Checks / bot-install (pull_request) Successful in 21s
PR Checks / client-build (pull_request) Successful in 28s
PR Checks / server-tests (pull_request) Successful in 41s
Reviewed-on: #102
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-27 21:00:03 +00:00
8ad892725f refactor(server): split admin users, account, invites and auth providers into capability routers
First of the five domain-split PRs in docs/website/API_V2_PLAN.md § Phase 2. Pure
mechanical re-wiring: routes move between files, no handler, gate, validator or
annotation changes, and not one URL moves.

New src/router/v1/admin/index.js owns the two things the group shares — the
`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. Four capability routers mount at the
prefix they already owned inside the monolith:

  account.router.js        6 routes  -> /admin/account   (self-service, no adminOnly)
  users.router.js         15 routes  -> /admin/users     (adminOnly, router-level)
  invites.router.js        3 routes  -> /admin/invites   (adminOnly, per-route)
  authProviders.router.js  4 routes  -> /admin/auth      (adminOnly, per-route)

admin.routes.js keeps the other 82 (6+15+3+4+82 = the 110 inventoried admin
routes) and is mounted last at the group root; none of the four prefixes appears
in it, so nothing depends on mount ordering. It disappears when PR 5 lands.

Handlers still live in admin.controller.js and usersShard.controller.js — this
re-wires routes, not logic. `adminOnly` moves with the routes that use it, and
`usersRouter.use(adminOnly)` is exactly equivalent to the old
`adminRouter.use('/users', adminOnly)` now that the router is mounted at /users.

All three generated gates are zero-diff:

  routes.manifest.json    unchanged (200 public + 2 internal)
  routes.guards.json      unchanged — no route lost or gained a gate
  swagger-output.json     unchanged, byte-for-byte

The spec staying byte-identical depends on the path normalization landed in the
preceding commit; without it the four collection routes would have documented as
/api/v1/admin/{users,invites,account}/ with a trailing slash.

Server tests green (434/434).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 15:54:00 -05:00
1a61cd1638 build(swagger): normalize and sort generated OpenAPI path keys
All checks were successful
PR Checks / bot-install (pull_request) Successful in 15s
PR Checks / client-build (pull_request) Successful in 23s
PR Checks / server-tests (pull_request) Successful in 9m16s
Prepares the committed spec for the admin router domain split
(docs/website/API_V2_PLAN.md § Phase 2) by post-processing swagger-autogen's
output in swagger/swagger.js. No route, handler or annotation changes.

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('/') 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
(non-strict routing treats the two as one route, and routes.manifest.json records
the canonical slash-less form), but the published spec is a contract. The split
creates one of these per capability router, so it is fixed once here rather than
by contorting the route declarations in every router file.

Path keys are also sorted. The generator emits them in router-traversal order, so
moving a route between files rewrites most of this ~5k-line committed artifact
even when the API is provably unchanged, burying the one line a reviewer needs to
see. OpenAPI attaches no meaning to path order, and scripts/routeManifest.js
already sorts for the same reason.

Verified inert: the regenerated spec is byte-for-byte the sorted form of the
previously committed one — same 198 operations, zero added or removed, and no
trailing-slash keys (there were none to strip yet; the guard is for the split).
A collision after normalization throws rather than silently dropping an
operation. Server tests green (434/434).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 15:49:31 -05:00
0dc5af0d8b Merge pull request 'feat(security): soak the tightened CSP on report-only, with a same-origin sink' (#100) from feature/csp-report-only into main
All checks were successful
sync-project-tree / sync (push) Successful in 12s
Build container images / build (push) Successful in 1m20s
Build container images / deploy (push) Successful in 42s
SonarQube / analysis (push) Successful in 2m44s
Reviewed-on: #100
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-27 20:31:31 +00:00
9b74999610 feat(security): soak the tightened CSP on report-only, with a same-origin sink
All checks were successful
PR Checks / bot-install (pull_request) Successful in 16s
PR Checks / server-tests (pull_request) Successful in 37s
PR Checks / client-build (pull_request) Successful in 9m15s
Phase 1 of docs/website/API_V2_PLAN.md. The tightened policy ships on
Content-Security-Policy-Report-Only alongside the unchanged enforced one for a
release; a follow-up PR flips it after the soak comes back clean.

The plan expected a two-directive delta. It is one. `form-action 'self'` was
described as absent because it is not in the directives object in app.js — but
the middleware runs with `useDefaults: true` and helmet's defaults already
supply it, so the header served in production has carried it all along. Caught
by capturing the live header from the running app instead of reading the config.
It is now written out explicitly in config/csp.js regardless: a security
directive should not depend on a third-party library's default surviving its
next major version. The enforced header's contents do not change at all, and a
test pins it verbatim.

So the whole behavioural delta is `frame-ancestors 'self'` -> `'none'`. That is
still the directive most worth soaking: a frame-ancestors report is generated by
the browser of whoever framed the site, which is the only way to find out that
something legitimately embeds us before an enforcing policy breaks it.

The policies move to config/csp.js, with the report-only one derived by spread
from the enforced one so the two cannot drift and the object reads as a diff.

`report-to` needs somewhere to point, so this adds POST /api/csp-report --
same-origin on purpose, since reports describe attacks against this site and
should not go to a third-party collector. It is 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 versioned client contract.

It is necessarily unauthenticated -- browsers send reports with no session, and
gating it would silence exactly the anonymous visitors worth hearing about -- so
it is bounded on every axis:

  * both wire formats, since report-uri (Firefox/Safari) sends hyphenated keys
    in application/csp-report and report-to (Chrome) sends camelCase envelopes
    in application/reports+json; handling one silently drops half the browsers,
  * report-to also needs the Reporting-Endpoints response header or it is inert,
  * 16 KB body cap, per-IP rate limit, fixed field allowlist, every logged field
    truncated (script-sample is attacker-influenced and can carry a whole inline
    script),
  * always 204, even for malformed input: a 4xx would reach the global error
    handler, which logs the offending body -- turning an open endpoint into a
    log-flood primitive.

Nothing is persisted; reports go to the `csp` log tag.

routes.manifest.json moves 199 -> 200, which is the freeze from PR 0 working as
designed: the one new URL is visible as a reviewed +1 rather than slipping
through. Swagger regenerated to match.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 15:19:26 -05:00
49b70ee04d Merge pull request 'chore(server): freeze the URL surface with a generated route manifest (PR 0)' (#99) from chore/route-manifest into main
All checks were successful
sync-project-tree / sync (push) Successful in 16s
SonarQube / analysis (push) Successful in 2m28s
Build container images / build (push) Successful in 1m15s
Build container images / deploy (push) Successful in 41s
Reviewed-on: #99
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-27 20:07:29 +00:00
1079b3fc05 chore(server): freeze the URL surface with a generated route manifest
All checks were successful
PR Checks / bot-install (pull_request) Successful in 17s
PR Checks / client-build (pull_request) Successful in 25s
PR Checks / server-tests (pull_request) Successful in 9m40s
PR 0 of the router domain split (docs/website/API_V2_PLAN.md § Phase 2). The
split promises that admin.routes.js can be carved into one router file per
business capability without moving a single URL. That promise has to be proved
by a diff, not asserted in review — this lands the tool that proves it, with no
router file moved.

scripts/routeManifest.js walks the live Express stack (runtime introspection,
not source parsing: route paths in admin.routes.js sit on the line *after*
`adminRouter.get(`, which defeats greps) and writes a sorted { method, path }
list to routes.manifest.json. It reproduces the frozen baseline in
docs/website/api-route-inventory.json byte-for-byte — 199 public routes plus 2
on the internal listener — so the freeze is confirmed accurate, not just
claimed.

Scope is /api/** and /.well-known/** plus the internal app. The SPA catch-all,
/uploads and /brand are filesystem-conditional static mounts, so including them
would make the output depend on whether CI had built the client. Static mounts
are not API contract.

Also emits routes.guards.json — a review aid, not a contract: per route, the
handler count and the *named* middleware on its mount chain. Router-level
`use(noindex, isLoggedIn, staffOnly)` gates never appear in an individual
route's own stack, so an extracted capability router that forgot to re-apply
one would otherwise publish authenticated endpoints silently. Names are a hint
only (requireRole(...) returns an anonymous arrow), but a vanished requireAuth
is unambiguous — and the test suite asserts every /admin/** and /player/**
route still carries it.

The plan's optional unauthenticated-status snapshot was tried and dropped, as
it allowed: against the dead-port mariadb pool the tests use, the sweep sits on
the pool's acquire timeout and had not finished after two minutes. A flaky
two-minute gate is worse than none; the requireAuth assertion covers the same
regression deterministically.

CI runs `npm run routes:manifest -- --check` on every PR, so a URL change can
only merge by deliberately committing the new manifest.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 14:55:38 -05:00
cbe54fcc91 Merge pull request 'ci(docs): auto-sync PROJECT_TREE.md to the docs repo on push to main' (#98) from chore/sync-project-tree-ci into main
All checks were successful
sync-project-tree / sync (push) Successful in -6s
Build container images / build (push) Successful in 1m17s
Build container images / deploy (push) Successful in 37s
SonarQube / analysis (push) Successful in 2m46s
Reviewed-on: #98
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 21:31:48 +00:00
9f9bcc6f6e ci(docs): auto-sync PROJECT_TREE.md to the docs repo on push to main
All checks were successful
PR Checks / bot-install (pull_request) Successful in 15s
PR Checks / client-build (pull_request) Successful in 24s
PR Checks / server-tests (pull_request) Successful in 9m33s
Add a sync-project-tree workflow that regenerates this repo's tracked-file
tree and opens (or force-updates) a PR against RunicGateway/docs whenever the
layout on main changes. Never writes to the docs repo's main directly. Reuses
the existing REGISTRY_USER / REGISTRY_TOKEN secrets. Tree rendering lives in
.gitea/scripts/gen_tree.py (deterministic, dirs-first ordering).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 16:21:22 -05:00
ebfae765d9 Merge pull request 'fix(moderation): windowValue must not fall back to the 30d total on a null column' (#97) from fix/window-value-null-column into main
All checks were successful
Build container images / build (push) Successful in 54s
Build container images / deploy (push) Successful in 36s
SonarQube / analysis (push) Successful in 2m27s
Reviewed-on: #97
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 18:15:46 +00:00
c075ab981c fix(moderation): windowValue must not fall back to the 30d total on a null column
All checks were successful
PR Checks / bot-install (pull_request) Successful in 15s
PR Checks / client-build (pull_request) Successful in 24s
PR Checks / server-tests (pull_request) Successful in 39s
windowValue mapped only the 24h/7d keys and used `?? row.d30` as the fallback:

    const col = { '24h': row.d1, '7d': row.d7 }[key] ?? row.d30

so a null d1/d7 (which the function is documented to tolerate) returned the
30-day count instead of 0, inflating the 24h/7d moderation tiles. It happens to
be masked today because `SUM(created_at >= ?)` nulls d1/d7/d30 only in unison,
but the contract is wrong and the existing test used an all-null row that hid it.

Map all three window keys explicitly so each reads its own column and a null
coerces to 0 via `Number(col) || 0`. Add a regression test with a null narrow
column and a non-null d30.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 13:14:02 -05:00
bcdba4ce0a Merge pull request 'fix(admin): restore digit match in discordId route validation' (#96) from fix/discord-id-validation-regex into main
All checks were successful
Build container images / build (push) Successful in 1m9s
Build container images / deploy (push) Successful in 46s
SonarQube / analysis (push) Successful in 2m36s
Reviewed-on: #96
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 18:11:42 +00:00
e08c0c9736 fix(admin): restore digit match in discordId route validation
All checks were successful
PR Checks / bot-install (pull_request) Successful in 17s
PR Checks / client-build (pull_request) Successful in 25s
PR Checks / server-tests (pull_request) Successful in 9m30s
The `:discordId` param validator on the five admin moderation routes used
`/^d{1,32}$/`, which matches 1-32 literal `d` characters instead of digits.
A real numeric Discord snowflake failed validation, so every
`/moderation/user/:discordId*` endpoint returned a 400 for valid input.

The backslash was dropped in a prior code-smell cleanup (12d50fd) that
intended `[0-9]` -> `\d`. Restore `\d` so the regex matches digits again.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 13:01:12 -05:00
5fe7032567 Merge pull request 'fix(ntfy): publish ntfy host port so the external reverse proxy can reach it' (#95) from fix/ntfy-published-port into main
All checks were successful
Build container images / build (push) Successful in 57s
Build container images / deploy (push) Successful in 34s
SonarQube / analysis (push) Successful in 2m31s
Reviewed-on: #95
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 09:08:13 +00:00
4151f7d44e fix(ntfy): publish ntfy host port so the external reverse proxy can reach it
All checks were successful
PR Checks / bot-install (pull_request) Successful in 18s
PR Checks / client-build (pull_request) Successful in 30s
PR Checks / server-tests (pull_request) Successful in 9m29s
The ntfy service was configured with no published host port, on the
assumption that the public reverse proxy shares the compose network and
can dial ntfy:80 directly. It does not — Pangolin runs outside the
compose network and reaches every service through a published host port
(exactly why `app` publishes 3000). With no published port there was
nothing for the notification subdomain to forward to, so push delivery
could never work in production.

Publish container :80 on a host port (NTFY_HOST_PORT, default 2586,
binds 0.0.0.0 like `app`) and correct the now-inaccurate comments in
docker-compose.yml and ntfy/server.yml. Document NTFY_HOST_PORT in
.env.example. No code change — deploy config only.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 03:57:32 -05:00
4f1a4902e8 Merge pull request 'fix(player): open the player self-service surface to staff' (#94) from fix/staff-player-self-service into main
All checks were successful
SonarQube / analysis (push) Successful in 2m40s
Build container images / build (push) Successful in 22s
Build container images / deploy (push) Successful in 38s
Reviewed-on: #94
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 08:36:13 +00:00
14dfc122ba fix(player): open the player self-service surface to staff
All checks were successful
PR Checks / bot-install (pull_request) Successful in 18s
PR Checks / client-build (pull_request) Successful in 27s
PR Checks / server-tests (pull_request) Successful in 9m28s
Staff are a superset of players — every player ability plus their staff
tools on top — but the /player/* group ran requireRole('player'), so a
signed-in admin/editor/moderator got 403 on their own linked game
accounts (e.g. GET /player/shard/accounts). On the Android client this
hid "My characters" and greyed the personal notification streams for
staff accounts, even when they had linked characters.

Drop the role gate: the group is now requireAuth-only. Every handler is
already self-scoped to the caller by req.user.id (with the pre-existing
isAdmin bypass still letting a genuine admin read any character), so this
only ever widens access to the caller's OWN data. Staff also reach the
identical self-scoped handlers under /admin/shard/* (same controller).

- player.routes.js: requireRole('player') -> requireAuth; corrected the
  five stale "Player role required" 403 descriptions and regenerated
  swagger-output.json.
- New test/playerRouteAccess.test.js mounts the router and asserts
  player/admin/editor/moderator all reach the handler, anon still 401s,
  and a disabled account still 403s. Suite: 420 pass.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 02:17:29 -05:00
514bc9d23c Merge pull request 'feat(auth): trusted devices, recovery codes, and admin MFA management' (#93) from feature/trusted-devices-mfa into main
All checks were successful
Build container images / build (push) Successful in 1m3s
Build container images / deploy (push) Successful in 36s
SonarQube / analysis (push) Successful in 2m32s
Reviewed-on: #93
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 05:09:04 +00:00
60ebacff2c feat(auth): trusted devices, recovery codes, and admin MFA management
All checks were successful
PR Checks / bot-install (pull_request) Successful in 19s
PR Checks / server-tests (pull_request) Successful in 42s
PR Checks / client-build (pull_request) Successful in 9m24s
Add opt-in "Trust this device" so a browser/app skips the TOTP step (never
the password) for 30 days, single-use bcrypt recovery codes as a 2FA-lockout
fallback, and admin trusted-device/MFA-reset management — backend, web UI,
OpenAPI spec, and tests.

- Schema: trusted_devices (sha256 token hash, looked up by unique index) and
  recovery_codes (bcrypt, single-use). Both additive/idempotent.
- Session service: trust-token mint/hash/resolve + cap helpers; new rg_trust
  httpOnly cookie (survives logout, revoked on untrust/password change/reset/
  TOTP disable). JWTs stay stateless — trust is a server-side row, not a claim.
- Web + mobile login accept a trusted-device token / recovery code; login/totp
  gains trustDevice + recoveryCode. Cap of 10/user with NO silent pruning — an
  over-cap trust returns 409/trustLimitReached and the client prompts to revoke.
- Self-service /auth/me/trusted-devices* + recovery-codes*; admin
  /admin/users/:id/trusted-devices* + /mfa/reset. All actions audit-logged.
- Client: "Trust this device" + recovery-code login options, one-time recovery
  code display, Trusted Devices + Recovery Codes account panels, a TOTP-styled
  revoke-to-continue cap modal, and admin per-user security controls.
- OpenAPI regenerated; 33 new server tests (all suites green).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 23:38:48 -05:00
8d5bdc0d6e Merge pull request 'docs(readme): add architecture mermaid diagram' (#92) from docs/website-architecture-diagram into main
All checks were successful
Build container images / build (push) Successful in 1m5s
Build container images / deploy (push) Successful in 40s
SonarQube / analysis (push) Successful in 2m23s
Reviewed-on: #92
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 02:17:16 +00:00
c991a07c8a docs(readme): add architecture mermaid diagram
All checks were successful
PR Checks / bot-install (pull_request) Successful in 14s
PR Checks / client-build (pull_request) Successful in 23s
PR Checks / server-tests (pull_request) Successful in 35s
Add an Architecture section with a Mermaid diagram of the full data path
(SPA/mobile clients -> layered Express backend -> MariaDB, and the uo-link
sidecar bridge to the ServUO shard) plus a Contents entry. Same diagram is
mirrored in the docs repo (docs/website/ARCHITECTURE.md).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 21:13:11 -05:00
4c13706958 Merge pull request 'chore(dev): stub OAuth IdP tooling for local mobile SSO testing' (#91) from feat/m10-native-sso-fix into main
All checks were successful
Build container images / build (push) Successful in 57s
Build container images / deploy (push) Successful in 37s
SonarQube / analysis (push) Successful in 11m55s
Reviewed-on: #91
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-21 21:42:57 +00:00
2306545574 chore(dev): add viewport meta to the stub IdP picker page
All checks were successful
PR Checks / bot-install (pull_request) Successful in 13s
PR Checks / client-build (pull_request) Successful in 9m24s
PR Checks / server-tests (pull_request) Successful in 10m47s
So the dev stub IdP's account-picker renders at the correct mobile size when it
opens in an Android Custom Tab during SSO testing.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 16:28:46 -05:00
70849f96ee chore(dev): add stub OAuth IdP + seed + bridge smoketest for native SSO
Dev environments have no real OAuth provider configured, so GET /auth/providers
returns [] and the native mobile SSO flow cannot be exercised locally. Add
dependency-free dev tooling under scripts/dev/:

- stub-idp.js: stub OAuth2/OIDC IdP (authorize picker, token, userinfo)
- seed-sso-provider.js: registers a 'devstub' auth_providers row + pre-links
  each principal's sub to a dev account (SSO is link-only)
- sso-bridge-smoketest.js: drives the full app flow headless (PKCE → start →
  IdP → callback → deep link → exchange) and asserts a bearer pair
- README.md: host + emulator usage

Verified end-to-end against the local site: player and admin principals both
sign in and receive the correct role. DEV ONLY — never deploy the stub.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 14:56:43 -05:00
1edef8e6db Merge pull request 'fix(footer): point Shard Status link to /site/shard' (#90) from fix/footer-shard-status-link into main
All checks were successful
Build container images / build (push) Successful in 2m21s
SonarQube / analysis (push) Successful in 2m27s
Build container images / deploy (push) Successful in 37s
Reviewed-on: #90
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-21 19:10:48 +00:00
dc90df9fff fix(footer): point Shard Status link to /site/shard
All checks were successful
PR Checks / bot-install (pull_request) Successful in 15s
PR Checks / client-build (pull_request) Successful in 25s
PR Checks / server-tests (pull_request) Successful in 38s
The footer's "Shard Status" link targeted /site/status; point it at the
richer live shard page at /site/shard.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 14:09:47 -05:00
68126efc0b Merge pull request 'refactor(server): dedupe shard-state shaping, upsert builder, and config DB models' (#89) from refactor/dedupe-shardstate-config-db into main
All checks were successful
Build container images / build (push) Successful in 1m4s
Build container images / deploy (push) Successful in 39s
SonarQube / analysis (push) Successful in 12m11s
Reviewed-on: #89
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-21 17:49:31 +00:00
401db8f75c refactor(server): dedupe shard-state shaping, upsert builder, and config DB models
All checks were successful
PR Checks / bot-install (pull_request) Successful in 18s
PR Checks / client-build (pull_request) Successful in 26s
PR Checks / server-tests (pull_request) Successful in 11m4s
Address the SonarQube copy-paste findings that reflect real duplication (as
opposed to the intentional cross-package / admin-player mirror copies, which
are by-design and left as-is):

- shardState.model.js: listOnline() re-inlined the exact field mapping that
  shapeOnline() already provides (used by listOnlineLinked). Collapse it onto
  shapeOnline so the two can no longer drift.
- shardState.db.js: extract a single upsertRow(table, pkCol, pk, fields,
  {coalesce}) builder for the five near-identical INSERT ... ON DUPLICATE KEY
  UPDATE bodies (online/houses/champs/guilds/governors). shard_online keeps its
  COALESCE-on-NULL semantics via the coalesce flag.
- botConfig/emailConfig/uoLinkConfig .db.js: generate get()/upsert() from a
  shared singletonConfigDb(table, cols) factory instead of three byte-identical
  copies.

Behavior unchanged; full server suite (381 tests) passes.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 12:35:56 -05:00
9b0f2d93d8 Merge pull request 'chore(quality): resolve SonarQube code smells across website' (#88) from chore/sonar-code-smells into main
All checks were successful
Build container images / build (push) Successful in 1m19s
Build container images / deploy (push) Successful in 39s
SonarQube / analysis (push) Successful in 2m29s
Reviewed-on: #88
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-21 14:27:56 +00:00
12d50fd615 chore(quality): resolve SonarQube code smells across website
All checks were successful
PR Checks / bot-install (pull_request) Successful in 13s
PR Checks / client-build (pull_request) Successful in 22s
PR Checks / server-tests (pull_request) Successful in 11m13s
Clears the 124 CODE_SMELL findings from the SonarQube scan (server, client,
and bot). All changes are behaviour-preserving refactors — no route, protocol,
schema, or config changes — verified against the full server (381) and client
(43) test suites plus a clean client build.

By rule:
- S3776 (20, cognitive complexity): extract helpers/handlers so each function
  drops under the threshold — shard model upsert builders, page/wiki update,
  block validation, notification stream mapping (dispatch table), SSO mobile
  login, shard ingest deps, uo-link socket backfill/connect, the bot slash-
  command dispatchers + discord manager, and the Shard/UserDetail/HeroEditor/
  CharacterStats React components.
- S4624 (34, nested template literals): pull inner templates into locals /
  a withQs() helper; rewrite shardEvents.describe() as a formatter table.
- S3358 (35, nested ternaries): lift to if/else vars, lookup maps, small
  components, or guarded JSX expressions.
- S6479 (12, array-index React keys): key by stable content instead of index
  (two in-editor lists left as-is; index matches their by-index edit model).
- S6353 (6): [0-9]/[^0-9] -> \d/\D.  S125 (5): reword state-shape comments that
  parsed as code.  S3800/S3782 (botScore): JSDoc-type PATH_WEIGHTS tuples.
- S6481 (2): memoize Auth/Site context values (and SiteContext brand).
- S4144: dedupe HeroEditor upload handler into useImageUpload().
- S1126 (2), S6035, S5869 (redundant A-Z under /i), S5843 (town-name regex ->
  prefix list): assorted one-liners.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 04:35:39 -05:00
4993470fa2 Merge pull request 'ci(sonarqube): populate the "Unit Tests" measure via a test-execution report' (#87) from ci/sonar-test-execution-report into main
All checks were successful
Build container images / build (push) Successful in 59s
Build container images / deploy (push) Successful in 37s
SonarQube / analysis (push) Successful in 2m28s
Reviewed-on: #87
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-21 06:26:40 +00:00
2f3e8f7df5 ci(sonarqube): report test execution so the Unit Tests measure populates
All checks were successful
PR Checks / bot-install (pull_request) Successful in 14s
PR Checks / client-build (pull_request) Successful in 23s
PR Checks / server-tests (pull_request) Successful in 9m25s
The lcov reports only feed SonarQube's Coverage metric — the "Unit Tests" tile
stayed "-" because we never provided a test-execution report (a separate input
via sonar.testExecutionReportPaths, in SonarQube's own Generic Test Execution
XML format, which the lcov/junit reporters don't produce).

Add a dependency-free custom node:test reporter (scripts/sonar-test-reporter.mjs)
that emits that XML — repo-root-relative <file path> entries matching sonar.tests,
integer-ms durations — and wire it into both the server and client coverage runs
in sonarqube.yml, plus sonar.testExecutionReportPaths in sonar-project.properties.

Verified locally: server 380 + client 43 test cases, well-formed XML, all three
reporters (spec/lcov/sonar) coexist in one `node --test` invocation.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 01:16:08 -05:00
68a4e9295b Merge pull request 'test: meaningful unit tests for server models/controllers + client logic' (#86) from test/coverage-meaningful-gaps into main
All checks were successful
Build container images / build (push) Successful in 1m3s
Build container images / deploy (push) Successful in 37s
SonarQube / analysis (push) Successful in 2m32s
Reviewed-on: #86
2026-07-21 05:55:46 +00:00
886a504152 test(client): unit-test the pure-logic modules + wire coverage into CI/Sonar
All checks were successful
PR Checks / bot-install (pull_request) Successful in 15s
PR Checks / client-build (pull_request) Successful in 44s
PR Checks / server-tests (pull_request) Successful in 9m36s
Stand up a client test suite on Node's built-in runner (no vitest/jsdom — the
targeted modules are plain ESM with no browser/DOM deps) and cover the
meaningful client logic, not presentational components:

- api/client.js: the fetch wrapper — always sends the session cookie, maps a
  non-2xx response to a thrown ApiError (body.message → statusText fallback),
  resolves an empty body to null, sets Content-Type for JSON but NOT for raw
  FormData uploads, and builds/encodes query strings + path params.
- lib/shardEvents.js: describe() across event kinds (payload vs live frame,
  actor name→acct→"Someone" fallback, sale pluralization, champ.update
  branches) and the categoryOf table-consistency check.
- data/regionBuckets.js: the presence roll-up, incl. the first-match-wins
  ordering and the "buckets always reconcile to the total" invariant.
- lib/heroLayout.js: parseLayout's version/shape guard and heroBackground's
  default-vs-custom branch.
- lib/format.js: the date/label formatters + relative-time buckets.

Wiring so these actually count:
- client/package.json gains a `test` script (node --test);
- pr-checks.yml runs the client tests as a PR gate;
- sonarqube.yml generates a client LCOV report and sonar-project.properties
  feeds it alongside the server report (SF paths resolve to client/src/...).

43 client tests; coverage on the tested modules: format/regionBuckets/
heroLayout 100%, api client 86%, shardEvents 80%.

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 00:33:01 -05:00
99fd9acddb test(server): unit-test pages, shardState, and moderation model logic
Add meaningful unit tests for three server models with untested business
logic, each against an in-memory fake db (no DB required):

- pages.model: slug validation + reserved-name guard, slug immutability,
  the protected ON-via-update / OFF-only-via-unprotect asymmetry, publish
  stamping, draft invisibility to public reads, dup-slug → 409, block gate.
- shardState.model: partial-refresh field dropping (vitals must not clobber
  login fields), is_idoc derivation, economy clamp/ordering/Number coercion,
  presence zero-snapshot defaults, payload-fallback shaping, and the
  camelCase read-shaping contract the site + Android client depend on.
- moderation.model: five-feed window merge, userSummary count/total
  semantics (total sums unknown types too), and graceful degradation when
  bot config is missing.

Lifts: pages.model 23%→82%, moderation.model 27%→85%,
shardState.model 33%→62% line coverage.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 00:26:01 -05:00
b474303052 Merge pull request 'ci(sonarqube): generate and report server test coverage' (#85) from ci/sonar-test-coverage into main
All checks were successful
Build container images / build (push) Successful in 2m54s
SonarQube / analysis (push) Successful in 2m55s
Build container images / deploy (push) Successful in 47s
Reviewed-on: #85
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-21 04:57:06 +00:00
e515fc0c9d ci(sonarqube): generate and report server test coverage
All checks were successful
PR Checks / bot-install (pull_request) Successful in 18s
PR Checks / server-tests (pull_request) Successful in 44s
PR Checks / client-build (pull_request) Successful in 9m24s
SonarQube reported 0% coverage because the analysis workflow never ran
the test suite — the scanner does static analysis only and was handed no
coverage report, and sonar-project.properties defined no report path.

Generate an LCOV report in sonarqube.yml before the scan using Node's
built-in test coverage (run from the repo root so SF: paths are
server/src/... and resolve against the project base dir), and point
the scanner at it via sonar.javascript.lcov.reportPaths. Node's lcov
coverage reporter needs Node >= 22, so the coverage job pins node 22.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 23:46:44 -05:00
c73273f1bc Merge pull request 'fix(security): add SPA CSP, drop x-powered-by, strengthen dedupe hash' (#84) from fix/security-headers-csp into main
All checks were successful
Build container images / build (push) Successful in 1m31s
Build container images / deploy (push) Successful in 38s
SonarQube / analysis (push) Successful in 2m15s
Reviewed-on: #84
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-21 04:13:41 +00:00
5e5e0d7a91 docs(readme): add SonarQube project badges
All checks were successful
PR Checks / bot-install (pull_request) Successful in 15s
PR Checks / client-build (pull_request) Successful in 9m23s
PR Checks / server-tests (pull_request) Successful in 10m10s
Bugs, code smells, duplicated lines, LOC, security hotspots, security rating,
and vulnerabilities badges linking to the project dashboard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-20 23:02:11 -05:00
e1461d9161 fix(security): add SPA CSP, drop x-powered-by, strengthen dedupe hash
Address SonarQube security hotspots on the website:

- server/src/app.js: replace `contentSecurityPolicy: false` with a helmet CSP
  tuned for the built React SPA (script-src 'self'; style-src adds 'unsafe-inline'
  for React inline styles + the Google Fonts stylesheet; font-src gstatic; img-src
  allows data:/https: for uploads, embedded body images and BRAND_* assets;
  connect-src 'self' for REST+SSE). upgrade-insecure-requests is intentionally
  omitted (TLS terminates at the proxy; keeps local `npm start` over http working).
  The /api/docs Swagger UI route gets a scoped looser policy (inline script/style)
  since swagger-ui-express injects an inline bootstrap.
- client/vite.config.js: disable the inline module-preload polyfill so code-split
  builds keep `script-src 'self'` valid (RichTextEditor is a separate chunk).
- bot/src/app.js, server/src/internalApp.js: disable x-powered-by on the two
  internal-only listeners (the public app already strips it via helmet).
- shardEvents dedupe key: SHA-1 -> SHA-256 truncated to 40 hex chars (fits the
  existing CHAR(40) column, no migration; it is a content fingerprint, not a
  security value). schema.sql comment updated to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-20 23:02:11 -05:00
82bf2c972c Merge pull request 'ci(sonarqube): non-blocking SonarQube analysis on push to main' (#83) from ci/sonarqube-analysis into main
All checks were successful
Build container images / build (push) Successful in 1m18s
Build container images / deploy (push) Successful in 36s
SonarQube / analysis (push) Successful in 2m13s
Reviewed-on: #83
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-21 02:54:14 +00:00
c54bb54834 ci(sonarqube): add non-blocking SonarQube analysis on push to main
All checks were successful
PR Checks / bot-install (pull_request) Successful in 1m16s
PR Checks / server-tests (pull_request) Successful in 9m41s
PR Checks / client-build (pull_request) Successful in 10m39s
Wire the self-hosted SonarQube server into Gitea via a Gitea Actions
workflow. Runs on push to `main` (post-merge) and workflow_dispatch, so
it feeds the dashboard without gating any PR. Adds sonar-project.properties
(project key runic-gateway-website; server/client/bot sources, server tests,
node_modules/dist/generated excluded).

Requires two one-time Gitea settings: secret SONAR_TOKEN and variable
SONAR_HOST_URL. The scan does not wait on the Quality Gate, keeping it
fully non-blocking.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 21:43:00 -05:00
86420661b5 Merge pull request 'fix(db): strip inline -- comments before splitting schema statements' (#82) from fix/schema-loader-inline-comment-split into main
All checks were successful
Build container images / build (push) Successful in 1m19s
Build container images / deploy (push) Successful in 45s
Reviewed-on: #82
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-21 00:31:13 +00:00
d1b3351360 fix(db): strip inline -- comments before splitting schema statements
All checks were successful
PR Checks / server-tests (pull_request) Successful in 10m18s
PR Checks / client-build (pull_request) Successful in 9m32s
PR Checks / bot-install (pull_request) Successful in 9m26s
The schema loader stripped only full-line -- comments, then split the
file on ';'. A trailing comment containing a semicolon (e.g. the
mobile_auth_sessions.session_id column: `-- uuid; carried inside...`)
chopped the CREATE TABLE in half, so MariaDB got the fragment and failed
with `error ... near '' at line 3`, crash-looping the server on boot.

Strip -- comments on every line (full-line and trailing) before the ';'
split. Safe because the schema never places -- inside a string literal.

Verified by running ensureSchema() against a fresh MariaDB: all 49 tables
create cleanly and mobile_auth_sessions has all 11 columns.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 19:10:53 -05:00
dacc1bd4f7 Merge pull request 'feat(mobile-sso): serve assetlinks.json + App Links redirect allowlist' (#81) from feat/mobile-app-links into main
All checks were successful
Build container images / build (push) Successful in 1m7s
Build container images / deploy (push) Successful in 34s
Reviewed-on: #81
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-20 23:57:53 +00:00
bcc96e7cfb feat(mobile-sso): serve assetlinks.json + App Links redirect allowlist
All checks were successful
PR Checks / server-tests (pull_request) Successful in 9m23s
PR Checks / client-build (pull_request) Successful in 10m6s
PR Checks / bot-install (pull_request) Successful in 9m16s
Add the server side of Android App Links (M9 follow-up, docs/android/APP_LINKS.md):

- GET /.well-known/assetlinks.json at the web root, gated by the new admin
  setting `mobile_app_links_enabled` (default off -> 404; on-but-no-fingerprint
  -> 404). Emits the Digital Asset Links statement for the fixed published
  package (MOBILE_APP_PACKAGE) + MOBILE_APP_CERT_SHA256 fingerprint(s).
- mobileSso `/start` additionally accepts this shard's own self-origin
  https://<host>/mobile/callback when App Links are enabled — one additive
  exact-match entry, derived from APP_BASE_URL/request origin, never client
  input; the custom-scheme allowlist is never narrowed. The settings lookup is
  short-circuited for non-https redirects so custom-scheme rejections stay fast.
- settings.isMobileAppLinksEnabled() (fail-closed) + getPublic().mobileAppLinks;
  admin updateSettings validates the boolean; seed default off.

Tests: test/appLinks.test.js (route gating + allowlist). Full suite 284 pass.
Swagger unchanged (web-root verification file is #swagger.ignore'd).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-20 18:37:17 -05:00
d37c3a46a9 Merge pull request 'feat(auth): native SSO authorization bridge for the Android app (M9 Part 1)' (#80) from feature/mobile-sso-bridge into main
All checks were successful
Build container images / build (push) Successful in 53s
Build container images / deploy (push) Successful in 35s
Reviewed-on: #80
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-20 22:27:22 +00:00
e3dd5358b6 feat(auth): Active Devices — view/revoke mobile sessions
All checks were successful
PR Checks / server-tests (pull_request) Successful in 9m27s
PR Checks / client-build (pull_request) Successful in 10m16s
PR Checks / bot-install (pull_request) Successful in 9m17s
Adds the self-service device-session surface the mobile-SSO spec requires, on
top of the existing mobile_refresh_tokens store.

- Schema: device_name + last_used_at columns on mobile_refresh_tokens (nullable,
  additive via the ALTER section; seeded to now on insert). With single-use
  rotation each login/refresh inserts a fresh row, so the active row's timestamp
  is the session's last activity, and the label is carried forward on refresh.
- Model: listActiveForUser (one row per live device, no token hash) +
  revokeByIdForUser (ownership-scoped, idempotent).
- GET /auth/me/sessions + DELETE /auth/me/sessions/:id (role-agnostic, behind
  requireAuth). Named distinctly from /auth/me/devices (push endpoints).
- device_name is an optional field on /auth/mobile/login and
  /auth/mobile/sso/exchange so the app can label a device.
- Client: an "Active Devices" panel on the player account page (list + sign a
  device out), plus the PlayerLogin change to honor the mobile SSO bridge's
  { redirect } deep link on a 2FA completion.
- Swagger DeviceSession schema + regenerated spec; 3 controller tests. Full
  server suite green (274); client builds.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 17:01:47 -05:00
61f4591a6b feat(auth): native SSO authorization bridge for the Android app
Add a Mobile SSO Authorization Bridge so the native app can "Sign in with
Google/Discord" without shipping any OAuth secret. It EXTENDS the existing
/auth/sso/* redirect flow (same PKCE-vs-IdP, link-only + opt-in provisioning,
TOTP gate) and terminates in the existing mobile bearer tokens — not a parallel
auth path.

- Schema: mobile_auth_sessions + mobile_auth_codes (short-lived, self-pruning;
  authorization code stored hash-only, PKCE challenge is a hash by construction).
- GET /auth/mobile/sso/start: validate provider enabled + redirect_uri by EXACT
  allowlist match (never prefix), seed a bridge session, reuse the SSO redirect
  tagged mode:'mobile' (new redirectToIdp helper extracted from beginFlow).
- SSO callback + finishSsoTotp gain a mode:'mobile' branch: mint a single-use,
  hashed, PKCE-bound code and redirect to the fixed app callback (code + echoed
  state, never a token) instead of setting a cookie. 2FA keeps full parity via
  the existing web TOTP form (now carrying the bridge session).
- POST /auth/mobile/sso/exchange: verify Layer-B PKCE (before burning the code),
  single-use consume, then issue the SAME pair as /auth/mobile/login.
- Discovery reuses GET /auth/providers; refresh/logout reuse /auth/mobile/*.
- Rate limits: /start per-IP+provider, /exchange per-IP. Boot-time +
  opportunistic prune of both tables (no cron, mirrors revoked_sessions).
- Redirect allowlist is MOBILE_AUTH_REDIRECT_URIS (default the one fixed
  runicgateway://auth/callback); App Link URIs can be appended per shard later.
- Swagger regenerated; 39 tests (model single-use/gating + full controller
  matrix: bad/expired/reused code, PKCE mismatch, disabled provider, redirect
  allowlist, TOTP-through-bridge). Full suite green (271).

Refs docs/website/BACKEND_DESIGN.md, docs/android/PLAN.md §9 (M9).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 16:55:06 -05:00
31b72859ce Merge pull request 'feat(settings): surface push.ntfyUrl in /public/settings for the app' (#79) from feat/settings-push-ntfy-url into main
All checks were successful
Build container images / build (push) Successful in 1m2s
Build container images / deploy (push) Successful in 45s
Reviewed-on: #79
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-20 20:55:56 +00:00
a789ee3ac9 feat(settings): surface push.ntfyUrl in /public/settings for the app
All checks were successful
PR Checks / server-tests (pull_request) Successful in 9m39s
PR Checks / client-build (pull_request) Successful in 9m24s
PR Checks / bot-install (pull_request) Successful in 9m17s
The Android app's embedded push distributor (M7 Part 2) needs the shard's
client-facing ntfy relay URL to build its device topic endpoint, but the M7
Part 1 backend only used the NTFY_* vars server-side and never surfaced them.

Add a `push: { ntfyUrl }` block to settings.getPublic(), sourced from
NTFY_PUBLIC_URL or the first NTFY_ALLOWED_ORIGINS entry (never the possibly
internal NTFY_BASE_URL); null when unconfigured, so the app shows push as
unavailable for that shard. Additive, non-sensitive, forward-compatible.

- Extend the PublicSettings swagger schema; regenerate swagger-output.json.
- publicBrand.test.js: cover null / NTFY_PUBLIC_URL / NTFY_ALLOWED_ORIGINS.
- Document NTFY_PUBLIC_URL in .env.example and (docs PR) BACKEND_DESIGN.md.

Full server suite green (250 pass).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 15:25:40 -05:00
4fa73d3ccf Merge pull request 'feat(push): M7 backend — opt-in push notifications via self-hosted ntfy' (#78) from feat/push-notifications-backend into main
All checks were successful
Build container images / build (push) Successful in 1m15s
Build container images / deploy (push) Successful in 35s
Reviewed-on: #78
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-20 15:27:59 +00:00
416761f8f7 feat(push): M7 backend — opt-in push notifications via self-hosted ntfy
All checks were successful
PR Checks / server-tests (pull_request) Successful in 9m37s
PR Checks / client-build (pull_request) Successful in 9m21s
PR Checks / bot-install (pull_request) Successful in 9m17s
Additive, v1-only backend contract for the Android app's opt-in push (Part 1 of
M7; docs/android/PLAN.md §11). The app is a pure consumer — this lands the
endpoints, fan-out, and relay it needs.

- Schema: push_devices (per-device endpoint) + notification_subscriptions
  (per-user opted-in streams), FK→users ON DELETE CASCADE.
- Stream catalog + event→stream mapping (config/notificationStreams.js): public
  streams (news.post, server.status, idoc.warning, champ.start, governor.election)
  drawn ONLY from the SSE PUBLIC_KINDS allowlist; personal owner-keyed streams
  (vendor.sale, house.idoc, account.login). Full-state upserts (champ/city) fire
  only on a real transition via an injectable tracker.
- Fan-out (utils/pushDispatch.js): content-free tickles ({ stream, ref }) POSTed
  to each subscribed device; never throws. Two producers — shardIngest.ingest
  (beside the SSE broadcast) and the create/publish-post path (news.post).
  Personal events resolve to the owner via shardLinks. SSRF guard: endpoints must
  be HTTPS, non-private, and on the NTFY_BASE_URL/NTFY_ALLOWED_ORIGINS allow-set —
  enforced at registration and every publish.
- Routes under the role-agnostic self surface (never /admin): POST|GET
  /auth/me/devices, DELETE /auth/me/devices/:id, GET
  /auth/me/notifications/streams, GET|PUT /auth/me/notifications/subscriptions.
  Swagger regenerated (4 paths, PushDevice/NotificationStreams/etc. schemas).
- ntfy service in docker-compose.yml: pinned image, declarative ./ntfy/server.yml,
  no published host port, anonymous unguessable topics (no accounts) — zero
  interactive setup. No publish token required (content-free design); optional
  NTFY_PUBLISH_TOKEN honored.
- Tests: pushDispatch (mapping, PUBLIC_KINDS gate, owner-keying, SSRF guard,
  content-free payload) + notifications route auth gate. Full suite green (247).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 05:13:48 -05:00
030414f13d Merge pull request 'feat(public): version/health surfacing + typed brand block' (#77) from feat/public-version-and-brand into main
All checks were successful
Build container images / build (push) Successful in 1m2s
Build container images / deploy (push) Successful in 33s
Reviewed-on: #77
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-19 17:38:20 +00:00
c35509e8b3 feat(public): type the brand block so mobile clients get typed theming
All checks were successful
PR Checks / client-build (pull_request) Successful in 9m29s
PR Checks / server-tests (pull_request) Successful in 10m30s
PR Checks / bot-install (pull_request) Successful in 9m21s
Branding is already returned by GET /public/settings (the `brand` block:
name/colors/logo/hero/favicon, per-shard from BRAND_*). §8.6 of the Android
plan asks to confirm it — this makes it a first-class part of the contract so
the app's OpenAPI codegen produces typed branding instead of an untyped map.

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-19 11:47:26 -05:00
93a2c0d55f Merge pull request 'feat(auth): role-agnostic self-service surface under /auth/me' (#76) from feat/auth-me-self-surface into main
All checks were successful
Build container images / build (push) Successful in 1m4s
Build container images / deploy (push) Successful in 34s
Reviewed-on: #76
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-19 16:34:40 +00:00
fc5255da99 feat(auth): role-agnostic self-service surface under /auth/me
All checks were successful
PR Checks / server-tests (pull_request) Successful in 9m27s
PR Checks / client-build (pull_request) Successful in 10m23s
PR Checks / bot-install (pull_request) Successful in 9m19s
Add /auth/me/account* — the canonical "me" endpoints for every authenticated
role (Android app §6.4/§8.1). Reuses the existing account.controller handlers
(getAccount, changeUsername, changePassword, TOTP setup/enable/disable, list/
unlink identities) verbatim behind requireAuth (any role) — no logic
duplication. The app gets one self surface and never has to touch /admin; the
old /player/account/* and /admin/account/* routes stay for web back-compat.

New routes (all bearer- or cookie-auth, any active role):
- GET    /auth/me/account
- PATCH  /auth/me/account/username
- PATCH  /auth/me/account/password
- POST   /auth/me/account/totp/setup|enable|disable
- GET    /auth/me/account/identities
- DELETE /auth/me/account/identities/:provider

Mounted as a sub-router; the bare GET /auth/me is unchanged. Swagger
regenerated with #swagger annotations. Adds test/authMe.test.js (the group
gate rejects unauthenticated callers with 401).

Verified end-to-end against MariaDB: a player and an editor both drive the
same surface (role-agnostic), username/password changes work, a password
change revokes the caller's old bearer token, and validation/401 paths behave.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-19 04:55:01 -05:00
715eaedd74 Merge pull request 'feat(auth): self-service password reset (backend + web)' (#75) from feat/password-reset into main
Some checks failed
Build container images / build (push) Successful in 1m14s
Build container images / deploy (push) Failing after 10m47s
Reviewed-on: #75
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-19 09:36:35 +00:00
250cb1e2d3 Merge branch 'main' into feat/password-reset
All checks were successful
PR Checks / server-tests (pull_request) Successful in 9m59s
PR Checks / client-build (pull_request) Successful in 9m26s
PR Checks / bot-install (pull_request) Successful in 9m31s
2026-07-19 09:04:43 +00:00
10aed49bb6 feat(auth): self-service password reset (backend + web)
All checks were successful
PR Checks / client-build (pull_request) Successful in 9m45s
PR Checks / server-tests (pull_request) Successful in 10m42s
PR Checks / bot-install (pull_request) Successful in 9m21s
Add a full password-reset flow — the prerequisite for the Android app
(docs/android/PLAN.md §8.2), which hands off to the website for reset
rather than shipping a native screen.

Backend:
- password_resets table: stores only the sha256 hash of an opaque 32-byte
  token (mirrors user_invites / mobile_refresh_tokens), single-use, ~1h TTL.
- model/passwordResets + users.getActiveByEmail (email is non-unique, so a
  request can match several accounts, each emailed its own link).
- mailer.sendPasswordReset (fails soft when email is unconfigured).
- Endpoints: POST /auth/password/forgot (always a generic 200 — no account
  enumeration), GET|POST /auth/password/reset/:token. Confirming rotates the
  hash and revokes every session (web cutoff + mobile refresh tokens); it does
  not auto-login, so a 2FA account still passes TOTP next sign-in. Also serves
  SSO-only accounts (null hash) as their set-initial-password path.
- Dedicated request/confirm rate limiters. Swagger regenerated.

Web:
- ForgotPassword + ResetPassword pages, routes /account/forgot and
  /account/reset/:token, and a "Forgot your password?" link on the login page.

Tests: test/passwordResets.test.js (5). All server tests pass; client builds;
end-to-end smoketest against MariaDB passes (no-enumeration, single-use, hash
rotation, session revoke, login with the new password).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-19 03:57:13 -05:00
896773b8a5 Merge pull request 'Moderation appeals (Phase 6c) + Discord auto-reversal (Phase 6d)' (#74) from feature/moderation-appeals into main
All checks were successful
Build container images / build (push) Successful in 1m32s
Build container images / deploy (push) Successful in 37s
Reviewed-on: #74
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-19 05:18:18 +00:00
60e6a60842 Merge branch 'main' into feature/moderation-appeals
All checks were successful
PR Checks / server-tests (pull_request) Successful in 9m35s
PR Checks / client-build (pull_request) Successful in 9m51s
PR Checks / bot-install (pull_request) Successful in 9m22s
2026-07-19 03:49:39 +00:00
9ac1f35fa0 Merge pull request 'docs: generalize deployment section to any reverse proxy' (#73) from docs/reverse-proxy-generic into main
All checks were successful
Build container images / build (push) Successful in 1m27s
Build container images / deploy (push) Successful in 36s
Reviewed-on: #73
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-19 03:20:01 +00:00
028ba8c5e4 feat(moderation): appeals (6c) + Discord reversal on approve (6d)
All checks were successful
PR Checks / server-tests (pull_request) Successful in 9m59s
PR Checks / client-build (pull_request) Successful in 9m32s
PR Checks / bot-install (pull_request) Successful in 9m37s
Players whose linked Discord identity was banned or muted can now submit
an appeal from the portal and track it; staff get a queue in the admin
moderation section to claim and resolve (approve/deny) appeals. Approving
a ban/mute appeal best-effort asks the Discord bot to reverse the action
(unban / clear timeout) via the internal API and posts a mod-log embed; a
down bot never fails the resolution (reversal_status is recorded).

- Schema: new server-owned `appeals` table (no cross-owner FK to
  mod_actions; existence validated in app code).
- Server: model/appeals/* + player appeals controller (submit/mine/
  eligible/withdraw) and admin queue handlers (list/claim/resolve/
  per-user) under the existing admin+moderator gate; one-active-appeal
  enforced app-side; eligibility keyed on the caller's linked Discord id.
- 6d: bot POST /internal/mod-reverse (+ modLog.postReversal) and
  server botInternalClient.reverseModAction, wired into resolve().
- Client: admin Appeals queue + resolve modal, ModerationUser appeals
  tab, player Appeals page (submit/withdraw), nav + routes + api methods.
- Docs: swagger annotations + component schemas, regenerated output.
- Tests: appeals controller + pure suites (server npm test 224 green).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XmHdsbnLzDMAVQkAoTQSBe
2026-07-18 22:01:06 -05:00
5f09ab1146 docs: generalize deployment section to any reverse proxy
All checks were successful
PR Checks / client-build (pull_request) Successful in 9m25s
PR Checks / server-tests (pull_request) Successful in 10m10s
PR Checks / bot-install (pull_request) Successful in 9m21s
Rework the README's Pangolin-specific deployment section to cover any
reverse proxy (Nginx, Caddy, Traefik, Pangolin). Add TRUST_PROXY /
X-Forwarded-* guidance and Nginx + Caddy config snippets, keeping
Pangolin as one documented example.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 22:00:01 -05:00
b0549f5845 Merge pull request 'fix(shard): restrict staff in-game location to admins/moderators' (#72) from fix/staff-location-visibility into main
All checks were successful
Build container images / build (push) Successful in 59s
Build container images / deploy (push) Successful in 38s
PR Checks / client-build (pull_request) Successful in 9m43s
PR Checks / server-tests (pull_request) Successful in 10m35s
PR Checks / bot-install (pull_request) Successful in 9m24s
Reviewed-on: #72
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-19 02:39:22 +00:00
aa2177715e fix(shard): restrict staff in-game location to admins/moderators
All checks were successful
PR Checks / client-build (pull_request) Successful in 9m23s
PR Checks / server-tests (pull_request) Successful in 10m33s
PR Checks / bot-install (pull_request) Successful in 9m18s
The public "Staff online" list on the Shard page exposed each staff
member's in-game location (map + coordinates) to everyone, including
logged-in players and unauthenticated visitors.

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XmHdsbnLzDMAVQkAoTQSBe
2026-07-18 21:18:52 -05:00
3e6ecb959a Merge pull request 'chore: add open-source governance files (GPLv3 + contributing docs)' (#71) from chore/open-source-governance into main
All checks were successful
Build container images / build (push) Successful in 54s
Build container images / deploy (push) Successful in 34s
Reviewed-on: #71
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-19 00:31:03 +00:00
Claude
d441e92029 chore: add open-source governance files (GPLv3 + contributing docs)
All checks were successful
PR Checks / client-build (pull_request) Successful in 9m24s
PR Checks / server-tests (pull_request) Successful in 10m32s
PR Checks / bot-install (pull_request) Successful in 9m20s
Add standard open-source project files:
- LICENSE.md — GNU GPL v3.0 or later (verbatim)
- CONTRIBUTING.md — setup, workflow, and required AI-usage disclosure
- CONTRIBUTORS.md — maintainers, contributors, AI-assistance policy
- CODE_OF_CONDUCT.md — Contributor Covenant 2.1
- SECURITY.md — private vulnerability reporting
- .gitea/ISSUE_TEMPLATE/* + PULL_REQUEST_TEMPLATE.md
- README: License section (Copyright (C) 2026 Runic Gateway)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XmHdsbnLzDMAVQkAoTQSBe
2026-07-18 19:10:55 -05:00
5639117936 Merge pull request 'fix(brand): link "Runic Gateway" footer badge to Gitea org' (#70) from fix/branding-footer-link into main
All checks were successful
Build container images / build (push) Successful in 1m11s
Build container images / deploy (push) Successful in 33s
Reviewed-on: #70
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-18 23:36:46 +00:00
50133155d6 fix(brand): link "Runic Gateway" footer badge to Gitea org
All checks were successful
PR Checks / server-tests (pull_request) Successful in 9m27s
PR Checks / client-build (pull_request) Successful in 10m16s
PR Checks / bot-install (pull_request) Successful in 9m19s
Wrap the "Powered by Runic Gateway" wordmark in an anchor pointing to
https://gitea.whitlocktech.com/RunicGateway (new tab, noopener). Adds a
subtle accent-color hover on the wordmark.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XmHdsbnLzDMAVQkAoTQSBe
2026-07-18 18:16:51 -05:00
bdce23f9b6 Merge pull request 'feat(brand): default emblem — favicon, hero medallion, footer credit' (#69) from feature/branding into main
All checks were successful
Build container images / build (push) Successful in 1m57s
Build container images / deploy (push) Successful in 2m3s
Reviewed-on: #69
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-18 22:20:27 +00:00
a0a70ce2ca docs: rename project-structure root label UOMSITE/ → website/
All checks were successful
PR Checks / server-tests (pull_request) Successful in 9m48s
PR Checks / client-build (pull_request) Successful in 10m48s
PR Checks / bot-install (pull_request) Successful in 9m21s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XmHdsbnLzDMAVQkAoTQSBe
2026-07-18 17:00:04 -05:00
526160721a feat(brand): emblem behind hero text + Powered By footer with logo
Address review: the SVG footer badge rendered too small, and the default
hero should feature the Runic Gateway emblem rather than the moon.

- Footer: drop powered-by.svg; render the emblem PNG beside a "Powered by
  Runic Gateway" label (left-justified, info text stays centered).
- Default hero: brand.hero (and the client fallbacks) now default to the
  emblem PNG. The untouched default hero centers the square emblem behind
  the text as a medallion with a symmetric legibility overlay (per-layer
  background-size so the overlay stays full-bleed). BRAND_HERO still
  overrides.
- Admin login / player shell / maintenance backgrounds center the emblem
  as a capped medallion instead of a cropped full-bleed cover.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XmHdsbnLzDMAVQkAoTQSBe
2026-07-18 16:56:22 -05:00
352ae4f256 feat(brand): default favicon emblem + Powered By footer badge
Ship the Runic Gateway emblem as the baked-in default favicon so an
instance renders a tab icon with no BRAND_FAVICON set, and place a
left-justified "Powered By" badge in the site footer.

- brand.favicon now defaults to /assets/img/favicon.ico (was empty).
  BRAND_FAVICON still overrides per-instance.
- Add favicon.ico + powered-by.svg under client/public/assets/img.
- SiteFooter: badge pinned to the left of the centered content column
  (absolute on >=641px, stacked on mobile); info text stays centered.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XmHdsbnLzDMAVQkAoTQSBe
2026-07-18 16:40:09 -05:00
a16092f13a Merge pull request 'feat(brand): BRAND_* env scheme — instance branding without a rebuild' (#68) from feature/brand-env into main
All checks were successful
Build container images / build (push) Successful in 2m13s
Build container images / deploy (push) Successful in 24s
Reviewed-on: #68
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-18 07:40:04 +00:00
7a08546da6 feat(brand): BRAND_* env scheme — instance branding without a rebuild
All checks were successful
PR Checks / client-build (pull_request) Successful in 9m24s
PR Checks / server-tests (pull_request) Successful in 10m33s
PR Checks / bot-install (pull_request) Successful in 9m20s
Replace baked-in UOM/MysticMoon/UOMysticmoon branding with a BRAND_* env
scheme so one prebuilt image runs as any shard; UOMysticmoon becomes the
first tenant that sets these vars rather than a special case in the code.

Architecture (chosen because the app ships as a prebuilt image):
- server/src/config/brand.js + bot/src/brand.js read BRAND_* once at boot,
  with Runic Gateway defaults.
- Text/colors reach the SPA at RUNTIME through the existing public settings
  API (settings.model.getPublic -> SiteContext), so no client rebuild. The
  admin-editable site title + contact email still override BRAND_NAME/email.
- SiteContext applies BRAND_ACCENT_COLOR to the --accent CSS var at runtime.
- Express templates the built index.html <title>/description/OG/favicon at
  serve time from BRAND_* (renderIndexHtml in app.js).
- Server-side consumers read brand directly: emails, TOTP issuer, API docs,
  boot logs, HTML error page. Bot uses it for embed color + logs.

Assets: logo/hero/favicon delivered from a ./brand:/app/brand bind-mount
(BRAND_LOGO/HERO/FAVICON), with neutral defaults baked in; hero falls back
to a built-in image when unset.

Scope: also genericized package.json names (uomysticmoon-* -> runic-gateway-*)
and the DB_NAME/DB_USER/COOKIE_NAME code defaults (runic_gateway/runic/
rg_token). Production keeps its real values by pinning them in .env — see
.env.uomysticmoon.example, which reproduces the exact UOMysticmoon identity
(proof the substitution works). Changing a deployed COOKIE_NAME invalidates
existing sessions, so UOMysticmoon pins uomm_token.

Verified: 193 server tests pass, client builds, app.js loads + templates the
built index.html, brand transform injects title/description/OG/favicon.
2026-07-18 02:20:04 -05:00
1bb9e3c3c3 Merge pull request 'docs: move design docs to RunicGateway/docs' (#67) from chore/extract-docs into main
Some checks failed
Build container images / build (push) Failing after 28s
Build container images / deploy (push) Has been skipped
Reviewed-on: #67
2026-07-18 05:51:30 +00:00
b8f67fb208 Merge branch 'main' into chore/extract-docs
All checks were successful
PR Checks / client-build (pull_request) Successful in 9m32s
PR Checks / server-tests (pull_request) Successful in 9m55s
PR Checks / bot-install (pull_request) Successful in 9m19s
2026-07-18 05:27:59 +00:00
e0fadbdcc2 Merge pull request 'chore(org): retarget org paths to RunicGateway' (#66) from chore/org-rename-runicgateway into main
All checks were successful
Build container images / build (push) Successful in 1m14s
Build container images / deploy (push) Successful in 26s
Reviewed-on: #66
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-18 05:06:43 +00:00
b3033909d3 docs: move design docs to RunicGateway/docs
All checks were successful
PR Checks / server-tests (pull_request) Successful in 9m49s
PR Checks / client-build (pull_request) Successful in 9m28s
PR Checks / bot-install (pull_request) Successful in 9m30s
Extracted BACKEND_DESIGN.md, HERO_EDITOR.md, and WIKI_UPGRADE.md into the
central RunicGateway/docs repo (under docs website/, full history preserved
via git filter-repo). Repoint the README's two BACKEND_DESIGN.md links at
the new location.

Docs repo: https://gitea.whitlocktech.com/RunicGateway/docs
2026-07-18 00:05:51 -05:00
6c967d9a6c chore(org): retarget org paths to RunicGateway
All checks were successful
PR Checks / client-build (pull_request) Successful in 9m30s
PR Checks / server-tests (pull_request) Successful in 10m34s
PR Checks / bot-install (pull_request) Successful in 9m21s
Repo was transferred UOM -> RunicGateway. build-images.yml already
derives its registry owner from github.repository_owner, so it now
publishes to gitea.whitlocktech.com/runicgateway/*, but docker-compose
still pulled from /uom/*. Point the app+bot images at the new owner so
deploys pull the images CI actually publishes. Also update the two
README links to the uo-link repo (UOM/link -> RunicGateway/link).

No branding text touched (that is handled separately).
2026-07-17 23:46:36 -05:00
ee085496ab Merge pull request 'Protocol 2.0/2.1 uo-link integration — boards, cross-links, news gump, account provisioning' (#65) from feature/protocol2-integration into main
All checks were successful
Build container images / build (push) Successful in 1m35s
Build container images / deploy (push) Successful in 35s
Reviewed-on: UOM/website#65
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-18 02:34:25 +00:00
3ef1c8e438 feat(provisioning): admin game-signup mode setting, invite link option, staff self-create
All checks were successful
PR Checks / server-tests (pull_request) Successful in 9m37s
PR Checks / client-build (pull_request) Successful in 10m18s
PR Checks / bot-install (pull_request) Successful in 9m22s
Follow-ups from live testing:

- Game-account creation is now an admin Settings control (disabled / website /
  hybrid / game) instead of a hidden on/off flag. The site offers creation for
  website+hybrid; help text notes the shard's SignupMode (Bridge.cfg) has the final
  say. game_account_signup setting widened to a 4-value enum + validated on save.
- Invites: the accept link is ALWAYS returned and shown with a Copy button, and a
  "Email the invitation" toggle lets an admin create a link-only invite (no email)
  or email it. Backend takes sendEmail (default true) and always returns acceptUrl.
- Staff can create a game account from their own /admin/characters page too
  (POST /admin/shard/account → the shared createGameAccount controller), so the
  form is reachable in both the player and admin portals.

Note: the admin Houses view (/admin/houses) already worked; the earlier failure
was a stale Vite HMR state for the new route (needs a hard refresh).

Client build clean; server routes load; swagger regenerated.

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

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

Server tests green, client build clean, swagger regenerated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:50:37 -05:00
a165c90c62 fix(schema): remove semicolons from shard_governor_terms inline comments
ensureSchema() splits schema.sql on ';' and is not comment-aware, so the inline
comments "epoch ms; NULL = current" and "not in the feed; reserved" shattered the
CREATE TABLE into invalid fragments (ER_PARSE_ERROR on a fresh boot). Reworded both
to drop the semicolons. Caught during live-stack bring-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:25:14 -05:00
2976d5982f feat(provisioning): provisioning UI — signup, invites, accept page, unlink
Phase 6: the UI for the Phase 5 provisioning backend.

- CreateGameAccountForm: reusable game-account form (own username + password),
  mapping the sidecar errors (409/429/403/503) to friendly messages. Wired into
  GameAccounts (self-serve) — shown alongside the [link flow when the
  game_account_signup flag is on (exposed via public settings), so a registered
  player can create + link a game account from their portal.
- Admin Invites view (/admin/invites, admin-only): send an invite at a chosen
  access level, list invites with status, revoke pending ones. When email isn't
  configured the create response's accept link is surfaced to copy manually.
- Public accept page (/invite/:token): validates the invite, sets username +
  password (email + role pre-assigned), creates the account at that role and logs
  in; for a player invite it then offers the built-in "create game account" step
  before the portal. Honeypot-guarded like registration.
- Admin unlink wired into UserDetail via GameAccounts (per-account Unlink button,
  confirm + reconcile).
- Backend: expose gameAccountSignup availability in public settings.

Client build clean; server 193/193.

Refs .plans/protocol2-integration.md (Phase 6). Completes the Protocol 2.0/2.1 integration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:06:01 -05:00
91c206bf76 feat(provisioning): game-account signup, admin email invites, unlink (2.0)
Phase 5: the account-provisioning backend — link-only stays, plus hybrid
self-signup, an admin email-invite tool, and site-side unlink.

- uoLinkClient.createAccount / unlinkAccount (v2). Password is forwarded to the
  shard (hashed there) and never stored/logged; the end-user browser IP is passed
  for the shard's per-IP cap; actor is stamped server-side.
- Hybrid signup: POST /player/shard/account provisions a game account (its own
  username + password) for the signed-in user and mirrors the link locally. Gated
  by the new game_account_signup setting AND the shard's own mode (mapped 403/409/
  429/400/503). Serves both self-serve signup and the invite-accept game step.
- Email invites: user_invites table (sha256 token hash, single-use, expiring);
  invites model + admin CRUD (POST/GET/DELETE /admin/invites, admin-only) +
  mailer.sendInvite (falls back to returning the accept link if email is off);
  public token-gated accept (GET /auth/invite/:token, POST .../accept) creates the
  user at the invite's preset role and logs them in, bypassing the registration
  gate. Accept is race-safe (atomic single-use; rolls back the user if it loses).
- Admin unlink: DELETE /admin/users/:id/shard/link/:account (admin-only) + local
  mirror drop; account.unlinked ingest reconciles the mirror when a player runs
  [unlink in game. account.audit / account.unlinked are logged (admin channel
  only — never on the public SSE allowlist).

Tests: invites model (hashing, single-use, expiry, revoke) + account.* ingest
reconcile/visibility. Full suite 193/193; swagger regenerated.

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:50:49 -05:00
55a3adea99 feat(news): auto-push published news to the in-game Town Cryer News gump (2.1)
Phase 4: sync the site's published news posts into the Protocol 2.1 News gump.

- uoLinkClient.postNews / deleteNews.
- utils/newsGump.js — a STATE SYNC (not a one-shot announce leg): an article
  stays in the gump while its post is published news and is pulled when it leaves
  that state. buildArticle renders a compact gump-HTML block (centred title +
  plain-text excerpt — the gump supports only a small HTML subset) with a
  "more info" link to /site/news and an optional gump image from the
  `news_gump_image` setting. Every call is best-effort / never-throws.
- Hooked into the posts pipeline alongside the existing announce enqueue:
  syncPost on create/update/publish (fresh publish announces; edits refresh
  silently; leaving published-news pulls the article), removePost on delete.
- reassertAll() runs in uoLinkSocket.backfill on every WS (re)connect —
  reconciles the gump to our source of truth and recovers any article whose
  original live push failed (silent, so a reconnect never re-proclaims old news).

Server 185/185, swagger regenerated. Refs .plans/protocol2-integration.md (Phase 4).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:40:47 -05:00
2957708bab feat(shard): Protocol 2.0 cross-links — titles, guild, governor, houses
Phase 3: surface the new board data on existing character/user pages.

- Character sheet: render the char.profile titles block (fame/karma + skill +
  selected reward title; numeric clilocs skipped since the site has no cliloc
  table yet), plus "Guildmaster" and "Governor of <city>" chips.
- Char profile enrichment (player/admin /shard/char/:serial, one shared path):
  attach guild + governorOf from our own boards. Guild is LEADERSHIP-ONLY — it's
  verifiable from current board state, whereas guessing membership from stale
  guild.join events risks showing a wrong guild, so we return null instead.
- Admin user detail (/admin/users/:id): new "Standing" section (governorships
  held + guilds led) via GET /users/:id/shard/standing; Houses rows now show the
  registry fields (decay level, placement price, co-owner/friend counts) already
  returned by listHousesForAccounts.

Server 179/179, client build clean, swagger regenerated.

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 12:45:15 -05:00
e9aa19a83d feat(shard): Protocol 2.0 boards UI — guilds, governors, houses, players-online
Phase 2: the public UI for the four new boards, following the ChampSpawns live
pattern (snapshot via useAsync + merge SSE deltas with useShardFeed).

- Players Online widget (components/PlayersOnline.jsx): total + region breakdown
  rolled up into display buckets (data/regionBuckets.js — the one place to retune
  the grouping); live via presence.online. Placed on the Shard page, replacing the
  static players-online stat tile.
- Guilds (/site/guilds): searchable board of rosters/alliances/leaders with a
  "recently joined" strip from guild.join.
- Governors (/site/governors): one card per city with a placeholder crest
  (data/cityCrests.js — swap for real art without touching components), election
  phase badge + autoPickAt countdown, and an on-demand "past governors" term
  history (the look-back reads the ledger captured in Phase 1). Clean empty state
  when City Loyalty isn't enabled.
- Houses (/site/houses): searchable registry with decay badges; price labelled
  "placement value", not a for-sale flag.
- API client methods + nav links (Guilds / Governors / Houses).

Client build clean (240 modules).

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 12:28:54 -05:00
3b333b1b49 Merge pull request 'ci(deploy): correct deploy runner label to uom-deploy-runner' (#64) from ci/fix-deploy-runner-label into main
All checks were successful
Build container images / build (push) Successful in 2m24s
Build container images / deploy (push) Successful in 43s
Reviewed-on: UOM/website#64
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-17 10:52:00 +00:00
0facdb2b2a ci(deploy): correct deploy runner label to uom-deploy-runner
All checks were successful
PR Checks / server-tests (pull_request) Successful in 9m29s
PR Checks / client-build (pull_request) Successful in 9m57s
PR Checks / bot-install (pull_request) Successful in 9m23s
The deploy job referenced a runner labelled `uom_deploy`, which doesn't
match the actual self-hosted runner (`uom-deploy-runner`), so the job
would queue forever waiting for a runner that never picks it up. Update
the `runs-on` label (and the two doc comments) to the real label.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 05:32:02 -05:00
49ad6891cf Merge pull request 'ci(deploy): auto-deploy prod stack after image build on merge to main' (#63) from ci/auto-deploy-on-merge into main
Some checks failed
Build container images / deploy (push) Has been cancelled
Build container images / build (push) Has been cancelled
Reviewed-on: UOM/website#63
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-17 10:29:04 +00:00
97d95052db ci(deploy): auto-deploy prod stack after image build on merge to main
All checks were successful
PR Checks / server-tests (pull_request) Successful in 10m20s
PR Checks / bot-install (pull_request) Successful in 9m39s
PR Checks / client-build (pull_request) Successful in 13m0s
Add a `deploy` job to build-images.yml that runs on the self-hosted
`uom_deploy` runner and, via `needs: build`, fires only after a clean
image build+push. It pulls the fresh :latest images and recreates the
stack (pull → down → up -d) from /home/perry/website. Guarded on
refs/heads/main so a workflow_dispatch off another branch can't deploy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 05:03:01 -05:00
e744723db2 Merge pull request 'fix(bot): retry boot-time config fetch so bot self-heals on cold start' (#62) from fix/bot-boot-config-retry into main
All checks were successful
Build container images / build (push) Successful in 1m43s
Reviewed-on: UOM/website#62
2026-07-15 19:17:22 +00:00
fc2554e5c3 fix(bot): retry boot-time config fetch so bot self-heals on cold start
All checks were successful
PR Checks / server-tests (pull_request) Successful in 10m25s
PR Checks / client-build (pull_request) Successful in 9m53s
PR Checks / bot-install (pull_request) Successful in 10m0s
On `docker compose up`/restart the bot and app start together. The bot's
`depends_on: app` uses `condition: service_started`, which only waits for the
app container to launch — not for its internal server (3001) to be listening
after it reaches the DB and boots Express. bootstrap.js did a single un-retried
fetch, lost that race, gave up, and left the bot disconnected while the DB
`enabled` flag stayed true — so the admin panel showed "enabled but
disconnected" until an admin toggled off/on to force a pushConfig.

Retry the boot config fetch with backoff (~1 min, 2s apart) until the app
answers: retry on network errors and 5xx, bail on 4xx (a real misconfig, not a
startup race). Also wrap discordManager.start in try/catch so a bad-token boot
logs and keeps the process alive instead of crashing it via server.js's
exit-on-start-failure.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
2026-07-15 09:39:19 -05:00
70122f3626 Merge pull request 'fix(public): always show real hero + drop nav from landing page' (#61) from fix/hero-stale-gate-and-nav into main
All checks were successful
Build container images / build (push) Successful in 1m24s
Reviewed-on: UOM/website#61
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-15 11:03:51 +00:00
64da0067f1 Merge branch 'main' into fix/hero-stale-gate-and-nav
All checks were successful
PR Checks / server-tests (pull_request) Successful in 10m4s
PR Checks / client-build (pull_request) Successful in 9m41s
PR Checks / bot-install (pull_request) Successful in 9m37s
2026-07-15 03:59:42 +00:00
5b6b63e1bc fix(public): always show real hero; drop nav from landing page
All checks were successful
PR Checks / server-tests (pull_request) Successful in 9m43s
PR Checks / client-build (pull_request) Successful in 9m39s
PR Checks / bot-install (pull_request) Successful in 9m40s
Bug 1 — Logged-out visitors saw the coming-soon Maintenance page while
admins saw the real hero. That difference is produced client-side by
MaintenanceGate (site_mode=maintenance && no user). Pull the `/` hero
route out from behind the gate so every visitor always lands on the real
Portal hero; the MaintenanceGate stays on all other public routes, so
content pages remain gated during maintenance and admins still preview
through it.

Bug 2 — The landing hero rendered the site nav because Portal used
PublicLayout with the default header=true. Pass header={false} so the
hero has no top nav (footer retained), using the layout's existing
escape hatch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
2026-07-14 22:57:49 -05:00
01b3bb52bf Merge pull request 'feat(shard): admin write plane, help-page queue, and public champion board' (#58) from feature/shard-admin-champs into main
All checks were successful
Build container images / build (push) Successful in 1m30s
Reviewed-on: UOM/website#58
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-14 18:46:50 +00:00
c31553aeb6 feat(shard): admin write plane, help-page queue, and public champion board
All checks were successful
PR Checks / server-tests (pull_request) Successful in 10m20s
PR Checks / client-build (pull_request) Successful in 9m49s
PR Checks / bot-install (pull_request) Successful in 9m33s
Wire up the three uo-link sidecar surfaces that weren't integrated yet.

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
2026-07-14 13:16:10 -05:00
2dc360ca48 Merge pull request 'ci: gate PRs into main on server tests + client build' (#57) from ci/pr-checks into main
All checks were successful
Build container images / build (push) Successful in 1m14s
Reviewed-on: UOM/website#57
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-12 15:40:17 +00:00
34c511c8d0 ci: gate PRs into main on server tests + client build
All checks were successful
PR Checks / server-tests (pull_request) Successful in 11m35s
PR Checks / client-build (pull_request) Successful in 9m42s
PR Checks / bot-install (pull_request) Successful in 9m34s
Add .gitea/workflows/pr-checks.yml running on pull_request into main.
Three parallel jobs on the existing ubuntu-latest runner:

  • server-tests  — npm ci + node --test (164 tests, no DB needed:
    the suite stubs models and points the pool at a dead port)
  • client-build  — npm ci + vite build
  • bot-install   — npm ci only (catches a broken/stale lockfile)

No ESLint exists in the repo yet, so no lint step. Complements
build-images.yml, which publishes images post-merge.

Enable in Branch Protection with status check pattern: PR Checks / *

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
2026-07-12 10:07:16 -05:00
4fe90ea368 Merge pull request 'feat(admin): view a user's shard footprint at /admin/users/:id' (#56) from feature/admin-user-view into main
All checks were successful
Build container images / build (push) Successful in 1m48s
Reviewed-on: UOM/website#56
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-12 14:41:59 +00:00
ba4d758eab feat(admin): view a user's shard footprint at /admin/users/:id
Add a "View" action beside Edit in the users table that opens a dedicated,
read-only page showing everything the uo-link shard knows about a user,
scoped to their linked game accounts: character rosters, currently-online
characters, houses (IDOC-first), and recent vendor sales.

Backend (admin-only, under the existing /users adminOnly gate):
- GET /admin/users/:id — single sanitized user (page is deep-linkable)
- GET /admin/users/:id/shard/{accounts,sales,houses,online}
- shardState: listHousesByAccounts / listOnlineByAccounts (+ model shapers)
- Extract salesForAccounts into utils/shardSales; reuse in player getSales
- Live rosters reuse the existing admin-bypass /admin/shard/* endpoints,
  so no new routes for roster/vendors/char

Frontend:
- UserDetail page reusing CharacterStats / GameAccounts / VendorSales
- GameAccounts gains a readOnly prop (drops link form + self-voice copy)
- api.admin.getUser + api.admin.userShard(id) scope; route + layout title

Tests: adminUserShard.test.js (404, account scoping, empty accounts,
salesForAccounts cap/filter). Full server suite 164 pass; client builds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
2026-07-12 09:36:43 -05:00
696d82f114 Merge pull request 'deploy: split build into docker-compose.dev.yml (prod compose pulls only)' (#55) from deploy/compose-dev-prod-split into main
All checks were successful
Build container images / build (push) Successful in 1m1s
Reviewed-on: UOM/website#55
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-11 23:45:41 +00:00
f4e7fc7e20 Merge branch 'main' into deploy/compose-dev-prod-split 2026-07-11 23:45:28 +00:00
6d4cd91bcc deploy: split build into docker-compose.dev.yml overlay
Make the base docker-compose.yml strictly production-shaped — image: only, no
build: — so a production host can only ever pull, never accidentally build
(compose gives build precedence for `up --build`/`build`, which mixed the two
modes). Local builds move to an explicit, non-auto-loaded overlay.

  Production:   docker compose pull && docker compose up -d
  Development:  docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build

Verified with `docker compose config`: base renders image-only (no build) for
both services; the dev overlay adds build back (app -> Dockerfile,
bot -> bot/Dockerfile). README quick-start updated to the two-file flow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
2026-07-11 18:42:05 -05:00
3628268dda Merge pull request 'deploy: pull prebuilt registry images in compose (IMAGE_TAG)' (#54) from deploy/compose-use-registry-images into main
All checks were successful
Build container images / build (push) Successful in 56s
Reviewed-on: UOM/website#54
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-11 23:36:55 +00:00
25ff5aa836 deploy: pull prebuilt registry images in compose (IMAGE_TAG)
Point the `app` and `bot` services at the images published to the Gitea
registry by the build-images workflow, so deploys pull instead of building:

  image: gitea.whitlocktech.com/uom/website-app:${IMAGE_TAG:-latest}
  image: gitea.whitlocktech.com/uom/website-bot:${IMAGE_TAG:-latest}

`build:` is kept, so `up --build` still works locally; the server runs
`docker compose pull && up -d`. IMAGE_TAG defaults to `latest` for routine
deploys and pins to an immutable `sha-<7>` build for reproducible deploys /
rollback — no per-deploy compose edits. Documented in .env.example + README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
2026-07-11 18:34:31 -05:00
042a151358 Merge pull request 'ci: build & publish app + bot images to Gitea registry on merge' (#53) from ci/gitea-actions-image-build into main
All checks were successful
Build container images / build (push) Successful in 24s
Reviewed-on: UOM/website#53
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-11 23:01:04 +00:00
4f24959d49 ci: build & publish app + bot images to Gitea registry on merge
Add a Gitea Actions workflow (.gitea/workflows/build-images.yml) that fires on
push to main (and workflow_dispatch). On the always-on ubuntu-latest runner it:

  - verifies the host Docker daemon is reachable (socket must be mounted)
  - logs into gitea.whitlocktech.com with a PAT (REGISTRY_USER / REGISTRY_TOKEN)
  - builds & pushes both images from the existing Dockerfiles, each tagged
    :latest and :sha-<7>:
      gitea.whitlocktech.com/<owner>/website-app  (./Dockerfile — server+client)
      gitea.whitlocktech.com/<owner>/website-bot   (./bot/Dockerfile)

Raw docker CLI (no marketplace actions) for portability on self-hosted Gitea;
the shared host daemon gives free layer caching between runs. Registry owner is
lowercased for Docker refs. Deploy (compose image: + pull) is a follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
2026-07-11 17:59:55 -05:00
99649727f3 Merge pull request 'News post → town crier + Discord announcement pipeline' (#52) from feature/news-announce-pipeline into main
Reviewed-on: UOM/website#52
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-11 21:58:31 +00:00
ac858875c0 Merge branch 'main' into feature/news-announce-pipeline 2026-07-11 21:57:52 +00:00
986a8d5d86 News post → town crier + Discord announcement pipeline
Replace the fire-and-forget Discord-only announce on publish with a
retry-safe, two-leg pipeline. When a post transitions into published-news
(false→true publish while in news, or category→news while published), an
announce_jobs row is enqueued with two INDEPENDENT delivery legs:

  • town crier — sidecar POST /towncrier via uoLinkClient (stable id
    `post-<id>` so a retry replaces rather than duplicates)
  • discord    — bot POST /internal/announce via botInternalClient
    (single source of truth for the #news channel stays in the bot)

An in-process poller (utils/announceWorker) sweeps the table every
ANNOUNCE_POLL_MS and dispatches each due leg with its own exponential
backoff (30s→2h, 6 attempts). A leg is retried on transient failures
(503/504/network) and failed fast on data/config errors (400 over-cap,
401/409). Publishing never blocks on the sidecar or Discord — enqueue is
local DB only. Parent `status` is a done/partial/failed rollup of the two
legs; posts.announced_at is stamped once both deliver.

Admin visibility: GET /admin/posts/:id/announce + a per-leg Retry
(POST .../announce/retry) surfaced in the PostEditor for news posts.

Pure decisions (text build/caps, classification, backoff, rollup) live in
announceJobs.logic and are unit-tested (server/test/announceJobs.test.js,
10 tests). The old manual /admin/uo-link/towncrier form is untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
2026-07-11 16:25:25 -05:00
350433635b Merge pull request 'Homepage teaser: rich text editor' (#51) from feature/homepage-teaser-rte into main
Reviewed-on: UOM/website#51
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-11 16:09:50 +00:00
a2590812e0 Give the homepage teaser a rich text editor
Replace the plain textarea for the homepage_teaser setting with the shared
TipTap rich-text editor, and render the teaser as sanitized HTML in the
portal hero's default layout.

- SettingsAdmin: teaser field now uses RichTextEditor (lazy-loaded, code-split
  like PostEditor); rich fields render in a <div> wrapper instead of <label>.
- HeroElement: text-block lines flagged `html` render sanitized HTML.
- heroLayout: the default-layout teaser line is now an HTML line.
- admin.controller: sanitize homepage_teaser against the body allowlist on save.
- theme.css: collapse the teaser's nested block margins in the hero.

Closes #48

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 11:06:58 -05:00
5ccb18e794 Merge pull request 'Frontend theme redo: player portal → Admin sidebar shell + stat-tile My Characters' (#50) from feature/frontend-theme-redo into main
Reviewed-on: UOM/website#50
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-11 16:00:17 +00:00
bf9edde5b7 Bring player portal in line with Admin + stat-tile My Characters
Implements the "Frontend Theme Redo" design (decision 1a): the logged-in
player portal now uses the same sidebar shell as Admin, and Admin's own
My Characters view gets the same stat-tile treatment.

- PlayerPortalLayout: replace the light 820px top-tab header with the
  Admin sidebar shell (icon nav, sticky content header with page title,
  signed-in footer with sign out). Reuses .admin-grid so the two
  logged-in experiences read as one app.
- Drop the now-redundant inner <h1> from PlayerCharacters/PlayerAccount;
  the title lives in the sticky header.
- CharacterStats: new stat-tile row (Characters / Online now / Linked
  account) that tolerates a restarting shard and hides until an account
  is linked.
- AdminCharacters: render CharacterStats above the roster instead of the
  bare intro paragraph, matching the Player Portal Characters page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3
2026-07-11 10:58:03 -05:00
6c310629c7 Merge pull request 'uo-link: staff-only public presence + admin character access' (#49) from feature/uo-link-sidecar into main
Reviewed-on: UOM/website#49
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-11 14:32:48 +00:00
d72c2dadfc docs: document the uo-link shard integration in the README
Add a "Shard integration (uo-link)" section explaining that the live
shard bridge is a separate sidecar service at UOM/link, how the site
talks to it (admin-managed encrypted config, WebSocket ingest + REST
round-trips, SSE fan-out with public vs admin channels), the in-game
[link account-linking flow, and what the public / player / admin
surfaces each expose. Also add an intro bullet, a contents entry, and
the shard endpoint groups to the API endpoints table.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3
2026-07-11 03:10:03 -05:00
74d2ead958 Let staff link their own characters + share the game-accounts UI
- Backend: /admin/shard/{link,accounts,roster/:account,vendors/:account} —
  staff self-service, reusing the player/shard controller (it keys off
  req.user.id, so the same handlers serve any logged-in role). Swagger under
  Admin · Account; spec regenerated.
- components/GameAccounts.jsx: the link-prompt + character-roster UI extracted
  into one reusable component parametrized by an api scope and a charTo(serial)
  route builder.
- PlayerCharacters now renders it (player scope → /player/char/:serial).
- Admin: "My Characters" nav item + /admin/characters (AdminCharacters) and
  /admin/characters/:serial (AdminCharacter, in-shell sheet), using the admin
  self-service scope. api.admin.shard.* added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3
2026-07-11 03:05:43 -05:00
49ce230c3a Full-site nav: one auth-aware nav bar on every page
- SiteHeader: a single consistent main nav (Home, News, Screenshots, Five on
  Friday, Newsletter, Wiki, Shard, About) with active-state highlighting, plus
  an auth-aware entry on the right — Sign in when logged out, My Account
  (player) or Admin (staff) when logged in.
- Portal (landing) now renders the site header too, so the nav is present
  across the entire site, not just interior pages.

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3
2026-07-11 02:51:50 -05:00
e7bc316863 Add admin shard control: config, status, town crier (phase 5)
- admin/uoLink.controller.js: GET /admin/uo-link/config (masked config + live
  health + ingestion stats from the socket/broadcaster); PUT to save base/ws
  URL + write-only token + protocol + enabled, which (re)starts or stops the WS
  ingest client and activity-logs the change; POST/DELETE /uo-link/towncrier to
  publish/remove town-crier messages; GET /uo-link/stream (admin SSE channel,
  full feed incl. audit/cheat). Mounted adminOnly with express-validator guards
  + #swagger annotations (new "Admin · Shard" tag, TownCrierRequest schema).
- server.js: startup probe (checkUoLink) that logs reachability and warns
  loudly on a protocol mismatch when the integration is enabled.
- client: api.admin uo-link methods; ShardAdmin.jsx control panel (status
  panel with ingestion stats, config form, town crier) modeled on
  DiscordBotAdmin; wired into AdminLayout nav/titles + the /admin/shard route.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3
2026-07-11 02:22:17 -05:00
1c9a9d26e1 Add public Shard page + player Game Accounts UI (phase 4)
Frontend for the uo-link integration, matching the existing site styling.

- api/client.js: api.shard.* (status/feed/economy/idoc/char), the
  shardStreamUrl SSE endpoint, and api.player.shard.* (link/accounts/roster/
  vendors).
- lib/useShardFeed.js: EventSource hook over /public/shard/stream with a
  rolling buffer and a connected flag (browser never touches the sidecar WS).
- routes/public/Shard.jsx: connection banner, stat tiles (online / gold supply
  / link), a gold-supply sparkline, "recent vendor sales" and "IDOC houses"
  lists, and a live event ticker — built from the shared panel/grid/format
  vocabulary. Registered at /site/shard under the maintenance gate and linked
  from the site header.
- routes/player/PlayerAccount.jsx: a "Game accounts" section — enter a [link
  code to link an account, then expand it to see characters and player vendors
  on demand (503 shows a retry banner).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3
2026-07-11 02:17:45 -05:00
064f02c4b6 Add player account linking + roster/vendor reads (phase 3)
Ties an in-game account to a website user and gates reads on ownership.

- schema: shard_account_links (account PK → user_id, char_name, linked_at;
  FK users ON DELETE CASCADE) — the site-side mirror of the sidecar's
  authoritative link.
- model/shardLinks: upsert/list/ownership-check/getByAccount/unlink.
- player/shard.controller.js:
  - POST /player/shard/link — confirm a one-time [link code via
    uoLinkClient.confirmLink(code, req.user.id); on link.ok mirror the link and
    activity.log it; bad/expired codes → 400, shard down → 503.
  - GET /player/shard/accounts — the caller's linked accounts.
  - GET /player/shard/roster/:account and /vendors/:account — live round-trips,
    ownership-checked against the mirror (403 otherwise), 503 on shard restart.
- player.routes.js: mounted under the existing requireRole('player') gate with
  express-validator guards + #swagger annotations; new "Player · Shard" tag and
  ShardLinkRequest/ShardLinkResult/ShardLink schemas; spec regenerated.

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3
2026-07-11 02:11:28 -05:00
9d9f5aac28 Add uo-link WS ingest, storage tables and SSE broadcaster (phase 1)
The site now ingests the sidecar's live WebSocket feed and persists it to its
own MariaDB, and re-broadcasts curated events to browsers over SSE.

- schema: shard_events (append-only notable-kind log, sha1 dedupe_key +
  INSERT IGNORE for idempotent reconnect backfill), shard_online (current
  players, upsert/refresh/remove), shard_economy (gold-supply series),
  shard_houses (per-house decay stage + derived is_idoc).
- model/shardEvents + model/shardState: the .db.js/.model.js split; writes
  take camelCase event data, reads are shaped; online upsert uses COALESCE so
  a partial char.vitals refresh never blanks login fields.
- utils/shardIngest: single dispatcher routing each kind to state writes
  and/or the event log, then the broadcaster. High-frequency kinds
  (char.vitals, economy.supply) update state only. A changed server.hello
  bootId clears the stale online roster. Deps are injected for unit testing.
- utils/uoLinkSocket: the server's first outbound WS client (ws dep). Verifies
  the ws.hello protocol, backfills via /history + /economy on every
  (re)connect (dedupe handles overlap), reconnects with capped backoff, and
  mirrors connection state into uo_link_config. Self-guards: only connects when
  the integration is enabled with a token.
- utils/shardBroadcast: SSE fan-out with public (safe kinds only) and admin
  (all) channels, keepalive pings, per-client cleanup.
- server.js: start the ingest socket on boot (no-op until configured) and stop
  it + close SSE streams on graceful shutdown.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3
2026-07-11 02:08:56 -05:00
ab647756f0 Add uo-link sidecar foundation: config store + REST client (phase 0)
Introduces the DB-backed connection config for the uo-link sidecar (the
HTTP + WebSocket bridge to the ServUO shard) and a never-throw REST client,
mirroring the existing Discord-bot integration:

- uo_link_config singleton table (base/ws URL, AES-256-GCM-encrypted shared
  token, protocol pin, enabled, and last-known status/plugin_connected/
  last_event_at/boot_id mirrors for the admin panel).
- model/uoLinkConfig: getSafe (never returns the token — only hasToken),
  getWithToken (server-side decrypt), save (blank token = unchanged),
  recordStatus (mirror the sidecar's reported state).
- utils/uoLinkClient: never-throw fetch client returning {ok,data,status,
  error}; Bearer token + X-UOLink-Version on every call; brief config cache;
  helpers for health/char/roster/vendors/history/economy/link/towncrier.
- .env.example: UOLINK_BASE_URL/WS_URL/PROTOCOL defaults (token stays
  admin-managed in the DB, never an env var).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3
2026-07-11 02:02:40 -05:00
d49008e9f2 Merge pull request 'CMS Page Builder (Wave 1): block-based Pages content type' (#47) from feature/cms-page-builder into main
Reviewed-on: UOM/website#47
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-10 02:15:05 +00:00
e7f5f24809 Regenerate Swagger with the CMS pages endpoints
swagger-output.json now documents GET/POST /admin/pages, GET/PATCH/DELETE
/admin/pages/:id, POST /admin/pages/:id/{unprotect,preview}, and the public
GET /public/pages/:slug + /public/pages/:id/preview/:token.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 20:57:39 -05:00
1dd7603f54 Add page builder admin UI + public page route (step 5 + step 6 client)
- PagesAdmin: list view of pages (title/slug/status/protected/updated) with
  new/edit navigation and a View link to the live page.
- PageBuilder: full-page block canvas — palette (adds any registered block),
  per-block editor cards with show/hide, up/down + native drag reorder, and
  remove; Content / Settings tabs; SEO metadata + layout/nav settings panels;
  publish/unpublish; protect (PATCH) and password-gated unprotect (modal);
  draft preview (mints a token, opens /preview/:id/:token); delete (blocked
  while protected). Surfaces server block-validation details on save.
- CmsPage: public renderer for /:slug (published; staff see drafts) and the
  token-gated /preview/:id/:token, rendering blocks via BlockList and
  reflecting the page title/meta.
- Routing: /:slug catch-all after all named routes + /preview/:id/:token
  outside the maintenance gate; admin /admin/pages, /pages/new, /pages/:id.
- api client: public page/pagePreview + admin pages CRUD/unprotect/preview.
- AdminLayout: "Pages" nav entry (Content group) with icon.
- theme.css: builder canvas + preview-banner + shell-wide styles.

Client builds clean (216 modules).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 20:54:36 -05:00
4d87c5f627 Add pages API: model, controller, routes, preview (steps 4/6/7/8)
Backend for the CMS page builder, all under the existing /api/v1:

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 20:48:31 -05:00
764fb0c069 Add Wave 1 block renderers + editors (page builder step 3, client half)
Client block registry now carries a renderer, edit form, palette label/icon,
and defaults for all seven Wave 1 blocks (self-registering via
client/src/blocks/types/*): heading, rich_text, image, two_column, cta,
divider, quote.

- BlockRenderer + BlockList render stored blocks via the registry (respect
  `visible`, tolerate unknown types), reading getBlock from ./registry to
  avoid the index -> twoColumn -> BlockRenderer cycle.
- editorKit: shared Field/TextField/TextAreaField/SelectField styled with the
  existing admin form classes; rich_text editor reuses RichTextEditor
  (variant post), image editor reuses the shared uploader.
- two_column editor is a mini per-column canvas (add from the leaf-only
  palette, edit via each block's registry editor, reorder, remove).
- theme.css: public block styles (heading/image alignment/cta/quote/
  two-column responsive grid) + column sub-block editor styles.

Verified: all 11 modules transform cleanly under esbuild. Full visual
verification comes with the builder UI (step 5) + public route (step 6).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 20:37:34 -05:00
6d31869ba2 Add Wave 1 block server schemas (page builder step 3, server half)
Register all seven Wave 1 block types with their server-side validation
schemas, self-registering via server/src/blocks/types/*:
heading, rich_text, image, two_column (container), cta, divider, quote.

- propHelpers.js: shared validators (isSafeUrl rejects javascript:/data:/
  protocol-relative, enum/required/optional text, strict key allowlist).
- rich_text carries a `sanitize` normalizer (registry now supports it) that
  runs html through the shared cleanBody allowlist on save.
- Registry entrypoint requires the type modules so all schemas load.

Verified: all 7 register; valid blocks pass; malformed props yield precise
per-path errors; one-level nesting cap enforced; rich_text sanitize strips
script/onerror.

Client renderers + editors (step 3 client half) still to come.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 20:30:58 -05:00
fcef08e9b6 Add pages table + block registry scaffold (page builder step 2)
New `pages` table: slug/title/blocks(JSON-as-text)/status/protected, author
FK, grouped SEO metadata + layout/nav settings columns (added up front per
spec — cheap now, painful to retrofit), published_at mirroring posts.

Block registry scaffold, server and client, defining the pattern without
any block types yet (Wave 1 lands in step 3):
- server/src/blocks: registry (register/get/list, reserved envelope keys,
  container metadata) + validateBlocks (authoritative save-time gate:
  envelope, registered-type, per-block schema, one-level nesting cap) +
  index entrypoint that will register Wave 1 defs.
- client/src/blocks: mirror registry carrying renderer/editor/palette +
  makeBlockId, plus index entrypoint.

Verified: schema applies idempotently against the dev DB (pages table +
indexes present); validator exercised for empty/non-array/unknown-type/
bad-envelope/duplicate-id/nested-container cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 20:26:14 -05:00
6180e8a071 Add rich-text alignment controls (left/center/right)
Shared RichTextEditor gains @tiptap/extension-text-align for heading and
paragraph nodes, serializing alignment as inline text-align on the block
node so it round-trips through save/reload. Fixed once at the shared
component so it also flows into the upcoming rich_text and two_column
page blocks.

Server sanitize allowlist now permits `style` on p/h1-h6, constrained by
allowedStyles to text-align (left/right/center/justify) only; all other
CSS properties and values are stripped.

Step 1 of the CMS Page Builder spec.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 20:21:17 -05:00
d7fc2dccb7 Merge pull request 'Modernize email: Gmail OAuth2 sending + admin sidebar redesign' (#46) from feature/email-oauth2 into main
Reviewed-on: UOM/website#46
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-08 03:33:42 +00:00
455e850b91 Merge branch 'main' into feature/email-oauth2 2026-07-08 03:33:11 +00:00
f8652c2399 Modernize email: Gmail OAuth2 sending, configured under Settings
Retire env-var SMTP basic-auth and send the contact form through Gmail over
OAuth2 (SMTP XOAUTH2), configured in Admin -> Settings -> Email via an in-app
"Connect Gmail" consent flow. Reuses the existing google SSO OAuth client; the
captured refresh token is stored AES-GCM-encrypted (write-only over the API,
never returned), mirroring the auth-provider and Discord-bot secret patterns.

- schema: new email_config singleton table (mirrors bot_config)
- model: emailConfig.{db,model} with encrypted refresh token + getSafe/getWithSecret
- mailer: nodemailer OAuth2 transport (client id/secret from the google provider
  row), contact recipient = contact_email setting, mailto: fallback preserved,
  plus sendTest()
- routes/controller: /admin/email config, connect start+callback (ssoState CSRF
  + PKCE), test, disconnect
- client: EmailDelivery section on the Settings page + api methods; Settings copy
  now spells out that contact_email is the delivery recipient
- docs/env: drop SMTP_*/CONTACT_TO from env examples; update README/BACKEND_DESIGN
- tests: emailConfig.model + mailer suites (8 new; full suite 142 pass)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XKeCQEJZr1AFJN4Bgcmvh3
2026-07-07 22:29:27 -05:00
5b3ab7f282 Merge pull request 'Redesign admin/staff sidebar: collapsible categories, icons, role-accurate nav' (#45) from feature/admin-nav-redesign into main
Reviewed-on: UOM/website#45
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-08 02:56:15 +00:00
17d42cebfe Redesign admin sidebar: collapsible categories, icons, role-accurate nav
Regroup the flat 12-link staff sidebar into collapsible category sections
(Content / Moderation / System, with Dashboard and Account ungrouped) and
add a small inline-SVG icon per item. Category collapse state persists in
localStorage and the group holding the active route auto-opens.

Gate each item by role to match server-side enforcement so the sidebar no
longer shows links that would 403: Content is admin/editor, Moderation is
admin/moderator, System (Users, Settings, Hero Editor, Authentication,
Discord Bot, Web Bot Activity) is admin-only. Existing moderator confinement
(Moderation + Account only, plus redirect) is preserved.

Rename "Bot Activity" to "Web Bot Activity" to distinguish the bot-scoring
view from the Discord Bot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 21:54:37 -05:00
5da27879e5 Merge pull request 'Gate /admin to staff roles; role-aware login redirects for players' (#44) from feature/player-accounts into main
Reviewed-on: UOM/website#44
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-07 01:02:53 +00:00
cda0c16149 Merge branch 'main' into feature/player-accounts 2026-07-07 01:02:39 +00:00
82807d18d9 Gate /admin to staff roles; role-aware login redirects for players
Introducing the 'player' role turned 'logged-in' into 'logged-in but possibly
untrusted', but the admin router only gated content routes (dashboard, posts,
wiki, uploads) by isLoggedIn — so a player session could reach editor-tier
endpoints. Fixes:
- Backend: requireRole('admin','editor','moderator') at the admin router base;
  players now 403 on all /admin/* and use /player instead.
- Client: RequireAuth redirects a signed-in player to /account (mirrors
  RequirePlayer).
- Both login pages redirect by role after auth (player -> /account, staff ->
  /admin) so you land in the right shell whichever door you used.

Verified live: player token 403s on /admin/dashboard + /admin/users, 200s on
/player/account; browser click-through confirms a player at /admin and at
/admin/login both land on /account. 134 server tests green; client builds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
2026-07-06 19:57:31 -05:00
d72deff2cc Merge pull request 'Player accounts: self-service player role, registration, and portal' (#43) from feature/player-accounts into main
Reviewed-on: UOM/website#43
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-06 20:29:42 +00:00
387db52510 Fix player getAccount has_password: read the raw row, not sanitized req.user
req.user comes from getById which strips password_hash, so has_password was
always false — the account page mis-rendered a real password account as the
SSO-only 'set a password' variant (and the change-password form omitted the
required current-password field). Read the raw row for that one flag.

Caught by a browser click-through of the /account portal. Adds a regression
test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
2026-07-06 05:11:36 -05:00
5daf260db9 Player accounts frontend + Swagger + schema comment fix
- Player portal: RequirePlayer guard, /account routes (login, register,
  settings) with shared PlayerShell; register reads /public/settings derived
  flags; AuthContext.register; api.register + api.player.* namespace.
- Admin UI: player role + status/email + reset-password hint in UserEditor,
  status column + badge-player in UsersAdmin, player_registration select in
  SettingsAdmin; 'disabled' SSO error copy.
- Swagger: Player tag + RegisterRequest/ChangeUsername/ChangePassword/
  PlayerAccount/OkFlag schemas; regenerated swagger-output.json.
- Fix: remove a semicolon from a schema.sql inline comment that broke the
  statement splitter in ensureSchema.

Verified against the live dev DB: schema migrations apply (player enum,
nullable password_hash, email/status/last_login_ip, seeded setting); 21-check
controller smoke (register gating, dup/reserved, null-hash rules, self change
username/password with session re-issue surviving the cutoff, SSO-only initial
password, banned-login refusal); case-insensitive uniqueness; public settings
expose only derived registration flags. Client builds; 133 server tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
2026-07-06 01:49:12 -05:00
f8bcc7f6a3 Player accounts backend: schema, registration, self-service, SSO provision
- Widen users.role enum to include 'player'; make password_hash nullable;
  add email/email_verified/status/last_login_ip; pin username _ci collation.
- POST /auth/register (honeypot + registerLimiter + botScore, reserved-name
  blocklist, duplicate->409, auto-login). player_registration setting gates it.
- SSO auto-provision in finishLogin (setting-gated); return/portal-aware SSO
  redirects for the player portal; status refusal on login + requireAuth.
- New /player self-service group (account, change username/password, TOTP,
  identities), reusing account.controller; accountChangeLimiter.
- Admin: 'player' role + status/email on user create/update, role/status audit,
  player_registration enum validation, derived public registration flags.
- usernamePolicy module (reserved, sanitize, derive, dedup) + unit tests;
  extend SSO callback tests. 133 server tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
2026-07-06 01:36:51 -05:00
cdd916e199 Ignore local .plans/ planning docs
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
2026-07-06 01:17:16 -05:00
4ab46410be Merge pull request 'Moderation dashboard: staff dashboard, user history, notes, event capture (Phase 6a + 6b)' (#42) from feature/moderation-dashboard into main
Reviewed-on: UOM/website#42
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-05 20:51:38 +00:00
2b4c4c5235 Merge branch 'main' into feature/moderation-dashboard 2026-07-05 20:51:16 +00:00
3027bb0400 Capture member/filter/spam events for the dashboard (Phase 6b)
Light up the moderation dashboard's previously-empty widgets by persisting the
event streams the bot only reacted to in-memory before.

Schema (bot-owned)
- member_events: join/leave, with invite_code/inviter_* for best-effort invite
  attribution on joins
- filter_hits: word / foreign-invite filter deletions (matched + action_taken)
- spam_hits: rate_limit / mass_mention / mass_emoji detections

Bot
- new models memberEvents/filterHits/spamHits
- guildMemberAdd records the join with invite attribution; new inviteTracker.js
  keeps an invite-use cache (GuildInvites intent + inviteCreate/inviteDelete) and
  diffs it on join to find which invite was used — best-effort, never blocks
  auto-role
- new guildMemberRemove records leaves
- messageFilter records filter/spam hits alongside the existing warn/mute;
  inviteFilter now returns the offending code; detectSpam identifies which spam
  rule tripped (preserving the rate-limit-first side-effect order)
- mod_actions still logs the resulting warn/mute — the new tables are additive

Server
- summary extended with joins/leaves/invite_joins/filter_hits/spam_hits per window
- new feeds: /api/v1/admin/moderation/{members,filter-hits,spam-hits}

Client
- overview now shows 8 tiles (mod actions + joins/leaves/filter/spam, joins tile
  notes "N via invite") plus an Events panel with Members/Filter/Spam tabs;
  removed the coming-soon note

Verified: 119 server unit tests, client build, 14-check DB-backed smoke, and a
browser click-through of every tile and events tab (incl. invite attribution).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
2026-07-05 10:36:09 -05:00
b0c0d1fe9b Add moderation dashboard, user history & notes (Phase 6a)
Surface the Discord bot's moderation data on the admin panel: a read-only
staff dashboard over the existing mod_actions log, per-user history, staff
notes, and a new moderator role. No bot changes.

Schema
- users.role ENUM gains 'moderator' (CREATE + idempotent ALTER for existing DBs)
- new server-owned mod_notes table (staff_only/admin_only visibility)

Server
- model/moderation: read mod_actions via the shared pool (documented read-only
  cross of the bot/server ownership boundary), correlate accounts through
  user_identities (provider='discord'), flag automated actions via
  staff_user_id === bot_config.application_id; pure reshaping helpers isolated
  in moderation.pure.js so they unit-test without opening a DB pool
- model/modNotes: list/add with role-gated admin_only visibility
- admin/moderation.controller + routes under /api/v1/admin/moderation/* gated by
  requireRole('admin','moderator'); admin_only note writes require admin
- allow assigning 'moderator' in the user create/update validators

Client
- /admin/moderation overview (window tiles, type-filterable recent feed, user
  lookup) and /user/:discordId history (tabs + notes with add-note)
- RoleGate; AdminLayout filters nav and confines moderators to their section
- moderator badge + action-type/auto badges

Deferred (see plan): 6b bot event capture (joins/leaves/filter/spam), 6c appeals
(needs public accounts), 6d /internal/mod-reverse bot reversal callback.

Verified: 116 server unit tests, client build, DB-backed model smoke, full
HTTP/RBAC e2e, and a browser click-through of the dashboard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
2026-07-05 10:16:34 -05:00
f2691959ff Merge pull request 'Fix bot container inheriting site PORT/LOG_FILE from shared .env' (#41) from bugfix/bot-container-port-leak into main
Reviewed-on: UOM/website#41
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-05 05:40:29 +00:00
60d2121b83 Fix bot container inheriting site PORT/LOG_FILE from shared .env
The app and bot services share env_file: .env, so the site's PORT=3000
leaked into the bot container. The bot code is `PORT || 4100`, so it
bound 3000 instead of 4100 — and the server's BOT_INTERNAL_URL
(http://bot:4100) then couldn't reach it, surfacing as "failed to fetch"
on the admin Discord Bot page even though the bot was otherwise healthy
and connected to Discord.

Pin PORT: 4100 on the bot service so it binds where the server expects.
Also override LOG_FILE: bot.log so the bot doesn't inherit the site's
LOG_FILE and write into app.log, keeping the two logs distinct.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
2026-07-05 00:39:16 -05:00
20d3fbf594 Merge pull request 'Audit and fix Swagger/OpenAPI accuracy; regenerate served spec' (#40) from docs/swagger-audit into main
Reviewed-on: UOM/website#40
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-05 04:33:36 +00:00
f8db61025b Audit and fix Swagger/OpenAPI accuracy; regenerate served spec
The route-level annotations were 100% present, but the committed/served
spec (swagger-output.json) was stale and several response schemas had
drifted from the controllers. This aligns the docs with actual behavior
and regenerates the spec.

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
2026-07-04 22:51:16 -05:00
2067028070 Merge pull request 'Enforce TOTP second factor on SSO login (#31)' (#39) from bugfix/sso-totp-bypass-31 into main
Reviewed-on: UOM/website#39
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-05 03:34:49 +00:00
03e62b56ad Enforce TOTP second factor on SSO login (#31)
SSO login minted a full session immediately, ignoring the account's
totp_enabled flag — so a 2FA admin with a linked Google/Discord/OIDC
identity could sign in without their authenticator code, silently
downgrading the account to single-factor (the strength of the IdP login).
The local password flow already gates on needsTotp(); SSO did not.

Wire SSO through the same staged-TOTP gate:

- ssoState: createTotpPending/verifyTotpPending + a short-lived httpOnly
  sso_totp cookie. The pending token carries stage:'totp' (session
  validation rejects it) + kind:'sso_totp' (scoped to the SSO endpoint)
  plus the resolved context (userId, provider, authMethod, returnTo).
- sso.controller: finishLogin now stages the challenge and redirects to
  /admin/login?sso_totp=1 instead of creating a session when the account
  has TOTP on. New finishSsoTotp verifies the code (backoff + bot-scoring
  on failure, mirroring loginTotp) and only then mints the session.
- sso.routes: POST /auth/sso/totp behind the same backoff/slow/limiter
  stack and code validation as the local TOTP endpoint.
- client: AdminLogin detects ?sso_totp=1 and completes over fetch via
  api.ssoLoginTotp; the challenge never touches the URL or JS.

Keeps the second factor httpOnly throughout, consistent with the SSO tx
cookie. 12 new tests; full suite 106/106.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
2026-07-04 22:17:35 -05:00
15cf8ea286 Merge pull request 'Fix SSO flow-token / session type confusion (#32)' (#38) from bugfix/sso-token-confusion-32 into main
Reviewed-on: UOM/website#38
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-05 02:56:31 +00:00
5f62eccdd8 Fix SSO flow-token / session type confusion (#32)
sessionFromDecoded validated sessions with a blocklist — it rejected a
token only when `decoded.stage` was present (the TOTP challenge). Because
every JWT is signed with the same JWT_SECRET and distinguished only by
claims, the SSO transaction cookie (sso_tx, which carries kind:'sso_tx'
and id:'sso' but no stage) passed validation and was accepted as a bogus
{ userId:'sso' } session.

requireAuth's DB re-load blocked protected admin routes, but non-DB
identity checks were fooled — notably siteMode's maintenance-preview
bypass, which trusts any truthy getUserFromRequest. An attacker could
start an SSO flow to obtain an sso_tx cookie and replay it as the auth
cookie / Bearer token to bypass the maintenance gate. The broader risk
was latent: any future code path trusting attachSession/getUserFromRequest
without a DB round-trip inherited an auth bypass.

Make session validation positively typed: real sessions are now stamped
with typ:'session' (createSession + mintMobileTokens), and
sessionFromDecoded accepts a token only when that marker is present. As
belt-and-suspenders it also rejects any token carrying a non-session
marker (stage || kind). Flow/challenge tokens are never stamped, so they
can no longer be mistaken for sessions.

Note: existing web cookie sessions predating this change lack the typ
claim and will be rejected once — users re-login. Mobile clients recover
automatically on next refresh.

Adds regression tests: the sso_tx flow token and a bare identity token
are both rejected by validateSession / decodeIdentity / getUserFromRequest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 21:54:58 -05:00
e8a54d9ff7 Merge pull request 'Implement web session/token revocation (#30)' (#37) from bugfix/session-revocation-30 into main
Reviewed-on: UOM/website#37
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-05 02:08:15 +00:00
933206a1b8 Implement web session/token revocation (#30)
Web sessions were stateless JWTs with no server-side store: the revocation
hooks in session.service were stubs that only logged. As a result web logout
was client-side only (a copied cookie stayed valid until natural JWT expiry)
and a password change never invalidated existing sessions. The mobile bearer
flow already had revocable, DB-stored tokens; this brings the web/cookie flow
to parity.

Two-layer revocation, both enforced in requireAuth (which already loads the
fresh user row each request):

- Per-session denylist: new `revoked_sessions` table keyed on the JWT `jti`
  (already minted per session). A single logout adds this session's jti;
  rows self-expire at the token's own exp and are pruned on boot. New model
  `revokedSessions` mirrors the `mobileSessions` db/model split.
- Per-user cutoff: new `users.tokens_valid_after` column. A password change
  (and the new `invalidateSessions` helper) bumps it to NOW(); any token whose
  iat is at or before the cutoff is rejected. The comparison is inclusive so a
  token minted in the same wall-clock second as the change is still revoked.

Wiring:
- session.service: revokeSession / invalidateSession / invalidateAllUserSessions
  now delegate to the stores; sessions carry `expiresAt` (JWT exp) so logout can
  set a self-pruning denylist row.
- /logout gains best-effort attachSession so the controller can revoke this
  session's jti and log auth.logout; stays a no-op for anonymous callers.
- users.model.update bumps the cutoff whenever the password hash is rotated.
- schema.sql: revoked_sessions table + tokens_valid_after column, added to the
  CREATE and to the idempotent migration block (ensureSchema on boot).

Verified end-to-end against the local dev DB: a captured cookie is rejected
after logout, and an existing session is rejected after a password change while
re-login with the new password succeeds. Full server test suite green (96).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
2026-07-04 21:06:50 -05:00
1cfb79f5ae Merge pull request 'Isolate internal bot-config route from the public listener (#33)' (#36) from bugfix/internal-token-endpoint-33 into main
Reviewed-on: UOM/website#36
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-04 22:50:05 +00:00
3ef84b41ef Merge branch 'main' into bugfix/internal-token-endpoint-33 2026-07-04 22:49:33 +00:00
5df943095d Isolate internal bot-config route from the public listener (#33)
The GET /internal/bot-config route returns the DECRYPTED Discord bot
token and was mounted on the same Express app / port 3000 that Pangolin
proxies publicly. Its only guard was the BOT_INTERNAL_KEY shared secret,
and .env.example shipped a placeholder default — so a forwarded path or a
weak/unrotated key would expose the plaintext token to the internet.

Move server<->bot internal traffic onto its own listener and fail fast on
a weak key:

- Add server/src/internalApp.js: a standalone Express app mounting
  requireInternalKey + /internal (and a no-secret /health), mirroring the
  bot's unpublished port-4100 pattern.
- server.js starts a second listener on INTERNAL_PORT (default 3001),
  closed on graceful shutdown.
- Remove the /internal mount from the public v1.router; the public app now
  404s /api/v1/internal/bot-config even with a valid key.
- Fail fast: new utils/botInternalKey.js rejects an empty, placeholder, or
  <16-char BOT_INTERNAL_KEY — fatal in production (exit 1), warning in dev.
- docker-compose: bot SITE_INTERNAL_URL -> app:3001/internal/bot-config;
  document that INTERNAL_PORT stays unpublished.
- .env.example (root/server/bot): document INTERNAL_PORT, the fail-fast
  behavior, and a defense-in-depth Pangolin deny rule for /api/v1/internal.

Tests: add requireInternalKey.test.js and botInternalKey.test.js
(node --test: 93 pass).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
2026-07-04 17:35:07 -05:00
bb5cc68c54 Merge pull request 'Add Discord bot: moderation, filters, scheduling, roles, invites, site integration' (#29) from feature/discord-bot into main
Reviewed-on: UOM/website#29
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-04 21:19:41 +00:00
17c1eb07e8 Merge branch 'main' into feature/discord-bot 2026-07-04 21:19:23 +00:00
7a21cc636c Add Discord bot (moderation, filters, scheduling, roles, invites, site integration)
Standalone bot/ service (its own package.json/Dockerfile) managed entirely
through a new admin-only Discord Bot panel — token stored encrypted in the
DB and pushed to the bot process in-memory, never an env var. Built in
phases, each independently verified against a live Discord guild:

- Bot skeleton: gateway connection, internal shared-secret API, self-heals
  on its own restart by pulling config from the site
- Moderation core: /ban /kick /mute /warn /warnings + mod-log channel
- Word/invite/spam filtering with leetspeak-resistant normalization and a
  staff role/channel allowlist
- Scheduled messages: recurring (cron) and one-off channel posts
- Role assignment: button role menus, auto-role on join, temp roles,
  bulk role ops
- Auto-rotating primary invite with an audit log
- Site integration: news-publish -> Discord announce webhook, manual
  /announce, read-only /wiki search

Also fixes a pre-existing bug in both DB pools (server + bot): the mariadb
driver defaulted to timezone 'local', silently mis-serializing bound Date
params by the host's local offset instead of the DB's UTC session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 15:54:41 -05:00
ad7aebb3ba Merge pull request 'Hero editor: fullscreen landing, remove two-card row, quick links into hero' (#28) from feature/hero-fullscreen-landing into main
Reviewed-on: UOM/website#28
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-04 02:55:50 +00:00
0318d6fe9f Make hero the full landing page; move quick links into hero editor
Remove the two-card destination row and the below-hero quick-links nav
from the portal so the hero fills the viewport with nothing rendered
after it. The 5 quick links (News, Screenshots, Five on Friday,
Monthly Newsletter, About) move into the hero editor as a third
buttons element in defaultLayout(), reusing the existing buttons
element type so they stay fully editable with no schema changes.

Also drop overflow:hidden on the hero section: on mobile, 100vh can
compute smaller than window.innerHeight, and with overflow hidden the
wrapped quick-links text was getting clipped at the bottom edge.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 21:49:45 -05:00
433e02d3ef Merge pull request 'Add Swagger/OpenAPI API docs (swagger-ui + swagger-autogen)' (#27) from feature/swagger-docs into main
Reviewed-on: UOM/website#27
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-03 20:30:14 +00:00
a1f0675577 Add Swagger/OpenAPI API docs (swagger-ui + swagger-autogen)
Generate an OpenAPI 3.0 spec from route annotations and serve it with
Swagger UI so the full REST API is browsable and testable.

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 15:28:13 -05:00
d7fb274bad Merge pull request 'Hero editor: scale text-block fonts with the resize handle (#25)' (#26) from enhancement/hero into main
Reviewed-on: UOM/website#26
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-03 20:01:50 +00:00
6af85c30b6 Hero editor: scale text block fonts with the resize handle (#25)
The text_block corner handle previously only changed the wrap width, so
the font size never tracked the box — making the editor un-WYSIWYG and
awkward to tune. Now dragging the handle scales every line's font
proportionally with the box, acting as a zoom that preserves the
h1/h2/p size ratios and keeps each line's manually-set baseline.

- Add scaleFontSize(): numeric px sizes (floored at 6px) and simple
  rem/em/px strings scale by the box ratio; responsive clamp()/vw
  strings are left untouched so the default hero stays fluid.
- Snapshot the box width + lines at drag start so scaling is computed
  against the origin (no rounding drift mid-drag).
- Update the canvas hint to note the handle scales text.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 14:59:15 -05:00
86e44a94a2 Merge pull request 'Add session abstraction, mobile bearer auth, and pluggable SSO (Google/Discord/OIDC)' (#24) from feature/auth-session-abstraction into main
Reviewed-on: UOM/website#24
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-03 15:35:44 +00:00
31b31c3a17 Add session abstraction, mobile bearer auth, and pluggable SSO
Refactor authentication into a provider-agnostic session layer and build
two new auth surfaces on top of it, without changing local password/TOTP
behavior. Every flow now issues sessions through
sessionService.createSession(user, authMethod).

Part 1 — Session abstraction (backward-compatible refactor):
- New server/src/auth/: token.js (JWT/cookie primitives), session.service.js
  (create/validate/partial-TOTP/revoke), session.middleware.js
  (attachSession/requireAuth/requireRole). utils/auth.js is now a thin
  compat facade so existing imports are unchanged.

Part 2 — Mobile bearer auth (additive):
- /api/v1/auth/mobile/{login,refresh,logout}: short-lived access JWT +
  long-lived refresh token, stored hashed and rotated on use, in a new
  mobile_refresh_tokens table. Reuses web bot-scoring/backoff; single-request
  TOTP. token.signToken gains a backward-compatible expiresIn option.

Part 3 — Pluggable SSO (Google, Discord, generic OIDC):
- OAuth2Provider base + built-in Google/Discord (fixed endpoints) + generic
  OIDC, a registry with health/validation, PKCE+CSRF transaction state, and
  discovery (GET /auth/providers), start/link/callback routes.
- Link-only policy: SSO signs in only to an already-linked account; external
  identities are never auto-provisioned. Client secrets encrypted at rest
  (AES-256-GCM, utils/secretBox.js). Admin CRUD (/admin/auth/providers) and
  account linking (/admin/account/identities). New auth_providers +
  user_identities tables.

Frontend:
- Login page renders provider buttons from /auth/providers (inline SVG icons,
  graceful with zero providers). New Authentication admin view
  (Local/Google/Discord/Custom). Account page linked-accounts section.

Tests: 83 passing (session, mobile, providers, registry, secretBox, ssoState,
ssoCallback) — all DB-free via fetch mocks + model stubs. README + .env.example
updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 10:31:29 -05:00
8fa34ca68e Merge pull request 'Add Bot Activity admin panel: banned-IP view + recent events + emergency unban' (#23) from feature/bot-activity-admin into main
Reviewed-on: UOM/website#23
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-03 08:29:54 +00:00
870971fc12 Add Bot Activity admin panel: banned-IP view + recent events + emergency unban
Expose the botScore middleware's in-memory scoring/ban state to admins.
Previously state lived only in the store Map with no persistence or API — the
only visibility was tailing container logs.

- botScore: bounded ring buffer (300) recording scan/login-fail/honeypot and
  ban events (most-recent-first); listState() snapshot of all scored IPs;
  unban() to clear a single IP.
- New admin-only endpoints GET /admin/bot-activity and
  POST /admin/bot-activity/unban (RBAC admin gate, IP validated). Unban is
  activity-logged with the admin username.
- Bot Activity tab: currently-banned table with Unban, plus a recent-events
  feed, following the existing admin table patterns.
- Tests for the buffer, listState, and unban (guard lets an unbanned IP back
  through). README updated.

Read + emergency-unban only — no ban-add or weight-editing surface. Buffer is
in-memory, matching the store; not persisted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 02:31:25 -05:00
58852a5078 Merge pull request 'Update README for today's security hardening and 2FA work' (#22) from docs/readme-refresh into main
Reviewed-on: UOM/website#22
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-03 06:09:53 +00:00
cd678e75ce Merge branch 'main' into docs/readme-refresh 2026-07-03 06:09:41 +00:00
a82f839c61 Update README for today's security hardening and 2FA work
Several changes merged today were not reflected in the README. Bring it
back in sync with main:

- Security section: rewrite into Session/authorization, Login hardening,
  Uploads/input, and Platform groups — documents DB re-validation of the
  JWT per request (#12), role-based authorization (#10), optional TOTP
  2FA (#9), login throttling + per-IP backoff, honeypot, bot-scoring/IP
  ban, and mimetype-derived upload extensions (#11) + username
  uniqueness checks on update (#13).
- Environment variables: add TRUST_PROXY, DEBUG_TRUST_PROXY, TOTP_ISSUER,
  TOTP_CHALLENGE_TTL, and UPLOAD_DIR.
- Routes/API tables: add /admin/account and the account/totp endpoints
  plus the login/totp second-factor step.
- Tech stack + project structure: note TOTP (speakeasy/qrcode), the
  loginProtection/botScore middleware, the totp util, and the Account view.

Docs-only; no code changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 01:07:22 -05:00
05933f8d94 Merge pull request 'Fail fast when JWT_SECRET is missing in production (closes #14)' (#21) from fix/jwt-secret-fail-fast into main
Reviewed-on: UOM/website#21
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-03 05:58:16 +00:00
073c010d72 Fail fast when JWT_SECRET is missing in production (#14)
auth.js previously only logged a warning when JWT_SECRET was unset and
then continued to boot. With no secret, jwt.sign/jwt.verify cannot
produce or validate a usable token, so every login silently fails while
the server appears healthy — and booting a production instance without a
configured secret is a safety hazard.

Resolve the secret through resolveJwtSecret():
  - production (NODE_ENV=production): throw, so the process refuses to
    start without a real secret instead of running unusable.
  - dev/other: fall back to a known insecure secret so local login keeps
    working, with a loud warning to set JWT_SECRET before deploying.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 00:55:07 -05:00
f305019c54 Merge pull request 'Make the hero Moon image configurable (src/alt), backwards-compatible' (#20) from feature/configurable-moon-image into main
Reviewed-on: UOM/website#20
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-03 04:45:24 +00:00
7e8ffeee6f Raise hero upload soft-warning from 1 MB to 5 MB
The "may slow the page" prompt is only a client-side nudge — the server
hard-limits uploads at 8 MB. 1 MB was arbitrarily low and nagged on
perfectly normal hero images. Bump to 5 MB (still well under the hard cap)
and pull the threshold + message into a single tooLargeToUpload() helper so
the background, moon, and image upload paths stay in sync.
2026-07-02 23:42:23 -05:00
6ab3e47d38 Make the hero Moon image configurable via props.src
The Moon stays a dedicated, first-class hero element — only its image
source becomes configurable. Adds optional src/alt props alongside the
existing size/glow.

- HeroElement: the moon renders props.src when present, else falls back to
  the default /assets/img/hero-moon.png. Size, glow, and animation are
  unchanged. alt is now props.alt (default '', same as before).
- HeroEditor MoonPanel: adds an image upload (reusing the existing shared
  api.admin.upload workflow, same as the image/background panels) that sets
  props.src, an alt-text field, and a "Use default" reset. Size/glow
  controls unchanged.

Fully backwards compatible: existing layouts with only size/glow and no
src render exactly as today via the fallback. No DB, API, or hero-JSON
changes; no migration.
2026-07-02 23:38:03 -05:00
ea46b5d346 Merge pull request 'Admin login hardening: RBAC-safe controls, optional TOTP, bot-scoring + IP ban (closes #9)' (#19) from feature/admin-login-hardening into main
Reviewed-on: UOM/website#19
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-03 04:27:50 +00:00
d38c98ad9e Harden admin login: RBAC-safe controls, 2FA, bot-scoring, rate limits (#9)
Adds a layered set of protections around the admin login and the app edge.

Trust proxy (server/src/utils/trustProxy.js)
- Configurable via TRUST_PROXY; pin to the newt agent ("ptero") LAN IP so
  X-Forwarded-For is trusted ONLY from that peer. A blanket "true" is
  rejected (coerced to 1) to prevent XFF spoofing that would dodge every
  IP-based control. DEBUG_TRUST_PROXY logs peer/XFF/req.ip to re-verify the
  proxy IP without a redeploy. Documents the Omada static-reservation
  assumption.

Login throttling (server/src/middleware/loginProtection.js, rateLimit.js)
- express-slow-down progressive delay + the existing hard rate cap + a
  separate per-IP exponential backoff that persists across the rate window.
  All failures return one generic message (no user/pass disclosure).

Honeypot (login form + auth.controller)
- Hidden, plausibly-named field ("company"); a filled value fails
  generically and is scored as an unambiguous bot.

Optional per-user TOTP 2FA (speakeasy/qrcode)
- totp_secret/totp_enabled columns (+ idempotent migration). Self-service
  Account page: enroll via QR, confirm a code to enable, code-gated disable.
- Login is two-step for enrolled users: after the password, a short-lived
  signed challenge (stage:'totp', not a session) is required before the
  real session is issued.

Bot / scanner scoring + IP ban (server/src/middleware/botScore.js)
- Weighted CMS-scanner paths (this app uses none). Junk paths 404 FIRST,
  unconditionally — independent of score/ban state, so a scanner rotating
  through fresh Cloudflare IPs gets no free pass. /wp-admin/install.php is
  the top-weighted near-1-hit ban (worst offender in prod logs). Per-IP
  score with quiet-period decay temp-bans an IP from ALL routes once past a
  (deliberately low) threshold, to protect /admin from credential stuffing.
  Failed logins and honeypot hits feed the same score.
- Periodic sweep evicts stale, unbanned, quiet entries so the in-memory
  store can't grow unbounded; the interval is unref'd and cleared on
  graceful shutdown.

Tests: node --test suite (40) covering trust-proxy parsing + live req.ip
(incl. pinned-IP), rate limiter + exponential backoff, honeypot rejection,
TOTP verify (enabled/disabled) + challenge-isn't-a-session, bot-score
threshold/decay/ban + junk-404-independence + install.php + store sweep.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 23:22:35 -05:00
ad9c556c9a Merge pull request 'Derive uploaded file extension from mimetype, not originalname (fixes #11)' (#18) from fix/upload-extension-xss into main
Reviewed-on: UOM/website#18
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-03 02:44:28 +00:00
e84835a0fb Derive uploaded file extension from mimetype, not originalname (#11)
The multer filename kept path.extname(file.originalname), while the
fileFilter only checked the spoofable client-supplied mimetype. An
attacker could send Content-Type: image/png with originalname x.html,
landing an .html file in /uploads that express.static serves as
text/html — same-origin stored XSS.

- Store the extension from a whitelist keyed by the accepted mimetype
  (MIME_EXT), never from originalname. The fileFilter uses the same map
  as its single source of truth, so only mimetypes with a safe mapped
  extension pass.
- Use crypto.randomBytes for the random filename component.
- Serve /uploads with an explicit X-Content-Type-Options: nosniff
  (defense in depth alongside helmet's global setting).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:41:56 -05:00
489 changed files with 83577 additions and 1669 deletions

View File

@@ -6,6 +6,18 @@
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev", "--prefix", "client"],
"port": 5173
},
{
"name": "server",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev", "--prefix", "server"],
"port": 3000
},
{
"name": "bot",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev", "--prefix", "bot"],
"port": 4100
}
]
}

View File

@@ -1,9 +1,22 @@
# ─── UOMysticmoon — root environment (used by docker-compose) ───
# ─── Runic Gateway — root environment (used by docker-compose) ───
# Copy to .env and fill in. NEVER commit the real .env.
# To run this as an existing branded instance (e.g. UOMysticmoon), see
# .env.uomysticmoon.example for the exact BRAND_*/DB pinning to copy in.
# Container image tag pulled by docker-compose (app + bot). Published by the
# Gitea Actions workflow on every merge to main as `latest` and `sha-<7>`.
# Leave as `latest` for routine deploys; pin to a specific build for a
# reproducible deploy or rollback, e.g. IMAGE_TAG=sha-042a151.
# Deploy: `docker compose pull && docker compose up -d`.
IMAGE_TAG=latest
# App
NODE_ENV=production
PORT=3000
# Separate, UNPUBLISHED port for server<->bot internal traffic (the decrypted
# bot-token route). Must match the port in the bot's SITE_INTERNAL_URL
# (docker-compose.yml) and must NEVER be published/proxied. See issue #33.
INTERNAL_PORT=3001
UPLOAD_DIR=/app/uploads
# Logging — written to BOTH the console and a log file.
LOG_LEVEL=info # console verbosity: error | warn | info | debug
@@ -12,11 +25,32 @@ LOG_TO_FILE=true # set false for console-only
LOG_DIR=/app/logs # log directory inside the container (bind-mounted to ./logs)
LOG_FILE=app.log
# Database (the values here are shared by the `db` and `app` containers)
# ─── Branding (BRAND_*) ───────────────────────────────────────────────────
# Instance identity. Defaults render as "Runic Gateway"; set these to rebrand
# without a rebuild. Text + colors reach the SPA through the settings API at
# runtime; the server templates index.html <title>/meta/OG/favicon at boot. The
# admin-editable "site title" and "contact email" settings, if set, override
# BRAND_NAME / BRAND_CONTACT_EMAIL.
BRAND_NAME=Runic Gateway
BRAND_SHORT_NAME=Runic Gateway
BRAND_TAGLINE=an independent private Ultima Online shard
BRAND_DESCRIPTION=Runic Gateway — an independent private Ultima Online shard. News, screenshots, guides, and community notes.
BRAND_CONTACT_EMAIL=
BRAND_URL=
# Accent color — drives the web theme's --accent and the Discord embed color.
BRAND_ACCENT_COLOR=#7f99bd
# Image assets: paths under the /brand mount (see docker-compose.yml) or absolute
# URLs. Blank = built-in defaults (hero falls back to a neutral built-in image).
BRAND_LOGO=
BRAND_HERO=
BRAND_FAVICON=
# Database (the values here are shared by the `db`, `app`, and `bot` containers —
# the bot only ever touches its own tables: guild_config, mod_actions, warnings)
DB_HOST=db
DB_PORT=3306
DB_NAME=uomysticmoon
DB_USER=uomm
DB_NAME=runic_gateway
DB_USER=runic
DB_PASSWORD=change-me-db-password
DB_ROOT_PASSWORD=change-me-root-password
@@ -26,20 +60,86 @@ JWT_EXPIRES_IN=1d
# auto = Secure cookie only when the request arrives over HTTPS (Pangolin).
# Leave as auto so login works both via the LAN IP (HTTP) and the proxy (HTTPS).
COOKIE_SECURE=auto
COOKIE_NAME=uomm_token
# Changing this on a live instance invalidates existing sessions (users re-login).
COOKIE_NAME=rg_token
# Reverse-proxy trust (req.ip / req.secure for rate limiting, backoff, bot-ban).
# Path: client -> Pangolin -> newt agent "ptero" (separate VM) -> app. Pin this
# to ptero's LAN IP (e.g. 10.0.0.42) so XFF is only trusted from ptero. Requires
# a static DHCP reservation for ptero in Omada, else a lease change breaks it.
# Integer hop count or "false" also accepted; a blanket "true" is rejected
# (coerced to 1) to prevent X-Forwarded-For spoofing.
TRUST_PROXY=1
# Set to 1 to log raw peer address + X-Forwarded-For + resolved req.ip per
# request (to verify/refresh ptero's IP without redeploying). Noisy; keep off.
DEBUG_TRUST_PROXY=0
# Optional TOTP two-factor (opt-in per user). Defaults to BRAND_NAME when unset.
# TOTP_ISSUER=Runic Gateway
TOTP_CHALLENGE_TTL=5m
# First admin bootstrap — created only if no users exist yet.
# Set, run once, then you can blank these out.
ADMIN_USERNAME=
ADMIN_PASSWORD=
# Email (optional). If SMTP_HOST is blank, the contact endpoint tells the
# client to fall back to a mailto: link instead.
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
CONTACT_TO=UOMysticmoon@gmail.com
# Email is configured in Admin → Settings → Email (Gmail over OAuth2), not via
# env. It reuses the Google auth provider's OAuth client and stores an encrypted
# refresh token in the DB. Until it's connected, the contact form falls back to
# a mailto: link (recipient = the `contact_email` site setting).
# CORS — only needed for local dev when the Vite dev server is a different origin.
CLIENT_ORIGIN=http://localhost:5173
# Discord bot — internal API (server <-> bot/, see docker-compose.yml's `bot`
# service). BOT_INTERNAL_KEY MUST be byte-for-byte identical to the same
# variable in bot/.env.example — it is the only auth on both sides' /internal/*
# routes, so a mismatch silently breaks every server<->bot call with 401s.
# It also guards the server's /internal/bot-config route, which returns the
# DECRYPTED Discord token; with NODE_ENV=production the app REFUSES TO START if
# this is left blank, at this placeholder, or shorter than 16 chars. Generate a
# long random string. The Discord bot TOKEN itself is not an env var — it's
# entered in the admin panel (Discord Bot page) and stored encrypted in the DB.
#
# Defense in depth: even with a strong key, configure Pangolin/your reverse
# proxy to DENY /api/v1/internal (and never forward INTERNAL_PORT). The route no
# longer rides the public listener, but an explicit deny rule is belt-and-braces.
BOT_INTERNAL_URL=http://bot:4100
BOT_INTERNAL_KEY=change-me-to-a-long-random-string
# uo-link sidecar — the HTTP + WebSocket bridge to the ServUO game server. The
# website ingests its live event feed and proxies its read queries/commands
# (shard status, online players, player-vendor sales, IDOC houses, character
# sheets, account linking, town-crier). In production the sidecar + shard run on
# a DIFFERENT host from the website, so both URLs are configurable. The
# shared-secret auth token is NOT an env var — it is entered in the admin panel
# (Shard page) and stored encrypted in the DB (same pattern as the Discord bot
# token). These URLs are just defaults; the admin can override them at runtime.
UOLINK_BASE_URL=http://127.0.0.1:8080
UOLINK_WS_URL=ws://127.0.0.1:8080/ws
# Wire protocol this build speaks (3 = Protocol 3.0). Only a fallback for a site
# with nothing saved yet — the admin panel's pinned value wins — but set it lower
# if you deliberately run an older sidecar.
UOLINK_PROTOCOL=3
# ─── Push notifications (M7) — self-hosted ntfy UnifiedPush relay ───
# The `ntfy` compose service and the backend's push fan-out (opt-in notifications
# for the Android app; docs/android/PLAN.md §11).
# NTFY_BASE_URL Public URL devices reach the relay at (behind the
# reverse proxy). Used BOTH to configure the ntfy service
# AND as the backend's SSRF allow-set — a device may only
# register an endpoint whose origin matches this.
# NTFY_ALLOWED_ORIGINS Optional, comma-separated extra allowed endpoint origins
# (defaults to NTFY_BASE_URL's origin). Set only if devices
# register endpoints on a different host than NTFY_BASE_URL.
# NTFY_PUBLISH_TOKEN Optional. The content-free-tickle design needs NO token;
# set one only to require auth on backend→ntfy publishes.
# NTFY_HOST_PORT Host port the ntfy container publishes :80 on (default
# 2586). The public reverse proxy forwards the notification
# subdomain to host:NTFY_HOST_PORT — required because the
# proxy lives outside the compose network and cannot reach
# ntfy any other way. Change only on a host-port conflict.
NTFY_BASE_URL=https://ntfy.example.com
# NTFY_ALLOWED_ORIGINS=https://ntfy.example.com
# NTFY_PUBLISH_TOKEN=
# NTFY_HOST_PORT=2586

30
.env.uomysticmoon.example Normal file
View File

@@ -0,0 +1,30 @@
# ─── UOMysticmoon instance — BRAND_* / identity overrides ───
#
# Runic Gateway's first "tenant". Copy these into the deploy .env (on top of
# .env.example) to run RunicGateway/website as UOMysticmoon. This is the proof
# that branding is data, not code: the same image renders as UOMysticmoon purely
# from these vars.
#
# Only the values that differ from the Runic Gateway defaults are shown.
# Identity
BRAND_NAME=UOMysticmoon
BRAND_SHORT_NAME=Mysticmoon
BRAND_TAGLINE=an independent private Ultima Online shard
BRAND_DESCRIPTION=UOMysticmoon — an independent private Ultima Online shard. News, screenshots, guides, and community notes.
BRAND_CONTACT_EMAIL=UOMysticmoon@gmail.com
# BRAND_URL=https://<your public url>
# Visual — the existing UOM accent + hero image (baked into the image already).
BRAND_ACCENT_COLOR=#7f99bd
BRAND_HERO=/assets/img/uomysticmoon-main-hero.png
# TOTP label (defaults to BRAND_NAME, so optional — shown for clarity).
TOTP_ISSUER=UOMysticmoon
# ── Infrastructure identifiers — PIN to the existing production values so the
# ── app keeps talking to the same database and existing sessions stay valid.
# ── (These are NOT branding; they must match what production already uses.)
DB_NAME=uomysticmoon
DB_USER=uomm
COOKIE_NAME=uomm_token

View File

@@ -0,0 +1,41 @@
---
name: Bug report
about: Report something that is broken or behaving unexpectedly
title: "[bug] "
labels:
- bug
---
## Summary
<!-- A clear, concise description of the bug. -->
## Steps to reproduce
1.
2.
3.
## Expected behavior
<!-- What you expected to happen. -->
## Actual behavior
<!-- What actually happened. Include exact error messages and logs if you have them. -->
## Environment
- Component / repo:
- Version or commit:
- OS / runtime (Node, Rust, ServUO, browser…):
- Deployment (Docker Compose, local dev, bare metal…):
## Additional context
<!-- Screenshots, config (with secrets redacted), anything else that helps. -->
<!--
Security issue? Do NOT file it here. See SECURITY.md and email
whitlocktech@gmail.com instead.
-->

View File

@@ -0,0 +1,5 @@
blank_issues_enabled: true
contact_links:
- name: Security vulnerability
url: https://gitea.whitlocktech.com/RunicGateway/website/src/branch/main/SECURITY.md
about: Please do not open a public issue for security problems — report them privately by email instead (see SECURITY.md).

View File

@@ -0,0 +1,23 @@
---
name: Feature request
about: Suggest an idea, enhancement, or new capability
title: "[feature] "
labels:
- enhancement
---
## Problem / motivation
<!-- What are you trying to do? What's missing or painful today? -->
## Proposed solution
<!-- What you'd like to see happen. -->
## Alternatives considered
<!-- Other approaches you thought about, and why you prefer the one above. -->
## Additional context
<!-- Mockups, links, related issues, affected component/repo, etc. -->

View File

@@ -0,0 +1,33 @@
<!--
Thanks for contributing to Runic Gateway!
Please fill out the sections below and check every box before requesting review.
-->
## What & why
<!-- What does this PR change, and why? Link any related issue: "Closes #123". -->
## How it was tested
<!-- Commands you ran, manual steps, screenshots. -->
## Checklist
- [ ] I have read [CONTRIBUTING.md](CONTRIBUTING.md).
- [ ] The change builds and existing tests/checks pass locally.
- [ ] I have added or updated tests/docs where it makes sense.
- [ ] My commits are reasonably scoped with clear messages.
## AI-assisted contributions (required)
This project **requires disclosure of AI tool usage**. Please pick one:
- [ ] No AI tools were used to produce this contribution.
- [ ] AI tools were used. Tool(s): `___________`. I have reviewed and understand
every change, and take responsibility for it. AI-authored commits are
marked with a `Co-Authored-By` / `Assisted-By` trailer.
## License
- [ ] I agree that my contribution is licensed under this project's license
(**GNU GPL v3.0 or later**), and I have the right to contribute it.

View File

@@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""Render an ASCII tree of tracked files, read from stdin (one path per line).
Used by the `sync-project-tree` workflow to regenerate this repo's PROJECT_TREE.md
snapshot in the RunicGateway/docs repo. Feed it `git ls-files`:
git ls-files | python3 .gitea/scripts/gen_tree.py <root-label>
Deterministic ordering: directories before files, each group sorted
case-insensitively with the raw name as a tiebreak. Output uses the classic
`tree(1)` box-drawing style so the result is stable across runs and platforms.
"""
import sys
def build(paths):
root = {}
for p in paths:
p = p.strip().replace("\\", "/")
if not p:
continue
node = root
for part in p.split("/"):
node = node.setdefault(part, {})
return root
def render(node, prefix, lines):
entries = list(node.items())
# directories (non-empty children dict) before files, then case-insensitive name
entries.sort(key=lambda kv: (0 if kv[1] else 1, kv[0].lower(), kv[0]))
for i, (name, child) in enumerate(entries):
last = i == len(entries) - 1
branch = "└── " if last else "├── "
suffix = "/" if child else ""
lines.append(f"{prefix}{branch}{name}{suffix}")
if child:
render(child, prefix + (" " if last else ""), lines)
def main():
try:
sys.stdout.reconfigure(encoding="utf-8", newline="\n")
except AttributeError:
pass
root_label = sys.argv[1] if len(sys.argv) > 1 else "."
tree = build(sys.stdin.read().splitlines())
lines = [f"{root_label}/"]
render(tree, "", lines)
sys.stdout.write("\n".join(lines) + "\n")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,120 @@
# Build the app + bot container images, publish them to Gitea's container
# registry, then roll the production stack onto the fresh images — all on every
# merge to main. Production only ever pulls prebuilt images; it never builds.
#
# Two jobs run in sequence:
# build — builds & pushes website-app / website-bot images (on ubuntu-latest)
# deploy — `needs: build`, so it starts only after a clean build+push, and
# pulls + recreates the stack on the production host (on uom-deploy-runner)
#
# Prerequisites (one-time):
# • An always-on Gitea runner with label `ubuntu-latest` whose jobs have the
# host Docker socket mounted (/var/run/docker.sock), so `docker build` talks
# to the host daemon. This also gives free layer caching between runs.
# • A second self-hosted runner labelled `uom-deploy-runner` ON the production host,
# with access to the Docker daemon and to /home/perry/website (the directory
# holding the production docker-compose.yml + .env). This is what actually
# rolls the stack; it must be able to `docker compose pull` from the registry
# (log in once on the host, or ensure the images are public-read).
# • Two repo secrets (Settings → Actions → Secrets):
# REGISTRY_USER — the Gitea username that owns the token below
# REGISTRY_TOKEN — a Gitea access token with `write:package` (+ read:package)
# See the PR description / README for step-by-step token creation.
#
# Produces, in gitea.whitlocktech.com/<owner>/ :
# website-app:latest + website-app:sha-<7>
# website-bot:latest + website-bot:sha-<7>
# then deploys the `:latest` images (docker-compose.yml defaults IMAGE_TAG=latest).
name: Build container images
on:
push:
branches: [main]
workflow_dispatch: {}
concurrency:
group: images-${{ github.ref }}
cancel-in-progress: true
env:
REGISTRY: gitea.whitlocktech.com
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Check out the merged commit
uses: actions/checkout@v4
- name: Derive image refs (registry owner must be lowercase for Docker)
run: |
set -euo pipefail
OWNER="$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')"
SHORT_SHA="${GITHUB_SHA:0:7}"
echo "APP_IMAGE=${REGISTRY}/${OWNER}/website-app" >> "$GITHUB_ENV"
echo "BOT_IMAGE=${REGISTRY}/${OWNER}/website-bot" >> "$GITHUB_ENV"
echo "TAG=sha-${SHORT_SHA}" >> "$GITHUB_ENV"
- name: Verify the Docker daemon is reachable
# Fails fast with a clear message if the host socket isn't mounted into
# the job container (the one hard runner prerequisite).
run: |
set -euo pipefail
if ! docker info >/dev/null 2>&1; then
echo "::error::Docker daemon not reachable. Mount /var/run/docker.sock into the runner's job containers."
exit 1
fi
echo "Docker daemon OK"
- name: Log in to the Gitea container registry
run: |
set -euo pipefail
echo "${{ secrets.REGISTRY_TOKEN }}" \
| docker login "${REGISTRY}" -u "${{ secrets.REGISTRY_USER }}" --password-stdin
- name: Build & push the app image (server + client)
run: |
set -euo pipefail
docker build -f Dockerfile \
-t "${APP_IMAGE}:latest" \
-t "${APP_IMAGE}:${TAG}" \
.
docker push "${APP_IMAGE}:latest"
docker push "${APP_IMAGE}:${TAG}"
- name: Build & push the bot image
run: |
set -euo pipefail
docker build -f bot/Dockerfile \
-t "${BOT_IMAGE}:latest" \
-t "${BOT_IMAGE}:${TAG}" \
.
docker push "${BOT_IMAGE}:latest"
docker push "${BOT_IMAGE}:${TAG}"
- name: Log out (clear cached credentials from the runner)
if: always()
run: docker logout "${REGISTRY}" || true
deploy:
# Roll production onto the images `build` just pushed. `needs: build` makes
# this wait for a clean build+push — if the build fails, deploy never fires,
# so the running stack is left untouched rather than torn down for nothing.
needs: build
runs-on: uom-deploy-runner
# Guard against a workflow_dispatch fired from a non-main branch: only ever
# deploy the main line to production.
if: github.ref == 'refs/heads/main'
steps:
- name: Pull the fresh images and recreate the stack
# `pull` grabs the new :latest images the build job published; `down`
# then `up -d` recreates the containers on them. Compose only recreates
# services whose image digest changed, so the DB stays put.
run: |
set -euo pipefail
cd /home/perry/website
docker compose pull
docker compose down
docker compose up -d
docker compose ps

View File

@@ -0,0 +1,80 @@
# Gate every pull request into `main` on a fast, DB-free check suite so a broken
# build or failing test can't reach the deployable branch. Complements
# build-images.yml, which runs only AFTER merge (on push to main) to publish
# images — this one runs BEFORE merge.
#
# Enforcement (one-time, in the Gitea UI):
# Repository Settings → Branches → Branch Protection (rule for `main`)
# • Enable Status Check
# • Status check patterns: PR Checks / *
# Note: Gitea only lists a context in its dropdown after it has reported once,
# so let this workflow run on one PR first. The `PR Checks / *` glob matches
# without needing the dropdown.
#
# Runner: reuses the existing self-hosted `ubuntu-latest` runner. These jobs need
# only Node (no Docker socket), and the server tests stub their models + point the
# DB pool at a dead port, so no MariaDB service is required.
name: PR Checks
on:
pull_request:
branches: [main]
# A newer push to the same PR cancels the in-flight run.
concurrency:
group: pr-checks-${{ github.ref }}
cancel-in-progress: true
jobs:
server-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: server/package-lock.json
- name: Install server deps
run: npm ci --prefix server
- name: Run server tests
run: npm test --prefix server
- name: Check the route manifest is current
# The URL surface is frozen while the routers are carved up by capability
# (docs/website/API_V2_PLAN.md § Phase 2). Regenerating from the live Express
# stack and diffing proves a "mechanical" refactor moved no URL. A PR that
# really does change one has to commit the new manifest, putting it in front
# of a reviewer instead of letting it pass silently.
run: npm run routes:manifest --prefix server -- --check
client-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: client/package-lock.json
- name: Install client deps
run: npm ci --prefix client
- name: Run client tests
# Pure-logic unit tests on Node's built-in runner (no browser/DOM).
run: npm test --prefix client
- name: Build client
run: npm run build --prefix client
bot-install:
# No tests/build to run; a clean install still catches a broken or
# out-of-sync lockfile before it ships in the bot image.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: bot/package-lock.json
- name: Install bot deps
run: npm ci --prefix bot

View File

@@ -0,0 +1,87 @@
# Run SonarQube static analysis against the code that just landed on `main` and
# report the results to the self-hosted SonarQube server for review. This is
# intentionally NON-BLOCKING: it triggers on push to main (i.e. AFTER merge),
# not on pull_request, so it never gates a PR. It complements pr-checks.yml
# (which gates PRs) and build-images.yml (which ships images) — this one only
# feeds the dashboard.
#
# Prerequisites (one-time, in the Gitea UI — Repo → Settings → Actions):
# • Secret SONAR_TOKEN — a SonarQube "Analysis" token generated at
# My Account → Security in SonarQube for the
# runic-gateway-website project (or a global one).
# • Variable SONAR_HOST_URL — the SonarQube base URL on your LAN, e.g.
# http://192.168.0.56:9000
# (kept as a variable, not committed, so the internal address stays out of git.)
#
# The runner (self-hosted `ubuntu-latest`, same as the other workflows) must be
# able to reach SONAR_HOST_URL on your network. Nothing here waits on the
# SonarQube Quality Gate, so a failing gate does not fail this job — check the
# dashboard when you want to.
name: SonarQube
on:
push:
branches: [main]
# Allow re-running the analysis on demand from the Actions tab.
workflow_dispatch: {}
concurrency:
group: sonarqube-${{ github.ref }}
cancel-in-progress: true
jobs:
analysis:
runs-on: ubuntu-latest
steps:
- name: Check out (full history for accurate new-code + blame)
uses: actions/checkout@v4
with:
# SonarQube uses git history to attribute issues to authors and to
# compute "new code". A shallow clone degrades both.
fetch-depth: 0
# SonarQube runs static analysis only — it never executes the test suite,
# so we must produce a coverage report ourselves and hand it to the
# scanner (see sonar.javascript.lcov.reportPaths in sonar-project.properties).
# Node's built-in `lcov` coverage reporter needs Node >= 22.
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: server/package-lock.json
- name: Install server deps
run: npm ci --prefix server
- name: Generate server test coverage (LCOV)
# Run from the repo root (not `--prefix server`) so the LCOV `SF:` paths
# are emitted as `server/src/...`, matching sonar.sources and letting the
# scanner resolve them against the project base dir. The server tests stub
# their models and point the DB pool at a dead port, so no MariaDB is needed.
run: |
mkdir -p server/coverage
node --test --experimental-test-coverage \
--test-reporter=spec --test-reporter-destination=stdout \
--test-reporter=lcov --test-reporter-destination=server/coverage/lcov.info \
--test-reporter=./scripts/sonar-test-reporter.mjs --test-reporter-destination=server/coverage/test-execution.xml \
server/test/*.test.js
- name: Generate client test coverage (LCOV)
# The client's pure-logic modules (lib/, api/, data/) are plain ESM with no
# browser/DOM deps, so they run on the same built-in runner. Run from the
# repo root so the `SF:` paths come out as `client/src/...`. No `npm ci`:
# the tested modules import only relative files + Node built-ins.
run: |
mkdir -p client/coverage
node --test --experimental-test-coverage \
--test-reporter=spec --test-reporter-destination=stdout \
--test-reporter=lcov --test-reporter-destination=client/coverage/lcov.info \
--test-reporter=./scripts/sonar-test-reporter.mjs --test-reporter-destination=client/coverage/test-execution.xml \
client/test/*.test.js
- name: Run SonarQube scan
uses: sonarsource/sonarqube-scan-action@v4
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ vars.SONAR_HOST_URL }}

View File

@@ -0,0 +1,111 @@
name: sync-project-tree
# Keeps this repo's file-layout snapshot (docs/website/PROJECT_TREE.md in the
# RunicGateway/docs repo) current. On every push to `main` it regenerates the
# tree from tracked files and, if it changed, opens (or force-updates) a pull
# request against the docs repo. It never writes to the docs repo's `main`
# directly. Auth reuses the same REGISTRY_USER / REGISTRY_TOKEN secrets the
# other workflows use (the token needs repo read/write on RunicGateway/docs).
on:
push:
branches: [main]
workflow_dispatch: {}
concurrency:
group: sync-project-tree
cancel-in-progress: true
env:
GITEA_HOST: gitea.whitlocktech.com
DOCS_REPO: RunicGateway/docs
SELF_REPO: RunicGateway/website
DOCS_PATH: website/PROJECT_TREE.md
TREE_TITLE: Website
ROOT_LABEL: website
PR_BRANCH: chore/sync-website-tree
jobs:
sync:
runs-on: ubuntu-latest
steps:
- name: Check out this repo
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Ensure python3 is available
run: |
set -euo pipefail
command -v python3 >/dev/null 2>&1 || { sudo apt-get update -qq && sudo apt-get install -y -qq python3; }
- name: Render PROJECT_TREE.md from tracked files
run: |
set -euo pipefail
mkdir -p _sync
{
printf '# %s — Project Tree\n\n' "${TREE_TITLE}"
printf '> **Auto-generated.** This file is maintained by the `sync-project-tree` CI workflow in\n'
printf '> the [`%s`](https://%s/%s) repository, which\n' "${SELF_REPO}" "${GITEA_HOST}" "${SELF_REPO}"
printf '> opens a pull request here whenever the tracked file layout on `main` changes. Do not edit\n'
printf '> by hand — changes will be overwritten by the next sync.\n\n'
printf 'A snapshot of the tracked files in the repository (build output, dependencies, and other\n'
printf 'git-ignored paths are excluded).\n\n'
printf '```text\n'
git ls-files | python3 .gitea/scripts/gen_tree.py "${ROOT_LABEL}"
printf '```\n'
} > _sync/PROJECT_TREE.md
echo "----- generated ${DOCS_PATH} -----"
cat _sync/PROJECT_TREE.md
- name: Open or update the docs PR if the tree changed
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
# Secrets can carry a trailing CR/LF depending on how they were pasted;
# strip line breaks before they land in a URL or Authorization header.
CI_USER="$(printf '%s' "${REGISTRY_USER}" | tr -d '\r\n')"
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
API="https://${GITEA_HOST}/api/v1/repos/${DOCS_REPO}"
REMOTE="https://${CI_USER}:${CI_TOKEN}@${GITEA_HOST}/${DOCS_REPO}.git"
git clone --depth 1 "${REMOTE}" docs_repo
cd docs_repo
git config user.name "runic-docs-bot"
git config user.email "ci@whitlocktech.com"
mkdir -p "$(dirname "${DOCS_PATH}")"
cp ../_sync/PROJECT_TREE.md "${DOCS_PATH}"
git add "${DOCS_PATH}"
if git diff --cached --quiet; then
echo "PROJECT_TREE.md already up to date — nothing to sync."
exit 0
fi
SHORT_SHA="$(echo "${GITHUB_SHA:-local}" | cut -c1-7)"
git checkout -B "${PR_BRANCH}"
git commit -m "docs(tree): sync ${DOCS_PATH} from ${SELF_REPO}@${SHORT_SHA} [skip ci]"
git push --force "${REMOTE}" "HEAD:${PR_BRANCH}"
# Open a PR only if one isn't already open for this branch (a force-push
# to an existing open PR's head updates it in place).
OPEN="$(curl -sSf -H "Authorization: token ${CI_TOKEN}" \
"${API}/pulls?state=open&limit=50" \
| jq --arg b "${PR_BRANCH}" '[.[] | select(.head.ref == $b)] | length')"
if [ "${OPEN}" = "0" ]; then
curl -sSf -X POST "${API}/pulls" \
-H "Authorization: token ${CI_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(jq -n \
--arg head "${PR_BRANCH}" \
--arg base "main" \
--arg title "docs(tree): sync ${DOCS_PATH}" \
--arg body "Automated project-tree sync from [\`${SELF_REPO}\`](https://${GITEA_HOST}/${SELF_REPO}), regenerated from tracked files on \`main\`. Merge once the layout looks right; the workflow will keep this branch current until then." \
'{head: $head, base: $base, title: $title, body: $body}')" \
>/dev/null
echo "Opened a new docs PR for ${PR_BRANCH}."
else
echo "Existing open docs PR for ${PR_BRANCH} was updated via force-push."
fi

25
.gitignore vendored
View File

@@ -6,6 +6,10 @@ server/node_modules/
# build output
client/dist/
# test coverage (generated in CI for SonarQube)
server/coverage/
coverage/
# env / secrets
.env
*.env
@@ -17,6 +21,24 @@ uploads/
server/logs/
logs/
# Operator-supplied spawn atlas artwork. Creature art is never committed: sprites
# are extracted from the operator's own UO client .mul/.uop files and are theirs,
# not ours to redistribute. The images live under server/uploads/atlas/, already
# ignored above; this is the slug -> file-name map pointing at them.
# See docs/website/SPAWN_ATLAS.md and db/data/spawnAtlas.art.example.json.
server/db/data/spawnAtlas.art.json
# Operator-supplied cliloc table. UO's localization strings are EA's, extracted
# from the operator's own client and converted once (docs/website/CLILOCS.md);
# the repo ships no string table, for the same reason it ships no artwork and no
# map snapshot. This covers the conventional in-repo location — the supported
# arrangement is a path OUTSIDE the repo, set from Admin → Shard.
server/db/data/cliloc*
server/db/data/clilocs.*
# The build output of tools/cliloc-export (a throwaway helper, not a package).
server/tools/cliloc-export/bin/
server/tools/cliloc-export/obj/
# reference material (extracted from the provided archives)
_reference/
@@ -31,5 +53,8 @@ Thumbs.db
.vscode/
.idea/
# local planning docs (not part of the tracked codebase)
.plans/
# scratch / temp scripts
_*.ps1

View File

@@ -1,330 +0,0 @@
# UOMysticmoon Website — Backend Design
> Phase 1 of 3: **backend design** → Claude Design (frontend mockup) → coding.
> This document is the contract the later phases build against.
Public contact email: **UOMysticmoon@gmail.com**
---
## 1. Stack & top-level decisions
| Concern | Decision | Rationale |
|---|---|---|
| Runtime | Node.js + Express | serverlinkr pattern |
| Database | MariaDB (own container) | spec; `mariadb` pool, parameterized SQL, no ORM (keeps the lightweight `model`/`db` split from serverlinkr) |
| Auth | JWT in an **httpOnly cookie** | spec says "JWT auth" + "secure cookies when HTTPS"; httpOnly keeps the token out of JS (XSS-safe), `SameSite=Strict` covers CSRF for a same-origin admin panel |
| Frontend | React + Vite, same repo, served by Express in prod | spec |
| Hashing | bcrypt (`bcryptjs`) | spec; matches serverlinkr |
| Deploy | Docker Compose (app + db) behind Pangolin | spec |
**Adapting serverlinkr → this project**
- `*.mongo.js` (mongoose) → `*.db.js` (MariaDB queries), exactly as the spec names them.
- Drop the session/passport hybrid (`express-session`, `passport`, `passport-local`, `connect-mongo`). Pure stateless JWT instead — simpler and matches "JWT auth".
- Routes grouped by **access level** (auth / public / admin) per spec, instead of serverlinkr's per-entity routers. Models stay grouped by **entity**.
---
## 2. Folder structure
Skeleton from the spec, with a small number of justified additions marked **(+)**.
```
server/
.env.example
package.json
db/
schema.sql (+) DDL, also auto-run by the MariaDB container
seed.js (+) seed wiki pages, default settings, first admin
src/
server.js bootstrap: ensure schema, then listen on 0.0.0.0
app.js express app + middleware wiring
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
model/
users/ users.model.js + users.db.js
posts/ posts.model.js + posts.db.js (news/five-on-friday/newsletter/screenshots)
wiki/ wiki.model.js + wiki.db.js
settings/ settings.model.js + settings.db.js
activity/ activity.model.js + activity.db.js (+) admin activity log
middleware/ (+)
siteMode.js LIVE/MAINTENANCE gate for public content
noindex.js X-Robots-Tag: noindex,nofollow on admin
rateLimit.js login limiter
validate.js express-validator error handler
utils/
auth.js JWT sign/verify, isLoggedIn middleware
db.js MariaDB pool + ensureSchema()
mailer.js (+) nodemailer; mailto fallback if SMTP unset
client/ built in Phase 2/3 (React + Vite)
Dockerfile
docker-compose.yml
.env.example
.gitignore
```
**Why the additions:** the spec's feature list requires an activity log, a maintenance-mode
gate, login rate limiting, admin `noindex`, and SMTP email — none fit cleanly in the four
listed models/two utils. They're isolated in `middleware/` + one `activity` model +
`utils/mailer.js`, and the spec explicitly says the layout is "expandable."
---
## 3. Database schema (MariaDB)
`utf8mb4` throughout. Created idempotently on boot (`ensureSchema()`) **and** shipped as
`db/schema.sql` for the container's `/docker-entrypoint-initdb.d`.
### users
| col | type | notes |
|---|---|---|
| id | INT PK AUTO_INCREMENT | |
| username | VARCHAR(32) UNIQUE NOT NULL | |
| password_hash | VARCHAR(72) NOT NULL | bcrypt; **never** returned by the API |
| role | ENUM('admin','editor') NOT NULL DEFAULT 'admin' | room to grow |
| created_at | DATETIME DEFAULT CURRENT_TIMESTAMP | |
| last_login_at | DATETIME NULL | shown in user management |
### posts — one table, four categories
| col | type | notes |
|---|---|---|
| id | INT PK AUTO_INCREMENT | |
| category | ENUM('news','five_on_friday','newsletter','screenshot') NOT NULL | |
| title | VARCHAR(200) NOT NULL | |
| slug | VARCHAR(220) NULL | optional clean URL |
| excerpt | VARCHAR(400) NULL | list teaser |
| body | MEDIUMTEXT NULL | markdown/HTML; main text for news/5oF/newsletter |
| image_url | VARCHAR(500) NULL | required for `screenshot`, optional hero elsewhere |
| published | TINYINT(1) NOT NULL DEFAULT 0 | publish/unpublish toggle |
| author_id | INT NULL FK→users(id) | ON DELETE SET NULL |
| created_at | DATETIME DEFAULT CURRENT_TIMESTAMP | |
| updated_at | DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | |
| published_at | DATETIME NULL | set when first published; list order |
Index: `(category, published, published_at DESC)`.
### wiki_pages
| col | type | notes |
|---|---|---|
| id | INT PK AUTO_INCREMENT | |
| slug | VARCHAR(120) UNIQUE NOT NULL | e.g. `new-player-guide` |
| title | VARCHAR(200) NOT NULL | |
| body | MEDIUMTEXT NULL | markdown/HTML |
| updated_by | INT NULL FK→users(id) | |
| created_at / updated_at | DATETIME | |
Seeded with the 8 spec categories: `new-player-guide, maps-atlas, systems, items, monsters, crafting, lore, rules`.
### settings — key/value, expandable
| col | type | notes |
|---|---|---|
| `key` | VARCHAR(64) PK | |
| value | TEXT NULL | |
| updated_by | INT NULL FK→users(id) | |
| updated_at | DATETIME ON UPDATE CURRENT_TIMESTAMP | |
Seeded keys: `site_mode` (default `maintenance`), `site_mode_changed_at`,
`site_mode_changed_by`, `maintenance_message`, `status_message`, `homepage_teaser`,
`contact_email` (=UOMysticmoon@gmail.com), `site_title`.
### activity_log — append-only
| col | type | notes |
|---|---|---|
| id | INT PK AUTO_INCREMENT | |
| user_id | INT NULL FK→users(id) | |
| action | VARCHAR(64) NOT NULL | e.g. `auth.login`, `site_mode.change`, `post.create` |
| detail | TEXT NULL | JSON string of what changed |
| ip | VARCHAR(45) NULL | from `req.ip` (needs `trust proxy`) |
| created_at | DATETIME DEFAULT CURRENT_TIMESTAMP | |
---
## 4. API contract
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)
| 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 |
No public `register`. First admin is bootstrapped by `seed.js` from env (see §6). Further
admins are created under `/admin/users`.
### /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 | `/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}` |
Public content GETs pass through the **siteMode** gate (§5).
### /admin (admin.routes.js → admin.controller.js) — all behind `isLoggedIn` + `noindex`
| Method | Path | Purpose |
|---|---|---|
| GET | `/dashboard` | current mode, last change time + who, content counts, recent activity |
| PUT | `/site-mode` | `{mode}` → update settings, stamp who/when, log `site_mode.change` |
| GET | `/posts?category=` | all posts incl. unpublished |
| POST | `/posts` | create |
| GET | `/posts/:id` | one |
| PUT | `/posts/:id` | edit |
| DELETE | `/posts/:id` | delete |
| PATCH | `/posts/:id/publish` | `{published}` toggle (sets `published_at`) |
| POST | `/posts/upload` | multipart image upload (multer) → `{image_url}` for screenshots |
| GET | `/wiki` · GET `/wiki/:slug` | read incl. unpublished |
| POST | `/wiki` · PUT `/wiki/:slug` · DELETE `/wiki/:slug` | manage pages |
| 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) |
Every admin write logs to `activity_log`.
---
## 5. Site mode (LIVE / MAINTENANCE)
State in `settings.site_mode` (`live`|`maintenance`), default **maintenance**.
`middleware/siteMode.js`, applied only to **public content** routes:
- `live` → pass through.
- `maintenance` → respond **503** with `{mode:"maintenance", message}` **unless** the request
carries a valid admin cookie (admin preview). This hides content server-side, not just in
the UI.
Always reachable regardless of mode: static assets / SPA shell, `/api/v1/auth/*`, all
`/api/v1/admin/*`. So admin login + panel + the maintenance "coming soon" page always load.
**Client behavior (Phase 3):** reads `GET /public/settings`; if `maintenance` and not an
admin previewing, render the polished dark coming-soon page (message + contact email).
Admin "preview live" simply hits the content APIs with the admin cookie, which bypass the gate.
Dashboard reads `site_mode` + `site_mode_changed_at`/`_by` for "current mode + last change +
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.
- **bcrypt** hashing (cost 10+); plaintext passwords never stored, logged, or returned.
- **Rate limiting** (`express-rate-limit`) on `/auth/login` and `/public/contact`.
- **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).
- **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`.
---
## 7. Email
`utils/mailer.js` (nodemailer) configured from `SMTP_HOST/PORT/USER/PASS`, sending to
`CONTACT_TO` (default UOMysticmoon@gmail.com). No Gmail password in code — env only.
If SMTP is unconfigured, `POST /public/contact` returns `{fallback:"mailto", email}` so the
client renders a `mailto:` link instead. Site mode changes / errors never leak SMTP creds.
---
## 7.5 Logging & observability
`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]`, …).
- **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`,
`/app/logs/app.log` in Docker, bind-mounted to `./logs`); verbosity = `FILE_LOG_LEVEL`
(default `debug`, so the file keeps a complete record while the console stays readable).
Toggle with `LOG_TO_FILE`. The stream is flushed on graceful shutdown.
- **HTTP access logs** via morgan piped into the logger: real client IP (`trust proxy`),
authenticated admin username, method, URL, status, response time, size.
- **Captured events**: startup config banner, schema/seed steps, login success/failure,
rate-limit hits, site-mode changes, maintenance-gate blocks (debug), all errors with
stack traces (5xx), and SIGINT/SIGTERM shutdown. Passwords and request bodies are never
logged. `unhandledRejection`/`uncaughtException` are caught and logged.
## 8. Deployment
**docker-compose.yml** — two services on a private network:
- `db`: `mariadb:11`, env `MARIADB_DATABASE/USER/PASSWORD/ROOT_PASSWORD`, volume
`dbdata:/var/lib/mysql`, mounts `schema.sql` into `/docker-entrypoint-initdb.d`, healthcheck.
- `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`.
Express listens on `0.0.0.0:${PORT||3000}`. Pangolin terminates TLS and proxies to `app`.
**.env.example** (committed; real `.env` ignored):
```
NODE_ENV=production
PORT=3000
DB_HOST=db
DB_PORT=3306
DB_NAME=uomysticmoon
DB_USER=uomm
DB_PASSWORD=
DB_ROOT_PASSWORD=
JWT_SECRET=
JWT_EXPIRES_IN=1d
COOKIE_SECURE=true
COOKIE_NAME=uomm_token
ADMIN_USERNAME=
ADMIN_PASSWORD=
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
CONTACT_TO=UOMysticmoon@gmail.com
CLIENT_ORIGIN=http://localhost:5173
```
`.gitignore`: `node_modules/`, `.env`, `_reference/`, `client/dist/`, `uploads/`.
---
## 9. Dependencies (server)
`express, cors, helmet, morgan, dotenv, mariadb, jsonwebtoken, bcryptjs, cookie-parser,
express-rate-limit, express-validator, multer, nodemailer` · dev: `nodemon`.
Removed vs serverlinkr: `mongoose, mongodb, connect-mongo, express-session, passport,
passport-local`.
---
## 10. Spec coverage
| Spec requirement | Covered by |
|---|---|
| Public pages (`/`, `/site/*`, `/wiki/*`) | `/public/*` API + Phase-3 SPA routes; content from `posts`/`wiki`/`settings` |
| News / 5-on-Friday / Newsletter / Screenshots | `posts` table, `category` column; admin CRUD + publish |
| Wiki 8 categories, editable later | `wiki_pages` seeded with 8 slugs; admin CRUD |
| Status page | `settings.status_message` + mode via `/public/status` |
| Admin dashboard (mode, last change, who) | `/admin/dashboard` + settings stamps + activity log |
| Site mode toggle | `PUT /admin/site-mode` + `siteMode` middleware |
| Admin activity log | `activity_log` + `/admin/activity` |
| Admin user management | `/admin/users` CRUD |
| Site settings editing | `/admin/settings` |
| JWT, bcrypt, rate limit, secure cookies, noindex, no dir browsing, no hardcoded creds, .env | §6 |
| Maintenance page, admin always in, static always loads, admin preview | §5 |
| SMTP via env, mailto fallback | §7 |
| Docker Compose + MariaDB + Pangolin, 0.0.0.0 bind | §8 |
| Design tokens / hero | reused from existing `assets/css/mysticmoon.css` + hero PNG in Phase 2/3 |
| Expandable | key/value settings, role enum, modular routers/models |
```

133
CODE_OF_CONDUCT.md Normal file
View File

@@ -0,0 +1,133 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, caste, color, religion, or sexual
identity and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the overall
community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or advances of
any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email address,
without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official email address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
**whitlocktech@gmail.com**.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series of
actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or permanent
ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within the
community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.1, available at
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
Community Impact Guidelines were inspired by
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
For answers to common questions about this code of conduct, see the FAQ at
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
[https://www.contributor-covenant.org/translations][translations].
[homepage]: https://www.contributor-covenant.org
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
[Mozilla CoC]: https://github.com/mozilla/diversity
[FAQ]: https://www.contributor-covenant.org/faq
[translations]: https://www.contributor-covenant.org/translations

100
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,100 @@
# Contributing to Runic Gateway — Website
Thanks for your interest in contributing! This repo is the full-stack website
(Node.js + Express API, MariaDB, React + Vite SPA). This guide covers how to get
set up, the workflow we follow, and the rules for contributions.
By participating you agree to abide by our
[Code of Conduct](CODE_OF_CONDUCT.md).
## Ways to contribute
- **Report a bug** or **request a feature** through the
[issue tracker](https://gitea.whitlocktech.com/RunicGateway/website/issues)
(issue templates are provided).
- **Improve the code or docs** by opening a pull request (see below).
- **Never** report a security vulnerability in a public issue — see
[SECURITY.md](SECURITY.md).
## Development setup
**Prerequisites:** Node.js 20+ and npm, plus Docker (for MariaDB).
The [README](README.md) has the full setup guide. The short version for local
development with hot reload:
```bash
# 1. Start a MariaDB the backend can reach
docker run -d --name rg-db -p 3306:3306 \
-e MARIADB_DATABASE=runic_gateway -e MARIADB_USER=runic \
-e MARIADB_PASSWORD=devpass -e MARIADB_ROOT_PASSWORD=rootpass mariadb:11
# 2. Backend (terminal 1)
cp server/.env.example server/.env # set DB_* , JWT_SECRET, ADMIN_USERNAME/PASSWORD
npm run install-all
npm run server # nodemon -> http://localhost:3000
# 3. Frontend (terminal 2)
npm run client # Vite -> http://localhost:5173
```
Develop against **http://localhost:5173** (the Vite dev server proxies `/api`).
### Tests & checks
Please run the server test suite and make sure the client builds before opening
a PR — these are the same checks CI runs on your PR:
```bash
npm test # server tests
npm run build # client production build
```
If you add or change an API route, regenerate the Swagger spec
(`cd server && npm run swagger`) and commit the updated
`server/swagger/swagger-output.json`.
The URL surface is also frozen by a generated manifest. If your change adds,
removes or renames a route, regenerate it (`cd server && npm run routes:manifest`)
and commit `server/routes.manifest.json` + `server/routes.guards.json` — CI fails
otherwise. A non-empty diff in `routes.manifest.json` means you changed the API
contract, so call it out in the PR description; a pure refactor must produce none.
## Branch & PR workflow
1. Fork or branch from `main`. Use a descriptive branch name
(`feature/…`, `fix/…`, `docs/…`, `chore/…`).
2. Keep changes focused; small PRs are easier to review.
3. Push and open a pull request against `main`. Fill out the PR template,
including the **AI-assisted contributions** disclosure.
4. Make sure PR checks (server tests + client build) are green.
5. A maintainer will review; address feedback by pushing follow-up commits.
### Commit messages
We use [Conventional Commits](https://www.conventionalcommits.org/) —
`type(scope): summary` (e.g. `feat(auth): add TOTP challenge step`,
`fix(brand): link footer badge to Gitea org`). Common types: `feat`, `fix`,
`docs`, `chore`, `refactor`, `test`, `ci`.
## AI-assisted contributions (disclosure required)
This project is developed openly with AI assistance, and we ask the same
transparency of everyone. **If you used an AI tool** (Claude, Copilot, ChatGPT,
Cursor, etc.) to help produce a contribution, you must disclose it:
- Tick the AI-usage box in the pull-request template and name the tool(s).
- Mark AI-authored commits with a trailer, e.g.
`Co-Authored-By: Claude <noreply@anthropic.com>` or `Assisted-By: <tool>`.
- You remain responsible for every line you submit: review it, understand it,
and make sure it is correct and that you have the right to contribute it.
Disclosed AI assistance is welcome. Undisclosed AI-generated contributions are
not, and may be closed.
## License
Runic Gateway is licensed under the **GNU General Public License v3.0 or later**
(see [LICENSE.md](LICENSE.md)). By submitting a contribution you agree that it is
licensed under the same terms (inbound = outbound) and that you have the right to
contribute it.

31
CONTRIBUTORS.md Normal file
View File

@@ -0,0 +1,31 @@
# Contributors
Runic Gateway is built and maintained by the people and tools listed here.
Thank you to everyone who has contributed.
## Maintainers
- **whitlocktech** &lt;whitlocktech@gmail.com&gt; — project lead and maintainer
## Contributors
<!--
Add yourself here when your contribution is merged — alphabetical by name or
handle. One line each:
- **Name or handle** (optional link) — what you contributed
-->
- _Your name could be here — see [CONTRIBUTING.md](CONTRIBUTING.md)._
## AI-assisted development
Parts of Runic Gateway were developed with the assistance of AI coding tools,
including **Claude** (Anthropic) via Claude Code. AI-assisted commits are
attributed in their commit trailers (e.g. `Co-Authored-By: Claude ...`).
In keeping with this project's transparency policy, **all contributors must
disclose their use of AI tools** on any contribution — see the
"AI-assisted contributions" section of [CONTRIBUTING.md](CONTRIBUTING.md).
Disclosed AI assistance is welcome; undisclosed AI-generated contributions are
not.

View File

@@ -1,134 +0,0 @@
# UOMysticmoon — Hero Canvas Editor Spec
> Branch: **`hero-feature`**. Build contract for the WYSIWYG portal-hero editor.
> Derived from the design doc *Hero Canvas Editor — Design Document*, **corrected
> to match the current codebase** and with the open questions resolved.
> Same workflow as the wiki upgrade: design → phased build → verify.
## 1. Goal
Let staff compose the portal hero (background image, overlay opacity, and floating
elements — text, CTA buttons, moon, badge, image) in-browser, then preview and
publish — no source edits. Layout persists as JSON in the existing `settings` table.
## 2. Locked decisions
| # | Decision |
|---|---|
| Scope | **Full v1** — background/overlay, all element types, drag/resize/z-order, draft→preview→publish (built in phases) |
| CTA buttons | **First-class `buttons` element type** (independently positioned), not baked into a text block |
| First run | **Pre-populate** the canvas with today's hero (headline, subtitle, teaser, CTAs) as editable elements so nothing changes visually until edited |
| Drag | **Native Pointer Events** (mouse/touch/pen), zero dependencies |
| Font size | Stored in **px** (fixed reference canvas) |
| Image compression | **None** server-side; client warns when a file is > ~1 MB |
| Preview | `?preview=1` renders the **draft** by reading it through the authenticated admin settings endpoint |
| Other pages | Out of scope for v1 (design allows a per-page key later) |
## 3. Corrections to the design doc (current-code reality)
1. **Public settings is a whitelist, not `getAll()`.** `GET /api/v1/public/settings`
`settings.getPublic()``PUBLIC_KEYS` in
[settings.model.js](server/src/model/settings/settings.model.js). The doc's
"no backend changes / picked up automatically" is wrong. **Fix:** add
`hero_layout` to `PUBLIC_KEYS` (one line). `hero_layout_draft` stays out
(admin-only) — which is why preview reads the draft via `api.admin.getSettings()`.
2. **Moon is a reusable component** ([MoonDot.jsx](client/src/components/MoonDot.jsx),
props `size`/`glow`), used in logo/login/maintenance — not "only the header."
The `moon` element reuses it; it gains an optional `color`.
3. **Route vs. nav live in different files.** `/admin/hero` route →
[App.jsx](client/src/App.jsx); sidebar link/title → `NAV`/`TITLES` in
[AdminLayout.jsx](client/src/routes/admin/AdminLayout.jsx).
4. **Admin content area is `maxWidth: 1000px`** — the editor canvas renders
scaled-to-fit; percentage positions stay faithful.
Everything else in the doc matches (hardcoded `HERO_BG` + CTAs + `homepage_teaser`
in [Portal.jsx](client/src/routes/public/Portal.jsx); `updateSettings` accepts
arbitrary keys; `/admin/uploads` exists; default hero asset present; TEXT settings
columns — no schema change).
## 4. Data model — no schema change
Two `settings` keys (TEXT): `hero_layout` (live) and `hero_layout_draft` (admin).
```jsonc
{
"version": 1,
"background": { "image_url": null, "position_x": "left", "position_y": "center", "size": "cover" },
"overlay": { "opacity": 0.72 },
"elements": [
{ "id": "uuid", "type": "text_block|buttons|moon|badge|image",
"x": 50, "y": 42, "z": 1, "anchor": "center", "props": { /* per type */ } }
]
}
```
Positions are **% of canvas** (reference width 1080, matching `.shell`), so the
layout adapts across viewports without breakpoint data. `version` is validated
(`=== 1`) before use; anything else falls back.
### Element props
| Type | Props |
|---|---|
| `text_block` | `lines: [{ text, tag(h1/h2/p/span), fontSize(px), color, weight }]`, `align` |
| `buttons` | `items: [{ label, to, variant(primary/ghost) }]`, `align`, `gap` |
| `moon` | `size`, `glow`, `color` |
| `badge` | `text`, `bgColor`, `textColor`, `borderRadius` |
| `image` | `src`, `width`(%), `alt` |
## 5. Backend changes
- **One line:** add `'hero_layout'` to `PUBLIC_KEYS`. No new routes/controllers —
layout saves through the existing `PUT /admin/settings`; images via `/admin/uploads`.
## 6. Frontend changes
- **New** `client/src/components/HeroElement.jsx` — renders one element by type
(shared by the live portal and the editor canvas).
- **New** `client/src/routes/admin/views/HeroEditor.jsx` — canvas + element tray +
properties panel; native-pointer drag/resize; background/overlay panel; snap grid;
auto-save draft, preview, publish, revert.
- **Edit** [Portal.jsx](client/src/routes/public/Portal.jsx) — parse `hero_layout`
(or draft when `?preview=1` + admin), render elements, fall back to a
`DEFAULT_LAYOUT` built from today's hero so the page is unchanged until edited.
- **Edit** [AdminLayout.jsx](client/src/routes/admin/AdminLayout.jsx) (nav) +
[App.jsx](client/src/App.jsx) (route `/admin/hero`).
- **Edit** [MoonDot.jsx](client/src/components/MoonDot.jsx) — optional `color`.
- **No** `client/src/api/client.js` changes needed beyond what exists
(`admin.updateSettings`, `admin.getSettings`, `admin.upload`).
## 7. Phased build (each phase: build → verify in preview → commit)
- **Phase 0 — Spec** ✅ this document.
- **Phase 1 — Data path & renderer** ✅ (verified 2026-06-28). `hero_layout`
whitelisted; `HeroElement.jsx`; Portal renders the layout with a `DEFAULT_LAYOUT`
fallback. Default render matches the old hero; publishing a layout re-renders;
draft key not exposed publicly. Shared helpers moved to `client/src/lib/heroLayout.js`.
- **Phase 2 — Editor shell + background/overlay** ✅ (verified 2026-06-28).
`/admin/hero` view + sidebar nav; canvas live-preview; background upload + 3×3
position + overlay opacity; debounced draft auto-save; publish; `?preview=1`
reads the draft (admin) with a banner; revert. Verified: overlay/position update
the canvas, auto-save writes the draft, publish writes live, preview shows the
draft while the normal portal shows live.
- **Phase 3 — Elements: select / drag / text_block / buttons** ✅ (verified
2026-06-28). Element tray (+ Text / + Buttons); click-to-select with outline;
native Pointer Events drag (% of canvas); Delete key + panel delete; z-order
(send back / bring forward); text_block line editor (text/tag/size/color/bold,
add/remove lines, align) and buttons editor (label/path/variant, add/remove).
Verified: select shows the line editor, editing a line updates the canvas live,
drag moved 50%→65%, add→3/delete→2 elements, empty-canvas click deselects.
- **Phase 4 — moon + badge + image + resize + snap grid** ✅ (verified 2026-06-28).
Tray adds moon/badge/image; property panels (moon: size/glow/color; badge:
text/colors/radius; image: upload/width/alt); corner resize handle (image→width%,
moon→size, text→box width); 8px snap-grid toggle with overlay; image placeholder
until a file is chosen. Verified: each type adds + edits, resize moved a moon
64→104px, snap grid shows, and a published moon+badge render on the live portal.
**Status: v1 feature-complete.** All phases verified end-to-end; ready for PR.
Deferred (noted in the design doc as follow-ups): 8-point resize (only a corner
handle for now), per-viewport layouts, server-side image compression.
## 8. Edge cases (from the doc, carried forward)
- `JSON.parse` wrapped in try/catch + `version` check → fall back to `DEFAULT_LAYOUT`.
- Element ids via `crypto.randomUUID()` (never array index).
- Empty `elements` → render `DEFAULT_LAYOUT` so the hero is never blank.
- Last-write-wins on concurrent admin edits (acceptable for this shard).
- Client-side warning for background files > ~1 MB (no hard block; 8 MB server cap).

674
LICENSE.md Normal file
View File

@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.

497
README.md
View File

@@ -1,18 +1,31 @@
# UOMysticmoon Website
# Runic Gateway Website
Public site, wiki, and protected admin panel for the **UOMysticmoon** private Ultima Online
shard — a full-stack app in one repo:
[![Bugs](https://sonar.whitlocktech.com/api/project_badges/measure?project=runic-gateway-website&metric=bugs&token=sqb_d3593f26ac5663cd3e666039b7038f3248e8df50)](https://sonar.whitlocktech.com/dashboard?id=runic-gateway-website)
[![Code Smells](https://sonar.whitlocktech.com/api/project_badges/measure?project=runic-gateway-website&metric=code_smells&token=sqb_d3593f26ac5663cd3e666039b7038f3248e8df50)](https://sonar.whitlocktech.com/dashboard?id=runic-gateway-website)
[![Duplicated Lines (%)](https://sonar.whitlocktech.com/api/project_badges/measure?project=runic-gateway-website&metric=duplicated_lines_density&token=sqb_d3593f26ac5663cd3e666039b7038f3248e8df50)](https://sonar.whitlocktech.com/dashboard?id=runic-gateway-website)
[![Lines of Code](https://sonar.whitlocktech.com/api/project_badges/measure?project=runic-gateway-website&metric=ncloc&token=sqb_d3593f26ac5663cd3e666039b7038f3248e8df50)](https://sonar.whitlocktech.com/dashboard?id=runic-gateway-website)
[![Security Hotspots](https://sonar.whitlocktech.com/api/project_badges/measure?project=runic-gateway-website&metric=security_hotspots&token=sqb_d3593f26ac5663cd3e666039b7038f3248e8df50)](https://sonar.whitlocktech.com/dashboard?id=runic-gateway-website)
[![Security Rating](https://sonar.whitlocktech.com/api/project_badges/measure?project=runic-gateway-website&metric=security_rating&token=sqb_d3593f26ac5663cd3e666039b7038f3248e8df50)](https://sonar.whitlocktech.com/dashboard?id=runic-gateway-website)
[![Vulnerabilities](https://sonar.whitlocktech.com/api/project_badges/measure?project=runic-gateway-website&metric=vulnerabilities&token=sqb_d3593f26ac5663cd3e666039b7038f3248e8df50)](https://sonar.whitlocktech.com/dashboard?id=runic-gateway-website)
- **Backend** — Node.js + Express REST API (layered `router → controller → model → db`), MariaDB, JWT-in-cookie auth.
Public site, wiki, and protected admin panel for a private Ultima Online shard — a
full-stack app in one repo. Branding is instance-configurable via `BRAND_*` (see
[Branding](#branding)); **UOMysticmoon** is the first instance.
A full-stack app in one repo:
- **Backend** — Node.js + Express REST API (layered `router → controller → model → db`), MariaDB, a provider-agnostic session layer (JWT cookie for web, bearer tokens for mobile, pluggable SSO).
- **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.
- **Deploy** — Docker Compose (app + MariaDB) behind a reverse proxy (Pangolin, Nginx, Caddy, Traefik, …). 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).
The design reference is [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (API contract, schema, security).
The design reference is [BACKEND_DESIGN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/BACKEND_DESIGN.md) (API contract, schema, security), in the [**RunicGateway/docs**](https://gitea.whitlocktech.com/RunicGateway/docs) repo — where all project documentation now lives.
---
## Contents
- [Architecture](#architecture)
- [Tech stack](#tech-stack)
- [Project structure](#project-structure)
- [Prerequisites](#prerequisites)
@@ -23,10 +36,108 @@ The design reference is [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (API contract, sc
- [First admin & site mode](#first-admin--site-mode)
- [Pages & routes](#pages--routes)
- [API endpoints](#api-endpoints)
- [API documentation (Swagger)](#api-documentation-swagger)
- [Shard integration (uo-link)](#shard-integration-uo-link)
- [Environment variables](#environment-variables)
- [Security](#security)
- [Logging](#logging)
- [Deployment behind Pangolin](#deployment-behind-pangolin)
- [Deployment behind a reverse proxy](#deployment-behind-a-reverse-proxy)
---
## 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.
```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.
---
@@ -35,34 +146,37 @@ The design reference is [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (API contract, sc
| Layer | Tech |
|---|---|
| Backend | Node.js 20+, Express 4, `mariadb` driver (parameterized SQL, no ORM) |
| Auth | JWT in an httpOnly cookie, bcrypt password hashing |
| Auth | Session service over JWT: httpOnly cookie (web) + bearer access/refresh tokens (mobile), bcrypt hashing, optional TOTP 2FA (`speakeasy` + `qrcode`), pluggable OAuth2/OIDC SSO (built-in Google & Discord + generic) |
| Database | MariaDB 11 (own container) |
| Frontend | React 18, Vite 5, React Router 6 |
| Email | Nodemailer (SMTP) with a `mailto:` fallback |
| Deploy | Docker Compose, Pangolin reverse proxy |
| Email | Nodemailer via Gmail OAuth2 (configured in admin), with a `mailto:` fallback |
| API docs | OpenAPI 3.0 via `swagger-autogen`, served with `swagger-ui-express` at `/api/docs` |
| Deploy | Docker Compose, any reverse proxy (Pangolin, Nginx, Caddy, Traefik, …) |
---
## Project structure
```
UOMSITE/
website/
├─ server/ Express API
│ ├─ src/
│ │ ├─ server.js bootstrap: ensure schema → seed → listen (0.0.0.0)
│ │ ├─ app.js middleware + static SPA + routes
│ │ ├─ router/v1/ auth / public / admin route groups
│ │ ├─ model/ users · posts · wiki · settings · activity (.model + .db)
│ │ ├─ middleware/ siteMode · noindex · rateLimit · validate
│ │ utils/ auth (JWT/cookies) · db (pool) · mailer · logger
│ │ ├─ auth/ session layer: session.service · token (JWT/cookies) · session.middleware · ssoState (PKCE/CSRF) · providers/ (base · oauth2 · google · discord · genericOidc · registry)
│ │ ├─ router/v1/ auth (web · mobile · sso) / public / admin route groups
│ │ ├─ model/ users · posts · wiki · settings · activity · mobileSessions · authProviders · userIdentities (.model + .db)
│ │ middleware/ siteMode · noindex · rateLimit · loginProtection · botScore · validate
│ │ └─ utils/ auth (compat facade) · totp (2FA) · secretBox (AES-GCM secrets) · db (pool) · mailer · logger
│ ├─ db/ schema.sql + seed.js
│ ├─ swagger/ swagger.js (OpenAPI generator config) + swagger-output.json (generated spec)
│ └─ .env.example
├─ client/ React + Vite SPA
│ ├─ src/
│ │ ├─ routes/public/ Portal, Website, News, Screenshots, FiveOnFriday, Newsletter(+Issue), Status, About, Maintenance
│ │ ├─ routes/wiki/ Wiki landing + WikiArticle
│ │ ├─ routes/admin/ AdminLogin, AdminLayout, views/ (Dashboard, Posts, Wiki, Settings, Activity, Users) + editors
│ │ ├─ components/ SiteHeader, SiteFooter, layout, guards, Modal, …
│ │ ├─ routes/admin/ AdminLogin (password + TOTP + SSO buttons), AdminLayout, views/ (Dashboard, Posts, Wiki, Settings, Activity, Bot Activity, Authentication, Users, Account) + editors
│ │ ├─ components/ SiteHeader, SiteFooter, layout, guards, Modal, ProviderIcon (inline SSO SVGs),
│ │ ├─ contexts/ AuthContext, SiteContext
│ │ ├─ api/client.js fetch wrapper (sends cookies)
│ │ └─ styles/theme.css design tokens
@@ -86,8 +200,10 @@ UOMSITE/
### Option A — Docker Compose (full stack)
The simplest way to run everything. The image installs server deps, **builds the React client**,
and Express serves it; MariaDB runs in its own container; tables + defaults + the first admin are
`docker-compose.yml` is **production-shaped**: it *pulls* the prebuilt `app` and `bot` images from
the Gitea container registry (published by `.gitea/workflows/build-images.yml` on every merge to
`main`) — it never builds. Each image already bundles the server deps and the built React client,
which Express serves. MariaDB runs in its own container; tables + defaults + the first admin are
created automatically on first boot.
```bash
@@ -97,7 +213,9 @@ cp .env.example .env
# JWT_SECRET (a long random string)
# ADMIN_USERNAME, ADMIN_PASSWORD (your first admin login)
docker compose up -d --build
docker compose pull && docker compose up -d # IMAGE_TAG defaults to `latest`
# pin a specific build (reproducible deploy / rollback):
IMAGE_TAG=sha-042a151 docker compose pull && docker compose up -d
```
- App: **http://localhost:3000** (binds `0.0.0.0`)
@@ -105,6 +223,16 @@ docker compose up -d --build
- Logs: `docker compose logs -f app` (and `./logs/app.log` on the host)
- Stop: `docker compose down` (add `-v` to also wipe the database + uploads volumes)
**Build the images locally instead of pulling** (offline, or to test an unmerged change) — overlay
the dev file, which adds `build:` back:
```bash
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
```
Keeping `build:` out of the base file means a production host can only ever pull — it can never
accidentally build.
### Option B — Local development (hot reload)
Run the API and the Vite dev server separately. The Vite server proxies `/api` and `/uploads`
@@ -113,14 +241,14 @@ to the backend, so the SPA stays same-origin (cookies work).
**1. Start a MariaDB the backend can reach** (published on `localhost:3306`):
```bash
docker run -d --name uomm-db -p 3306:3306 -e MARIADB_DATABASE=uomysticmoon -e MARIADB_USER=uomm -e MARIADB_PASSWORD=devpass -e MARIADB_ROOT_PASSWORD=rootpass mariadb:11
docker run -d --name rg-db -p 3306:3306 -e MARIADB_DATABASE=runic_gateway -e MARIADB_USER=runic -e MARIADB_PASSWORD=devpass -e MARIADB_ROOT_PASSWORD=rootpass mariadb:11
```
**2. Configure + start the backend** (terminal 1):
```bash
cp server/.env.example server/.env
# Set DB_HOST=127.0.0.1, DB_PORT=3306, DB_USER=uomm, DB_PASSWORD=devpass,
# Set DB_HOST=127.0.0.1, DB_PORT=3306, DB_USER=runic, DB_PASSWORD=devpass,
# JWT_SECRET=<anything>, ADMIN_USERNAME=admin, ADMIN_PASSWORD=<your password>
npm run install-server
npm run server # nodemon → http://localhost:3000
@@ -187,7 +315,10 @@ npm start # node server → serves API + SPA at http://localhost:3
| `/admin/wiki` | Wiki pages CRUD |
| `/admin/settings` | Site settings |
| `/admin/activity` | Activity log |
| `/admin/bot-activity` | Bot activity — banned IPs + recent scoring events, emergency unban (admin only) |
| `/admin/auth-providers` | Authentication — enable/configure SSO providers: built-in Google & Discord + custom OIDC/OAuth2 (admin only) |
| `/admin/users` | User management |
| `/admin/account` | Account security (self-service TOTP two-factor + linked SSO accounts) |
---
@@ -195,12 +326,152 @@ npm start # node server → serves API + SPA at http://localhost:3
| Group | Base | Auth |
|---|---|---|
| Auth | `/api/v1/auth` (`login`, `logout`, `me`) | cookie |
| Auth (web) | `/api/v1/auth` (`login`, `login/totp`, `logout`, `me`) | cookie |
| Auth (mobile) | `/api/v1/auth/mobile` (`login`, `refresh`, `logout`) | bearer (access + refresh tokens) |
| SSO | `/api/v1/auth` (`providers` — public discovery; `sso/:provider/start`, `sso/:provider/link`, `sso/:provider/callback`) | redirect flow |
| Public | `/api/v1/public` (`settings`, `status`, `posts/:category`, `posts/:category/:idOrSlug`, `wiki`, `wiki/:slug`, `contact`) | none |
| Admin | `/api/v1/admin` (`dashboard`, `site-mode`, `posts`, `posts/upload`, `wiki`, `settings`, `activity`, `users`) | cookie (admin) |
| Admin | `/api/v1/admin` (`dashboard`, `site-mode`, `posts`, `posts/upload`, `wiki`, `settings`, `activity`, `bot-activity`, `bot-activity/unban`, `auth/providers` (CRUD), `users`, `account`, `account/totp/*`, `account/identities`) | cookie (admin) |
| Public · Shard | `/api/v1/public/shard` (`status`, `feed`, `economy`, `online`, `idoc`, `stream`) | none |
| Player · Shard | `/api/v1/player/shard` (`link`, `accounts`, `roster/:account`, `vendors/:account`, `char/:serial`, `sales`) | cookie/bearer (player) |
| Admin · Shard | `/api/v1/admin/shard` (self linking, same as player) · `/api/v1/admin/uo-link` (`config`, `towncrier`, `stream`) | cookie (staff / admin) |
Post categories (URL form): `news`, `five-on-friday`, `newsletter`, `screenshots`.
See [BACKEND_DESIGN.md](BACKEND_DESIGN.md) §4 for the full contract.
`authMethod` on a session ∈ `local · totp · mobile · google · discord · oidc`.
See [BACKEND_DESIGN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/BACKEND_DESIGN.md) §4 for the full contract, or the interactive Swagger
docs below for a per-endpoint reference (parameters, request bodies, response codes).
---
## API documentation (Swagger)
The full API is documented as an **OpenAPI 3.0** spec and served with **Swagger UI**:
| URL | What |
|---|---|
| `http://localhost:3000/api/docs` | Interactive Swagger UI (try-it-out, auth) |
| `http://localhost:3000/api/docs.json` | Raw OpenAPI 3.0 spec (JSON) |
Every endpoint is tagged and grouped (Auth, Auth · Mobile, Auth · SSO, Public, and the Admin
groups) with its summary, parameters, request body, security requirement, and the response codes it
actually returns (`400` validation, `401`/`403` auth, `404`, `409` conflicts, `429` rate limits, …).
**Authentication in the UI** — click **Authorize** and provide either:
- `cookieAuth` — the session cookie (name `rg_token`, configurable via `COOKIE_NAME`; set automatically in the browser after
`POST /api/v1/auth/login`), or
- `bearerAuth` — a mobile access token from `POST /api/v1/auth/mobile/login` (sent as
`Authorization: Bearer <token>`).
**Regenerating the spec** — the spec is generated from `#swagger.*` annotations next to each route
(`server/src/router/**`) plus the shared definitions in `server/swagger/swagger.js`
([swagger-autogen](https://github.com/davibaltar/swagger-autogen)). The output
`server/swagger/swagger-output.json` is committed so the docs work with no build step. After adding
or changing a route, regenerate it:
```bash
cd server
npm run swagger # → server/swagger/swagger-output.json
```
If the generated spec is missing, the server logs a warning and simply disables `/api/docs` (it does
not crash).
### The route manifest (frozen URL surface)
`server/routes.manifest.json` is a generated, sorted `{ method, path }` list of every route the two
Express listeners actually expose. It is **not** documentation — it is the machine-checkable freeze of
the URL surface, so that carving the router files up by business capability
(`docs/website/API_V2_PLAN.md`) can be proved to move no URL instead of merely claiming it.
```bash
cd server
npm run routes:manifest # → routes.manifest.json + routes.guards.json
npm run routes:manifest -- --check # exit 1 if either file is stale (what CI runs)
```
The generator walks the live Express stack (runtime introspection, not source parsing — a route's path
sits on the line *after* `router.get(`, which defeats greps) and keeps only
`/api/**` and `/.well-known/**` plus the internal listener. The SPA catch-all, `/uploads` and `/brand`
are filesystem-conditional static mounts, not API contract, so they are excluded and the output does
not depend on whether the client has been built.
Two generated files, two very different meanings:
| File | Meaning of a diff |
|---|---|
| `routes.manifest.json` | **Contract change.** A URL moved. Justify it in the PR description; never let one ride along in a "mechanical" refactor. |
| `routes.guards.json` | **Review aid.** Per route: handler count + the *named* middleware on its mount chain. Names are a hint only — `requireRole(...)` returns an anonymous arrow and cannot be seen — but a vanished `requireAuth` is unambiguous. |
Unlike the Swagger spec, the manifest is annotation-free: `swagger-output.json` documents intent (only
annotated routes appear), the manifest records reality.
---
## Shard integration (uo-link)
The site is wired to the live in-game world through **uo-link**, a standalone sidecar service that
runs next to the ServUO shard. Its source lives in a separate repo:
**[RunicGateway/link](https://gitea.whitlocktech.com/RunicGateway/link)**. uo-link speaks the shard's internals and
exposes a small, authenticated HTTP + WebSocket API; this website is a *client* of it. The shard
itself is never exposed to the internet — only the sidecar is, and only the website's backend talks
to it.
### How it works
```
ServUO shard ──▶ uo-link sidecar (RunicGateway/link) ──▶ website backend ──▶ browser
REST + WebSocket, bearer-auth ingest + REST same-origin JSON/SSE
```
- **Connection is admin-managed, not env.** The sidecar's base URL, WebSocket URL, shared-secret
token, and protocol version are stored in the database (`uoLinkConfig`), edited from the
**Admin → Shard** panel. The token is **encrypted at rest** (AES-256-GCM) and is **write-only** in
the API — it is never returned to any client and never sent to the browser. Every call the backend
makes carries `Authorization: Bearer <token>` and an `X-UOLink-Version` header (a protocol
mismatch fails fast with `409` instead of being mis-parsed).
- **Live ingest (WebSocket).** When enabled, the backend opens an outbound WebSocket to the sidecar
and receives a stream of game events — `mob.login`/`logout`, `char.vitals`, `economy.supply`,
`vendor.sale`, `player.death`/`murdered`, `house.decay` (IDOC), staff `audit.*`/`cheat.*`,
`link.request`, and `server.hello`/`shutdown`. A single dispatcher (`utils/shardIngest.js`) routes
each event: state-changing kinds update `shard_online` / `shard_economy` / `shard_houses`; notable
kinds are appended to an append-only `shard_events` log; high-frequency kinds (vitals, supply
ticks) only update state and are not logged. A changed boot id on `server.hello` is detected as a
restart and stale "online" rows are cleared. On reconnect the backend backfills missed events via
the sidecar's `/history`.
- **Live round-trips (REST).** For point-in-time reads the backend calls the sidecar directly —
`/char/serial/:serial`, `/roster/:account`, `/vendors/:account`, `/economy`, `/history` — plus
commands `/link/confirm` and `/towncrier`. The REST client (`utils/uoLinkClient.js`) **never
throws**: every call returns `{ ok, data, status }`, so a shard that is down or mid-restart
degrades to a `503`/retry banner instead of a 500.
- **Fan-out to the browser.** Ingested events are pushed to browsers over **Server-Sent Events**.
Two channels exist: a **public** stream carrying only a safe allowlist of kinds, and an
**admin-only** stream that also includes sensitive kinds (staff audit, cheat detection, login
attempts, IPs). Sensitive kinds can never leak onto the public channel.
### Account linking
A player (or staff member) proves ownership of a game account without sharing any game credentials:
1. In game, the player runs **`[link`** and receives a one-time code.
2. On the website (Player portal, or Admin → Account for staff) they enter the code.
3. The backend confirms the code with the sidecar (`POST /link/confirm`), which permanently tags the
game account with the website user id, and mirrors the link locally in `shard_account_links`.
That mirror is the authorization basis for character reads: roster/vendor/character-sheet endpoints
are **ownership-checked** so a user only sees accounts they linked. **Admins may view any
character**; players and editor/moderator staff are limited to their own linked accounts.
### What each audience sees
| 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. |
| **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). |
The sidecar URL and token are set once in **Admin → Shard**; if uo-link is not configured (or the
shard is offline), every shard surface degrades gracefully — the public page still renders, showing
the shard as offline.
---
@@ -212,29 +483,121 @@ Copy `.env.example` (Compose) or `server/.env.example` (local) and fill in. **`.
|---|---|---|
| `NODE_ENV` | `production` | |
| `PORT` | `3000` | server listens on `0.0.0.0:PORT` |
| `UPLOAD_DIR` | `<server>/uploads` | where post images are written (`/app/uploads`, volume-mounted, in Compose) |
| `DB_HOST` / `DB_PORT` | `db` / `3306` | `db` in Compose; `127.0.0.1` for local dev |
| `DB_NAME` / `DB_USER` / `DB_PASSWORD` | `uomysticmoon` / `uomm` / — | app database credentials |
| `DB_NAME` / `DB_USER` / `DB_PASSWORD` | `runic_gateway` / `runic` / — | app database credentials |
| `DB_ROOT_PASSWORD` | — | MariaDB root (Compose only) |
| `JWT_SECRET` | — | **required** — long random string |
| `JWT_EXPIRES_IN` | `1d` | token + cookie lifetime |
| `COOKIE_SECURE` | `auto` | `auto` = Secure only over HTTPS (works on LAN HTTP + Pangolin HTTPS) |
| `COOKIE_NAME` | `uomm_token` | |
| `JWT_SECRET` | — | **required** — long random string; signs session, mobile, and SSO-flow tokens |
| `JWT_EXPIRES_IN` | `1d` | web session token + cookie lifetime |
| `COOKIE_SECURE` | `auto` | `auto` = Secure only over HTTPS (works on LAN HTTP + proxy HTTPS) |
| `COOKIE_NAME` | `rg_token` | changing it on a live instance invalidates existing sessions |
| `BRAND_*` | Runic Gateway | instance branding (name, tagline, colors, logo/hero/favicon) — see [Branding](#branding) |
| `SECRET_ENC_KEY` | — | **required in prod** — key for AES-256-GCM encryption of stored OAuth client secrets. Dev falls back to a key derived from `JWT_SECRET` (with a warning) |
| `APP_BASE_URL` | — | public base URL, used to build the SSO OAuth `redirect_uri` (`${APP_BASE_URL}/api/v1/auth/sso/:provider/callback`). Set in prod to match what you register with Google/Discord; if unset it is derived from the request (fine for local dev) |
| `MOBILE_ACCESS_TTL` | `15m` | mobile bearer **access** token lifetime (short-lived) |
| `MOBILE_REFRESH_TTL_DAYS` | `30` | mobile **refresh** token lifetime (long-lived, rotated on use) |
| `TRUST_PROXY` | `1` | reverse-proxy trust for correct `req.ip` / `req.secure` (rate limiting, backoff, bot-ban). Pin to the proxy hop's LAN IP in prod. A blanket `true` is rejected (coerced to `1`) to block `X-Forwarded-For` spoofing |
| `DEBUG_TRUST_PROXY` | `0` | `1` logs raw peer address + `X-Forwarded-For` + resolved `req.ip` per request (to verify/refresh the proxy IP). Noisy — leave off |
| `TOTP_ISSUER` | `BRAND_NAME` | label shown in authenticator apps for optional per-user 2FA |
| `TOTP_CHALLENGE_TTL` | `5m` | lifetime of the short-lived post-password "awaiting code" step |
| `ADMIN_USERNAME` / `ADMIN_PASSWORD` | — | first-admin bootstrap (first boot only) |
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASS` | — | optional; blank → contact form uses `mailto:` |
| `CONTACT_TO` | `UOMysticmoon@gmail.com` | contact recipient |
| _Email_ | — | configured in Admin → Settings → Email (Gmail OAuth2), not via env; recipient = `contact_email` setting |
| `CLIENT_ORIGIN` | `http://localhost:5173` | enables CORS in dev only |
| `LOG_LEVEL` / `FILE_LOG_LEVEL` | `info` / `debug` | console / file verbosity |
| `LOG_TO_FILE` / `LOG_DIR` / `LOG_FILE` | `true` / `<server>/logs` / `app.log` | log file (bind-mounted to `./logs` in Docker) |
| `ANNOUNCE_POLL_MS` | `15000` | how often the news-announcement dispatcher sweeps `announce_jobs` for due/retry legs (town crier + Discord) |
| `TOWNCRIER_DURATION_SEC` | `3600` | how long a news post's in-game town-crier message stays up (≤ `86400`) |
---
## Branding
Instance identity is data, not code — set via `BRAND_*` env vars, so one prebuilt
image can run as any shard. With none set, everything renders as **Runic Gateway**.
| Var | What |
|---|---|
| `BRAND_NAME` / `BRAND_SHORT_NAME` | display name (full / short-in-prose) |
| `BRAND_TAGLINE` / `BRAND_DESCRIPTION` | tagline + meta/OG description |
| `BRAND_CONTACT_EMAIL` / `BRAND_URL` | contact + canonical URL (for OG/absolute links) |
| `BRAND_ACCENT_COLOR` | theme `--accent` (web) + Discord embed color |
| `BRAND_LOGO` / `BRAND_HERO` / `BRAND_FAVICON` | image paths under the `/brand` mount, or absolute URLs |
**How it flows:** text/colors reach the SPA at runtime through the public settings
API (`SiteContext`), so no rebuild is needed; the server templates `index.html`
`<title>`/meta/OG/favicon at boot; emails, TOTP issuer, and the Discord bot read
`BRAND_*` directly. The admin-editable **site title** and **contact email**
settings override `BRAND_NAME` / `BRAND_CONTACT_EMAIL` when set. Image assets are
delivered from the `./brand` bind-mount (see `brand/README.md`).
**UOMysticmoon** is the first instance — [`.env.uomysticmoon.example`](.env.uomysticmoon.example)
holds the exact `BRAND_*` + infra (`DB_NAME`/`DB_USER`/`COOKIE_NAME`) pinning to
run this repo as UOMysticmoon.
---
## Security
JWT in an httpOnly, `SameSite=Lax` cookie (`Secure` auto-detected) · bcrypt hashing · login &
contact rate limiting · `express-validator` on writes · `helmet` · admin routes `noindex` +
`robots.txt` disallow · `trust proxy` for correct client IPs behind Pangolin · first admin seeded
from env (no hardcoded credentials) · `.env` git-ignored. Passwords and request bodies are never
logged. SMTP is optional — the contact form falls back to a `mailto:` link when unconfigured.
**Session & authorization**
- All auth flows go through one **session service** (`server/src/auth/`): controllers call
`sessionService.createSession(user, authMethod)` and middleware calls `validateSession()`, so web
cookies, mobile bearer tokens, and SSO all produce the *same* authenticated session model.
`utils/auth.js` remains a thin backward-compat facade.
- JWT in an httpOnly, `SameSite=Lax` cookie (`Secure` auto-detected), bcrypt password hashing.
- Admin routes are **re-validated against the database on every request**, so a demoted or deleted
user loses access immediately instead of keeping their old role until the token expires.
- **Role-based authorization** — admin-only endpoints (users, site mode, settings, auth providers)
are gated by a `requireRole` check, so a lower-privilege editor can't reach them.
**Mobile bearer auth**
- Native clients use `/api/v1/auth/mobile/*`: a short-lived **access token** (bearer JWT, validated
by the same middleware as the cookie) plus a long-lived, **server-stored, revocable refresh
token** that is **rotated on every refresh** (a replayed refresh token is single-use). Refresh
tokens are stored **hashed** (never in the clear); logout revokes one or all. Mobile login reuses
the same bot-scoring + backoff defenses as web, with single-request TOTP.
**Single sign-on (OAuth2 / OIDC)**
- Pluggable providers — built-in **Google** and **Discord** (endpoints fixed in code; admins supply
only client id/secret) plus fully-configurable **custom OIDC/OAuth2** providers, managed from the
**Authentication** admin panel. Only `enabled` + fully-configured providers are shown to users.
- **Link-only** by policy: an SSO login succeeds *only* if the external identity is already linked to
an existing account (linked by the user from **Account**). External identities are **never
auto-provisioned** — no one gains access without an account you created.
- The redirect flow is CSRF-protected with a signed, httpOnly, short-lived transaction cookie plus
**PKCE**; OAuth client secrets are **encrypted at rest** (AES-256-GCM) and never returned to any
client. SSO logins go through the same `sessionService`, so login/activity logging, RBAC, and bot
protection are identical to a local login.
**Login hardening**
- **Optional per-user TOTP two-factor** (opt-in, self-service on `/admin/account`). When enabled,
the password step issues only a short-lived, non-session `stage:'totp'` challenge; a session
cookie is granted only after the second factor verifies.
- **Login throttling** — `express-slow-down` + a hard rate cap + a separate per-IP exponential
backoff, with generic error messages that don't reveal whether the username exists.
- **Honeypot** field on the login form; submissions that fill it are treated as bots.
- **Bot-scoring + automatic IP ban** — weighted scoring of CMS-scanner paths and junk 404s (with a
periodic sweep of stale entries) bans hostile scanners; failed logins and honeypot hits feed the
score. Admins get visibility into this on the **Bot Activity** panel: currently banned IPs and a
recent-events feed (in-memory, most-recent-first), plus a logged emergency **unban** for false
positives — read + unban only, not a scoring-config surface.
**Uploads & input**
- Uploaded file extensions are derived from the **validated mimetype**, not the client-supplied
filename (prevents a disguised-extension upload).
- `express-validator` on all writes; usernames are validated **and** uniqueness-checked on update.
**Platform**
- `helmet`, admin routes `noindex` + `robots.txt` disallow, `trust proxy` for correct client IPs
behind a reverse proxy (see `TRUST_PROXY`), first admin seeded from env (no hardcoded credentials),
`.env` git-ignored. Passwords and request bodies are never logged. Email sends through Gmail
OAuth2 configured in the admin (refresh token stored AES-GCM-encrypted, never in env); the
contact form falls back to a `mailto:` link when unconfigured.
---
@@ -258,10 +621,60 @@ bind-mounted to `./logs/app.log` and `docker compose logs -f app` shows the cons
---
## Deployment behind Pangolin
## Deployment behind a reverse proxy
`docker compose up -d --build` exposes the `app` container on `0.0.0.0:3000` (no `127.0.0.1`
binding) so Pangolin can reach it. Point a Pangolin resource at `app:3000`. Because `COOKIE_SECURE`
defaults to `auto`, the admin login works both directly via the LAN IP over HTTP **and** through
Pangolin over HTTPS — no config change needed. MariaDB stays on the private Compose network
(no published port by default); data persists in the `dbdata` volume, uploads in `uploads`.
binding) so a reverse proxy — Pangolin, Nginx, Caddy, Traefik, etc. — can reach it. Point the
proxy at `app:3000` (or the host's `:3000` if the proxy runs outside Compose) and terminate TLS
there. Because `COOKIE_SECURE` defaults to `auto`, the admin login works both directly via the
LAN IP over HTTP **and** through the proxy over HTTPS — no config change needed. MariaDB stays on
the private Compose network (no published port by default); data persists in the `dbdata` volume,
uploads in `uploads`.
Set `TRUST_PROXY` so Express reads the real client IP from the proxy's `X-Forwarded-For` header
(see [Environment variables](#environment-variables)) — required for rate limiting, bot scoring,
and correct logging. Forward the standard `X-Forwarded-For` and `X-Forwarded-Proto` headers from
your proxy.
Minimal proxy examples:
```nginx
# Nginx
location / {
proxy_pass http://app:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
```caddy
# Caddy — Caddyfile (automatic HTTPS; forwards X-Forwarded-* by default)
your.domain {
reverse_proxy app:3000
}
```
**Pangolin:** create a resource targeting `app:3000`; it forwards the required headers and
terminates HTTPS out of the box, so no extra configuration is needed.
---
## License
Runic Gateway is free software, licensed under the **GNU General Public License
v3.0 or later** — see [LICENSE.md](LICENSE.md).
Copyright (C) 2026 Runic Gateway
This program is free software: you can redistribute it and/or modify it under
the terms of the GNU General Public License as published by the Free Software
Foundation, either version 3 of the License, or (at your option) any later
version. It is distributed WITHOUT ANY WARRANTY; without even the implied
warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
Contributions are welcome — please read [CONTRIBUTING.md](CONTRIBUTING.md) (note
the **AI-usage disclosure** requirement) and our
[Code of Conduct](CODE_OF_CONDUCT.md). Report vulnerabilities privately per
[SECURITY.md](SECURITY.md).

50
SECURITY.md Normal file
View File

@@ -0,0 +1,50 @@
# Security Policy
Thank you for helping keep Runic Gateway and its users safe.
## Reporting a vulnerability
**Please do not report security vulnerabilities through public issues, pull
requests, or the wiki.** A public report tips off attackers before a fix is
available.
Instead, report privately by email to:
**whitlocktech@gmail.com**
Please include as much of the following as you can:
- The repository and component affected.
- The type of issue (e.g. authentication bypass, injection, secret exposure,
remote code execution, denial of service).
- Step-by-step instructions to reproduce, and a proof-of-concept if you have one.
- The impact — what an attacker could do with it.
- Any suggested remediation.
You will receive an acknowledgement of your report, typically within a few days.
We will keep you informed as we investigate and work toward a fix, and we are
happy to credit you in the release notes once the issue is resolved (let us know
if you would prefer to remain anonymous).
## Scope
Runic Gateway is a self-hosted platform made up of several components:
| Component | Repo | Network exposure |
|---|---|---|
| Website (site + admin + API) | `RunicGateway/website` | Internet-facing (behind a reverse proxy) |
| uo-link sidecar | `RunicGateway/link` | The only network-facing part of the game bridge |
| ServUO plugin | `RunicGateway/servuo-plugins` | Loopback only — dials the sidecar on `127.0.0.1` |
| Documentation | `RunicGateway/docs` | Content only |
Because instances are self-hosted, the security of any given deployment also
depends on how it is configured and operated — strong secrets (`JWT_SECRET`,
`SECRET_ENC_KEY`, database and admin passwords), a correctly configured reverse
proxy and `TRUST_PROXY`, and keeping the shard itself unreachable from the
internet (only the sidecar should be exposed). See each repo's README for the
security model.
## Supported versions
This project is developed continuously and does not maintain long-term release
branches. Security fixes land on `main`; please run a recent build.

View File

@@ -1,366 +0,0 @@
# UOMysticmoon Website — Wiki Upgrade Spec
> Branch: **`wiki-upgrade`**. This document is the contract for upgrading the CMS
> wiki from a flat single-table page store into a feature-complete wiki.
> It follows the project workflow: **design (this doc) → build in phases → verify**.
>
> Companion to [`BACKEND_DESIGN.md`](BACKEND_DESIGN.md); reuses its stack, auth,
> logging, and Docker decisions unchanged.
---
## 1. Goal & scope
Turn the wiki into something that behaves like a typical wiki, while staying inside
the existing Node/Express + MariaDB + React/Vite architecture and the **staff-only**
auth model (admin/editor — no new roles, no public contributions).
**In scope**
| Feature | Summary |
|---|---|
| Rich-text editing | TipTap (ProseMirror) WYSIWYG in the admin; outputs HTML |
| Sanitization | Server-side allowlist on save **and** client-side on render (fixes today's stored-XSS gap) |
| Categories / sections | First-class `wiki_categories` table; replaces hardcoded frontend blurbs |
| Drafts & publish | `published` + `published_at`, mirroring the `posts` pattern |
| Tags | Many-to-many tags with filtering |
| Internal links | `[[slug]]`-style links authored in the editor; red-link detection |
| Backlinks | "Linked from" list, maintained on save |
| Inline images | Reuse/generalize the existing multer upload for in-body images |
| Search | MariaDB `FULLTEXT` over title + body |
| Revision history | Per-save snapshots with view / diff / restore |
**Out of scope (this branch)**
- Public/player editing or suggestion workflow, moderation/review queues.
- New roles or per-page ACLs (all staff with a login can edit all pages).
- Real-time collaborative editing, comments/discussion pages, file attachments
other than images, page templates/transclusion, multilingual pages.
**Decisions locked from planning**
- Editor: **TipTap**, storing **HTML** (not Markdown, not JSON).
- Search: **MariaDB FULLTEXT** (no new infrastructure).
- Revision history and search are **included** (recommended additions beyond the
minimum requested set).
- Authoring is **admin + editor** (`isLoggedIn`); no anonymous edits.
---
## 2. Current state (baseline being replaced)
| Layer | Today | File |
|---|---|---|
| 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) |
| 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) |
**Known issues this upgrade resolves**
- **Stored XSS**: body is raw HTML rendered with `dangerouslySetInnerHTML` and never
sanitized ([WikiArticle.jsx:91](client/src/routes/wiki/WikiArticle.jsx)).
- Category blurbs and ordering are **faked in the component** ([Wiki.jsx:11](client/src/routes/wiki/Wiki.jsx)), not data.
- No drafts (every save is instantly public), no history, no search, no tags, no links.
---
## 3. Data model
`utf8mb4`, InnoDB throughout. All changes are **additive and idempotent** so
`ensureSchema()` upgrades existing databases on boot with no data loss. New columns
are nullable or have safe defaults; **existing pages default to `published = 1`** so
nothing disappears on deploy.
### 3.1 `wiki_categories` (new)
| col | type | notes |
|---|---|---|
| id | INT PK AI | |
| slug | VARCHAR(120) UNIQUE NOT NULL | e.g. `guides` |
| title | VARCHAR(200) NOT NULL | |
| description | VARCHAR(400) NULL | card teaser on the wiki index |
| sort_order | INT NOT NULL DEFAULT 0 | manual ordering |
| created_at / updated_at | DATETIME | standard stamps |
### 3.2 `wiki_pages` (altered)
Add to the existing table:
| col | type | notes |
|---|---|---|
| category_id | INT NULL FK→wiki_categories(id) ON DELETE SET NULL | |
| excerpt | VARCHAR(400) NULL | card/search teaser (replaces hardcoded blurbs) |
| published | TINYINT(1) NOT NULL DEFAULT 1 | draft/publish toggle |
| published_at | DATETIME NULL | set on first publish |
| sort_order | INT NOT NULL DEFAULT 0 | ordering within a category |
| FULLTEXT idx_wiki_search (title, body) | | search |
### 3.3 `wiki_tags` + `wiki_page_tags` (new)
```
wiki_tags( id PK, slug VARCHAR(120) UNIQUE, label VARCHAR(120) )
wiki_page_tags( page_id FK→wiki_pages ON DELETE CASCADE,
tag_id FK→wiki_tags ON DELETE CASCADE,
PRIMARY KEY(page_id, tag_id) )
```
### 3.4 `wiki_links` (new) — backlinks index
Rebuilt for a page on every save by parsing its body for internal links.
| col | type | notes |
|---|---|---|
| source_page_id | INT FK→wiki_pages ON DELETE CASCADE | |
| target_slug | VARCHAR(120) NOT NULL | may point at a not-yet-created page (red link) |
| INDEX idx_wiki_links_target (target_slug) | | backlink lookups |
Backlinks for page X = `SELECT source pages WHERE target_slug = X.slug AND source is published`.
### 3.5 `wiki_revisions` (new) — history
| col | type | notes |
|---|---|---|
| id | INT PK AI | |
| page_id | INT FK→wiki_pages ON DELETE CASCADE | |
| title / body / excerpt | snapshot of content at save time | |
| category_id | INT NULL | snapshot |
| editor_id | INT NULL FK→users(id) | who saved |
| change_note | VARCHAR(280) NULL | optional summary |
| created_at | DATETIME DEFAULT CURRENT_TIMESTAMP | |
A revision is written **inside the same transaction** as each page create/update.
### 3.6 Seed changes
Rework [seed.js](server/db/seed.js): the current 8 hardcoded pages become **categories**
(title + the blurb currently living in the frontend), each seeded idempotently via a new
`seedDefaultCategory`. Existing seeded pages are migrated/attached where applicable.
`seedDefault` for pages stays `INSERT IGNORE` so reseeding is safe.
---
## 4. Backend changes
Keep the `model` (entity) / `db` (SQL) split and the route grouping by access level.
### 4.1 Models (`server/src/model/wiki/`)
- `wiki.db.js` — add SQL for: category CRUD; page list with `category`, `published`,
`q` (FULLTEXT) filters and ordering; tag upsert + attach/detach; `wiki_links` rebuild;
revision insert/list/get; backlink query.
- `wiki.model.js` — orchestration. On **create/update** (single transaction):
1. sanitize `body` with the allowlist (§6),
2. upsert the page,
3. insert a `wiki_revisions` snapshot,
4. parse body for internal links → rebuild `wiki_links` for the page,
5. sync tags.
- A small `wiki.links.js` helper: parse internal links out of the saved HTML
(anchors written by the editor as `href="/wiki/<slug>"` / a `data-wiki-slug` attr),
return the set of target slugs.
### 4.2 Public API (`/api/v1/public`)
| Method | Path | Notes |
|---|---|---|
| GET | `/wiki/categories` | ordered categories with page counts |
| GET | `/wiki?category=&tag=&q=` | **published only**; list/filter/search summaries |
| GET | `/wiki/:slug` | page + category + tags + backlinks (published only) |
Still passes through the `siteMode` maintenance gate like other public content.
### 4.3 Admin API (`/api/v1/admin`, behind `isLoggedIn` + `noindex`)
| Method | Path | Purpose |
|---|---|---|
| GET | `/wiki` | all pages incl. drafts (filters: category, tag, q, status) |
| GET | `/wiki/:slug` | one page incl. draft, tags, category |
| POST | `/wiki` | create (slug, title, body, excerpt, category_id, tags, published) |
| PUT | `/wiki/:slug` | update (allows slug rename — see §7) |
| PATCH | `/wiki/:slug/publish` | `{published}` toggle, stamps `published_at` |
| DELETE | `/wiki/:slug` | delete (cascades revisions/links/tags) |
| GET | `/wiki/:slug/revisions` | list snapshots |
| GET | `/wiki/:slug/revisions/:id` | one snapshot (for diff/preview) |
| POST | `/wiki/:slug/revisions/:id/restore` | restore (writes a new revision) |
| GET/POST/PUT/DELETE | `/wiki/categories[...]` | category CRUD + reorder |
| GET/POST | `/wiki/tags` | list/create tags |
| POST | `/uploads` | generalized image upload (see §4.4) → `{url}` |
Validation via `express-validator` (slug regex `^[a-z0-9-]+$`, title required, etc.),
centralized error handler unchanged. **Every write logs to `activity_log`**
(`wiki.create`, `wiki.update`, `wiki.publish`, `wiki.delete`, `wiki.revision.restore`,
`wiki.category.*`) following the existing convention.
### 4.4 Image uploads
Generalize the existing screenshot upload (multer config in [admin.routes.js:17](server/src/router/v1/admin/admin.routes.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.
---
## 5. Frontend changes
### 5.1 Admin
- **`WikiEditor.jsx`** — replace the raw-HTML `<textarea>` with a **TipTap** editor:
bold/italic/headings (H2 for TOC)/lists/quote/code, link tool, **image insert**
(uploads via `/admin/uploads`), and an **internal-link picker** (`[[`-triggered
autocomplete over existing slugs; flags red links). Adds: category dropdown, tag
input (create-on-type), excerpt field, **Save draft / Publish** actions, and a
**History** tab (revision list → preview → diff → restore).
- **`WikiAdmin.jsx`** — list gains status (draft/published), category column, and
filters; plus a **Categories** manager (CRUD + drag-to-reorder).
### 5.2 Public
- **`Wiki.jsx`** — fully data-driven: categories + real excerpts from the API
(delete the hardcoded `BLURBS`/`ROMAN` constants), a **search box**, optional
tag filter.
- **`WikiArticle.jsx`** — keep auto-TOC; add category breadcrumb, tag chips, a
**"Linked from"** backlinks section, "last updated by", and **render via DOMPurify**
(`dangerouslySetInnerHTML` only after sanitize).
### 5.3 API client & routes
- Extend [client/src/api/client.js](client/src/api/client.js) with the new public/admin
wiki calls (categories, search params, revisions, tags, uploads).
- Add a public search/category route if needed; admin categories view registered in
[App.jsx](client/src/App.jsx) under `/admin/wiki` (sub-tab, no new top-level route required).
### 5.4 Dependencies (new)
- **client**: `@tiptap/react`, `@tiptap/starter-kit`, `@tiptap/extension-link`,
`@tiptap/extension-image` (+ a small diff lib for history, e.g. `diff`); `dompurify`.
- **server**: `sanitize-html`.
(The client currently ships only React + react-router, so this is the first feature
dependency addition — keep the bundle lean, import only the extensions used.)
---
## 6. Security
- **Two-layer sanitization.** Server sanitizes on save with a strict `sanitize-html`
allowlist (headings, p, lists, blockquote, code/pre, a[href], img[src,alt],
strong/em, hr, table basics); strips scripts, event handlers, `javascript:` URLs,
styles. Client re-sanitizes with DOMPurify before render. The stored value is already
clean, so even direct DB edits or future API clients can't inject script.
- **Upload safety** unchanged from posts: mime allowlist (png/jpe/gif/webp/avif),
8 MB cap, random filenames, served as static files (no execution).
- **Authorization**: all mutating wiki/category/tag/upload routes stay behind
`isLoggedIn` (admin or editor). Public routes are read-only and published-only.
- **No secrets/logging changes**; reuse existing rate-limit, helmet/CSP, noindex.
CSP `img-src` already covers `/uploads`.
---
## 7. Migration & backward compatibility
- Schema migration is additive; run by `ensureSchema()` on boot and shipped in
`schema.sql` for fresh containers. Use `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`
/ `ADD INDEX` guarded for idempotency (MariaDB 11 supports `IF NOT EXISTS`).
- Existing pages: `published` backfills to `1`, `published_at` to `updated_at`,
`category_id` left NULL (surface as "Uncategorized" until assigned).
- **Slug rename** (new capability): on `PUT` slug change, update the page slug and
best-effort rewrite known internal links pointing at the old slug; old slug is not
auto-redirected (acceptable for a staff-curated wiki) — note in release notes.
- Public API response shape is **extended, not broken**: existing fields
(`slug`, `title`, `body`, `updated_at`) remain; new fields are additive, so the
current frontend keeps working between phases.
---
## 8. Implementation process (phased)
Each phase is a self-contained, shippable unit: build → run locally → verify in the
browser preview → commit on `wiki-upgrade`. Open a PR into `main` at the end (or per
phase if preferred). Do not merge a phase that hasn't been verified.
### Phase 0 — Branch & scaffolding ✅ (this doc)
- `wiki-upgrade` branch created; this spec committed.
### Phase 1 — Foundation & safety (highest value) ✅
- Schema: add `wiki_categories`, alter `wiki_pages` (category_id, excerpt, published,
published_at, sort_order, FULLTEXT), update `seed.js`.
- Server: server-side sanitization on save; drafts/publish endpoints; categories CRUD;
public list filtered to published + categories endpoint.
- Client: data-driven `Wiki.jsx` (remove hardcoded blurbs); DOMPurify render in
`WikiArticle.jsx`; draft/publish + category in the (still-textarea) admin editor.
- **Exit check**: existing pages still render; XSS payload in body is neutralized;
draft pages hidden from the public list/article.
- **Verified** (2026-06-27): schema migration ran clean on MariaDB 11; XSS payload
(`<script>`, `onerror=`, `javascript:`) stripped server-side; drafts return 404 on
the public API and are absent from the public list while visible in admin; public
index is data-driven (categories + sections); article shows category breadcrumb;
client builds and server boots with no errors.
### Phase 2 — Authoring UX ✅
- TipTap editor replaces the textarea; generalized `/admin/uploads`; inline images.
- **Exit check**: create/edit a page with headings, a list, a link, and an inline
image; verify it renders sanitized on the public page.
- **Verified** (2026-06-27): `/admin/uploads` returns `{url}` and the file serves as
an image; a page authored with H2/H3, lists, a link, and an uploaded inline image
round-trips through the WYSIWYG and renders sanitized publicly (link `rel` forced,
`<script>` stripped); a toolbar edit (insert divider) saved and persisted. TipTap
is code-split into its own chunk (lazy-loaded), keeping it off the public bundle.
### Phase 3 — Connectivity ✅
- Internal `[[slug]]` links + red-link detection; `wiki_links` rebuild on save;
backlinks on the article; tags + tag/category filtering.
- **Exit check**: link page A→B, confirm B shows A under "Linked from"; tag filter works.
- **Verified** (2026-06-27): internal links authored via an in-editor page picker
(links to `/wiki/<slug>`); A→B made B list A under "Linked from"; a link to a
non-existent page renders as a red link; removing the link on save cleared the
backlink (link index rebuilt). Tags upsert on save, filter via `?tag=` (chips +
flat index view), list with published counts, and orphan tags are auto-pruned.
- Implementation note: links are plain anchors to `/wiki/<slug>` (the WYSIWYG fits
this better than `[[ ]]` syntax); the sanitizer also allows `data-wiki-slug`.
### Phase 4 — Discovery & trust ✅
- FULLTEXT search (public search box + admin filter); revision history list /
diff / restore.
- **Exit check**: search returns expected pages; edit a page twice, diff the
revisions, restore an older one, confirm a new revision is recorded.
- **Verified** (2026-06-27): `?q=` natural-language search matches on both body
(`recipes`→crafting) and title (`monsters`); the public search box and admin
filter both work. A page edited twice produced 3 revisions; the History modal
shows a word-level diff (added vs removed) of an old revision against current;
restoring reverted the page and appended a "Restored from revision #N" entry.
### Verification (every phase)
Use the preview workflow, not manual hand-off: start the dev server, exercise the
public wiki and the admin editor, check console/network for errors, and capture a
screenshot of the changed surface. Confirm `npm run` lint/build passes for the client
and the server boots cleanly with `ensureSchema()` applying the migration.
---
## 9. File-change map (reference)
| Area | Files |
|---|---|
| Schema/seed | `server/db/schema.sql`, `server/db/seed.js`, `server/src/utils/db.js` (ensureSchema) |
| Models | `server/src/model/wiki/wiki.db.js`, `wiki.model.js`, **new** `wiki.links.js` |
| API | `server/src/router/v1/public/public.{routes,controller}.js`, `server/src/router/v1/admin/admin.{routes,controller}.js` |
| Sanitize | **new** `server/src/utils/sanitizeHtml.js` |
| Client API | `client/src/api/client.js` |
| Public UI | `client/src/routes/wiki/Wiki.jsx`, `WikiArticle.jsx` |
| Admin UI | `client/src/routes/admin/views/WikiAdmin.jsx`, `WikiEditor.jsx`, **new** category manager + revisions view |
| Deps | `client/package.json`, `server/package.json` |
---
## 10. Open questions / assumptions
1. **Slug redirects**: assumed not needed on rename (staff wiki). Revisit if pages get
external inbound links.
2. **Search ranking**: FULLTEXT natural-language mode assumed; can switch to BOOLEAN
mode if operators are wanted later.
3. **Diff granularity**: line/word diff of the HTML source is assumed sufficient for
revision compare; a rendered visual diff is a later nice-to-have.
4. **Editor scope**: tables and embeds beyond images are deferred unless requested.

45
bot/.env.example Normal file
View File

@@ -0,0 +1,45 @@
# ─── Runic Gateway Discord bot — local dev environment ───
# Copy to bot/.env for running `npm run dev` outside Docker.
# (In Docker, the root .env / docker-compose provides these instead.)
#
# NOTE: there is no Discord bot token here on purpose. The token is entered
# in the admin panel (Discord Bot page), stored encrypted in the main site's
# DB, and pushed to this process in-memory over the internal API. It is
# never read from an env var and never written to this process's disk.
PORT=4100
# Logging — written to BOTH the console and a log file (default <bot>/logs/bot.log).
LOG_LEVEL=debug # console verbosity: error | warn | info | debug
FILE_LOG_LEVEL=debug # file verbosity
LOG_TO_FILE=true # set false for console-only
# LOG_DIR= # defaults to bot/logs
# LOG_FILE=bot.log
# Shared secret for the internal API between this bot and the main site
# (server/). MUST be byte-for-byte identical to BOT_INTERNAL_KEY in
# server/.env.example / the root .env.example — it is the only auth on both
# sides' /internal/* routes, so a mismatch silently breaks every server<->bot
# call with 401s. Generate one long random string and copy it to both places.
BOT_INTERNAL_KEY=dev-only-change-me-bot-key
# Where this bot calls back to the main site to fetch its config on boot
# (GET .../internal/bot-config), so a restart self-reconnects without needing
# the admin panel to push config again. This targets the site's UNPUBLISHED
# internal port (INTERNAL_PORT, default 3001) — NOT the public 3000. See #33.
SITE_INTERNAL_URL=http://localhost:3001/internal/bot-config
# Read-only PUBLIC API base (Phase 7) — no shared secret, same data any
# visitor's browser can fetch. Used by /wiki (search) and /announce
# (re-post an existing news item).
SITE_PUBLIC_URL=http://localhost:3000/api/v1/public
# Database (Phase 2+) — same physical DB as the main site, but the bot only
# ever reads/writes its OWN tables (guild_config, mod_actions, warnings, and
# more in later phases). It never touches site tables (users, bot_config,
# etc.) directly. Point this at the same DB the server/ uses.
DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=runic_gateway
DB_USER=runic
DB_PASSWORD=change-me-db-password

3
bot/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
.env
logs/

16
bot/Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
FROM node:20-alpine
WORKDIR /app/bot
COPY bot/package*.json ./
RUN npm install --omit=dev
COPY bot/ .
RUN mkdir -p /app/bot/logs && chown -R node:node /app/bot/logs
USER node
EXPOSE 4100
CMD ["node", "src/server.js"]

1609
bot/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
bot/package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "runic-gateway-bot",
"version": "1.0.0",
"description": "Discord bot for the Runic Gateway community server",
"private": true,
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"dev": "nodemon src/server.js"
},
"keywords": ["discord", "discord.js"],
"author": "whitlocktech",
"license": "ISC",
"dependencies": {
"discord.js": "^14.16.3",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"mariadb": "^3.3.1",
"node-cron": "^3.0.3"
},
"devDependencies": {
"nodemon": "^3.1.4"
}
}

15
bot/src/app.js Normal file
View File

@@ -0,0 +1,15 @@
const express = require('express')
const internalRouter = require('./internal/internal.routes')
const app = express()
// Internal-only listener, but don't advertise the stack anyway (defense in depth).
app.disable('x-powered-by')
app.use(express.json())
app.get('/health', (req, res) => res.json({ status: 'ok' }))
app.use('/internal', internalRouter)
module.exports = app

74
bot/src/bootstrap.js vendored Normal file
View File

@@ -0,0 +1,74 @@
// Runs once at process start, before the internal Express server is
// considered ready. Fetches current config from the main site (token,
// guildId, enabled) and reconnects immediately if enabled — so a bot
// container restart (crash, `docker compose restart`, host reboot) self-heals
// without any admin-panel interaction. Node 20's built-in fetch is used; no
// extra HTTP client dependency needed for a single startup call.
//
// The fetch RETRIES with backoff: on `docker compose up`, the bot and the app
// start together and the bot's `depends_on: app` only waits for the container
// to *start*, not for the app's internal server to be listening (it still has
// to reach the DB and boot Express). Without retries the very first fetch loses
// that race, bootstrap gives up, and the bot sits disconnected while the DB
// still says enabled — the exact "enabled but disconnected until I toggle it"
// bug. Retrying until the site answers makes a cold whole-stack start heal on
// its own.
const discordManager = require('./discord/discordManager')
const createLogger = require('./utils/logger')
const log = createLogger('bootstrap')
const MAX_ATTEMPTS = 30 // ~30 tries * ~2s ≈ 1 min of patience for the app to come up
const RETRY_DELAY_MS = 2000
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
// Fetch config from the site, retrying while the site is unreachable or not yet
// ready (network error or 5xx). Returns the parsed config, or null if we gave
// up after MAX_ATTEMPTS. A 4xx (e.g. bad internal key) is a real misconfig, not
// a transient startup race, so we don't retry those.
async function fetchConfig(siteUrl, key) {
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
try {
const res = await fetch(siteUrl, { headers: { 'X-Internal-Key': key } })
if (res.ok) return await res.json()
if (res.status >= 400 && res.status < 500) {
log.error('boot-time config fetch rejected — not retrying', { status: res.status })
return null
}
log.warn('boot-time config fetch not ready — retrying', { status: res.status, attempt })
} catch (err) {
log.warn('boot-time config fetch errored — retrying', { message: err.message, attempt })
}
if (attempt < MAX_ATTEMPTS) await sleep(RETRY_DELAY_MS)
}
log.error('boot-time config fetch gave up after retries — staying disconnected until the admin panel pushes config', {
attempts: MAX_ATTEMPTS,
})
return null
}
async function bootstrap() {
const siteUrl = process.env.SITE_INTERNAL_URL
const key = process.env.BOT_INTERNAL_KEY
if (!siteUrl || !key) {
log.warn('SITE_INTERNAL_URL or BOT_INTERNAL_KEY not set — skipping boot-time config fetch, staying disconnected until the admin panel pushes config')
return
}
const config = await fetchConfig(siteUrl, key)
if (!config) return
if (config.enabled) {
log.info('boot-time config says enabled — reconnecting', { guildId: config.guildId })
try {
await discordManager.start({ token: config.token, guildId: config.guildId })
} catch (err) {
log.error('boot-time reconnect failed', { message: err.message })
}
} else {
log.info('boot-time config says disabled — staying disconnected')
}
}
module.exports = bootstrap

13
bot/src/brand.js Normal file
View File

@@ -0,0 +1,13 @@
// Branding for the Discord bot. Mirrors the server's BRAND_* scheme so embeds and
// logs carry the instance identity. Kept minimal — the bot only needs the name
// and the accent color (as an int for discord.js embeds).
require('dotenv').config()
const name = process.env.BRAND_NAME || 'Runic Gateway'
const accentHex = process.env.BRAND_ACCENT_COLOR || '#7f99bd'
const accentInt = (() => {
const n = parseInt(String(accentHex).replace('#', ''), 16)
return Number.isNaN(n) ? 0x7f99bd : n
})()
module.exports = { name, accentHex, accentInt }

41
bot/src/db.js Normal file
View File

@@ -0,0 +1,41 @@
// DB pool for the bot's OWN tables (guild_config, mod_actions, warnings) —
// mirrors server/src/utils/db.js. The bot never reads/writes any table it
// doesn't own; site-owned tables (users, bot_config, etc.) are reached only
// through the internal API, never directly. Schema for these tables lives in
// server/db/schema.sql (same physical database, ensured by the main server on
// boot) — there's no separate migration tool to justify a second database for
// a single-guild v1 bot.
const mariadb = require('mariadb')
const pool = mariadb.createPool({
host: process.env.DB_HOST || '127.0.0.1',
port: Number(process.env.DB_PORT) || 3306,
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || '',
database: process.env.DB_NAME || 'runic_gateway',
connectionLimit: 5,
insertIdAsNumber: true,
bigIntAsNumber: true,
decimalAsNumber: true,
// The driver defaults to 'local' — silently serializing bound JS Date
// params using the HOST MACHINE's local offset instead of the DB session's
// timezone (discovered via temp_roles.expires_at coming back hours off in
// dev, CDT vs the container's UTC). 'auto' negotiates the actual session
// timezone so Date round-trips correctly regardless of host TZ.
timezone: 'auto',
})
async function query(sql, params) {
const conn = await pool.getConnection()
try {
return await conn.query(sql, params)
} finally {
conn.release()
}
}
async function close() {
await pool.end()
}
module.exports = { query, close }

View File

@@ -0,0 +1,49 @@
const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js')
const siteApiClient = require('../../site/siteApiClient')
const newsAnnounce = require('../newsAnnounce')
function siteOrigin() {
const base = process.env.SITE_PUBLIC_URL || 'http://localhost:3000/api/v1/public'
return new URL(base).origin
}
module.exports = {
data: {
name: 'announce',
description: 'Re-post or boost an existing news item.',
default_member_permissions: PermissionFlagsBits.ManageGuild.toString(),
options: [
{ name: 'post', description: 'News post id or slug', type: ApplicationCommandOptionType.String, required: true },
],
},
async execute(interaction) {
const idOrSlug = interaction.options.getString('post', true)
await interaction.deferReply({ ephemeral: true })
const result = await siteApiClient.getNewsPost(idOrSlug)
if (result.maintenance) {
await interaction.editReply({ content: `Can't reach the site right now: ${result.message || 'maintenance mode'}` })
return
}
if (!result.ok) {
await interaction.editReply({ content: `Couldn't find that news post ("${idOrSlug}").` })
return
}
const post = result.data
const origin = siteOrigin()
try {
await newsAnnounce.postAnnounce(interaction.client, interaction.guildId, {
title: post.title,
excerpt: post.excerpt,
url: `${origin}/site/news`,
// image_url is stored relative — Discord embeds require an absolute URL.
imageUrl: post.image_url ? new URL(post.image_url, origin).toString() : null,
})
await interaction.editReply({ content: `Posted "${post.title}" to the news channel.` })
} catch (err) {
await interaction.editReply({ content: `Couldn't post: ${err.message}` })
}
},
}

View File

@@ -0,0 +1,30 @@
const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js')
const guildConfig = require('../../model/guildConfig')
module.exports = {
data: {
name: 'autorole',
description: 'View or set the role automatically assigned to new members on join.',
default_member_permissions: PermissionFlagsBits.ManageGuild.toString(),
options: [
{
name: 'role',
description: 'Role to auto-assign on join. Omit to view the current setting.',
type: ApplicationCommandOptionType.Role,
required: false,
},
],
},
async execute(interaction) {
const role = interaction.options.getRole('role')
if (!role) {
const currentId = await guildConfig.getAutoRoleId(interaction.guildId)
const content = currentId ? `Auto-role is set to <@&${currentId}>.` : 'No auto-role is set yet.'
await interaction.reply({ content, ephemeral: true })
return
}
await guildConfig.setAutoRoleId(interaction.guildId, role.id)
await interaction.reply({ content: `Auto-role set to ${role}. New members will get this automatically.`, ephemeral: true })
},
}

View File

@@ -0,0 +1,34 @@
const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js')
const modLog = require('../modLog')
module.exports = {
data: {
name: 'ban',
description: 'Ban a member from the server.',
default_member_permissions: PermissionFlagsBits.BanMembers.toString(),
options: [
{ name: 'user', description: 'Member to ban', type: ApplicationCommandOptionType.User, required: true },
{ name: 'reason', description: 'Reason for the ban', type: ApplicationCommandOptionType.String, required: true },
],
},
async execute(interaction) {
const user = interaction.options.getUser('user', true)
const reason = interaction.options.getString('reason', true)
if (user.id === interaction.user.id) {
await interaction.reply({ content: "You can't ban yourself.", ephemeral: true })
return
}
const member = interaction.guild.members.cache.get(user.id)
if (member && !member.bannable) {
await interaction.reply({ content: "I don't have permission to ban that member (role hierarchy).", ephemeral: true })
return
}
await interaction.guild.members.ban(user, { reason })
await modLog.record({ client: interaction.client, guildId: interaction.guildId, actionType: 'ban', target: user, staffUser: interaction.user, reason })
await interaction.reply({ content: `Banned ${user.tag}.`, ephemeral: true })
},
}

View File

@@ -0,0 +1,73 @@
const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js')
const filterWords = require('../../model/filterWords')
const filterCache = require('../../filter/filterCache')
module.exports = {
data: {
name: 'filter',
description: 'Manage the banned-word filter.',
default_member_permissions: PermissionFlagsBits.ManageGuild.toString(),
options: [
{
name: 'add',
description: 'Add a word to the filter.',
type: ApplicationCommandOptionType.Subcommand,
options: [
{ name: 'word', description: 'Word or phrase to ban', type: ApplicationCommandOptionType.String, required: true },
{
name: 'severity',
description: 'Auto-action when triggered (default: delete)',
type: ApplicationCommandOptionType.String,
required: false,
choices: [
{ name: 'Delete only', value: 'delete' },
{ name: 'Delete + warn', value: 'warn' },
{ name: 'Delete + mute (10m)', value: 'mute' },
],
},
],
},
{
name: 'remove',
description: 'Remove a word from the filter.',
type: ApplicationCommandOptionType.Subcommand,
options: [
{ name: 'word', description: 'Word or phrase to remove', type: ApplicationCommandOptionType.String, required: true },
],
},
{
name: 'list',
description: 'List all filtered words.',
type: ApplicationCommandOptionType.Subcommand,
options: [],
},
],
},
async execute(interaction) {
const sub = interaction.options.getSubcommand()
if (sub === 'add') {
const word = interaction.options.getString('word', true)
const severity = interaction.options.getString('severity') || 'delete'
await filterWords.add({ guildId: interaction.guildId, word, severity, addedBy: interaction.user.id, addedByTag: interaction.user.tag })
await filterCache.refresh(interaction.guildId)
await interaction.reply({ content: `Added "${word}" to the filter (${severity}).`, ephemeral: true })
return
}
if (sub === 'remove') {
const word = interaction.options.getString('word', true)
const removed = await filterWords.remove(interaction.guildId, word)
await filterCache.refresh(interaction.guildId)
await interaction.reply({ content: removed ? `Removed "${word}" from the filter.` : `"${word}" wasn't in the filter.`, ephemeral: true })
return
}
if (sub === 'list') {
const words = await filterWords.list(interaction.guildId)
const content = words.length === 0 ? 'The filter list is empty.' : words.map((w) => `${w.word} (${w.severity})`).join('\n')
await interaction.reply({ content, ephemeral: true })
}
},
}

View File

@@ -0,0 +1,61 @@
const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js')
const filterAllowlist = require('../../model/filterAllowlist')
const filterCache = require('../../filter/filterCache')
module.exports = {
data: {
name: 'filterallow',
description: 'Manage roles/channels that bypass the filter entirely.',
default_member_permissions: PermissionFlagsBits.ManageGuild.toString(),
options: [
{
name: 'role',
description: 'Toggle a role in/out of the filter bypass list.',
type: ApplicationCommandOptionType.Subcommand,
options: [{ name: 'role', description: 'Role to toggle', type: ApplicationCommandOptionType.Role, required: true }],
},
{
name: 'channel',
description: 'Toggle a channel in/out of the filter bypass list.',
type: ApplicationCommandOptionType.Subcommand,
options: [{ name: 'channel', description: 'Channel to toggle', type: ApplicationCommandOptionType.Channel, required: true }],
},
{
name: 'list',
description: 'Show current filter bypass roles/channels.',
type: ApplicationCommandOptionType.Subcommand,
options: [],
},
],
},
async execute(interaction) {
const sub = interaction.options.getSubcommand()
if (sub === 'role') {
const role = interaction.options.getRole('role', true)
const nowAllowed = await filterAllowlist.toggleRole(interaction.guildId, role.id)
await filterCache.refresh(interaction.guildId)
await interaction.reply({ content: `${role} is ${nowAllowed ? 'now' : 'no longer'} bypassing the filter.`, ephemeral: true })
return
}
if (sub === 'channel') {
const channel = interaction.options.getChannel('channel', true)
const nowAllowed = await filterAllowlist.toggleChannel(interaction.guildId, channel.id)
await filterCache.refresh(interaction.guildId)
await interaction.reply({ content: `${channel} is ${nowAllowed ? 'now' : 'no longer'} bypassing the filter.`, ephemeral: true })
return
}
if (sub === 'list') {
const [roles, channels] = await Promise.all([
filterAllowlist.getRoles(interaction.guildId),
filterAllowlist.getChannels(interaction.guildId),
])
const roleText = roles.length ? roles.map((id) => `<@&${id}>`).join(', ') : 'none'
const channelText = channels.length ? channels.map((id) => `<#${id}>`).join(', ') : 'none'
await interaction.reply({ content: `Bypass roles: ${roleText}\nBypass channels: ${channelText}`, ephemeral: true })
}
},
}

View File

@@ -0,0 +1,31 @@
// Command registry. Each module exports { data, execute } — `data` is the
// slash-command definition pushed to Discord (registerCommands), `execute` is
// the interactionCreate handler (dispatch). Adding a new command is just
// adding a file here — discordManager.js never needs to change.
const commands = [
require('./ping.command'),
require('./modlog.command'),
require('./ban.command'),
require('./kick.command'),
require('./mute.command'),
require('./warn.command'),
require('./warnings.command'),
require('./filter.command'),
require('./filterallow.command'),
require('./schedule.command'),
require('./rolemenu.command'),
require('./autorole.command'),
require('./role.command'),
require('./roles.command'),
require('./invite.command'),
require('./news.command'),
require('./announce.command'),
require('./wiki.command'),
]
const byName = new Map(commands.map((c) => [c.data.name, c]))
module.exports = {
all: commands,
get: (name) => byName.get(name),
}

View File

@@ -0,0 +1,87 @@
const { PermissionFlagsBits, ApplicationCommandOptionType, ChannelType } = require('discord.js')
const guildConfig = require('../../model/guildConfig')
const inviteLog = require('../../model/inviteLog')
const inviteRotator = require('../../invites/inviteRotator')
// Per-subcommand handlers, split out of execute() so the dispatch stays flat.
async function handleChannel(interaction) {
const channel = interaction.options.getChannel('channel')
if (!channel) {
const currentId = await guildConfig.getInviteChannelId(interaction.guildId)
const content = currentId ? `Invites are created in <#${currentId}>.` : 'No invite channel is set yet.'
await interaction.reply({ content, ephemeral: true })
return
}
await guildConfig.setInviteChannelId(interaction.guildId, channel.id)
await interaction.reply({ content: `Invite channel set to ${channel}.`, ephemeral: true })
}
async function handleRotate(interaction) {
await interaction.deferReply({ ephemeral: true })
try {
const invite = await inviteRotator.rotate(interaction.client, interaction.guildId, {
triggeredBy: interaction.user.id,
triggeredByTag: interaction.user.tag,
})
await interaction.editReply({ content: `New invite: https://discord.gg/${invite.code}` })
} catch (err) {
await interaction.editReply({ content: `Couldn't rotate the invite: ${err.message}` })
}
}
async function handleLog(interaction) {
const rows = await inviteLog.list(interaction.guildId, 10)
if (rows.length === 0) {
await interaction.reply({ content: 'No invite rotations logged yet.', ephemeral: true })
return
}
const lines = rows.map((r) => {
const who = r.triggered_by_tag || 'automatic (scheduled)'
const status = r.revoked_at ? `revoked ${new Date(r.revoked_at).toLocaleString()}` : 'active'
return `\`${r.invite_code}\` — by ${who} on ${new Date(r.created_at).toLocaleString()} (${status})`
})
await interaction.reply({ content: lines.join('\n'), ephemeral: true })
}
module.exports = {
data: {
name: 'invite',
description: 'Manage the auto-rotating primary server invite.',
default_member_permissions: PermissionFlagsBits.ManageGuild.toString(),
options: [
{
name: 'channel',
description: 'View or set the channel new invites are created in.',
type: ApplicationCommandOptionType.Subcommand,
options: [
{
name: 'channel',
description: 'Channel to create invites in. Omit to view the current setting.',
type: ApplicationCommandOptionType.Channel,
channel_types: [ChannelType.GuildText],
required: false,
},
],
},
{
name: 'rotate',
description: 'Revoke the current invite and generate a new one now.',
type: ApplicationCommandOptionType.Subcommand,
options: [],
},
{
name: 'log',
description: 'Show recent invite rotation history.',
type: ApplicationCommandOptionType.Subcommand,
options: [],
},
],
},
async execute(interaction) {
const sub = interaction.options.getSubcommand()
if (sub === 'channel') return handleChannel(interaction)
if (sub === 'rotate') return handleRotate(interaction)
if (sub === 'log') return handleLog(interaction)
},
}

View File

@@ -0,0 +1,38 @@
const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js')
const modLog = require('../modLog')
module.exports = {
data: {
name: 'kick',
description: 'Kick a member from the server.',
default_member_permissions: PermissionFlagsBits.KickMembers.toString(),
options: [
{ name: 'user', description: 'Member to kick', type: ApplicationCommandOptionType.User, required: true },
{ name: 'reason', description: 'Reason for the kick', type: ApplicationCommandOptionType.String, required: true },
],
},
async execute(interaction) {
const user = interaction.options.getUser('user', true)
const reason = interaction.options.getString('reason', true)
if (user.id === interaction.user.id) {
await interaction.reply({ content: "You can't kick yourself.", ephemeral: true })
return
}
const member = interaction.guild.members.cache.get(user.id)
if (!member) {
await interaction.reply({ content: 'That user is not a member of this server.', ephemeral: true })
return
}
if (!member.kickable) {
await interaction.reply({ content: "I don't have permission to kick that member (role hierarchy).", ephemeral: true })
return
}
await member.kick(reason)
await modLog.record({ client: interaction.client, guildId: interaction.guildId, actionType: 'kick', target: user, staffUser: interaction.user, reason })
await interaction.reply({ content: `Kicked ${user.tag}.`, ephemeral: true })
},
}

View File

@@ -0,0 +1,33 @@
const { PermissionFlagsBits, ApplicationCommandOptionType, ChannelType } = require('discord.js')
const guildConfig = require('../../model/guildConfig')
module.exports = {
data: {
name: 'modlog',
description: 'View or set the mod-log channel (ban/kick/mute/warn actions post here).',
// Configuration, not a moderation action — gated to Manage Server rather
// than the ModerateMembers bit the action commands use.
default_member_permissions: PermissionFlagsBits.ManageGuild.toString(),
options: [
{
name: 'channel',
description: 'Channel to post mod-log entries to. Omit to view the current setting.',
type: ApplicationCommandOptionType.Channel,
channel_types: [ChannelType.GuildText],
required: false,
},
],
},
async execute(interaction) {
const channel = interaction.options.getChannel('channel')
if (!channel) {
const currentId = await guildConfig.getModLogChannelId(interaction.guildId)
const content = currentId ? `Mod-log channel is set to <#${currentId}>.` : 'No mod-log channel is set yet.'
await interaction.reply({ content, ephemeral: true })
return
}
await guildConfig.setModLogChannelId(interaction.guildId, channel.id)
await interaction.reply({ content: `Mod-log channel set to ${channel}.`, ephemeral: true })
},
}

View File

@@ -0,0 +1,49 @@
const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js')
const modLog = require('../modLog')
const { parseDuration, MAX_TIMEOUT_MS } = require('../../utils/duration')
module.exports = {
data: {
name: 'mute',
description: 'Timeout a member for a duration (e.g. 10m, 2h, 1d).',
default_member_permissions: PermissionFlagsBits.ModerateMembers.toString(),
options: [
{ name: 'user', description: 'Member to mute', type: ApplicationCommandOptionType.User, required: true },
{ name: 'duration', description: 'e.g. 30s, 10m, 2h, 1d (max 28d)', type: ApplicationCommandOptionType.String, required: true },
{ name: 'reason', description: 'Reason for the mute', type: ApplicationCommandOptionType.String, required: true },
],
},
async execute(interaction) {
const user = interaction.options.getUser('user', true)
const durationInput = interaction.options.getString('duration', true)
const reason = interaction.options.getString('reason', true)
if (user.id === interaction.user.id) {
await interaction.reply({ content: "You can't mute yourself.", ephemeral: true })
return
}
const ms = parseDuration(durationInput)
if (!ms) {
await interaction.reply({ content: 'Invalid duration — use a number plus s/m/h/d, e.g. `10m`, `2h`, `1d`.', ephemeral: true })
return
}
const clampedMs = Math.min(ms, MAX_TIMEOUT_MS)
const member = interaction.guild.members.cache.get(user.id)
if (!member) {
await interaction.reply({ content: 'That user is not a member of this server.', ephemeral: true })
return
}
if (!member.moderatable) {
await interaction.reply({ content: "I don't have permission to timeout that member (role hierarchy).", ephemeral: true })
return
}
await member.timeout(clampedMs, reason)
const durationSeconds = Math.round(clampedMs / 1000)
await modLog.record({ client: interaction.client, guildId: interaction.guildId, actionType: 'mute', target: user, staffUser: interaction.user, reason, durationSeconds })
await interaction.reply({ content: `Muted ${user.tag} for ${durationInput}.`, ephemeral: true })
},
}

View File

@@ -0,0 +1,31 @@
const { PermissionFlagsBits, ApplicationCommandOptionType, ChannelType } = require('discord.js')
const guildConfig = require('../../model/guildConfig')
module.exports = {
data: {
name: 'news',
description: 'View or set the channel news posts are announced to.',
default_member_permissions: PermissionFlagsBits.ManageGuild.toString(),
options: [
{
name: 'channel',
description: 'Channel for news announcements. Omit to view the current setting.',
type: ApplicationCommandOptionType.Channel,
channel_types: [ChannelType.GuildText],
required: false,
},
],
},
async execute(interaction) {
const channel = interaction.options.getChannel('channel')
if (!channel) {
const currentId = await guildConfig.getNewsChannelId(interaction.guildId)
const content = currentId ? `News channel is set to <#${currentId}>.` : 'No news channel is set yet.'
await interaction.reply({ content, ephemeral: true })
return
}
await guildConfig.setNewsChannelId(interaction.guildId, channel.id)
await interaction.reply({ content: `News channel set to ${channel}.`, ephemeral: true })
},
}

View File

@@ -0,0 +1,15 @@
const { PermissionFlagsBits } = require('discord.js')
module.exports = {
data: {
name: 'ping',
description: 'Health-check — replies pong if the bot is alive and staff-permitted.',
// Restricted by default to members with Moderate Members — proves slash
// commands can be permission-gated via Discord's own permission model,
// per the spec's "restrict staff commands via Discord's permission system".
default_member_permissions: PermissionFlagsBits.ModerateMembers.toString(),
},
async execute(interaction) {
await interaction.reply({ content: 'pong', ephemeral: true })
},
}

View File

@@ -0,0 +1,71 @@
const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js')
const tempRoles = require('../../model/tempRoles')
const { parseDuration } = require('../../utils/duration')
module.exports = {
data: {
name: 'role',
description: 'Assign or remove a role for a single member.',
default_member_permissions: PermissionFlagsBits.ManageRoles.toString(),
options: [
{
name: 'add',
description: 'Add a role to a member, optionally temporary.',
type: ApplicationCommandOptionType.Subcommand,
options: [
{ name: 'user', description: 'Member', type: ApplicationCommandOptionType.User, required: true },
{ name: 'role', description: 'Role to add', type: ApplicationCommandOptionType.Role, required: true },
{ name: 'duration', description: 'Optional — makes this temporary, e.g. 1h, 2d, 7d', type: ApplicationCommandOptionType.String, required: false },
],
},
{
name: 'remove',
description: 'Remove a role from a member.',
type: ApplicationCommandOptionType.Subcommand,
options: [
{ name: 'user', description: 'Member', type: ApplicationCommandOptionType.User, required: true },
{ name: 'role', description: 'Role to remove', type: ApplicationCommandOptionType.Role, required: true },
],
},
],
},
async execute(interaction) {
const sub = interaction.options.getSubcommand()
const user = interaction.options.getUser('user', true)
const role = interaction.options.getRole('role', true)
const member = interaction.guild.members.cache.get(user.id)
if (!member) {
await interaction.reply({ content: 'That user is not a member of this server.', ephemeral: true })
return
}
if (sub === 'add') {
await member.roles.add(role.id)
const durationInput = interaction.options.getString('duration')
if (!durationInput) {
await interaction.reply({ content: `Added ${role} to ${user.tag}.`, ephemeral: true })
return
}
const ms = parseDuration(durationInput)
if (!ms) {
await interaction.reply({
content: `Added ${role}, but "${durationInput}" isn't a valid duration so it won't expire automatically. Use e.g. 1h, 2d, 7d.`,
ephemeral: true,
})
return
}
const expiresAt = new Date(Date.now() + ms)
await tempRoles.add({ guildId: interaction.guildId, userId: user.id, roleId: role.id, expiresAt, createdBy: interaction.user.id })
await interaction.reply({ content: `Added ${role} to ${user.tag} until ${expiresAt.toLocaleString()}.`, ephemeral: true })
return
}
if (sub === 'remove') {
await member.roles.remove(role.id)
await tempRoles.remove(interaction.guildId, user.id, role.id)
await interaction.reply({ content: `Removed ${role} from ${user.tag}.`, ephemeral: true })
}
},
}

View File

@@ -0,0 +1,86 @@
const {
PermissionFlagsBits,
ApplicationCommandOptionType,
ChannelType,
EmbedBuilder,
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
} = require('discord.js')
const roleMenus = require('../../model/roleMenus')
const brand = require('../../brand')
// Capped at 5 roles per menu — a single Discord action row holds at most 5
// buttons, and one row keeps this a single simple slash command instead of
// needing a multi-step builder/modal flow.
const MAX_ROLES = 5
// role1/label1 are declared inline in `data` (ahead of the optional
// `description` option, per Discord's required-before-optional rule) — this
// generates the rest, all optional.
function roleOptions(from, to) {
const opts = []
for (let i = from; i <= to; i++) {
opts.push({ name: `role${i}`, description: `Role #${i}`, type: ApplicationCommandOptionType.Role, required: false })
opts.push({ name: `label${i}`, description: `Button label for role #${i} (default: role name)`, type: ApplicationCommandOptionType.String, required: false })
}
return opts
}
module.exports = {
data: {
name: 'rolemenu',
description: 'Post a button menu for self-assignable roles (up to 5).',
default_member_permissions: PermissionFlagsBits.ManageGuild.toString(),
// Discord requires all required options before any optional ones across
// the whole array — role1 (required) must come before description
// (optional), even though they read more naturally in the other order.
options: [
{ name: 'channel', description: 'Channel to post the menu in', type: ApplicationCommandOptionType.Channel, channel_types: [ChannelType.GuildText], required: true },
{ name: 'title', description: 'Menu title', type: ApplicationCommandOptionType.String, required: true },
{ name: 'role1', description: 'Role #1', type: ApplicationCommandOptionType.Role, required: true },
{ name: 'description', description: 'Menu description', type: ApplicationCommandOptionType.String, required: false },
{ name: 'label1', description: 'Button label for role #1 (default: role name)', type: ApplicationCommandOptionType.String, required: false },
...roleOptions(2, MAX_ROLES),
],
},
async execute(interaction) {
const channel = interaction.options.getChannel('channel', true)
const title = interaction.options.getString('title', true)
const description = interaction.options.getString('description') || undefined
const entries = []
for (let i = 1; i <= MAX_ROLES; i++) {
const role = interaction.options.getRole(`role${i}`)
if (!role) continue
const label = interaction.options.getString(`label${i}`) || role.name
entries.push({ roleId: role.id, label })
}
if (entries.length === 0) {
await interaction.reply({ content: 'Provide at least one role (role1).', ephemeral: true })
return
}
const embed = new EmbedBuilder().setTitle(title).setColor(brand.accentInt)
if (description) embed.setDescription(description)
const row = new ActionRowBuilder().addComponents(
entries.map((e) =>
new ButtonBuilder().setCustomId(`rolemenu:${e.roleId}`).setLabel(e.label).setStyle(ButtonStyle.Secondary),
),
)
const message = await channel.send({ embeds: [embed], components: [row] })
await roleMenus.add({
guildId: interaction.guildId,
channelId: channel.id,
messageId: message.id,
mapping: entries,
createdBy: interaction.user.id,
})
await interaction.reply({ content: `Role menu posted in ${channel}.`, ephemeral: true })
},
}

View File

@@ -0,0 +1,68 @@
const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js')
// Bulk targeting is "by existing role" only — the spec also mentions an
// explicit list of members, but Discord slash commands have no multi-user
// picker, so that variant is deferred rather than faked with a handful of
// user1..user5 options that would feel arbitrary and cramped.
module.exports = {
data: {
name: 'roles',
description: 'Bulk role operations across members who share an existing role.',
default_member_permissions: PermissionFlagsBits.ManageRoles.toString(),
options: [
{
name: 'bulk-assign',
description: 'Add a role to every member who has another role.',
type: ApplicationCommandOptionType.Subcommand,
options: [
{ name: 'has-role', description: 'Members with this role are targeted', type: ApplicationCommandOptionType.Role, required: true },
{ name: 'add-role', description: 'Role to add to those members', type: ApplicationCommandOptionType.Role, required: true },
],
},
{
name: 'bulk-remove',
description: 'Remove a role from every member who has another role.',
type: ApplicationCommandOptionType.Subcommand,
options: [
{ name: 'has-role', description: 'Members with this role are targeted', type: ApplicationCommandOptionType.Role, required: true },
{ name: 'remove-role', description: 'Role to remove from those members', type: ApplicationCommandOptionType.Role, required: true },
],
},
],
},
async execute(interaction) {
const sub = interaction.options.getSubcommand()
// Fetching every member + looping role updates can easily exceed
// Discord's 3-second initial-response window.
await interaction.deferReply({ ephemeral: true })
const hasRole = interaction.options.getRole('has-role', true)
const members = await interaction.guild.members.fetch()
const targets = members.filter((m) => m.roles.cache.has(hasRole.id))
if (sub === 'bulk-assign') {
const addRole = interaction.options.getRole('add-role', true)
let count = 0
for (const member of targets.values()) {
if (!member.roles.cache.has(addRole.id)) {
await member.roles.add(addRole.id).catch(() => {})
count++
}
}
await interaction.editReply({ content: `Added ${addRole} to ${count} member(s) who have ${hasRole}.` })
return
}
if (sub === 'bulk-remove') {
const removeRole = interaction.options.getRole('remove-role', true)
let count = 0
for (const member of targets.values()) {
if (member.roles.cache.has(removeRole.id)) {
await member.roles.remove(removeRole.id).catch(() => {})
count++
}
}
await interaction.editReply({ content: `Removed ${removeRole} from ${count} member(s) who have ${hasRole}.` })
}
},
}

View File

@@ -0,0 +1,121 @@
const { PermissionFlagsBits, ApplicationCommandOptionType, ChannelType } = require('discord.js')
const cron = require('node-cron')
const scheduledMessages = require('../../model/scheduledMessages')
const scheduler = require('../../scheduler/scheduler')
const { parseDuration } = require('../../utils/duration')
// Per-subcommand handlers, split out of execute() so the dispatch stays flat.
async function handleRecurring(interaction) {
const channel = interaction.options.getChannel('channel', true)
const cronExpr = interaction.options.getString('cron', true)
const message = interaction.options.getString('message', true)
if (!cron.validate(cronExpr)) {
await interaction.reply({ content: `"${cronExpr}" isn't a valid cron expression.`, ephemeral: true })
return
}
const id = await scheduledMessages.addRecurring({
guildId: interaction.guildId,
channelId: channel.id,
content: message,
cronExpression: cronExpr,
createdBy: interaction.user.id,
createdByTag: interaction.user.tag,
})
await scheduler.refresh()
await interaction.reply({ content: `Scheduled recurring message #${id} in ${channel} on \`${cronExpr}\`.`, ephemeral: true })
}
async function handleOnce(interaction) {
const channel = interaction.options.getChannel('channel', true)
const inInput = interaction.options.getString('in', true)
const message = interaction.options.getString('message', true)
const ms = parseDuration(inInput)
if (!ms) {
await interaction.reply({ content: 'Invalid time — use a number plus s/m/h/d, e.g. `30m`, `2h`, `1d`.', ephemeral: true })
return
}
const runAt = new Date(Date.now() + ms)
const id = await scheduledMessages.addOnce({
guildId: interaction.guildId,
channelId: channel.id,
content: message,
runAt,
createdBy: interaction.user.id,
createdByTag: interaction.user.tag,
})
await interaction.reply({ content: `Scheduled one-off message #${id} in ${channel} for ${runAt.toLocaleString()}.`, ephemeral: true })
}
async function handleRemove(interaction) {
const id = interaction.options.getInteger('id', true)
const removed = await scheduledMessages.remove(interaction.guildId, id)
await scheduler.refresh()
await interaction.reply({ content: removed ? `Removed scheduled message #${id}.` : `No scheduled message #${id} found.`, ephemeral: true })
}
async function handleList(interaction) {
const rows = await scheduledMessages.list(interaction.guildId)
if (rows.length === 0) {
await interaction.reply({ content: 'No scheduled messages.', ephemeral: true })
return
}
const lines = rows.map((r) => {
let kind
if (r.cron_expression) kind = `cron \`${r.cron_expression}\``
else if (r.sent_at) kind = `sent ${new Date(r.sent_at).toLocaleString()}`
else kind = `due ${new Date(r.run_at).toLocaleString()}`
const suffix = r.enabled ? '' : ' (disabled)'
return `**#${r.id}** <#${r.channel_id}> — ${kind}${suffix}`
})
await interaction.reply({ content: lines.join('\n'), ephemeral: true })
}
module.exports = {
data: {
name: 'schedule',
description: 'Manage recurring and one-off scheduled channel messages.',
default_member_permissions: PermissionFlagsBits.ManageGuild.toString(),
options: [
{
name: 'recurring',
description: 'Schedule a recurring message on a cron schedule.',
type: ApplicationCommandOptionType.Subcommand,
options: [
{ name: 'channel', description: 'Channel to post in', type: ApplicationCommandOptionType.Channel, channel_types: [ChannelType.GuildText], required: true },
{ name: 'cron', description: 'Cron expression, e.g. "0 9 * * 5" (Fridays 9am)', type: ApplicationCommandOptionType.String, required: true },
{ name: 'message', description: 'Message content to post', type: ApplicationCommandOptionType.String, required: true },
],
},
{
name: 'once',
description: 'Schedule a one-off message for a future time.',
type: ApplicationCommandOptionType.Subcommand,
options: [
{ name: 'channel', description: 'Channel to post in', type: ApplicationCommandOptionType.Channel, channel_types: [ChannelType.GuildText], required: true },
{ name: 'in', description: 'When to post, e.g. 30m, 2h, 1d', type: ApplicationCommandOptionType.String, required: true },
{ name: 'message', description: 'Message content to post', type: ApplicationCommandOptionType.String, required: true },
],
},
{
name: 'remove',
description: 'Remove a scheduled message by id.',
type: ApplicationCommandOptionType.Subcommand,
options: [{ name: 'id', description: 'Scheduled message id (see /schedule list)', type: ApplicationCommandOptionType.Integer, required: true }],
},
{
name: 'list',
description: 'List all scheduled messages.',
type: ApplicationCommandOptionType.Subcommand,
options: [],
},
],
},
async execute(interaction) {
const sub = interaction.options.getSubcommand()
if (sub === 'recurring') return handleRecurring(interaction)
if (sub === 'once') return handleOnce(interaction)
if (sub === 'remove') return handleRemove(interaction)
if (sub === 'list') return handleList(interaction)
},
}

View File

@@ -0,0 +1,40 @@
const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js')
const modLog = require('../modLog')
const warnings = require('../../model/warnings')
// Escalation (e.g. "3 active warns -> auto-mute for X hours") and warning
// decay/expiry are in the original spec but deferred past this phase — this
// just records the warning and posts it to the mod-log, matching the
// "Suggested Build Order" step 2 scope (core moderation).
module.exports = {
data: {
name: 'warn',
description: 'Log a warning against a member.',
default_member_permissions: PermissionFlagsBits.ModerateMembers.toString(),
options: [
{ name: 'user', description: 'Member to warn', type: ApplicationCommandOptionType.User, required: true },
{ name: 'reason', description: 'Reason for the warning', type: ApplicationCommandOptionType.String, required: true },
],
},
async execute(interaction) {
const user = interaction.options.getUser('user', true)
const reason = interaction.options.getString('reason', true)
if (user.id === interaction.user.id) {
await interaction.reply({ content: "You can't warn yourself.", ephemeral: true })
return
}
await warnings.add({
guildId: interaction.guildId,
targetUserId: user.id,
targetTag: user.tag,
staffUserId: interaction.user.id,
staffTag: interaction.user.tag,
reason,
})
await modLog.record({ client: interaction.client, guildId: interaction.guildId, actionType: 'warn', target: user, staffUser: interaction.user, reason })
await interaction.reply({ content: `Warned ${user.tag}.`, ephemeral: true })
},
}

View File

@@ -0,0 +1,34 @@
const { PermissionFlagsBits, ApplicationCommandOptionType, EmbedBuilder } = require('discord.js')
const warnings = require('../../model/warnings')
module.exports = {
data: {
name: 'warnings',
description: "List a member's active warnings.",
default_member_permissions: PermissionFlagsBits.ModerateMembers.toString(),
options: [
{ name: 'user', description: 'Member to look up', type: ApplicationCommandOptionType.User, required: true },
],
},
async execute(interaction) {
const user = interaction.options.getUser('user', true)
const rows = await warnings.listActive(interaction.guildId, user.id)
if (rows.length === 0) {
await interaction.reply({ content: `${user.tag} has no active warnings.`, ephemeral: true })
return
}
const embed = new EmbedBuilder()
.setColor(0xe0b070)
.setTitle(`Warnings — ${user.tag}`)
.setDescription(
rows
.map((w, i) => `**${i + 1}.** ${w.reason || '(no reason given)'} — by ${w.staff_tag || 'unknown'} on ${new Date(w.created_at).toLocaleDateString()}`)
.join('\n'),
)
await interaction.reply({ embeds: [embed], ephemeral: true })
},
}

View File

@@ -0,0 +1,40 @@
const { ApplicationCommandOptionType } = require('discord.js')
const siteApiClient = require('../../site/siteApiClient')
// Public command — no default_member_permissions restriction. Read-only:
// searches wiki titles/content and links to the best match. Never posts to or
// edits the wiki. Category-scoped search (spec's optional "/wiki spells
// fireball") is deferred — the site's public search endpoint currently
// ignores category filters whenever a text query is given.
function siteOrigin() {
const base = process.env.SITE_PUBLIC_URL || 'http://localhost:3000/api/v1/public'
return new URL(base).origin
}
module.exports = {
data: {
name: 'wiki',
description: 'Search the wiki.',
options: [{ name: 'query', description: 'What to search for', type: ApplicationCommandOptionType.String, required: true }],
},
async execute(interaction) {
const query = interaction.options.getString('query', true)
await interaction.deferReply()
const result = await siteApiClient.searchWiki(query)
if (result.maintenance) {
await interaction.editReply({ content: `The wiki is unavailable right now: ${result.message || 'maintenance mode'}` })
return
}
if (!result.ok || !result.data || result.data.length === 0) {
await interaction.editReply({ content: `No wiki results for "${query}".` })
return
}
const best = result.data[0]
const url = `${siteOrigin()}/wiki/${best.slug}`
const content = best.excerpt ? `**${best.title}**\n${best.excerpt}\n${url}` : `**${best.title}**\n${url}`
await interaction.editReply({ content })
},
}

View File

@@ -0,0 +1,149 @@
// Owns the single discord.js Client instance for this process: lifecycle
// (start/stop/status) and slash-command registration/dispatch. Command
// definitions themselves live in ./commands — this file only wires them up.
const { Client, GatewayIntentBits, REST, Routes } = require('discord.js')
const createLogger = require('../utils/logger')
const commands = require('./commands')
const messageFilter = require('./messageFilter')
const scheduler = require('../scheduler/scheduler')
const roleMenuHandler = require('./roleMenuHandler')
const { handleGuildMemberAdd } = require('./guildMemberAdd')
const { handleGuildMemberRemove } = require('./guildMemberRemove')
const inviteTracker = require('./inviteTracker')
const tempRoleSweeper = require('../roles/tempRoleSweeper')
const inviteScheduler = require('../invites/inviteScheduler')
const log = createLogger('discord')
let client = null
let guildId = null
let status = 'disconnected' // disconnected | connecting | connected | error
let statusDetail = null
let lastConnectedAt = null
async function registerCommands(applicationId, targetGuildId) {
const rest = new REST({ version: '10' }).setToken(client.token)
await rest.put(Routes.applicationGuildCommands(applicationId, targetGuildId), {
body: commands.all.map((c) => c.data),
})
log.info('registered guild slash commands', { guildId: targetGuildId, count: commands.all.length })
}
async function stop() {
if (!client) {
status = 'disconnected'
statusDetail = null
return
}
scheduler.stop()
tempRoleSweeper.stop()
inviteScheduler.stop()
try {
await client.destroy()
} catch (err) {
log.warn('error while destroying client', { message: err.message })
}
client = null
status = 'disconnected'
statusDetail = null
log.info('discord client disconnected')
}
// Post-login startup: register commands and start the background workers. A
// failure here leaves the client connected but flags an error status.
async function onReady() {
try {
await registerCommands(client.application.id, guildId)
await scheduler.start(client)
tempRoleSweeper.start(client)
inviteScheduler.start(client, guildId)
await inviteTracker.prime(client, guildId)
status = 'connected'
statusDetail = null
lastConnectedAt = new Date()
log.info('discord client ready', { user: client.user?.tag, guildId })
} catch (err) {
status = 'error'
statusDetail = `startup failed: ${err.message}`
log.error('post-login startup failed (commands/scheduler/temp-roles/invites)', { message: err.message })
}
}
// Route an interaction: role-menu handler first, then chat-input slash commands.
async function onInteractionCreate(interaction) {
if (await roleMenuHandler.handleInteraction(interaction)) return
if (!interaction.isChatInputCommand()) return
const command = commands.get(interaction.commandName)
if (!command) return
try {
await command.execute(interaction)
} catch (err) {
log.error('command execution failed', { command: interaction.commandName, message: err.message })
const payload = { content: 'Something went wrong running that command.', ephemeral: true }
if (interaction.replied || interaction.deferred) await interaction.followUp(payload)
else await interaction.reply(payload)
}
}
// start({ token, guildId }) — (re)connects. Always stops any existing client
// first so re-saving config or toggling Enabled off/on is idempotent.
async function start({ token, guildId: gid }) {
await stop()
guildId = gid
status = 'connecting'
statusDetail = null
// GuildMessages + MessageContent (Phase 3, filter) and GuildMembers
// (Phase 5, auto-role + bulk role ops) are all privileged — must be enabled
// in the Discord Developer Portal, see the Phase 1 setup notes. GuildInvites
// (Phase 6b, invite-usage attribution) is NOT privileged — no portal toggle.
client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
GatewayIntentBits.GuildMembers,
GatewayIntentBits.GuildInvites,
],
})
client.once('ready', onReady)
client.on('interactionCreate', onInteractionCreate)
client.on('messageCreate', messageFilter.handleMessageCreate)
client.on('guildMemberAdd', handleGuildMemberAdd)
client.on('guildMemberRemove', handleGuildMemberRemove)
// Keep the invite-use cache fresh so guildMemberAdd can attribute joins.
client.on('inviteCreate', inviteTracker.onInviteCreate)
client.on('inviteDelete', inviteTracker.onInviteDelete)
client.on('error', (err) => {
status = 'error'
statusDetail = err.message
log.error('discord client error', { message: err.message })
})
try {
await client.login(token)
} catch (err) {
status = 'error'
statusDetail = err.message
client = null
log.error('discord login failed', { message: err.message })
throw err
}
}
function getStatus() {
return { status, statusDetail, guildId, lastConnectedAt }
}
// For code that needs the live client + which guild it's connected to (the
// /internal/announce handler, slash commands already get both from the
// interaction itself so they don't need this). Returns null if disconnected.
function getConnection() {
if (!client || status !== 'connected') return null
return { client, guildId }
}
module.exports = { start, stop, getStatus, getConnection }

View File

@@ -0,0 +1,45 @@
// Member join handling: record the join event (with best-effort invite
// attribution, Phase 6b) then apply the configured auto-role. Requires the
// Server Members privileged intent (already enabled per the Phase 1 setup notes)
// and, for invite attribution, the GuildInvites intent.
const guildConfig = require('../model/guildConfig')
const memberEvents = require('../model/memberEvents')
const inviteTracker = require('./inviteTracker')
const createLogger = require('../utils/logger')
const log = createLogger('members')
async function handleGuildMemberAdd(member) {
// Attribute the invite first (diffs the invite-use cache), then record the join.
// Both are best-effort — a failure here must never block the auto-role below.
let invite = { code: null, inviterId: null, inviterTag: null }
try {
invite = await inviteTracker.attribute(member)
} catch (err) {
log.warn('invite attribution threw', { userId: member.id, message: err.message })
}
try {
await memberEvents.record({
guildId: member.guild.id,
eventType: 'join',
discordUserId: member.id,
username: member.user?.tag,
inviteCode: invite.code,
inviterId: invite.inviterId,
inviterTag: invite.inviterTag,
})
} catch (err) {
log.warn('member join record failed', { userId: member.id, message: err.message })
}
try {
const roleId = await guildConfig.getAutoRoleId(member.guild.id)
if (!roleId) return
await member.roles.add(roleId)
log.info('auto-role assigned', { userId: member.id, roleId })
} catch (err) {
log.warn('auto-role assignment failed', { userId: member.id, message: err.message })
}
}
module.exports = { handleGuildMemberAdd }

View File

@@ -0,0 +1,23 @@
// Member leave handling (Phase 6b): record a leave event for the dashboard's
// members feed. Fires on both voluntary leaves and kicks/bans — Discord doesn't
// distinguish them on this event, and the mod-action (if any) is logged
// separately via mod_actions, so a leave row here is purely the lifecycle fact.
const memberEvents = require('../model/memberEvents')
const createLogger = require('../utils/logger')
const log = createLogger('members')
async function handleGuildMemberRemove(member) {
try {
await memberEvents.record({
guildId: member.guild.id,
eventType: 'leave',
discordUserId: member.id,
username: member.user?.tag,
})
} catch (err) {
log.warn('member leave record failed', { userId: member.id, message: err.message })
}
}
module.exports = { handleGuildMemberRemove }

View File

@@ -0,0 +1,74 @@
// Best-effort invite-usage attribution (Phase 6b). Discord doesn't tell you
// which invite a member used, so the standard approach is to keep a cache of
// each invite's use-count and, on guildMemberAdd, re-fetch and find the one
// whose count went up. Requires the GuildInvites intent + Manage Guild (the bot
// already creates/deletes invites, so it has the permission). All calls are
// best-effort: any failure just yields a null attribution and the join is still
// recorded. Vanity-URL and bot-added joins are inherently unattributable.
const createLogger = require('../utils/logger')
const log = createLogger('invites')
// guildId -> Map<inviteCode, uses>
const cache = new Map()
async function snapshot(guild) {
const map = new Map()
const invites = await guild.invites.fetch()
for (const inv of invites.values()) map.set(inv.code, inv.uses || 0)
return map
}
// Populate the cache for a guild (call once the client is ready).
async function prime(client, guildId) {
try {
const guild = client.guilds.cache.get(guildId) || (await client.guilds.fetch(guildId))
cache.set(guildId, await snapshot(guild))
log.info('invite cache primed', { guildId, count: cache.get(guildId).size })
} catch (err) {
log.warn('invite cache prime failed (missing Manage Guild / GuildInvites?)', { message: err.message })
}
}
function onInviteCreate(invite) {
if (!invite.guild) return
const g = cache.get(invite.guild.id) || new Map()
g.set(invite.code, invite.uses || 0)
cache.set(invite.guild.id, g)
}
function onInviteDelete(invite) {
if (!invite.guild) return
const g = cache.get(invite.guild.id)
if (g) g.delete(invite.code)
}
// Diff current invite uses against the cached snapshot to find which invite the
// joining member used, then refresh the cache. Returns { code, inviterId,
// inviterTag } with nulls when it can't be determined.
async function attribute(member) {
const empty = { code: null, inviterId: null, inviterTag: null }
try {
const guild = member.guild
const before = cache.get(guild.id) || new Map()
const current = await guild.invites.fetch()
let found = empty
for (const inv of current.values()) {
const prev = before.get(inv.code) || 0
if ((inv.uses || 0) > prev && found === empty) {
found = { code: inv.code, inviterId: inv.inviter?.id || null, inviterTag: inv.inviter?.tag || null }
}
}
const next = new Map()
for (const inv of current.values()) next.set(inv.code, inv.uses || 0)
cache.set(guild.id, next)
return found
} catch (err) {
log.warn('invite attribution failed', { message: err.message })
return empty
}
}
module.exports = { prime, onInviteCreate, onInviteDelete, attribute }

View File

@@ -0,0 +1,136 @@
// messageCreate orchestration: allowlist bypass -> invite link -> banned word
// -> spam/mass-mention/mass-emoji. Invite/spam triggers always delete + warn
// (no severity tiers for those, unlike the word filter) — kept simple per the
// spec's "start simple" guidance. Filter-triggered mutes use a fixed 10-minute
// duration; per-severity-configurable durations are a future refinement.
const filterCache = require('../filter/filterCache')
const { findMatch } = require('../filter/normalize')
const inviteFilter = require('../filter/inviteFilter')
const spamFilter = require('../filter/spamFilter')
const warnings = require('../model/warnings')
const filterHits = require('../model/filterHits')
const spamHits = require('../model/spamHits')
const modLog = require('./modLog')
const createLogger = require('../utils/logger')
const log = createLogger('filter')
const FILTER_MUTE_SECONDS = 600 // 10 minutes
function botActor(client) {
return { id: client.user.id, tag: client.user.tag }
}
// Dashboard event capture (Phase 6b). Best-effort — recording a hit must never
// break the moderation action it accompanies, so failures are swallowed+logged.
async function recordFilterHit(message, hitType, matched, actionTaken) {
try {
await filterHits.record({
guildId: message.guildId,
hitType,
discordUserId: message.author.id,
username: message.author.tag,
channelId: message.channelId,
matched,
actionTaken,
})
} catch (err) {
log.warn('filter hit record failed', { message: err.message })
}
}
async function recordSpamHit(message, spamType) {
try {
await spamHits.record({
guildId: message.guildId,
spamType,
discordUserId: message.author.id,
username: message.author.tag,
channelId: message.channelId,
})
} catch (err) {
log.warn('spam hit record failed', { message: err.message })
}
}
// Which spam rule tripped (for the spam_hits row). isRateLimited has a side
// effect (records this message's timestamp) so it must be evaluated first, and
// exactly once — mirroring the original OR-order.
function detectSpam(message) {
if (spamFilter.isRateLimited(message.guildId, message.author.id)) return 'rate_limit'
if (spamFilter.isMassMention(message)) return 'mass_mention'
if (spamFilter.isMassEmoji(message.content)) return 'mass_emoji'
return null
}
async function isBypassed(message, cache) {
if (cache.allowChannels.has(message.channelId)) return true
const memberRoles = message.member ? message.member.roles.cache : null
return Boolean(memberRoles && [...memberRoles.keys()].some((id) => cache.allowRoles.has(id)))
}
async function applyWarnAction(message, reason) {
const staff = botActor(message.client)
await warnings.add({
guildId: message.guildId,
targetUserId: message.author.id,
targetTag: message.author.tag,
staffUserId: staff.id,
staffTag: staff.tag,
reason,
})
await modLog.record({ client: message.client, guildId: message.guildId, actionType: 'warn', target: message.author, staffUser: staff, reason })
}
async function applyMuteAction(message, reason) {
const staff = botActor(message.client)
if (message.member && message.member.moderatable) {
await message.member.timeout(FILTER_MUTE_SECONDS * 1000, reason)
}
await modLog.record({
client: message.client,
guildId: message.guildId,
actionType: 'mute',
target: message.author,
staffUser: staff,
reason,
durationSeconds: FILTER_MUTE_SECONDS,
})
}
async function handleMessageCreate(message) {
if (message.author.bot || !message.guildId) return
try {
const cache = await filterCache.getOrLoad(message.guildId)
if (await isBypassed(message, cache)) return
const foreignCode = await inviteFilter.foreignInviteCode(message)
if (foreignCode) {
await message.delete().catch(() => {})
await recordFilterHit(message, 'invite', foreignCode, 'warn')
await applyWarnAction(message, 'Posted a Discord invite link')
return
}
const match = findMatch(message.content, cache.words)
if (match) {
await message.delete().catch(() => {})
await recordFilterHit(message, 'word', match.word, match.severity)
if (match.severity === 'mute') await applyMuteAction(message, `Filtered word: ${match.word}`)
else if (match.severity === 'warn') await applyWarnAction(message, `Filtered word: ${match.word}`)
return
}
const spamType = detectSpam(message)
if (spamType) {
await message.delete().catch(() => {})
await recordSpamHit(message, spamType)
await applyWarnAction(message, 'Automated spam detection (rate limit / mass mention / mass emoji)')
}
} catch (err) {
log.error('messageFilter failed', { message: err.message })
}
}
module.exports = { handleMessageCreate }

81
bot/src/discord/modLog.js Normal file
View File

@@ -0,0 +1,81 @@
// Shared by every moderation command (ban/kick/mute/warn): writes the audit
// row and posts the embed to the configured mod-log channel. Takes `client`
// as a parameter (from interaction.client) rather than importing
// discordManager directly, to avoid a require cycle (discordManager -> commands
// -> modLog -> discordManager).
const { EmbedBuilder } = require('discord.js')
const db = require('../db')
const guildConfig = require('../model/guildConfig')
const createLogger = require('../utils/logger')
const log = createLogger('modlog')
const COLOR = { ban: 0xd98b84, kick: 0xe0b070, mute: 0xe0b070, warn: 0xe0b070 }
async function record({ client, guildId, actionType, target, staffUser, reason, durationSeconds }) {
await db.query(
`INSERT INTO mod_actions (guild_id, action_type, target_user_id, target_tag, staff_user_id, staff_tag, reason, duration_seconds)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[guildId, actionType, target.id, target.tag || null, staffUser.id, staffUser.tag || null, reason || null, durationSeconds || null],
)
try {
const channelId = await guildConfig.getModLogChannelId(guildId)
if (!channelId) return
const channel = await client.channels.fetch(channelId)
if (!channel || !channel.isTextBased()) return
const embed = new EmbedBuilder()
.setColor(COLOR[actionType] || 0x9aa5b1)
.setTitle(actionType.toUpperCase())
.addFields(
{ name: 'Target', value: `${target.tag || target.id} (${target.id})`, inline: true },
{ name: 'Staff', value: `${staffUser.tag || staffUser.id} (${staffUser.id})`, inline: true },
)
.setTimestamp()
if (reason) embed.addFields({ name: 'Reason', value: reason })
if (durationSeconds) embed.addFields({ name: 'Duration', value: formatDuration(durationSeconds), inline: true })
await channel.send({ embeds: [embed] })
} catch (err) {
log.warn('failed to post mod-log embed', { message: err.message })
}
}
// Post an "appeal approved → action reversed" embed to the mod-log channel.
// Unlike record() this NEVER inserts a mod_actions row — the reversal is an
// out-of-band correction driven by the site's appeals flow, not a new staff
// action. Best-effort: a missing channel or send failure is logged, not thrown.
async function postReversal({ client, guildId, actionType, discordUserId, appealId }) {
try {
const channelId = await guildConfig.getModLogChannelId(guildId)
if (!channelId) return
const channel = await client.channels.fetch(channelId)
if (!channel || !channel.isTextBased()) return
const reversed = actionType === 'ban' ? 'Ban lifted (unbanned)' : 'Mute cleared (timeout removed)'
const embed = new EmbedBuilder()
.setColor(0x88c0a0)
.setTitle('APPEAL APPROVED')
.addFields(
{ name: 'Action reversed', value: reversed, inline: true },
{ name: 'Target id', value: `${discordUserId}`, inline: true },
{ name: 'Appeal', value: `#${appealId}`, inline: true },
)
.setTimestamp()
await channel.send({ embeds: [embed] })
} catch (err) {
log.warn('failed to post appeal-reversal embed', { message: err.message })
}
}
function formatDuration(seconds) {
if (seconds % 86400 === 0) return `${seconds / 86400}d`
if (seconds % 3600 === 0) return `${seconds / 3600}h`
if (seconds % 60 === 0) return `${seconds / 60}m`
return `${seconds}s`
}
module.exports = { record, postReversal }

View File

@@ -0,0 +1,27 @@
// Shared by the /internal/announce webhook (site publishes a news post) and
// the manual /announce command (staff re-posts/boosts an existing one) — so
// both paths produce an identical embed.
const { EmbedBuilder } = require('discord.js')
const guildConfig = require('../model/guildConfig')
const brand = require('../brand')
const createLogger = require('../utils/logger')
const log = createLogger('news')
async function postAnnounce(client, guildId, { title, excerpt, url, imageUrl }) {
const channelId = await guildConfig.getNewsChannelId(guildId)
if (!channelId) throw new Error('No news channel configured — set one with /news first.')
const channel = await client.channels.fetch(channelId)
if (!channel || !channel.isTextBased()) throw new Error('Configured news channel is missing or not text-based.')
const embed = new EmbedBuilder().setColor(brand.accentInt).setTitle(title).setURL(url)
if (excerpt) embed.setDescription(excerpt)
if (imageUrl) embed.setImage(imageUrl)
await channel.send({ embeds: [embed] })
log.info('news announced', { title, channelId })
}
module.exports = { postAnnounce }

View File

@@ -0,0 +1,41 @@
// Button-based self-assignable role menus. customId is `rolemenu:<roleId>` —
// the message's own id (not known until after it's sent, so it can't be
// embedded in the customId itself) is instead used to look up the tracked
// role_menus row and confirm the clicked roleId is really part of that
// menu's mapping, so a stale/foreign button can't toggle an untracked role.
const roleMenus = require('../model/roleMenus')
const createLogger = require('../utils/logger')
const log = createLogger('rolemenu')
const PREFIX = 'rolemenu:'
// Returns true if this handler owned the interaction (caller should stop
// looking for another handler), false if it's not a role-menu button at all.
async function handleInteraction(interaction) {
if (!interaction.isButton() || !interaction.customId.startsWith(PREFIX)) return false
const roleId = interaction.customId.slice(PREFIX.length)
try {
const menu = await roleMenus.getByMessageId(interaction.message.id)
if (!menu || !menu.mapping.some((m) => m.roleId === roleId)) {
await interaction.reply({ content: 'This role menu is no longer valid.', ephemeral: true })
return true
}
const member = interaction.member
if (member.roles.cache.has(roleId)) {
await member.roles.remove(roleId)
await interaction.reply({ content: `Removed <@&${roleId}>.`, ephemeral: true })
} else {
await member.roles.add(roleId)
await interaction.reply({ content: `Added <@&${roleId}>.`, ephemeral: true })
}
} catch (err) {
log.error('role menu toggle failed', { message: err.message })
await interaction.reply({ content: 'Something went wrong toggling that role.', ephemeral: true }).catch(() => {})
}
return true
}
module.exports = { handleInteraction }

View File

@@ -0,0 +1,31 @@
// In-memory per-guild filter state (word list + allowlist), loaded at startup
// and refreshed on config change — the messageCreate handler runs on every
// message, so it must never hit the DB per message (per the spec's
// performance note).
const filterWords = require('../model/filterWords')
const filterAllowlist = require('../model/filterAllowlist')
const cache = new Map() // guildId -> { words, allowRoles: Set, allowChannels: Set }
async function load(guildId) {
const [words, roles, channels] = await Promise.all([
filterWords.list(guildId),
filterAllowlist.getRoles(guildId),
filterAllowlist.getChannels(guildId),
])
const entry = { words, allowRoles: new Set(roles), allowChannels: new Set(channels) }
cache.set(guildId, entry)
return entry
}
// Lazy-loads on first access per guild (e.g. the first message after boot).
async function getOrLoad(guildId) {
return cache.get(guildId) || load(guildId)
}
// Called by /filter and /filterallow after any mutation.
function refresh(guildId) {
return load(guildId)
}
module.exports = { getOrLoad, refresh }

View File

@@ -0,0 +1,26 @@
// Detects Discord invite links and blocks any that don't resolve to the
// current guild (anti-raid/anti-advertising). An invite that fails to resolve
// (expired/invalid/vanity-only) is treated as foreign too — safer default
// than silently letting an unresolvable link through.
const INVITE_REGEX = /(?:discord\.gg|discord(?:app)?\.com\/invite)\/([a-z0-9-]+)/gi
// Returns the first foreign (or unresolvable) invite code found in the message,
// or null if the message contains no foreign invites. Returning the code (rather
// than a bare boolean) lets the caller record which invite was blocked.
async function foreignInviteCode(message) {
const matches = [...message.content.matchAll(INVITE_REGEX)]
if (matches.length === 0) return null
for (const match of matches) {
const code = match[1]
try {
const invite = await message.client.fetchInvite(code)
if (invite.guild?.id !== message.guildId) return code
} catch {
return code
}
}
return null
}
module.exports = { foreignInviteCode }

View File

@@ -0,0 +1,33 @@
// Basic obfuscation-resistant normalization for the word filter: lowercase,
// common leetspeak substitutions, and collapsing 3+ repeated characters
// ("sooooo" -> "so") to one. Deliberately simple per the spec ("start simple,
// leave room to tighten later") — spaced-out letters ("b a d") and more exotic
// unicode lookalikes aren't handled yet.
const SUBS = { 4: 'a', '@': 'a', 3: 'e', 1: 'i', '!': 'i', 0: 'o', $: 's', 5: 's', 7: 't' }
const SUB_CHARS = /[4@31!05$7]/g
function normalize(text) {
return text
.toLowerCase()
.replace(SUB_CHARS, (ch) => SUBS[ch] || ch)
.replace(/(.)\1{2,}/g, '$1')
}
function escapeRegex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
// Word-boundary match against already-normalized text. `word` is normalized
// here too, so callers can pass the raw stored value.
function matches(normalizedText, word) {
const pattern = new RegExp(`\\b${escapeRegex(normalize(word))}\\b`, 'i')
return pattern.test(normalizedText)
}
// Returns the first matching filter_words row ({word, severity}) or null.
function findMatch(content, words) {
const normalizedText = normalize(content)
return words.find((w) => matches(normalizedText, w.word)) || null
}
module.exports = { normalize, matches, findMatch }

View File

@@ -0,0 +1,42 @@
// Basic in-memory spam/rate-limit detection. Per-user message-rate tracking is
// the only stateful piece here (mass-mention/mass-emoji are per-message
// counts) — kept in memory rather than the DB since this runs on every
// message and needs to be fast.
const RATE_LIMIT_COUNT = 5
const RATE_LIMIT_WINDOW_MS = 5000
const MENTION_THRESHOLD = 5
const EMOJI_THRESHOLD = 10
const SWEEP_INTERVAL_MS = 5 * 60 * 1000
const history = new Map() // `${guildId}:${userId}` -> timestamps[]
function isRateLimited(guildId, userId) {
const key = `${guildId}:${userId}`
const now = Date.now()
const timestamps = (history.get(key) || []).filter((t) => now - t < RATE_LIMIT_WINDOW_MS)
timestamps.push(now)
history.set(key, timestamps)
return timestamps.length > RATE_LIMIT_COUNT
}
function isMassMention(message) {
return message.mentions.users.size + message.mentions.roles.size > MENTION_THRESHOLD
}
const EMOJI_REGEX = /<a?:\w+:\d+>|\p{Extended_Pictographic}/gu
function isMassEmoji(content) {
const count = (content.match(EMOJI_REGEX) || []).length
return count > EMOJI_THRESHOLD
}
// Periodic cleanup so `history` doesn't grow unbounded over a long-running
// process — drops any key with no recent activity.
setInterval(() => {
const now = Date.now()
for (const [key, timestamps] of history) {
if (timestamps.every((t) => now - t >= RATE_LIMIT_WINDOW_MS)) history.delete(key)
}
}, SWEEP_INTERVAL_MS).unref()
module.exports = { isRateLimited, isMassMention, isMassEmoji }

View File

@@ -0,0 +1,102 @@
const discordManager = require('../discord/discordManager')
const newsAnnounce = require('../discord/newsAnnounce')
const modLog = require('../discord/modLog')
const createLogger = require('../utils/logger')
const log = createLogger('internal')
const REVERSIBLE = new Set(['ban', 'mute'])
// discord.js REST error code for removing a ban that no longer exists.
const UNKNOWN_BAN = 10026
// POST /internal/config — called by the main server right after an admin
// saves the Discord Bot panel, and by the bot's own bootstrap on startup
// (via a GET to the server for the current config, then this same start/stop
// logic locally). Body: { token, guildId, enabled }.
async function setConfig(req, res) {
const { token, guildId, enabled } = req.body || {}
try {
if (enabled) {
if (!token || !guildId) {
return res.status(400).json({ message: 'token and guildId are required when enabled' })
}
await discordManager.start({ token, guildId })
} else {
await discordManager.stop()
}
return res.json(discordManager.getStatus())
} catch (err) {
log.error('setConfig failed', { message: err.message })
// Still 200 with an error status — the caller (admin panel) should surface
// discordManager's status/statusDetail rather than treat this as a 5xx.
return res.json(discordManager.getStatus())
}
}
// GET /internal/status — live connection state, polled by the admin panel.
function getStatusHandler(req, res) {
return res.json(discordManager.getStatus())
}
// POST /internal/announce — called by the main server right after a news
// post is published. Body: { title, excerpt, url, imageUrl }.
async function announce(req, res) {
const connection = discordManager.getConnection()
if (!connection) return res.status(503).json({ message: 'Bot is not connected' })
try {
await newsAnnounce.postAnnounce(connection.client, connection.guildId, req.body || {})
return res.json({ posted: true })
} catch (err) {
log.warn('announce failed', { message: err.message })
return res.status(400).json({ message: err.message })
}
}
// POST /internal/mod-reverse — called by the main server when a staffer APPROVES
// a moderation appeal (Phase 6d). Body: { discord_user_id, action_type, appeal_id }.
// Reverses the Discord action: 'ban' → lift the ban, 'mute' → clear the timeout.
// Idempotent-friendly: an already-lifted ban ("Unknown Ban") or a member who has
// left the guild is treated as success (the desired end state already holds).
async function reverseModAction(req, res) {
const { discord_user_id: discordUserId, action_type: actionType, appeal_id: appealId } = req.body || {}
if (!REVERSIBLE.has(actionType)) {
return res.status(400).json({ message: 'action_type must be ban or mute' })
}
const connection = discordManager.getConnection()
if (!connection) return res.status(503).json({ message: 'Bot is not connected' })
const reason = `Appeal #${appealId} approved`
try {
const guild = await connection.client.guilds.fetch(connection.guildId)
if (actionType === 'ban') {
try {
await guild.bans.remove(discordUserId, reason)
} catch (err) {
// Unknown Ban → already unbanned; anything else is a real failure.
if (err.code !== UNKNOWN_BAN) throw err
}
} else {
// mute: clear the timeout. If the member has left, there's nothing to clear.
const member = await guild.members.fetch(discordUserId).catch(() => null)
if (member) await member.timeout(null, reason)
}
await modLog.postReversal({
client: connection.client,
guildId: connection.guildId,
actionType,
discordUserId,
appealId,
})
return res.json({ reversed: true })
} catch (err) {
log.error('mod-reverse failed', { message: err.message, actionType, discordUserId })
return res.status(500).json({ message: err.message })
}
}
module.exports = { setConfig, getStatus: getStatusHandler, announce, reverseModAction }

View File

@@ -0,0 +1,15 @@
const express = require('express')
const requireInternalKey = require('./requireInternalKey')
const ctrl = require('./internal.controller')
const router = express.Router()
router.use(requireInternalKey)
router.post('/config', ctrl.setConfig)
router.get('/status', ctrl.getStatus)
router.post('/announce', ctrl.announce)
router.post('/mod-reverse', ctrl.reverseModAction)
module.exports = router

View File

@@ -0,0 +1,19 @@
// Gate for the bot's /internal/* API. The only caller is the main Runic Gateway
// server, over the private compose network — never expose this route through
// the public reverse proxy. Timing-safe compare so response time can't be used
// to brute-force the shared secret one byte at a time.
const crypto = require('crypto')
function requireInternalKey(req, res, next) {
const expected = process.env.BOT_INTERNAL_KEY || ''
const provided = req.get('X-Internal-Key') || ''
const a = Buffer.from(expected)
const b = Buffer.from(provided)
const match = expected.length > 0 && a.length === b.length && crypto.timingSafeEqual(a, b)
if (!match) return res.status(401).json({ message: 'Unauthorized' })
return next()
}
module.exports = requireInternalKey

View File

@@ -0,0 +1,37 @@
// Shared by both /invite rotate and the weekly cron job (inviteScheduler.js)
// so manual and automatic rotations log identically. maxAge is set to match
// the rotation cadence as defense-in-depth: if the scheduled rotation were
// ever to silently stop running, the invite still expires on its own instead
// of staying live forever.
const guildConfig = require('../model/guildConfig')
const inviteLog = require('../model/inviteLog')
const createLogger = require('../utils/logger')
const log = createLogger('invites')
const ROTATION_MAX_AGE_SECONDS = 7 * 24 * 60 * 60 // 7 days
async function rotate(client, guildId, { triggeredBy, triggeredByTag } = {}) {
const channelId = await guildConfig.getInviteChannelId(guildId)
if (!channelId) throw new Error('No invite channel configured — set one with /invite channel first.')
const channel = await client.channels.fetch(channelId)
if (!channel || !channel.isTextBased()) throw new Error('Configured invite channel is missing or not text-based.')
const current = await inviteLog.getCurrent(guildId)
if (current) {
try {
await channel.guild.invites.delete(current.invite_code, 'Invite rotation')
} catch (err) {
log.warn('failed to revoke previous invite (may already be gone)', { message: err.message })
}
await inviteLog.markRevoked(current.id)
}
const invite = await channel.createInvite({ maxAge: ROTATION_MAX_AGE_SECONDS, unique: true, reason: 'Invite rotation' })
await inviteLog.record({ guildId, channelId, inviteCode: invite.code, triggeredBy, triggeredByTag })
log.info('invite rotated', { code: invite.code, triggeredBy: triggeredByTag || 'automatic (scheduled)' })
return invite
}
module.exports = { rotate }

View File

@@ -0,0 +1,31 @@
// Weekly automatic invite rotation (Sundays at midnight). A missing invite
// channel config just skips quietly (warn-logged) — most guilds won't set
// this up on day one, and that shouldn't spam errors every week until they do.
const cron = require('node-cron')
const inviteRotator = require('./inviteRotator')
const createLogger = require('../utils/logger')
const log = createLogger('invites')
let task = null
function start(client, guildId) {
task = cron.schedule('0 0 * * 0', async () => {
try {
await inviteRotator.rotate(client, guildId, {})
} catch (err) {
log.warn('scheduled invite rotation skipped', { message: err.message })
}
})
log.info('invite rotation scheduler started')
}
function stop() {
if (task) {
task.stop()
task = null
}
}
module.exports = { start, stop }

View File

@@ -0,0 +1,40 @@
// Roles/channels that bypass word/invite/spam filtering entirely (staff roles,
// bot-commands channels, etc.). Stored as CSV in guild_config rather than a
// separate table — short, rarely-changed lists.
const guildConfig = require('./guildConfig')
const ROLES_KEY = 'filter_allow_roles'
const CHANNELS_KEY = 'filter_allow_channels'
function parseCsv(value) {
return value ? value.split(',').filter(Boolean) : []
}
async function getRoles(guildId) {
return parseCsv(await guildConfig.get(guildId, ROLES_KEY))
}
async function getChannels(guildId) {
return parseCsv(await guildConfig.get(guildId, CHANNELS_KEY))
}
// Toggle: adds the id if absent, removes it if present. Returns the new state (true = now allowed).
async function toggleRole(guildId, roleId) {
const roles = await getRoles(guildId)
const idx = roles.indexOf(roleId)
if (idx === -1) roles.push(roleId)
else roles.splice(idx, 1)
await guildConfig.set(guildId, ROLES_KEY, roles.join(','))
return idx === -1
}
async function toggleChannel(guildId, channelId) {
const channels = await getChannels(guildId)
const idx = channels.indexOf(channelId)
if (idx === -1) channels.push(channelId)
else channels.splice(idx, 1)
await guildConfig.set(guildId, CHANNELS_KEY, channels.join(','))
return idx === -1
}
module.exports = { getRoles, getChannels, toggleRole, toggleChannel }

View File

@@ -0,0 +1,15 @@
// Automated content-filter hits (Phase 6b). Bot-owned; recorded whenever the
// word filter or foreign-invite filter deletes a message. mod_actions still
// records the resulting warn/mute separately. Schema: server/db/schema.sql
// (filter_hits).
const db = require('../db')
async function record({ guildId, hitType, discordUserId, username, channelId, matched, actionTaken }) {
await db.query(
`INSERT INTO filter_hits (guild_id, hit_type, discord_user_id, username, channel_id, matched, action_taken)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[guildId, hitType, discordUserId, username || null, channelId || null, matched || null, actionTaken],
)
}
module.exports = { record }

View File

@@ -0,0 +1,22 @@
const db = require('../db')
async function add({ guildId, word, severity, addedBy, addedByTag }) {
await db.query(
`INSERT INTO filter_words (guild_id, word, severity, added_by, added_by_tag)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE severity = VALUES(severity), added_by = VALUES(added_by), added_by_tag = VALUES(added_by_tag)`,
[guildId, word.toLowerCase(), severity || 'delete', addedBy || null, addedByTag || null],
)
}
// Returns true if a row was actually removed.
async function remove(guildId, word) {
const res = await db.query('DELETE FROM filter_words WHERE guild_id = ? AND word = ?', [guildId, word.toLowerCase()])
return Number(res.affectedRows || 0) > 0
}
async function list(guildId) {
return db.query('SELECT word, severity FROM filter_words WHERE guild_id = ? ORDER BY word ASC', [guildId])
}
module.exports = { add, remove, list }

View File

@@ -0,0 +1,47 @@
// Per-guild key/value config the bot owns (see guild_config in
// server/db/schema.sql). Generic get/set now; filters/schedules/role-menu
// config reuses this same table in later phases.
const db = require('../db')
const MOD_LOG_CHANNEL_KEY = 'mod_log_channel_id'
const AUTO_ROLE_KEY = 'auto_role_id'
const INVITE_CHANNEL_KEY = 'invite_channel_id'
const NEWS_CHANNEL_KEY = 'news_channel_id'
async function get(guildId, key) {
const rows = await db.query('SELECT value FROM guild_config WHERE guild_id = ? AND `key` = ? LIMIT 1', [guildId, key])
return rows[0] ? rows[0].value : null
}
async function set(guildId, key, value) {
await db.query(
`INSERT INTO guild_config (guild_id, \`key\`, value) VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE value = VALUES(value)`,
[guildId, key, value],
)
}
const getModLogChannelId = (guildId) => get(guildId, MOD_LOG_CHANNEL_KEY)
const setModLogChannelId = (guildId, channelId) => set(guildId, MOD_LOG_CHANNEL_KEY, channelId)
const getAutoRoleId = (guildId) => get(guildId, AUTO_ROLE_KEY)
const setAutoRoleId = (guildId, roleId) => set(guildId, AUTO_ROLE_KEY, roleId)
const getInviteChannelId = (guildId) => get(guildId, INVITE_CHANNEL_KEY)
const setInviteChannelId = (guildId, channelId) => set(guildId, INVITE_CHANNEL_KEY, channelId)
const getNewsChannelId = (guildId) => get(guildId, NEWS_CHANNEL_KEY)
const setNewsChannelId = (guildId, channelId) => set(guildId, NEWS_CHANNEL_KEY, channelId)
module.exports = {
get,
set,
getModLogChannelId,
setModLogChannelId,
getAutoRoleId,
setAutoRoleId,
getInviteChannelId,
setInviteChannelId,
getNewsChannelId,
setNewsChannelId,
}

View File

@@ -0,0 +1,29 @@
const db = require('../db')
async function record({ guildId, channelId, inviteCode, triggeredBy, triggeredByTag }) {
const res = await db.query(
`INSERT INTO invite_log (guild_id, channel_id, invite_code, triggered_by, triggered_by_tag)
VALUES (?, ?, ?, ?, ?)`,
[guildId, channelId, inviteCode, triggeredBy || null, triggeredByTag || null],
)
return res.insertId
}
// The active (not-yet-revoked) invite for a guild, if any.
async function getCurrent(guildId) {
const rows = await db.query(
'SELECT * FROM invite_log WHERE guild_id = ? AND revoked_at IS NULL ORDER BY created_at DESC LIMIT 1',
[guildId],
)
return rows[0] || null
}
async function markRevoked(id) {
await db.query('UPDATE invite_log SET revoked_at = NOW() WHERE id = ?', [id])
}
async function list(guildId, limit = 10) {
return db.query('SELECT * FROM invite_log WHERE guild_id = ? ORDER BY created_at DESC LIMIT ?', [guildId, limit])
}
module.exports = { record, getCurrent, markRevoked, list }

View File

@@ -0,0 +1,14 @@
// Guild member join/leave events (Phase 6b). Bot-owned; the site reads these for
// the moderation dashboard's members feed + invite-usage view. Schema in
// server/db/schema.sql (member_events).
const db = require('../db')
async function record({ guildId, eventType, discordUserId, username, inviteCode, inviterId, inviterTag }) {
await db.query(
`INSERT INTO member_events (guild_id, event_type, discord_user_id, username, invite_code, inviter_id, inviter_tag)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[guildId, eventType, discordUserId, username || null, inviteCode || null, inviterId || null, inviterTag || null],
)
}
module.exports = { record }

View File

@@ -0,0 +1,17 @@
const db = require('../db')
async function add({ guildId, channelId, messageId, mapping, createdBy }) {
await db.query(
`INSERT INTO role_menus (guild_id, channel_id, message_id, mapping, created_by)
VALUES (?, ?, ?, ?, ?)`,
[guildId, channelId, messageId, JSON.stringify(mapping), createdBy || null],
)
}
async function getByMessageId(messageId) {
const rows = await db.query('SELECT * FROM role_menus WHERE message_id = ? LIMIT 1', [messageId])
if (!rows[0]) return null
return { ...rows[0], mapping: JSON.parse(rows[0].mapping) }
}
module.exports = { add, getByMessageId }

View File

@@ -0,0 +1,57 @@
const db = require('../db')
async function addRecurring({ guildId, channelId, content, cronExpression, createdBy, createdByTag }) {
const res = await db.query(
`INSERT INTO scheduled_messages (guild_id, channel_id, content, cron_expression, created_by, created_by_tag)
VALUES (?, ?, ?, ?, ?, ?)`,
[guildId, channelId, content, cronExpression, createdBy || null, createdByTag || null],
)
return res.insertId
}
async function addOnce({ guildId, channelId, content, runAt, createdBy, createdByTag }) {
const res = await db.query(
`INSERT INTO scheduled_messages (guild_id, channel_id, content, run_at, created_by, created_by_tag)
VALUES (?, ?, ?, ?, ?, ?)`,
[guildId, channelId, content, runAt, createdBy || null, createdByTag || null],
)
return res.insertId
}
// Returns true if a row was actually removed (scoped to the guild so one
// guild can't remove another's rows).
async function remove(guildId, id) {
const res = await db.query('DELETE FROM scheduled_messages WHERE id = ? AND guild_id = ?', [id, guildId])
return Number(res.affectedRows || 0) > 0
}
async function list(guildId) {
return db.query(
`SELECT id, channel_id, content, cron_expression, run_at, enabled, sent_at FROM scheduled_messages
WHERE guild_id = ? ORDER BY id ASC`,
[guildId],
)
}
// All enabled recurring rows across every guild the bot serves — v1 only
// ever has one, but the scheduler doesn't need to special-case that.
async function listEnabledRecurring() {
return db.query(
`SELECT id, guild_id, channel_id, content, cron_expression FROM scheduled_messages
WHERE cron_expression IS NOT NULL AND enabled = 1`,
)
}
// One-off rows due to post right now.
async function listDueOneOff() {
return db.query(
`SELECT id, guild_id, channel_id, content FROM scheduled_messages
WHERE run_at IS NOT NULL AND sent_at IS NULL AND enabled = 1 AND run_at <= NOW()`,
)
}
async function markSent(id) {
await db.query('UPDATE scheduled_messages SET sent_at = NOW() WHERE id = ?', [id])
}
module.exports = { addRecurring, addOnce, remove, list, listEnabledRecurring, listDueOneOff, markSent }

14
bot/src/model/spamHits.js Normal file
View File

@@ -0,0 +1,14 @@
// Automated spam-detection hits (Phase 6b). Bot-owned; recorded when the
// rate-limit / mass-mention / mass-emoji checks trip. mod_actions still logs the
// resulting warn separately. Schema: server/db/schema.sql (spam_hits).
const db = require('../db')
async function record({ guildId, spamType, discordUserId, username, channelId }) {
await db.query(
`INSERT INTO spam_hits (guild_id, spam_type, discord_user_id, username, channel_id)
VALUES (?, ?, ?, ?, ?)`,
[guildId, spamType, discordUserId, username || null, channelId || null],
)
}
module.exports = { record }

View File

@@ -0,0 +1,26 @@
const db = require('../db')
// Upsert — re-granting the same temp role refreshes its expiry instead of
// creating a duplicate row (see UNIQUE(guild,user,role) in schema.sql).
async function add({ guildId, userId, roleId, expiresAt, createdBy }) {
await db.query(
`INSERT INTO temp_roles (guild_id, user_id, role_id, expires_at, created_by)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE expires_at = VALUES(expires_at), created_by = VALUES(created_by)`,
[guildId, userId, roleId, expiresAt, createdBy || null],
)
}
async function remove(guildId, userId, roleId) {
await db.query('DELETE FROM temp_roles WHERE guild_id = ? AND user_id = ? AND role_id = ?', [guildId, userId, roleId])
}
async function listExpired() {
return db.query('SELECT id, guild_id, user_id, role_id FROM temp_roles WHERE expires_at <= NOW()')
}
async function removeById(id) {
await db.query('DELETE FROM temp_roles WHERE id = ?', [id])
}
module.exports = { add, remove, listExpired, removeById }

26
bot/src/model/warnings.js Normal file
View File

@@ -0,0 +1,26 @@
// Standing warnings (separate from mod_actions so /warnings can list a
// user's active warnings). expires_at is always NULL for now — decay/escalation
// (e.g. "3 active warns -> auto-mute") is deferred past Phase 2, see
// warn.command.js.
const db = require('../db')
async function add({ guildId, targetUserId, targetTag, staffUserId, staffTag, reason }) {
await db.query(
`INSERT INTO warnings (guild_id, target_user_id, target_tag, staff_user_id, staff_tag, reason)
VALUES (?, ?, ?, ?, ?, ?)`,
[guildId, targetUserId, targetTag || null, staffUserId, staffTag || null, reason || null],
)
}
// Active = not expired. Every row is active today since expires_at is never
// set, but the query is written to already respect it once decay lands.
async function listActive(guildId, targetUserId) {
return db.query(
`SELECT id, reason, staff_tag, created_at FROM warnings
WHERE guild_id = ? AND target_user_id = ? AND (expires_at IS NULL OR expires_at > NOW())
ORDER BY created_at DESC`,
[guildId, targetUserId],
)
}
module.exports = { add, listActive }

View File

@@ -0,0 +1,48 @@
// Once-a-minute sweep for expired temp_roles: removes the Discord role (best
// effort — the member/guild/role may already be gone) then deletes the row
// regardless, so a stale row can never block future re-grants of the same
// role to the same member.
const cron = require('node-cron')
const tempRoles = require('../model/tempRoles')
const createLogger = require('../utils/logger')
const log = createLogger('temproles')
let client = null
let task = null
async function sweep() {
try {
const expired = await tempRoles.listExpired()
for (const row of expired) {
try {
const guild = await client.guilds.fetch(row.guild_id)
const member = await guild.members.fetch(row.user_id).catch(() => null)
if (member) await member.roles.remove(row.role_id).catch(() => {})
} catch (err) {
log.warn('failed to remove expired temp role', { message: err.message, roleId: row.role_id, userId: row.user_id })
} finally {
await tempRoles.removeById(row.id)
}
}
} catch (err) {
log.error('temp role sweep failed', { message: err.message })
}
}
function start(discordClient) {
client = discordClient
task = cron.schedule('* * * * *', sweep)
log.info('temp role sweeper started')
}
function stop() {
if (task) {
task.stop()
task = null
}
client = null
}
module.exports = { start, stop }

View File

@@ -0,0 +1,83 @@
// Recurring + one-off scheduled channel messages. Recurring rows are each
// registered as their own node-cron task; one-off rows are picked up by a
// once-a-minute sweep that checks for anything due and marks it sent so it
// never reposts. Needs a live discord.js Client to actually send — wired up
// by discordManager.js (start() once the client is ready, stop() alongside
// client teardown).
const cron = require('node-cron')
const scheduledMessages = require('../model/scheduledMessages')
const createLogger = require('../utils/logger')
const log = createLogger('scheduler')
let discordClient = null
const recurringTasks = new Map() // id -> node-cron ScheduledTask
let sweepTask = null
async function sendToChannel(channelId, content) {
try {
const channel = await discordClient.channels.fetch(channelId)
if (!channel || !channel.isTextBased()) {
log.warn('scheduled message skipped — channel missing or not text-based', { channelId })
return
}
await channel.send({ content })
log.info('sent scheduled message', { channelId })
} catch (err) {
log.warn('failed to send scheduled message', { channelId, message: err.message })
}
}
async function loadRecurring() {
for (const task of recurringTasks.values()) task.stop()
recurringTasks.clear()
const rows = await scheduledMessages.listEnabledRecurring()
for (const row of rows) {
if (!cron.validate(row.cron_expression)) {
log.warn('skipping scheduled message with invalid cron expression', { id: row.id, cron: row.cron_expression })
continue
}
const task = cron.schedule(row.cron_expression, () => sendToChannel(row.channel_id, row.content))
recurringTasks.set(row.id, task)
}
log.info('loaded recurring scheduled messages', { count: recurringTasks.size })
}
async function sweepDueOneOff() {
try {
const due = await scheduledMessages.listDueOneOff()
for (const row of due) {
await sendToChannel(row.channel_id, row.content)
await scheduledMessages.markSent(row.id)
}
} catch (err) {
log.error('one-off sweep failed', { message: err.message })
}
}
async function start(client) {
discordClient = client
await loadRecurring()
sweepTask = cron.schedule('* * * * *', sweepDueOneOff)
log.info('scheduler started')
}
// Called by /schedule after any add/remove so changes apply without a restart.
async function refresh() {
if (!discordClient) return
await loadRecurring()
}
function stop() {
for (const task of recurringTasks.values()) task.stop()
recurringTasks.clear()
if (sweepTask) {
sweepTask.stop()
sweepTask = null
}
discordClient = null
}
module.exports = { start, stop, refresh }

53
bot/src/server.js Normal file
View File

@@ -0,0 +1,53 @@
require('dotenv').config()
const app = require('./app')
const bootstrap = require('./bootstrap')
const createLogger = require('./utils/logger')
const discordManager = require('./discord/discordManager')
const brand = require('./brand')
const pkg = require('../package.json')
const log = createLogger('server')
const PORT = Number(process.env.PORT) || 4100
const HOST = '0.0.0.0'
async function start() {
log.info(`starting ${brand.name} bot v${pkg.version}`, {
node: process.version,
logFile: createLogger.logFilePath || 'disabled (console only)',
})
const server = app.listen(PORT, HOST, () => {
log.info(`internal API listening on http://${HOST}:${PORT}`)
})
await bootstrap()
setupShutdown(server)
}
function setupShutdown(server) {
let closing = false
const shutdown = async (signal) => {
if (closing) return
closing = true
log.warn(`${signal} received — shutting down gracefully`)
server.close(() => log.info('internal API closed'))
await discordManager.stop()
await createLogger.close()
process.exit(0)
}
process.on('SIGINT', () => shutdown('SIGINT'))
process.on('SIGTERM', () => shutdown('SIGTERM'))
process.on('unhandledRejection', (reason) => log.error('unhandledRejection', { reason: String(reason) }))
process.on('uncaughtException', (err) => {
log.error('uncaughtException', err)
process.exit(1)
})
}
start().catch((err) => {
log.error('failed to start bot', err)
process.exit(1)
})

View File

@@ -0,0 +1,42 @@
// Read-only client for the main site's PUBLIC API (no shared secret — this is
// the same unauthenticated data any visitor's browser can fetch). Used by
// /wiki (search) and /announce (re-post an existing news item). Distinct from
// botInternalClient.js, which is the shared-secret-gated server<->bot channel.
const createLogger = require('../utils/logger')
const log = createLogger('site-api')
const BASE_URL = (process.env.SITE_PUBLIC_URL || 'http://localhost:3000/api/v1/public').replace(/\/+$/, '')
const TIMEOUT_MS = 5000
async function call(path) {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS)
try {
const res = await fetch(`${BASE_URL}${path}`, { signal: controller.signal })
const data = await res.json().catch(() => null)
// Public content routes 503 with this shape while the site is in
// maintenance mode (see server/src/middleware/siteMode.js) — surface it
// distinctly so commands can show a clear message instead of a generic error.
if (res.status === 503 && data?.mode === 'maintenance') {
return { ok: false, maintenance: true, message: data.message }
}
if (!res.ok) return { ok: false, error: `site responded ${res.status}` }
return { ok: true, data }
} catch (err) {
log.warn('site API call failed', { path, message: err.message })
return { ok: false, error: err.message }
} finally {
clearTimeout(timeout)
}
}
function getNewsPost(idOrSlug) {
return call(`/posts/news/${encodeURIComponent(idOrSlug)}`)
}
function searchWiki(query) {
return call(`/wiki?q=${encodeURIComponent(query)}`)
}
module.exports = { getNewsPost, searchWiki }

16
bot/src/utils/duration.js Normal file
View File

@@ -0,0 +1,16 @@
// Parses simple duration strings ("30s", "10m", "2h", "1d") to milliseconds.
// Returns null for anything unparseable. Discord's own timeout API caps at 28
// days — callers should clamp to MAX_TIMEOUT_MS rather than trust user input.
const UNIT_MS = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }
const MAX_TIMEOUT_MS = 28 * 86_400_000
function parseDuration(input) {
if (!input) return null
const match = /^(\d+)\s*([smhd])$/i.exec(input.trim())
if (!match) return null
const [, amount, unit] = match
return Number(amount) * UNIT_MS[unit.toLowerCase()]
}
module.exports = { parseDuration, MAX_TIMEOUT_MS }

97
bot/src/utils/logger.js Normal file
View File

@@ -0,0 +1,97 @@
// Dual-transport logger: writes to the console AND to a log file.
// Levels: error | warn | info | debug.
// LOG_LEVEL console verbosity (default info)
// FILE_LOG_LEVEL file verbosity (default debug — keep a full record on disk)
// LOG_TO_FILE enable file logging (default true)
// LOG_DIR log directory (default <bot>/logs)
// LOG_FILE log file name (default bot.log)
//
// Copied from server/src/utils/logger.js rather than shared — the bot is an
// independently deployable process with its own package.json/Dockerfile.
const fs = require('fs')
const path = require('path')
const LEVELS = { error: 0, warn: 1, info: 2, debug: 3 }
const consoleThreshold = LEVELS[(process.env.LOG_LEVEL || 'info').toLowerCase()] ?? LEVELS.info
const fileThreshold = LEVELS[(process.env.FILE_LOG_LEVEL || 'debug').toLowerCase()] ?? LEVELS.debug
// Color only on an interactive TTY — never in files or Docker logs.
const useColor = Boolean(process.stdout.isTTY) && process.env.NO_COLOR == null
const COLOR = { error: '\x1b[31m', warn: '\x1b[33m', info: '\x1b[36m', debug: '\x1b[90m' }
const RESET = '\x1b[0m'
// ── File transport ────────────────────────────────────────────────────
const fileEnabled = (process.env.LOG_TO_FILE || 'true').toLowerCase() !== 'false'
let fileStream = null
let logFilePath = null
if (fileEnabled) {
try {
const dir = process.env.LOG_DIR || path.join(__dirname, '..', '..', 'logs')
fs.mkdirSync(dir, { recursive: true })
logFilePath = path.join(dir, process.env.LOG_FILE || 'bot.log')
fileStream = fs.createWriteStream(logFilePath, { flags: 'a' })
fileStream.on('error', (err) => {
process.stderr.write(`[logger] file logging disabled: ${err.message}\n`)
fileStream = null
})
} catch (err) {
process.stderr.write(`[logger] could not open log file: ${err.message}\n`)
fileStream = null
}
}
function fmt(meta) {
if (meta == null) return ''
if (typeof meta === 'string') return meta
if (meta instanceof Error) return JSON.stringify({ message: meta.message, stack: meta.stack })
try {
return JSON.stringify(meta)
} catch {
return String(meta)
}
}
function emit(level, tag, msg, meta) {
const levelNum = LEVELS[level]
if (levelNum === undefined) return
const ts = new Date().toISOString()
const lvl = level.toUpperCase().padEnd(5)
const label = tag ? ` [${tag}]` : ''
const metaStr = meta === undefined ? '' : ` ${fmt(meta)}`
const plain = `${ts} ${lvl}${label} ${msg}${metaStr}`
// Console transport
if (levelNum <= consoleThreshold) {
const line = useColor ? `${COLOR[level] || ''}${plain}${RESET}` : plain
const stream = level === 'error' || level === 'warn' ? process.stderr : process.stdout
stream.write(`${line}\n`)
}
// File transport (plain text, no color)
if (fileStream && levelNum <= fileThreshold) {
fileStream.write(`${plain}\n`)
}
}
function createLogger(tag) {
return {
error: (msg, meta) => emit('error', tag, msg, meta),
warn: (msg, meta) => emit('warn', tag, msg, meta),
info: (msg, meta) => emit('info', tag, msg, meta),
debug: (msg, meta) => emit('debug', tag, msg, meta),
}
}
// Flush and close the file stream (called on graceful shutdown).
createLogger.close = () =>
new Promise((resolve) => {
if (fileStream) fileStream.end(resolve)
else resolve()
})
createLogger.emit = emit
createLogger.logFilePath = logFilePath
module.exports = createLogger

15
brand/README.md Normal file
View File

@@ -0,0 +1,15 @@
# Brand assets (per-instance)
This directory is bind-mounted into the container at `/app/brand` (see
`docker-compose.yml`). Drop instance branding images here and point the matching
`BRAND_*` env vars at them, e.g.:
```
BRAND_LOGO=/brand/logo.png
BRAND_HERO=/brand/hero.png
BRAND_FAVICON=/brand/favicon.ico
```
Leave the vars blank to use the built-in defaults (the hero falls back to a
neutral built-in image; no logo/favicon is injected). Nothing here is required
for the app to run — it renders cleanly with an empty `brand/`.

View File

@@ -3,8 +3,8 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>UOMysticmoon</title>
<meta name="description" content="UOMysticmoon — an independent private Ultima Online shard. News, screenshots, guides, and community notes." />
<title>Runic Gateway</title>
<meta name="description" content="Runic Gateway — an independent private Ultima Online shard. News, screenshots, guides, and community notes." />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600;700&display=swap" rel="stylesheet" />

View File

@@ -1,15 +1,16 @@
{
"name": "uomysticmoon-client",
"name": "runic-gateway-client",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "uomysticmoon-client",
"name": "runic-gateway-client",
"version": "1.0.0",
"dependencies": {
"@tiptap/extension-image": "^2.27.2",
"@tiptap/extension-link": "^2.27.2",
"@tiptap/extension-text-align": "^2.27.2",
"@tiptap/react": "^2.27.2",
"@tiptap/starter-kit": "^2.27.2",
"diff": "^5.2.2",
@@ -1483,6 +1484,19 @@
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-text-align": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-2.27.2.tgz",
"integrity": "sha512-0Pyks6Hu+Q/+9+5/osoSv0SP6jIerdWMYbi13aaZLsJoj3lBj5WNaE11JtAwSFN5sx0IbqhDSlp1zkvRnzgZ8g==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-text-style": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.27.2.tgz",

View File

@@ -1,16 +1,18 @@
{
"name": "uomysticmoon-client",
"name": "runic-gateway-client",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "node --test"
},
"dependencies": {
"@tiptap/extension-image": "^2.27.2",
"@tiptap/extension-link": "^2.27.2",
"@tiptap/extension-text-align": "^2.27.2",
"@tiptap/react": "^2.27.2",
"@tiptap/starter-kit": "^2.27.2",
"diff": "^5.2.2",

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@@ -3,6 +3,8 @@ import { AuthProvider } from './contexts/AuthContext.jsx'
import { SiteProvider } from './contexts/SiteContext.jsx'
import MaintenanceGate from './components/MaintenanceGate.jsx'
import RequireAuth from './components/RequireAuth.jsx'
import RequirePlayer from './components/RequirePlayer.jsx'
import RoleGate from './components/RoleGate.jsx'
// Public
import Portal from './routes/public/Portal.jsx'
@@ -14,26 +16,74 @@ import Newsletter from './routes/public/Newsletter.jsx'
import NewsletterIssue from './routes/public/NewsletterIssue.jsx'
import About from './routes/public/About.jsx'
import Status from './routes/public/Status.jsx'
import Shard from './routes/public/Shard.jsx'
import ShardActivity from './routes/public/ShardActivity.jsx'
import ChampSpawns from './routes/public/ChampSpawns.jsx'
import Guilds from './routes/public/Guilds.jsx'
import Governors from './routes/public/Governors.jsx'
import Houses from './routes/public/Houses.jsx'
import Rules from './routes/public/Rules.jsx'
import Atlas from './routes/public/Atlas.jsx'
import AtlasCreature from './routes/public/AtlasCreature.jsx'
import Leaderboards from './routes/public/Leaderboards.jsx'
import Market from './routes/public/Market.jsx'
import MarketVendor from './routes/public/MarketVendor.jsx'
import Wiki from './routes/wiki/Wiki.jsx'
import WikiArticle from './routes/wiki/WikiArticle.jsx'
import CmsPage from './routes/public/CmsPage.jsx'
// Admin
import AdminLogin from './routes/admin/AdminLogin.jsx'
import AdminLayout from './routes/admin/AdminLayout.jsx'
import Dashboard from './routes/admin/views/Dashboard.jsx'
import PostsAdmin from './routes/admin/views/PostsAdmin.jsx'
import PagesAdmin from './routes/admin/views/PagesAdmin.jsx'
import PageBuilder from './routes/admin/views/PageBuilder.jsx'
import WikiAdmin from './routes/admin/views/WikiAdmin.jsx'
import HeroEditor from './routes/admin/views/HeroEditor.jsx'
import SettingsAdmin from './routes/admin/views/SettingsAdmin.jsx'
import ActivityAdmin from './routes/admin/views/ActivityAdmin.jsx'
import BotActivityAdmin from './routes/admin/views/BotActivityAdmin.jsx'
import DiscordBotAdmin from './routes/admin/views/DiscordBotAdmin.jsx'
import ShardAdmin from './routes/admin/views/ShardAdmin.jsx'
import ShardVisibility from './routes/admin/views/ShardVisibility.jsx'
import SpawnAtlasAdmin from './routes/admin/views/SpawnAtlas.jsx'
import ShardOps from './routes/admin/views/ShardOps.jsx'
import AdminCharacters from './routes/admin/views/AdminCharacters.jsx'
import AdminCharacter from './routes/admin/views/AdminCharacter.jsx'
import AuthProvidersAdmin from './routes/admin/views/AuthProvidersAdmin.jsx'
import UsersAdmin from './routes/admin/views/UsersAdmin.jsx'
import UserDetail from './routes/admin/views/UserDetail.jsx'
import InvitesAdmin from './routes/admin/views/InvitesAdmin.jsx'
import HousesAdmin from './routes/admin/views/HousesAdmin.jsx'
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
import Moderation from './routes/admin/views/Moderation.jsx'
import ModerationUser from './routes/admin/views/ModerationUser.jsx'
import Appeals from './routes/admin/views/Appeals.jsx'
// Player portal
import PlayerLogin from './routes/player/PlayerLogin.jsx'
import PlayerRegister from './routes/player/PlayerRegister.jsx'
import ForgotPassword from './routes/player/ForgotPassword.jsx'
import ResetPassword from './routes/player/ResetPassword.jsx'
import AcceptInvite from './routes/player/AcceptInvite.jsx'
import PlayerPortalLayout from './routes/player/PlayerPortalLayout.jsx'
import PlayerCharacters from './routes/player/PlayerCharacters.jsx'
import PlayerCharacter from './routes/player/PlayerCharacter.jsx'
import PlayerAccount from './routes/player/PlayerAccount.jsx'
import PlayerAppeals from './routes/player/PlayerAppeals.jsx'
export default function App() {
return (
<AuthProvider>
<SiteProvider>
<Routes>
{/* Public site — gated by maintenance mode (admins preview through it) */}
{/* Landing hero — always public, even in maintenance mode. The hero is
itself the pre-launch "coming soon" page, so it sits outside the
MaintenanceGate and every visitor sees it regardless of auth/site mode. */}
<Route path="/" element={<Portal />} />
{/* Rest of the public site — gated by maintenance mode (admins preview through it) */}
<Route
element={
<MaintenanceGate>
@@ -41,7 +91,6 @@ export default function App() {
</MaintenanceGate>
}
>
<Route path="/" element={<Portal />} />
<Route path="/site" element={<Website />} />
<Route path="/site/news" element={<News />} />
<Route path="/site/screenshots" element={<Screenshots />} />
@@ -50,10 +99,29 @@ export default function App() {
<Route path="/site/newsletter/:id" element={<NewsletterIssue />} />
<Route path="/site/about" element={<About />} />
<Route path="/site/status" element={<Status />} />
<Route path="/site/shard" element={<Shard />} />
<Route path="/site/shard/activity" element={<ShardActivity />} />
<Route path="/site/champs" element={<ChampSpawns />} />
<Route path="/site/guilds" element={<Guilds />} />
<Route path="/site/governors" element={<Governors />} />
<Route path="/site/houses" element={<Houses />} />
<Route path="/site/rules" element={<Rules />} />
<Route path="/site/atlas" element={<Atlas />} />
<Route path="/site/atlas/:slug" element={<AtlasCreature />} />
<Route path="/site/leaderboards" element={<Leaderboards />} />
<Route path="/site/market" element={<Market />} />
<Route path="/site/market/vendors/:serial" element={<MarketVendor />} />
<Route path="/wiki" element={<Wiki />} />
<Route path="/wiki/:slug" element={<WikiArticle />} />
{/* CMS pages: top-level /:slug, matched only after the named routes
above (React Router ranks static routes over this dynamic one). */}
<Route path="/:slug" element={<CmsPage />} />
</Route>
{/* Draft-preview link (token-gated). Outside the maintenance gate so a
preview link works regardless of site mode. */}
<Route path="/preview/:id/:token" element={<CmsPage preview />} />
{/* Admin */}
<Route path="/admin/login" element={<AdminLogin />} />
<Route
@@ -66,14 +134,75 @@ export default function App() {
>
<Route index element={<Dashboard />} />
<Route path="posts" element={<PostsAdmin />} />
<Route path="pages" element={<PagesAdmin />} />
<Route path="pages/new" element={<PageBuilder />} />
<Route path="pages/:id" element={<PageBuilder />} />
<Route path="wiki" element={<WikiAdmin />} />
<Route path="hero" element={<HeroEditor />} />
<Route path="settings" element={<SettingsAdmin />} />
<Route
path="moderation"
element={
<RoleGate roles={['admin', 'moderator']}>
<Outlet />
</RoleGate>
}
>
<Route index element={<Moderation />} />
<Route path="user/:discordId" element={<ModerationUser />} />
<Route path="appeals" element={<Appeals />} />
</Route>
<Route path="activity" element={<ActivityAdmin />} />
<Route path="bot-activity" element={<BotActivityAdmin />} />
<Route path="discord-bot" element={<DiscordBotAdmin />} />
<Route path="shard" element={<ShardAdmin />} />
<Route path="shard-visibility" element={<ShardVisibility />} />
<Route path="shard-atlas" element={<SpawnAtlasAdmin />} />
<Route
path="shard-ops"
element={
<RoleGate roles={['admin', 'moderator']}>
<ShardOps />
</RoleGate>
}
/>
<Route
path="houses"
element={
<RoleGate roles={['admin', 'moderator']}>
<HousesAdmin />
</RoleGate>
}
/>
<Route path="characters" element={<AdminCharacters />} />
<Route path="characters/:serial" element={<AdminCharacter />} />
<Route path="auth-providers" element={<AuthProvidersAdmin />} />
<Route path="users" element={<UsersAdmin />} />
<Route path="users/:id" element={<UserDetail />} />
<Route path="invites" element={<InvitesAdmin />} />
<Route path="account" element={<AccountAdmin />} />
<Route path="*" element={<Navigate to="/admin" replace />} />
</Route>
{/* Player portal */}
<Route path="/account/login" element={<PlayerLogin />} />
<Route path="/account/register" element={<PlayerRegister />} />
<Route path="/account/forgot" element={<ForgotPassword />} />
<Route path="/account/reset/:token" element={<ResetPassword />} />
<Route path="/invite/:token" element={<AcceptInvite />} />
<Route
element={
<RequirePlayer>
<PlayerPortalLayout />
</RequirePlayer>
}
>
<Route path="/player" element={<PlayerCharacters />} />
<Route path="/player/char/:serial" element={<PlayerCharacter />} />
<Route path="/account" element={<PlayerAccount />} />
<Route path="/account/appeals" element={<PlayerAppeals />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</SiteProvider>

View File

@@ -2,6 +2,10 @@
// same-origin API (/api/v1) — proxied to the Express server in dev.
const BASE = '/api/v1'
// Prefix a non-empty query string with "?" (and nothing when it is empty), so
// callers can append it to a path without a dangling "?".
const withQs = (s) => (s ? `?${s}` : '')
class ApiError extends Error {
constructor(status, message, body) {
super(message)
@@ -41,8 +45,57 @@ function safeParse(text) {
export const api = {
// ----- auth -----
me: () => req('/auth/me'),
login: (username, password) => req('/auth/login', { method: 'POST', body: { username, password } }),
// `extra` carries the honeypot field (and any future login fields).
login: (username, password, extra = {}) =>
req('/auth/login', { method: 'POST', body: { username, password, ...extra } }),
// Public self-registration (player accounts). `extra` carries the honeypot +
// optional email. Returns { user } and sets the session cookie on success.
register: (username, password, extra = {}) =>
req('/auth/register', { method: 'POST', body: { username, password, ...extra } }),
// Email invites (public, token-gated accept).
getInvite: (token) => req(`/auth/invite/${encodeURIComponent(token)}`),
acceptInvite: (token, username, password, extra = {}) =>
req(`/auth/invite/${encodeURIComponent(token)}/accept`, { method: 'POST', body: { username, password, ...extra } }),
// Second factor for web login. `extra` carries the optional recoveryCode (an
// alternative to code) and the trustDevice/deviceName opt-in. On success the
// response may include { trustLimitReached, devices } when trust was requested
// but the device cap is reached.
loginTotp: (challenge, code, extra = {}) =>
req('/auth/login/totp', { method: 'POST', body: { challenge, code, ...extra } }),
// Self-service password reset (public, token-gated). forgot always resolves the
// same way whether or not the email exists (no enumeration); getPasswordReset
// validates a link (200 → { username }, 404 → invalid/expired); resetPassword
// sets the new password and revokes all sessions (the user then signs in fresh).
forgotPassword: (email) => req('/auth/password/forgot', { method: 'POST', body: { email } }),
getPasswordReset: (token) => req(`/auth/password/reset/${encodeURIComponent(token)}`),
resetPassword: (token, password) =>
req(`/auth/password/reset/${encodeURIComponent(token)}`, { method: 'POST', body: { password } }),
// Second factor for an SSO login (challenge is held in an httpOnly cookie set by
// the callback, so only the code is sent). `extra` carries the trustDevice/
// deviceName opt-in, same as the password path. Returns { user, returnTo } — plus
// { trustLimitReached, devices } when trust was asked for but the cap is reached.
ssoLoginTotp: (code, extra = {}) => req('/auth/sso/totp', { method: 'POST', body: { code, ...extra } }),
logout: () => req('/auth/logout', { method: 'POST' }),
// Public SSO provider discovery — drives the login-page provider buttons.
authProviders: () => req('/auth/providers'),
// Active mobile device sessions (role-agnostic self-service under /auth/me).
// List the active ones and revoke a single device by its session id.
mySessions: () => req('/auth/me/sessions'),
revokeMySession: (id) => req(`/auth/me/sessions/${encodeURIComponent(id)}`, { method: 'DELETE' }),
// Trusted devices (MFA "Trust this device"), role-agnostic under /auth/me. These
// are the browsers/apps allowed to skip the TOTP step at login (distinct from
// mySessions, which are live mobile login sessions).
myTrustedDevices: () => req('/auth/me/trusted-devices'),
trustThisDevice: (deviceName) =>
req('/auth/me/trusted-devices', { method: 'POST', body: { deviceName } }),
revokeTrustedDevice: (id) =>
req(`/auth/me/trusted-devices/${encodeURIComponent(id)}`, { method: 'DELETE' }),
revokeAllTrustedDevices: () => req('/auth/me/trusted-devices', { method: 'DELETE' }),
// Recovery (backup) codes. status → remaining count; generate → a fresh set,
// returned ONCE (password step-up for accounts that have a password).
recoveryCodesStatus: () => req('/auth/me/account/recovery-codes/status'),
generateRecoveryCodes: (currentPassword) =>
req('/auth/me/account/recovery-codes/generate', { method: 'POST', body: { currentPassword } }),
// ----- public -----
publicSettings: () => req('/public/settings'),
@@ -55,24 +108,139 @@ export const api = {
if (opts.tag) qs.set('tag', opts.tag)
if (opts.q) qs.set('q', opts.q)
const s = qs.toString()
return req(`/public/wiki${s ? `?${s}` : ''}`)
return req(`/public/wiki${withQs(s)}`)
},
wikiCategories: () => req('/public/wiki/categories'),
wikiTags: () => req('/public/wiki/tags'),
wikiPage: (slug) => req(`/public/wiki/${slug}`),
// CMS pages (block-based). Published-only for the public; a draft-preview link
// is fetched by id + token.
page: (slug) => req(`/public/pages/${slug}`),
pagePreview: (id, token) => req(`/public/pages/${id}/preview/${token}`),
contact: (payload) => req('/public/contact', { method: 'POST', body: payload }),
// ----- shard live data (uo-link) -----
// Token-free, same-origin reads backed by the ingested feed + a cached live
// character round-trip. shardStreamUrl is the SSE endpoint for useShardFeed.
shard: {
status: () => req('/public/shard/status'),
feed: (opts = {}) => {
const qs = new URLSearchParams()
if (opts.kind) qs.set('kind', opts.kind)
if (opts.limit) qs.set('limit', opts.limit)
const s = qs.toString()
return req(`/public/shard/feed${withQs(s)}`)
},
economy: (limit) => {
const q = limit ? `limit=${limit}` : ''
return req(`/public/shard/economy${withQs(q)}`)
},
online: () => req('/public/shard/online'),
idoc: () => req('/public/shard/idoc'),
champs: () => req('/public/shard/champs'),
// Protocol 2.0 boards.
guilds: () => req('/public/shard/guilds'),
governors: () => req('/public/shard/governors'),
governorHistory: (city, limit) => {
const q = limit ? `limit=${limit}` : ''
return req(`/public/shard/governors/${encodeURIComponent(city)}/history${withQs(q)}`)
},
presence: () => req('/public/shard/presence'),
houses: () => req('/public/shard/houses'),
// Protocol 3.0: the shard's published ruleset. Resolves to null when the
// shard has never published one — a real answer, not an error.
ruleset: () => req('/public/shard/ruleset'),
// Protocol 3.0: points/loyalty leaderboards, one board per point system.
// `board` 404s for a system the shard has never published.
points: () => req('/public/shard/points'),
pointsBoard: (system) => req(`/public/shard/points/${encodeURIComponent(system)}`),
// Protocol 3.0: the player-vendor marketplace. Rate-limited server-side, so
// the page debounces its search box rather than firing per keystroke.
market: (opts = {}) => {
const qs = new URLSearchParams()
if (opts.q) qs.set('q', opts.q)
if (opts.minPrice != null && opts.minPrice !== '') qs.set('minPrice', opts.minPrice)
if (opts.maxPrice != null && opts.maxPrice !== '') qs.set('maxPrice', opts.maxPrice)
if (opts.itemId != null && opts.itemId !== '') qs.set('itemId', opts.itemId)
if (opts.map) qs.set('map', opts.map)
if (opts.region) qs.set('region', opts.region)
if (opts.sort) qs.set('sort', opts.sort)
if (opts.limit) qs.set('limit', opts.limit)
if (opts.offset) qs.set('offset', opts.offset)
return req(`/public/shard/market${withQs(qs.toString())}`)
},
marketMeta: () => req('/public/shard/market/meta'),
marketVendor: (serial, opts = {}) => {
const qs = new URLSearchParams()
if (opts.limit) qs.set('limit', opts.limit)
if (opts.offset) qs.set('offset', opts.offset)
return req(`/public/shard/market/vendors/${encodeURIComponent(serial)}${withQs(qs.toString())}`)
},
// Which shard surfaces this caller may reach, plus the audience rung they
// resolved to. Drives nav so we never render a link that would 403.
features: () => req('/public/shard/features'),
},
// ----- spawn atlas (Protocol 3.0 Part C) -----
// Static shard CONTENT, parsed from the shard's own ServUO tree — deliberately
// not under /shard, because nothing here depends on the sidecar and the pages
// stay populated while the shard is offline.
atlas: {
creatures: (opts = {}) => {
const qs = new URLSearchParams()
if (opts.q) qs.set('q', opts.q)
if (opts.facet) qs.set('facet', opts.facet)
if (opts.limit) qs.set('limit', opts.limit)
if (opts.offset) qs.set('offset', opts.offset)
return req(`/public/atlas/creatures${withQs(qs.toString())}`)
},
creature: (slug, opts = {}) => {
const qs = new URLSearchParams()
if (opts.facet) qs.set('facet', opts.facet)
if (opts.points) qs.set('points', opts.points)
return req(`/public/atlas/creatures/${encodeURIComponent(slug)}${withQs(qs.toString())}`)
},
regions: (opts = {}) => {
const qs = new URLSearchParams()
if (opts.facet) qs.set('facet', opts.facet)
if (opts.q) qs.set('q', opts.q)
return req(`/public/atlas/regions${withQs(qs.toString())}`)
},
landmarks: (opts = {}) => {
const qs = new URLSearchParams()
if (opts.facet) qs.set('facet', opts.facet)
if (opts.q) qs.set('q', opts.q)
return req(`/public/atlas/landmarks${withQs(qs.toString())}`)
},
// The CONFIGURED altar roster, not the live board — see shard.champs() for
// "which spawn is on level 3 right now".
champions: (facet) => req(`/public/atlas/champions${withQs(facet ? `facet=${encodeURIComponent(facet)}` : '')}`),
meta: () => req('/public/atlas/meta'),
},
// Full paths (incl. /api/v1) for the browser EventSource — the req() wrapper is
// fetch-only, so SSE subscribers build the URL from here. The admin stream
// carries every kind (incl. audit/cheat) and needs the staff session cookie.
shardStreamUrl: `${BASE}/public/shard/stream`,
adminShardStreamUrl: `${BASE}/admin/uo-link/stream`,
// ----- admin -----
admin: {
dashboard: () => req('/admin/dashboard'),
setSiteMode: (mode) => req('/admin/site-mode', { method: 'PUT', body: { mode } }),
listPosts: (category) => req(`/admin/posts${category ? `?category=${category}` : ''}`),
listPosts: (category) => {
const q = category ? `category=${category}` : ''
return req(`/admin/posts${withQs(q)}`)
},
getPost: (id) => req(`/admin/posts/${id}`),
createPost: (data) => req('/admin/posts', { method: 'POST', body: data }),
updatePost: (id, data) => req(`/admin/posts/${id}`, { method: 'PUT', body: data }),
deletePost: (id) => req(`/admin/posts/${id}`, { method: 'DELETE' }),
publishPost: (id, published) =>
req(`/admin/posts/${id}/publish`, { method: 'PATCH', body: { published } }),
// News announcement pipeline (town crier + Discord) status + per-leg retry.
getAnnounce: (id) => req(`/admin/posts/${id}/announce`),
retryAnnounceLeg: (id, leg) =>
req(`/admin/posts/${id}/announce/retry`, { method: 'POST', body: { leg } }),
uploadImage: (file) => {
const fd = new FormData()
fd.append('image', file)
@@ -84,6 +252,15 @@ export const api = {
fd.append('image', file)
return req('/admin/uploads', { method: 'POST', body: fd, raw: true })
},
// ----- CMS pages (block-based page builder) -----
listPages: () => req('/admin/pages'),
getPage: (id) => req(`/admin/pages/${id}`),
createPage: (data) => req('/admin/pages', { method: 'POST', body: data }),
updatePage: (id, data) => req(`/admin/pages/${id}`, { method: 'PATCH', body: data }),
deletePage: (id) => req(`/admin/pages/${id}`, { method: 'DELETE' }),
unprotectPage: (id, password) =>
req(`/admin/pages/${id}/unprotect`, { method: 'POST', body: { password } }),
createPagePreview: (id) => req(`/admin/pages/${id}/preview`, { method: 'POST' }),
listWiki: (params = '') => req(`/admin/wiki${params}`),
getWiki: (slug) => req(`/admin/wiki/${slug}`),
createWiki: (data) => req('/admin/wiki', { method: 'POST', body: data }),
@@ -104,10 +281,215 @@ export const api = {
getSettings: () => req('/admin/settings'),
updateSettings: (obj) => req('/admin/settings', { method: 'PUT', body: obj }),
activity: (limit = 50) => req(`/admin/activity?limit=${limit}`),
botActivity: () => req('/admin/bot-activity'),
unbanIp: (ip) => req('/admin/bot-activity/unban', { method: 'POST', body: { ip } }),
listUsers: () => req('/admin/users'),
getUser: (id) => req(`/admin/users/${id}`),
createUser: (data) => req('/admin/users', { method: 'POST', body: data }),
updateUser: (id, data) => req(`/admin/users/${id}`, { method: 'PUT', body: data }),
deleteUser: (id) => req(`/admin/users/${id}`, { method: 'DELETE' }),
// A user's trusted devices + MFA reset (admin only).
userTrustedDevices: (id) => req(`/admin/users/${id}/trusted-devices`),
revokeUserTrustedDevice: (id, deviceId) =>
req(`/admin/users/${id}/trusted-devices/${deviceId}`, { method: 'DELETE' }),
revokeAllUserTrustedDevices: (id) =>
req(`/admin/users/${id}/trusted-devices`, { method: 'DELETE' }),
resetUserMfa: (id) => req(`/admin/users/${id}/mfa/reset`, { method: 'POST' }),
// Email invites.
listInvites: () => req('/admin/invites'),
createInvite: (email, role, sendEmail = true) =>
req('/admin/invites', { method: 'POST', body: { email, role, sendEmail } }),
revokeInvite: (id) => req(`/admin/invites/${id}`, { method: 'DELETE' }),
// A single user's shard (uo-link) footprint, scoped to their linked accounts.
// accounts/sales/houses/online are user-scoped endpoints; roster/vendors/char
// reuse the admin-bypass /admin/shard/* endpoints (which already read any
// account) so the shared GameAccounts component works unchanged.
userShard: (id) => ({
accounts: () => req(`/admin/users/${id}/shard/accounts`),
roster: (account) => req(`/admin/shard/roster/${encodeURIComponent(account)}`),
vendors: (account) => req(`/admin/shard/vendors/${encodeURIComponent(account)}`),
char: (serial) => req(`/admin/shard/char/${encodeURIComponent(serial)}`),
sales: () => req(`/admin/users/${id}/shard/sales`),
houses: () => req(`/admin/users/${id}/shard/houses`),
online: () => req(`/admin/users/${id}/shard/online`),
standing: () => req(`/admin/users/${id}/shard/standing`),
unlink: (account) => req(`/admin/users/${id}/shard/link/${encodeURIComponent(account)}`, { method: 'DELETE' }),
}),
// ----- moderation dashboard (admin + moderator) -----
modSummary: () => req('/admin/moderation/stats/summary'),
modRecent: (params = {}) => {
const qs = new URLSearchParams()
if (params.type) qs.set('type', params.type)
if (params.limit) qs.set('limit', params.limit)
if (params.offset) qs.set('offset', params.offset)
const s = qs.toString()
return req(`/admin/moderation/recent${withQs(s)}`)
},
modSearch: (q) => req(`/admin/moderation/search?q=${encodeURIComponent(q)}`),
modMembers: (params = {}) => {
const qs = new URLSearchParams()
if (params.type) qs.set('type', params.type)
if (params.limit) qs.set('limit', params.limit)
if (params.offset) qs.set('offset', params.offset)
const s = qs.toString()
return req(`/admin/moderation/members${withQs(s)}`)
},
modFilterHits: (params = {}) => {
const qs = new URLSearchParams()
if (params.limit) qs.set('limit', params.limit)
if (params.offset) qs.set('offset', params.offset)
const s = qs.toString()
return req(`/admin/moderation/filter-hits${withQs(s)}`)
},
modSpamHits: (params = {}) => {
const qs = new URLSearchParams()
if (params.limit) qs.set('limit', params.limit)
if (params.offset) qs.set('offset', params.offset)
const s = qs.toString()
return req(`/admin/moderation/spam-hits${withQs(s)}`)
},
modUser: (discordId) => req(`/admin/moderation/user/${discordId}`),
modUserActions: (discordId, params = {}) => {
const qs = new URLSearchParams()
if (params.type) qs.set('type', params.type)
if (params.limit) qs.set('limit', params.limit)
if (params.offset) qs.set('offset', params.offset)
const s = qs.toString()
return req(`/admin/moderation/user/${discordId}/actions${withQs(s)}`)
},
modUserNotes: (discordId) => req(`/admin/moderation/user/${discordId}/notes`),
addModNote: (discordId, data) =>
req(`/admin/moderation/user/${discordId}/notes`, { method: 'POST', body: data }),
// ----- moderation appeals (admin + moderator) -----
getAppeals: (params = {}) => {
const qs = new URLSearchParams()
if (params.status) qs.set('status', params.status)
if (params.limit) qs.set('limit', params.limit)
if (params.offset) qs.set('offset', params.offset)
const s = qs.toString()
return req(`/admin/moderation/appeals${withQs(s)}`)
},
getAppeal: (id) => req(`/admin/moderation/appeals/${id}`),
claimAppeal: (id) => req(`/admin/moderation/appeals/${id}/claim`, { method: 'POST' }),
resolveAppeal: (id, data) =>
req(`/admin/moderation/appeals/${id}/resolve`, { method: 'POST', body: data }),
getUserAppeals: (discordId) => req(`/admin/moderation/user/${discordId}/appeals`),
// ----- account security (self-service 2FA) -----
getAccount: () => req('/admin/account'),
totpSetup: () => req('/admin/account/totp/setup', { method: 'POST' }),
totpEnable: (code) => req('/admin/account/totp/enable', { method: 'POST', body: { code } }),
totpDisable: (code) => req('/admin/account/totp/disable', { method: 'POST', body: { code } }),
// ----- linked SSO identities (self-service) -----
linkedIdentities: () => req('/admin/account/identities'),
unlinkIdentity: (provider) => req(`/admin/account/identities/${provider}`, { method: 'DELETE' }),
// ----- game account linking (self-service, staff) -----
shard: {
link: (code) => req('/admin/shard/link', { method: 'POST', body: { code } }),
accounts: () => req('/admin/shard/accounts'),
roster: (account) => req(`/admin/shard/roster/${encodeURIComponent(account)}`),
vendors: (account) => req(`/admin/shard/vendors/${encodeURIComponent(account)}`),
char: (serial) => req(`/admin/shard/char/${encodeURIComponent(serial)}`),
sales: () => req('/admin/shard/sales'),
houses: () => req('/admin/shard/houses'), // full registry (admin/moderator)
createAccount: (account, password) =>
req('/admin/shard/account', { method: 'POST', body: { account, password } }),
},
// ----- auth providers / SSO config (admin only) -----
listAuthProviders: () => req('/admin/auth/providers'),
createAuthProvider: (data) => req('/admin/auth/providers', { method: 'POST', body: data }),
updateAuthProvider: (id, data) => req(`/admin/auth/providers/${id}`, { method: 'PUT', body: data }),
deleteAuthProvider: (id) => req(`/admin/auth/providers/${id}`, { method: 'DELETE' }),
// ----- Discord bot control (admin only) -----
getDiscordBotConfig: () => req('/admin/discord-bot/config'),
saveDiscordBotConfig: (data) => req('/admin/discord-bot/config', { method: 'PUT', body: data }),
// ----- uo-link sidecar control (admin only) -----
getUoLinkConfig: () => req('/admin/uo-link/config'),
saveUoLinkConfig: (data) => req('/admin/uo-link/config', { method: 'PUT', body: data }),
postTownCrier: (data) => req('/admin/uo-link/towncrier', { method: 'POST', body: data }),
deleteTownCrier: (id) => req(`/admin/uo-link/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' }),
// Per-feature shard visibility: who may see which shard surface, and which
// sensitive fields within it. Admin only — it decides what ANONYMOUS
// visitors get. acct/webId are admin-only always and the API rejects any
// attempt to configure them.
getShardVisibility: () => req('/admin/shard/visibility'),
saveShardVisibility: (features) =>
req('/admin/shard/visibility', { method: 'PUT', body: { features } }),
// ----- spawn atlas operation (admin only) -----
// The atlas re-derives itself from the ServUO tree on every boot; these are
// for applying a map change without a restart, and for the approve/reject
// decision on a refresh that would remove a facet.
atlas: {
status: () => req('/admin/shard/atlas'),
import: (force = false) => req('/admin/shard/atlas/import', { method: 'POST', body: { force } }),
approve: () => req('/admin/shard/atlas/approve', { method: 'POST', body: {} }),
reject: () => req('/admin/shard/atlas/reject', { method: 'POST', body: {} }),
setPath: (path) => req('/admin/shard/atlas/path', { method: 'PUT', body: { path } }),
},
// ----- in-game staff operations: write plane + support queue (admin/moderator) -----
// `actor` is stamped server-side from the session — never sent from here.
shardOps: {
kick: (data) => req('/admin/shard/kick', { method: 'POST', body: data }),
ban: (data) => req('/admin/shard/ban', { method: 'POST', body: data }),
unban: (account) => req('/admin/shard/unban', { method: 'POST', body: { account } }),
broadcast: (data) => req('/admin/shard/broadcast', { method: 'POST', body: data }),
pages: () => req('/admin/shard/pages'),
respondPage: (id, data) =>
req(`/admin/shard/pages/${encodeURIComponent(id)}/respond`, { method: 'POST', body: data }),
closePage: (id) => req(`/admin/shard/pages/${encodeURIComponent(id)}/close`, { method: 'POST' }),
audit: (limit) => req(`/admin/shard/audit${limit ? `?limit=${limit}` : ''}`),
},
// ----- Email delivery / Gmail OAuth2 (admin only) -----
getEmailConfig: () => req('/admin/email/config'),
saveEmailConfig: (data) => req('/admin/email/config', { method: 'PUT', body: data }),
emailConnectUrl: () => req('/admin/email/connect/start'),
testEmail: (to) => req('/admin/email/test', { method: 'POST', body: { to } }),
disconnectEmail: () => req('/admin/email/disconnect', { method: 'POST' }),
},
// ----- player self-service (role: 'player') -----
// Mirrors the admin account methods but self-scoped under /player. The change
// endpoints re-issue the session cookie server-side, so the caller stays signed in.
player: {
getAccount: () => req('/player/account'),
changeUsername: (username) =>
req('/player/account/username', { method: 'PATCH', body: { username } }),
changePassword: (newPassword, currentPassword) =>
req('/player/account/password', { method: 'PATCH', body: { newPassword, currentPassword } }),
totpSetup: () => req('/player/account/totp/setup', { method: 'POST' }),
totpEnable: (code) => req('/player/account/totp/enable', { method: 'POST', body: { code } }),
totpDisable: (code) => req('/player/account/totp/disable', { method: 'POST', body: { code } }),
linkedIdentities: () => req('/player/account/identities'),
unlinkIdentity: (provider) => req(`/player/account/identities/${provider}`, { method: 'DELETE' }),
// ----- game account linking (uo-link) -----
shard: {
link: (code) => req('/player/shard/link', { method: 'POST', body: { code } }),
accounts: () => req('/player/shard/accounts'),
roster: (account) => req(`/player/shard/roster/${encodeURIComponent(account)}`),
vendors: (account) => req(`/player/shard/vendors/${encodeURIComponent(account)}`),
char: (serial) => req(`/player/shard/char/${encodeURIComponent(serial)}`),
sales: () => req('/player/shard/sales'),
houses: () => req('/player/shard/houses'), // the caller's own houses
createAccount: (account, password) =>
req('/player/shard/account', { method: 'POST', body: { account, password } }),
},
// ----- moderation appeals (self-service) -----
getMyAppeals: () => req('/player/appeals'),
getEligibleAppeals: () => req('/player/appeals/eligible'),
submitAppeal: (data) => req('/player/appeals', { method: 'POST', body: data }),
withdrawAppeal: (id) => req(`/player/appeals/${id}/withdraw`, { method: 'POST' }),
},
}

View File

@@ -0,0 +1,26 @@
// Renders stored blocks via their registry component. Used by the public page
// route, the draft preview, and (recursively) the two_column block. Kept
// separate from the registry so both the renderer and the builder can import it.
// Import the lookup from the registry directly (not ./index) to avoid a cycle:
// index → types/twoColumn → BlockRenderer. The page route/builder import ./index,
// which registers every block before anything renders.
import { getBlock } from './registry.js'
/**
* Render one block. A block with `visible === false` renders nothing (admins
* hide blocks without deleting them). An unknown type also renders nothing —
* server validation prevents storing one, so this only guards a client/server
* registry skew rather than crashing the whole page.
*/
export default function BlockRenderer({ block }) {
if (!block || block.visible === false) return null
const def = getBlock(block.type)
if (!def || !def.component) return null
const Component = def.component
return <Component props={block.props || {}} block={block} />
}
/** Render an ordered array of blocks (array position = display order). */
export function BlockList({ blocks }) {
return (blocks || []).map((block) => <BlockRenderer key={block.id} block={block} />)
}

View File

@@ -0,0 +1,64 @@
// Shared form controls for block editors, styled with the existing admin design
// system (.field-label / .input / .select). Every block's editor is a
// ({ props, onChange }) component; these keep the seven of them consistent and
// short. onChange always receives the full next props object.
export function Field({ label, hint, children }) {
return (
<label style={{ display: 'block' }}>
<span className="field-label">{label}</span>
{children}
{hint && (
<span className="sans dim" style={{ display: 'block', fontSize: '0.78rem', marginTop: 4 }}>
{hint}
</span>
)}
</label>
)
}
export function TextField({ label, hint, value, onChange, placeholder, maxLength }) {
return (
<Field label={label} hint={hint}>
<input
type="text"
className="input"
value={value ?? ''}
placeholder={placeholder}
maxLength={maxLength}
onChange={(e) => onChange(e.target.value)}
/>
</Field>
)
}
export function TextAreaField({ label, hint, value, onChange, placeholder, rows = 4, maxLength }) {
return (
<Field label={label} hint={hint}>
<textarea
className="input"
rows={rows}
value={value ?? ''}
placeholder={placeholder}
maxLength={maxLength}
onChange={(e) => onChange(e.target.value)}
style={{ resize: 'vertical', fontFamily: 'inherit' }}
/>
</Field>
)
}
// options: array of [value, label] tuples.
export function SelectField({ label, hint, value, onChange, options }) {
return (
<Field label={label} hint={hint}>
<select className="select" value={value ?? ''} onChange={(e) => onChange(e.target.value)}>
{options.map(([v, l]) => (
<option key={v} value={v}>
{l}
</option>
))}
</select>
</Field>
)
}

View File

@@ -0,0 +1,19 @@
// Client block registry entrypoint. Importing this module registers every
// browser-side block definition (renderer + editor + palette entry) exactly
// once, then re-exports the registry API. The page builder and the public page
// renderer should import from HERE, not ./registry, so the definitions are
// loaded before anything reads the registry.
//
// Wave 1 definitions are registered below as each block is built (spec build
// order step 3), one import per block.
export * from './registry'
// ── Wave 1 block definitions (self-register on import) ─────────────────
import './types/heading.jsx'
import './types/richText.jsx'
import './types/image.jsx'
import './types/twoColumn.jsx'
import './types/cta.jsx'
import './types/divider.jsx'
import './types/quote.jsx'

View File

@@ -0,0 +1,84 @@
// Block registry (client side) — mirrors the server registry
// (server/src/blocks/registry.js) but carries the browser-only concerns: the
// React renderer, the admin edit form, and the palette icon/label. The page
// builder's palette, drag-reorder canvas, per-block edit panel, and the public
// page renderer all read from this registry, so adding a block later is one
// entry here (plus its server-side schema entry) rather than edits scattered
// across the builder and renderer.
//
// A registered definition looks like:
// {
// type: 'heading', // must match the server registry type
// version: 1, // must match the server schema version
// label: 'Heading', // palette display name
// icon: 'heading', // palette icon key
// component: HeadingBlock, // renderer: (props) => JSX
// editor: HeadingEditor, // admin edit form: ({ props, onChange }) => JSX
// defaults: () => ({ ... }), // starting props when a block is added
// container: false, // true only for two_column
// containerSlots: [], // ['left','right'] for two_column
// }
//
// This module only defines the pattern; Wave 1 definitions register via
// ./index.js as each block is built (spec build order step 3).
const registry = new Map()
// Kept in sync with the server's RESERVED_KEYS — the only top-level keys on a
// stored block object. Exported so the builder can construct envelopes without
// hard-coding the shape.
export const RESERVED_KEYS = ['id', 'type', 'version', 'visible', 'props']
/**
* Register a block definition. Throws on a duplicate type — a programmer error
* caught at module load, not runtime.
* @param {object} def
* @returns {object} the stored definition
*/
export function registerBlock(def) {
if (!def || typeof def.type !== 'string' || def.type.length === 0) {
throw new Error('registerBlock: a block definition needs a string `type`')
}
if (registry.has(def.type)) {
throw new Error(`registerBlock: block type already registered: ${def.type}`)
}
const entry = {
type: def.type,
version: Number.isInteger(def.version) ? def.version : 1,
label: def.label || def.type,
icon: def.icon || null,
component: def.component || null,
editor: def.editor || null,
defaults: typeof def.defaults === 'function' ? def.defaults : () => ({}),
container: Boolean(def.container),
containerSlots: def.containerSlots ? [...def.containerSlots] : [],
}
registry.set(entry.type, entry)
return entry
}
/** @returns {object|null} the definition for `type`, or null if unknown. */
export function getBlock(type) {
return registry.get(type) || null
}
/** @returns {boolean} whether `type` is a registered block. */
export function hasBlock(type) {
return registry.has(type)
}
/** @returns {object[]} all registered definitions (registration order). */
export function listBlocks() {
return [...registry.values()]
}
/**
* Generate a stable block id. Called once when a block is added to the canvas;
* never derived from array position, so a reorder keeps ids intact (they are the
* React key and the future revision-history join point).
* @returns {string}
*/
export function makeBlockId() {
const rand = Math.random().toString(36).slice(2, 8).toUpperCase()
return `b_${rand}`
}

View File

@@ -0,0 +1,63 @@
// cta block — a call-to-action button/link. Renders as an anchor styled with the
// existing button system (primary / secondary).
import { registerBlock } from '../registry'
import { SelectField, TextField } from '../editorKit.jsx'
const STYLES = [
['primary', 'Primary'],
['secondary', 'Secondary'],
]
function CtaBlock({ props }) {
if (!props.url || !props.text) return null
const style = props.style === 'secondary' ? 'secondary' : 'primary'
// External links get a safe rel; same-origin relative links don't need it.
const external = /^https?:\/\//i.test(props.url)
return (
<div className="page-cta-wrap">
<a
className={`btn btn-sq page-cta page-cta--${style}`}
href={props.url}
{...(external ? { rel: 'noopener noreferrer nofollow' } : {})}
>
{props.text}
</a>
</div>
)
}
function CtaEditor({ props, onChange }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<TextField
label="Button text"
value={props.text}
maxLength={100}
onChange={(text) => onChange({ ...props, text })}
/>
<TextField
label="URL"
hint="A full https:// link or a same-site path like /wiki/getting-started."
value={props.url}
placeholder="https://…"
onChange={(url) => onChange({ ...props, url })}
/>
<SelectField
label="Style"
value={props.style || 'primary'}
onChange={(style) => onChange({ ...props, style })}
options={STYLES}
/>
</div>
)
}
registerBlock({
type: 'cta',
version: 1,
label: 'Button',
icon: '⇥',
component: CtaBlock,
editor: CtaEditor,
defaults: () => ({ text: '', url: '', style: 'primary' }),
})

View File

@@ -0,0 +1,25 @@
// divider block — a pure spacer / horizontal rule. No props, so its editor is
// just a note.
import { registerBlock } from '../registry'
function DividerBlock() {
return <hr className="page-divider" />
}
function DividerEditor() {
return (
<p className="sans dim" style={{ margin: 0, fontSize: '0.85rem' }}>
A divider has no options it adds a horizontal rule and spacing.
</p>
)
}
registerBlock({
type: 'divider',
version: 1,
label: 'Divider',
icon: '—',
component: DividerBlock,
editor: DividerEditor,
defaults: () => ({}),
})

View File

@@ -0,0 +1,46 @@
// heading block — plain-text section heading (h1h4). Text is rendered as text
// (React escapes it); use rich_text for inline markup.
import { registerBlock } from '../registry'
import { SelectField, TextField } from '../editorKit.jsx'
const LEVELS = [
['h1', 'Heading 1'],
['h2', 'Heading 2'],
['h3', 'Heading 3'],
['h4', 'Heading 4'],
]
const VALID = ['h1', 'h2', 'h3', 'h4']
function HeadingBlock({ props }) {
const Tag = VALID.includes(props.level) ? props.level : 'h2'
return <Tag className="page-heading">{props.text}</Tag>
}
function HeadingEditor({ props, onChange }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<SelectField
label="Level"
value={props.level || 'h2'}
onChange={(level) => onChange({ ...props, level })}
options={LEVELS}
/>
<TextField
label="Text"
value={props.text}
maxLength={200}
onChange={(text) => onChange({ ...props, text })}
/>
</div>
)
}
registerBlock({
type: 'heading',
version: 1,
label: 'Heading',
icon: 'H',
component: HeadingBlock,
editor: HeadingEditor,
defaults: () => ({ level: 'h2', text: '' }),
})

View File

@@ -0,0 +1,100 @@
// image block — a single image with optional caption and alignment. Upload
// reuses the shared admin uploader (returns { url }); the block stays URL-based
// until the Wave 3 asset picker lands.
import { useState } from 'react'
import { registerBlock } from '../registry'
import { api } from '../../api/client.js'
import { SelectField, TextField } from '../editorKit.jsx'
const ALIGN = [
['left', 'Left'],
['center', 'Center'],
['right', 'Right'],
['full', 'Full width'],
]
const VALID = ['left', 'center', 'right', 'full']
function ImageBlock({ props }) {
if (!props.src) return null
const align = VALID.includes(props.alignment) ? props.alignment : 'center'
return (
<figure className={`page-image page-image--${align}`}>
<img src={props.src} alt={props.alt || ''} />
{props.caption && <figcaption>{props.caption}</figcaption>}
</figure>
)
}
function ImageEditor({ props, onChange }) {
const [uploading, setUploading] = useState(false)
const [error, setError] = useState('')
async function onUpload(e) {
const file = e.target.files?.[0]
e.target.value = ''
if (!file) return
setUploading(true)
setError('')
try {
const { url } = await api.admin.upload(file)
onChange({ ...props, src: url })
} catch (err) {
setError(err.message || 'Upload failed')
} finally {
setUploading(false)
}
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div>
<span className="field-label">Image</span>
<input
type="file"
accept="image/*"
onChange={onUpload}
className="sans"
style={{ color: 'var(--muted)', fontSize: '0.85rem', display: 'block' }}
/>
{uploading && <span className="sans dim" style={{ fontSize: '0.8rem' }}> uploading</span>}
{error && <span className="sans" style={{ fontSize: '0.8rem', color: '#d98b84' }}>{error}</span>}
{props.src && (
<img
src={props.src}
alt=""
style={{ display: 'block', marginTop: 10, maxWidth: '100%', borderRadius: 8, border: '1px solid var(--line)' }}
/>
)}
</div>
<TextField
label="Alt text"
hint="Describes the image for screen readers and when it fails to load."
value={props.alt}
maxLength={300}
onChange={(alt) => onChange({ ...props, alt })}
/>
<TextField
label="Caption (optional)"
value={props.caption}
maxLength={500}
onChange={(caption) => onChange({ ...props, caption })}
/>
<SelectField
label="Alignment"
value={props.alignment || 'center'}
onChange={(alignment) => onChange({ ...props, alignment })}
options={ALIGN}
/>
</div>
)
}
registerBlock({
type: 'image',
version: 1,
label: 'Image',
icon: '🖼',
component: ImageBlock,
editor: ImageEditor,
defaults: () => ({ src: '', alt: '', caption: '', alignment: 'center' }),
})

Some files were not shown because too many files have changed in this diff Show More