From 0d79ffd73c49017c439037e4bc9f632f6d472ac4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 19:23:13 -0500 Subject: [PATCH] fix(link): a protocol bump owes four declaration sites, not three MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v4's cross-repo obligation table named module-uo's ingest work and stopped there, so both of that module's PIN sites — DEFAULT_PROTOCOL and the uo_link_config.protocol column default — stayed at 3 when protocol 4 shipped. The consequence is not partial degradation. A sidecar answers a stale client `409 protocol version mismatch` rather than mis-parsing it, so a fresh install read nothing at all from its shard — empty marketplace, empty guild board, no status — until an admin edited the number by hand in Admin → Shard. Existing installs were unaffected, which is why it went unnoticed: their stored row had already been moved by the protocol-3 one-shot, and the stored row wins. Found while standing up a demo deployment for the marketing site's screenshots; fixed in Module-uo. Co-Authored-By: Claude --- android/TRUSTED_DEVICES_APP_HANDOFF.md | 126 +++++++++++++++++++++++++ link/v4.md | 14 ++- 2 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 android/TRUSTED_DEVICES_APP_HANDOFF.md diff --git a/android/TRUSTED_DEVICES_APP_HANDOFF.md b/android/TRUSTED_DEVICES_APP_HANDOFF.md new file mode 100644 index 0000000..81ef644 --- /dev/null +++ b/android/TRUSTED_DEVICES_APP_HANDOFF.md @@ -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: ` +- `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:'',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. diff --git a/link/v4.md b/link/v4.md index 5699d89..26e6b99 100644 --- a/link/v4.md +++ b/link/v4.md @@ -236,7 +236,7 @@ page listing character names versus one publishing 150 account names, so it carr |---|---| | `servuo-plugins` | member-set diff, `guild.roster` + `guild.leave`, `BridgeJson.Actors`, two `Bridge.cfg` keys, **`overlay.toml` protocol → 4** | | `link` | `PROTOCOL_VERSION` → 4, `members` column + `user_version` migration, roster reassembly, `GET /guilds` projection | -| `module-uo` | `shard_guild_members`, `guild.roster`/`guild.leave` ingest, the kind→feature map entries | +| `module-uo` | `shard_guild_members`, `guild.roster`/`guild.leave` ingest, the kind→feature map entries, **the pinned protocol → 4** | | `installer` | `backup.rs`'s stated reason for skipping the sidecar DB (§3.2 falsifies it) — docs only | | `docs` | this file, `PROTOCOL_2.md` §10.1, `INTEGRATION.md` | @@ -244,6 +244,18 @@ page listing character names versus one publishing 150 account names, so it carr the installer refuses to pair an overlay and a sidecar whose protocol numbers disagree, so a bump landing separately would silently fail to compose into a bundle. +**The pinned protocol is a fourth declaration site, and this table missed it once.** `module-uo` +pins the version the WEBSITE speaks, in two places — `DEFAULT_PROTOCOL` in +`server/model/uoLinkConfig/uoLinkConfig.model.js` and the `uo_link_config.protocol` column default +in `server/db/schema.sql`. Both stayed at 3 when protocol 4 shipped, because this row named only the +ingest. The consequence is not a partial degradation: a sidecar answers a stale client `409 protocol +version mismatch` rather than mis-parsing it, so a fresh install read *nothing* from its shard — +empty marketplace, empty guild board, no status — until an admin edited the number by hand in +Admin → Shard. Existing installs were unaffected, which is why it went unnoticed: the protocol-3 +one-shot had already moved their stored row, and the stored row wins. Fixed in `module-uo` (the pin, +plus a protocol-4 one-shot mirroring the protocol-3 one). **A protocol bump owes four declaration +sites, not three.** + --- ## 6. Verification