Follows the redesign in website #112. Two decisions from the original §6 were rejected in review and replaced; the docs now describe what was actually built. **The committed artifact is gone.** A shard's maps change over its life, so a snapshot in the repo silently drifts from the world players actually see. The ServUO tree is the single source of truth and the atlas is re-derived on every server boot, hash-gated so an unchanged tree costs one read pass and no write. **Nothing may name a facet.** The first implementation carried a lookup table of the six stock UO facets. A shard may add facets, replace them outright, or rename them when its maps are updated, and a built-in list mishandles all three silently. Reconciliation is now by matching against the facet set discovered from the shard's own data, with an unmatched name keeping its own rather than being forced into a wrong bucket. ## Changes - **`website/SPAWN_ATLAS.md`** rewritten: the two ideas that shape the design, how to configure the tree path, the boot flow as a decision tree, the approve/reject flow, and the code layout. The artwork policy is unchanged and still explicit — no art ever ships, operators extract their own from their own client files. - **`link/v3.md` §6.1 (new)** records the two rejected decisions plus the two boot-path contracts. The old "what real data changed" list becomes §6.2. §6's now-superseded passages — the artifact bullet, the payload budget, the operator re-run story — are marked rather than deleted, so the reasoning stays legible. - **`website/BACKEND_DESIGN.md`** documents `shard_atlas_pending` and the two contracts that make it safe: a facet removal is staged for a human, and the boot refresh can never block startup. The two contracts are the part worth reviewing. Losing a facet is indistinguishable at boot from a half-copied or mid-update tree, so it is staged rather than applied; and no failure mode of the atlas — missing path, unreadable mount, malformed file, database error — is allowed to stop the site coming up. --- - [x] AI-assisted: written with **Claude Code** (Claude Opus 5), reviewed before opening. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U7CBg11prhLimL9iHSX1bP
965 lines
69 KiB
Markdown
965 lines
69 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
|
|
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_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), `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`.
|
|
|
|
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.
|
|
|
|
---
|
|
|
|
## 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.** 200 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/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. |
|
|
|
|
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`) |
|
|
|
|
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` and `/public/contact`.
|
|
- **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 |
|
|
```
|