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
27 changed files with 37 additions and 1969 deletions

View File

@@ -9,8 +9,6 @@ so they live in one place, independent of either codebase.
```
website/ docs from the shard website (Node/Express + MariaDB + React/Vite)
link/ docs from the ServUO bridge (C# plugin + Rust sidecar + Node WS)
android/ docs from the native Android client (Kotlin + Jetpack Compose)
ci/ cross-cutting CI/quality notes
```
### `website/`
@@ -20,7 +18,6 @@ ci/ cross-cutting CI/quality notes
| [HERO_EDITOR.md](website/HERO_EDITOR.md) | Hero canvas editor feature spec |
| [WIKI_UPGRADE.md](website/WIKI_UPGRADE.md) | Wiki subsystem upgrade notes |
| [website-README.md](website/website-README.md) | Snapshot of the website repo's README (setup/run reference) |
| [PROJECT_TREE.md](website/PROJECT_TREE.md) | Auto-generated snapshot of the repo's tracked file layout |
### `link/`
| Doc | What it covers |
@@ -32,17 +29,6 @@ ci/ cross-cutting CI/quality notes
| [PLAN.md](link/PLAN.md) | uo-link build plan |
| [RESEARCH.md](link/RESEARCH.md) | Research notes |
| [link-README.md](link/link-README.md) | Snapshot of the link repo's README |
| [PROJECT_TREE.md](link/PROJECT_TREE.md) | Auto-generated snapshot of the repo's tracked file layout |
### `android/`
| Doc | What it covers |
|---|---|
| [PLAN.md](android/PLAN.md) | Android client build plan / milestones |
| [COVERAGE_PLAN.md](android/COVERAGE_PLAN.md) | Test-coverage rollout plan |
| [APP_LINKS.md](android/APP_LINKS.md) | Android App Links / deep-link setup |
| [theme-plan.md](android/theme-plan.md) | Theming plan |
| [TRUSTED_DEVICES_APP_HANDOFF.md](android/TRUSTED_DEVICES_APP_HANDOFF.md) | Trusted-devices app handoff notes |
| [PROJECT_TREE.md](android/PROJECT_TREE.md) | Auto-generated snapshot of the repo's tracked file layout |
## Provenance

View File

@@ -1,204 +0,0 @@
# Android App — Test Coverage Plan
**Goal:** clear the SonarQube coverage quality gate (`new_coverage ≥ 50%`) for
`Runic-Gateway-Android-app`, and leave a durable unit-test culture behind it. Companion to
[`PLAN.md`](./PLAN.md) §12.1 (the JaCoCo wiring that made coverage measurable).
## 1. Current state (2026-07-22, post `Android-app#26`)
The JaCoCo→Sonar wiring is live on `main`, so coverage is now real — and the gate is **failing**:
| Metric | Value |
|---|---|
| Quality gate | **ERROR** (one condition) |
| `new_coverage` | **16.4%** (threshold ≥ 50%) |
| overall `coverage` | 16.3% |
| lines to cover | 3,188 |
| covered | 542 |
Everything else on the gate is green (reliability/security/maintainability **A**, duplication 0%).
Because this is the *first* measured version, Sonar's "new code" window is essentially the whole
codebase, so `new_coverage ≈ overall coverage` — to pass we need to roughly **triple** covered
lines, from 542 to ~1,600.
### Where the uncovered lines are
Bucketed from Sonar's per-file `uncovered_lines` (exclusions from #26 already applied, so `*Screen.kt`
is absent):
| Bucket | Files | Lines to cover | Covered % | Verdict |
|---|--:|--:|--:|---|
| **ViewModels** | 28 | 1,153 | **0.1%** | **Test** — the dominant lever; no ViewModel has any test |
| **DTOs** | 12 | 648 | 26.7% | **Test** — trivial (serialization); a pattern already exists |
| **Repositories** | 11 | 274 | 11.3% | **Test** — fake the API interface |
| **core/\* (logic)** | 21 | 365 | 56.2% | **Test** — top up the partially-covered ones |
| UI composables (`*Components.kt`, `BlockRenderer`, …) | 7 | 255 | 3.9% | **Exclude** — not JVM-unit-testable, and `*Screen.kt`'s exclusion missed them |
| core/push (Android services) | 4 | ~191 | ~0% | **Exclude** (or Robolectric later) — foreground service / notifications |
| core/auth `Encrypted*` stores | 3 | 76 | 0% | **Exclude** — Android Keystore / EncryptedSharedPreferences |
| framework glue (`di/`, `RunicGatewayApp`, `LocalAssetResolver`) | 3 | ~17 | 0% | **Exclude** |
**Two levers, applied together:** (a) stop *counting* code a JVM unit test physically cannot execute,
and (b) actually *test* the logic — ViewModels, DTOs, repositories, core utilities.
## 2. Strategy & projected math
Numbers below are line-coverage projections against Sonar's `lines_to_cover`. They are estimates, but
grounded in the current per-bucket totals.
### Phase 0 — Broaden coverage exclusions (no tests; ~½ day)
Move non-unit-testable code out of the **coverage** denominator (it stays in *analysis* — bugs and
smells are still reported). Extend `sonar.coverage.exclusions` in `sonar-project.properties`:
```properties
sonar.coverage.exclusions=\
app/src/main/java/**/ui/**/*Screen.kt,\
app/src/main/java/**/ui/**/*Screen*.kt,\
app/src/main/java/**/ui/**/*Components.kt,\
app/src/main/java/**/ui/page/BlockRenderer.kt,\
app/src/main/java/**/ui/components/**,\
app/src/main/java/**/ui/shard/FrameFields.kt,\
app/src/main/java/**/ui/theme/**,\
app/src/main/java/**/ui/LocalAssetResolver.kt,\
app/src/main/java/**/RunicApp.kt,\
app/src/main/java/**/MainActivity.kt,\
app/src/main/java/**/*Application.kt,\
app/src/main/java/**/RunicGatewayApp.kt,\
app/src/main/java/**/di/**,\
app/src/main/java/**/core/push/PushService.kt,\
app/src/main/java/**/core/push/PushManager.kt,\
app/src/main/java/**/core/push/PushNotifier.kt,\
app/src/main/java/**/core/push/NtfyStreamClient.kt,\
app/src/main/java/**/core/auth/Encrypted*.kt
```
> Verify each glob targets composable-only / framework-only files before committing (e.g. confirm
> `FrameFields.kt` holds no testable logic). Keep the *pure-logic* push files in coverage
> (`PushPreferences`, `PushTickle`, `NtfyTopic`, `PushStreams`) — they already have tests.
Effect: denominator ~3,188 → ~2,650; covered ~542 → ~532. **Coverage ≈ 20%.** (Removing ~540 lines
that were ~2% covered.)
### Phase 1 — DTO serialization tests (highest ROI; ~1 day) → ~34%
DTOs are `@Serializable` data classes; test them with kotlinx-serialization round-trips against
representative backend JSON. The pattern already exists (`AccountDtoTest`, `AuthDtoTest`,
`NotificationsDtoTest`, `PlayerShardDtoTest`, `ShardDtoTest`). Add/extend:
- **New:** `AdminDto` (111 uncov — biggest single file), `WikiDto` (51), `PublicDto` (32),
`PageDto` (16), `PostDto` (12), `ContactDto` (11), `SsoDto`, `PageDto`.
- **Extend to ~85%:** `PlayerShardDto` (30.8%), `ShardDto` (35.6%), `AccountDto` (50.7%),
`AuthDto` (47.4%).
Target DTOs to ~85%: **+~380 covered lines** → covered ~912 / ~2,650 ≈ **34%**.
### Phase 2 — ViewModel tests (the big one; ~34 days) → clears the gate
28 ViewModels, ~1,153 lines, currently 0%. This is where the gate is won. Requires a small test
harness (§3). Each test drives the VM with fake collaborators and asserts `UiState` transitions
(loading → success/error, form validation, actions).
Priority by uncovered lines:
1. `LoginViewModel` (116), `AccountViewModel` (104), `CharactersViewModel` (77),
`AdminContentViewModel` (74), `NotificationsViewModel` (66), `TrustedDevicesViewModel` (63),
`ShardViewModel` (59)
2. `HousesViewModel` (51), `GovernorsViewModel` (47), `AdminDashboardViewModel` (43),
`AdminSupportViewModel` (41), `ChampsViewModel` (40), `AdminModerationViewModel` (40),
`GuildsViewModel` (39), `RecoveryCodesViewModel` (38), `ConnectViewModel` (37),
`ContactViewModel` (36), `VendorsViewModel` (32), `AppViewModel` (29)
3. The small ones (`PostViewModel`, `NewsViewModel`, `WikiViewModel`, `CharacterViewModel`,
`MyHousesViewModel`, `WikiPageViewModel`, `PageViewModel`, `HomeViewModel`, `SessionViewModel`)
Target ViewModels to ~70%: **+~800 covered lines** → covered ~1,712 / ~2,650 ≈ **65%. ✅ Gate passes.**
> Phases 0 + 2 alone (skipping DTOs) already reach ~50.5% — but DTOs are cheap insurance and Phase 1
> lands first because it de-risks the harness work.
### Phase 3 — Repository tests (~12 days) → margin
Repositories map API `Response`/exceptions to `ApiResult`; test with a fake `*Api` interface (or
OkHttp `MockWebServer`). Priority: `AuthRepository` (89), `ConnectionRepository` (43),
`ShardRepository` (27), `AdminRepository` (21), then the small content/wiki/player repos. Target ~70%:
**+~160 lines** → buffer well above 50% and resilience as the new-code window narrows.
### Phase 4 — core/net + core/auth top-up (~½1 day) → durability
Fill the partially-covered utilities: `TokenAuthenticator` (31), `ShardStreamClient` (36),
`HostSelectionInterceptor` (9), `ApiResult` (6), `WebHandoff`, `WebsiteUrls`, `DeviceNameProvider`,
`ServerPreferences`, `AppConfig`.
### Trajectory
| After | Denominator | Covered | Coverage |
|---|--:|--:|--:|
| Today | 3,188 | 542 | 16.3% |
| Phase 0 (exclusions) | ~2,650 | ~532 | ~20% |
| Phase 1 (DTOs) | ~2,650 | ~912 | ~34% |
| **Phase 2 (ViewModels)** | ~2,650 | ~1,712 | **~65% ✅** |
| Phase 3 (repos) | ~2,650 | ~1,872 | ~71% |
| Phase 4 (core) | ~2,650 | ~2,000+ | ~75%+ |
## 3. Test infrastructure to add
The existing suite tests pure-logic classes only; ViewModel/coroutine testing needs a little scaffold.
`kotlinx-coroutines-test` is already a `testImplementation` dependency.
**`MainDispatcherRule`** (JUnit4) — swaps `Dispatchers.Main` (used by `viewModelScope`) for a test
dispatcher:
```kotlin
// app/src/test/java/com/runicgateway/app/util/MainDispatcherRule.kt
@OptIn(ExperimentalCoroutinesApi::class)
class MainDispatcherRule(
private val dispatcher: TestDispatcher = StandardTestDispatcher(),
) : TestWatcher() {
override fun starting(d: Description) = Dispatchers.setMain(dispatcher)
override fun finished(d: Description) = Dispatchers.resetMain()
}
```
**ViewModel test pattern** — hand-written fakes (matches the repo's existing no-mock convention; no new
dependency):
```kotlin
class LoginViewModelTest {
@get:Rule val mainDispatcher = MainDispatcherRule()
private class FakeAuthRepository(var result: LoginResult) : AuthRepository { /* stub the seam */ }
@Test fun `blank credentials surface INVALID_CREDENTIALS without a network call`() = runTest {
val vm = LoginViewModel(FakeAuthRepository(LoginResult.Success), /* … */)
vm.submit()
assertEquals(LoginError.INVALID_CREDENTIALS, vm.state.value.error)
}
}
```
- Assert on `viewModel.state.value` after `advanceUntilIdle()`; or collect the `StateFlow` in a
background `launch` when you need to see intermediate (loading) states.
- **Optional deps (decide once):** `mockk` would cut fake-writing for wide interfaces, and `turbine`
simplifies Flow assertions. Recommendation: **stay with hand fakes** to match convention; revisit
only if VM tests get boilerplate-heavy.
## 4. Execution notes
- CI already runs `./gradlew testDebugUnitTest jacocoTestReport` before the scan (`sonarqube.yml`),
so new tests count automatically on merge to `main`. Locally on this machine: JDK 21 needs
`-Pksp.incremental=false`.
- Sonar recomputes the gate on the post-merge scan; there's no way to fully confirm the number
pre-merge. Land phases as separate PRs (0, 1, 2, …) so coverage climbs visibly and reviews stay
small.
- `sonar.coverage.exclusions` removes files from **coverage only** — analysis still flags bugs/smells
in them, so excluding UI/framework code is safe.
- **Android-framework code deferred, not abandoned:** push services and `Encrypted*` stores are
excluded now; if we want them covered later, add Robolectric (`testImplementation`) and a
`RobolectricTestRunner` suite rather than instrumented tests, to keep it in the fast JVM `test`
source set the scan already consumes.
## 5. Definition of done
- `new_coverage ≥ 50%` and the SonarQube quality gate is **green**.
- `MainDispatcherRule` + a documented ViewModel test pattern exist and are reused.
- Coverage exclusions list only genuinely non-unit-testable files (UI composables, Android-framework
glue) — no ViewModel, repository, DTO, or pure core-logic file is excluded.

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
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
declarative `./ntfy/server.yml` and named volume, **published on a host port** (`NTFY_HOST_PORT`,
default `2586` → container `:80`) so the public reverse proxy — which runs *outside* the compose
network — can forward the notification subdomain to it, anonymous read-write to unguessable topics
(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.
declarative `./ntfy/server.yml` and named volume, **no published host port** (reached via the
reverse proxy; internal-only for the publisher), anonymous read-write to unguessable topics (no
per-user accounts — safe because tickles are content-free).
**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
@@ -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.
- **Opt-in push notifications** (post-v1; architected for from the start): per-stream subscriptions the
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)
The exclusions are now a *narrow* list (M10 brought the operational admin subset in-scope, above). The
app still never ships:
- The **hero editor** and CMS **block/page authoring** (the visual page builder). *(News-post and
wiki category/tag management ARE in scope; the excluded piece is the CMS block editor / hero builder.)*
- **Discord bot** configuration (and anything under the unpublished `/internal/**` port — it 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
app performs *shard operations* like kick/ban/broadcast against the live shard, but never configures
the sidecar connection itself.)
- **OAuth / SSO provider setup** — creating/editing identity providers and their client secrets.
- The **hero editor** and any CMS authoring/block editing.
- The **admin / auth-management console** — user management, invites issuance, SSO provider config,
moderation console, email config, bot-activity/ban console. (Players still *log in*; what's
excluded is the management surface, not authentication itself.)
- The **Discord bot** management (and anything under the unpublished `/internal/**` port — it
returns the decrypted bot token and must never be reachable from a client).
- **Shard / uo-link administration** — sidecar base-URL/token config (`uoLinkConfig`), shard ops,
the staff shard-user console. (The app shows *public* shard widgets and a player's *own* game
data; it does not manage the sidecar.)
> The app calls `/api/v1/public/**`, `/api/v1/auth/**` (incl. the role-agnostic self surface
> `/auth/me/*`, §6.4), `/api/v1/player/**`, and — for staff, M10 — the **defined `/api/v1/admin/**`
> operations listed above. It never touches `/api/v1/internal/**`, nor the four excluded admin surfaces
> (hero/CMS block editor, Discord-bot config, uo-link config, OAuth-provider setup).
> The excluded surfaces all live under `/api/v1/admin/**` and `/api/v1/internal/**`. The app only
> ever calls `/api/v1/public/**`, `/api/v1/auth/**` (incl. the new role-agnostic self surface
> `/auth/me/*`, §6.4), and `/api/v1/player/**` — it never references `/admin`.
---
@@ -502,13 +491,7 @@ compiled in.
`/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.
- 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`,
`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.
debug for local dev against `127.0.0.1:3000`).
- 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
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.
- `POST /auth/mobile/logout` `{ refreshToken?, all? }` (requires bearer) — revoke this session or all
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
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):
@@ -679,18 +629,11 @@ Guidelines:
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
**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
back-compat; `/auth/me/*` reuses the same `account.controller` handlers behind `requireAuth` (any
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
@@ -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
scheme remains the permanent fallback on every build. Full spec + rollout in
[`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.
- **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
stack up provisions a working push relay. Reachable via the existing reverse proxy on its own
hostname/path — the container publishes `:80` on a host port (`NTFY_HOST_PORT`, default `2586`)
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.
stack up provisions a working push relay. Reachable to devices via the existing reverse proxy on its
own hostname/path; internal-only for the backend publisher.
- **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,
unguessable endpoints UnifiedPush hands out; the backend treats ntfy as an **untrusted relay**.
@@ -966,17 +887,6 @@ The ntfy relay is treated as **untrusted infrastructure**, and the design makes
write to `/auth/me/notifications/subscriptions`. Tapping a notification deep-links to the relevant
screen (§ open item below).
> **Gotcha — the PUT body must always carry `streams`, even when empty.** The backend validator
> requires the field (`body('streams').isArray()`), so an empty set has to be sent as
> `{"streams":[]}`, never `{}`. kotlinx.serialization omits a property equal to its default
> (`encodeDefaults=false`), so a DTO field like `streams: List<String> = emptyList()` gets *dropped*
> from the body when the set is empty — the app then sends `{}` and the server rejects it `400`.
> Symptom: clearing your **last** subscription fails with "could not save" and the toggle sticks
> (any non-empty set still includes the field, so only the final toggle-off breaks). Fix: give the
> request DTO field **no default** so kotlinx always encodes it (Android-app
> `fix/notifications-empty-subscriptions`). The same trap applies to any "replace the full set"
> `PUT`/`POST` whose empty value equals a DTO default — prefer no default on required request fields.
## 12. Build & CI (Gitea Actions)
Builds run on the org's existing self-hosted runners (`runs-on: ubuntu-latest`, same label the other
@@ -995,33 +905,6 @@ repos use), on a bare `ubuntu:latest` container.
signing identity stable from the first release (Play later requires consistency).
- Semantic `versionName` + monotonic `versionCode`; tag releases.
### 12.1 Code quality — SonarQube (`Runic-Gateway-Android-app`)
Analysis runs post-merge and non-blocking (`.gitea/workflows/sonarqube.yml`); the gate is
informational. The project is clean (0 bugs / 0 vulns / 0 hotspots, Maintainability **A**); the only
gate failure is **coverage = 0% on new code**, which is a *reporting* gap, not a testing gap — the
25-file JVM unit suite exists, but the source-only Sonar scan never received a JaCoCo report.
> Once the wiring below landed (`Android-app#26`), real coverage measured **16.4%** on new code —
> still under the 50% gate. The plan to raise it (exclude non-unit-testable framework/UI code + test
> ViewModels/DTOs/repositories) lives in [`COVERAGE_PLAN.md`](./COVERAGE_PLAN.md).
**Coverage wiring (the fix):**
- Apply the `jacoco` plugin in `app/build.gradle.kts` + a `jacocoTestReport` task fed by
`testDebugUnitTest`, emitting XML. Exclude generated/DI/Compose scaffolding
(`**/*_Hilt*`, `**/*_Factory*`, `**/di/**`, `**/*ComposableSingletons*`, `R`/`BuildConfig`).
- `sonar-project.properties`: set `sonar.coverage.jacoco.xmlReportPaths` to the report, and
`sonar.coverage.exclusions` for pure-`@Composable` UI (JVM unit tests can't execute composable
bodies without Robolectric, so counting those lines would unfairly sink new-code coverage).
- `sonarqube.yml` must now run a real Gradle build before the scan (JDK 17 + Android SDK, mirroring
`pr-checks.yml`): `./gradlew testDebugUnitTest jacocoTestReport` → then the scan step.
**Issue triage (2026-07-22):** of 15 code smells, **3 fixed in code** — remove an unused import
(`AdminContentScreen.kt`), remove an unused `page` param (`AdminSupportScreen.RespondDialog`), and
decompose `LoginViewModel` (cognitive complexity 20). The remaining **12 marked *Won't Fix*** via the
Sonar API with rationale: 5 snake_case DTO fields (intentionally mirror the JSON wire contract) and 7
Compose/nav cognitive-complexity + hoisted-callback param-count smells (idiomatic for Jetpack Compose).
## 13. Open questions (revisit as we go)
**Decided (recorded here for context):** single shard per install (§3); native auth is
@@ -1046,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).
- 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
end-to-end testable (the app registers an endpoint on that origin; the SSRF guard rejects others). The
reverse proxy forwards that hostname to the ntfy container's published host port (`NTFY_HOST_PORT`,
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
end-to-end testable (the app registers an endpoint on that origin; the SSRF guard rejects others). No
backend publish token — **decided** (the content-free-tickle design does not require one; optional
`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
Part 2 `PushTransport` seam keeps this swap cheap.)

View File

@@ -1,348 +0,0 @@
# Android App — Project Tree
> **Auto-generated.** This file is maintained by the `sync-project-tree` CI workflow in
> the [`RunicGateway/Android-app`](https://gitea.whitlocktech.com/RunicGateway/Android-app) repository, which
> opens a pull request here whenever the tracked file layout on `main` changes. Do not edit
> by hand — changes will be overwritten by the next sync.
A snapshot of the tracked files in the repository (build output, dependencies, and other
git-ignored paths are excluded).
```text
android-app/
├── .gitea/
│ ├── scripts/
│ │ └── gen_tree.py
│ └── workflows/
│ ├── pr-checks.yml
│ ├── release.yml
│ ├── sonarqube.yml
│ └── sync-project-tree.yml
├── app/
│ ├── licenses/
│ │ └── Cinzel-OFL.txt
│ ├── src/
│ │ ├── debug/
│ │ │ └── res/
│ │ │ └── xml/
│ │ │ └── network_security_config.xml
│ │ ├── main/
│ │ │ ├── java/
│ │ │ │ └── com/
│ │ │ │ └── runicgateway/
│ │ │ │ └── app/
│ │ │ │ ├── core/
│ │ │ │ │ ├── auth/
│ │ │ │ │ │ ├── sso/
│ │ │ │ │ │ │ ├── EncryptedPendingSsoStore.kt
│ │ │ │ │ │ │ ├── PendingSsoStore.kt
│ │ │ │ │ │ │ ├── Pkce.kt
│ │ │ │ │ │ │ └── SsoAuthManager.kt
│ │ │ │ │ │ ├── DeviceNameProvider.kt
│ │ │ │ │ │ ├── EncryptedTokenStore.kt
│ │ │ │ │ │ ├── EncryptedTrustTokenStore.kt
│ │ │ │ │ │ ├── Session.kt
│ │ │ │ │ │ ├── SessionManager.kt
│ │ │ │ │ │ ├── TokenStore.kt
│ │ │ │ │ │ └── TrustTokenStore.kt
│ │ │ │ │ ├── net/
│ │ │ │ │ │ ├── AuthInterceptor.kt
│ │ │ │ │ │ ├── BaseUrlHolder.kt
│ │ │ │ │ │ ├── HostSelectionInterceptor.kt
│ │ │ │ │ │ ├── Http.kt
│ │ │ │ │ │ ├── ServerUrl.kt
│ │ │ │ │ │ ├── ShardStream.kt
│ │ │ │ │ │ ├── ShardStreamClient.kt
│ │ │ │ │ │ ├── ShardStreamEvent.kt
│ │ │ │ │ │ ├── TokenAuthenticator.kt
│ │ │ │ │ │ └── UserAgentInterceptor.kt
│ │ │ │ │ ├── prefs/
│ │ │ │ │ │ └── ServerPreferences.kt
│ │ │ │ │ ├── push/
│ │ │ │ │ │ ├── NtfyStreamClient.kt
│ │ │ │ │ │ ├── NtfyTopic.kt
│ │ │ │ │ │ ├── PushManager.kt
│ │ │ │ │ │ ├── PushNotifier.kt
│ │ │ │ │ │ ├── PushPreferences.kt
│ │ │ │ │ │ ├── PushService.kt
│ │ │ │ │ │ ├── PushStreams.kt
│ │ │ │ │ │ └── PushTickle.kt
│ │ │ │ │ ├── result/
│ │ │ │ │ │ └── ApiResult.kt
│ │ │ │ │ ├── web/
│ │ │ │ │ │ ├── WebHandoff.kt
│ │ │ │ │ │ └── WebsiteUrls.kt
│ │ │ │ │ └── AppConfig.kt
│ │ │ │ ├── data/
│ │ │ │ │ ├── api/
│ │ │ │ │ │ ├── dto/
│ │ │ │ │ │ │ ├── AccountDto.kt
│ │ │ │ │ │ │ ├── AdminDto.kt
│ │ │ │ │ │ │ ├── AuthDto.kt
│ │ │ │ │ │ │ ├── ContactDto.kt
│ │ │ │ │ │ │ ├── NotificationsDto.kt
│ │ │ │ │ │ │ ├── PageDto.kt
│ │ │ │ │ │ │ ├── PlayerShardDto.kt
│ │ │ │ │ │ │ ├── PostDto.kt
│ │ │ │ │ │ │ ├── PublicDto.kt
│ │ │ │ │ │ │ ├── ShardDto.kt
│ │ │ │ │ │ │ ├── SsoDto.kt
│ │ │ │ │ │ │ └── WikiDto.kt
│ │ │ │ │ │ ├── AdminApi.kt
│ │ │ │ │ │ ├── AuthApi.kt
│ │ │ │ │ │ ├── AuthRefreshApi.kt
│ │ │ │ │ │ ├── MeApi.kt
│ │ │ │ │ │ ├── NotificationsApi.kt
│ │ │ │ │ │ ├── PlayerShardApi.kt
│ │ │ │ │ │ ├── PublicApi.kt
│ │ │ │ │ │ └── SsoApi.kt
│ │ │ │ │ └── repository/
│ │ │ │ │ ├── AccountRepository.kt
│ │ │ │ │ ├── AdminRepository.kt
│ │ │ │ │ ├── AuthRepository.kt
│ │ │ │ │ ├── ConnectionRepository.kt
│ │ │ │ │ ├── ContactRepository.kt
│ │ │ │ │ ├── ContentRepository.kt
│ │ │ │ │ ├── NotificationsRepository.kt
│ │ │ │ │ ├── PlayerShardRepository.kt
│ │ │ │ │ ├── SettingsRepository.kt
│ │ │ │ │ ├── ShardRepository.kt
│ │ │ │ │ └── WikiRepository.kt
│ │ │ │ ├── di/
│ │ │ │ │ ├── AppModule.kt
│ │ │ │ │ ├── NetworkModule.kt
│ │ │ │ │ └── StorageModule.kt
│ │ │ │ ├── ui/
│ │ │ │ │ ├── admin/
│ │ │ │ │ │ ├── AdminContentScreen.kt
│ │ │ │ │ │ ├── AdminContentViewModel.kt
│ │ │ │ │ │ ├── AdminDashboardScreen.kt
│ │ │ │ │ │ ├── AdminDashboardViewModel.kt
│ │ │ │ │ │ ├── AdminModerationScreen.kt
│ │ │ │ │ │ ├── AdminModerationViewModel.kt
│ │ │ │ │ │ ├── AdminSupportScreen.kt
│ │ │ │ │ │ └── AdminSupportViewModel.kt
│ │ │ │ │ ├── auth/
│ │ │ │ │ │ ├── AccountScreen.kt
│ │ │ │ │ │ ├── AccountViewModel.kt
│ │ │ │ │ │ ├── LoginScreen.kt
│ │ │ │ │ │ ├── LoginViewModel.kt
│ │ │ │ │ │ ├── RecoveryCodesScreen.kt
│ │ │ │ │ │ ├── RecoveryCodesViewModel.kt
│ │ │ │ │ │ ├── TrustedDevicesScreen.kt
│ │ │ │ │ │ └── TrustedDevicesViewModel.kt
│ │ │ │ │ ├── components/
│ │ │ │ │ │ ├── HtmlText.kt
│ │ │ │ │ │ ├── StateViews.kt
│ │ │ │ │ │ └── ThemeComponents.kt
│ │ │ │ │ ├── connect/
│ │ │ │ │ │ ├── ConnectScreen.kt
│ │ │ │ │ │ └── ConnectViewModel.kt
│ │ │ │ │ ├── contact/
│ │ │ │ │ │ ├── ContactScreen.kt
│ │ │ │ │ │ └── ContactViewModel.kt
│ │ │ │ │ ├── home/
│ │ │ │ │ │ ├── HomeScreen.kt
│ │ │ │ │ │ └── HomeViewModel.kt
│ │ │ │ │ ├── navigation/
│ │ │ │ │ │ ├── Menu.kt
│ │ │ │ │ │ └── Routes.kt
│ │ │ │ │ ├── news/
│ │ │ │ │ │ ├── NewsScreen.kt
│ │ │ │ │ │ ├── NewsViewModel.kt
│ │ │ │ │ │ ├── PostScreen.kt
│ │ │ │ │ │ └── PostViewModel.kt
│ │ │ │ │ ├── notifications/
│ │ │ │ │ │ ├── NotificationsScreen.kt
│ │ │ │ │ │ └── NotificationsViewModel.kt
│ │ │ │ │ ├── page/
│ │ │ │ │ │ ├── BlockRenderer.kt
│ │ │ │ │ │ ├── PageScreen.kt
│ │ │ │ │ │ └── PageViewModel.kt
│ │ │ │ │ ├── player/
│ │ │ │ │ │ ├── CharacterSheetScreen.kt
│ │ │ │ │ │ ├── CharactersScreen.kt
│ │ │ │ │ │ ├── CharactersViewModel.kt
│ │ │ │ │ │ ├── CharacterViewModel.kt
│ │ │ │ │ │ ├── MyHousesScreen.kt
│ │ │ │ │ │ ├── MyHousesViewModel.kt
│ │ │ │ │ │ ├── VendorsScreen.kt
│ │ │ │ │ │ └── VendorsViewModel.kt
│ │ │ │ │ ├── session/
│ │ │ │ │ │ └── SessionViewModel.kt
│ │ │ │ │ ├── shard/
│ │ │ │ │ │ ├── ChampsScreen.kt
│ │ │ │ │ │ ├── ChampsViewModel.kt
│ │ │ │ │ │ ├── FrameFields.kt
│ │ │ │ │ │ ├── GovernorsScreen.kt
│ │ │ │ │ │ ├── GovernorsViewModel.kt
│ │ │ │ │ │ ├── GuildsScreen.kt
│ │ │ │ │ │ ├── GuildsViewModel.kt
│ │ │ │ │ │ ├── HousesScreen.kt
│ │ │ │ │ │ ├── HousesViewModel.kt
│ │ │ │ │ │ ├── LiveBoard.kt
│ │ │ │ │ │ ├── ShardComponents.kt
│ │ │ │ │ │ ├── ShardEventText.kt
│ │ │ │ │ │ ├── ShardScreen.kt
│ │ │ │ │ │ └── ShardViewModel.kt
│ │ │ │ │ ├── theme/
│ │ │ │ │ │ ├── BrandColor.kt
│ │ │ │ │ │ ├── Color.kt
│ │ │ │ │ │ ├── Font.kt
│ │ │ │ │ │ ├── Theme.kt
│ │ │ │ │ │ └── Type.kt
│ │ │ │ │ ├── wiki/
│ │ │ │ │ │ ├── WikiPageScreen.kt
│ │ │ │ │ │ ├── WikiPageViewModel.kt
│ │ │ │ │ │ ├── WikiScreen.kt
│ │ │ │ │ │ └── WikiViewModel.kt
│ │ │ │ │ ├── AppViewModel.kt
│ │ │ │ │ ├── LocalAssetResolver.kt
│ │ │ │ │ ├── RunicApp.kt
│ │ │ │ │ └── UiState.kt
│ │ │ │ ├── MainActivity.kt
│ │ │ │ └── RunicGatewayApp.kt
│ │ │ ├── res/
│ │ │ │ ├── drawable/
│ │ │ │ │ └── ic_launcher_background.xml
│ │ │ │ ├── drawable-anydpi/
│ │ │ │ │ └── ic_stat_name.xml
│ │ │ │ ├── drawable-hdpi/
│ │ │ │ │ └── ic_stat_name.png
│ │ │ │ ├── drawable-mdpi/
│ │ │ │ │ └── ic_stat_name.png
│ │ │ │ ├── drawable-xhdpi/
│ │ │ │ │ └── ic_stat_name.png
│ │ │ │ ├── drawable-xxhdpi/
│ │ │ │ │ └── ic_stat_name.png
│ │ │ │ ├── font/
│ │ │ │ │ └── cinzel_variable.ttf
│ │ │ │ ├── mipmap-anydpi-v26/
│ │ │ │ │ ├── ic_launcher.xml
│ │ │ │ │ └── ic_launcher_round.xml
│ │ │ │ ├── mipmap-hdpi/
│ │ │ │ │ ├── ic_launcher.webp
│ │ │ │ │ ├── ic_launcher_foreground.webp
│ │ │ │ │ └── ic_launcher_round.webp
│ │ │ │ ├── mipmap-mdpi/
│ │ │ │ │ ├── ic_launcher.webp
│ │ │ │ │ ├── ic_launcher_foreground.webp
│ │ │ │ │ └── ic_launcher_round.webp
│ │ │ │ ├── mipmap-xhdpi/
│ │ │ │ │ ├── ic_launcher.webp
│ │ │ │ │ ├── ic_launcher_foreground.webp
│ │ │ │ │ └── ic_launcher_round.webp
│ │ │ │ ├── mipmap-xxhdpi/
│ │ │ │ │ ├── ic_launcher.webp
│ │ │ │ │ ├── ic_launcher_foreground.webp
│ │ │ │ │ └── ic_launcher_round.webp
│ │ │ │ ├── mipmap-xxxhdpi/
│ │ │ │ │ ├── ic_launcher.webp
│ │ │ │ │ ├── ic_launcher_foreground.webp
│ │ │ │ │ └── ic_launcher_round.webp
│ │ │ │ ├── values/
│ │ │ │ │ ├── colors.xml
│ │ │ │ │ ├── strings.xml
│ │ │ │ │ └── themes.xml
│ │ │ │ └── xml/
│ │ │ │ ├── backup_rules.xml
│ │ │ │ ├── data_extraction_rules.xml
│ │ │ │ └── network_security_config.xml
│ │ │ ├── AndroidManifest.xml
│ │ │ └── ic_launcher-playstore.png
│ │ └── test/
│ │ └── java/
│ │ └── com/
│ │ └── runicgateway/
│ │ └── app/
│ │ ├── core/
│ │ │ ├── auth/
│ │ │ │ ├── sso/
│ │ │ │ │ ├── PkceTest.kt
│ │ │ │ │ └── SsoAuthManagerTest.kt
│ │ │ │ └── SessionManagerTest.kt
│ │ │ ├── net/
│ │ │ │ ├── HostRewriteTest.kt
│ │ │ │ ├── ServerUrlTest.kt
│ │ │ │ └── ShardStreamClientTest.kt
│ │ │ ├── push/
│ │ │ │ ├── NtfyTopicTest.kt
│ │ │ │ └── PushTickleTest.kt
│ │ │ └── result/
│ │ │ ├── ApiResultExtrasTest.kt
│ │ │ └── ApiResultTest.kt
│ │ ├── data/
│ │ │ ├── api/
│ │ │ │ ├── dto/
│ │ │ │ │ ├── AccountDtoTest.kt
│ │ │ │ │ ├── AdminDtoTest.kt
│ │ │ │ │ ├── AuthDtoTest.kt
│ │ │ │ │ ├── AuthRequestDtoTest.kt
│ │ │ │ │ ├── ContentDtoTest.kt
│ │ │ │ │ ├── NotificationsDtoTest.kt
│ │ │ │ │ ├── PlayerGameDataDtoTest.kt
│ │ │ │ │ ├── PlayerShardDtoTest.kt
│ │ │ │ │ ├── PublicDtoTest.kt
│ │ │ │ │ ├── ShardBoardDtoTest.kt
│ │ │ │ │ ├── ShardDtoTest.kt
│ │ │ │ │ ├── SsoDtoTest.kt
│ │ │ │ │ └── WikiDtoTest.kt
│ │ │ │ └── fake/
│ │ │ │ ├── FakeAdminApi.kt
│ │ │ │ ├── FakePlayerShardApi.kt
│ │ │ │ ├── FakePublicApi.kt
│ │ │ │ └── FakeShardStream.kt
│ │ │ └── repository/
│ │ │ ├── AccountTrustedDevicesTest.kt
│ │ │ └── ConnectionVersionGuardTest.kt
│ │ ├── ui/
│ │ │ ├── admin/
│ │ │ │ ├── AdminContentViewModelTest.kt
│ │ │ │ ├── AdminDashboardViewModelTest.kt
│ │ │ │ ├── AdminModerationViewModelTest.kt
│ │ │ │ └── AdminSupportViewModelTest.kt
│ │ │ ├── contact/
│ │ │ │ └── ContactViewModelTest.kt
│ │ │ ├── navigation/
│ │ │ │ └── MenuAccessTest.kt
│ │ │ ├── notifications/
│ │ │ │ └── NotificationRoutingTest.kt
│ │ │ ├── player/
│ │ │ │ ├── CharacterSheetHelpersTest.kt
│ │ │ │ ├── CharactersViewModelTest.kt
│ │ │ │ └── PlayerViewModelTest.kt
│ │ │ ├── shard/
│ │ │ │ ├── FrameFieldsTest.kt
│ │ │ │ ├── LiveBoardTest.kt
│ │ │ │ ├── ShardBoardViewModelTest.kt
│ │ │ │ └── ShardEventTextTest.kt
│ │ │ ├── theme/
│ │ │ │ └── BrandColorTest.kt
│ │ │ ├── ContentViewModelTest.kt
│ │ │ └── UiStateTest.kt
│ │ ├── util/
│ │ │ ├── FakeApiSupport.kt
│ │ │ └── MainDispatcherRule.kt
│ │ └── ScaffoldSanityTest.kt
│ ├── build.gradle.kts
│ └── proguard-rules.pro
├── gradle/
│ ├── wrapper/
│ │ ├── gradle-wrapper.jar
│ │ └── gradle-wrapper.properties
│ └── libs.versions.toml
├── .gitattributes
├── .gitignore
├── build.gradle.kts
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── CONTRIBUTORS.md
├── gradle.properties
├── gradlew
├── gradlew.bat
├── LICENSE.md
├── README.md
├── SECURITY.md
├── settings.gradle.kts
└── sonar-project.properties
```

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 |
|---|---|---|---|
| `website` | `runic-gateway-website` | `server/src`, `client/src`, `bot/src` | JS/TS |
| `link` | `runic-gateway-link` | `sidecar/src` | Rust |
| `Android-app` | `runic-gateway-android-app` | `app/src/main` | Kotlin |
| `link` | `Runic-Gateway-link` | `sidecar/src` | Rust |
| `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

View File

@@ -1,46 +0,0 @@
# uo-link — Project Tree
> **Auto-generated.** This file is maintained by the `sync-project-tree` CI workflow in
> the [`RunicGateway/link`](https://gitea.whitlocktech.com/RunicGateway/link) repository, which
> opens a pull request here whenever the tracked file layout on `main` changes. Do not edit
> by hand — changes will be overwritten by the next sync.
A snapshot of the tracked files in the repository (build output, dependencies, and other
git-ignored paths are excluded).
```text
link/
├── .gitea/
│ ├── ISSUE_TEMPLATE/
│ │ ├── bug_report.md
│ │ ├── config.yaml
│ │ └── feature_request.md
│ ├── scripts/
│ │ └── gen_tree.py
│ ├── workflows/
│ │ ├── release.yml
│ │ ├── sonarqube.yml
│ │ └── sync-project-tree.yml
│ └── PULL_REQUEST_TEMPLATE.md
├── sidecar/
│ ├── src/
│ │ ├── config.rs
│ │ ├── main.rs
│ │ ├── rpc.rs
│ │ ├── shard.rs
│ │ ├── store.rs
│ │ └── web.rs
│ ├── .gitignore
│ ├── Cargo.lock
│ ├── Cargo.toml
│ ├── README.md
│ └── sidecar.toml.example
├── .gitignore
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── CONTRIBUTORS.md
├── LICENSE.md
├── README.md
├── SECURITY.md
└── sonar-project.properties
```

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
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
@@ -255,9 +233,8 @@ 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`. If the account has TOTP **and this browser is a trusted device** (a valid `rg_trust` cookie bound to the user), the TOTP step is **skipped** and a session is issued directly (logs `auth.login.trusted_device`). Otherwise a 2FA account returns `{totpRequired, challenge}`. |
| POST | `/login/totp` | — (rate-limited) | `{challenge, code? \| recoveryCode?, trustDevice?, deviceName?}` | complete 2FA with a TOTP **or** single-use recovery code. `trustDevice` sets the `rg_trust` cookie so future logins skip TOTP; at the device cap the session is still issued and the body carries `{trustLimitReached, devices}`. |
| POST | `/logout` | cookie | — | clear cookie (the `rg_trust` trust cookie deliberately **survives** logout) |
| 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) |
@@ -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`) |
| PATCH | `/me/account/username` | cookie / bearer (rate-limited) | `{username}` | change own username; re-issues the caller's session |
| PATCH | `/me/account/password` | cookie / bearer (rate-limited) | `{newPassword, currentPassword?}` | change/set own password (current required unless the account has none); revokes other sessions, keeps the caller's |
| POST | `/me/account/totp/setup` · `…/enable` · `…/disable` | cookie / bearer | `{code}` on enable/disable | self 2FA enrollment (disable needs a valid current code, not a password). **enable** returns the one-time `recoveryCodes`; **disable** clears the user's trusted devices + recovery codes |
| 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/trusted-devices` | cookie / bearer | — | list own active trusted devices (never tokens) |
| POST | `/me/trusted-devices` | cookie / bearer (rate-limited) | `{deviceName?}` | trust the current device; web gets an httpOnly `rg_trust` cookie, native gets `{trustToken}`. **409 `{error:'trusted_device_limit', devices}`** at the cap |
| DELETE | `/me/trusted-devices` · `…/:id` | cookie / bearer | — | untrust all / one (ownership-scoped) |
| GET | `/me/account/recovery-codes/status` | cookie / bearer | — | remaining unused code count (never the codes) |
| POST | `/me/account/recovery-codes/generate` | cookie / bearer (rate-limited, **password step-up**) | `{currentPassword?}` | regenerate the one-time recovery codes (returned once); refused when 2FA is off |
| POST | `/me/devices` | cookie / bearer | `{endpoint, transport?, platform?}` | register a push endpoint; **rejects a disallowed endpoint 400** (SSRF guard). Idempotent per (user, endpoint) |
| GET | `/me/devices` · DELETE `…/:id` | cookie / bearer | — | list / unregister own push devices |
| GET | `/me/notifications/streams` | cookie / bearer | — | the subscribable catalog (`personal`/`requiresLinkedAccount` flags) |
@@ -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
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
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
@@ -413,9 +375,6 @@ Public content GETs pass through the **siteMode** gate (§5).
| GET | `/settings` · PUT `/settings` | read all / update `{key:value,...}` |
| GET | `/activity?limit=&offset=` | paginated activity log |
| GET | `/users` · POST `/users` · PUT `/users/:id` · DELETE `/users/:id` | user mgmt (can't delete self / last admin; password hashed on write) |
| GET | `/users/:id/trusted-devices` | list a user's active trusted devices (never tokens) |
| DELETE | `/users/:id/trusted-devices` · `…/:deviceId` | revoke all / one of a user's trusted devices (logs `admin.trusted_device.revoke[_all]`) |
| POST | `/users/:id/mfa/reset` | recover a locked-out user: disable TOTP + revoke all trusted devices + clear recovery codes (logs `admin.user.totp.reset`) |
Every admin write logs to `activity_log`.
@@ -446,8 +405,7 @@ who"; `activity_log` provides the history feed.
## 6. Auth & security
- **JWT** signed with `JWT_SECRET`, `expiresIn=JWT_EXPIRES_IN` (default `1d`); payload `{id,username,role}`.
- **Cookie**: `httpOnly`, `sameSite=Lax`, `path=/`, and **`secure` decided per-request** (`COOKIE_SECURE=auto``secure: req.secure`).
- **Trusted-device MFA.** A second, separate httpOnly cookie (`rg_trust`, default 30d) — opaque, sha256-hashed server-side in `trusted_devices` — lets a browser/app **skip the TOTP step** (never the password) on future logins. It is a server-side, per-row-revocable record (never a JWT claim), so the stateless session JWT is unchanged and trust stays revocable. It only ever gates the **second factor**; it deliberately outlives logout, and is cleared on untrust / password change / password reset / TOTP disable. **Recovery codes** (bcrypt, single-use) are the 2FA-lockout fallback. All admin trusted-device/MFA actions and the self actions (`auth.login.trusted_device`, `account.trusted_device.*`, `account.recovery_code*`, `admin.trusted_device.*`, `admin.user.totp.reset`) are audit-logged. See `docs/website/TRUSTED_DEVICES_MFA.md`. This is the key to dual access: the cookie is `Secure` when reached through Pangolin (HTTPS, `X-Forwarded-Proto: https`) but **not** `Secure` when reached directly over the LAN IP on plain HTTP — so login works in both. `COOKIE_SECURE=true|false` can force it. Requires `trust proxy` (below). `localhost:5173` (Vite) and `localhost:3000` are same-site, so the cookie flows in dev too.
- **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.
- **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.
@@ -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`,
`ports: "3000:3000"`**binds 0.0.0.0** (no `127.0.0.1:` prefix) so Pangolin reaches it.
- `ntfy` (M7): pinned upstream `binwiederhier/ntfy` image, declarative config only
(`./ntfy/server.yml` mounted `:ro` + `NTFY_BASE_URL`), volume `ntfydata:/var/lib/ntfy`,
**publishes `:80` on a host port** (`${NTFY_HOST_PORT:-2586}:80`, binds 0.0.0.0) so Pangolin — which
runs outside the compose network — can forward the notification subdomain to it, the same reason
`app` publishes `3000`. Both devices (SSE subscribe) and the backend publisher (POSTing tickles to
registered device endpoints) reach ntfy on that public origin. Anonymous read-write to unguessable
topics (no accounts to provision) — safe because pushes are content-free tickles. Bringing the stack
up provisions a working push relay with **zero interactive setup**.
(`./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`.
@@ -550,9 +506,6 @@ NTFY_BASE_URL=https://ntfy.example.com
# shows push as unavailable for the shard.
NTFY_PUBLIC_URL=https://ntfy.example.com
NTFY_ALLOWED_ORIGINS=https://ntfy.example.com
# Host port the ntfy container publishes :80 on (default 2586); the reverse proxy
# forwards the notification subdomain to host:NTFY_HOST_PORT. Change on a conflict.
NTFY_HOST_PORT=2586
```
`.gitignore`: `node_modules/`, `.env`, `_reference/`, `client/dist/`, `uploads/`.

View File

@@ -1,534 +0,0 @@
# Website — Project Tree
> **Auto-generated.** This file is maintained by the `sync-project-tree` CI workflow in
> the [`RunicGateway/website`](https://gitea.whitlocktech.com/RunicGateway/website) repository, which
> opens a pull request here whenever the tracked file layout on `main` changes. Do not edit
> by hand — changes will be overwritten by the next sync.
A snapshot of the tracked files in the repository (build output, dependencies, and other
git-ignored paths are excluded).
```text
website/
├── .claude/
│ └── launch.json
├── .gitea/
│ ├── ISSUE_TEMPLATE/
│ │ ├── bug_report.md
│ │ ├── config.yaml
│ │ └── feature_request.md
│ ├── scripts/
│ │ └── gen_tree.py
│ ├── workflows/
│ │ ├── build-images.yml
│ │ ├── pr-checks.yml
│ │ ├── sonarqube.yml
│ │ └── sync-project-tree.yml
│ └── PULL_REQUEST_TEMPLATE.md
├── bot/
│ ├── src/
│ │ ├── discord/
│ │ │ ├── commands/
│ │ │ │ ├── announce.command.js
│ │ │ │ ├── autorole.command.js
│ │ │ │ ├── ban.command.js
│ │ │ │ ├── filter.command.js
│ │ │ │ ├── filterallow.command.js
│ │ │ │ ├── index.js
│ │ │ │ ├── invite.command.js
│ │ │ │ ├── kick.command.js
│ │ │ │ ├── modlog.command.js
│ │ │ │ ├── mute.command.js
│ │ │ │ ├── news.command.js
│ │ │ │ ├── ping.command.js
│ │ │ │ ├── role.command.js
│ │ │ │ ├── rolemenu.command.js
│ │ │ │ ├── roles.command.js
│ │ │ │ ├── schedule.command.js
│ │ │ │ ├── warn.command.js
│ │ │ │ ├── warnings.command.js
│ │ │ │ └── wiki.command.js
│ │ │ ├── discordManager.js
│ │ │ ├── guildMemberAdd.js
│ │ │ ├── guildMemberRemove.js
│ │ │ ├── inviteTracker.js
│ │ │ ├── messageFilter.js
│ │ │ ├── modLog.js
│ │ │ ├── newsAnnounce.js
│ │ │ └── roleMenuHandler.js
│ │ ├── filter/
│ │ │ ├── filterCache.js
│ │ │ ├── inviteFilter.js
│ │ │ ├── normalize.js
│ │ │ └── spamFilter.js
│ │ ├── internal/
│ │ │ ├── internal.controller.js
│ │ │ ├── internal.routes.js
│ │ │ └── requireInternalKey.js
│ │ ├── invites/
│ │ │ ├── inviteRotator.js
│ │ │ └── inviteScheduler.js
│ │ ├── model/
│ │ │ ├── filterAllowlist.js
│ │ │ ├── filterHits.js
│ │ │ ├── filterWords.js
│ │ │ ├── guildConfig.js
│ │ │ ├── inviteLog.js
│ │ │ ├── memberEvents.js
│ │ │ ├── roleMenus.js
│ │ │ ├── scheduledMessages.js
│ │ │ ├── spamHits.js
│ │ │ ├── tempRoles.js
│ │ │ └── warnings.js
│ │ ├── roles/
│ │ │ └── tempRoleSweeper.js
│ │ ├── scheduler/
│ │ │ └── scheduler.js
│ │ ├── site/
│ │ │ └── siteApiClient.js
│ │ ├── utils/
│ │ │ ├── duration.js
│ │ │ └── logger.js
│ │ ├── app.js
│ │ ├── bootstrap.js
│ │ ├── brand.js
│ │ ├── db.js
│ │ └── server.js
│ ├── .env.example
│ ├── .gitignore
│ ├── Dockerfile
│ ├── package-lock.json
│ └── package.json
├── brand/
│ └── README.md
├── client/
│ ├── public/
│ │ ├── assets/
│ │ │ └── img/
│ │ │ ├── favicon.ico
│ │ │ ├── hero-moon.png
│ │ │ ├── runic-emblem.png
│ │ │ └── uomysticmoon-main-hero.png
│ │ └── robots.txt
│ ├── src/
│ │ ├── api/
│ │ │ └── client.js
│ │ ├── blocks/
│ │ │ ├── types/
│ │ │ │ ├── cta.jsx
│ │ │ │ ├── divider.jsx
│ │ │ │ ├── heading.jsx
│ │ │ │ ├── image.jsx
│ │ │ │ ├── quote.jsx
│ │ │ │ ├── richText.jsx
│ │ │ │ └── twoColumn.jsx
│ │ │ ├── BlockRenderer.jsx
│ │ │ ├── editorKit.jsx
│ │ │ ├── index.js
│ │ │ └── registry.js
│ │ ├── components/
│ │ │ ├── security/
│ │ │ │ ├── RecoveryCodesDisplay.jsx
│ │ │ │ ├── RecoveryCodesPanel.jsx
│ │ │ │ ├── TrustedDevicesPanel.jsx
│ │ │ │ └── TrustLimitModal.jsx
│ │ │ ├── CharacterSheet.jsx
│ │ │ ├── CharacterStats.jsx
│ │ │ ├── CreateGameAccountForm.jsx
│ │ │ ├── GameAccounts.jsx
│ │ │ ├── HeroElement.jsx
│ │ │ ├── MaintenanceGate.jsx
│ │ │ ├── Modal.jsx
│ │ │ ├── MoonDot.jsx
│ │ │ ├── PageHeader.jsx
│ │ │ ├── PageState.jsx
│ │ │ ├── PlayersOnline.jsx
│ │ │ ├── ProviderIcon.jsx
│ │ │ ├── PublicLayout.jsx
│ │ │ ├── RequireAuth.jsx
│ │ │ ├── RequirePlayer.jsx
│ │ │ ├── RichTextEditor.jsx
│ │ │ ├── RoleGate.jsx
│ │ │ ├── ShardAccountActions.jsx
│ │ │ ├── SiteFooter.jsx
│ │ │ ├── SiteHeader.jsx
│ │ │ └── VendorSales.jsx
│ │ ├── contexts/
│ │ │ ├── AuthContext.jsx
│ │ │ └── SiteContext.jsx
│ │ ├── data/
│ │ │ ├── cityCrests.js
│ │ │ └── regionBuckets.js
│ │ ├── lib/
│ │ │ ├── format.js
│ │ │ ├── heroLayout.js
│ │ │ ├── shardEvents.js
│ │ │ ├── useAsync.js
│ │ │ └── useShardFeed.js
│ │ ├── routes/
│ │ │ ├── admin/
│ │ │ │ ├── views/
│ │ │ │ │ ├── AccountAdmin.jsx
│ │ │ │ │ ├── ActivityAdmin.jsx
│ │ │ │ │ ├── AdminCharacter.jsx
│ │ │ │ │ ├── AdminCharacters.jsx
│ │ │ │ │ ├── Appeals.jsx
│ │ │ │ │ ├── AuthProvidersAdmin.jsx
│ │ │ │ │ ├── BotActivityAdmin.jsx
│ │ │ │ │ ├── Dashboard.jsx
│ │ │ │ │ ├── DiscordBotAdmin.jsx
│ │ │ │ │ ├── EmailDelivery.jsx
│ │ │ │ │ ├── HeroEditor.jsx
│ │ │ │ │ ├── HousesAdmin.jsx
│ │ │ │ │ ├── InvitesAdmin.jsx
│ │ │ │ │ ├── Moderation.jsx
│ │ │ │ │ ├── ModerationUser.jsx
│ │ │ │ │ ├── PageBuilder.jsx
│ │ │ │ │ ├── PagesAdmin.jsx
│ │ │ │ │ ├── PostEditor.jsx
│ │ │ │ │ ├── PostsAdmin.jsx
│ │ │ │ │ ├── SettingsAdmin.jsx
│ │ │ │ │ ├── ShardAdmin.jsx
│ │ │ │ │ ├── ShardOps.jsx
│ │ │ │ │ ├── UserDetail.jsx
│ │ │ │ │ ├── UserEditor.jsx
│ │ │ │ │ ├── UsersAdmin.jsx
│ │ │ │ │ ├── WikiAdmin.jsx
│ │ │ │ │ ├── WikiCategories.jsx
│ │ │ │ │ ├── WikiEditor.jsx
│ │ │ │ │ └── WikiHistory.jsx
│ │ │ │ ├── AdminLayout.jsx
│ │ │ │ └── AdminLogin.jsx
│ │ │ ├── player/
│ │ │ │ ├── AcceptInvite.jsx
│ │ │ │ ├── ForgotPassword.jsx
│ │ │ │ ├── PlayerAccount.jsx
│ │ │ │ ├── PlayerAppeals.jsx
│ │ │ │ ├── PlayerCharacter.jsx
│ │ │ │ ├── PlayerCharacters.jsx
│ │ │ │ ├── PlayerLogin.jsx
│ │ │ │ ├── PlayerPortalLayout.jsx
│ │ │ │ ├── PlayerRegister.jsx
│ │ │ │ ├── PlayerShell.jsx
│ │ │ │ └── ResetPassword.jsx
│ │ │ ├── public/
│ │ │ │ ├── About.jsx
│ │ │ │ ├── ChampSpawns.jsx
│ │ │ │ ├── CmsPage.jsx
│ │ │ │ ├── FiveOnFriday.jsx
│ │ │ │ ├── Governors.jsx
│ │ │ │ ├── Guilds.jsx
│ │ │ │ ├── Houses.jsx
│ │ │ │ ├── Maintenance.jsx
│ │ │ │ ├── News.jsx
│ │ │ │ ├── Newsletter.jsx
│ │ │ │ ├── NewsletterIssue.jsx
│ │ │ │ ├── Portal.jsx
│ │ │ │ ├── Screenshots.jsx
│ │ │ │ ├── Shard.jsx
│ │ │ │ ├── ShardActivity.jsx
│ │ │ │ ├── Status.jsx
│ │ │ │ └── Website.jsx
│ │ │ └── wiki/
│ │ │ ├── Wiki.jsx
│ │ │ └── WikiArticle.jsx
│ │ ├── styles/
│ │ │ └── theme.css
│ │ ├── App.jsx
│ │ └── main.jsx
│ ├── test/
│ │ ├── apiClient.test.js
│ │ ├── format.test.js
│ │ ├── heroLayout.test.js
│ │ ├── regionBuckets.test.js
│ │ └── shardEvents.test.js
│ ├── index.html
│ ├── package-lock.json
│ ├── package.json
│ └── vite.config.js
├── ntfy/
│ └── server.yml
├── scripts/
│ ├── dev/
│ │ ├── README.md
│ │ ├── seed-sso-provider.js
│ │ ├── sso-bridge-smoketest.js
│ │ └── stub-idp.js
│ └── sonar-test-reporter.mjs
├── server/
│ ├── db/
│ │ ├── schema.sql
│ │ └── seed.js
│ ├── src/
│ │ ├── auth/
│ │ │ ├── providers/
│ │ │ │ ├── base.provider.js
│ │ │ │ ├── discord.provider.js
│ │ │ │ ├── genericOidc.provider.js
│ │ │ │ ├── google.provider.js
│ │ │ │ ├── local.provider.js
│ │ │ │ ├── oauth2.provider.js
│ │ │ │ └── registry.js
│ │ │ ├── session.middleware.js
│ │ │ ├── session.service.js
│ │ │ ├── ssoState.js
│ │ │ ├── token.js
│ │ │ └── usernamePolicy.js
│ │ ├── blocks/
│ │ │ ├── types/
│ │ │ │ ├── cta.js
│ │ │ │ ├── divider.js
│ │ │ │ ├── heading.js
│ │ │ │ ├── image.js
│ │ │ │ ├── quote.js
│ │ │ │ ├── richText.js
│ │ │ │ └── twoColumn.js
│ │ │ ├── index.js
│ │ │ ├── propHelpers.js
│ │ │ ├── registry.js
│ │ │ ├── sanitizeBlocks.js
│ │ │ └── validateBlocks.js
│ │ ├── config/
│ │ │ ├── brand.js
│ │ │ ├── notificationStreams.js
│ │ │ └── version.js
│ │ ├── middleware/
│ │ │ ├── botScore.js
│ │ │ ├── loginProtection.js
│ │ │ ├── noindex.js
│ │ │ ├── rateLimit.js
│ │ │ ├── requireInternalKey.js
│ │ │ ├── siteMode.js
│ │ │ └── validate.js
│ │ ├── model/
│ │ │ ├── activity/
│ │ │ │ ├── activity.db.js
│ │ │ │ └── activity.model.js
│ │ │ ├── announceJobs/
│ │ │ │ ├── announceJobs.db.js
│ │ │ │ ├── announceJobs.logic.js
│ │ │ │ └── announceJobs.model.js
│ │ │ ├── appeals/
│ │ │ │ ├── appeals.db.js
│ │ │ │ ├── appeals.model.js
│ │ │ │ └── appeals.pure.js
│ │ │ ├── authProviders/
│ │ │ │ ├── authProviders.db.js
│ │ │ │ └── authProviders.model.js
│ │ │ ├── botConfig/
│ │ │ │ ├── botConfig.db.js
│ │ │ │ └── botConfig.model.js
│ │ │ ├── emailConfig/
│ │ │ │ ├── emailConfig.db.js
│ │ │ │ └── emailConfig.model.js
│ │ │ ├── invites/
│ │ │ │ ├── invites.db.js
│ │ │ │ └── invites.model.js
│ │ │ ├── mobileAuthBridge/
│ │ │ │ ├── mobileAuthBridge.db.js
│ │ │ │ └── mobileAuthBridge.model.js
│ │ │ ├── mobileSessions/
│ │ │ │ ├── mobileSessions.db.js
│ │ │ │ └── mobileSessions.model.js
│ │ │ ├── moderation/
│ │ │ │ ├── moderation.db.js
│ │ │ │ ├── moderation.model.js
│ │ │ │ └── moderation.pure.js
│ │ │ ├── modNotes/
│ │ │ │ ├── modNotes.db.js
│ │ │ │ └── modNotes.model.js
│ │ │ ├── notificationSubs/
│ │ │ │ ├── notificationSubs.db.js
│ │ │ │ └── notificationSubs.model.js
│ │ │ ├── pages/
│ │ │ │ ├── pages.db.js
│ │ │ │ ├── pages.model.js
│ │ │ │ └── reservedSlugs.js
│ │ │ ├── passwordResets/
│ │ │ │ ├── passwordResets.db.js
│ │ │ │ └── passwordResets.model.js
│ │ │ ├── posts/
│ │ │ │ ├── posts.db.js
│ │ │ │ └── posts.model.js
│ │ │ ├── pushDevices/
│ │ │ │ ├── pushDevices.db.js
│ │ │ │ └── pushDevices.model.js
│ │ │ ├── recoveryCodes/
│ │ │ │ ├── recoveryCodes.db.js
│ │ │ │ └── recoveryCodes.model.js
│ │ │ ├── revokedSessions/
│ │ │ │ ├── revokedSessions.db.js
│ │ │ │ └── revokedSessions.model.js
│ │ │ ├── settings/
│ │ │ │ ├── settings.db.js
│ │ │ │ └── settings.model.js
│ │ │ ├── shardEvents/
│ │ │ │ ├── shardEvents.db.js
│ │ │ │ └── shardEvents.model.js
│ │ │ ├── shardLinks/
│ │ │ │ ├── shardLinks.db.js
│ │ │ │ └── shardLinks.model.js
│ │ │ ├── shardState/
│ │ │ │ ├── shardState.db.js
│ │ │ │ └── shardState.model.js
│ │ │ ├── trustedDevices/
│ │ │ │ ├── trustedDevices.db.js
│ │ │ │ └── trustedDevices.model.js
│ │ │ ├── uoLinkConfig/
│ │ │ │ ├── uoLinkConfig.db.js
│ │ │ │ └── uoLinkConfig.model.js
│ │ │ ├── userIdentities/
│ │ │ │ ├── userIdentities.db.js
│ │ │ │ └── userIdentities.model.js
│ │ │ ├── users/
│ │ │ │ ├── users.db.js
│ │ │ │ └── users.model.js
│ │ │ ├── wiki/
│ │ │ │ ├── wiki.db.js
│ │ │ │ ├── wiki.links.js
│ │ │ │ └── wiki.model.js
│ │ │ └── singletonConfigDb.js
│ │ ├── router/
│ │ │ ├── v1/
│ │ │ │ ├── admin/
│ │ │ │ │ ├── account.controller.js
│ │ │ │ │ ├── admin.controller.js
│ │ │ │ │ ├── admin.routes.js
│ │ │ │ │ ├── authProviders.controller.js
│ │ │ │ │ ├── botActivity.controller.js
│ │ │ │ │ ├── discordBot.controller.js
│ │ │ │ │ ├── emailConfig.controller.js
│ │ │ │ │ ├── invites.controller.js
│ │ │ │ │ ├── moderation.controller.js
│ │ │ │ │ ├── pages.controller.js
│ │ │ │ │ ├── shardOps.controller.js
│ │ │ │ │ ├── uoLink.controller.js
│ │ │ │ │ └── usersShard.controller.js
│ │ │ │ ├── auth/
│ │ │ │ │ ├── auth.controller.js
│ │ │ │ │ ├── auth.routes.js
│ │ │ │ │ ├── invite.controller.js
│ │ │ │ │ ├── me.routes.js
│ │ │ │ │ ├── mobile.controller.js
│ │ │ │ │ ├── mobile.routes.js
│ │ │ │ │ ├── mobileSso.controller.js
│ │ │ │ │ ├── mobileSso.routes.js
│ │ │ │ │ ├── notifications.controller.js
│ │ │ │ │ ├── notifications.routes.js
│ │ │ │ │ ├── passwordReset.controller.js
│ │ │ │ │ ├── sso.controller.js
│ │ │ │ │ ├── sso.routes.js
│ │ │ │ │ └── trustDevice.helper.js
│ │ │ │ ├── internal/
│ │ │ │ │ ├── internal.controller.js
│ │ │ │ │ └── internal.routes.js
│ │ │ │ ├── player/
│ │ │ │ │ ├── appeals.controller.js
│ │ │ │ │ ├── player.routes.js
│ │ │ │ │ └── shard.controller.js
│ │ │ │ ├── public/
│ │ │ │ │ ├── public.controller.js
│ │ │ │ │ ├── public.routes.js
│ │ │ │ │ └── shard.controller.js
│ │ │ │ └── v1.router.js
│ │ │ ├── api.router.js
│ │ │ └── wellKnown.controller.js
│ │ ├── utils/
│ │ │ ├── announceWorker.js
│ │ │ ├── auth.js
│ │ │ ├── botInternalClient.js
│ │ │ ├── botInternalKey.js
│ │ │ ├── db.js
│ │ │ ├── logger.js
│ │ │ ├── mailer.js
│ │ │ ├── newsGump.js
│ │ │ ├── pushDispatch.js
│ │ │ ├── sanitizeHtml.js
│ │ │ ├── secretBox.js
│ │ │ ├── shardBroadcast.js
│ │ │ ├── shardIngest.js
│ │ │ ├── shardSales.js
│ │ │ ├── totp.js
│ │ │ ├── trustProxy.js
│ │ │ ├── uoLinkClient.js
│ │ │ └── uoLinkSocket.js
│ │ ├── app.js
│ │ ├── internalApp.js
│ │ └── server.js
│ ├── swagger/
│ │ ├── swagger-output.json
│ │ └── swagger.js
│ ├── test/
│ │ ├── _helper.js
│ │ ├── adminTrustedDevices.test.js
│ │ ├── adminUserShard.test.js
│ │ ├── announceJobs.test.js
│ │ ├── appeals.pure.test.js
│ │ ├── appeals.test.js
│ │ ├── appLinks.test.js
│ │ ├── authController.test.js
│ │ ├── authMe.test.js
│ │ ├── authTrustedDevice.test.js
│ │ ├── botInternalKey.test.js
│ │ ├── botScore.test.js
│ │ ├── emailConfig.model.test.js
│ │ ├── honeypot.test.js
│ │ ├── inviteController.test.js
│ │ ├── invites.test.js
│ │ ├── loginProtection.test.js
│ │ ├── mailer.test.js
│ │ ├── mobileAuthBridge.model.test.js
│ │ ├── mobileDeviceSessions.test.js
│ │ ├── mobileSession.test.js
│ │ ├── mobileSsoBridge.test.js
│ │ ├── moderation.model.test.js
│ │ ├── moderation.test.js
│ │ ├── newsGump.test.js
│ │ ├── notificationsRoutes.test.js
│ │ ├── pages.model.test.js
│ │ ├── passwordResetController.test.js
│ │ ├── passwordResets.test.js
│ │ ├── playerAccounts.test.js
│ │ ├── playerRouteAccess.test.js
│ │ ├── providers.test.js
│ │ ├── publicBrand.test.js
│ │ ├── publicController.test.js
│ │ ├── publicShardOnline.test.js
│ │ ├── publicVersion.test.js
│ │ ├── pushDispatch.test.js
│ │ ├── recoveryCodes.test.js
│ │ ├── registry.test.js
│ │ ├── requireInternalKey.test.js
│ │ ├── secretBox.test.js
│ │ ├── selfTrustedDevices.test.js
│ │ ├── session.test.js
│ │ ├── shardControllerPublic.test.js
│ │ ├── shardIngest.champsPages.test.js
│ │ ├── shardIngest.protocol2.test.js
│ │ ├── shardState.governorTerms.test.js
│ │ ├── shardState.model.test.js
│ │ ├── ssoCallback.test.js
│ │ ├── ssoState.test.js
│ │ ├── totp.test.js
│ │ ├── trustedDevices.test.js
│ │ ├── trustProxy.test.js
│ │ └── usernamePolicy.test.js
│ ├── .env.example
│ ├── package-lock.json
│ └── package.json
├── .dockerignore
├── .env.example
├── .env.uomysticmoon.example
├── .gitignore
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── CONTRIBUTORS.md
├── docker-compose.dev.yml
├── docker-compose.yml
├── Dockerfile
├── LICENSE.md
├── package.json
├── README.md
├── SECURITY.md
└── sonar-project.properties
```

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
- [Architecture](#architecture)
- [Tech stack](#tech-stack)
- [Project structure](#project-structure)
- [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
| Layer | Tech |