Files
docs/android/TRUSTED_DEVICES_APP_HANDOFF.md
wtclaude 8acee8a3fa 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>
2026-08-05 17:21:56 -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.