Update M6 docs to reflect release.yml mirroring link/'s engine (auto version +
changelog + tag + signed APK + Gitea release on merge to main), not a v* tag
trigger. Note REGISTRY_USER/REGISTRY_TOKEN + the main-push requirement, and the
derived monotonic versionCode. §9 status, §9 M6/M7 milestones, §12.
Co-Authored-By: Claude <noreply@anthropic.com>
Mark M6 landed (RunicGateway/Android-app#11): signed-APK release plumbing
(R8 minify + resource shrink, release signingConfig from a gitignored keystore,
release.yml on a v* tag), the §3 version-mismatch guard, and the default brand
app icons (deep-indigo medallion). Record the decision to descope the optional
biometric app-lock from v1 (tokens already encrypted at rest) across §4.3, the
M3 note, §9, and §13.
Co-Authored-By: Claude <noreply@anthropic.com>
Mark the M5 shard-website design pass complete in docs/android/PLAN.md,
matching RunicGateway/Android-app#10: dark-only theme (deep blue-black
surfaces, slate accent, Cinzel display face), reusable pill/label/card/meter
components, and retained per-shard brand-accent seeding — restyling the
working M1–M4 screens with no architecture, data-flow, endpoint, or DTO
change. Updates the status header, the §9 build-progress record, and the
milestone list (M0–M5 landed; M6 release hardening next).
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
Marks M4 done in android/PLAN.md §9 build-progress and flips the top
status to "M0–M4 landed; M5 (design pass) next." Documents the account
self-service (/auth/me/account*), game-account linking, and text-only
own game-data screens shipped in RunicGateway/Android-app#9 — a pure
consumer of the existing bearer API, no backend/protocol change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
Records the M3 functional auth pass in docs/android/PLAN.md: native
username/password (+TOTP) login, EncryptedSharedPreferences token storage,
the refresh-on-401 authenticator, /auth/me resume re-validation, the
declarative access-level menu + My Account, and the Custom-Tab hand-offs for
register / forgot-password / SSO. Also records the decision to defer the
optional biometric app-lock to M6 (tokens are already encrypted at rest).
No backend/API change accompanied M3 — the app is a pure consumer of the
existing mobile bearer + /auth/me surface — so no Swagger regeneration.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
Mark M2 done in the build-progress section: the public shard widgets over
/public/shard/* (hub + champ/guild/governor/house boards) and the live SSE
feed with self-driven reconnect/backoff (Android-app#7). Update the status
line to "M0–M2 landed; M3 next". No API change — the app consumes the
existing public shard surface.
Co-Authored-By: Claude <noreply@anthropic.com>
Mark M0+M1 done in the build-progress header and next up M2. Summarize
the M1 functional pass (first-run connect, runtime base URL + host
interceptor, layered ApiResult stack, brand-seeded theming, public
content/wiki/pages/contact screens) and record the deliberate
hand-written-vs-openapi-generated API-client deviation from §2 and its
rationale (swagger-autogen schemas are meta-descriptive, not
codegen-clean). Tracks RunicGateway/Android-app#6.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
Update the PLAN.md status header now that the Android-app repo scaffold
(RunicGateway/Android-app#2) is in: Gradle+Compose+Hilt skeleton, version
catalog, and CI. Records M0 done and points at M1 (functional Kotlin pass).
Co-Authored-By: Claude <noreply@anthropic.com>
Counterpart to RunicGateway/website#77.
- BACKEND_DESIGN.md: /public table now documents the new GET /public/version
(DB-free identity/version), the version block on /public/status, and the brand
block on /public/settings (per-shard theming: name/accent/logo/hero/favicon).
- android/PLAN.md: mark §8 item 4 (version/health) and item 6 (branding) DONE and
update the prerequisite-progress summary — all v1 prerequisites are now done;
only push notifications (item 3) remains and is post-v1 (M7). App M0–M4 unblocked.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
Counterpart to RunicGateway/website#76 (role-agnostic /auth/me/* self surface).
- BACKEND_DESIGN.md: add the /auth/me/account* rows to the /auth API contract and
a note that the surface reuses account.controller behind requireAuth (any role),
so a client manages its own account without touching /admin.
- android/PLAN.md: mark §8 item 1 (role-agnostic self-service) DONE and update the
prerequisite-progress summary; version/health (item 4) and branding (item 6)
remain open, push (item 3) is post-v1.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
§8 item 2 (password reset — the "build FIRST before app work" prerequisite)
shipped in RunicGateway/website#75 + docs#8. Mark it done, note the shipped
design (opaque token stored as a sha256 hash in password_resets, mirroring
user_invites, rather than a signed JWT), and record which §8 items remain open.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
Add /auth/password/forgot and /auth/password/reset/:token to the API
contract and the password_resets table to the schema section, matching the
website change (RunicGateway/website feat/password-reset). Notes the
no-enumeration behaviour, single-use hashed-token model, and that the Android
app hands off to the web reset page (PLAN.md §4.2).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
Record the two-pass build process: functional Kotlin first (M0-M4), then a Claude Design pass producing the front-end design that Claude Code implements as the final UI (new M5). Add §2.1 documenting the workflow and reconcile §9 milestones (insert M5 design pass, renumber Polish/Push/Play to M6-M8) plus the milestone cross-references in §8, §11, §12, and §13.
Co-Authored-By: Claude <noreply@anthropic.com>
Add docs/android/PLAN.md — the design contract for the RunicGateway/Android-app
repo (planning only, no app code yet).
Scope: native Kotlin + Jetpack Compose client of the website v1 API. Public
content + public shard widgets (incl. SSE), native username/password + TOTP
login, player self-service via a new role-agnostic /auth/me/* surface, and a
player's own shard/game data. Excludes every admin/management console (hero
editor, auth/provider admin, Discord bot, shard/uo-link ops).
Key decisions captured: stay on v1 (all additions are additive, no v2);
registration/invite/reset/SSO are website-handled hand-offs, not native screens;
password reset is built on the backend + web front end first; single shard per
install; minSdk 29; no telemetry and no offline cache in v1; text-only game data
(paperdoll is future); strings externalized from day one; push via a self-hosted
ntfy/UnifiedPush service with content-free tickles that keep the relay untrusted;
Gitea Actions build on ubuntu:latest with a signed-APK release.
Co-Authored-By: Claude <noreply@anthropic.com>
The "What each audience sees" table said the public "Staff online" list
is shown "with name + map location". Location is now privileged: the
server includes map/coords only for admin/moderator callers and strips
them from the payload for players and the public. Update the wording to
match (RunicGateway/website#72).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XmHdsbnLzDMAVQkAoTQSBe
Status: **M0–M6 landed; the functional build, design pass, and release mechanics are complete (the auto-release engine cuts a tagged, signed APK on merge to `main`; M7 push notifications next).** 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
| 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
| expires_at | DATETIME NOT NULL | ~1h TTL, enforced in the model on top of this |
| created_at / used_at | DATETIME | |
Same "store only the hash of an opaque token" pattern as `user_invites` / `mobile_refresh_tokens`.
A DB read never yields a usable reset link. See §4 `/auth/password/*`.
---
---
## 4. API contract
## 4. API contract
@@ -154,16 +168,36 @@ accepts `Authorization: Bearer` for API testing).
|---|---|---|---|---|
|---|---|---|---|---|
| POST | `/login` | — (rate-limited) | `{username,password}` | verify, set cookie, log `auth.login`, update `last_login_at` |
| POST | `/login` | — (rate-limited) | `{username,password}` | verify, set cookie, log `auth.login`, update `last_login_at` |
| POST | `/logout` | cookie | — | clear cookie |
| POST | `/logout` | cookie | — | clear cookie |
| GET | `/me` | cookie | — | current user (no hash) or 401 — client bootstraps auth state |
| GET | `/me` | cookie / bearer | — | current user (no hash) or 401 — client bootstraps auth state |
| POST | `/password/forgot` | — (rate-limited) | `{email}` | email a single-use, ~1h reset link to **every active account** on the address; **always** returns the same generic 200 (no account enumeration). Email is non-unique, so several accounts may each get a link naming their username. Logs `account.password.reset.request`. |
| GET | `/password/reset/:token` | — | — | validate a link → `{username}` for the form, else 404 (never distinguishes expired/used/never-existed) |
| POST | `/password/reset/:token` | — (rate-limited) | `{password}` | consume the single-use link, rotate the hash, and revoke **all** sessions (web cutoff + mobile refresh tokens). Does **not** sign the user in — they log in fresh (so a 2FA account still passes TOTP). Logs `account.password.reset.complete`. |
| PATCH | `/me/account/username` | cookie / bearer (rate-limited) | `{username}` | change own username; re-issues the caller's session |
| PATCH | `/me/account/password` | cookie / bearer (rate-limited) | `{newPassword, currentPassword?}` | change/set own password (current required unless the account has none); revokes other sessions, keeps the caller's |
| POST | `/me/account/totp/setup` · `…/enable` · `…/disable` | cookie / bearer | `{code}` on enable/disable | self 2FA enrollment (disable needs a valid current code, not a password) |
| GET | `/me/account/identities` · DELETE `…/:provider` | cookie / bearer | — | list / unlink own SSO identities |
No public `register`. First admin is bootstrapped by `seed.js` from env (see §6). Further
**Role-agnostic self-service (`/auth/me/*`).** The canonical "me" surface for **every** authenticated
admins are created under`/admin/users`.
role. It reuses the exact `account.controller` handlers as `/player/account/*` and`/admin/account/*`
(no logic duplication) behind `requireAuth`**only** — any active account, never a specific role. This
lets a client (the Android app) manage its own account through one surface without ever touching
`/admin` (docs/android/PLAN.md §6.4). The older `/player/account/*` + `/admin/account/*` routes stay
for web back-compat.
**Password reset.** Uses the same audited pattern as `user_invites`: an opaque 32-byte token
whose **sha256 hash only** is stored in `password_resets`, single-use and short-lived (~1h). It
also serves SSO-only accounts (null `password_hash`) as their "set an initial password" path. The
reset link points at the web front end (`/account/reset/:token`); the Android app hands off here
rather than shipping its own reset screen (docs/android/PLAN.md §4.2). First admin is bootstrapped
by `seed.js` from env (see §6); further staff are created under `/admin/users` or via email invites.
### /public (public.routes.js → public.controller.js) — all GET, no auth
### /public (public.routes.js → public.controller.js) — all GET, no auth
| Method | Path | Notes |
| Method | Path | Notes |
|---|---|---|
|---|---|---|
| GET | `/settings` | whitelisted public keys only (mode, maintenance_message, status_message, homepage_teaser, contact_email, site_title) |
| GET | `/settings` | whitelisted public keys, derived `registration`/`gameAccountSignup` flags, and the per-shard **`brand`** block (name, `accent` color, logo/hero/favicon) a client themes itself from — one image runs as any shard. Asset fields may be site-relative paths (resolve against the base URL). |
| GET | `/status` | status message + current mode |
| GET | `/status` | status message + current mode, **plus a `version` block** (`{ service:'runic-gateway', api, server }`) so a client first-run probe recognizes the backend and can run a version-mismatch guard |
| GET | `/version` | lightweight, **DB-free** backend identity/version (`{ service, api, server }`) — the canonical target for the version guard and a cheap liveness check |
| GET | `/posts/:category` | published only; `category` ∈ news\|five-on-friday\|newsletter\|screenshots |
| GET | `/posts/:category` | published only; `category` ∈ news\|five-on-friday\|newsletter\|screenshots |
| GET | `/posts/:category/:idOrSlug` | single published post |
| GET | `/posts/:category/:idOrSlug` | single published post |
> 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:
- **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
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.