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 33 additions and 282 deletions

View File

@@ -1,6 +1,6 @@
# Android App — Plan # Android App — Plan
Status: **M0M7 landed; M7 (push notifications) both parts done — Part 1 backend (website#78) and Part 2 app (Android-app#15) plus a small `push.ntfyUrl` settings addition (website#79). Remaining: set the shard's `NTFY_*` deploy config so push lights up, and cut the v1 tag.** 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 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 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 reference is the committed OpenAPI spec at `website/server/swagger/swagger-output.json` (regenerated
@@ -128,193 +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 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** 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 `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). `versionName` is the committed source of truth (bumped by the release engine); `versionCode` is derived
**CI `release.yml`** — on a `v*` tag, builds a **signed** APK (keystore decoded from a base64 Gitea from it (`major*10000+minor*100+patch`, monotonic); both stay `-P`-overridable for local builds (§10).
secret) and attaches it + `SHA256SUMS` to a Gitea release; `workflow_dispatch` is a signing dry run. **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 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). 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 functional build (M0M4), design pass (M5), and release mechanics (M6) are complete. The first
the first `v*` release tag (once the signing secrets are set + the on-device QA pass is done) and M7 signed release now cuts automatically on the next release-worthy merge to `main` — once the signing +
push notifications are what remain.** `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.**
### M7 plan — push notifications (in progress)
M7 spans three repos, so it ships in **two parts**; the backend contract lands first because the app
is a pure consumer of it (§8/§11).
**Part 1 — `website/` backend + `docs/` — ✅ LANDED** (2026-07-20, `RunicGateway/website#78` merged
+ docs#20). Additive, v1-only (new tables/routes/compose
service; no existing response shape changes). Decision: **no ntfy publish token** — publishes go over
the internal compose network to unguessable per-device topics carrying **content-free tickles**
(`{ stream, ref }`); the publisher honors an optional `NTFY_PUBLISH_TOKEN` if ever set but requires
none (keeps §11's zero-interaction promise).
- **Two event sources, one publisher.** The fan-out is a small transport-agnostic
`utils/pushDispatch.js` that both producers call: `utils/shardIngest.js` (`ingest()`, beside the
existing `broadcast(event)`) for shard-derived streams, and the admin create-post path for the
`news.post` stream (§11 lists news posts as a public stream, but they originate in the website, not
the shard feed).
- **Stream catalog** (`config/notificationStreams.js`): public/opt-in — `news.post`,
`server.status`, `idoc.warning`, `champ.start`, `governor.election`; personal/owner-keyed
(require a linked game account) — `vendor.sale`, `house.idoc`, `account.login`. `mapShardEvent()`
maps event kinds → streams, drawing public streams **only** from the SSE `PUBLIC_KINDS` allowlist;
sensitive kinds are never fanned out publicly. Personal events are delivered only to the owning
user's devices, resolved via `shardLinks.getByAccount` (same ownership source as `/player/shard/*`).
- **Tables:** `push_devices` (per-device endpoint) and `notification_subscriptions` (per-user opted-in
streams), FK → `users` ON DELETE CASCADE, mirroring `mobile_refresh_tokens`.
- **Routes** under the role-agnostic self surface (never `/admin`): `POST|GET /auth/me/devices`,
`DELETE /auth/me/devices/:id`, `GET /auth/me/notifications/streams` (catalog),
`GET|PUT /auth/me/notifications/subscriptions`. All bearer/cookie auth; Swagger regenerated.
- **SSRF guard (important):** a device `endpoint` is a client-supplied URL the backend POSTs to, so
registration and every publish validate it is HTTPS and its origin is in the shard's ntfy
allow-set (`NTFY_BASE_URL` / `NTFY_ALLOWED_ORIGINS`), rejecting loopback/private hosts.
- **ntfy** added to `website/docker-compose.yml` as a pinned upstream image with a committed
declarative `./ntfy/server.yml` and named volume, **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 — ✅ LANDED** (2026-07-20, `RunicGateway/Android-app#15` + a small
`RunicGateway/website#79` settings addition + this docs PR). Built exactly to the plan below, with
two recorded implementation decisions:
- **Direct-ntfy embedded distributor, no UnifiedPush library (deviation from §2's "UnifiedPush
connector" wording — the plan's stated likely path, work item 1).** The app talks straight to ntfy
over its own topic rather than pulling in `org.unifiedpush.android:connector` + an external
distributor: a foreground `PushService` holds an OkHttp-SSE connection to `<ntfy>/<topic>/sse`
(reusing the M2 `ShardStreamClient` reconnect pattern) on a **bare** client, `PushManager`
orchestrates topic mint / device register / start-stop keyed to the session, and `PushNotifier`
posts a per-stream notification whose tap deep-links via `MainActivity` intent extras. No new Gradle
dependency; a `PushResult`/transport seam keeps the future FCM Play flavor cheap. Reasons: the
UnifiedPush distributor model assumes a *separate* app (exactly what the user vetoed), we already own
the SSE machinery, and this keeps the APK Google-free and dependency-light. New code lives in
`core/push/` + `ui/notifications/` + a `NotificationsApi`/`NotificationsRepository`; no existing
screen's data flow changed.
- **One small additive backend field was required after all (`push.ntfyUrl`).** The embedded
distributor must know the shard's client-facing ntfy URL to build its topic endpoint, and Part 1
never surfaced it (the `NTFY_*` vars are server-only). So `/public/settings` now carries
`push: { ntfyUrl }` (from `NTFY_PUBLIC_URL` / first `NTFY_ALLOWED_ORIGINS`; never the internal
`NTFY_BASE_URL`), null when unconfigured → the app shows push as unavailable for that shard. This is
the "no backend work in Part 2" caveat corrected: it is additive, non-sensitive, and forward-compatible
(an older backend omitting it just decodes to null). **Deploy dependency stands:** push only delivers
once the shard sets `NTFY_PUBLIC_URL`/`NTFY_ALLOWED_ORIGINS` (§13).
Verified green: `:app:testDebugUnitTest` (18 new JVM tests — notifications DTO decode, ntfy tickle
parse incl. malformed, topic/URL building, stream→route map + personal gating) + `:app:lintDebug` +
`:app:assembleDebug`; backend 250 tests (+3 for `push.ntfyUrl`) and `npm run swagger` clean. The
foreground-service tradeoff (§11) and the POST_NOTIFICATIONS runtime permission are implemented as
planned; an on-device delivery pass against a live ntfy is the one open QA item.
**Part 2 (original plan) — the Android app.** UnifiedPush receiver + device registration
against the merged Part-1 contract, a Notifications settings screen, and notification-tap deep-links.
The app is architected for push from M0 (§11), so this is **additive** — a new feature slice
(`core/push` + `ui/notifications` + a `DevicesApi`/`NotificationsApi` pair) that touches no existing
screen's data flow. Everything the app calls already exists and is merged; there is **no backend
work** in Part 2.
The Part-1 contract the app codes against (verified against the merged `website` source):
- `POST /auth/me/devices` `{ transport?: 'unifiedpush'|'fcm', endpoint, platform? }``201 PushDevice`
`{ id, transport, endpoint, platform, createdAt, lastSeenAt }`. Idempotent per `(user, endpoint)`
(upsert). `endpoint` **must** be HTTPS on the shard's ntfy allow-set — a private/loopback or
off-allowlist origin is rejected `400` (the SSRF guard). Bearer-auth, so registration only happens
while signed in.
- `GET /auth/me/devices``PushDevice[]`; `DELETE /auth/me/devices/:id``{ ok: true }` (`404` if not
the caller's).
- `GET /auth/me/notifications/streams` → `{ streams: [{ id, label, description, personal,
requiresLinkedAccount }] }` — the eight-stream catalog (`news.post`, `server.status`,
`idoc.warning`, `champ.start`, `governor.election`; personal `vendor.sale`, `house.idoc`,
`account.login`). Render from this, don't hardcode.
- `GET /auth/me/notifications/subscriptions` → `{ streams: [id…] }`; `PUT` the same shape (full
replace; unknown ids dropped server-side; the stored set is echoed back).
- **The wire tickle** the device receives is the content-free `{ "stream": "<id>", "ref": "<opaque>" }`
JSON body (`utils/pushDispatch.js`). `ref` is a serial / city / timestamp hint — **never** content.
Work items:
1. **Transport — the app is its own distributor; no second app (DECIDED).** The Runic Gateway app
**embeds its own UnifiedPush distributor**. The self-hosted **ntfy is only the relay server**, never
a user-installed app — the user installs *one* APK and it receives its own notifications, with no
external distributor (no ntfy app, no NextPush) and no Google Play Services. Concretely, the embedded
distributor holds a **persistent connection to the shard's ntfy** in a **foreground service**,
reusing the OkHttp reconnect/backoff pattern already built for `core/net/ShardStreamClient` (M2): it
subscribes to the app's own random, unguessable ntfy **topic** (over `wss://<ntfy-host>/<topic>/ws`
or the `/json` stream) and forwards each received `{stream,ref}` tickle to the app's receiver. The
**endpoint the app registers** with the backend (work item 5) is that topic's public URL
(`https://<ntfy-host>/<topic>`) — exactly the client-supplied `endpoint` the merged `POST
/auth/me/devices` contract expects and the URL the backend POSTs tickles to. Keep the transport
behind a small `PushTransport` seam so the **future Play/FCM build flavor** (§11, §M8) can swap the
embedded-ntfy distributor for FCM without touching registration, subscriptions, or notification code.
(Implementation detail to confirm: whether a maintained Google-free embedded UnifiedPush-distributor
library fits, or — more likely — a thin in-app distributor written directly over ntfy's subscribe API
reusing `ShardStreamClient`. Either way the distributor lives **inside this app**; the UnifiedPush
*receiver* abstraction is retained only to keep the FCM-flavor seam clean.)
- **Tradeoff, accepted:** instant background delivery requires a persistent foreground service with
an ongoing (low-importance) notification and its battery cost — this is exactly how ntfy's own app
does instant delivery, and it is the price of Google-free self-delivery. A future "battery saver"
option could fall back to periodic polling, but v1 ships the always-connected foreground service.
2. **Deps + manifest.** Add the UnifiedPush connector + the embedded-distributor transport (per #1) to
the version catalog; declare `POST_NOTIFICATIONS` (API 33+ runtime permission) **and
`FOREGROUND_SERVICE` + `FOREGROUND_SERVICE_DATA_SYNC`** (API 34+, for the persistent ntfy
connection); register the receiver and the foreground service in `AndroidManifest.xml`; define the
notification channels (id/name externalized, §2) — one for real notifications plus a low-importance
channel for the ongoing foreground-service notification — and reuse the "RG" notification icon
**already staged in M6**.
3. **`core/push` — embedded distributor + receiver.** The **distributor** component is a foreground
service that owns the ntfy connection (per #1): it (re)creates the app's topic, subscribes over
OkHttp with reconnect/backoff cloned from `ShardStreamClient`, and forwards each frame to the
receiver. The **receiver** parses the `{ stream, ref }` tickle (`kotlinx.serialization`; an
unknown/garbled body is dropped, not crashed — §7 discipline) and posts a notification (work item 7).
Endpoint (re)registration against the backend fires on first subscribe / topic (re)creation
(work item 5); a transient ntfy drop is just a reconnect, not a re-register.
4. **`DevicesApi` + `NotificationsApi` (Retrofit) + DTOs.** Hand-authored, spec-aligned (as recorded
for M1): `RegisterDeviceRequestDto`, `PushDeviceDto`, `NotificationStreamDto`,
`NotificationStreamsDto`, `NotificationSubscriptionsDto`. Both go through the existing bearer/refresh
stack (`AuthInterceptor` + `TokenAuthenticator`) and return the typed `ApiResult` (§7). A
`NotificationsRepository` owns register/list/delete-device and get/put streams+subscriptions.
5. **Endpoint ↔ backend lifecycle (mirror the M3 token teardown).** Persist the app's ntfy topic, its
endpoint URL, and the returned device `id` in prefs (DataStore; the topic/endpoint isn't a secret —
its security rests on being unguessable + the content-free tickle, §11). Start the embedded
distributor and `POST /auth/me/devices` **only when the user has ≥1 subscription and is signed in**.
On **logout / dead-refresh sign-out / Settings→Server switch**, `DELETE /auth/me/devices/:id`, **stop
the foreground service**, and drop the topic — wire this into `SessionManager` beside the existing
token-clear so a signed-out device stops receiving (§4.3, §11 "unregister on logout / token
revocation"). On a **server (base-URL) switch**, mint a fresh topic against the new shard's ntfy (the
old endpoint's origin won't be on the new host's allow-set). Re-assert the endpoint + restart the
service on app start when signed-in + subscribed. A `400` on register (endpoint origin off the
shard's `NTFY_ALLOWED_ORIGINS`) surfaces a clear "your shard's push relay isn't reachable" state, not
a crash.
6. **Notifications settings screen (`ui/notifications`).** Lists the catalog from
`GET …/streams` with a per-stream toggle bound to `GET/PUT …/subscriptions`; a **personal** stream
(`requiresLinkedAccount`) is greyed with a "link a game account" hint until the user has a linked
account — reuse the linked-accounts signal already fetched for M4's player surface
(`PlayerShardRepository`), not a fresh source of truth. Toggling to a non-empty set triggers the
register flow (#5) and requests `POST_NOTIFICATIONS`; emptying the set unregisters. Each mutation
folds its `ApiResult` into a section-scoped, localized banner (§7 parity with M4).
7. **Deep-links (resolves the §13 open item).** Tapping a notification opens the app to the stream's
home: `news.post`→News, `server.status`/`champ.start`/`idoc.warning`/`governor.election`→Shard,
`vendor.sale`→Vendors, `house.idoc`→My Houses, `account.login`→My Account. Routed through the
existing `ui/navigation/Routes.kt`; a signed-out/deep-link-to-player tap lands on the `PlayerGate`
(M4) rather than erroring. **v1 shows a generic per-stream notification** (localized catalog
`label`) and deep-links — it does **not** pull `ref` content first; the content-free design means
nothing needs decrypting to render the tap, and the target screen fetches fresh over the
authenticated API on open. (Pulling `ref` for a richer inline notification is a possible later
enhancement, not v1.)
8. **Menu.** Add a **Notifications** entry to the signed-in group in `ui/navigation/Menu.kt` (near My
Account), visible once signed in.
9. **Permission UX.** Request `POST_NOTIFICATIONS` at the moment the user first enables a stream (API
33+); on denial, keep the toggle off and show how to enable it in system settings — never nag on
launch.
10. **Tests (JVM, `testDebugUnitTest`).** DTO decode (device/stream/subscription), `{ stream, ref }`
tickle parse (incl. a malformed body → dropped), the stream→deep-link map, the "personal greyed
until linked" gate, and the register/unregister lifecycle over a fake `SessionManager` + repository
(parity with M3's session tests).
**Cross-repo dependency to confirm before/at implementation** (a Part-1 §13 open item): the shard's
finalized **ntfy reverse-proxy hostname** must be in `NTFY_ALLOWED_ORIGINS`, because the distributor
hands the app an endpoint on *that* origin and the backend rejects a register whose origin isn't
allow-listed. This is deployment config, not code, but Part 2 can't be end-to-end tested until it's
pinned. No `website`/`link`/`servuo-plugins` code change is expected in Part 2.
Ships as `RunicGateway/Android-app#15`; bumps `versionCode`/`versionName` for a post-v1 release
(§10). Like M1M4 it records itself in the §9 build-progress block on landing.
**Prerequisite progress (§8):** all v1 prerequisites are **done** (2026-07-19) — ✅ password reset **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), (item 2; website#75 + docs#8), ✅ role-agnostic `/auth/me/*` self surface (item 1; website#76 + docs#10),
@@ -628,12 +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 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`** `/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). with fully declarative, zero-interaction config. Not required for the first release (M7, not M1M6).
✅ **Backend + docs LANDED (2026-07-20, RunicGateway/website#78 merged (+ docs#20)).** The
contract Part 1 is built: the two tables, the stream catalog + `PUBLIC_KINDS`-gated event mapping,
the content-free-tickle fan-out (`utils/pushDispatch`, SSRF-guarded endpoints, owner-keyed personal
streams), the six `/auth/me/*` routes (Swagger regenerated), and the declarative `ntfy` compose
service (247 server tests green). The app (Part 2, §9 M7) consumes this next — see the "M7 plan"
block for the detailed Part 2 plan.
4. **Version/health surfacing.** 4. **Version/health surfacing.**
✅ **DONE (2026-07-19, RunicGateway/website#77 (+ this docs PR)).** A dependency-free ✅ **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 `config/version.js` (`{ service:'runic-gateway', api:'v1', server:<pkg> }`) is surfaced on
@@ -690,18 +513,13 @@ push, and Play (M6M8) follow the designed app.
7. **M6 — Polish & release mechanics**: settings (server switch = hard reset, done M3), 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 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 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`). **Landed** 2026-07-20 (`RunicGateway/Android-app#11`).
8. **M7 — Push notifications** (post-v1): add the self-hosted `ntfy` service to 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 `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 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. §11). The app is built with room for this from M0 but it does not gate the first release.
✅ **Both parts landed** 2026-07-20 — Part 1 backend (`RunicGateway/website#78` merged + docs#20),
Part 2 app (`RunicGateway/Android-app#15`) + a small `push.ntfyUrl` settings addition
(`RunicGateway/website#79`). The app embeds its own ntfy distributor (a foreground-service SSE
connection, no second app, no Google Play Services, no UnifiedPush library); see the "M7 plan"
Part 2 block for the recorded transport + backend-field decisions. Push delivers once the shard sets
its `NTFY_*` deploy config (§13).
9. **M8 — Google Play**: Play Console listing, signing/upload key, and (optionally) an FCM build flavor 9. **M8 — Google Play**: Play Console listing, signing/upload key, and (optionally) an FCM build flavor
— after the direct-APK release is stable. — after the direct-APK release is stable.
@@ -726,10 +544,6 @@ first release. Users **opt in per stream**: nothing is pushed unless subscribed.
- **Primary: UnifiedPush, delivered by a self-hosted `ntfy` service added to the website's - **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 `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. device, and keeps delivery under the org's own infrastructure — consistent with the self-hosted ethos.
- **The app embeds its own distributor — no second app (decided; see M7 Part 2 work item 1).** ntfy is
purely the relay *server*; the Runic Gateway app receives notifications itself via an in-app embedded
UnifiedPush distributor (a foreground-service persistent connection to the shard's ntfy). The user
installs one APK — never a separate distributor app — and no Google Play Services is involved.
- **FCM stays optional and Play-only.** If/when a Play build wants it, add FCM as a **build flavor**; - **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 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. to whatever endpoint a device registered, so adding FCM later touches no core logic.
@@ -801,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.) 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`. - **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. 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 - **Release** (`.gitea/workflows/release.yml`, M6): mirrors `link/`'s release engine — on every push to
Gitea release (mirrors `link/`'s release job). The **keystore is a base64 Gitea Actions secret** `main` it computes the next version from conventional commits since the last `v*` tag, generates a
decoded in CI; store/key passwords are secrets. The keystore never lives in the repo. Keep the changelog, bumps `build.gradle.kts`, builds a **signed release APK**, commits the bump `[skip ci]`,
signing identity stable from the first release (Play later requires consistency). tags `vX.Y.Z`, and creates the Gitea release with the notes + APK + `SHA256SUMS`. The **keystore is a
- Semantic `versionName` + monotonic `versionCode`; tag releases. 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) ## 13. Open questions (revisit as we go)
@@ -814,8 +633,7 @@ password+TOTP only, with registration/invite/reset/SSO **handled by the website*
reset built on backend + web first**, before app work (§8); minSdk 29, compile/target 35 (§2); no 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 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 data in v1, pretty paperdoll is future (§6.3); **no offline cache in v1** (§7); push via self-hosted
ntfy / UnifiedPush (§11) with the **distributor embedded in the app — no second app to install** ntfy / UnifiedPush (§11); **biometric app-lock descoped from v1** (tokens already encrypted at rest, so
(M7 Part 2 work item 1); **biometric app-lock descoped from v1** (tokens already encrypted at rest, so
it is a UX convenience, not a v1 requirement — deferred at M3, descoped at M6; revisit only if requested). it is a UX convenience, not a v1 requirement — deferred at M3, descoped at M6; revisit only if requested).
**Still open:** **Still open:**
@@ -824,14 +642,9 @@ it is a UX convenience, not a v1 requirement — deferred at M3, descoped at M6;
the fixed launcher name (baked at build even though in-app branding is per-shard — one APK, any shard). 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 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. own verified app links — confirm whether any deep-link-back is wanted at all for v1.
- ntfy: exact upstream image + pinned tag (Part-1 landed the compose service — confirm the tag), and - ntfy: exact upstream image + pinned tag, its reverse-proxy hostname/path, and whether to add a
its reverse-proxy hostname/path. The hostname must land in `NTFY_ALLOWED_ORIGINS` before M7 Part 2 is backend publish token (optional hardening — the content-free-tickle design does not require one).
end-to-end testable (the app registers an endpoint on that origin; the SSRF guard rejects others). No - FCM flavor: build it for the Play release or ship Play on UnifiedPush too? Decide at M8.
backend publish token — **decided** (the content-free-tickle design does not require one; optional - Deep-link / share targets for wiki pages, posts, and notification taps.
`NTFY_PUBLISH_TOKEN` is honored if ever set).
- FCM flavor: build it for the Play release or ship Play on UnifiedPush too? Decide at M8. (The M7
Part 2 `PushTransport` seam keeps this swap cheap.)
- Deep-link / share targets for wiki pages and posts (share/open-in-app). *Notification-tap* deep-links
are **resolved** for M7 Part 2 (stream→screen map, work item 7).
- iOS: none planned (this is the Android-only choice); revisit only if cross-platform is later - 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). required (would change §2 — and push, which would then favor a cross-platform transport).

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`. 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/*`. 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 ## 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 | | 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) | | 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 | | 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-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/*` role. It reuses the exact `account.controller` handlers as `/player/account/*` and `/admin/account/*`
@@ -218,30 +192,10 @@ 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 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. 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 ### /public (public.routes.js → public.controller.js) — all GET, no auth
| Method | Path | Notes | | Method | Path | Notes |
|---|---|---| |---|---|---|
| GET | `/settings` | whitelisted public keys, derived `registration`/`gameAccountSignup` flags, 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) — and a **`push`** block `{ ntfyUrl }` (M7): the client-facing ntfy relay URL the app's embedded distributor registers its device topic against, from `NTFY_PUBLIC_URL` / first `NTFY_ALLOWED_ORIGINS` (never the internal `NTFY_BASE_URL`); `null` when push isn't configured for the shard. | | 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, **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 | `/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 | `/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 |
@@ -350,13 +304,7 @@ subsystem (`[server]`, `[http]`, `[db]`, `[auth]`, `[admin]`, `[ratelimit]`, …
- `app`: builds the Dockerfile (installs client+server, builds Vite, serves via Express), - `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`, `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. `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 - Volumes: `dbdata`, `uploads`.
(`./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`.
Express listens on `0.0.0.0:${PORT||3000}`. Pangolin terminates TLS and proxies to `app`. Express listens on `0.0.0.0:${PORT||3000}`. Pangolin terminates TLS and proxies to `app`.
@@ -378,16 +326,6 @@ ADMIN_USERNAME=
ADMIN_PASSWORD= ADMIN_PASSWORD=
# Email: configured in Admin → Settings → Email (Gmail OAuth2), not via env # Email: configured in Admin → Settings → Email (Gmail OAuth2), not via env
CLIENT_ORIGIN=http://localhost:5173 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
# The client-facing ntfy URL surfaced to the app via /public/settings.push.ntfyUrl
# (the app registers its topic endpoint here). Defaults to the first
# NTFY_ALLOWED_ORIGINS entry; set explicitly when the public URL differs from the
# internal NTFY_BASE_URL. Without it (and without NTFY_ALLOWED_ORIGINS) the app
# shows push as unavailable for the shard.
NTFY_PUBLIC_URL=https://ntfy.example.com
NTFY_ALLOWED_ORIGINS=https://ntfy.example.com
``` ```
`.gitignore`: `node_modules/`, `.env`, `_reference/`, `client/dist/`, `uploads/`. `.gitignore`: `node_modules/`, `.env`, `_reference/`, `client/dist/`, `uploads/`.