Accounts & tokens
Accounts are optional — the core representative lookup works signed out. Identity exists to power the community section, message delivery, and the ZIP-code-derived districts on the profile.
Sign-in paths and lifecycle
Four ways in, all landing on the same token response: email + password (POST /account/signup, POST /account/signin), Google (POST /account/oauth/google with the ID token), and Apple (POST /account/oauth/apple with the identity token, plus given/family name on first authorization only, because Apple supplies them exactly once). Sign-in failures for unknown email and wrong password are deliberately indistinguishable (invalid_credentials). Apple sign-in is iOS-only for now; Android would need a web-redirect flow that isn't implemented.
Sign-out posts to POST /account/signout but treats server failure as ignorable — local state is always cleared in a finally, because a device that can't reach the server must still be able to sign out. Account deletion (DELETE /account) is the opposite: the server call must succeed before local state is cleared, so a failed deletion never leaves the user believing their data is gone.
Token storage
The server issues a four-part credential: access token, access expiry, refresh token, refresh expiry. All four live in flutter_secure_storage — the iOS Keychain / Android Keystore — under the keys account_access_token, account_access_expires_at, account_refresh_token, and account_refresh_expires_at. The house rule is explicit in the store's documentation: tokens must never touch SQLite, preferences, or logs. A corrupt or partially-missing entry reads as signed-out (the store clears itself) rather than crashing. The refresh token is rotated on every use; a malformed expiry in a server response parses as epoch zero, which safely reads as already expired.
The refresh design
AccountService registers two hooks on the API client at init. The first is the user-header provider: on every request it returns Authorization: Bearer …, and if the access token expires within five minutes it refreshes first. The second is the 401 hook: an unauthorized response triggers a refresh and a single retry.
The subtlety both hooks share is that the refresh request itself resolves headers through the same provider it is refreshing for. Awaiting the in-flight refresh from inside the provider would deadlock, and re-entering it would recurse; so the provider skips proactive refresh whenever a refresh is already running (concurrent requests ride out the five-minute leeway), and the 401 hook returns false when a refresh is in flight, because a 401 arriving mid-refresh can only have come from the refresh call itself. The in-flight marker is published synchronously — before the POST begins — precisely so the re-entrant call sees it.
POST /account/refresh returns a fully rotated pair on success. If the server answers invalid_token or unauthorized, the session is unrecoverable — expired, revoked, or the refresh token was reused (rotation makes reuse detectable server-side) — and the client clears all local state, signing the user out. Transient failures keep the tokens: the request that wanted headers proceeds and normal 401 recovery gets another chance later.
Profile, districts, and the ZIP lock
The profile (GET/PUT /account/profile) carries name, email, and a ZIP code from which the server derives state, congressional, state-house, and state-senate districts — the same districts that determine community hub membership. Because hub membership follows the ZIP, changing it locks it for 30 days (zipcode_locked_until); the profile UI disables the field and explains the lock. Privacy stance, stated in the UI itself: districts are computed from the ZIP; the street address never leaves the device. There is no display-name field — the public name shown to other users is derived client-side as "First L.".
Profile photos are raw JPEG bytes, not multipart: the picker resizes to a 512-px long edge and steps JPEG quality down (80/60/40/25) until under 200 KB, then POST /account/photo uploads with Content-Type: image/jpeg. Reads go memory → disk cache (account_profile_photo.jpg) → GET /account/photo; the in-memory copy exists so the title-bar avatar renders synchronously. A has_photo flag on the profile short-circuits the fetch for users without one.
For offline continuity, the profile (never the tokens) is cached as JSON in the database's app_metadata table, so a signed-in user who launches without connectivity still sees their name and districts while the background profile sync fails quietly.
Error surface
Server errors arrive as short codes embedded in the response (email_in_use, invalid_credentials, weak_password, invalid_zipcode, zipcode_locked, and so on); AccountService maps them to AccountException(code) and the views translate codes to human copy. Cancelled OAuth flows surface as cancelled rather than errors, and connectivity failures as network_error.
| Concern | Decision |
|---|---|
| Token storage | Keychain/Keystore only; four keys; never SQLite or prefs |
| Access expiry leeway | 5 minutes (proactive refresh window) |
| Refresh concurrency | Single-flight; marker published before the POST |
| Refresh-reuse / revocation | Terminal: clear state, sign out |
| ZIP change | Locked 30 days (pins hub membership) |
| Photo upload | Raw JPEG ≤ ~200 KB client-side (server rejects > 300 KB) |
| Offline profile | Cached in app_metadata; tokens never cached there |