1 Commits

Author SHA1 Message Date
cbaa18ea0b docs(android): release.yml is a conventional-commit auto-release engine
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>
2026-07-20 04:25:05 -05:00
2 changed files with 27 additions and 117 deletions

View File

@@ -1,6 +1,6 @@
# Android App — Plan
Status: **M0M6 landed; M7 (push notifications) Part 1 — backend + docs — in review (website#78). Remaining: cut the v1 tag, then M7 Part 2 (the app's UnifiedPush integration).** This document is the
Status: **M0M6 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
@@ -128,55 +128,22 @@ probe refuses a backend whose API version this build can't speak (a future `v2`)
signed release) with keep-rules for the kotlinx.serialization serializers, the wire DTOs, and the
Retrofit interfaces; a release `signingConfig` that reads keystore material from a **gitignored**
`keystore.properties` or env vars (absent → unsigned; the keystore is never committed); and
`versionName`/`versionCode` overridable via `-P` so a release tag + CI run number drive them (§10).
**CI `release.yml`** — on a `v*` tag, builds a **signed** APK (keystore decoded from a base64 Gitea
secret) and attaches it + `SHA256SUMS` to a Gitea release; `workflow_dispatch` is a signing dry run.
`versionName` is the committed source of truth (bumped by the release engine); `versionCode` is derived
from it (`major*10000+minor*100+patch`, monotonic); both stay `-P`-overridable for local builds (§10).
**CI `release.yml`** — mirrors `link/`'s language-agnostic release engine, adapted for Android: on every
push to `main` it derives the next version from conventional-commit subjects since the last `v*` tag
(`feat!`/BREAKING → major, `feat` → minor, `fix`/`perf` → patch; nothing releasable → no release),
generates a grouped changelog, bumps `build.gradle.kts`, builds the **signed** APK (keystore decoded from
a base64 Gitea secret), then commits the bump `[skip ci]`, tags `vX.Y.Z`, and creates the Gitea release
with notes + APK + `SHA256SUMS`. Uses `REGISTRY_USER`/`REGISTRY_TOKEN` (as `link/` does) to push the bump
and create the release, so `main` must allow that account to push.
HTTPS-only in release (M1), no token logging (logging is debug-gated, M3), and the Settings → Server
hard reset (M3) were already in place. Biometric app-lock is **descoped from v1** (see the note below).
**The functional build (M0M4), design pass (M5), and release mechanics (M6) are complete. Cutting
the first `v*` release tag (once the signing secrets are set + the on-device QA pass is done) and M7
push notifications are what remain.**
### M7 plan — push notifications (in progress)
M7 spans three repos, so it ships in **two parts**; the backend contract lands first because the app
is a pure consumer of it (§8/§11).
**Part 1 — `website/` backend + `docs/` (this pass).** Additive, v1-only (new tables/routes/compose
service; no existing response shape changes). Decision: **no ntfy publish token** — publishes go over
the internal compose network to unguessable per-device topics carrying **content-free tickles**
(`{ stream, ref }`); the publisher honors an optional `NTFY_PUBLISH_TOKEN` if ever set but requires
none (keeps §11's zero-interaction promise).
- **Two event sources, one publisher.** The fan-out is a small transport-agnostic
`utils/pushDispatch.js` that both producers call: `utils/shardIngest.js` (`ingest()`, beside the
existing `broadcast(event)`) for shard-derived streams, and the admin create-post path for the
`news.post` stream (§11 lists news posts as a public stream, but they originate in the website, not
the shard feed).
- **Stream catalog** (`config/notificationStreams.js`): public/opt-in — `news.post`,
`server.status`, `idoc.warning`, `champ.start`, `governor.election`; personal/owner-keyed
(require a linked game account) — `vendor.sale`, `house.idoc`, `account.login`. `mapShardEvent()`
maps event kinds → streams, drawing public streams **only** from the SSE `PUBLIC_KINDS` allowlist;
sensitive kinds are never fanned out publicly. Personal events are delivered only to the owning
user's devices, resolved via `shardLinks.getByAccount` (same ownership source as `/player/shard/*`).
- **Tables:** `push_devices` (per-device endpoint) and `notification_subscriptions` (per-user opted-in
streams), FK → `users` ON DELETE CASCADE, mirroring `mobile_refresh_tokens`.
- **Routes** under the role-agnostic self surface (never `/admin`): `POST|GET /auth/me/devices`,
`DELETE /auth/me/devices/:id`, `GET /auth/me/notifications/streams` (catalog),
`GET|PUT /auth/me/notifications/subscriptions`. All bearer/cookie auth; Swagger regenerated.
- **SSRF guard (important):** a device `endpoint` is a client-supplied URL the backend POSTs to, so
registration and every publish validate it is HTTPS and its origin is in the shard's ntfy
allow-set (`NTFY_BASE_URL` / `NTFY_ALLOWED_ORIGINS`), rejecting loopback/private hosts.
- **ntfy** added to `website/docker-compose.yml` as a pinned upstream image with a committed
declarative `./ntfy/server.yml` and named volume, **no published host port** (reached via the
reverse proxy; internal-only for the publisher), anonymous read-write to unguessable topics (no
per-user accounts — safe because tickles are content-free).
**Part 2 — the Android app (next pass).** UnifiedPush distributor integration, device registration
against `POST /auth/me/devices`, the Notifications settings screen (per-stream toggles; personal
streams greyed until a game account is linked), notification-tap deep-links, and the notification
channel/icon. Built against the Part-1 contract; the app is architected for push from M0 (§11) so it
adds no data-flow change to the existing screens.
**The functional build (M0M4), design pass (M5), and release mechanics (M6) are complete. The first
signed release now cuts automatically on the next release-worthy merge to `main` — once the signing +
`REGISTRY_*` secrets are set, `main` allows the CI account to push, and the on-device QA pass is done.
M7 push notifications are what remain.**
**Prerequisite progress (§8):** all v1 prerequisites are **done** (2026-07-19) — ✅ password reset
(item 2; website#75 + docs#8), ✅ role-agnostic `/auth/me/*` self surface (item 1; website#76 + docs#10),
@@ -490,11 +457,6 @@ maintenance cost. Reserve v2 for a real breaking re-shape if one ever arises.
3. **Push notifications** — see §11. Additive v1 endpoints under `/auth/me/devices*` and
`/auth/me/notifications*`, plus a **self-hosted `ntfy` service added to `website/docker-compose.yml`**
with fully declarative, zero-interaction config. Not required for the first release (M7, not M1M6).
🚧 **Backend + docs IN REVIEW (2026-07-20, RunicGateway/website#78 (+ this docs PR)).** The
contract Part 1 is built: the two tables, the stream catalog + `PUBLIC_KINDS`-gated event mapping,
the content-free-tickle fan-out (`utils/pushDispatch`, SSRF-guarded endpoints, owner-keyed personal
streams), the six `/auth/me/*` routes (Swagger regenerated), and the declarative `ntfy` compose
service. Full server suite green. The app (Part 2, §9 M7) consumes this next.
4. **Version/health surfacing.**
✅ **DONE (2026-07-19, RunicGateway/website#77 (+ this docs PR)).** A dependency-free
`config/version.js` (`{ service:'runic-gateway', api:'v1', server:<pkg> }`) is surfaced on
@@ -551,15 +513,13 @@ push, and Play (M6M8) follow the designed app.
7. **M6 — Polish & release mechanics**: settings (server switch = hard reset, done M3),
version-mismatch guard, release build hardening (HTTPS-only, no token logging, R8 minify + resource
shrink, release signing). No offline cache in v1 (§7). **Ships v1 as a signed APK attached to a Gitea
release** via `release.yml` on a `v*` tag (see §10). Biometric app-lock **descoped** (below).
release** via `release.yml`'s conventional-commit engine on merge to `main` (see §10, §12).
Biometric app-lock **descoped** (below).
**Landed** 2026-07-20 (`RunicGateway/Android-app#11`).
8. **M7 — Push notifications** (post-v1): add the self-hosted `ntfy` service to
`website/docker-compose.yml` (declarative, zero-interaction config), UnifiedPush integration in the
app, device registration, the subscriptions UI, and the content-free-tickle backend fan-out (see
§11). The app is built with room for this from M0 but it does not gate the first release.
🚧 **Part 1 (backend + docs) in review** 2026-07-20 (`RunicGateway/website#78` + this docs PR) — see
the "M7 plan" build-progress block above and §8 item 3. **Part 2 (the app: UnifiedPush, device
registration, subscriptions UI, notification handling) is next.**
9. **M8 — Google Play**: Play Console listing, signing/upload key, and (optionally) an FCM build flavor
— after the direct-APK release is stable.
@@ -655,11 +615,16 @@ repos use), on a bare `ubuntu:latest` container.
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.
- **Release** (`.gitea/workflows/release.yml`, M6): mirrors `link/`'s release engine — on every push to
`main` it computes the next version from conventional commits since the last `v*` tag, generates a
changelog, bumps `build.gradle.kts`, builds a **signed release APK**, commits the bump `[skip ci]`,
tags `vX.Y.Z`, and creates the Gitea release with the notes + APK + `SHA256SUMS`. The **keystore is a
base64 Gitea Actions secret** decoded in CI (`ANDROID_KEYSTORE_BASE64`); store/key passwords + alias
are secrets too. The keystore never lives in the repo. `REGISTRY_USER`/`REGISTRY_TOKEN`
(`write:repository`) push the bump + create the release, so `main` must allow that account to push.
Keep the signing identity stable from the first release (Play later requires consistency).
- Semantic `versionName` (bumped by the engine) + derived monotonic `versionCode`
(`major*10000+minor*100+patch`); the engine tags each release.
## 13. Open questions (revisit as we go)

View File

@@ -156,28 +156,6 @@ Seeded keys: `site_mode` (default `maintenance`), `site_mode_changed_at`,
Same "store only the hash of an opaque token" pattern as `user_invites` / `mobile_refresh_tokens`.
A DB read never yields a usable reset link. See §4 `/auth/password/*`.
### push_devices — opt-in push endpoints (M7)
| col | type | notes |
|---|---|---|
| id | INT PK AUTO_INCREMENT | |
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | owner |
| transport | ENUM('unifiedpush','fcm') DEFAULT 'unifiedpush' | UnifiedPush for the sideloaded APK; FCM reserved for a later Play flavor |
| endpoint | VARCHAR(512) NOT NULL | the distributor URL the app's ntfy topic was handed (or an FCM token). Unguessable but **not a secret** — stored in the clear (unlike refresh tokens), because pushes are content-free tickles |
| platform | VARCHAR(40) NULL | free-form label, e.g. `android` |
| created_at / last_seen_at | DATETIME | |
`UNIQUE(user_id, endpoint)` — re-registering the same endpoint is an idempotent upsert.
### notification_subscriptions — which streams a user opted into (M7)
| col | type | notes |
|---|---|---|
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | |
| stream_id | VARCHAR(64) NOT NULL | an id from the catalog (`config/notificationStreams.js`), validated on write |
| created_at | DATETIME | |
`PRIMARY KEY(user_id, stream_id)`. Subscriptions are per-user (applied to every device); a PUT
replaces the whole set. Nothing is pushed unless the user subscribed.
---
## 4. API contract
@@ -199,10 +177,6 @@ accepts `Authorization: Bearer` for API testing).
| PATCH | `/me/account/password` | cookie / bearer (rate-limited) | `{newPassword, currentPassword?}` | change/set own password (current required unless the account has none); revokes other sessions, keeps the caller's |
| POST | `/me/account/totp/setup` · `…/enable` · `…/disable` | cookie / bearer | `{code}` on enable/disable | self 2FA enrollment (disable needs a valid current code, not a password) |
| GET | `/me/account/identities` · DELETE `…/:provider` | cookie / bearer | — | list / unlink own SSO identities |
| POST | `/me/devices` | cookie / bearer | `{endpoint, transport?, platform?}` | register a push endpoint; **rejects a disallowed endpoint 400** (SSRF guard). Idempotent per (user, endpoint) |
| GET | `/me/devices` · DELETE `…/:id` | cookie / bearer | — | list / unregister own push devices |
| GET | `/me/notifications/streams` | cookie / bearer | — | the subscribable catalog (`personal`/`requiresLinkedAccount` flags) |
| GET · PUT | `/me/notifications/subscriptions` | cookie / bearer | `{streams:[id]}` on PUT | get / replace own opted-in streams (unknown ids dropped) |
**Role-agnostic self-service (`/auth/me/*`).** The canonical "me" surface for **every** authenticated
role. It reuses the exact `account.controller` handlers as `/player/account/*` and `/admin/account/*`
@@ -218,26 +192,6 @@ reset link points at the web front end (`/account/reset/:token`); the Android ap
rather than shipping its own reset screen (docs/android/PLAN.md §4.2). First admin is bootstrapped
by `seed.js` from env (see §6); further staff are created under `/admin/users` or via email invites.
**Push notifications (M7, opt-in).** The app subscribes per stream (`/auth/me/notifications/*`) and
registers device endpoints (`/auth/me/devices`); nothing is pushed unless subscribed. Delivery is a
**content-free tickle**`{ stream, ref }`, no sensitive data — POSTed to each subscribed device's
self-hosted **ntfy** endpoint (`utils/pushDispatch`); the app wakes and pulls the real, ownership-
checked content over the authenticated API. Two producers fan out through the one publisher: the shard
ingest dispatcher (`utils/shardIngest`, beside the SSE broadcast) for shard-derived streams, and the
create/publish-post path for `news.post`. The stream catalog + event→stream mapping is
`config/notificationStreams.js`. Security invariants:
- **Same public/admin split as the SSE feed.** Public streams are drawn *only* from the SSE
`PUBLIC_KINDS` allowlist; a sensitive kind (audit/cheat/IP/login-attempt) can never produce a public
push.
- **Personal streams are owner-keyed.** `vendor.sale` / `house.idoc` / `account.login` are delivered
only to the *owning* user's devices, resolved via `shardLinks` (the same ownership check as
`/player/shard/*`).
- **SSRF guard.** A device `endpoint` is a client-supplied URL the server POSTs to, so registration and
every publish validate it is HTTPS, non-private/loopback, and (when configured) on the shard's ntfy
allow-set (`NTFY_BASE_URL` / `NTFY_ALLOWED_ORIGINS`).
- ntfy is treated as an **untrusted relay** — no per-user accounts, unguessable topics; an optional
`NTFY_PUBLISH_TOKEN` hardens backend→ntfy publishes but is not required. See docs/android/PLAN.md §11.
### /public (public.routes.js → public.controller.js) — all GET, no auth
| Method | Path | Notes |
|---|---|---|
@@ -350,13 +304,7 @@ subsystem (`[server]`, `[http]`, `[db]`, `[auth]`, `[admin]`, `[ratelimit]`, …
- `app`: builds the Dockerfile (installs client+server, builds Vite, serves via Express),
`env_file: .env`, `DB_HOST=db`, `depends_on: db (healthy)`, volume `uploads:/app/uploads`,
`ports: "3000:3000"`**binds 0.0.0.0** (no `127.0.0.1:` prefix) so Pangolin reaches it.
- `ntfy` (M7): pinned upstream `binwiederhier/ntfy` image, declarative config only
(`./ntfy/server.yml` mounted `:ro` + `NTFY_BASE_URL`), volume `ntfydata:/var/lib/ntfy`, **no
published host port** — devices reach it via the reverse proxy; the backend publisher reaches it
over the private compose network. Anonymous read-write to unguessable topics (no accounts to
provision) — safe because pushes are content-free tickles. Bringing the stack up provisions a
working push relay with **zero interactive setup**.
- Volumes: `dbdata`, `uploads`, `ntfydata`.
- Volumes: `dbdata`, `uploads`.
Express listens on `0.0.0.0:${PORT||3000}`. Pangolin terminates TLS and proxies to `app`.
@@ -378,9 +326,6 @@ ADMIN_USERNAME=
ADMIN_PASSWORD=
# Email: configured in Admin → Settings → Email (Gmail OAuth2), not via env
CLIENT_ORIGIN=http://localhost:5173
# Push (M7): the ntfy relay URL — also the backend's SSRF allow-set for device
# endpoints. NTFY_ALLOWED_ORIGINS / NTFY_PUBLISH_TOKEN are optional.
NTFY_BASE_URL=https://ntfy.example.com
```
`.gitignore`: `node_modules/`, `.env`, `_reference/`, `client/dist/`, `uploads/`.