Skip to content

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:

  1. Talks to the local-tunnel ADR-0006 backend (NestJS + mock JWT) for the duration of Phase 13.
  2. 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.
  3. 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-converter adapter (NOT Moshi — kotlinx is the Compose-aligned default and matches the iOS Codable story 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).

@Serializable
data class AuthCallbackRequest(
val email: String,
val displayName: String? = null,
)
@Serializable
data class Session(
val accessToken: String,
val refreshToken: String? = null, // always null in mock mode (ADR-0006)
val expiresAt: String, // ISO-8601 UTC
)
@Serializable
data class WalkerSummary(
val id: String,
val displayName: String,
val level: Int,
val currentRegionId: String,
val totalLifetimeSteps: Long,
val availablePoints: Int,
val createdAt: String,
)
@Serializable
data 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) and expires_at (ISO-8601 UTC, parsed lazily).
  • No refresh_token key — 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 EncryptedSharedPreferences already provides. The wrapper is the right abstraction layer for this use case.
  • Hilt @Singleton in-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):

  1. RequestIdInterceptor — generates a UUID.randomUUID() for every outbound request, sets header X-Request-Id: <uuid>. Stored in a request tag for later log correlation.
  2. AuthInterceptor — reads the access token from EncryptedSharedPreferences. If present AND not expired (compare expiresAt to Clock.System.now()), attaches Authorization: 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).
  3. LoggingInterceptorHttpLoggingInterceptor(level = BODY) in BuildConfig.DEBUG only. Headers redaction MUST include Authorization to 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:

StateTriggerAction
No tokenCold start, post-logoutShow mock-auth screen (email + displayName fields), call auth/callback, persist returned token + expiresAt.
Token present, not expiredApp resume / API callUse existing token.
Token present, expiredLocal check OR server returns 401Discard 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 401Server 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

HeaderDirectionRequiredNotes
Authorization: Bearer <jwt>RequestYes (except auth/callback)Bearer scheme per ADR-0006.
X-Request-Id: <uuid-v4>RequestYesGenerated client-side. Server logs it for correlation. Backend echoes on errors.
Content-Type: application/json; charset=utf-8RequestAuto (Retrofit)kotlinx-serialization converter sets this.
Accept: application/jsonRequestAuto (Retrofit)
User-Agent: walkrpg-android/<versionName> (Android <sdkInt>)RequestYesOkHttp 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:

@Serializable
data 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_URL points 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_URL swaps to the VPS public hostname. Single Gradle field change, no Kotlin edit.
  • Production: BASE_URL swaps 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 Authorization header, 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:

ItemAndroid (this ADR)iOS (Phase 15) inherits
Wire contract shapeAuthCallbackRequest, AuthCallbackResponse, WalkerProfileResponseVerbatim. Swift Codable structs mirror the same fields, same JSON keys (camelCase). No re-design.
JWT claimssub, 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 semanticsISO-8601 UTC, 7-day window, 60-second skew bufferVerbatim. Same parser story (ISO8601DateFormatter), same buffer.
Bearer token attachmentAuthorization: Bearer <jwt> via OkHttp interceptorAuthorization: Bearer <jwt> via URLSession adapter / Alamofire interceptor (mobile-developer’s call at Phase 15). Header name + value format identical.
Header conventionsX-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 hierarchySame envelope, parsed into a Swift enum APIError with associated values. Error code strings are the contract, not the language type.
Token storageEncryptedSharedPreferences (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 expiryNo silent refresh in mock modeIdentical behaviour. Mock mode has no refresh-token endpoint regardless of platform.
BASE_URL configurationGradle BuildConfigField per build variantInfo.plist field per scheme (or xcconfig). Single point of swap, same pattern.
CORS implicationTunnel hostname allowed by backendSame hostname allowed; no per-platform CORS list.

What does NOT inherit verbatim:

  • DI framework (Hilt → Swift @Environment or manual init / SwiftUI’s dependency story).
  • HTTP client library (Retrofit/OkHttp → URLSession directly or Alamofire — mobile-developer’s call).
  • Serialization library (kotlinx-serialization-json → Swift Codable).

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=firebase and starts verifying firebaseIdToken + App Check token in the auth/callback controller. 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 Authorization header 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_MODE to "firebase". The AuthRepository swaps 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 the auth/callback request body (request DTO gains optional firebaseIdToken + appCheckToken fields; backend reads them only in firebase mode). Server responds with the same AuthCallbackResponse — Android stores the returned session token in EncryptedSharedPreferences exactly as today. The OkHttp AuthInterceptor continues attaching Authorization: Bearer <jwt> with no change.

  • iOS client mirrors the Android swap symmetrically.

  • The AuthCallbackResponse shape 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: AuthRepository rewrite + Firebase SDK integration + App Check SDK integration. Network layer, error handling, token storage all unchanged.

  • Backend has three sub-phase-13-1 deliverables added:

    1. HttpExceptionFilter normalizing error envelope to { error, message, details, requestId }.
    2. X-Request-Id middleware (generate-or-echo, log to Pino).
    3. CORS bootstrap in main.ts reading CORS_ALLOWED_ORIGINS env 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.

DeliverableFileStatus
HttpExceptionFilterbackend/src/common/filters/http-exception.filter.tsShipped
RequestIdMiddlewarebackend/src/common/middleware/request-id.middleware.tsShipped
CORS bootstrapbackend/src/main.ts + backend/.env.exampleShipped

Implementation notes:

  • HttpExceptionFilter is registered via app.useGlobalFilters(new HttpExceptionFilter()) in main.ts. Catches all HttpException subtypes (BadRequestException → VALIDATION_ERROR, NotFoundException → NOT_FOUND, UnauthorizedException → UNAUTHORIZED, ForbiddenException → FORBIDDEN, ConflictException → CONFLICT). Falls through to 500 INTERNAL_ERROR for 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 NestJS Logger.

  • RequestIdMiddleware registered globally via AppModule.configure() / MiddlewareConsumer. Uses crypto.randomUUID() (Node 18+ built-in — no uuid package 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 in backend/.env.example. credentials: true per spec; allowedHeaders includes Authorization, Content-Type, X-Request-Id; exposedHeaders includes X-Request-Id.

  • Pino integration status: Pino is not present in the dependency tree. Standard NestJS Logger used throughout. If Pino is added in a later phase, the logger.warn/logger.error/logger.debug calls 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 specified credentials: true. Implemented as credentials: true per the dispatch brief. No functional difference for the Bearer-token auth flow since CORS credentials governs 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 generate to fix pre-existing longestLengthDays schema-drift failure), post-implementation 137 (+14 tests: 8 filter tests + 6 middleware tests).

Open questions (CEO awareness, not blocking 13-1 dispatch)

  1. 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.
  2. 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.
  3. HttpExceptionFilter shape — requestId field placement. Spec says inside the body alongside error/message/details. Alternative: top-level alongside statusCode. Decision: inside the body for two reasons: (a) NestJS already produces a statusCode/message/error triple at top level and re-mixing diverges from defaults, (b) the body’s error code is what clients dispatch on, having requestId adjacent simplifies log correlation in a single grep. B-level, backend-engineer can flip if implementation friction emerges.