Compare commits
10 Commits
chore/open
...
099e5b0af4
| Author | SHA1 | Date | |
|---|---|---|---|
| 099e5b0af4 | |||
| c59ff9270b | |||
| 033292504e | |||
| 8963269ff0 | |||
| e889700227 | |||
| 50244e5c2b | |||
| 4f0c282f3f | |||
| f1aa65cc17 | |||
| 8a3e37ae73 | |||
| 363eb810da |
478
android/PLAN.md
Normal file
478
android/PLAN.md
Normal file
@@ -0,0 +1,478 @@
|
|||||||
|
# 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).
|
||||||
|
|
||||||
|
### 2.1 Build workflow: Kotlin first, then design-led UI
|
||||||
|
The app is built in two passes. **First**, the functional Kotlin is written — the layering above with
|
||||||
|
placeholder/functional Compose screens: navigation, ViewModels, repositories, the generated API
|
||||||
|
client, auth/token handling, and every screen wired to its endpoints and working end-to-end. **Then**,
|
||||||
|
once that Kotlin code is done, **Claude Design produces the front-end design** for the app, and
|
||||||
|
**Claude Code implements the final UI (Compose screens, theming, components) according to that
|
||||||
|
design.** The design pass restyles and refines the already-working screens; it does not change the
|
||||||
|
architecture, data flow, or endpoint contracts established in the first pass. Keeping strings
|
||||||
|
externalized and branding data-driven (§2, §3) from the start is what lets the design pass reskin
|
||||||
|
freely without touching logic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Base URL: first-run + settings
|
||||||
|
|
||||||
|
The app is **brandable to any shard's site** (one site per install), so the API host is not
|
||||||
|
compiled in.
|
||||||
|
|
||||||
|
- **First run (before init):** a mandatory **"Connect to your shard's website"** screen asks for the
|
||||||
|
site base URL. The app validates it by calling `GET /api/v1/public/status` (and reads
|
||||||
|
`/public/settings` for branding: name/colors/logo). Only on a successful, well-formed response is
|
||||||
|
the URL persisted to DataStore and the app allowed to initialize its main UI.
|
||||||
|
- Accept `https://host[/base]`; normalize/trim; require HTTPS in release builds (allow HTTP only in
|
||||||
|
debug for local dev against `127.0.0.1:3000`).
|
||||||
|
- Failure states: unreachable, non-2xx, not-a-Runic-Gateway-site (missing expected `/public/status`
|
||||||
|
shape), TLS error — each gets a clear retry message. Nothing else in the app runs until this
|
||||||
|
succeeds.
|
||||||
|
- **Settings:** the base URL is editable later under **Settings → Server**. Changing it is a
|
||||||
|
hard reset of session state: clear stored tokens, drop cached content, re-run the validation probe,
|
||||||
|
and return to a signed-out state against the new host.
|
||||||
|
- **Version guard:** the backend is versioned; surface a clear "app/site version mismatch" state if a
|
||||||
|
future protocol/version header disagrees, rather than mis-rendering.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Authentication & token handling
|
||||||
|
|
||||||
|
**Design rule (decided): credential/identity flows live on the website, not in the app.** The app
|
||||||
|
implements **only native username/password (+TOTP) login**. Registration, invite acceptance,
|
||||||
|
forgot/reset password, and SSO all **run through the website's API + web front end** — the app hands
|
||||||
|
off to the website in a browser (Chrome Custom Tab) and the user returns to sign in. This keeps every
|
||||||
|
account-provisioning, OAuth, and password path in one audited place rather than duplicated (and
|
||||||
|
security-reviewed twice), and it means **no new mobile-facing auth endpoints** are required for v1.
|
||||||
|
Password reset is being built on the backend + web front end **before** app work begins (§8), so it is
|
||||||
|
simply available in that hand-off, not app scope.
|
||||||
|
|
||||||
|
### 4.1 Username + password (+ TOTP) — the app's only native auth, ready today
|
||||||
|
Uses the existing **mobile bearer** surface, no backend changes:
|
||||||
|
|
||||||
|
- `POST /auth/mobile/login` `{ username, password, code? }` →
|
||||||
|
`{ accessToken, refreshToken, expiresIn, user: { id, username, role } }`.
|
||||||
|
- **Single-request 2FA:** a `401 { totpRequired: true }` means re-submit with `code`. The login
|
||||||
|
screen reveals a code field on that response.
|
||||||
|
- Respect `429` (backoff / rate-limit) with a friendly "try again shortly" state — login is guarded
|
||||||
|
by per-IP backoff → slow-down → hard cap on the server.
|
||||||
|
- `POST /auth/mobile/refresh` `{ refreshToken }` → new pair. **Refresh tokens are single-use and
|
||||||
|
rotated**: store the new pair atomically; a failed refresh (401) means the session is dead → sign
|
||||||
|
out and return to login. An OkHttp `Authenticator`/interceptor performs a one-shot refresh on a
|
||||||
|
`401` from a bearer call, with a mutex so concurrent 401s trigger only one refresh.
|
||||||
|
- `POST /auth/mobile/logout` `{ refreshToken?, all? }` (requires bearer) — revoke this session or all
|
||||||
|
sessions. Called on user logout and on "sign out everywhere."
|
||||||
|
### 4.2 Website-handled flows: registration, invite, forgot-password, SSO
|
||||||
|
These are **not** rebuilt in the app. The app links out to the website's own pages/API and the user
|
||||||
|
completes them in a Custom Tab, then returns and signs in natively (§4.1):
|
||||||
|
- **Register / accept invite** — the app opens the website's register / `…/invite/:token` pages. Invite
|
||||||
|
emails already link to the website. After the account exists, the user signs into the app with their
|
||||||
|
new username + password. (No mobile register/invite endpoints needed.)
|
||||||
|
- **Forgot / reset password** — the app links to the website's reset page (the flow being built in §8
|
||||||
|
before app work). The user resets there, then signs into the app. (No mobile reset endpoint needed.)
|
||||||
|
- **SSO (Google / Discord / OIDC)** — 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 (M7, not M1–M6).
|
||||||
|
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
|
||||||
|
|
||||||
|
**Two passes (§2.1).** M0–M4 are 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 is the
|
||||||
|
design pass**: once the functional Kotlin is done, Claude Design produces the front-end design and
|
||||||
|
Claude Code implements the final UI to it. Polish/release, push, and Play (M6–M8) 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). Optional biometric app-lock. *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 M1–M4 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.
|
||||||
|
7. **M6 — 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).
|
||||||
|
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.
|
||||||
|
9. **M8 — 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 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.
|
||||||
|
- **FCM stays optional and Play-only.** If/when a Play build wants it, add FCM as a **build flavor**;
|
||||||
|
the direct-APK flavor stays Google-free. The backend fan-out is **transport-agnostic** and dispatches
|
||||||
|
to whatever endpoint a device registered, so adding FCM later touches no core logic.
|
||||||
|
|
||||||
|
### ntfy deployment — fully automated, zero interactive setup (hard requirement)
|
||||||
|
- Runs as an **additional service in `website/docker-compose.yml`** (the compose *pulls* images and
|
||||||
|
never builds — ntfy is a pinned upstream image, so this fits that model). Confirm the exact image
|
||||||
|
path/tag at implementation.
|
||||||
|
- **All config is declarative** — a committed `ntfy` config file and/or `NTFY_*` env vars baked into
|
||||||
|
compose. No `docker exec`, no interactive `ntfy user add`, no post-deploy manual steps. Bringing the
|
||||||
|
stack up provisions a working push relay. Reachable to devices via the existing reverse proxy on its
|
||||||
|
own hostname/path; internal-only for the backend publisher.
|
||||||
|
- **No per-user ntfy accounts to administer.** The security model (below) removes the need for ntfy ACL
|
||||||
|
provisioning, which is exactly what keeps setup interaction-free. ntfy topics are the random,
|
||||||
|
unguessable endpoints UnifiedPush hands out; the backend treats ntfy as an **untrusted relay**.
|
||||||
|
|
||||||
|
### Backend (additive, v1)
|
||||||
|
- `POST /auth/me/devices` — register a device: `{ transport, endpoint, platform }` where `endpoint` is
|
||||||
|
the UnifiedPush/ntfy URL the distributor gave the app (or an FCM token for a Play/FCM build). `DELETE
|
||||||
|
/auth/me/devices/:id` — unregister. Devices belong to the authenticated user.
|
||||||
|
- `GET /notifications/streams` — catalog of subscribable streams + which require a linked game account.
|
||||||
|
- `GET|PUT /auth/me/notifications/subscriptions` — the user's selected streams (per-user; applied to
|
||||||
|
all their devices).
|
||||||
|
- **Fan-out worker** hangs off the existing event dispatcher (`website` `utils/shardIngest.js`) — the
|
||||||
|
same event source that already feeds the SSE channels — matches events against subscriptions and
|
||||||
|
**publishes a content-free tickle** (see below) to each matching device's endpoint. Store endpoints
|
||||||
|
per device. Any secret (an ntfy publish token, or an FCM server key if that flavor is used) is
|
||||||
|
encrypted at rest via `utils/secretBox.js`, like the other secrets.
|
||||||
|
|
||||||
|
### Stream catalog (initial)
|
||||||
|
- **Public / opt-in** (no account needed): news posts, server up/down, IDOC warnings, champion-spawn
|
||||||
|
starts, governor elections.
|
||||||
|
- **Personal** (require a linked game account; delivered only to the owner): *your* vendor sold an
|
||||||
|
item, *your* house entered IDOC, a login to *your* account.
|
||||||
|
|
||||||
|
### Security boundary (hard requirement)
|
||||||
|
The ntfy relay is treated as **untrusted infrastructure**, and the design makes that safe:
|
||||||
|
|
||||||
|
- **Content-free tickles.** A push payload carries **no sensitive data** — only a stream id and an
|
||||||
|
opaque reference (e.g. `{ stream: "vendor.sale", ref: "…" }`). On receipt the app wakes and **pulls
|
||||||
|
the actual content over the authenticated, ownership-checked API** (`/auth/me/*`, `/player/shard/*`).
|
||||||
|
So even if an ntfy topic name leaked, nothing meaningful leaks with it, and no data reaches a device
|
||||||
|
that its user isn't already entitled to fetch. This is what lets ntfy be automated with no per-user
|
||||||
|
ACLs while still honoring the security rules.
|
||||||
|
- **Same allowlist split as the SSE streams.** Sensitive kinds (staff audit, cheat detection, login
|
||||||
|
attempts, IPs) are never fanned out to push at all — the publisher applies the identical public/safe
|
||||||
|
allowlist used by the SSE dispatcher.
|
||||||
|
- **Personal events are owner-keyed.** A personal tickle (your vendor sold, your house IDOC) is
|
||||||
|
published **only** to the endpoints of the owning user, decided by the same ownership check as the
|
||||||
|
`/player/shard/*` reads — a device never receives another user's events.
|
||||||
|
- **Transport hardening.** ntfy served over TLS via the reverse proxy; the backend→ntfy publish is
|
||||||
|
internal. Endpoints are unguessable random topics; unregister on logout / token revocation.
|
||||||
|
|
||||||
|
### App
|
||||||
|
- A **Notifications** settings screen lists the catalog with per-stream toggles; personal streams are
|
||||||
|
disabled/greyed until the user has a linked game account. Registration happens after login; toggles
|
||||||
|
write to `/auth/me/notifications/subscriptions`. Tapping a notification deep-links to the relevant
|
||||||
|
screen (§ open item below).
|
||||||
|
|
||||||
|
## 12. Build & CI (Gitea Actions)
|
||||||
|
|
||||||
|
Builds run on the org's existing self-hosted runners (`runs-on: ubuntu-latest`, same label the other
|
||||||
|
repos use), on a bare `ubuntu:latest` container.
|
||||||
|
|
||||||
|
- **Toolchain:** JDK **17** (temurin) for Android Gradle Plugin 8.x; Android SDK installed in-CI via
|
||||||
|
`android-actions/setup-android@v3` (cmdline-tools + license acceptance). Cache `~/.gradle` and the SDK.
|
||||||
|
- **Bare-image gotcha:** `ubuntu:latest` lacks `git`/`curl`/`unzip` that `actions/checkout` and
|
||||||
|
`sdkmanager` need — first step `apt-get install -y git curl unzip`. (Faster option once builds are
|
||||||
|
frequent: run the job under a prebuilt Android-SDK `container:` image so nothing installs per-run.)
|
||||||
|
- **PR gate** (`.gitea/workflows/pr-checks.yml`, on PR → `main`): `./gradlew lint test assembleDebug`.
|
||||||
|
Debug builds are auto-signed, so the gate needs no secrets. Mirrors `website/`'s pre-merge gate.
|
||||||
|
- **Release** (`.gitea/workflows/release.yml`, M6+): build a **signed release APK** and attach it to a
|
||||||
|
Gitea release (mirrors `link/`'s release job). The **keystore is a base64 Gitea Actions secret**
|
||||||
|
decoded in CI; store/key passwords are secrets. The keystore never lives in the repo. Keep the
|
||||||
|
signing identity stable from the first release (Play later requires consistency).
|
||||||
|
- Semantic `versionName` + monotonic `versionCode`; tag releases.
|
||||||
|
|
||||||
|
## 13. Open questions (revisit as we go)
|
||||||
|
|
||||||
|
**Decided (recorded here for context):** single shard per install (§3); native auth is
|
||||||
|
password+TOTP only, with registration/invite/reset/SSO **handled by the website** (§4); **password
|
||||||
|
reset built on backend + web first**, before app work (§8); minSdk 29, compile/target 35 (§2); no
|
||||||
|
telemetry in v1 (§2); strings externalized from day one, English-only bundled (§2); **text-only** game
|
||||||
|
data in v1, pretty paperdoll is future (§6.3); **no offline cache in v1** (§7); push via self-hosted
|
||||||
|
ntfy / UnifiedPush (§11).
|
||||||
|
|
||||||
|
**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 M8.
|
||||||
|
- 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).
|
||||||
128
website/MODERATION_APPEALS.md
Normal file
128
website/MODERATION_APPEALS.md
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
# Runic Gateway Website — Moderation Appeals (Phase 6c/6d)
|
||||||
|
|
||||||
|
> Website feature branch: **`feature/moderation-appeals`**. Builds on the moderation
|
||||||
|
> dashboard (Phase 6a/6b) and the Discord bot's `mod_actions` log. Companion to
|
||||||
|
> [website-README.md](website-README.md) (overview) and
|
||||||
|
> [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (base API contract).
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
A player whose linked Discord identity was **banned** or **muted** — an action
|
||||||
|
recorded in the bot's `mod_actions` log — can open an **appeal** from the player
|
||||||
|
portal and track its status. Staff (**admin** or **moderator** role) work the
|
||||||
|
appeal from an **appeals queue** in the admin moderation section: claim it, then
|
||||||
|
resolve it **approved** or **denied** with a written staff response.
|
||||||
|
|
||||||
|
When staff **approve** a ban/mute appeal, the website makes a best-effort call to
|
||||||
|
the Discord bot's internal API to actually lift the ban / clear the timeout in
|
||||||
|
Discord, and the bot posts a mod-log embed ("Appeal approved"). This is
|
||||||
|
**best-effort**: if the bot is unreachable the appeal still resolves as approved,
|
||||||
|
the reversal is recorded as failed, and staff can reverse the sanction manually in
|
||||||
|
Discord.
|
||||||
|
|
||||||
|
Only **ban** and **mute** actions are appealable — the sanctions that have an
|
||||||
|
ongoing effect. Warnings/kicks and similar one-shot actions are not.
|
||||||
|
|
||||||
|
## 2. Ownership & eligibility
|
||||||
|
|
||||||
|
- **`appeals` is a server-owned table** — only the website reads/writes it. It
|
||||||
|
references the bot-owned `mod_actions` log by a plain id column
|
||||||
|
(`mod_action_id`); there is **no hard cross-owner foreign key** between the two
|
||||||
|
databases, so the reference is validated in application code (same pattern as
|
||||||
|
the rest of the uo-link / bot integration, where the two services never share a
|
||||||
|
live FK).
|
||||||
|
- **Eligibility** — the appellant must be a **logged-in player** whose linked
|
||||||
|
Discord identity (`user_identities`, `provider = 'discord'`) matches the
|
||||||
|
`mod_actions` row's target. A player cannot open an appeal for someone else's
|
||||||
|
action, and an unlinked player has nothing eligible to appeal.
|
||||||
|
- **One active appeal per action** — only one `pending` / `under_review` appeal is
|
||||||
|
allowed for a given `mod_action_id` at a time; a second attempt while one is
|
||||||
|
already open is rejected.
|
||||||
|
|
||||||
|
## 3. Appeal lifecycle
|
||||||
|
|
||||||
|
```
|
||||||
|
pending ──▶ under_review ──▶ approved
|
||||||
|
└─▶ denied
|
||||||
|
|
||||||
|
pending ──▶ withdrawn
|
||||||
|
under_review ──▶ withdrawn
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`pending`** — submitted by the player, not yet claimed.
|
||||||
|
- **`under_review`** — claimed by a staffer (the claiming admin/moderator is
|
||||||
|
stamped on the row).
|
||||||
|
- **`approved`** / **`denied`** — resolved by staff with an optional
|
||||||
|
`staff_response`. Approving a ban/mute appeal triggers the Phase 6d reversal
|
||||||
|
(§5).
|
||||||
|
- **`withdrawn`** — the player pulled the appeal back before it was resolved.
|
||||||
|
|
||||||
|
`reversal_status` (only meaningful on an approved ban/mute appeal) is one of
|
||||||
|
`none` (not attempted / not applicable), `done`, or `failed`. No new
|
||||||
|
`mod_actions` row is written for a reversal — it modifies the *original* action's
|
||||||
|
standing rather than logging a new one.
|
||||||
|
|
||||||
|
## 4. API — player (role: `player`)
|
||||||
|
|
||||||
|
Base `/api/v1/player/appeals`.
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/player/appeals` | The caller's own appeals. |
|
||||||
|
| GET | `/player/appeals/eligible` | The caller's ban/mute actions with no active appeal (empty if they have no linked Discord identity). |
|
||||||
|
| POST | `/player/appeals` | Open an appeal — `{ mod_action_id, submitted_text }`. `403` if the action isn't the caller's, `400` if the action isn't a ban/mute, `409` if one is already open for it. |
|
||||||
|
| POST | `/player/appeals/:id/withdraw` | Withdraw an appeal that hasn't been resolved yet. |
|
||||||
|
|
||||||
|
## 5. API — staff (role: `admin` or `moderator`)
|
||||||
|
|
||||||
|
Base `/api/v1/admin/moderation/appeals`, alongside the existing moderation
|
||||||
|
section.
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/admin/moderation/appeals?status=&limit=&offset=` | The queue. Defaults to `pending` + `under_review`; pass `status=all` or a specific status to filter. |
|
||||||
|
| GET | `/admin/moderation/appeals/:id` | One appeal. |
|
||||||
|
| POST | `/admin/moderation/appeals/:id/claim` | `pending` → `under_review`, stamping the claiming staffer. |
|
||||||
|
| POST | `/admin/moderation/appeals/:id/resolve` | `{ status: 'approved' \| 'denied', staff_response? }`. On an approved ban/mute, triggers the Discord reversal (§6). |
|
||||||
|
| GET | `/admin/moderation/user/:discordId/appeals` | A user's appeals — shown as a tab on the per-user moderation history page. |
|
||||||
|
|
||||||
|
`resolve` returns a `reversal` object describing what happened:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"reversal": {
|
||||||
|
"attempted": true,
|
||||||
|
"ok": true,
|
||||||
|
"reversal_status": "done", // "none" | "done" | "failed"
|
||||||
|
"bot_status": 200,
|
||||||
|
"error": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Auto-reversal (Phase 6d)
|
||||||
|
|
||||||
|
On `resolve` with `status: 'approved'` against a ban/mute appeal, the website
|
||||||
|
calls the Discord bot's internal API:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /internal/mod-reverse
|
||||||
|
```
|
||||||
|
|
||||||
|
— gated by the same shared-secret scheme as the existing `/internal/announce`
|
||||||
|
call. The bot lifts the ban / clears the timeout for the target and posts an
|
||||||
|
"Appeal approved" embed to its mod log.
|
||||||
|
|
||||||
|
The call is **best-effort**: the appeal resolution itself always completes
|
||||||
|
(the appeal is marked `approved` and the staff response is saved) regardless of
|
||||||
|
whether the bot answers. If the bot is down or the call otherwise fails,
|
||||||
|
`reversal_status` is recorded as `failed` and staff are expected to reverse the
|
||||||
|
sanction by hand in Discord; the `reversal` object in the `resolve` response
|
||||||
|
surfaces `ok: false` and an `error` so the UI can flag it. Denied appeals never
|
||||||
|
attempt a reversal.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
See [website-README.md](website-README.md) for the moderation dashboard's place
|
||||||
|
in the wider site, and [BACKEND_DESIGN.md](BACKEND_DESIGN.md) for the base API
|
||||||
|
conventions (auth, error shapes, response codes) these endpoints follow.
|
||||||
@@ -10,6 +10,7 @@ A full-stack app in one repo:
|
|||||||
- **Frontend** — React + Vite single-page app (public site, wiki, and the admin panel), dark "gothic" theme (Cinzel + Georgia).
|
- **Frontend** — React + Vite single-page app (public site, wiki, and the admin panel), dark "gothic" theme (Cinzel + Georgia).
|
||||||
- **Deploy** — Docker Compose (app + MariaDB) behind a Pangolin reverse proxy. Express serves the built SPA in production.
|
- **Deploy** — Docker Compose (app + MariaDB) behind a Pangolin reverse proxy. Express serves the built SPA in production.
|
||||||
- **Shard link** — a live bridge to the in-game ServUO shard through the **uo-link** sidecar ([RunicGateway/link](https://gitea.whitlocktech.com/RunicGateway/link)): the site ingests a live event feed and makes server-side REST calls to show shard status, economy, staff presence, IDOCs, live activity, and per-character sheets. See [Shard integration (uo-link)](#shard-integration-uo-link).
|
- **Shard link** — a live bridge to the in-game ServUO shard through the **uo-link** sidecar ([RunicGateway/link](https://gitea.whitlocktech.com/RunicGateway/link)): the site ingests a live event feed and makes server-side REST calls to show shard status, economy, staff presence, IDOCs, live activity, and per-character sheets. See [Shard integration (uo-link)](#shard-integration-uo-link).
|
||||||
|
- **Moderation appeals** — a player whose linked Discord identity was banned or muted (per the bot's `mod_actions` log) can open an appeal from the player portal; staff claim and resolve appeals from an admin queue, and approving a ban/mute appeal best-effort reverses it in Discord automatically. See [MODERATION_APPEALS.md](MODERATION_APPEALS.md).
|
||||||
|
|
||||||
The design reference is [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (API contract, schema, security).
|
The design reference is [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (API contract, schema, security).
|
||||||
|
|
||||||
@@ -331,7 +332,7 @@ character**; players and editor/moderator staff are limited to their own linked
|
|||||||
|
|
||||||
| Surface | Endpoints | Who | Data |
|
| Surface | Endpoints | Who | Data |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| **Public** | `/api/v1/public/shard/*` (`status`, `feed`, `economy`, `online`, `idoc`, `stream`) | anyone | Shard up/down, gold-supply series, IDOC houses, a curated live feed, and **"Staff online"** — only players whose account is linked to a **staff** user (admin/editor/moderator), shown with name + map location. Linked *players* are never listed publicly; no vitals or account are exposed. |
|
| **Public** | `/api/v1/public/shard/*` (`status`, `feed`, `economy`, `online`, `idoc`, `stream`) | anyone | Shard up/down, gold-supply series, IDOC houses, a curated live feed, and **"Staff online"** — only players whose account is linked to a **staff** user (admin/editor/moderator), shown by name. Their in-game **map location is only included for admin/moderator viewers** — for players and the public it is stripped from the payload entirely (server-enforced, not just hidden in the UI). Linked *players* are never listed publicly; no vitals or account are exposed. |
|
||||||
| **Player** | `/api/v1/player/shard/*` (`link`, `accounts`, `roster/:account`, `vendors/:account`, `char/:serial`, `sales`) | logged-in player | Their own linked accounts: character rosters, character sheets, player-vendor snapshots, and recent vendor sales. |
|
| **Player** | `/api/v1/player/shard/*` (`link`, `accounts`, `roster/:account`, `vendors/:account`, `char/:serial`, `sales`) | logged-in player | Their own linked accounts: character rosters, character sheets, player-vendor snapshots, and recent vendor sales. |
|
||||||
| **Admin** | `/api/v1/admin/shard/*` (self-linking, same as player) · `/api/v1/admin/uo-link/*` (`config`, `towncrier`, `stream`) | staff / admin | Staff link their own accounts like players; **admins** additionally read *any* character's data, edit the sidecar connection config, publish/remove **town-crier** messages, and subscribe to the full event stream (incl. audit/cheat). |
|
| **Admin** | `/api/v1/admin/shard/*` (self-linking, same as player) · `/api/v1/admin/uo-link/*` (`config`, `towncrier`, `stream`) | staff / admin | Staff link their own accounts like players; **admins** additionally read *any* character's data, edit the sidecar connection config, publish/remove **town-crier** messages, and subscribe to the full event stream (incl. audit/cheat). |
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user