95 Commits

Author SHA1 Message Date
81f7d58fbe Merge pull request 'docs(website): cross-component blast-radius review + /api/mobile facade (Phase 0)' (#47) from docs/api-v2-blast-radius-mobile-facade into main
Reviewed-on: #47
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-23 05:00:00 +00:00
3eafaef97e docs(website): add cross-component blast-radius review + /api/mobile facade (Phase 0)
Expand the API v2 plan to account for consumers beyond the browser and
insulate the Android app from version churn before the v2 work begins.

- Inventory the three v1 API consumers (browser, Android app, Discord bot)
  and map the cross-component contracts (site<->link, site<->mobile).
- Fix two concrete plan bugs: the public shard SSE stream must stay
  anonymous (logged-out browsers and the app's ShardStreamClient send no
  auth header), and the useShardFeed fetch-rewrite is admin-stream-only.
- Add "Phase 0 - the mobile facade": a version-agnostic /api/mobile
  namespace (a thin BFF delegating to current controllers behind pinned
  wire shapes), landed before v2 so the auth merge never touches the app.
- Note link/ is essentially out of scope (no PROTOCOL_VERSION bump), with
  the admin-stream allowlist split as the only shared seam.
- Resequence PRs (Phase 0 first) and gate v1 retirement on the pre-facade
  app fleet aging out via an app-version floor, not the web client.
- Add M11 to docs/android/PLAN.md: migrate the app to /api/mobile + ship
  the app-version floor, cross-referenced with the website plan's Phase 0.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-22 23:54:15 -05:00
4810830c8a Merge pull request 'docs(website): add /api/v2 skeleton (PR 1 scaffold)' (#46) from docs/api-v2-skeleton into main
Reviewed-on: #46
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 21:57:19 +00:00
bfb006c888 Merge branch 'main' into docs/api-v2-skeleton 2026-07-22 21:57:09 +00:00
2cb6f7a3b9 docs(website): add /api/v2 skeleton (PR 1 scaffold), link from plan
Companion doc to API_V2_PLAN.md describing PR 1: stand up router/v2/ empty but
wired next to a frozen /api/v1, with a trivial GET /api/v2/version to make the
mount testable and no behavior change. Includes the file tree, the api.router.js
/ v2.router.js wiring, empty capability-router stubs, and acceptance criteria.
Adds a forward link from the plan's PR-1 line to the skeleton doc.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 16:54:23 -05:00
cfd202b23e Merge pull request 'docs(website): add API v2 plan (auth merge, CSP hardening, domain split)' (#45) from docs/api-v2-plan into main
Reviewed-on: #45
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 21:52:04 +00:00
eb19556802 docs(website): add API v2 plan (auth merge, CSP hardening, domain split)
Plan for website API v2, sequenced in two phases behind a parallel /api/v2:

- Phase 1: retire httpOnly session cookies; unify web + mobile on the existing
  bearer access + rotating/revocable refresh model. Separates removable session
  cookies from the SSO/email transaction cookies that must stay. SSE moves to
  fetch-based streaming with Authorization: Bearer.
- Phase 1b: tighten the shipped CSP for the now-JS-held token (add form-action
  'self', frame-ancestors 'none'); self-host fonts + Trusted Types as follow-ups.
- Phase 2: break the monolithic route wiring (admin.routes.js, ~100 routes) into
  one router per business capability so the URL predicts the file.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 16:50:33 -05:00
c2f4866012 Merge pull request 'docs(tree): sync website/PROJECT_TREE.md' (#44) from chore/sync-website-tree into main
Reviewed-on: #44
2026-07-22 21:43:28 +00:00
runic-docs-bot
daf735f48f docs(tree): sync website/PROJECT_TREE.md from RunicGateway/website@cbe54fc [skip ci] 2026-07-22 21:31:42 +00:00
57395c51b1 Merge pull request 'docs(tree): sync link/PROJECT_TREE.md' (#42) from chore/sync-link-tree into main
Reviewed-on: #42
2026-07-22 21:27:34 +00:00
a458d4f594 Merge branch 'main' into chore/sync-link-tree 2026-07-22 21:27:11 +00:00
3e98a6c437 Merge pull request 'docs(tree): sync android/PROJECT_TREE.md' (#43) from chore/sync-android-tree into main
Reviewed-on: #43
2026-07-22 21:26:59 +00:00
runic-docs-bot
468c9b5541 docs(tree): sync android/PROJECT_TREE.md from RunicGateway/Android-app@f3da6ea [skip ci] 2026-07-22 21:25:02 +00:00
runic-docs-bot
7f9d63308b docs(tree): sync link/PROJECT_TREE.md from RunicGateway/link@7e4177c [skip ci] 2026-07-22 21:20:56 +00:00
d10aefa755 Merge pull request 'docs(tree): add per-repo PROJECT_TREE snapshots + index them' (#41) from docs/add-project-trees into main
Reviewed-on: #41
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 21:16:08 +00:00
3ae4d1c3db docs(tree): add per-repo PROJECT_TREE snapshots + index them
Add auto-generated project-tree snapshots for the website, link, and
Android-app repos under docs/<repo>/PROJECT_TREE.md, and link them from
the README (adding a previously-missing android/ section). These files
are maintained going forward by the sync-project-tree CI workflow in each
source repo, which opens a PR here whenever the tracked layout changes.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 16:14:52 -05:00
f773a13c23 Merge pull request 'docs(android): add test coverage plan to reach the 50% gate' (#40) from docs/android-coverage-plan into main
Reviewed-on: #40
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 19:30:04 +00:00
43d9782d2e docs(android): add test coverage plan to reach the 50% gate
Post-#26 the JaCoCo→Sonar wiring is live and coverage measures 16.4% on new
code — under the 50% gate. Add COVERAGE_PLAN.md: a bucketed analysis of the
gap (ViewModels 0.1%/1153 lines is the dominant lever; DTOs, repositories,
core utils next) and a phased plan — Phase 0 broadens coverage exclusions to
drop non-unit-testable UI/framework code, Phases 1–4 test DTOs, ViewModels,
repositories, and core. Includes a MainDispatcherRule harness + VM test
pattern and per-phase coverage projections (Phase 2 clears the gate at ~65%).
Cross-linked from PLAN.md §12.1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-22 14:27:39 -05:00
1e6710dd50 Merge pull request 'docs(android): record SonarQube coverage wiring and issue triage (§12.1)' (#39) from docs/android-sonar-coverage into main
Reviewed-on: #39
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 19:00:35 +00:00
a743b6000c docs(android): record SonarQube coverage wiring and issue triage (§12.1)
Add §12.1 documenting the Android-app SonarQube setup: the coverage gate
failed at 0% because the source-only scan received no JaCoCo report (a
reporting gap, not a testing gap). Records the JaCoCo wiring (jacoco plugin +
report task, sonar.coverage.jacoco.xmlReportPaths, pure-UI coverage
exclusions, a Gradle step in sonarqube.yml) and the 2026-07-22 triage:
3 smells fixed in code, 12 marked Won't Fix (snake_case DTO fields that
mirror the wire contract; idiomatic Compose/nav complexity).

Pairs with RunicGateway/Android-app chore/sonar-coverage-and-cleanup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-22 13:59:14 -05:00
2686ade632 Merge pull request 'docs(android): note the empty-subscriptions PUT serialization gotcha' (#38) from docs/notifications-empty-subscriptions-gotcha into main
Reviewed-on: #38
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 16:39:30 +00:00
2d2a68d4a7 docs(android): note the empty-subscriptions PUT serialization gotcha
Record the "can't turn off the last notification" class of bug in PLAN.md §11:
the PUT /auth/me/notifications/subscriptions validator requires `streams`, so an
empty set must serialize as {"streams":[]} not {}. kotlinx.serialization drops a
property equal to its default (encodeDefaults=false), so a request DTO field
defaulting to emptyList() gets omitted when empty and the server rejects it 400.
Generalized to any "replace the full set" PUT/POST whose empty value equals a DTO
default. Documents the fix in Android-app fix/notifications-empty-subscriptions.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 11:37:53 -05:00
7d7df6ac15 Merge pull request 'docs(ntfy): ntfy publishes a host port for the external reverse proxy' (#37) from fix/ntfy-published-port into main
Reviewed-on: #37
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 08:59:19 +00:00
0d5c0486dd docs(ntfy): ntfy publishes a host port for the external reverse proxy
Match the website change: the ntfy relay is reached through the public
reverse proxy, which runs outside the compose network and can only reach
a service via a published host port. Update the "no published host port /
internal-only publisher" claims in android/PLAN.md (§11 + §13) and
website/BACKEND_DESIGN.md to describe the published NTFY_HOST_PORT
(default 2586 -> ntfy:80), and note that both devices and the backend
publisher reach ntfy on the public origin.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 03:57:42 -05:00
0251df5bfe Merge pull request 'docs(backend): note the /player/* group is role-agnostic self-service' (#36) from docs/staff-player-self-service into main
Reviewed-on: #36
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 08:28:55 +00:00
30cfa0df1f docs(backend): note the /player/* group is role-agnostic self-service
Record that the whole /player/* router sits behind requireAuth only (not
requireRole('player')): staff are a superset of players, every handler is
self-scoped to the caller, and staff reach the identical handlers under
/admin/shard/*. This is why a staff account with linked characters gets
its "My characters" and personal notification streams on the mobile
client. Matches the code change in RunicGateway/website.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 02:18:22 -05:00
9fe7b75833 Merge pull request 'docs(android): full trusted-devices/MFA smoke-test walkthrough frames' (#35) from docs/trusted-devices-screenshots-walkthrough into main
Reviewed-on: #35
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 06:50:07 +00:00
1809f15456 docs(android): add full trusted-devices/MFA smoke-test walkthrough frames
Supplements the five curated highlights (docs#34) with the remaining
distinct frames from the same live smoke-test session, in flow order:
home/connected, signed-out + signed-in drawers, login + empty 2FA step,
account overview, recovery-codes pre-generate, and the untrust-all to
empty-list to TOTP-required-again sequence. Extends the screenshots
README with a walkthrough table.

Excludes the raws superseded by the five highlights and pure automation
artifacts (soft-keyboard popups, mid-transition spinners, duplicate Home
landings) that do not represent a distinct app state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-22 01:33:48 -05:00
7d09475c53 Merge pull request 'docs(android): trusted-devices/MFA live smoke-test screenshots' (#34) from docs/trusted-devices-screenshots into main
Reviewed-on: #34
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 06:27:37 +00:00
e34284561f @
docs(android): add trusted-devices/MFA live smoke-test screenshots

Live end-to-end smoke test of the Android trusted-device + recovery-code
feature (app PR RunicGateway/Android-app#23) against the local server +
MariaDB on an API 36 emulator. Adds android/screenshots/ with five
captures (login trust step, account Security section, Trusted Devices,
recovery-codes show-once, recovery-code login) and a README documenting
the verified flows — including that trust survives logout (password-only
re-login skipped TOTP) and untrust-all clears the local token.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
@
2026-07-22 01:23:58 -05:00
b64b310d67 Merge pull request 'docs(android): record trusted-devices app implementation in PLAN §4.1.1' (#33) from docs/trusted-devices-mfa-app into main
Reviewed-on: #33
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 06:05:28 +00:00
db983fbb8f @
docs(android): record trusted-devices app implementation in PLAN §4.1.1

Marks §4.1.1 implemented (app PR RunicGateway/Android-app
feature/trusted-devices-mfa) and captures two deliberate design
decisions from the build:

- The trust token DELIBERATELY SURVIVES logout (native analogue of the
  rg_trust cookie): it is only consulted at a fresh login, so clearing
  it on logout would make the feature a no-op. Kept in a separate,
  username-scoped encrypted store; cleared only on Settings→Server
  switch, untrust-all, or server-side revocation. Supersedes the earlier
  handoff note.
- The login-time trust cap is surfaced + resolved on the Trusted Devices
  screen rather than a blocking login modal, since native login already
  issued the session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
@
2026-07-22 00:42:45 -05:00
f495db572a Merge pull request 'docs: trusted devices & MFA improvements' (#32) from docs/trusted-devices-mfa into main
Reviewed-on: #32
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 04:50:48 +00:00
6e7da3acbe docs: trusted devices & MFA improvements (design + API/security/schema)
Add TRUSTED_DEVICES_MFA.md (the approved design/implementation plan) and fold
the feature into BACKEND_DESIGN §3 (trusted_devices + recovery_codes schema),
§4 (login/totp trust+recovery, /auth/me/trusted-devices*, recovery-codes*,
admin trusted-device + /mfa/reset routes), and §6 (trusted-device security
model + audit actions). Note the app-side trust/recovery flow in android PLAN §4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 23:39:05 -05:00
e82eac6d97 Merge pull request 'docs(website): add architecture diagram' (#31) from docs/website-architecture-diagram into main
Reviewed-on: #31
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 02:15:37 +00:00
80f75b1b4c docs(website): add architecture diagram
Add docs/website/ARCHITECTURE.md (canonical copy of the website architecture
Mermaid diagram, with fuller notes) and embed the same diagram in the
website-README mirror. Mirrors the diagram added to the website repo README.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 21:13:29 -05:00
eba08bc53d Merge pull request 'docs(android): M10 — native SSO fixes + staff operations scope' (#30) from docs/m10-native-sso-admin into main
Reviewed-on: #30
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-21 21:33:04 +00:00
e50c6dda2c docs(android): M10 — native SSO fixes + staff operations scope
Record the M10 plan and correct the admin-scope contract:

- §1: narrow the app's exclusion list. The operational admin subset is now
  IN scope for staff (moderation, support queue, dashboard/site-mode, content =
  news posts + wiki cats/tags). Only the hero/CMS block editor, Discord-bot
  config, uo-link config, and OAuth-provider setup remain excluded.
- §6.4: self-service stays role-agnostic under /auth/me/*, but the operational
  admin subset now calls /api/v1/admin/** directly, gated by a STAFF/ADMIN menu
  access level; bearer is accepted and role re-checked every request.
- §9: add milestone M10 covering the SSO discovery/reachability fixes (native
  buttons, no website fallback, encrypted pending PKCE, scrollable drawer, dev
  stub IdP) and the staff-operations screens.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 15:23:01 -05:00
c3062f9960 Merge pull request 'docs(website): add Discord bot test plan' (#29) from docs/bot-test-plan into main
Reviewed-on: #29
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-21 06:05:59 +00:00
027fe2cbc3 docs(website): add Discord bot test plan
Document the test plan for the bot/ workspace — the last of the three website
npm workspaces without a suite (server + client landed in website PR #86).
Records the shared node --test conventions (no jest/vitest/jsdom; DB pool at a
dead port; Discord objects hand-faked) and maps the meaningful bot behavior to
lock: the normalize/duration/spam pure logic, the invite/site-api/internal-key
single-collaborator units, the messageFilter pipeline (bypass-first decision
order + fixed-duration mute + best-effort recording), and the models with real
shaping logic. Includes the CI + SonarQube coverage wiring to mirror PR #86 and
a suggested phasing.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 01:01:34 -05:00
f97931f8c3 Merge pull request 'docs(android): note explicit network security config for cleartext posture' (#28) from fix/android-cleartext-doc into main
Reviewed-on: #28
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-21 04:42:16 +00:00
c1d99f943a docs(android): note explicit network security config for cleartext posture
Record that the app's HTTPS-only-in-release / HTTP-in-debug rule (ServerUrl,
allowInsecureHttp = BuildConfig.DEBUG) is backed at the platform socket layer
by an explicit network security config: main/release forbids all cleartext, a
debug override re-permits cleartext to loopback only. Matches the fix in
RunicGateway/Android-app (fix/manifest-cleartext-traffic).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 23:39:40 -05:00
f3fa48c932 Merge pull request 'docs(ci): document the SonarQube static-analysis setup' (#27) from docs/ci-sonarqube into main
Reviewed-on: #27
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-21 04:16:50 +00:00
2257df09eb docs(ci): document the SonarQube static-analysis setup
Adds docs/ci/SONARQUBE.md covering the non-blocking push-to-main scan
wired into website, link, and Android-app: server URL, per-repo project
keys/sources, the SONAR_TOKEN secret + SONAR_HOST_URL variable, and how
to onboard a new repo.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 23:13:54 -05:00
17f9207a17 Merge pull request 'docs(website): document the SPA Content-Security-Policy' (#26) from docs/csp-security-headers into main
Reviewed-on: #26
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-21 04:04:02 +00:00
28c5c228f7 docs(website): document the SPA Content-Security-Policy
Replace the vague "helmet with a CSP suited to the SPA" line with the actual
policy now implemented in server/src/app.js: per-directive sources and the
rationale for each non-'self' allowance (Google Fonts, inline React styles,
external/embedded images, same-origin REST+SSE), why upgrade-insecure-requests
is omitted, the scoped looser CSP for the /api/docs Swagger UI route, and the
X-Powered-By handling across the public and internal listeners.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-20 23:02:57 -05:00
6e0ff2a821 Merge pull request 'docs(android): spec Android App Links (assetlinks.json + build-time host)' (#25) from docs/app-links into main
Reviewed-on: #25
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-20 23:42:24 +00:00
0109df6963 Merge branch 'main' into docs/app-links 2026-07-20 23:42:13 +00:00
752793f6c3 docs(android): spec Android App Links (assetlinks.json + build-time host)
Promote APP_LINKS.md from a deferred design note into an implementation spec
matching the website `feat/mobile-app-links` and android `feat/app-links`
branches: the server-side `/.well-known/assetlinks.json` route + `mobile_app_links_enabled`
toggle + additive redirect-allowlist entry, and the app-side `autoVerify`
intent-filter driven by a build-time `appLinkHost` (a single multi-tenant APK
cannot autoVerify open-ended shard domains, so App Links are a white-label /
first-party build opt-in; the custom scheme stays the permanent fallback).

Update PLAN.md §9 (M9 follow-up) and the §14 open item, and the redirect-URI
allowlist section of website/BACKEND_DESIGN.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-20 18:37:04 -05:00
82d88f26ec Merge pull request 'docs(android): record the M9 Part 2 native-SSO app plan' (#24) from docs/m9-native-sso-part2 into main
Reviewed-on: #24
2026-07-20 22:56:27 +00:00
44544dc3bc docs(android): record the M9 Part 2 native-SSO app plan
Add the "M9 plan — native SSO login" block to android/PLAN.md: the two-part
(backend-first) split, the frozen Part-1 bridge contract the app codes against
(/auth/providers, /auth/mobile/sso/start Custom-Tab redirect, the code/error
callback deep link, /auth/mobile/sso/exchange), and the six Part-2 app work
items (PKCE+state, SsoAuthManager, SsoApi+DTOs, the callback intent-filter,
the login-screen provider list, and the JVM tests).

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-20 17:52:56 -05:00
b3fa93e9c4 Merge pull request 'docs(auth): design the mobile SSO authorization bridge (M9)' (#23) from docs/mobile-sso-bridge into main
Reviewed-on: #23
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-20 22:09:08 +00:00
1aba1ff93d docs(auth): design the mobile SSO authorization bridge (M9)
Record the plan before coding: native Android "Sign in with Google/Discord"
via a Mobile SSO Authorization Bridge that extends the existing /auth/sso/*
redirect flow and terminates in the existing mobile bearer tokens.

- BACKEND_DESIGN.md: mobile_auth_sessions / mobile_auth_codes schema, the
  /auth/mobile/sso/{start,exchange} contract, the two PKCE layers, state/CSRF,
  exact-match redirect-URI allowlist, TOTP parity, and the documented
  revocation-latency window.
- android/PLAN.md: promote §4.2's "possible later enhancement" to milestone M9
  (backend-first, mirroring M7); status note.
- android/APP_LINKS.md: new architecture note on the per-shard assetlinks.json
  / pairing multi-tenancy question (App Links deferred; custom scheme only now).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 17:01:57 -05:00
5a7bbc26fa Merge pull request 'docs: M7 Part 2 landed — embedded ntfy distributor + push.ntfyUrl' (#22) from docs/android-m7-part2-landed into main
Reviewed-on: #22
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-20 20:29:43 +00:00
71cb181152 docs(android): M7 Part 2 landed — embedded ntfy distributor + push.ntfyUrl
Flip M7 Part 2 to landed (Android-app#15) and record the two implementation
decisions: the direct-ntfy embedded distributor (no UnifiedPush library — the
plan's stated likely path; foreground-service SSE, no second app, no Google
Play Services), and the small additive push.ntfyUrl settings field the app
needs to discover the relay (website#79). Document push.ntfyUrl +
NTFY_PUBLIC_URL in BACKEND_DESIGN.md.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 15:27:55 -05:00
837b546f49 Merge pull request 'docs(android): plan M7 Part 2 — app UnifiedPush push notifications' (#21) from docs/android-m7-part2-plan into main
Reviewed-on: #21
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-20 19:58:55 +00:00
1dbdeb789e docs(android): plan M7 Part 2 (app UnifiedPush push notifications)
Part 1 backend landed (website#78 merged); flip its status to landed and
expand the M7 plan block into a detailed Part 2 (app) plan, grounded in the
merged /auth/me/devices + notifications contract.

Transport decision (per user): the app EMBEDS its own UnifiedPush
distributor — ntfy is only the relay server, no second app installed, no
Google Play Services. A foreground-service persistent ntfy connection
(reusing the ShardStreamClient pattern) subscribes to the app's own topic;
the registered endpoint is that topic URL. A PushTransport seam keeps the
future FCM Play flavor cheap.

Also records the ten Part-2 work items, resolves the §13 notification-tap
deep-link open item, and drops the now-decided distributor-strategy question.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 14:57:32 -05:00
9a8c083a1e Merge pull request 'docs: M7 push-notification backend contract + plan' (#20) from docs/android-m7-push into main
Reviewed-on: #20
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-20 15:24:36 +00:00
cb10cee6f1 docs: M7 push backend contract + status (website#78)
- BACKEND_DESIGN.md: push_devices + notification_subscriptions tables (§3), the
  /auth/me/devices* + /auth/me/notifications/* API rows (§4), a push-notification
  design + security section (content-free tickles, PUBLIC_KINDS split, owner-keyed
  personal streams, SSRF endpoint guard, untrusted-relay model), and the ntfy
  compose service in the deploy section (§8).
- PLAN.md: flip M7 Part 1 (backend + docs) to in-review — status line, §8 item 3,
  §9 M7.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 05:15:25 -05:00
a51017f4c4 docs(android): record M7 push-notifications plan (Part 1 backend + docs)
Capture the M7 backend/docs breakdown in PLAN.md before the code lands: two
event sources / one publisher, the stream catalog + PUBLIC_KINDS-gated mapping,
push_devices + notification_subscriptions tables, the /auth/me routes, the SSRF
endpoint guard, and the content-free-tickle ntfy service (no publish token).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 04:51:43 -05:00
6f9632f77b Merge pull request 'docs(android): record M6 (release mechanics) landed' (#19) from docs/android-m6-landed into main
Reviewed-on: #19
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-20 08:40:12 +00:00
f79c2fa5a9 docs(android): record M6 (release mechanics) landed
Mark M6 landed (RunicGateway/Android-app#11): signed-APK release plumbing
(R8 minify + resource shrink, release signingConfig from a gitignored keystore,
release.yml on a v* tag), the §3 version-mismatch guard, and the default brand
app icons (deep-indigo medallion). Record the decision to descope the optional
biometric app-lock from v1 (tokens already encrypted at rest) across §4.3, the
M3 note, §9, and §13.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 03:37:37 -05:00
8798a0ff79 Merge pull request 'docs(android): record M5 (design pass) landed' (#18) from docs/android-m5-landed into main
Reviewed-on: #18
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-20 08:03:55 +00:00
4eae12448a docs(android): record M5 (design pass) landed
Mark the M5 shard-website design pass complete in docs/android/PLAN.md,
matching RunicGateway/Android-app#10: dark-only theme (deep blue-black
surfaces, slate accent, Cinzel display face), reusable pill/label/card/meter
components, and retained per-shard brand-accent seeding — restyling the
working M1–M4 screens with no architecture, data-flow, endpoint, or DTO
change. Updates the status header, the §9 build-progress record, and the
milestone list (M0–M5 landed; M6 release hardening next).

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-20 01:56:27 -05:00
17320ac578 Merge pull request 'docs(android): summarize website frontend theme for Android client' (#17) from docs/android-m4-landed into main
Reviewed-on: #17
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-20 03:44:03 +00:00
a3a5985268 docs(android): summarize website frontend theme for Android client
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-19 22:41:45 -05:00
3a1d091a71 Merge pull request 'docs(android): record M4 (player self-service & game data) landed' (#16) from docs/android-m4-landed into main
Reviewed-on: #16
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-20 03:36:32 +00:00
cdc2bf580e docs(android): record M4 (player self-service & game data) landed
Marks M4 done in android/PLAN.md §9 build-progress and flips the top
status to "M0–M4 landed; M5 (design pass) next." Documents the account
self-service (/auth/me/account*), game-account linking, and text-only
own game-data screens shipped in RunicGateway/Android-app#9 — a pure
consumer of the existing bearer API, no backend/protocol change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-19 22:35:35 -05:00
da3be61c7f Merge pull request 'docs(android): record M2 (public shard) + M3 (auth) landed' (#15) from docs/android-m3-landed into main
Reviewed-on: #15
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-20 00:16:39 +00:00
ed53990679 Merge branch 'main' into docs/android-m3-landed 2026-07-20 00:16:28 +00:00
0d5743ec74 docs(android): record M3 (auth) landed, M4 next
Records the M3 functional auth pass in docs/android/PLAN.md: native
username/password (+TOTP) login, EncryptedSharedPreferences token storage,
the refresh-on-401 authenticator, /auth/me resume re-validation, the
declarative access-level menu + My Account, and the Custom-Tab hand-offs for
register / forgot-password / SSO. Also records the decision to defer the
optional biometric app-lock to M6 (tokens are already encrypted at rest).

No backend/API change accompanied M3 — the app is a pure consumer of the
existing mobile bearer + /auth/me surface — so no Swagger regeneration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-19 19:09:15 -05:00
07603691bb Merge pull request 'docs(android): record M2 (public shard + SSE) landed' (#14) from docs/android-m2-landed into main
Reviewed-on: #14
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-19 23:39:02 +00:00
90bc9a0dad docs(android): record M2 (public shard + SSE) landed
Mark M2 done in the build-progress section: the public shard widgets over
/public/shard/* (hub + champ/guild/governor/house boards) and the live SSE
feed with self-driven reconnect/backoff (Android-app#7). Update the status
line to "M0–M2 landed; M3 next". No API change — the app consumes the
existing public shard surface.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-19 18:36:17 -05:00
292cdb3274 Merge pull request 'docs(android): record M1 (connect & browse) landed' (#13) from docs/android-m1-progress into main
Reviewed-on: #13
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-19 22:12:43 +00:00
eda817e6f3 docs(android): record M1 (connect & browse) landed
Mark M0+M1 done in the build-progress header and next up M2. Summarize
the M1 functional pass (first-run connect, runtime base URL + host
interceptor, layered ApiResult stack, brand-seeded theming, public
content/wiki/pages/contact screens) and record the deliberate
hand-written-vs-openapi-generated API-client deviation from §2 and its
rationale (swagger-autogen schemas are meta-descriptive, not
codegen-clean). Tracks RunicGateway/Android-app#6.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-19 17:11:15 -05:00
061fbee8fc Merge pull request 'docs(android): mark M0 scaffold landed, M1 next' (#12) from docs/android-m0-status into main
Reviewed-on: #12
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-19 18:14:30 +00:00
78a455e3bb docs(android): mark M0 scaffold landed, M1 next
Update the PLAN.md status header now that the Android-app repo scaffold
(RunicGateway/Android-app#2) is in: Gradle+Compose+Hilt skeleton, version
catalog, and CI. Records M0 done and points at M1 (functional Kotlin pass).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-19 12:54:25 -05:00
e78c92850b Merge pull request 'docs: /public version+brand; mark PLAN §8 items 4 & 6 done' (#11) from docs/public-version-and-brand into main
Reviewed-on: #11
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-19 17:22:06 +00:00
874fcfd79d docs: document /public version+brand; mark PLAN §8 items 4 & 6 done
Counterpart to RunicGateway/website#77.

- BACKEND_DESIGN.md: /public table now documents the new GET /public/version
  (DB-free identity/version), the version block on /public/status, and the brand
  block on /public/settings (per-shard theming: name/accent/logo/hero/favicon).
- android/PLAN.md: mark §8 item 4 (version/health) and item 6 (branding) DONE and
  update the prerequisite-progress summary — all v1 prerequisites are now done;
  only push notifications (item 3) remains and is post-v1 (M7). App M0–M4 unblocked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-19 12:18:53 -05:00
d05b59316b Merge pull request 'docs: document /auth/me self surface; mark PLAN §8.1 done' (#10) from docs/auth-me-self-surface into main
Reviewed-on: #10
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-19 16:36:04 +00:00
63bce88bd7 docs: document /auth/me self surface; mark PLAN §8.1 done
Counterpart to RunicGateway/website#76 (role-agnostic /auth/me/* self surface).

- BACKEND_DESIGN.md: add the /auth/me/account* rows to the /auth API contract and
  a note that the surface reuses account.controller behind requireAuth (any role),
  so a client manages its own account without touching /admin.
- android/PLAN.md: mark §8 item 1 (role-agnostic self-service) DONE and update the
  prerequisite-progress summary; version/health (item 4) and branding (item 6)
  remain open, push (item 3) is post-v1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-19 04:56:55 -05:00
a3ede38c5d Merge pull request 'docs(android): mark the password-reset prerequisite done in PLAN' (#9) from docs/android-plan-prereq-status into main
Reviewed-on: #9
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-19 09:12:21 +00:00
7fa6eee9a9 docs(android): mark the password-reset prerequisite done in PLAN
§8 item 2 (password reset — the "build FIRST before app work" prerequisite)
shipped in RunicGateway/website#75 + docs#8. Mark it done, note the shipped
design (opaque token stored as a sha256 hash in password_resets, mirroring
user_invites, rather than a signed JWT), and record which §8 items remain open.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-19 04:10:12 -05:00
ee87ce0729 Merge pull request 'docs(backend): document the password-reset endpoints and table' (#8) from docs/password-reset into main
Reviewed-on: #8
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-19 09:01:33 +00:00
d87a45e914 docs(backend): document the password-reset endpoints and table
Add /auth/password/forgot and /auth/password/reset/:token to the API
contract and the password_resets table to the schema section, matching the
website change (RunicGateway/website feat/password-reset). Notes the
no-enumeration behaviour, single-use hashed-token model, and that the Android
app hands off to the web reset page (PLAN.md §4.2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-19 03:57:22 -05:00
099e5b0af4 Merge pull request 'docs(android): add design-pass workflow to app PLAN' (#7) from docs/android-design-pass-workflow into main
Reviewed-on: #7
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-19 08:36:23 +00:00
c59ff9270b docs(android): add design-pass workflow to app PLAN
Record the two-pass build process: functional Kotlin first (M0-M4), then a Claude Design pass producing the front-end design that Claude Code implements as the final UI (new M5). Add §2.1 documenting the workflow and reconcile §9 milestones (insert M5 design pass, renumber Polish/Push/Play to M6-M8) plus the milestone cross-references in §8, §11, §12, and §13.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-19 03:35:22 -05:00
033292504e Merge pull request 'docs(android): add Android app design plan' (#6) from docs/android-app-plan into main
Reviewed-on: #6
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-19 08:17:42 +00:00
8963269ff0 docs(android): add Android app design plan
Add docs/android/PLAN.md — the design contract for the RunicGateway/Android-app
repo (planning only, no app code yet).

Scope: native Kotlin + Jetpack Compose client of the website v1 API. Public
content + public shard widgets (incl. SSE), native username/password + TOTP
login, player self-service via a new role-agnostic /auth/me/* surface, and a
player's own shard/game data. Excludes every admin/management console (hero
editor, auth/provider admin, Discord bot, shard/uo-link ops).

Key decisions captured: stay on v1 (all additions are additive, no v2);
registration/invite/reset/SSO are website-handled hand-offs, not native screens;
password reset is built on the backend + web front end first; single shard per
install; minSdk 29; no telemetry and no offline cache in v1; text-only game data
(paperdoll is future); strings externalized from day one; push via a self-hosted
ntfy/UnifiedPush service with content-free tickles that keep the relay untrusted;
Gitea Actions build on ubuntu:latest with a signed-APK release.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-19 03:14:37 -05:00
e889700227 Merge pull request 'docs(website): moderation appeals + Discord reversal (Phase 6c/6d)' (#5) from docs/moderation-appeals into main
Reviewed-on: #5
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-19 03:41:11 +00:00
50244e5c2b docs(website): link moderation appeals doc from the feature index 2026-07-19 03:36:11 +00:00
4f0c282f3f docs(website): add moderation appeals + Discord reversal (Phase 6c/6d) 2026-07-19 03:34:26 +00:00
f1aa65cc17 Merge pull request 'docs(website): staff in-game location is admin/moderator-only' (#4) from fix/staff-location-visibility into main
Reviewed-on: #4
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-19 02:23:52 +00:00
8a3e37ae73 docs(website): staff in-game location is admin/moderator-only
The "What each audience sees" table said the public "Staff online" list
is shown "with name + map location". Location is now privileged: the
server includes map/coords only for admin/moderator callers and strips
them from the payload for players and the public. Update the wording to
match (RunicGateway/website#72).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XmHdsbnLzDMAVQkAoTQSBe
2026-07-18 21:21:20 -05:00
363eb810da Merge pull request 'chore: add open-source governance files (GPLv3 + contributing docs)' (#3) from chore/open-source-governance into main
Reviewed-on: #3
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-19 00:29:56 +00:00
32 changed files with 4117 additions and 11 deletions

View File

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

196
android/APP_LINKS.md Normal file
View File

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

204
android/COVERAGE_PLAN.md Normal file
View File

@@ -0,0 +1,204 @@
# 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.

1092
android/PLAN.md Normal file

File diff suppressed because it is too large Load Diff

348
android/PROJECT_TREE.md Normal file
View File

@@ -0,0 +1,348 @@
# 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.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

View File

@@ -0,0 +1,49 @@
# 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.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

98
android/theme-plan.md Normal file
View File

@@ -0,0 +1,98 @@
# Android theme plan — mirroring the website frontend
This is a summary of the **website frontend theme** (source of truth:
`website/client/src/styles/theme.css`, applied at runtime by
`website/client/src/contexts/SiteContext.jsx`) so the native Android client can
present a visually consistent brand. Where the web uses CSS custom properties,
the Android equivalent is a Compose `MaterialTheme` `ColorScheme` + `Typography`.
## Overall character
A **dark, moody, "arcane fantasy" theme** — deep blue-black backgrounds, muted
slate-blue accent, parchment-white text, and an engraved serif display face. It
reads like a leather-and-moonlight fantasy ledger, not a bright consumer app.
There is **no light mode** on the web; the app should ship dark-only to match.
## Color tokens
The web theme is a flat set of CSS variables under `:root`. Map them to Compose
as follows (hex is authoritative):
| Web token | Hex | Role | Compose slot (suggested) |
|-------------------|------------|----------------------------------------|-------------------------------|
| `--bg` | `#0e1318` | App background | `background` |
| `--bg-deep` | `#0b0f14` | Deepest surface / on-accent text | `surfaceDim` / `onPrimary` |
| `--panel-a` | `#192231` | Card gradient top | `surface` |
| `--panel-b` | `#141a21` | Card gradient bottom | `surfaceContainer` |
| `--panel-flat` | `#11161d` | Flat panels, toolbars | `surfaceContainerLow` |
| `--line` | `#2a3544` | Borders / dividers | `outline` |
| `--line-soft` | `#1d2733` | Subtle row dividers | `outlineVariant` |
| `--accent` | `#7f99bd` | **Primary accent** (brand-overridable) | `primary` |
| `--accent-bright` | `#cdd9e8` | Primary button fill, active states | `primaryContainer` / bright |
| `--ink` | `#eef3f8` | Highest-contrast text | `onBackground` |
| `--head` | `#e6edf6` | Headings | heading color |
| `--text` | `#c4cdd8` | Body prose | `onSurface` |
| `--muted` | `#aeb8c4` | Secondary text | `onSurfaceVariant` |
| `--dim` | `#6f7d8e` | Meta / captions / placeholders | dim / disabled text |
| `--blue` | `#13243c` | Accent hover/active background | `secondaryContainer` |
| `--mode-live` | `#5fb98a` | "Shard live" status (green) | success |
| `--mode-maint` | `#e6c26a` | "Maintenance" status (amber) | warning |
### Semantic / status colors (used in badges, diffs, moderation)
- **Success / published / live:** green `#5fb98a` (fills at ~1622% alpha, text `#7fd0a4`).
- **Warning / maintenance / moderation (kick/mute/warn):** amber `#e0b070` / `#e6c26a`.
- **Danger / ban / red-link / errors:** desaturated red `#d98b84` (borders `#6e3b38`).
- **Admin badge:** near-white `#d8e2ef` on `#3a4a5e`.
## Branding is data, not code
The `--accent` value is **overridden at runtime** per shard instance. On the web,
`SiteContext` reads `brand.accent` from the site settings API and sets the CSS
variable, so one build reskins for any shard. **The Android app should do the
same:** fetch the brand payload (name, `accent`, colors, logo/hero/favicon) from
the website API and derive the `primary` color at runtime rather than hardcoding
`#7f99bd`. Default to `#7f99bd` when the brand payload is absent/offline.
## Typography
Three font families, by role:
- **Display** (`--display`): **Cinzel**, falling back to Georgia serif — an
engraved Roman capitals face used for the logo, `h1`/`.h1`, and prose
`h2`/`h3`. Bundle Cinzel as an app font; this face carries the brand.
- **Serif body** (`--serif`): **Georgia / Times New Roman** — default body and
prose text; `line-height ≈ 1.6`.
- **Sans** (`--sans`): **Helvetica Neue / Arial** — UI chrome: buttons, pills,
form labels, table headers, badges, meta. Labels/eyebrows/kickers are
UPPERCASE with wide letter-spacing (`0.10.18em`) and small (0.680.86rem).
Heading scale is fluid on web (`h1` clamps ~2.43.6rem); pick fixed Material type
scale equivalents (e.g. display for `h1`, headline for `h2`, title for `h3`).
## Shape, elevation & motion
- **Corners:** cards/panels `1012px` radius; inputs/small elements `8px`;
pills and buttons are **fully rounded** (`999px` / capsule).
- **Cards:** vertical gradient `--panel-a → --panel-b`, 1px `--line` border, soft
drop shadow (`0 14px 34px rgba(0,0,0,0.3)`). On hover the web lifts `-3px` and
brightens the border to `--accent` — translate to a pressed/focused accent
border on Android.
- **Buttons:** primary = bright fill (`--accent-bright`) with dark text;
ghost/secondary = translucent dark fill with accent-on-hover border.
- **Motion:** short, subtle transitions (0.120.18s). Keep animations understated.
## Signature accents (nice-to-have)
- The **"moon"** motif: a radial-gradient sphere (`#eef3f8 → #9fb0c6 → #5d6e88`) —
a small brand flourish worth reproducing.
- Accent-tinted focus rings and left-border "note" callouts
(`border-left: 3px solid --accent` over a translucent `--blue` background).
## Implementation note for Compose
Define one `darkColorScheme(...)` from the table above, a `Typography` binding the
three families, and a `Shapes` set (`small = 8.dp`, `medium = 10.dp`, capsule for
buttons). Load `accent` from the brand API into a state holder and rebuild the
`primary` (and derived `primaryContainer`) at runtime so a shard's custom accent
flows through the whole UI — exactly as `SiteContext` does on the web.

59
ci/SONARQUBE.md Normal file
View File

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

46
link/PROJECT_TREE.md Normal file
View File

@@ -0,0 +1,46 @@
# 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
```

378
website/API_V2_PLAN.md Normal file
View File

@@ -0,0 +1,378 @@
# Website API v2 — Plan
Status: **planning** · Target repo: `website/` · Docs owner: this file + `BACKEND_DESIGN.md`
v2 is three sequenced pieces of work, landed in order:
0. **The mobile facade** (lands *first*, before any v2 work) — a **version-agnostic `/api/mobile`
namespace** is stood up over the current controllers, and the Android app is migrated to it. From
then on the app is pinned to `/api/mobile`, insulated from all internal version churn — so the
auth merge and domain split below never touch it. See Phase 0.
1. **The auth merge** — httpOnly session cookies go away; a bearer JWT (access) + rotating
refresh token becomes the single session model for *every* client (web and mobile).
2. **The domain split** — the monolithic route wiring (esp. `admin.routes.js`, 1552 lines) is
broken into one router file per business capability, so a developer can predict where an
endpoint lives from its URL.
## Locked decisions
| Decision | Choice | Consequence |
|---|---|---|
| Mobile decoupling | **A version-agnostic `/api/mobile` facade, landed before v2** | The Android app pins one stable namespace; internal v1→v2→vN churn never reaches it. The app leaves the v2 blast radius entirely. |
| Versioning | **New `/api/v2` mounted in parallel** with a frozen `/api/v1` | Migrate the *web* client route-by-route; delete v1 once no caller remains. No big-bang break. |
| Web session model | **Web adopts mobile's access + refresh** | One session model everywhere. Reuses `session.service` machinery that already exists — nothing new invented. |
| Live-feed (SSE) auth | **admin stream → fetch + `Authorization: Bearer`; public stream stays anonymous** | Admin token stays out of URLs/logs. Public/mobile stream keeps `EventSource`, no auth header. |
## What already exists (so we don't rebuild it)
- `auth/token.js` already extracts a token from cookie **or** `Authorization: Bearer`, and
`auth/session.service.js` already unifies both into one session.
- Mobile already has the full target model: `mintMobileTokens` / `createMobileSession` /
`refreshMobileSession`, with **hashed, rotated, revocable** refresh tokens stored server-side.
Endpoints live at `/api/v1/auth/mobile/{login,refresh,logout}`.
- **The merge is therefore mostly deletion + rename:** web joins the mobile session model, the
internal `/mobile` auth namespace collapses into unified `/auth/*`, and the cookie code path is
removed. (The *app-facing* `/api/mobile` facade is unaffected — it re-points its internals to the
unified flow; the app sees no change. See Phase 0.)
---
## Phase 0 — The mobile facade (`/api/mobile`, lands before v2)
**Goal:** make the Android app's contract *version-agnostic* so the v2 work below can proceed without
ever breaking an installed app. The app currently hardcodes ~70 `api/v1/…` endpoints (plus the SSE
path and SSO URLs) and has **no version negotiation and no force-update** — which is exactly why v1
can't otherwise be retired on the web client's schedule. Phase 0 pays that migration **once**, up
front, against a pure rename with no behavior change, and never again.
**What it is:** a thin routing facade mounted at `/api/mobile`, next to `/api/v1`, that delegates to
the **same controllers with the same middleware** as the routes it mirrors. It is *routing + optional
response-shaping*, never business logic — a Backend-for-Frontend, not a fork.
### Server
1. **Mount `/api/mobile` in `api.router.js`** next to `/v1` (today it mounts only `/v1`).
2. **Re-home the app's *entire* surface under it — not just the mobile-auth routes.** The app calls
mostly *shared* routes (`auth/me/*`, `public/*`, `player/*`, `admin/*`) plus the mobile-only auth
routes and the anonymous `public/shard/stream`. All of them move under `/api/mobile/**`. If any
endpoint the app needs is left only under `/api/v1`, the app is not decoupled and the whole point
is lost. Cross-check against the app's endpoint inventory (§ Cross-component blast radius).
3. **Delegate; never bypass auth.** Each facade route requires the *same* middleware chain as its
underlying route (`requireAuth`, `staffOnly`/`adminOnly`, validators, the public/admin SSE
allowlist split). A re-exposed admin route missing `adminOnly` is a privilege-escalation hole —
treat the facade as a security surface, not a convenience alias.
4. **Keep the mobile SSE stream anonymous.** `/api/mobile/…/shard/stream` carries no auth (the app
sends no `Authorization` header); only the public/safe kinds, same allowlist as today.
5. **Pin the wire shapes with contract tests.** `/api/mobile` is now a **committed stable contract**:
a v2 internal refactor that changes a response shape must fail a test here *before* it can ship to
installed apps. This is the facade's ongoing cost and its entire value — enforce it in CI.
### Android app (`Android-app` repo — separate PR, separate release)
6. **Re-point everything to `/api/mobile`:** the ~70 Retrofit endpoints (`data/api/*.kt`), the SSE
path in `ShardStreamClient.kt`, and the SSO start/exchange URLs in `SsoAuthManager.kt`. Drop the
`api/v1/` and `auth/mobile/` prefixes; the app now knows only `/api/mobile`.
7. **Add the app-version header + a server-side min-version floor** (the mechanism the blast-radius
section calls for). Its first job is to sunset the *pre-facade* app so `/api/v1` can eventually be
deleted; thereafter it is insurance for any genuinely breaking `/api/mobile` change (negotiated
in-band, since URL-path versioning is deliberately gone here).
8. **Ship and let the fleet adopt** before starting v2. The old app keeps working on frozen `/api/v1`
until the version floor ages it out.
### Docs / spec
9. Document `/api/mobile` as its own tagged surface in Swagger; record the facade + the version-floor
mechanism in `docs/android/PLAN.md`, and note the app-contract change in the Android repo's docs.
**Not in scope for Phase 0:** any behavior change, any auth-model change, any v2 route. The facade
maps 1:1 onto today's controllers; the auth merge happens later and reaches the app only as an
internal re-point behind the unchanged `/api/mobile` shapes.
---
## Phase 1 — The auth merge (cookie removal)
### Server
1. **Promote the mobile flow to the mainline v2 auth routes.** Under `/api/v2/auth`:
- `POST /login` → password (+ TOTP) → returns `{ accessToken, refreshToken, user }` (no `Set-Cookie`).
- `POST /refresh` → rotate: validate + revoke presented refresh, mint a new pair.
- `POST /logout` → revoke the current refresh/session server-side.
- `POST /login/totp` → second-factor step, same token shape.
The `/auth/mobile/*` namespace is **not** carried into v2 — it collapses into these.
2. **Delete the session-cookie code path in v2 controllers.** Stop calling `setAuthCookie` /
`clearAuthCookie`. `extractToken` keeps its Bearer branch; the cookie branch is dead for v2
routes (v1 keeps it until v1 is removed).
3. **Separate *session* cookies from *transaction* cookies — the latter stay.** The SSO / email-
connect redirect flow (`sso.controller.js`, `emailConfig.controller.js`) *must* keep its
short-lived httpOnly tx / PKCE-verifier / pending-TOTP cookies: the browser leaves for the IdP
and returns with no JS context to carry a bearer across the hop. Only the **final session**
handoff changes — the callback ends by issuing bearer tokens (redirect with a one-time code the
SPA exchanges, so tokens never land in the URL) instead of setting `rg_token`.
4. **Trusted-device token** already supports the `X-Trust-Token` header for native clients
(`extractTrustToken`). Web switches to the same header + client storage; `rg_trust` cookie is
dropped for v2.
5. **SSE auth is per-channel — the public stream stays anonymous.** The **admin** shard stream
(today gated by the session cookie via `isLoggedIn`) moves to `requireAuth` on the Bearer header,
since its browser client becomes fetch-based (below). The **public** shard stream
(`/public/shard/stream`) has **no auth middleware today and must keep none** — it is consumed by
logged-out browser visitors *and* by the Android `ShardStreamClient`, neither of which sends an
`Authorization` header. Adding `requireAuth` to it would black out the public live boards on web
and mobile. Keep the public/admin allowlist split — that security boundary is unchanged.
### Client
6. **`api/client.js`:** drop `credentials: 'include'`; attach `Authorization: Bearer <access>`;
on `401`, silent-refresh once via `/auth/refresh`, retry, else bounce to login.
7. **Token storage:** access token in memory (JS var/context); refresh token in `localStorage`.
Short access TTL keeps the XSS window small — the accepted tradeoff for losing httpOnly.
8. **`lib/useShardFeed.js`:** only the **admin** stream needs the rewrite — replace
`EventSource(adminShardStreamUrl, { withCredentials: true })` with a `fetch()` + `ReadableStream`
reader that sends the Bearer header, parses SSE frames, and adds reconnect/backoff +
access-token refresh-on-401. The **public** stream stays on `EventSource` (no credentials, so
nothing to change) and keeps its free auto-reconnect. Do not convert both blindly.
### Docs / spec (required, same PR)
9. Update `BACKEND_DESIGN.md` auth section (cookie → bearer-everywhere; tx-cookie exception).
10. Regenerate Swagger (`npm run swagger`) — v2 auth routes, `Authorization` security scheme,
remove `Set-Cookie` from documented responses.
---
## Phase 1b — CSP hardening (ships with the auth merge)
Once httpOnly is gone the access token lives in JS, so CSP's job becomes: **injected script can't
run, and if it somehow runs it can't phone home.** The app already ships a tuned policy
(`server/src/app.js`); v2 tightens it rather than rewriting it.
Two directives are load-bearing for the token-theft threat and must stay tight:
- `script-src 'self'` — no `'unsafe-inline'` / `'unsafe-eval'`. Primary defense; guard it.
- `connect-src 'self'` — the exfiltration channel. Do **not** widen it (e.g. to a separate API host)
unless the API genuinely becomes cross-origin; the SPA + REST + fetch-SSE are all same-origin here.
`style-src 'unsafe-inline'` stays — it permits inline styling, not script execution, and React's
pervasive `style={{…}}` attributes can't be nonce'd. It is not a meaningful hole.
Target enforced policy:
```
default-src 'self';
script-src 'self';
connect-src 'self';
img-src 'self' data: https:;
style-src 'self' 'unsafe-inline'; /* + fonts.googleapis.com only until fonts are self-hosted */
font-src 'self'; /* + fonts.gstatic.com only until fonts are self-hosted */
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
```
Changes vs. the current policy:
- **Add `form-action 'self'`** — blocks an injected `<form action="https://evil">` from POSTing the
token/credentials off-origin (an exfil path `connect-src` doesn't cover).
- **Tighten `frame-ancestors` `'self'``'none'`** — nothing legitimately frames the site.
- Keep `base-uri 'self'`, `object-src 'none'`, and `img-src … https:` (external `BRAND_*`
logo/hero and `<img>` in sanitized wiki/news bodies rely on `https:`).
Tracked follow-ups (own PRs, not blocking the merge):
- **Self-host the Cinzel font** → drop `fonts.googleapis.com` from `style-src` and
`fonts.gstatic.com` from `font-src`, removing two third-party origins from the trust surface.
- **Trusted Types** — roll out `require-trusted-types-for 'script'` + a `trusted-types` policy in
**report-only** first; neutralizes most DOM-based XSS at the sink (the exact bug class that would
steal a JS-held token). Audit `dangerouslySetInnerHTML` + the `sanitizeHtml` render path first.
- **Report-only rollout** — ship the tightened policy via `Content-Security-Policy-Report-Only` with
`report-to` for one release, watch for violations, then flip to enforce.
Verify before trusting `script-src 'self'`: Vite's build injects an inline modulepreload-polyfill
`<script>` into `dist/index.html`, which that directive blocks (harmless but throws a violation).
Confirm it's disabled or set `build.modulepreload.polyfill = false` in the Vite config. (The
`renderIndexHtml` branding injection adds only `<meta>`/`<link>` tags — no inline script, no nonce
needed.)
---
## Phase 2 — The domain split
**Rule:** one router file = one business capability; the URL names the domain; related endpoints
live together regardless of HTTP method; no generic `admin.router.js` / `api.router.js` catch-alls.
Controllers are **already** domain-split — Phase 2 is mostly re-wiring the routes, not the logic.
Target tree (`router/v2/`):
```
router/v2/
admin/
dashboard.router.js users.router.js moderation.router.js
content.router.js wiki.router.js shard.router.js
settings.router.js invites.router.js bot-activity.router.js
auth/
login.router.js sso.router.js totp.router.js session.router.js
public/
news.router.js wiki.router.js page.router.js shard.router.js
player/
profile.router.js appeals.router.js shard.router.js
internal/ (unchanged — stays on the unpublished port, never mounted publicly)
```
Steps:
11. Carve `admin.routes.js` (~100 routes) into the per-capability files above, each requiring its
already-existing controller. A thin `admin/index.js` mounts them under `/admin`.
12. Split `public.routes.js` and `player.routes.js` the same way.
13. `v2.router.js` mounts the domain sub-routers; `api.router.js` mounts `/v1` (frozen) **and** `/v2`.
14. Keep `/internal` off the public listener exactly as v1 does (separate `internalApp.js` port).
15. Update `#swagger.*` annotations for every moved route, regenerate the spec, and update
`PROJECT_TREE.md` + `BACKEND_DESIGN.md` route map.
---
## Sequencing & PR breakdown
**Phase 0 lands entirely before the v2 scaffold** — the app must be off `/api/v1`-direct and onto
`/api/mobile` (and the fleet adopting) before v2 behavior work begins.
1. **PR 0a — mobile facade (server):** mount `/api/mobile` in `api.router.js`; re-home the app's full
endpoint surface as thin delegates to existing controllers (same middleware); add contract tests
pinning the wire shapes; add v1-usage telemetry. No behavior change. See Phase 0.
2. **PR 0b — mobile migration (separate `Android-app` repo):** re-point the ~70 Retrofit endpoints,
the `ShardStreamClient` SSE path, and the SSO start/exchange URLs to `/api/mobile`; add the
app-version header + server-side min-version floor. Released and adopted on the app-store cadence
before v2 starts. See `docs/android/PLAN.md`.
3. **PR 1 — v2 scaffold:** `router/v2/` skeleton, `v2.router.js`, mount `/v2` next to `/v1`. Empty
but wired; no behavior change. Detailed in [API_V2_SKELETON.md](./API_V2_SKELETON.md).
4. **PR 2 — auth merge (server):** v2 bearer auth routes + SSO callback code-exchange + admin-SSE-on-
Bearer + docs/swagger. **Re-point `/api/mobile`'s internals to the unified flow behind unchanged
wire shapes** (contract tests must stay green) — the app sees nothing.
5. **PR 3 — auth merge (web client):** `client.js` bearer + silent-refresh; `useShardFeed.js` fetch
stream for the **admin** stream only.
6. **PR 4…N — domain split:** one PR per admin capability (dashboard, users, moderation, content,
wiki, shard, settings, …) to keep diffs reviewable; then public + player. Keep `/api/mobile`
delegating correctly as controllers move; contract tests catch any shape drift.
7. **PR final — retire v1:** only once (a) the web client is fully on v2, **and** (b) telemetry shows
no `/api/v1` traffic from the pre-facade app, i.e. the old fleet has aged past the version floor.
Then delete `router/v1` and the dead cookie code in `token.js`. See § Cross-component blast radius.
Each PR: server tests green (`cd website/server && npm test`), Swagger regenerated, matching `docs/`
edit, Conventional Commit, AI-disclosure trailer, branch from a freshly-pulled `main`.
## Cross-component blast radius
The plan above is written as if the website is the whole world. It isn't. **Three independent
clients consume the site's HTTP/SSE API, and two of them live in separate repos on separate release
cadences.** Any change to a URL shape, an auth mechanism, or a namespace is a *contract* change, not
a local refactor — the same discipline the docs demand for the shard↔sidecar wire protocol applies
here. This section is the coordination map the per-repo work must respect.
### Who calls the site API
| Consumer | Repo (release cadence) | How it's pinned to v1 | Migration cost |
|---|---|---|---|
| Browser SPA | `website/client` (same repo, lockstep) | `BASE = /api/v1` in `client/src/api/client.js` | In-plan (Phase 1 client + Phase 2) |
| **Android app** | `Android-app` (separate repo, app-store cadence, un-updatable installs in the wild) | **~70 Retrofit endpoints hardcode `api/v1/…`** across `data/api/*.kt`; SSE path hardcoded in `ShardStreamClient.kt`; SSO in `SsoAuthManager.kt` | **Migrated once to the version-agnostic `/api/mobile` facade in Phase 0; thereafter decoupled from v2.** |
| Discord bot | `website/bot` (separate deploy, but env-configured) | `SITE_PUBLIC_URL` env → `/api/v1/public` (`announce`/`wiki` commands) | Trivial — repoint the env var once `/v2/public` exists; unaffected by the auth merge (anonymous public reads) |
Only the browser migrates in true lockstep. The bot is a one-line env change. **The Android app was
the load-bearing coupling — the `/api/mobile` facade (Phase 0) is what removes it from the critical
path so v2 can proceed freely.**
### Mobile (site ↔ Android) — decoupled by the Phase 0 facade
The facade turns the mobile problem from "the app must chase every version rename forever" into "the
app pins one stable namespace, migrated once." What remains to keep in view:
1. **The facade must be *complete* or the decoupling leaks.** The app uses mostly *shared* routes
(`auth/me/*`, `public/*`, `player/*`, `admin/*`), not just the mobile-auth ones. Every endpoint in
the app's inventory (below) must exist under `/api/mobile`, or the app still calls `/api/v1`
directly and is not actually insulated. This is the single most important Phase 0 check.
2. **The one-time cutover is still fleet-gated — the facade doesn't erase that, it *contains* it.**
The *pre-facade* installed app still calls `/api/v1/*`, and with no force-update it does so until
its users update. So `/api/v1` stays alive until that old fleet ages out. The difference: this is
now a **pure-rename** migration decoupled from v2 behavior, paid once, and closed out by the
version floor (next point) rather than blocking the auth merge.
3. **The version floor is still needed — its role just changed.** Add an app-version header + a
server-side min-version gate (Phase 0 step 7). Its *first* job is to sunset the pre-facade app so
`/api/v1` can finally be deleted; *afterward* it is the in-band mechanism for any breaking
`/api/mobile` change, since URL-path versioning is deliberately absent there. Without it there is
still no safe way to force the last stragglers off v1.
4. **v1-usage telemetry** — per-route counts, tagged to distinguish the migrated browser from
lingering pre-facade app installs, so "nothing calls v1" is measured, not assumed.
5. **`/api/mobile` is now a frozen wire contract** — contract tests (Phase 0 step 5) must guard its
response shapes so a v2 internal refactor can't silently break installed apps. This replaces
"don't break the app during the auth merge" with "the auth merge can't reach the app at all."
6. **The public/mobile SSE stream must stay anonymous** (Phase 0 step 4 / Phase 1 step 5) — the app's
`ShardStreamClient` sends no auth header.
7. **Record the mobile side in `docs/android/PLAN.md`.** This plan owns the `/api/mobile` server
contract + the facade; that plan owns the app migration + version-floor rollout.
### Shard (site ↔ link) — mostly *out* of the blast radius, with one seam to guard
The auth merge is a **website↔client** change; the **website↔sidecar** contract is orthogonal and
should not move:
- The sidecar's own bearer token lives in the DB (`uoLinkConfig`, write-only in the API), the
server-side REST client (`utils/uoLinkClient.js`) and live ingest (`utils/shardIngest.js`) talk to
the sidecar independent of any browser/mobile session, and `PROTOCOL_VERSION` / `X-UOLink-Version`
are their own versioning axis. **None of that changes for v2**`link/` needs no edit and
`PROTOCOL_VERSION` does **not** bump for this work.
- **The one seam:** the *admin* SSE stream re-fans the sidecar's full feed (including sensitive kinds).
When that endpoint moves to Bearer auth under v2, the public/admin allowlist split in the fan-out
(`utils/shardBroadcast.js` / `shardIngest.js`) is the same security boundary it is today and must be
preserved verbatim. This is a website-internal concern; it does not reach into `link/`.
### SSO transaction cookies are load-bearing for *both* web and native
Phase 1 step 3's carve-out (keep the short-lived httpOnly tx / PKCE-verifier / pending-TOTP cookies)
isn't only a web concern. The Android SSO flow opens a Custom Tab to the website's
`/auth/…/sso/start`, so it rides the **same** server-side redirect transaction and the **same** tx
cookies. The mobile side already ends in a code-exchange (`/auth/mobile/sso/exchange`) — which is the
exact pattern v2's web callback is adopting. So: don't let "cookies go away" delete the tx cookies, or
you break native SSO as well as web SSO.
### Contract-sync checklist (this is a multi-repo change)
A v2 endpoint or auth change is not "done" until every consumer's contract is reconciled:
- `docs/website/BACKEND_DESIGN.md` — route map + auth/security contract, incl. the `/api/mobile`
facade as its own documented, version-agnostic surface with pinned response shapes.
- `server/swagger/swagger-output.json` — regenerated (tag `/api/mobile` separately from v1/v2).
- Facade contract tests — the frozen `/api/mobile` wire shapes; kept green across every v2 change.
- `docs/android/PLAN.md` — the `/api/mobile` migration milestone + the app-version-floor mechanism.
- The Android app's own endpoint constants + contract comments (`data/api/*.kt`,
`ShardStreamClient.kt`, `SsoAuthManager.kt`) — re-pointed to `/api/mobile` in a separate
`Android-app` PR.
- Bot: note the `SITE_PUBLIC_URL` repoint in `website/` deploy docs when `/v2/public` lands.
## Risks / watch-items
- **XSS is now token-theft.** Losing httpOnly means any XSS can read the access token. Mitigation:
short access TTL + refresh rotation + revocation, paired with the Phase 1b CSP hardening.
- **SSO/email tx cookies cannot be removed** — don't let "cookies go away" over-reach into the
redirect transaction. Only the session handoff changes.
- **SSE reconnect regressions** — `EventSource` gave auto-reconnect + `Last-Event-ID` for free; the
fetch reader must reproduce backoff and (if used) event-id resume, plus refresh a stale token
mid-stream.
- **Incomplete facade defeats the purpose** — if any endpoint the app needs is left only under
`/api/v1`, the app still calls v1 directly and Phase 0's decoupling silently leaks. Reconcile the
facade against the app's full endpoint inventory before shipping PR 0a.
- **The facade is a security surface, not an alias** — each `/api/mobile` route must carry the *same*
auth middleware as the route it mirrors. A re-exposed admin route missing `adminOnly`, or the
mobile SSE leaking sensitive kinds, is a privilege/data-exposure hole introduced by the facade.
- **Silent shape drift through the facade** — once `/api/mobile` internals re-point to v2, a v2
response-shape change can break installed apps with no compile error. Contract tests on the facade
are the guardrail; treat a failing one as a release blocker, not a test to update.
- **Double maintenance while v1 and v2 coexist** — bug fixes may need both. Keep the window short;
drive the *web* client to v2 completion. The facade means the window is no longer bounded by app
behavior — only by the pre-facade fleet aging out (see § Cross-component blast radius).
- **Public SSE must not become auth-gated** — the single most likely regression in Phase 1 is
reflexively wrapping *both* shard streams in `requireAuth`. The public stream is anonymous by
contract; doing so blacks out the public live boards for every logged-out browser and every phone.
- **v1 deletion is still fleet-gated (once)** — even with the facade, the *pre-facade* installed app
calls `/api/v1` until the version floor ages it out. The facade contains this to a one-time,
behavior-free cutover done *before* v2; it does not make v1 deletable on the web client's schedule.
- **`link/` is not in scope but is adjacent** — resist bumping `PROTOCOL_VERSION` or touching the
sidecar client for v2 work; the only shared seam is preserving the admin-stream allowlist split.

122
website/API_V2_SKELETON.md Normal file
View File

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

114
website/ARCHITECTURE.md Normal file
View File

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

View File

@@ -142,6 +142,109 @@ Seeded keys: `site_mode` (default `maintenance`), `site_mode_changed_at`,
| ip | VARCHAR(45) NULL | from `req.ip` (needs `trust proxy`) | | ip | VARCHAR(45) NULL | from `req.ip` (needs `trust proxy`) |
| created_at | DATETIME DEFAULT CURRENT_TIMESTAMP | | | created_at | DATETIME DEFAULT CURRENT_TIMESTAMP | |
### password_resets — self-service reset links
| col | type | notes |
|---|---|---|
| id | INT PK AUTO_INCREMENT | |
| token_hash | CHAR(64) UNIQUE NOT NULL | sha256 hex of the opaque token; **plaintext never stored** |
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | the account this reset targets |
| status | ENUM('pending','used') DEFAULT 'pending' | single-use (atomic `markUsed`) |
| requested_ip | VARCHAR(64) NULL | who asked (audit only) |
| expires_at | DATETIME NOT NULL | ~1h TTL, enforced in the model on top of this |
| created_at / used_at | DATETIME | |
Same "store only the hash of an opaque token" pattern as `user_invites` / `mobile_refresh_tokens`.
A DB read never yields a usable reset link. See §4 `/auth/password/*`.
### push_devices — opt-in push endpoints (M7)
| col | type | notes |
|---|---|---|
| id | INT PK AUTO_INCREMENT | |
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | owner |
| transport | ENUM('unifiedpush','fcm') DEFAULT 'unifiedpush' | UnifiedPush for the sideloaded APK; FCM reserved for a later Play flavor |
| endpoint | VARCHAR(512) NOT NULL | the distributor URL the app's ntfy topic was handed (or an FCM token). Unguessable but **not a secret** — stored in the clear (unlike refresh tokens), because pushes are content-free tickles |
| platform | VARCHAR(40) NULL | free-form label, e.g. `android` |
| created_at / last_seen_at | DATETIME | |
`UNIQUE(user_id, endpoint)` — re-registering the same endpoint is an idempotent upsert.
### notification_subscriptions — which streams a user opted into (M7)
| col | type | notes |
|---|---|---|
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | |
| stream_id | VARCHAR(64) NOT NULL | an id from the catalog (`config/notificationStreams.js`), validated on write |
| created_at | DATETIME | |
`PRIMARY KEY(user_id, stream_id)`. Subscriptions are per-user (applied to every device); a PUT
replaces the whole set. Nothing is pushed unless the user subscribed.
### mobile_auth_sessions / mobile_auth_codes — mobile SSO bridge (M9)
Two short-lived, self-pruning tables that bridge a browser SSO redirect flow to a native client. They
carry the **app ↔ website** PKCE + CSRF state (a *second* PKCE layer, distinct from the website ↔ IdP
PKCE the `sso_tx` cookie already carries) and the one-time authorization code the app exchanges for
bearer tokens. Neither holds a secret in the clear — the PKCE `code_challenge` is a hash by
construction, and the authorization code is stored as a **sha256 hash only** (same pattern as
`user_invites` / `password_resets` / `mobile_refresh_tokens`).
`mobile_auth_sessions` — one row per `/auth/mobile/sso/start`:
| col | type | notes |
|---|---|---|
| id | INT PK AUTO_INCREMENT | |
| session_id | CHAR(36) UNIQUE | opaque uuid; carried inside the signed `sso_tx` (mode `mobile`) so the callback can find this row |
| provider | VARCHAR(40) NOT NULL | provider id validated enabled at `/start` |
| code_challenge | VARCHAR(255) NOT NULL | app-supplied PKCE S256 challenge (base64url); verified at `/exchange` |
| redirect_uri | VARCHAR(255) NOT NULL | the requested app callback — **exact-match** against the allowlist (never prefix) |
| state | VARCHAR(255) NOT NULL | app-generated opaque CSRF value, echoed on the callback for the app to verify |
| status | ENUM('pending','completed','consumed') DEFAULT 'pending' | `pending``completed` when the code is minted; `consumed` after a successful exchange |
| user_id | INT NULL FK→users(id) ON DELETE CASCADE | set once SSO resolves the account |
| expires_at | DATETIME NOT NULL | short (~10 min — one redirect round-trip incl. TOTP) |
| created_at / used_at | DATETIME | `used_at` stamped at exchange |
`mobile_auth_codes` — one row per completed SSO callback (the code the app redeems):
| col | type | notes |
|---|---|---|
| id | INT PK AUTO_INCREMENT | |
| code_hash | CHAR(64) UNIQUE | sha256 hex of the opaque ≥128-bit code; the raw code never touches the DB |
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | the authenticated account |
| session_id | CHAR(36) NOT NULL | the owning `mobile_auth_sessions.session_id` (ties the code to its PKCE challenge) |
| expires_at | DATETIME NOT NULL | very short (~5 min) |
| used_at | DATETIME NULL | set on first successful exchange — **single use** (a reused code fails) |
| created_at | DATETIME | |
Both self-prune (indexed `expires_at`): a best-effort sweep runs at boot beside the existing
`revoked_sessions` prune, and each bridge write opportunistically deletes expired rows — so no cron
infra is added (same approach as `revoked_sessions`).
**`mobile_refresh_tokens` additions (M9).** Two nullable columns are added to support the device
list/revoke surface: `device_name VARCHAR(100) NULL` (a friendly label) and `last_used_at DATETIME
NULL` (bumped on each refresh). Existing rows get them via the schema's ALTER section; the token model
is otherwise unchanged.
### trusted_devices — MFA "Trust this device"
Lets a browser/app **skip the TOTP step** at login (never the password) for 30 days. Pattern-identical
to `mobile_refresh_tokens`: the opaque trust token lives client-side (the `rg_trust` httpOnly cookie on
web, `X-Trust-Token` / EncryptedSharedPreferences on native) and only its **sha256** hash is stored
(`token_hash CHAR(64) UNIQUE`) — sha256, not bcrypt, because a 256-bit random token is looked up **by
its hash** via the unique index (a per-row salt would break that). Columns mirror the mobile table
(`platform`, `device_name`, `device_hash`, `user_agent`, `created_at`, `last_used_at`, `expires_at`,
`revoked_at`). Capped at 10 rows/user **in application code — no silent pruning** (an over-cap trust is
refused so the client can prompt the user to revoke one first). Consulted only at the login/password
step, never at token refresh, and revoked wholesale on untrust / password change / password reset /
TOTP disable. See `docs/website/TRUSTED_DEVICES_MFA.md`.
### recovery_codes — single-use MFA backup codes
Generated at TOTP enrollment (10 at a time, shown to the user **once**) so a user who loses their
authenticator can complete login without an admin reset. `code_hash VARCHAR(72)` is a **bcrypt** hash
(not sha256): a recovery code is a human-typed, lower-entropy fallback credential — the closest
analogue to a password — and there is no hash-lookup constraint (verification fetches the user's ≤10
unused rows and `bcrypt.compare`s each, like password verification). `used_at` is the single-use
marker. Cleared wholesale on TOTP disable / password change / password reset.
--- ---
## 4. API contract ## 4. API contract
@@ -152,18 +255,139 @@ accepts `Authorization: Bearer` for API testing).
### /auth (auth.routes.js → auth.controller.js) ### /auth (auth.routes.js → auth.controller.js)
| Method | Path | Auth | Body | Purpose | | Method | Path | Auth | Body | Purpose |
|---|---|---|---|---| |---|---|---|---|---|
| POST | `/login` | — (rate-limited) | `{username,password}` | verify, set cookie, log `auth.login`, update `last_login_at` | | 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 | `/logout` | cookie | — | clear cookie | | 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}`. |
| GET | `/me` | cookie | — | current user (no hash) or 401 — client bootstraps auth state | | POST | `/logout` | cookie | — | clear cookie (the `rg_trust` trust cookie deliberately **survives** logout) |
| GET | `/me` | cookie / bearer | — | current user (no hash) or 401 — client bootstraps auth state |
| 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) |
| POST | `/password/reset/:token` | — (rate-limited) | `{password}` | consume the single-use link, rotate the hash, and revoke **all** sessions (web cutoff + mobile refresh tokens). Does **not** sign the user in — they log in fresh (so a 2FA account still passes TOTP). Logs `account.password.reset.complete`. |
| 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 |
| GET | `/me/account/identities` · DELETE `…/:provider` | cookie / bearer | — | list / unlink own SSO identities |
| GET | `/me/trusted-devices` | cookie / bearer | — | list own active trusted devices (never tokens) |
| POST | `/me/trusted-devices` | cookie / bearer (rate-limited) | `{deviceName?}` | trust the current device; web gets an httpOnly `rg_trust` cookie, native gets `{trustToken}`. **409 `{error:'trusted_device_limit', devices}`** at the cap |
| DELETE | `/me/trusted-devices` · `…/:id` | cookie / bearer | — | untrust all / one (ownership-scoped) |
| GET | `/me/account/recovery-codes/status` | cookie / bearer | — | remaining unused code count (never the codes) |
| POST | `/me/account/recovery-codes/generate` | cookie / bearer (rate-limited, **password step-up**) | `{currentPassword?}` | regenerate the one-time recovery codes (returned once); refused when 2FA is off |
| POST | `/me/devices` | cookie / bearer | `{endpoint, transport?, platform?}` | register a push endpoint; **rejects a disallowed endpoint 400** (SSRF guard). Idempotent per (user, endpoint) |
| GET | `/me/devices` · DELETE `…/:id` | cookie / bearer | — | list / unregister own push devices |
| GET | `/me/notifications/streams` | cookie / bearer | — | the subscribable catalog (`personal`/`requiresLinkedAccount` flags) |
| GET · PUT | `/me/notifications/subscriptions` | cookie / bearer | `{streams:[id]}` on PUT | get / replace own opted-in streams (unknown ids dropped) |
No public `register`. First admin is bootstrapped by `seed.js` from env (see §6). Further **Role-agnostic self-service (`/auth/me/*`).** The canonical "me" surface for **every** authenticated
admins are created under `/admin/users`. role. It reuses the exact `account.controller` handlers as `/player/account/*` and `/admin/account/*`
(no logic duplication) behind `requireAuth` **only** — any active account, never a specific role. This
lets a client (the Android app) manage its own account through one surface without ever touching
`/admin` (docs/android/PLAN.md §6.4). The older `/player/account/*` + `/admin/account/*` routes stay
for web back-compat.
**The `/player/*` group is self-service, not player-only.** Staff are a **superset** of players — every
player ability plus their staff tools on top — so the whole `/player/*` router (game-account linking,
character/vendor/house reads, credential changes, appeals) sits behind `requireAuth` **only**, never
`requireRole('player')`. Every handler is self-scoped to the caller by `req.user.id`, so an admin/editor/
moderator using it sees only their **own** linked accounts and characters (with the pre-existing
`isAdmin` bypass still letting a genuine admin read *any* character). Staff also reach the identical
self-scoped handlers under `/admin/shard/*` (same controller) for the web admin surface; the two are
interchangeable. This is why a staff account with linked game characters gets its "My characters" and
personal notification streams on the mobile client — the group no longer 403s a non-`player` role.
**Password reset.** Uses the same audited pattern as `user_invites`: an opaque 32-byte token
whose **sha256 hash only** is stored in `password_resets`, single-use and short-lived (~1h). It
also serves SSO-only accounts (null `password_hash`) as their "set an initial password" path. The
reset link points at the web front end (`/account/reset/:token`); the Android app hands off here
rather than shipping its own reset screen (docs/android/PLAN.md §4.2). First admin is bootstrapped
by `seed.js` from env (see §6); further staff are created under `/admin/users` or via email invites.
**Push notifications (M7, opt-in).** The app subscribes per stream (`/auth/me/notifications/*`) and
registers device endpoints (`/auth/me/devices`); nothing is pushed unless subscribed. Delivery is a
**content-free tickle**`{ stream, ref }`, no sensitive data — POSTed to each subscribed device's
self-hosted **ntfy** endpoint (`utils/pushDispatch`); the app wakes and pulls the real, ownership-
checked content over the authenticated API. Two producers fan out through the one publisher: the shard
ingest dispatcher (`utils/shardIngest`, beside the SSE broadcast) for shard-derived streams, and the
create/publish-post path for `news.post`. The stream catalog + event→stream mapping is
`config/notificationStreams.js`. Security invariants:
- **Same public/admin split as the SSE feed.** Public streams are drawn *only* from the SSE
`PUBLIC_KINDS` allowlist; a sensitive kind (audit/cheat/IP/login-attempt) can never produce a public
push.
- **Personal streams are owner-keyed.** `vendor.sale` / `house.idoc` / `account.login` are delivered
only to the *owning* user's devices, resolved via `shardLinks` (the same ownership check as
`/player/shard/*`).
- **SSRF guard.** A device `endpoint` is a client-supplied URL the server POSTs to, so registration and
every publish validate it is HTTPS, non-private/loopback, and (when configured) on the shard's ntfy
allow-set (`NTFY_BASE_URL` / `NTFY_ALLOWED_ORIGINS`).
- ntfy is treated as an **untrusted relay** — no per-user accounts, unguessable topics; an optional
`NTFY_PUBLISH_TOKEN` hardens backend→ntfy publishes but is not required. See docs/android/PLAN.md §11.
### Mobile SSO Authorization Bridge (`/auth/mobile/sso/*`, M9)
Native "Sign in with Google/Discord" for the Android app **without shipping any OAuth secret in the
app**. The website stays the identity authority: each shard owner's provider credentials live in
`auth_providers` (encrypted at rest) and are only ever used server-side. The bridge is a **new
consumer of the existing SSO + mobile-bearer machinery**, not a parallel auth path — it reuses the
`/auth/sso/:provider/*` redirect flow, the link-only + opt-in-provisioning policy, the TOTP gate, and
issues the **same** token pair as `/auth/mobile/login`.
| Method | Path | Auth | Body / Query | Purpose |
|---|---|---|---|---|
| GET | `/auth/providers` | — | — | **reused** discovery; the app renders provider buttons from this (never exposes secrets) |
| GET | `/auth/mobile/sso/start` | — (rate-limited per-IP + per-provider) | `?provider&code_challenge&state&redirect_uri` | validate provider enabled + `redirect_uri` **exact-match** allowlist; insert a `mobile_auth_sessions` row; create the existing `sso_tx` tagged `mode:'mobile'` carrying `session_id`; **302 to the IdP** (existing authorize URL) |
| GET | `/auth/sso/:provider/callback` | — (signed `sso_tx`) | `?code&state` | **existing** endpoint; a new branch when `tx.mode==='mobile'`: resolve the account (same policy as web login incl. TOTP), mint a single-use hashed authorization code into `mobile_auth_codes`, mark the session `completed`, and **302 to `redirect_uri?code=…&state=…`** (the app's original `state`) — **no cookie is set** |
| POST | `/auth/mobile/sso/exchange` | — (rate-limited per-IP) | `{code, code_verifier}` | validate the code exists / unexpired / unused (mark used) and `sha256(code_verifier)` matches the stored challenge → issue the existing mobile access + refresh pair (`createMobileSession`) → `{accessToken, refreshToken, expiresIn, user}` |
| POST | `/auth/mobile/refresh` | — | `{refreshToken}` | **reused** unchanged — rotate the pair |
| POST | `/auth/mobile/logout` | bearer | `{refreshToken?, all?}` | **reused** unchanged — revoke this (or all) refresh token(s) |
| GET | `/auth/me/sessions` · DELETE `…/:id` | cookie / bearer | — | list / revoke own **mobile sessions** (device_name, last_used_at, created_at) — the "Active Devices" surface (distinct from `/auth/me/devices`, which is push endpoints) |
**Two PKCE layers (do not conflate).**
- *Layer A (existing):* website ↔ IdP. The `code_verifier` is generated at `/start`, kept only in the
httpOnly `sso_tx` cookie, sent to the IdP token endpoint at the callback. Unchanged.
- *Layer B (new):* app ↔ website. The **app** generates `code_verifier`/`code_challenge`; the
challenge is stored in `mobile_auth_sessions` at `/start`; the verifier is presented at `/exchange`.
This is what stops an intercepted callback code from being redeemed by anyone but the real app.
**State / CSRF.** The app-generated `state` is stored at `/start`, echoed on the callback redirect,
and **verified by the app** before it calls `/exchange` — a CSRF guard independent of both PKCE
layers (a different app instance triggering `/start` cannot complete someone else's flow).
**Redirect-URI allowlist.** `/start` and the callback validate `redirect_uri` by **exact match**
against a configured allowlist (`MOBILE_AUTH_REDIRECT_URIS`, default the one fixed application-owned
callback `runicgateway://auth/callback`) — **never prefix match** (prefix matching on custom schemes
is a known open-redirect vector). Tokens are **never** placed in the callback URL — only the
short-lived authorization code.
*App Links (implemented).* When the admin toggle `mobile_app_links_enabled` is **on**, `/start` also
accepts the self-origin HTTPS callback `https://<request-host>/mobile/callback` — one *additive*
exact-match entry, derived from the request/`APP_BASE_URL` and never from client input; the
custom-scheme allowlist is never narrowed. The shard then auto-serves `GET
/.well-known/assetlinks.json` (fixed package `com.runicgateway.app` + `MOBILE_APP_CERT_SHA256`
fingerprints; 404 when the toggle is off or no fingerprint is configured), and
`settings.getPublic()` advertises `mobileAppLinks: <bool>`. These two things — one static file route
and one more allowlist entry — are the *entire* server surface App Links require. See
docs/android/APP_LINKS.md.
**TOTP through the bridge.** A 2FA account keeps full parity: the callback stages the existing
pending-TOTP cookie (now also carrying the bridge `session_id`) and bounces the Custom Tab through the
web TOTP form; on a correct code the completion mints the authorization code and deep-links back to
the app — it never mints a session cookie for a mobile flow.
**Revocation latency (documented tradeoff).** Revoking a refresh token (device revoke / logout) stops
future renewals but does **not** invalidate an already-issued access token until it expires — up to
the access-token lifetime (`MOBILE_ACCESS_TTL`, default 15 min) of continued access. This is an
accepted tradeoff given the short lifetime. If instant revocation is ever required, add an
access-token (jti) blocklist check on the `requireAuth` path — the same `revoked_sessions` mechanism
web sessions already use.
**Authorization code.** Cryptographically random, ≥128 bits, stored **hash-only**, single-use, short
expiry (~5 min); `/exchange` is rate-limited per-IP. The bridge tables self-prune (§3).
### /public (public.routes.js → public.controller.js) — all GET, no auth ### /public (public.routes.js → public.controller.js) — all GET, no auth
| Method | Path | Notes | | Method | Path | Notes |
|---|---|---| |---|---|---|
| GET | `/settings` | whitelisted public keys only (mode, maintenance_message, status_message, homepage_teaser, contact_email, site_title) | | GET | `/settings` | whitelisted public keys, derived `registration`/`gameAccountSignup` flags, the per-shard **`brand`** block (name, `accent` color, logo/hero/favicon) a client themes itself from — one image runs as any shard, asset fields may be site-relative paths (resolve against the base URL) — and a **`push`** block `{ ntfyUrl }` (M7): the client-facing ntfy relay URL the app's embedded distributor registers its device topic against, from `NTFY_PUBLIC_URL` / first `NTFY_ALLOWED_ORIGINS` (never the internal `NTFY_BASE_URL`); `null` when push isn't configured for the shard. |
| GET | `/status` | status message + current mode | | GET | `/status` | status message + current mode, **plus a `version` block** (`{ service:'runic-gateway', api, server }`) so a client first-run probe recognizes the backend and can run a version-mismatch guard |
| GET | `/version` | lightweight, **DB-free** backend identity/version (`{ service, api, server }`) — the canonical target for the version guard and a cheap liveness check |
| GET | `/posts/:category` | published only; `category` ∈ news\|five-on-friday\|newsletter\|screenshots | | GET | `/posts/:category` | published only; `category` ∈ news\|five-on-friday\|newsletter\|screenshots |
| GET | `/posts/:category/:idOrSlug` | single published post | | GET | `/posts/:category/:idOrSlug` | single published post |
| GET | `/wiki` | list of pages (slug + title) | | GET | `/wiki` | list of pages (slug + title) |
@@ -189,6 +413,9 @@ Public content GETs pass through the **siteMode** gate (§5).
| GET | `/settings` · PUT `/settings` | read all / update `{key:value,...}` | | GET | `/settings` · PUT `/settings` | read all / update `{key:value,...}` |
| GET | `/activity?limit=&offset=` | paginated activity log | | GET | `/activity?limit=&offset=` | paginated activity log |
| GET | `/users` · POST `/users` · PUT `/users/:id` · DELETE `/users/:id` | user mgmt (can't delete self / last admin; password hashed on write) | | GET | `/users` · POST `/users` · PUT `/users/:id` · DELETE `/users/:id` | user mgmt (can't delete self / last admin; password hashed on write) |
| GET | `/users/:id/trusted-devices` | list a user's active trusted devices (never tokens) |
| DELETE | `/users/:id/trusted-devices` · `…/:deviceId` | revoke all / one of a user's trusted devices (logs `admin.trusted_device.revoke[_all]`) |
| POST | `/users/:id/mfa/reset` | recover a locked-out user: disable TOTP + revoke all trusted devices + clear recovery codes (logs `admin.user.totp.reset`) |
Every admin write logs to `activity_log`. Every admin write logs to `activity_log`.
@@ -219,11 +446,24 @@ who"; `activity_log` provides the history feed.
## 6. Auth & security ## 6. Auth & security
- **JWT** signed with `JWT_SECRET`, `expiresIn=JWT_EXPIRES_IN` (default `1d`); payload `{id,username,role}`. - **JWT** signed with `JWT_SECRET`, `expiresIn=JWT_EXPIRES_IN` (default `1d`); payload `{id,username,role}`.
- **Cookie**: `httpOnly`, `sameSite=Lax`, `path=/`, and **`secure` decided per-request** (`COOKIE_SECURE=auto``secure: req.secure`). 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`).
- **Trusted-device MFA.** A second, separate httpOnly cookie (`rg_trust`, default 30d) — opaque, sha256-hashed server-side in `trusted_devices` — lets a browser/app **skip the TOTP step** (never the password) on future logins. It is a server-side, per-row-revocable record (never a JWT claim), so the stateless session JWT is unchanged and trust stays revocable. It only ever gates the **second factor**; it deliberately outlives logout, and is cleared on untrust / password change / password reset / TOTP disable. **Recovery codes** (bcrypt, single-use) are the 2FA-lockout fallback. All admin trusted-device/MFA actions and the self actions (`auth.login.trusted_device`, `account.trusted_device.*`, `account.recovery_code*`, `admin.trusted_device.*`, `admin.user.totp.reset`) are audit-logged. See `docs/website/TRUSTED_DEVICES_MFA.md`. This is the key to dual access: the cookie is `Secure` when reached through Pangolin (HTTPS, `X-Forwarded-Proto: https`) but **not** `Secure` when reached directly over the LAN IP on plain HTTP — so login works in both. `COOKIE_SECURE=true|false` can force it. Requires `trust proxy` (below). `localhost:5173` (Vite) and `localhost:3000` are same-site, so the cookie flows in dev too.
- **bcrypt** hashing (cost 10+); plaintext passwords never stored, logged, or returned. - **bcrypt** hashing (cost 10+); plaintext passwords never stored, logged, or returned.
- **Rate limiting** (`express-rate-limit`) on `/auth/login` and `/public/contact`. - **Rate limiting** (`express-rate-limit`) on `/auth/login` and `/public/contact`.
- **Validation** (`express-validator`) on all writes; centralized error handler. - **Validation** (`express-validator`) on all writes; centralized error handler.
- **helmet** with a CSP suited to the SPA (self + inline styles as needed; image sources for uploads/hero). - **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
`style={{…}}` attributes can't be nonce'd, plus the Google Fonts stylesheet); `font-src 'self'
https://fonts.gstatic.com` (Cinzel); `img-src 'self' data: https:` (same-origin uploads, plus
external https images embedded in wiki/news bodies or `BRAND_*` logo/hero/favicon); `connect-src
'self'` (REST + SSE are same-origin); `frame-ancestors 'self'`; `object-src 'none'`; `base-uri
'self'`. `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.
- **Admin not indexed**: `X-Robots-Tag: noindex, nofollow` on `/api/v1/admin` and the admin SPA routes; `robots.txt` disallows `/admin`. - **Admin not indexed**: `X-Robots-Tag: noindex, nofollow` on `/api/v1/admin` and the admin SPA routes; `robots.txt` disallows `/admin`.
- **No directory browsing** (express.static doesn't list; no `serve-index`). - **No directory browsing** (express.static doesn't list; no `serve-index`).
- **No hardcoded credentials**: first admin via `seed.js` reading `ADMIN_USERNAME`/`ADMIN_PASSWORD` from env (created only if no users exist); `.env` git-ignored, `.env.example` committed. - **No hardcoded credentials**: first admin via `seed.js` reading `ADMIN_USERNAME`/`ADMIN_PASSWORD` from env (created only if no users exist); `.env` git-ignored, `.env.example` committed.
@@ -270,7 +510,15 @@ subsystem (`[server]`, `[http]`, `[db]`, `[auth]`, `[admin]`, `[ratelimit]`, …
- `app`: builds the Dockerfile (installs client+server, builds Vite, serves via Express), - `app`: builds the Dockerfile (installs client+server, builds Vite, serves via Express),
`env_file: .env`, `DB_HOST=db`, `depends_on: db (healthy)`, volume `uploads:/app/uploads`, `env_file: .env`, `DB_HOST=db`, `depends_on: db (healthy)`, volume `uploads:/app/uploads`,
`ports: "3000:3000"`**binds 0.0.0.0** (no `127.0.0.1:` prefix) so Pangolin reaches it. `ports: "3000:3000"`**binds 0.0.0.0** (no `127.0.0.1:` prefix) so Pangolin reaches it.
- Volumes: `dbdata`, `uploads`. - `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**.
- Volumes: `dbdata`, `uploads`, `ntfydata`.
Express listens on `0.0.0.0:${PORT||3000}`. Pangolin terminates TLS and proxies to `app`. Express listens on `0.0.0.0:${PORT||3000}`. Pangolin terminates TLS and proxies to `app`.
@@ -292,6 +540,19 @@ ADMIN_USERNAME=
ADMIN_PASSWORD= ADMIN_PASSWORD=
# Email: configured in Admin → Settings → Email (Gmail OAuth2), not via env # Email: configured in Admin → Settings → Email (Gmail OAuth2), not via env
CLIENT_ORIGIN=http://localhost:5173 CLIENT_ORIGIN=http://localhost:5173
# Push (M7): the ntfy relay URL — also the backend's SSRF allow-set for device
# endpoints. NTFY_ALLOWED_ORIGINS / NTFY_PUBLISH_TOKEN are optional.
NTFY_BASE_URL=https://ntfy.example.com
# The client-facing ntfy URL surfaced to the app via /public/settings.push.ntfyUrl
# (the app registers its topic endpoint here). Defaults to the first
# NTFY_ALLOWED_ORIGINS entry; set explicitly when the public URL differs from the
# internal NTFY_BASE_URL. Without it (and without NTFY_ALLOWED_ORIGINS) the app
# shows push as unavailable for the shard.
NTFY_PUBLIC_URL=https://ntfy.example.com
NTFY_ALLOWED_ORIGINS=https://ntfy.example.com
# Host port the ntfy container publishes :80 on (default 2586); the reverse proxy
# forwards the notification subdomain to host:NTFY_HOST_PORT. Change on a conflict.
NTFY_HOST_PORT=2586
``` ```
`.gitignore`: `node_modules/`, `.env`, `_reference/`, `client/dist/`, `uploads/`. `.gitignore`: `node_modules/`, `.env`, `_reference/`, `client/dist/`, `uploads/`.

View File

@@ -0,0 +1,128 @@
# Runic Gateway Website — Moderation Appeals (Phase 6c/6d)
> Website feature branch: **`feature/moderation-appeals`**. Builds on the moderation
> dashboard (Phase 6a/6b) and the Discord bot's `mod_actions` log. Companion to
> [website-README.md](website-README.md) (overview) and
> [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (base API contract).
## 1. Overview
A player whose linked Discord identity was **banned** or **muted** — an action
recorded in the bot's `mod_actions` log — can open an **appeal** from the player
portal and track its status. Staff (**admin** or **moderator** role) work the
appeal from an **appeals queue** in the admin moderation section: claim it, then
resolve it **approved** or **denied** with a written staff response.
When staff **approve** a ban/mute appeal, the website makes a best-effort call to
the Discord bot's internal API to actually lift the ban / clear the timeout in
Discord, and the bot posts a mod-log embed ("Appeal approved"). This is
**best-effort**: if the bot is unreachable the appeal still resolves as approved,
the reversal is recorded as failed, and staff can reverse the sanction manually in
Discord.
Only **ban** and **mute** actions are appealable — the sanctions that have an
ongoing effect. Warnings/kicks and similar one-shot actions are not.
## 2. Ownership & eligibility
- **`appeals` is a server-owned table** — only the website reads/writes it. It
references the bot-owned `mod_actions` log by a plain id column
(`mod_action_id`); there is **no hard cross-owner foreign key** between the two
databases, so the reference is validated in application code (same pattern as
the rest of the uo-link / bot integration, where the two services never share a
live FK).
- **Eligibility** — the appellant must be a **logged-in player** whose linked
Discord identity (`user_identities`, `provider = 'discord'`) matches the
`mod_actions` row's target. A player cannot open an appeal for someone else's
action, and an unlinked player has nothing eligible to appeal.
- **One active appeal per action** — only one `pending` / `under_review` appeal is
allowed for a given `mod_action_id` at a time; a second attempt while one is
already open is rejected.
## 3. Appeal lifecycle
```
pending ──▶ under_review ──▶ approved
└─▶ denied
pending ──▶ withdrawn
under_review ──▶ withdrawn
```
- **`pending`** — submitted by the player, not yet claimed.
- **`under_review`** — claimed by a staffer (the claiming admin/moderator is
stamped on the row).
- **`approved`** / **`denied`** — resolved by staff with an optional
`staff_response`. Approving a ban/mute appeal triggers the Phase 6d reversal
(§5).
- **`withdrawn`** — the player pulled the appeal back before it was resolved.
`reversal_status` (only meaningful on an approved ban/mute appeal) is one of
`none` (not attempted / not applicable), `done`, or `failed`. No new
`mod_actions` row is written for a reversal — it modifies the *original* action's
standing rather than logging a new one.
## 4. API — player (role: `player`)
Base `/api/v1/player/appeals`.
| Method | Path | Purpose |
|---|---|---|
| GET | `/player/appeals` | The caller's own appeals. |
| GET | `/player/appeals/eligible` | The caller's ban/mute actions with no active appeal (empty if they have no linked Discord identity). |
| POST | `/player/appeals` | Open an appeal — `{ mod_action_id, submitted_text }`. `403` if the action isn't the caller's, `400` if the action isn't a ban/mute, `409` if one is already open for it. |
| POST | `/player/appeals/:id/withdraw` | Withdraw an appeal that hasn't been resolved yet. |
## 5. API — staff (role: `admin` or `moderator`)
Base `/api/v1/admin/moderation/appeals`, alongside the existing moderation
section.
| Method | Path | Purpose |
|---|---|---|
| GET | `/admin/moderation/appeals?status=&limit=&offset=` | The queue. Defaults to `pending` + `under_review`; pass `status=all` or a specific status to filter. |
| GET | `/admin/moderation/appeals/:id` | One appeal. |
| POST | `/admin/moderation/appeals/:id/claim` | `pending``under_review`, stamping the claiming staffer. |
| POST | `/admin/moderation/appeals/:id/resolve` | `{ status: 'approved' \| 'denied', staff_response? }`. On an approved ban/mute, triggers the Discord reversal (§6). |
| GET | `/admin/moderation/user/:discordId/appeals` | A user's appeals — shown as a tab on the per-user moderation history page. |
`resolve` returns a `reversal` object describing what happened:
```jsonc
{
"reversal": {
"attempted": true,
"ok": true,
"reversal_status": "done", // "none" | "done" | "failed"
"bot_status": 200,
"error": null
}
}
```
## 6. Auto-reversal (Phase 6d)
On `resolve` with `status: 'approved'` against a ban/mute appeal, the website
calls the Discord bot's internal API:
```
POST /internal/mod-reverse
```
— gated by the same shared-secret scheme as the existing `/internal/announce`
call. The bot lifts the ban / clears the timeout for the target and posts an
"Appeal approved" embed to its mod log.
The call is **best-effort**: the appeal resolution itself always completes
(the appeal is marked `approved` and the staff response is saved) regardless of
whether the bot answers. If the bot is down or the call otherwise fails,
`reversal_status` is recorded as `failed` and staff are expected to reverse the
sanction by hand in Discord; the `reversal` object in the `resolve` response
surfaces `ok: false` and an `error` so the UI can flag it. Denied appeals never
attempt a reversal.
---
See [website-README.md](website-README.md) for the moderation dashboard's place
in the wider site, and [BACKEND_DESIGN.md](BACKEND_DESIGN.md) for the base API
conventions (auth, error shapes, response codes) these endpoints follow.

534
website/PROJECT_TREE.md Normal file
View File

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

View File

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

154
website/test-plan.md Normal file
View File

@@ -0,0 +1,154 @@
# 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

@@ -10,6 +10,7 @@ A full-stack app in one repo:
- **Frontend** — React + Vite single-page app (public site, wiki, and the admin panel), dark "gothic" theme (Cinzel + Georgia). - **Frontend** — React + Vite single-page app (public site, wiki, and the admin panel), dark "gothic" theme (Cinzel + Georgia).
- **Deploy** — Docker Compose (app + MariaDB) behind a Pangolin reverse proxy. Express serves the built SPA in production. - **Deploy** — Docker Compose (app + MariaDB) behind a Pangolin reverse proxy. Express serves the built SPA in production.
- **Shard link** — a live bridge to the in-game ServUO shard through the **uo-link** sidecar ([RunicGateway/link](https://gitea.whitlocktech.com/RunicGateway/link)): the site ingests a live event feed and makes server-side REST calls to show shard status, economy, staff presence, IDOCs, live activity, and per-character sheets. See [Shard integration (uo-link)](#shard-integration-uo-link). - **Shard link** — a live bridge to the in-game ServUO shard through the **uo-link** sidecar ([RunicGateway/link](https://gitea.whitlocktech.com/RunicGateway/link)): the site ingests a live event feed and makes server-side REST calls to show shard status, economy, staff presence, IDOCs, live activity, and per-character sheets. See [Shard integration (uo-link)](#shard-integration-uo-link).
- **Moderation appeals** — a player whose linked Discord identity was banned or muted (per the bot's `mod_actions` log) can open an appeal from the player portal; staff claim and resolve appeals from an admin queue, and approving a ban/mute appeal best-effort reverses it in Discord automatically. See [MODERATION_APPEALS.md](MODERATION_APPEALS.md).
The design reference is [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (API contract, schema, security). The design reference is [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (API contract, schema, security).
@@ -17,6 +18,7 @@ The design reference is [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (API contract, sc
## Contents ## Contents
- [Architecture](#architecture)
- [Tech stack](#tech-stack) - [Tech stack](#tech-stack)
- [Project structure](#project-structure) - [Project structure](#project-structure)
- [Prerequisites](#prerequisites) - [Prerequisites](#prerequisites)
@@ -36,6 +38,103 @@ The design reference is [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (API contract, sc
--- ---
## Architecture
How the pieces fit together — the React SPA and native app talk to one Express backend
(`router → controller → model → db`), which persists to MariaDB and bridges to the live
game world only through the **uo-link** sidecar. The shard itself is never internet-facing.
See [ARCHITECTURE.md](ARCHITECTURE.md) for the fuller write-up.
```mermaid
flowchart TB
%% ---------- Clients ----------
subgraph clients["Clients"]
browser["Browser<br/>React + Vite SPA<br/>(public · wiki · admin)"]
mobile["Native mobile app<br/>(bearer tokens)"]
end
idp["SSO providers<br/>Google · Discord · custom OIDC"]
discord["Discord"]
%% ---------- Website (one repo) ----------
subgraph website["website/ &nbsp;— Node app (one repo)"]
direction TB
subgraph backend["server/ — Express backend"]
direction TB
mw["Middleware<br/>helmet · siteMode · noindex<br/>rateLimit · loginProtection · botScore · validate"]
router["Router /api/v1<br/>auth (web · mobile · sso) · public · admin"]
ctrl["Controllers"]
auth["Session layer (auth/)<br/>sessionService · JWT/cookie · bearer · SSO+PKCE"]
model["Models (.model + .db)<br/>raw parameterized SQL — no ORM"]
sse["SSE fan-out<br/>public stream (allowlist) · admin stream (sensitive)"]
subgraph shardutil["Shard integration (utils/)"]
ingest["shardIngest.js<br/>WS ingest dispatcher"]
restcli["uoLinkClient.js<br/>REST client (never throws)"]
end
secret["secretBox.js<br/>AES-256-GCM secrets at rest"]
end
bot["bot/<br/>Discord bot"]
end
db[("MariaDB<br/>users · posts · wiki · settings · activity<br/>mobileSessions · authProviders · userIdentities<br/>uoLinkConfig · shard_online/economy/houses/events")]
%% ---------- Shard side ----------
subgraph shardside["Game shard (never internet-facing)"]
direction TB
sidecar["uo-link sidecar<br/>(Rust) — the only bridge exposed"]
servuo["ServUO shard<br/>(C# plugin)"]
end
%% ---------- Edges ----------
browser <-->|"same-origin JSON + SSE (cookie)"| mw
mobile -->|"REST (bearer access/refresh)"| mw
browser -.->|"OAuth redirect + PKCE"| idp
auth -.->|"token exchange"| idp
mw --> router --> ctrl
ctrl --> auth
ctrl --> model
ctrl --> restcli
ctrl --> sse
auth --> model
model <--> db
auth -. reads/writes secrets .-> secret
restcli -. reads config/token .-> secret
ingest --> model
ingest --> sse
sse -->|"live events"| browser
bot -->|"messages"| discord
bot <--> db
restcli -->|"REST: /char /roster /economy /history · /link/confirm · /towncrier"| sidecar
sidecar -->|"WebSocket live event feed (bearer + X-UOLink-Version)"| ingest
servuo -->|"loopback TCP 127.0.0.1:7788<br/>newline-delimited JSON (shard dials out)"| sidecar
%% ---------- Styling ----------
classDef ext fill:#2d2233,stroke:#7a5c94,color:#e8dff0;
classDef store fill:#1f2d2a,stroke:#4c8c7d,color:#dff0ea;
classDef bridge fill:#2d2620,stroke:#94764c,color:#f0e6d8;
class idp,discord ext;
class db store;
class sidecar,servuo bridge;
```
- **One backend, layered.** Every request flows `middleware → router → controller → model → db`.
Web browsers authenticate with an httpOnly JWT cookie; the native app uses short-lived bearer
access tokens plus rotated refresh tokens; SSO (Google/Discord/OIDC) is link-only and PKCE-guarded.
All three surfaces produce the *same* session via the session layer.
- **The shard is never reachable.** The ServUO shard *dials out* over loopback TCP to the uo-link
sidecar; only the sidecar is exposed, and only the backend talks to it. The REST client
(`uoLinkClient.js`) never throws, so the site degrades gracefully when the shard is down.
- **Sensitive events stay private.** Ingested game events fan out to browsers over two SSE channels —
a public allowlist stream and an admin-only stream that adds staff audit / cheat / login events.
---
## Tech stack ## Tech stack
| Layer | Tech | | Layer | Tech |
@@ -331,7 +430,7 @@ character**; players and editor/moderator staff are limited to their own linked
| Surface | Endpoints | Who | Data | | Surface | Endpoints | Who | Data |
|---|---|---|---| |---|---|---|---|
| **Public** | `/api/v1/public/shard/*` (`status`, `feed`, `economy`, `online`, `idoc`, `stream`) | anyone | Shard up/down, gold-supply series, IDOC houses, a curated live feed, and **"Staff online"** — only players whose account is linked to a **staff** user (admin/editor/moderator), shown with name + map location. Linked *players* are never listed publicly; no vitals or account are exposed. | | **Public** | `/api/v1/public/shard/*` (`status`, `feed`, `economy`, `online`, `idoc`, `stream`) | anyone | Shard up/down, gold-supply series, IDOC houses, a curated live feed, and **"Staff online"** — only players whose account is linked to a **staff** user (admin/editor/moderator), shown by name. Their in-game **map location is only included for admin/moderator viewers** — for players and the public it is stripped from the payload entirely (server-enforced, not just hidden in the UI). Linked *players* are never listed publicly; no vitals or account are exposed. |
| **Player** | `/api/v1/player/shard/*` (`link`, `accounts`, `roster/:account`, `vendors/:account`, `char/:serial`, `sales`) | logged-in player | Their own linked accounts: character rosters, character sheets, player-vendor snapshots, and recent vendor sales. | | **Player** | `/api/v1/player/shard/*` (`link`, `accounts`, `roster/:account`, `vendors/:account`, `char/:serial`, `sales`) | logged-in player | Their own linked accounts: character rosters, character sheets, player-vendor snapshots, and recent vendor sales. |
| **Admin** | `/api/v1/admin/shard/*` (self-linking, same as player) · `/api/v1/admin/uo-link/*` (`config`, `towncrier`, `stream`) | staff / admin | Staff link their own accounts like players; **admins** additionally read *any* character's data, edit the sidecar connection config, publish/remove **town-crier** messages, and subscribe to the full event stream (incl. audit/cheat). | | **Admin** | `/api/v1/admin/shard/*` (self-linking, same as player) · `/api/v1/admin/uo-link/*` (`config`, `towncrier`, `stream`) | staff / admin | Staff link their own accounts like players; **admins** additionally read *any* character's data, edit the sidecar connection config, publish/remove **town-crier** messages, and subscribe to the full event stream (incl. audit/cheat). |