Replace the vague "helmet with a CSP suited to the SPA" line with the actual policy now implemented in server/src/app.js: per-directive sources and the rationale for each non-'self' allowance (Google Fonts, inline React styles, external/embedded images, same-origin REST+SSE), why upgrade-insecure-requests is omitted, the scoped looser CSP for the /api/docs Swagger UI route, and the X-Powered-By handling across the public and internal listeners. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
34 KiB
UOMysticmoon Website — Backend Design
Phase 1 of 3: backend design → Claude Design (frontend mockup) → coding. This document is the contract the later phases build against.
Public contact email: UOMysticmoon@gmail.com
1. Stack & top-level decisions
| Concern | Decision | Rationale |
|---|---|---|
| Runtime | Node.js + Express | serverlinkr pattern |
| Database | MariaDB (own container) | spec; mariadb pool, parameterized SQL, no ORM (keeps the lightweight model/db split from serverlinkr) |
| Auth | JWT in an httpOnly cookie | spec says "JWT auth" + "secure cookies when HTTPS"; httpOnly keeps the token out of JS (XSS-safe), SameSite=Strict covers CSRF for a same-origin admin panel |
| Frontend | React + Vite, same repo, served by Express in prod | spec |
| Hashing | bcrypt (bcryptjs) |
spec; matches serverlinkr |
| Deploy | Docker Compose (app + db) behind Pangolin | spec |
Adapting serverlinkr → this project
*.mongo.js(mongoose) →*.db.js(MariaDB queries), exactly as the spec names them.- Drop the session/passport hybrid (
express-session,passport,passport-local,connect-mongo). Pure stateless JWT instead — simpler and matches "JWT auth". - Routes grouped by access level (auth / public / admin) per spec, instead of serverlinkr's per-entity routers. Models stay grouped by entity.
2. Folder structure
Skeleton from the spec, with a small number of justified additions marked (+).
server/
.env.example
package.json
db/
schema.sql (+) DDL, also auto-run by the MariaDB container
seed.js (+) seed wiki pages, default settings, first admin
src/
server.js bootstrap: ensure schema, then listen on 0.0.0.0
app.js express app + middleware wiring
router/
api.router.js mounts /v1
v1/
v1.router.js mounts /auth /public /admin
auth/ auth.routes.js + auth.controller.js
public/ public.routes.js + public.controller.js
admin/ admin.routes.js + admin.controller.js
model/
users/ users.model.js + users.db.js
posts/ posts.model.js + posts.db.js (news/five-on-friday/newsletter/screenshots)
wiki/ wiki.model.js + wiki.db.js
settings/ settings.model.js + settings.db.js
activity/ activity.model.js + activity.db.js (+) admin activity log
middleware/ (+)
siteMode.js LIVE/MAINTENANCE gate for public content
noindex.js X-Robots-Tag: noindex,nofollow on admin
rateLimit.js login limiter
validate.js express-validator error handler
utils/
auth.js JWT sign/verify, isLoggedIn middleware
db.js MariaDB pool + ensureSchema()
mailer.js (+) nodemailer; mailto fallback if SMTP unset
client/ built in Phase 2/3 (React + Vite)
Dockerfile
docker-compose.yml
.env.example
.gitignore
Why the additions: the spec's feature list requires an activity log, a maintenance-mode
gate, login rate limiting, admin noindex, and SMTP email — none fit cleanly in the four
listed models/two utils. They're isolated in middleware/ + one activity model +
utils/mailer.js, and the spec explicitly says the layout is "expandable."
3. Database schema (MariaDB)
utf8mb4 throughout. Created idempotently on boot (ensureSchema()) and shipped as
db/schema.sql for the container's /docker-entrypoint-initdb.d.
users
| col | type | notes |
|---|---|---|
| id | INT PK AUTO_INCREMENT | |
| username | VARCHAR(32) UNIQUE NOT NULL | |
| password_hash | VARCHAR(72) NOT NULL | bcrypt; never returned by the API |
| role | ENUM('admin','editor') NOT NULL DEFAULT 'admin' | room to grow |
| created_at | DATETIME DEFAULT CURRENT_TIMESTAMP | |
| last_login_at | DATETIME NULL | shown in user management |
posts — one table, four categories
| col | type | notes |
|---|---|---|
| id | INT PK AUTO_INCREMENT | |
| category | ENUM('news','five_on_friday','newsletter','screenshot') NOT NULL | |
| title | VARCHAR(200) NOT NULL | |
| slug | VARCHAR(220) NULL | optional clean URL |
| excerpt | VARCHAR(400) NULL | list teaser |
| body | MEDIUMTEXT NULL | markdown/HTML; main text for news/5oF/newsletter |
| image_url | VARCHAR(500) NULL | required for screenshot, optional hero elsewhere |
| published | TINYINT(1) NOT NULL DEFAULT 0 | publish/unpublish toggle |
| author_id | INT NULL FK→users(id) | ON DELETE SET NULL |
| created_at | DATETIME DEFAULT CURRENT_TIMESTAMP | |
| updated_at | DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | |
| published_at | DATETIME NULL | set when first published; list order |
Index: (category, published, published_at DESC).
wiki_pages
| col | type | notes |
|---|---|---|
| id | INT PK AUTO_INCREMENT | |
| slug | VARCHAR(120) UNIQUE NOT NULL | e.g. new-player-guide |
| title | VARCHAR(200) NOT NULL | |
| body | MEDIUMTEXT NULL | markdown/HTML |
| updated_by | INT NULL FK→users(id) | |
| created_at / updated_at | DATETIME |
Seeded with the 8 spec categories: new-player-guide, maps-atlas, systems, items, monsters, crafting, lore, rules.
settings — key/value, expandable
| col | type | notes |
|---|---|---|
key |
VARCHAR(64) PK | |
| value | TEXT NULL | |
| updated_by | INT NULL FK→users(id) | |
| updated_at | DATETIME ON UPDATE CURRENT_TIMESTAMP |
Seeded keys: site_mode (default maintenance), site_mode_changed_at,
site_mode_changed_by, maintenance_message, status_message, homepage_teaser,
contact_email (=UOMysticmoon@gmail.com), site_title.
activity_log — append-only
| col | type | notes |
|---|---|---|
| id | INT PK AUTO_INCREMENT | |
| user_id | INT NULL FK→users(id) | |
| action | VARCHAR(64) NOT NULL | e.g. auth.login, site_mode.change, post.create |
| detail | TEXT NULL | JSON string of what changed |
| ip | VARCHAR(45) NULL | from req.ip (needs trust proxy) |
| created_at | DATETIME DEFAULT CURRENT_TIMESTAMP |
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 |
| 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.
4. API contract
Base path /api/v1. JSON in/out. Auth via httpOnly cookie (isLoggedIn reads it; also
accepts Authorization: Bearer for API testing).
/auth (auth.routes.js → auth.controller.js)
| Method | Path | Auth | Body | Purpose |
|---|---|---|---|---|
| POST | /login |
— (rate-limited) | {username,password} |
verify, set cookie, log auth.login, update last_login_at |
| POST | /logout |
cookie | — | clear cookie |
| GET | /me |
cookie / 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) |
| GET | /me/account/identities · DELETE …/:provider |
cookie / bearer | — | list / unlink own SSO identities |
| 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.
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_KINDSallowlist; a sensitive kind (audit/cheat/IP/login-attempt) can never produce a public push. - Personal streams are owner-keyed.
vendor.sale/house.idoc/account.loginare delivered only to the owning user's devices, resolved viashardLinks(the same ownership check as/player/shard/*). - SSRF guard. A device
endpointis 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_TOKENhardens 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.
| 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} |
| 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_verifieris generated at/start, kept only in the httpOnlysso_txcookie, 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 inmobile_auth_sessionsat/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.routes.js → public.controller.js) — all GET, no auth
| 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} |
Public content GETs pass through the siteMode gate (§5).
/admin (admin.routes.js → admin.controller.js) — all behind isLoggedIn + noindex
| Method | Path | Purpose |
|---|---|---|
| GET | /dashboard |
current mode, last change time + who, content counts, recent activity |
| PUT | /site-mode |
{mode} → update settings, stamp who/when, log site_mode.change |
| GET | /posts?category= |
all posts incl. unpublished |
| POST | /posts |
create |
| GET | /posts/:id |
one |
| PUT | /posts/:id |
edit |
| DELETE | /posts/:id |
delete |
| PATCH | /posts/:id/publish |
{published} toggle (sets published_at) |
| POST | /posts/upload |
multipart image upload (multer) → {image_url} for screenshots |
| GET | /wiki · GET /wiki/:slug |
read incl. unpublished |
| POST | /wiki · PUT /wiki/:slug · DELETE /wiki/:slug |
manage pages |
| GET | /settings · PUT /settings |
read all / update {key:value,...} |
| GET | /activity?limit=&offset= |
paginated activity log |
| GET | /users · POST /users · PUT /users/:id · DELETE /users/:id |
user mgmt (can't delete self / last admin; password hashed on write) |
Every admin write logs to activity_log.
5. Site mode (LIVE / MAINTENANCE)
State in settings.site_mode (live|maintenance), default maintenance.
middleware/siteMode.js, applied only to public content routes:
live→ pass through.maintenance→ respond 503 with{mode:"maintenance", message}unless the request carries a valid admin cookie (admin preview). This hides content server-side, not just in the UI.
Always reachable regardless of mode: static assets / SPA shell, /api/v1/auth/*, all
/api/v1/admin/*. So admin login + panel + the maintenance "coming soon" page always load.
Client behavior (Phase 3): reads GET /public/settings; if maintenance and not an
admin previewing, render the polished dark coming-soon page (message + contact email).
Admin "preview live" simply hits the content APIs with the admin cookie, which bypass the gate.
Dashboard reads site_mode + site_mode_changed_at/_by for "current mode + last change +
who"; activity_log provides the history feed.
6. Auth & security
- JWT signed with
JWT_SECRET,expiresIn=JWT_EXPIRES_IN(default1d); payload{id,username,role}. - Cookie:
httpOnly,sameSite=Lax,path=/, andsecuredecided per-request (COOKIE_SECURE=auto→secure: req.secure). This is the key to dual access: the cookie isSecurewhen reached through Pangolin (HTTPS,X-Forwarded-Proto: https) but notSecurewhen reached directly over the LAN IP on plain HTTP — so login works in both.COOKIE_SECURE=true|falsecan force it. Requirestrust proxy(below).localhost:5173(Vite) andlocalhost:3000are 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/loginand/public/contact. - Validation (
express-validator) on all writes; centralized error handler. - helmet with a Content-Security-Policy tuned for the built React SPA (see
server/src/app.js):default-src 'self';script-src 'self'(the Vite build emits only external module chunks — the inline module-preload polyfill is disabled inclient/vite.config.jsto keep this valid);style-src 'self' 'unsafe-inline' https://fonts.googleapis.com(React's pervasive inlinestyle={{…}}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 orBRAND_*logo/hero/favicon);connect-src 'self'(REST + SSE are same-origin);frame-ancestors 'self';object-src 'none';base-uri 'self'.upgrade-insecure-requestsis intentionally not set (TLS terminates at the proxy, there are no mixed-content subresources, and it would break a localnpm startover plain http). The/api/docsSwagger UI route gets a looser policy that additionally allows inline script/style, since swagger-ui-express injects an inline bootstrap. helmet also stripsX-Powered-By; the two internal-only listeners (internalApp.js,bot/src/app.js) disable it explicitly too. - Admin not indexed:
X-Robots-Tag: noindex, nofollowon/api/v1/adminand the admin SPA routes;robots.txtdisallows/admin. - No directory browsing (express.static doesn't list; no
serve-index). - No hardcoded credentials: first admin via
seed.jsreadingADMIN_USERNAME/ADMIN_PASSWORDfrom env (created only if no users exist);.envgit-ignored,.env.examplecommitted. 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) withcredentials:true.
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], …).
- Console: color on a TTY, plain in Docker; verbosity =
LOG_LEVEL(defaultinfo). - File: plain text appended to
LOG_DIR/LOG_FILE(default<server>/logs/app.log,/app/logs/app.login Docker, bind-mounted to./logs); verbosity =FILE_LOG_LEVEL(defaultdebug, so the file keeps a complete record while the console stays readable). Toggle withLOG_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/uncaughtExceptionare caught and logged.
8. Deployment
docker-compose.yml — two services on a private network:
db:mariadb:11, envMARIADB_DATABASE/USER/PASSWORD/ROOT_PASSWORD, volumedbdata:/var/lib/mysql, mountsschema.sqlinto/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), volumeuploads:/app/uploads,ports: "3000:3000"— binds 0.0.0.0 (no127.0.0.1:prefix) so Pangolin reaches it.ntfy(M7): pinned upstreambinwiederhier/ntfyimage, declarative config only (./ntfy/server.ymlmounted:ro+NTFY_BASE_URL), volumentfydata:/var/lib/ntfy, no published host port — devices reach it via the reverse proxy; the backend publisher reaches it over the private compose network. 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
.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 |