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