docs(installer): bundles publish to a branch, releases only tag

Two corrections to §7.1, both forced by the first compose run that ever
had a bundle to write (org lead, 2026-08-05).

The section said bundles are committed to `main` and that this "needs no
new branch-protection exception: release.yml's version-bump commit
already requires the CI user to be able to push to main". Both halves
were wrong. `main` is protected and declines the push, and release.yml
had never pushed anything: its bump step has never executed in any repo
carrying it, because an empty template expression written literally in
one of its comments makes the runner fail to build the step and skip it
without failing the job. The tags exist because Gitea's release API
creates one when it publishes. The assumption that a working push path
already existed had never been tested by anything.

Bundles now publish to a `bundles` branch at its root, which keeps every
property the original choice was for -- reviewable diff, git history of
the compat matrix, plain anonymous raw URLs, no credentials on the shard
host -- and needs no exception. The release workflows are tag-only for
the same reason, as servuo-plugins has always been: the version is still
written into Cargo.toml before building so a released binary
self-reports correctly, but is not committed back.

Also updated: the raw URLs in INSTALL.md Appendix A1 and in Phase 0.3's
as-built note.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-05 17:21:56 -05:00
parent 00d476c00b
commit 8acee8a3fa
3 changed files with 158 additions and 10 deletions

View File

@@ -0,0 +1,126 @@
# Handoff — Trusted Devices & MFA: remaining work
> Written 2026-07-22 at the end of the backend/web implementation session, for a
> fresh session to continue. Canonical design: `docs/website/TRUSTED_DEVICES_MFA.md`.
> App-side plan: `docs/android/PLAN.md §4.1.1`. This doc is the "what's left + how".
## 1. Status at handoff
**Done, in review, and live-smoke-tested** (real MariaDB + AVD):
- Backend (schema, session service, web + mobile login, self-service + admin
endpoints, invalidation), web client UI, admin front-end UI, OpenAPI spec,
33 server tests — all in **website PR #93** (branch `feature/trusted-devices-mfa`).
- Docs (BACKEND_DESIGN §3/§4/§6, TRUSTED_DEVICES_MFA.md, PLAN §4.1.1) — **docs PR #32**
(branch `docs/trusted-devices-mfa`).
- Live smoke test passed end-to-end: trusted-device TOTP-skip (web + mobile
`X-Trust-Token`), recovery-code login (single-use), cap 409, admin MFA reset,
audit logging, and a real 2FA login through the Android app on an emulator.
**Not done — the two remaining items below.**
## 2. Remaining item A — merge gate (no code)
- **website#93** and **docs#32** need CI green + review, then merge to `main`.
- CI (`.gitea/workflows/pr-checks.yml`) runs server tests, client build, bot install.
- Nothing to build here; just get them reviewed/merged. The Android work should land
**after** #93 merges so the app builds against the merged contract.
## 3. Remaining item B — Android app implementation (the real work)
The backend is fully ready and additive; the existing app is unaffected (verified).
The app just needs to *consume* the new endpoints. Own PR in the **`android-app`**
repo, branch `feature/trusted-devices-mfa`. Package id `com.runicgateway.app`.
### 3.1 Scope (from PLAN §4.1.1)
1. **Login/TOTP screen:** on the existing `401 { totpRequired }` step, add
- a **"Trust this device"** checkbox, and
- a **"use a recovery code instead"** toggle (send `recoveryCode` instead of `code`).
2. **Trust token storage:** when a login response carries `trustToken`, store it in
**EncryptedSharedPreferences** (same secure store as the bearer tokens — never
plain prefs/logs). On subsequent logins send it as the **`X-Trust-Token`** header
so the server skips the TOTP prompt.
3. **Cap handling:** a login response with `{ trustLimitReached: true, devices }`
means show the device list and prompt the user to revoke one
(`DELETE /auth/me/trusted-devices/:id`) then retry trusting.
4. **Account screens:**
- **Trusted Devices**: list (`GET /auth/me/trusted-devices`), revoke one, untrust
all; "trust this device" (`POST /auth/me/trusted-devices` → returns `trustToken`
for native — store it).
- **Recovery Codes**: show the one-time batch returned by TOTP enable; remaining
count (`GET …/recovery-codes/status`); regenerate (`POST …/recovery-codes/generate`,
password step-up) with a show-once display + copy/share.
5. **Invalidation:** on logout / dead-refresh sign-out / Settings→Server switch,
**clear the stored `trustToken`** alongside the bearer tokens. (A server-side
password change/reset or TOTP disable already revokes it.)
6. **Tests:** JVM `:app:testDebugUnitTest` — DTO decode for the new fields, and
repository logic (trust-token persist/clear, recoveryCode vs code branch).
### 3.2 Exact API contract the app consumes
`POST /auth/mobile/login` — body `{ username, password, code?, recoveryCode?, trustDevice?, device_name? }`, optional header `X-Trust-Token: <token>`
- `200` → `{ accessToken, refreshToken, expiresIn, user:{id,username,role}, trustToken?, trustLimitReached?, devices? }`
- `trustToken` present only when `trustDevice:true` was accepted (store it).
- `trustLimitReached:true` + `devices[]` when at the cap (login still succeeded).
- `401` → `{ totpRequired:true, message }` (missing/invalid 2nd factor) — reveal the
code field (existing behavior); or generic `{ message }` for bad credentials.
- A valid `X-Trust-Token` bound to the user makes a code unnecessary → straight `200`.
Self-service (Bearer access token):
- `GET /auth/me/trusted-devices` → `[{ id, platform, deviceName, userAgent, createdAt, lastUsedAt, expiresAt }]`
- `POST /auth/me/trusted-devices` body `{ deviceName? }` → `{ trusted:true, trustToken }` (native) | `409 { error:'trusted_device_limit', devices }`
- `DELETE /auth/me/trusted-devices/:id` → `{ revoked:boolean }`
- `DELETE /auth/me/trusted-devices` → `{ revoked:number }`
- `GET /auth/me/account/recovery-codes/status` → `{ remaining:number }`
- `POST /auth/me/account/recovery-codes/generate` body `{ currentPassword? }` → `{ recoveryCodes:[string] }`
- `POST /auth/me/account/totp/enable` body `{ code }` → `{ totp_enabled:true, recoveryCodes:[string] }` (codes shown once)
The OpenAPI spec (`website/server/swagger/swagger-output.json`, schemas `TrustedDevice`,
`TrustDeviceResult`, `TrustedDeviceLimit`, `RecoveryCodes`) is the source of truth.
### 3.3 Where it likely goes in the app
The app already handles `totpRequired` (reveals a code field — verified live), so the
auth surface exists. Extend the existing auth Retrofit API + repository + login
ViewModel/screen, add a secure `trustToken` accessor to the encrypted token store,
and add two account screens. Explore `android-app/app/src/main/java/com/runicgateway/app/`
(auth/data/core modules) at the start — don't assume file names.
## 4. Environment & how-to (verified this session)
- **DB:** Docker container `uomm-db`, MariaDB on host port **3307**, db `uomysticmoon`,
user `uomm` (password: `docker exec uomm-db printenv MARIADB_PASSWORD`).
- **Run the server:** `cd website/server && node src/server.js` (uses `.env`, already
points at 127.0.0.1:3307). It ensures schema + seeds on boot. For cap testing set
`MAX_TRUSTED_DEVICES=2`; recovery count via `RECOVERY_CODE_COUNT`.
- Pre-existing noise: `uo-link-socket … Unsupported state` decrypt errors are an old
encrypted `uo_link_config` row, unrelated — ignore.
- **Server tests:** `cd website/server && DB_HOST=127.0.0.1 DB_PORT=59999 node --test`
(dead port by design; models stubbed). Client: `cd website/client && npm test`.
- **TOTP codes for manual testing:** from `website/server`,
`node -e "process.stdout.write(require('speakeasy').totp({secret:'<BASE32>',encoding:'base32'}))"`.
- **Android build:** JDK 21; `cd android-app && ./gradlew :app:assembleDebug -Pksp.incremental=false`.
Unit tests: `./gradlew :app:testDebugUnitTest -Pksp.incremental=false`.
- **Emulator:** SDK at `~/AppData/Local/Android/Sdk`; AVDs `Medium_Phone_API_36.1`,
`s22_ultra`. `adb install -r -g app/build/outputs/apk/debug/app-debug.apk`.
- **⚠ Cleartext HTTP gotcha (dev only):** the app blocks plain HTTP to `10.0.2.2`
(`CLEARTEXT communication … not permitted`). Cleartext to `127.0.0.1` **is** allowed,
so for local testing run `adb reverse tcp:3000 tcp:3000` and set the app's server URL
to `http://127.0.0.1:3000` (its default). Production uses HTTPS via the proxy — not a
code issue. (Consider whether v1 wants a `network_security_config` dev exception; not
required for the feature.)
## 5. Conventions (CLAUDE.md)
- Branch from up-to-date `main`; commit/push as **`wtclaude`** using the token at
`C:\Users\colby\.gitea_token_claude` via `http.extraHeader` (never inline in URL).
- Conventional Commits; end commit messages with the `Co-Authored-By: Claude …` +
`Claude-Session:` trailers. PR template requires ticking the **AI-assisted** box
(tool: Claude Code) and the license box.
- Use `mcp__gitea__*` for PRs/issues. GPL-3.0-or-later.
## 6. First moves for the fresh session
1. Check whether website#93 / docs#32 have merged (`mcp__gitea__pull_request_read`).
2. In `android-app`: sync `main`, `git checkout -b feature/trusted-devices-mfa`.
3. Explore the app's auth module; implement §3 against the §3.2 contract.
4. Test with the local server + emulator via the §4 cleartext workaround.
5. Open a single `android-app` PR; update `docs/android/PLAN.md` if the app design
deviates from §4.1.1.

View File

@@ -648,7 +648,7 @@ Throughout: `<servuo>` is your ServUO root, and **the shard is stopped**.
### A1. Fetch the bundle (so you install a checked pair)
```bash
curl -s https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/main/bundles/current.json
curl -s https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/bundles/current.json
```
It names the sidecar tag, the overlay tag, their agreed `protocol`, and the SHA256 of every asset.

View File

@@ -35,7 +35,7 @@ described a ServUO integration that does not match how `servuo-plugins` actually
|---|---|
| 0.1 `servuo-plugins` release workflow | ✅ Merged — [servuo-plugins#7](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/7) + [#8](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/8); first overlay release is [`v0.1.1`](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/releases/tag/v0.1.1) |
| 0.2 `link` installable (data paths + `--print-config`) | ✅ Merged — [link#24](https://gitea.whitlocktech.com/RunicGateway/link/pulls/24) (docs half [docs#84](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/84)); released as [`v1.1.0`](https://gitea.whitlocktech.com/RunicGateway/link/releases/tag/v1.1.0) |
| 0.3 Bundle CI in the installer repo | ✅ Merged — [installer#3](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/3), plus the dispatch step in each component ([link#25](https://gitea.whitlocktech.com/RunicGateway/link/pulls/25), [servuo-plugins#9](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/9)). First bundle: [`2026.08.04`](https://gitea.whitlocktech.com/RunicGateway/installer/src/branch/main/bundles/current.json) |
| 0.3 Bundle CI in the installer repo | ✅ Merged — [installer#3](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/3), plus the dispatch step in each component ([link#25](https://gitea.whitlocktech.com/RunicGateway/link/pulls/25), [servuo-plugins#9](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/9)). First bundle: [`2026.08.04`](https://gitea.whitlocktech.com/RunicGateway/installer/src/branch/bundles/current.json) |
| 0.4 This file + `INSTALL.md` | ✅ Merged — [docs#87](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/87). [`INSTALL.md`](INSTALL.md) is the operator guide, written before the binary because it *is* the specification of the run |
| — Repo bootstrap (governance + CI) | ✅ [`RunicGateway/installer`](https://gitea.whitlocktech.com/RunicGateway/installer) created; workflows merged ([installer#1](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/1), [#2](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/2)) |
@@ -433,7 +433,9 @@ Repo work that must land before an installer can exist.
- **Bundles are committed to the installer repo, not published as releases** — see §7.1 for
where and why. That was the one genuinely open question here, and the deciding factor is that
this repo's *own* releases are the installer binaries.
this repo's *own* releases are the installer binaries. They were committed to `main` until
2026-08-05, when the first run that actually had a bundle to write found `main` protected;
they now live on a `bundles` branch (§7.1).
- **Gate 1 reads the sidecar's protocol from source at the release tag**, not from the binary.
`--print-config` (Phase 0.2) would answer authoritatively, but only for releases from `v1.1.0`
onward, and `--bundle <tag>` has to be able to recompose a bundle from an older pair. Reading
@@ -1200,24 +1202,44 @@ Two gates run at compose time, both cheap and both worth it:
#### Where bundles are published
Committed to the installer repo under `bundles/`, so the installer's fetch is a plain anonymous
`GET` against a public repo — the shard host has no Gitea credentials (§1):
Committed to the installer repo, on a **`bundles` branch of their own** and at its root, so the
installer's fetch is a plain anonymous `GET` against a public repo — the shard host has no Gitea
credentials (§1):
```
bundles/current.json → …/RunicGateway/installer/raw/branch/main/bundles/current.json
bundles/bundle-<tag>.json → …/raw/branch/main/bundles/bundle-2026.08.04.json (--bundle)
current.json → …/RunicGateway/installer/raw/branch/bundles/current.json
bundle-<tag>.json → …/raw/branch/bundles/bundle-2026.08.04.json (--bundle)
```
Every bundle is kept forever, so `--bundle` stays reproducible. Tags are UTC dates; a second bundle
on the same day — a sidecar release in the morning and an overlay release in the afternoon is the
normal way that happens — becomes `2026.08.04.2`, so one tag always names exactly one matrix.
**A branch, not `main`, and that correction cost a day.** This section originally said bundles were
committed to `main` and "needs no new branch-protection exception: `release.yml`'s version-bump
commit already requires the CI user to be able to push to `main`". Both halves were wrong. `main` is
protected and declines a push from CI (`pre-receive hook declined`), and `release.yml` had never
pushed anything — its bump step has never executed in any repo carrying it, because an **empty
template expression written literally in one of its comments** makes the runner fail to build the
step and skip it *without failing the job*. The tags exist because Gitea's release API creates one
when it publishes. So the assumption that a working push path already existed was never tested by
anything.
Publishing to a branch of its own keeps every property the original choice was for — a reviewable
diff, a git history of the compat matrix, plain anonymous raw URLs, no credentials on the shard host
— and needs no exception at all. The alternative, whitelisting a scheduled job for pushes to the
default branch, buys nothing this does not.
**Not one Gitea release per bundle**, which was the obvious alternative. This repo's own releases
are the installer *binaries*, and `/releases/latest` returns whichever release is newest regardless
of kind — interleaving bundle releases would make "latest" intermittently resolve to a release
carrying no installer binary. Committing also yields a reviewable diff and a git history of the
compat matrix, and needs no new branch-protection exception: `release.yml`'s version-bump commit
already requires the CI user to be able to push to `main`.
carrying no installer binary.
**The release workflows tag and never write to a branch**, for the same reason and settled at the
same time (org lead, 2026-08-05): the tag *is* the version, as `servuo-plugins` has always done it.
The version is still written into `Cargo.toml` before building — so a released binary self-reports
correctly — but is no longer committed back, and the next version is computed from the newest tag.
A first release must not depend on a write to a protected branch.
### 7.2 What triggers a bundle