Compare commits
10 Commits
docs/andro
...
b3fa93e9c4
| Author | SHA1 | Date | |
|---|---|---|---|
| b3fa93e9c4 | |||
| 1aba1ff93d | |||
| 5a7bbc26fa | |||
| 71cb181152 | |||
| 837b546f49 | |||
| 1dbdeb789e | |||
| 9a8c083a1e | |||
| cb10cee6f1 | |||
| a51017f4c4 | |||
| 6f9632f77b |
103
android/APP_LINKS.md
Normal file
103
android/APP_LINKS.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# Architecture note — App Links & the multi-tenant callback problem
|
||||
|
||||
Status: **design note; not yet implemented.** Written before the App Links work begins so the
|
||||
multi-tenancy question is decided on paper first (per the mobile-SSO spec). The native SSO bridge
|
||||
ships with the **custom-scheme** callback only (`runicgateway://auth/callback`); everything below is
|
||||
the *later* hardening path and its open design questions.
|
||||
|
||||
Read alongside: the "Mobile SSO Authorization Bridge" section of
|
||||
[`../website/BACKEND_DESIGN.md`](../website/BACKEND_DESIGN.md) (the endpoints/tables), and
|
||||
[`PLAN.md`](./PLAN.md) §4.2 / §9 (the app milestone).
|
||||
|
||||
---
|
||||
|
||||
## 1. The problem
|
||||
|
||||
The mobile SSO bridge redirects the browser back to the app with a one-time code:
|
||||
|
||||
```
|
||||
runicgateway://auth/callback?code=…&state=…
|
||||
```
|
||||
|
||||
A **custom URI scheme** is fine for a self-hosted, single-tenant, internal client, but it is *not*
|
||||
owned by anyone: any other Android app can also register an intent-filter for
|
||||
`runicgateway://auth/callback` and, if chosen by the user (or if it registers more specifically),
|
||||
intercept the callback. The code is single-use, PKCE-bound, and short-lived — so an interceptor
|
||||
still cannot complete `/exchange` without the app's `code_verifier` — but a hijacked callback is
|
||||
still a denial-of-service and a phishing surface we would rather close.
|
||||
|
||||
**Android App Links** (verified `https://` deep links) close it: the OS only routes an `https://`
|
||||
link to an app that has proven, via a file served from *that domain*, that it owns the app. An
|
||||
attacker cannot serve that file on a domain they do not control.
|
||||
|
||||
## 2. Why this is harder here than in a normal app
|
||||
|
||||
RunicGateway is **self-hosted per shard**. There is no single canonical domain — every shard owner
|
||||
runs the website on **their own** domain (`play.exampleshard.com`, `uo.anothershard.net`, …). App
|
||||
Links verification is **per-domain**: the domain must serve
|
||||
|
||||
```
|
||||
https://<shard-domain>/.well-known/assetlinks.json
|
||||
```
|
||||
|
||||
asserting the Android app's **package name** + **signing-certificate SHA-256 fingerprint**. The one
|
||||
published app binary (one package name, one signing cert) must therefore be verifiable against
|
||||
**every** shard domain that wants App Links — a domain set that is open-ended and not known at build
|
||||
time.
|
||||
|
||||
Two consequences:
|
||||
|
||||
1. **The shard must serve `assetlinks.json`.** Shard owners will not hand-edit a JSON file with a
|
||||
cert fingerprint. The website has to **auto-serve** it from an admin setting.
|
||||
2. **The app must know which shard domain it is paired to** before it can trust an App Link for that
|
||||
domain. This is a *pairing/bootstrapping* problem, not just a callback-security detail — it is the
|
||||
part that makes App Links more than a drop-in swap for the custom scheme.
|
||||
|
||||
## 3. Proposed shape (when we build it)
|
||||
|
||||
### 3.1 Server: auto-served `assetlinks.json`
|
||||
|
||||
- One published app ⇒ one package name (`com.runicgateway.app`) and one release signing cert. Its
|
||||
SHA-256 fingerprint is a **constant of the published app**, not shard-specific.
|
||||
- Add a website route `GET /.well-known/assetlinks.json` (served at the **web root**, outside
|
||||
`/api/v1`) that emits the Digital Asset Links statement for that fixed package + fingerprint.
|
||||
- Gate it behind an admin setting `mobile_app_links_enabled` (default **off**). Off ⇒ the route 404s
|
||||
and the app stays on the custom scheme for that shard. On ⇒ the shard opts into App Links.
|
||||
- The fingerprint is the same for every shard, so it can be a shipped constant / env default
|
||||
(`MOBILE_APP_CERT_SHA256`) rather than something each owner types. The **only** per-shard action is
|
||||
flipping the setting on.
|
||||
- When enabled, the shard also registers its `https://<domain>/mobile/callback` URL into the mobile
|
||||
redirect-URI allowlist (see the bridge's exact-match allowlist) **in addition to** the custom
|
||||
scheme — the custom scheme is never removed, it is the universal fallback.
|
||||
|
||||
### 3.2 App: which domain do I trust?
|
||||
|
||||
- The app already stores the shard **base URL** it is paired to (first-run connect flow, PLAN §3).
|
||||
That base URL's host is the *only* domain the app should accept an App Link callback from.
|
||||
- The intent-filter for `https://…/mobile/callback` cannot be scoped to a runtime host in the
|
||||
manifest (intent-filters are static). Options, in order of preference:
|
||||
1. **Custom scheme stays the default**; App Links are an *opt-in* the app only relies on after it
|
||||
has (a) a paired base URL and (b) confirmed that host serves a valid `assetlinks.json`. Until
|
||||
both hold, the app requests the custom-scheme `redirect_uri` at `/start`. This keeps a single
|
||||
code path and avoids trusting an unverified `https` callback.
|
||||
2. Register a broad `https` autoVerify intent-filter and **reject at runtime** any callback whose
|
||||
host ≠ the paired base-URL host. AutoVerify only succeeds for domains that actually serve the
|
||||
file, so in practice only real, opted-in shard domains route to the app; the runtime host check
|
||||
is defense-in-depth.
|
||||
- **Decision to make at build time:** whether to ship the `https` autoVerify intent-filter at all in
|
||||
v1 of the native SSO client, or defer it entirely and ship custom-scheme-only. Given the spec's
|
||||
guidance ("custom scheme is the practical default; App Links can be layered on per-instance"),
|
||||
**custom-scheme-only for the first native-SSO release** is the recommended path.
|
||||
|
||||
## 4. Recommendation
|
||||
|
||||
- **This round:** custom scheme only. No `assetlinks.json` route, no autoVerify intent-filter, no
|
||||
pairing changes. The bridge's redirect-URI allowlist contains exactly the one fixed
|
||||
application-owned callback (`runicgateway://auth/callback`).
|
||||
- **Follow-up (opt-in hardening), only if/when the app is published publicly:** implement §3.1
|
||||
(auto-served `assetlinks.json` behind an admin toggle) and §3.2 option 1 (App Links relied on only
|
||||
after the paired host is verified). Keep the custom scheme as the permanent fallback.
|
||||
|
||||
Nothing in the bridge's server design has to change to add App Links later: it is purely *more
|
||||
entries in the redirect-URI allowlist* plus a static file route. That is the point of keeping the
|
||||
allowlist exact-match and configurable from day one.
|
||||
250
android/PLAN.md
250
android/PLAN.md
@@ -1,6 +1,6 @@
|
||||
# Android App — Plan
|
||||
|
||||
Status: **M0–M6 landed; the functional build, design pass, and release mechanics are complete (cut the v1 tag, then M7 push notifications).** This document is the
|
||||
Status: **M0–M7 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. M9 (native SSO login) is now underway backend-first — the Mobile SSO Authorization Bridge is being built in `website/` + `docs/` ahead of the app-side client (§4.2, §9 M9); custom-scheme callback only for now, App Links deferred (see [`APP_LINKS.md`](./APP_LINKS.md)).** 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
|
||||
@@ -138,6 +138,184 @@ hard reset (M3) were already in place. Biometric app-lock is **descoped from v1*
|
||||
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/` — ✅ 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 M1–M4 it records itself in the §9 build-progress block on landing.
|
||||
|
||||
**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),
|
||||
✅ version/health surfacing (item 4) and ✅ branding for mobile (item 6). **Push notifications (item 3)
|
||||
@@ -289,14 +467,19 @@ completes them in a Custom Tab, then returns and signs in natively (§4.1):
|
||||
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.
|
||||
- **SSO (Google / Discord / OIDC)** — **v1** shipped this as a website browser hand-off: an SSO user
|
||||
links their identity and sets a password on the website, then uses password login in the app.
|
||||
`GET /auth/providers` is shown so the login screen can direct users to "sign in with … on the website."
|
||||
- **Native in-app SSO — now being built (M9), post-v1 additive.** The "possible later enhancement"
|
||||
noted here is now the **Mobile SSO Authorization Bridge**: a Custom-Tab flow that hands a one-time
|
||||
code back to the app's fixed callback (`runicgateway://auth/callback`), exchanged for the *existing*
|
||||
mobile bearer tokens. It **extends** the existing `/auth/sso/*` redirect flow rather than adding a
|
||||
parallel auth path — same PKCE-vs-IdP, same link-only + opt-in-provisioning policy, same TOTP gate,
|
||||
same token shape as `/auth/mobile/login`. The bridge adds a **second** PKCE layer (app ↔ website)
|
||||
and an app-generated `state` (CSRF, verified by the app before exchange). Backend + docs land first
|
||||
(this document's canonical API ref is `../website/BACKEND_DESIGN.md` → "Mobile SSO Authorization
|
||||
Bridge"); the native app client is M9. Custom-scheme callback only for now — App Links are deferred
|
||||
(see [`APP_LINKS.md`](./APP_LINKS.md)).
|
||||
|
||||
### 4.3 Session model (all paths)
|
||||
- **Refresh:** `POST /auth/mobile/refresh` `{ refreshToken }` → new pair. **Single-use / rotated:** store
|
||||
@@ -450,6 +633,12 @@ 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 M1–M6).
|
||||
✅ **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.**
|
||||
✅ **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
|
||||
@@ -512,8 +701,31 @@ push, and Play (M6–M8) follow the designed app.
|
||||
`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.
|
||||
✅ **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
|
||||
— after the direct-APK release is stable.
|
||||
10. **M9 — Native SSO login** (post-v1, additive; independent of M8): in-app "Sign in with Google /
|
||||
Discord" via the **Mobile SSO Authorization Bridge** (§4.2). **Backend-first**, mirroring M7's
|
||||
split:
|
||||
- **Part 1 — backend + docs (in progress):** `mobile_auth_sessions` + `mobile_auth_codes` bridge
|
||||
tables; `GET /auth/mobile/sso/start` (seeds a bridge session, reuses the existing SSO redirect
|
||||
tagged `mode:'mobile'`); a mobile branch in the SSO callback + TOTP-completion that mints a
|
||||
single-use, hashed, PKCE-bound authorization code and redirects to the fixed app callback instead
|
||||
of setting a cookie; `POST /auth/mobile/sso/exchange` (code + PKCE verifier → the existing mobile
|
||||
bearer token pair); an exact-match redirect-URI allowlist; boot-time + opportunistic cleanup of
|
||||
the bridge tables. Reuses `GET /auth/providers` for discovery and `POST /auth/mobile/{refresh,
|
||||
logout}` unchanged. See `../website/BACKEND_DESIGN.md`.
|
||||
- **Part 2 — app client:** register the `runicgateway://auth/callback` intent-filter; generate
|
||||
`code_verifier`/`code_challenge` + `state`; open the Custom Tab at `/auth/mobile/sso/start`;
|
||||
verify `state` on the callback; `POST …/exchange`; store the returned pair in the existing
|
||||
`TokenStore` (M3). No new token-storage or refresh code — it feeds the M3 session machinery.
|
||||
- **Deferred:** App Links / per-shard `assetlinks.json` / pairing — custom scheme only for now
|
||||
([`APP_LINKS.md`](./APP_LINKS.md)).
|
||||
|
||||
---
|
||||
|
||||
@@ -536,6 +748,10 @@ 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
|
||||
`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.
|
||||
- **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**;
|
||||
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.
|
||||
@@ -620,7 +836,8 @@ 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
|
||||
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); **biometric app-lock descoped from v1** (tokens already encrypted at rest, so
|
||||
ntfy / UnifiedPush (§11) with the **distributor embedded in the app — no second app to install**
|
||||
(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).
|
||||
|
||||
**Still open:**
|
||||
@@ -629,9 +846,14 @@ 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).
|
||||
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.
|
||||
- ntfy: exact upstream image + pinned tag (Part-1 landed the compose service — confirm the tag), and
|
||||
its reverse-proxy hostname/path. The hostname must land in `NTFY_ALLOWED_ORIGINS` before M7 Part 2 is
|
||||
end-to-end testable (the app registers an endpoint on that origin; the SSRF guard rejects others). No
|
||||
backend publish token — **decided** (the content-free-tickle design does not require one; optional
|
||||
`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
|
||||
required (would change §2 — and push, which would then favor a cross-platform transport).
|
||||
|
||||
@@ -156,6 +156,73 @@ 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.
|
||||
|
||||
### mobile_auth_sessions / mobile_auth_codes — mobile SSO bridge (M9)
|
||||
|
||||
Two short-lived, self-pruning tables that bridge a browser SSO redirect flow to a native client. They
|
||||
carry the **app ↔ website** PKCE + CSRF state (a *second* PKCE layer, distinct from the website ↔ IdP
|
||||
PKCE the `sso_tx` cookie already carries) and the one-time authorization code the app exchanges for
|
||||
bearer tokens. Neither holds a secret in the clear — the PKCE `code_challenge` is a hash by
|
||||
construction, and the authorization code is stored as a **sha256 hash only** (same pattern as
|
||||
`user_invites` / `password_resets` / `mobile_refresh_tokens`).
|
||||
|
||||
`mobile_auth_sessions` — one row per `/auth/mobile/sso/start`:
|
||||
|
||||
| col | type | notes |
|
||||
|---|---|---|
|
||||
| id | INT PK AUTO_INCREMENT | |
|
||||
| session_id | CHAR(36) UNIQUE | opaque uuid; carried inside the signed `sso_tx` (mode `mobile`) so the callback can find this row |
|
||||
| provider | VARCHAR(40) NOT NULL | provider id validated enabled at `/start` |
|
||||
| code_challenge | VARCHAR(255) NOT NULL | app-supplied PKCE S256 challenge (base64url); verified at `/exchange` |
|
||||
| redirect_uri | VARCHAR(255) NOT NULL | the requested app callback — **exact-match** against the allowlist (never prefix) |
|
||||
| state | VARCHAR(255) NOT NULL | app-generated opaque CSRF value, echoed on the callback for the app to verify |
|
||||
| status | ENUM('pending','completed','consumed') DEFAULT 'pending' | `pending`→`completed` when the code is minted; `consumed` after a successful exchange |
|
||||
| user_id | INT NULL FK→users(id) ON DELETE CASCADE | set once SSO resolves the account |
|
||||
| expires_at | DATETIME NOT NULL | short (~10 min — one redirect round-trip incl. TOTP) |
|
||||
| created_at / used_at | DATETIME | `used_at` stamped at exchange |
|
||||
|
||||
`mobile_auth_codes` — one row per completed SSO callback (the code the app redeems):
|
||||
|
||||
| col | type | notes |
|
||||
|---|---|---|
|
||||
| id | INT PK AUTO_INCREMENT | |
|
||||
| code_hash | CHAR(64) UNIQUE | sha256 hex of the opaque ≥128-bit code; the raw code never touches the DB |
|
||||
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | the authenticated account |
|
||||
| session_id | CHAR(36) NOT NULL | the owning `mobile_auth_sessions.session_id` (ties the code to its PKCE challenge) |
|
||||
| expires_at | DATETIME NOT NULL | very short (~5 min) |
|
||||
| used_at | DATETIME NULL | set on first successful exchange — **single use** (a reused code fails) |
|
||||
| created_at | DATETIME | |
|
||||
|
||||
Both self-prune (indexed `expires_at`): a best-effort sweep runs at boot beside the existing
|
||||
`revoked_sessions` prune, and each bridge write opportunistically deletes expired rows — so no cron
|
||||
infra is added (same approach as `revoked_sessions`).
|
||||
|
||||
**`mobile_refresh_tokens` additions (M9).** Two nullable columns are added to support the device
|
||||
list/revoke surface: `device_name VARCHAR(100) NULL` (a friendly label) and `last_used_at DATETIME
|
||||
NULL` (bumped on each refresh). Existing rows get them via the schema's ALTER section; the token model
|
||||
is otherwise unchanged.
|
||||
|
||||
---
|
||||
|
||||
## 4. API contract
|
||||
@@ -177,6 +244,10 @@ 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/*`
|
||||
@@ -192,10 +263,82 @@ 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.
|
||||
|
||||
### Mobile SSO Authorization Bridge (`/auth/mobile/sso/*`, M9)
|
||||
|
||||
Native "Sign in with Google/Discord" for the Android app **without shipping any OAuth secret in the
|
||||
app**. The website stays the identity authority: each shard owner's provider credentials live in
|
||||
`auth_providers` (encrypted at rest) and are only ever used server-side. The bridge is a **new
|
||||
consumer of the existing SSO + mobile-bearer machinery**, not a parallel auth path — it reuses the
|
||||
`/auth/sso/:provider/*` redirect flow, the link-only + opt-in-provisioning policy, the TOTP gate, and
|
||||
issues the **same** token pair as `/auth/mobile/login`.
|
||||
|
||||
| Method | Path | Auth | Body / Query | Purpose |
|
||||
|---|---|---|---|---|
|
||||
| GET | `/auth/providers` | — | — | **reused** discovery; the app renders provider buttons from this (never exposes secrets) |
|
||||
| GET | `/auth/mobile/sso/start` | — (rate-limited per-IP + per-provider) | `?provider&code_challenge&state&redirect_uri` | validate provider enabled + `redirect_uri` **exact-match** allowlist; insert a `mobile_auth_sessions` row; create the existing `sso_tx` tagged `mode:'mobile'` carrying `session_id`; **302 to the IdP** (existing authorize URL) |
|
||||
| GET | `/auth/sso/:provider/callback` | — (signed `sso_tx`) | `?code&state` | **existing** endpoint; a new branch when `tx.mode==='mobile'`: resolve the account (same policy as web login incl. TOTP), mint a single-use hashed authorization code into `mobile_auth_codes`, mark the session `completed`, and **302 to `redirect_uri?code=…&state=…`** (the app's original `state`) — **no cookie is set** |
|
||||
| POST | `/auth/mobile/sso/exchange` | — (rate-limited per-IP) | `{code, code_verifier}` | validate the code exists / unexpired / unused (mark used) and `sha256(code_verifier)` matches the stored challenge → issue the existing mobile access + refresh pair (`createMobileSession`) → `{accessToken, refreshToken, expiresIn, user}` |
|
||||
| POST | `/auth/mobile/refresh` | — | `{refreshToken}` | **reused** unchanged — rotate the pair |
|
||||
| POST | `/auth/mobile/logout` | bearer | `{refreshToken?, all?}` | **reused** unchanged — revoke this (or all) refresh token(s) |
|
||||
| GET | `/auth/me/sessions` · DELETE `…/:id` | cookie / bearer | — | list / revoke own **mobile sessions** (device_name, last_used_at, created_at) — the "Active Devices" surface (distinct from `/auth/me/devices`, which is push endpoints) |
|
||||
|
||||
**Two PKCE layers (do not conflate).**
|
||||
- *Layer A (existing):* website ↔ IdP. The `code_verifier` is generated at `/start`, kept only in the
|
||||
httpOnly `sso_tx` cookie, sent to the IdP token endpoint at the callback. Unchanged.
|
||||
- *Layer B (new):* app ↔ website. The **app** generates `code_verifier`/`code_challenge`; the
|
||||
challenge is stored in `mobile_auth_sessions` at `/start`; the verifier is presented at `/exchange`.
|
||||
This is what stops an intercepted callback code from being redeemed by anyone but the real app.
|
||||
|
||||
**State / CSRF.** The app-generated `state` is stored at `/start`, echoed on the callback redirect,
|
||||
and **verified by the app** before it calls `/exchange` — a CSRF guard independent of both PKCE
|
||||
layers (a different app instance triggering `/start` cannot complete someone else's flow).
|
||||
|
||||
**Redirect-URI allowlist.** `/start` and the callback validate `redirect_uri` by **exact match**
|
||||
against a configured allowlist (`MOBILE_AUTH_REDIRECT_URIS`, default the one fixed application-owned
|
||||
callback `runicgateway://auth/callback`) — **never prefix match** (prefix matching on custom schemes
|
||||
is a known open-redirect vector). Tokens are **never** placed in the callback URL — only the
|
||||
short-lived authorization code. HTTPS App Link URIs can be appended to the allowlist later per shard;
|
||||
that is the *only* server change App Links require (see docs/android/APP_LINKS.md).
|
||||
|
||||
**TOTP through the bridge.** A 2FA account keeps full parity: the callback stages the existing
|
||||
pending-TOTP cookie (now also carrying the bridge `session_id`) and bounces the Custom Tab through the
|
||||
web TOTP form; on a correct code the completion mints the authorization code and deep-links back to
|
||||
the app — it never mints a session cookie for a mobile flow.
|
||||
|
||||
**Revocation latency (documented tradeoff).** Revoking a refresh token (device revoke / logout) stops
|
||||
future renewals but does **not** invalidate an already-issued access token until it expires — up to
|
||||
the access-token lifetime (`MOBILE_ACCESS_TTL`, default 15 min) of continued access. This is an
|
||||
accepted tradeoff given the short lifetime. If instant revocation is ever required, add an
|
||||
access-token (jti) blocklist check on the `requireAuth` path — the same `revoked_sessions` mechanism
|
||||
web sessions already use.
|
||||
|
||||
**Authorization code.** Cryptographically random, ≥128 bits, stored **hash-only**, single-use, short
|
||||
expiry (~5 min); `/exchange` is rate-limited per-IP. The bridge tables self-prune (§3).
|
||||
|
||||
### /public (public.routes.js → public.controller.js) — all GET, no auth
|
||||
| Method | Path | Notes |
|
||||
|---|---|---|
|
||||
| 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 | `/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 | `/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 |
|
||||
@@ -304,7 +447,13 @@ 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.
|
||||
- Volumes: `dbdata`, `uploads`.
|
||||
- `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`.
|
||||
|
||||
Express listens on `0.0.0.0:${PORT||3000}`. Pangolin terminates TLS and proxies to `app`.
|
||||
|
||||
@@ -326,6 +475,16 @@ 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
|
||||
# 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/`.
|
||||
|
||||
Reference in New Issue
Block a user