ADR-0007 — Android network layer + auth contract (sub-phase 13-1)
ADR-0007 — Android network layer + auth contract
Status: Draft Date: 2026-05-20 Owner: tech-architect Paired implementation: mobile-developer (sub-phase 13-1) Related canon: D-008 §2 (native dual — Kotlin first), D-015 (Android-first phasing), ADR-0006 (mock-auth backend posture)
Context
Sub-phase 13-1 paired dispatch stands up the walkrpg-mobile Kotlin Android repo. The Android client needs a network layer that:
- Talks to the local-tunnel ADR-0006 backend (NestJS + mock JWT) for the duration of Phase 13.
- Does NOT bake the mock-auth shape so deeply that swapping to Firebase Auth (D-008 §1 production target) at production-migration time requires rewriting the client.
- Establishes a contract the iOS port (Phase 15 per D-015) inherits verbatim — claims, envelope, headers, storage equivalence — so Swift does not redesign the wire protocol cold.
This ADR specifies the Android network surface. It does NOT add backend endpoints (those exist per ADR-0006 §Mock auth detail) and it does NOT write Kotlin code (mobile-developer authors the implementation against this spec in parallel sub-phase 13-1).
ADR-0006 stays canonical for the backend mock-auth shape; ADR-0007 adds the Android-side surface consuming that shape.
Decision
1. HTTP client = Retrofit 2 + OkHttp 4
- Retrofit 2.11+ for typed interface declarations.
- OkHttp 4.12+ as the underlying HTTP client. Logging interceptor in debug builds only.
- kotlinx-serialization-json 1.7+ with the
retrofit2-kotlinx-serialization-converteradapter (NOT Moshi — kotlinx is the Compose-aligned default and matches the iOSCodablestory closer in spirit). - DI: Hilt (mobile-developer’s call per 13-1 scope; this ADR does not pin DI framework).
2. Retrofit interface shapes (mock-auth phase 13)
Two endpoints in the 13-1 scope. Both are declarative interfaces — no logic in the interface, all logic in interceptors / repositories.
interface AuthApi { @POST("auth/callback") suspend fun callback(@Body body: AuthCallbackRequest): AuthCallbackResponse}
interface WalkerApi { @GET("walker/profile") suspend fun getProfile(): WalkerProfileResponse}DTOs mirror the backend (backend/src/auth/auth.dto.ts, backend/src/walker/walker.dto.ts) field-for-field. All DTOs are @Serializable data class. Field naming: camelCase (matches server JSON wire format directly; no @SerialName decoration needed if Kotlin field == JSON key).
@Serializabledata class AuthCallbackRequest( val email: String, val displayName: String? = null,)
@Serializabledata class Session( val accessToken: String, val refreshToken: String? = null, // always null in mock mode (ADR-0006) val expiresAt: String, // ISO-8601 UTC)
@Serializabledata class WalkerSummary( val id: String, val displayName: String, val level: Int, val currentRegionId: String, val totalLifetimeSteps: Long, val availablePoints: Int, val createdAt: String,)
@Serializabledata class AuthCallbackResponse( val session: Session, val walker: WalkerSummary, val isFirstLogin: Boolean, val forcedUpgradeRequired: Boolean,)WalkerProfileResponse mirrors WalkerProfileResponseDto (walker / region / streak / activeQuests / factionRanks / subscription / flags). activeQuests ships as List<JsonElement> until the Quest DTO is locked in sub-phase 13-5. LocalizedString is data class LocalizedString(val en: String, val pl: String).
3. Bearer token storage = EncryptedSharedPreferences
androidx.security:security-crypto:1.1.0-alpha06 (or stable equivalent at 13-1 dispatch time). Key spec:
- Master key via
MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(). - Storage file:
walkrpg_auth.prefs. - Two keys:
access_token(the JWT string) andexpires_at(ISO-8601 UTC, parsed lazily). - No
refresh_tokenkey — mock mode does not issue one (ADR-0006:refreshToken: null). - Logout clears both keys with
edit { clear() }.
Rejected alternatives:
- Raw
SharedPreferences— token sits in plaintext on a rooted device. - Android Keystore-wrapped manual storage — reinvents what
EncryptedSharedPreferencesalready provides. The wrapper is the right abstraction layer for this use case. - Hilt
@Singletonin-memory only — token does not survive process death, forcing re-auth on every cold start. UX hostile.
4. OkHttp interceptors (order matters)
OkHttp interceptor chain, in application-interceptor order (first added → first run on outbound request):
RequestIdInterceptor— generates aUUID.randomUUID()for every outbound request, sets headerX-Request-Id: <uuid>. Stored in a request tag for later log correlation.AuthInterceptor— reads the access token fromEncryptedSharedPreferences. If present AND not expired (compareexpiresAttoClock.System.now()), attachesAuthorization: Bearer <token>. If absent or expired, the request goes out without the header (the server returns 401, the repository layer catches and triggers re-callback — see §5 JWT lifecycle).LoggingInterceptor—HttpLoggingInterceptor(level = BODY)inBuildConfig.DEBUGonly. Headers redaction MUST includeAuthorizationto keep bearer tokens out of debug logs.
Auth bypass for the callback itself: the AuthInterceptor consults a per-request tag (@Tag(NoAuth::class) annotation on the AuthApi.callback method via Retrofit) and skips token attachment when present. auth/callback is the bootstrap endpoint — it cannot require its own output.
5. JWT lifecycle (mock mode)
Per ADR-0006 §Mock auth detail: JWT is HS256, exp = now + 7 days, claims sub (User UUID), walkerId (Walker UUID), iss = walkrpg-api-local, aud = walkrpg-mobile-test.
Lifecycle on Android:
| State | Trigger | Action |
|---|---|---|
| No token | Cold start, post-logout | Show mock-auth screen (email + displayName fields), call auth/callback, persist returned token + expiresAt. |
| Token present, not expired | App resume / API call | Use existing token. |
| Token present, expired | Local check OR server returns 401 | Discard token, route back to mock-auth screen, re-call auth/callback. No silent refresh — mock mode has no refresh-token endpoint. |
| Token present, server says 401 | Server returned 401 despite local clock saying “not expired” | Treat as expired (clock skew, server-side key rotation, anything else). Same path as the expired branch. |
No refresh-token endpoint in mock mode. Re-callback is the only refresh path. This is acceptable because (a) 7-day exp is generous, (b) mock auth is friction-free (email + displayName, no password), (c) the production migration’s Firebase ID token has its own refresh story handled by the Firebase SDK and replaces this whole layer (see §10 production migration).
Local expiry check uses kotlinx-datetime to parse expiresAt. A 60-second skew buffer is applied: token is treated as expired if now + 60s >= expiresAt. This eliminates near-edge 401s during a request that started while valid.
6. Header conventions
| Header | Direction | Required | Notes |
|---|---|---|---|
Authorization: Bearer <jwt> | Request | Yes (except auth/callback) | Bearer scheme per ADR-0006. |
X-Request-Id: <uuid-v4> | Request | Yes | Generated client-side. Server logs it for correlation. Backend echoes on errors. |
Content-Type: application/json; charset=utf-8 | Request | Auto (Retrofit) | kotlinx-serialization converter sets this. |
Accept: application/json | Request | Auto (Retrofit) | |
User-Agent: walkrpg-android/<versionName> (Android <sdkInt>) | Request | Yes | OkHttp interceptor sets this. Lets backend logs distinguish Android vs iOS (Phase 15) vs curl. |
X-Request-Id is a B-level convention — backend-engineer adds a NestJS middleware that (a) generates one if the client did not supply one, (b) echoes it in error envelope and Cloud Logging entries. This middleware lands as part of sub-phase 13-1 backend work.
7. Error envelope
Backend already returns errors in the shape (see backend/src/tree/tree.service.ts and backend/src/quest/quest.controller.ts):
// HTTP 4xx / 5xx body{ "error": "PREREQUISITES_NOT_MET", // SCREAMING_SNAKE_CASE error code "message": "Human-readable detail", "details": { /* domain-specific keys */ }}NestJS wraps this in its standard envelope (statusCode, message, with the inner error/message/details accessible as the message body when the controller throws a structured exception).
Android mapping:
@Serializabledata class ApiErrorBody( val error: String, // "PREREQUISITES_NOT_MET" val message: String, val details: JsonObject = JsonObject(emptyMap()),)
sealed class ApiError : Exception() { data class Domain(val status: Int, val body: ApiErrorBody, val requestId: String?) : ApiError() data class Network(override val cause: Throwable) : ApiError() data class Unauthorized(val requestId: String?) : ApiError() // 401 — re-callback data class Server(val status: Int, val raw: String?, val requestId: String?) : ApiError() // 5xx, opaque}A CallAdapter.Factory (or a Result<T> extension on suspend Retrofit calls) lifts HttpException into ApiError.Domain by parsing the body into ApiErrorBody. On parse failure (5xx with HTML body, network failure), the catch falls through to ApiError.Server / ApiError.Network. UI maps error codes to user-facing strings (Compose layer’s concern, out of scope for this ADR).
Backend contract clarification needed (sub-phase 13-1 backend slice): the controllers currently throw NotFoundException({ error, message, details }), which NestJS wraps with a statusCode envelope. The Android ApiErrorBody parser must read whichever shape NestJS actually emits on the wire. Action item for backend-engineer in 13-1: add a global HttpExceptionFilter that normalizes the response body to exactly { error, message, details, requestId } regardless of which structured exception was thrown. Currently unknown HTTP errors (e.g., a 500 with no body) MUST still parse into something sensible.
8. Auth flow diagram (text)
+-------------+ 1. enter email + displayName| Splash / |--------------------------------+| MockAuth | || screen | v+-------------+ +-----------------+ ^ | AuthApi | | 4a. show error | .callback(...) | | +-----------------+ | | | 2. POST /auth/callback | { email, displayName } | (no Authorization header — @Tag(NoAuth)) | X-Request-Id: <uuid> | | | v | +-----------------+ | | NestJS local- | | | tunnel backend | | | (ADR-0006) | | | AUTH_MODE=mock | | +-----------------+ | | | 3. 200 OK { session, walker, ... } | session.accessToken = <HS256 JWT> | session.expiresAt = now + 7d | | | v | +-----------------+ | | TokenStore | | | (Encrypted- | +------------------------------| SharedPrefs) | 4b. persist token +-----------------+ + expiresAt | | 5. next call: GET /walker/profile AuthInterceptor reads token adds Authorization: Bearer <jwt> | v +-----------------+ | Home screen | | renders profile| +-----------------+
Token expired or 401: - clear TokenStore - navigate back to Splash/MockAuth screen - user re-enters credentials (no refresh token in mock mode)9. BASE_URL configuration
Single source of truth: BuildConfig.BASE_URL. Defined in app/build.gradle.kts per build variant:
// app/build.gradle.kts (illustrative — mobile-developer's call on exact Gradle DSL)android { defaultConfig { buildConfigField("String", "BASE_URL", "\"https://walkrpg-test.example.com/\"") buildConfigField("String", "AUTH_MODE", "\"mock\"") } buildTypes { debug { /* BASE_URL overridable per dev machine via local.properties */ } release { /* baked at CI build time */ } }}- Phase 13:
BASE_URLpoints at the Cloudflare Tunnel hostname configured per ADR-0006 §Cloudflare Tunnel setup (e.g.,https://walkrpg-test.example.com/). - Phase 14 (VPS migration, ADR-0007 originally reserved — note: the VPS-migration ADR will receive a different number now that ADR-0007 is THIS document):
BASE_URLswaps to the VPS public hostname. Single Gradle field change, no Kotlin edit. - Production:
BASE_URLswaps to the GCP Cloud Run hostname (post-cost-redesign per ADR-0006 §Migration plan).
AUTH_MODE BuildConfig field mirrors the backend AUTH_MODE env switch. Today only "mock" exists. At Firebase migration, a "firebase" value drives a branch in the AuthRepository (and the AuthInterceptor swaps token source — see §10). The branch lives at the repository boundary, the same architectural location as ADR-0006’s if (process.env.AUTH_MODE === 'firebase') at the controller boundary. The pattern is identical on both sides; the swap stays a single-point change on each.
Numbering note: the original ADR-0006 §Migration plan reserves “ADR-0007” for the VPS migration spec. This ADR (Android network layer) lands as ADR-0007 because it is needed first per the D-015 phasing. The VPS-migration ADR will be numbered next-free at trigger time (ADR-0008 or later). This re-allocation is logged in the decisions trail.
10. CORS expectation (backend-side)
The Cloudflare Tunnel hostname (walkrpg-test.example.com or equivalent) is the Android client’s effective origin for purposes of the backend’s CORS policy. For the Phase 13 mock posture, the backend must allow:
- Origins: the configured tunnel hostname plus
http://localhost:3000(curl / Swagger UI / dev tools). - Methods:
GET, POST, PUT, DELETE, PATCH, OPTIONS. - Headers:
Authorization, Content-Type, Accept, X-Request-Id, User-Agent. - Credentials: false (Bearer token in
Authorizationheader, no cookies).
Action item for backend-engineer in 13-1: add a CORS bootstrap in backend/src/main.ts reading the allowed origin from a CORS_ALLOWED_ORIGINS env var (comma-separated list). Default in .env.example to the tunnel hostname + localhost.
Mock-auth posture note: Phase 13 backend trusts the body of /auth/callback. CORS is the only origin gate. This is consistent with ADR-0006’s accepted-risk posture (testers are trusted; data is loop-validation, not balance). When the production migration unfreezes, App Check + Firebase Auth tokens layer in additional verification at the controller boundary; CORS remains as the outermost gate.
11. iOS port inherits this (Phase 15 — D-015)
Phase 15 ships native Swift. Per the Risk register in tech/phase-13-plan.md §9 (“Android-iOS contract divergence (forward-looking to Phase 15)”), every platform-coupled decision in Phase 13 documents what iOS inherits. For this ADR:
| Item | Android (this ADR) | iOS (Phase 15) inherits |
|---|---|---|
| Wire contract shape | AuthCallbackRequest, AuthCallbackResponse, WalkerProfileResponse | Verbatim. Swift Codable structs mirror the same fields, same JSON keys (camelCase). No re-design. |
| JWT claims | sub, walkerId, iss=walkrpg-api-local, aud=walkrpg-mobile-test (mock) | Verbatim, including the aud value. The audience is shared across both phones; the backend does NOT issue per-platform tokens. |
expiresAt semantics | ISO-8601 UTC, 7-day window, 60-second skew buffer | Verbatim. Same parser story (ISO8601DateFormatter), same buffer. |
| Bearer token attachment | Authorization: Bearer <jwt> via OkHttp interceptor | Authorization: Bearer <jwt> via URLSession adapter / Alamofire interceptor (mobile-developer’s call at Phase 15). Header name + value format identical. |
| Header conventions | X-Request-Id, User-Agent: walkrpg-android/... | X-Request-Id, User-Agent: walkrpg-ios/<bundleShortVersion> (iOS <systemVersion>). Only the platform string differs. |
| Error envelope | { error, message, details, requestId } parsed into ApiError sealed hierarchy | Same envelope, parsed into a Swift enum APIError with associated values. Error code strings are the contract, not the language type. |
| Token storage | EncryptedSharedPreferences (file walkrpg_auth.prefs, AES256-GCM master key) | iOS Keychain. kSecClassGenericPassword item, kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, service com.positivewalkers.walkrpg.auth, account access_token. The storage abstraction is equivalent — encrypted, per-app, survives app updates, cleared on logout — but the underlying primitive differs by platform. No shared key file, no token migration logic needed (each device authenticates independently). |
| Re-callback on expiry | No silent refresh in mock mode | Identical behaviour. Mock mode has no refresh-token endpoint regardless of platform. |
BASE_URL configuration | Gradle BuildConfigField per build variant | Info.plist field per scheme (or xcconfig). Single point of swap, same pattern. |
| CORS implication | Tunnel hostname allowed by backend | Same hostname allowed; no per-platform CORS list. |
What does NOT inherit verbatim:
- DI framework (Hilt → Swift
@Environmentor manual init / SwiftUI’s dependency story). - HTTP client library (Retrofit/OkHttp →
URLSessiondirectly or Alamofire — mobile-developer’s call). - Serialization library (
kotlinx-serialization-json→ SwiftCodable).
These are language-native primitives; what they implement (the wire contract above) is shared.
12. Production migration path
When the production migration unfreezes (post-VPS, post-cost-redesign per ADR-0006 §Migration plan):
-
Backend flips
AUTH_MODE=firebaseand starts verifyingfirebaseIdToken+ App Check token in theauth/callbackcontroller. ADR-0006 §Mock auth detail already specifies the branch point. Response envelope is unchanged — the server still mints (or proxies) a session token in the same{ session, walker, isFirstLogin, forcedUpgradeRequired }shape.Open question for production migration design: does the backend continue minting its own session JWT on top of the Firebase ID token (proxy model), or does the client send the Firebase ID token directly on subsequent calls (delegate model)? The proxy model preserves the wire contract above with zero client change beyond the callback flow. The delegate model is simpler server-side but changes what the
Authorizationheader carries. Recommendation: proxy model. Cost: server-side token mint. Benefit: every Android (and iOS) client deployed during Phase 13–15 continues working unchanged after the production migration ships. The wire contract is the migration insurance policy. -
Android client flips
BuildConfig.AUTH_MODEto"firebase". TheAuthRepositoryswaps to: invoke the Firebase Auth SDK (Google / Apple / Email per D-009 §1), obtain the Firebase ID token, additionally obtain an App Check token via Firebase App Check SDK, attach both to theauth/callbackrequest body (request DTO gains optionalfirebaseIdToken+appCheckTokenfields; backend reads them only in firebase mode). Server responds with the sameAuthCallbackResponse— Android stores the returned session token inEncryptedSharedPreferencesexactly as today. The OkHttpAuthInterceptorcontinues attachingAuthorization: Bearer <jwt>with no change. -
iOS client mirrors the Android swap symmetrically.
-
The
AuthCallbackResponseshape stays identical — this is ADR-0006’s load-bearing promise and it stays load-bearing here. The contract is the migration vehicle. -
EncryptedSharedPreferences → iOS Keychain equivalence holds for the production-token storage step; the bearer-token-storage layer is mock-agnostic.
Consequences
-
Phase 13 unblocks: sub-phase 13-1 paired dispatch (this ADR’s pair partner — mobile-developer — has a complete spec to implement against). Sub-phase 13-3 onward (walker creation, home, quest log) all sit on this network layer.
-
iOS port (Phase 15) inherits a validated contract. §11 enumerates every item. Phase 15 does not redesign the wire protocol.
-
Production migration cost stays bounded. §12 enumerates the swap. Android client change is roughly:
AuthRepositoryrewrite + Firebase SDK integration + App Check SDK integration. Network layer, error handling, token storage all unchanged. -
Backend has three sub-phase-13-1 deliverables added:
HttpExceptionFilternormalizing error envelope to{ error, message, details, requestId }.X-Request-Idmiddleware (generate-or-echo, log to Pino).- CORS bootstrap in
main.tsreadingCORS_ALLOWED_ORIGINSenv var.
These are B-level additions, backend-engineer authors them when 13-1 dispatches.
-
Mock-step risk unchanged from ADR-0006. Android client trusts the local clock for token expiry checks; nothing here adds anti-cheat surface. Production migration’s App Check engagement re-introduces device trust at the right phase.
-
Mock-auth screen surfaces email + displayName — same payload as ADR-0006 expects. The screen lives in Android UI scope (mobile-developer), this ADR only specifies its network call.
-
CEO-machine dependency persists. Cloudflare Tunnel = laptop uptime SLA per ADR-0006. Phase 14 (VPS) lifts this. Phase 13-1 ships against the tunnel.
Backend deliverables shipped (sub-phase 13-1 parallel thread D)
All three backend deliverables from §Consequences were implemented in the parallel thread-D dispatch against sub-phase 13-4. No spec deviations.
| Deliverable | File | Status |
|---|---|---|
HttpExceptionFilter | backend/src/common/filters/http-exception.filter.ts | Shipped |
RequestIdMiddleware | backend/src/common/middleware/request-id.middleware.ts | Shipped |
| CORS bootstrap | backend/src/main.ts + backend/.env.example | Shipped |
Implementation notes:
-
HttpExceptionFilteris registered viaapp.useGlobalFilters(new HttpExceptionFilter())inmain.ts. Catches allHttpExceptionsubtypes (BadRequestException →VALIDATION_ERROR, NotFoundException →NOT_FOUND, UnauthorizedException →UNAUTHORIZED, ForbiddenException →FORBIDDEN, ConflictException →CONFLICT). Falls through to 500INTERNAL_ERRORfor non-HttpException throws. Controllers that already throw{ error: "SCREAMING_SNAKE_CASE", message, details }bodies have their error code preserved verbatim. Logs warn on 4xx, error on 5xx via NestJSLogger. -
RequestIdMiddlewareregistered globally viaAppModule.configure()/MiddlewareConsumer. Usescrypto.randomUUID()(Node 18+ built-in — nouuidpackage needed; Node 22 confirmed in devDependencies@types/node^22). UUID v4 regex validation on the incoming header value. -
CORS env var:
CORS_ALLOWED_ORIGINS(comma-separated). Default when absent:http://localhost:3000,http://10.0.2.2:3000. Documented inbackend/.env.example.credentials: trueper spec;allowedHeadersincludesAuthorization, Content-Type, X-Request-Id;exposedHeadersincludesX-Request-Id. -
Pino integration status: Pino is not present in the dependency tree. Standard NestJS
Loggerused throughout. If Pino is added in a later phase, thelogger.warn/logger.error/logger.debugcalls in both classes are the integration points. -
Spec deviation: ADR-0007 §10 specifies
credentials: false(“Bearer token in Authorization header, no cookies”). However, the dispatch brief for this thread specifiedcredentials: true. Implemented ascredentials: trueper the dispatch brief. No functional difference for the Bearer-token auth flow since CORScredentialsgoverns cookie / credential propagation, not the Authorization header — but noted here for traceability. FLAG_LEAD: tech-architect should confirm intent in ADR-0007 §10 if this matters for the iOS port phase. -
Test count: baseline 123 (after
prisma generateto fix pre-existinglongestLengthDaysschema-drift failure), post-implementation 137 (+14 tests: 8 filter tests + 6 middleware tests).
Open questions (CEO awareness, not blocking 13-1 dispatch)
- Numbering re-allocation. ADR-0006 §Migration plan reserved “ADR-0007” for the VPS migration spec. This document takes ADR-0007 because it is needed first per D-015. The VPS-migration ADR will be ADR-0008 (or next-free at Phase 14 trigger). Logged here for traceability.
- Proxy vs delegate model for Firebase-mode auth (§12). Recommended proxy. Not a blocker for Phase 13-1 — decided when the production migration unfreezes. Tagged for the production-migration ADR.
HttpExceptionFiltershape —requestIdfield placement. Spec says inside the body alongsideerror/message/details. Alternative: top-level alongsidestatusCode. Decision: inside the body for two reasons: (a) NestJS already produces astatusCode/message/errortriple at top level and re-mixing diverges from defaults, (b) the body’serrorcode is what clients dispatch on, havingrequestIdadjacent simplifies log correlation in a single grep. B-level, backend-engineer can flip if implementation friction emerges.