Compare commits
1 Commits
81f7d58fbe
...
docs/ci-so
| Author | SHA1 | Date | |
|---|---|---|---|
| ec468e9983 |
14
README.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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; ~3–4 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 (~1–2 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.
|
|
||||||
194
android/PLAN.md
@@ -169,12 +169,9 @@ none (keeps §11's zero-interaction promise).
|
|||||||
registration and every publish validate it is HTTPS and its origin is in the shard's ntfy
|
registration and every publish validate it is HTTPS and its origin is in the shard's ntfy
|
||||||
allow-set (`NTFY_BASE_URL` / `NTFY_ALLOWED_ORIGINS`), rejecting loopback/private hosts.
|
allow-set (`NTFY_BASE_URL` / `NTFY_ALLOWED_ORIGINS`), rejecting loopback/private hosts.
|
||||||
- **ntfy** added to `website/docker-compose.yml` as a pinned upstream image with a committed
|
- **ntfy** added to `website/docker-compose.yml` as a pinned upstream image with a committed
|
||||||
declarative `./ntfy/server.yml` and named volume, **published on a host port** (`NTFY_HOST_PORT`,
|
declarative `./ntfy/server.yml` and named volume, **no published host port** (reached via the
|
||||||
default `2586` → container `:80`) so the public reverse proxy — which runs *outside* the compose
|
reverse proxy; internal-only for the publisher), anonymous read-write to unguessable topics (no
|
||||||
network — can forward the notification subdomain to it, anonymous read-write to unguessable topics
|
per-user accounts — safe because tickles are content-free).
|
||||||
(no per-user accounts — safe because tickles are content-free). Both the app (SSE subscribe) and the
|
|
||||||
backend (POSTing tickles to registered device endpoints) reach ntfy on that public origin, so all
|
|
||||||
ntfy traffic transits the proxy — there is no separate internal publish port.
|
|
||||||
|
|
||||||
**Part 2 — the Android app — ✅ LANDED** (2026-07-20, `RunicGateway/Android-app#15` + a small
|
**Part 2 — the Android app — ✅ LANDED** (2026-07-20, `RunicGateway/Android-app#15` + a small
|
||||||
`RunicGateway/website#79` settings addition + this docs PR). Built exactly to the plan below, with
|
`RunicGateway/website#79` settings addition + this docs PR). Built exactly to the plan below, with
|
||||||
@@ -422,29 +419,21 @@ console. It is a **read + self-service** app, not an operator tool.
|
|||||||
- **Access-level menu**: one shared navigation that reveals items based on the signed-in user's role.
|
- **Access-level menu**: one shared navigation that reveals items based on the signed-in user's role.
|
||||||
- **Opt-in push notifications** (post-v1; architected for from the start): per-stream subscriptions the
|
- **Opt-in push notifications** (post-v1; architected for from the start): per-stream subscriptions the
|
||||||
user chooses — nothing is pushed unless subscribed. See §11.
|
user chooses — nothing is pushed unless subscribed. See §11.
|
||||||
- **Staff operations subset** (post-v1, **M10** — decided 2026-07-21): a *defined* slice of the admin
|
|
||||||
surface, native, for `admin`/`moderator` staff. In scope: **moderation actions** (kick / ban / unban /
|
|
||||||
broadcast), the **support (help-page) queue** (reply / close / resolve), the **dashboard summary +
|
|
||||||
site-mode** toggle, and **content management** (news posts — list / create / edit / publish / delete —
|
|
||||||
plus wiki category & tag management). These consume the existing `/api/v1/admin/**` routes (which
|
|
||||||
already accept bearer auth and re-check role every request); no backend routes are added. See §6.4 + §10.
|
|
||||||
|
|
||||||
### Explicitly OUT of scope (never in the app, for any role)
|
### Explicitly OUT of scope (never in the app, for any role)
|
||||||
The exclusions are now a *narrow* list (M10 brought the operational admin subset in-scope, above). The
|
- The **hero editor** and any CMS authoring/block editing.
|
||||||
app still never ships:
|
- The **admin / auth-management console** — user management, invites issuance, SSO provider config,
|
||||||
- The **hero editor** and CMS **block/page authoring** (the visual page builder). *(News-post and
|
moderation console, email config, bot-activity/ban console. (Players still *log in*; what's
|
||||||
wiki category/tag management ARE in scope; the excluded piece is the CMS block editor / hero builder.)*
|
excluded is the management surface, not authentication itself.)
|
||||||
- **Discord bot** configuration (and anything under the unpublished `/internal/**` port — it returns the
|
- The **Discord bot** management (and anything under the unpublished `/internal/**` port — it
|
||||||
decrypted bot token and must never be reachable from a client).
|
returns the decrypted bot token and must never be reachable from a client).
|
||||||
- **uo-link / sidecar administration** — the shard sidecar base-URL/token config (`uoLinkConfig`). (The
|
- **Shard / uo-link administration** — sidecar base-URL/token config (`uoLinkConfig`), shard ops,
|
||||||
app performs *shard operations* like kick/ban/broadcast against the live shard, but never configures
|
the staff shard-user console. (The app shows *public* shard widgets and a player's *own* game
|
||||||
the sidecar connection itself.)
|
data; it does not manage the sidecar.)
|
||||||
- **OAuth / SSO provider setup** — creating/editing identity providers and their client secrets.
|
|
||||||
|
|
||||||
> The app calls `/api/v1/public/**`, `/api/v1/auth/**` (incl. the role-agnostic self surface
|
> The excluded surfaces all live under `/api/v1/admin/**` and `/api/v1/internal/**`. The app only
|
||||||
> `/auth/me/*`, §6.4), `/api/v1/player/**`, and — for staff, M10 — the **defined `/api/v1/admin/**`
|
> ever calls `/api/v1/public/**`, `/api/v1/auth/**` (incl. the new role-agnostic self surface
|
||||||
> operations listed above. It never touches `/api/v1/internal/**`, nor the four excluded admin surfaces
|
> `/auth/me/*`, §6.4), and `/api/v1/player/**` — it never references `/admin`.
|
||||||
> (hero/CMS block editor, Discord-bot config, uo-link config, OAuth-provider setup).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -502,13 +491,7 @@ compiled in.
|
|||||||
`/public/settings` for branding: name/colors/logo). Only on a successful, well-formed response is
|
`/public/settings` for branding: name/colors/logo). Only on a successful, well-formed response is
|
||||||
the URL persisted to DataStore and the app allowed to initialize its main UI.
|
the URL persisted to DataStore and the app allowed to initialize its main UI.
|
||||||
- Accept `https://host[/base]`; normalize/trim; require HTTPS in release builds (allow HTTP only in
|
- Accept `https://host[/base]`; normalize/trim; require HTTPS in release builds (allow HTTP only in
|
||||||
debug for local dev against `127.0.0.1:3000`). This app-layer rule (`ServerUrl`,
|
debug for local dev against `127.0.0.1:3000`).
|
||||||
`allowInsecureHttp = BuildConfig.DEBUG`) is backed at the platform socket layer by an explicit
|
|
||||||
**network security config** (`res/xml/network_security_config.xml`, wired via
|
|
||||||
`application android:networkSecurityConfig`): the main/release config permits **no** cleartext,
|
|
||||||
and a debug-only override (`app/src/debug/res/xml/`) re-permits cleartext to loopback
|
|
||||||
(`127.0.0.1`/`localhost`) only. Being explicit also stops a merged library manifest from
|
|
||||||
re-enabling cleartext and clears the `usesCleartextTraffic`-implicitly-enabled scanner finding.
|
|
||||||
- Failure states: unreachable, non-2xx, not-a-Runic-Gateway-site (missing expected `/public/status`
|
- Failure states: unreachable, non-2xx, not-a-Runic-Gateway-site (missing expected `/public/status`
|
||||||
shape), TLS error — each gets a clear retry message. Nothing else in the app runs until this
|
shape), TLS error — each gets a clear retry message. Nothing else in the app runs until this
|
||||||
succeeds.
|
succeeds.
|
||||||
@@ -546,39 +529,6 @@ Uses the existing **mobile bearer** surface, no backend changes:
|
|||||||
`401` from a bearer call, with a mutex so concurrent 401s trigger only one refresh.
|
`401` from a bearer call, with a mutex so concurrent 401s trigger only one refresh.
|
||||||
- `POST /auth/mobile/logout` `{ refreshToken?, all? }` (requires bearer) — revoke this session or all
|
- `POST /auth/mobile/logout` `{ refreshToken?, all? }` (requires bearer) — revoke this session or all
|
||||||
sessions. Called on user logout and on "sign out everywhere."
|
sessions. Called on user logout and on "sign out everywhere."
|
||||||
|
|
||||||
#### 4.1.1 Trusted devices & recovery codes — **implemented** (app PR `feature/trusted-devices-mfa`)
|
|
||||||
The backend trusted-device + recovery-code feature (canonical ref: `../website/TRUSTED_DEVICES_MFA.md`)
|
|
||||||
is additive on the mobile surface. As built:
|
|
||||||
- **Login extras:** `POST /auth/mobile/login` accepts `recoveryCode` (a single-use alternative to
|
|
||||||
`code`), `trustDevice: true`, `device_name`, and an `X-Trust-Token` header. On the `401 { totpRequired }`
|
|
||||||
screen the app offers a "use a recovery code instead" toggle and a **"Trust this device"** checkbox.
|
|
||||||
When `trustDevice` is accepted, the response carries `trustToken` → stored in a **dedicated
|
|
||||||
EncryptedSharedPreferences file** (`runic_trust`, AES-256-GCM; never plain prefs/logs) **scoped to the
|
|
||||||
username** it was minted for, and replayed as `X-Trust-Token` on that account's future logins to skip
|
|
||||||
the TOTP prompt.
|
|
||||||
- **Login-time cap:** a `{ trustLimitReached, devices }` response means login **succeeded** but the
|
|
||||||
device was not remembered (session is already issued on native, unlike the web cookie step). Rather
|
|
||||||
than a blocking login-time modal, this is surfaced + resolved on the **Trusted Devices** account
|
|
||||||
screen, whose `POST /auth/me/trusted-devices` gives the exact revoke-one-then-retry flow. (Deliberate
|
|
||||||
deviation from the earlier "prompt at login" sketch — the mobile login is past the point a modal would
|
|
||||||
gate.)
|
|
||||||
- **Self-service (account screens):** a **Security** section on the account screen links to two
|
|
||||||
dedicated screens. **Trusted Devices** — `GET /auth/me/trusted-devices` (list), `DELETE …/:id`
|
|
||||||
(revoke one), `DELETE …/trusted-devices` (untrust all), and `POST /auth/me/trusted-devices` to trust
|
|
||||||
the current device (stores the returned `{ trustToken }`). **Recovery Codes** — enabling TOTP returns
|
|
||||||
the one-time `recoveryCodes` (shown once on the account screen with copy/share); `GET
|
|
||||||
…/recovery-codes/status` for the remaining count; `POST …/recovery-codes/generate` (password step-up)
|
|
||||||
to regenerate, shown once.
|
|
||||||
- **Invalidation — trust token deliberately survives logout.** The trust token is the native analogue
|
|
||||||
of the web `rg_trust` cookie, which per the canonical design *survives logout so the next login skips
|
|
||||||
2FA*. On mobile the token is only ever consulted at a **fresh** login — i.e. exactly after a logout or
|
|
||||||
a dead-refresh sign-out — so clearing it there would make the feature a no-op. It is therefore kept in
|
|
||||||
its own store, **untouched by session teardown** (`SessionManager.onSignedOut`), and cleared only on a
|
|
||||||
**Settings → Server switch** (bound to the old host), an **untrust-all**, or server-side revocation
|
|
||||||
(password change/reset, TOTP disable — which makes any surviving token inert; the next login just
|
|
||||||
prompts for the code). This supersedes the earlier handoff note that said to clear it on logout.
|
|
||||||
|
|
||||||
### 4.2 Website-handled flows: registration, invite, forgot-password, SSO
|
### 4.2 Website-handled flows: registration, invite, forgot-password, SSO
|
||||||
These are **not** rebuilt in the app. The app links out to the website's own pages/API and the user
|
These are **not** rebuilt in the app. The app links out to the website's own pages/API and the user
|
||||||
completes them in a Custom Tab, then returns and signs in natively (§4.1):
|
completes them in a Custom Tab, then returns and signs in natively (§4.1):
|
||||||
@@ -648,11 +598,6 @@ Guidelines:
|
|||||||
|
|
||||||
## 6. Screen ↔ endpoint map
|
## 6. Screen ↔ endpoint map
|
||||||
|
|
||||||
> **Path note (M11):** endpoints below are written with their current `/api/v1`-relative paths. Under
|
|
||||||
> **M11** the app moves onto the version-agnostic **`/api/mobile`** facade (a pure rename — same
|
|
||||||
> shapes, same auth); these references update to `/api/mobile/**` when that migration executes. See
|
|
||||||
> [`../website/API_V2_PLAN.md`](../website/API_V2_PLAN.md) § Phase 0.
|
|
||||||
|
|
||||||
### 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`),
|
||||||
@@ -684,18 +629,11 @@ Guidelines:
|
|||||||
Player self-service is under `/player/account/*` (gated to `role='player'`) and staff use the *same*
|
Player self-service is under `/player/account/*` (gated to `role='player'`) and staff use the *same*
|
||||||
handlers under `/admin/account/*`. Rather than have the app branch by role (and touch `/admin`), we
|
handlers under `/admin/account/*`. Rather than have the app branch by role (and touch `/admin`), we
|
||||||
**add a role-agnostic self surface under `/auth/**`** — the canonical "me" endpoints for every role.
|
**add a role-agnostic self surface under `/auth/**`** — the canonical "me" endpoints for every role.
|
||||||
The app calls these regardless of role. This is an **additive v1**
|
The app calls these regardless of role, and never references `/admin`. This is an **additive v1**
|
||||||
change (see §8): the existing `/player/account/*` and `/admin/account/*` routes stay for web
|
change (see §8): the existing `/player/account/*` and `/admin/account/*` routes stay for web
|
||||||
back-compat; `/auth/me/*` reuses the same `account.controller` handlers behind `requireAuth` (any
|
back-compat; `/auth/me/*` reuses the same `account.controller` handlers behind `requireAuth` (any
|
||||||
authenticated role), so there's no logic duplication.
|
authenticated role), so there's no logic duplication.
|
||||||
|
|
||||||
> **M10 update (2026-07-21):** *self-service* stays role-agnostic under `/auth/me/*` as above. Separately,
|
|
||||||
> the **operational** admin subset (§1, §10 — moderation, support queue, dashboard/site-mode, content)
|
|
||||||
> *does* call `/api/v1/admin/**` directly, gated to staff by a new `STAFF`/`ADMIN` menu access level.
|
|
||||||
> The earlier "the app never references `/admin`" rule is superseded for these defined staff operations
|
|
||||||
> only; `validateSession` accepts the mobile bearer on those routes and `requireRole` re-checks every
|
|
||||||
> request, so a demoted user loses the surface immediately (the app treats any `403` as authoritative).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Degradation & offline
|
## 7. Degradation & offline
|
||||||
@@ -864,53 +802,6 @@ push, and Play (M6–M8) follow the designed app.
|
|||||||
generic build stays custom-scheme; white-label/first-party builds bake one host). The custom
|
generic build stays custom-scheme; white-label/first-party builds bake one host). The custom
|
||||||
scheme remains the permanent fallback on every build. Full spec + rollout in
|
scheme remains the permanent fallback on every build. Full spec + rollout in
|
||||||
[`APP_LINKS.md`](./APP_LINKS.md).
|
[`APP_LINKS.md`](./APP_LINKS.md).
|
||||||
11. **M10 — Native SSO fixes + staff operations** (post-v1; decided 2026-07-21). Two threads found
|
|
||||||
during on-device QA:
|
|
||||||
- **SSO discovery + reachability fixes (app-only, done):** the native login screen only rendered
|
|
||||||
provider buttons when `GET /auth/providers` was non-empty and otherwise fell back to the desktop
|
|
||||||
website login (which can't deep-link a mobile session back → it hung). `AuthRepository.ssoProviders()`
|
|
||||||
now returns `Available` / `None` / `Unavailable` (retry once); the login screen shows native
|
|
||||||
buttons / a loading hint / a retry, and the website-login fallback is removed. The pending PKCE
|
|
||||||
`{state,verifier}` is persisted (encrypted `PendingSsoStore`) so the exchange survives Custom-Tab
|
|
||||||
process death. The nav drawer is now `verticalScroll`-wrapped so a signed-in session's longer menu
|
|
||||||
(which includes **Notifications**) can't clip on short screens. Dev testing uses a **stub OAuth IdP**
|
|
||||||
(`website/scripts/dev/`), since dev configures no real provider. Verified end-to-end on an emulator.
|
|
||||||
- **Staff operations (§1, §6.4):** a new `STAFF`/`ADMIN` menu access level reveals a staff section
|
|
||||||
for `admin`/`moderator`, with native screens over the existing `/api/v1/admin/**`: **moderation**
|
|
||||||
(`POST /admin/shard/{kick,ban,unban,broadcast}`), **support queue** (`GET /admin/shard/pages`,
|
|
||||||
`POST /admin/shard/pages/:id/{respond,close}`), **dashboard + site-mode** (`GET /admin/dashboard`,
|
|
||||||
`PUT /admin/site-mode`), and **content** (news posts under `/admin/posts*`, wiki categories/tags
|
|
||||||
under `/admin/wiki/*`). No backend routes added (they already accept bearer + re-check role);
|
|
||||||
shard-write actions degrade gracefully when the sidecar is offline. Excluded: hero/CMS block
|
|
||||||
editor, Discord-bot config, uo-link config, OAuth-provider setup.
|
|
||||||
12. **M11 — Migrate to the version-agnostic `/api/mobile` facade** (post-v1; decided 2026-07-22).
|
|
||||||
Prerequisite for the website's API v2 work — see
|
|
||||||
[`../website/API_V2_PLAN.md`](../website/API_V2_PLAN.md) § Phase 0. Today the app hardcodes ~70
|
|
||||||
`api/v1/…` endpoints, the SSE stream path, and the SSO URLs, has **no version negotiation and no
|
|
||||||
force-update**, and calls a mix of mobile-only (`/auth/mobile/*`) and *shared* web routes
|
|
||||||
(`/auth/me/*`, `/public/*`, `/player/*`, `/admin/*`) — so it is directly coupled to v1 and would
|
|
||||||
break the day v1 is retired. The website introduces a stable, version-agnostic `/api/mobile`
|
|
||||||
facade (a thin BFF that delegates to the current controllers behind pinned wire shapes); this
|
|
||||||
milestone moves the app onto it **once**, after which the app is insulated from all internal
|
|
||||||
v1→v2→vN churn.
|
|
||||||
- **App repoint (this milestone's core):** drop the `api/v1/` and `auth/mobile/` prefixes and
|
|
||||||
re-point everything to `/api/mobile` — the ~70 Retrofit endpoints in `data/api/*.kt`, the
|
|
||||||
`STREAM_PATH` in `core/net/ShardStreamClient.kt`, and the SSO start/exchange URLs in
|
|
||||||
`core/auth/sso/SsoAuthManager.kt`. Pure rename; no behavior, auth-model, or token-shape change
|
|
||||||
(the app is already bearer). The public shard stream stays **anonymous** (no `Authorization`
|
|
||||||
header) under its `/api/mobile` path.
|
|
||||||
- **App-version floor (ships in this milestone):** the app sends an app-version header on every
|
|
||||||
request, and the website gains a server-side min-supported-version gate that can return a
|
|
||||||
"please update" response. Its first job is to let the shard **sunset the pre-facade app** so the
|
|
||||||
website can finally delete `/api/v1`; thereafter it is the in-band mechanism for any breaking
|
|
||||||
`/api/mobile` change (URL-path versioning is deliberately absent on the facade).
|
|
||||||
- **Rollout & ordering:** the facade + app must be **released and adopted before the website
|
|
||||||
begins its v2 auth-merge / domain-split work** (website PR 0a = facade + telemetry; PR 0b =
|
|
||||||
this app repoint + the version floor). The old, un-updated app keeps working against frozen
|
|
||||||
`/api/v1` until the version floor ages it out — at which point v1 is deletable.
|
|
||||||
- **Not in scope:** any new screen, any auth-flow change, any UI work. This is a networking-layer
|
|
||||||
rename plus the version-floor plumbing. Once landed, the endpoint paths throughout §6 read
|
|
||||||
`/api/mobile/**`; update this doc's references when the migration executes.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -947,11 +838,8 @@ first release. Users **opt in per stream**: nothing is pushed unless subscribed.
|
|||||||
path/tag at implementation.
|
path/tag at implementation.
|
||||||
- **All config is declarative** — a committed `ntfy` config file and/or `NTFY_*` env vars baked into
|
- **All config is declarative** — a committed `ntfy` config file and/or `NTFY_*` env vars baked into
|
||||||
compose. No `docker exec`, no interactive `ntfy user add`, no post-deploy manual steps. Bringing the
|
compose. No `docker exec`, no interactive `ntfy user add`, no post-deploy manual steps. Bringing the
|
||||||
stack up provisions a working push relay. Reachable via the existing reverse proxy on its own
|
stack up provisions a working push relay. Reachable to devices via the existing reverse proxy on its
|
||||||
hostname/path — the container publishes `:80` on a host port (`NTFY_HOST_PORT`, default `2586`)
|
own hostname/path; internal-only for the backend publisher.
|
||||||
because the proxy runs *outside* the compose network and can only reach a service through a
|
|
||||||
published host port (the same reason `app` publishes `3000`). Both devices and the backend publisher
|
|
||||||
reach ntfy on that public origin.
|
|
||||||
- **No per-user ntfy accounts to administer.** The security model (below) removes the need for ntfy ACL
|
- **No per-user ntfy accounts to administer.** The security model (below) removes the need for ntfy ACL
|
||||||
provisioning, which is exactly what keeps setup interaction-free. ntfy topics are the random,
|
provisioning, which is exactly what keeps setup interaction-free. ntfy topics are the random,
|
||||||
unguessable endpoints UnifiedPush hands out; the backend treats ntfy as an **untrusted relay**.
|
unguessable endpoints UnifiedPush hands out; the backend treats ntfy as an **untrusted relay**.
|
||||||
@@ -999,17 +887,6 @@ The ntfy relay is treated as **untrusted infrastructure**, and the design makes
|
|||||||
write to `/auth/me/notifications/subscriptions`. Tapping a notification deep-links to the relevant
|
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
|
||||||
@@ -1028,33 +905,6 @@ repos use), on a bare `ubuntu:latest` container.
|
|||||||
signing identity stable from the first release (Play later requires consistency).
|
signing identity stable from the first release (Play later requires consistency).
|
||||||
- Semantic `versionName` + monotonic `versionCode`; tag releases.
|
- Semantic `versionName` + monotonic `versionCode`; tag releases.
|
||||||
|
|
||||||
### 12.1 Code quality — SonarQube (`Runic-Gateway-Android-app`)
|
|
||||||
|
|
||||||
Analysis runs post-merge and non-blocking (`.gitea/workflows/sonarqube.yml`); the gate is
|
|
||||||
informational. The project is clean (0 bugs / 0 vulns / 0 hotspots, Maintainability **A**); the only
|
|
||||||
gate failure is **coverage = 0% on new code**, which is a *reporting* gap, not a testing gap — the
|
|
||||||
25-file JVM unit suite exists, but the source-only Sonar scan never received a JaCoCo report.
|
|
||||||
|
|
||||||
> Once the wiring below landed (`Android-app#26`), real coverage measured **16.4%** on new code —
|
|
||||||
> still under the 50% gate. The plan to raise it (exclude non-unit-testable framework/UI code + test
|
|
||||||
> ViewModels/DTOs/repositories) lives in [`COVERAGE_PLAN.md`](./COVERAGE_PLAN.md).
|
|
||||||
|
|
||||||
**Coverage wiring (the fix):**
|
|
||||||
- Apply the `jacoco` plugin in `app/build.gradle.kts` + a `jacocoTestReport` task fed by
|
|
||||||
`testDebugUnitTest`, emitting XML. Exclude generated/DI/Compose scaffolding
|
|
||||||
(`**/*_Hilt*`, `**/*_Factory*`, `**/di/**`, `**/*ComposableSingletons*`, `R`/`BuildConfig`).
|
|
||||||
- `sonar-project.properties`: set `sonar.coverage.jacoco.xmlReportPaths` to the report, and
|
|
||||||
`sonar.coverage.exclusions` for pure-`@Composable` UI (JVM unit tests can't execute composable
|
|
||||||
bodies without Robolectric, so counting those lines would unfairly sink new-code coverage).
|
|
||||||
- `sonarqube.yml` must now run a real Gradle build before the scan (JDK 17 + Android SDK, mirroring
|
|
||||||
`pr-checks.yml`): `./gradlew testDebugUnitTest jacocoTestReport` → then the scan step.
|
|
||||||
|
|
||||||
**Issue triage (2026-07-22):** of 15 code smells, **3 fixed in code** — remove an unused import
|
|
||||||
(`AdminContentScreen.kt`), remove an unused `page` param (`AdminSupportScreen.RespondDialog`), and
|
|
||||||
decompose `LoginViewModel` (cognitive complexity 20). The remaining **12 marked *Won't Fix*** via the
|
|
||||||
Sonar API with rationale: 5 snake_case DTO fields (intentionally mirror the JSON wire contract) and 7
|
|
||||||
Compose/nav cognitive-complexity + hoisted-callback param-count smells (idiomatic for Jetpack Compose).
|
|
||||||
|
|
||||||
## 13. Open questions (revisit as we go)
|
## 13. Open questions (revisit as we go)
|
||||||
|
|
||||||
**Decided (recorded here for context):** single shard per install (§3); native auth is
|
**Decided (recorded here for context):** single shard per install (§3); native auth is
|
||||||
@@ -1079,10 +929,8 @@ it is a UX convenience, not a v1 requirement — deferred at M3, descoped at M6;
|
|||||||
See [`APP_LINKS.md`](./APP_LINKS.md).
|
See [`APP_LINKS.md`](./APP_LINKS.md).
|
||||||
- ntfy: exact upstream image + pinned tag (Part-1 landed the compose service — confirm the tag), and
|
- ntfy: exact upstream image + pinned tag (Part-1 landed the compose service — confirm the tag), and
|
||||||
its reverse-proxy hostname/path. The hostname must land in `NTFY_ALLOWED_ORIGINS` before M7 Part 2 is
|
its reverse-proxy hostname/path. The hostname must land in `NTFY_ALLOWED_ORIGINS` before M7 Part 2 is
|
||||||
end-to-end testable (the app registers an endpoint on that origin; the SSRF guard rejects others). The
|
end-to-end testable (the app registers an endpoint on that origin; the SSRF guard rejects others). No
|
||||||
reverse proxy forwards that hostname to the ntfy container's published host port (`NTFY_HOST_PORT`,
|
backend publish token — **decided** (the content-free-tickle design does not require one; optional
|
||||||
default `2586`) — the container publishes `:80` because the proxy runs outside the compose network.
|
|
||||||
No backend publish token — **decided** (the content-free-tickle design does not require one; optional
|
|
||||||
`NTFY_PUBLISH_TOKEN` is honored if ever set).
|
`NTFY_PUBLISH_TOKEN` is honored if ever set).
|
||||||
- FCM flavor: build it for the Play release or ship Play on UnifiedPush too? Decide at M8. (The M7
|
- FCM flavor: build it for the Play release or ship Play on UnifiedPush too? Decide at M8. (The M7
|
||||||
Part 2 `PushTransport` seam keeps this swap cheap.)
|
Part 2 `PushTransport` seam keeps this swap cheap.)
|
||||||
|
|||||||
@@ -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
|
|
||||||
```
|
|
||||||
|
Before Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 194 KiB |
|
Before Width: | Height: | Size: 126 KiB |
@@ -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. |
|
|
||||||
|
Before Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 120 KiB |
@@ -16,8 +16,13 @@ feeds the dashboard.
|
|||||||
| Repo | Project key | Sources analysed | Language |
|
| Repo | Project key | Sources analysed | Language |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `website` | `runic-gateway-website` | `server/src`, `client/src`, `bot/src` | JS/TS |
|
| `website` | `runic-gateway-website` | `server/src`, `client/src`, `bot/src` | JS/TS |
|
||||||
| `link` | `runic-gateway-link` | `sidecar/src` | Rust |
|
| `link` | `Runic-Gateway-link` | `sidecar/src` | Rust |
|
||||||
| `Android-app` | `runic-gateway-android-app` | `app/src/main` | Kotlin |
|
| `Android-app` | `Runic-Gateway-Android-app` | `app/src/main` | Kotlin |
|
||||||
|
|
||||||
|
> Project keys are **case-sensitive** and must match what already exists on the
|
||||||
|
> server — SonarQube refuses to create a key that differs only in case from an
|
||||||
|
> existing one. `link` and `Android-app` reuse the pre-existing capitalised keys
|
||||||
|
> above; `website` predates this note with its lower-case key.
|
||||||
|
|
||||||
## How it's wired
|
## How it's wired
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
```
|
|
||||||
@@ -1,378 +0,0 @@
|
|||||||
# Website API v2 — Plan
|
|
||||||
|
|
||||||
Status: **planning** · Target repo: `website/` · Docs owner: this file + `BACKEND_DESIGN.md`
|
|
||||||
|
|
||||||
v2 is three sequenced pieces of work, landed in order:
|
|
||||||
|
|
||||||
0. **The mobile facade** (lands *first*, before any v2 work) — a **version-agnostic `/api/mobile`
|
|
||||||
namespace** is stood up over the current controllers, and the Android app is migrated to it. From
|
|
||||||
then on the app is pinned to `/api/mobile`, insulated from all internal version churn — so the
|
|
||||||
auth merge and domain split below never touch it. See Phase 0.
|
|
||||||
1. **The auth merge** — httpOnly session cookies go away; a bearer JWT (access) + rotating
|
|
||||||
refresh token becomes the single session model for *every* client (web and mobile).
|
|
||||||
2. **The domain split** — the monolithic route wiring (esp. `admin.routes.js`, 1552 lines) is
|
|
||||||
broken into one router file per business capability, so a developer can predict where an
|
|
||||||
endpoint lives from its URL.
|
|
||||||
|
|
||||||
## Locked decisions
|
|
||||||
|
|
||||||
| Decision | Choice | Consequence |
|
|
||||||
|---|---|---|
|
|
||||||
| Mobile decoupling | **A version-agnostic `/api/mobile` facade, landed before v2** | The Android app pins one stable namespace; internal v1→v2→vN churn never reaches it. The app leaves the v2 blast radius entirely. |
|
|
||||||
| Versioning | **New `/api/v2` mounted in parallel** with a frozen `/api/v1` | Migrate the *web* client route-by-route; delete v1 once no caller remains. No big-bang break. |
|
|
||||||
| Web session model | **Web adopts mobile's access + refresh** | One session model everywhere. Reuses `session.service` machinery that already exists — nothing new invented. |
|
|
||||||
| Live-feed (SSE) auth | **admin stream → fetch + `Authorization: Bearer`; public stream stays anonymous** | Admin token stays out of URLs/logs. Public/mobile stream keeps `EventSource`, no auth header. |
|
|
||||||
|
|
||||||
## What already exists (so we don't rebuild it)
|
|
||||||
|
|
||||||
- `auth/token.js` already extracts a token from cookie **or** `Authorization: Bearer`, and
|
|
||||||
`auth/session.service.js` already unifies both into one session.
|
|
||||||
- Mobile already has the full target model: `mintMobileTokens` / `createMobileSession` /
|
|
||||||
`refreshMobileSession`, with **hashed, rotated, revocable** refresh tokens stored server-side.
|
|
||||||
Endpoints live at `/api/v1/auth/mobile/{login,refresh,logout}`.
|
|
||||||
- **The merge is therefore mostly deletion + rename:** web joins the mobile session model, the
|
|
||||||
internal `/mobile` auth namespace collapses into unified `/auth/*`, and the cookie code path is
|
|
||||||
removed. (The *app-facing* `/api/mobile` facade is unaffected — it re-points its internals to the
|
|
||||||
unified flow; the app sees no change. See Phase 0.)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 0 — The mobile facade (`/api/mobile`, lands before v2)
|
|
||||||
|
|
||||||
**Goal:** make the Android app's contract *version-agnostic* so the v2 work below can proceed without
|
|
||||||
ever breaking an installed app. The app currently hardcodes ~70 `api/v1/…` endpoints (plus the SSE
|
|
||||||
path and SSO URLs) and has **no version negotiation and no force-update** — which is exactly why v1
|
|
||||||
can't otherwise be retired on the web client's schedule. Phase 0 pays that migration **once**, up
|
|
||||||
front, against a pure rename with no behavior change, and never again.
|
|
||||||
|
|
||||||
**What it is:** a thin routing facade mounted at `/api/mobile`, next to `/api/v1`, that delegates to
|
|
||||||
the **same controllers with the same middleware** as the routes it mirrors. It is *routing + optional
|
|
||||||
response-shaping*, never business logic — a Backend-for-Frontend, not a fork.
|
|
||||||
|
|
||||||
### Server
|
|
||||||
|
|
||||||
1. **Mount `/api/mobile` in `api.router.js`** next to `/v1` (today it mounts only `/v1`).
|
|
||||||
2. **Re-home the app's *entire* surface under it — not just the mobile-auth routes.** The app calls
|
|
||||||
mostly *shared* routes (`auth/me/*`, `public/*`, `player/*`, `admin/*`) plus the mobile-only auth
|
|
||||||
routes and the anonymous `public/shard/stream`. All of them move under `/api/mobile/**`. If any
|
|
||||||
endpoint the app needs is left only under `/api/v1`, the app is not decoupled and the whole point
|
|
||||||
is lost. Cross-check against the app's endpoint inventory (§ Cross-component blast radius).
|
|
||||||
3. **Delegate; never bypass auth.** Each facade route requires the *same* middleware chain as its
|
|
||||||
underlying route (`requireAuth`, `staffOnly`/`adminOnly`, validators, the public/admin SSE
|
|
||||||
allowlist split). A re-exposed admin route missing `adminOnly` is a privilege-escalation hole —
|
|
||||||
treat the facade as a security surface, not a convenience alias.
|
|
||||||
4. **Keep the mobile SSE stream anonymous.** `/api/mobile/…/shard/stream` carries no auth (the app
|
|
||||||
sends no `Authorization` header); only the public/safe kinds, same allowlist as today.
|
|
||||||
5. **Pin the wire shapes with contract tests.** `/api/mobile` is now a **committed stable contract**:
|
|
||||||
a v2 internal refactor that changes a response shape must fail a test here *before* it can ship to
|
|
||||||
installed apps. This is the facade's ongoing cost and its entire value — enforce it in CI.
|
|
||||||
|
|
||||||
### Android app (`Android-app` repo — separate PR, separate release)
|
|
||||||
|
|
||||||
6. **Re-point everything to `/api/mobile`:** the ~70 Retrofit endpoints (`data/api/*.kt`), the SSE
|
|
||||||
path in `ShardStreamClient.kt`, and the SSO start/exchange URLs in `SsoAuthManager.kt`. Drop the
|
|
||||||
`api/v1/` and `auth/mobile/` prefixes; the app now knows only `/api/mobile`.
|
|
||||||
7. **Add the app-version header + a server-side min-version floor** (the mechanism the blast-radius
|
|
||||||
section calls for). Its first job is to sunset the *pre-facade* app so `/api/v1` can eventually be
|
|
||||||
deleted; thereafter it is insurance for any genuinely breaking `/api/mobile` change (negotiated
|
|
||||||
in-band, since URL-path versioning is deliberately gone here).
|
|
||||||
8. **Ship and let the fleet adopt** before starting v2. The old app keeps working on frozen `/api/v1`
|
|
||||||
until the version floor ages it out.
|
|
||||||
|
|
||||||
### Docs / spec
|
|
||||||
|
|
||||||
9. Document `/api/mobile` as its own tagged surface in Swagger; record the facade + the version-floor
|
|
||||||
mechanism in `docs/android/PLAN.md`, and note the app-contract change in the Android repo's docs.
|
|
||||||
|
|
||||||
**Not in scope for Phase 0:** any behavior change, any auth-model change, any v2 route. The facade
|
|
||||||
maps 1:1 onto today's controllers; the auth merge happens later and reaches the app only as an
|
|
||||||
internal re-point behind the unchanged `/api/mobile` shapes.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 1 — The auth merge (cookie removal)
|
|
||||||
|
|
||||||
### Server
|
|
||||||
|
|
||||||
1. **Promote the mobile flow to the mainline v2 auth routes.** Under `/api/v2/auth`:
|
|
||||||
- `POST /login` → password (+ TOTP) → returns `{ accessToken, refreshToken, user }` (no `Set-Cookie`).
|
|
||||||
- `POST /refresh` → rotate: validate + revoke presented refresh, mint a new pair.
|
|
||||||
- `POST /logout` → revoke the current refresh/session server-side.
|
|
||||||
- `POST /login/totp` → second-factor step, same token shape.
|
|
||||||
The `/auth/mobile/*` namespace is **not** carried into v2 — it collapses into these.
|
|
||||||
2. **Delete the session-cookie code path in v2 controllers.** Stop calling `setAuthCookie` /
|
|
||||||
`clearAuthCookie`. `extractToken` keeps its Bearer branch; the cookie branch is dead for v2
|
|
||||||
routes (v1 keeps it until v1 is removed).
|
|
||||||
3. **Separate *session* cookies from *transaction* cookies — the latter stay.** The SSO / email-
|
|
||||||
connect redirect flow (`sso.controller.js`, `emailConfig.controller.js`) *must* keep its
|
|
||||||
short-lived httpOnly tx / PKCE-verifier / pending-TOTP cookies: the browser leaves for the IdP
|
|
||||||
and returns with no JS context to carry a bearer across the hop. Only the **final session**
|
|
||||||
handoff changes — the callback ends by issuing bearer tokens (redirect with a one-time code the
|
|
||||||
SPA exchanges, so tokens never land in the URL) instead of setting `rg_token`.
|
|
||||||
4. **Trusted-device token** already supports the `X-Trust-Token` header for native clients
|
|
||||||
(`extractTrustToken`). Web switches to the same header + client storage; `rg_trust` cookie is
|
|
||||||
dropped for v2.
|
|
||||||
5. **SSE auth is per-channel — the public stream stays anonymous.** The **admin** shard stream
|
|
||||||
(today gated by the session cookie via `isLoggedIn`) moves to `requireAuth` on the Bearer header,
|
|
||||||
since its browser client becomes fetch-based (below). The **public** shard stream
|
|
||||||
(`/public/shard/stream`) has **no auth middleware today and must keep none** — it is consumed by
|
|
||||||
logged-out browser visitors *and* by the Android `ShardStreamClient`, neither of which sends an
|
|
||||||
`Authorization` header. Adding `requireAuth` to it would black out the public live boards on web
|
|
||||||
and mobile. Keep the public/admin allowlist split — that security boundary is unchanged.
|
|
||||||
|
|
||||||
### Client
|
|
||||||
|
|
||||||
6. **`api/client.js`:** drop `credentials: 'include'`; attach `Authorization: Bearer <access>`;
|
|
||||||
on `401`, silent-refresh once via `/auth/refresh`, retry, else bounce to login.
|
|
||||||
7. **Token storage:** access token in memory (JS var/context); refresh token in `localStorage`.
|
|
||||||
Short access TTL keeps the XSS window small — the accepted tradeoff for losing httpOnly.
|
|
||||||
8. **`lib/useShardFeed.js`:** only the **admin** stream needs the rewrite — replace
|
|
||||||
`EventSource(adminShardStreamUrl, { withCredentials: true })` with a `fetch()` + `ReadableStream`
|
|
||||||
reader that sends the Bearer header, parses SSE frames, and adds reconnect/backoff +
|
|
||||||
access-token refresh-on-401. The **public** stream stays on `EventSource` (no credentials, so
|
|
||||||
nothing to change) and keeps its free auto-reconnect. Do not convert both blindly.
|
|
||||||
|
|
||||||
### Docs / spec (required, same PR)
|
|
||||||
|
|
||||||
9. Update `BACKEND_DESIGN.md` auth section (cookie → bearer-everywhere; tx-cookie exception).
|
|
||||||
10. Regenerate Swagger (`npm run swagger`) — v2 auth routes, `Authorization` security scheme,
|
|
||||||
remove `Set-Cookie` from documented responses.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 1b — CSP hardening (ships with the auth merge)
|
|
||||||
|
|
||||||
Once httpOnly is gone the access token lives in JS, so CSP's job becomes: **injected script can't
|
|
||||||
run, and if it somehow runs it can't phone home.** The app already ships a tuned policy
|
|
||||||
(`server/src/app.js`); v2 tightens it rather than rewriting it.
|
|
||||||
|
|
||||||
Two directives are load-bearing for the token-theft threat and must stay tight:
|
|
||||||
|
|
||||||
- `script-src 'self'` — no `'unsafe-inline'` / `'unsafe-eval'`. Primary defense; guard it.
|
|
||||||
- `connect-src 'self'` — the exfiltration channel. Do **not** widen it (e.g. to a separate API host)
|
|
||||||
unless the API genuinely becomes cross-origin; the SPA + REST + fetch-SSE are all same-origin here.
|
|
||||||
|
|
||||||
`style-src 'unsafe-inline'` stays — it permits inline styling, not script execution, and React's
|
|
||||||
pervasive `style={{…}}` attributes can't be nonce'd. It is not a meaningful hole.
|
|
||||||
|
|
||||||
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';
|
|
||||||
```
|
|
||||||
|
|
||||||
Changes vs. the current policy:
|
|
||||||
|
|
||||||
- **Add `form-action 'self'`** — blocks an injected `<form action="https://evil">` from POSTing the
|
|
||||||
token/credentials off-origin (an exfil path `connect-src` doesn't cover).
|
|
||||||
- **Tighten `frame-ancestors` `'self'` → `'none'`** — nothing legitimately frames the site.
|
|
||||||
- Keep `base-uri 'self'`, `object-src 'none'`, and `img-src … https:` (external `BRAND_*`
|
|
||||||
logo/hero and `<img>` in sanitized wiki/news bodies rely on `https:`).
|
|
||||||
|
|
||||||
Tracked follow-ups (own PRs, not blocking the merge):
|
|
||||||
|
|
||||||
- **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** — roll out `require-trusted-types-for 'script'` + a `trusted-types` policy in
|
|
||||||
**report-only** first; neutralizes most DOM-based XSS at the sink (the exact bug class that would
|
|
||||||
steal a JS-held token). Audit `dangerouslySetInnerHTML` + the `sanitizeHtml` render path first.
|
|
||||||
- **Report-only rollout** — ship the tightened policy via `Content-Security-Policy-Report-Only` with
|
|
||||||
`report-to` for one release, watch for violations, then flip to enforce.
|
|
||||||
|
|
||||||
Verify 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).
|
|
||||||
Confirm it's disabled or set `build.modulepreload.polyfill = false` in the Vite config. (The
|
|
||||||
`renderIndexHtml` branding injection adds only `<meta>`/`<link>` tags — no inline script, no nonce
|
|
||||||
needed.)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 2 — The domain split
|
|
||||||
|
|
||||||
**Rule:** one router file = one business capability; the URL names the domain; related endpoints
|
|
||||||
live together regardless of HTTP method; no generic `admin.router.js` / `api.router.js` catch-alls.
|
|
||||||
Controllers are **already** domain-split — Phase 2 is mostly re-wiring the routes, not the logic.
|
|
||||||
|
|
||||||
Target tree (`router/v2/`):
|
|
||||||
|
|
||||||
```
|
|
||||||
router/v2/
|
|
||||||
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/
|
|
||||||
login.router.js sso.router.js totp.router.js session.router.js
|
|
||||||
public/
|
|
||||||
news.router.js wiki.router.js page.router.js shard.router.js
|
|
||||||
player/
|
|
||||||
profile.router.js appeals.router.js shard.router.js
|
|
||||||
internal/ (unchanged — stays on the unpublished port, never mounted publicly)
|
|
||||||
```
|
|
||||||
|
|
||||||
Steps:
|
|
||||||
|
|
||||||
11. Carve `admin.routes.js` (~100 routes) into the per-capability files above, each requiring its
|
|
||||||
already-existing controller. A thin `admin/index.js` mounts them under `/admin`.
|
|
||||||
12. Split `public.routes.js` and `player.routes.js` the same way.
|
|
||||||
13. `v2.router.js` mounts the domain sub-routers; `api.router.js` mounts `/v1` (frozen) **and** `/v2`.
|
|
||||||
14. Keep `/internal` off the public listener exactly as v1 does (separate `internalApp.js` port).
|
|
||||||
15. Update `#swagger.*` annotations for every moved route, regenerate the spec, and update
|
|
||||||
`PROJECT_TREE.md` + `BACKEND_DESIGN.md` route map.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Sequencing & PR breakdown
|
|
||||||
|
|
||||||
**Phase 0 lands entirely before the v2 scaffold** — the app must be off `/api/v1`-direct and onto
|
|
||||||
`/api/mobile` (and the fleet adopting) before v2 behavior work begins.
|
|
||||||
|
|
||||||
1. **PR 0a — mobile facade (server):** mount `/api/mobile` in `api.router.js`; re-home the app's full
|
|
||||||
endpoint surface as thin delegates to existing controllers (same middleware); add contract tests
|
|
||||||
pinning the wire shapes; add v1-usage telemetry. No behavior change. See Phase 0.
|
|
||||||
2. **PR 0b — mobile migration (separate `Android-app` repo):** re-point the ~70 Retrofit endpoints,
|
|
||||||
the `ShardStreamClient` SSE path, and the SSO start/exchange URLs to `/api/mobile`; add the
|
|
||||||
app-version header + server-side min-version floor. Released and adopted on the app-store cadence
|
|
||||||
before v2 starts. See `docs/android/PLAN.md`.
|
|
||||||
3. **PR 1 — v2 scaffold:** `router/v2/` skeleton, `v2.router.js`, mount `/v2` next to `/v1`. Empty
|
|
||||||
but wired; no behavior change. Detailed in [API_V2_SKELETON.md](./API_V2_SKELETON.md).
|
|
||||||
4. **PR 2 — auth merge (server):** v2 bearer auth routes + SSO callback code-exchange + admin-SSE-on-
|
|
||||||
Bearer + docs/swagger. **Re-point `/api/mobile`'s internals to the unified flow behind unchanged
|
|
||||||
wire shapes** (contract tests must stay green) — the app sees nothing.
|
|
||||||
5. **PR 3 — auth merge (web client):** `client.js` bearer + silent-refresh; `useShardFeed.js` fetch
|
|
||||||
stream for the **admin** stream only.
|
|
||||||
6. **PR 4…N — domain split:** one PR per admin capability (dashboard, users, moderation, content,
|
|
||||||
wiki, shard, settings, …) to keep diffs reviewable; then public + player. Keep `/api/mobile`
|
|
||||||
delegating correctly as controllers move; contract tests catch any shape drift.
|
|
||||||
7. **PR final — retire v1:** only once (a) the web client is fully on v2, **and** (b) telemetry shows
|
|
||||||
no `/api/v1` traffic from the pre-facade app, i.e. the old fleet has aged past the version floor.
|
|
||||||
Then delete `router/v1` and the dead cookie code in `token.js`. See § Cross-component blast radius.
|
|
||||||
|
|
||||||
Each PR: 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
|
|
||||||
|
|
||||||
The plan above is written as if the website is the whole world. It isn't. **Three independent
|
|
||||||
clients consume the site's HTTP/SSE API, and two of them live in separate repos on separate release
|
|
||||||
cadences.** Any change to a URL shape, an auth mechanism, or a namespace is a *contract* change, not
|
|
||||||
a local refactor — the same discipline the docs demand for the shard↔sidecar wire protocol applies
|
|
||||||
here. This section is the coordination map the per-repo work must respect.
|
|
||||||
|
|
||||||
### Who calls the site API
|
|
||||||
|
|
||||||
| Consumer | Repo (release cadence) | How it's pinned to v1 | Migration cost |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Browser SPA | `website/client` (same repo, lockstep) | `BASE = /api/v1` in `client/src/api/client.js` | In-plan (Phase 1 client + Phase 2) |
|
|
||||||
| **Android app** | `Android-app` (separate repo, app-store cadence, un-updatable installs in the wild) | **~70 Retrofit endpoints hardcode `api/v1/…`** across `data/api/*.kt`; SSE path hardcoded in `ShardStreamClient.kt`; SSO in `SsoAuthManager.kt` | **Migrated once to the version-agnostic `/api/mobile` facade in Phase 0; thereafter decoupled from v2.** |
|
|
||||||
| Discord bot | `website/bot` (separate deploy, but env-configured) | `SITE_PUBLIC_URL` env → `/api/v1/public` (`announce`/`wiki` commands) | Trivial — repoint the env var once `/v2/public` exists; unaffected by the auth merge (anonymous public reads) |
|
|
||||||
|
|
||||||
Only the browser migrates in true lockstep. The bot is a one-line env change. **The Android app was
|
|
||||||
the load-bearing coupling — the `/api/mobile` facade (Phase 0) is what removes it from the critical
|
|
||||||
path so v2 can proceed freely.**
|
|
||||||
|
|
||||||
### Mobile (site ↔ Android) — decoupled by the Phase 0 facade
|
|
||||||
|
|
||||||
The facade turns the mobile problem from "the app must chase every version rename forever" into "the
|
|
||||||
app pins one stable namespace, migrated once." What remains to keep in view:
|
|
||||||
|
|
||||||
1. **The facade must be *complete* or the decoupling leaks.** The app uses mostly *shared* routes
|
|
||||||
(`auth/me/*`, `public/*`, `player/*`, `admin/*`), not just the mobile-auth ones. Every endpoint in
|
|
||||||
the app's inventory (below) must exist under `/api/mobile`, or the app still calls `/api/v1`
|
|
||||||
directly and is not actually insulated. This is the single most important Phase 0 check.
|
|
||||||
2. **The one-time cutover is still fleet-gated — the facade doesn't erase that, it *contains* it.**
|
|
||||||
The *pre-facade* installed app still calls `/api/v1/*`, and with no force-update it does so until
|
|
||||||
its users update. So `/api/v1` stays alive until that old fleet ages out. The difference: this is
|
|
||||||
now a **pure-rename** migration decoupled from v2 behavior, paid once, and closed out by the
|
|
||||||
version floor (next point) rather than blocking the auth merge.
|
|
||||||
3. **The version floor is still needed — its role just changed.** Add an app-version header + a
|
|
||||||
server-side min-version gate (Phase 0 step 7). Its *first* job is to sunset the pre-facade app so
|
|
||||||
`/api/v1` can finally be deleted; *afterward* it is the in-band mechanism for any breaking
|
|
||||||
`/api/mobile` change, since URL-path versioning is deliberately absent there. Without it there is
|
|
||||||
still no safe way to force the last stragglers off v1.
|
|
||||||
4. **v1-usage telemetry** — per-route counts, tagged to distinguish the migrated browser from
|
|
||||||
lingering pre-facade app installs, so "nothing calls v1" is measured, not assumed.
|
|
||||||
5. **`/api/mobile` is now a frozen wire contract** — contract tests (Phase 0 step 5) must guard its
|
|
||||||
response shapes so a v2 internal refactor can't silently break installed apps. This replaces
|
|
||||||
"don't break the app during the auth merge" with "the auth merge can't reach the app at all."
|
|
||||||
6. **The public/mobile SSE stream must stay anonymous** (Phase 0 step 4 / Phase 1 step 5) — the app's
|
|
||||||
`ShardStreamClient` sends no auth header.
|
|
||||||
7. **Record the mobile side in `docs/android/PLAN.md`.** This plan owns the `/api/mobile` server
|
|
||||||
contract + the facade; that plan owns the app migration + version-floor rollout.
|
|
||||||
|
|
||||||
### Shard (site ↔ link) — mostly *out* of the blast radius, with one seam to guard
|
|
||||||
|
|
||||||
The auth merge is a **website↔client** change; the **website↔sidecar** contract is orthogonal and
|
|
||||||
should not move:
|
|
||||||
|
|
||||||
- The sidecar's own bearer token lives in the DB (`uoLinkConfig`, write-only in the API), the
|
|
||||||
server-side REST client (`utils/uoLinkClient.js`) and live ingest (`utils/shardIngest.js`) talk to
|
|
||||||
the sidecar independent of any browser/mobile session, and `PROTOCOL_VERSION` / `X-UOLink-Version`
|
|
||||||
are their own versioning axis. **None of that changes for v2** — `link/` needs no edit and
|
|
||||||
`PROTOCOL_VERSION` does **not** bump for this work.
|
|
||||||
- **The one seam:** the *admin* SSE stream re-fans the sidecar's full feed (including sensitive kinds).
|
|
||||||
When that endpoint moves to Bearer auth under v2, the public/admin allowlist split in the fan-out
|
|
||||||
(`utils/shardBroadcast.js` / `shardIngest.js`) is the same security boundary it is today and must be
|
|
||||||
preserved verbatim. This is a website-internal concern; it does not reach into `link/`.
|
|
||||||
|
|
||||||
### SSO transaction cookies are load-bearing for *both* web and native
|
|
||||||
|
|
||||||
Phase 1 step 3's carve-out (keep the short-lived httpOnly tx / PKCE-verifier / pending-TOTP cookies)
|
|
||||||
isn't only a web concern. The Android SSO flow opens a Custom Tab to the website's
|
|
||||||
`/auth/…/sso/start`, so it rides the **same** server-side redirect transaction and the **same** tx
|
|
||||||
cookies. The mobile side already ends in a code-exchange (`/auth/mobile/sso/exchange`) — which is the
|
|
||||||
exact pattern v2's web callback is adopting. So: don't let "cookies go away" delete the tx cookies, or
|
|
||||||
you break native SSO as well as web SSO.
|
|
||||||
|
|
||||||
### Contract-sync checklist (this is a multi-repo change)
|
|
||||||
|
|
||||||
A v2 endpoint or auth change is not "done" until every consumer's contract is reconciled:
|
|
||||||
|
|
||||||
- `docs/website/BACKEND_DESIGN.md` — route map + auth/security contract, incl. the `/api/mobile`
|
|
||||||
facade as its own documented, version-agnostic surface with pinned response shapes.
|
|
||||||
- `server/swagger/swagger-output.json` — regenerated (tag `/api/mobile` separately from v1/v2).
|
|
||||||
- Facade contract tests — the frozen `/api/mobile` wire shapes; kept green across every v2 change.
|
|
||||||
- `docs/android/PLAN.md` — the `/api/mobile` migration milestone + the app-version-floor mechanism.
|
|
||||||
- The Android app's own endpoint constants + contract comments (`data/api/*.kt`,
|
|
||||||
`ShardStreamClient.kt`, `SsoAuthManager.kt`) — re-pointed to `/api/mobile` in a separate
|
|
||||||
`Android-app` PR.
|
|
||||||
- Bot: note the `SITE_PUBLIC_URL` repoint in `website/` deploy docs when `/v2/public` lands.
|
|
||||||
|
|
||||||
## Risks / watch-items
|
|
||||||
|
|
||||||
- **XSS is now token-theft.** Losing httpOnly means any XSS can read the access token. Mitigation:
|
|
||||||
short access TTL + refresh rotation + revocation, paired with the Phase 1b CSP hardening.
|
|
||||||
- **SSO/email tx cookies cannot be removed** — don't let "cookies go away" over-reach into the
|
|
||||||
redirect transaction. Only the session handoff changes.
|
|
||||||
- **SSE reconnect regressions** — `EventSource` gave auto-reconnect + `Last-Event-ID` for free; the
|
|
||||||
fetch reader must reproduce backoff and (if used) event-id resume, plus refresh a stale token
|
|
||||||
mid-stream.
|
|
||||||
- **Incomplete facade defeats the purpose** — if any endpoint the app needs is left only under
|
|
||||||
`/api/v1`, the app still calls v1 directly and Phase 0's decoupling silently leaks. Reconcile the
|
|
||||||
facade against the app's full endpoint inventory before shipping PR 0a.
|
|
||||||
- **The facade is a security surface, not an alias** — each `/api/mobile` route must carry the *same*
|
|
||||||
auth middleware as the route it mirrors. A re-exposed admin route missing `adminOnly`, or the
|
|
||||||
mobile SSE leaking sensitive kinds, is a privilege/data-exposure hole introduced by the facade.
|
|
||||||
- **Silent shape drift through the facade** — once `/api/mobile` internals re-point to v2, a v2
|
|
||||||
response-shape change can break installed apps with no compile error. Contract tests on the facade
|
|
||||||
are the guardrail; treat a failing one as a release blocker, not a test to update.
|
|
||||||
- **Double maintenance while v1 and v2 coexist** — bug fixes may need both. Keep the window short;
|
|
||||||
drive the *web* client to v2 completion. The facade means the window is no longer bounded by app
|
|
||||||
behavior — only by the pre-facade fleet aging out (see § Cross-component blast radius).
|
|
||||||
- **Public SSE must not become auth-gated** — the single most likely regression in Phase 1 is
|
|
||||||
reflexively wrapping *both* shard streams in `requireAuth`. The public stream is anonymous by
|
|
||||||
contract; doing so blacks out the public live boards for every logged-out browser and every phone.
|
|
||||||
- **v1 deletion is still fleet-gated (once)** — even with the facade, the *pre-facade* installed app
|
|
||||||
calls `/api/v1` until the version floor ages it out. The facade contains this to a one-time,
|
|
||||||
behavior-free cutover done *before* v2; it does not make v1 deletable on the web client's schedule.
|
|
||||||
- **`link/` is not in scope but is adjacent** — resist bumping `PROTOCOL_VERSION` or touching the
|
|
||||||
sidecar client for v2 work; the only shared seam is preserving the admin-stream allowlist split.
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
# Website API v2 — `/api/v2` Skeleton (PR 1)
|
|
||||||
|
|
||||||
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.
|
|
||||||
@@ -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/ — 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.
|
|
||||||
@@ -223,28 +223,6 @@ list/revoke surface: `device_name VARCHAR(100) NULL` (a friendly label) and `las
|
|||||||
NULL` (bumped on each refresh). Existing rows get them via the schema's ALTER section; the token model
|
NULL` (bumped on each refresh). Existing rows get them via the schema's ALTER section; the token model
|
||||||
is otherwise unchanged.
|
is otherwise unchanged.
|
||||||
|
|
||||||
### trusted_devices — MFA "Trust this device"
|
|
||||||
|
|
||||||
Lets a browser/app **skip the TOTP step** at login (never the password) for 30 days. Pattern-identical
|
|
||||||
to `mobile_refresh_tokens`: the opaque trust token lives client-side (the `rg_trust` httpOnly cookie on
|
|
||||||
web, `X-Trust-Token` / EncryptedSharedPreferences on native) and only its **sha256** hash is stored
|
|
||||||
(`token_hash CHAR(64) UNIQUE`) — sha256, not bcrypt, because a 256-bit random token is looked up **by
|
|
||||||
its hash** via the unique index (a per-row salt would break that). Columns mirror the mobile table
|
|
||||||
(`platform`, `device_name`, `device_hash`, `user_agent`, `created_at`, `last_used_at`, `expires_at`,
|
|
||||||
`revoked_at`). Capped at 10 rows/user **in application code — no silent pruning** (an over-cap trust is
|
|
||||||
refused so the client can prompt the user to revoke one first). Consulted only at the login/password
|
|
||||||
step, never at token refresh, and revoked wholesale on untrust / password change / password reset /
|
|
||||||
TOTP disable. See `docs/website/TRUSTED_DEVICES_MFA.md`.
|
|
||||||
|
|
||||||
### recovery_codes — single-use MFA backup codes
|
|
||||||
|
|
||||||
Generated at TOTP enrollment (10 at a time, shown to the user **once**) so a user who loses their
|
|
||||||
authenticator can complete login without an admin reset. `code_hash VARCHAR(72)` is a **bcrypt** hash
|
|
||||||
(not sha256): a recovery code is a human-typed, lower-entropy fallback credential — the closest
|
|
||||||
analogue to a password — and there is no hash-lookup constraint (verification fetches the user's ≤10
|
|
||||||
unused rows and `bcrypt.compare`s each, like password verification). `used_at` is the single-use
|
|
||||||
marker. Cleared wholesale on TOTP disable / password change / password reset.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. API contract
|
## 4. API contract
|
||||||
@@ -255,9 +233,8 @@ accepts `Authorization: Bearer` for API testing).
|
|||||||
### /auth (auth.routes.js → auth.controller.js)
|
### /auth (auth.routes.js → auth.controller.js)
|
||||||
| Method | Path | Auth | Body | Purpose |
|
| Method | Path | Auth | Body | Purpose |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| POST | `/login` | — (rate-limited) | `{username,password}` | verify, set cookie, log `auth.login`, update `last_login_at`. If the account has TOTP **and this browser is a trusted device** (a valid `rg_trust` cookie bound to the user), the TOTP step is **skipped** and a session is issued directly (logs `auth.login.trusted_device`). Otherwise a 2FA account returns `{totpRequired, challenge}`. |
|
| POST | `/login` | — (rate-limited) | `{username,password}` | verify, set cookie, log `auth.login`, update `last_login_at` |
|
||||||
| POST | `/login/totp` | — (rate-limited) | `{challenge, code? \| recoveryCode?, trustDevice?, deviceName?}` | complete 2FA with a TOTP **or** single-use recovery code. `trustDevice` sets the `rg_trust` cookie so future logins skip TOTP; at the device cap the session is still issued and the body carries `{trustLimitReached, devices}`. |
|
| POST | `/logout` | cookie | — | clear cookie |
|
||||||
| POST | `/logout` | cookie | — | clear cookie (the `rg_trust` trust cookie deliberately **survives** logout) |
|
|
||||||
| GET | `/me` | cookie / bearer | — | current user (no hash) or 401 — client bootstraps auth state |
|
| GET | `/me` | cookie / bearer | — | current user (no hash) or 401 — client bootstraps auth state |
|
||||||
| POST | `/password/forgot` | — (rate-limited) | `{email}` | email a single-use, ~1h reset link to **every active account** on the address; **always** returns the same generic 200 (no account enumeration). Email is non-unique, so several accounts may each get a link naming their username. Logs `account.password.reset.request`. |
|
| POST | `/password/forgot` | — (rate-limited) | `{email}` | email a single-use, ~1h reset link to **every active account** on the address; **always** returns the same generic 200 (no account enumeration). Email is non-unique, so several accounts may each get a link naming their username. Logs `account.password.reset.request`. |
|
||||||
| GET | `/password/reset/:token` | — | — | validate a link → `{username}` for the form, else 404 (never distinguishes expired/used/never-existed) |
|
| GET | `/password/reset/:token` | — | — | validate a link → `{username}` for the form, else 404 (never distinguishes expired/used/never-existed) |
|
||||||
@@ -265,13 +242,8 @@ accepts `Authorization: Bearer` for API testing).
|
|||||||
| GET | `/me/account` | cookie / bearer | — | full self account (`id, username, role, email, status, totp_enabled, has_password`) |
|
| GET | `/me/account` | cookie / bearer | — | full self account (`id, username, role, email, status, totp_enabled, has_password`) |
|
||||||
| PATCH | `/me/account/username` | cookie / bearer (rate-limited) | `{username}` | change own username; re-issues the caller's session |
|
| PATCH | `/me/account/username` | cookie / bearer (rate-limited) | `{username}` | change own username; re-issues the caller's session |
|
||||||
| PATCH | `/me/account/password` | cookie / bearer (rate-limited) | `{newPassword, currentPassword?}` | change/set own password (current required unless the account has none); revokes other sessions, keeps the caller's |
|
| PATCH | `/me/account/password` | cookie / bearer (rate-limited) | `{newPassword, currentPassword?}` | change/set own password (current required unless the account has none); revokes other sessions, keeps the caller's |
|
||||||
| POST | `/me/account/totp/setup` · `…/enable` · `…/disable` | cookie / bearer | `{code}` on enable/disable | self 2FA enrollment (disable needs a valid current code, not a password). **enable** returns the one-time `recoveryCodes`; **disable** clears the user's trusted devices + recovery codes |
|
| POST | `/me/account/totp/setup` · `…/enable` · `…/disable` | cookie / bearer | `{code}` on enable/disable | self 2FA enrollment (disable needs a valid current code, not a password) |
|
||||||
| GET | `/me/account/identities` · DELETE `…/:provider` | cookie / bearer | — | list / unlink own SSO identities |
|
| GET | `/me/account/identities` · DELETE `…/:provider` | cookie / bearer | — | list / unlink own SSO identities |
|
||||||
| GET | `/me/trusted-devices` | cookie / bearer | — | list own active trusted devices (never tokens) |
|
|
||||||
| POST | `/me/trusted-devices` | cookie / bearer (rate-limited) | `{deviceName?}` | trust the current device; web gets an httpOnly `rg_trust` cookie, native gets `{trustToken}`. **409 `{error:'trusted_device_limit', devices}`** at the cap |
|
|
||||||
| DELETE | `/me/trusted-devices` · `…/:id` | cookie / bearer | — | untrust all / one (ownership-scoped) |
|
|
||||||
| GET | `/me/account/recovery-codes/status` | cookie / bearer | — | remaining unused code count (never the codes) |
|
|
||||||
| POST | `/me/account/recovery-codes/generate` | cookie / bearer (rate-limited, **password step-up**) | `{currentPassword?}` | regenerate the one-time recovery codes (returned once); refused when 2FA is off |
|
|
||||||
| POST | `/me/devices` | cookie / bearer | `{endpoint, transport?, platform?}` | register a push endpoint; **rejects a disallowed endpoint 400** (SSRF guard). Idempotent per (user, endpoint) |
|
| POST | `/me/devices` | cookie / bearer | `{endpoint, transport?, platform?}` | register a push endpoint; **rejects a disallowed endpoint 400** (SSRF guard). Idempotent per (user, endpoint) |
|
||||||
| GET | `/me/devices` · DELETE `…/:id` | cookie / bearer | — | list / unregister own push devices |
|
| GET | `/me/devices` · DELETE `…/:id` | cookie / bearer | — | list / unregister own push devices |
|
||||||
| GET | `/me/notifications/streams` | cookie / bearer | — | the subscribable catalog (`personal`/`requiresLinkedAccount` flags) |
|
| GET | `/me/notifications/streams` | cookie / bearer | — | the subscribable catalog (`personal`/`requiresLinkedAccount` flags) |
|
||||||
@@ -284,16 +256,6 @@ lets a client (the Android app) manage its own account through one surface witho
|
|||||||
`/admin` (docs/android/PLAN.md §6.4). The older `/player/account/*` + `/admin/account/*` routes stay
|
`/admin` (docs/android/PLAN.md §6.4). The older `/player/account/*` + `/admin/account/*` routes stay
|
||||||
for web back-compat.
|
for web back-compat.
|
||||||
|
|
||||||
**The `/player/*` group is self-service, not player-only.** Staff are a **superset** of players — every
|
|
||||||
player ability plus their staff tools on top — so the whole `/player/*` router (game-account linking,
|
|
||||||
character/vendor/house reads, credential changes, appeals) sits behind `requireAuth` **only**, never
|
|
||||||
`requireRole('player')`. Every handler is self-scoped to the caller by `req.user.id`, so an admin/editor/
|
|
||||||
moderator using it sees only their **own** linked accounts and characters (with the pre-existing
|
|
||||||
`isAdmin` bypass still letting a genuine admin read *any* character). Staff also reach the identical
|
|
||||||
self-scoped handlers under `/admin/shard/*` (same controller) for the web admin surface; the two are
|
|
||||||
interchangeable. This is why a staff account with linked game characters gets its "My characters" and
|
|
||||||
personal notification streams on the mobile client — the group no longer 403s a non-`player` role.
|
|
||||||
|
|
||||||
**Password reset.** Uses the same audited pattern as `user_invites`: an opaque 32-byte token
|
**Password reset.** Uses the same audited pattern as `user_invites`: an opaque 32-byte token
|
||||||
whose **sha256 hash only** is stored in `password_resets`, single-use and short-lived (~1h). It
|
whose **sha256 hash only** is stored in `password_resets`, single-use and short-lived (~1h). It
|
||||||
also serves SSO-only accounts (null `password_hash`) as their "set an initial password" path. The
|
also serves SSO-only accounts (null `password_hash`) as their "set an initial password" path. The
|
||||||
@@ -413,9 +375,6 @@ Public content GETs pass through the **siteMode** gate (§5).
|
|||||||
| GET | `/settings` · PUT `/settings` | read all / update `{key:value,...}` |
|
| GET | `/settings` · PUT `/settings` | read all / update `{key:value,...}` |
|
||||||
| GET | `/activity?limit=&offset=` | paginated activity log |
|
| GET | `/activity?limit=&offset=` | paginated activity log |
|
||||||
| GET | `/users` · POST `/users` · PUT `/users/:id` · DELETE `/users/:id` | user mgmt (can't delete self / last admin; password hashed on write) |
|
| GET | `/users` · POST `/users` · PUT `/users/:id` · DELETE `/users/:id` | user mgmt (can't delete self / last admin; password hashed on write) |
|
||||||
| GET | `/users/:id/trusted-devices` | list a user's active trusted devices (never tokens) |
|
|
||||||
| DELETE | `/users/:id/trusted-devices` · `…/:deviceId` | revoke all / one of a user's trusted devices (logs `admin.trusted_device.revoke[_all]`) |
|
|
||||||
| POST | `/users/:id/mfa/reset` | recover a locked-out user: disable TOTP + revoke all trusted devices + clear recovery codes (logs `admin.user.totp.reset`) |
|
|
||||||
|
|
||||||
Every admin write logs to `activity_log`.
|
Every admin write logs to `activity_log`.
|
||||||
|
|
||||||
@@ -446,8 +405,7 @@ who"; `activity_log` provides the history feed.
|
|||||||
## 6. Auth & security
|
## 6. Auth & security
|
||||||
|
|
||||||
- **JWT** signed with `JWT_SECRET`, `expiresIn=JWT_EXPIRES_IN` (default `1d`); payload `{id,username,role}`.
|
- **JWT** signed with `JWT_SECRET`, `expiresIn=JWT_EXPIRES_IN` (default `1d`); payload `{id,username,role}`.
|
||||||
- **Cookie**: `httpOnly`, `sameSite=Lax`, `path=/`, and **`secure` decided per-request** (`COOKIE_SECURE=auto` → `secure: req.secure`).
|
- **Cookie**: `httpOnly`, `sameSite=Lax`, `path=/`, and **`secure` decided per-request** (`COOKIE_SECURE=auto` → `secure: req.secure`). This is the key to dual access: the cookie is `Secure` when reached through Pangolin (HTTPS, `X-Forwarded-Proto: https`) but **not** `Secure` when reached directly over the LAN IP on plain HTTP — so login works in both. `COOKIE_SECURE=true|false` can force it. Requires `trust proxy` (below). `localhost:5173` (Vite) and `localhost:3000` are same-site, so the cookie flows in dev too.
|
||||||
- **Trusted-device MFA.** A second, separate httpOnly cookie (`rg_trust`, default 30d) — opaque, sha256-hashed server-side in `trusted_devices` — lets a browser/app **skip the TOTP step** (never the password) on future logins. It is a server-side, per-row-revocable record (never a JWT claim), so the stateless session JWT is unchanged and trust stays revocable. It only ever gates the **second factor**; it deliberately outlives logout, and is cleared on untrust / password change / password reset / TOTP disable. **Recovery codes** (bcrypt, single-use) are the 2FA-lockout fallback. All admin trusted-device/MFA actions and the self actions (`auth.login.trusted_device`, `account.trusted_device.*`, `account.recovery_code*`, `admin.trusted_device.*`, `admin.user.totp.reset`) are audit-logged. See `docs/website/TRUSTED_DEVICES_MFA.md`. This is the key to dual access: the cookie is `Secure` when reached through Pangolin (HTTPS, `X-Forwarded-Proto: https`) but **not** `Secure` when reached directly over the LAN IP on plain HTTP — so login works in both. `COOKIE_SECURE=true|false` can force it. Requires `trust proxy` (below). `localhost:5173` (Vite) and `localhost:3000` are same-site, so the cookie flows in dev too.
|
|
||||||
- **bcrypt** hashing (cost 10+); plaintext passwords never stored, logged, or returned.
|
- **bcrypt** hashing (cost 10+); plaintext passwords never stored, logged, or returned.
|
||||||
- **Rate limiting** (`express-rate-limit`) on `/auth/login` and `/public/contact`.
|
- **Rate limiting** (`express-rate-limit`) on `/auth/login` and `/public/contact`.
|
||||||
- **Validation** (`express-validator`) on all writes; centralized error handler.
|
- **Validation** (`express-validator`) on all writes; centralized error handler.
|
||||||
@@ -511,13 +469,11 @@ subsystem (`[server]`, `[http]`, `[db]`, `[auth]`, `[admin]`, `[ratelimit]`, …
|
|||||||
`env_file: .env`, `DB_HOST=db`, `depends_on: db (healthy)`, volume `uploads:/app/uploads`,
|
`env_file: .env`, `DB_HOST=db`, `depends_on: db (healthy)`, volume `uploads:/app/uploads`,
|
||||||
`ports: "3000:3000"` — **binds 0.0.0.0** (no `127.0.0.1:` prefix) so Pangolin reaches it.
|
`ports: "3000:3000"` — **binds 0.0.0.0** (no `127.0.0.1:` prefix) so Pangolin reaches it.
|
||||||
- `ntfy` (M7): pinned upstream `binwiederhier/ntfy` image, declarative config only
|
- `ntfy` (M7): pinned upstream `binwiederhier/ntfy` image, declarative config only
|
||||||
(`./ntfy/server.yml` mounted `:ro` + `NTFY_BASE_URL`), volume `ntfydata:/var/lib/ntfy`,
|
(`./ntfy/server.yml` mounted `:ro` + `NTFY_BASE_URL`), volume `ntfydata:/var/lib/ntfy`, **no
|
||||||
**publishes `:80` on a host port** (`${NTFY_HOST_PORT:-2586}:80`, binds 0.0.0.0) so Pangolin — which
|
published host port** — devices reach it via the reverse proxy; the backend publisher reaches it
|
||||||
runs outside the compose network — can forward the notification subdomain to it, the same reason
|
over the private compose network. Anonymous read-write to unguessable topics (no accounts to
|
||||||
`app` publishes `3000`. Both devices (SSE subscribe) and the backend publisher (POSTing tickles to
|
provision) — safe because pushes are content-free tickles. Bringing the stack up provisions a
|
||||||
registered device endpoints) reach ntfy on that public origin. Anonymous read-write to unguessable
|
working push relay with **zero interactive setup**.
|
||||||
topics (no accounts to provision) — safe because pushes are content-free tickles. Bringing the stack
|
|
||||||
up provisions a working push relay with **zero interactive setup**.
|
|
||||||
- Volumes: `dbdata`, `uploads`, `ntfydata`.
|
- Volumes: `dbdata`, `uploads`, `ntfydata`.
|
||||||
|
|
||||||
Express listens on `0.0.0.0:${PORT||3000}`. Pangolin terminates TLS and proxies to `app`.
|
Express listens on `0.0.0.0:${PORT||3000}`. Pangolin terminates TLS and proxies to `app`.
|
||||||
@@ -550,9 +506,6 @@ NTFY_BASE_URL=https://ntfy.example.com
|
|||||||
# shows push as unavailable for the shard.
|
# shows push as unavailable for the shard.
|
||||||
NTFY_PUBLIC_URL=https://ntfy.example.com
|
NTFY_PUBLIC_URL=https://ntfy.example.com
|
||||||
NTFY_ALLOWED_ORIGINS=https://ntfy.example.com
|
NTFY_ALLOWED_ORIGINS=https://ntfy.example.com
|
||||||
# Host port the ntfy container publishes :80 on (default 2586); the reverse proxy
|
|
||||||
# forwards the notification subdomain to host:NTFY_HOST_PORT. Change on a conflict.
|
|
||||||
NTFY_HOST_PORT=2586
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`.gitignore`: `node_modules/`, `.env`, `_reference/`, `client/dist/`, `uploads/`.
|
`.gitignore`: `node_modules/`, `.env`, `_reference/`, `client/dist/`, `uploads/`.
|
||||||
|
|||||||
@@ -1,534 +0,0 @@
|
|||||||
# Website — Project Tree
|
|
||||||
|
|
||||||
> **Auto-generated.** This file is maintained by the `sync-project-tree` CI workflow in
|
|
||||||
> the [`RunicGateway/website`](https://gitea.whitlocktech.com/RunicGateway/website) repository, which
|
|
||||||
> opens a pull request here whenever the tracked file layout on `main` changes. Do not edit
|
|
||||||
> by hand — changes will be overwritten by the next sync.
|
|
||||||
|
|
||||||
A snapshot of the tracked files in the repository (build output, dependencies, and other
|
|
||||||
git-ignored paths are excluded).
|
|
||||||
|
|
||||||
```text
|
|
||||||
website/
|
|
||||||
├── .claude/
|
|
||||||
│ └── launch.json
|
|
||||||
├── .gitea/
|
|
||||||
│ ├── ISSUE_TEMPLATE/
|
|
||||||
│ │ ├── bug_report.md
|
|
||||||
│ │ ├── config.yaml
|
|
||||||
│ │ └── feature_request.md
|
|
||||||
│ ├── scripts/
|
|
||||||
│ │ └── gen_tree.py
|
|
||||||
│ ├── workflows/
|
|
||||||
│ │ ├── build-images.yml
|
|
||||||
│ │ ├── pr-checks.yml
|
|
||||||
│ │ ├── sonarqube.yml
|
|
||||||
│ │ └── sync-project-tree.yml
|
|
||||||
│ └── PULL_REQUEST_TEMPLATE.md
|
|
||||||
├── bot/
|
|
||||||
│ ├── src/
|
|
||||||
│ │ ├── discord/
|
|
||||||
│ │ │ ├── commands/
|
|
||||||
│ │ │ │ ├── announce.command.js
|
|
||||||
│ │ │ │ ├── autorole.command.js
|
|
||||||
│ │ │ │ ├── ban.command.js
|
|
||||||
│ │ │ │ ├── filter.command.js
|
|
||||||
│ │ │ │ ├── filterallow.command.js
|
|
||||||
│ │ │ │ ├── index.js
|
|
||||||
│ │ │ │ ├── invite.command.js
|
|
||||||
│ │ │ │ ├── kick.command.js
|
|
||||||
│ │ │ │ ├── modlog.command.js
|
|
||||||
│ │ │ │ ├── mute.command.js
|
|
||||||
│ │ │ │ ├── news.command.js
|
|
||||||
│ │ │ │ ├── ping.command.js
|
|
||||||
│ │ │ │ ├── role.command.js
|
|
||||||
│ │ │ │ ├── rolemenu.command.js
|
|
||||||
│ │ │ │ ├── roles.command.js
|
|
||||||
│ │ │ │ ├── schedule.command.js
|
|
||||||
│ │ │ │ ├── warn.command.js
|
|
||||||
│ │ │ │ ├── warnings.command.js
|
|
||||||
│ │ │ │ └── wiki.command.js
|
|
||||||
│ │ │ ├── discordManager.js
|
|
||||||
│ │ │ ├── guildMemberAdd.js
|
|
||||||
│ │ │ ├── guildMemberRemove.js
|
|
||||||
│ │ │ ├── inviteTracker.js
|
|
||||||
│ │ │ ├── messageFilter.js
|
|
||||||
│ │ │ ├── modLog.js
|
|
||||||
│ │ │ ├── newsAnnounce.js
|
|
||||||
│ │ │ └── roleMenuHandler.js
|
|
||||||
│ │ ├── filter/
|
|
||||||
│ │ │ ├── filterCache.js
|
|
||||||
│ │ │ ├── inviteFilter.js
|
|
||||||
│ │ │ ├── normalize.js
|
|
||||||
│ │ │ └── spamFilter.js
|
|
||||||
│ │ ├── internal/
|
|
||||||
│ │ │ ├── internal.controller.js
|
|
||||||
│ │ │ ├── internal.routes.js
|
|
||||||
│ │ │ └── requireInternalKey.js
|
|
||||||
│ │ ├── invites/
|
|
||||||
│ │ │ ├── inviteRotator.js
|
|
||||||
│ │ │ └── inviteScheduler.js
|
|
||||||
│ │ ├── model/
|
|
||||||
│ │ │ ├── filterAllowlist.js
|
|
||||||
│ │ │ ├── filterHits.js
|
|
||||||
│ │ │ ├── filterWords.js
|
|
||||||
│ │ │ ├── guildConfig.js
|
|
||||||
│ │ │ ├── inviteLog.js
|
|
||||||
│ │ │ ├── memberEvents.js
|
|
||||||
│ │ │ ├── roleMenus.js
|
|
||||||
│ │ │ ├── scheduledMessages.js
|
|
||||||
│ │ │ ├── spamHits.js
|
|
||||||
│ │ │ ├── tempRoles.js
|
|
||||||
│ │ │ └── warnings.js
|
|
||||||
│ │ ├── roles/
|
|
||||||
│ │ │ └── tempRoleSweeper.js
|
|
||||||
│ │ ├── scheduler/
|
|
||||||
│ │ │ └── scheduler.js
|
|
||||||
│ │ ├── site/
|
|
||||||
│ │ │ └── siteApiClient.js
|
|
||||||
│ │ ├── utils/
|
|
||||||
│ │ │ ├── duration.js
|
|
||||||
│ │ │ └── logger.js
|
|
||||||
│ │ ├── app.js
|
|
||||||
│ │ ├── bootstrap.js
|
|
||||||
│ │ ├── brand.js
|
|
||||||
│ │ ├── db.js
|
|
||||||
│ │ └── server.js
|
|
||||||
│ ├── .env.example
|
|
||||||
│ ├── .gitignore
|
|
||||||
│ ├── Dockerfile
|
|
||||||
│ ├── package-lock.json
|
|
||||||
│ └── package.json
|
|
||||||
├── brand/
|
|
||||||
│ └── README.md
|
|
||||||
├── client/
|
|
||||||
│ ├── public/
|
|
||||||
│ │ ├── assets/
|
|
||||||
│ │ │ └── img/
|
|
||||||
│ │ │ ├── favicon.ico
|
|
||||||
│ │ │ ├── hero-moon.png
|
|
||||||
│ │ │ ├── runic-emblem.png
|
|
||||||
│ │ │ └── uomysticmoon-main-hero.png
|
|
||||||
│ │ └── robots.txt
|
|
||||||
│ ├── src/
|
|
||||||
│ │ ├── api/
|
|
||||||
│ │ │ └── client.js
|
|
||||||
│ │ ├── blocks/
|
|
||||||
│ │ │ ├── types/
|
|
||||||
│ │ │ │ ├── cta.jsx
|
|
||||||
│ │ │ │ ├── divider.jsx
|
|
||||||
│ │ │ │ ├── heading.jsx
|
|
||||||
│ │ │ │ ├── image.jsx
|
|
||||||
│ │ │ │ ├── quote.jsx
|
|
||||||
│ │ │ │ ├── richText.jsx
|
|
||||||
│ │ │ │ └── twoColumn.jsx
|
|
||||||
│ │ │ ├── BlockRenderer.jsx
|
|
||||||
│ │ │ ├── editorKit.jsx
|
|
||||||
│ │ │ ├── index.js
|
|
||||||
│ │ │ └── registry.js
|
|
||||||
│ │ ├── components/
|
|
||||||
│ │ │ ├── security/
|
|
||||||
│ │ │ │ ├── RecoveryCodesDisplay.jsx
|
|
||||||
│ │ │ │ ├── RecoveryCodesPanel.jsx
|
|
||||||
│ │ │ │ ├── TrustedDevicesPanel.jsx
|
|
||||||
│ │ │ │ └── TrustLimitModal.jsx
|
|
||||||
│ │ │ ├── CharacterSheet.jsx
|
|
||||||
│ │ │ ├── CharacterStats.jsx
|
|
||||||
│ │ │ ├── CreateGameAccountForm.jsx
|
|
||||||
│ │ │ ├── GameAccounts.jsx
|
|
||||||
│ │ │ ├── HeroElement.jsx
|
|
||||||
│ │ │ ├── MaintenanceGate.jsx
|
|
||||||
│ │ │ ├── Modal.jsx
|
|
||||||
│ │ │ ├── MoonDot.jsx
|
|
||||||
│ │ │ ├── PageHeader.jsx
|
|
||||||
│ │ │ ├── PageState.jsx
|
|
||||||
│ │ │ ├── PlayersOnline.jsx
|
|
||||||
│ │ │ ├── ProviderIcon.jsx
|
|
||||||
│ │ │ ├── PublicLayout.jsx
|
|
||||||
│ │ │ ├── RequireAuth.jsx
|
|
||||||
│ │ │ ├── RequirePlayer.jsx
|
|
||||||
│ │ │ ├── RichTextEditor.jsx
|
|
||||||
│ │ │ ├── RoleGate.jsx
|
|
||||||
│ │ │ ├── ShardAccountActions.jsx
|
|
||||||
│ │ │ ├── SiteFooter.jsx
|
|
||||||
│ │ │ ├── SiteHeader.jsx
|
|
||||||
│ │ │ └── VendorSales.jsx
|
|
||||||
│ │ ├── contexts/
|
|
||||||
│ │ │ ├── AuthContext.jsx
|
|
||||||
│ │ │ └── SiteContext.jsx
|
|
||||||
│ │ ├── data/
|
|
||||||
│ │ │ ├── cityCrests.js
|
|
||||||
│ │ │ └── regionBuckets.js
|
|
||||||
│ │ ├── lib/
|
|
||||||
│ │ │ ├── format.js
|
|
||||||
│ │ │ ├── heroLayout.js
|
|
||||||
│ │ │ ├── shardEvents.js
|
|
||||||
│ │ │ ├── useAsync.js
|
|
||||||
│ │ │ └── useShardFeed.js
|
|
||||||
│ │ ├── routes/
|
|
||||||
│ │ │ ├── admin/
|
|
||||||
│ │ │ │ ├── views/
|
|
||||||
│ │ │ │ │ ├── AccountAdmin.jsx
|
|
||||||
│ │ │ │ │ ├── ActivityAdmin.jsx
|
|
||||||
│ │ │ │ │ ├── AdminCharacter.jsx
|
|
||||||
│ │ │ │ │ ├── AdminCharacters.jsx
|
|
||||||
│ │ │ │ │ ├── Appeals.jsx
|
|
||||||
│ │ │ │ │ ├── AuthProvidersAdmin.jsx
|
|
||||||
│ │ │ │ │ ├── BotActivityAdmin.jsx
|
|
||||||
│ │ │ │ │ ├── Dashboard.jsx
|
|
||||||
│ │ │ │ │ ├── DiscordBotAdmin.jsx
|
|
||||||
│ │ │ │ │ ├── EmailDelivery.jsx
|
|
||||||
│ │ │ │ │ ├── HeroEditor.jsx
|
|
||||||
│ │ │ │ │ ├── HousesAdmin.jsx
|
|
||||||
│ │ │ │ │ ├── InvitesAdmin.jsx
|
|
||||||
│ │ │ │ │ ├── Moderation.jsx
|
|
||||||
│ │ │ │ │ ├── ModerationUser.jsx
|
|
||||||
│ │ │ │ │ ├── PageBuilder.jsx
|
|
||||||
│ │ │ │ │ ├── PagesAdmin.jsx
|
|
||||||
│ │ │ │ │ ├── PostEditor.jsx
|
|
||||||
│ │ │ │ │ ├── PostsAdmin.jsx
|
|
||||||
│ │ │ │ │ ├── SettingsAdmin.jsx
|
|
||||||
│ │ │ │ │ ├── ShardAdmin.jsx
|
|
||||||
│ │ │ │ │ ├── ShardOps.jsx
|
|
||||||
│ │ │ │ │ ├── UserDetail.jsx
|
|
||||||
│ │ │ │ │ ├── UserEditor.jsx
|
|
||||||
│ │ │ │ │ ├── UsersAdmin.jsx
|
|
||||||
│ │ │ │ │ ├── WikiAdmin.jsx
|
|
||||||
│ │ │ │ │ ├── WikiCategories.jsx
|
|
||||||
│ │ │ │ │ ├── WikiEditor.jsx
|
|
||||||
│ │ │ │ │ └── WikiHistory.jsx
|
|
||||||
│ │ │ │ ├── AdminLayout.jsx
|
|
||||||
│ │ │ │ └── AdminLogin.jsx
|
|
||||||
│ │ │ ├── player/
|
|
||||||
│ │ │ │ ├── AcceptInvite.jsx
|
|
||||||
│ │ │ │ ├── ForgotPassword.jsx
|
|
||||||
│ │ │ │ ├── PlayerAccount.jsx
|
|
||||||
│ │ │ │ ├── PlayerAppeals.jsx
|
|
||||||
│ │ │ │ ├── PlayerCharacter.jsx
|
|
||||||
│ │ │ │ ├── PlayerCharacters.jsx
|
|
||||||
│ │ │ │ ├── PlayerLogin.jsx
|
|
||||||
│ │ │ │ ├── PlayerPortalLayout.jsx
|
|
||||||
│ │ │ │ ├── PlayerRegister.jsx
|
|
||||||
│ │ │ │ ├── PlayerShell.jsx
|
|
||||||
│ │ │ │ └── ResetPassword.jsx
|
|
||||||
│ │ │ ├── public/
|
|
||||||
│ │ │ │ ├── About.jsx
|
|
||||||
│ │ │ │ ├── ChampSpawns.jsx
|
|
||||||
│ │ │ │ ├── CmsPage.jsx
|
|
||||||
│ │ │ │ ├── FiveOnFriday.jsx
|
|
||||||
│ │ │ │ ├── Governors.jsx
|
|
||||||
│ │ │ │ ├── Guilds.jsx
|
|
||||||
│ │ │ │ ├── Houses.jsx
|
|
||||||
│ │ │ │ ├── Maintenance.jsx
|
|
||||||
│ │ │ │ ├── News.jsx
|
|
||||||
│ │ │ │ ├── Newsletter.jsx
|
|
||||||
│ │ │ │ ├── NewsletterIssue.jsx
|
|
||||||
│ │ │ │ ├── Portal.jsx
|
|
||||||
│ │ │ │ ├── Screenshots.jsx
|
|
||||||
│ │ │ │ ├── Shard.jsx
|
|
||||||
│ │ │ │ ├── ShardActivity.jsx
|
|
||||||
│ │ │ │ ├── Status.jsx
|
|
||||||
│ │ │ │ └── Website.jsx
|
|
||||||
│ │ │ └── wiki/
|
|
||||||
│ │ │ ├── Wiki.jsx
|
|
||||||
│ │ │ └── WikiArticle.jsx
|
|
||||||
│ │ ├── styles/
|
|
||||||
│ │ │ └── theme.css
|
|
||||||
│ │ ├── App.jsx
|
|
||||||
│ │ └── main.jsx
|
|
||||||
│ ├── test/
|
|
||||||
│ │ ├── apiClient.test.js
|
|
||||||
│ │ ├── format.test.js
|
|
||||||
│ │ ├── heroLayout.test.js
|
|
||||||
│ │ ├── regionBuckets.test.js
|
|
||||||
│ │ └── shardEvents.test.js
|
|
||||||
│ ├── index.html
|
|
||||||
│ ├── package-lock.json
|
|
||||||
│ ├── package.json
|
|
||||||
│ └── vite.config.js
|
|
||||||
├── ntfy/
|
|
||||||
│ └── server.yml
|
|
||||||
├── scripts/
|
|
||||||
│ ├── dev/
|
|
||||||
│ │ ├── README.md
|
|
||||||
│ │ ├── seed-sso-provider.js
|
|
||||||
│ │ ├── sso-bridge-smoketest.js
|
|
||||||
│ │ └── stub-idp.js
|
|
||||||
│ └── sonar-test-reporter.mjs
|
|
||||||
├── server/
|
|
||||||
│ ├── db/
|
|
||||||
│ │ ├── schema.sql
|
|
||||||
│ │ └── seed.js
|
|
||||||
│ ├── src/
|
|
||||||
│ │ ├── auth/
|
|
||||||
│ │ │ ├── providers/
|
|
||||||
│ │ │ │ ├── base.provider.js
|
|
||||||
│ │ │ │ ├── discord.provider.js
|
|
||||||
│ │ │ │ ├── genericOidc.provider.js
|
|
||||||
│ │ │ │ ├── google.provider.js
|
|
||||||
│ │ │ │ ├── local.provider.js
|
|
||||||
│ │ │ │ ├── oauth2.provider.js
|
|
||||||
│ │ │ │ └── registry.js
|
|
||||||
│ │ │ ├── session.middleware.js
|
|
||||||
│ │ │ ├── session.service.js
|
|
||||||
│ │ │ ├── ssoState.js
|
|
||||||
│ │ │ ├── token.js
|
|
||||||
│ │ │ └── usernamePolicy.js
|
|
||||||
│ │ ├── blocks/
|
|
||||||
│ │ │ ├── types/
|
|
||||||
│ │ │ │ ├── cta.js
|
|
||||||
│ │ │ │ ├── divider.js
|
|
||||||
│ │ │ │ ├── heading.js
|
|
||||||
│ │ │ │ ├── image.js
|
|
||||||
│ │ │ │ ├── quote.js
|
|
||||||
│ │ │ │ ├── richText.js
|
|
||||||
│ │ │ │ └── twoColumn.js
|
|
||||||
│ │ │ ├── index.js
|
|
||||||
│ │ │ ├── propHelpers.js
|
|
||||||
│ │ │ ├── registry.js
|
|
||||||
│ │ │ ├── sanitizeBlocks.js
|
|
||||||
│ │ │ └── validateBlocks.js
|
|
||||||
│ │ ├── config/
|
|
||||||
│ │ │ ├── brand.js
|
|
||||||
│ │ │ ├── notificationStreams.js
|
|
||||||
│ │ │ └── version.js
|
|
||||||
│ │ ├── middleware/
|
|
||||||
│ │ │ ├── botScore.js
|
|
||||||
│ │ │ ├── loginProtection.js
|
|
||||||
│ │ │ ├── noindex.js
|
|
||||||
│ │ │ ├── rateLimit.js
|
|
||||||
│ │ │ ├── requireInternalKey.js
|
|
||||||
│ │ │ ├── siteMode.js
|
|
||||||
│ │ │ └── validate.js
|
|
||||||
│ │ ├── model/
|
|
||||||
│ │ │ ├── activity/
|
|
||||||
│ │ │ │ ├── activity.db.js
|
|
||||||
│ │ │ │ └── activity.model.js
|
|
||||||
│ │ │ ├── announceJobs/
|
|
||||||
│ │ │ │ ├── announceJobs.db.js
|
|
||||||
│ │ │ │ ├── announceJobs.logic.js
|
|
||||||
│ │ │ │ └── announceJobs.model.js
|
|
||||||
│ │ │ ├── appeals/
|
|
||||||
│ │ │ │ ├── appeals.db.js
|
|
||||||
│ │ │ │ ├── appeals.model.js
|
|
||||||
│ │ │ │ └── appeals.pure.js
|
|
||||||
│ │ │ ├── authProviders/
|
|
||||||
│ │ │ │ ├── authProviders.db.js
|
|
||||||
│ │ │ │ └── authProviders.model.js
|
|
||||||
│ │ │ ├── botConfig/
|
|
||||||
│ │ │ │ ├── botConfig.db.js
|
|
||||||
│ │ │ │ └── botConfig.model.js
|
|
||||||
│ │ │ ├── emailConfig/
|
|
||||||
│ │ │ │ ├── emailConfig.db.js
|
|
||||||
│ │ │ │ └── emailConfig.model.js
|
|
||||||
│ │ │ ├── invites/
|
|
||||||
│ │ │ │ ├── invites.db.js
|
|
||||||
│ │ │ │ └── invites.model.js
|
|
||||||
│ │ │ ├── mobileAuthBridge/
|
|
||||||
│ │ │ │ ├── mobileAuthBridge.db.js
|
|
||||||
│ │ │ │ └── mobileAuthBridge.model.js
|
|
||||||
│ │ │ ├── mobileSessions/
|
|
||||||
│ │ │ │ ├── mobileSessions.db.js
|
|
||||||
│ │ │ │ └── mobileSessions.model.js
|
|
||||||
│ │ │ ├── moderation/
|
|
||||||
│ │ │ │ ├── moderation.db.js
|
|
||||||
│ │ │ │ ├── moderation.model.js
|
|
||||||
│ │ │ │ └── moderation.pure.js
|
|
||||||
│ │ │ ├── modNotes/
|
|
||||||
│ │ │ │ ├── modNotes.db.js
|
|
||||||
│ │ │ │ └── modNotes.model.js
|
|
||||||
│ │ │ ├── notificationSubs/
|
|
||||||
│ │ │ │ ├── notificationSubs.db.js
|
|
||||||
│ │ │ │ └── notificationSubs.model.js
|
|
||||||
│ │ │ ├── pages/
|
|
||||||
│ │ │ │ ├── pages.db.js
|
|
||||||
│ │ │ │ ├── pages.model.js
|
|
||||||
│ │ │ │ └── reservedSlugs.js
|
|
||||||
│ │ │ ├── passwordResets/
|
|
||||||
│ │ │ │ ├── passwordResets.db.js
|
|
||||||
│ │ │ │ └── passwordResets.model.js
|
|
||||||
│ │ │ ├── posts/
|
|
||||||
│ │ │ │ ├── posts.db.js
|
|
||||||
│ │ │ │ └── posts.model.js
|
|
||||||
│ │ │ ├── pushDevices/
|
|
||||||
│ │ │ │ ├── pushDevices.db.js
|
|
||||||
│ │ │ │ └── pushDevices.model.js
|
|
||||||
│ │ │ ├── recoveryCodes/
|
|
||||||
│ │ │ │ ├── recoveryCodes.db.js
|
|
||||||
│ │ │ │ └── recoveryCodes.model.js
|
|
||||||
│ │ │ ├── revokedSessions/
|
|
||||||
│ │ │ │ ├── revokedSessions.db.js
|
|
||||||
│ │ │ │ └── revokedSessions.model.js
|
|
||||||
│ │ │ ├── settings/
|
|
||||||
│ │ │ │ ├── settings.db.js
|
|
||||||
│ │ │ │ └── settings.model.js
|
|
||||||
│ │ │ ├── shardEvents/
|
|
||||||
│ │ │ │ ├── shardEvents.db.js
|
|
||||||
│ │ │ │ └── shardEvents.model.js
|
|
||||||
│ │ │ ├── shardLinks/
|
|
||||||
│ │ │ │ ├── shardLinks.db.js
|
|
||||||
│ │ │ │ └── shardLinks.model.js
|
|
||||||
│ │ │ ├── shardState/
|
|
||||||
│ │ │ │ ├── shardState.db.js
|
|
||||||
│ │ │ │ └── shardState.model.js
|
|
||||||
│ │ │ ├── trustedDevices/
|
|
||||||
│ │ │ │ ├── trustedDevices.db.js
|
|
||||||
│ │ │ │ └── trustedDevices.model.js
|
|
||||||
│ │ │ ├── uoLinkConfig/
|
|
||||||
│ │ │ │ ├── uoLinkConfig.db.js
|
|
||||||
│ │ │ │ └── uoLinkConfig.model.js
|
|
||||||
│ │ │ ├── userIdentities/
|
|
||||||
│ │ │ │ ├── userIdentities.db.js
|
|
||||||
│ │ │ │ └── userIdentities.model.js
|
|
||||||
│ │ │ ├── users/
|
|
||||||
│ │ │ │ ├── users.db.js
|
|
||||||
│ │ │ │ └── users.model.js
|
|
||||||
│ │ │ ├── wiki/
|
|
||||||
│ │ │ │ ├── wiki.db.js
|
|
||||||
│ │ │ │ ├── wiki.links.js
|
|
||||||
│ │ │ │ └── wiki.model.js
|
|
||||||
│ │ │ └── singletonConfigDb.js
|
|
||||||
│ │ ├── router/
|
|
||||||
│ │ │ ├── v1/
|
|
||||||
│ │ │ │ ├── admin/
|
|
||||||
│ │ │ │ │ ├── account.controller.js
|
|
||||||
│ │ │ │ │ ├── admin.controller.js
|
|
||||||
│ │ │ │ │ ├── admin.routes.js
|
|
||||||
│ │ │ │ │ ├── authProviders.controller.js
|
|
||||||
│ │ │ │ │ ├── botActivity.controller.js
|
|
||||||
│ │ │ │ │ ├── discordBot.controller.js
|
|
||||||
│ │ │ │ │ ├── emailConfig.controller.js
|
|
||||||
│ │ │ │ │ ├── invites.controller.js
|
|
||||||
│ │ │ │ │ ├── moderation.controller.js
|
|
||||||
│ │ │ │ │ ├── pages.controller.js
|
|
||||||
│ │ │ │ │ ├── shardOps.controller.js
|
|
||||||
│ │ │ │ │ ├── uoLink.controller.js
|
|
||||||
│ │ │ │ │ └── usersShard.controller.js
|
|
||||||
│ │ │ │ ├── auth/
|
|
||||||
│ │ │ │ │ ├── auth.controller.js
|
|
||||||
│ │ │ │ │ ├── auth.routes.js
|
|
||||||
│ │ │ │ │ ├── invite.controller.js
|
|
||||||
│ │ │ │ │ ├── me.routes.js
|
|
||||||
│ │ │ │ │ ├── mobile.controller.js
|
|
||||||
│ │ │ │ │ ├── mobile.routes.js
|
|
||||||
│ │ │ │ │ ├── mobileSso.controller.js
|
|
||||||
│ │ │ │ │ ├── mobileSso.routes.js
|
|
||||||
│ │ │ │ │ ├── notifications.controller.js
|
|
||||||
│ │ │ │ │ ├── notifications.routes.js
|
|
||||||
│ │ │ │ │ ├── passwordReset.controller.js
|
|
||||||
│ │ │ │ │ ├── sso.controller.js
|
|
||||||
│ │ │ │ │ ├── sso.routes.js
|
|
||||||
│ │ │ │ │ └── trustDevice.helper.js
|
|
||||||
│ │ │ │ ├── internal/
|
|
||||||
│ │ │ │ │ ├── internal.controller.js
|
|
||||||
│ │ │ │ │ └── internal.routes.js
|
|
||||||
│ │ │ │ ├── player/
|
|
||||||
│ │ │ │ │ ├── appeals.controller.js
|
|
||||||
│ │ │ │ │ ├── player.routes.js
|
|
||||||
│ │ │ │ │ └── shard.controller.js
|
|
||||||
│ │ │ │ ├── public/
|
|
||||||
│ │ │ │ │ ├── public.controller.js
|
|
||||||
│ │ │ │ │ ├── public.routes.js
|
|
||||||
│ │ │ │ │ └── shard.controller.js
|
|
||||||
│ │ │ │ └── v1.router.js
|
|
||||||
│ │ │ ├── api.router.js
|
|
||||||
│ │ │ └── wellKnown.controller.js
|
|
||||||
│ │ ├── utils/
|
|
||||||
│ │ │ ├── announceWorker.js
|
|
||||||
│ │ │ ├── auth.js
|
|
||||||
│ │ │ ├── botInternalClient.js
|
|
||||||
│ │ │ ├── botInternalKey.js
|
|
||||||
│ │ │ ├── db.js
|
|
||||||
│ │ │ ├── logger.js
|
|
||||||
│ │ │ ├── mailer.js
|
|
||||||
│ │ │ ├── newsGump.js
|
|
||||||
│ │ │ ├── pushDispatch.js
|
|
||||||
│ │ │ ├── sanitizeHtml.js
|
|
||||||
│ │ │ ├── secretBox.js
|
|
||||||
│ │ │ ├── shardBroadcast.js
|
|
||||||
│ │ │ ├── shardIngest.js
|
|
||||||
│ │ │ ├── shardSales.js
|
|
||||||
│ │ │ ├── totp.js
|
|
||||||
│ │ │ ├── trustProxy.js
|
|
||||||
│ │ │ ├── uoLinkClient.js
|
|
||||||
│ │ │ └── uoLinkSocket.js
|
|
||||||
│ │ ├── app.js
|
|
||||||
│ │ ├── internalApp.js
|
|
||||||
│ │ └── server.js
|
|
||||||
│ ├── swagger/
|
|
||||||
│ │ ├── swagger-output.json
|
|
||||||
│ │ └── swagger.js
|
|
||||||
│ ├── test/
|
|
||||||
│ │ ├── _helper.js
|
|
||||||
│ │ ├── adminTrustedDevices.test.js
|
|
||||||
│ │ ├── adminUserShard.test.js
|
|
||||||
│ │ ├── announceJobs.test.js
|
|
||||||
│ │ ├── appeals.pure.test.js
|
|
||||||
│ │ ├── appeals.test.js
|
|
||||||
│ │ ├── appLinks.test.js
|
|
||||||
│ │ ├── authController.test.js
|
|
||||||
│ │ ├── authMe.test.js
|
|
||||||
│ │ ├── authTrustedDevice.test.js
|
|
||||||
│ │ ├── botInternalKey.test.js
|
|
||||||
│ │ ├── botScore.test.js
|
|
||||||
│ │ ├── emailConfig.model.test.js
|
|
||||||
│ │ ├── honeypot.test.js
|
|
||||||
│ │ ├── inviteController.test.js
|
|
||||||
│ │ ├── invites.test.js
|
|
||||||
│ │ ├── loginProtection.test.js
|
|
||||||
│ │ ├── mailer.test.js
|
|
||||||
│ │ ├── mobileAuthBridge.model.test.js
|
|
||||||
│ │ ├── mobileDeviceSessions.test.js
|
|
||||||
│ │ ├── mobileSession.test.js
|
|
||||||
│ │ ├── mobileSsoBridge.test.js
|
|
||||||
│ │ ├── moderation.model.test.js
|
|
||||||
│ │ ├── moderation.test.js
|
|
||||||
│ │ ├── newsGump.test.js
|
|
||||||
│ │ ├── notificationsRoutes.test.js
|
|
||||||
│ │ ├── pages.model.test.js
|
|
||||||
│ │ ├── passwordResetController.test.js
|
|
||||||
│ │ ├── passwordResets.test.js
|
|
||||||
│ │ ├── playerAccounts.test.js
|
|
||||||
│ │ ├── playerRouteAccess.test.js
|
|
||||||
│ │ ├── providers.test.js
|
|
||||||
│ │ ├── publicBrand.test.js
|
|
||||||
│ │ ├── publicController.test.js
|
|
||||||
│ │ ├── publicShardOnline.test.js
|
|
||||||
│ │ ├── publicVersion.test.js
|
|
||||||
│ │ ├── pushDispatch.test.js
|
|
||||||
│ │ ├── recoveryCodes.test.js
|
|
||||||
│ │ ├── registry.test.js
|
|
||||||
│ │ ├── requireInternalKey.test.js
|
|
||||||
│ │ ├── secretBox.test.js
|
|
||||||
│ │ ├── selfTrustedDevices.test.js
|
|
||||||
│ │ ├── session.test.js
|
|
||||||
│ │ ├── shardControllerPublic.test.js
|
|
||||||
│ │ ├── shardIngest.champsPages.test.js
|
|
||||||
│ │ ├── shardIngest.protocol2.test.js
|
|
||||||
│ │ ├── shardState.governorTerms.test.js
|
|
||||||
│ │ ├── shardState.model.test.js
|
|
||||||
│ │ ├── ssoCallback.test.js
|
|
||||||
│ │ ├── ssoState.test.js
|
|
||||||
│ │ ├── totp.test.js
|
|
||||||
│ │ ├── trustedDevices.test.js
|
|
||||||
│ │ ├── trustProxy.test.js
|
|
||||||
│ │ └── usernamePolicy.test.js
|
|
||||||
│ ├── .env.example
|
|
||||||
│ ├── package-lock.json
|
|
||||||
│ └── package.json
|
|
||||||
├── .dockerignore
|
|
||||||
├── .env.example
|
|
||||||
├── .env.uomysticmoon.example
|
|
||||||
├── .gitignore
|
|
||||||
├── CODE_OF_CONDUCT.md
|
|
||||||
├── CONTRIBUTING.md
|
|
||||||
├── CONTRIBUTORS.md
|
|
||||||
├── docker-compose.dev.yml
|
|
||||||
├── docker-compose.yml
|
|
||||||
├── Dockerfile
|
|
||||||
├── LICENSE.md
|
|
||||||
├── package.json
|
|
||||||
├── README.md
|
|
||||||
├── SECURITY.md
|
|
||||||
└── sonar-project.properties
|
|
||||||
```
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.1–4.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.
|
|
||||||
@@ -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/ — 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 |
|
||||||
|
|||||||