53 Commits

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 23:21:04 -05:00
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
7 changed files with 1649 additions and 8 deletions

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.

940
android/PLAN.md Normal file
View File

@@ -0,0 +1,940 @@
# Android App — Plan
Status: **M0M7 landed; M7 (push notifications) both parts done — Part 1 backend (website#78) and Part 2 app (Android-app#15) plus a small `push.ntfyUrl` settings addition (website#79). Remaining: set the shard's `NTFY_*` deploy config so push lights up, and cut the v1 tag. M9 (native SSO login) is now underway backend-first — the Mobile SSO Authorization Bridge is being built in `website/` + `docs/` ahead of the app-side client (§4.2, §9 M9); custom-scheme callback only for now, App Links deferred (see [`APP_LINKS.md`](./APP_LINKS.md)).** This document is the
design contract for the `RunicGateway/Android-app` repo. It was written before implementation so the
API changes it depends on could be landed in `website/` and `docs/` first. The authoritative API
reference is the committed OpenAPI spec at `website/server/swagger/swagger-output.json` (regenerated
via `npm run swagger`).
**Build progress (§9):****M0 — repo scaffold** (2026-07-19, `RunicGateway/Android-app#2`):
Gradle 8.7 wrapper + AGP 8.6.1 / Kotlin 2.0.20, JDK 17, minSdk 29 / compile-target 35,
`applicationId com.runicgateway.app`; a version catalog pinning the full §2 stack; a Compose + Hilt
single-activity skeleton (externalized strings, adaptive icon); and CI (`pr-checks.yml`
`./gradlew lint test assembleDebug`).
**M1 — connect & browse** (2026-07-19, `RunicGateway/Android-app#6`, functional Kotlin pass): the
first-run base-URL connect flow (probe `GET /public/status`, verify the backend's version identity,
persist to DataStore; HTTPS-only in release, HTTP allowed in debug; Settings → Server hard reset);
a runtime-selected base URL via a sentinel-host Retrofit + `HostSelectionInterceptor` (the host is
**not** compiled in) plus a `UserAgentInterceptor` past the scanner guard (§8); the layered
`screen → ViewModel → repository → PublicApi → DTO` stack returning a typed `ApiResult`
(`Ok`/`HttpError`/`NetworkError`) for graceful degradation (§7); brand-seeded Material 3 theming from
`/public/settings`; and functional Compose screens for Home/Status, News (+ post detail), Wiki
(+ detail), CMS pages (block renderer: `heading/rich_text/image/quote/cta/divider/two_column`), and
the contact form, under one declarative navigation drawer (§5). JVM unit tests cover URL
normalization, host rewriting, `ApiResult`/`UiState` mapping, and brand-color parsing.
> **API-client deviation from §2 (recorded):** DTOs + the Retrofit interface are **hand-written and
> spec-aligned**, not `openapi-generator` output. The committed `swagger-output.json` is produced by
> **swagger-autogen**, whose component schemas are meta-descriptive (nested `{type, example}`
> wrappers) rather than codegen-clean OpenAPI models, so a generator would emit unusable DTOs. The
> hand-authored client is the "checked-in generated module" §2 already allows; shapes were matched
> against the website controllers/models and every DTO ignores unknown keys (additive fields are
> safe). True codegen would first require authoring the spec's component schemas as real OpenAPI
> models.
**M2 — public shard** (2026-07-19, `RunicGateway/Android-app#7`, functional Kotlin pass): the public
shard widgets (§6.2) over `/public/shard/*` — a shard hub (connection status, online count, latest
economy, presence, staff online) plus live boards for champion spawns, guilds, governors (with
on-demand term history) and falling houses (IDOC) — and the live **SSE** feed. `ShardStreamClient`
consumes `/public/shard/stream` over OkHttp SSE and, unlike the browser `EventSource`, drives its own
reconnect/backoff (reset on open, no read timeout for the idle keepalive), so a dropped feed degrades
to "offline" rather than crashing (§7). Boards seed from a snapshot then merge `*.update` / `*.remove`
SSE deltas in place via a reusable `LiveBoard`, mirroring the website's merge semantics; DTOs are
hand-authored + spec-aligned (as recorded for M1) and the live frames decode into the same board
DTOs. Wired into the shared drawer (§5), all strings externalized (§2). JVM unit tests (28) cover DTO
/ live-frame decode, the board merge, event-text formatting (parity with `lib/shardEvents.js`), and
SSE frame parsing. No backend/API change — the app is a pure consumer of the existing public shard
surface.
**M3 — auth** (2026-07-19, `RunicGateway/Android-app#8`, functional Kotlin pass): native
**username/password (+ single-request TOTP) login** over the existing `POST /auth/mobile/login` — a
`401 { totpRequired }` reveals the code field and a wrong code re-lands as a code error; `429` surfaces
a friendly backoff message (§4.1). The token pair lives in **EncryptedSharedPreferences** (a
`TokenStore` behind `SessionManager`, the single source of truth for the in-memory bearer + the
observable `Session`); the base URL stays in plain DataStore (§4.3). An OkHttp `AuthInterceptor`
attaches the bearer and a `TokenAuthenticator` does a **one-shot, mutex-serialized refresh** on a
bearer `401` and replays the request — refresh runs on its own **bare** client (no interceptor/
authenticator) so it can never recurse, rotated single-use tokens are stored atomically, and a dead
refresh (`401`) signs out while a transient network error keeps the session. Logout
(`POST /auth/mobile/logout`, this session or all devices) tears down locally even if the call fails.
`GET /auth/me` **re-validates the role on every resume** (`LifecycleResumeEffect`); a surviving `401`
signs out, so a server-side demotion drops menu access promptly (role stays advisory — the backend is
authority, §4.3). The **access-level menu** is one declarative list (`visibleEntries` filters by
session — public / signed-in / player) with a Sign in / Sign out toggle and a **My Account** screen
(identity + role + sign-out / sign-out-everywhere). Registration, forgot-password, and SSO are
**Custom-Tab hand-offs** (androidx.browser) to the website's own pages (`/account/register`,
`/account/forgot`, `/account/login`) — no native screens (§4.2). The Settings → Server switch now also
clears the stored session (§3). JVM unit tests (18) cover auth-DTO decode (incl. `totpRequired` vs a
plain credential `401`), the `SessionManager` lifecycle over a fake store, and the menu access filter
+ role mapping. **No backend/API change** — the app is a pure consumer of the existing mobile bearer +
`/auth/me` surface.
> **Biometric app-lock — descoped from v1 (decided at M6).** §4.3/§9 flagged an *optional* biometric
> app-lock, deferred from M3 to M6. At M6 it was **descoped from v1 entirely**: tokens are already
> encrypted at rest (Tink/AES-256-GCM), so an app-lock is a pure UX convenience, not a security
> requirement, and it changes no data flow. It is **not** in the first release; revisit only if it
> becomes a requested feature.
**M4 — player self-service & game data** (2026-07-19, `RunicGateway/Android-app#9`, functional Kotlin
pass): the signed-in player surface, all as a pure consumer of the existing bearer-gated API.
**Account self-service** over the role-agnostic `/auth/me/account*` (§6.4) — change username (409
"taken" surfaced; a success re-validates the session so the shell reflects the new name at once),
change/set password (the SSO-account "no current password" path from `has_password`), TOTP
**setup → scan → enable** (the `data:` QR is base64-decoded to a bitmap in-app) / disable-by-code, and
list/unlink SSO identities — each mutation folding its `ApiResult` into a section-scoped, localized
banner (§7). **Game-account linking** (§6.3) — the in-game `[link` one-time code (`POST
/player/shard/link`) plus the hybrid signup (`POST /player/shard/account`, shown only when the public
`gameAccountSignup` flag is set), and the linked-accounts list. **Own game data**, text-only (§6.3):
per-account character roster → a character sheet (attributes, vitals, resistances, best-first skills,
equipment with AOS mods, and guild/governor standing chips — bare cliloc-number titles/item names are
skipped, as the app ships no cliloc table, matching the website's `CharacterSheet.jsx`); player
vendors (shops + listings) with recent sales; and the player's own houses (decay/IDOC). Each
per-account read carries its **own** load state, so a down shard degrades that one account to
offline/retry (`503`) — or not-found (`403`) — without blocking the rest. The menu gains three
**PLAYER-access** groups (My Characters / Vendors / Houses) revealed only when the session role is
`player`; a `PlayerGate` sends a signed-out or server-side-demoted user home. DTOs are hand-authored +
spec-aligned (as recorded for M1); 17 new JVM unit tests cover the account + player-shard DTO decode
(hex serials, permissive objects, equipment mods) and the character-sheet title/skill display helpers.
**No backend/API change** — the `/auth/me/*` and `/player/shard/*` surfaces the app consumes were the
§8 prerequisites, already landed.
**M5 — design pass** (2026-07-20, `RunicGateway/Android-app#10`): the shard-website theme applied
across every screen, restyling the working M1M4 UI with **no architecture, data-flow, endpoint, or DTO
change** (§2.1). The design was produced in Claude Design (`Runic Gateway Screens.dc.html`) and
implemented in Compose. Because the functional screens already draw their color/type/shape from
`MaterialTheme` tokens (§2), the restyle lives mostly in the **theme layer** and propagates: a deep
blue-black surface stack (page `#0b0f14` / screen `#0e1318` / elevated `#11161d`), a slate-blue accent
(`#7f99bd`) with a light CTA fill (`#cdd9e8`), parchment serif body copy, and the engraved **Cinzel**
serif display face (bundled weight-axis variable font, SIL OFL) for headings and the top bar. The app is
now **dark-only** — the shard-website look is a single dark theme, so the light scheme is dropped and the
system light/dark setting is ignored; **per-shard brand-accent seeding is retained** (a site's published
accent still tints the primary/secondary roles, §3). A small set of reusable components — semantic status
pills, section labels, a gradient "feature" card, and slim stat meters — carries the motifs the design
repeats (home status, shard-online banner, champ/character/house status, character vitals & skills). The
launch theme and system bars are darkened so the first frame matches (no white flash). Verified by
`:app:assembleDebug` + `:app:testDebugUnitTest` (green); an on-device visual pass against the mockup is
the one open QA item noted on the PR.
**M6 — polish & release mechanics** (2026-07-20, `RunicGateway/Android-app#11`): the release
plumbing to ship v1 as a signed, sideloadable APK, with **no architecture, data-flow, or endpoint
change**. **Default brand app icons** — a gateway-medallion adaptive launcher icon (all densities +
round + Play Store icon) over the deep-indigo brand background (the Image Asset wizard's default
green grid was replaced, and the legacy square/round bitmaps + 512 Play icon recomposited to match);
plus an "RG" notification icon staged for M7. A **version-mismatch guard** (§3): the first-run connect
probe refuses a backend whose API version this build can't speak (a future `v2`) with a clear
"app out of date" message rather than mis-rendering (lenient on an older backend that omits `api`).
**Release build hardening** (§7, §12) — R8 full-mode minify + resource shrink (~31 MB debug → ~4 MB
signed release) with keep-rules for the kotlinx.serialization serializers, the wire DTOs, and the
Retrofit interfaces; a release `signingConfig` that reads keystore material from a **gitignored**
`keystore.properties` or env vars (absent → unsigned; the keystore is never committed); and
`versionName`/`versionCode` overridable via `-P` so a release tag + CI run number drive them (§10).
**CI `release.yml`** — on a `v*` tag, builds a **signed** APK (keystore decoded from a base64 Gitea
secret) and attaches it + `SHA256SUMS` to a Gitea release; `workflow_dispatch` is a signing dry run.
HTTPS-only in release (M1), no token logging (logging is debug-gated, M3), and the Settings → Server
hard reset (M3) were already in place. Biometric app-lock is **descoped from v1** (see the note below).
**The functional build (M0M4), design pass (M5), and release mechanics (M6) are complete. Cutting
the first `v*` release tag (once the signing secrets are set + the on-device QA pass is done) and M7
push notifications are what remain.**
### M7 plan — push notifications (in progress)
M7 spans three repos, so it ships in **two parts**; the backend contract lands first because the app
is a pure consumer of it (§8/§11).
**Part 1 — `website/` backend + `docs/` — ✅ LANDED** (2026-07-20, `RunicGateway/website#78` merged
+ docs#20). Additive, v1-only (new tables/routes/compose
service; no existing response shape changes). Decision: **no ntfy publish token** — publishes go over
the internal compose network to unguessable per-device topics carrying **content-free tickles**
(`{ stream, ref }`); the publisher honors an optional `NTFY_PUBLISH_TOKEN` if ever set but requires
none (keeps §11's zero-interaction promise).
- **Two event sources, one publisher.** The fan-out is a small transport-agnostic
`utils/pushDispatch.js` that both producers call: `utils/shardIngest.js` (`ingest()`, beside the
existing `broadcast(event)`) for shard-derived streams, and the admin create-post path for the
`news.post` stream (§11 lists news posts as a public stream, but they originate in the website, not
the shard feed).
- **Stream catalog** (`config/notificationStreams.js`): public/opt-in — `news.post`,
`server.status`, `idoc.warning`, `champ.start`, `governor.election`; personal/owner-keyed
(require a linked game account) — `vendor.sale`, `house.idoc`, `account.login`. `mapShardEvent()`
maps event kinds → streams, drawing public streams **only** from the SSE `PUBLIC_KINDS` allowlist;
sensitive kinds are never fanned out publicly. Personal events are delivered only to the owning
user's devices, resolved via `shardLinks.getByAccount` (same ownership source as `/player/shard/*`).
- **Tables:** `push_devices` (per-device endpoint) and `notification_subscriptions` (per-user opted-in
streams), FK → `users` ON DELETE CASCADE, mirroring `mobile_refresh_tokens`.
- **Routes** under the role-agnostic self surface (never `/admin`): `POST|GET /auth/me/devices`,
`DELETE /auth/me/devices/:id`, `GET /auth/me/notifications/streams` (catalog),
`GET|PUT /auth/me/notifications/subscriptions`. All bearer/cookie auth; Swagger regenerated.
- **SSRF guard (important):** a device `endpoint` is a client-supplied URL the backend POSTs to, so
registration and every publish validate it is HTTPS and its origin is in the shard's ntfy
allow-set (`NTFY_BASE_URL` / `NTFY_ALLOWED_ORIGINS`), rejecting loopback/private hosts.
- **ntfy** added to `website/docker-compose.yml` as a pinned upstream image with a committed
declarative `./ntfy/server.yml` and named volume, **no published host port** (reached via the
reverse proxy; internal-only for the publisher), anonymous read-write to unguessable topics (no
per-user accounts — safe because tickles are content-free).
**Part 2 — the Android app — ✅ LANDED** (2026-07-20, `RunicGateway/Android-app#15` + a small
`RunicGateway/website#79` settings addition + this docs PR). Built exactly to the plan below, with
two recorded implementation decisions:
- **Direct-ntfy embedded distributor, no UnifiedPush library (deviation from §2's "UnifiedPush
connector" wording — the plan's stated likely path, work item 1).** The app talks straight to ntfy
over its own topic rather than pulling in `org.unifiedpush.android:connector` + an external
distributor: a foreground `PushService` holds an OkHttp-SSE connection to `<ntfy>/<topic>/sse`
(reusing the M2 `ShardStreamClient` reconnect pattern) on a **bare** client, `PushManager`
orchestrates topic mint / device register / start-stop keyed to the session, and `PushNotifier`
posts a per-stream notification whose tap deep-links via `MainActivity` intent extras. No new Gradle
dependency; a `PushResult`/transport seam keeps the future FCM Play flavor cheap. Reasons: the
UnifiedPush distributor model assumes a *separate* app (exactly what the user vetoed), we already own
the SSE machinery, and this keeps the APK Google-free and dependency-light. New code lives in
`core/push/` + `ui/notifications/` + a `NotificationsApi`/`NotificationsRepository`; no existing
screen's data flow changed.
- **One small additive backend field was required after all (`push.ntfyUrl`).** The embedded
distributor must know the shard's client-facing ntfy URL to build its topic endpoint, and Part 1
never surfaced it (the `NTFY_*` vars are server-only). So `/public/settings` now carries
`push: { ntfyUrl }` (from `NTFY_PUBLIC_URL` / first `NTFY_ALLOWED_ORIGINS`; never the internal
`NTFY_BASE_URL`), null when unconfigured → the app shows push as unavailable for that shard. This is
the "no backend work in Part 2" caveat corrected: it is additive, non-sensitive, and forward-compatible
(an older backend omitting it just decodes to null). **Deploy dependency stands:** push only delivers
once the shard sets `NTFY_PUBLIC_URL`/`NTFY_ALLOWED_ORIGINS` (§13).
Verified green: `:app:testDebugUnitTest` (18 new JVM tests — notifications DTO decode, ntfy tickle
parse incl. malformed, topic/URL building, stream→route map + personal gating) + `:app:lintDebug` +
`:app:assembleDebug`; backend 250 tests (+3 for `push.ntfyUrl`) and `npm run swagger` clean. The
foreground-service tradeoff (§11) and the POST_NOTIFICATIONS runtime permission are implemented as
planned; an on-device delivery pass against a live ntfy is the one open QA item.
**Part 2 (original plan) — the Android app.** UnifiedPush receiver + device registration
against the merged Part-1 contract, a Notifications settings screen, and notification-tap deep-links.
The app is architected for push from M0 (§11), so this is **additive** — a new feature slice
(`core/push` + `ui/notifications` + a `DevicesApi`/`NotificationsApi` pair) that touches no existing
screen's data flow. Everything the app calls already exists and is merged; there is **no backend
work** in Part 2.
The Part-1 contract the app codes against (verified against the merged `website` source):
- `POST /auth/me/devices` `{ transport?: 'unifiedpush'|'fcm', endpoint, platform? }``201 PushDevice`
`{ id, transport, endpoint, platform, createdAt, lastSeenAt }`. Idempotent per `(user, endpoint)`
(upsert). `endpoint` **must** be HTTPS on the shard's ntfy allow-set — a private/loopback or
off-allowlist origin is rejected `400` (the SSRF guard). Bearer-auth, so registration only happens
while signed in.
- `GET /auth/me/devices``PushDevice[]`; `DELETE /auth/me/devices/:id``{ ok: true }` (`404` if not
the caller's).
- `GET /auth/me/notifications/streams` → `{ streams: [{ id, label, description, personal,
requiresLinkedAccount }] }` — the eight-stream catalog (`news.post`, `server.status`,
`idoc.warning`, `champ.start`, `governor.election`; personal `vendor.sale`, `house.idoc`,
`account.login`). Render from this, don't hardcode.
- `GET /auth/me/notifications/subscriptions` → `{ streams: [id…] }`; `PUT` the same shape (full
replace; unknown ids dropped server-side; the stored set is echoed back).
- **The wire tickle** the device receives is the content-free `{ "stream": "<id>", "ref": "<opaque>" }`
JSON body (`utils/pushDispatch.js`). `ref` is a serial / city / timestamp hint — **never** content.
Work items:
1. **Transport — the app is its own distributor; no second app (DECIDED).** The Runic Gateway app
**embeds its own UnifiedPush distributor**. The self-hosted **ntfy is only the relay server**, never
a user-installed app — the user installs *one* APK and it receives its own notifications, with no
external distributor (no ntfy app, no NextPush) and no Google Play Services. Concretely, the embedded
distributor holds a **persistent connection to the shard's ntfy** in a **foreground service**,
reusing the OkHttp reconnect/backoff pattern already built for `core/net/ShardStreamClient` (M2): it
subscribes to the app's own random, unguessable ntfy **topic** (over `wss://<ntfy-host>/<topic>/ws`
or the `/json` stream) and forwards each received `{stream,ref}` tickle to the app's receiver. The
**endpoint the app registers** with the backend (work item 5) is that topic's public URL
(`https://<ntfy-host>/<topic>`) — exactly the client-supplied `endpoint` the merged `POST
/auth/me/devices` contract expects and the URL the backend POSTs tickles to. Keep the transport
behind a small `PushTransport` seam so the **future Play/FCM build flavor** (§11, §M8) can swap the
embedded-ntfy distributor for FCM without touching registration, subscriptions, or notification code.
(Implementation detail to confirm: whether a maintained Google-free embedded UnifiedPush-distributor
library fits, or — more likely — a thin in-app distributor written directly over ntfy's subscribe API
reusing `ShardStreamClient`. Either way the distributor lives **inside this app**; the UnifiedPush
*receiver* abstraction is retained only to keep the FCM-flavor seam clean.)
- **Tradeoff, accepted:** instant background delivery requires a persistent foreground service with
an ongoing (low-importance) notification and its battery cost — this is exactly how ntfy's own app
does instant delivery, and it is the price of Google-free self-delivery. A future "battery saver"
option could fall back to periodic polling, but v1 ships the always-connected foreground service.
2. **Deps + manifest.** Add the UnifiedPush connector + the embedded-distributor transport (per #1) to
the version catalog; declare `POST_NOTIFICATIONS` (API 33+ runtime permission) **and
`FOREGROUND_SERVICE` + `FOREGROUND_SERVICE_DATA_SYNC`** (API 34+, for the persistent ntfy
connection); register the receiver and the foreground service in `AndroidManifest.xml`; define the
notification channels (id/name externalized, §2) — one for real notifications plus a low-importance
channel for the ongoing foreground-service notification — and reuse the "RG" notification icon
**already staged in M6**.
3. **`core/push` — embedded distributor + receiver.** The **distributor** component is a foreground
service that owns the ntfy connection (per #1): it (re)creates the app's topic, subscribes over
OkHttp with reconnect/backoff cloned from `ShardStreamClient`, and forwards each frame to the
receiver. The **receiver** parses the `{ stream, ref }` tickle (`kotlinx.serialization`; an
unknown/garbled body is dropped, not crashed — §7 discipline) and posts a notification (work item 7).
Endpoint (re)registration against the backend fires on first subscribe / topic (re)creation
(work item 5); a transient ntfy drop is just a reconnect, not a re-register.
4. **`DevicesApi` + `NotificationsApi` (Retrofit) + DTOs.** Hand-authored, spec-aligned (as recorded
for M1): `RegisterDeviceRequestDto`, `PushDeviceDto`, `NotificationStreamDto`,
`NotificationStreamsDto`, `NotificationSubscriptionsDto`. Both go through the existing bearer/refresh
stack (`AuthInterceptor` + `TokenAuthenticator`) and return the typed `ApiResult` (§7). A
`NotificationsRepository` owns register/list/delete-device and get/put streams+subscriptions.
5. **Endpoint ↔ backend lifecycle (mirror the M3 token teardown).** Persist the app's ntfy topic, its
endpoint URL, and the returned device `id` in prefs (DataStore; the topic/endpoint isn't a secret —
its security rests on being unguessable + the content-free tickle, §11). Start the embedded
distributor and `POST /auth/me/devices` **only when the user has ≥1 subscription and is signed in**.
On **logout / dead-refresh sign-out / Settings→Server switch**, `DELETE /auth/me/devices/:id`, **stop
the foreground service**, and drop the topic — wire this into `SessionManager` beside the existing
token-clear so a signed-out device stops receiving (§4.3, §11 "unregister on logout / token
revocation"). On a **server (base-URL) switch**, mint a fresh topic against the new shard's ntfy (the
old endpoint's origin won't be on the new host's allow-set). Re-assert the endpoint + restart the
service on app start when signed-in + subscribed. A `400` on register (endpoint origin off the
shard's `NTFY_ALLOWED_ORIGINS`) surfaces a clear "your shard's push relay isn't reachable" state, not
a crash.
6. **Notifications settings screen (`ui/notifications`).** Lists the catalog from
`GET …/streams` with a per-stream toggle bound to `GET/PUT …/subscriptions`; a **personal** stream
(`requiresLinkedAccount`) is greyed with a "link a game account" hint until the user has a linked
account — reuse the linked-accounts signal already fetched for M4's player surface
(`PlayerShardRepository`), not a fresh source of truth. Toggling to a non-empty set triggers the
register flow (#5) and requests `POST_NOTIFICATIONS`; emptying the set unregisters. Each mutation
folds its `ApiResult` into a section-scoped, localized banner (§7 parity with M4).
7. **Deep-links (resolves the §13 open item).** Tapping a notification opens the app to the stream's
home: `news.post`→News, `server.status`/`champ.start`/`idoc.warning`/`governor.election`→Shard,
`vendor.sale`→Vendors, `house.idoc`→My Houses, `account.login`→My Account. Routed through the
existing `ui/navigation/Routes.kt`; a signed-out/deep-link-to-player tap lands on the `PlayerGate`
(M4) rather than erroring. **v1 shows a generic per-stream notification** (localized catalog
`label`) and deep-links — it does **not** pull `ref` content first; the content-free design means
nothing needs decrypting to render the tap, and the target screen fetches fresh over the
authenticated API on open. (Pulling `ref` for a richer inline notification is a possible later
enhancement, not v1.)
8. **Menu.** Add a **Notifications** entry to the signed-in group in `ui/navigation/Menu.kt` (near My
Account), visible once signed in.
9. **Permission UX.** Request `POST_NOTIFICATIONS` at the moment the user first enables a stream (API
33+); on denial, keep the toggle off and show how to enable it in system settings — never nag on
launch.
10. **Tests (JVM, `testDebugUnitTest`).** DTO decode (device/stream/subscription), `{ stream, ref }`
tickle parse (incl. a malformed body → dropped), the stream→deep-link map, the "personal greyed
until linked" gate, and the register/unregister lifecycle over a fake `SessionManager` + repository
(parity with M3's session tests).
**Cross-repo dependency to confirm before/at implementation** (a Part-1 §13 open item): the shard's
finalized **ntfy reverse-proxy hostname** must be in `NTFY_ALLOWED_ORIGINS`, because the distributor
hands the app an endpoint on *that* origin and the backend rejects a register whose origin isn't
allow-listed. This is deployment config, not code, but Part 2 can't be end-to-end tested until it's
pinned. No `website`/`link`/`servuo-plugins` code change is expected in Part 2.
Ships as `RunicGateway/Android-app#15`; bumps `versionCode`/`versionName` for a post-v1 release
(§10). Like M1M4 it records itself in the §9 build-progress block on landing.
### M9 plan — native SSO login (in progress)
M9 spans `website/` + `android-app/` + `docs/`, so — like M7 — it ships in **two parts**, backend
first (the app is a pure consumer of the bridge contract; §4.2, §9 item 10).
**Part 1 — `website/` backend + `docs/` — ✅ LANDED** (the Mobile SSO Authorization Bridge:
`mobile_auth_sessions`/`mobile_auth_codes` tables, `GET /auth/mobile/sso/start`, the `mode:'mobile'`
branch in the reused SSO callback + TOTP completion, `POST /auth/mobile/sso/exchange`, the exact-match
`MOBILE_AUTH_REDIRECT_URIS` allowlist, and bridge-table cleanup). Canonical ref:
`../website/BACKEND_DESIGN.md` → "Mobile SSO Authorization Bridge".
**Part 2 — the Android app (this milestone).** The native in-app "Sign in with Google / Discord"
client. **Additive** — a new auth slice (`core/auth/sso` + a `SsoApi`/`SsoAuthManager` + a login-screen
provider list) that feeds the *existing* M3 session machinery; it adds **no** new token-storage or
refresh code, and touches no other screen. **No backend work** — every endpoint it calls is merged.
The Part-1 contract the app codes against (verified against the merged `website` source):
- `GET /auth/providers` → `[{ id, name, icon, loginUrl, priority }]` (public discovery, no secrets).
`icon` ∈ `google|discord|oidc|oauth2`. Render the provider buttons from this — don't hardcode.
- `GET /auth/mobile/sso/start?provider&code_challenge&state&redirect_uri` — **opened in a Custom Tab**
(not an XHR): it 302s through the IdP and finally deep-links back to `redirect_uri`. `redirect_uri`
must be an **exact** allowlist entry — the app always sends the one fixed callback
`runicgateway://auth/callback`.
- The callback deep link carries **either** `?code=<one-time>&state=<echoed>` (success) **or**
`?error=<reason>&state=<echoed>` (`invalid_provider`/`provider_unavailable`/`server_error`, or an
IdP/link refusal) — **never a token**.
- `POST /auth/mobile/sso/exchange` `{ code, code_verifier }` → the **same** `{ accessToken,
refreshToken, expiresIn, user }` pair as `/auth/mobile/login`; `401` on an unknown/expired/used code
or a PKCE-verifier mismatch.
Work items:
1. **PKCE + state (Layer B, app↔website).** A pure-JVM `Pkce` helper (unit-testable, no Android
framework types): `code_verifier` = 32 random bytes base64url (RFC 7636 S256), `code_challenge` =
base64url(SHA-256(verifier)), plus a random `state`. `java.util.Base64` URL encoder without padding
+ `MessageDigest` — matches the backend's `crypto.createHash('sha256')…base64url` exactly.
2. **`SsoAuthManager` (Singleton) — the flow orchestrator.** Holds the **pending** `{state, verifier}`
in memory (lost on process death → the exchange fails closed and the user retries; acceptable and
safe, documented). `buildStartUrl(provider)` mints PKCE+state, stashes pending, and builds the
absolute `/start` URL off `BaseUrlHolder` for the Custom Tab. `isCallback(uri)` matches our scheme;
`complete(uri)` verifies `state` (CSRF), maps an `error`, exchanges the `code` with the stashed
`verifier`, and on success drives `SessionManager.onSignedIn` — the *same* entry the password login
uses, so push registration (`PushManager` observes the session) and the menu react identically. It
exposes an `outcome: StateFlow` (Idle/Success/Failed(reason)) the login screen consumes, robust to a
ViewModel/activity recreation while the Custom Tab is foreground.
3. **`SsoApi` + DTOs.** `GET api/v1/auth/providers` → `List<SsoProviderDto>`; `POST
api/v1/auth/mobile/sso/exchange` tagged `Http.NO_SESSION_HEADER` (no bearer; a credential-style
`401` must not be read as an expired session or trip the refresh `Authenticator`) → the reused
`MobileTokenResponse`. Lenient Json (additive fields safe, §8).
4. **Deep link.** Register the `runicgateway://auth/callback` intent-filter on `MainActivity`
(`VIEW` + `DEFAULT` + `BROWSABLE`, `scheme/host/path` from one shared constant) and set
`launchMode="singleTop"` so the returning Custom Tab reuses the running task; `onCreate`/`onNewIntent`
route a matching `ACTION_VIEW` intent to `SsoAuthManager.complete` on `lifecycleScope`. Custom scheme
only for now — App Links deferred (`APP_LINKS.md`).
5. **Login screen.** Replace the single "SSO on the website" hand-off with a native provider list from
`GET /auth/providers`: one button per provider (Google/Discord/OIDC glyph from `icon`), each opening
its `/start` URL in a Custom Tab via the existing `WebHandoff`. The `LoginViewModel` collects
`SsoAuthManager.outcome` → a success pops back like a password sign-in; a failure surfaces a friendly
inline error (reusing the existing `LoginError` channel + a new SSO string). Falls back to the
website login hand-off when discovery returns no providers or the base URL is unset.
6. **Tests (JVM, `testDebugUnitTest`).** `Pkce` (verifier charset/length, challenge = base64url-SHA-256
of a known vector, no padding), start-URL building (encoded params, fixed `redirect_uri`), and
`SsoAuthManager.complete` over a fake `SsoApi` + `SessionManager`: success signs in; a mismatched or
missing `state` fails without exchanging; an `error=` callback maps to the right reason; a `401`
exchange maps to expired-code; a missing pending (process death) fails closed.
Ships as a `RunicGateway/Android-app` PR; bumps `versionCode`/`versionName` for a post-v1 release
(§10) and records itself in the §9 build-progress block on landing. No `website`/`link`/`servuo-plugins`
code change is expected in Part 2.
**Prerequisite progress (§8):** all v1 prerequisites are **done** (2026-07-19) — ✅ password reset
(item 2; website#75 + docs#8), ✅ role-agnostic `/auth/me/*` self surface (item 1; website#76 + docs#10),
✅ version/health surfacing (item 4) and ✅ branding for mobile (item 6). **Push notifications (item 3)
is the only remaining §8 work and is post-v1 (M7).** The app's functional Kotlin pass (M0M4) is now
unblocked.
The workspace already holds `website/`, `link/`, `servuo-plugins/`, and `docs/`. `android-app/` is
the fifth repo. It is **purely an API client of the website backend** — it never talks to the
`link/` sidecar or the shard directly, and it ships none of the shard/sidecar wiring.
---
## 1. Purpose & scope
A native Android client for a Runic Gateway shard's public site + player self-service. It surfaces
the same content and player features as `website/client`, minus every administrative/management
console. It is a **read + self-service** app, not an operator tool.
### In scope
- **Public content** (no auth): news / Five-on-Friday / newsletter / screenshots, wiki, CMS pages,
site status & maintenance page, contact form.
- **Public shard widgets** (no auth): shard status, online staff, live event feed, economy series,
champion spawns, guilds, governors, houses/IDOC, presence — including the live **SSE** stream.
- **Account & auth** (bearer token): native **username/password login (with TOTP 2FA)**, logout,
refresh; account self-service (change username/password, TOTP enroll/disable, list/unlink SSO
identities). Registration, invite acceptance, password reset, and SSO are **website-handled** — the
app hands off to the website's pages for those (§4.2), not native screens.
- **Player's own shard/game data** (bearer token): link a game account via a `[link` one-time code,
hybrid game-account signup, list linked accounts, own character roster, character sheet, own
player vendors, own vendor sales, own houses (home/decay status).
- **Access-level menu**: one shared navigation that reveals items based on the signed-in user's role.
- **Opt-in push notifications** (post-v1; architected for from the start): per-stream subscriptions the
user chooses — nothing is pushed unless subscribed. See §11.
### Explicitly OUT of scope (never in the app, for any role)
- The **hero editor** and any CMS authoring/block editing.
- The **admin / auth-management console** — user management, invites issuance, SSO provider config,
moderation console, email config, bot-activity/ban console. (Players still *log in*; what's
excluded is the management surface, not authentication itself.)
- The **Discord bot** management (and anything under the unpublished `/internal/**` port — it
returns the decrypted bot token and must never be reachable from a client).
- **Shard / uo-link administration** — sidecar base-URL/token config (`uoLinkConfig`), shard ops,
the staff shard-user console. (The app shows *public* shard widgets and a player's *own* game
data; it does not manage the sidecar.)
> The excluded surfaces all live under `/api/v1/admin/**` and `/api/v1/internal/**`. The app only
> ever calls `/api/v1/public/**`, `/api/v1/auth/**` (incl. the new role-agnostic self surface
> `/auth/me/*`, §6.4), and `/api/v1/player/**` — it never references `/admin`.
---
## 2. Architecture & stack
Native, Android-only:
| Concern | Choice |
|---|---|
| Language / UI | **Kotlin + Jetpack Compose** (Material 3) |
| Navigation | Navigation-Compose, single-activity |
| HTTP | **Retrofit + OkHttp**, `kotlinx.serialization` converter |
| Async | Coroutines + Flow; `viewModelScope` |
| DI | Hilt |
| Saved base URL / prefs | **Jetpack DataStore** (Preferences) |
| Tokens at rest | **EncryptedSharedPreferences** (Jetpack Security / Tink-backed) |
| Live feed | OkHttp SSE (`EventSource`) for `/public/shard/stream` |
| Images | Coil |
| Min SDK | **Android 10 (API 29)** — ~95% device reach with a modern baseline (biometric, storage, TLS) and no compat shims |
| Target/compile SDK | Latest stable (35) |
| Telemetry | **None in v1** — no crash/analytics SDK (privacy-first). Revisit self-hosted crash reporting later. |
| Localization | **Strings externalized from day one** (`res/values/strings.xml`); English is the only bundled locale, but the structure invites community translations. No hardcoded UI strings. |
| Web hand-off | Chrome Custom Tabs — opens the website for registration / invite / password reset / SSO (§4.2) |
**API model generation.** The DTOs and the Retrofit interface are generated from
`swagger-output.json` (OpenAPI 3.0) rather than hand-written, so the client stays in lockstep with
the backend contract. A build step (or a checked-in generated module regenerated on contract change)
runs `openapi-generator` against the committed spec. Endpoints that return
`additionalProperties: true` (several shard reads) are typed as permissive maps / JsonElement.
**Layering** mirrors the backend's discipline: `screen (Compose) → ViewModel → repository → API
service (Retrofit) → DTO`. Repositories expose `Result`-like sealed types so the UI degrades
gracefully (see §7).
### 2.1 Build workflow: Kotlin first, then design-led UI
The app is built in two passes. **First**, the functional Kotlin is written — the layering above with
placeholder/functional Compose screens: navigation, ViewModels, repositories, the generated API
client, auth/token handling, and every screen wired to its endpoints and working end-to-end. **Then**,
once that Kotlin code is done, **Claude Design produces the front-end design** for the app, and
**Claude Code implements the final UI (Compose screens, theming, components) according to that
design.** The design pass restyles and refines the already-working screens; it does not change the
architecture, data flow, or endpoint contracts established in the first pass. Keeping strings
externalized and branding data-driven (§2, §3) from the start is what lets the design pass reskin
freely without touching logic.
---
## 3. Base URL: first-run + settings
The app is **brandable to any shard's site** (one site per install), so the API host is not
compiled in.
- **First run (before init):** a mandatory **"Connect to your shard's website"** screen asks for the
site base URL. The app validates it by calling `GET /api/v1/public/status` (and reads
`/public/settings` for branding: name/colors/logo). Only on a successful, well-formed response is
the URL persisted to DataStore and the app allowed to initialize its main UI.
- Accept `https://host[/base]`; normalize/trim; require HTTPS in release builds (allow HTTP only in
debug for local dev against `127.0.0.1:3000`).
- Failure states: unreachable, non-2xx, not-a-Runic-Gateway-site (missing expected `/public/status`
shape), TLS error — each gets a clear retry message. Nothing else in the app runs until this
succeeds.
- **Settings:** the base URL is editable later under **Settings → Server**. Changing it is a
hard reset of session state: clear stored tokens, drop cached content, re-run the validation probe,
and return to a signed-out state against the new host.
- **Version guard:** the backend is versioned; surface a clear "app/site version mismatch" state if a
future protocol/version header disagrees, rather than mis-rendering.
---
## 4. Authentication & token handling
**Design rule (decided): credential/identity flows live on the website, not in the app.** The app
implements **only native username/password (+TOTP) login**. Registration, invite acceptance,
forgot/reset password, and SSO all **run through the website's API + web front end** — the app hands
off to the website in a browser (Chrome Custom Tab) and the user returns to sign in. This keeps every
account-provisioning, OAuth, and password path in one audited place rather than duplicated (and
security-reviewed twice), and it means **no new mobile-facing auth endpoints** are required for v1.
Password reset is being built on the backend + web front end **before** app work begins (§8), so it is
simply available in that hand-off, not app scope.
### 4.1 Username + password (+ TOTP) — the app's only native auth, ready today
Uses the existing **mobile bearer** surface, no backend changes:
- `POST /auth/mobile/login` `{ username, password, code? }` →
`{ accessToken, refreshToken, expiresIn, user: { id, username, role } }`.
- **Single-request 2FA:** a `401 { totpRequired: true }` means re-submit with `code`. The login
screen reveals a code field on that response.
- Respect `429` (backoff / rate-limit) with a friendly "try again shortly" state — login is guarded
by per-IP backoff → slow-down → hard cap on the server.
- `POST /auth/mobile/refresh` `{ refreshToken }` → new pair. **Refresh tokens are single-use and
rotated**: store the new pair atomically; a failed refresh (401) means the session is dead → sign
out and return to login. An OkHttp `Authenticator`/interceptor performs a one-shot refresh on a
`401` from a bearer call, with a mutex so concurrent 401s trigger only one refresh.
- `POST /auth/mobile/logout` `{ refreshToken?, all? }` (requires bearer) — revoke this session or all
sessions. Called on user logout and on "sign out everywhere."
### 4.2 Website-handled flows: registration, invite, forgot-password, SSO
These are **not** rebuilt in the app. The app links out to the website's own pages/API and the user
completes them in a Custom Tab, then returns and signs in natively (§4.1):
- **Register / accept invite** — the app opens the website's register / `…/invite/:token` pages. Invite
emails already link to the website. After the account exists, the user signs into the app with their
new username + password. (No mobile register/invite endpoints needed.)
- **Forgot / reset password** — the app links to the website's reset page (the flow being built in §8
before app work). The user resets there, then signs into the app. (No mobile reset endpoint needed.)
- **SSO (Google / Discord / OIDC)** — **v1** shipped this as a website browser hand-off: an SSO user
links their identity and sets a password on the website, then uses password login in the app.
`GET /auth/providers` is shown so the login screen can direct users to "sign in with … on the website."
- **Native in-app SSO — now being built (M9), post-v1 additive.** The "possible later enhancement"
noted here is now the **Mobile SSO Authorization Bridge**: a Custom-Tab flow that hands a one-time
code back to the app's fixed callback (`runicgateway://auth/callback`), exchanged for the *existing*
mobile bearer tokens. It **extends** the existing `/auth/sso/*` redirect flow rather than adding a
parallel auth path — same PKCE-vs-IdP, same link-only + opt-in-provisioning policy, same TOTP gate,
same token shape as `/auth/mobile/login`. The bridge adds a **second** PKCE layer (app ↔ website)
and an app-generated `state` (CSRF, verified by the app before exchange). Backend + docs land first
(this document's canonical API ref is `../website/BACKEND_DESIGN.md` → "Mobile SSO Authorization
Bridge"); the native app client is M9. Custom-scheme callback only for now — App Links are deferred
(see [`APP_LINKS.md`](./APP_LINKS.md)).
### 4.3 Session model (all paths)
- **Refresh:** `POST /auth/mobile/refresh` `{ refreshToken }` → new pair. **Single-use / rotated:** store
the new pair atomically; a failed refresh (401) means the session is dead → sign out. An OkHttp
`Authenticator` does a one-shot refresh on a bearer `401`, behind a mutex so concurrent 401s trigger
only one refresh.
- **Logout:** `POST /auth/mobile/logout` `{ refreshToken?, all? }` (requires bearer) — this session or
all sessions ("sign out everywhere").
- **Storage:** access + refresh tokens live in EncryptedSharedPreferences, never in plain prefs/logs.
The base URL may live in plain DataStore; tokens must not. An optional **biometric app-lock** was
considered here but **descoped from v1** (tokens are already encrypted at rest; see the M3 note).
- **Role for the menu** comes from the login response `user.role` and is re-validated via
`GET /auth/me` on app resume (roles can change server-side; admin access is re-checked every
request on the backend, so the app treats role as *advisory for menu rendering* and lets the server
be the authority — a 403 is handled gracefully, never assumed-away).
---
## 5. Navigation — one shared, access-level menu
A **single** navigation definition; each entry declares the minimum access it requires, and the menu
renders only the entries the current session satisfies. Roles: `anonymous` < `player` /
`moderator` / `editor` / `admin` (the three staff roles are not a strict ladder — gate by capability,
not rank).
| Menu group | Visible to | Backing endpoints |
|---|---|---|
| Home / Status | everyone | `/public/status`, `/public/settings` |
| News & content | everyone | `/public/posts/:category`, `/public/pages/:slug` |
| Wiki | everyone | `/public/wiki`, `/public/wiki/categories`, `/public/wiki/tags`, `/public/wiki/:slug` |
| Shard (live) | everyone | `/public/shard/*` + `/public/shard/stream` (SSE) |
| Contact | everyone | `/public/contact` |
| **My Account** | signed-in | `/player/account/*` (or `/admin/account/*` for staff — see §6.4) |
| **My Characters / Vendors / Houses** | `player` (linked) | `/player/shard/*` |
| Sign in / Sign out | toggles on session | `/auth/mobile/*` |
Guidelines:
- The menu is **declarative + data-driven**, not a pile of `if role ==` checks — one list of entries
with a `minAccess`/`requiredCapability` field, filtered by the session.
- Never hide the fact that more exists behind auth in a way that misleads; anonymous users see public
groups and a "Sign in" affordance.
- The server is the source of truth: a hidden/greyed item is a UX convenience; every gated call still
enforces on the backend and the app handles 401/403 cleanly.
---
## 6. Screen ↔ endpoint map
### 6.1 Public content
- **Home/Status** — `GET /public/status`, `GET /public/settings` (branding + maintenance banner).
- **News hub** — `GET /public/posts/:category` (`news | five-on-friday | newsletter | screenshots`),
detail via `GET /public/posts/:category/:idOrSlug`.
- **CMS pages** — `GET /public/pages/:slug` (block-based; render the block types the site uses).
- **Wiki** — list/categories/tags/detail as above.
- **Contact** — `POST /public/contact` (rate-limited; handle 429/502).
### 6.2 Public shard (live)
- Status/online/feed/economy/champs/guilds/governors(+history)/presence/houses/idoc — the
`/public/shard/*` GETs.
- **Live updates** — subscribe to `GET /public/shard/stream` (SSE, safe kinds only) and patch the
in-memory boards in place (champ/guild/city/house/presence update+remove frames). Reconnect with
backoff; fall back to poll if SSE drops.
### 6.3 Player self-service & game data (bearer)
- **Account** — `GET /player/account`; `PATCH /player/account/username`;
`PATCH /player/account/password`; TOTP `setup`/`enable`/`disable`; identities `GET` / `DELETE`.
- **Game account linking** — `POST /player/shard/link` (one-time `[link` code),
`POST /player/shard/account` (hybrid signup, when enabled), `GET /player/shard/accounts`.
- **My game data** — `GET /player/shard/roster/:account`, `/char/:serial`, `/vendors/:account`,
`/sales`, `/houses`. All ownership-checked server-side; a `503` means shard/sidecar down → show an
"offline, retry" state (see §7).
- **Presentation is text-only for v1.** Character sheets and vendor listings render as data/text — no
item icons or paperdoll art. A richer "pretty paperdoll" view is a **future** enhancement (pending the
art/asset work on the platform side) and is explicitly out of the first release.
### 6.4 Self-service is role-agnostic under `/auth/**` (decided)
Player self-service is under `/player/account/*` (gated to `role='player'`) and staff use the *same*
handlers under `/admin/account/*`. Rather than have the app branch by role (and touch `/admin`), we
**add a role-agnostic self surface under `/auth/**`** — the canonical "me" endpoints for every role.
The app calls these regardless of role, and never references `/admin`. This is an **additive v1**
change (see §8): the existing `/player/account/*` and `/admin/account/*` routes stay for web
back-compat; `/auth/me/*` reuses the same `account.controller` handlers behind `requireAuth` (any
authenticated role), so there's no logic duplication.
---
## 7. Degradation & offline
Mirrors the website's "degrade gracefully" invariant:
- Every repository call returns a typed result (`Ok`/`HttpError(status)`/`NetworkError`); the UI never
crashes on a down backend or shard.
- **Shard down** (`503` from shard reads, or `/public/shard/status` shows disconnected) → render the
shard as **offline**, keep the rest of the app usable.
- **Site maintenance** (`/public/status` = maintenance) → show the maintenance page; public shard
widgets may still render (they're not maintenance-gated server-side).
- **Offline caching is not a v1 requirement** (decided). The app assumes connectivity and shows clean
loading/error/retry states; it does **not** ship a Room cache in v1. Cached read-only content can be
added later without reworking the repository layer (its typed results already isolate the UI from the
data source). No `Room` dependency in the initial build.
---
## 8. Cross-repo work to do *before* coding the app
The bridge repos are contracts; the app adds a new consumer. Land these first (in `website/` +
`docs/`), each with regenerated Swagger.
**Already verified — no change needed** (checked against the current backend):
- **CORS / native reachability.** CORS is only enabled when `CLIENT_ORIGIN` is set (local Vite dev);
in prod the SPA is same-origin and CORS is off. A native HTTP client is not browser-origin-bound, so
no CORS/preflight applies. *Caveat:* `app.js` mounts a bot/scanner guard before routing — the app
must send a sane `User-Agent` so it isn't caught by scanner heuristics.
- **`GET /auth/me` bearer support.** `auth/token.js:extractToken` reads the cookie *then* falls back to
`Authorization: Bearer`, and `/auth/me` advertises both auth schemes. It returns the current user for
a bearer token today. The entire `/player/**` and self-service surface works with bearer as-is.
- **Token lifetimes.** Access `MOBILE_ACCESS_TTL` = 15m default; refresh `MOBILE_REFRESH_TTL_DAYS` =
30 days. The login/refresh response's `expiresIn` reflects the access TTL — drive proactive refresh
off it.
**API versioning: everything below stays in v1 (decided).** These are all *additive* routes — new
endpoints that change no existing response shape — so they do **not** warrant a v2. A v2 API is only
justified by a breaking change to a contract existing clients depend on, which none of this is. The
web client and the app both consume v1; a second parallel route tree + Swagger spec would be pure
maintenance cost. Reserve v2 for a real breaking re-shape if one ever arises.
**To build (all additive, v1):**
1. **Role-agnostic self-service under `/auth/**` (§6.4, decided).**
✅ **DONE (2026-07-19, RunicGateway/website#76 (+ this docs PR)).** A `me.routes.js`
sub-router mounts the existing `account.controller` self handlers behind `requireAuth` (any role) at
`/auth/me/*`, so the app has one self surface and never touches `/admin`. The old
`/player/account/*` + `/admin/account/*` routes stay for web back-compat. Shipped routes:
- `GET /auth/me` — current `{ id, username, role }` (already existed; the app's role source).
- `GET /auth/me/account` — full self account.
- `PATCH /auth/me/account/username`, `PATCH /auth/me/account/password`.
- `POST /auth/me/account/totp/setup|enable|disable`.
- `GET /auth/me/account/identities`, `DELETE /auth/me/account/identities/:provider`.
- Swagger regenerated with `#swagger` annotations; `test/authMe.test.js` guards the auth gate; and
an end-to-end smoketest confirmed both a player and an editor (staff) drive the same surface.
2. **Password reset — build on backend + web front end FIRST (a prerequisite, not app scope).**
✅ **DONE (2026-07-19, RunicGateway/website#75 + docs#8).** Full platform flow shipped in `website/`:
request-reset (`POST /auth/password/forgot`, always a generic 200 — no account enumeration) emails a
single-use, ~1h link → reset page + endpoints (`GET|POST /auth/password/reset/:token`) that verify,
set the password, and revoke every session (web cutoff + mobile refresh tokens). The token is an
opaque random value stored as a **sha256 hash** in a new `password_resets` table (mirroring
`user_invites` — chosen over a signed JWT to match the house pattern; functionally equivalent). It
also serves SSO-only accounts (null hash) as their set-initial-password path. Swagger regenerated;
documented in `BACKEND_DESIGN.md`. The app just links users to the web page (§4.2) — **no mobile reset
endpoint.**
- No mobile SSO/invite/register endpoints are needed: SSO, registration, and invite acceptance all
stay website-handled and the app hands off to them (§4.2). This is a deliberate scope reduction.
3. **Push notifications** — see §11. Additive v1 endpoints under `/auth/me/devices*` and
`/auth/me/notifications*`, plus a **self-hosted `ntfy` service added to `website/docker-compose.yml`**
with fully declarative, zero-interaction config. Not required for the first release (M7, not M1M6).
✅ **Backend + docs LANDED (2026-07-20, RunicGateway/website#78 merged (+ docs#20)).** The
contract Part 1 is built: the two tables, the stream catalog + `PUBLIC_KINDS`-gated event mapping,
the content-free-tickle fan-out (`utils/pushDispatch`, SSRF-guarded endpoints, owner-keyed personal
streams), the six `/auth/me/*` routes (Swagger regenerated), and the declarative `ntfy` compose
service (247 server tests green). The app (Part 2, §9 M7) consumes this next — see the "M7 plan"
block for the detailed Part 2 plan.
4. **Version/health surfacing.**
✅ **DONE (2026-07-19, RunicGateway/website#77 (+ this docs PR)).** A dependency-free
`config/version.js` (`{ service:'runic-gateway', api:'v1', server:<pkg> }`) is surfaced on
`GET /public/status` (so the first-run probe recognizes the backend + reads its version in one call)
and on a new **DB-free `GET /public/version`** (canonical target for the version-mismatch guard +
a cheap liveness check). Swagger: `PublicVersion` schema. `test/publicVersion.test.js` covers both.
5. **Docs** — update `docs/website/BACKEND_DESIGN.md` for any new/changed endpoint; keep this file and
the OpenAPI spec current. (The workspace `CLAUDE.md` is a **local, uncommitted** file — update it in
place as repos come online, but it is never committed.)
6. **Branding for mobile.**
✅ **DONE (2026-07-19, RunicGateway/website#77 (+ this docs PR)).** Confirmed
`GET /public/settings` returns the per-shard `brand` block (name, `accent` color, logo/hero/favicon,
plus shortName/tagline/description/url/contactEmail) sourced from `BRAND_*` with admin
`site_title`/`contact_email` overrides — the app themes itself from it. Made it first-class in the
OpenAPI contract (`Brand` + `PublicSettings` schemas) so the app's codegen gets typed branding
instead of an untyped map; `test/publicBrand.test.js` locks the contract. Asset fields may be
site-relative paths — the app resolves them against its stored base URL.
No `link/` or `servuo-plugins/` changes are expected — the app is downstream of the website only.
---
## 9. Milestones
**Two passes (§2.1).** M0M4 were the **functional Kotlin pass** — every screen wired to its endpoints
and working end-to-end with placeholder/functional Compose UI, no design investment yet. **M5 was the
design pass**: with the functional Kotlin done, Claude Design produced the front-end design and Claude
Code implemented the final UI to it. **Both passes are now complete** (M0M5 landed); polish/release,
push, and Play (M6M8) follow the designed app.
1. **M0 — Repo scaffold**: Gradle + Compose + Hilt skeleton, CI (build + lint + unit test), license
headers (GPL-3.0-or-later), CONTRIBUTING/AI-disclosure parity with the other repos.
2. **M1 — Connect & browse** *(functional pass)*: first-run base-URL flow,
`/public/status`+`/public/settings` theming, generated API client, public content
(news/wiki/pages) + contact. No auth yet.
3. **M2 — Public shard** *(functional pass)*: shard widgets + SSE live stream with
reconnect/degradation.
4. **M3 — Auth (§4)** *(functional pass)*: native password+TOTP login (429 handling), token storage,
refresh interceptor, logout, `/auth/me` role re-validation, the access-level menu. Custom-Tab
**hand-offs** to the website for register / invite / password-reset / SSO (no native screens for
those). (An optional biometric app-lock was considered here, then descoped from v1 at M6.)
*Prerequisite:* the website password-reset flow (§8) is already built.
5. **M4 — Player self-service & game data** *(functional pass)*: account management (via
`/auth/me/*`), game-account linking, own roster/characters/vendors/houses/sales — **text-only**
presentation (§6.3).
6. **M5 — Design pass & final UI (§2.1)**: with the functional Kotlin from M1M4 working end-to-end,
**Claude Design produces the front-end design** for the app, then **Claude Code implements the final
UI to it** — Compose screens, Material 3 theming from the per-shard branding (§3), reusable
components, loading/error/empty states, the designed access-level menu. Restyles the existing
screens only; no changes to architecture, data flow, or endpoint contracts. Text-only game data
(§6.3) still holds — this is visual design of the data screens, not paperdoll art. **Landed**
2026-07-20 (`RunicGateway/Android-app#10`): dark-only shard-website theme, Cinzel display face,
reusable pill/label/card/meter components; brand-accent seeding retained (see §9 build progress).
7. **M6 — Polish & release mechanics**: settings (server switch = hard reset, done M3),
version-mismatch guard, release build hardening (HTTPS-only, no token logging, R8 minify + resource
shrink, release signing). No offline cache in v1 (§7). **Ships v1 as a signed APK attached to a Gitea
release** via `release.yml` on a `v*` tag (see §10). Biometric app-lock **descoped** (below).
**Landed** 2026-07-20 (`RunicGateway/Android-app#11`).
8. **M7 — Push notifications** (post-v1): add the self-hosted `ntfy` service to
`website/docker-compose.yml` (declarative, zero-interaction config), UnifiedPush integration in the
app, device registration, the subscriptions UI, and the content-free-tickle backend fan-out (see
§11). The app is built with room for this from M0 but it does not gate the first release.
✅ **Both parts landed** 2026-07-20 — Part 1 backend (`RunicGateway/website#78` merged + docs#20),
Part 2 app (`RunicGateway/Android-app#15`) + a small `push.ntfyUrl` settings addition
(`RunicGateway/website#79`). The app embeds its own ntfy distributor (a foreground-service SSE
connection, no second app, no Google Play Services, no UnifiedPush library); see the "M7 plan"
Part 2 block for the recorded transport + backend-field decisions. Push delivers once the shard sets
its `NTFY_*` deploy config (§13).
9. **M8 — Google Play**: Play Console listing, signing/upload key, and (optionally) an FCM build flavor
— after the direct-APK release is stable.
10. **M9 — Native SSO login** (post-v1, additive; independent of M8): in-app "Sign in with Google /
Discord" via the **Mobile SSO Authorization Bridge** (§4.2). **Backend-first**, mirroring M7's
split:
- **Part 1 — backend + docs (in progress):** `mobile_auth_sessions` + `mobile_auth_codes` bridge
tables; `GET /auth/mobile/sso/start` (seeds a bridge session, reuses the existing SSO redirect
tagged `mode:'mobile'`); a mobile branch in the SSO callback + TOTP-completion that mints a
single-use, hashed, PKCE-bound authorization code and redirects to the fixed app callback instead
of setting a cookie; `POST /auth/mobile/sso/exchange` (code + PKCE verifier → the existing mobile
bearer token pair); an exact-match redirect-URI allowlist; boot-time + opportunistic cleanup of
the bridge tables. Reuses `GET /auth/providers` for discovery and `POST /auth/mobile/{refresh,
logout}` unchanged. See `../website/BACKEND_DESIGN.md`.
- **Part 2 — app client:** register the `runicgateway://auth/callback` intent-filter; generate
`code_verifier`/`code_challenge` + `state`; open the Custom Tab at `/auth/mobile/sso/start`;
verify `state` on the callback; `POST …/exchange`; store the returned pair in the existing
`TokenStore` (M3). No new token-storage or refresh code — it feeds the M3 session machinery.
- **Follow-up — App Links (opt-in hardening on top of Part 2):** a website `GET
/.well-known/assetlinks.json` route behind the `mobile_app_links_enabled` admin toggle, a
self-origin HTTPS entry added to the redirect-URI allowlist when enabled, and an app-side
`autoVerify` intent-filter for `https://<host>/mobile/callback` driven by a **build-time**
`appLinkHost` (a single multi-tenant APK cannot autoVerify open-ended shard domains, so the
generic build stays custom-scheme; white-label/first-party builds bake one host). The custom
scheme remains the permanent fallback on every build. Full spec + rollout in
[`APP_LINKS.md`](./APP_LINKS.md).
---
## 10. Distribution
- **v1: direct APK.** Build a signed release APK in CI and **attach it to a Gitea release** (mirrors
how `link/` cuts release binaries). Users sideload; the app already self-configures its server URL on
first run (§3), so one APK works for any shard. Keep a stable **upload/signing keystore** out of the
repo from day one — Play later requires a consistent signing identity.
- **Later: Google Play.** Add a Play Console listing and (if using FCM) a `google-services` config as a
**build flavor**, so the direct-APK build stays Google-free. Versioning: semantic `versionName` +
monotonic `versionCode`; tag releases in the repo.
## 11. Push notifications (built-for, shipped post-v1)
The app is architected from M0 to accommodate push, but push itself ships in M7 — it does not block the
first release. Users **opt in per stream**: nothing is pushed unless subscribed.
### Transport — UnifiedPush via self-hosted ntfy (decided)
- **Primary: UnifiedPush, delivered by a self-hosted `ntfy` service added to the website's
`docker-compose.yml`.** FOSS, no Google Play Services dependency, works for the sideloaded APK on any
device, and keeps delivery under the org's own infrastructure — consistent with the self-hosted ethos.
- **The app embeds its own distributor — no second app (decided; see M7 Part 2 work item 1).** ntfy is
purely the relay *server*; the Runic Gateway app receives notifications itself via an in-app embedded
UnifiedPush distributor (a foreground-service persistent connection to the shard's ntfy). The user
installs one APK — never a separate distributor app — and no Google Play Services is involved.
- **FCM stays optional and Play-only.** If/when a Play build wants it, add FCM as a **build flavor**;
the direct-APK flavor stays Google-free. The backend fan-out is **transport-agnostic** and dispatches
to whatever endpoint a device registered, so adding FCM later touches no core logic.
### ntfy deployment — fully automated, zero interactive setup (hard requirement)
- Runs as an **additional service in `website/docker-compose.yml`** (the compose *pulls* images and
never builds — ntfy is a pinned upstream image, so this fits that model). Confirm the exact image
path/tag at implementation.
- **All config is declarative** — a committed `ntfy` config file and/or `NTFY_*` env vars baked into
compose. No `docker exec`, no interactive `ntfy user add`, no post-deploy manual steps. Bringing the
stack up provisions a working push relay. Reachable to devices via the existing reverse proxy on its
own hostname/path; internal-only for the backend publisher.
- **No per-user ntfy accounts to administer.** The security model (below) removes the need for ntfy ACL
provisioning, which is exactly what keeps setup interaction-free. ntfy topics are the random,
unguessable endpoints UnifiedPush hands out; the backend treats ntfy as an **untrusted relay**.
### Backend (additive, v1)
- `POST /auth/me/devices` — register a device: `{ transport, endpoint, platform }` where `endpoint` is
the UnifiedPush/ntfy URL the distributor gave the app (or an FCM token for a Play/FCM build). `DELETE
/auth/me/devices/:id` — unregister. Devices belong to the authenticated user.
- `GET /notifications/streams` — catalog of subscribable streams + which require a linked game account.
- `GET|PUT /auth/me/notifications/subscriptions` — the user's selected streams (per-user; applied to
all their devices).
- **Fan-out worker** hangs off the existing event dispatcher (`website` `utils/shardIngest.js`) — the
same event source that already feeds the SSE channels — matches events against subscriptions and
**publishes a content-free tickle** (see below) to each matching device's endpoint. Store endpoints
per device. Any secret (an ntfy publish token, or an FCM server key if that flavor is used) is
encrypted at rest via `utils/secretBox.js`, like the other secrets.
### Stream catalog (initial)
- **Public / opt-in** (no account needed): news posts, server up/down, IDOC warnings, champion-spawn
starts, governor elections.
- **Personal** (require a linked game account; delivered only to the owner): *your* vendor sold an
item, *your* house entered IDOC, a login to *your* account.
### Security boundary (hard requirement)
The ntfy relay is treated as **untrusted infrastructure**, and the design makes that safe:
- **Content-free tickles.** A push payload carries **no sensitive data** — only a stream id and an
opaque reference (e.g. `{ stream: "vendor.sale", ref: "…" }`). On receipt the app wakes and **pulls
the actual content over the authenticated, ownership-checked API** (`/auth/me/*`, `/player/shard/*`).
So even if an ntfy topic name leaked, nothing meaningful leaks with it, and no data reaches a device
that its user isn't already entitled to fetch. This is what lets ntfy be automated with no per-user
ACLs while still honoring the security rules.
- **Same allowlist split as the SSE streams.** Sensitive kinds (staff audit, cheat detection, login
attempts, IPs) are never fanned out to push at all — the publisher applies the identical public/safe
allowlist used by the SSE dispatcher.
- **Personal events are owner-keyed.** A personal tickle (your vendor sold, your house IDOC) is
published **only** to the endpoints of the owning user, decided by the same ownership check as the
`/player/shard/*` reads — a device never receives another user's events.
- **Transport hardening.** ntfy served over TLS via the reverse proxy; the backend→ntfy publish is
internal. Endpoints are unguessable random topics; unregister on logout / token revocation.
### App
- A **Notifications** settings screen lists the catalog with per-stream toggles; personal streams are
disabled/greyed until the user has a linked game account. Registration happens after login; toggles
write to `/auth/me/notifications/subscriptions`. Tapping a notification deep-links to the relevant
screen (§ open item below).
## 12. Build & CI (Gitea Actions)
Builds run on the org's existing self-hosted runners (`runs-on: ubuntu-latest`, same label the other
repos use), on a bare `ubuntu:latest` container.
- **Toolchain:** JDK **17** (temurin) for Android Gradle Plugin 8.x; Android SDK installed in-CI via
`android-actions/setup-android@v3` (cmdline-tools + license acceptance). Cache `~/.gradle` and the SDK.
- **Bare-image gotcha:** `ubuntu:latest` lacks `git`/`curl`/`unzip` that `actions/checkout` and
`sdkmanager` need — first step `apt-get install -y git curl unzip`. (Faster option once builds are
frequent: run the job under a prebuilt Android-SDK `container:` image so nothing installs per-run.)
- **PR gate** (`.gitea/workflows/pr-checks.yml`, on PR → `main`): `./gradlew lint test assembleDebug`.
Debug builds are auto-signed, so the gate needs no secrets. Mirrors `website/`'s pre-merge gate.
- **Release** (`.gitea/workflows/release.yml`, M6+): build a **signed release APK** and attach it to a
Gitea release (mirrors `link/`'s release job). The **keystore is a base64 Gitea Actions secret**
decoded in CI; store/key passwords are secrets. The keystore never lives in the repo. Keep the
signing identity stable from the first release (Play later requires consistency).
- Semantic `versionName` + monotonic `versionCode`; tag releases.
## 13. Open questions (revisit as we go)
**Decided (recorded here for context):** single shard per install (§3); native auth is
password+TOTP only, with registration/invite/reset/SSO **handled by the website** (§4); **password
reset built on backend + web first**, before app work (§8); minSdk 29, compile/target 35 (§2); no
telemetry in v1 (§2); strings externalized from day one, English-only bundled (§2); **text-only** game
data in v1, pretty paperdoll is future (§6.3); **no offline cache in v1** (§7); push via self-hosted
ntfy / UnifiedPush (§11) with the **distributor embedded in the app — no second app to install**
(M7 Part 2 work item 1); **biometric app-lock descoped from v1** (tokens already encrypted at rest, so
it is a UX convenience, not a v1 requirement — deferred at M3, descoped at M6; revisit only if requested).
**Still open:**
- **App identity / domain.** Target application ID **`com.runicgateway.app`** — pending securing the
`runicgateway.app` domain (needed for a verified app-link host and a matching package namespace). Also
the fixed launcher name (baked at build even though in-app branding is per-shard — one APK, any shard).
Since SSO/invite/reset are website-handled, the app mostly *opens* website URLs rather than needing its
own verified app links. **App Links resolved (M9 follow-up):** the SSO callback is the one place a
verified deep-link-back helps; the server side (`assetlinks.json` + toggle) ships for any shard, but
the app-side `autoVerify` needs a **literal build-time host**, so it is a white-label/first-party build
opt-in (`-PappLinkHost=<host>`) — the generic multi-tenant build stays custom-scheme. A canonical
`runicgateway.app` relay host, if secured, would let the generic build autoVerify one central domain.
See [`APP_LINKS.md`](./APP_LINKS.md).
- ntfy: exact upstream image + pinned tag (Part-1 landed the compose service — confirm the tag), and
its reverse-proxy hostname/path. The hostname must land in `NTFY_ALLOWED_ORIGINS` before M7 Part 2 is
end-to-end testable (the app registers an endpoint on that origin; the SSRF guard rejects others). No
backend publish token — **decided** (the content-free-tickle design does not require one; optional
`NTFY_PUBLISH_TOKEN` is honored if ever set).
- FCM flavor: build it for the Play release or ship Play on UnifiedPush too? Decide at M8. (The M7
Part 2 `PushTransport` seam keeps this swap cheap.)
- Deep-link / share targets for wiki pages and posts (share/open-in-app). *Notification-tap* deep-links
are **resolved** for M7 Part 2 (stream→screen map, work item 7).
- iOS: none planned (this is the Android-only choice); revisit only if cross-platform is later
required (would change §2 — and push, which would then favor a cross-platform transport).

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.

64
ci/SONARQUBE.md Normal file
View File

@@ -0,0 +1,64 @@
# 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 |
> Project keys are **case-sensitive** and must match what already exists on the
> server — SonarQube refuses to create a key that differs only in case from an
> existing one. `link` and `Android-app` reuse the pre-existing capitalised keys
> above; `website` predates this note with its lower-case key.
## How it's wired
Each repo carries two files, identical in shape across repos:
- **`sonar-project.properties`** (repo root) — declares the project key, sources,
tests, and exclusions. The Sonar scanner reads this.
- **`.gitea/workflows/sonarqube.yml`** — a `SonarQube` workflow that, on push to
`main` (and via manual `workflow_dispatch`), checks out with full history
(`fetch-depth: 0`, needed for accurate blame + "new code") and runs
`sonarsource/sonarqube-scan-action@v4`.
The scan is **source-based** — it does not build the project or run a language
toolchain, so the workflows are lightweight (checkout + scan only). Richer
signals (Rust Clippy, Android Lint, JaCoCo coverage) are left as documented,
commented-out enrichment in each repo's `sonar-project.properties`; enable them
per repo when wanted.
## One-time setup per repo (Gitea UI → Repo → Settings → Actions)
Both are consumed by the scan action via `env:` in the workflow:
- **Secret `SONAR_TOKEN`** — a SonarQube *Analysis* token (My Account →
Security in SonarQube; project-scoped or global).
- **Variable `SONAR_HOST_URL`** — the SonarQube base URL reachable from the
self-hosted runner. Kept as a **variable, not committed**, so the internal
address stays out of git.
The self-hosted `ubuntu-latest` runner must be able to reach `SONAR_HOST_URL` on
the network. Nothing waits on the SonarQube Quality Gate, so a failing gate does
not fail the job — check the dashboard.
## Adding a new repo
1. Create the project in SonarQube; note its key.
2. Add `sonar-project.properties` (copy an existing repo's, adjust key + sources).
3. Add `.gitea/workflows/sonarqube.yml` (copy verbatim — it's language-agnostic).
4. Set the `SONAR_TOKEN` secret and `SONAR_HOST_URL` variable in the repo's
Gitea Actions settings.

View File

@@ -142,6 +142,87 @@ Seeded keys: `site_mode` (default `maintenance`), `site_mode_changed_at`,
| ip | VARCHAR(45) NULL | from `req.ip` (needs `trust proxy`) |
| 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.
---
## 4. API contract
@@ -154,16 +235,121 @@ accepts `Authorization: Bearer` for API testing).
|---|---|---|---|---|
| POST | `/login` | — (rate-limited) | `{username,password}` | verify, set cookie, log `auth.login`, update `last_login_at` |
| POST | `/logout` | cookie | — | clear cookie |
| GET | `/me` | cookie | — | current user (no hash) or 401 — client bootstraps auth state |
| GET | `/me` | cookie / bearer | — | current user (no hash) or 401 — client bootstraps auth state |
| POST | `/password/forgot` | — (rate-limited) | `{email}` | email a single-use, ~1h reset link to **every active account** on the address; **always** returns the same generic 200 (no account enumeration). Email is non-unique, so several accounts may each get a link naming their username. Logs `account.password.reset.request`. |
| 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) |
| GET | `/me/account/identities` · DELETE `…/:provider` | cookie / bearer | — | list / unlink own SSO identities |
| 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
admins are created under `/admin/users`.
**Role-agnostic self-service (`/auth/me/*`).** The canonical "me" surface for **every** authenticated
role. It reuses the exact `account.controller` handlers as `/player/account/*` and `/admin/account/*`
(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.
**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
| Method | Path | Notes |
|---|---|---|
| GET | `/settings` | whitelisted public keys only (mode, maintenance_message, status_message, homepage_teaser, contact_email, site_title) |
| GET | `/status` | status message + current mode |
| 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, **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/:idOrSlug` | single published post |
| GET | `/wiki` | list of pages (slug + title) |
@@ -223,7 +409,19 @@ who"; `activity_log` provides the history feed.
- **bcrypt** hashing (cost 10+); plaintext passwords never stored, logged, or returned.
- **Rate limiting** (`express-rate-limit`) on `/auth/login` and `/public/contact`.
- **Validation** (`express-validator`) on all writes; centralized error handler.
- **helmet** with a 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`.
- **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.
@@ -270,7 +468,13 @@ subsystem (`[server]`, `[http]`, `[db]`, `[auth]`, `[admin]`, `[ratelimit]`, …
- `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`,
`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`, **no
published host port** — devices reach it via the reverse proxy; the backend publisher reaches it
over the private compose network. Anonymous read-write to unguessable topics (no accounts to
provision) — safe because pushes are content-free tickles. Bringing the stack up provisions a
working push relay with **zero interactive setup**.
- Volumes: `dbdata`, `uploads`, `ntfydata`.
Express listens on `0.0.0.0:${PORT||3000}`. Pangolin terminates TLS and proxies to `app`.
@@ -292,6 +496,16 @@ ADMIN_USERNAME=
ADMIN_PASSWORD=
# Email: configured in Admin → Settings → Email (Gmail OAuth2), not via env
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
```
`.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.

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).
- **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).
- **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).
@@ -331,7 +332,7 @@ character**; players and editor/moderator staff are limited to their own linked
| 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. |
| **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). |