1 Commits

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

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

View File

@@ -9,8 +9,6 @@ so they live in one place, independent of either codebase.
```
website/ docs from the shard website (Node/Express + MariaDB + React/Vite)
link/ docs from the ServUO bridge (C# plugin + Rust sidecar + Node WS)
android/ docs from the native Android client (Kotlin + Jetpack Compose)
ci/ cross-cutting CI/quality notes
```
### `website/`
@@ -19,32 +17,18 @@ ci/ cross-cutting CI/quality notes
| [BACKEND_DESIGN.md](website/BACKEND_DESIGN.md) | API contract, DB schema, security model |
| [HERO_EDITOR.md](website/HERO_EDITOR.md) | Hero canvas editor feature spec |
| [WIKI_UPGRADE.md](website/WIKI_UPGRADE.md) | Wiki subsystem upgrade notes |
| [SHARD_VISIBILITY.md](website/SHARD_VISIBILITY.md) | Who sees which shard data — the admin-configurable audience framework |
| [website-README.md](website/website-README.md) | Snapshot of the website repo's README (setup/run reference) |
| [PROJECT_TREE.md](website/PROJECT_TREE.md) | Auto-generated snapshot of the repo's tracked file layout |
### `link/`
| Doc | What it covers |
|---|---|
| [INTEGRATION.md](link/INTEGRATION.md) | How the website integrates with the uo-link sidecar |
| [PROTOCOL_2.md](link/PROTOCOL_2.md) | Protocol 2.0 / 2.1 design |
| [v3.md](link/v3.md) | Protocol 3.0 design — shard content/standings streams + the visibility framework |
| [ADMIN_CONTROLS.md](link/ADMIN_CONTROLS.md) | Staff write-plane (kick/ban/broadcast, page queue) |
| [SHARD_PREREQS.md](link/SHARD_PREREQS.md) | Shard-side prerequisites for the bridge |
| [PLAN.md](link/PLAN.md) | uo-link build plan |
| [RESEARCH.md](link/RESEARCH.md) | Research notes |
| [link-README.md](link/link-README.md) | Snapshot of the link repo's README |
| [PROJECT_TREE.md](link/PROJECT_TREE.md) | Auto-generated snapshot of the repo's tracked file layout |
### `android/`
| Doc | What it covers |
|---|---|
| [PLAN.md](android/PLAN.md) | Android client build plan / milestones |
| [COVERAGE_PLAN.md](android/COVERAGE_PLAN.md) | Test-coverage rollout plan |
| [APP_LINKS.md](android/APP_LINKS.md) | Android App Links / deep-link setup |
| [theme-plan.md](android/theme-plan.md) | Theming plan |
| [TRUSTED_DEVICES_APP_HANDOFF.md](android/TRUSTED_DEVICES_APP_HANDOFF.md) | Trusted-devices app handoff notes |
| [PROJECT_TREE.md](android/PROJECT_TREE.md) | Auto-generated snapshot of the repo's tracked file layout |
## Provenance

View File

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

View File

@@ -169,12 +169,9 @@ none (keeps §11's zero-interaction promise).
registration and every publish validate it is HTTPS and its origin is in the shard's ntfy
allow-set (`NTFY_BASE_URL` / `NTFY_ALLOWED_ORIGINS`), rejecting loopback/private hosts.
- **ntfy** added to `website/docker-compose.yml` as a pinned upstream image with a committed
declarative `./ntfy/server.yml` and named volume, **published on a host port** (`NTFY_HOST_PORT`,
default `2586` → container `:80`) so the public reverse proxy — which runs *outside* the compose
network — can forward the notification subdomain to it, anonymous read-write to unguessable topics
(no per-user accounts — safe because tickles are content-free). Both the app (SSE subscribe) and the
backend (POSTing tickles to registered device endpoints) reach ntfy on that public origin, so all
ntfy traffic transits the proxy — there is no separate internal publish port.
declarative `./ntfy/server.yml` and named volume, **no published host port** (reached via the
reverse proxy; internal-only for the publisher), anonymous read-write to unguessable topics (no
per-user accounts — safe because tickles are content-free).
**Part 2 — the Android app — ✅ LANDED** (2026-07-20, `RunicGateway/Android-app#15` + a small
`RunicGateway/website#79` settings addition + this docs PR). Built exactly to the plan below, with
@@ -422,29 +419,21 @@ console. It is a **read + self-service** app, not an operator tool.
- **Access-level menu**: one shared navigation that reveals items based on the signed-in user's role.
- **Opt-in push notifications** (post-v1; architected for from the start): per-stream subscriptions the
user chooses — nothing is pushed unless subscribed. See §11.
- **Staff operations subset** (post-v1, **M10** — decided 2026-07-21): a *defined* slice of the admin
surface, native, for `admin`/`moderator` staff. In scope: **moderation actions** (kick / ban / unban /
broadcast), the **support (help-page) queue** (reply / close / resolve), the **dashboard summary +
site-mode** toggle, and **content management** (news posts — list / create / edit / publish / delete —
plus wiki category & tag management). These consume the existing `/api/v1/admin/**` routes (which
already accept bearer auth and re-check role every request); no backend routes are added. See §6.4 + §10.
### Explicitly OUT of scope (never in the app, for any role)
The exclusions are now a *narrow* list (M10 brought the operational admin subset in-scope, above). The
app still never ships:
- The **hero editor** and CMS **block/page authoring** (the visual page builder). *(News-post and
wiki category/tag management ARE in scope; the excluded piece is the CMS block editor / hero builder.)*
- **Discord bot** configuration (and anything under the unpublished `/internal/**` port — it returns the
decrypted bot token and must never be reachable from a client).
- **uo-link / sidecar administration** — the shard sidecar base-URL/token config (`uoLinkConfig`). (The
app performs *shard operations* like kick/ban/broadcast against the live shard, but never configures
the sidecar connection itself.)
- **OAuth / SSO provider setup** — creating/editing identity providers and their client secrets.
- The **hero editor** and any CMS authoring/block editing.
- The **admin / auth-management console** — user management, invites issuance, SSO provider config,
moderation console, email config, bot-activity/ban console. (Players still *log in*; what's
excluded is the management surface, not authentication itself.)
- The **Discord bot** management (and anything under the unpublished `/internal/**` port — it
returns the decrypted bot token and must never be reachable from a client).
- **Shard / uo-link administration** — sidecar base-URL/token config (`uoLinkConfig`), shard ops,
the staff shard-user console. (The app shows *public* shard widgets and a player's *own* game
data; it does not manage the sidecar.)
> The app calls `/api/v1/public/**`, `/api/v1/auth/**` (incl. the role-agnostic self surface
> `/auth/me/*`, §6.4), `/api/v1/player/**`, and — for staff, M10 — the **defined `/api/v1/admin/**`
> operations listed above. It never touches `/api/v1/internal/**`, nor the four excluded admin surfaces
> (hero/CMS block editor, Discord-bot config, uo-link config, OAuth-provider setup).
> The excluded surfaces all live under `/api/v1/admin/**` and `/api/v1/internal/**`. The app only
> ever calls `/api/v1/public/**`, `/api/v1/auth/**` (incl. the new role-agnostic self surface
> `/auth/me/*`, §6.4), and `/api/v1/player/**` — it never references `/admin`.
---
@@ -502,13 +491,7 @@ compiled in.
`/public/settings` for branding: name/colors/logo). Only on a successful, well-formed response is
the URL persisted to DataStore and the app allowed to initialize its main UI.
- Accept `https://host[/base]`; normalize/trim; require HTTPS in release builds (allow HTTP only in
debug for local dev against `127.0.0.1:3000`). This app-layer rule (`ServerUrl`,
`allowInsecureHttp = BuildConfig.DEBUG`) is backed at the platform socket layer by an explicit
**network security config** (`res/xml/network_security_config.xml`, wired via
`application android:networkSecurityConfig`): the main/release config permits **no** cleartext,
and a debug-only override (`app/src/debug/res/xml/`) re-permits cleartext to loopback
(`127.0.0.1`/`localhost`) only. Being explicit also stops a merged library manifest from
re-enabling cleartext and clears the `usesCleartextTraffic`-implicitly-enabled scanner finding.
debug for local dev against `127.0.0.1:3000`).
- Failure states: unreachable, non-2xx, not-a-Runic-Gateway-site (missing expected `/public/status`
shape), TLS error — each gets a clear retry message. Nothing else in the app runs until this
succeeds.
@@ -546,39 +529,6 @@ Uses the existing **mobile bearer** surface, no backend changes:
`401` from a bearer call, with a mutex so concurrent 401s trigger only one refresh.
- `POST /auth/mobile/logout` `{ refreshToken?, all? }` (requires bearer) — revoke this session or all
sessions. Called on user logout and on "sign out everywhere."
#### 4.1.1 Trusted devices & recovery codes — **implemented** (app PR `feature/trusted-devices-mfa`)
The backend trusted-device + recovery-code feature (canonical ref: `../website/TRUSTED_DEVICES_MFA.md`)
is additive on the mobile surface. As built:
- **Login extras:** `POST /auth/mobile/login` accepts `recoveryCode` (a single-use alternative to
`code`), `trustDevice: true`, `device_name`, and an `X-Trust-Token` header. On the `401 { totpRequired }`
screen the app offers a "use a recovery code instead" toggle and a **"Trust this device"** checkbox.
When `trustDevice` is accepted, the response carries `trustToken` → stored in a **dedicated
EncryptedSharedPreferences file** (`runic_trust`, AES-256-GCM; never plain prefs/logs) **scoped to the
username** it was minted for, and replayed as `X-Trust-Token` on that account's future logins to skip
the TOTP prompt.
- **Login-time cap:** a `{ trustLimitReached, devices }` response means login **succeeded** but the
device was not remembered (session is already issued on native, unlike the web cookie step). Rather
than a blocking login-time modal, this is surfaced + resolved on the **Trusted Devices** account
screen, whose `POST /auth/me/trusted-devices` gives the exact revoke-one-then-retry flow. (Deliberate
deviation from the earlier "prompt at login" sketch — the mobile login is past the point a modal would
gate.)
- **Self-service (account screens):** a **Security** section on the account screen links to two
dedicated screens. **Trusted Devices** — `GET /auth/me/trusted-devices` (list), `DELETE …/:id`
(revoke one), `DELETE …/trusted-devices` (untrust all), and `POST /auth/me/trusted-devices` to trust
the current device (stores the returned `{ trustToken }`). **Recovery Codes** — enabling TOTP returns
the one-time `recoveryCodes` (shown once on the account screen with copy/share); `GET
…/recovery-codes/status` for the remaining count; `POST …/recovery-codes/generate` (password step-up)
to regenerate, shown once.
- **Invalidation — trust token deliberately survives logout.** The trust token is the native analogue
of the web `rg_trust` cookie, which per the canonical design *survives logout so the next login skips
2FA*. On mobile the token is only ever consulted at a **fresh** login — i.e. exactly after a logout or
a dead-refresh sign-out — so clearing it there would make the feature a no-op. It is therefore kept in
its own store, **untouched by session teardown** (`SessionManager.onSignedOut`), and cleared only on a
**Settings → Server switch** (bound to the old host), an **untrust-all**, or server-side revocation
(password change/reset, TOTP disable — which makes any surviving token inert; the next login just
prompts for the code). This supersedes the earlier handoff note that said to clear it on logout.
### 4.2 Website-handled flows: registration, invite, forgot-password, SSO
These are **not** rebuilt in the app. The app links out to the website's own pages/API and the user
completes them in a Custom Tab, then returns and signs in natively (§4.1):
@@ -648,10 +598,6 @@ Guidelines:
## 6. Screen ↔ endpoint map
> **Path note:** endpoints below are `/api/v1`-relative and stay that way. The website's router
> refactor preserves every URL, and the `/api/mobile` facade that would have renamed them is deferred —
> see [`../website/API_V2_PLAN.md`](../website/API_V2_PLAN.md) § Deferred: the `/api/mobile` facade.
### 6.1 Public content
- **Home/Status** — `GET /public/status`, `GET /public/settings` (branding + maintenance banner).
- **News hub** — `GET /public/posts/:category` (`news | five-on-friday | newsletter | screenshots`),
@@ -683,18 +629,11 @@ Guidelines:
Player self-service is under `/player/account/*` (gated to `role='player'`) and staff use the *same*
handlers under `/admin/account/*`. Rather than have the app branch by role (and touch `/admin`), we
**add a role-agnostic self surface under `/auth/**`** — the canonical "me" endpoints for every role.
The app calls these regardless of role. This is an **additive v1**
The app calls these regardless of role, and never references `/admin`. This is an **additive v1**
change (see §8): the existing `/player/account/*` and `/admin/account/*` routes stay for web
back-compat; `/auth/me/*` reuses the same `account.controller` handlers behind `requireAuth` (any
authenticated role), so there's no logic duplication.
> **M10 update (2026-07-21):** *self-service* stays role-agnostic under `/auth/me/*` as above. Separately,
> the **operational** admin subset (§1, §10 — moderation, support queue, dashboard/site-mode, content)
> *does* call `/api/v1/admin/**` directly, gated to staff by a new `STAFF`/`ADMIN` menu access level.
> The earlier "the app never references `/admin`" rule is superseded for these defined staff operations
> only; `validateSession` accepts the mobile bearer on those routes and `requireRole` re-checks every
> request, so a demoted user loses the surface immediately (the app treats any `403` as authoritative).
---
## 7. Degradation & offline
@@ -863,35 +802,6 @@ push, and Play (M6M8) follow the designed app.
generic build stays custom-scheme; white-label/first-party builds bake one host). The custom
scheme remains the permanent fallback on every build. Full spec + rollout in
[`APP_LINKS.md`](./APP_LINKS.md).
11. **M10 — Native SSO fixes + staff operations** (post-v1; decided 2026-07-21). Two threads found
during on-device QA:
- **SSO discovery + reachability fixes (app-only, done):** the native login screen only rendered
provider buttons when `GET /auth/providers` was non-empty and otherwise fell back to the desktop
website login (which can't deep-link a mobile session back → it hung). `AuthRepository.ssoProviders()`
now returns `Available` / `None` / `Unavailable` (retry once); the login screen shows native
buttons / a loading hint / a retry, and the website-login fallback is removed. The pending PKCE
`{state,verifier}` is persisted (encrypted `PendingSsoStore`) so the exchange survives Custom-Tab
process death. The nav drawer is now `verticalScroll`-wrapped so a signed-in session's longer menu
(which includes **Notifications**) can't clip on short screens. Dev testing uses a **stub OAuth IdP**
(`website/scripts/dev/`), since dev configures no real provider. Verified end-to-end on an emulator.
- **Staff operations (§1, §6.4):** a new `STAFF`/`ADMIN` menu access level reveals a staff section
for `admin`/`moderator`, with native screens over the existing `/api/v1/admin/**`: **moderation**
(`POST /admin/shard/{kick,ban,unban,broadcast}`), **support queue** (`GET /admin/shard/pages`,
`POST /admin/shard/pages/:id/{respond,close}`), **dashboard + site-mode** (`GET /admin/dashboard`,
`PUT /admin/site-mode`), and **content** (news posts under `/admin/posts*`, wiki categories/tags
under `/admin/wiki/*`). No backend routes added (they already accept bearer + re-check role);
shard-write actions degrade gracefully when the sidecar is offline. Excluded: hero/CMS block
editor, Discord-bot config, uo-link config, OAuth-provider setup.
### Deferred (not a milestone)
- **`/api/mobile` facade migration + app-version floor** — briefly planned as M11 (2026-07-22), now
**deferred with no app work scheduled**. The website's router refactor is being done in place with
every URL byte-identical and `/api/v1` is not being retired, so the app's ~70 hardcoded `api/v1/…`
endpoints, its SSE path, and its SSO URLs keep working untouched. If the mobile contract ever needs
to diverge from web, the migration comes back — starting from a one-line alias mount on the server,
not a hand-written delegate layer. Reasoning and revival triggers:
[`../website/API_V2_PLAN.md`](../website/API_V2_PLAN.md) § Deferred: the `/api/mobile` facade.
---
@@ -928,11 +838,8 @@ first release. Users **opt in per stream**: nothing is pushed unless subscribed.
path/tag at implementation.
- **All config is declarative** — a committed `ntfy` config file and/or `NTFY_*` env vars baked into
compose. No `docker exec`, no interactive `ntfy user add`, no post-deploy manual steps. Bringing the
stack up provisions a working push relay. Reachable via the existing reverse proxy on its own
hostname/path — the container publishes `:80` on a host port (`NTFY_HOST_PORT`, default `2586`)
because the proxy runs *outside* the compose network and can only reach a service through a
published host port (the same reason `app` publishes `3000`). Both devices and the backend publisher
reach ntfy on that public origin.
stack up provisions a working push relay. Reachable to devices via the existing reverse proxy on its
own hostname/path; internal-only for the backend publisher.
- **No per-user ntfy accounts to administer.** The security model (below) removes the need for ntfy ACL
provisioning, which is exactly what keeps setup interaction-free. ntfy topics are the random,
unguessable endpoints UnifiedPush hands out; the backend treats ntfy as an **untrusted relay**.
@@ -980,17 +887,6 @@ The ntfy relay is treated as **untrusted infrastructure**, and the design makes
write to `/auth/me/notifications/subscriptions`. Tapping a notification deep-links to the relevant
screen (§ open item below).
> **Gotcha — the PUT body must always carry `streams`, even when empty.** The backend validator
> requires the field (`body('streams').isArray()`), so an empty set has to be sent as
> `{"streams":[]}`, never `{}`. kotlinx.serialization omits a property equal to its default
> (`encodeDefaults=false`), so a DTO field like `streams: List<String> = emptyList()` gets *dropped*
> from the body when the set is empty — the app then sends `{}` and the server rejects it `400`.
> Symptom: clearing your **last** subscription fails with "could not save" and the toggle sticks
> (any non-empty set still includes the field, so only the final toggle-off breaks). Fix: give the
> request DTO field **no default** so kotlinx always encodes it (Android-app
> `fix/notifications-empty-subscriptions`). The same trap applies to any "replace the full set"
> `PUT`/`POST` whose empty value equals a DTO default — prefer no default on required request fields.
## 12. Build & CI (Gitea Actions)
Builds run on the org's existing self-hosted runners (`runs-on: ubuntu-latest`, same label the other
@@ -1009,33 +905,6 @@ repos use), on a bare `ubuntu:latest` container.
signing identity stable from the first release (Play later requires consistency).
- Semantic `versionName` + monotonic `versionCode`; tag releases.
### 12.1 Code quality — SonarQube (`Runic-Gateway-Android-app`)
Analysis runs post-merge and non-blocking (`.gitea/workflows/sonarqube.yml`); the gate is
informational. The project is clean (0 bugs / 0 vulns / 0 hotspots, Maintainability **A**); the only
gate failure is **coverage = 0% on new code**, which is a *reporting* gap, not a testing gap — the
25-file JVM unit suite exists, but the source-only Sonar scan never received a JaCoCo report.
> Once the wiring below landed (`Android-app#26`), real coverage measured **16.4%** on new code —
> still under the 50% gate. The plan to raise it (exclude non-unit-testable framework/UI code + test
> ViewModels/DTOs/repositories) lives in [`COVERAGE_PLAN.md`](./COVERAGE_PLAN.md).
**Coverage wiring (the fix):**
- Apply the `jacoco` plugin in `app/build.gradle.kts` + a `jacocoTestReport` task fed by
`testDebugUnitTest`, emitting XML. Exclude generated/DI/Compose scaffolding
(`**/*_Hilt*`, `**/*_Factory*`, `**/di/**`, `**/*ComposableSingletons*`, `R`/`BuildConfig`).
- `sonar-project.properties`: set `sonar.coverage.jacoco.xmlReportPaths` to the report, and
`sonar.coverage.exclusions` for pure-`@Composable` UI (JVM unit tests can't execute composable
bodies without Robolectric, so counting those lines would unfairly sink new-code coverage).
- `sonarqube.yml` must now run a real Gradle build before the scan (JDK 17 + Android SDK, mirroring
`pr-checks.yml`): `./gradlew testDebugUnitTest jacocoTestReport` → then the scan step.
**Issue triage (2026-07-22):** of 15 code smells, **3 fixed in code** — remove an unused import
(`AdminContentScreen.kt`), remove an unused `page` param (`AdminSupportScreen.RespondDialog`), and
decompose `LoginViewModel` (cognitive complexity 20). The remaining **12 marked *Won't Fix*** via the
Sonar API with rationale: 5 snake_case DTO fields (intentionally mirror the JSON wire contract) and 7
Compose/nav cognitive-complexity + hoisted-callback param-count smells (idiomatic for Jetpack Compose).
## 13. Open questions (revisit as we go)
**Decided (recorded here for context):** single shard per install (§3); native auth is
@@ -1060,10 +929,8 @@ it is a UX convenience, not a v1 requirement — deferred at M3, descoped at M6;
See [`APP_LINKS.md`](./APP_LINKS.md).
- ntfy: exact upstream image + pinned tag (Part-1 landed the compose service — confirm the tag), and
its reverse-proxy hostname/path. The hostname must land in `NTFY_ALLOWED_ORIGINS` before M7 Part 2 is
end-to-end testable (the app registers an endpoint on that origin; the SSRF guard rejects others). The
reverse proxy forwards that hostname to the ntfy container's published host port (`NTFY_HOST_PORT`,
default `2586`) — the container publishes `:80` because the proxy runs outside the compose network.
No backend publish token — **decided** (the content-free-tickle design does not require one; optional
end-to-end testable (the app registers an endpoint on that origin; the SSRF guard rejects others). No
backend publish token — **decided** (the content-free-tickle design does not require one; optional
`NTFY_PUBLISH_TOKEN` is honored if ever set).
- FCM flavor: build it for the Play release or ship Play on UnifiedPush too? Decide at M8. (The M7
Part 2 `PushTransport` seam keeps this swap cheap.)

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

View File

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

View File

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

View File

@@ -1,577 +0,0 @@
# Protocol 3.0 — Shard content, standings & the visibility framework
**Status:** Planned, approved 2026-07-28. Not yet built. All work lands on an `edge` branch in each repo; `edge``main` is the v3 cutover.
**Date:** 2026-07-28
**Codebase:** ServUO 57.4, `<servuo>`, net48 / x64, Expansion **EJ**.
**Companion to** [`PLAN.md`](PLAN.md) (1.0 read/event plane), [`PROTOCOL_2.md`](PROTOCOL_2.md) (2.0 provisioning + world-state streams), [`ADMIN_CONTROLS.md`](ADMIN_CONTROLS.md) (staff write plane), [`INTEGRATION.md`](INTEGRATION.md) (website API).
---
## 1. Why 3.0
A survey of the live ServUO tree against everything the bridge already surfaces end-to-end found that
**the bridge covers live *activity* well and covers shard *content and standings* almost not at all.**
Covered by 1.0 + 2.0: presence/online, region transitions, char vitals + profile + roster, house
registry + IDOC decay, champion spawns, guild board, city governors + term history, help-page queue,
total gold supply, player-vendor sales log, deaths/murders/kills, skill gains, fame/karma, quest
completes, staff/cheat audit, account linking + creation, town crier + news.
Not covered by anything: every leaderboard, every ruleset fact, every "where do I find X", and the
entire player economy outside a player's own vendors.
3.0 has **three scope areas**:
- **A — The visibility framework (§3).** Admin-configurable, per-feature and per-field audience
control over every shard-derived surface on the website. Ships first; the rest depends on it.
- **B — Three new wire streams (§5, §7, §8).** `world.ruleset`, `points.board`,
`vendor.listing`/`vendor.listing.remove`.
- **C — One website-only feature (§6).** The spawn atlas, built from static ServUO data files with no
wire involvement at all.
---
## 2. Survey: the full gap list
Recorded so the items *not* scoped for 3.0 aren't re-derived later.
| # | Gap | Source on the shard | Value | Cost | Status |
|---|---|---|---|---|---|
| 1 | **Points/loyalty leaderboards** — 25 point currencies | `Scripts/Services/PointsSystems/PointsSystem.cs``static List<PointsSystem> Systems`, each `List<PointsEntry>{Player,Points}` | Very high | Low | **3.0 §7** |
| 2 | **Shard ruleset page** | `Config/*.cfg` via `Server.Config.Get` | High | Very low | **3.0 §5** |
| 3 | **Shard-wide marketplace** | `PlayerVendor.PlayerVendors` + `VendorSearch.cs` | Very high | High | **3.0 §8** |
| 4 | **Spawn atlas / bestiary** | `Spawns/*.xml` (6,455 spawners), `RevampedSpawns/*.xml` (333), `Data/Regions.xml`, `Data/Locations/*.xml`, `Config/ChampionSpawns.xml`, `Data/teleporters.csv`, `Data/HarvestLocs/*` | High | Medium | **3.0 §6** |
| 5 | VvV standings + battle status | `Services/ViceVsVirtue/{ViceVsVirtueSystem,GuildStats,VvVBattle}.cs` | High | Medium | `PROTOCOL_2.md` §10.4 deferred this pending "which PvP system does this shard run?" — **now answered: `VvV.cfg Enabled=True`, `Factions.cfg` off.** Unblocked, not scoped here |
| 6 | Skill leaderboards + shard census | `Services/Reports/Reports.cs``GetSkillDistribution()`, `CompileGeneralStats()`, `StaffHistory` | High | Low | Spec'd `PROTOCOL_2.md` §14 (Part B phase 6), unbuilt. Shares §7's UI — fold in after |
| 7 | Custom mounts/pets codex — ~35 across 4 tiers | `Scripts/Custom/{Companions,Legendary,Named,New Legacy}` | Medium-high | Very low | Pure wiki/CMS content, zero bridge work. The shard's most distinctive content, with zero site presence |
| 8 | Community Collections progress | `Services/CommunityCollections/CollectionsSystem.cs` | Medium | Low | Natural public "community goal" widget |
| 9 | Seasonal/holiday event calendar | `Services/Seasonal Events/SeasonalEventSystem.cs`, Krampus, Forsaken Foes | Medium | Low | "What's live now / what's next" |
| 10 | Crafting / taming / harvesting feeds | `EventSink.CraftSuccess` / `TameCreature` / `ResourceHarvestSuccess` | Medium | Low | Spec'd `PROTOCOL_2.md` §11 #3/#4/#5, unbuilt |
| 11 | Virtue progression | `EventSink.VirtueLevelChange`, `Services/Ethics/` | Medium | Low | Spec'd §11 #6, unbuilt |
| 12 | Bulk Order Deeds + reward tables | `Services/BulkOrders/`, `Data/Bulk Orders/*` | Medium | Low | Feed spec'd §11 #7; the static reward tables are a free wiki page |
| 13 | Guild wars | war state on `Guild` | Low-medium | Low | Spec'd §11 #8, unbuilt |
| 14 | Astronomy discovery log | `Services/Astronomy/AstronomySystem.cs` (104 KB save) | Low | Low | Niche completion leaderboard |
| 15 | In-game chat relay | `Services/Chat/`, `Logs/Chat/{General,Help,Trade,LFG}` | Low | Medium | Privacy-sensitive; staff-only at most |
| 16 | Shard health telemetry | Crash logs, `LayerConflict.log`, `throttle.log`, `world.save.after` counts, AutoSave/AutoRestart schedule | Low-medium | Low | `world.save.after` is already ingested but never charted — world-size-over-time is nearly free |
| 17 | Ultima Store / Sovereigns balance | `Store.cfg Enabled=True, CurrencyName=Sovereigns`; `UltimaStore.GetCurrency` | ? | Medium | Only worth it if sovereigns are actually sold |
**Excluded permanently** — see [`ADMIN_CONTROLS.md`](ADMIN_CONTROLS.md) Tier H/N: firewall/IP-block,
kill/resurrect, jail, item/gold grants, set-access-level, arbitrary `[set`/`[add`.
**Noticed during the survey, out of scope:** `Scripts/Custom/PerryOwnerFix.cs` hardcodes an
`EventSink.Login` hook granting `AccessLevel.Owner` to account `"ShardOnwerPerry"`. Worth reviewing
independently of this work.
---
## 3. Part A — The visibility framework
### 3.1 The leak this replaces (verified 2026-07-28)
`BridgeJson.Actor()` (`BridgeJson.cs:85-117`) writes `serial`, `name`, **`acct`**, **`webId`**,
`player`. `shardState.model.js:346 shapeGuild()` returns `r.payload` verbatim, and
`GET /api/v1/public/shard/guilds` (anonymous, `shard.controller.js:131`) serves it. **A guild
leader's game account name and website user id are readable on an anonymous public endpoint today.**
The same path exists for `shapeGovernor``/public/shard/governors`. `Actor` also feeds
`guild.join`, `city.update` and `region.enter`, all three in `PUBLIC_KINDS` on the anonymous SSE
stream.
The framework below is the vehicle for the fix, and the reason it ships before anything else.
### 3.2 Where visibility lives
**On the website, never in the sidecar.** The sidecar's job for 3.0 is unchanged in character: accept
frames, persist them to its SQLite store, forward them verbatim over WS, and serve store-backed reads
that survive a shard outage. It defines no access parameters, no audiences, no field projection, and
advertises no capabilities.
### 3.3 The audience ladder
`anonymous → logged_in → player → staff → admin`, each rung implying the ones below it.
`viewerLevel(req)` resolves: no session ⇒ `anonymous`; authenticated ⇒ `logged_in`; authenticated
with a linked shard account ⇒ `player`; moderator/admin role ⇒ `staff`/`admin`. **Staff always
satisfy the `player` rung** even without a linked game account, consistent with the existing rule
that `/player/*` is role-agnostic self-service.
### 3.4 Two limits an admin cannot override
1. **`acct` and `webId` are admin-only, always.** They are not in-game-visible and are not exposed as
configurable fields.
2. **A kind absent from the kind→feature map is never broadcast below `admin`.** Fail closed. This
preserves the property that today's static `PUBLIC_KINDS` allowlist is a security boundary rather
than a convenience filter.
### 3.5 Configuration
```sql
CREATE TABLE IF NOT EXISTS shard_feature_visibility (
feature VARCHAR(48) NOT NULL PRIMARY KEY,
enabled TINYINT(1) NOT NULL DEFAULT 1,
audience VARCHAR(20) NOT NULL DEFAULT 'anonymous',
field_rules JSON NULL, -- {"<field>": "<rung>"} for sensitive fields only
updated_by INT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
Seeded on boot in `server.js`, one row per feature. **All ten shard features are covered — the four
new ones and the six that already ship — and every default reproduces today's behavior, so the
retrofit is a no-op until an admin changes something.**
| Feature | Default audience | Sensitive fields (default rung) |
|---|---|---|
| `status`, `activity`, `champs`, `guilds`, `governors` | `anonymous` | guilds/governors: `leaderAcct` / `leaderWebId`**admin (locked)** |
| `houses` | `anonymous` | `owner``staff`, `price``staff` (matches today's IDOC-only public view) |
| `presence` | `anonymous` | `location``staff` (matches today's staff-only, location-gated `/online`) |
| `ruleset` (new) | `anonymous` | `connect``anonymous` |
| `atlas` (new) | `anonymous` | — |
| `leaderboards` (new) | `anonymous` | `characterName``anonymous` |
| `market` (new) | `anonymous` | `ownerName``anonymous`, `location``anonymous` |
### 3.6 Enforcement — three points, one config
New `website/server/src/utils/shardVisibility.js`:
- `LADDER = ['anonymous','logged_in','player','staff','admin']`, `rank()`, `meets(viewer, required)`
- `viewerLevel(req)` (§3.3)
- `KIND_FEATURE` — every event kind → its feature; unmapped ⇒ admin-only (§3.4)
- `getConfig()` — DB-backed, cached ~5 s like `uoLinkClient`'s config cache, busted on admin `PUT`
- `requireFeature(name)`**404 when disabled** (don't leak existence), **403 when enabled but the
viewer is below the audience**
- `projectFeature(name, payload, viewerLevel)` — strips fields whose rung the viewer doesn't meet;
`acct`/`webId` always stripped below `admin`
Applied at:
1. **Routes**`requireFeature(…)` on every `/public/shard/*`, `/public/atlas/*` and the
shard-derived player routes; `projectFeature` in the controllers, replacing the ad-hoc
`shapeGuild`-returns-payload-verbatim path.
2. **SSE**`shardBroadcast.js` moves from *"one public channel with a static `PUBLIC_KINDS`
allowlist plus one admin channel"* to **per-connection filtering**: each subscriber carries its
`viewerLevel`; each frame is mapped kind→feature, gated on `enabled && meets(...)`, then passed
through `projectFeature` before write. `PUBLIC_KINDS` becomes the seed data for `KIND_FEATURE`
rather than a hardcoded gate. **This is the largest single change in Part A and where the security
boundary now lives.**
3. **Nav**`GET /api/v1/public/shard/features` returns only the features the calling viewer can
see, so the SPA hides nav entries rather than rendering links that 403.
### 3.7 Admin surface
`GET` / `PUT /api/v1/admin/shard/visibility` (admin-only). Validate feature names against the known
set and rungs against the ladder; reject any attempt to set `acct`/`webId` below `admin`. Writes an
`admin.audit`-style row so visibility changes are traceable. New client panel
`routes/admin/ShardVisibility.jsx` at `/admin/shard-visibility`, linked from `ShardAdmin.jsx`.
---
## 4. The version bump and the rollout
`PROTOCOL_VERSION` **2 → 3** in `link/sidecar/src/main.rs:27`. v3 is defined as *"adds
`world.ruleset`, `points.board`, `vendor.listing` / `vendor.listing.remove`"*.
A bump is an operator-visible hard cutover — `web.rs::gate` returns 409 on every protected route on
mismatch, `uoLinkSocket.js::handleHello` closes the WS, and the website's declared version is the
admin-set `uo_link_config.protocol` column — so it happens **exactly once**, at the end:
- Cut an **`edge`** branch from `main` in each of `website/`, `link/`, `servuo-plugins/`, `docs/`.
- Every phase PRs into `edge`, never `main`. Feature branches are cut from `edge`.
- Part A lands first, alone.
- When all parts are built and tested, one `edge``main` PR per repo, merged together. **That merge
is the v3 cutover.**
- A schema migration sets `uo_link_config.protocol` (the existing row **and** the column default)
from 2 to 3, so the cutover doesn't require a manual admin edit. `UOLINK_PROTOCOL` still overrides.
- No feature-negotiation array anywhere — v3 implies all three kinds.
---
## 5. Part B/1 — `world.ruleset`
`PROTOCOL_2.md` §10.4 sketches a `world.systems` capability frame that was never implemented
(`grep` returns nothing across all four repos). **`world.ruleset` subsumes it**, carrying a `systems`
sub-object with the `cityLoyalty` / `vvv` / `factions` booleans §10.4 asked for. §10.4 is marked
superseded; no orphan kind is left behind.
### 5.1 Plugin
NEW `servuo-plugins/overlay/Scripts/Custom/Bridge/BridgeRuleset.cs`, modelled on
`BridgeBoot.EmitHello`**not** a sweep. Subscribes `BridgeLink.Connected_Core += Emit` so a sidecar
that comes up second still learns the ruleset.
Built from an **explicit allowlist** of `Server.Config.Get<T>` calls. **Never enumerate
`Config.Entries`** (`Server/Config.cs:162`) — it would sweep in secrets. An FNV-1a `rev` over the body
makes an unchanged reconnect a site-side no-op (`String.GetHashCode()` is not stable across runs and
must not be used).
`BridgeConfig.cs` + `overlay/Config/Bridge.cfg`: `RulesetEnabled=true`, `PublicConnectAddress=""`,
`RulesetIncludeSchedule=true`. `BridgeBoot.cs`: `reload` → re-emit, `status` → rev/bytes. Not wired
to `sweepnow`; it isn't a sweep.
### 5.2 Payload
Every block optional, omitted when its system is off:
`shard`, `expansion`, `connect` (only from `PublicConnectAddress`),
`systems{cityLoyalty,vvv,factions,siege,chat,store,dailyRares,honesty,shadowguard,treasureMaps,vetRewards,testCenter}`,
`caps{skill:1000,totalSkill:7000,stat:225,str/dex/int:125,strMax/dexMax/intMax:150}`,
`housing{accountHouseLimit:1}`, `accounts{perIp:3,charSlots:7,autoCreate}`,
`vetRewards{enabled,rewardIntervalDays:30}`,
`loot{feluccaLuckBonus:1000,feluccaBudgetBonus:100,feluccaMaxProps:11}`,
`vendors{restockDelayMinutes,maxSell,economyStockAmount}`,
`champions{powerScrolls:6,statScrolls:16,scrollChance,transcendenceChance,rankThresholds}`,
`treasureMaps`, `vvv{enabled,startSilver:2000,enhancedRules}`, `store{enabled,currencyName}`,
`schedule{autoSaveFrequencyMinutes,autoRestart*}`.
**Excluded by name — in a code comment and here:** `Server.cfg` (Address/Listen/Port; only
`PublicConnectAddress` is published), `Staff.cfg`, `Email.cfg`, `DataPath.cfg`, `Bridge.cfg`,
`Compiler.cfg`, `Reports.cfg`, `Client.cfg`.
### 5.3 Sidecar and website
Sidecar — `store.rs`: singleton `ruleset(id CHECK(id=1), rev, json, updated_t)` + upsert/get;
`main.rs`: new arm in the board-projection match; `web.rs`: `GET /ruleset` served from the store, so
it answers during a shard outage (`PROTOCOL_2.md` §12.2).
Website — `uoLinkClient.getRuleset()`; `uoLinkSocket.backfill()` (object-shaped, so follow the
`getPresence()` block's explicit form, not the array-only `snapshot()` helper); `shardIngest.js`
`shardState.setRuleset`, **not** in `LOGGED_KINDS` (it re-arrives every reconnect and `server.hello`
already marks those); `KIND_FEATURE['world.ruleset'] = 'ruleset'`; `shard_ruleset` singleton table
(`rev`, `expansion`, `payload JSON`, `t`); `GET /public/shard/ruleset` behind
`requireFeature('ruleset')`, returning `null` ⇒ "not published yet".
Client — NEW `routes/public/Rules.jsx` at `/site/rules`, alongside
`/site/champs|guilds|governors|houses`; live via `useShardFeed({ filter: new Set(['world.ruleset']) })`.
### 5.4 Risk
Perf is nil (~3 KB per connect). The only real risk is publishing a secret, mitigated by the explicit
allowlist, the no-`Config.Entries` rule, the named exclusion list, and a manual eyeball of the emitted
frame during verification.
---
## 6. Part C — Spawn atlas / bestiary (website-only)
**No plugin, no sidecar, no `Bridge.cfg` knob, no new kinds.** Not part of the v3 wire change.
**Decision: committed generated artifact + idempotent DB import**, split in two because the build
needs the ServUO tree (which the website container does not have) and the import does not. Not
runtime import (10.5 MB of XML per boot), not a browser-served blob.
New in `website/server/`:
- `src/utils/spawnAtlasParse.js`**pure functions, no fs**, so they are unit-testable in CI without
a ServUO tree: `parseObjects2()`, `parsePoints()`, `parseRegions()`, `parseLocations()`,
`resolveRegion()`.
- `scripts/buildSpawnAtlas.js` (`--servuo <path> --out db/data/`) and `scripts/importSpawnAtlas.js`
(TRUNCATE + batched INSERT in one transaction); `package.json` scripts `atlas:build`, `atlas:import`.
- `db/data/spawnAtlas.<facet>.json` ×13 + `spawnAtlas.index.json` (creatures, champions, regions,
landmarks, meta with per-source-file hashes).
- `src/model/shardAtlas/{shardAtlas.db.js,shardAtlas.model.js}` following the `shardState` split.
- `src/router/v1/public/atlas.{router,controller}.js`; `test/spawnAtlas.parse.test.js`.
Two parsing notes that matter:
- `<Objects2>` is `Type:MX=n:SB=…` segments joined by `:OBJ=` — verified against `trammel.xml`, where
a single point carries six types. Split on `:OBJ=`; the token before the first `:` is the type.
- **The high-value transform:** point-in-rect each spawn against the facet's `Regions.xml` rects
(highest `priority` wins), falling back to the nearest `Data/Locations` landmark, else
`"Wilderness"`. This is what turns *"lizardman at 5411,1234"* into ***"Despise, Felucca"*** and is
the entire reason the page is worth building. `Regions.xml` is genuinely nested and needs a ~120-line
recursive tokenizer **or** one devDependency (`fast-xml-parser`) — the server has zero XML deps
today, so that is an explicit call to make at implementation time. The flat `<Points>` files need
only regex/streaming; **do not** put 10.5 MB through a DOM parser.
Tables: `shard_spawn_creatures` (slug PK, name, total, facets JSON), `shard_spawn_points` (slug,
facet, x, y, region, landmark, max_count, tod_*), `shard_regions`, `shard_landmarks`,
`shard_champion_spawns`, `shard_atlas_meta`. Plain `INDEX` on name, **not `FULLTEXT`** — ~1,500
creature rows makes a `LIKE` scan free, and FULLTEXT brings min-token-length trouble for names like
"orc". No FKs, consistent with every existing `shard_*` table.
Routes at `/api/v1/public/atlas`, **not** under `/shard` — the atlas is static shard *content*, not
live shard *state*; it must not look sidecar-dependent, and unlike `/shard/*` it *should* be
`siteMode`-gated like `/posts` and `/wiki`. `GET /creatures?q=&facet=`, `/creatures/:slug`,
`/regions`, `/landmarks`, `/champions`, `/meta`, all behind `requireFeature('atlas')`. Admin:
`GET /admin/shard/atlas/status` (artifact-vs-DB drift) and `POST /admin/shard/atlas/import`. **Build
stays CLI-only.**
Client: `routes/public/Atlas.jsx` (`/site/atlas`) and `AtlasCreature.jsx` (`/site/atlas/:slug`).
**Payload risk** — a monolithic artifact would be 23 MB of committed JSON. Shard per facet and drop
every `<Points>` field the site cannot use (`UniqueId`, all trigger/refractory/proximity/sequential
fields, sound ids), keeping Name/Map/X/Y/W/H/Range/MaxCount/MinDelay/MaxDelay/TOD*/types — well under
1 MB. The artifact never reaches the browser; the browser sees only paginated API responses.
**Operator re-run story** — spawns changed → `npm run atlas:build -- --servuo <path>` on a machine
with the tree → commit the regenerated `db/data/spawnAtlas.*.json` → deploy → `npm run atlas:import`
(or `POST /admin/shard/atlas/import`). `shard_atlas_meta.source` holds per-file hashes, so
`GET /admin/shard/atlas/status` reports when the DB is behind the artifact. Full detail in
`docs/website/SPAWN_ATLAS.md`.
---
## 7. Part B/2 — `points.board`
Two deliverables: a diff sweep for the boards, and a `points` block folded into `char.profile`
the `PROTOCOL_2.md` §10.3 `titles` precedent (read-model enrichment, no new request kind).
### 7.1 Plugin
NEW `BridgePoints.cs`, copying the `BridgeHousing.cs` diff-sweep shape (`Initialize`
`ServerStarted`, `Connected_Core += OnConnected` clearing `_last` + `Rearm()`, `SweepOnce()`,
`Status()`, skip when `!BridgeLink.Connected`, try/catch throughout).
Which systems: default to `PointsSystem.Systems` filtered to `ShowOnLoyaltyGump == true` — reuse the
shard's own "this is player-facing" signal rather than inventing one. `Bridge.cfg PointsSystems=`
overrides. Null-guard `PointsSystem.Systems`; it is a mutable static populated by 25 separate
subsystem constructors.
**The perf trap.** `PlayerTable` is a plain `List<PointsEntry>`, and `QueensLoyalty` has `AutoAdd`, so
it can hold an entry for every `PlayerMobile` that ever existed. A naive
`.OrderByDescending().Take(N)` across 25 systems is 25 full sorts — at 20,000 historical characters,
~7.5 M comparisons, tens of ms on the Core thread. `BRIDGE_PLUGIN_PLAN.md` §1 found that nothing
except bulk profile generation comes close to a frame budget; this would be the second thing that
does.
**Mitigation — single-pass bounded selection** into a fixed N-element sorted array (N=10): O(n·N) with
tiny constants and one allocation. Skip `Player == null || Deleted` and `Points <= 0`. ~500 k cheap
iterations at a 300 s interval.
Diff signature per system: `concat(serial + ":" + (long)points)` over the top N, plus the entry count.
**No `points.remove`** — the system set is fixed, the same argument `city.update` already uses.
### 7.2 Payload — one frame per system
25 × ~600 B rather than one 12 KB frame, matching `champ.update` / `guild.update`:
```jsonc
{"t":,"kind":"points.board","system":"QueensLoyalty",
"nameString":"Queen's Loyalty","nameNumber":1114938,
"maxPoints":30000,"showOnGump":true,"players":842,
"top":[{"rank":1,"serial":"0x1A2B","name":"Darrow","points":29500}, ]}
```
`nameString` **and** `nameNumber` are both emitted (a `TextDefinition` may be a cliloc), resolved
website-side — the contract `titles.reward` already documents at `BridgeProfile.cs:107-110`.
**Entries are written inline as `{serial, name}` — never via `BridgeJson.Actor`.** Deliberate even
though the website can now reveal fields by rung: `acct`/`webId` are not needed here, because the
website resolves serial→user from its own `shard_account_links` mirror for staff views. Keep the wire
minimal.
### 7.3 `char.profile` enrichment
`BridgeProfile.cs` gains `WritePoints(sb, m)` alongside `WriteTitles`:
`"points":[{system,nameString,points,maxPoints}]`, omitting systems with no entry or 0 points.
**Deliberately no `rank`** — computing it means scanning each system's `PlayerTable` once per profile
(25 × n), which would dominate the measured 0.069 ms/profile budget. The website derives rank from
the board when the character appears in the top N. Gate behind `PointsProfileRank=false` if it is
ever wanted.
### 7.4 Config, sidecar, website
`Bridge.cfg`: `PointsSweepSeconds=300`, `PointsLeaderboardEnabled=true`, `PointsTopN=10`,
`PointsSystems=` (blank ⇒ auto), `PointsProfileEnabled=true`, `PointsProfileRank=false`.
`BridgeBoot.cs`: `Rearm()` in `reload`, `SweepOnce()` in `sweepnow`, `Status()` in both.
Sidecar — `points_boards(system PK, name, json, updated_t)`; `main.rs` arm keyed on `system`;
`GET /points` and `GET /points/:system`.
Website — `shard_points_boards(system PK, name, name_cliloc, max_points, players, show_on_gump,
payload JSON, t)`. **The top-N list stays in `payload`** — a fixed-size list read whole, exactly like
`shard_governors.candidates`. Do not normalize into a `shard_points_entries` table until a
per-character reverse lookup is actually needed. `shardIngest.js``upsertPointsBoard`, **not** in
`LOGGED_KINDS` (board state, like `guild.update`). `KIND_FEATURE['points.board'] = 'leaderboards'`,
with `characterName` as its per-field rule. `GET /public/shard/points` and `/points/:system` behind
`requireFeature('leaderboards')`; validate `system` ≤ 48 chars.
**No new player route** — per-character points ride inside `char.profile`, already served by
`GET /player/shard/char/:serial` with its `shardLinks.ownsAccount` check.
Client — NEW `routes/public/Leaderboards.jsx` at `/site/leaderboards`; a "Loyalty & Points" section
added to `components/CharacterSheet.jsx`, one edit serving both `PlayerCharacter.jsx` and
`AdminCharacter.jsx`.
---
## 8. Part B/3 — `vendor.listing`
### 8.1 It cannot be an RPC, and this is load-bearing
`rpc.rs::try_route` correlates on the **first** frame carrying a matching `reqId` and resolves a
single `oneshot`. A chunked reply sharing one `reqId` would deliver chunk 1 to the HTTP caller and
**leak chunks 2..N onto the broadcast feed**. `REPLY_TIMEOUT` is 10 s (the client waits 12 s), so a
whole-world snapshot could not fit regardless.
**a per-vendor diff sweep on the broadcast stream**, like `champ.update` / `house.update`. The
existing per-account `vendor.snapshot` RPC is untouched; the player portal keeps using it.
Kinds: `vendor.listing` (one frame per vendor, authoritative for that vendor) and
`vendor.listing.remove`. Payload: `serial, shopName, owner:{serial,name}, map, x, y, region, house,
count, truncated, items:[{serial,itemId,hue,amount,price,name,cliloc,child}]`.
### 8.2 Two perf traps
Measured baseline (`BRIDGE_PLUGIN_PLAN.md` §1): 30 vendors / 1,200 listings = 0.343 ms via
`pack.Items` + `GetVendorItem`; extrapolated to 500 vendors / 40,000 listings ≈ 12 ms per full pass.
Except:
1. **`VendorSearch.GetItemName(Item)` is a packet builder, not a field read.** It constructs an
`ObjectPropertyList`, calls `GetProperties`, serialises, then byte-parses the packet
(`VendorSearch.cs:681-789`) — per item. Across 40,000 items in one tick that is a
multi-hundred-millisecond stall. **Mandatory: never call it in the sweep.** Emit `itemId`, `hue`,
`amount`, `price`, `item.Name` (the plain field, null for most) and `item.LabelNumber`, resolving
display names website-side — exactly what `char.profile.equipment` already does
(`BridgeProfile.cs:173`).
2. **`VendorSearch.GetItems(PlayerVendor)` is private** (`:791`). The reusable public API is
`GetItems(Container, List<Item>)` (`:807`), which recurses into sub-containers, so real item counts
run above the top-level `pack.Items` the 0.343 ms measurement used. Budget accordingly.
### 8.3 Mitigations
- **Amortized round-robin sweep** — `MarketSweepSeconds=60`, at most `MarketSweepBatch=25` vendors per
tick, with a persistent cursor over `PlayerVendor.PlayerVendors`. Full coverage in
`ceil(vendors/25) × 60 s`, with **per-tick cost bounded independent of world size**. This is the one
genuinely new pattern versus the existing sweeps and should be flagged in review.
- **Per-vendor signature diff** (`count | Σ(serial ^ price) | x | y | shopName`), as `BridgeHousing`
does — most vendors are static, so steady-state emission is near zero.
- **`MarketMaxListings=250`**, then `"truncated":true`. `BridgeJson.Parse` caps *inbound* at 1 MB;
outbound is uncapped and `shard.rs::read_line` will allocate whatever arrives.
- On `Connected_Core`, clear `_last` **and reset the cursor**; the re-emit is self-throttled by the
round-robin window.
### 8.4 Player opt-out and privacy
**Honour `pv.VendorSearch`** — ServUO's own per-vendor opt-out, which `DoSearch` filters on (`:62`).
Skip opted-out vendors entirely; the seen-set removal then drops them from the board, so **a player
who hid their vendor in game is hidden on the website too.** Also skip `Map == null || Map.Internal`
and `Backpack == null`, matching `DoSearch`.
A vendor's shop name, owner character name and location are **already globally visible in-game** — the
stock Vendor Search gump surfaces exactly this set to any player — which is why they default to
`anonymous`. They remain per-field configurable (`ownerName`, `location`) so an admin can tighten
them. Account name and website user id never go on the wire.
### 8.5 Sidecar and website
Sidecar — one table `vendors(serial PK, shop_name, owner_name, map, x, y, region, count, json,
updated_t)` storing the whole-vendor blob. **No `vendor_items` table** — the sidecar's job here is
outage resilience (`PROTOCOL_2.md` §12.2), not search; search lives in MariaDB. Endpoint is
**`GET /market`**, not `/vendors` — axum would route the latter fine, but the collision with the
per-account RPC is a readability trap.
Website — `shard_vendors` + `shard_vendor_items` (indexes on `vendor_serial`, `price`, `item_id`,
`display_name`; delete-then-insert per vendor in one transaction; no FKs). `shardIngest.js` handles
both kinds; **not** in `LOGGED_KINDS`.
`KIND_FEATURE['vendor.listing'] = 'market'`, but the market feature's **SSE mapping is disabled by
default**: a live firehose of full vendor inventories would be the site's single biggest bandwidth
consumer, and no page needs it live. The page is a paginated DB query with a staleness stamp; an
admin can turn the stream on. `uoLinkSocket` paginates `/market` on reconnect, bounded by
`MARKET_SNAPSHOT_MAX = 5000` vendors so a pathological world cannot hang startup.
`GET /public/shard/market?q=&minPrice=&maxPrice=&itemId=&map=&region=&sort=&limit=&offset=`
(limit 1..100, default 50; `q` ≤ 60 chars; `sort ∈ {price_asc, price_desc, recent}`) and
`/market/vendors/:serial`, behind `requireFeature('market')`. **Rate-limit it** — this is the first
genuinely expensive public endpoint; `express-rate-limit` is already a dependency.
### 8.6 The open dependency — cliloc names
`CharacterSheet.jsx:14-15` already documents the gap ("without a cliloc table on the site we can only
show literals") and renders equipment as `id {itemId}`. Search-by-name needs that table.
- **Recommended:** `scripts/buildClilocs.js` reads the UO client's `Cliloc.enu` → committed
`db/data/clilocs.json`; ingest denormalizes into `shard_vendor_items.display_name`. Same
build-artifact pattern as §6, and it **also fixes the character sheet**.
- **Fallback:** ship with item-art + price + region filters, and name search only over renamed items.
This decision is the reason §8 is sequenced last.
### 8.7 Client
`routes/public/Market.jsx` at `/site/market`, with a *"prices last refreshed N minutes ago"* banner
driven by `staleAt` (the oldest `shard_vendors.updated_at`). The round-robin sweep means data is
inherently up to one full cycle old, and the UI must say so.
---
## 9. Sequencing
| Order | Part | Repos touched | Wire change |
|---|---|---|---|
| 1 | **A** — visibility framework + actor-leak fix | website, docs | none |
| 2 | **B/1**`world.ruleset` (§5) | all four | new kind |
| 3 | **C** — spawn atlas (§6) | website, docs | none |
| 4 | **B/2**`points.board` (§7) | all four | new kind + `char.profile` field |
| 5 | **B/3**`vendor.listing` (§8) | all four | new kinds |
| 6 | **Cutover**`PROTOCOL_VERSION` 2→3, `edge``main` | all four | the bump |
---
## 10. Documentation obligations
- This file (`link/v3.md`) is the canonical 3.0 design.
- `PROTOCOL_2.md` §10.4 gains a note that `world.systems` is superseded by `world.ruleset`, and that
the deferred VvV question is answered (`VvV.cfg Enabled=True`, Factions off).
- `INTEGRATION.md` — catalog entries and §6 consumer sections for each new kind, plus the v2→v3
upgrade note for operators.
- `PLAN.md` — phasing.
- `website/BACKEND_DESIGN.md` — every new table and route, and **the visibility framework as a
security contract**: the audience ladder, the two locked rules, and the fail-closed kind map belong
in the security section.
- NEW `website/SHARD_VISIBILITY.md` — admin-facing: what each feature exposes, what each rung means,
what cannot be loosened.
- NEW `website/SPAWN_ATLAS.md`, NEW `website/MARKETPLACE.md`.
- `PROJECT_TREE.md` in each touched repo.
- `npm run swagger` **and** `npm run routes:manifest` on every route-touching PR — both are committed
artifacts, and `test/routeManifest.test.js` fails on drift.
**Follow-up, not scoped for 3.0:** the Android app consumes the same public/player shard API and will
need `/public/shard/features` to hide its own nav. Track separately against `android-app/`.
---
## 11. Verification
**Plugin**`servuo-plugins\deploy.ps1 -ServerPath <servuo> -Verify`, inspect the ADD/CHANGE list,
then re-run without `-Verify` (ServUO must be stopped). Boot with `tools/stub_sidecar.ps1` listening
and **confirm the compile banner in the console, not merely the absence of errors**
`BRIDGE_PLUGIN_PLAN.md` §1 warns that a failing build is silently ignored and the previous
`Scripts.dll` reloads. Then `[bridge status`, `[bridge sweepnow`, `[bridge reload`.
- §5: eyeball the emitted `world.ruleset` frame for anything sourced from `Server.cfg`, `Staff.cfg`,
`Email.cfg`, `DataPath.cfg` or `Bridge.cfg`.
- §8: with a seeded world, time one sweep tick and confirm the batch cap holds it under ~1 ms.
**Sidecar**`cargo build && cargo clippy`; `curl -H "Authorization: Bearer <token>"
localhost:8080/ruleset` (and `/points`, `/market`); confirm `X-UOLink-Version: 3` and that a client
declaring 2 receives a 409.
**Website server**`DB_HOST=127.0.0.1 DB_PORT=59999 node --test`. New tests, each modelled on an
existing sibling: `test/shardVisibility.test.js`, `test/shardBroadcast.visibility.test.js`,
`test/shardIngest.{ruleset,points,market}.test.js` (after `shardIngest.protocol2.test.js` — stubbed
deps, asserting routing and `logged` flags), `test/spawnAtlas.parse.test.js` (pure functions, inline
fixtures). Then `npm run routes:manifest` and `npm run swagger`, committing both.
**Full stack** — against a local MariaDB: apply `db/schema.sql` (idempotent), start the server,
confirm `uoLinkSocket` backfill logs the new snapshot lines and that `uo_link_config.protocol`
migrated to 3, then load `/site/rules`, `/site/atlas`, `/site/leaderboards`, `/site/market`.
**Visibility smoke test** — for each of the five rungs, walk every shard page and confirm gating and
field projection match the configured matrix. Same shape as the 200-routes × 5-access-levels sweep
already run for the domain split.
---
## 12. Critical files
| File | Why |
|---|---|
| `website/server/src/utils/shardBroadcast.js` | The security boundary; reworked from a static allowlist to per-connection audience filtering. **The highest-risk file in 3.0.** |
| `website/server/src/utils/shardVisibility.js` (new) | Ladder, kind→feature map, projection |
| `website/server/src/utils/shardIngest.js` | The dispatcher every new kind routes through |
| `website/server/src/model/shardState/shardState.model.js` | The `shape*` projections, including the `shapeGuild` leak §3.1 fixes |
| `servuo-plugins/overlay/Scripts/Custom/Bridge/BridgeHousing.cs` | Cleanest copy of the diff-sweep pattern; template for `BridgePoints.cs` and `BridgeMarket.cs` |
| `link/sidecar/src/main.rs` | `PROTOCOL_VERSION` 2→3 and the board-projection match |
| `website/server/db/schema.sql` | All new `shard_*` tables plus the `uo_link_config.protocol` migration |

View File

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

View File

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

View File

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

View File

@@ -29,24 +29,6 @@ Public contact email: **UOMysticmoon@gmail.com**
Skeleton from the spec, with a small number of justified additions marked **(+)**.
> **Complete.** The monolithic route files (`admin.routes.js` especially, originally 1552 lines /
> 110 routes) have been split into one router file per business capability — **in place, with every
> URL unchanged**. See [API_V2_PLAN.md](./API_V2_PLAN.md) § Phase 2.
>
> `users`, `account`, `invites`, `auth/providers` (PR 1, 28 routes), `moderation`, `bot-activity`,
> `activity` (PR 2, 18 routes), `posts`, `uploads`, `wiki`, `pages` (PR 3, 31 routes) and `shard`,
> `uo-link`, `email`, `discord-bot`, `settings`, `dashboard`/`site-mode` (PR 4, 33 routes) each live
> in their own router under `admin/`, behind `admin/index.js`. PR 5 did the same for `public/` (24),
> `player/` (20) and the residual `auth/` (10). **`admin.routes.js`, `public.routes.js`,
> `player.routes.js` and `auth.routes.js` are all deleted**; each group is now a directory whose
> `index.js` owns the group gate and the mount table and declares no routes of its own.
>
> "Every URL unchanged" is enforced mechanically, not by review: `server/scripts/routeManifest.js`
> (`npm run routes:manifest`) walks the live Express stack and writes the sorted
> `{ method, path }` freeze to `server/routes.manifest.json`, mirrored here as
> [api-route-inventory.json](./api-route-inventory.json). PR checks regenerate it and fail on any
> diff, so a split PR that moves a URL cannot merge silently. See § 4.0.
```
server/
.env.example
@@ -60,104 +42,10 @@ server/
router/
api.router.js mounts /v1
v1/
v1.router.js mounts /auth /public /admin /player
auth/ index.js mounts the routers below; no group gate — /auth
is where an anonymous caller becomes
authenticated, so the authenticated parts gate
themselves. Mount order is load-bearing (see
session.router.js)
login.router.js (2) /auth/login + /login/totp — shared
loginGuards stack
register.router.js (1) /auth/register — honours the
player_registration setting
invite.router.js (2) /auth/invite/:token[/accept] — the
token is its own authority, so it
bypasses player_registration
password.router.js (3) /auth/password/forgot + reset/:token
session.router.js (2) POST /logout and GET /me — the two
singletons owning no path segment, so
mounted at the group root, LAST: the
/me sub-routers below also match the
bare /me and supply its noindex header
me.routes.js (23) /auth/me/account*, sessions, trusted
devices — router-level requireAuth
notifications.routes.js (3) /auth/me/devices*, notifications/*
mobile.routes.js + /auth/mobile/* — native bearer login
mobileSso.routes.js (5)
sso.routes.js (4) mounted PATHLESS: owns two prefixes,
/auth/providers and /auth/sso/*
loginGuards.js shared backoff/slow/limiter stack for
every credential-guessing surface
(not a router)
auth.controller.js + invite/passwordReset/sso/mobile controllers
public/ index.js mounts the routers below; **no group gate** —
this surface is anonymous by design (SPA
logged-out, Discord bot, Android ShardStream)
posts.router.js (2) /public/posts/:category[/:idOrSlug]
wiki.router.js (4) /public/wiki — /categories and /tags
MUST precede /:slug
pages.router.js (2) /public/pages — the draft-preview
route precedes /:slug and is
deliberately not site-mode gated
shard.router.js (13) /public/shard/* incl. the anonymous
SSE stream; never site-mode gated
site.router.js (4) /settings /status /version /contact —
the group-root singletons; declares no
router-level middleware
public.controller.js + shard.controller.js
player/ index.js owns the shared `noindex, requireAuth` gate
(authenticated, ANY role — staff are a superset
of players) and the mount table
account.router.js (8) /player/account — credentials, TOTP,
linked identities; handlers shared
with /admin/account and /auth/me
shard.router.js (8) /player/shard — linking + own roster,
vendors, chars, sales, houses
appeals.router.js (4) /player/appeals
shard.controller.js + appeals.controller.js
admin/ index.js mounts the capability routers below at their
own prefixes; owns the shared
`noindex, isLoggedIn, staffOnly` gate and
declares no routes itself
account.router.js (6) /admin/account — self-service, no adminOnly
users.router.js (15) /admin/users — adminOnly
invites.router.js (3) /admin/invites — adminOnly
authProviders.router.js (4) /admin/auth — adminOnly
moderation.router.js (15) /admin/moderation — modAccess
(admin+moderator) at router level
botActivity.router.js (2) /admin/bot-activity — adminOnly
activity.router.js (1) /admin/activity — staff-wide
audit log, no extra gate
posts.router.js (9) /admin/posts — editor tier, no
gate beyond staffOnly
uploads.router.js (1) /admin/uploads — rich-text editor
image upload
wiki.router.js (14) /admin/wiki — pages, revisions,
categories, tags
pages.router.js (7) /admin/pages — CMS page builder
imageUpload.js shared multer config for the two
upload routes above (not a router)
shard.router.js (16) /admin/shard — 7 self-service
account-linking routes (no extra
gate, handlers shared with
/player/shard) + 9 in-game staff
ops on modAccess, per route
uoLink.router.js (5) /admin/uo-link — sidecar config,
town crier, admin SSE — adminOnly
email.router.js (6) /admin/email — Gmail OAuth2
delivery — adminOnly
discordBot.router.js (2) /admin/discord-bot — adminOnly
settings.router.js (2) /admin/settings — adminOnly
dashboard.router.js (2) GET /dashboard (staff-wide) and
PUT /site-mode (adminOnly) — the
two singletons owning no path
segment, so mounted at the group
root; declares no router-level
middleware, which is what makes a
root mount safe
admin.controller.js + the per-capability controllers
(already domain-split; the split PRs re-wire
routes, not logic)
v1.router.js mounts /auth /public /admin
auth/ auth.routes.js + auth.controller.js
public/ public.routes.js + public.controller.js
admin/ admin.routes.js + admin.controller.js
model/
users/ users.model.js + users.db.js
posts/ posts.model.js + posts.db.js (news/five-on-friday/newsletter/screenshots)
@@ -311,7 +199,6 @@ construction, and the authorization code is stored as a **sha256 hash only** (sa
| state | VARCHAR(255) NOT NULL | app-generated opaque CSRF value, echoed on the callback for the app to verify |
| status | ENUM('pending','completed','consumed') DEFAULT 'pending' | `pending``completed` when the code is minted; `consumed` after a successful exchange |
| user_id | INT NULL FK→users(id) ON DELETE CASCADE | set once SSO resolves the account |
| trust_device | TINYINT(1) NOT NULL DEFAULT 0 | user ticked "trust this device" on the Custom Tab TOTP form. A **boolean only** — it tells `/exchange` to mint the app's own trust token; the token never rests here (only its sha256 reaches `trusted_devices`) |
| expires_at | DATETIME NOT NULL | short (~10 min — one redirect round-trip incl. TOTP) |
| created_at / used_at | DATETIME | `used_at` stamped at exchange |
@@ -336,41 +223,6 @@ list/revoke surface: `device_name VARCHAR(100) NULL` (a friendly label) and `las
NULL` (bumped on each refresh). Existing rows get them via the schema's ALTER section; the token model
is otherwise unchanged.
### trusted_devices — MFA "Trust this device"
Lets a browser/app **skip the TOTP step** at login (never the password) for 30 days. Pattern-identical
to `mobile_refresh_tokens`: the opaque trust token lives client-side (the `rg_trust` httpOnly cookie on
web, `X-Trust-Token` / EncryptedSharedPreferences on native) and only its **sha256** hash is stored
(`token_hash CHAR(64) UNIQUE`) — sha256, not bcrypt, because a 256-bit random token is looked up **by
its hash** via the unique index (a per-row salt would break that). Columns mirror the mobile table
(`platform`, `device_name`, `device_hash`, `user_agent`, `created_at`, `last_used_at`, `expires_at`,
`revoked_at`). Capped at 10 rows/user **in application code — no silent pruning** (an over-cap trust is
refused so the client can prompt the user to revoke one first). Consulted only at the login/password
step, never at token refresh, and revoked wholesale on untrust / password change / password reset /
TOTP disable. See `docs/website/TRUSTED_DEVICES_MFA.md`.
### recovery_codes — single-use MFA backup codes
Generated at TOTP enrollment (10 at a time, shown to the user **once**) so a user who loses their
authenticator can complete login without an admin reset. `code_hash VARCHAR(72)` is a **bcrypt** hash
(not sha256): a recovery code is a human-typed, lower-entropy fallback credential — the closest
analogue to a password — and there is no hash-lookup constraint (verification fetches the user's ≤10
unused rows and `bcrypt.compare`s each, like password verification). `used_at` is the single-use
marker. Cleared wholesale on TOTP disable / password change / password reset.
### shard_feature_visibility — per-feature audience config (Protocol 3.0)
One row per shard feature: `feature` (PK), `enabled`, `audience` (a rung on the ladder in §6.5),
`stream` (whether the feature's kinds fan out over SSE at all), `field_rules` JSON (`{field: rung}`
for the sensitive fields only), `updated_by`, `updated_at`.
**An absent row means "use the compiled default", and the compiled defaults reproduce pre-3.0
behavior — so an empty table is a no-op and there is nothing to seed.** Stored rows are merged over
the defaults on read, which is also where the invariants are re-applied: a row naming an unknown
feature is ignored (a stale row must not resurrect a removed feature), an invalid rung falls back to
the default rather than failing open, and a rule touching a locked field (`acct` / `webId`) is
discarded. See §6.5.
---
## 4. API contract
@@ -378,50 +230,11 @@ discarded. See §6.5.
Base path `/api/v1`. JSON in/out. Auth via httpOnly cookie (`isLoggedIn` reads it; also
accepts `Authorization: Bearer` for API testing).
### 4.0 The authoritative route list
The prose tables below are **orientation for a human reader** and can drift. Two generated artifacts
are authoritative, and they answer different questions:
| Artifact | Source of truth for | Generated by |
|---|---|---|
| `server/routes.manifest.json` — mirrored as [api-route-inventory.json](./api-route-inventory.json) | **What URLs exist.** 200 public routes + 2 on the internal listener, sorted, method + path only. | `npm run routes:manifest`, by walking the live Express stack |
| `server/swagger/swagger-output.json` — served at `/api/docs` | **What each route means.** Parameters, bodies, response codes, security. | `npm run swagger`, from `#swagger.*` annotations |
The split is deliberate: Swagger is annotation-derived, so an unannotated route is invisible in it and
it churns whenever a description is reworded — it documents *intent*. The manifest is introspection-
derived and records *reality*, which is why it, not Swagger, is the thing PR checks freeze
(`npm run routes:manifest -- --check`).
Both artifacts are emitted with **sorted** keys, so a diff in either is proportional to the change
rather than to how the routers happen to be traversed. `swagger.js` additionally strips trailing
slashes from generated path keys — see *Regenerating the spec* in the website README for why the
domain split makes that necessary.
Scope: the manifest keeps `/api/**` and `/.well-known/**` from the public app plus everything on the
internal listener. The SPA catch-all, `/uploads` and `/brand` are filesystem-conditional static
mounts — not API contract, and including them would make the output depend on whether CI had built
the client.
A third generated file, `server/routes.guards.json`, is a **review aid and not a contract**: per route,
the middleware handler count plus the *named* middleware on its mount chain. It exists because a
router-level `router.use(noindex, isLoggedIn, staffOnly)` gate never appears in an individual route's
own stack, so a capability router extracted without re-applying the gate would otherwise publish
authenticated endpoints silently. Names are a hint only — `requireRole(...)` returns an anonymous
arrow and cannot be observed — but a *missing* `requireAuth` is unambiguous, and the server test suite
asserts every `/admin/**` and `/player/**` route still carries it.
### /auth (auth/index.js → the capability routers in §2)
No group gate — `/auth` is where an anonymous caller becomes authenticated. The authenticated parts
gate themselves: `me.routes.js` and `notifications.routes.js` each apply `noindex, requireAuth` at
their own router level, and `/sso/:provider/link` carries `requireAuth` per route.
### /auth (auth.routes.js → auth.controller.js)
| Method | Path | Auth | Body | Purpose |
|---|---|---|---|---|
| POST | `/login` | — (rate-limited) | `{username,password}` | verify, set cookie, log `auth.login`, update `last_login_at`. If the account has TOTP **and this browser is a trusted device** (a valid `rg_trust` cookie bound to the user), the TOTP step is **skipped** and a session is issued directly (logs `auth.login.trusted_device`). Otherwise a 2FA account returns `{totpRequired, challenge}`. |
| POST | `/login/totp` | — (rate-limited) | `{challenge, code? \| recoveryCode?, trustDevice?, deviceName?}` | complete 2FA with a TOTP **or** single-use recovery code. `trustDevice` sets the `rg_trust` cookie so future logins skip TOTP; at the device cap the session is still issued and the body carries `{trustLimitReached, devices}`. |
| POST | `/logout` | cookie | — | clear cookie (the `rg_trust` trust cookie deliberately **survives** logout) |
| POST | `/login` | — (rate-limited) | `{username,password}` | verify, set cookie, log `auth.login`, update `last_login_at` |
| POST | `/logout` | cookie | — | clear cookie |
| GET | `/me` | cookie / bearer | — | current user (no hash) or 401 — client bootstraps auth state |
| POST | `/password/forgot` | — (rate-limited) | `{email}` | email a single-use, ~1h reset link to **every active account** on the address; **always** returns the same generic 200 (no account enumeration). Email is non-unique, so several accounts may each get a link naming their username. Logs `account.password.reset.request`. |
| GET | `/password/reset/:token` | — | — | validate a link → `{username}` for the form, else 404 (never distinguishes expired/used/never-existed) |
@@ -429,13 +242,8 @@ their own router level, and `/sso/:provider/link` carries `requireAuth` per rout
| GET | `/me/account` | cookie / bearer | — | full self account (`id, username, role, email, status, totp_enabled, has_password`) |
| PATCH | `/me/account/username` | cookie / bearer (rate-limited) | `{username}` | change own username; re-issues the caller's session |
| PATCH | `/me/account/password` | cookie / bearer (rate-limited) | `{newPassword, currentPassword?}` | change/set own password (current required unless the account has none); revokes other sessions, keeps the caller's |
| POST | `/me/account/totp/setup` · `…/enable` · `…/disable` | cookie / bearer | `{code}` on enable/disable | self 2FA enrollment (disable needs a valid current code, not a password). **enable** returns the one-time `recoveryCodes`; **disable** clears the user's trusted devices + recovery codes |
| POST | `/me/account/totp/setup` · `…/enable` · `…/disable` | cookie / bearer | `{code}` on enable/disable | self 2FA enrollment (disable needs a valid current code, not a password) |
| GET | `/me/account/identities` · DELETE `…/:provider` | cookie / bearer | — | list / unlink own SSO identities |
| GET | `/me/trusted-devices` | cookie / bearer | — | list own active trusted devices (never tokens) |
| POST | `/me/trusted-devices` | cookie / bearer (rate-limited) | `{deviceName?}` | trust the current device; web gets an httpOnly `rg_trust` cookie, native gets `{trustToken}`. **409 `{error:'trusted_device_limit', devices}`** at the cap |
| DELETE | `/me/trusted-devices` · `…/:id` | cookie / bearer | — | untrust all / one (ownership-scoped) |
| GET | `/me/account/recovery-codes/status` | cookie / bearer | — | remaining unused code count (never the codes) |
| POST | `/me/account/recovery-codes/generate` | cookie / bearer (rate-limited, **password step-up**) | `{currentPassword?}` | regenerate the one-time recovery codes (returned once); refused when 2FA is off |
| POST | `/me/devices` | cookie / bearer | `{endpoint, transport?, platform?}` | register a push endpoint; **rejects a disallowed endpoint 400** (SSRF guard). Idempotent per (user, endpoint) |
| GET | `/me/devices` · DELETE `…/:id` | cookie / bearer | — | list / unregister own push devices |
| GET | `/me/notifications/streams` | cookie / bearer | — | the subscribable catalog (`personal`/`requiresLinkedAccount` flags) |
@@ -448,16 +256,6 @@ lets a client (the Android app) manage its own account through one surface witho
`/admin` (docs/android/PLAN.md §6.4). The older `/player/account/*` + `/admin/account/*` routes stay
for web back-compat.
**The `/player/*` group is self-service, not player-only.** Staff are a **superset** of players — every
player ability plus their staff tools on top — so the whole group (`account.router.js`,
`shard.router.js`, `appeals.router.js`, mounted by `player/index.js`) sits behind the shared
`noindex, requireAuth` gate **only**, never `requireRole('player')`. Every handler is self-scoped to the caller by `req.user.id`, so an admin/editor/
moderator using it sees only their **own** linked accounts and characters (with the pre-existing
`isAdmin` bypass still letting a genuine admin read *any* character). Staff also reach the identical
self-scoped handlers under `/admin/shard/*` (same controller) for the web admin surface; the two are
interchangeable. This is why a staff account with linked game characters gets its "My characters" and
personal notification streams on the mobile client — the group no longer 403s a non-`player` role.
**Password reset.** Uses the same audited pattern as `user_invites`: an opaque 32-byte token
whose **sha256 hash only** is stored in `password_resets`, single-use and short-lived (~1h). It
also serves SSO-only accounts (null `password_hash`) as their "set an initial password" path. The
@@ -494,18 +292,12 @@ consumer of the existing SSO + mobile-bearer machinery**, not a parallel auth pa
`/auth/sso/:provider/*` redirect flow, the link-only + opt-in-provisioning policy, the TOTP gate, and
issues the **same** token pair as `/auth/mobile/login`.
The TOTP gate it reuses includes the **trusted-device skip** (see
`TRUSTED_DEVICES_MFA.md` §6). Because the app opens this flow in a Custom Tab, which shares the
system browser's cookie jar, the `rg_trust` cookie set on the TOTP form is presented back on the next
app sign-in — so "don't ask me again" works for native SSO without the app injecting a header into a
tab it does not control, and without a trust token ever appearing in a start URL.
| Method | Path | Auth | Body / Query | Purpose |
|---|---|---|---|---|
| GET | `/auth/providers` | — | — | **reused** discovery; the app renders provider buttons from this (never exposes secrets) |
| GET | `/auth/mobile/sso/start` | — (rate-limited per-IP + per-provider) | `?provider&code_challenge&state&redirect_uri` | validate provider enabled + `redirect_uri` **exact-match** allowlist; insert a `mobile_auth_sessions` row; create the existing `sso_tx` tagged `mode:'mobile'` carrying `session_id`; **302 to the IdP** (existing authorize URL) |
| GET | `/auth/sso/:provider/callback` | — (signed `sso_tx`) | `?code&state` | **existing** endpoint; a new branch when `tx.mode==='mobile'`: resolve the account (same policy as web login incl. TOTP), mint a single-use hashed authorization code into `mobile_auth_codes`, mark the session `completed`, and **302 to `redirect_uri?code=…&state=…`** (the app's original `state`) — **no cookie is set** |
| POST | `/auth/mobile/sso/exchange` | — (rate-limited per-IP) | `{code, code_verifier}` | validate the code exists / unexpired / unused (mark used) and `sha256(code_verifier)` matches the stored challenge → issue the existing mobile access + refresh pair (`createMobileSession`) → `{accessToken, refreshToken, expiresIn, user}`. When the session carries `trust_device`, also mint a `platform:'mobile'` trusted device and add `trustToken` — minted here, on an authenticated app→server call, so it never travels in the deep link. Best-effort: at the trusted-device cap the response simply omits it rather than failing the sign-in |
| POST | `/auth/mobile/sso/exchange` | — (rate-limited per-IP) | `{code, code_verifier}` | validate the code exists / unexpired / unused (mark used) and `sha256(code_verifier)` matches the stored challenge → issue the existing mobile access + refresh pair (`createMobileSession`) → `{accessToken, refreshToken, expiresIn, user}` |
| POST | `/auth/mobile/refresh` | — | `{refreshToken}` | **reused** unchanged — rotate the pair |
| POST | `/auth/mobile/logout` | bearer | `{refreshToken?, all?}` | **reused** unchanged — revoke this (or all) refresh token(s) |
| GET | `/auth/me/sessions` · DELETE `…/:id` | cookie / bearer | — | list / revoke own **mobile sessions** (device_name, last_used_at, created_at) — the "Active Devices" surface (distinct from `/auth/me/devices`, which is push endpoints) |
@@ -552,13 +344,7 @@ web sessions already use.
**Authorization code.** Cryptographically random, ≥128 bits, stored **hash-only**, single-use, short
expiry (~5 min); `/exchange` is rate-limited per-IP. The bridge tables self-prune (§3).
### /public (public/index.js → the capability routers in §2) — all GET except `/contact`, no auth
**No group gate, deliberately.** This surface is anonymous by design: the SPA renders it logged-out,
the Discord bot reads it with no credentials, and the Android `ShardStreamClient` consumes
`/public/shard/stream` without an `Authorization` header. Content visibility during maintenance comes
from the per-route **siteMode** middleware (§5), never from an auth gate.
### /public (public.routes.js → public.controller.js) — all GET, no auth
| Method | Path | Notes |
|---|---|---|
| GET | `/settings` | whitelisted public keys, derived `registration`/`gameAccountSignup` flags, the per-shard **`brand`** block (name, `accent` color, logo/hero/favicon) a client themes itself from — one image runs as any shard, asset fields may be site-relative paths (resolve against the base URL) — and a **`push`** block `{ ntfyUrl }` (M7): the client-facing ntfy relay URL the app's embedded distributor registers its device topic against, from `NTFY_PUBLIC_URL` / first `NTFY_ALLOWED_ORIGINS` (never the internal `NTFY_BASE_URL`); `null` when push isn't configured for the shard. |
@@ -569,31 +355,10 @@ from the per-route **siteMode** middleware (§5), never from an auth gate.
| GET | `/wiki` | list of pages (slug + title) |
| GET | `/wiki/:slug` | single page |
| POST | `/contact` | (rate-limited) send mail via SMTP; if unconfigured, respond `{fallback:"mailto", email}` |
| GET | `/shard/features` | the shard features **this caller** may reach plus the audience rung they resolved to (§6.5), so a client hides nav it can't follow. Reports only what the caller can see — the list itself never discloses a gated feature. Consumed by the SPA header and (pending) the Android nav. |
Public content GETs pass through the **siteMode** gate (§5).
### /admin (admin/index.js → the capability routers in §2) — all behind `isLoggedIn` + `noindex` + `staffOnly`
`admin/index.js` applies the shared gate and mounts each capability router at the prefix it owns;
`users`, `invites`, `auth/providers` and `bot-activity` add `adminOnly` on top, and `moderation` adds
`modAccess` (admin + moderator, so editors are excluded). The content capabilities — `posts`,
`uploads`, `wiki`, `pages` — add nothing: managing content is the editor tier's job, so `staffOnly` is
the whole gate. The ops/config capabilities — `uo-link`, `email`, `discord-bot`, `settings`, and
`PUT /site-mode` — are `adminOnly`; `shard` is the one mixed prefix, where self-service account
linking carries no extra gate and the in-game staff operations carry `modAccess`. There is no residual
file: every admin route is declared in a capability router.
`GET`/`PUT /admin/shard/visibility` are the third tier on that mixed prefix: **`adminOnly`**, because
they decide what *anonymous* visitors can see (§6.5). They sit above `modAccess` deliberately — a
moderator can ban a player but cannot decide what the public internet reads.
`GET /dashboard` and `PUT /site-mode` are the one place where a **single screen spans two tiers**: the
dashboard is staff-wide, but the site-mode toggle on it is `adminOnly`. The client must therefore gate
that control on its own (`Dashboard.jsx` renders it only for `role === 'admin'`) rather than relying on
the route gate that admitted them to the page — the same rule the sidebar follows, so a non-admin is
never shown a control that would 403. The URLs below are unaffected by which
file a route sits in — that is the property the route manifest freezes.
### /admin (admin.routes.js → admin.controller.js) — all behind `isLoggedIn` + `noindex`
| Method | Path | Purpose |
|---|---|---|
| GET | `/dashboard` | current mode, last change time + who, content counts, recent activity |
@@ -610,9 +375,6 @@ file a route sits in — that is the property the route manifest freezes.
| GET | `/settings` · PUT `/settings` | read all / update `{key:value,...}` |
| GET | `/activity?limit=&offset=` | paginated activity log |
| GET | `/users` · POST `/users` · PUT `/users/:id` · DELETE `/users/:id` | user mgmt (can't delete self / last admin; password hashed on write) |
| GET | `/users/:id/trusted-devices` | list a user's active trusted devices (never tokens) |
| DELETE | `/users/:id/trusted-devices` · `…/:deviceId` | revoke all / one of a user's trusted devices (logs `admin.trusted_device.revoke[_all]`) |
| POST | `/users/:id/mfa/reset` | recover a locked-out user: disable TOTP + revoke all trusted devices + clear recovery codes (logs `admin.user.totp.reset`) |
Every admin write logs to `activity_log`.
@@ -643,13 +405,11 @@ who"; `activity_log` provides the history feed.
## 6. Auth & security
- **JWT** signed with `JWT_SECRET`, `expiresIn=JWT_EXPIRES_IN` (default `1d`); payload `{id,username,role}`.
- **Cookie**: `httpOnly`, `sameSite=Lax`, `path=/`, and **`secure` decided per-request** (`COOKIE_SECURE=auto``secure: req.secure`).
- **Trusted-device MFA.** A second, separate httpOnly cookie (`rg_trust`, default 30d) — opaque, sha256-hashed server-side in `trusted_devices` — lets a browser/app **skip the TOTP step** (never the password) on future logins. It is a server-side, per-row-revocable record (never a JWT claim), so the stateless session JWT is unchanged and trust stays revocable. It only ever gates the **second factor**; it deliberately outlives logout, and is cleared on untrust / password change / password reset / TOTP disable. **Recovery codes** (bcrypt, single-use) are the 2FA-lockout fallback. All admin trusted-device/MFA actions and the self actions (`auth.login.trusted_device`, `account.trusted_device.*`, `account.recovery_code*`, `admin.trusted_device.*`, `admin.user.totp.reset`) are audit-logged. See `docs/website/TRUSTED_DEVICES_MFA.md`. This is the key to dual access: the cookie is `Secure` when reached through Pangolin (HTTPS, `X-Forwarded-Proto: https`) but **not** `Secure` when reached directly over the LAN IP on plain HTTP — so login works in both. `COOKIE_SECURE=true|false` can force it. Requires `trust proxy` (below). `localhost:5173` (Vite) and `localhost:3000` are same-site, so the cookie flows in dev too.
- **Cookie**: `httpOnly`, `sameSite=Lax`, `path=/`, and **`secure` decided per-request** (`COOKIE_SECURE=auto``secure: req.secure`). This is the key to dual access: the cookie is `Secure` when reached through Pangolin (HTTPS, `X-Forwarded-Proto: https`) but **not** `Secure` when reached directly over the LAN IP on plain HTTP — so login works in both. `COOKIE_SECURE=true|false` can force it. Requires `trust proxy` (below). `localhost:5173` (Vite) and `localhost:3000` are same-site, so the cookie flows in dev too.
- **bcrypt** hashing (cost 10+); plaintext passwords never stored, logged, or returned.
- **Rate limiting** (`express-rate-limit`) on `/auth/login` and `/public/contact`.
- **Validation** (`express-validator`) on all writes; centralized error handler.
- **helmet** with a Content-Security-Policy tuned for the built React SPA. The policies now live in
**`server/src/config/csp.js`** (`app.js` only wires them up):
- **helmet** with a Content-Security-Policy tuned for the built React SPA (see `server/src/app.js`):
`default-src 'self'`; `script-src 'self'` (the Vite build emits only external module chunks — the
inline module-preload polyfill is disabled in `client/vite.config.js` to keep this valid);
`style-src 'self' 'unsafe-inline' https://fonts.googleapis.com` (React's pervasive inline
@@ -657,89 +417,17 @@ who"; `activity_log` provides the history feed.
https://fonts.gstatic.com` (Cinzel); `img-src 'self' data: https:` (same-origin uploads, plus
external https images embedded in wiki/news bodies or `BRAND_*` logo/hero/favicon); `connect-src
'self'` (REST + SSE are same-origin); `frame-ancestors 'self'`; `object-src 'none'`; `base-uri
'self'`; `form-action 'self'` (blocks an injected `<form action="https://evil">` from POSTing
credentials off-origin — an exfil path `connect-src` does not cover; it was always emitted via
helmet's `useDefaults` and is now pinned explicitly so it cannot vanish under a helmet upgrade).
`upgrade-insecure-requests` is intentionally **not** set (TLS terminates at the proxy, there
'self'`. `upgrade-insecure-requests` is intentionally **not** set (TLS terminates at the proxy, there
are no mixed-content subresources, and it would break a local `npm start` over plain http). The
`/api/docs` Swagger UI route gets a **looser** policy that additionally allows inline script/style,
since swagger-ui-express injects an inline bootstrap. helmet also strips `X-Powered-By`; the two
internal-only listeners (`internalApp.js`, `bot/src/app.js`) disable it explicitly too.
- **A second, tightened policy ships alongside on `Content-Security-Policy-Report-Only`** for one
release before it replaces the enforced one (`docs/website/API_V2_PLAN.md` § Phase 1). It is derived
from the enforced policy so the two cannot drift, and differs by exactly one directive:
`frame-ancestors 'self'`**`'none'`**. Serving both headers at once means the live policy keeps
protecting users while anything the tightened version would break arrives as a report rather than as
a broken page — and for `frame-ancestors` specifically, a report from the browser of whoever framed
the site is the only way to learn that something does.
- **`POST /api/csp-report`** is the same-origin violation sink that `report-to` / `report-uri` point at
(`report-to` additionally requires the `Reporting-Endpoints` response header, which is set alongside).
Same-origin on purpose: reports describe attacks against this site and are not handed to a
third-party collector. It parses **both** wire formats (`application/csp-report` from Firefox/Safari,
`application/reports+json` from Chrome's Reporting API — handling one drops half the browsers),
writes to the `csp` log tag and **stores nothing**. Necessarily unauthenticated (browsers send
reports with no session), so it is bounded on every axis: 16 KB body cap, per-IP rate limit, fixed
field allowlist, every logged field truncated, and **always 204 — even for malformed input**, since a
4xx would make the global error handler log the attacker-supplied body and turn an open endpoint into
a log-flood primitive. Mounted outside `/api/v1` next to `/api/health`: the browser learns the path
from the policy header, never from a client build, so it is not part of the versioned client
contract.
- **Admin not indexed**: `X-Robots-Tag: noindex, nofollow` on `/api/v1/admin` and the admin SPA routes; `robots.txt` disallows `/admin`.
- **No directory browsing** (express.static doesn't list; no `serve-index`).
- **No hardcoded credentials**: first admin via `seed.js` reading `ADMIN_USERNAME`/`ADMIN_PASSWORD` from env (created only if no users exist); `.env` git-ignored, `.env.example` committed.
- **`app.set('trust proxy', 1)`** so secure cookies, `req.ip`, and rate-limiting work behind Pangolin.
- **CORS**: same-origin in prod (SPA served by Express). Dev only: allow `CLIENT_ORIGIN` (Vite, `http://localhost:5173`) with `credentials:true`.
### 6.5 Shard visibility — the audience boundary (Protocol 3.0)
Every shard-derived surface is gated by an **admin-configurable, per-feature and per-field** audience
setting. This **replaces** the static `PUBLIC_KINDS` allowlist that used to be the whole boundary.
Policy lives in `utils/shardVisibility.js`; rows live in `shard_feature_visibility`; the admin surface
is `GET`/`PUT /admin/shard/visibility` (`adminOnly`). Admin-facing guide:
[`SHARD_VISIBILITY.md`](SHARD_VISIBILITY.md). Design: [`../link/v3.md`](../link/v3.md) §3.
**The ladder.** `anonymous < logged_in < player < staff < admin`, each rung implying the ones below.
`viewerLevel(req)` resolves it: no session ⇒ `anonymous`; authenticated ⇒ `logged_in`; authenticated
with a linked game account ⇒ `player`; moderator ⇒ `staff`; admin ⇒ `admin`. **Staff satisfy the
`player` rung without a linked account** (consistent with `/player/*` being role-agnostic).
**`editor` gets no shard privilege** — it is a content role, and mapping it to `staff` would silently
widen what editors see.
**Two invariants that are code, not configuration.** Both are enforced server-side and both reject
rather than silently ignore:
1. **`acct` and `webId` are admin-only, always.** They are not exposed as configurable fields, and a
stored row attempting to loosen them is discarded on read as well as rejected on write. A character
name is visible in game; the account behind it and the website user it links to are not.
2. **A kind absent from `KIND_FEATURE` is never broadcast below `admin`.** Fail closed. This is what
keeps the kind map a security boundary rather than a convenience filter, and it means a shard that
starts emitting an unknown event degrades to staff-only, never to public.
**Fail-closed everywhere else too.** An unreadable visibility config withholds every public frame; a
DB failure falls back to the compiled defaults (pre-3.0 behavior), not to open; an unresolvable viewer
subscribes as `anonymous`. The ladder comparison uses **asymmetric** fallbacks by design — an unknown
*viewer* level floors to the bottom rung and an unknown *requirement* ceils to admin, so an
unrecognised value loses on both sides. (A single shared fallback cannot do that: whichever direction
it picks, it fails open on one side.)
**Three enforcement points, one config:**
| Where | Mechanism |
|---|---|
| Routes | `requireFeature(name)`**404** when the feature is disabled (don't leak that it exists), **403** when the caller is below its audience. `projectFeature` then strips out-of-rung fields from the body. |
| SSE (`utils/shardBroadcast.js`) | Per-connection filtering. A subscriber's rung is resolved **once at subscribe time and frozen** for that connection, so a long-lived stream can't gain privilege; each frame is then mapped kind→feature, gated, and field-projected per viewer. Two subscribers can legitimately receive different versions of one event, or one of them nothing. |
| Nav | `GET /public/shard/features` returns only what the caller may reach, so the SPA never renders a link that would 403. Presentation only. |
Config reads are cached ~5s, so admin changes take effect within seconds **including on already-open
streams**. `PUBLIC_KINDS` still exists and is still exported (`/feed` filtering, `notificationStreams.js`)
but is now **derived** from the kind map rather than hand-maintained, so the two cannot drift.
**Defaults reproduce pre-3.0 behavior exactly**, so installing the framework is a no-op until an admin
changes something — with one deliberate exception, which is the leak it was written to close:
`/public/shard/guilds` and `/public/shard/governors` previously returned the raw stored payload, whose
`leader` / `governor` actors carry `acct` and `webId`. Those fields are now stripped for every caller
below admin.
---
## 7. Email
@@ -758,11 +446,7 @@ instead. Errors never leak credentials.
`utils/logger.js` — a small dependency-free logger with **two transports, console + file**,
and four levels (`error`/`warn`/`info`/`debug`). Each line is timestamped and tagged by
subsystem (`[server]`, `[http]`, `[db]`, `[auth]`, `[admin]`, `[ratelimit]`, `[csp]`, …).
> During the CSP report-only soak, `[csp]` is the tag to watch: a `csp violation` warn line with
> `directive: frame-ancestors` means something really does frame the site and the enforce PR would
> break it. Silence across one release is the green light to flip.
subsystem (`[server]`, `[http]`, `[db]`, `[auth]`, `[admin]`, `[ratelimit]`, …).
- **Console**: color on a TTY, plain in Docker; verbosity = `LOG_LEVEL` (default `info`).
- **File**: plain text appended to `LOG_DIR/LOG_FILE` (default `<server>/logs/app.log`,
@@ -785,13 +469,11 @@ subsystem (`[server]`, `[http]`, `[db]`, `[auth]`, `[admin]`, `[ratelimit]`, `[c
`env_file: .env`, `DB_HOST=db`, `depends_on: db (healthy)`, volume `uploads:/app/uploads`,
`ports: "3000:3000"`**binds 0.0.0.0** (no `127.0.0.1:` prefix) so Pangolin reaches it.
- `ntfy` (M7): pinned upstream `binwiederhier/ntfy` image, declarative config only
(`./ntfy/server.yml` mounted `:ro` + `NTFY_BASE_URL`), volume `ntfydata:/var/lib/ntfy`,
**publishes `:80` on a host port** (`${NTFY_HOST_PORT:-2586}:80`, binds 0.0.0.0) so Pangolin — which
runs outside the compose network — can forward the notification subdomain to it, the same reason
`app` publishes `3000`. Both devices (SSE subscribe) and the backend publisher (POSTing tickles to
registered device endpoints) reach ntfy on that public origin. Anonymous read-write to unguessable
topics (no accounts to provision) — safe because pushes are content-free tickles. Bringing the stack
up provisions a working push relay with **zero interactive setup**.
(`./ntfy/server.yml` mounted `:ro` + `NTFY_BASE_URL`), volume `ntfydata:/var/lib/ntfy`, **no
published host port** — devices reach it via the reverse proxy; the backend publisher reaches it
over the private compose network. Anonymous read-write to unguessable topics (no accounts to
provision) — safe because pushes are content-free tickles. Bringing the stack up provisions a
working push relay with **zero interactive setup**.
- Volumes: `dbdata`, `uploads`, `ntfydata`.
Express listens on `0.0.0.0:${PORT||3000}`. Pangolin terminates TLS and proxies to `app`.
@@ -824,9 +506,6 @@ NTFY_BASE_URL=https://ntfy.example.com
# shows push as unavailable for the shard.
NTFY_PUBLIC_URL=https://ntfy.example.com
NTFY_ALLOWED_ORIGINS=https://ntfy.example.com
# Host port the ntfy container publishes :80 on (default 2586); the reverse proxy
# forwards the notification subdomain to host:NTFY_HOST_PORT. Change on a conflict.
NTFY_HOST_PORT=2586
```
`.gitignore`: `node_modules/`, `.env`, `_reference/`, `client/dist/`, `uploads/`.

View File

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

View File

@@ -1,124 +0,0 @@
# Shard visibility — who sees which shard data
**Status:** Built (Protocol 3.0 Part A). Admin → Shard Visibility.
**Audience:** shard owners and admins.
**Companion to** [`../link/v3.md`](../link/v3.md) §3 (the design) and
[`BACKEND_DESIGN.md`](BACKEND_DESIGN.md) §6 (the security contract).
The website surfaces a lot of live shard data. What your players, your staff and the anonymous
internet may each see is **yours to decide**, per feature, from Admin → Shard Visibility.
Nothing changes until you change it: every setting ships at the value that reproduces how the site
behaved before this panel existed.
---
## 1. The audience ladder
Five rungs. Each one includes everyone below it.
| Rung | In the UI | Who that is |
|---|---|---|
| `anonymous` | **Everyone** | Anyone at all, signed in or not. |
| `logged_in` | **Signed in** | Any registered account, whether or not they've linked a game account. |
| `player` | **Linked players** | Accounts with a linked in-game account. **Staff always qualify**, linked or not. |
| `staff` | **Staff** | Admins and moderators. |
| `admin` | **Admins only** | Admins. |
Two notes that surprise people:
- **`editor` is a content role, not a shard role.** Editors write news and wiki pages; they get no
shard privilege from that. An editor is treated by link status like any other member. This matches
the rest of the site, where shard staff powers are admin-or-moderator.
- **Staff satisfy `player` without linking.** Otherwise an admin would be locked out of surfaces
they'd gated to players, which is how the `/player/*` routes already behave.
## 2. What you can set per feature
**Enabled.** Off means gone. The feature's pages return “not found”, not “forbidden” — a disabled
feature doesn't advertise that it exists.
**Who can see it.** The minimum rung, from the ladder above.
**Live updates.** Whether this feature pushes changes to open pages in real time. Turning it off
doesn't break the page; it just refreshes on load instead of updating in place.
**Sensitive fields.** Some features expose a field that deserves its own rung — you can publish the
board while holding back one column. See the table in §3.
## 3. The features, and their defaults
| Feature | What it exposes | Default | Sensitive fields |
|---|---|---|---|
| **Shard status** | Connection state, online count, gold-supply series | Everyone | — |
| **Activity feed** | Deaths, kills, skill gains, quests, logins | Everyone | — |
| **Champion spawns** | The live champion / mini-champ / sea-boss board | Everyone | — |
| **Guilds** | Guild rosters, alliances, leaders | Everyone | — |
| **Town governors** | City Loyalty governors, elections, term history | Everyone | — |
| **Houses / IDOC** | Houses in danger | Everyone | House owner → Staff · House price → Staff |
| **Players online** | Population aggregate, staff-online widget | Everyone | In-game location → Staff |
| **Shard rules** | Skill/stat caps, house limits, vet rewards, the ruleset | Everyone | Connect address → Everyone |
| **Spawn atlas** | Bestiary and spawn locations (static content) | Everyone | — |
| **Leaderboards** | Point and loyalty standings | Everyone | Character names → Everyone |
| **Marketplace** | The shard-wide player-vendor index | Everyone, **live updates off** | Vendor owner name → Everyone · Location → Everyone |
**Why the marketplace ships with live updates off.** A live feed of every vendor's full inventory
would be the single largest thing the site sends. No page needs it — the marketplace is a search over
stored data with a “prices last refreshed N minutes ago” stamp. Turn it on only if you want it.
**Why house owner/price default to Staff.** The public Houses page has always been a "where are the
falling houses" board — location only. Owner and price are the staff view. That split is preserved.
## 4. What you cannot change
Two rules are enforced in code and are not settings. Attempting to set them returns an error rather
than silently ignoring you.
**1. Game account names and website user ids are admin-only, always.**
`acct` and `webId` never appear below the admin rung on any surface. A character *name* is visible in
game to anyone standing next to them; the **account** behind it is not, and neither is the website
user it's linked to. Publishing those would disclose something the shard itself doesn't, and would
tie a player's in-game identity to their forum identity without their consent.
**2. Unknown event kinds are never broadcast below admin.**
The live stream maps each event kind to a feature. A kind with no mapping — a new event from a shard
plugin the site doesn't know yet, say — goes to admins only. It fails closed. This is what keeps the
stream safe by default when the shard starts sending something new: the worst case is that staff see
it and players don't, never the reverse.
## 5. How it's enforced
Three places, one config:
- **Page and API requests** are checked before the handler runs, and the response is then stripped of
any field above the caller's rung.
- **The live stream** resolves a viewer's rung once, when they connect, and freezes it for that
connection — a long-open page can't gain privilege because something changed underneath it. Each
event is then gated and stripped per viewer, so two people watching the same page can legitimately
receive different versions of the same event, or one of them nothing.
- **Navigation** hides links a viewer can't follow, so they don't hit a wall. This is presentation
only — the gate is server-side either way.
Changes take effect within about five seconds, **including on streams that are already open**. You
don't need to restart anything.
If the database is briefly unreachable, the site falls back to the built-in defaults — the pre-v3
behavior — rather than to "everything is public".
## 6. Worked examples
**"I want a private shard — nothing public until people register."**
Set every feature to **Signed in**. Anonymous visitors still get the site itself; the shard data
disappears from the nav.
**"Publish the market, but don't tie vendors to players."**
Marketplace → Everyone, with **Vendor owner name** → Staff. Prices, items and locations stay public;
who owns each vendor doesn't.
**"Leaderboards for members only."**
Leaderboards → **Linked players**. Anyone who's linked a game account sees the standings; drive-by
visitors don't.
**"Let players see house owners."**
Houses → Everyone, **House owner** → Linked players. Note this is a real disclosure: house ownership
is visible in game, but the website makes it searchable in a way the game doesn't.

View File

@@ -1,259 +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)`.
### `mobile_auth_sessions.trust_device` (SSO bridge)
| Column | Type | Notes |
|---|---|---|
| trust_device | TINYINT(1) NOT NULL DEFAULT 0 | user ticked "trust this device" on the Custom Tab TOTP form |
A **boolean only**. It records the user's choice so `POST /auth/mobile/sso/exchange`
knows to mint the app's own trust token over that authenticated app→server call; the
token itself is never written here (only its sha256 reaches `trusted_devices`). Set
only while the session is still `pending` and unexpired, for the same reason
`completeSession` is guarded — a replayed TOTP post must not re-arm a consumed
session.
### `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.
#### SSO login paths
SSO is **not** exempt: an account with TOTP on is asked for a code after a
Google/Discord sign-in exactly as it is after a password one, and a trusted device
skips that code exactly the same way. (Originally SSO consulted trust nowhere, so a
user who signed in with an external identity was asked for a code on *every* sign-in
no matter how many times they had ticked "trust this device".)
- `GET /auth/sso/:provider/callback` — once the account is resolved and before a
TOTP challenge is staged, resolve the presented trust (cookie, or `X-Trust-Token`)
and, if it belongs to **this** user, skip the code, stamp `last_used_at`, and log
`auth.login.trusted_device`. The first factor is the IdP authentication that just
succeeded, so this is the same posture as the password path. A store error falls
through to the challenge — fail **closed** to asking for the code.
- `POST /auth/sso/totp` — gains optional `trustDevice` + `deviceName`, mints the
trust and sets the `rg_trust` cookie on success. At the cap the sign-in still
completes and the response carries `{ trustLimitReached, devices }`, matching
`POST /auth/login/totp`. Recovery codes remain password-login only: this step
verifies an authenticator code against the staged challenge.
**How this reaches the Android app.** The app's SSO runs in a Custom Tab, which
shares the system browser's cookie jar, so both halves land in the same place: the
`rg_trust` cookie set on the Custom Tab TOTP form is presented back on the *next*
app SSO sign-in and skips the code — no app change, and no trust token smuggled
through a start URL where it would leak into query strings and logs.
To cover the app's **native** password login on the same device as well, ticking
the box also sets `mobile_auth_sessions.trust_device` (a boolean — never the
token), and `POST /auth/mobile/sso/exchange` then mints a `platform: 'mobile'`
trust and returns `{ trustToken }` in its JSON body. Minting at exchange time is
deliberate: it is an authenticated app→server call, so the raw token never travels
in the deep link and never rests in the bridge row. One tick therefore produces two
independently-revocable rows (the browser and the app) — which is honest, since they
are two distinct credentials on one device. If the user is at the cap, the exchange
simply returns no token; it never turns a successful sign-in into an error.
### Self-service (`/auth/me/*`, `requireAuth`, any role)
- `GET /auth/me/trusted-devices` — list active trusted devices (never tokens).
- `POST /auth/me/trusted-devices` — trust the current browser/device (cap-checked).
- `DELETE /auth/me/trusted-devices/:id` — revoke one (ownership-scoped).
- `DELETE /auth/me/trusted-devices` — revoke all ("untrust everywhere").
- `POST /auth/me/account/recovery-codes/generate`**password step-up required**;
returns the codes **once**.
- `GET /auth/me/account/recovery-codes/status` — remaining count only.
### Admin (`requireRole('admin')`)
- `GET /admin/users/:id/trusted-devices` — list a user's trusted devices.
- `DELETE /admin/users/:id/trusted-devices/:deviceId` — revoke one.
- `DELETE /admin/users/:id/trusted-devices` — revoke all.
- MFA reset control (revoke trust + disable TOTP + clear recovery codes).
## 7. Cookie / refresh / JWT interaction
- New **`rg_trust`** cookie: `httpOnly`, `sameSite=Lax`, `secure` per-request
(reuse `cookieSecure`), `path=/`, `maxAge` 30d, opaque 256-bit base64url,
sha256-hashed server-side. **Separate from the session cookie and deliberately
survives logout** (so the next login skips 2FA); only untrust / password-change /
TOTP-disable revoke it.
- **JWTs stay stateless and unchanged** — trust is a server-side cookie+row, never
a JWT claim, so it remains revocable.
- **Refresh flow untouched** — trust is consulted only at the login/password step,
never at token refresh; the two stores stay independent.
## 8. Security & invalidation
- Trust **only ever gates the second factor**; password is always required.
- Recovery-code entry reuses the login brute-force stack (backoff + bot scoring +
rate limits); recovery codes are single-use.
- **Password change/reset and TOTP-disable clear trust and recovery codes.**
- **Audit logging** via existing `activity.log` / `activity_log`:
`auth.login.trusted_device`, `account.trusted_device.add` / `.revoke` /
`.revoke_all`, `account.recovery_codes.generate`, `account.recovery_code.consume`,
and admin `admin.trusted_device.revoke` / `.revoke_all`, `admin.user.totp.reset`
— each with actor, target user, and device id in `detail`.
## 9. Backwards compatibility
Fully additive. With no `rg_trust` cookie the behavior is exactly today's (TOTP
every login). Recovery codes exist only for users who generate them. No existing
session or login flow changes shape. New tables via `CREATE TABLE IF NOT EXISTS`
and columns via `ALTER TABLE … ADD COLUMN IF NOT EXISTS`.
## 10. Implementation roadmap
1. **Schema + models**`trusted_devices` (sha256, cap-checked) + `recovery_codes`
(bcrypt); `.db.js` / `.model.js` pairs mirroring `mobileSessions`.
2. **Session service** — trust-token mint/sha256/verify + recovery-code
generate/bcrypt-verify/consume helpers (pure, DB-free); shared
`assertUnderTrustCap()`.
3. **Web login** — trust-cookie skip in `/auth/login`; `trustDevice` / recovery
handling + cap `409` in `/auth/login/totp`; set/clear `rg_trust`.
4. **Mobile login**`trustDevice` / `trustToken` / `recoveryCode`, same cap.
5. **Self-service + admin backend**`/auth/me/trusted-devices*` + recovery-code
endpoints; `/admin/users/:id/trusted-devices*` + MFA reset (all admin-gated).
6. **Invalidation wiring** — password change/reset & TOTP-disable revoke trust +
delete recovery codes.
7. **Web client UI** — "Trust this device" checkbox; cap-reached TOTP-styled modal
(revoke-to-continue / cancel); user Trusted Devices + Recovery Codes screens.
8. **Admin front-end UI** — admin Trusted Devices management & revocation screens
(per-user list, revoke one / revoke all, MFA reset), wired to step 5.
9. **Android** — record the app-side trust/recovery flow in
`docs/android/PLAN.md`; app implementation sequenced after the backend lands.
10. **OpenAPI + docs**`#swagger.*` on every new/modified route + regenerate
`server/swagger/swagger-output.json`; update `BACKEND_DESIGN.md` §3/§4/§6.
11. **Automated tests** — trusted-device login skip (valid / missing / expired /
revoked), token mint+hash, recovery-code single-use consume + wrong-code
backoff, cap `409` behavior, revocation (self + admin), invalidation on
password-change / TOTP-disable, and permission checks (admin routes reject
non-admins; self routes ownership-scoped); web client pure-logic tests; Android
JVM DTO/repository tests.

View File

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

View File

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

View File

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

View File

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