1 Commits

Author SHA1 Message Date
cbaa18ea0b docs(android): release.yml is a conventional-commit auto-release engine
Update M6 docs to reflect release.yml mirroring link/'s engine (auto version +
changelog + tag + signed APK + Gitea release on merge to main), not a v* tag
trigger. Note REGISTRY_USER/REGISTRY_TOKEN + the main-push requirement, and the
derived monotonic versionCode. §9 status, §9 M6/M7 milestones, §12.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 04:25:05 -05:00
32 changed files with 68 additions and 4491 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) website/ docs from the shard website (Node/Express + MariaDB + React/Vite)
link/ docs from the ServUO bridge (C# plugin + Rust sidecar + Node WS) 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/` ### `website/`
@@ -20,7 +18,6 @@ ci/ cross-cutting CI/quality notes
| [HERO_EDITOR.md](website/HERO_EDITOR.md) | Hero canvas editor feature spec | | [HERO_EDITOR.md](website/HERO_EDITOR.md) | Hero canvas editor feature spec |
| [WIKI_UPGRADE.md](website/WIKI_UPGRADE.md) | Wiki subsystem upgrade notes | | [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) | | [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/` ### `link/`
| Doc | What it covers | | Doc | What it covers |
@@ -32,17 +29,6 @@ ci/ cross-cutting CI/quality notes
| [PLAN.md](link/PLAN.md) | uo-link build plan | | [PLAN.md](link/PLAN.md) | uo-link build plan |
| [RESEARCH.md](link/RESEARCH.md) | Research notes | | [RESEARCH.md](link/RESEARCH.md) | Research notes |
| [link-README.md](link/link-README.md) | Snapshot of the link repo's README | | [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 ## Provenance

View File

@@ -1,196 +0,0 @@
# Android App Links — implementation spec
Status: **implementation spec (M9 follow-up).** Stacks on the native SSO bridge (M9 Part 2):
the app already handles the **custom-scheme** callback `runicgateway://auth/callback`, and that stays
the permanent default and universal fallback. App Links are an **opt-in hardening** layered on top —
a verified `https://` callback that only the domain's real owner can claim.
Read alongside: the "Mobile SSO Authorization Bridge" section of
[`../website/BACKEND_DESIGN.md`](../website/BACKEND_DESIGN.md) (endpoints/tables/allowlist), and
[`PLAN.md`](./PLAN.md) §4.2 / §9 (the app milestone). This spec matches what ships on the
`feat/mobile-app-links` (website) and `feat/app-links` (android) branches.
---
## 1. The problem it solves
The mobile SSO bridge redirects the browser back to the app with a one-time code:
```
runicgateway://auth/callback?code=…&state=…
```
A **custom URI scheme** is fine for a self-hosted internal client, but it is not *owned* by anyone:
any other Android app can register an intent-filter for `runicgateway://auth/callback` and, if chosen
by the user, intercept the callback. The code is single-use, PKCE-bound (Layer B), and short-lived —
so an interceptor still cannot complete `/exchange` without the app's `code_verifier` — but a hijacked
callback is still a denial-of-service and a phishing surface we would rather close.
**Android App Links** (verified `https://` deep links) close it: the OS only routes an `https://`
link to an app that has proven, via a file served from *that domain*, that it owns the app. An
attacker cannot serve that file on a domain they do not control.
## 2. Why this is harder here than in a normal app
RunicGateway is **self-hosted per shard**. There is no single canonical domain — every shard owner
runs the website on **their own** domain (`play.exampleshard.com`, `uo.anothershard.net`, …). App
Links verification is **per-domain**: the domain must serve
```
https://<shard-domain>/.well-known/assetlinks.json
```
asserting the Android app's **package name** + **signing-certificate SHA-256 fingerprint**.
That is only half the problem. The other half is an Android platform constraint that decides the whole
shape of the app side:
> **`android:autoVerify` needs a *literal* host at build time.** An intent-filter's `<data android:host>`
> is a static string in the merged manifest; there is no "any host" or runtime host. A **single
> published multi-tenant APK therefore cannot autoVerify an open-ended set of shard domains** — the set
> is not known when the APK is built.
So App Links here are **not** a drop-in replacement for the custom scheme. They split into two pieces
that ship independently:
1. **Server (`assetlinks.json`) — shippable now, benefits any App-Links-capable build.** Every shard
can auto-serve its Digital Asset Links statement behind an admin toggle. This is a pure add and is
implemented on `feat/mobile-app-links`.
2. **App (`autoVerify` intent-filter) — a *build-time* opt-in.** Because the host must be baked in,
App Links are available to:
- a **white-label / first-party build** that bakes one shard's host (`-PappLinkHost=play.myshard.com`);
- a future **canonical relay domain** (`runicgateway.app`, PLAN §14 — *not yet secured*) that all
shards could bounce their final callback through, autoVerified by the generic build.
The **generic multi-tenant build bakes no host and stays custom-scheme-only** — correct and safe.
The custom scheme is never removed. It is the fallback on every build, for every shard, always.
## 3. Server design — `feat/mobile-app-links`
### 3.1 Auto-served `assetlinks.json`
- **Route:** `GET /.well-known/assetlinks.json`, served at the **web root** (outside `/api/v1`, before
the SPA catch-all) in `server/src/app.js`.
- **Gate:** the admin setting `mobile_app_links_enabled` (default **off**). Off ⇒ the route **404s** and
the app stays on the custom scheme for that shard. On ⇒ the shard opts into App Links.
- **Body:** the Digital Asset Links statement for the fixed package `com.runicgateway.app` and the
release signing cert SHA-256 fingerprint(s):
```json
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.runicgateway.app",
"sha256_cert_fingerprints": ["AB:CD:…"]
}
}
]
```
- **Fingerprint source:** env `MOBILE_APP_CERT_SHA256` — comma-separated (supports **cert rotation** and
a debug + release cert during testing). It is a **constant of the published app**, identical for every
shard, so it is a shipped/env default, not something each owner types. The package name is likewise
fixed (`MOBILE_APP_PACKAGE`, default `com.runicgateway.app`).
- **Enabled but no fingerprint configured ⇒ 404** (+ a one-time warn): serving a statement with no
fingerprint asserts nothing and would only mislead the verifier.
- Response is `application/json`, `Cache-Control: public, max-age=3600` (the Play verifier and the OS
re-fetch it; it changes only on a cert rotation).
### 3.2 Redirect-URI allowlist extension
`mobileSso.controller` validates the app's `redirect_uri` by **exact match** against
`MOBILE_AUTH_REDIRECT_URIS` (default `runicgateway://auth/callback`). App Links add exactly one more
acceptable value, and **only when the toggle is on**:
- When `mobile_app_links_enabled`, `/start` additionally accepts the **self-origin** HTTPS callback
`https://<request-host>/mobile/callback` (derived from the request/`APP_BASE_URL`, never from
attacker-controlled input). Still **exact match** — never a prefix match.
- The static custom-scheme allowlist is never narrowed; the HTTPS entry is *additive*.
- No new table or schema: the check reads the one boolean setting.
### 3.3 Public settings advertise the capability
`settings.getPublic()` gains `mobileAppLinks: <bool>` (mirrors the toggle) so a client can tell whether
a shard opted in before requesting an HTTPS `redirect_uri` (a white-label build uses it to avoid asking
for a callback the server would reject).
## 4. App design — `feat/app-links`
### 4.1 Build-time host (`appLinkHost`)
- Gradle property `appLinkHost` (default empty). Wired in `app/build.gradle.kts` into **both**:
- `BuildConfig.APP_LINK_HOST` — read by `SsoAuthManager` to decide the redirect;
- `manifestPlaceholders["appLinkHost"]` — substituted into the App Link intent-filter's host.
- **Default (generic build):** empty ⇒ `BuildConfig.APP_LINK_HOST = ""` and the placeholder falls back
to the reserved sentinel `runic-gateway.invalid` (RFC 6761 — never resolves), so the `autoVerify`
filter is **inert**: it matches no real link and verification simply never succeeds. No custom-scheme
behaviour changes.
- **White-label build:** `./gradlew assembleRelease -PappLinkHost=play.myshard.com` bakes that one host
into the filter and enables the HTTPS redirect for that host.
### 4.2 Manifest
A second intent-filter on `MainActivity`, alongside the unchanged custom-scheme one:
```xml
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https"
android:host="${appLinkHost}"
android:path="/mobile/callback" />
</intent-filter>
```
### 4.3 `SsoAuthManager` (pure Kotlin, unit-tested on the JVM)
- **Redirect selection in `buildStartUrl`:** request the HTTPS `redirect_uri`
`https://<pairedHost>/mobile/callback` **iff** `BuildConfig.APP_LINK_HOST` is non-blank *and* equals
the paired base-URL host (case-insensitive); otherwise the fixed custom-scheme `REDIRECT_URI`. A
white-label build that bakes the host is responsible for enabling the server toggle too (§3.2).
- **Verified-callback matcher + host-trust check:** a new `matchesAppLinkCallback(scheme, host, path)`
accepts only `scheme == https`, `path == /mobile/callback`, and **`host == the paired base-URL host`**.
The paired-host equality is defense-in-depth: even though `autoVerify` already means only a real,
opted-in shard domain can route here, the app still refuses any HTTPS callback whose host isn't the
shard it is currently paired to.
- The rest is unchanged: both matchers feed the *same* `complete(state, code, error)` → `/exchange` →
`SessionManager.onSignedIn`. There is no second auth path.
### 4.4 `MainActivity`
`handleSsoCallback` routes a VIEW intent through **`matchesCallback(...) || matchesAppLinkCallback(...)`**;
everything downstream (state check, exchange, sign-in) is shared. Custom-scheme and App Link callbacks
are indistinguishable past the edge.
## 5. Turning it on for a shard
1. Publish/point the app build at the shard host (`-PappLinkHost=<host>`) — or use the generic build and
leave App Links off.
2. Set `MOBILE_APP_CERT_SHA256` (release cert fingerprint) in the website env.
3. Admin → Shard/Settings: enable **App Links** (`mobile_app_links_enabled`).
4. Verify `https://<host>/.well-known/assetlinks.json` returns the statement; confirm Android verifies
(`adb shell pm get-app-links com.runicgateway.app`).
If any step is skipped the app transparently keeps using the custom scheme — nothing breaks.
## 6. Testing
- **Server (`node --test`):** route 404s when the toggle is off; 404s when on but no fingerprint;
returns the correct statement + content-type when on and configured; the redirect allowlist accepts
`https://<host>/mobile/callback` only when enabled and rejects it otherwise (custom scheme always
accepted).
- **App (JVM unit tests):** `matchesAppLinkCallback` accepts only https + `/mobile/callback` + the paired
host and rejects a foreign host / http / wrong path; `buildStartUrl` requests the HTTPS redirect only
when the baked host matches the paired host, else the custom scheme.
## 7. What does *not* change
- The bridge's server design (PKCE Layer A/B, single-use codes, `/start` + `/exchange`) is untouched;
App Links are *one more allowlist entry* + *one static file route*. That is the whole point of keeping
the allowlist exact-match and configurable from day one.
- The custom scheme remains on every build and is the permanent fallback.
- No change to `servuo-plugins/` — App Links are entirely a website ↔ app concern.

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

@@ -1,6 +1,6 @@
# Android App — Plan # Android App — Plan
Status: **M0M7 landed; M7 (push notifications) both parts done — Part 1 backend (website#78) and Part 2 app (Android-app#15) plus a small `push.ntfyUrl` settings addition (website#79). Remaining: set the shard's `NTFY_*` deploy config so push lights up, and cut the v1 tag. M9 (native SSO login) is now underway backend-first — the Mobile SSO Authorization Bridge is being built in `website/` + `docs/` ahead of the app-side client (§4.2, §9 M9); custom-scheme callback only for now, App Links deferred (see [`APP_LINKS.md`](./APP_LINKS.md)).** This document is the Status: **M0M6 landed; the functional build, design pass, and release mechanics are complete (the auto-release engine cuts a tagged, signed APK on merge to `main`; M7 push notifications next).** This document is the
design contract for the `RunicGateway/Android-app` repo. It was written before implementation so the design contract for the `RunicGateway/Android-app` repo. It was written before implementation so the
API changes it depends on could be landed in `website/` and `docs/` first. The authoritative API API changes it depends on could be landed in `website/` and `docs/` first. The authoritative API
reference is the committed OpenAPI spec at `website/server/swagger/swagger-output.json` (regenerated reference is the committed OpenAPI spec at `website/server/swagger/swagger-output.json` (regenerated
@@ -128,266 +128,22 @@ probe refuses a backend whose API version this build can't speak (a future `v2`)
signed release) with keep-rules for the kotlinx.serialization serializers, the wire DTOs, and the signed release) with keep-rules for the kotlinx.serialization serializers, the wire DTOs, and the
Retrofit interfaces; a release `signingConfig` that reads keystore material from a **gitignored** Retrofit interfaces; a release `signingConfig` that reads keystore material from a **gitignored**
`keystore.properties` or env vars (absent → unsigned; the keystore is never committed); and `keystore.properties` or env vars (absent → unsigned; the keystore is never committed); and
`versionName`/`versionCode` overridable via `-P` so a release tag + CI run number drive them (§10). `versionName` is the committed source of truth (bumped by the release engine); `versionCode` is derived
**CI `release.yml`** — on a `v*` tag, builds a **signed** APK (keystore decoded from a base64 Gitea from it (`major*10000+minor*100+patch`, monotonic); both stay `-P`-overridable for local builds (§10).
secret) and attaches it + `SHA256SUMS` to a Gitea release; `workflow_dispatch` is a signing dry run. **CI `release.yml`** — mirrors `link/`'s language-agnostic release engine, adapted for Android: on every
push to `main` it derives the next version from conventional-commit subjects since the last `v*` tag
(`feat!`/BREAKING → major, `feat` → minor, `fix`/`perf` → patch; nothing releasable → no release),
generates a grouped changelog, bumps `build.gradle.kts`, builds the **signed** APK (keystore decoded from
a base64 Gitea secret), then commits the bump `[skip ci]`, tags `vX.Y.Z`, and creates the Gitea release
with notes + APK + `SHA256SUMS`. Uses `REGISTRY_USER`/`REGISTRY_TOKEN` (as `link/` does) to push the bump
and create the release, so `main` must allow that account to push.
HTTPS-only in release (M1), no token logging (logging is debug-gated, M3), and the Settings → Server HTTPS-only in release (M1), no token logging (logging is debug-gated, M3), and the Settings → Server
hard reset (M3) were already in place. Biometric app-lock is **descoped from v1** (see the note below). hard reset (M3) were already in place. Biometric app-lock is **descoped from v1** (see the note below).
**The functional build (M0M4), design pass (M5), and release mechanics (M6) are complete. Cutting **The functional build (M0M4), design pass (M5), and release mechanics (M6) are complete. The first
the first `v*` release tag (once the signing secrets are set + the on-device QA pass is done) and M7 signed release now cuts automatically on the next release-worthy merge to `main` — once the signing +
push notifications are what remain.** `REGISTRY_*` secrets are set, `main` allows the CI account to push, and the on-device QA pass is done.
M7 push notifications are what remain.**
### M7 plan — push notifications (in progress)
M7 spans three repos, so it ships in **two parts**; the backend contract lands first because the app
is a pure consumer of it (§8/§11).
**Part 1 — `website/` backend + `docs/` — ✅ LANDED** (2026-07-20, `RunicGateway/website#78` merged
+ docs#20). Additive, v1-only (new tables/routes/compose
service; no existing response shape changes). Decision: **no ntfy publish token** — publishes go over
the internal compose network to unguessable per-device topics carrying **content-free tickles**
(`{ stream, ref }`); the publisher honors an optional `NTFY_PUBLISH_TOKEN` if ever set but requires
none (keeps §11's zero-interaction promise).
- **Two event sources, one publisher.** The fan-out is a small transport-agnostic
`utils/pushDispatch.js` that both producers call: `utils/shardIngest.js` (`ingest()`, beside the
existing `broadcast(event)`) for shard-derived streams, and the admin create-post path for the
`news.post` stream (§11 lists news posts as a public stream, but they originate in the website, not
the shard feed).
- **Stream catalog** (`config/notificationStreams.js`): public/opt-in — `news.post`,
`server.status`, `idoc.warning`, `champ.start`, `governor.election`; personal/owner-keyed
(require a linked game account) — `vendor.sale`, `house.idoc`, `account.login`. `mapShardEvent()`
maps event kinds → streams, drawing public streams **only** from the SSE `PUBLIC_KINDS` allowlist;
sensitive kinds are never fanned out publicly. Personal events are delivered only to the owning
user's devices, resolved via `shardLinks.getByAccount` (same ownership source as `/player/shard/*`).
- **Tables:** `push_devices` (per-device endpoint) and `notification_subscriptions` (per-user opted-in
streams), FK → `users` ON DELETE CASCADE, mirroring `mobile_refresh_tokens`.
- **Routes** under the role-agnostic self surface (never `/admin`): `POST|GET /auth/me/devices`,
`DELETE /auth/me/devices/:id`, `GET /auth/me/notifications/streams` (catalog),
`GET|PUT /auth/me/notifications/subscriptions`. All bearer/cookie auth; Swagger regenerated.
- **SSRF guard (important):** a device `endpoint` is a client-supplied URL the backend POSTs to, so
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.
**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
two recorded implementation decisions:
- **Direct-ntfy embedded distributor, no UnifiedPush library (deviation from §2's "UnifiedPush
connector" wording — the plan's stated likely path, work item 1).** The app talks straight to ntfy
over its own topic rather than pulling in `org.unifiedpush.android:connector` + an external
distributor: a foreground `PushService` holds an OkHttp-SSE connection to `<ntfy>/<topic>/sse`
(reusing the M2 `ShardStreamClient` reconnect pattern) on a **bare** client, `PushManager`
orchestrates topic mint / device register / start-stop keyed to the session, and `PushNotifier`
posts a per-stream notification whose tap deep-links via `MainActivity` intent extras. No new Gradle
dependency; a `PushResult`/transport seam keeps the future FCM Play flavor cheap. Reasons: the
UnifiedPush distributor model assumes a *separate* app (exactly what the user vetoed), we already own
the SSE machinery, and this keeps the APK Google-free and dependency-light. New code lives in
`core/push/` + `ui/notifications/` + a `NotificationsApi`/`NotificationsRepository`; no existing
screen's data flow changed.
- **One small additive backend field was required after all (`push.ntfyUrl`).** The embedded
distributor must know the shard's client-facing ntfy URL to build its topic endpoint, and Part 1
never surfaced it (the `NTFY_*` vars are server-only). So `/public/settings` now carries
`push: { ntfyUrl }` (from `NTFY_PUBLIC_URL` / first `NTFY_ALLOWED_ORIGINS`; never the internal
`NTFY_BASE_URL`), null when unconfigured → the app shows push as unavailable for that shard. This is
the "no backend work in Part 2" caveat corrected: it is additive, non-sensitive, and forward-compatible
(an older backend omitting it just decodes to null). **Deploy dependency stands:** push only delivers
once the shard sets `NTFY_PUBLIC_URL`/`NTFY_ALLOWED_ORIGINS` (§13).
Verified green: `:app:testDebugUnitTest` (18 new JVM tests — notifications DTO decode, ntfy tickle
parse incl. malformed, topic/URL building, stream→route map + personal gating) + `:app:lintDebug` +
`:app:assembleDebug`; backend 250 tests (+3 for `push.ntfyUrl`) and `npm run swagger` clean. The
foreground-service tradeoff (§11) and the POST_NOTIFICATIONS runtime permission are implemented as
planned; an on-device delivery pass against a live ntfy is the one open QA item.
**Part 2 (original plan) — the Android app.** UnifiedPush receiver + device registration
against the merged Part-1 contract, a Notifications settings screen, and notification-tap deep-links.
The app is architected for push from M0 (§11), so this is **additive** — a new feature slice
(`core/push` + `ui/notifications` + a `DevicesApi`/`NotificationsApi` pair) that touches no existing
screen's data flow. Everything the app calls already exists and is merged; there is **no backend
work** in Part 2.
The Part-1 contract the app codes against (verified against the merged `website` source):
- `POST /auth/me/devices` `{ transport?: 'unifiedpush'|'fcm', endpoint, platform? }``201 PushDevice`
`{ id, transport, endpoint, platform, createdAt, lastSeenAt }`. Idempotent per `(user, endpoint)`
(upsert). `endpoint` **must** be HTTPS on the shard's ntfy allow-set — a private/loopback or
off-allowlist origin is rejected `400` (the SSRF guard). Bearer-auth, so registration only happens
while signed in.
- `GET /auth/me/devices``PushDevice[]`; `DELETE /auth/me/devices/:id``{ ok: true }` (`404` if not
the caller's).
- `GET /auth/me/notifications/streams` → `{ streams: [{ id, label, description, personal,
requiresLinkedAccount }] }` — the eight-stream catalog (`news.post`, `server.status`,
`idoc.warning`, `champ.start`, `governor.election`; personal `vendor.sale`, `house.idoc`,
`account.login`). Render from this, don't hardcode.
- `GET /auth/me/notifications/subscriptions` → `{ streams: [id…] }`; `PUT` the same shape (full
replace; unknown ids dropped server-side; the stored set is echoed back).
- **The wire tickle** the device receives is the content-free `{ "stream": "<id>", "ref": "<opaque>" }`
JSON body (`utils/pushDispatch.js`). `ref` is a serial / city / timestamp hint — **never** content.
Work items:
1. **Transport — the app is its own distributor; no second app (DECIDED).** The Runic Gateway app
**embeds its own UnifiedPush distributor**. The self-hosted **ntfy is only the relay server**, never
a user-installed app — the user installs *one* APK and it receives its own notifications, with no
external distributor (no ntfy app, no NextPush) and no Google Play Services. Concretely, the embedded
distributor holds a **persistent connection to the shard's ntfy** in a **foreground service**,
reusing the OkHttp reconnect/backoff pattern already built for `core/net/ShardStreamClient` (M2): it
subscribes to the app's own random, unguessable ntfy **topic** (over `wss://<ntfy-host>/<topic>/ws`
or the `/json` stream) and forwards each received `{stream,ref}` tickle to the app's receiver. The
**endpoint the app registers** with the backend (work item 5) is that topic's public URL
(`https://<ntfy-host>/<topic>`) — exactly the client-supplied `endpoint` the merged `POST
/auth/me/devices` contract expects and the URL the backend POSTs tickles to. Keep the transport
behind a small `PushTransport` seam so the **future Play/FCM build flavor** (§11, §M8) can swap the
embedded-ntfy distributor for FCM without touching registration, subscriptions, or notification code.
(Implementation detail to confirm: whether a maintained Google-free embedded UnifiedPush-distributor
library fits, or — more likely — a thin in-app distributor written directly over ntfy's subscribe API
reusing `ShardStreamClient`. Either way the distributor lives **inside this app**; the UnifiedPush
*receiver* abstraction is retained only to keep the FCM-flavor seam clean.)
- **Tradeoff, accepted:** instant background delivery requires a persistent foreground service with
an ongoing (low-importance) notification and its battery cost — this is exactly how ntfy's own app
does instant delivery, and it is the price of Google-free self-delivery. A future "battery saver"
option could fall back to periodic polling, but v1 ships the always-connected foreground service.
2. **Deps + manifest.** Add the UnifiedPush connector + the embedded-distributor transport (per #1) to
the version catalog; declare `POST_NOTIFICATIONS` (API 33+ runtime permission) **and
`FOREGROUND_SERVICE` + `FOREGROUND_SERVICE_DATA_SYNC`** (API 34+, for the persistent ntfy
connection); register the receiver and the foreground service in `AndroidManifest.xml`; define the
notification channels (id/name externalized, §2) — one for real notifications plus a low-importance
channel for the ongoing foreground-service notification — and reuse the "RG" notification icon
**already staged in M6**.
3. **`core/push` — embedded distributor + receiver.** The **distributor** component is a foreground
service that owns the ntfy connection (per #1): it (re)creates the app's topic, subscribes over
OkHttp with reconnect/backoff cloned from `ShardStreamClient`, and forwards each frame to the
receiver. The **receiver** parses the `{ stream, ref }` tickle (`kotlinx.serialization`; an
unknown/garbled body is dropped, not crashed — §7 discipline) and posts a notification (work item 7).
Endpoint (re)registration against the backend fires on first subscribe / topic (re)creation
(work item 5); a transient ntfy drop is just a reconnect, not a re-register.
4. **`DevicesApi` + `NotificationsApi` (Retrofit) + DTOs.** Hand-authored, spec-aligned (as recorded
for M1): `RegisterDeviceRequestDto`, `PushDeviceDto`, `NotificationStreamDto`,
`NotificationStreamsDto`, `NotificationSubscriptionsDto`. Both go through the existing bearer/refresh
stack (`AuthInterceptor` + `TokenAuthenticator`) and return the typed `ApiResult` (§7). A
`NotificationsRepository` owns register/list/delete-device and get/put streams+subscriptions.
5. **Endpoint ↔ backend lifecycle (mirror the M3 token teardown).** Persist the app's ntfy topic, its
endpoint URL, and the returned device `id` in prefs (DataStore; the topic/endpoint isn't a secret —
its security rests on being unguessable + the content-free tickle, §11). Start the embedded
distributor and `POST /auth/me/devices` **only when the user has ≥1 subscription and is signed in**.
On **logout / dead-refresh sign-out / Settings→Server switch**, `DELETE /auth/me/devices/:id`, **stop
the foreground service**, and drop the topic — wire this into `SessionManager` beside the existing
token-clear so a signed-out device stops receiving (§4.3, §11 "unregister on logout / token
revocation"). On a **server (base-URL) switch**, mint a fresh topic against the new shard's ntfy (the
old endpoint's origin won't be on the new host's allow-set). Re-assert the endpoint + restart the
service on app start when signed-in + subscribed. A `400` on register (endpoint origin off the
shard's `NTFY_ALLOWED_ORIGINS`) surfaces a clear "your shard's push relay isn't reachable" state, not
a crash.
6. **Notifications settings screen (`ui/notifications`).** Lists the catalog from
`GET …/streams` with a per-stream toggle bound to `GET/PUT …/subscriptions`; a **personal** stream
(`requiresLinkedAccount`) is greyed with a "link a game account" hint until the user has a linked
account — reuse the linked-accounts signal already fetched for M4's player surface
(`PlayerShardRepository`), not a fresh source of truth. Toggling to a non-empty set triggers the
register flow (#5) and requests `POST_NOTIFICATIONS`; emptying the set unregisters. Each mutation
folds its `ApiResult` into a section-scoped, localized banner (§7 parity with M4).
7. **Deep-links (resolves the §13 open item).** Tapping a notification opens the app to the stream's
home: `news.post`→News, `server.status`/`champ.start`/`idoc.warning`/`governor.election`→Shard,
`vendor.sale`→Vendors, `house.idoc`→My Houses, `account.login`→My Account. Routed through the
existing `ui/navigation/Routes.kt`; a signed-out/deep-link-to-player tap lands on the `PlayerGate`
(M4) rather than erroring. **v1 shows a generic per-stream notification** (localized catalog
`label`) and deep-links — it does **not** pull `ref` content first; the content-free design means
nothing needs decrypting to render the tap, and the target screen fetches fresh over the
authenticated API on open. (Pulling `ref` for a richer inline notification is a possible later
enhancement, not v1.)
8. **Menu.** Add a **Notifications** entry to the signed-in group in `ui/navigation/Menu.kt` (near My
Account), visible once signed in.
9. **Permission UX.** Request `POST_NOTIFICATIONS` at the moment the user first enables a stream (API
33+); on denial, keep the toggle off and show how to enable it in system settings — never nag on
launch.
10. **Tests (JVM, `testDebugUnitTest`).** DTO decode (device/stream/subscription), `{ stream, ref }`
tickle parse (incl. a malformed body → dropped), the stream→deep-link map, the "personal greyed
until linked" gate, and the register/unregister lifecycle over a fake `SessionManager` + repository
(parity with M3's session tests).
**Cross-repo dependency to confirm before/at implementation** (a Part-1 §13 open item): the shard's
finalized **ntfy reverse-proxy hostname** must be in `NTFY_ALLOWED_ORIGINS`, because the distributor
hands the app an endpoint on *that* origin and the backend rejects a register whose origin isn't
allow-listed. This is deployment config, not code, but Part 2 can't be end-to-end tested until it's
pinned. No `website`/`link`/`servuo-plugins` code change is expected in Part 2.
Ships as `RunicGateway/Android-app#15`; bumps `versionCode`/`versionName` for a post-v1 release
(§10). Like M1M4 it records itself in the §9 build-progress block on landing.
### M9 plan — native SSO login (in progress)
M9 spans `website/` + `android-app/` + `docs/`, so — like M7 — it ships in **two parts**, backend
first (the app is a pure consumer of the bridge contract; §4.2, §9 item 10).
**Part 1 — `website/` backend + `docs/` — ✅ LANDED** (the Mobile SSO Authorization Bridge:
`mobile_auth_sessions`/`mobile_auth_codes` tables, `GET /auth/mobile/sso/start`, the `mode:'mobile'`
branch in the reused SSO callback + TOTP completion, `POST /auth/mobile/sso/exchange`, the exact-match
`MOBILE_AUTH_REDIRECT_URIS` allowlist, and bridge-table cleanup). Canonical ref:
`../website/BACKEND_DESIGN.md` → "Mobile SSO Authorization Bridge".
**Part 2 — the Android app (this milestone).** The native in-app "Sign in with Google / Discord"
client. **Additive** — a new auth slice (`core/auth/sso` + a `SsoApi`/`SsoAuthManager` + a login-screen
provider list) that feeds the *existing* M3 session machinery; it adds **no** new token-storage or
refresh code, and touches no other screen. **No backend work** — every endpoint it calls is merged.
The Part-1 contract the app codes against (verified against the merged `website` source):
- `GET /auth/providers` → `[{ id, name, icon, loginUrl, priority }]` (public discovery, no secrets).
`icon` ∈ `google|discord|oidc|oauth2`. Render the provider buttons from this — don't hardcode.
- `GET /auth/mobile/sso/start?provider&code_challenge&state&redirect_uri` — **opened in a Custom Tab**
(not an XHR): it 302s through the IdP and finally deep-links back to `redirect_uri`. `redirect_uri`
must be an **exact** allowlist entry — the app always sends the one fixed callback
`runicgateway://auth/callback`.
- The callback deep link carries **either** `?code=<one-time>&state=<echoed>` (success) **or**
`?error=<reason>&state=<echoed>` (`invalid_provider`/`provider_unavailable`/`server_error`, or an
IdP/link refusal) — **never a token**.
- `POST /auth/mobile/sso/exchange` `{ code, code_verifier }` → the **same** `{ accessToken,
refreshToken, expiresIn, user }` pair as `/auth/mobile/login`; `401` on an unknown/expired/used code
or a PKCE-verifier mismatch.
Work items:
1. **PKCE + state (Layer B, app↔website).** A pure-JVM `Pkce` helper (unit-testable, no Android
framework types): `code_verifier` = 32 random bytes base64url (RFC 7636 S256), `code_challenge` =
base64url(SHA-256(verifier)), plus a random `state`. `java.util.Base64` URL encoder without padding
+ `MessageDigest` — matches the backend's `crypto.createHash('sha256')…base64url` exactly.
2. **`SsoAuthManager` (Singleton) — the flow orchestrator.** Holds the **pending** `{state, verifier}`
in memory (lost on process death → the exchange fails closed and the user retries; acceptable and
safe, documented). `buildStartUrl(provider)` mints PKCE+state, stashes pending, and builds the
absolute `/start` URL off `BaseUrlHolder` for the Custom Tab. `isCallback(uri)` matches our scheme;
`complete(uri)` verifies `state` (CSRF), maps an `error`, exchanges the `code` with the stashed
`verifier`, and on success drives `SessionManager.onSignedIn` — the *same* entry the password login
uses, so push registration (`PushManager` observes the session) and the menu react identically. It
exposes an `outcome: StateFlow` (Idle/Success/Failed(reason)) the login screen consumes, robust to a
ViewModel/activity recreation while the Custom Tab is foreground.
3. **`SsoApi` + DTOs.** `GET api/v1/auth/providers` → `List<SsoProviderDto>`; `POST
api/v1/auth/mobile/sso/exchange` tagged `Http.NO_SESSION_HEADER` (no bearer; a credential-style
`401` must not be read as an expired session or trip the refresh `Authenticator`) → the reused
`MobileTokenResponse`. Lenient Json (additive fields safe, §8).
4. **Deep link.** Register the `runicgateway://auth/callback` intent-filter on `MainActivity`
(`VIEW` + `DEFAULT` + `BROWSABLE`, `scheme/host/path` from one shared constant) and set
`launchMode="singleTop"` so the returning Custom Tab reuses the running task; `onCreate`/`onNewIntent`
route a matching `ACTION_VIEW` intent to `SsoAuthManager.complete` on `lifecycleScope`. Custom scheme
only for now — App Links deferred (`APP_LINKS.md`).
5. **Login screen.** Replace the single "SSO on the website" hand-off with a native provider list from
`GET /auth/providers`: one button per provider (Google/Discord/OIDC glyph from `icon`), each opening
its `/start` URL in a Custom Tab via the existing `WebHandoff`. The `LoginViewModel` collects
`SsoAuthManager.outcome` → a success pops back like a password sign-in; a failure surfaces a friendly
inline error (reusing the existing `LoginError` channel + a new SSO string). Falls back to the
website login hand-off when discovery returns no providers or the base URL is unset.
6. **Tests (JVM, `testDebugUnitTest`).** `Pkce` (verifier charset/length, challenge = base64url-SHA-256
of a known vector, no padding), start-URL building (encoded params, fixed `redirect_uri`), and
`SsoAuthManager.complete` over a fake `SsoApi` + `SessionManager`: success signs in; a mismatched or
missing `state` fails without exchanging; an `error=` callback maps to the right reason; a `401`
exchange maps to expired-code; a missing pending (process death) fails closed.
Ships as a `RunicGateway/Android-app` PR; bumps `versionCode`/`versionName` for a post-v1 release
(§10) and records itself in the §9 build-progress block on landing. No `website`/`link`/`servuo-plugins`
code change is expected in Part 2.
**Prerequisite progress (§8):** all v1 prerequisites are **done** (2026-07-19) — ✅ password reset **Prerequisite progress (§8):** all v1 prerequisites are **done** (2026-07-19) — ✅ password reset
(item 2; website#75 + docs#8), ✅ role-agnostic `/auth/me/*` self surface (item 1; website#76 + docs#10), (item 2; website#75 + docs#8), ✅ role-agnostic `/auth/me/*` self surface (item 1; website#76 + docs#10),
@@ -422,29 +178,21 @@ console. It is a **read + self-service** app, not an operator tool.
- **Access-level menu**: one shared navigation that reveals items based on the signed-in user's role. - **Access-level menu**: one shared navigation that reveals items based on the signed-in user's role.
- **Opt-in push notifications** (post-v1; architected for from the start): per-stream subscriptions the - **Opt-in push notifications** (post-v1; architected for from the start): per-stream subscriptions the
user chooses — nothing is pushed unless subscribed. See §11. user chooses — nothing is pushed unless subscribed. See §11.
- **Staff operations subset** (post-v1, **M10** — decided 2026-07-21): a *defined* slice of the admin
surface, native, for `admin`/`moderator` staff. In scope: **moderation actions** (kick / ban / unban /
broadcast), the **support (help-page) queue** (reply / close / resolve), the **dashboard summary +
site-mode** toggle, and **content management** (news posts — list / create / edit / publish / delete —
plus wiki category & tag management). These consume the existing `/api/v1/admin/**` routes (which
already accept bearer auth and re-check role every request); no backend routes are added. See §6.4 + §10.
### Explicitly OUT of scope (never in the app, for any role) ### Explicitly OUT of scope (never in the app, for any role)
The exclusions are now a *narrow* list (M10 brought the operational admin subset in-scope, above). The - The **hero editor** and any CMS authoring/block editing.
app still never ships: - The **admin / auth-management console** — user management, invites issuance, SSO provider config,
- The **hero editor** and CMS **block/page authoring** (the visual page builder). *(News-post and moderation console, email config, bot-activity/ban console. (Players still *log in*; what's
wiki category/tag management ARE in scope; the excluded piece is the CMS block editor / hero builder.)* excluded is the management surface, not authentication itself.)
- **Discord bot** configuration (and anything under the unpublished `/internal/**` port — it returns the - The **Discord bot** management (and anything under the unpublished `/internal/**` port — it
decrypted bot token and must never be reachable from a client). returns the decrypted bot token and must never be reachable from a client).
- **uo-link / sidecar administration** — the shard sidecar base-URL/token config (`uoLinkConfig`). (The - **Shard / uo-link administration** — sidecar base-URL/token config (`uoLinkConfig`), shard ops,
app performs *shard operations* like kick/ban/broadcast against the live shard, but never configures the staff shard-user console. (The app shows *public* shard widgets and a player's *own* game
the sidecar connection itself.) data; it does not manage the sidecar.)
- **OAuth / SSO provider setup** — creating/editing identity providers and their client secrets.
> The app calls `/api/v1/public/**`, `/api/v1/auth/**` (incl. the role-agnostic self surface > The excluded surfaces all live under `/api/v1/admin/**` and `/api/v1/internal/**`. The app only
> `/auth/me/*`, §6.4), `/api/v1/player/**`, and — for staff, M10 — the **defined `/api/v1/admin/**` > ever calls `/api/v1/public/**`, `/api/v1/auth/**` (incl. the new role-agnostic self surface
> operations listed above. It never touches `/api/v1/internal/**`, nor the four excluded admin surfaces > `/auth/me/*`, §6.4), and `/api/v1/player/**` — it never references `/admin`.
> (hero/CMS block editor, Discord-bot config, uo-link config, OAuth-provider setup).
--- ---
@@ -502,13 +250,7 @@ compiled in.
`/public/settings` for branding: name/colors/logo). Only on a successful, well-formed response is `/public/settings` for branding: name/colors/logo). Only on a successful, well-formed response is
the URL persisted to DataStore and the app allowed to initialize its main UI. the URL persisted to DataStore and the app allowed to initialize its main UI.
- Accept `https://host[/base]`; normalize/trim; require HTTPS in release builds (allow HTTP only in - Accept `https://host[/base]`; normalize/trim; require HTTPS in release builds (allow HTTP only in
debug for local dev against `127.0.0.1:3000`). This app-layer rule (`ServerUrl`, debug for local dev against `127.0.0.1:3000`).
`allowInsecureHttp = BuildConfig.DEBUG`) is backed at the platform socket layer by an explicit
**network security config** (`res/xml/network_security_config.xml`, wired via
`application android:networkSecurityConfig`): the main/release config permits **no** cleartext,
and a debug-only override (`app/src/debug/res/xml/`) re-permits cleartext to loopback
(`127.0.0.1`/`localhost`) only. Being explicit also stops a merged library manifest from
re-enabling cleartext and clears the `usesCleartextTraffic`-implicitly-enabled scanner finding.
- Failure states: unreachable, non-2xx, not-a-Runic-Gateway-site (missing expected `/public/status` - Failure states: unreachable, non-2xx, not-a-Runic-Gateway-site (missing expected `/public/status`
shape), TLS error — each gets a clear retry message. Nothing else in the app runs until this shape), TLS error — each gets a clear retry message. Nothing else in the app runs until this
succeeds. succeeds.
@@ -546,39 +288,6 @@ Uses the existing **mobile bearer** surface, no backend changes:
`401` from a bearer call, with a mutex so concurrent 401s trigger only one refresh. `401` from a bearer call, with a mutex so concurrent 401s trigger only one refresh.
- `POST /auth/mobile/logout` `{ refreshToken?, all? }` (requires bearer) — revoke this session or all - `POST /auth/mobile/logout` `{ refreshToken?, all? }` (requires bearer) — revoke this session or all
sessions. Called on user logout and on "sign out everywhere." sessions. Called on user logout and on "sign out everywhere."
#### 4.1.1 Trusted devices & recovery codes — **implemented** (app PR `feature/trusted-devices-mfa`)
The backend trusted-device + recovery-code feature (canonical ref: `../website/TRUSTED_DEVICES_MFA.md`)
is additive on the mobile surface. As built:
- **Login extras:** `POST /auth/mobile/login` accepts `recoveryCode` (a single-use alternative to
`code`), `trustDevice: true`, `device_name`, and an `X-Trust-Token` header. On the `401 { totpRequired }`
screen the app offers a "use a recovery code instead" toggle and a **"Trust this device"** checkbox.
When `trustDevice` is accepted, the response carries `trustToken` → stored in a **dedicated
EncryptedSharedPreferences file** (`runic_trust`, AES-256-GCM; never plain prefs/logs) **scoped to the
username** it was minted for, and replayed as `X-Trust-Token` on that account's future logins to skip
the TOTP prompt.
- **Login-time cap:** a `{ trustLimitReached, devices }` response means login **succeeded** but the
device was not remembered (session is already issued on native, unlike the web cookie step). Rather
than a blocking login-time modal, this is surfaced + resolved on the **Trusted Devices** account
screen, whose `POST /auth/me/trusted-devices` gives the exact revoke-one-then-retry flow. (Deliberate
deviation from the earlier "prompt at login" sketch — the mobile login is past the point a modal would
gate.)
- **Self-service (account screens):** a **Security** section on the account screen links to two
dedicated screens. **Trusted Devices** — `GET /auth/me/trusted-devices` (list), `DELETE …/:id`
(revoke one), `DELETE …/trusted-devices` (untrust all), and `POST /auth/me/trusted-devices` to trust
the current device (stores the returned `{ trustToken }`). **Recovery Codes** — enabling TOTP returns
the one-time `recoveryCodes` (shown once on the account screen with copy/share); `GET
…/recovery-codes/status` for the remaining count; `POST …/recovery-codes/generate` (password step-up)
to regenerate, shown once.
- **Invalidation — trust token deliberately survives logout.** The trust token is the native analogue
of the web `rg_trust` cookie, which per the canonical design *survives logout so the next login skips
2FA*. On mobile the token is only ever consulted at a **fresh** login — i.e. exactly after a logout or
a dead-refresh sign-out — so clearing it there would make the feature a no-op. It is therefore kept in
its own store, **untouched by session teardown** (`SessionManager.onSignedOut`), and cleared only on a
**Settings → Server switch** (bound to the old host), an **untrust-all**, or server-side revocation
(password change/reset, TOTP disable — which makes any surviving token inert; the next login just
prompts for the code). This supersedes the earlier handoff note that said to clear it on logout.
### 4.2 Website-handled flows: registration, invite, forgot-password, SSO ### 4.2 Website-handled flows: registration, invite, forgot-password, SSO
These are **not** rebuilt in the app. The app links out to the website's own pages/API and the user These are **not** rebuilt in the app. The app links out to the website's own pages/API and the user
completes them in a Custom Tab, then returns and signs in natively (§4.1): completes them in a Custom Tab, then returns and signs in natively (§4.1):
@@ -587,19 +296,14 @@ completes them in a Custom Tab, then returns and signs in natively (§4.1):
new username + password. (No mobile register/invite endpoints needed.) new username + password. (No mobile register/invite endpoints needed.)
- **Forgot / reset password** — the app links to the website's reset page (the flow being built in §8 - **Forgot / reset password** — the app links to the website's reset page (the flow being built in §8
before app work). The user resets there, then signs into the app. (No mobile reset endpoint needed.) before app work). The user resets there, then signs into the app. (No mobile reset endpoint needed.)
- **SSO (Google / Discord / OIDC)** — **v1** shipped this as a website browser hand-off: an SSO user - **SSO (Google / Discord / OIDC)** — SSO stays the website's browser redirect flow (`/auth/sso/*`),
links their identity and sets a password on the website, then uses password login in the app. **link-only** (no auto-provisioning). For v1 the app does **not** do one-tap in-app SSO; instead an
`GET /auth/providers` is shown so the login screen can direct users to "sign in with … on the website." SSO user links their identity and sets a password on the website (the existing "set initial password"
- **Native in-app SSO — now being built (M9), post-v1 additive.** The "possible later enhancement" path for SSO-provisioned accounts), then uses password login in the app. `GET /auth/sso/providers`
noted here is now the **Mobile SSO Authorization Bridge**: a Custom-Tab flow that hands a one-time can still be shown so the login screen can direct users to "sign in with … on the website."
code back to the app's fixed callback (`runicgateway://auth/callback`), exchanged for the *existing* - *Possible later enhancement (out of v1):* true in-app SSO via a Custom-Tab flow that hands a
mobile bearer tokens. It **extends** the existing `/auth/sso/*` redirect flow rather than adding a one-time code back to an app link, exchanged for mobile tokens — a small new backend endpoint. Only
parallel auth path — same PKCE-vs-IdP, same link-only + opt-in-provisioning policy, same TOTP gate, build it if password-for-SSO-users proves too clunky.
same token shape as `/auth/mobile/login`. The bridge adds a **second** PKCE layer (app ↔ website)
and an app-generated `state` (CSRF, verified by the app before exchange). Backend + docs land first
(this document's canonical API ref is `../website/BACKEND_DESIGN.md` → "Mobile SSO Authorization
Bridge"); the native app client is M9. Custom-scheme callback only for now — App Links are deferred
(see [`APP_LINKS.md`](./APP_LINKS.md)).
### 4.3 Session model (all paths) ### 4.3 Session model (all paths)
- **Refresh:** `POST /auth/mobile/refresh` `{ refreshToken }` → new pair. **Single-use / rotated:** store - **Refresh:** `POST /auth/mobile/refresh` `{ refreshToken }` → new pair. **Single-use / rotated:** store
@@ -648,10 +352,6 @@ Guidelines:
## 6. Screen ↔ endpoint map ## 6. Screen ↔ endpoint map
> **Path note:** endpoints below are `/api/v1`-relative and stay that way. The website's router
> refactor preserves every URL, and the `/api/mobile` facade that would have renamed them is deferred —
> see [`../website/API_V2_PLAN.md`](../website/API_V2_PLAN.md) § Deferred: the `/api/mobile` facade.
### 6.1 Public content ### 6.1 Public content
- **Home/Status** — `GET /public/status`, `GET /public/settings` (branding + maintenance banner). - **Home/Status** — `GET /public/status`, `GET /public/settings` (branding + maintenance banner).
- **News hub** — `GET /public/posts/:category` (`news | five-on-friday | newsletter | screenshots`), - **News hub** — `GET /public/posts/:category` (`news | five-on-friday | newsletter | screenshots`),
@@ -683,18 +383,11 @@ Guidelines:
Player self-service is under `/player/account/*` (gated to `role='player'`) and staff use the *same* Player self-service is under `/player/account/*` (gated to `role='player'`) and staff use the *same*
handlers under `/admin/account/*`. Rather than have the app branch by role (and touch `/admin`), we handlers under `/admin/account/*`. Rather than have the app branch by role (and touch `/admin`), we
**add a role-agnostic self surface under `/auth/**`** — the canonical "me" endpoints for every role. **add a role-agnostic self surface under `/auth/**`** — the canonical "me" endpoints for every role.
The app calls these regardless of role. This is an **additive v1** The app calls these regardless of role, and never references `/admin`. This is an **additive v1**
change (see §8): the existing `/player/account/*` and `/admin/account/*` routes stay for web change (see §8): the existing `/player/account/*` and `/admin/account/*` routes stay for web
back-compat; `/auth/me/*` reuses the same `account.controller` handlers behind `requireAuth` (any back-compat; `/auth/me/*` reuses the same `account.controller` handlers behind `requireAuth` (any
authenticated role), so there's no logic duplication. authenticated role), so there's no logic duplication.
> **M10 update (2026-07-21):** *self-service* stays role-agnostic under `/auth/me/*` as above. Separately,
> the **operational** admin subset (§1, §10 — moderation, support queue, dashboard/site-mode, content)
> *does* call `/api/v1/admin/**` directly, gated to staff by a new `STAFF`/`ADMIN` menu access level.
> The earlier "the app never references `/admin`" rule is superseded for these defined staff operations
> only; `validateSession` accepts the mobile bearer on those routes and `requireRole` re-checks every
> request, so a demoted user loses the surface immediately (the app treats any `403` as authoritative).
--- ---
## 7. Degradation & offline ## 7. Degradation & offline
@@ -764,12 +457,6 @@ maintenance cost. Reserve v2 for a real breaking re-shape if one ever arises.
3. **Push notifications** — see §11. Additive v1 endpoints under `/auth/me/devices*` and 3. **Push notifications** — see §11. Additive v1 endpoints under `/auth/me/devices*` and
`/auth/me/notifications*`, plus a **self-hosted `ntfy` service added to `website/docker-compose.yml`** `/auth/me/notifications*`, plus a **self-hosted `ntfy` service added to `website/docker-compose.yml`**
with fully declarative, zero-interaction config. Not required for the first release (M7, not M1M6). with fully declarative, zero-interaction config. Not required for the first release (M7, not M1M6).
✅ **Backend + docs LANDED (2026-07-20, RunicGateway/website#78 merged (+ docs#20)).** The
contract Part 1 is built: the two tables, the stream catalog + `PUBLIC_KINDS`-gated event mapping,
the content-free-tickle fan-out (`utils/pushDispatch`, SSRF-guarded endpoints, owner-keyed personal
streams), the six `/auth/me/*` routes (Swagger regenerated), and the declarative `ntfy` compose
service (247 server tests green). The app (Part 2, §9 M7) consumes this next — see the "M7 plan"
block for the detailed Part 2 plan.
4. **Version/health surfacing.** 4. **Version/health surfacing.**
✅ **DONE (2026-07-19, RunicGateway/website#77 (+ this docs PR)).** A dependency-free ✅ **DONE (2026-07-19, RunicGateway/website#77 (+ this docs PR)).** A dependency-free
`config/version.js` (`{ service:'runic-gateway', api:'v1', server:<pkg> }`) is surfaced on `config/version.js` (`{ service:'runic-gateway', api:'v1', server:<pkg> }`) is surfaced on
@@ -826,72 +513,15 @@ push, and Play (M6M8) follow the designed app.
7. **M6 — Polish & release mechanics**: settings (server switch = hard reset, done M3), 7. **M6 — Polish & release mechanics**: settings (server switch = hard reset, done M3),
version-mismatch guard, release build hardening (HTTPS-only, no token logging, R8 minify + resource version-mismatch guard, release build hardening (HTTPS-only, no token logging, R8 minify + resource
shrink, release signing). No offline cache in v1 (§7). **Ships v1 as a signed APK attached to a Gitea shrink, release signing). No offline cache in v1 (§7). **Ships v1 as a signed APK attached to a Gitea
release** via `release.yml` on a `v*` tag (see §10). Biometric app-lock **descoped** (below). release** via `release.yml`'s conventional-commit engine on merge to `main` (see §10, §12).
Biometric app-lock **descoped** (below).
**Landed** 2026-07-20 (`RunicGateway/Android-app#11`). **Landed** 2026-07-20 (`RunicGateway/Android-app#11`).
8. **M7 — Push notifications** (post-v1): add the self-hosted `ntfy` service to 8. **M7 — Push notifications** (post-v1): add the self-hosted `ntfy` service to
`website/docker-compose.yml` (declarative, zero-interaction config), UnifiedPush integration in the `website/docker-compose.yml` (declarative, zero-interaction config), UnifiedPush integration in the
app, device registration, the subscriptions UI, and the content-free-tickle backend fan-out (see app, device registration, the subscriptions UI, and the content-free-tickle backend fan-out (see
§11). The app is built with room for this from M0 but it does not gate the first release. §11). The app is built with room for this from M0 but it does not gate the first release.
✅ **Both parts landed** 2026-07-20 — Part 1 backend (`RunicGateway/website#78` merged + docs#20),
Part 2 app (`RunicGateway/Android-app#15`) + a small `push.ntfyUrl` settings addition
(`RunicGateway/website#79`). The app embeds its own ntfy distributor (a foreground-service SSE
connection, no second app, no Google Play Services, no UnifiedPush library); see the "M7 plan"
Part 2 block for the recorded transport + backend-field decisions. Push delivers once the shard sets
its `NTFY_*` deploy config (§13).
9. **M8 — Google Play**: Play Console listing, signing/upload key, and (optionally) an FCM build flavor 9. **M8 — Google Play**: Play Console listing, signing/upload key, and (optionally) an FCM build flavor
— after the direct-APK release is stable. — after the direct-APK release is stable.
10. **M9 — Native SSO login** (post-v1, additive; independent of M8): in-app "Sign in with Google /
Discord" via the **Mobile SSO Authorization Bridge** (§4.2). **Backend-first**, mirroring M7's
split:
- **Part 1 — backend + docs (in progress):** `mobile_auth_sessions` + `mobile_auth_codes` bridge
tables; `GET /auth/mobile/sso/start` (seeds a bridge session, reuses the existing SSO redirect
tagged `mode:'mobile'`); a mobile branch in the SSO callback + TOTP-completion that mints a
single-use, hashed, PKCE-bound authorization code and redirects to the fixed app callback instead
of setting a cookie; `POST /auth/mobile/sso/exchange` (code + PKCE verifier → the existing mobile
bearer token pair); an exact-match redirect-URI allowlist; boot-time + opportunistic cleanup of
the bridge tables. Reuses `GET /auth/providers` for discovery and `POST /auth/mobile/{refresh,
logout}` unchanged. See `../website/BACKEND_DESIGN.md`.
- **Part 2 — app client:** register the `runicgateway://auth/callback` intent-filter; generate
`code_verifier`/`code_challenge` + `state`; open the Custom Tab at `/auth/mobile/sso/start`;
verify `state` on the callback; `POST …/exchange`; store the returned pair in the existing
`TokenStore` (M3). No new token-storage or refresh code — it feeds the M3 session machinery.
- **Follow-up — App Links (opt-in hardening on top of Part 2):** a website `GET
/.well-known/assetlinks.json` route behind the `mobile_app_links_enabled` admin toggle, a
self-origin HTTPS entry added to the redirect-URI allowlist when enabled, and an app-side
`autoVerify` intent-filter for `https://<host>/mobile/callback` driven by a **build-time**
`appLinkHost` (a single multi-tenant APK cannot autoVerify open-ended shard domains, so the
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.
### Deferred (not a milestone)
- **`/api/mobile` facade migration + app-version floor** — briefly planned as M11 (2026-07-22), now
**deferred with no app work scheduled**. The website's router refactor is being done in place with
every URL byte-identical and `/api/v1` is not being retired, so the app's ~70 hardcoded `api/v1/…`
endpoints, its SSE path, and its SSO URLs keep working untouched. If the mobile contract ever needs
to diverge from web, the migration comes back — starting from a one-line alias mount on the server,
not a hand-written delegate layer. Reasoning and revival triggers:
[`../website/API_V2_PLAN.md`](../website/API_V2_PLAN.md) § Deferred: the `/api/mobile` facade.
--- ---
@@ -914,10 +544,6 @@ first release. Users **opt in per stream**: nothing is pushed unless subscribed.
- **Primary: UnifiedPush, delivered by a self-hosted `ntfy` service added to the website's - **Primary: UnifiedPush, delivered by a self-hosted `ntfy` service added to the website's
`docker-compose.yml`.** FOSS, no Google Play Services dependency, works for the sideloaded APK on any `docker-compose.yml`.** FOSS, no Google Play Services dependency, works for the sideloaded APK on any
device, and keeps delivery under the org's own infrastructure — consistent with the self-hosted ethos. device, and keeps delivery under the org's own infrastructure — consistent with the self-hosted ethos.
- **The app embeds its own distributor — no second app (decided; see M7 Part 2 work item 1).** ntfy is
purely the relay *server*; the Runic Gateway app receives notifications itself via an in-app embedded
UnifiedPush distributor (a foreground-service persistent connection to the shard's ntfy). The user
installs one APK — never a separate distributor app — and no Google Play Services is involved.
- **FCM stays optional and Play-only.** If/when a Play build wants it, add FCM as a **build flavor**; - **FCM stays optional and Play-only.** If/when a Play build wants it, add FCM as a **build flavor**;
the direct-APK flavor stays Google-free. The backend fan-out is **transport-agnostic** and dispatches the direct-APK flavor stays Google-free. The backend fan-out is **transport-agnostic** and dispatches
to whatever endpoint a device registered, so adding FCM later touches no core logic. to whatever endpoint a device registered, so adding FCM later touches no core logic.
@@ -928,11 +554,8 @@ first release. Users **opt in per stream**: nothing is pushed unless subscribed.
path/tag at implementation. path/tag at implementation.
- **All config is declarative** — a committed `ntfy` config file and/or `NTFY_*` env vars baked into - **All config is declarative** — a committed `ntfy` config file and/or `NTFY_*` env vars baked into
compose. No `docker exec`, no interactive `ntfy user add`, no post-deploy manual steps. Bringing the compose. No `docker exec`, no interactive `ntfy user add`, no post-deploy manual steps. Bringing the
stack up provisions a working push relay. Reachable via the existing reverse proxy on its own stack up provisions a working push relay. Reachable to devices via the existing reverse proxy on its
hostname/path — the container publishes `:80` on a host port (`NTFY_HOST_PORT`, default `2586`) own hostname/path; internal-only for the backend publisher.
because the proxy runs *outside* the compose network and can only reach a service through a
published host port (the same reason `app` publishes `3000`). Both devices and the backend publisher
reach ntfy on that public origin.
- **No per-user ntfy accounts to administer.** The security model (below) removes the need for ntfy ACL - **No per-user ntfy accounts to administer.** The security model (below) removes the need for ntfy ACL
provisioning, which is exactly what keeps setup interaction-free. ntfy topics are the random, provisioning, which is exactly what keeps setup interaction-free. ntfy topics are the random,
unguessable endpoints UnifiedPush hands out; the backend treats ntfy as an **untrusted relay**. unguessable endpoints UnifiedPush hands out; the backend treats ntfy as an **untrusted relay**.
@@ -980,17 +603,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 write to `/auth/me/notifications/subscriptions`. Tapping a notification deep-links to the relevant
screen (§ open item below). 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) ## 12. Build & CI (Gitea Actions)
Builds run on the org's existing self-hosted runners (`runs-on: ubuntu-latest`, same label the other Builds run on the org's existing self-hosted runners (`runs-on: ubuntu-latest`, same label the other
@@ -1003,38 +615,16 @@ repos use), on a bare `ubuntu:latest` container.
frequent: run the job under a prebuilt Android-SDK `container:` image so nothing installs per-run.) frequent: run the job under a prebuilt Android-SDK `container:` image so nothing installs per-run.)
- **PR gate** (`.gitea/workflows/pr-checks.yml`, on PR → `main`): `./gradlew lint test assembleDebug`. - **PR gate** (`.gitea/workflows/pr-checks.yml`, on PR → `main`): `./gradlew lint test assembleDebug`.
Debug builds are auto-signed, so the gate needs no secrets. Mirrors `website/`'s pre-merge gate. Debug builds are auto-signed, so the gate needs no secrets. Mirrors `website/`'s pre-merge gate.
- **Release** (`.gitea/workflows/release.yml`, M6+): build a **signed release APK** and attach it to a - **Release** (`.gitea/workflows/release.yml`, M6): mirrors `link/`'s release engine — on every push to
Gitea release (mirrors `link/`'s release job). The **keystore is a base64 Gitea Actions secret** `main` it computes the next version from conventional commits since the last `v*` tag, generates a
decoded in CI; store/key passwords are secrets. The keystore never lives in the repo. Keep the changelog, bumps `build.gradle.kts`, builds a **signed release APK**, commits the bump `[skip ci]`,
signing identity stable from the first release (Play later requires consistency). tags `vX.Y.Z`, and creates the Gitea release with the notes + APK + `SHA256SUMS`. The **keystore is a
- Semantic `versionName` + monotonic `versionCode`; tag releases. base64 Gitea Actions secret** decoded in CI (`ANDROID_KEYSTORE_BASE64`); store/key passwords + alias
are secrets too. The keystore never lives in the repo. `REGISTRY_USER`/`REGISTRY_TOKEN`
### 12.1 Code quality — SonarQube (`Runic-Gateway-Android-app`) (`write:repository`) push the bump + create the release, so `main` must allow that account to push.
Keep the signing identity stable from the first release (Play later requires consistency).
Analysis runs post-merge and non-blocking (`.gitea/workflows/sonarqube.yml`); the gate is - Semantic `versionName` (bumped by the engine) + derived monotonic `versionCode`
informational. The project is clean (0 bugs / 0 vulns / 0 hotspots, Maintainability **A**); the only (`major*10000+minor*100+patch`); the engine tags each release.
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) ## 13. Open questions (revisit as we go)
@@ -1043,8 +633,7 @@ password+TOTP only, with registration/invite/reset/SSO **handled by the website*
reset built on backend + web first**, before app work (§8); minSdk 29, compile/target 35 (§2); no reset built on backend + web first**, before app work (§8); minSdk 29, compile/target 35 (§2); no
telemetry in v1 (§2); strings externalized from day one, English-only bundled (§2); **text-only** game telemetry in v1 (§2); strings externalized from day one, English-only bundled (§2); **text-only** game
data in v1, pretty paperdoll is future (§6.3); **no offline cache in v1** (§7); push via self-hosted data in v1, pretty paperdoll is future (§6.3); **no offline cache in v1** (§7); push via self-hosted
ntfy / UnifiedPush (§11) with the **distributor embedded in the app — no second app to install** ntfy / UnifiedPush (§11); **biometric app-lock descoped from v1** (tokens already encrypted at rest, so
(M7 Part 2 work item 1); **biometric app-lock descoped from v1** (tokens already encrypted at rest, so
it is a UX convenience, not a v1 requirement — deferred at M3, descoped at M6; revisit only if requested). it is a UX convenience, not a v1 requirement — deferred at M3, descoped at M6; revisit only if requested).
**Still open:** **Still open:**
@@ -1052,22 +641,10 @@ it is a UX convenience, not a v1 requirement — deferred at M3, descoped at M6;
`runicgateway.app` domain (needed for a verified app-link host and a matching package namespace). Also `runicgateway.app` domain (needed for a verified app-link host and a matching package namespace). Also
the fixed launcher name (baked at build even though in-app branding is per-shard — one APK, any shard). the fixed launcher name (baked at build even though in-app branding is per-shard — one APK, any shard).
Since SSO/invite/reset are website-handled, the app mostly *opens* website URLs rather than needing its Since SSO/invite/reset are website-handled, the app mostly *opens* website URLs rather than needing its
own verified app links. **App Links resolved (M9 follow-up):** the SSO callback is the one place a own verified app links — confirm whether any deep-link-back is wanted at all for v1.
verified deep-link-back helps; the server side (`assetlinks.json` + toggle) ships for any shard, but - ntfy: exact upstream image + pinned tag, its reverse-proxy hostname/path, and whether to add a
the app-side `autoVerify` needs a **literal build-time host**, so it is a white-label/first-party build backend publish token (optional hardening — the content-free-tickle design does not require one).
opt-in (`-PappLinkHost=<host>`) — the generic multi-tenant build stays custom-scheme. A canonical - FCM flavor: build it for the Play release or ship Play on UnifiedPush too? Decide at M8.
`runicgateway.app` relay host, if secured, would let the generic build autoVerify one central domain. - Deep-link / share targets for wiki pages, posts, and notification taps.
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
`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.)
- Deep-link / share targets for wiki pages and posts (share/open-in-app). *Notification-tap* deep-links
are **resolved** for M7 Part 2 (stream→screen map, work item 7).
- iOS: none planned (this is the Android-only choice); revisit only if cross-platform is later - iOS: none planned (this is the Android-only choice); revisit only if cross-platform is later
required (would change §2 — and push, which would then favor a cross-platform transport). required (would change §2 — and push, which would then favor a cross-platform transport).

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

@@ -1,59 +0,0 @@
# SonarQube static analysis
Each code repo in the Runic Gateway org reports static-analysis results to the
self-hosted **SonarQube** server for review. Analysis is **non-blocking**: it
runs on push to `main` (i.e. *after* merge), never on pull requests, so it never
gates a PR. It complements each repo's PR gate and release pipeline — it only
feeds the dashboard.
## Server
- **URL:** `https://sonar.whitlocktech.com`
- Each repo is a separate SonarQube project, keyed as below.
## Projects
| 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 |
## How it's wired
Each repo carries two files, identical in shape across repos:
- **`sonar-project.properties`** (repo root) — declares the project key, sources,
tests, and exclusions. The Sonar scanner reads this.
- **`.gitea/workflows/sonarqube.yml`** — a `SonarQube` workflow that, on push to
`main` (and via manual `workflow_dispatch`), checks out with full history
(`fetch-depth: 0`, needed for accurate blame + "new code") and runs
`sonarsource/sonarqube-scan-action@v4`.
The scan is **source-based** — it does not build the project or run a language
toolchain, so the workflows are lightweight (checkout + scan only). Richer
signals (Rust Clippy, Android Lint, JaCoCo coverage) are left as documented,
commented-out enrichment in each repo's `sonar-project.properties`; enable them
per repo when wanted.
## One-time setup per repo (Gitea UI → Repo → Settings → Actions)
Both are consumed by the scan action via `env:` in the workflow:
- **Secret `SONAR_TOKEN`** — a SonarQube *Analysis* token (My Account →
Security in SonarQube; project-scoped or global).
- **Variable `SONAR_HOST_URL`** — the SonarQube base URL reachable from the
self-hosted runner. Kept as a **variable, not committed**, so the internal
address stays out of git.
The self-hosted `ubuntu-latest` runner must be able to reach `SONAR_HOST_URL` on
the network. Nothing waits on the SonarQube Quality Gate, so a failing gate does
not fail the job — check the dashboard.
## Adding a new repo
1. Create the project in SonarQube; note its key.
2. Add `sonar-project.properties` (copy an existing repo's, adjust key + sources).
3. Add `.gitea/workflows/sonarqube.yml` (copy verbatim — it's language-agnostic).
4. Set the `SONAR_TOKEN` secret and `SONAR_HOST_URL` variable in the repo's
Gitea Actions settings.

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,643 +0,0 @@
# Website API — router domain split + CSP hardening
Status: **in progress** — PR 0 (route manifest), CSP report-only, and split PRs 12 of 5 have landed ·
Target repo: `website/` · Docs owner: this file + `BACKEND_DESIGN.md`
> **This file replaces the earlier "API v2" plan** (auth merge → CSP → domain split, with a parallel
> `/api/v2` mount and an `/api/mobile` facade). Three of those four pieces are **not being built**:
> the auth merge and the mobile facade are deferred with their reasoning recorded below, and the
> parallel-version scaffold in [API_V2_SKELETON.md](./API_V2_SKELETON.md) is superseded. The filename
> is kept so existing links resolve. What remains is genuinely useful work:
>
> 1. **CSP hardening** — small, independent, ships on its own cadence.
> 2. **The domain split** — `admin.routes.js` (1552 lines, 110 routes) broken into one router file per
> business capability, **in place, with every URL unchanged**. This is the actual driver.
---
## Why the auth merge is out
The original plan replaced httpOnly session cookies with a bearer JWT + rotating refresh token for
every client, so web and mobile would share one session model. Reasons that no longer hold up:
1. **The current model is the more secure one.** httpOnly + SameSite cookies are unreadable from JS
and carry CSRF protection by default. Every migration target is a sideways or backwards move:
- Refresh token in `localStorage` → any XSS becomes **persistent full account takeover**, not a
bounded access-token window. A short access TTL does not help; the attacker mints new pairs.
- Refresh token in an httpOnly cookie scoped to the refresh endpoint → safe, but that is
*cookies with extra steps*. It concedes the premise.
2. **"One session model everywhere" is already true where it matters.** `auth/session.service.js`
unifies cookie and bearer into a single session, and `auth/token.js` already extracts from either
`Cookie` or `Authorization: Bearer`. That abstraction is written, working, and paid for. The merge
would move complexity *out* of `extractToken` and *into* the SPA.
3. **The cookie codepath survives the merge anyway.** The SSO / email-connect redirect flow must keep
its short-lived httpOnly tx / PKCE-verifier / pending-TOTP cookies — the browser leaves for the
IdP and returns with no JS context. So the merge never actually delivered "cookies are gone."
4. **It carried the plan's most bug-prone work as a dependency.** The admin SSE rewrite
(`EventSource` → hand-rolled `fetch` + `ReadableStream` + SSE frame parser + reconnect/backoff +
refresh-on-401) existed *only* to serve the bearer model. Without the merge, the admin stream stays
on `EventSource` with `withCredentials` and that code is never written.
5. **It is a contract change with no user-visible payoff**, competing for the same review attention as
the domain split, which is the thing that actually hurts today.
### Dropped with it
- v2 bearer auth routes (`/api/v2/auth/{login,refresh,logout,login/totp}`).
- Deletion of the `setAuthCookie` / `clearAuthCookie` path.
- `rg_trust` cookie → `X-Trust-Token` header migration for web (the header stays available for native
clients via `extractTrustToken`, unchanged).
- `api/client.js` bearer + silent-refresh rewrite.
- `lib/useShardFeed.js` admin-stream fetch rewrite. **The admin SSE stream stays as-is.**
### Kept from it
- **CSP hardening** — now its own phase (below). It was justified as a compensating control for a
JS-held token; it is worth doing regardless, just no longer urgent.
- **The public/admin SSE allowlist split** — unchanged security boundary, unrelated to session model.
- **The tx-cookie carve-out reasoning** — recorded here so a future merge attempt doesn't rediscover it.
---
## Deferred: the auth merge
Not cancelled — parked behind trigger conditions. Revisit if **any** of these become true:
| Trigger | Why it changes the answer |
|---|---|
| The API becomes genuinely cross-origin (separate API host) | `SameSite` cookies stop being the easy path; bearer becomes the natural model. |
| Third-party or OAuth clients are introduced | Cookies don't serve clients you don't control. |
| Mobile and web session behavior diverge enough to cause real bugs | The unification argument gets teeth it currently lacks. |
If it is ever revived, two specs the original plan lacked must be written **first**:
- **Refresh-token reuse detection.** Rotation is only useful with it: replay of an already-consumed
refresh must revoke the entire token family, not just fail the one request.
- **Rollback procedure.** Once web clients have discarded their cookies, a bad deploy locks everyone
out. Needs a documented path back.
---
## Why there is no `/api/v2`
The domain split reorganizes router *files*. It does not need to move a single URL — because the URL
surface is **already grouped by capability**. Inventory taken from the live Express stack — 196
`/api/v1` routes, plus three outside it (`GET /api/health`, `GET /api/docs.json`,
`GET /.well-known/assetlinks.json`) and 2 on the internal port. Full machine-readable list:
[`api-route-inventory.json`](./api-route-inventory.json).
| Group | Routes | Second segment → capability |
|---|---|---|
| `/admin` | 110 | `shard` 16 · `moderation` 15 · `users` 15 · `wiki` 14 · `posts` 9 · `pages` 7 · `account` 6 · `email` 6 · `uo-link` 5 · `auth` 4 · `invites` 3 · `bot-activity` 2 · `discord-bot` 2 · `settings` 2 · `activity` 1 · `dashboard` 1 · `site-mode` 1 · `uploads` 1 |
| `/auth` | 42 | `me` 23 · `mobile` 5 · `sso` 4 · `password` 3 · `invite` 2 · `login` 2 · `logout` 1 · `providers` 1 · `register` 1 |
| `/public` | 24 | `shard` 12 · `wiki` 4 · `pages` 2 · `posts` 2 · `contact` 1 · `settings` 1 · `status` 1 · `version` 1 |
| `/player` | 20 | `account` 8 · `shard` 8 · `appeals` 4 |
Every capability already owns a URL prefix, so each new router file mounts at the prefix it already
owns and the emitted paths are **byte-identical**. No URL change means no contract change, and no
contract change means no reason to mount a parallel version.
Consequences of doing it in place:
- No `/api/v2`, no dual mount, no route-by-route migration, no v1-usage telemetry project, and no
v1-retirement sequence.
- `BASE = /api/v1` in `client/src/api/client.js` never changes. The Discord bot's `SITE_PUBLIC_URL`
never changes. The Android app is untouched.
- [API_V2_SKELETON.md](./API_V2_SKELETON.md) (the `router/v2/` scaffold) is **superseded and not
scheduled**. It is kept as the concrete recipe if a real contract break ever forces a versioned API.
**Tripwire:** if any endpoint turns out to *need* a new URL, that is a contract change, not a
refactor. List it explicitly, and reopen the versioning question before writing the code — do not
smuggle a URL change into a "mechanical" PR.
---
## Deferred: the `/api/mobile` facade and the app-version floor
The earlier plan's Phase 0 stood up a version-agnostic `/api/mobile` namespace and migrated the
Android app onto it, plus an app-version header and a server-side min-version floor.
**Why it was proposed:** the app hardcodes **69** distinct `api/v1/…` paths (`data/api/*.kt`,
`core/net/ShardStreamClient.kt`, `core/net/HostSelectionInterceptor.kt`,
`core/auth/sso/SsoAuthManager.kt`), has no version negotiation and no force-update, and installs in
the wild cannot be forced forward. Under a parallel-`/api/v2` plan that made the app the load-bearing
coupling: v1 could not be retired until the fleet aged out.
**Why it is deferred:** with the split done in place, no URL moves and nothing is being deleted — so
there is no fleet to sunset and no coupling to break. A facade would add ~70 permanently maintained
delegate routes plus a contract-test suite to solve a problem that does not currently exist. The
version floor was scoped to sunsetting the pre-facade fleet, so it goes with it.
**If it is ever revived** (the trigger is the mobile contract genuinely needing to diverge from web —
different response shapes, a mobile-only aggregation endpoint, a real breaking change):
- **Start with the alias mount, not a delegate layer:** `apiRouter.use('/mobile', v1Router)` gives the
app a stable, version-agnostic namespace with identical wiring, identical middleware and zero
per-route maintenance. Build hand-written delegates only for the routes that actually diverge.
- **A facade is a security surface, not a convenience alias.** Any hand-written route must carry the
*same* middleware chain as the route it mirrors (`requireAuth`, `staffOnly`/`adminOnly`, validators,
the public/admin SSE allowlist split). A re-exposed admin route missing `adminOnly` is privilege
escalation.
- **It needs contract tests.** The moment the app pins a namespace, its response shapes are a
committed contract; an internal refactor that changes a shape must fail a test before it ships to
installed apps.
- **The mobile SSE stream stays anonymous** under whatever path it gets — the app sends no
`Authorization` header.
---
## Phase 1 — CSP hardening (independent)
Previously bundled with the auth merge as a compensating control for a JS-held token. With no token in
JS, this is **defense in depth on its own merits** — cheap, worth doing, blocking nothing. It has no
dependency on the domain split and can ship at any time.
**Sequencing fix from the original plan:** the old version both "ships with the auth merge" and called
for a one-release report-only soak. Those contradict. Correct order is **report-only first, observe one
release, then enforce** — now trivially satisfiable since nothing waits on it.
The app already ships a tuned policy (`server/src/app.js`). Two directives are load-bearing and are
**already correct** — the job is to keep them that way:
- `script-src 'self'` — no `'unsafe-inline'` / `'unsafe-eval'`. Primary defense.
- `connect-src 'self'` — the exfiltration channel. Don't widen it unless the API genuinely becomes
cross-origin (which would also reopen the auth-merge question — see the trigger table).
`style-src 'unsafe-inline'` stays — it permits inline styling, not script execution, and React's
pervasive `style={{…}}` attributes can't be nonce'd. Not a meaningful hole. The `/api/docs` route keeps
its deliberately looser policy (swagger-ui injects an inline bootstrap script); that carve-out is
scoped to the one route and stays scoped.
Target enforced policy:
```
default-src 'self';
script-src 'self';
connect-src 'self';
img-src 'self' data: https:;
style-src 'self' 'unsafe-inline'; /* + fonts.googleapis.com only until fonts are self-hosted */
font-src 'self'; /* + fonts.gstatic.com only until fonts are self-hosted */
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
```
Delta vs. the policy in `server/src/app.js` today. This was written as two directives; on
implementation it turned out to be **one**:
- ~~**Add `form-action 'self'`** (currently absent)~~ — **it was not absent.** The directives object in
`app.js` does not list it, but the middleware is configured `useDefaults: true`, and helmet's default
set already supplies `form-action 'self'` — so the header served in production has carried it all
along. Verified by capturing the live `Content-Security-Policy` header from the running app rather
than reading the config, which is how the plan got this wrong. **No behavioural change here.** It is
now written out explicitly in `config/csp.js` anyway: a security directive should not depend on a
third-party library's defaults surviving its next major version.
- **Tighten `frame-ancestors`** `'self'``'none'` — nothing legitimately frames the site. **This is
the entire behavioural delta of the phase.**
- Unchanged: `default-src`, `script-src`, `connect-src`, `object-src 'none'`, `base-uri 'self'`, and
`img-src … https:` (external `BRAND_*` logo/hero and `<img>` in sanitized wiki/news bodies rely on
`https:`).
The soak is still worth running for that one directive, and arguably it is the directive that most
needs one: a `frame-ancestors` report is generated by the browser of *whoever framed the site*, so it
is the only way to discover that something legitimately embeds us before the enforcing policy breaks
it. Nothing else can tell us that.
**Rollout:** ship via `Content-Security-Policy-Report-Only` with `report-to` for one release, watch for
violations, then flip to enforce.
Before trusting `script-src 'self'`: Vite's build injects an inline modulepreload-polyfill `<script>`
into `dist/index.html`, which that directive blocks (harmless, but throws a violation).
**Already handled**`client/vite.config.js` sets `modulePreload: { polyfill: false }`, so the build
emits no inline bootstrap script. (The `renderIndexHtml` branding injection adds only `<meta>`/`<link>`
tags — no inline script, no nonce needed.)
### Where reports go
`report-to` needs somewhere to point, so the report-only PR stands up a same-origin sink:
**`POST /api/csp-report`** (`server/src/router/cspReport.controller.js`, wired in `app.js`). Same-origin
on purpose — violation reports describe attacks against this site and are not handed to a third-party
collector. It writes to the `csp` log tag and stores nothing.
It is mounted outside `/api/v1`, alongside `/api/health`: the browser learns the path from the policy
header, never from a client build, so it is not part of the versioned client contract. This is the
`+1` in the route manifest that made PR 0 go first (see § Sequencing).
Necessary properties, since it is an unauthenticated public `POST` (browsers send reports with no
session, and gating it would silence exactly the anonymous visitors worth hearing about):
- **Both wire formats.** `report-uri` (Firefox, Safari) sends `application/csp-report` with a single
hyphenated-key object; `report-to` (Chrome) sends `application/reports+json` with an array of
camelCase envelopes. Handling one silently drops half the browsers. Both directives are emitted, and
`report-to` additionally needs a `Reporting-Endpoints` response header or it is inert.
- **Always 204, even for junk.** A 4xx would make the global error handler write an ERROR line quoting
the attacker-supplied body — turning an open endpoint into a log-flood primitive. A browser cannot
act on an error from a report sink anyway.
- **Bounded everywhere:** 16 KB body cap, a per-IP rate limit, a fixed field allowlist, and every
logged field truncated (`script-sample` is attacker-influenced and can carry a whole inline script).
**Retiring it:** the sink exists for the soak. When the tightened policy flips to enforced and the
report-only twin is deleted, this endpoint goes with it — *unless* a `report-to` group is deliberately
kept on the enforced policy, which is a reasonable thing to want. Decide that in the enforce PR rather
than leaving an orphan route behind.
Tracked follow-ups (own PRs):
- **Self-host the Cinzel font** → drop `fonts.googleapis.com` from `style-src` and `fonts.gstatic.com`
from `font-src`, removing two third-party origins from the trust surface.
- **Trusted Types** — `require-trusted-types-for 'script'` + a `trusted-types` policy, report-only
first. Audit `dangerouslySetInnerHTML` + the `sanitizeHtml` render path first.
---
## Phase 2 — The domain split
**This is the reason the plan exists.** Everything else is supporting work.
**Rule:** one router file = one business capability; the URL names the domain; related endpoints live
together regardless of HTTP method; no generic `admin.routes.js` catch-all. Controllers are **already**
domain-split — this re-wires routes, not logic.
**Invariant:** each capability router mounts at the prefix it already owns, so the emitted URL set does
not change. Proved per PR by the route manifest (§ PR 0).
Target tree — derived from the inventory above, **inside `router/v1/`** (no `v2/` directory):
```
router/v1/
admin/
index.js # mounts the capability routers below under /admin, keeps the
# shared `noindex, isLoggedIn, staffOnly` gate in one place
users.router.js account.router.js invites.router.js
authProviders.router.js moderation.router.js botActivity.router.js
posts.router.js pages.router.js wiki.router.js
uploads.router.js shard.router.js uoLink.router.js
email.router.js discordBot.router.js settings.router.js
activity.router.js # the staff audit log — landed in PR 2, not with dashboard
dashboard.router.js # + the /site-mode singleton
auth/
login.router.js register.router.js password.router.js invite.router.js
sso.router.js mobile.router.js me.routes.js (already split, 23 routes)
public/
news.router.js (posts) pages.router.js wiki.router.js shard.router.js
site.router.js # status, settings, version, contact
player/
account.router.js shard.router.js appeals.router.js
internal/ (unchanged — stays on the unpublished port, never mounted publicly)
```
Steps:
1. **Land PR 0 (route manifest) first** — the mechanical proof that later PRs move no URL.
2. Carve `admin.routes.js` into the per-capability files above, each requiring its already-existing
controller. `admin/index.js` keeps the shared gate (`noindex, isLoggedIn, staffOnly`) and mounts
each capability router at its existing prefix; the `adminOnly` / `modAccess` gates move with the
routes that use them.
3. Split `public.routes.js`, `player.routes.js`, and the remaining `auth.routes.js` groups the same
way. `auth/me.routes.js` is already a separate file and stays.
4. Keep `/internal` off the public listener exactly as today (separate `internalApp.js` port).
5. Move each route's `#swagger.*` annotations **with** the route, then regenerate
(`cd website/server && npm run swagger`).
6. Update `BACKEND_DESIGN.md` §2 (folder structure) and §4 (API contract — it names
`admin.routes.js → admin.controller.js` and friends) as routers move, plus `PROJECT_TREE.md`.
Because the auth model is untouched and the URLs are frozen, each PR is a **pure mechanical refactor
with a green test suite and a zero-diff route manifest as its acceptance criteria** — which is what
makes grouped PRs actually reviewable.
> **Step 6 correction:** `PROJECT_TREE.md` is no longer hand-edited. Since website#98 it is
> auto-generated by the `sync-project-tree` CI workflow, which opens its own docs PR after a merge to
> `main`. Leave it alone in split PRs. `BACKEND_DESIGN.md` §2/§4 are still manual.
### PR 1 — as landed
`admin/index.js` owns the shared `noindex, isLoggedIn, staffOnly` gate and the mount table, and
declares no routes itself. The gate sits **ahead of every mount**, so a capability router extracted in
a later PR cannot silently ship without it. Route counts:
| Router | Routes | Prefix | Extra gate |
|---|---|---|---|
| `account.router.js` | 6 | `/admin/account` | none — self-service, an editor manages their own 2FA |
| `users.router.js` | 15 | `/admin/users` | `adminOnly` at router level |
| `invites.router.js` | 3 | `/admin/invites` | `adminOnly` per route |
| `authProviders.router.js` | 4 | `/admin/auth` (routes are `/providers[/:id]`) | `adminOnly` per route |
| `admin.routes.js` (residual) | 82 | group root, mounted last | unchanged |
6 + 15 + 3 + 4 + 82 = the 110 inventoried admin routes. None of the four prefixes appears in the
residual file, so nothing depends on mount ordering.
Three findings worth carrying into PRs 25:
- **The self-service `/shard/*` routes stay with `shard` (PR 4), despite their `Admin · Account`
swagger tag.** The invariant is *prefix ownership*, not tag agreement: one router owns `/shard`, so
splitting six routes off it by capability would mean two routers mounting under the same prefix and
an ordering hazard for no gain. Retag them in PR 4 if the tag still grates.
- **`usersRouter.use(adminOnly)` is exactly equivalent to the old `adminRouter.use('/users', adminOnly)`**
now that the router is mounted at `/users` — but only because it is mounted at a prefix. Under a
*pathless* mount, a bare `use(gate)` would run for every request passing through en route to a later
mount, 403-ing an editor on `/admin/posts`. Do not "simplify" a prefix mount away.
- **`routes.guards.json` came back zero-diff too**, not just the manifest — no route lost or gained a
gate. Worth checking both every time; the guards file is the one that would catch a dropped
`adminOnly` that the method+path freeze cannot see.
The OpenAPI spec was also byte-for-byte unchanged, which required a prerequisite fix — see below.
### PR 2 — as landed
`moderation`, `bot-activity` and `activity` — 18 routes, leaving 64 in the residual file.
| Router | Routes | Prefix | Extra gate |
|---|---|---|---|
| `moderation.router.js` | 15 | `/admin/moderation` | `modAccess` (admin + moderator) at router level |
| `botActivity.router.js` | 2 | `/admin/bot-activity` | `adminOnly` per route |
| `activity.router.js` | 1 | `/admin/activity` | none — staff-wide audit log |
| `admin.routes.js` (residual) | 64 | group root, mounted last | unchanged |
All four gates came back zero-diff: `routes.manifest.json` (200 public + 2 internal),
`routes.guards.json`, `swagger-output.json` (198 operations), and `docs/website/api-route-inventory.json`
was already in sync. 434 server tests green.
Notes:
- **`/activity` gets its own file, deviating from the target tree above**, which parked it as a
singleton inside `dashboard.router.js`. It is folded into PR 2 by the sequencing list, and PR 4 is
where `dashboard` lands — so honouring the tree would have meant leaving one route in the residual
file for two PRs to satisfy a filename. It is also a genuinely separate capability: `/activity` is
the **staff audit log** (`activity.model.js`), while `/dashboard` is a stats overview and
`/bot-activity` is the botScore middleware's in-memory ban state. Three different things that read
alike. **PR 4 mounts `dashboard` and `site-mode` only.**
- **`modAccess` moved to a router-level `use`; `adminOnly` on bot-activity deliberately did not.**
Moderation was already gated by a *prefix* mount (`adminRouter.use('/moderation', modAccess)`), so
`moderationRouter.use(modAccess)` is the exact equivalent (the PR 1 `users` case). Bot-activity's
gate was per-route, and keeping it per-route is what holds the per-route handler count — the one
number in `routes.guards.json` that would catch a dropped `adminOnly`, since `requireRole(...)`
returns an anonymous arrow and never shows up by name. **Rule for PRs 35: move a gate to router
level only where it was already a prefix mount; otherwise leave it on the route.**
- **`modAccess` stays in the residual file** — the `/shard/*` in-game staff operations still use it
and do not move until PR 4. Its comment there was retargeted rather than deleted.
### PR 3 — as landed
`posts`, `uploads`, `wiki` and `pages` — 31 routes, leaving 33 in the residual file. The content tier,
and the first split PR where no gate moved at all: all four capabilities are editor-tier, so the shared
`staffOnly` in `admin/index.js` is their whole gate.
| Router | Routes | Prefix | Extra gate |
|---|---|---|---|
| `posts.router.js` | 9 | `/admin/posts` | none — editor tier |
| `uploads.router.js` | 1 | `/admin/uploads` | none — editor tier |
| `wiki.router.js` | 14 | `/admin/wiki` | none — editor tier |
| `pages.router.js` | 7 | `/admin/pages` | none — editor tier |
| `admin.routes.js` (residual) | 33 | group root, mounted last | unchanged |
All four gates zero-diff: `routes.manifest.json` (200 public + 2 internal), `routes.guards.json`,
`swagger-output.json` (198 operations), and `docs/website/api-route-inventory.json` was already in
sync. 434 server tests green.
Notes:
- **The residual 33 is exactly PR 4's list** — `shard` 16, `email` 6, `uo-link` 5, `settings` 2,
`discord-bot` 2, `dashboard` 1, `site-mode` 1. So `admin.routes.js` is deleted by PR 4, one PR
earlier than the sequencing list implies, and PR 5 touches only `public/*`, `player/*` and `auth/*`.
- **A shared module was unavoidable here, and it is the first one in the split.** The multer config
(upload dir, mimetype→extension allowlist, 8 MB cap) was defined inline in `admin.routes.js` and used
by *two* routes that this PR puts in different files: `POST /posts/upload` (→ `{image_url}`) and
`POST /uploads` (→ `{url}`). It moved to `admin/imageUpload.js` rather than being duplicated —
duplicating a security allowlist is how the two copies drift. It stays in `admin/` deliberately:
`UPLOAD_DIR` is resolved `__dirname`-relative, so relocating the file would silently repoint the
upload directory. Guard freshness is unaffected — multer's middleware is named `multerMiddleware`
wherever it is constructed, so `routes.guards.json` did not move.
- **`POST /uploads` keeps its `Admin · Posts` swagger tag**, which now disagrees with its filename. The
acceptance criterion is a byte-identical spec, so retagging is a real OpenAPI diff and does not
belong in a route-move PR. Same call as PR 1's `/shard/*` tag mismatch: fix tags in a PR that is
*about* tags.
- **The wiki router is the first one with load-bearing intra-file route order.** `/categories` and
`/tags` are literal paths that must stay ahead of `/:slug`, or `GET /admin/wiki/categories` gets
dispatched as a page whose slug is "categories". **The manifest cannot catch this — it sorts its
entries, so a reordering is invisible in all three gates.** It was verified separately by
introspecting the built router stack and asserting the last literal layer precedes the first `/:slug`
layer. Any future PR moving `/:slug`-style routes needs the same explicit check.
- **`/admin/pages` (CMS page builder) and `/admin/shard/pages` (in-game help-page queue) are unrelated
capabilities that read alike** — the latter stays with `shard` in PR 4. Same trap as PR 2's
`activity` / `dashboard` / `bot-activity` trio.
### PR 4 — as landed
`shard`, `uo-link`, `email`, `discord-bot`, `settings` and `dashboard`/`site-mode` — the whole residual
33. **`admin.routes.js` is deleted**, so the admin group is fully split and every one of its 110 routes
is declared in a capability router.
| Router | Routes | Prefix | Extra gate |
|---|---|---|---|
| `shard.router.js` | 16 | `/admin/shard` | none on the 7 self-service routes; `modAccess` per route on the 9 staff ops |
| `uoLink.router.js` | 5 | `/admin/uo-link` | `adminOnly` per route |
| `email.router.js` | 6 | `/admin/email` | `adminOnly` per route |
| `discordBot.router.js` | 2 | `/admin/discord-bot` | `adminOnly` per route |
| `settings.router.js` | 2 | `/admin/settings` | `adminOnly` per route |
| `dashboard.router.js` | 2 | **group root** (`/dashboard`, `/site-mode`) | `adminOnly` per route on `/site-mode` only |
| ~~`admin.routes.js`~~ | — | deleted | — |
16 + 5 + 6 + 2 + 2 + 2 = 33. All four gates zero-diff: `routes.manifest.json` (200 public + 2
internal), `routes.guards.json`, `swagger-output.json` (198 operations), and
`docs/website/api-route-inventory.json` was already in sync. 434 server tests green.
Notes:
- **`dashboard.router.js` is mounted at the group root, not a prefix — the one relaxation of the
"always mount at a prefix" rule, and it is deliberate.** `GET /dashboard` and `PUT /site-mode` own no
common path segment, so a prefix mount would mean two one-route files instead of the single file the
target tree calls for. It is safe **only** because the file declares no router-level middleware: a
bare `use(gate)` in a root-mounted router runs for every request passing through toward another
mount and would 403 an editor on an unrelated route (the PR 1 finding). The file says so in a
comment, because the next person to add a gate there is the one who needs to know.
- **`/shard` is the first prefix where two tiers share one router**, and it is why prefix ownership
beats swagger-tag grouping. The 7 self-service routes (`link`, `accounts`, `roster/:account`,
`vendors/:account`, `char/:serial`, `sales`, `POST account`) are tagged `Admin · Account`, run with
no gate beyond the shared `staffOnly`, and are served by the very same `player/shard.controller`
handlers as `/player/shard` — staff are a superset of players, and the controller keys off
`req.user.id`. The 9 in-game ops are tagged `Admin · Shard` and carry `modAccess`. Splitting them by
tag would put two routers under one prefix for no gain; instead one router owns `/shard` and gates
per route. The tag mismatch stays, on the PR 1 and PR 3 precedent: retagging is a real spec diff and
belongs in a PR that is about tags.
- **No gate moved to router level anywhere in this PR.** Every `adminOnly` in the residual file was
per-route, and `modAccess` on `/shard` must stay per-route because half that router must *not* have
it. This keeps the per-route handler count intact — the one number `routes.guards.json` can actually
check, since `requireRole(...)` returns an anonymous arrow.
- **The `/:param` shadowing check was run again and is clean**, since the manifest sorts and therefore
cannot see declaration order. Introspecting the built stack, all 110 admin routes and all 59 literal
admin paths dispatch to their own layer — nothing is captured first by a `:param` sibling. The
near-misses worth naming: `GET /shard/pages` (help-page queue) sits alongside
`POST /shard/pages/:id/respond|close`, and `POST /shard/towncrier` alongside
`DELETE /uo-link/towncrier/:id` — different depths and methods, so neither collides.
- **Deleting the file left dangling `see admin.routes.js` pointers**, which were repointed in the same
PR: `botActivity.controller.js``botActivity.router.js`, `moderation.controller.js`
`moderation.router.js`, `announceJobs.logic.js`'s town-crier cap mirror → `admin/uoLink.router.js`,
and the "route paths sit on the line *after* `adminRouter.get(`" rationale in
`scripts/routeManifest.js`, `README.md` and `pr-checks.yml` was generalized (it was never about that
one file).
- **`/admin/shard/pages` vs `/admin/pages` stayed separate**, as PR 3 flagged: the former is the
in-game help-page (support) queue and belongs to `shard`; the latter is the CMS page builder.
### The swagger path-normalization prerequisite (landed before PR 1)
swagger-autogen builds a path by string-concatenating the mount prefix with the route argument, so a
capability router mounted at `/users` whose collection route is `router.get('/')` documents as
`/api/v1/admin/users/` — advertising a URL no client calls while dropping the one the SPA, the Android
app and the Discord bot all do. Express is indifferent; the published spec is not. It also emits path
keys in *router-traversal order*, so moving a route between files rewrote most of the ~5k-line
committed artifact even when the API was provably unchanged — burying the one line a reviewer needs.
Both are fixed once in `server/swagger/swagger.js`, which post-processes the generator's output to
strip trailing slashes and sort path keys (throwing on a collision rather than silently dropping an
operation). It shipped as its own PR ahead of PR 1, verified inert by the regenerated spec being
byte-for-byte the sorted form of the previously committed one — 198 operations, none added or removed.
`#swagger.path` was rejected as the fix: it bypasses the mount prefix, so every route would hardcode
its absolute path in a comment that silently lies the moment a mount moves.
**Consequence for PRs 25: the swagger diff is now a signal.** With sorting in place, a pure route
move produces *no* spec diff at all, so any diff there means an annotation actually changed. Treat
`swagger-output.json`, `routes.manifest.json` and `routes.guards.json` as three zero-diff gates.
### PR 0 — the route manifest (prerequisite of the first split PR) — **landed**
> **Status: shipped.** `server/scripts/routeManifest.js` + `npm run routes:manifest`,
> `server/routes.manifest.json` (199 public + 2 internal), `server/routes.guards.json`,
> `server/test/routeManifest.test.js`, and a `routes:manifest -- --check` step in
> `.gitea/workflows/pr-checks.yml`. No router file moved. **The generator reproduced
> `api-route-inventory.json` byte-for-byte on first run**, so the freeze is in effect and the
> committed baseline is confirmed accurate rather than merely asserted.
>
> Two deviations from the design below, both deliberate:
>
> - **The unauthenticated-status snapshot was tried and dropped**, exactly as this section allowed.
> Firing unauthenticated GETs at every manifest path against the dead-port mariadb pool the tests
> use does not fail fast — the pool sits on its acquire timeout, and a partial sweep had not
> finished after two minutes. A flaky two-minute gate is worse than none. What replaced it is
> cheap and deterministic: the test suite asserts from the introspected stack that every
> `/api/v1/admin/**` and `/api/v1/player/**` route still carries `requireAuth`.
> - **`routes.guards.json` is committed and staleness-checked**, though a diff in it is explicitly
> *not* a contract change. Left ungenerated it would rot into a misleading review aid within a
> release. The gate is on freshness; the meaning of a guards diff is still "read this", not
> "justify this". The generator drops app-level plumbing (helmet, morgan, the JSON parser, the bot
> guard) since it applies uniformly to all 199 routes and would bury the per-route gates.
"Every URL is unchanged" must be *proved by a diff*, not asserted in review. PR 0 lands the tool that
proves it, with no router file moved.
- **Generator:** `server/scripts/routeManifest.js`, wired as `npm run routes:manifest`. It requires
`src/app.js` (which exports the app and neither listens nor connects to the DB — `server.js` owns
those), walks `app._router.stack` recursively through mounted routers, reconstructs each full path
from the layer regexps, and writes a **sorted** array of
`{ "method": "GET", "path": "/api/v1/admin/users/:id" }` to `server/routes.manifest.json`.
`internalApp.js` is walked into a separate `internal` section so the unpublished port is inventoried
without being confused for public surface.
- **Scope it to the API surface, or it won't be deterministic.** Three mounts are *filesystem*
conditional: the SPA catch-all `GET *` (only when `client/dist/index.html` exists), the `/brand`
static mount, and `/api/docs*` (only when `swagger-output.json` is present — it is committed, so it
is stable). The manifest keeps only `/api/**`, `/.well-known/**`, and the internal app's routes, so
it does not change depending on whether CI built the client. Static mounts are not API contract.
- **The baseline already exists:** [`api-route-inventory.json`](./api-route-inventory.json) in this
directory is the frozen surface — **199 API routes** (110 of them `/api/v1/admin`) plus 2
internal at the time PR 0 was written. PR 0's generator must **reproduce this file byte-for-byte**;
that is PR 0's own acceptance test, and it means the freeze is already in effect before the first
router moves. *(It did, on first run. The file has since moved to **200** — the CSP report-only PR
added `POST /api/csp-report`, the first deliberate, reviewed manifest diff.)*
- **Runtime introspection, not source parsing.** It is authoritative about mounts, and the route paths
in `admin.routes.js` sit on the line *after* `adminRouter.get(`, which defeats naive greps.
- **Not `swagger-output.json`.** That is annotation-derived (only annotated routes appear) and churns
for unrelated reasons; it documents intent, the manifest records reality.
- **Frozen key: method + path only.** That is exactly the contract being preserved. Handler names are
useless as a guard check here — `requireRole(...)` returns an anonymous arrow, and router-level gates
like `adminRouter.use(noindex, isLoggedIn, staffOnly)` never appear in a route's own stack.
- **Guard coverage, separately.** The generator also emits a non-gated review aid: per route, the
handler count plus any *named* middleware collected along the mount chain. If a stable behavioral
check proves cheap, prefer it — a test that fires an **unauthenticated** request at every manifest
path and snapshots the status code catches a dropped `adminOnly` (403 → 200) in a way names cannot.
Try it in PR 0; if DB-touching public routes make it slow or noisy against the dead-port pool the
tests use, drop it rather than ship a flaky gate.
- **CI:** `.gitea/workflows/pr-checks.yml` runs `npm run routes:manifest` and
`git diff --exit-code server/routes.manifest.json`. A PR that moves a URL fails unless it
deliberately commits the new manifest — which puts the URL change in front of a reviewer instead of
letting it pass silently.
- **Published copy:** `docs/website/api-route-inventory.json` mirrors `server/routes.manifest.json` and
is refreshed in each split PR's mandatory docs edit. The markdown table above is orientation for a
human reader; **the manifest is the authoritative freeze.**
---
## Sequencing & PR breakdown
CSP and the split are independent; the only hard ordering is PR 0 before the first split PR.
**Resequenced during implementation: PR 0 ships first, before the CSP pair.** The CSP report-only PR
has to stand up a violation collector (`POST /api/csp-report`) for `report-to` to point at — which is
a new URL under `/api/**`. Landing it first would mean PR 0's generator emitting 200 routes against a
199-route committed baseline, so PR 0 could no longer prove itself by reproducing
`api-route-inventory.json` byte-for-byte. With PR 0 first, the collector shows up as a reviewed,
deliberate `+1` in the manifest — which is exactly the mechanism working as designed.
1. **PR 0 — route manifest.** Generator + CI check + committed baseline of today's surface. No routers moved. ✅ landed
2. **PR — CSP report-only.** Tightened policy behind `Content-Security-Policy-Report-Only` + `report-to`,
plus the report collector (manifest `+1` — the first deliberate, reviewed manifest diff). ✅ landed
3. **PR — CSP enforce.** One release later, assuming a clean violation report. **Blocked on real soak
data**, not on code: watch the `csp` log tag for `frame-ancestors` reports across one release before
flipping. Also decide there whether `/api/csp-report` is retired with the report-only twin or kept
as a `report-to` group on the enforced policy.
4. **PR 1 — admin:** `users`, `account`, `invites`, `auth` (providers). ✅ landed
5. **PR 2 — admin:** `moderation`, `bot-activity`, `activity`. ✅ landed
6. **PR 3 — admin (content):** `posts`, `pages`, `wiki`, `uploads`. ✅ landed
7. **PR 4 — admin (ops/config):** `shard`, `uo-link`, `email`, `discord-bot`, `settings`, `site-mode`,
`dashboard`. (`activity` went with PR 2 — see § PR 2 — as landed.) **This is the whole residual
file** — `admin.routes.js` is deleted here, not by PR 5. ✅ landed
8. **PR 5 — `public/*` + `player/*`** (and the residual `auth/*` grouping). **The only split PR left.**
Each PR: **zero-line diff in `routes.manifest.json`**, server tests green
(`cd website/server && npm test`), Swagger regenerated, matching `docs/` edit, Conventional Commit,
AI-disclosure trailer, branch from a freshly-pulled `main`.
---
## Cross-component blast radius
Three independent clients consume the site's HTTP/SSE API, two of them in separate repos on separate
release cadences. **With the auth merge and the version bump both gone, the blast radius is empty**
no client's URLs or authentication change at all.
| Consumer | Repo (cadence) | Pinning | Impact under this plan |
|---|---|---|---|
| Browser SPA | `website/client` (lockstep) | `BASE = /api/v1` in `client/src/api/client.js` | **None.** Same URLs, same cookie session. |
| Android app | `android-app` (app-store cadence, un-updatable installs in the wild) | 69 hardcoded `api/v1/…` paths; SSE path in `ShardStreamClient.kt`; SSO in `SsoAuthManager.kt` | **None.** No repoint, no release required. |
| Discord bot | `website/bot` (separate deploy, env-configured) | `SITE_PUBLIC_URL` env → `/api/v1/public` | **None.** Anonymous public reads on unchanged paths. |
Standing constraints, unchanged:
- **The public SSE stream stays anonymous.** Consumed by logged-out browser visitors *and* the Android
`ShardStreamClient`, neither of which sends an `Authorization` header. Adding `requireAuth` blacks
out the public live boards on web and mobile. The single most likely regression in a careless
refactor is reflexively wrapping *both* shard streams in auth.
- **The admin SSE stream keeps its current cookie-based gating** (`isLoggedIn`, `EventSource` +
`withCredentials`) — the rewrite that would have changed this went out with the auth merge.
- **The public/admin allowlist split is a security boundary**, not an implementation detail. Preserve
it verbatim in `utils/shardBroadcast.js` / `utils/shardIngest.js` as routes move.
- **SSO / email-connect transaction cookies are load-bearing for web *and* native.** The Android SSO
flow opens a Custom Tab to the website's `/auth/…/sso/start` and rides the same server-side redirect
transaction and the same tx cookies. Nothing in this plan touches them; don't let a future "cookies
go away" push delete them.
- **`link/` is out of scope.** The sidecar contract (`uoLinkConfig`, `utils/uoLinkClient.js`,
`utils/shardIngest.js`, `X-UOLink-Version`) is a separate versioning axis. `PROTOCOL_VERSION` does
**not** bump for this work.
---
## Appendix: mapping from the previous plan
| Previous | Now |
|---|---|
| Phase 0 — `/api/mobile` facade + app-version floor | **Deferred.** See § Deferred: the `/api/mobile` facade |
| Phase 1 — auth merge | **Removed.** See § Why the auth merge is out and § Deferred: the auth merge |
| Phase 1b — CSP hardening (shipped with the auth merge) | **Phase 1**, standalone; report-only-first ordering fixed |
| Phase 2 — domain split under `router/v2/` | **Phase 2**, in place under `router/v1/`; now the primary driver |
| PR 1 — `/api/v2` scaffold ([API_V2_SKELETON.md](./API_V2_SKELETON.md)) | **Superseded**, kept as the recipe if a versioned API is ever forced |
| PR final — retire v1 | **Not applicable** — v1 is never replaced |

View File

@@ -1,131 +0,0 @@
# Website API v2 — `/api/v2` Skeleton (PR 1)
> ## ⚠ Superseded — not scheduled
>
> The router domain split is being done **in place**, with every URL byte-identical, so there is no
> parallel version to stand up and this scaffold will not be built. See
> [API_V2_PLAN.md](./API_V2_PLAN.md) § Why there is no `/api/v2`.
>
> The file is kept, unedited below, as the concrete recipe **if** a real contract break ever forces a
> versioned API. Nothing here describes current or planned work.
Companion to [API_V2_PLAN.md](./API_V2_PLAN.md) — this is the concrete scaffold for **PR 1** in that
plan's sequencing. It stands up `/api/v2` **empty but wired**, next to a frozen `/api/v1`, with **no
behavior change**. Endpoints are filled in by the later PRs (auth merge, then the domain split).
## Scope
- Create the `router/v2/` tree of empty, domain-named routers.
- Mount `/v2` alongside `/v1` in `api.router.js`.
- Add a single trivial `GET /api/v2/version` so the mount is testable end-to-end.
- **Out of scope:** any real endpoint, any auth change, any controller edit. Those are PR 2+.
## File tree to create
```
website/server/src/router/v2/
v2.router.js # mounts the domain sub-routers; adds GET /version
admin/
index.js # mounts the admin capability routers under /admin
dashboard.router.js users.router.js moderation.router.js
content.router.js wiki.router.js shard.router.js
settings.router.js invites.router.js bot-activity.router.js
auth/
index.js login.router.js sso.router.js totp.router.js session.router.js
public/
index.js news.router.js wiki.router.js page.router.js shard.router.js
player/
index.js profile.router.js appeals.router.js shard.router.js
```
`internal/` is **not** part of v2's public tree — the internal routes stay on the separate,
unpublished port (`internalApp.js`), exactly as in v1. See `API_V2_PLAN.md` § Phase 2.
## Wiring
`api.router.js` gains the v2 mount next to v1:
```js
const v1Router = require('./v1/v1.router')
const v2Router = require('./v2/v2.router')
apiRouter.use('/v1', v1Router)
apiRouter.use('/v2', v2Router) // NEW — parallel version, migrate off v1 route-by-route
```
`v2.router.js` mounts each domain group and exposes the version ping:
```js
const express = require('express')
const v2Router = express.Router()
const adminRouter = require('./admin')
const authRouter = require('./auth')
const publicRouter = require('./public')
const playerRouter = require('./player')
// Cheap liveness/mount check so the parallel version is testable before any
// real endpoint exists. Returns the API major version, nothing sensitive.
v2Router.get('/version', (req, res) => res.json({ version: 2 }))
v2Router.use('/auth', authRouter)
v2Router.use('/public', publicRouter)
v2Router.use('/admin', adminRouter)
v2Router.use('/player', playerRouter)
// NOTE: /internal is intentionally NOT mounted here — same reason as v1.
module.exports = v2Router
```
Each capability router is an empty stub at this stage — a router that mounts cleanly and adds no
routes yet, so PR 2+ only has to add handlers, never re-wire:
```js
// router/v2/admin/dashboard.router.js
const express = require('express')
const router = express.Router()
// Routes added in the admin domain-split PR (see API_V2_PLAN.md § Phase 2).
module.exports = router
```
Each `index.js` mounts its group's capability routers under the URL that names them, e.g.:
```js
// router/v2/admin/index.js
const express = require('express')
const admin = express.Router()
admin.use('/dashboard', require('./dashboard.router'))
admin.use('/users', require('./users.router'))
admin.use('/moderation', require('./moderation.router'))
admin.use('/content', require('./content.router'))
admin.use('/wiki', require('./wiki.router'))
admin.use('/shard', require('./shard.router'))
admin.use('/settings', require('./settings.router'))
admin.use('/invites', require('./invites.router'))
admin.use('/bot-activity', require('./bot-activity.router'))
module.exports = admin
```
## Acceptance criteria
- Server boots with no error; every sub-router mounts.
- `GET /api/v2/version``200 { "version": 2 }`.
- `GET /api/v1/**` behavior is **byte-for-byte unchanged** — v1 is untouched.
- Existing server tests stay green (`cd website/server && npm test`).
- A new test asserts the `/api/v2/version` mount (smallest possible coverage of the wiring).
## Docs / spec
- Swagger regeneration is deferred until v2 has real routes (PR 2) — a lone `/version` ping doesn't
need an annotation. When PR 2 lands, add `#swagger.*` to the new routes and run `npm run swagger`.
- No `BACKEND_DESIGN.md` change here beyond noting the parallel `/api/v2` mount exists; the
route-map/security-contract edits land with the PRs that add real endpoints.
## Next
PR 2 fills the `auth/` routers with the bearer access + refresh flow and drops the session cookie —
see [API_V2_PLAN.md](./API_V2_PLAN.md) § Phase 1.

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

@@ -29,24 +29,6 @@ Public contact email: **UOMysticmoon@gmail.com**
Skeleton from the spec, with a small number of justified additions marked **(+)**. Skeleton from the spec, with a small number of justified additions marked **(+)**.
> **In progress:** the monolithic route files below (`admin.routes.js` especially, originally 1552
> lines / 110 routes) are being split into one router file per business capability — **in place, with
> every URL unchanged**. This section and §4 get updated as each split PR lands. See
> [API_V2_PLAN.md](./API_V2_PLAN.md) § Phase 2.
>
> **Landed so far:** the **admin group is fully split**. `users`, `account`, `invites`,
> `auth/providers` (PR 1, 28 routes), `moderation`, `bot-activity`, `activity` (PR 2, 18 routes),
> `posts`, `uploads`, `wiki`, `pages` (PR 3, 31 routes) and `shard`, `uo-link`, `email`,
> `discord-bot`, `settings`, `dashboard`/`site-mode` (PR 4, 33 routes) each live in their own router
> under `admin/`, behind `admin/index.js` — **`admin.routes.js` is deleted**. `public/` and `player/`
> are untouched and are the subject of the last split PR.
>
> "Every URL unchanged" is enforced mechanically, not by review: `server/scripts/routeManifest.js`
> (`npm run routes:manifest`) walks the live Express stack and writes the sorted
> `{ method, path }` freeze to `server/routes.manifest.json`, mirrored here as
> [api-route-inventory.json](./api-route-inventory.json). PR checks regenerate it and fail on any
> diff, so a split PR that moves a URL cannot merge silently. See § 4.0.
``` ```
server/ server/
.env.example .env.example
@@ -63,49 +45,7 @@ server/
v1.router.js mounts /auth /public /admin v1.router.js mounts /auth /public /admin
auth/ auth.routes.js + auth.controller.js auth/ auth.routes.js + auth.controller.js
public/ public.routes.js + public.controller.js public/ public.routes.js + public.controller.js
admin/ index.js mounts the capability routers below at their admin/ admin.routes.js + admin.controller.js
own prefixes; owns the shared
`noindex, isLoggedIn, staffOnly` gate and
declares no routes itself
account.router.js (6) /admin/account — self-service, no adminOnly
users.router.js (15) /admin/users — adminOnly
invites.router.js (3) /admin/invites — adminOnly
authProviders.router.js (4) /admin/auth — adminOnly
moderation.router.js (15) /admin/moderation — modAccess
(admin+moderator) at router level
botActivity.router.js (2) /admin/bot-activity — adminOnly
activity.router.js (1) /admin/activity — staff-wide
audit log, no extra gate
posts.router.js (9) /admin/posts — editor tier, no
gate beyond staffOnly
uploads.router.js (1) /admin/uploads — rich-text editor
image upload
wiki.router.js (14) /admin/wiki — pages, revisions,
categories, tags
pages.router.js (7) /admin/pages — CMS page builder
imageUpload.js shared multer config for the two
upload routes above (not a router)
shard.router.js (16) /admin/shard — 7 self-service
account-linking routes (no extra
gate, handlers shared with
/player/shard) + 9 in-game staff
ops on modAccess, per route
uoLink.router.js (5) /admin/uo-link — sidecar config,
town crier, admin SSE — adminOnly
email.router.js (6) /admin/email — Gmail OAuth2
delivery — adminOnly
discordBot.router.js (2) /admin/discord-bot — adminOnly
settings.router.js (2) /admin/settings — adminOnly
dashboard.router.js (2) GET /dashboard (staff-wide) and
PUT /site-mode (adminOnly) — the
two singletons owning no path
segment, so mounted at the group
root; declares no router-level
middleware, which is what makes a
root mount safe
admin.controller.js + the per-capability controllers
(already domain-split; the split PRs re-wire
routes, not logic)
model/ model/
users/ users.model.js + users.db.js users/ users.model.js + users.db.js
posts/ posts.model.js + posts.db.js (news/five-on-friday/newsletter/screenshots) posts/ posts.model.js + posts.db.js (news/five-on-friday/newsletter/screenshots)
@@ -216,95 +156,6 @@ Seeded keys: `site_mode` (default `maintenance`), `site_mode_changed_at`,
Same "store only the hash of an opaque token" pattern as `user_invites` / `mobile_refresh_tokens`. Same "store only the hash of an opaque token" pattern as `user_invites` / `mobile_refresh_tokens`.
A DB read never yields a usable reset link. See §4 `/auth/password/*`. A DB read never yields a usable reset link. See §4 `/auth/password/*`.
### push_devices — opt-in push endpoints (M7)
| col | type | notes |
|---|---|---|
| id | INT PK AUTO_INCREMENT | |
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | owner |
| transport | ENUM('unifiedpush','fcm') DEFAULT 'unifiedpush' | UnifiedPush for the sideloaded APK; FCM reserved for a later Play flavor |
| endpoint | VARCHAR(512) NOT NULL | the distributor URL the app's ntfy topic was handed (or an FCM token). Unguessable but **not a secret** — stored in the clear (unlike refresh tokens), because pushes are content-free tickles |
| platform | VARCHAR(40) NULL | free-form label, e.g. `android` |
| created_at / last_seen_at | DATETIME | |
`UNIQUE(user_id, endpoint)` — re-registering the same endpoint is an idempotent upsert.
### notification_subscriptions — which streams a user opted into (M7)
| col | type | notes |
|---|---|---|
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | |
| stream_id | VARCHAR(64) NOT NULL | an id from the catalog (`config/notificationStreams.js`), validated on write |
| created_at | DATETIME | |
`PRIMARY KEY(user_id, stream_id)`. Subscriptions are per-user (applied to every device); a PUT
replaces the whole set. Nothing is pushed unless the user subscribed.
### mobile_auth_sessions / mobile_auth_codes — mobile SSO bridge (M9)
Two short-lived, self-pruning tables that bridge a browser SSO redirect flow to a native client. They
carry the **app ↔ website** PKCE + CSRF state (a *second* PKCE layer, distinct from the website ↔ IdP
PKCE the `sso_tx` cookie already carries) and the one-time authorization code the app exchanges for
bearer tokens. Neither holds a secret in the clear — the PKCE `code_challenge` is a hash by
construction, and the authorization code is stored as a **sha256 hash only** (same pattern as
`user_invites` / `password_resets` / `mobile_refresh_tokens`).
`mobile_auth_sessions` — one row per `/auth/mobile/sso/start`:
| col | type | notes |
|---|---|---|
| id | INT PK AUTO_INCREMENT | |
| session_id | CHAR(36) UNIQUE | opaque uuid; carried inside the signed `sso_tx` (mode `mobile`) so the callback can find this row |
| provider | VARCHAR(40) NOT NULL | provider id validated enabled at `/start` |
| code_challenge | VARCHAR(255) NOT NULL | app-supplied PKCE S256 challenge (base64url); verified at `/exchange` |
| redirect_uri | VARCHAR(255) NOT NULL | the requested app callback — **exact-match** against the allowlist (never prefix) |
| state | VARCHAR(255) NOT NULL | app-generated opaque CSRF value, echoed on the callback for the app to verify |
| status | ENUM('pending','completed','consumed') DEFAULT 'pending' | `pending``completed` when the code is minted; `consumed` after a successful exchange |
| user_id | INT NULL FK→users(id) ON DELETE CASCADE | set once SSO resolves the account |
| expires_at | DATETIME NOT NULL | short (~10 min — one redirect round-trip incl. TOTP) |
| created_at / used_at | DATETIME | `used_at` stamped at exchange |
`mobile_auth_codes` — one row per completed SSO callback (the code the app redeems):
| col | type | notes |
|---|---|---|
| id | INT PK AUTO_INCREMENT | |
| code_hash | CHAR(64) UNIQUE | sha256 hex of the opaque ≥128-bit code; the raw code never touches the DB |
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | the authenticated account |
| session_id | CHAR(36) NOT NULL | the owning `mobile_auth_sessions.session_id` (ties the code to its PKCE challenge) |
| expires_at | DATETIME NOT NULL | very short (~5 min) |
| used_at | DATETIME NULL | set on first successful exchange — **single use** (a reused code fails) |
| created_at | DATETIME | |
Both self-prune (indexed `expires_at`): a best-effort sweep runs at boot beside the existing
`revoked_sessions` prune, and each bridge write opportunistically deletes expired rows — so no cron
infra is added (same approach as `revoked_sessions`).
**`mobile_refresh_tokens` additions (M9).** Two nullable columns are added to support the device
list/revoke surface: `device_name VARCHAR(100) NULL` (a friendly label) and `last_used_at DATETIME
NULL` (bumped on each refresh). Existing rows get them via the schema's ALTER section; the token model
is otherwise unchanged.
### trusted_devices — MFA "Trust this device"
Lets a browser/app **skip the TOTP step** at login (never the password) for 30 days. Pattern-identical
to `mobile_refresh_tokens`: the opaque trust token lives client-side (the `rg_trust` httpOnly cookie on
web, `X-Trust-Token` / EncryptedSharedPreferences on native) and only its **sha256** hash is stored
(`token_hash CHAR(64) UNIQUE`) — sha256, not bcrypt, because a 256-bit random token is looked up **by
its hash** via the unique index (a per-row salt would break that). Columns mirror the mobile table
(`platform`, `device_name`, `device_hash`, `user_agent`, `created_at`, `last_used_at`, `expires_at`,
`revoked_at`). Capped at 10 rows/user **in application code — no silent pruning** (an over-cap trust is
refused so the client can prompt the user to revoke one first). Consulted only at the login/password
step, never at token refresh, and revoked wholesale on untrust / password change / password reset /
TOTP disable. See `docs/website/TRUSTED_DEVICES_MFA.md`.
### recovery_codes — single-use MFA backup codes
Generated at TOTP enrollment (10 at a time, shown to the user **once**) so a user who loses their
authenticator can complete login without an admin reset. `code_hash VARCHAR(72)` is a **bcrypt** hash
(not sha256): a recovery code is a human-typed, lower-entropy fallback credential — the closest
analogue to a password — and there is no hash-lookup constraint (verification fetches the user's ≤10
unused rows and `bcrypt.compare`s each, like password verification). `used_at` is the single-use
marker. Cleared wholesale on TOTP disable / password change / password reset.
--- ---
## 4. API contract ## 4. API contract
@@ -312,45 +163,11 @@ marker. Cleared wholesale on TOTP disable / password change / password reset.
Base path `/api/v1`. JSON in/out. Auth via httpOnly cookie (`isLoggedIn` reads it; also Base path `/api/v1`. JSON in/out. Auth via httpOnly cookie (`isLoggedIn` reads it; also
accepts `Authorization: Bearer` for API testing). accepts `Authorization: Bearer` for API testing).
### 4.0 The authoritative route list
The prose tables below are **orientation for a human reader** and can drift. Two generated artifacts
are authoritative, and they answer different questions:
| Artifact | Source of truth for | Generated by |
|---|---|---|
| `server/routes.manifest.json` — mirrored as [api-route-inventory.json](./api-route-inventory.json) | **What URLs exist.** 200 public routes + 2 on the internal listener, sorted, method + path only. | `npm run routes:manifest`, by walking the live Express stack |
| `server/swagger/swagger-output.json` — served at `/api/docs` | **What each route means.** Parameters, bodies, response codes, security. | `npm run swagger`, from `#swagger.*` annotations |
The split is deliberate: Swagger is annotation-derived, so an unannotated route is invisible in it and
it churns whenever a description is reworded — it documents *intent*. The manifest is introspection-
derived and records *reality*, which is why it, not Swagger, is the thing PR checks freeze
(`npm run routes:manifest -- --check`).
Both artifacts are emitted with **sorted** keys, so a diff in either is proportional to the change
rather than to how the routers happen to be traversed. `swagger.js` additionally strips trailing
slashes from generated path keys — see *Regenerating the spec* in the website README for why the
domain split makes that necessary.
Scope: the manifest keeps `/api/**` and `/.well-known/**` from the public app plus everything on the
internal listener. The SPA catch-all, `/uploads` and `/brand` are filesystem-conditional static
mounts — not API contract, and including them would make the output depend on whether CI had built
the client.
A third generated file, `server/routes.guards.json`, is a **review aid and not a contract**: per route,
the middleware handler count plus the *named* middleware on its mount chain. It exists because a
router-level `router.use(noindex, isLoggedIn, staffOnly)` gate never appears in an individual route's
own stack, so a capability router extracted without re-applying the gate would otherwise publish
authenticated endpoints silently. Names are a hint only — `requireRole(...)` returns an anonymous
arrow and cannot be observed — but a *missing* `requireAuth` is unambiguous, and the server test suite
asserts every `/admin/**` and `/player/**` route still carries it.
### /auth (auth.routes.js → auth.controller.js) ### /auth (auth.routes.js → auth.controller.js)
| Method | Path | Auth | Body | Purpose | | Method | Path | Auth | Body | Purpose |
|---|---|---|---|---| |---|---|---|---|---|
| POST | `/login` | — (rate-limited) | `{username,password}` | verify, set cookie, log `auth.login`, update `last_login_at`. If the account has TOTP **and this browser is a trusted device** (a valid `rg_trust` cookie bound to the user), the TOTP step is **skipped** and a session is issued directly (logs `auth.login.trusted_device`). Otherwise a 2FA account returns `{totpRequired, challenge}`. | | POST | `/login` | — (rate-limited) | `{username,password}` | verify, set cookie, log `auth.login`, update `last_login_at` |
| POST | `/login/totp` | — (rate-limited) | `{challenge, code? \| recoveryCode?, trustDevice?, deviceName?}` | complete 2FA with a TOTP **or** single-use recovery code. `trustDevice` sets the `rg_trust` cookie so future logins skip TOTP; at the device cap the session is still issued and the body carries `{trustLimitReached, devices}`. | | POST | `/logout` | cookie | — | clear cookie |
| POST | `/logout` | cookie | — | clear cookie (the `rg_trust` trust cookie deliberately **survives** logout) |
| GET | `/me` | cookie / bearer | — | current user (no hash) or 401 — client bootstraps auth state | | GET | `/me` | cookie / bearer | — | current user (no hash) or 401 — client bootstraps auth state |
| POST | `/password/forgot` | — (rate-limited) | `{email}` | email a single-use, ~1h reset link to **every active account** on the address; **always** returns the same generic 200 (no account enumeration). Email is non-unique, so several accounts may each get a link naming their username. Logs `account.password.reset.request`. | | POST | `/password/forgot` | — (rate-limited) | `{email}` | email a single-use, ~1h reset link to **every active account** on the address; **always** returns the same generic 200 (no account enumeration). Email is non-unique, so several accounts may each get a link naming their username. Logs `account.password.reset.request`. |
| GET | `/password/reset/:token` | — | — | validate a link → `{username}` for the form, else 404 (never distinguishes expired/used/never-existed) | | GET | `/password/reset/:token` | — | — | validate a link → `{username}` for the form, else 404 (never distinguishes expired/used/never-existed) |
@@ -358,17 +175,8 @@ asserts every `/admin/**` and `/player/**` route still carries it.
| GET | `/me/account` | cookie / bearer | — | full self account (`id, username, role, email, status, totp_enabled, has_password`) | | GET | `/me/account` | cookie / bearer | — | full self account (`id, username, role, email, status, totp_enabled, has_password`) |
| PATCH | `/me/account/username` | cookie / bearer (rate-limited) | `{username}` | change own username; re-issues the caller's session | | PATCH | `/me/account/username` | cookie / bearer (rate-limited) | `{username}` | change own username; re-issues the caller's session |
| PATCH | `/me/account/password` | cookie / bearer (rate-limited) | `{newPassword, currentPassword?}` | change/set own password (current required unless the account has none); revokes other sessions, keeps the caller's | | PATCH | `/me/account/password` | cookie / bearer (rate-limited) | `{newPassword, currentPassword?}` | change/set own password (current required unless the account has none); revokes other sessions, keeps the caller's |
| POST | `/me/account/totp/setup` · `…/enable` · `…/disable` | cookie / bearer | `{code}` on enable/disable | self 2FA enrollment (disable needs a valid current code, not a password). **enable** returns the one-time `recoveryCodes`; **disable** clears the user's trusted devices + recovery codes | | POST | `/me/account/totp/setup` · `…/enable` · `…/disable` | cookie / bearer | `{code}` on enable/disable | self 2FA enrollment (disable needs a valid current code, not a password) |
| GET | `/me/account/identities` · DELETE `…/:provider` | cookie / bearer | — | list / unlink own SSO identities | | GET | `/me/account/identities` · DELETE `…/:provider` | cookie / bearer | — | list / unlink own SSO identities |
| GET | `/me/trusted-devices` | cookie / bearer | — | list own active trusted devices (never tokens) |
| POST | `/me/trusted-devices` | cookie / bearer (rate-limited) | `{deviceName?}` | trust the current device; web gets an httpOnly `rg_trust` cookie, native gets `{trustToken}`. **409 `{error:'trusted_device_limit', devices}`** at the cap |
| DELETE | `/me/trusted-devices` · `…/:id` | cookie / bearer | — | untrust all / one (ownership-scoped) |
| GET | `/me/account/recovery-codes/status` | cookie / bearer | — | remaining unused code count (never the codes) |
| POST | `/me/account/recovery-codes/generate` | cookie / bearer (rate-limited, **password step-up**) | `{currentPassword?}` | regenerate the one-time recovery codes (returned once); refused when 2FA is off |
| POST | `/me/devices` | cookie / bearer | `{endpoint, transport?, platform?}` | register a push endpoint; **rejects a disallowed endpoint 400** (SSRF guard). Idempotent per (user, endpoint) |
| GET | `/me/devices` · DELETE `…/:id` | cookie / bearer | — | list / unregister own push devices |
| GET | `/me/notifications/streams` | cookie / bearer | — | the subscribable catalog (`personal`/`requiresLinkedAccount` flags) |
| GET · PUT | `/me/notifications/subscriptions` | cookie / bearer | `{streams:[id]}` on PUT | get / replace own opted-in streams (unknown ids dropped) |
**Role-agnostic self-service (`/auth/me/*`).** The canonical "me" surface for **every** authenticated **Role-agnostic self-service (`/auth/me/*`).** The canonical "me" surface for **every** authenticated
role. It reuses the exact `account.controller` handlers as `/player/account/*` and `/admin/account/*` role. It reuses the exact `account.controller` handlers as `/player/account/*` and `/admin/account/*`
@@ -377,16 +185,6 @@ lets a client (the Android app) manage its own account through one surface witho
`/admin` (docs/android/PLAN.md §6.4). The older `/player/account/*` + `/admin/account/*` routes stay `/admin` (docs/android/PLAN.md §6.4). The older `/player/account/*` + `/admin/account/*` routes stay
for web back-compat. for web back-compat.
**The `/player/*` group is self-service, not player-only.** Staff are a **superset** of players — every
player ability plus their staff tools on top — so the whole `/player/*` router (game-account linking,
character/vendor/house reads, credential changes, appeals) sits behind `requireAuth` **only**, never
`requireRole('player')`. Every handler is self-scoped to the caller by `req.user.id`, so an admin/editor/
moderator using it sees only their **own** linked accounts and characters (with the pre-existing
`isAdmin` bypass still letting a genuine admin read *any* character). Staff also reach the identical
self-scoped handlers under `/admin/shard/*` (same controller) for the web admin surface; the two are
interchangeable. This is why a staff account with linked game characters gets its "My characters" and
personal notification streams on the mobile client — the group no longer 403s a non-`player` role.
**Password reset.** Uses the same audited pattern as `user_invites`: an opaque 32-byte token **Password reset.** Uses the same audited pattern as `user_invites`: an opaque 32-byte token
whose **sha256 hash only** is stored in `password_resets`, single-use and short-lived (~1h). It whose **sha256 hash only** is stored in `password_resets`, single-use and short-lived (~1h). It
also serves SSO-only accounts (null `password_hash`) as their "set an initial password" path. The also serves SSO-only accounts (null `password_hash`) as their "set an initial password" path. The
@@ -394,91 +192,10 @@ reset link points at the web front end (`/account/reset/:token`); the Android ap
rather than shipping its own reset screen (docs/android/PLAN.md §4.2). First admin is bootstrapped rather than shipping its own reset screen (docs/android/PLAN.md §4.2). First admin is bootstrapped
by `seed.js` from env (see §6); further staff are created under `/admin/users` or via email invites. by `seed.js` from env (see §6); further staff are created under `/admin/users` or via email invites.
**Push notifications (M7, opt-in).** The app subscribes per stream (`/auth/me/notifications/*`) and
registers device endpoints (`/auth/me/devices`); nothing is pushed unless subscribed. Delivery is a
**content-free tickle**`{ stream, ref }`, no sensitive data — POSTed to each subscribed device's
self-hosted **ntfy** endpoint (`utils/pushDispatch`); the app wakes and pulls the real, ownership-
checked content over the authenticated API. Two producers fan out through the one publisher: the shard
ingest dispatcher (`utils/shardIngest`, beside the SSE broadcast) for shard-derived streams, and the
create/publish-post path for `news.post`. The stream catalog + event→stream mapping is
`config/notificationStreams.js`. Security invariants:
- **Same public/admin split as the SSE feed.** Public streams are drawn *only* from the SSE
`PUBLIC_KINDS` allowlist; a sensitive kind (audit/cheat/IP/login-attempt) can never produce a public
push.
- **Personal streams are owner-keyed.** `vendor.sale` / `house.idoc` / `account.login` are delivered
only to the *owning* user's devices, resolved via `shardLinks` (the same ownership check as
`/player/shard/*`).
- **SSRF guard.** A device `endpoint` is a client-supplied URL the server POSTs to, so registration and
every publish validate it is HTTPS, non-private/loopback, and (when configured) on the shard's ntfy
allow-set (`NTFY_BASE_URL` / `NTFY_ALLOWED_ORIGINS`).
- ntfy is treated as an **untrusted relay** — no per-user accounts, unguessable topics; an optional
`NTFY_PUBLISH_TOKEN` hardens backend→ntfy publishes but is not required. See docs/android/PLAN.md §11.
### Mobile SSO Authorization Bridge (`/auth/mobile/sso/*`, M9)
Native "Sign in with Google/Discord" for the Android app **without shipping any OAuth secret in the
app**. The website stays the identity authority: each shard owner's provider credentials live in
`auth_providers` (encrypted at rest) and are only ever used server-side. The bridge is a **new
consumer of the existing SSO + mobile-bearer machinery**, not a parallel auth path — it reuses the
`/auth/sso/:provider/*` redirect flow, the link-only + opt-in-provisioning policy, the TOTP gate, and
issues the **same** token pair as `/auth/mobile/login`.
| Method | Path | Auth | Body / Query | Purpose |
|---|---|---|---|---|
| GET | `/auth/providers` | — | — | **reused** discovery; the app renders provider buttons from this (never exposes secrets) |
| GET | `/auth/mobile/sso/start` | — (rate-limited per-IP + per-provider) | `?provider&code_challenge&state&redirect_uri` | validate provider enabled + `redirect_uri` **exact-match** allowlist; insert a `mobile_auth_sessions` row; create the existing `sso_tx` tagged `mode:'mobile'` carrying `session_id`; **302 to the IdP** (existing authorize URL) |
| GET | `/auth/sso/:provider/callback` | — (signed `sso_tx`) | `?code&state` | **existing** endpoint; a new branch when `tx.mode==='mobile'`: resolve the account (same policy as web login incl. TOTP), mint a single-use hashed authorization code into `mobile_auth_codes`, mark the session `completed`, and **302 to `redirect_uri?code=…&state=…`** (the app's original `state`) — **no cookie is set** |
| POST | `/auth/mobile/sso/exchange` | — (rate-limited per-IP) | `{code, code_verifier}` | validate the code exists / unexpired / unused (mark used) and `sha256(code_verifier)` matches the stored challenge → issue the existing mobile access + refresh pair (`createMobileSession`) → `{accessToken, refreshToken, expiresIn, user}` |
| POST | `/auth/mobile/refresh` | — | `{refreshToken}` | **reused** unchanged — rotate the pair |
| POST | `/auth/mobile/logout` | bearer | `{refreshToken?, all?}` | **reused** unchanged — revoke this (or all) refresh token(s) |
| GET | `/auth/me/sessions` · DELETE `…/:id` | cookie / bearer | — | list / revoke own **mobile sessions** (device_name, last_used_at, created_at) — the "Active Devices" surface (distinct from `/auth/me/devices`, which is push endpoints) |
**Two PKCE layers (do not conflate).**
- *Layer A (existing):* website ↔ IdP. The `code_verifier` is generated at `/start`, kept only in the
httpOnly `sso_tx` cookie, sent to the IdP token endpoint at the callback. Unchanged.
- *Layer B (new):* app ↔ website. The **app** generates `code_verifier`/`code_challenge`; the
challenge is stored in `mobile_auth_sessions` at `/start`; the verifier is presented at `/exchange`.
This is what stops an intercepted callback code from being redeemed by anyone but the real app.
**State / CSRF.** The app-generated `state` is stored at `/start`, echoed on the callback redirect,
and **verified by the app** before it calls `/exchange` — a CSRF guard independent of both PKCE
layers (a different app instance triggering `/start` cannot complete someone else's flow).
**Redirect-URI allowlist.** `/start` and the callback validate `redirect_uri` by **exact match**
against a configured allowlist (`MOBILE_AUTH_REDIRECT_URIS`, default the one fixed application-owned
callback `runicgateway://auth/callback`) — **never prefix match** (prefix matching on custom schemes
is a known open-redirect vector). Tokens are **never** placed in the callback URL — only the
short-lived authorization code.
*App Links (implemented).* When the admin toggle `mobile_app_links_enabled` is **on**, `/start` also
accepts the self-origin HTTPS callback `https://<request-host>/mobile/callback` — one *additive*
exact-match entry, derived from the request/`APP_BASE_URL` and never from client input; the
custom-scheme allowlist is never narrowed. The shard then auto-serves `GET
/.well-known/assetlinks.json` (fixed package `com.runicgateway.app` + `MOBILE_APP_CERT_SHA256`
fingerprints; 404 when the toggle is off or no fingerprint is configured), and
`settings.getPublic()` advertises `mobileAppLinks: <bool>`. These two things — one static file route
and one more allowlist entry — are the *entire* server surface App Links require. See
docs/android/APP_LINKS.md.
**TOTP through the bridge.** A 2FA account keeps full parity: the callback stages the existing
pending-TOTP cookie (now also carrying the bridge `session_id`) and bounces the Custom Tab through the
web TOTP form; on a correct code the completion mints the authorization code and deep-links back to
the app — it never mints a session cookie for a mobile flow.
**Revocation latency (documented tradeoff).** Revoking a refresh token (device revoke / logout) stops
future renewals but does **not** invalidate an already-issued access token until it expires — up to
the access-token lifetime (`MOBILE_ACCESS_TTL`, default 15 min) of continued access. This is an
accepted tradeoff given the short lifetime. If instant revocation is ever required, add an
access-token (jti) blocklist check on the `requireAuth` path — the same `revoked_sessions` mechanism
web sessions already use.
**Authorization code.** Cryptographically random, ≥128 bits, stored **hash-only**, single-use, short
expiry (~5 min); `/exchange` is rate-limited per-IP. The bridge tables self-prune (§3).
### /public (public.routes.js → public.controller.js) — all GET, no auth ### /public (public.routes.js → public.controller.js) — all GET, no auth
| Method | Path | Notes | | Method | Path | Notes |
|---|---|---| |---|---|---|
| GET | `/settings` | whitelisted public keys, derived `registration`/`gameAccountSignup` flags, the per-shard **`brand`** block (name, `accent` color, logo/hero/favicon) a client themes itself from — one image runs as any shard, asset fields may be site-relative paths (resolve against the base URL) — and a **`push`** block `{ ntfyUrl }` (M7): the client-facing ntfy relay URL the app's embedded distributor registers its device topic against, from `NTFY_PUBLIC_URL` / first `NTFY_ALLOWED_ORIGINS` (never the internal `NTFY_BASE_URL`); `null` when push isn't configured for the shard. | | GET | `/settings` | whitelisted public keys, derived `registration`/`gameAccountSignup` flags, and the per-shard **`brand`** block (name, `accent` color, logo/hero/favicon) a client themes itself from — one image runs as any shard. Asset fields may be site-relative paths (resolve against the base URL). |
| GET | `/status` | status message + current mode, **plus a `version` block** (`{ service:'runic-gateway', api, server }`) so a client first-run probe recognizes the backend and can run a version-mismatch guard | | GET | `/status` | status message + current mode, **plus a `version` block** (`{ service:'runic-gateway', api, server }`) so a client first-run probe recognizes the backend and can run a version-mismatch guard |
| GET | `/version` | lightweight, **DB-free** backend identity/version (`{ service, api, server }`) — the canonical target for the version guard and a cheap liveness check | | GET | `/version` | lightweight, **DB-free** backend identity/version (`{ service, api, server }`) — the canonical target for the version guard and a cheap liveness check |
| GET | `/posts/:category` | published only; `category` ∈ news\|five-on-friday\|newsletter\|screenshots | | GET | `/posts/:category` | published only; `category` ∈ news\|five-on-friday\|newsletter\|screenshots |
@@ -489,17 +206,7 @@ expiry (~5 min); `/exchange` is rate-limited per-IP. The bridge tables self-prun
Public content GETs pass through the **siteMode** gate (§5). Public content GETs pass through the **siteMode** gate (§5).
### /admin (admin/index.js → the capability routers in §2) — all behind `isLoggedIn` + `noindex` + `staffOnly` ### /admin (admin.routes.js → admin.controller.js) — all behind `isLoggedIn` + `noindex`
`admin/index.js` applies the shared gate and mounts each capability router at the prefix it owns;
`users`, `invites`, `auth/providers` and `bot-activity` add `adminOnly` on top, and `moderation` adds
`modAccess` (admin + moderator, so editors are excluded). The content capabilities — `posts`,
`uploads`, `wiki`, `pages` — add nothing: managing content is the editor tier's job, so `staffOnly` is
the whole gate. The ops/config capabilities — `uo-link`, `email`, `discord-bot`, `settings`, and
`PUT /site-mode` — are `adminOnly`; `shard` is the one mixed prefix, where self-service account
linking carries no extra gate and the in-game staff operations carry `modAccess`. There is no residual
file: every admin route is declared in a capability router. The URLs below are unaffected by which
file a route sits in — that is the property the route manifest freezes.
| Method | Path | Purpose | | Method | Path | Purpose |
|---|---|---| |---|---|---|
| GET | `/dashboard` | current mode, last change time + who, content counts, recent activity | | GET | `/dashboard` | current mode, last change time + who, content counts, recent activity |
@@ -516,9 +223,6 @@ file a route sits in — that is the property the route manifest freezes.
| GET | `/settings` · PUT `/settings` | read all / update `{key:value,...}` | | GET | `/settings` · PUT `/settings` | read all / update `{key:value,...}` |
| GET | `/activity?limit=&offset=` | paginated activity log | | GET | `/activity?limit=&offset=` | paginated activity log |
| GET | `/users` · POST `/users` · PUT `/users/:id` · DELETE `/users/:id` | user mgmt (can't delete self / last admin; password hashed on write) | | GET | `/users` · POST `/users` · PUT `/users/:id` · DELETE `/users/:id` | user mgmt (can't delete self / last admin; password hashed on write) |
| GET | `/users/:id/trusted-devices` | list a user's active trusted devices (never tokens) |
| DELETE | `/users/:id/trusted-devices` · `…/:deviceId` | revoke all / one of a user's trusted devices (logs `admin.trusted_device.revoke[_all]`) |
| POST | `/users/:id/mfa/reset` | recover a locked-out user: disable TOTP + revoke all trusted devices + clear recovery codes (logs `admin.user.totp.reset`) |
Every admin write logs to `activity_log`. Every admin write logs to `activity_log`.
@@ -549,47 +253,11 @@ who"; `activity_log` provides the history feed.
## 6. Auth & security ## 6. Auth & security
- **JWT** signed with `JWT_SECRET`, `expiresIn=JWT_EXPIRES_IN` (default `1d`); payload `{id,username,role}`. - **JWT** signed with `JWT_SECRET`, `expiresIn=JWT_EXPIRES_IN` (default `1d`); payload `{id,username,role}`.
- **Cookie**: `httpOnly`, `sameSite=Lax`, `path=/`, and **`secure` decided per-request** (`COOKIE_SECURE=auto``secure: req.secure`). - **Cookie**: `httpOnly`, `sameSite=Lax`, `path=/`, and **`secure` decided per-request** (`COOKIE_SECURE=auto``secure: req.secure`). This is the key to dual access: the cookie is `Secure` when reached through Pangolin (HTTPS, `X-Forwarded-Proto: https`) but **not** `Secure` when reached directly over the LAN IP on plain HTTP — so login works in both. `COOKIE_SECURE=true|false` can force it. Requires `trust proxy` (below). `localhost:5173` (Vite) and `localhost:3000` are same-site, so the cookie flows in dev too.
- **Trusted-device MFA.** A second, separate httpOnly cookie (`rg_trust`, default 30d) — opaque, sha256-hashed server-side in `trusted_devices` — lets a browser/app **skip the TOTP step** (never the password) on future logins. It is a server-side, per-row-revocable record (never a JWT claim), so the stateless session JWT is unchanged and trust stays revocable. It only ever gates the **second factor**; it deliberately outlives logout, and is cleared on untrust / password change / password reset / TOTP disable. **Recovery codes** (bcrypt, single-use) are the 2FA-lockout fallback. All admin trusted-device/MFA actions and the self actions (`auth.login.trusted_device`, `account.trusted_device.*`, `account.recovery_code*`, `admin.trusted_device.*`, `admin.user.totp.reset`) are audit-logged. See `docs/website/TRUSTED_DEVICES_MFA.md`. This is the key to dual access: the cookie is `Secure` when reached through Pangolin (HTTPS, `X-Forwarded-Proto: https`) but **not** `Secure` when reached directly over the LAN IP on plain HTTP — so login works in both. `COOKIE_SECURE=true|false` can force it. Requires `trust proxy` (below). `localhost:5173` (Vite) and `localhost:3000` are same-site, so the cookie flows in dev too.
- **bcrypt** hashing (cost 10+); plaintext passwords never stored, logged, or returned. - **bcrypt** hashing (cost 10+); plaintext passwords never stored, logged, or returned.
- **Rate limiting** (`express-rate-limit`) on `/auth/login` and `/public/contact`. - **Rate limiting** (`express-rate-limit`) on `/auth/login` and `/public/contact`.
- **Validation** (`express-validator`) on all writes; centralized error handler. - **Validation** (`express-validator`) on all writes; centralized error handler.
- **helmet** with a Content-Security-Policy tuned for the built React SPA. The policies now live in - **helmet** with a CSP suited to the SPA (self + inline styles as needed; image sources for uploads/hero).
**`server/src/config/csp.js`** (`app.js` only wires them up):
`default-src 'self'`; `script-src 'self'` (the Vite build emits only external module chunks — the
inline module-preload polyfill is disabled in `client/vite.config.js` to keep this valid);
`style-src 'self' 'unsafe-inline' https://fonts.googleapis.com` (React's pervasive inline
`style={{…}}` attributes can't be nonce'd, plus the Google Fonts stylesheet); `font-src 'self'
https://fonts.gstatic.com` (Cinzel); `img-src 'self' data: https:` (same-origin uploads, plus
external https images embedded in wiki/news bodies or `BRAND_*` logo/hero/favicon); `connect-src
'self'` (REST + SSE are same-origin); `frame-ancestors 'self'`; `object-src 'none'`; `base-uri
'self'`; `form-action 'self'` (blocks an injected `<form action="https://evil">` from POSTing
credentials off-origin — an exfil path `connect-src` does not cover; it was always emitted via
helmet's `useDefaults` and is now pinned explicitly so it cannot vanish under a helmet upgrade).
`upgrade-insecure-requests` is intentionally **not** set (TLS terminates at the proxy, there
are no mixed-content subresources, and it would break a local `npm start` over plain http). The
`/api/docs` Swagger UI route gets a **looser** policy that additionally allows inline script/style,
since swagger-ui-express injects an inline bootstrap. helmet also strips `X-Powered-By`; the two
internal-only listeners (`internalApp.js`, `bot/src/app.js`) disable it explicitly too.
- **A second, tightened policy ships alongside on `Content-Security-Policy-Report-Only`** for one
release before it replaces the enforced one (`docs/website/API_V2_PLAN.md` § Phase 1). It is derived
from the enforced policy so the two cannot drift, and differs by exactly one directive:
`frame-ancestors 'self'`**`'none'`**. Serving both headers at once means the live policy keeps
protecting users while anything the tightened version would break arrives as a report rather than as
a broken page — and for `frame-ancestors` specifically, a report from the browser of whoever framed
the site is the only way to learn that something does.
- **`POST /api/csp-report`** is the same-origin violation sink that `report-to` / `report-uri` point at
(`report-to` additionally requires the `Reporting-Endpoints` response header, which is set alongside).
Same-origin on purpose: reports describe attacks against this site and are not handed to a
third-party collector. It parses **both** wire formats (`application/csp-report` from Firefox/Safari,
`application/reports+json` from Chrome's Reporting API — handling one drops half the browsers),
writes to the `csp` log tag and **stores nothing**. Necessarily unauthenticated (browsers send
reports with no session), so it is bounded on every axis: 16 KB body cap, per-IP rate limit, fixed
field allowlist, every logged field truncated, and **always 204 — even for malformed input**, since a
4xx would make the global error handler log the attacker-supplied body and turn an open endpoint into
a log-flood primitive. Mounted outside `/api/v1` next to `/api/health`: the browser learns the path
from the policy header, never from a client build, so it is not part of the versioned client
contract.
- **Admin not indexed**: `X-Robots-Tag: noindex, nofollow` on `/api/v1/admin` and the admin SPA routes; `robots.txt` disallows `/admin`. - **Admin not indexed**: `X-Robots-Tag: noindex, nofollow` on `/api/v1/admin` and the admin SPA routes; `robots.txt` disallows `/admin`.
- **No directory browsing** (express.static doesn't list; no `serve-index`). - **No directory browsing** (express.static doesn't list; no `serve-index`).
- **No hardcoded credentials**: first admin via `seed.js` reading `ADMIN_USERNAME`/`ADMIN_PASSWORD` from env (created only if no users exist); `.env` git-ignored, `.env.example` committed. - **No hardcoded credentials**: first admin via `seed.js` reading `ADMIN_USERNAME`/`ADMIN_PASSWORD` from env (created only if no users exist); `.env` git-ignored, `.env.example` committed.
@@ -614,11 +282,7 @@ instead. Errors never leak credentials.
`utils/logger.js` — a small dependency-free logger with **two transports, console + file**, `utils/logger.js` — a small dependency-free logger with **two transports, console + file**,
and four levels (`error`/`warn`/`info`/`debug`). Each line is timestamped and tagged by and four levels (`error`/`warn`/`info`/`debug`). Each line is timestamped and tagged by
subsystem (`[server]`, `[http]`, `[db]`, `[auth]`, `[admin]`, `[ratelimit]`, `[csp]`, …). subsystem (`[server]`, `[http]`, `[db]`, `[auth]`, `[admin]`, `[ratelimit]`, …).
> During the CSP report-only soak, `[csp]` is the tag to watch: a `csp violation` warn line with
> `directive: frame-ancestors` means something really does frame the site and the enforce PR would
> break it. Silence across one release is the green light to flip.
- **Console**: color on a TTY, plain in Docker; verbosity = `LOG_LEVEL` (default `info`). - **Console**: color on a TTY, plain in Docker; verbosity = `LOG_LEVEL` (default `info`).
- **File**: plain text appended to `LOG_DIR/LOG_FILE` (default `<server>/logs/app.log`, - **File**: plain text appended to `LOG_DIR/LOG_FILE` (default `<server>/logs/app.log`,
@@ -640,15 +304,7 @@ subsystem (`[server]`, `[http]`, `[db]`, `[auth]`, `[admin]`, `[ratelimit]`, `[c
- `app`: builds the Dockerfile (installs client+server, builds Vite, serves via Express), - `app`: builds the Dockerfile (installs client+server, builds Vite, serves via Express),
`env_file: .env`, `DB_HOST=db`, `depends_on: db (healthy)`, volume `uploads:/app/uploads`, `env_file: .env`, `DB_HOST=db`, `depends_on: db (healthy)`, volume `uploads:/app/uploads`,
`ports: "3000:3000"`**binds 0.0.0.0** (no `127.0.0.1:` prefix) so Pangolin reaches it. `ports: "3000:3000"`**binds 0.0.0.0** (no `127.0.0.1:` prefix) so Pangolin reaches it.
- `ntfy` (M7): pinned upstream `binwiederhier/ntfy` image, declarative config only - Volumes: `dbdata`, `uploads`.
(`./ntfy/server.yml` mounted `:ro` + `NTFY_BASE_URL`), volume `ntfydata:/var/lib/ntfy`,
**publishes `:80` on a host port** (`${NTFY_HOST_PORT:-2586}:80`, binds 0.0.0.0) so Pangolin — which
runs outside the compose network — can forward the notification subdomain to it, the same reason
`app` publishes `3000`. Both devices (SSE subscribe) and the backend publisher (POSTing tickles to
registered device endpoints) reach ntfy on that public origin. Anonymous read-write to unguessable
topics (no accounts to provision) — safe because pushes are content-free tickles. Bringing the stack
up provisions a working push relay with **zero interactive setup**.
- Volumes: `dbdata`, `uploads`, `ntfydata`.
Express listens on `0.0.0.0:${PORT||3000}`. Pangolin terminates TLS and proxies to `app`. Express listens on `0.0.0.0:${PORT||3000}`. Pangolin terminates TLS and proxies to `app`.
@@ -670,19 +326,6 @@ ADMIN_USERNAME=
ADMIN_PASSWORD= ADMIN_PASSWORD=
# Email: configured in Admin → Settings → Email (Gmail OAuth2), not via env # Email: configured in Admin → Settings → Email (Gmail OAuth2), not via env
CLIENT_ORIGIN=http://localhost:5173 CLIENT_ORIGIN=http://localhost:5173
# Push (M7): the ntfy relay URL — also the backend's SSRF allow-set for device
# endpoints. NTFY_ALLOWED_ORIGINS / NTFY_PUBLISH_TOKEN are optional.
NTFY_BASE_URL=https://ntfy.example.com
# The client-facing ntfy URL surfaced to the app via /public/settings.push.ntfyUrl
# (the app registers its topic endpoint here). Defaults to the first
# NTFY_ALLOWED_ORIGINS entry; set explicitly when the public URL differs from the
# internal NTFY_BASE_URL. Without it (and without NTFY_ALLOWED_ORIGINS) the app
# shows push as unavailable for the shard.
NTFY_PUBLIC_URL=https://ntfy.example.com
NTFY_ALLOWED_ORIGINS=https://ntfy.example.com
# Host port the ntfy container publishes :80 on (default 2586); the reverse proxy
# forwards the notification subdomain to host:NTFY_HOST_PORT. Change on a conflict.
NTFY_HOST_PORT=2586
``` ```
`.gitignore`: `node_modules/`, `.env`, `_reference/`, `client/dist/`, `uploads/`. `.gitignore`: `node_modules/`, `.env`, `_reference/`, `client/dist/`, `uploads/`.

View File

@@ -1,550 +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
│ ├── scripts/
│ │ └── routeManifest.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
│ │ │ ├── csp.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
│ │ │ │ │ ├── account.router.js
│ │ │ │ │ ├── activity.router.js
│ │ │ │ │ ├── admin.controller.js
│ │ │ │ │ ├── admin.routes.js
│ │ │ │ │ ├── authProviders.controller.js
│ │ │ │ │ ├── authProviders.router.js
│ │ │ │ │ ├── botActivity.controller.js
│ │ │ │ │ ├── botActivity.router.js
│ │ │ │ │ ├── discordBot.controller.js
│ │ │ │ │ ├── emailConfig.controller.js
│ │ │ │ │ ├── index.js
│ │ │ │ │ ├── invites.controller.js
│ │ │ │ │ ├── invites.router.js
│ │ │ │ │ ├── moderation.controller.js
│ │ │ │ │ ├── moderation.router.js
│ │ │ │ │ ├── pages.controller.js
│ │ │ │ │ ├── shardOps.controller.js
│ │ │ │ │ ├── uoLink.controller.js
│ │ │ │ │ ├── users.router.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
│ │ │ ├── cspReport.controller.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
│ │ ├── csp.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
│ │ ├── routeManifest.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
│ ├── routes.guards.json
│ └── routes.manifest.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

@@ -54,7 +54,7 @@ auth model (admin/editor — no new roles, no public contributions).
| Schema | flat `wiki_pages(slug,title,body,updated_by,timestamps)` | [server/db/schema.sql:31](server/db/schema.sql) | | Schema | flat `wiki_pages(slug,title,body,updated_by,timestamps)` | [server/db/schema.sql:31](server/db/schema.sql) |
| Model | thin CRUD by slug | [server/src/model/wiki/wiki.db.js](server/src/model/wiki/wiki.db.js), [wiki.model.js](server/src/model/wiki/wiki.model.js) | | Model | thin CRUD by slug | [server/src/model/wiki/wiki.db.js](server/src/model/wiki/wiki.db.js), [wiki.model.js](server/src/model/wiki/wiki.model.js) |
| Public API | `GET /public/wiki`, `GET /public/wiki/:slug` | [public.controller.js:53](server/src/router/v1/public/public.controller.js) | | Public API | `GET /public/wiki`, `GET /public/wiki/:slug` | [public.controller.js:53](server/src/router/v1/public/public.controller.js) |
| Admin API | `GET/POST/PUT/DELETE /admin/wiki[...]` | [admin.controller.js:163](server/src/router/v1/admin/admin.controller.js), [wiki.router.js](server/src/router/v1/admin/wiki.router.js) | | Admin API | `GET/POST/PUT/DELETE /admin/wiki[...]` | [admin.controller.js:163](server/src/router/v1/admin/admin.controller.js), [admin.routes.js:68](server/src/router/v1/admin/admin.routes.js) |
| Public UI | card grid (hardcoded blurbs + Roman numerals), article w/ auto-TOC | [Wiki.jsx](client/src/routes/wiki/Wiki.jsx), [WikiArticle.jsx](client/src/routes/wiki/WikiArticle.jsx) | | Public UI | card grid (hardcoded blurbs + Roman numerals), article w/ auto-TOC | [Wiki.jsx](client/src/routes/wiki/Wiki.jsx), [WikiArticle.jsx](client/src/routes/wiki/WikiArticle.jsx) |
| Admin UI | raw-HTML `<textarea>` modal | [WikiAdmin.jsx](client/src/routes/admin/views/WikiAdmin.jsx), [WikiEditor.jsx](client/src/routes/admin/views/WikiEditor.jsx) | | Admin UI | raw-HTML `<textarea>` modal | [WikiAdmin.jsx](client/src/routes/admin/views/WikiAdmin.jsx), [WikiEditor.jsx](client/src/routes/admin/views/WikiEditor.jsx) |
| API client | `api.wiki`, `api.admin.*Wiki` | [client/src/api/client.js:52](client/src/api/client.js) | | API client | `api.wiki`, `api.admin.*Wiki` | [client/src/api/client.js:52](client/src/api/client.js) |
@@ -196,7 +196,7 @@ centralized error handler unchanged. **Every write logs to `activity_log`**
### 4.4 Image uploads ### 4.4 Image uploads
Generalize the existing screenshot upload (multer config now in [admin/imageUpload.js](server/src/router/v1/admin/imageUpload.js)) Generalize the existing screenshot upload (multer config in [admin.routes.js:17](server/src/router/v1/admin/admin.routes.js))
into a shared `POST /admin/uploads` returning `{ url: "/uploads/<file>" }`, reused by both into a shared `POST /admin/uploads` returning `{ url: "/uploads/<file>" }`, reused by both
the post editor and the wiki editor. Same size/mime limits. No new storage — the post editor and the wiki editor. Same size/mime limits. No new storage —
served from the existing `uploads/` volume. served from the existing `uploads/` volume.

View File

@@ -1,815 +0,0 @@
{
"$comment": "Generated route inventory - the authoritative freeze of the URL surface. Regenerate with `npm run routes:manifest` in website/server; a domain-split PR must produce a zero-line diff here.",
"public": [
{
"method": "GET",
"path": "/.well-known/assetlinks.json"
},
{
"method": "POST",
"path": "/api/csp-report"
},
{
"method": "GET",
"path": "/api/docs.json"
},
{
"method": "GET",
"path": "/api/health"
},
{
"method": "GET",
"path": "/api/v1/admin/account"
},
{
"method": "GET",
"path": "/api/v1/admin/account/identities"
},
{
"method": "DELETE",
"path": "/api/v1/admin/account/identities/:provider"
},
{
"method": "POST",
"path": "/api/v1/admin/account/totp/disable"
},
{
"method": "POST",
"path": "/api/v1/admin/account/totp/enable"
},
{
"method": "POST",
"path": "/api/v1/admin/account/totp/setup"
},
{
"method": "GET",
"path": "/api/v1/admin/activity"
},
{
"method": "GET",
"path": "/api/v1/admin/auth/providers"
},
{
"method": "POST",
"path": "/api/v1/admin/auth/providers"
},
{
"method": "DELETE",
"path": "/api/v1/admin/auth/providers/:id"
},
{
"method": "PUT",
"path": "/api/v1/admin/auth/providers/:id"
},
{
"method": "GET",
"path": "/api/v1/admin/bot-activity"
},
{
"method": "POST",
"path": "/api/v1/admin/bot-activity/unban"
},
{
"method": "GET",
"path": "/api/v1/admin/dashboard"
},
{
"method": "GET",
"path": "/api/v1/admin/discord-bot/config"
},
{
"method": "PUT",
"path": "/api/v1/admin/discord-bot/config"
},
{
"method": "GET",
"path": "/api/v1/admin/email/config"
},
{
"method": "PUT",
"path": "/api/v1/admin/email/config"
},
{
"method": "GET",
"path": "/api/v1/admin/email/connect/callback"
},
{
"method": "GET",
"path": "/api/v1/admin/email/connect/start"
},
{
"method": "POST",
"path": "/api/v1/admin/email/disconnect"
},
{
"method": "POST",
"path": "/api/v1/admin/email/test"
},
{
"method": "GET",
"path": "/api/v1/admin/invites"
},
{
"method": "POST",
"path": "/api/v1/admin/invites"
},
{
"method": "DELETE",
"path": "/api/v1/admin/invites/:id"
},
{
"method": "GET",
"path": "/api/v1/admin/moderation/appeals"
},
{
"method": "GET",
"path": "/api/v1/admin/moderation/appeals/:id"
},
{
"method": "POST",
"path": "/api/v1/admin/moderation/appeals/:id/claim"
},
{
"method": "POST",
"path": "/api/v1/admin/moderation/appeals/:id/resolve"
},
{
"method": "GET",
"path": "/api/v1/admin/moderation/filter-hits"
},
{
"method": "GET",
"path": "/api/v1/admin/moderation/members"
},
{
"method": "GET",
"path": "/api/v1/admin/moderation/recent"
},
{
"method": "GET",
"path": "/api/v1/admin/moderation/search"
},
{
"method": "GET",
"path": "/api/v1/admin/moderation/spam-hits"
},
{
"method": "GET",
"path": "/api/v1/admin/moderation/stats/summary"
},
{
"method": "GET",
"path": "/api/v1/admin/moderation/user/:discordId"
},
{
"method": "GET",
"path": "/api/v1/admin/moderation/user/:discordId/actions"
},
{
"method": "GET",
"path": "/api/v1/admin/moderation/user/:discordId/appeals"
},
{
"method": "GET",
"path": "/api/v1/admin/moderation/user/:discordId/notes"
},
{
"method": "POST",
"path": "/api/v1/admin/moderation/user/:discordId/notes"
},
{
"method": "GET",
"path": "/api/v1/admin/pages"
},
{
"method": "POST",
"path": "/api/v1/admin/pages"
},
{
"method": "DELETE",
"path": "/api/v1/admin/pages/:id"
},
{
"method": "GET",
"path": "/api/v1/admin/pages/:id"
},
{
"method": "PATCH",
"path": "/api/v1/admin/pages/:id"
},
{
"method": "POST",
"path": "/api/v1/admin/pages/:id/preview"
},
{
"method": "POST",
"path": "/api/v1/admin/pages/:id/unprotect"
},
{
"method": "GET",
"path": "/api/v1/admin/posts"
},
{
"method": "POST",
"path": "/api/v1/admin/posts"
},
{
"method": "DELETE",
"path": "/api/v1/admin/posts/:id"
},
{
"method": "GET",
"path": "/api/v1/admin/posts/:id"
},
{
"method": "PUT",
"path": "/api/v1/admin/posts/:id"
},
{
"method": "GET",
"path": "/api/v1/admin/posts/:id/announce"
},
{
"method": "POST",
"path": "/api/v1/admin/posts/:id/announce/retry"
},
{
"method": "PATCH",
"path": "/api/v1/admin/posts/:id/publish"
},
{
"method": "POST",
"path": "/api/v1/admin/posts/upload"
},
{
"method": "GET",
"path": "/api/v1/admin/settings"
},
{
"method": "PUT",
"path": "/api/v1/admin/settings"
},
{
"method": "POST",
"path": "/api/v1/admin/shard/account"
},
{
"method": "GET",
"path": "/api/v1/admin/shard/accounts"
},
{
"method": "GET",
"path": "/api/v1/admin/shard/audit"
},
{
"method": "POST",
"path": "/api/v1/admin/shard/ban"
},
{
"method": "POST",
"path": "/api/v1/admin/shard/broadcast"
},
{
"method": "GET",
"path": "/api/v1/admin/shard/char/:serial"
},
{
"method": "GET",
"path": "/api/v1/admin/shard/houses"
},
{
"method": "POST",
"path": "/api/v1/admin/shard/kick"
},
{
"method": "POST",
"path": "/api/v1/admin/shard/link"
},
{
"method": "GET",
"path": "/api/v1/admin/shard/pages"
},
{
"method": "POST",
"path": "/api/v1/admin/shard/pages/:id/close"
},
{
"method": "POST",
"path": "/api/v1/admin/shard/pages/:id/respond"
},
{
"method": "GET",
"path": "/api/v1/admin/shard/roster/:account"
},
{
"method": "GET",
"path": "/api/v1/admin/shard/sales"
},
{
"method": "POST",
"path": "/api/v1/admin/shard/unban"
},
{
"method": "GET",
"path": "/api/v1/admin/shard/vendors/:account"
},
{
"method": "PUT",
"path": "/api/v1/admin/site-mode"
},
{
"method": "GET",
"path": "/api/v1/admin/uo-link/config"
},
{
"method": "PUT",
"path": "/api/v1/admin/uo-link/config"
},
{
"method": "GET",
"path": "/api/v1/admin/uo-link/stream"
},
{
"method": "POST",
"path": "/api/v1/admin/uo-link/towncrier"
},
{
"method": "DELETE",
"path": "/api/v1/admin/uo-link/towncrier/:id"
},
{
"method": "POST",
"path": "/api/v1/admin/uploads"
},
{
"method": "GET",
"path": "/api/v1/admin/users"
},
{
"method": "POST",
"path": "/api/v1/admin/users"
},
{
"method": "DELETE",
"path": "/api/v1/admin/users/:id"
},
{
"method": "GET",
"path": "/api/v1/admin/users/:id"
},
{
"method": "PUT",
"path": "/api/v1/admin/users/:id"
},
{
"method": "POST",
"path": "/api/v1/admin/users/:id/mfa/reset"
},
{
"method": "GET",
"path": "/api/v1/admin/users/:id/shard/accounts"
},
{
"method": "GET",
"path": "/api/v1/admin/users/:id/shard/houses"
},
{
"method": "DELETE",
"path": "/api/v1/admin/users/:id/shard/link/:account"
},
{
"method": "GET",
"path": "/api/v1/admin/users/:id/shard/online"
},
{
"method": "GET",
"path": "/api/v1/admin/users/:id/shard/sales"
},
{
"method": "GET",
"path": "/api/v1/admin/users/:id/shard/standing"
},
{
"method": "DELETE",
"path": "/api/v1/admin/users/:id/trusted-devices"
},
{
"method": "GET",
"path": "/api/v1/admin/users/:id/trusted-devices"
},
{
"method": "DELETE",
"path": "/api/v1/admin/users/:id/trusted-devices/:deviceId"
},
{
"method": "GET",
"path": "/api/v1/admin/wiki"
},
{
"method": "POST",
"path": "/api/v1/admin/wiki"
},
{
"method": "DELETE",
"path": "/api/v1/admin/wiki/:slug"
},
{
"method": "GET",
"path": "/api/v1/admin/wiki/:slug"
},
{
"method": "PUT",
"path": "/api/v1/admin/wiki/:slug"
},
{
"method": "PATCH",
"path": "/api/v1/admin/wiki/:slug/publish"
},
{
"method": "GET",
"path": "/api/v1/admin/wiki/:slug/revisions"
},
{
"method": "GET",
"path": "/api/v1/admin/wiki/:slug/revisions/:id"
},
{
"method": "POST",
"path": "/api/v1/admin/wiki/:slug/revisions/:id/restore"
},
{
"method": "GET",
"path": "/api/v1/admin/wiki/categories"
},
{
"method": "POST",
"path": "/api/v1/admin/wiki/categories"
},
{
"method": "DELETE",
"path": "/api/v1/admin/wiki/categories/:id"
},
{
"method": "PUT",
"path": "/api/v1/admin/wiki/categories/:id"
},
{
"method": "GET",
"path": "/api/v1/admin/wiki/tags"
},
{
"method": "GET",
"path": "/api/v1/auth/invite/:token"
},
{
"method": "POST",
"path": "/api/v1/auth/invite/:token/accept"
},
{
"method": "POST",
"path": "/api/v1/auth/login"
},
{
"method": "POST",
"path": "/api/v1/auth/login/totp"
},
{
"method": "POST",
"path": "/api/v1/auth/logout"
},
{
"method": "GET",
"path": "/api/v1/auth/me"
},
{
"method": "GET",
"path": "/api/v1/auth/me/account"
},
{
"method": "GET",
"path": "/api/v1/auth/me/account/identities"
},
{
"method": "DELETE",
"path": "/api/v1/auth/me/account/identities/:provider"
},
{
"method": "PATCH",
"path": "/api/v1/auth/me/account/password"
},
{
"method": "POST",
"path": "/api/v1/auth/me/account/recovery-codes/generate"
},
{
"method": "GET",
"path": "/api/v1/auth/me/account/recovery-codes/status"
},
{
"method": "POST",
"path": "/api/v1/auth/me/account/totp/disable"
},
{
"method": "POST",
"path": "/api/v1/auth/me/account/totp/enable"
},
{
"method": "POST",
"path": "/api/v1/auth/me/account/totp/setup"
},
{
"method": "PATCH",
"path": "/api/v1/auth/me/account/username"
},
{
"method": "GET",
"path": "/api/v1/auth/me/devices"
},
{
"method": "POST",
"path": "/api/v1/auth/me/devices"
},
{
"method": "DELETE",
"path": "/api/v1/auth/me/devices/:id"
},
{
"method": "GET",
"path": "/api/v1/auth/me/notifications/streams"
},
{
"method": "GET",
"path": "/api/v1/auth/me/notifications/subscriptions"
},
{
"method": "PUT",
"path": "/api/v1/auth/me/notifications/subscriptions"
},
{
"method": "GET",
"path": "/api/v1/auth/me/sessions"
},
{
"method": "DELETE",
"path": "/api/v1/auth/me/sessions/:id"
},
{
"method": "DELETE",
"path": "/api/v1/auth/me/trusted-devices"
},
{
"method": "GET",
"path": "/api/v1/auth/me/trusted-devices"
},
{
"method": "POST",
"path": "/api/v1/auth/me/trusted-devices"
},
{
"method": "DELETE",
"path": "/api/v1/auth/me/trusted-devices/:id"
},
{
"method": "POST",
"path": "/api/v1/auth/mobile/login"
},
{
"method": "POST",
"path": "/api/v1/auth/mobile/logout"
},
{
"method": "POST",
"path": "/api/v1/auth/mobile/refresh"
},
{
"method": "POST",
"path": "/api/v1/auth/mobile/sso/exchange"
},
{
"method": "GET",
"path": "/api/v1/auth/mobile/sso/start"
},
{
"method": "POST",
"path": "/api/v1/auth/password/forgot"
},
{
"method": "GET",
"path": "/api/v1/auth/password/reset/:token"
},
{
"method": "POST",
"path": "/api/v1/auth/password/reset/:token"
},
{
"method": "GET",
"path": "/api/v1/auth/providers"
},
{
"method": "POST",
"path": "/api/v1/auth/register"
},
{
"method": "GET",
"path": "/api/v1/auth/sso/:provider/callback"
},
{
"method": "GET",
"path": "/api/v1/auth/sso/:provider/link"
},
{
"method": "GET",
"path": "/api/v1/auth/sso/:provider/start"
},
{
"method": "POST",
"path": "/api/v1/auth/sso/totp"
},
{
"method": "GET",
"path": "/api/v1/player/account"
},
{
"method": "GET",
"path": "/api/v1/player/account/identities"
},
{
"method": "DELETE",
"path": "/api/v1/player/account/identities/:provider"
},
{
"method": "PATCH",
"path": "/api/v1/player/account/password"
},
{
"method": "POST",
"path": "/api/v1/player/account/totp/disable"
},
{
"method": "POST",
"path": "/api/v1/player/account/totp/enable"
},
{
"method": "POST",
"path": "/api/v1/player/account/totp/setup"
},
{
"method": "PATCH",
"path": "/api/v1/player/account/username"
},
{
"method": "GET",
"path": "/api/v1/player/appeals"
},
{
"method": "POST",
"path": "/api/v1/player/appeals"
},
{
"method": "POST",
"path": "/api/v1/player/appeals/:id/withdraw"
},
{
"method": "GET",
"path": "/api/v1/player/appeals/eligible"
},
{
"method": "POST",
"path": "/api/v1/player/shard/account"
},
{
"method": "GET",
"path": "/api/v1/player/shard/accounts"
},
{
"method": "GET",
"path": "/api/v1/player/shard/char/:serial"
},
{
"method": "GET",
"path": "/api/v1/player/shard/houses"
},
{
"method": "POST",
"path": "/api/v1/player/shard/link"
},
{
"method": "GET",
"path": "/api/v1/player/shard/roster/:account"
},
{
"method": "GET",
"path": "/api/v1/player/shard/sales"
},
{
"method": "GET",
"path": "/api/v1/player/shard/vendors/:account"
},
{
"method": "POST",
"path": "/api/v1/public/contact"
},
{
"method": "GET",
"path": "/api/v1/public/pages/:id/preview/:token"
},
{
"method": "GET",
"path": "/api/v1/public/pages/:slug"
},
{
"method": "GET",
"path": "/api/v1/public/posts/:category"
},
{
"method": "GET",
"path": "/api/v1/public/posts/:category/:idOrSlug"
},
{
"method": "GET",
"path": "/api/v1/public/settings"
},
{
"method": "GET",
"path": "/api/v1/public/shard/champs"
},
{
"method": "GET",
"path": "/api/v1/public/shard/economy"
},
{
"method": "GET",
"path": "/api/v1/public/shard/feed"
},
{
"method": "GET",
"path": "/api/v1/public/shard/governors"
},
{
"method": "GET",
"path": "/api/v1/public/shard/governors/:city/history"
},
{
"method": "GET",
"path": "/api/v1/public/shard/guilds"
},
{
"method": "GET",
"path": "/api/v1/public/shard/houses"
},
{
"method": "GET",
"path": "/api/v1/public/shard/idoc"
},
{
"method": "GET",
"path": "/api/v1/public/shard/online"
},
{
"method": "GET",
"path": "/api/v1/public/shard/presence"
},
{
"method": "GET",
"path": "/api/v1/public/shard/status"
},
{
"method": "GET",
"path": "/api/v1/public/shard/stream"
},
{
"method": "GET",
"path": "/api/v1/public/status"
},
{
"method": "GET",
"path": "/api/v1/public/version"
},
{
"method": "GET",
"path": "/api/v1/public/wiki"
},
{
"method": "GET",
"path": "/api/v1/public/wiki/:slug"
},
{
"method": "GET",
"path": "/api/v1/public/wiki/categories"
},
{
"method": "GET",
"path": "/api/v1/public/wiki/tags"
}
],
"internal": [
{
"method": "GET",
"path": "/health"
},
{
"method": "GET",
"path": "/internal/bot-config"
}
]
}

View File

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

View File

@@ -18,7 +18,6 @@ The design reference is [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (API contract, sc
## Contents ## Contents
- [Architecture](#architecture)
- [Tech stack](#tech-stack) - [Tech stack](#tech-stack)
- [Project structure](#project-structure) - [Project structure](#project-structure)
- [Prerequisites](#prerequisites) - [Prerequisites](#prerequisites)
@@ -38,103 +37,6 @@ The design reference is [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (API contract, sc
--- ---
## Architecture
How the pieces fit together — the React SPA and native app talk to one Express backend
(`router → controller → model → db`), which persists to MariaDB and bridges to the live
game world only through the **uo-link** sidecar. The shard itself is never internet-facing.
See [ARCHITECTURE.md](ARCHITECTURE.md) for the fuller write-up.
```mermaid
flowchart TB
%% ---------- Clients ----------
subgraph clients["Clients"]
browser["Browser<br/>React + Vite SPA<br/>(public · wiki · admin)"]
mobile["Native mobile app<br/>(bearer tokens)"]
end
idp["SSO providers<br/>Google · Discord · custom OIDC"]
discord["Discord"]
%% ---------- Website (one repo) ----------
subgraph website["website/ &nbsp;— Node app (one repo)"]
direction TB
subgraph backend["server/ — Express backend"]
direction TB
mw["Middleware<br/>helmet · siteMode · noindex<br/>rateLimit · loginProtection · botScore · validate"]
router["Router /api/v1<br/>auth (web · mobile · sso) · public · admin"]
ctrl["Controllers"]
auth["Session layer (auth/)<br/>sessionService · JWT/cookie · bearer · SSO+PKCE"]
model["Models (.model + .db)<br/>raw parameterized SQL — no ORM"]
sse["SSE fan-out<br/>public stream (allowlist) · admin stream (sensitive)"]
subgraph shardutil["Shard integration (utils/)"]
ingest["shardIngest.js<br/>WS ingest dispatcher"]
restcli["uoLinkClient.js<br/>REST client (never throws)"]
end
secret["secretBox.js<br/>AES-256-GCM secrets at rest"]
end
bot["bot/<br/>Discord bot"]
end
db[("MariaDB<br/>users · posts · wiki · settings · activity<br/>mobileSessions · authProviders · userIdentities<br/>uoLinkConfig · shard_online/economy/houses/events")]
%% ---------- Shard side ----------
subgraph shardside["Game shard (never internet-facing)"]
direction TB
sidecar["uo-link sidecar<br/>(Rust) — the only bridge exposed"]
servuo["ServUO shard<br/>(C# plugin)"]
end
%% ---------- Edges ----------
browser <-->|"same-origin JSON + SSE (cookie)"| mw
mobile -->|"REST (bearer access/refresh)"| mw
browser -.->|"OAuth redirect + PKCE"| idp
auth -.->|"token exchange"| idp
mw --> router --> ctrl
ctrl --> auth
ctrl --> model
ctrl --> restcli
ctrl --> sse
auth --> model
model <--> db
auth -. reads/writes secrets .-> secret
restcli -. reads config/token .-> secret
ingest --> model
ingest --> sse
sse -->|"live events"| browser
bot -->|"messages"| discord
bot <--> db
restcli -->|"REST: /char /roster /economy /history · /link/confirm · /towncrier"| sidecar
sidecar -->|"WebSocket live event feed (bearer + X-UOLink-Version)"| ingest
servuo -->|"loopback TCP 127.0.0.1:7788<br/>newline-delimited JSON (shard dials out)"| sidecar
%% ---------- Styling ----------
classDef ext fill:#2d2233,stroke:#7a5c94,color:#e8dff0;
classDef store fill:#1f2d2a,stroke:#4c8c7d,color:#dff0ea;
classDef bridge fill:#2d2620,stroke:#94764c,color:#f0e6d8;
class idp,discord ext;
class db store;
class sidecar,servuo bridge;
```
- **One backend, layered.** Every request flows `middleware → router → controller → model → db`.
Web browsers authenticate with an httpOnly JWT cookie; the native app uses short-lived bearer
access tokens plus rotated refresh tokens; SSO (Google/Discord/OIDC) is link-only and PKCE-guarded.
All three surfaces produce the *same* session via the session layer.
- **The shard is never reachable.** The ServUO shard *dials out* over loopback TCP to the uo-link
sidecar; only the sidecar is exposed, and only the backend talks to it. The REST client
(`uoLinkClient.js`) never throws, so the site degrades gracefully when the shard is down.
- **Sensitive events stay private.** Ingested game events fan out to browsers over two SSE channels —
a public allowlist stream and an admin-only stream that adds staff audit / cheat / login events.
---
## Tech stack ## Tech stack
| Layer | Tech | | Layer | Tech |
@@ -367,18 +269,6 @@ cd server
npm run swagger # → server/swagger/swagger-output.json npm run swagger # → server/swagger/swagger-output.json
``` ```
`swagger.js` post-processes the generator's output in two ways before writing it:
- **Trailing slashes are stripped from path keys.** swagger-autogen builds a path by
string-concatenating the mount prefix with the route argument, so a capability router mounted at
`/users` whose collection route is `router.get('/')` would document as `/api/v1/admin/users/`
a URL no client calls, while dropping the one they all do. Express is indifferent (non-strict
routing treats the two as one route), but the published spec is a contract.
- **Path keys are sorted.** The generator emits them in router-traversal order, so moving a route
between files rewrote most of this ~5k-line committed artifact even when the API was provably
unchanged. Sorting keeps the diff proportional to the change. OpenAPI attaches no meaning to path
order, and `scripts/routeManifest.js` already sorts for the same reason.
If the generated spec is missing, the server logs a warning and simply disables `/api/docs` (it does If the generated spec is missing, the server logs a warning and simply disables `/api/docs` (it does
not crash). not crash).