Files
docs/website/BACKEND_DESIGN.md
wtclaude 1aba1ff93d docs(auth): design the mobile SSO authorization bridge (M9)
Record the plan before coding: native Android "Sign in with Google/Discord"
via a Mobile SSO Authorization Bridge that extends the existing /auth/sso/*
redirect flow and terminates in the existing mobile bearer tokens.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 17:01:57 -05:00

32 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
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' pendingcompleted 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_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.

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_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. HTTPS App Link URIs can be appended to the allowlist later per shard; that is the only server change 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 (default 1d); payload {id,username,role}.
  • Cookie: httpOnly, sameSite=Lax, path=/, and secure decided per-request (COOKIE_SECURE=autosecure: req.secure). This is the key to dual access: the cookie is Secure when reached through Pangolin (HTTPS, X-Forwarded-Proto: https) but not Secure when reached directly over the LAN IP on plain HTTP — so login works in both. COOKIE_SECURE=true|false can force it. Requires trust proxy (below). localhost:5173 (Vite) and localhost:3000 are same-site, so the cookie flows in dev too.
  • bcrypt hashing (cost 10+); plaintext passwords never stored, logged, or returned.
  • Rate limiting (express-rate-limit) on /auth/login and /public/contact.
  • Validation (express-validator) on all writes; centralized error handler.
  • helmet with a CSP suited to the SPA (self + inline styles as needed; image sources for uploads/hero).
  • Admin not indexed: X-Robots-Tag: noindex, nofollow on /api/v1/admin and the admin SPA routes; robots.txt disallows /admin.
  • No directory browsing (express.static doesn't list; no serve-index).
  • No hardcoded credentials: first admin via seed.js reading ADMIN_USERNAME/ADMIN_PASSWORD from env (created only if no users exist); .env git-ignored, .env.example committed.
  • app.set('trust proxy', 1) so secure cookies, req.ip, and rate-limiting work behind Pangolin.
  • CORS: same-origin in prod (SPA served by Express). Dev only: allow CLIENT_ORIGIN (Vite, http://localhost:5173) with credentials:true.

7. Email

utils/mailer.js (nodemailer) 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 (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, 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