Files
docs/android/TRUSTED_DEVICES_APP_HANDOFF.md
wtclaude f3a0050c06 docs(installer): record Phase 0.3 — the bundle CI and where bundles live
Documentation half of installer Phase 0.3. Code PRs: RunicGateway/installer#3,
RunicGateway/link#25, RunicGateway/servuo-plugins#9.

Phase 0 item 3 asked for a compose job, a nightly cron, and a dispatch step in
each component. All three landed, and building them settled questions §7 had
left open — so those sections are corrected rather than appended to.

Status: Phase 0 is now all but complete. 0.2 is merged and released as link
v1.1.0; 0.3 is in review; 0.4 (INSTALL.md) is the remaining item, and the shape
it was waiting on has settled.

Phase 0 item 3 gains an "As built" subsection matching items 1 and 2, recording
what was chosen rather than inherited: why gate 1 reads the sidecar's protocol
from source at the release tag instead of asking the binary via --print-config
(it only answers for v1.1.0+, and --bundle <tag> must be able to recompose an
older pair); why gate 2 records the hash CI computed itself and separately
checks for assets absent from SHA256SUMS; why release metadata is read
anonymously; why an unrecognized asset name is a hard failure; and why a run
that changes nothing writes nothing.

§7.1 is corrected in two places. The manifest shape now shows link.assets as a
map keyed by platform — the single sha256 this section sketched could only ever
have described one of the two binaries link publishes — plus schema/generated
and the servuo block. And it gains a subsection naming where bundles are
published: committed under bundles/ in the installer repo, NOT one Gitea release
per bundle, because that repo's own releases are the installer binaries and
/releases/latest returns whichever release is newest regardless of kind.

§7.2 records that the dispatch steps now exist, and that a failed dispatch is a
warning rather than a failed release — which is what keeps the installer repo's
token out of the components' hard requirements.

§7.3 records that the releasable-commit rule must also exclude merge commits,
whose subject quotes the real one: without that, merging a docs: branch whose
title mentions a fix: would re-dispatch a declining release workflow nightly.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 11:17:10 -05:00

7.9 KiB

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.