Spec: F01 — Authentication
Status: shipped · Audited: 2026-08-05
1. Problem & CEO Job
Serves all three jobs indirectly: nothing in Lumi works without a trusted identity. Auth succeeds when nobody thinks about it — the CEO opens Lumi at 5am and is already in. Any morning that starts with a login form is a product failure; any login that fails with a vague error is worse.
2. User Stories & Acceptance Criteria
- As a returning user, I want to stay signed in across days, so that the morning brief is one tap away.
- Given the correct password and an email whose letter casing differs from the stored account, when I log in, then Lumi resolves the same account.
- Given a valid refresh token, when the access token expires, then the client refreshes silently — no visible re-login.
- Given a valid browser refresh cookie, when Lumi cold-starts, then the
rotated response also restores the user’s profile, active memberships, and
membership-authorized current organization before route data starts — no
follow-up
/auth/me/,/auth/me/orgs/, or/organizations/current/waterfall. Native refresh responses remain token-only. - Given an expired/invalid refresh token, when any request fails auth, then the user lands on
/auth/loginwith their email pre-filled if known.
- As a new user who is not joining from an invitation, I want Lumi to ask about my organization immediately after signup or first login, so that I do not land in a broken no-tenant state.
- Given a valid email + password + name, when I register, then I am signed in even if I belong to zero organizations.
- On Android, the native registration submit CTA stays above the system navigation area while the shared keyboard inset remains platform-owned; iOS keeps the host-owned safe-area behavior.
- Given an email whose letter casing differs from an existing account, when I register, then Lumi rejects it as the same identity. New account emails are stored in lowercase and the database enforces the same case-insensitive uniqueness rule.
- Given an authenticated user with zero active memberships, when the app bootstraps, then the next step is organization setup (
create organizationorjoin with code) instead of/work/today,/settings, or a paywall. - Given an invitation token or short code, when the user joins an existing organization, then organization setup is skipped and the invited org becomes current.
- As a user, I want wrong-credential errors to tell me what to do, so that I’m not guessing.
- Given wrong credentials, when I submit, then I see an actionable bilingual message — never a raw 401 or English-only string.
- Given repeated failed attempts, when the lockout threshold is hit (django-axes), then the error says to wait, not “invalid credentials” forever.
- Given a login email-field
400whose response is a non-empty array of non-blank string messages, when the shared Android/iOS form displays that validation feedback, then native telemetry treats it as expected input rather than an application error; malformed or unknown failures remain reportable. - Given the device cannot reach the auth API, when a native login or registration request fails offline, then the form shows authored network guidance rather than a generic failure or transport internals and remains usable for another attempt.
- As an iOS user whose credentials were accepted, I want a recoverable
workspace-loading failure to stay visible, so that login never appears to do nothing.
- A transient post-authentication session-hydration failure keeps persisted credentials and presents localized Retry and Cancel actions after the bounded retry is exhausted.
- Retry repeats session hydration with the same credentials, clears the failure on success, and resumes normal entitlement routing. An explicit unauthorized response still clears the invalid session.
- If one final account-wide terminal-cleanup attempt cannot finish, an explicit accepted login abandons the stale Keychain cleanup state and commits the new credentials. A stale record left across reinstall must never become a permanent login denial.
- As a user entering a password, I want to reveal or hide it without
clearing the field, so that I can correct typing mistakes before signing in.
- The login password starts masked on web and shared Android/iOS.
- An eye action reveals the existing value and toggles back to masking without submitting the form. On iOS it remains outside the native editor’s hit-test bounds. Pointer/touch activation preserves input focus; keyboard users can focus and activate the action normally.
- The action exposes localized
Show password/Hide passwordnames to assistive technology.
- As an iOS user, I want my email and password to be recognized as one saved credential,
so that Password AutoFill can fill or save the account without making me focus fields twice.
- Login keeps separate native UIKit username and password fields mounted together so one Password AutoFill selection can populate the credential pair. Registration retains Compose Multiplatform’s native iOS text-input path. Shared content semantics and platform keyboard roles remain explicit on Android/JVM and the registration path.
- The iOS app requests
webcredentials:runlumi.appandwebcredentials:dev.runlumi.app, and the frontend source publishes both signed app identifiers in/.well-known/apple-app-site-associationwithout SPA fallback. Release scripts fail closed unless the archived app retains the exact application identifier and required associated domains. The 2026-08-05 deployed readback predates the dev-identifier correction and is historical evidence only; fresh deployed and TestFlight readback remain part ofF01.GAP014. - The registered XCTest/Maestro audits exercise the active auth field and submit-action geometry through login and registration focus transitions. The lifecycle-owned iOS auth runner also proves the complete journeys against an isolated local backend on the current signed artifact.
- The real Password AutoFill prompt/save behavior and named-iPhone evidence remain open in
F01.GAP014.
- As a native user, I want the auth doorway to wait for my first deliberate tap,
so that the keyboard and credential manager do not race the form into a half-height state.
- Android and iOS do not request focus or open the keyboard on auth entry; field-to-field Next/Done actions remain available after the user chooses a field.
- Switching between login and registration hides the keyboard and clears the old responder before the next form is mounted.
- The exact Android emulator proof covers calm cold entry, mode switching, the native
Next chain, and keyboard-visible physical login/sign-up taps. The registered Android
calm-entry flow also explicitly dismisses the IME, captures the settled filled-form state,
and taps the CTA without a compensating scroll; exact Android evidence now passes that path.
The approved iOS simulator now passes the equivalent calm-entry and full local-backend
journey; named-device evidence remains open in
F01.GAP011.
- As a browser user, I want the sign-in and sign-up fields to identify their credential roles,
so that browser and iOS web credential managers can fill or save the account without guessing.
- Login declares
usernameandcurrent-passwordautocomplete tokens. - Registration declares
name,username, andnew-passwordautocomplete tokens. - Web login and registration remain unfocused until deliberate input and publish email/password
keyboard hints (
inputmode, capitalization, correction, spell-check, and enter-key behavior). - Pointer/touch password reveal and re-mask preserve the existing value and password-field focus.
- Login declares
- As a returning mobile user, I want the keyboard to dismiss when my
credentials are accepted, so that the authenticated shell exposes its
navigation immediately instead of leaving the bottom bar hidden behind a
stale IME state.
- Shared native login clears the password field focus and requests IME dismissal before handing the successful session to the platform shell.
- As a user, I want to set my language, so that Lumi speaks to me natively.
- Given a language change via
/api/auth/me/language/, then all subsequent UI renders in that language and persists across sessions. - Given a signed-out mobile user on the login shell, when they choose English or Vietnamese before authentication, then the choice updates both login and registration copy and is persisted locally without an authenticated API call.
- Given a language change via
- As a user responding to a credential compromise, I want password
changes and resets to terminate old refresh sessions, so that changing my
password actually removes an attacker’s durable access.
- Reset passwords pass every configured Django password validator.
- A rejected weak password does not consume the reset token.
- Password change and reset revoke all refresh sessions issued before the credential change.
- As a returning user, I want refresh rotation to be race-safe, so that
one stolen token cannot mint two valid successor sessions concurrently.
- Consumption of a refresh JTI is atomic and exactly one concurrent request succeeds.
- Logout consumes its presented JTI under the same lifecycle lock; an already-rotated predecessor clears the caller’s cookie but cannot revoke a newer session unless its durable operation ID proves the exact still-active successor created by that rotation.
- Browser logout waits on the refresh Web Lock and delayed refresh completions honor the durable logout tombstone, so an in-flight rotation cannot restore the session after explicit logout.
- If rotation committed but its response was aborted, logout preserves and submits the operation ID; the server maps the consumed predecessor to that exact successor and revokes it even after the retry cache expires, before the client clears the handle.
- Malformed or expired logout credentials keep a uniform 204 response, but a database/revocation failure returns an error so the durable client tombstone survives and can retry instead of falsely reporting success.
- Successful login waits on the refresh/logout Web Lock; logout response cleanup only clears its tombstone, so it cannot erase a newer session.
- Logout intent invalidates a login already in flight, including intent received from another tab; authentication still queued rejects before the request, while an in-flight stale response revokes its own cookie before releasing the lock to any newer login.
- Login and registration publish credential custody and authenticated store state before releasing their browser or native mutation boundary.
- Browser cookie mutation requires one native Web Locks domain; absent or denied Web Locks fail closed before any credential request is sent.
- Registration joins the browser cookie-mutation lock, while desktop refresh and logout share an in-process queue so logout submits the rotated successor; local keyring custody clears immediately, a private handoff keeps the successor available for queued revocation, and restart refuses any residual keyring credential while logout remains pending.
- The client authenticates from the registration response itself; it does not mint a second session through a redundant follow-up login.
- As a browser user, I want session persistence without script-readable
bearer credentials, so that an injected script cannot exfiltrate a
seven-day session from Web Storage.
- Browser access tokens are memory-only and refresh credentials use an HttpOnly, Secure, SameSite=Lax cookie through each frontend’s same-site API alias; refresh/logout require exact allowed origins, aborted rotation is idempotently recoverable, failed or refresh-racing logout cannot resurrect the cookie session, and startup purges credentials left by pre-cookie releases. Native body-token clients remain compatible.
3. Scope Boundaries
- In: email/password login, registration, JWT access+refresh, current-user profile, language preference, list of my organizations, and the no-organization bootstrap state.
- Out: OIDC/SSO, 2FA, and session management UI. Password-reset delivery is owned by F35; F01 owns the password/session security invariant it invokes.
Competitive note — the real alternative is no account at all. Our target SME owner — a Vietnamese interior fit-out / thi công contractor — does not sign in to software; they run the company from Zalo and a phone contact list. There is no SSO to inherit a name and locale from, because there is no incumbent workspace to inherit from. The bar Lumi must clear is not “smoother than Google sign-in”; it is “worth creating an account at all instead of staying in Zalo + Excel + owner memory.” That makes first-session orientation load-bearing: a registration that lands a non-technical owner in a blank no-tenant state loses them to the chat app they already trust. Lumi’s flow captures locale from the Accept-Language header (defaulting to vi), immediately routes zero-org users into org setup (create or join — never a blank dashboard), and onboards in their language. The mechanism is already in the spec — locale capture in §8, org-setup gate in §2 — and the edge is getting a chat-and-spreadsheet owner to a working organization in their first minute.
4. Data Model
accounts.User (custom user, email login). Not tenant-owned. User is global
and email identity is lowercase plus case-insensitively unique; org context
comes from OrganizationMember (F02). No state machine.
One flag qualifies the row rather than the identity: is_provisioned (default
False) marks a User that exists because a member invite named the address
(F02), not because its owner signed up. A provisioned row holds no usable
password and is the only state registration may claim in place. Every other
account — including system users such as @lumi — is unclaimable by
construction.
5. API Contract
| Method | Path | Auth | Notes |
|---|---|---|---|
| POST | /api/auth/login/ | anon | Canonicalizes email casing, then returns access + refresh JWT. Brute-force guarded by django-axes |
| POST | /api/auth/register/ | anon | Canonicalizes email casing and rejects case-insensitive duplicates. A plain registration creates only the User and returns explicit organization: null, organizations: []. An address holding a provisioned invite placeholder (is_provisioned) is claimed in place under a row lock; the invited membership survives capped at member, and the response returns that active workspace context so native clients do not misroute the member through organization setup (ADR-049). Organization creation/join otherwise happens in F02 after auth |
| POST | /api/auth/refresh/ | anon | New access + refresh token pair from a valid refresh token; no access-token header required |
| POST | /api/auth/logout/ | refresh token/cookie | Atomically consumes a still-active presented JTI and revokes that user’s remaining refresh sessions; a consumed predecessor can authorize revocation only when X-Lumi-Refresh-ID matches its persisted rotation digest and its exact successor is still active; invalid/unknown credentials return a uniform 204 and clear the browser cookie, while infrastructure failure surfaces as an error |
| POST | /api/auth/me/change-password/ | JWT | Validates the old/new password and revokes all older refresh sessions |
| POST | /api/auth/password-reset/confirm/ | reset token | Validates the new password, returns field-specific policy errors that the reset form displays without calling the token expired, consumes the reset token only on success, and revokes older refresh sessions |
| GET | /api/auth/me/ | JWT | Current user profile |
| PATCH | /api/auth/me/ | JWT | Update own name and/or avatar_url; email remains read-only |
| PATCH | /api/auth/me/language/ | JWT | Persist en/vi preference |
| GET | /api/auth/me/orgs/ | JWT | Memberships for the org switcher — drives X-Organization-ID |
No tenant scoping here by design: auth is pre-tenant. Everything after login is scoped via X-Organization-ID (F02).
6. UI / UX
/auth/loginuses an eager, translated shell (pages/auth/LoginPage.tsx) and a progressively loaded RHF/Zod form (pages/auth/LoginForm.tsx), while/auth/registerremains inpages/auth/RegisterPage.tsx; the post-auth org-setup gate applies whenme/orgs/is empty, with state instores/authStore.ts.- States: idle form, submitting (button disabled, no layout shift), credential error (inline, near the form, actionable), locked-out.
- The password field starts masked and owns a trailing show/hide action. Toggling changes presentation only: the field value, focus, and submission state are preserved.
- The native registration sheet applies Android’s system-navigation bottom inset at the shared screen boundary. Its keyboard inset remains a separate platform-owned modifier, while iOS and desktop keep their host/no-op implementations so the shared surface does not double-consume safe-area space.
- 5am test: high-contrast form, email keyboard type, no marketing clutter on
/auth/login. - Floor-device startup: the Login shell and route-specific locale paint independently from the validation/submission stack; the reserved fallback matches settled form geometry so the progressive swap does not shift the card.
- i18n:
login,registernamespaces —en+vialways together. - Signed-out mobile users get a compact English/Tiếng Việt picker above the login/register switch. The shared auth surface owns the selection; Android and iOS shells persist it locally and apply it before rebuilding the localized shell. No authenticated request is made until credentials are submitted.
7. Test Plan
| # | Case | Type | Where |
|---|---|---|---|
| 1 | Login happy path, token shape | backend | tests/test_api.py::TestAuth |
| 2 | Registration succeeds without any organization membership | backend | tests/test_api.py::TestAuth |
| 3 | Authenticated user with zero orgs is routed to org setup, not Today | frontend/mobile | web E2E + mobile gate tests |
| 4 | Wrong credentials → 4xx with message, no stack trace | backend | tests/test_api.py::TestAuth |
| 5 | Login with memberships → current org selected; login without memberships → org setup | e2e | frontend/tests/e2e/organization.spec.ts |
| 6 | Weak reset rejected without consuming token; valid retry succeeds | backend | backend/tests/notifications/test_email_password_reset.py |
| 7 | Concurrent refresh rotates exactly once | backend | backend/tests/security/test_auth.py |
| 8 | Change/reset rejects every previously issued refresh session | backend | backend/tests/security/test_auth.py + password-reset tests |
| 9 | Browser auth persists through HttpOnly cookie without Web Storage tokens | backend/frontend/E2E | auth integration + store/client unit tests + auth E2E |
| 10 | Login password starts masked; localized action reveals and re-masks without changing the value or submitting, including after native iOS text entry | frontend/native | frontend/src/test/auth/LoginPage.test.tsx + scripts/tests/test_login_password_visibility_policy.py + AuthFlowUITests.test_loginPasswordVisibilityTogglesAfterTyping |
| 11 | An invited address stays registerable: registration claims the provisioned row once under a lock, preserves the invited membership capped at member, and refuses real, already-claimed, and system accounts | backend | backend/tests/security/test_invited_account_claim.py |
| 13 | Shared Android/iOS login validation stays visible but does not emit a non-fatal event only for the documented string-array payload; unrecognized, malformed, or non-string login 400s remain observable | native JVM | AuthScreenModelTest.loginEmailValidationRemainsVisibleWithoutNonFatalTelemetry + AuthScreenModelTest.loginUnrecognizedClientFailureRemainsReportable + AuthScreenModelTest.loginMalformedEmailValidationRemainsReportable + AuthScreenModelTest.loginNonStringEmailValidationRemainsReportable |
| 12 | iOS transient session hydration keeps credentials, exposes localized retry feedback, and enters the shell after a successful retry | iOS | LifecyclePathTests.test_postAuthenticationTransientRestoreFailure_surfacesRetryableStateAndKeepsCredentials + scripts/tests/test_ios_auth_bootstrap_feedback_policy.py |
| 14 | Native registration submit CTA stays above Android system navigation while keyboard and iOS host safe-area ownership remain unchanged | source contract + mobile runtime | AuthScreenSourceTest.focusedAuthFieldsRequestOnlyTheirOwnVisibility + scripts/tests/test_android_auth_registration_insets_policy.py (F01.AC017.INSETS, F01.AC017.MAESTRO_CTA_PATH, F01.AC017.ANDROID_KEYBOARD_HIDDEN) + .maestro/critical/02_auth_register.yaml (critical_02_signup_cta_keyboard_visible, critical_02_signup_cta_keyboard_hidden) + .maestro/critical/120_android_auth_calm_entry.yaml (f01_android_auth_signup_keyboard_hidden) + F01 Android evidence; approved iOS simulator and exact Android keyboard-visible/explicit-IME-dismissal no-scroll direct-CTA proofs pass, while named-device evidence remains F01.GAP011 |
| 15 | Successful shared-native login dismisses the IME and releases form focus before the authenticated shell replaces the auth surface | native JVM/source + mobile runtime | android/shared/src/jvmTest/kotlin/com/locuno/lumi/shared/ui/AuthScreenSourceTest.kt; named-device evidence remains F01.GAP012 |
| 16 | Signed-out Android/iOS users can choose English or Vietnamese before authentication; login and registration share the choice and each native shell persists it locally without an authenticated request | native JVM/source + iOS XCTest | AuthScreenSourceTest.authLanguagePickerIsPreAuthAndShared + LanguageManagerTests.test_authLanguageSelectionPersistsLocally; named iOS runtime evidence remains F01.GAP013 |
| 17 | iOS login keeps a persistent native UIKit username/password pair in the hierarchy for one-selection credential fill; registration keeps native Compose input. Both paths retain platform-correct credential/profile roles, dual production/dev webcredentials custody, signed-archive entitlement verification, and keyboard-reduced auth geometry; each focused field uses a local relocation target, and each password focus targets its immediately-following submit CTA rather than moving the form to its end | iOS/common/source policy + XCTest/Maestro contract | F01.AC020.SOURCE + F01.AC020.COMMON_SEMANTICS + F01.AC020.PAIRED_FIELDS + F01.AC020.ASSOCIATION + F01.AC020.SIGNED_ASSOCIATION + F01.AC020.IOS_LOGIN_GEOMETRY + F01.AC020.IOS_REGISTER_GEOMETRY + F01.AC020.MAESTRO_CONTRACT + F01.AC023.FIELD_VISIBILITY; scripts/run-ios-auth-calm-proof.sh owns the isolated backend and complete journey; fresh deployed association, real Password AutoFill fill/prompt/save, and named-iPhone evidence remain F01.GAP014 |
| 18 | Web login and registration expose standard credential autocomplete roles for browser/iOS web Password AutoFill, remain unfocused until deliberate input, publish mobile editing hints, and preserve password focus when visibility changes | frontend unit + browser E2E | F01.AC022.WEB_LOGIN + F01.AC022.WEB_LOGIN_ERROR + F01.AC022.WEB_REGISTER + F01.AC022.WEB_REGISTER_ERROR + F01.AC022.WEB_BROWSER |
| 19 | Native auth entry does not steal focus; login/register switching releases the old responder before replacing the form; deliberate field focus requests a local target, with each password focus bringing its immediate submit CTA into view instead of recentering the whole form | native source + Android/iOS runtime | F01.AC023.CALM_ENTRY + F01.AC023.FIELD_VISIBILITY + F01.AC023.IOS_RUNTIME + AuthScreenSourceTest.authDoesNotOpenTheKeyboardOrStealFocusOnEntry + AuthScreenSourceTest.focusedAuthFieldsRequestOnlyTheirOwnVisibility + scripts/run-ios-auth-calm-proof.sh; managed Android/iOS proof remains distinct from named-device confirmation under F01.GAP011 |
8. Current Behavior
Registration, tenant bootstrap, locale capture, lockout, and hardened token
rotation are shipped. Reset confirmation uses configured password validators
without consuming rejected tokens; password changes/resets revoke outstanding
refresh sessions; refresh and logout JTI consumption is atomic; unproven
consumed predecessors cannot revoke active successors, while a matching
operation ID revokes only its exact still-active successor; browser logout
serializes behind in-flight refresh and discards delayed access; and browser sessions use
an HttpOnly refresh cookie plus a process-memory access token. Browser refresh
is serialized across tabs. If startup refresh fails without an HTTP response,
a non-secret email hint preserves a pending shell until connectivity returns;
an explicit 401/403 still clears the session. On iOS, a transient /me or
myOrgs failure after the bounded retry keeps credentials and presents a
localized retry path instead of silently returning the authentication form to idle.
The shared native registration sheet now applies Android’s system-navigation
bottom inset at its outer boundary while retaining the existing platform-owned
keyboard inset. iOS and desktop leave this modifier as a no-op because their
hosts own the visible safe area. Successful native login now dismisses the IME
and clears the form focus before the authenticated shell is mounted, so shell
navigation cannot remain hidden by the auth form’s stale keyboard state.
Signed-out Android and iOS users can now choose English or Vietnamese directly
on the shared auth shell. The native host persists that selection locally and
rebuilds the login/register copy before authentication; the server-owned
language remains authoritative once a session is established.
The web login and registration fields now publish explicit browser credential
autocomplete roles (username, current-password, name, and new-password)
so the web credential path does not rely on browser heuristics. Native auth uses
platform-specific credential roles: iOS login mounts persistent UIKit username and
password fields together, while registration uses singular username/newPassword
traits through Compose’s native input. Android and JVM retain username + emailAddress/newUsername + emailAddress suggestions. Registration also marks
full name as a profile field, without changing the account credential pair.
The native auth doorway now stays idle until the user taps a field; switching between
login and registration releases the prior responder before replacing the form, so Android
and iOS do not carry a stale keyboard into the next mode. Web auth forms also publish
email-specific keyboard hints and preserve password focus when the visibility action is used.
When a native auth field is focused, it uses Compose’s relocation contract to request a local target.
Login, name, and email target their own bounds; each password targets its immediately-following
submit action so the completed credential stays actionable. A change in the measured scroll range
retriggers that local request after iOS finishes reducing the host viewport, but the range is never
used as the scroll destination. Email and password focus changes therefore do not recenter the whole
auth surface.
9. Error States
All entries verified against actual code responses. No invention. Messages shown as returned by the API.
| # | Endpoint | Condition | Status | Response body |
|---|---|---|---|---|
| ES1 | POST /api/auth/login/ | Missing email or invalid email format | 400 | {"email": ["Enter a valid email address."]} |
| ES2 | POST /api/auth/login/ | Missing password | 400 | {"password": ["This field is required."]} |
| ES3 | POST /api/auth/login/ | Wrong password OR nonexistent user OR inactive user | 401 | {"detail": "Invalid credentials"} |
| ES4 | POST /api/auth/login/ | DRF throttle exceeded (anon: 30/min or env THROTTLE_ANON) | 429 | DRF throttle message |
| ES5 | POST /api/auth/register/ | Missing or invalid email | 400 | {"email": ["This field is required."]} or ["Enter a valid email address."] |
| ES6 | POST /api/auth/register/ | Missing password | 400 | {"password": ["This field is required."]} |
| ES7 | POST /api/auth/register/ | Password violates configured validators (including minimum 10 characters) | 400 | field-level password validation errors |
| ES8 | POST /api/auth/register/ | Duplicate email, including a letter-case variant | 400 | {"email": ["user with this email already exists."]} |
| ES9 | POST /api/auth/refresh/ | Missing refresh token in body | 400 | {"detail": "Refresh token required"} |
| ES10 | POST /api/auth/refresh/ | Invalid, expired, rotated, or forged refresh token | 401 | {"detail": "Invalid refresh token"} |
| ES11 | POST /api/auth/refresh/ | No Authorization header | Uses refresh token only | Access-token header is not required on this endpoint |
| ES12 | GET /api/auth/me/ | No valid JWT | 401 | {"detail": "Authentication credentials were not provided."} |
| ES13 | GET /api/auth/me/ | Expired access token | 401 | {"detail": "Given token not valid for any token type", "code": "token_not_valid"} |
| ES14 | PATCH /api/auth/me/language/ | Missing language field or empty string | 400 | {"detail": "Invalid language"} |
| ES15 | PATCH /api/auth/me/language/ | Language code not in SUPPORTED_LOCALES | 400 | {"detail": "Invalid language"} |
| ES16 | POST /api/auth/me/language/confirm-detected/ | No detected language to confirm (language_detected is None) | 409 | {"detail": "No detected language to confirm."} |
| ES17 | POST /api/auth/password-reset/ | Missing email | 400 | {"detail": "Email required."} |
| ES18 | POST /api/auth/password-reset/ | Unknown email | 200 | {"detail": "If that email is registered, a reset link has been sent."} (no user enumeration) |
| ES19 | POST /api/auth/password-reset/confirm/ | Missing token or new_password | 400 | {"detail": "Token and new_password required."} |
| ES20 | POST /api/auth/password-reset/confirm/ | Invalid token UUID (not found) | 400 | {"detail": "Invalid or expired token."} |
| ES21 | POST /api/auth/password-reset/confirm/ | Expired or already-used token | 400 | {"detail": "Token has expired or already been used. Please request a new one."} |
| ES27 | POST /api/auth/password-reset/confirm/ | New password violates configured validators | 400 | {"new_password": [<validator messages>]}; token remains unused |
| ES28 | POST /api/auth/refresh/ | Concurrent request loses atomic JTI consumption | 401 | replay-detected response; no successor session issued |
| ES29 | POST /api/auth/logout/ | Presented refresh JTI is consumed, unknown, malformed, or expired | 204 | browser cookie is cleared; a consumed predecessor revokes only when a matching operation ID proves its exact still-active successor, while every other case has no server-side effect and exposes no token-validity oracle |
| ES30 | Browser logout | Refresh rotation is in flight | local logout is immediate | logout waits for the shared Web Lock, submits the delivered successor cookie, and any delayed access response is discarded while the tombstone is present |
| ES31 | Browser login | An older logout request is still in flight | login waits, then remains authenticated | the shared Web Lock orders cookie responses; logout response cleanup only clears its tombstone and cannot erase credentials |
| ES32 | Browser login | Login started before a same-tab or cross-tab logout intent | login completion is rejected as superseded | the logout-intent generation rejects a queued request before network; if the response was already in flight, its cookie is revoked while the same Web Lock is still held, before any newer login can install a cookie |
| ES33 | Browser auth cookie operation | Web Locks unavailable or denied | Operation rejects before network mutation | the client does not switch to an emulated lock domain that cannot coordinate with native holders or fence a frozen page |
| ES34 | Browser registration | Refresh, login, or logout cookie mutation is in flight | registration waits for the shared lock | the registration response cannot overwrite a newer cookie mutation |
| ES35 | Desktop logout | Native refresh rotation is in flight or the app closes before server revocation | local custody clears immediately and restart stays anonymous | logout privately hands the rotated successor to queued server revocation, clears the keyring again after older writers finish, and a durable tombstone forbids keyring restore |
| ES36 | Login or registration | Authentication succeeds while another tab is ready to log out | credential custody and authenticated store state commit before lock release | logout cannot land between the final generation guard and a late store commit |
| ES37 | Browser logout | Refresh rotation committed but its response was aborted | local logout is immediate; server logout revokes the mapped successor | logout submits the durable operation ID with the retained predecessor cookie; the server verifies its persisted digest and revokes the exact still-active successor after any retry-cache expiry, without issuing a recovery refresh, then the client clears the handle |
| ES38 | POST /api/auth/logout/ | Refresh-session lookup or revocation infrastructure fails | 500 | the server does not emit a false 204 or deletion cookie; the browser keeps its durable logout tombstone and retries cleanup later |
| ES22 | Any non-auth tenant endpoint | Missing X-Organization-ID header | 403 | {"detail": "You do not have permission to perform this action."} |
| ES23 | Any non-auth tenant endpoint | Invalid UUID in X-Organization-ID | 403 | DRF default |
| ES24 | Any non-auth tenant endpoint | No active membership in org | 403 | DRF default |
| ES25 | Any tenant write endpoint | Org subscription_status=read_only (auth paths exempt) | 403 | {"detail": "subscription_read_only"} |
| ES26 | Any non-auth endpoint | DRF user throttle exceeded (120/min or env THROTTLE_USER) | 429 | DRF throttle message |
2026-08-04 — Pre-auth language picker (lumi-fb #70)
- Mobile login now exposes the existing English/Tiếng Việt locale choices before authentication.
- Login and registration share the same picker; Android stores the choice in its existing app-language preference and iOS routes it through
LanguageManager/UserDefaults. - The picker is local-only until credentials are submitted, so it cannot make an authenticated request while the user is signed out. Expanded locale catalogs remain owned by F69.
- Generic Contract Graph CI defers the native XCTest collector; the local callback test is not runtime or named-device proof. The exact iOS dev-artifact journey remains explicitly open as
F01.GAP013.
2026-08-01 — Android registration CTA clears the system navigation area (lumi-fb #71)
- The registration sheet filled the edge-to-edge Android window through the bottom edge, so the submit CTA could sit underneath the system navigation bar and become difficult or impossible to tap.
AuthScreennow composes a platform-aware bottom safe-area modifier after the existing IME modifier. Android consumesnavigationBarsPadding(); iOS keeps the SwiftUI host’s safe-area ownership and JVM previews remain unchanged.scripts/tests/test_android_auth_registration_insets_policy.pylocks the source contract and rejects direct Android inset APIs in shared UI. The reporter-device journey on the exact dev artifact remains open asF01.GAP011.
2026-07-25 — Email identity is case-insensitive
- Registration canonicalizes valid email addresses to lowercase before uniqueness validation and storage.
- Existing account lookup uses case-insensitive equality, so a legacy mixed-case address cannot be registered again with different casing.
- Login and direct member invitation use the same canonical email identity, so historical normalization does not break uppercase login input and invitation lookup cannot fall through to a database collision.
- The database owns a
Lower(email)unique constraint. Its data migration lowercases unambiguous historical rows and fails closed when two user IDs already collide, requiring explicit identity review instead of guessing which account to keep. - Focused backend proofs cover registration rejection, canonical storage, case-insensitive login, direct invitation, and a model write that bypasses the manager.
2026-07-24 — Native registration states the password floor before submit
- The shared Android/iOS registration form now states
at least 10 charactersimmediately below the password field, before the signup action. It also advises against common or personal passwords and prohibits numeric-only passwords, matching Django’s configured validators without inventing a character-class requirement. - English and Vietnamese resource entries use the same form location and stable semantic selector.
Backend validation remains authoritative;
F01.GAP003retains named-device legibility and screen-reader proof.
2026-07-24 — Native duplicate-email feedback is localized
- The shared registration model classifies the backend’s email uniqueness response as a typed
EmailAlreadyExistsstate. The form renders authored English or Vietnamese copy instead of displaying DRF’s raw English validator sentence. - The mapping remains narrow: unrelated email validation and transport errors still use their
existing error paths.
F01.GAP004retains real-account and named-device rendering proof.
2026-07-24 — Native password-validation feedback is localized
- The shared registration model classifies any non-empty backend
passwordfield-error array as a typedInvalidPasswordstate. The form renders authored English or Vietnamese guidance rather than displaying Django validator sentences verbatim. - The mapping is deliberately lossy: minimum-length, similarity, common-password, and numeric-only validator sentences collapse into one stable localized recovery state. If a response contains both password and email field errors, password recovery takes priority so raw password copy can never leak through the fallback path.
- Backend validation remains authoritative. Email edits preserve this password-owned state;
password edits clear it. Password edits preserve duplicate-email state; email edits clear it.
Unrelated transport errors keep their existing behavior.
F01.GAP005retains live-endpoint proof on the reporter’s Android build and the approved iOS runtime. - Postmortem: mobile registration leaked English password-validator copy.
10. Diagrams & Docs Touched
DIAGRAMS.md pre-auth locale selection and iOS session hydration · frontend/DIAGRAMS.md auth states · backend/DIAGRAMS.md request lifecycle.
11. Implementation Audit (2026-06-12)
| # | Claim | Status | Evidence |
|---|---|---|---|
| 1 | Silent JWT refresh — client refreshes without visible re-login when access token expires | ✅ match | stores/authStore.ts axios interceptor; TestRefreshTokenReuse::test_new_refresh_on_legitimate_refresh |
| 2 | Zero-org bootstrap → org-setup gate (not Today/settings/paywall) | ✅ match | me/orgs/ returns []; frontend branches to /organization-setup; E2E: organization.spec.ts |
| 3 | Wrong credentials → unified 401 (nonexistent user indistinguishable from bad password) | ✅ match | views.py:48-51 authenticate() returns None for both; ES3 |
| 4 | Inactive user cannot continue using old tokens to access or mint fresh auth | ✅ fixed | SimpleJWT JWTAuthentication.get_user() gates on is_active; 2026-07-01 refresh_view now also returns 401 for inactive users before issuing new tokens; TestRefreshTokenReuse::test_inactive_user_refresh_returns_401 |
| 5 | Locale capture from Accept-Language on register; fallback to “vi” on miss | ✅ match | views.py:93-96; SUPPORTED_LOCALES check; fallback silent — no error |
| 6 | Registration without name succeeds (blank=True) | ✅ match | User model name is blank=True; name="" or null accepted |
| 7 | POST /api/auth/refresh/ works without an access-token header | ✅ match | @permission_classes([AllowAny]); replay/rotation tests in tests/test_auth.py post only the refresh token in the body |
| 8 | ROTATE_REFRESH_TOKENS=True — replay of old refresh fails with 401 | ✅ match | TokenError → 401; TestRefreshTokenReuse::test_token_reuse_rejected |
| 9 | Password reset — unknown email → 200 (no user enumeration) | ✅ match | notifications/views.py:43-45; ES18 |
| 10 | Read-only org middleware: auth endpoints exempt; X-Organization-ID absent → 403 | ✅ match | core/middleware.py:117 (BlockReadOnlyOrgMiddleware); ES22-ES25 |
| 11 | Visual QC: login input labels are programmatically associated with fields | ✅ match | 2026-06-18 screenshot harness found visible-only labels; fixed LoginPage.tsx with explicit htmlFor associations |
| 12 | Visual QC: login screen presents a finished front-door hierarchy without adding marketing clutter | ✅ match | 2026-06-18 visual QC: LoginPage.tsx now adds an auth eyebrow, calmer descriptive copy, helper chips for persistence/brief readiness, placeholders, and a separated footer action block while keeping the page single-card and work-focused. |
| 13 | iOS reports a transient post-authentication bootstrap failure and offers a retry without discarding valid credentials | ✅ fixed | APIClientSession.finishFailedSessionRestore publishes typed retryable state; RootContainer owns localized Retry/Cancel feedback; XCTest proves credential custody and recovery |
| 14 | Native auth transport failures remain actionable when the device is offline | ✅ fixed | friendlyError recognizes Darwin NSURLErrorDomain offline failures and maps them to authored network guidance; ErrorExtTest.friendlyErrorMapsDarwinOfflineTransportToNetworkGuidance |
Summary: 15/15 match. Inactive-user refresh closed 2026-07-01; refresh endpoint contract corrected back to the real anon-by-refresh-token behavior.
Edge Cases Catalog (formerly §10, 2026-06-12; 17 items): Key items integrated into audit rows 4 and 7 and §9 error states. Full catalog covers: login (5 cases — nonexistent/inactive unified, zero-org 200, multi-org array, token rotation), register (3 — missing name, locale fallback, org not created), refresh (2 — replay, inactive user 401), me/ (inactive user blocked by JWT auth), language/ (unsupported BCP47, empty string), orgs/ (zero memberships 200, suspended filtered), password reset (nonexistent email 200, expired/used token 400), middleware (read-only org, IsOrgMember, healthcheck bypass). Full catalog in git history (2026-06-12 changelog entry).
12. Open Questions
me/orgs/already includes per-org role in code. The remaining decision is whether it should also expose a server-chosencurrent_org_id; defer unless switching UX becomes inconsistent.
13. Bug / Change Log
2026-08-25 — iOS authenticated shell remounts for account and tenant changes
- Issue #103 exposed that a Settings KMP controller could retain account A’s profile after a logout/login transition to account B when the shell identity stayed stable.
- The shell identity now includes the authenticated user ID and current organization ID. It remains stable for unrelated AppState updates (preserving the selected tab) but remounts hosted KMP screens at a real account or tenant boundary, preventing stale Settings data from surviving the transition.
- A focused XCTest covers account/organization identity changes and source wiring. This is a local shell/session proof; authenticated A → logout → B on a named iPhone and provider/dev handoff remain separate evidence gates.
2026-08-22 — Stale Keychain cleanup cannot permanently deny explicit login
- Restored the intentional
b402c5c0brecovery after6e67f3a71reversed it: native and shared explicit-login paths attempt terminal cleanup once, abandon unfinishable stale durable/in-memory state, and commit the accepted replacement credentials. - Kept lower-level credential commit, session restoration, and background cleanup fail closed so only the explicit user-login boundary owns the availability escape path.
- Aligned F01/F36 CUE, test plans, platform guidance, and focused XCTest proof. The signed
reinstall journey remains open under
F01.GAP010.
2026-08-11 — Issue #92 follow-up fixes field relocation and dev credential trust
- The reporter’s TestFlight retest failed after the 2026-08-09 motion-only change. Animating
maxValuemade the same incorrect whole-form movement slower; it did not preserve the focused field’s position. Each auth field now owns a localBringIntoViewRequestertarget, while each password focuses its immediately-following submit CTA. The measured range is only a revision signal that repeats that local request after the iOS viewport settles, never a destination. - The published association source listed only the production signed identifier even though the
dev TestFlight bundle is
7MBXZKYSY4.com.locuno.lumi.dev. The association now lists both exact identifiers forapplinksandwebcredentials. - Production and dev publishers now inspect the archived app’s code-signed entitlements before
export and fail closed on a stripped domain or wrong application identifier. Fresh deployed
response, exact TestFlight artifact, Password AutoFill prompt/save, and named-iPhone proof remain
F01.GAP014; source or simulator evidence cannot close them. - On 2026-08-12, the cross-platform Android lifecycle showed that login password focus still
stranded Login below the keyboard. The final shared correction gives both password fields their
immediate submit action as the local target. Fresh Android proof directly tapped the
keyboard-visible Login CTA and completed login and signup (APK SHA-256
92524a2061f4d40aa3f7627fc4133f330e9b6c6519c54a9c17d54b61df116800); fresh iOS proof rebuilt the shared framework, passed all three auth flows, directly exercised Login and Sign up, and recorded login 200 plus registration 201 oncom.locuno.lumi.iosbuild 186 (executable SHA-256bdcd975095f4a5896b56a2a96570945c061acff0c8b52efac33bed69f1e4a207). This is managed runtime mechanics evidence only, never deployed AASA, TestFlight, named-iPhone, or Password AutoFill provider proof.
2026-08-09 — Native auth focus changes no longer snap the form
- Feedback #92 showed build 185 jumping when focus moved between the iOS email and password fields.
The shared form synchronously called
scrollTo(maxValue)whenever focus or the keyboard-reduced viewport changed, so Password AutoFill keyboard transitions appeared as abrupt layout jumps. - Login and registration animated the same measured end-of-form movement while preserving an immediate path for reduced-motion users. The reporter’s dev retest later showed this was not a sufficient fix; the 2026-08-11 follow-up replaces whole-form end scrolling with field-scoped relocation.
AuthScreenSourceTest.focusedAuthScrollTracksKeyboardViewportWithoutSnappingwas the red-first source contract for that attempt; it was superseded by the field-scoped 2026-08-11 contract. The iOS geometry test repeats Email → Password focus transfer three times. Managed-runtime results and named-device confirmation remain separate evidence classes.
2026-08-05 — Complete local-backend auth journeys pass on iOS
- The lifecycle-owned
scripts/run-ios-auth-calm-proof.shnow starts an isolated SQLite backend, builds and installs the current signed artifact, and runs the input contract, login, and signup Maestro flows through one driver against that backend. - Approved simulator
0A8C774B-C1D3-4A43-816C-81D3D3D849D8passed build 184 with installed app SHA-256ab079dff2aebd86ecdff93a6a2d6a6d940676c2bda2f08841f8c4075016aedc3; all 3 flows passed, the backend recorded login200and registration201, and the signup user was persisted. Focused login/register keyboard-geometry XCTest checks also passed 2/2. - This closes the approved iOS simulator runtime proof for calm entry and auth completion. The
deployed production/dev association readback is recorded in F01 evidence. Real iOS Password AutoFill prompt/save behavior and named-device evidence remain open under
F01.GAP014andF01.GAP011.
2026-08-05 — Cross-platform calm-entry and credential-role audit
- The evidence runs used source commit
683208ac207c6755e022a414f14f048afad47a11before this branch was rebased ontoc8e4f5ab3fdc383120351bff07c28068ec7d6fb3; no authentication implementation files changed between those commits. That source passed the registered browser credential-manager journey in Chromium at the mobile viewport: both auth forms stayed unfocused until deliberate input, exposed standard credential roles, and preserved password focus/value through reveal and re-mask. - The same commit passed the managed Android auth journey on
emulator-5554with matching build and installed APK SHA-256aee4efc6d1638bcfecab8c4f00aaffd8310694416ebcd4a4dd7f9dfb739c2e1b. Cold entry, keyboard-visible login/sign-up, mode switching, explicit IME dismissal, settled CTA reachability, and local signup persistence all passed. - The same commit passed the three-flow iOS auth journey on the approved simulator with build 184
artifact SHA-256
ee558e054449cb31578ffc93288a7733ae5d23473decce732c0a780117ec5e0e. The local backend recorded login 200, registration 201, and signup persistence. Evidence is recorded in the current F01 evidence set. - The focused iOS geometry XCTest also passed both login and registration checks (2/2), keeping
every credential field and submit CTA above the keyboard without a duplicate-height inset. The
derived test app binary SHA-256 was
60c91a9cb17d54334363d75e6297a96b8511c8305fc6512b573e8f451d8d58a7. - These are managed emulator/simulator and browser proofs, not named-device or provider proofs.
Real Apple Password AutoFill prompt/save behavior remains open under
F01.GAP014.
2026-08-04 — Native credential roles match each platform’s password manager
- The shared auth primitive previously advertised
username + emailAddressfor native email fields on every platform. That is useful to Android autofill providers but can leave iOS Password AutoFill without the singularusernamerole it expects when pairing the email and password controls. - Platform actuals now supply
username/newUsernameto iOS and preserve the combined username/email hints on Android and JVM. The registration full-name field is explicitlyPersonFullName; password roles remainpassword/newPassword. - Source-policy and JVM auth tests cover the split. Real iOS Password AutoFill
prompt/save behavior and named-device proof remain
F01.GAP014; emulator, simulator, and source evidence do not close that gap.
2026-08-23 — Forward iOS credential roles into UIKit AutoFill traits
- The previous repair kept the shared
ContentType.Usernamesemantics but did not carry that role into the native iOS input traits. Compose’s native iOS input falls back fromKeyboardType.EmailtoUITextContentTypeEmailAddress, while the password field inferredUITextContentTypePassword; Password AutoFill could therefore offer only the password. - The iOS actual now receives the field’s credential role and sets Compose’s explicit
PlatformImeOptions.textContentType: login uses UIKitusername+password, and registration usesusername+newPassword(UIKit has no separatenewUsernameconstant). Android/JVM autofill semantics remain unchanged. - The source policy, shared JVM test, and iOS simulator compilation cover the bridge. Real
Password AutoFill fill/prompt/save behavior on a named iPhone remains open under
F01.GAP014.
2026-08-23 — Keep the iOS login credential pair mounted together
- The named-iPhone retest confirmed the explicit username trait fixed the first symptom: AutoFill now populated the email. It still required a second Password AutoFill tap after focusing the password field.
- Compose’s iOS native text-input service owns one current native input connection and removes the
previous field’s
UITextInputview when focus changes. Correct traits therefore existed only on one UIKit field at a time; Password AutoFill had no simultaneous username/password pair to fill. - iOS login now renders two persistent
UITextFields inside the shared form. Both remain bound toAuthScreenModel, carry stable accessibility identifiers, preserve calm entry and local focus relocation, and never log or persist credential text. Android/JVM and iOS registration behavior remain unchanged. - The first managed runtime rejected the bridge because
UIKitViewdisables native accessibility traversal by default. The final bridge opts in explicitly; the login harness also dismisses the exact optional iOSSave Password?sheet before asserting the authenticated shell. - The final guarded lifecycle rebuilt the release XCFramework, installed production bundle build
199 (SHA-256
6a36c4c1735b9c5ea68ca74d89c8b06c869e5095dc339b2ae1a47c2c5c491275), passed input/login/registration 3/3, recorded login 200 and registration 201, observed the iOS save-password sheet, and verified simulator shutdown on exact source commit7be3e68105ca7b1dfaa14bd25b427d8558614949; the evidence record preserves the boundary. This is commit-bound managed-simulator evidence, not named-device proof. One-selection fill on the reporting iPhone remainsF01.GAP014until a replacement TestFlight artifact is tested.
2026-08-23 — Preserve the UIKit password value across visibility changes
- The persistent iOS login field previously changed
secureTextEntryand only synchronized text when UIKit reported a value different from the shared auth model. UIKit can disturb an active secure field during that presentation change even when its immediate text readback is unchanged. - The bridge now reapplies the shared-model password whenever secure presentation changes. The
registered XCUITest now reveals, re-masks, and reveals again, asserting that the entered
credential and keyboard focus survive both transitions. A passing run is managed-runtime proof
only and does not close the named-iPhone AutoFill gap in
F01.GAP014.
2026-08-04 — Web auth fields declare credential-manager roles
- The web login form now marks its email field as
usernameand its password field ascurrent-password; registration marks the name, email, and new password fields asname,username, andnew-password. - Frontend tests protect the complete sign-in and sign-up credential pairs. This complements the
native iOS input semantics and associated-domain trust chain; it does not close the separate
named-iPhone Password AutoFill prompt/save gap in
F01.GAP014.
2026-08-04 — Offline iOS auth failures keep the doorway actionable
- A simulator audit exposed the actual transport failure behind the generic sign-in error:
Darwin returned
NSURLErrorDomain Code=-1009when the device had no network route. The shared mapper did not recognize the platform’s offline wording, so login and registration showedSomething went wrong — please try again. - Native offline, no-internet, unreachable-network, and
-1009failures now map to authoredConnection unavailable — check your network and try againguidance. The existing form state and credentials remain in place, so the user can retry after connectivity returns. ErrorExtTest.friendlyErrorMapsDarwinOfflineTransportToNetworkGuidanceprotects the exact Darwin-shaped failure without weakening the generic fallback for unknown exceptions.
2026-08-04 — iOS auth fields use native text input for AutoFill and stable keyboard geometry
- The shared auth screen used Compose’s legacy iOS hidden-input path for email, name, and password. That path exposed password AutoFill inconsistently and could leave the form with a keyboard-height layout jump or duplicate-looking blank band.
- Auth fields now opt into Compose Multiplatform 1.11’s native iOS text-input implementation and
declare explicit login
Username/Passwordand registrationNewUsername/NewPasswordcontent semantics. Android and JVM retain the combined username/email suggestions through their platform actuals. The platform actual is iOS-only; Android and JVM retain their existing input paths. This gives iOS native editing and saved-password AutoFill support while keeping the existing shared IME ownership contract intact. - Live audit then exposed a second, distinct defect: the iOS accessibility tree retained a
visible registration CTA at
[24,629][378,683]while the keyboard occupied[0,583][402,816]. The CTA was therefore covered even though Maestro could find it. Auth now has an explicitAuthKeyboardAvoidingViewControllerboundary tied toUIKeyboardLayoutGuide; this is scoped to auth and does not add a second global iOS IME-inset owner. - On the approved simulator, one guarded signed artifact passed Maestro 107, login, and signup
in one driver, and the two focused XCTest geometry checks executed 2/2 with zero failures.
The iOS Password AutoFill prompt/save result and named-iPhone screenshots remain explicitly
open as
F01.GAP014.
2026-08-04 — Bind iOS Password AutoFill to Lumi’s trusted web domains
- The native fields now carry the correct credential semantics, but the app’s associated-domains
entitlement requested only
applinks:. That left iOS without a declaredwebcredentials:trust relationship for the production and dev Lumi domains. Lumi.entitlementsnow requestswebcredentials:runlumi.appandwebcredentials:dev.runlumi.app. Cloudflare Pages publishes the matching signed app identifier in/.well-known/apple-app-site-association, including the auth universal-link paths, with an explicit JSON content type and no SPA fallback.scripts/tests/test_ios_password_autofill_association_policy.pylocks the entitlement, app-ID, path, and header relationship. Deployed endpoint responses, the real iOS Password AutoFill prompt/save result, and named-iPhone evidence remainF01.GAP014.
2026-08-04 — Signup regression coverage activates the iOS CTA directly
- The full signup journey previously used the password Return action on iOS, so it did not exercise the reported failure mode where the registration CTA was present in the accessibility tree but physically covered by the keyboard.
- The iOS branch now captures
critical_02_signup_cta_keyboard_visibleand tapsregister_submit_buttonwhile the keyboard remains open; Android capturescritical_02_signup_cta_keyboard_hiddenbefore its scroll-then-tap path. The source policy and Contract Graph register this asF01.AC017.MAESTRO_CTA_PATH. - The fresh build-183 simulator evidence now proves the iOS keyboard-visible direct tap and full
signup route; Android, keyboard-hidden/system-navigation, and named-device evidence remain
F01.GAP011.
2026-08-04 — Registration CTA follows password focus into the keyboard viewport
- The first exact current-branch direct-CTA audit passed the input contract and login flow, then failed signup with no registration request reaching the local backend. The captured visible frame showed the password field at the viewport edge and the CTA below it; accessibility presence had again been mistaken for physical reachability.
- The first exact current-branch direct-CTA audit passed the input contract and login flow, then failed signup with no registration request reaching the local backend. A follow-up with the descendant-focus correction and a settled keyboard wait failed the same way; the captured CTA remained below the reduced viewport.
RegisterFormnow observes descendant password focus and keys the shared form scroll to its measuredmaxValueas the iOS host viewport changes. This keeps the fix in the shared scroll container rather than adding another iOS inset owner or relying on a Maestro scroll gesture.- The focused JVM source proof passes. A fresh exact build-183 artifact (SHA-256
e9bacfbfeacd8a3187d3e0cc55bb740d1c377590fc1f5b4b2a39a0155de49792) passed the one-process input-contract, login, and direct keyboard-visible signup flows 3/3; the captured frame showed the password field and Sign up CTA together, and the local backend recorded/api/auth/register/201 before organization setup. Android, keyboard-hidden/system-navigation, and named-device proof remainF01.GAP011.
2026-08-04 — Login CTA follows field focus into the keyboard viewport
- The first exact Android calm-entry audit proved that the cold login screen opened without a keyboard, but then found the login CTA physically below the keyboard after the password field received focus. Maestro could locate the node, but the user could not reach it in the reduced viewport.
LoginFormnow observes descendant email/password focus and scrolls the shared auth container to its measured maximum whenever the keyboard changes the available viewport. Switching modes resets that shared scroll position before the next form is shown, so login and registration use the same calm geometry contract.- The focused
AuthScreenSourceTestnow passes with the login and registration scroll assertions. - A fresh exact Android lifecycle on
Lumi_Pixel7_API35(APK SHA-25697b554d442e41ff98266cffd4782c959be1e1e901c2b8df7a1bdf9ce92203af7) passed one Maestro flow: calm cold entry, keyboard-visible physical login CTA tap, both mode switches, the registration Next chain, keyboard-visible physical signup CTA tap, organization setup, and the local database signup assertion. iOS simulator service health, named-device behavior, and real Password AutoFill prompt/save evidence remain separately open underF01.GAP014; named-device behavior was not established by that emulator run and remainsF01.GAP011.
2026-07-29 — iOS session bootstrap failures no longer disappear (lumi-fb #68)
- The reporter’s credentials were accepted, but the following iOS session-hydration request failed
and
RootContainerignoredloadSession()returningfalse. The spinner stopped with no error, no navigation, and no next action. - Transient network or server failures now publish a typed retryable state after the existing bounded retry. The authentication root presents localized Retry and Cancel actions while keeping the access token, refresh token, and organization selection intact.
- Retry runs the same session-hydration path and resumes entitlement routing only after user and organization context load successfully. Explicit unauthorized responses retain the existing credential-clearing behavior.
- XCTest covers failed hydration, credential custody, successful retry, and state clearing. A
source-contract test covers the alert wiring and English/Vietnamese localization. Reporter-device
rendering remains explicit as
F01.GAP010.
2026-07-29 — An invited address stays registerable by its owner
- A direct member invite (F02) created a
Userrow withpassword="", so the address was rejected at registration as a duplicate while login had no password to check. Every invited person was locked out of their own email (lumi-fb#66). User.is_provisionednow marks a row the invite fabricated. Registration claims such a row in place — password, name, and language land on it, the flag clears, and the membership the CEO created survives, so the invitee finishes sign-up already inside the organization.- The gate is the explicit flag, not “has no usable password”: system users such as
@lumiare also created passwordless, andregister_viewissues tokens throughRefreshToken.for_user()without consultingis_active, so an inferred rule would have made the assistant identity claimable. A regression pins the trap — Django reportshas_usable_password() is Truefor an empty password. - Migration
accounts.0009backfills already-squatted rows, scoped to active, never-logged-in, non-staff rows with an empty password and no system membership. - The claim runs under
select_for_updateinsidetransaction.atomic, so two concurrent registrations for one invited address cannot both mint a session for that identity. - A claim is capped at
member: anyowner/admin/managerrole orcustom_roleit would inherit is lowered and recorded as amember.role_capped_on_claimaudit entry, so an admin can re-grant the intended role to the right person. - Joining deliberately has no mailbox-verification gate — ADR-049 records why: email is unreliable in our markets, joining should stay as easy as joining a Discord server, and impostors are handled by admin review and removal. The cap is what bounds that accepted risk.
- Registration now returns the claimed user’s active
organization/organizationscontext in the same authenticated response. This closes lumi-fb#69’s Android timing gap: the native shell sees the server-ownedmemberrole before choosing between workspace and zero-org setup. Plain new accounts retain an explicit empty workspace context.
2026-07-28 — Registration reuses the native password visibility action
- Shared Android/iOS registration now opts into the same localized eye action as login.
- The password remains masked by default. Revealing changes only its visual transformation; the field value and submit path are unchanged, and the action is disabled while registration loads.
- Source-contract proof covers localization, semantics, and shared primitive reuse. Reporter-device focus, keyboard, VoiceOver, and TalkBack evidence remain open until the dev build is tested.
2026-07-28 — Handled native registration validation stays out of Sentry
- A duplicate-email
400is authored field feedback, not an application fault. The shared registration model classifies the exact typed validation state before telemetry reporting, while unrelated API failures remain reportable. - JVM proof covers both the visible localized recovery state and the absence of a non-fatal report. Named-device rendering and release-bound Sentry observation remain open.
2026-08-01 — Login email validation stays out of Sentry
- Sentry issue
LUMI-ANDROID-2Kshowed a production login email-field400reported as a non-fatal error even though the shared form displayed the response as normal corrective feedback. - The shared Android/iOS login model now excludes only a status-400 response whose
emailfield-error array is non-empty and contains only non-blank string messages. It does not suppress unknown or malformed 400 payloads, and it does not alter the existing 401/403 transient policy. - JVM reporter-spy tests prove both sides of that boundary. Named-device rendering on Android and iOS, plus post-release Sentry non-recurrence, remain open until a release containing this commit is observed.
2026-07-16 — Login critical shell clears the floor-device LCP target
Type: Frontend performance fix
Summary:
- Kept the 96 kB raw RHF/Zod form stack out of the critical Login shell and loaded it after the first non-suspending commit behind a dimensionally stable fallback.
- Made the 3.2 kB raw Login shell eager in the route tree and preloaded the
loginlocale only on/auth/login, removing the outer route-Suspense reveal delay without widening other route namespaces. - Preserved login, offline-error, invitation-redirect, open-redirect, and authenticated-home behavior with focused tests.
Evidence:
- Canonical three-run floor profile: Login LCP
2388 ms, FCP1432 ms, CLS0.000, route JS243.5 kB, telemetry0.0 kB(before: LCP2868 ms). - Eight-route blast-radius run kept every route under
300 kB; highest was Tasks at294.8 kB. - Exact-main registry scoring remains a separate PM step after merge.
Tests:
pnpm test -- src/test/auth/LoginPage.test.tsx src/test/_shared/bundle-budget.test.ts src/test/_shared/routePreloaders.test.ts --runVITE_API_BASE_URL=http://localhost:8001/api pnpm build:ssgCWV_PORT=41733 BACKEND_URL=http://localhost:8001 node scripts/measure-cwv-p0.mjs 3
2026-07-14 — Close credential lifecycle and browser-session hardening
Type: Security hardening
Summary:
- Added executable proof for reset validation, atomic refresh rotation, password-triggered session revocation, and memory/cookie browser auth.
- Corrected the stale six-character password and password-reset scope language.
- Independent review added cross-tab Web Lock serialization and a distinct pending-offline bootstrap state so network loss neither races cookie rotation nor impersonates a server-rejected session.
- Production web now primes that pending state synchronously and renders before the bounded cookie refresh settles, so an unreachable API cannot blank public routes. Refresh rotation and logout/password revocation hold the same user-row lifecycle lock across old-JTI consumption and successor creation.
- Root routing now resolves a hinted cookie session before choosing marketing versus the assistant, while a missing-cookie 400 clears stale hints and routes subsequent protected requests back to login instead of preserving a false pending session.
- Post-merge review found that logout accepted a JWT-valid but already-consumed predecessor as authority to revoke every newer session. Logout now consumes its presented JTI and revokes the remainder in one lifecycle-locked transaction; replay keeps the uniform 204 response but has no server-side revocation effect.
- Follow-up review found the legitimate mirror race: logout could send the old cookie after refresh committed but before its successor response arrived. Browser logout now shares the refresh Web Lock, and refresh completion checks the durable tombstone before restoring process-memory access.
- Re-review found that unconditional asynchronous logout cleanup could erase a login completed while the old request was in flight, including deleting the new refresh cookie after login’s response. Browser login now joins the shared cookie-mutation lock; logout response cleanup is limited to its tombstone and never clears credentials restored by a later authentication.
- Final review tested the reverse ordering: login starts first, then another tab logs out. Login now captures the logout-intent generation; same-tab and cross-tab logout advance it, so queued authentication rejects before network and an in-flight stale response revokes its own cookie before releasing the lock to any newer authentication.
- Final platform review found the lock helper failed open when Web Locks were absent. Cookie requests are bounded and unavailable or denied native locking now fails closed before network mutation, clears a false pending-session hint so login remains reachable, and never uses an emulated second lock domain.
- Native/platform re-review found desktop refresh bypassed that ordering and registration remained an unguarded browser cookie write. Native auth mutations now use an in-process queue through successor publication and keyring cleanup; registration uses the same cross-tab lock as login/logout.
- Registration review found the page discarded its already-authenticated response and logged in again, orphaning the first refresh JTI. Registration now commits that first response directly to the auth store and creates one session only.
- Final full review found logout cleared an aborted refresh operation ID before recovery, so a server-committed successor could survive behind a consumed predecessor. A second review found client-side recovery still depended on a 60-second cache, could be blocked by refresh rate limiting, and was bypassed by stale-auth cleanup. Rotation now persists the successor JTI plus an operation-ID digest; every browser logout path submits that ID directly, and the server revokes only the exact still-active successor before the client clears recovery state. That review also narrowed malformed-token handling so database/revocation exceptions cannot be swallowed as a false 204 logout.
2026-07-10 — Cross-tab logout coherence + guarded localStorage in the 401 path (W-SYS-10 Check 3)
Type: Bug Fix (frontend)
Summary:
docs/tests/evidence/web/W-SYS-10/walk.mdfound logging out in one tab could leave a second (same-profile) tab showing a fully authenticated- looking, interactive UI over a session that was already dead server-side — recovering only once its next request happened to 401 (which can take up to the query’s 30sstaleTime), and once not recovering within the walk’s observation window at all, alongside aSecurityError: Failed to read the 'localStorage' property …in BOTH tabs’ consoles.- Added
frontend/src/lib/crossTabAuth.ts’sinstallCrossTabLogoutSync(), wired intofrontend/src/stores/authStore.tsat module load: awindow.addEventListener('storage', …)listener that reacts the instant another tab removesaccess_token, clearing this tab’s auth state and hard-redirecting to/auth/login— no longer dependent on a stale query’s next request. - Added
frontend/src/lib/safeStorage.ts(safeGetItem/safeSetItem/safeRemoveItem) and rewired everylocalStorageread/write infrontend/src/api/client.ts’s 401/refresh-handling path — the exact path implicated by the walk’sSecurityError— through it, so a throw there (e.g.SecurityErrorin a torn-down/sandboxed document) can never crash the interceptor; it’s treated the same as “no token”. - Tests:
frontend/src/test/_shared/crossTabAuth.test.ts(6, including aSecurityError-throwinglocalStoragemock proving no crash),apiClient.test.ts(+1, same guard at theclient.tslayer), and 3 new integration cases inauthStore.test.tsdispatching a realStorageEventonwindow. - Re-run of the committed walk (
node frontend/scripts/audit-walks/w-sys-10.mjs) confirms Check 3 now redirects tab B 0ms after focus with zero console errors — see the walk.md’s 2026-07-10 “After fix” section.
2026-07-10 — Offline is not logout: network-vs-401 distinction in bootstrap + refresh (W-SYS-03)
Type: Bug Fix (frontend, honest-copy)
Summary:
authStore.loadUser()’s catch and the axios refresh-interceptor’s catch (frontend/src/api/client.ts) both treated a network failure (no HTTP.responseat all — offline/timeout/DNS/CORS-preflight drop) identically to a real 401/403 auth rejection. Bootstrapping while offline logged the user out, bounced to/auth/login, whose chunk can’t be fetched offline either — stuck on the error boundary until a manual reload (WU8 violation).- Both catch blocks now check
err.response?.statusfor401/403before clearing credentials; a response-less failure keeps all tokens andisAuthenticated(re-derived from token presence), and stops there — no redirect, no reload. - Added
frontend/src/hooks/useOnlineStatus.tsand a reconnect-retry effect inLayout.tsx: on the offline→online transition, ifusernever loaded (the network-failure case),loadUser()/loadOrganizations()/loadOrganization()retry automatically. - Honest copy for the two smaller findings from the same walk: Login showed
“Login failed” for an offline attempt (implies bad credentials) — added
login.error_offline(en+vi, propagated to all 61 locales) shown when the request has no.response. Pulse’s Slow-3G submit-failure toast could leak the raw i18n keyerror_submit(namespace-load race) — addedpulse.error_submit_offline(en+vi, propagated) for the network-error case and adefaultValuefallback on botht()calls so a not-yet-loaded namespace can never render a raw key again.
Reason:
docs/tests/evidence/web/W-SYS-03/walk.md(offline/flaky-network audit walk) drove Login/Pulse/Tasks/Today/Deals through offline → Slow-3G → restored; Deals bootstrapping while offline was the honest FAIL — stuck on the error boundary even after connectivity returned.
Impact:
- No API contract change. Frontend-only:
authStore.ts,api/client.ts,layouts/Layout.tsx,pages/auth/LoginPage.tsx,pages/intel/PulsePage.tsx, newhooks/useOnlineStatus.ts,login/pulselocale namespaces (en+vi authored, 60 other locales propagated as English placeholders per the repo’s standard i18n workflow).
Tests:
pnpm test -- src/test/_shared/authStore.test.ts src/test/_shared/apiClient.test.ts src/test/_shared/Layout.test.tsx src/test/intel/PulsePage.test.tsx src/test/auth/LoginPage.test.tsx src/test/hooks/useOnlineStatus.test.ts./node_modules/.bin/tsc --noEmitnode scripts/check-design-tokens.mjsnode scripts/check-locale-parity.mjs
Postmortem: docs/postmortems/2026-07-10-offline-bootstrap-logout.md
2026-07-08 — Shared mobile auth now preserves field-validation payloads on login/register
Type: Mobile auth UX bug fix
Summary:
- The shared KMP transport layer preserves DRF field-validation payloads instead of reducing them
to a generic mobile
Request failedbanner. - Known duplicate-email and password failures are now classified into typed, localized UI states. Unclassified validation failures retain the first backend field message as a compatibility fallback.
- Added regression coverage in
android/sharedfor the raw parser (ErrorExtTest) and the auth screen model (AuthScreenModelTest).
Reason:
- The backend already returned accurate 400 payloads, but the mobile parser only understood
{"detail": ...}and threw away field arrays like{"password": ["Ensure this field has at least 10 characters."]}. The result was a false appearance that auth itself was broken.
Impact:
- Mobile users can recover from ordinary auth validation mistakes without guessing. Typed known failures are locale-owned; only unclassified compatibility errors may retain backend text. Backend behavior, API shapes, and web auth are unchanged.
Tests:
cd android && ./gradlew :shared:jvmTest --tests "com.locuno.lumi.shared.presentation.common.ErrorExtTest" --tests "com.locuno.lumi.shared.presentation.auth.AuthScreenModelTest"
2026-07-02 — Documentation truth: correct §5 method, §8 field name, §11 stale citations
Type: Documentation Change
Summary:
- §5:
POST /api/auth/me/language/corrected toPATCH— the endpoint is decorated@api_view(["PATCH"])(accounts/views.py:235);POSTreturns 405. This aligns §5 with §9 ES14/ES15 which already citedPATCH. - §8:
user.language_preferencecorrected touser.language— the actual model field (accounts/models.py:29);language_preferencedoes not exist. - §11 row 1:
test_silent_refresh_successdoes not exist; replaced withTestRefreshTokenReuse::test_new_refresh_on_legitimate_refresh(tests/test_auth.py:237). - §11 row 3: line reference
views.py:35-38corrected toviews.py:48-51—user = authenticate(...)is at line 48. - §11 row 5: line reference
views.py:69-71corrected toviews.py:93-96— locale capture inregister_viewis at line 93. - §11 row 8:
test_refresh_token_rotationdoes not exist; replaced withTestRefreshTokenReuse::test_token_reuse_rejected(tests/test_auth.py:221). - §11 row 9:
notifications/views.py:33-35corrected tonotifications/views.py:43-45— the no-enumeration 200 return is at line 43. - §11 row 10:
core/middleware.py:15,20-30,59corrected tocore/middleware.py:117—BlockReadOnlyOrgMiddlewareclass starts at line 117.
Reason:
- All six citation errors were pre-existing stale line numbers and non-existent test names. No behavior changed.
Impact:
- Documentation only. All corrected citations now match
grep -noutput against the live files.
Tests:
- None (documentation change).
2026-06-18 — Visual QC: login labels wired to inputs
Type: Bug Fix
Summary:
- The frontend screenshot flow could see the login form, but Playwright could not resolve the visible Email/Password labels to the matching inputs.
- Added explicit
htmlForassociations on the login form labels so assistive technology and browser automation can target the fields by label.
Tests:
pnpm test./node_modules/.bin/tsc --noEmitpnpm exec playwright test tests/e2e/screenshot-flow.spec.ts --project=chromium --reporter=list
2026-06-18 — Visual QC: calmer login hierarchy and helper copy
Type: UX Fix
Summary:
- The login page worked, but the screenshot audit still showed it as a very plain default card floating in empty space.
- Added a small auth eyebrow, more specific subtitle copy, helper chips about staying signed in and the morning brief, input placeholders, and a clearer footer separation between sign-in and secondary actions.
- The page remains intentionally minimal: one card, one form, no marketing detour.
Tests:
pnpm test -- src/test/LoginPage.test.tsx./node_modules/.bin/tsc --noEmitpnpm exec playwright test tests/e2e/screenshot-flow.spec.ts --project=chromium --reporter=list -g "02-auth"
2026-07-01 — Inactive users can no longer refresh old JWT sessions
Type: Backend auth fix + documentation truth fix
Summary:
refresh_viewnow returns401for inactive users instead of minting a fresh token pair from an old refresh token.- Corrected the F01 refresh contract back to the real implementation:
/api/auth/refresh/is anonymous apart from the refresh token itself; it does not require an access-token header.
Reason:
- The old audit mixed two different truths: SimpleJWT already blocks inactive users on authenticated requests, but our custom refresh endpoint still reissued tokens for inactive accounts. At the same time, the spec had drifted into claiming the refresh endpoint required an access token when the code and tests never did.
Impact:
- Deactivated accounts cannot silently revive a session by rotating refresh tokens.
- The F01 spec now matches the actual auth flow instead of describing a non-existent access-header requirement.
Tests:
cd backend && DJANGO_SETTINGS_MODULE=config.settings.test ./.venv/bin/python -m pytest tests/test_auth.py -k "inactive_user_refresh_returns_401 or token_reuse_rejected or new_refresh_on_legitimate_refresh" -x --tb=short
2026-06-15 — Frontend route namespaced
Frontend route namespaced: post-auth landing /today -> /work/today (app-wide IA restructure; /settings and API paths unchanged).
2026-06-14 — Strategy v2 realignment (positioning)
Type: Documentation Change
Summary:
- Rewrote the §3 competitive note. Old framing positioned Lumi’s onboarding edge against SSO-first SaaS (Google, Microsoft). New framing positions against the real first-market alternative: no account at all — a Vietnamese contractor running on Zalo/WhatsApp + Excel + owner memory. The edge is now “get a chat-and-spreadsheet owner to a working organization in their first minute,” not “smoother than Google sign-in.”
Reason:
- Realign to competitive-strategy-v2.md (Inversion 1 — be the stack, there is nothing to overlay; Inversion 7 — the real competitor is Zalo/WhatsApp + Excel) and the registry “Positioning doctrine (v2)” rules 1, 2, and 4. The target SME owns no US SaaS, so SSO is not the competitive frame.
Impact: Positioning only. No behavior change — the Accept-Language locale capture, zero-org bootstrap gate, and org-setup routing are unchanged.
Tests: None (documentation change).
Notes: No edits to §4 Data Model, §5 API Contract, §7 Test Plan, §9 Error States, §11 Implementation Audit, or the Status line.
2026-06-14 — Restructure to TEMPLATE.md section order; expand §11 audit (SP fleet)
Type: Documentation Change
Summary:
- Numbered “Current Behavior” as §8.
- §11 Error States → §9 (renumbered ES1–ES26); §8 Diagrams → §10; §9 Implementation Audit (narrative) → §11 (10-row table); §10 Edge Cases absorbed into §11 note; §12 Open Questions → §12; Bug/Change Log → §13.
- §5 API contract note:
POST /api/auth/refresh/requires valid access token (corrected “anon” label). - §11 audit: 10 rows, 8 ✅, 2 issues (inactive user JWT gap, refresh spec drift corrected).
2026-06-12 — Added Edge Cases and Error States sections derived from full code audit
Type: Documentation Change
Summary:
- Added §10 Edge Cases (17 items) and §11 Error States (26 responses) from code audit of
apps/accounts/views.py,notifications/views.py,core/middleware.py,organizations/permissions.py.
2026-06-12 — Success metrics + competitive framing (G-r17)
Type: Documentation Change
Summary:
- Added §1b Success Metrics with three tracked signals (org-setup completion rate, silent refresh success rate, locale detection accuracy).
- Added competitive framing to §3 — names SSO-first onboarding (Google, Microsoft) as the concretely worse alternative, grounds Lumi’s edge in first-session orientation.
2026-07-13 — Contract Graph migration slice
Type: Contract and proof traceability
Summary:
- Added the canonical F01 CUE contract for the seven owned authentication requirements and attached stable proof IDs to the existing backend auth tests, including organization membership coverage.
- Regenerated the Contract Graph reports; F01 is intentionally
partialbecause browser and named Android, iOS, and macOS runtime evidence are not yet available.
Impact: No product behavior change. Detected-language confirmation remains owned by F37 and account deletion remains owned by F88.
Tests: The Contract Graph verifier collected and passed the 14 selected F01 pytest proof records;
the remaining runtime gap is explicit in contracts/features/F01.cue.
14. Success Metrics (Appendix)
- Zero-org setup completion rate > 70%: measured as the share of registrations that reach org setup (create or join) in the same browser session. Tied to §2 story 2 — the org-setup gate after registration is designed to prevent the “broken no-tenant state.” A rate below 70% suggests the gate is confusing or friction-heavy.
- Silent refresh success rate > 99%: measured server-side as refresh-token-accepted ÷ refresh-token-attempted. Tied to §2 story 1 — the CEO who opens Lumi at 5am should never see a login form. A rate below 99% means stale/invalid tokens are forcing visible re-login.
- Locale detection accuracy on first authentication: measured as the share of registrations where the
Accept-Languageheader matches the user’s subsequent explicit language preference (§2 story 4). Tied toAccept-Languageparsing in §8 — if detection systematically defaults to"vi"for non-Vietnamese users, the signal is trusted less.
2026-07-18 — Android rejected-login copy distinguishes credentials from session expiry
- A 401 from the login endpoint means the supplied credentials were rejected; no authenticated session exists to expire.
- Android now carries that semantic distinction in
AuthUiStateand renders the existing localized sign-in failure guidance. Authenticated 401 handling retains the session-expired message. - Critical flow 79 retains the rejected-login runtime frame; named-device proof remains open.
2026-08-25 — Contract Graph records the iOS shell-identity collection gap
Type: Contract and proof traceability
Summary:
- Added F01.GAP015 for F01.AC024 because the three authenticated-shell identity XCTest proofs are intentionally deferred by generic Linux Contract Graph CI.
- The gap keeps the missing native collection and named-device evidence visible instead of allowing the planner to report a structural diagnostic with no owning truth surface.
Impact: No product behavior change. The local iOS release gate still runs the full XCTest suite; this records the separate CI collection boundary honestly.
Tests: make contract-fmt-check, make contract-validate, make contract-generate-check, and
the Contract Graph planner.