1 Commits

Author SHA1 Message Date
ec468e9983 docs(ci): use the actual case-sensitive SonarQube project keys
link and Android-app reuse the pre-existing capitalised keys
(Runic-Gateway-link, Runic-Gateway-Android-app); the server rejects
case-variant duplicates. Note the case-sensitivity gotcha.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 23:21:04 -05:00
22 changed files with 37 additions and 785 deletions

View File

@@ -169,12 +169,9 @@ none (keeps §11's zero-interaction promise).
registration and every publish validate it is HTTPS and its origin is in the shard's ntfy registration and every publish validate it is HTTPS and its origin is in the shard's ntfy
allow-set (`NTFY_BASE_URL` / `NTFY_ALLOWED_ORIGINS`), rejecting loopback/private hosts. allow-set (`NTFY_BASE_URL` / `NTFY_ALLOWED_ORIGINS`), rejecting loopback/private hosts.
- **ntfy** added to `website/docker-compose.yml` as a pinned upstream image with a committed - **ntfy** added to `website/docker-compose.yml` as a pinned upstream image with a committed
declarative `./ntfy/server.yml` and named volume, **published on a host port** (`NTFY_HOST_PORT`, declarative `./ntfy/server.yml` and named volume, **no published host port** (reached via the
default `2586` → container `:80`) so the public reverse proxy — which runs *outside* the compose reverse proxy; internal-only for the publisher), anonymous read-write to unguessable topics (no
network — can forward the notification subdomain to it, anonymous read-write to unguessable topics per-user accounts — safe because tickles are content-free).
(no per-user accounts — safe because tickles are content-free). Both the app (SSE subscribe) and the
backend (POSTing tickles to registered device endpoints) reach ntfy on that public origin, so all
ntfy traffic transits the proxy — there is no separate internal publish port.
**Part 2 — the Android app — ✅ LANDED** (2026-07-20, `RunicGateway/Android-app#15` + a small **Part 2 — the Android app — ✅ LANDED** (2026-07-20, `RunicGateway/Android-app#15` + a small
`RunicGateway/website#79` settings addition + this docs PR). Built exactly to the plan below, with `RunicGateway/website#79` settings addition + this docs PR). Built exactly to the plan below, with
@@ -422,29 +419,21 @@ console. It is a **read + self-service** app, not an operator tool.
- **Access-level menu**: one shared navigation that reveals items based on the signed-in user's role. - **Access-level menu**: one shared navigation that reveals items based on the signed-in user's role.
- **Opt-in push notifications** (post-v1; architected for from the start): per-stream subscriptions the - **Opt-in push notifications** (post-v1; architected for from the start): per-stream subscriptions the
user chooses — nothing is pushed unless subscribed. See §11. user chooses — nothing is pushed unless subscribed. See §11.
- **Staff operations subset** (post-v1, **M10** — decided 2026-07-21): a *defined* slice of the admin
surface, native, for `admin`/`moderator` staff. In scope: **moderation actions** (kick / ban / unban /
broadcast), the **support (help-page) queue** (reply / close / resolve), the **dashboard summary +
site-mode** toggle, and **content management** (news posts — list / create / edit / publish / delete —
plus wiki category & tag management). These consume the existing `/api/v1/admin/**` routes (which
already accept bearer auth and re-check role every request); no backend routes are added. See §6.4 + §10.
### Explicitly OUT of scope (never in the app, for any role) ### Explicitly OUT of scope (never in the app, for any role)
The exclusions are now a *narrow* list (M10 brought the operational admin subset in-scope, above). The - The **hero editor** and any CMS authoring/block editing.
app still never ships: - The **admin / auth-management console** — user management, invites issuance, SSO provider config,
- The **hero editor** and CMS **block/page authoring** (the visual page builder). *(News-post and moderation console, email config, bot-activity/ban console. (Players still *log in*; what's
wiki category/tag management ARE in scope; the excluded piece is the CMS block editor / hero builder.)* excluded is the management surface, not authentication itself.)
- **Discord bot** configuration (and anything under the unpublished `/internal/**` port — it returns the - The **Discord bot** management (and anything under the unpublished `/internal/**` port — it
decrypted bot token and must never be reachable from a client). returns the decrypted bot token and must never be reachable from a client).
- **uo-link / sidecar administration** — the shard sidecar base-URL/token config (`uoLinkConfig`). (The - **Shard / uo-link administration** — sidecar base-URL/token config (`uoLinkConfig`), shard ops,
app performs *shard operations* like kick/ban/broadcast against the live shard, but never configures the staff shard-user console. (The app shows *public* shard widgets and a player's *own* game
the sidecar connection itself.) data; it does not manage the sidecar.)
- **OAuth / SSO provider setup** — creating/editing identity providers and their client secrets.
> The app calls `/api/v1/public/**`, `/api/v1/auth/**` (incl. the role-agnostic self surface > The excluded surfaces all live under `/api/v1/admin/**` and `/api/v1/internal/**`. The app only
> `/auth/me/*`, §6.4), `/api/v1/player/**`, and — for staff, M10 — the **defined `/api/v1/admin/**` > ever calls `/api/v1/public/**`, `/api/v1/auth/**` (incl. the new role-agnostic self surface
> operations listed above. It never touches `/api/v1/internal/**`, nor the four excluded admin surfaces > `/auth/me/*`, §6.4), and `/api/v1/player/**` — it never references `/admin`.
> (hero/CMS block editor, Discord-bot config, uo-link config, OAuth-provider setup).
--- ---
@@ -502,13 +491,7 @@ compiled in.
`/public/settings` for branding: name/colors/logo). Only on a successful, well-formed response is `/public/settings` for branding: name/colors/logo). Only on a successful, well-formed response is
the URL persisted to DataStore and the app allowed to initialize its main UI. the URL persisted to DataStore and the app allowed to initialize its main UI.
- Accept `https://host[/base]`; normalize/trim; require HTTPS in release builds (allow HTTP only in - Accept `https://host[/base]`; normalize/trim; require HTTPS in release builds (allow HTTP only in
debug for local dev against `127.0.0.1:3000`). This app-layer rule (`ServerUrl`, debug for local dev against `127.0.0.1:3000`).
`allowInsecureHttp = BuildConfig.DEBUG`) is backed at the platform socket layer by an explicit
**network security config** (`res/xml/network_security_config.xml`, wired via
`application android:networkSecurityConfig`): the main/release config permits **no** cleartext,
and a debug-only override (`app/src/debug/res/xml/`) re-permits cleartext to loopback
(`127.0.0.1`/`localhost`) only. Being explicit also stops a merged library manifest from
re-enabling cleartext and clears the `usesCleartextTraffic`-implicitly-enabled scanner finding.
- Failure states: unreachable, non-2xx, not-a-Runic-Gateway-site (missing expected `/public/status` - Failure states: unreachable, non-2xx, not-a-Runic-Gateway-site (missing expected `/public/status`
shape), TLS error — each gets a clear retry message. Nothing else in the app runs until this shape), TLS error — each gets a clear retry message. Nothing else in the app runs until this
succeeds. succeeds.
@@ -546,39 +529,6 @@ Uses the existing **mobile bearer** surface, no backend changes:
`401` from a bearer call, with a mutex so concurrent 401s trigger only one refresh. `401` from a bearer call, with a mutex so concurrent 401s trigger only one refresh.
- `POST /auth/mobile/logout` `{ refreshToken?, all? }` (requires bearer) — revoke this session or all - `POST /auth/mobile/logout` `{ refreshToken?, all? }` (requires bearer) — revoke this session or all
sessions. Called on user logout and on "sign out everywhere." sessions. Called on user logout and on "sign out everywhere."
#### 4.1.1 Trusted devices & recovery codes — **implemented** (app PR `feature/trusted-devices-mfa`)
The backend trusted-device + recovery-code feature (canonical ref: `../website/TRUSTED_DEVICES_MFA.md`)
is additive on the mobile surface. As built:
- **Login extras:** `POST /auth/mobile/login` accepts `recoveryCode` (a single-use alternative to
`code`), `trustDevice: true`, `device_name`, and an `X-Trust-Token` header. On the `401 { totpRequired }`
screen the app offers a "use a recovery code instead" toggle and a **"Trust this device"** checkbox.
When `trustDevice` is accepted, the response carries `trustToken` → stored in a **dedicated
EncryptedSharedPreferences file** (`runic_trust`, AES-256-GCM; never plain prefs/logs) **scoped to the
username** it was minted for, and replayed as `X-Trust-Token` on that account's future logins to skip
the TOTP prompt.
- **Login-time cap:** a `{ trustLimitReached, devices }` response means login **succeeded** but the
device was not remembered (session is already issued on native, unlike the web cookie step). Rather
than a blocking login-time modal, this is surfaced + resolved on the **Trusted Devices** account
screen, whose `POST /auth/me/trusted-devices` gives the exact revoke-one-then-retry flow. (Deliberate
deviation from the earlier "prompt at login" sketch — the mobile login is past the point a modal would
gate.)
- **Self-service (account screens):** a **Security** section on the account screen links to two
dedicated screens. **Trusted Devices** — `GET /auth/me/trusted-devices` (list), `DELETE …/:id`
(revoke one), `DELETE …/trusted-devices` (untrust all), and `POST /auth/me/trusted-devices` to trust
the current device (stores the returned `{ trustToken }`). **Recovery Codes** — enabling TOTP returns
the one-time `recoveryCodes` (shown once on the account screen with copy/share); `GET
…/recovery-codes/status` for the remaining count; `POST …/recovery-codes/generate` (password step-up)
to regenerate, shown once.
- **Invalidation — trust token deliberately survives logout.** The trust token is the native analogue
of the web `rg_trust` cookie, which per the canonical design *survives logout so the next login skips
2FA*. On mobile the token is only ever consulted at a **fresh** login — i.e. exactly after a logout or
a dead-refresh sign-out — so clearing it there would make the feature a no-op. It is therefore kept in
its own store, **untouched by session teardown** (`SessionManager.onSignedOut`), and cleared only on a
**Settings → Server switch** (bound to the old host), an **untrust-all**, or server-side revocation
(password change/reset, TOTP disable — which makes any surviving token inert; the next login just
prompts for the code). This supersedes the earlier handoff note that said to clear it on logout.
### 4.2 Website-handled flows: registration, invite, forgot-password, SSO ### 4.2 Website-handled flows: registration, invite, forgot-password, SSO
These are **not** rebuilt in the app. The app links out to the website's own pages/API and the user These are **not** rebuilt in the app. The app links out to the website's own pages/API and the user
completes them in a Custom Tab, then returns and signs in natively (§4.1): completes them in a Custom Tab, then returns and signs in natively (§4.1):
@@ -679,18 +629,11 @@ Guidelines:
Player self-service is under `/player/account/*` (gated to `role='player'`) and staff use the *same* Player self-service is under `/player/account/*` (gated to `role='player'`) and staff use the *same*
handlers under `/admin/account/*`. Rather than have the app branch by role (and touch `/admin`), we handlers under `/admin/account/*`. Rather than have the app branch by role (and touch `/admin`), we
**add a role-agnostic self surface under `/auth/**`** — the canonical "me" endpoints for every role. **add a role-agnostic self surface under `/auth/**`** — the canonical "me" endpoints for every role.
The app calls these regardless of role. This is an **additive v1** The app calls these regardless of role, and never references `/admin`. This is an **additive v1**
change (see §8): the existing `/player/account/*` and `/admin/account/*` routes stay for web change (see §8): the existing `/player/account/*` and `/admin/account/*` routes stay for web
back-compat; `/auth/me/*` reuses the same `account.controller` handlers behind `requireAuth` (any back-compat; `/auth/me/*` reuses the same `account.controller` handlers behind `requireAuth` (any
authenticated role), so there's no logic duplication. authenticated role), so there's no logic duplication.
> **M10 update (2026-07-21):** *self-service* stays role-agnostic under `/auth/me/*` as above. Separately,
> the **operational** admin subset (§1, §10 — moderation, support queue, dashboard/site-mode, content)
> *does* call `/api/v1/admin/**` directly, gated to staff by a new `STAFF`/`ADMIN` menu access level.
> The earlier "the app never references `/admin`" rule is superseded for these defined staff operations
> only; `validateSession` accepts the mobile bearer on those routes and `requireRole` re-checks every
> request, so a demoted user loses the surface immediately (the app treats any `403` as authoritative).
--- ---
## 7. Degradation & offline ## 7. Degradation & offline
@@ -859,25 +802,6 @@ push, and Play (M6M8) follow the designed app.
generic build stays custom-scheme; white-label/first-party builds bake one host). The custom generic build stays custom-scheme; white-label/first-party builds bake one host). The custom
scheme remains the permanent fallback on every build. Full spec + rollout in scheme remains the permanent fallback on every build. Full spec + rollout in
[`APP_LINKS.md`](./APP_LINKS.md). [`APP_LINKS.md`](./APP_LINKS.md).
11. **M10 — Native SSO fixes + staff operations** (post-v1; decided 2026-07-21). Two threads found
during on-device QA:
- **SSO discovery + reachability fixes (app-only, done):** the native login screen only rendered
provider buttons when `GET /auth/providers` was non-empty and otherwise fell back to the desktop
website login (which can't deep-link a mobile session back → it hung). `AuthRepository.ssoProviders()`
now returns `Available` / `None` / `Unavailable` (retry once); the login screen shows native
buttons / a loading hint / a retry, and the website-login fallback is removed. The pending PKCE
`{state,verifier}` is persisted (encrypted `PendingSsoStore`) so the exchange survives Custom-Tab
process death. The nav drawer is now `verticalScroll`-wrapped so a signed-in session's longer menu
(which includes **Notifications**) can't clip on short screens. Dev testing uses a **stub OAuth IdP**
(`website/scripts/dev/`), since dev configures no real provider. Verified end-to-end on an emulator.
- **Staff operations (§1, §6.4):** a new `STAFF`/`ADMIN` menu access level reveals a staff section
for `admin`/`moderator`, with native screens over the existing `/api/v1/admin/**`: **moderation**
(`POST /admin/shard/{kick,ban,unban,broadcast}`), **support queue** (`GET /admin/shard/pages`,
`POST /admin/shard/pages/:id/{respond,close}`), **dashboard + site-mode** (`GET /admin/dashboard`,
`PUT /admin/site-mode`), and **content** (news posts under `/admin/posts*`, wiki categories/tags
under `/admin/wiki/*`). No backend routes added (they already accept bearer + re-check role);
shard-write actions degrade gracefully when the sidecar is offline. Excluded: hero/CMS block
editor, Discord-bot config, uo-link config, OAuth-provider setup.
--- ---
@@ -914,11 +838,8 @@ first release. Users **opt in per stream**: nothing is pushed unless subscribed.
path/tag at implementation. path/tag at implementation.
- **All config is declarative** — a committed `ntfy` config file and/or `NTFY_*` env vars baked into - **All config is declarative** — a committed `ntfy` config file and/or `NTFY_*` env vars baked into
compose. No `docker exec`, no interactive `ntfy user add`, no post-deploy manual steps. Bringing the compose. No `docker exec`, no interactive `ntfy user add`, no post-deploy manual steps. Bringing the
stack up provisions a working push relay. Reachable via the existing reverse proxy on its own stack up provisions a working push relay. Reachable to devices via the existing reverse proxy on its
hostname/path — the container publishes `:80` on a host port (`NTFY_HOST_PORT`, default `2586`) own hostname/path; internal-only for the backend publisher.
because the proxy runs *outside* the compose network and can only reach a service through a
published host port (the same reason `app` publishes `3000`). Both devices and the backend publisher
reach ntfy on that public origin.
- **No per-user ntfy accounts to administer.** The security model (below) removes the need for ntfy ACL - **No per-user ntfy accounts to administer.** The security model (below) removes the need for ntfy ACL
provisioning, which is exactly what keeps setup interaction-free. ntfy topics are the random, provisioning, which is exactly what keeps setup interaction-free. ntfy topics are the random,
unguessable endpoints UnifiedPush hands out; the backend treats ntfy as an **untrusted relay**. unguessable endpoints UnifiedPush hands out; the backend treats ntfy as an **untrusted relay**.
@@ -1008,10 +929,8 @@ it is a UX convenience, not a v1 requirement — deferred at M3, descoped at M6;
See [`APP_LINKS.md`](./APP_LINKS.md). See [`APP_LINKS.md`](./APP_LINKS.md).
- ntfy: exact upstream image + pinned tag (Part-1 landed the compose service — confirm the tag), and - ntfy: exact upstream image + pinned tag (Part-1 landed the compose service — confirm the tag), and
its reverse-proxy hostname/path. The hostname must land in `NTFY_ALLOWED_ORIGINS` before M7 Part 2 is its reverse-proxy hostname/path. The hostname must land in `NTFY_ALLOWED_ORIGINS` before M7 Part 2 is
end-to-end testable (the app registers an endpoint on that origin; the SSRF guard rejects others). The end-to-end testable (the app registers an endpoint on that origin; the SSRF guard rejects others). No
reverse proxy forwards that hostname to the ntfy container's published host port (`NTFY_HOST_PORT`, backend publish token — **decided** (the content-free-tickle design does not require one; optional
default `2586`) — the container publishes `:80` because the proxy runs outside the compose network.
No backend publish token — **decided** (the content-free-tickle design does not require one; optional
`NTFY_PUBLISH_TOKEN` is honored if ever set). `NTFY_PUBLISH_TOKEN` is honored if ever set).
- FCM flavor: build it for the Play release or ship Play on UnifiedPush too? Decide at M8. (The M7 - FCM flavor: build it for the Play release or ship Play on UnifiedPush too? Decide at M8. (The M7
Part 2 `PushTransport` seam keeps this swap cheap.) Part 2 `PushTransport` seam keeps this swap cheap.)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

View File

@@ -1,49 +0,0 @@
# Android app — trusted devices & recovery codes (live smoke test)
Screenshots from a live end-to-end smoke test of the trusted-device + MFA feature on
the Android client (app PR `RunicGateway/Android-app#23`), captured against the local
Node server (`127.0.0.1:3000`) and a `uomysticmoon` MariaDB, on an API 36 emulator.
See `../PLAN.md §4.1.1` for the design and `../../website/TRUSTED_DEVICES_MFA.md` for
the canonical contract.
| # | Screenshot | Shows |
|---|---|---|
| 1 | [`01-login-2fa-trust-device.png`](01-login-2fa-trust-device.png) | The `401 { totpRequired }` login step: the **authentication code** field, the **"Use a recovery code instead"** toggle, and the **"Trust this device (skip codes for 30 days)"** checkbox (ticked). |
| 2 | [`02-account-security-section.png`](02-account-security-section.png) | The new **Security** section on the account screen linking to Trusted devices and Recovery codes. |
| 3 | [`03-trusted-devices.png`](03-trusted-devices.png) | The **Trusted Devices** screen listing this device (`Google sdk_gphone64_x86_64` — the `device_name` sent at login) with revoke / trust-this-device / untrust-all. |
| 4 | [`04-recovery-codes-show-once.png`](04-recovery-codes-show-once.png) | The **Recovery Codes** screen after a password-stepped regenerate: the one-time batch shown once with copy / share, and the updated remaining count. |
| 5 | [`05-recovery-code-login.png`](05-recovery-code-login.png) | Signing in with a **single-use recovery code** instead of the authenticator code. |
## Verified flows (all passed)
1. **2FA login + "Trust this device"**`200`, trust token stored; server logged `device trusted`.
2. **Trust survives logout** — after signing out, a **password-only** sign-in skipped the
TOTP step entirely (server: a clean `200` with **no** preceding `401 totpRequired`).
This is the headline behaviour: the trust token is *only* consulted at a fresh login,
so it must outlive logout (see PLAN §4.1.1).
3. **Trusted Devices** — list, and the device's `last_used` stamp advancing after the
trust-skip login.
4. **Untrust all** — cleared the server rows **and** the local token; the next
password-only sign-in correctly required the TOTP step again (server: `401`).
5. **Recovery codes** — generate (password step-up, shown once) and a successful
**recovery-code login** (server: `mobile login via recovery code``200`).
## Full step-by-step walkthrough
The five images above are the curated highlights. These `walkthrough-*` frames are the
rest of the same smoke-test session, in flow order, for a complete record. (Pure
automation-artifact frames — soft-keyboard popups, mid-transition spinners, and
duplicate Home landings — are omitted; the raws that the highlights above supersede are
not repeated here.)
| # | Screenshot | Shows |
|---|---|---|
| 1 | [`walkthrough-01-home-connected.png`](walkthrough-01-home-connected.png) | Home with the shard **Online** (already connected to the local server), signed out. |
| 2 | [`walkthrough-02-drawer-signed-out.png`](walkthrough-02-drawer-signed-out.png) | Navigation drawer while signed out — **Sign in** entry. |
| 3 | [`walkthrough-03-login-screen.png`](walkthrough-03-login-screen.png) | The native login screen (no SSO providers configured in dev). |
| 4 | [`walkthrough-04-login-2fa-step.png`](walkthrough-04-login-2fa-step.png) | The `401 { totpRequired }` step with the fields empty — the **"Use a recovery code instead"** toggle and **"Trust this device"** checkbox before entry (companion to highlight #1, which shows them filled). |
| 5 | [`walkthrough-05-drawer-signed-in.png`](walkthrough-05-drawer-signed-in.png) | Drawer once signed in — **My account**, Notifications, player groups, **Sign out**. |
| 6 | [`walkthrough-06-account-overview.png`](walkthrough-06-account-overview.png) | Top of the account screen: identity, username/password, **two-factor ENABLED**. |
| 7 | [`walkthrough-07-recovery-codes-before-generate.png`](walkthrough-07-recovery-codes-before-generate.png) | Recovery Codes screen before generating — **0 codes remaining** + the password-step-up form. |
| 8 | [`walkthrough-08-trusted-devices-empty-after-untrust.png`](walkthrough-08-trusted-devices-empty-after-untrust.png) | Trusted Devices after **Untrust all** — "All devices untrusted." and the empty state. |
| 9 | [`walkthrough-09-login-2fa-required-after-untrust.png`](walkthrough-09-login-2fa-required-after-untrust.png) | The next sign-in **re-prompting for the TOTP step** — proof that untrust-all cleared the local trust token. |

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

View File

@@ -16,8 +16,13 @@ feeds the dashboard.
| Repo | Project key | Sources analysed | Language | | Repo | Project key | Sources analysed | Language |
|---|---|---|---| |---|---|---|---|
| `website` | `runic-gateway-website` | `server/src`, `client/src`, `bot/src` | JS/TS | | `website` | `runic-gateway-website` | `server/src`, `client/src`, `bot/src` | JS/TS |
| `link` | `runic-gateway-link` | `sidecar/src` | Rust | | `link` | `Runic-Gateway-link` | `sidecar/src` | Rust |
| `Android-app` | `runic-gateway-android-app` | `app/src/main` | Kotlin | | `Android-app` | `Runic-Gateway-Android-app` | `app/src/main` | Kotlin |
> Project keys are **case-sensitive** and must match what already exists on the
> server — SonarQube refuses to create a key that differs only in case from an
> existing one. `link` and `Android-app` reuse the pre-existing capitalised keys
> above; `website` predates this note with its lower-case key.
## How it's wired ## How it's wired

View File

@@ -1,114 +0,0 @@
# Website — Architecture
How the pieces of `RunicGateway/website` fit together. The React SPA and the native mobile app
talk to one Express backend (`router → controller → model → db`), which persists to MariaDB and
bridges to the live game world **only** through the **uo-link** sidecar. The ServUO shard itself is
never internet-facing — it dials out to the sidecar over loopback, and only the sidecar is exposed.
This is the canonical copy of the diagram; the same diagram is embedded in the website's
[`README.md`](https://gitea.whitlocktech.com/RunicGateway/website/src/branch/main/README.md#architecture).
See [BACKEND_DESIGN.md](BACKEND_DESIGN.md) for the full API / schema / security contract, and
[`docs/link/`](../link/) for the wire protocol between the sidecar and the shard.
```mermaid
flowchart TB
%% ---------- Clients ----------
subgraph clients["Clients"]
browser["Browser<br/>React + Vite SPA<br/>(public · wiki · admin)"]
mobile["Native mobile app<br/>(bearer tokens)"]
end
idp["SSO providers<br/>Google · Discord · custom OIDC"]
discord["Discord"]
%% ---------- Website (one repo) ----------
subgraph website["website/ &nbsp;— Node app (one repo)"]
direction TB
subgraph backend["server/ — Express backend"]
direction TB
mw["Middleware<br/>helmet · siteMode · noindex<br/>rateLimit · loginProtection · botScore · validate"]
router["Router /api/v1<br/>auth (web · mobile · sso) · public · admin"]
ctrl["Controllers"]
auth["Session layer (auth/)<br/>sessionService · JWT/cookie · bearer · SSO+PKCE"]
model["Models (.model + .db)<br/>raw parameterized SQL — no ORM"]
sse["SSE fan-out<br/>public stream (allowlist) · admin stream (sensitive)"]
subgraph shardutil["Shard integration (utils/)"]
ingest["shardIngest.js<br/>WS ingest dispatcher"]
restcli["uoLinkClient.js<br/>REST client (never throws)"]
end
secret["secretBox.js<br/>AES-256-GCM secrets at rest"]
end
bot["bot/<br/>Discord bot"]
end
db[("MariaDB<br/>users · posts · wiki · settings · activity<br/>mobileSessions · authProviders · userIdentities<br/>uoLinkConfig · shard_online/economy/houses/events")]
%% ---------- Shard side ----------
subgraph shardside["Game shard (never internet-facing)"]
direction TB
sidecar["uo-link sidecar<br/>(Rust) — the only bridge exposed"]
servuo["ServUO shard<br/>(C# plugin)"]
end
%% ---------- Edges ----------
browser <-->|"same-origin JSON + SSE (cookie)"| mw
mobile -->|"REST (bearer access/refresh)"| mw
browser -.->|"OAuth redirect + PKCE"| idp
auth -.->|"token exchange"| idp
mw --> router --> ctrl
ctrl --> auth
ctrl --> model
ctrl --> restcli
ctrl --> sse
auth --> model
model <--> db
auth -. reads/writes secrets .-> secret
restcli -. reads config/token .-> secret
ingest --> model
ingest --> sse
sse -->|"live events"| browser
bot -->|"messages"| discord
bot <--> db
restcli -->|"REST: /char /roster /economy /history · /link/confirm · /towncrier"| sidecar
sidecar -->|"WebSocket live event feed (bearer + X-UOLink-Version)"| ingest
servuo -->|"loopback TCP 127.0.0.1:7788<br/>newline-delimited JSON (shard dials out)"| sidecar
%% ---------- Styling ----------
classDef ext fill:#2d2233,stroke:#7a5c94,color:#e8dff0;
classDef store fill:#1f2d2a,stroke:#4c8c7d,color:#dff0ea;
classDef bridge fill:#2d2620,stroke:#94764c,color:#f0e6d8;
class idp,discord ext;
class db store;
class sidecar,servuo bridge;
```
## Notes on the diagram
- **One backend, layered.** Every request flows `middleware → router → controller → model → db`.
Web browsers authenticate with an httpOnly JWT cookie; the native app uses short-lived bearer
access tokens plus rotated, hashed, revocable refresh tokens; SSO (Google/Discord/OIDC) is
link-only and PKCE-guarded. All three surfaces resolve to the *same* session model via the session
layer, and admin routes are re-validated against the DB on every request.
- **The shard is never reachable.** The ServUO shard *dials out* over loopback TCP `127.0.0.1:7788`
(newline-delimited JSON) to the uo-link sidecar; only the sidecar is exposed, and only the backend
talks to it. Every backend→sidecar call carries `Authorization: Bearer <token>` and an
`X-UOLink-Version` header (a protocol mismatch fails fast with `409`). The REST client
(`uoLinkClient.js`) never throws — every call returns `{ ok, data, status }` — so the site degrades
gracefully when the shard is down.
- **Two ways in from the sidecar.** Live game events arrive over an outbound **WebSocket** and are
routed by the `shardIngest.js` dispatcher (state-changing kinds update `shard_*` tables, notable
kinds append to `shard_events`, high-frequency kinds only update state). Point-in-time reads and
commands go over **REST** through `uoLinkClient.js`.
- **Sensitive events stay private.** Ingested events fan out to browsers over two **SSE** channels —
a public allowlist stream and an admin-only stream that additionally carries staff audit, cheat
detection, and login-attempt events. The allowlist split is a security boundary; sensitive kinds
can never leak onto the public channel.
- **Secrets at rest.** OAuth client secrets, the uo-link token, and the Gmail refresh token are
AES-256-GCM encrypted via `secretBox.js` (keyed by `SECRET_ENC_KEY`). The uo-link token is
write-only in the API — never returned to any client.

View File

@@ -223,28 +223,6 @@ list/revoke surface: `device_name VARCHAR(100) NULL` (a friendly label) and `las
NULL` (bumped on each refresh). Existing rows get them via the schema's ALTER section; the token model NULL` (bumped on each refresh). Existing rows get them via the schema's ALTER section; the token model
is otherwise unchanged. 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.
--- ---
## 4. API contract ## 4. API contract
@@ -255,9 +233,8 @@ accepts `Authorization: Bearer` for API testing).
### /auth (auth.routes.js → auth.controller.js) ### /auth (auth.routes.js → auth.controller.js)
| Method | Path | Auth | Body | Purpose | | 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` | — (rate-limited) | `{username,password}` | verify, set cookie, log `auth.login`, update `last_login_at` |
| 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 |
| 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 | | 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`. | | 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) | | GET | `/password/reset/:token` | — | — | validate a link → `{username}` for the form, else 404 (never distinguishes expired/used/never-existed) |
@@ -265,13 +242,8 @@ accepts `Authorization: Bearer` for API testing).
| GET | `/me/account` | cookie / bearer | — | full self account (`id, username, role, email, status, totp_enabled, has_password`) | | 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/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 | | 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 | | 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 | | 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) | | 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/devices` · DELETE `…/:id` | cookie / bearer | — | list / unregister own push devices |
| GET | `/me/notifications/streams` | cookie / bearer | — | the subscribable catalog (`personal`/`requiresLinkedAccount` flags) | | GET | `/me/notifications/streams` | cookie / bearer | — | the subscribable catalog (`personal`/`requiresLinkedAccount` flags) |
@@ -284,16 +256,6 @@ lets a client (the Android app) manage its own account through one surface witho
`/admin` (docs/android/PLAN.md §6.4). The older `/player/account/*` + `/admin/account/*` routes stay `/admin` (docs/android/PLAN.md §6.4). The older `/player/account/*` + `/admin/account/*` routes stay
for web back-compat. 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 `/player/*` router (game-account linking,
character/vendor/house reads, credential changes, appeals) sits behind `requireAuth` **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 **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 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 also serves SSO-only accounts (null `password_hash`) as their "set an initial password" path. The
@@ -413,9 +375,6 @@ Public content GETs pass through the **siteMode** gate (§5).
| GET | `/settings` · PUT `/settings` | read all / update `{key:value,...}` | | GET | `/settings` · PUT `/settings` | read all / update `{key:value,...}` |
| GET | `/activity?limit=&offset=` | paginated activity log | | 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` · 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`. Every admin write logs to `activity_log`.
@@ -446,8 +405,7 @@ who"; `activity_log` provides the history feed.
## 6. Auth & security ## 6. Auth & security
- **JWT** signed with `JWT_SECRET`, `expiresIn=JWT_EXPIRES_IN` (default `1d`); payload `{id,username,role}`. - **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`). - **Cookie**: `httpOnly`, `sameSite=Lax`, `path=/`, and **`secure` decided per-request** (`COOKIE_SECURE=auto``secure: req.secure`). This is the key to dual access: the cookie is `Secure` when reached through Pangolin (HTTPS, `X-Forwarded-Proto: https`) but **not** `Secure` when reached directly over the LAN IP on plain HTTP — so login works in both. `COOKIE_SECURE=true|false` can force it. Requires `trust proxy` (below). `localhost:5173` (Vite) and `localhost:3000` are same-site, so the cookie flows in dev too.
- **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. - **bcrypt** hashing (cost 10+); plaintext passwords never stored, logged, or returned.
- **Rate limiting** (`express-rate-limit`) on `/auth/login` and `/public/contact`. - **Rate limiting** (`express-rate-limit`) on `/auth/login` and `/public/contact`.
- **Validation** (`express-validator`) on all writes; centralized error handler. - **Validation** (`express-validator`) on all writes; centralized error handler.
@@ -511,13 +469,11 @@ subsystem (`[server]`, `[http]`, `[db]`, `[auth]`, `[admin]`, `[ratelimit]`, …
`env_file: .env`, `DB_HOST=db`, `depends_on: db (healthy)`, volume `uploads:/app/uploads`, `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. `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` (M7): pinned upstream `binwiederhier/ntfy` image, declarative config only
(`./ntfy/server.yml` mounted `:ro` + `NTFY_BASE_URL`), volume `ntfydata:/var/lib/ntfy`, (`./ntfy/server.yml` mounted `:ro` + `NTFY_BASE_URL`), volume `ntfydata:/var/lib/ntfy`, **no
**publishes `:80` on a host port** (`${NTFY_HOST_PORT:-2586}:80`, binds 0.0.0.0) so Pangolin — which published host port** — devices reach it via the reverse proxy; the backend publisher reaches it
runs outside the compose network — can forward the notification subdomain to it, the same reason over the private compose network. Anonymous read-write to unguessable topics (no accounts to
`app` publishes `3000`. Both devices (SSE subscribe) and the backend publisher (POSTing tickles to provision) — safe because pushes are content-free tickles. Bringing the stack up provisions a
registered device endpoints) reach ntfy on that public origin. Anonymous read-write to unguessable working push relay with **zero interactive setup**.
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`. - Volumes: `dbdata`, `uploads`, `ntfydata`.
Express listens on `0.0.0.0:${PORT||3000}`. Pangolin terminates TLS and proxies to `app`. Express listens on `0.0.0.0:${PORT||3000}`. Pangolin terminates TLS and proxies to `app`.
@@ -550,9 +506,6 @@ NTFY_BASE_URL=https://ntfy.example.com
# shows push as unavailable for the shard. # shows push as unavailable for the shard.
NTFY_PUBLIC_URL=https://ntfy.example.com NTFY_PUBLIC_URL=https://ntfy.example.com
NTFY_ALLOWED_ORIGINS=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/`. `.gitignore`: `node_modules/`, `.env`, `_reference/`, `client/dist/`, `uploads/`.

View File

@@ -1,210 +0,0 @@
# Trusted Devices & MFA Improvements — Design & Implementation Plan
> Reference plan for the trusted-device + MFA hardening work. Approved 2026-07-21.
> This document is the contract the implementation builds against; keep it in sync
> with `BACKEND_DESIGN.md` (§3 schema, §4 API, §6 security) as code lands.
## 1. Goal & scope
Reduce 2FA friction without weakening the second-factor boundary, and close the
2FA-lockout gap. Four deliverables:
1. **Trusted devices** — an opt-in "Trust this device" that lets a browser or the
Android app **skip the TOTP step** (never the password) on future logins for a
fixed window.
2. **Recovery / backup codes** — single-use codes generated at 2FA enrollment so a
user who loses their authenticator can self-recover instead of needing an admin
reset.
3. **Admin-managed revocation** — staff can view and revoke a user's trusted
devices and reset their MFA, with full audit logging (**backend endpoints _and_
admin front-end screens**).
4. **Step-up (password) for sensitive operations** — reusing the existing
`currentPassword`-verification pattern; disabling TOTP keeps its stronger
current-TOTP-code requirement.
Touches `website/` (server + client), `docs/`, and `android-app/` (plan only in
this pass). **No** `link/` or `servuo-plugins/` change — no wire-protocol impact.
### Approved decisions
| Decision | Value |
|---|---|
| Trust duration | **30 days** (matches mobile refresh-token lifetime) |
| Roles eligible | **All roles** (no staff carve-out) |
| Opt-in model | **Explicit "Trust this device" checkbox, default off** |
| Recovery codes | **10 codes**, shown **once**, single-use |
| Trusted-device cap | **10 per user, no silent pruning** (see §5) |
| Trust-token hashing | **sha256** |
| Recovery-code hashing | **bcrypt** (cost 10) |
## 2. Current state (starting point)
- **One session service** (`server/src/auth/session.service.js`) backs web (JWT
`httpOnly` cookie, 1d) and mobile (15m access JWT + 30d opaque refresh token).
`requireAuth` accepts either via `token.extractToken()`.
- **TOTP** is opt-in per user (`users.totp_secret` / `totp_enabled`), demanded on
**every** login. Web uses a staged 5-min `stage:'totp'` challenge; mobile uses a
single-request `401 { totpRequired }`. **No recovery codes** exist today.
- **Device tracking exists only on mobile** (`mobile_refresh_tokens` rows with
`device_name` / `device_hash` / `user_agent` / `last_used_at`). Web JWTs are
stateless with no per-session row.
- **Revocation is mature:** `revoked_sessions` (jti denylist) + `tokens_valid_after`
(per-user cutoff) for web; per-token rows + `revokeAllForUser` for mobile.
- **No trusted-device or step-up concept exists anywhere.**
## 3. Hashing rationale
The repo already splits hashing by secret entropy, and this plan follows it:
- **sha256** — every high-entropy machine-generated opaque token
(`mobile_refresh_tokens`, `mobile_auth_codes`, `user_invites`, `password_resets`,
SSO PKCE). **Trusted-device tokens use sha256:** they are 256-bit random values
(nothing to brute-force) looked up **by a `token_hash UNIQUE` index**, which
requires a deterministic hash — bcrypt's per-row salt would break the lookup and
truncates input at 72 bytes.
- **bcrypt (`bcryptjs`, cost 10)** — the repo uses it only for **passwords**, the
one human-chosen low-entropy secret. **Recovery codes use bcrypt:** they are a
human-typed, lower-entropy fallback credential that grants a login (the closest
analogue to a password), and there is no hash-lookup constraint — we fetch the
identified user's ≤10 code rows and `bcrypt.compare` each, exactly like password
verification.
## 4. Database (additive, idempotent — matches `schema.sql` style)
### `trusted_devices`
Pattern-identical to `mobile_refresh_tokens`; stores only the token hash.
| Column | Type | Notes |
|---|---|---|
| id | INT PK AUTO_INCREMENT | |
| user_id | INT NOT NULL | FK → users, `ON DELETE CASCADE` |
| token_hash | CHAR(64) NOT NULL UNIQUE | sha256 hex of the opaque trust token |
| platform | ENUM('web','mobile') NOT NULL DEFAULT 'web' | |
| device_name | VARCHAR(100) NULL | friendly label |
| device_hash | VARCHAR(32) NULL | best-effort UA+IP, **display only** |
| user_agent | VARCHAR(255) NULL | |
| created_at | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | |
| last_used_at | DATETIME NULL | stamped when trust is honored at login |
| expires_at | DATETIME NOT NULL | created_at + 30d |
| revoked_at | DATETIME NULL | |
Indices: `idx_td_user (user_id)`, `idx_td_expires (expires_at)`.
### `recovery_codes`
| Column | Type | Notes |
|---|---|---|
| id | INT PK AUTO_INCREMENT | |
| user_id | INT NOT NULL | FK → users, `ON DELETE CASCADE` |
| code_hash | VARCHAR(72) NOT NULL | **bcrypt** hash of one code |
| used_at | DATETIME NULL | single-use marker |
| created_at | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | |
Index: `idx_rc_user (user_id)`.
No new `users` column: password change/reset and TOTP-disable **bulk-revoke**
`trusted_devices` rows and **delete** `recovery_codes` (consistent with
`revokeAllForUser`), so no "trust epoch" column is needed.
## 5. Trusted-device cap — no silent pruning
Cap = **10**. A shared `assertUnderTrustCap(userId)` guards both entry points (the
login/TOTP trust path and the authenticated "trust this device" path). On the 11th
attempt the backend **refuses to create the row** and returns
`409 { error: 'trusted_device_limit', devices: [...] }`. Login itself still
succeeds — only the trust marker is withheld. The web client then renders a modal
**in the same visual pattern as the TOTP entry flow** that:
1. shows the existing trusted devices,
2. requires revoking ≥1 before continuing,
3. completes via `POST /auth/me/trusted-devices` (trust current device), and
4. offers **Cancel**, which returns without creating any trust entry.
## 6. API additions
### Auth (login paths)
- `POST /auth/login` — after password verify, if a valid unrevoked `rg_trust`
cookie matches a live `trusted_devices` row for this user → **skip TOTP**, issue
the session, log `auth.login.trusted_device`, stamp `last_used_at`. Otherwise
unchanged (`{ totpRequired, challenge }`).
- `POST /auth/login/totp` — gains optional `trustDevice` + `deviceName`, and
accepts a **recovery code** as an alternative to the TOTP code (single-use). On
success with `trustDevice`, mint the opaque trust token, set the `rg_trust`
cookie, insert the row (subject to the cap → `409` signal).
- `POST /auth/mobile/login` — gains `trustDevice` / `recoveryCode`; returns a
`trustToken` the app stores in EncryptedSharedPreferences and replays on a later
login to skip TOTP. Same cap behavior.
### Self-service (`/auth/me/*`, `requireAuth`, any role)
- `GET /auth/me/trusted-devices` — list active trusted devices (never tokens).
- `POST /auth/me/trusted-devices` — trust the current browser/device (cap-checked).
- `DELETE /auth/me/trusted-devices/:id` — revoke one (ownership-scoped).
- `DELETE /auth/me/trusted-devices` — revoke all ("untrust everywhere").
- `POST /auth/me/account/recovery-codes/generate`**password step-up required**;
returns the codes **once**.
- `GET /auth/me/account/recovery-codes/status` — remaining count only.
### Admin (`requireRole('admin')`)
- `GET /admin/users/:id/trusted-devices` — list a user's trusted devices.
- `DELETE /admin/users/:id/trusted-devices/:deviceId` — revoke one.
- `DELETE /admin/users/:id/trusted-devices` — revoke all.
- MFA reset control (revoke trust + disable TOTP + clear recovery codes).
## 7. Cookie / refresh / JWT interaction
- New **`rg_trust`** cookie: `httpOnly`, `sameSite=Lax`, `secure` per-request
(reuse `cookieSecure`), `path=/`, `maxAge` 30d, opaque 256-bit base64url,
sha256-hashed server-side. **Separate from the session cookie and deliberately
survives logout** (so the next login skips 2FA); only untrust / password-change /
TOTP-disable revoke it.
- **JWTs stay stateless and unchanged** — trust is a server-side cookie+row, never
a JWT claim, so it remains revocable.
- **Refresh flow untouched** — trust is consulted only at the login/password step,
never at token refresh; the two stores stay independent.
## 8. Security & invalidation
- Trust **only ever gates the second factor**; password is always required.
- Recovery-code entry reuses the login brute-force stack (backoff + bot scoring +
rate limits); recovery codes are single-use.
- **Password change/reset and TOTP-disable clear trust and recovery codes.**
- **Audit logging** via existing `activity.log` / `activity_log`:
`auth.login.trusted_device`, `account.trusted_device.add` / `.revoke` /
`.revoke_all`, `account.recovery_codes.generate`, `account.recovery_code.consume`,
and admin `admin.trusted_device.revoke` / `.revoke_all`, `admin.user.totp.reset`
— each with actor, target user, and device id in `detail`.
## 9. Backwards compatibility
Fully additive. With no `rg_trust` cookie the behavior is exactly today's (TOTP
every login). Recovery codes exist only for users who generate them. No existing
session or login flow changes shape. New tables via `CREATE TABLE IF NOT EXISTS`
and columns via `ALTER TABLE … ADD COLUMN IF NOT EXISTS`.
## 10. Implementation roadmap
1. **Schema + models**`trusted_devices` (sha256, cap-checked) + `recovery_codes`
(bcrypt); `.db.js` / `.model.js` pairs mirroring `mobileSessions`.
2. **Session service** — trust-token mint/sha256/verify + recovery-code
generate/bcrypt-verify/consume helpers (pure, DB-free); shared
`assertUnderTrustCap()`.
3. **Web login** — trust-cookie skip in `/auth/login`; `trustDevice` / recovery
handling + cap `409` in `/auth/login/totp`; set/clear `rg_trust`.
4. **Mobile login**`trustDevice` / `trustToken` / `recoveryCode`, same cap.
5. **Self-service + admin backend**`/auth/me/trusted-devices*` + recovery-code
endpoints; `/admin/users/:id/trusted-devices*` + MFA reset (all admin-gated).
6. **Invalidation wiring** — password change/reset & TOTP-disable revoke trust +
delete recovery codes.
7. **Web client UI** — "Trust this device" checkbox; cap-reached TOTP-styled modal
(revoke-to-continue / cancel); user Trusted Devices + Recovery Codes screens.
8. **Admin front-end UI** — admin Trusted Devices management & revocation screens
(per-user list, revoke one / revoke all, MFA reset), wired to step 5.
9. **Android** — record the app-side trust/recovery flow in
`docs/android/PLAN.md`; app implementation sequenced after the backend lands.
10. **OpenAPI + docs**`#swagger.*` on every new/modified route + regenerate
`server/swagger/swagger-output.json`; update `BACKEND_DESIGN.md` §3/§4/§6.
11. **Automated tests** — trusted-device login skip (valid / missing / expired /
revoked), token mint+hash, recovery-code single-use consume + wrong-code
backoff, cap `409` behavior, revocation (self + admin), invalidation on
password-change / TOTP-disable, and permission checks (admin routes reject
non-admins; self routes ownership-scoped); web client pure-logic tests; Android
JVM DTO/repository tests.

View File

@@ -1,154 +0,0 @@
# Runic Gateway Website — Test Plan (Discord bot)
> Companion to [website-README.md](website-README.md) (overview) and
> [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (server API/schema/security contract).
> Establishes the test plan for the **`bot/` workspace** (the Discord bot), the
> last of the three `website` npm workspaces without a suite. The server and
> client suites already exist (website PR #86); this doc closes the bot gap and
> records the shared conventions so all three stay consistent.
## 1. Goal & philosophy
Cover **meaningful bot behavior** — the moderation/filter decisions a future
change could silently break — not a coverage number. The bot's value is in *what
it decides to delete, warn, mute, or let through*; a good test reads "given this
message + config, what action does the bot take?", never "did this function call
that function?".
The whole `website` repo tests on **Node's built-in runner (`node --test`)** with
**zero external test dependencies** — no jest, no vitest, no jsdom. Unit tests
run without a database or a live Discord gateway: the DB pool is pointed at a dead
port and every collaborator (`db.query`, a model, a Discord `message`/`client`) is
replaced with an in-memory fake. This mirrors the server suite exactly (the bot is
CommonJS, like the server — `require`/`module.exports` — so the same patterns
apply verbatim).
## 2. Current state
| Workspace | Runner | Status |
|---|---|---|
| `server/` | `node --test` | **Exists** — models, controllers, auth/session, shard ingest. |
| `client/` | `node --test` (pure-logic ESM) | **Exists** (PR #86) — `lib/`, `api/`, `data/`. |
| `bot/` | — | **This plan** — no runner or tests yet. |
The 46% SonarQube aggregate is dragged down by the bot (and the client's
un-unit-testable React components) reading as 0% covered. Standing up the bot
suite lifts the real number and, more importantly, locks the filter/moderation
rules.
## 3. Harness
Add a test script to `bot/package.json` (mirrors server/client):
```json
"scripts": { "test": "node --test" }
```
Conventions, identical to `server/test/`:
- **No DB.** Set `process.env.DB_HOST = '127.0.0.1'` / `DB_PORT = '59999'` at the
top of any test whose module transitively `require`s `../db`, so the pool is
built against a dead port and a stray query fails fast instead of hanging.
Models are exercised by monkeypatching `db.query` (or the model method the unit
under test calls) with an in-memory fake; restore it in `afterEach`.
- **No Discord.** The `discord.js` `Client`, `Message`, `GuildMember`, and
`Interaction` objects are hand-rolled fakes carrying only the fields the unit
reads (e.g. `message.mentions.users.size`, `message.member.roles.cache`,
`message.client.fetchInvite`). Never construct a real client or open a gateway
connection.
- **Tests live in `bot/test/*.test.js`.** One file per module under test.
## 4. Units to cover
Ordered high-value first. Each row names the module, the behavior worth locking,
and the seam a test drives it through.
### 4.1 Pure logic (no mocks beyond inputs)
| Module | Behaviors to lock | Notes |
|---|---|---|
| `filter/normalize.js` | leetspeak folding (`b4d``bad`, `@ss``ass`), 3+-repeat collapse (`sooooo``so`), case-fold; `matches()` is word-boundary anchored (so `bad` does not fire inside `badminton`); `findMatch()` returns the first `{word, severity}` row or `null`; the *documented gaps* stay gaps (spaced-out `b a d` is **not** caught). | The core obfuscation-resistance contract. Pin the gaps too, so a future tightening is a deliberate, test-visible change. |
| `utils/duration.js` | `"30s"/"10m"/"2h"/"1d"` → correct ms; whitespace tolerated; garbage/empty/unknown-unit → `null`; `MAX_TIMEOUT_MS` is 28 days (the Discord cap callers clamp to). | Feeds `/mute` and temp-role expiry — a wrong parse mutes for the wrong span. |
| `filter/spamFilter.js` | `isRateLimited` trips on the 6th message inside the 5 s window and is per-`guild:user`; it has a **side effect** (records the timestamp) so it must be called once; `isMassMention` counts users **+** roles against the threshold; `isMassEmoji` counts custom `<:name:id>` and unicode pictographs. | Drive time with a stubbed `Date.now` (or accept the real clock and use tight windows). The module-load `setInterval(...).unref()` sweep must not keep the runner alive — `.unref()` already handles this; assert no leak by letting the suite exit. |
### 4.2 Logic with a single mocked collaborator
| Module | Behaviors to lock | Seam / mock |
|---|---|---|
| `filter/inviteFilter.js` | returns the **code** (not a bool) of the first *foreign* invite; an invite resolving to the current guild is allowed; an invite that **fails to resolve** (expired/invalid) is treated as foreign (fail-closed); no invite in the message → `null`. | Fake `message.client.fetchInvite(code)` — resolve with `{ guild: { id } }` for local/foreign, or throw for unresolvable. Set `message.guildId` + `message.content`. |
| `site/siteApiClient.js` | never throws on a network/timeout/HTTP failure — always returns the `{ ok, ... }` shape so a command never breaks when the site is down: `{ ok: true, data }` on success, `{ ok: false, error }` on failure, and the distinct `{ ok: false, maintenance: true, message }` for the site's 503 maintenance response. (Public read client — **no** shared secret; the keyed channel is `botInternalClient.js`.) | Stub `global.fetch` (resolve various statuses/bodies, reject/timeout via `AbortController`). Same non-throwing contract the server's `uoLinkClient` follows. |
| `internal/requireInternalKey.js` | `401` on a missing/wrong key; `next()` on the configured key; **fail-closed** when `BOT_INTERNAL_KEY` is empty (never matches); timing-safe compare (equal-length + `crypto.timingSafeEqual`, no early-exit length leak). | Call the middleware with a fake `req` (`req.get('X-Internal-Key')`) + `res`/`next` spies; mirrors `server/test/requireInternalKey.test.js`. |
### 4.3 The filter pipeline (orchestration)
`discord/messageFilter.js` is the highest-value integration seam — it composes
all of §4.14.2. Lock the **decision order and the actions**, with every
collaborator faked:
- **Bypass wins first:** an allow-listed channel, or a member holding an
allow-listed role, short-circuits the whole pipeline (`isBypassed`) — no
delete, no hit recorded.
- **Order:** invite link → banned word → spam (rate-limit → mass-mention →
mass-emoji). `detectSpam` must evaluate `isRateLimited` **first and exactly
once** (it has the timestamp side effect).
- **Actions:** invite/spam always delete + warn (no severity tiers); a
word-filter hit applies the action for its severity; filter-triggered mutes use
the fixed `FILTER_MUTE_SECONDS` (600 s).
- **Best-effort recording never breaks moderation:** `recordFilterHit` /
`recordSpamHit` throwing must be swallowed — the delete/warn still happens.
Mocks: `filterCache` (in-memory `allowChannels`/`allowRoles` Sets + words), a fake
`message` (content, author, member roles, `delete()`, `guildId`), and spies on
`warnings`/`filterHits`/`spamHits`/`modLog` to assert what was called.
### 4.4 Models (`bot/model/*.js`)
Thin `db.query` wrappers (e.g. `warnings.js`, `filterWords.js`, `filterHits.js`,
`spamHits.js`, `memberEvents.js`, `guildConfig.js`, `scheduledMessages.js`,
`tempRoles.js`). Cover only those with **shaping/branching logic** (a query that
maps rows, applies an "active/not-expired" predicate, or upserts) by asserting the
**SQL params** passed to a fake `db.query` and the shape returned. Skip pure
one-line inserts with no logic — testing those only proves the mock.
## 5. Explicitly out of scope
- **`discord.js` internals / the live gateway** — not ours to test; never open a
real connection.
- **Thin command wrappers** (`discord/commands/*.command.js`) that only validate
args and call a Discord API + a model — cover their *logic* (arg parsing,
duration clamping) if any, but not the Discord round-trip.
- **`node-cron` scheduling itself** — test the job's callback logic, not that cron
fires.
## 6. CI & coverage wiring
Mirror what PR #86 did for the client:
- **`.gitea/workflows/pr-checks.yml`** — add a `bot-tests` job (or a
`Run bot tests` step) that runs `npm test --prefix bot`, gating PRs into `main`.
- **`.gitea/workflows/sonarqube.yml`** — generate a bot LCOV from the repo root so
`SF:` paths resolve to `bot/src/...`:
```
node --test --experimental-test-coverage \
--test-reporter=lcov --test-reporter-destination=bot/coverage/lcov.info \
bot/test/*.test.js
```
- **`sonar-project.properties`** — add `bot/test` to `sonar.tests`, the glob to
`sonar.test.inclusions`, `bot/coverage/lcov.info` to
`sonar.javascript.lcov.reportPaths`, and `bot/coverage/**` to `sonar.exclusions`.
(`bot/src` is already in `sonar.sources`.)
Bot units need no `npm ci` to run when they import only relative files + Node
built-ins; a test that stubs `global.fetch` or fakes `db` needs no `discord.js`
either. Keep the pool pointed at a dead port so the run is hermetic.
## 7. Suggested phasing
1. **Harness + pure logic** — `test` script, then `normalize`, `duration`,
`spamFilter`. Fast, zero mocks, immediate value.
2. **Single-collaborator units** — `inviteFilter`, `siteApiClient`,
`requireInternalKey`.
3. **Pipeline** — `messageFilter` decision order + actions (the payoff test).
4. **Models with logic**, then the **CI/Sonar wiring** so it all counts.

View File

@@ -18,7 +18,6 @@ The design reference is [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (API contract, sc
## Contents ## Contents
- [Architecture](#architecture)
- [Tech stack](#tech-stack) - [Tech stack](#tech-stack)
- [Project structure](#project-structure) - [Project structure](#project-structure)
- [Prerequisites](#prerequisites) - [Prerequisites](#prerequisites)
@@ -38,103 +37,6 @@ The design reference is [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (API contract, sc
--- ---
## Architecture
How the pieces fit together — the React SPA and native app talk to one Express backend
(`router → controller → model → db`), which persists to MariaDB and bridges to the live
game world only through the **uo-link** sidecar. The shard itself is never internet-facing.
See [ARCHITECTURE.md](ARCHITECTURE.md) for the fuller write-up.
```mermaid
flowchart TB
%% ---------- Clients ----------
subgraph clients["Clients"]
browser["Browser<br/>React + Vite SPA<br/>(public · wiki · admin)"]
mobile["Native mobile app<br/>(bearer tokens)"]
end
idp["SSO providers<br/>Google · Discord · custom OIDC"]
discord["Discord"]
%% ---------- Website (one repo) ----------
subgraph website["website/ &nbsp;— Node app (one repo)"]
direction TB
subgraph backend["server/ — Express backend"]
direction TB
mw["Middleware<br/>helmet · siteMode · noindex<br/>rateLimit · loginProtection · botScore · validate"]
router["Router /api/v1<br/>auth (web · mobile · sso) · public · admin"]
ctrl["Controllers"]
auth["Session layer (auth/)<br/>sessionService · JWT/cookie · bearer · SSO+PKCE"]
model["Models (.model + .db)<br/>raw parameterized SQL — no ORM"]
sse["SSE fan-out<br/>public stream (allowlist) · admin stream (sensitive)"]
subgraph shardutil["Shard integration (utils/)"]
ingest["shardIngest.js<br/>WS ingest dispatcher"]
restcli["uoLinkClient.js<br/>REST client (never throws)"]
end
secret["secretBox.js<br/>AES-256-GCM secrets at rest"]
end
bot["bot/<br/>Discord bot"]
end
db[("MariaDB<br/>users · posts · wiki · settings · activity<br/>mobileSessions · authProviders · userIdentities<br/>uoLinkConfig · shard_online/economy/houses/events")]
%% ---------- Shard side ----------
subgraph shardside["Game shard (never internet-facing)"]
direction TB
sidecar["uo-link sidecar<br/>(Rust) — the only bridge exposed"]
servuo["ServUO shard<br/>(C# plugin)"]
end
%% ---------- Edges ----------
browser <-->|"same-origin JSON + SSE (cookie)"| mw
mobile -->|"REST (bearer access/refresh)"| mw
browser -.->|"OAuth redirect + PKCE"| idp
auth -.->|"token exchange"| idp
mw --> router --> ctrl
ctrl --> auth
ctrl --> model
ctrl --> restcli
ctrl --> sse
auth --> model
model <--> db
auth -. reads/writes secrets .-> secret
restcli -. reads config/token .-> secret
ingest --> model
ingest --> sse
sse -->|"live events"| browser
bot -->|"messages"| discord
bot <--> db
restcli -->|"REST: /char /roster /economy /history · /link/confirm · /towncrier"| sidecar
sidecar -->|"WebSocket live event feed (bearer + X-UOLink-Version)"| ingest
servuo -->|"loopback TCP 127.0.0.1:7788<br/>newline-delimited JSON (shard dials out)"| sidecar
%% ---------- Styling ----------
classDef ext fill:#2d2233,stroke:#7a5c94,color:#e8dff0;
classDef store fill:#1f2d2a,stroke:#4c8c7d,color:#dff0ea;
classDef bridge fill:#2d2620,stroke:#94764c,color:#f0e6d8;
class idp,discord ext;
class db store;
class sidecar,servuo bridge;
```
- **One backend, layered.** Every request flows `middleware → router → controller → model → db`.
Web browsers authenticate with an httpOnly JWT cookie; the native app uses short-lived bearer
access tokens plus rotated refresh tokens; SSO (Google/Discord/OIDC) is link-only and PKCE-guarded.
All three surfaces produce the *same* session via the session layer.
- **The shard is never reachable.** The ServUO shard *dials out* over loopback TCP to the uo-link
sidecar; only the sidecar is exposed, and only the backend talks to it. The REST client
(`uoLinkClient.js`) never throws, so the site degrades gracefully when the shard is down.
- **Sensitive events stay private.** Ingested game events fan out to browsers over two SSE channels —
a public allowlist stream and an admin-only stream that adds staff audit / cheat / login events.
---
## Tech stack ## Tech stack
| Layer | Tech | | Layer | Tech |