From 8963269ff0d0e43e752a894c7fe6624df15dcb9c Mon Sep 17 00:00:00 2001 From: wtclaude Date: Sun, 19 Jul 2026 03:14:37 -0500 Subject: [PATCH] docs(android): add Android app design plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- android/PLAN.md | 451 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 451 insertions(+) create mode 100644 android/PLAN.md diff --git a/android/PLAN.md b/android/PLAN.md new file mode 100644 index 0000000..fee7275 --- /dev/null +++ b/android/PLAN.md @@ -0,0 +1,451 @@ +# Android App — Plan + +Status: **planning only, no code yet.** This document is the design contract for the +`RunicGateway/Android-app` repo. It is written before implementation so the API changes it +depends on can be landed in `website/` and `docs/` first. When we start coding, the authoritative +API reference is the committed OpenAPI spec at +`website/server/swagger/swagger-output.json` (regenerated via `npm run swagger`). + +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). + +--- + +## 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)** — SSO stays the website's browser redirect flow (`/auth/sso/*`), + **link-only** (no auto-provisioning). For v1 the app does **not** do one-tap in-app SSO; instead an + SSO user links their identity and sets a password on the website (the existing "set initial password" + path for SSO-provisioned accounts), then uses password login in the app. `GET /auth/sso/providers` + can still be shown so the login screen can direct users to "sign in with … on the website." + - *Possible later enhancement (out of v1):* true in-app SSO via a Custom-Tab flow that hands a + one-time code back to an app link, exchanged for mobile tokens — a small new backend endpoint. Only + build it if password-for-SSO-users proves too clunky. + +### 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. Optional **biometric app-lock** (available + cleanly at API 29) gates access to the stored session — decide at M3. +- **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).** Mount the existing + `account.controller` self handlers behind `requireAuth` (any role), so the app has one self surface + and never touches `/admin`. Keep the old `/player/account/*` + `/admin/account/*` routes for web + back-compat. New canonical routes: + - `GET /auth/me` — current `{ id, username, role }` (already exists; 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`. + - Regenerate Swagger; add `#swagger` annotations for each. +2. **Password reset — build on backend + web front end FIRST (a prerequisite, not app scope).** + No reset route exists anywhere today. Build the full platform flow in `website/` **before** app work + starts: request-reset (email a signed, single-use, expiring token) → reset page + endpoint (verify → + set password → revoke sessions), reusing the mail sender + `secretBox`/hashing. The app then just + links users to that website page (§4.2) — **no mobile reset endpoint.** Regenerate Swagger; document + in `BACKEND_DESIGN.md`. + - 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 (M6, not M1–M5). +4. **Version/health surfacing** — ensure `/public/status` (or a light `/public/version`) exposes + enough for the app's first-run probe and version-mismatch guard. +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** — confirm `/public/settings` returns the `BRAND_*` values (name, colors, + logo/hero/favicon URLs) the app needs to theme itself per shard. + +No `link/` or `servuo-plugins/` changes are expected — the app is downstream of the website only. + +--- + +## 9. Milestones + +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**: 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**: shard widgets + SSE live stream with reconnect/degradation. +4. **M3 — Auth (§4)**: 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). Optional biometric + app-lock. *Prerequisite:* the website password-reset flow (§8) is already built. +5. **M4 — Player self-service & game data**: account management (via `/auth/me/*`), game-account + linking, own roster/characters/vendors/houses/sales — **text-only** presentation (§6.3). +6. **M5 — Polish & first release**: settings (server switch = hard reset), version-mismatch guard, + release build hardening (HTTPS-only, no token logging). No offline cache in v1 (§7). **Ship v1 as a + signed APK attached to a Gitea release** (see §10). +7. **M6 — 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. +8. **M7 — Google Play**: Play Console listing, signing/upload key, and (optionally) an FCM build flavor + — after the direct-APK release is stable. + +--- + +## 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 M6 — 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. +- **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`, M5+): 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). + +**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 — confirm whether any deep-link-back is wanted at all for v1. +- ntfy: exact upstream image + pinned tag, its reverse-proxy hostname/path, and whether to add a + backend publish token (optional hardening — the content-free-tickle design does not require one). +- FCM flavor: build it for the Play release or ship Play on UnifiedPush too? Decide at M7. +- Deep-link / share targets for wiki pages, posts, and notification taps. +- 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). -- 2.49.1