Files
docs/android/PLAN.md
wtclaude 5de5e19445 docs(website): finalize the API plan — in-place router split, no /api/v2
The API v2 plan is revised down to the work that is actually justified: a CSP
hardening pass and an in-place domain split of the monolithic route wiring.

- Auth merge (httpOnly cookies -> bearer + rotating refresh for every client) is
  removed and re-filed as deferred behind trigger conditions. httpOnly+SameSite
  is the stronger model, session.service.js already unifies cookie and bearer,
  the SSO/PKCE transaction cookies survive any merge, and it dragged the admin
  SSE fetch/ReadableStream rewrite along as a dependency for no user-visible
  payoff. A revival must first spec refresh-token reuse detection and a rollback
  procedure.
- No parallel /api/v2. The URL surface is already grouped by capability, so each
  new router file mounts at the prefix it already owns and every URL stays
  byte-identical. No dual mount, no per-route migration, no v1 retirement; the
  SPA, Discord bot, and Android app are all untouched. API_V2_SKELETON.md is
  marked superseded (kept as the recipe if a versioned API is ever forced).
- The /api/mobile facade and app-version floor are deferred with the revival note
  that it starts as a one-line alias mount, not ~70 hand-written delegates. The
  M11 milestone is dropped from android/PLAN.md.
- Adds PR 0: a generated route manifest, so "every URL is unchanged" is proved by
  a zero-line diff rather than asserted in review. The baseline
  api-route-inventory.json (199 API routes + 2 internal) is committed here and is
  what PR 0's generator must reproduce byte-for-byte.
- Split sequenced as five grouped PRs; CSP fixed to report-only first, then
  enforce (the old plan contradicted itself), with the verified delta being just
  form-action 'self' and frame-ancestors 'none'.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 14:31:26 -05:00

83 KiB
Raw Blame History

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). 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, published on a host port (NTFY_HOST_PORT, default 2586 → container :80) so the public reverse proxy — which runs outside the compose network — can forward the notification subdomain to it, anonymous read-write to unguessable topics (no per-user accounts — safe because tickles are content-free). Both the app (SSE subscribe) and the backend (POSTing tickles to registered device endpoints) reach ntfy on that public origin, so all ntfy traffic transits the proxy — there is no separate internal publish port.

Part 2 — the Android app — LANDED (2026-07-20, RunicGateway/Android-app#15 + a small RunicGateway/website#79 settings addition + this docs PR). Built exactly to the plan below, with two recorded implementation decisions:

  • Direct-ntfy embedded distributor, no UnifiedPush library (deviation from §2's "UnifiedPush connector" wording — the plan's stated likely path, work item 1). The app talks straight to ntfy over its own topic rather than pulling in org.unifiedpush.android:connector + an external distributor: a foreground PushService holds an OkHttp-SSE connection to <ntfy>/<topic>/sse (reusing the M2 ShardStreamClient reconnect pattern) on a bare client, PushManager orchestrates topic mint / device register / start-stop keyed to the session, and PushNotifier posts a per-stream notification whose tap deep-links via MainActivity intent extras. No new Gradle dependency; a PushResult/transport seam keeps the future FCM Play flavor cheap. Reasons: the UnifiedPush distributor model assumes a separate app (exactly what the user vetoed), we already own the SSE machinery, and this keeps the APK Google-free and dependency-light. New code lives in core/push/ + ui/notifications/ + a NotificationsApi/NotificationsRepository; no existing screen's data flow changed.
  • One small additive backend field was required after all (push.ntfyUrl). The embedded distributor must know the shard's client-facing ntfy URL to build its topic endpoint, and Part 1 never surfaced it (the NTFY_* vars are server-only). So /public/settings now carries push: { ntfyUrl } (from NTFY_PUBLIC_URL / first NTFY_ALLOWED_ORIGINS; never the internal NTFY_BASE_URL), null when unconfigured → the app shows push as unavailable for that shard. This is the "no backend work in Part 2" caveat corrected: it is additive, non-sensitive, and forward-compatible (an older backend omitting it just decodes to null). Deploy dependency stands: push only delivers once the shard sets NTFY_PUBLIC_URL/NTFY_ALLOWED_ORIGINS (§13).

Verified green: :app:testDebugUnitTest (18 new JVM tests — notifications DTO decode, ntfy tickle parse incl. malformed, topic/URL building, stream→route map + personal gating) + :app:lintDebug + :app:assembleDebug; backend 250 tests (+3 for push.ntfyUrl) and npm run swagger clean. The foreground-service tradeoff (§11) and the POST_NOTIFICATIONS runtime permission are implemented as planned; an on-device delivery pass against a live ntfy is the one open QA item.

Part 2 (original plan) — the Android app. UnifiedPush receiver + device registration against the merged Part-1 contract, a Notifications settings screen, and notification-tap deep-links. The app is architected for push from M0 (§11), so this is additive — a new feature slice (core/push + ui/notifications + a DevicesApi/NotificationsApi pair) that touches no existing screen's data flow. Everything the app calls already exists and is merged; there is no backend work in Part 2.

The Part-1 contract the app codes against (verified against the merged website source):

  • POST /auth/me/devices { transport?: 'unifiedpush'|'fcm', endpoint, platform? }201 PushDevice { id, transport, endpoint, platform, createdAt, lastSeenAt }. Idempotent per (user, endpoint) (upsert). endpoint must be HTTPS on the shard's ntfy allow-set — a private/loopback or off-allowlist origin is rejected 400 (the SSRF guard). Bearer-auth, so registration only happens while signed in.
  • GET /auth/me/devicesPushDevice[]; 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). icongoogle|discord|oidc|oauth2. Render the provider buttons from this — don't hardcode.
  • GET /auth/mobile/sso/start?provider&code_challenge&state&redirect_uriopened 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/providersList<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.
  • Staff operations subset (post-v1, M10 — decided 2026-07-21): a defined slice of the admin surface, native, for admin/moderator staff. In scope: moderation actions (kick / ban / unban / broadcast), the support (help-page) queue (reply / close / resolve), the dashboard summary + site-mode toggle, and content management (news posts — list / create / edit / publish / delete — plus wiki category & tag management). These consume the existing /api/v1/admin/** routes (which already accept bearer auth and re-check role every request); no backend routes are added. See §6.4 + §10.

Explicitly OUT of scope (never in the app, for any role)

The exclusions are now a narrow list (M10 brought the operational admin subset in-scope, above). The app still never ships:

  • The hero editor and CMS block/page authoring (the visual page builder). (News-post and wiki category/tag management ARE in scope; the excluded piece is the CMS block editor / hero builder.)
  • Discord bot configuration (and anything under the unpublished /internal/** port — it returns the decrypted bot token and must never be reachable from a client).
  • uo-link / sidecar administration — the shard sidecar base-URL/token config (uoLinkConfig). (The app performs shard operations like kick/ban/broadcast against the live shard, but never configures the sidecar connection itself.)
  • OAuth / SSO provider setup — creating/editing identity providers and their client secrets.

The app calls /api/v1/public/**, /api/v1/auth/** (incl. the role-agnostic self surface /auth/me/*, §6.4), /api/v1/player/**, and — for staff, M10 — the **defined /api/v1/admin/** operations listed above. It never touches /api/v1/internal/**, nor the four excluded admin surfaces (hero/CMS block editor, Discord-bot config, uo-link config, OAuth-provider setup).


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). This app-layer rule (ServerUrl, allowInsecureHttp = BuildConfig.DEBUG) is backed at the platform socket layer by an explicit network security config (res/xml/network_security_config.xml, wired via application android:networkSecurityConfig): the main/release config permits no cleartext, and a debug-only override (app/src/debug/res/xml/) re-permits cleartext to loopback (127.0.0.1/localhost) only. Being explicit also stops a merged library manifest from re-enabling cleartext and clears the usesCleartextTraffic-implicitly-enabled scanner finding.
    • 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.1.1 Trusted devices & recovery codes — implemented (app PR feature/trusted-devices-mfa)

The backend trusted-device + recovery-code feature (canonical ref: ../website/TRUSTED_DEVICES_MFA.md) is additive on the mobile surface. As built:

  • Login extras: POST /auth/mobile/login accepts recoveryCode (a single-use alternative to code), trustDevice: true, device_name, and an X-Trust-Token header. On the 401 { totpRequired } screen the app offers a "use a recovery code instead" toggle and a "Trust this device" checkbox. When trustDevice is accepted, the response carries trustToken → stored in a dedicated EncryptedSharedPreferences file (runic_trust, AES-256-GCM; never plain prefs/logs) scoped to the username it was minted for, and replayed as X-Trust-Token on that account's future logins to skip the TOTP prompt.
  • Login-time cap: a { trustLimitReached, devices } response means login succeeded but the device was not remembered (session is already issued on native, unlike the web cookie step). Rather than a blocking login-time modal, this is surfaced + resolved on the Trusted Devices account screen, whose POST /auth/me/trusted-devices gives the exact revoke-one-then-retry flow. (Deliberate deviation from the earlier "prompt at login" sketch — the mobile login is past the point a modal would gate.)
  • Self-service (account screens): a Security section on the account screen links to two dedicated screens. Trusted DevicesGET /auth/me/trusted-devices (list), DELETE …/:id (revoke one), DELETE …/trusted-devices (untrust all), and POST /auth/me/trusted-devices to trust the current device (stores the returned { trustToken }). Recovery Codes — enabling TOTP returns the one-time recoveryCodes (shown once on the account screen with copy/share); GET …/recovery-codes/status for the remaining count; POST …/recovery-codes/generate (password step-up) to regenerate, shown once.
  • Invalidation — trust token deliberately survives logout. The trust token is the native analogue of the web rg_trust cookie, which per the canonical design survives logout so the next login skips 2FA. On mobile the token is only ever consulted at a fresh login — i.e. exactly after a logout or a dead-refresh sign-out — so clearing it there would make the feature a no-op. It is therefore kept in its own store, untouched by session teardown (SessionManager.onSignedOut), and cleared only on a Settings → Server switch (bound to the old host), an untrust-all, or server-side revocation (password change/reset, TOTP disable — which makes any surviving token inert; the next login just prompts for the code). This supersedes the earlier handoff note that said to clear it on logout.

4.2 Website-handled flows: registration, invite, forgot-password, SSO

These are not rebuilt in the app. The app links out to the website's own pages/API and the user completes them in a Custom Tab, then returns and signs in natively (§4.1):

  • 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).

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

Path note: endpoints below are /api/v1-relative and stay that way. The website's router refactor preserves every URL, and the /api/mobile facade that would have renamed them is deferred — see ../website/API_V2_PLAN.md § Deferred: the /api/mobile facade.

6.1 Public content

  • Home/StatusGET /public/status, GET /public/settings (branding + maintenance banner).
  • News hubGET /public/posts/:category (news | five-on-friday | newsletter | screenshots), detail via GET /public/posts/:category/:idOrSlug.
  • CMS pagesGET /public/pages/:slug (block-based; render the block types the site uses).
  • Wiki — list/categories/tags/detail as above.
  • ContactPOST /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)

  • AccountGET /player/account; PATCH /player/account/username; PATCH /player/account/password; TOTP setup/enable/disable; identities GET / DELETE.
  • Game account linkingPOST /player/shard/link (one-time [link code), POST /player/shard/account (hybrid signup, when enabled), GET /player/shard/accounts.
  • My game dataGET /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. This is an additive v1 change (see §8): the existing /player/account/* and /admin/account/* routes stay for web back-compat; /auth/me/* reuses the same account.controller handlers behind requireAuth (any authenticated role), so there's no logic duplication.

M10 update (2026-07-21): self-service stays role-agnostic under /auth/me/* as above. Separately, the operational admin subset (§1, §10 — moderation, support queue, dashboard/site-mode, content) does call /api/v1/admin/** directly, gated to staff by a new STAFF/ADMIN menu access level. The earlier "the app never references /admin" rule is superseded for these defined staff operations only; validateSession accepts the mobile bearer on those routes and requireRole re-checks every request, so a demoted user loses the surface immediately (the app treats any 403 as authoritative).


7. Degradation & offline

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.
  11. M10 — Native SSO fixes + staff operations (post-v1; decided 2026-07-21). Two threads found during on-device QA:
    • SSO discovery + reachability fixes (app-only, done): the native login screen only rendered provider buttons when GET /auth/providers was non-empty and otherwise fell back to the desktop website login (which can't deep-link a mobile session back → it hung). AuthRepository.ssoProviders() now returns Available / None / Unavailable (retry once); the login screen shows native buttons / a loading hint / a retry, and the website-login fallback is removed. The pending PKCE {state,verifier} is persisted (encrypted PendingSsoStore) so the exchange survives Custom-Tab process death. The nav drawer is now verticalScroll-wrapped so a signed-in session's longer menu (which includes Notifications) can't clip on short screens. Dev testing uses a stub OAuth IdP (website/scripts/dev/), since dev configures no real provider. Verified end-to-end on an emulator.
    • Staff operations (§1, §6.4): a new STAFF/ADMIN menu access level reveals a staff section for admin/moderator, with native screens over the existing /api/v1/admin/**: moderation (POST /admin/shard/{kick,ban,unban,broadcast}), support queue (GET /admin/shard/pages, POST /admin/shard/pages/:id/{respond,close}), dashboard + site-mode (GET /admin/dashboard, PUT /admin/site-mode), and content (news posts under /admin/posts*, wiki categories/tags under /admin/wiki/*). No backend routes added (they already accept bearer + re-check role); shard-write actions degrade gracefully when the sidecar is offline. Excluded: hero/CMS block editor, Discord-bot config, uo-link config, OAuth-provider setup.

Deferred (not a milestone)

  • /api/mobile facade migration + app-version floor — briefly planned as M11 (2026-07-22), now deferred with no app work scheduled. The website's router refactor is being done in place with every URL byte-identical and /api/v1 is not being retired, so the app's ~70 hardcoded api/v1/… endpoints, its SSE path, and its SSO URLs keep working untouched. If the mobile contract ever needs to diverge from web, the migration comes back — starting from a one-line alias mount on the server, not a hand-written delegate layer. Reasoning and revival triggers: ../website/API_V2_PLAN.md § Deferred: the /api/mobile facade.

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 via the existing reverse proxy on its own hostname/path — the container publishes :80 on a host port (NTFY_HOST_PORT, default 2586) because the proxy runs outside the compose network and can only reach a service through a published host port (the same reason app publishes 3000). Both devices and the backend publisher reach ntfy on that public origin.
  • 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).

Gotcha — the PUT body must always carry streams, even when empty. The backend validator requires the field (body('streams').isArray()), so an empty set has to be sent as {"streams":[]}, never {}. kotlinx.serialization omits a property equal to its default (encodeDefaults=false), so a DTO field like streams: List<String> = emptyList() gets dropped from the body when the set is empty — the app then sends {} and the server rejects it 400. Symptom: clearing your last subscription fails with "could not save" and the toggle sticks (any non-empty set still includes the field, so only the final toggle-off breaks). Fix: give the request DTO field no default so kotlinx always encodes it (Android-app fix/notifications-empty-subscriptions). The same trap applies to any "replace the full set" PUT/POST whose empty value equals a DTO default — prefer no default on required request fields.

12. Build & CI (Gitea Actions)

Builds run on the org's existing self-hosted runners (runs-on: ubuntu-latest, same label the other 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.

12.1 Code quality — SonarQube (Runic-Gateway-Android-app)

Analysis runs post-merge and non-blocking (.gitea/workflows/sonarqube.yml); the gate is informational. The project is clean (0 bugs / 0 vulns / 0 hotspots, Maintainability A); the only gate failure is coverage = 0% on new code, which is a reporting gap, not a testing gap — the 25-file JVM unit suite exists, but the source-only Sonar scan never received a JaCoCo report.

Once the wiring below landed (Android-app#26), real coverage measured 16.4% on new code — still under the 50% gate. The plan to raise it (exclude non-unit-testable framework/UI code + test ViewModels/DTOs/repositories) lives in COVERAGE_PLAN.md.

Coverage wiring (the fix):

  • Apply the jacoco plugin in app/build.gradle.kts + a jacocoTestReport task fed by testDebugUnitTest, emitting XML. Exclude generated/DI/Compose scaffolding (**/*_Hilt*, **/*_Factory*, **/di/**, **/*ComposableSingletons*, R/BuildConfig).
  • sonar-project.properties: set sonar.coverage.jacoco.xmlReportPaths to the report, and sonar.coverage.exclusions for pure-@Composable UI (JVM unit tests can't execute composable bodies without Robolectric, so counting those lines would unfairly sink new-code coverage).
  • sonarqube.yml must now run a real Gradle build before the scan (JDK 17 + Android SDK, mirroring pr-checks.yml): ./gradlew testDebugUnitTest jacocoTestReport → then the scan step.

Issue triage (2026-07-22): of 15 code smells, 3 fixed in code — remove an unused import (AdminContentScreen.kt), remove an unused page param (AdminSupportScreen.RespondDialog), and decompose LoginViewModel (cognitive complexity 20). The remaining 12 marked Won't Fix via the Sonar API with rationale: 5 snake_case DTO fields (intentionally mirror the JSON wire contract) and 7 Compose/nav cognitive-complexity + hoisted-callback param-count smells (idiomatic for Jetpack Compose).

13. Open questions (revisit as we go)

Decided (recorded here for context): single shard per install (§3); native auth is 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.
  • ntfy: exact upstream image + pinned tag (Part-1 landed the compose service — confirm the tag), and its reverse-proxy hostname/path. The hostname must land in NTFY_ALLOWED_ORIGINS before M7 Part 2 is end-to-end testable (the app registers an endpoint on that origin; the SSRF guard rejects others). The reverse proxy forwards that hostname to the ntfy container's published host port (NTFY_HOST_PORT, default 2586) — the container publishes :80 because the proxy runs outside the compose network. No backend publish token — decided (the content-free-tickle design does not require one; optional NTFY_PUBLISH_TOKEN is honored if ever set).
  • FCM flavor: build it for the Play release or ship Play on UnifiedPush too? Decide at M8. (The M7 Part 2 PushTransport seam keeps this swap cheap.)
  • Deep-link / share targets for wiki pages and posts (share/open-in-app). Notification-tap deep-links are resolved for M7 Part 2 (stream→screen map, work item 7).
  • iOS: none planned (this is the Android-only choice); revisit only if cross-platform is later required (would change §2 — and push, which would then favor a cross-platform transport).