Authentication (register & login)
Both apps share one email + OTP register/login flow, parameterized per
deployment. The flow logic is framework-light and lives in @enode/core; the
screens live in @enode/ui; each app supplies a small config and its own page
chrome. There are no app-specific copies of the state machine.
The flow is email + one-time code — for every account, including ones that originally registered via Apple/Google: social sign-in is retired, so those accounts also authenticate with the email OTP (product decision 2026-07-28; live-verified in
docs/testing/auth-test-plan.md, AUTH-LOG-07). The OTP auto-submits when the last digit is entered (no verify button).
The two deployments
Pro app (@enode/tracking, Capacitor) | Portal (@enode/portal, web) | |
|---|---|---|
flavor | "pro_app" | "portal" |
| Who may log in | Pro only (a One user is blocked with a 314, missing canAccessPro) | Pro only (a One user is blocked with a 314, missing canAccessPortal) |
| Who may register | Pro only | Pro only |
| Post-auth route | /workouts/today | /dashboard |
| Legacy migration | enode_pro / enode_one (from OTP reason) | enode_one / enode_pro (from OTP reason) |
Both apps are Pro-only, for login AND registration (product decision
2026-07-28, live-verified — AUTH-LOG-10/11 in
docs/testing/auth-test-plan.md): One users stay in the One app. The earlier
"One may log in to the Portal to migrate" story is retired; the client's
allowOneLogin flag and the One welcome copy are pending removal.
Where the code lives
@enode/core/api/auth— the network calls:requestLoginOtp,requestRegisterOtp,login,register,migrateLegacy,submitOnboarding,requestDeleteOtp+deleteAccount(OTP-protected account deletion), pluspreloadAfterAuth(shared reference-data warm-up) andlogout.@enode/core/auth/use-auth-flow— the headless state machine (useAuthFlow) and its pure helpers (authReducer,migrationOriginForReason,flavorMismatchFromError,authErrorInfoFor). Renders nothing.@enode/core/auth/flavor-config—AppFlavor,AuthFlavorConfig, and theDEFAULT_PRO_LICENSE_BASE_IDfallback.@enode/ui/auth— the screens:AuthSteps(the composite both apps render),WelcomeContent,OrgNameStep,EmailStep,OtpStep, the segmentedOtpInput,FlavorMismatchDialog, and the redirect surfaces (ActionDialogHost,DeviceLimitDialog,InvitationsDialog,OnboardingScreen,ActionResolverInit,action-dialog-store).- each app —
src/app/page.tsxbuilds anAuthFlavorConfigand renders<AuthSteps controller={…} flavor={…} />inside its chrome;src/app/ApiConfigInit.tsxinjects theapi-key+device-type;src/app/layout.tsxmounts the error engine,ActionResolverInit,ActionDialogHost,AuthGuard, and the toaster.
The flows
Sign-in and sign-up are separate (the welcome screen picks one) so a typo can't silently create a duplicate account.
Login: welcome → email → requestLoginOtp → enter OTP (auto-submits on the
last digit) → login → home. Pro accounts only — a One user is blocked with a
314 on both deployments.
Register: welcome → organisation name + email (one step) →
requestRegisterOtp → enter OTP (auto-submits) → register (name +
licenseBaseID) → home. Pro orgs only. The welcome screen presents "Create
organisation account" and "Sign in" as equal choices so One users aren't pushed
away. (UI copy uses British "organisation".)
The OTP request returns a reason (OTPReason). After auth, a loginPro /
loginOne / registerOne reason dispatches migrateLegacy (fire-and-forget; a
migration failure never blocks the now-valid session). The migration origin is
derived from the reason inside the hook, never from the flavor.
A single OTP is valid for 15 minutes and across any number of devices
(the emailed code, not the requesting device, is what's checked). It is consumed
after 3 failed attempts or when the 15 minutes elapse — whichever comes
first. "Resend code" on the OTP step hits a separate endpoint
(resendLoginOtp / resendRegisterOtp → /users/{login,register}/otp/resend/:email,
i.e. /resend/ inserted before the base64 email), NOT the initial request
route. A resend re-mails the code for the same OTP session and keeps the captured
reason — it never re-classifies the account, so its errors carry no wrong-flow
mode-switch hint. The controller exposes it as resendOtp (distinct from
submitEmail, which the email step uses for the first request).
Errors & redirects
The flow uses the one error engine — it reacts to a disposition, never a raw status. Auth-relevant cases:
- 401 (bad/expired OTP) → inline error on the OTP step.
- 400 on an OTP request (wrong flow, e.g. "already registered") → inline error with a one-tap switch to the other mode.
- 310 DeviceLimit →
DeviceLimitDialog(remove a session) → resume. - 312 OpenInvitations →
InvitationsDialog(accept/decline) → resume. The sheet is inescapable: backdrop, Escape and browser Back are all refused (onDismissAttempt={() => false}), so the only exits are its two buttons. That is not decoration —login()fires its preloads concurrently and each will 312 in turn, so a dismissable sheet simply re-summons itself on the next response.
Android destroys 3xx unless the transport is bypassed
These three redirects are the reason @enode/core/api/capacitor-http exists.
With CapacitorHttp: { enabled: true }, Capacitor patches window.fetch and
splits by method. Writes go to the CapacitorHttp plugin, which hands JS a
Response carrying whatever status came back. Reads (GET / HEAD /
OPTIONS / TRACE) are instead rewritten to
https://localhost/_capacitor_http_interceptor_/?u=<url> and served by the
WebView, which must return an android.webkit.WebResourceResponse — and that
class throws on any status in [300, 399]. Capacitor swallows the exception and
returns null, so JS receives anything except the real status.
The effect: 310/311/312 landing on a GET were silently destroyed on Android.
apiRequest never saw the status, actionForError never fired, and the dialog
that should have interrupted the user never opened. The symptom that found this
was a first-login subuser never being shown InvitationsDialog on Android while
the same build in a browser worked.
installNativeHttpTransport() (called from the tracking app's ApiConfigInit)
routes Android GETs through the plugin instead, rebuilding an equivalent
Response so nothing downstream can tell the difference. It is a no-op on iOS —
whose interceptor re-emits an HTTPURLResponse, which carries any status — and
on the web.
The deeper fix is for these interruptions to stop using 3xx at all. They carry a JSON body, no
Location, and expect the client to act and retry, which is not what a redirect is. A 4xx would make the platform restriction irrelevant and remove the asymmetry between Android and iOS reads.
The admission gate
A 312 does not arrive on the login call; it rides on the preloads login()
fires without awaiting, i.e. after the login-time 314 gate has already passed.
Resolving it can also change the account's tier — declining drops the user out of
their parent org and can leave them without canAccessPortal / canAccessPro.
So submitOtp (auth/use-auth-flow.ts) awaits verifyAppAccess(config.flavor)
(auth/app-access.ts) before calling onAuthenticated. Awaiting it is what
keeps the login page mounted: any redirect dialog raised by a preload opens over
the login screen and is answered there, and the app only navigates once the
account is admitted. verifyAppAccess force-refreshes the profile, treats a 314
on that refresh as the server's verdict, and otherwise checks the flavor's
privilege. A denial calls logout() — the token must be gone before the login
page re-renders, or its if (getToken()) redirect re-admits the user — and
dispatches the blocked step, landing on the same FlavorMismatchDialog a
login-time 314 shows.
It fails open on anything else (network failure, an unresolved redirect), so a blip can't eject a legitimate user; the server-side 314 remains the real gate. The check runs only at login, so an invitation arriving mid-session is not re-verified — part of the wider post-login 314 gap.
The check must never be run from inside ActionDialogHost: the profile read
would 310/311/312 itself, coalesce onto the queue entry the host has not settled
yet, and deadlock against the resolveAction that is waiting on it.
- 311 UserNotEnabled → the full-screen
OnboardingScreen. A One user is sent to finish in the Enode One app; a not-yet-enabled Pro user sets their organisation name (submitOnboarding), which enables the account so the original request resumes. This is the same name surface the register flow shows up front. A cancel sticks for the current session token: on a not-enabled account the backend 311s every request — including background ones (SSE reconnects, preloads) — so without the stickiness the screen would re-summon itself seconds after being dismissed. Repeat 311s auto-cancel until a fresh login (new token) or a completed onboarding re-arms the screen (action-dialog-store.ts). - 314 AppFlavorMismatch → terminal
FlavorMismatchDialog. The account's license can't access this app (e.g. a One user on the Pro app). It is ablockingdisposition inerrors/status-table.ts— deliberately not anaction-table.tsredirect, so it never enters the resume/retry loop. The gate fires on the very first call: live (2026-09-08) the OTP requestGET /users/login/otp/:emailalready answers 314 for a One account on the Pro app, sorequestOtpStepmaps a 314 to theblockedstep exactly likeverifyOtpStepdoes (before, it fell through to the inline error as the raw reason "App flavor mismatch"). The Pro-app copy names the backend'scurrentTier("Your current license is Enode One, …"), asks the user to reach out to enode for Pro, and shows the App Store / Google Play badges for the orange Enode One app (AppStoreBadges); a backenduserMessagestill wins over the client fallback.
Account deletion
A destructive Delete account control lives at the bottom of the tracking
app's Settings (@enode/ui/auth/delete-account-section). It is OTP-protected and
irreversible: tap → an "are you sure, this can't be undone" confirm sheet →
requestDeleteOtp(email) mails a code → the segmented OtpInput (auto-submit) →
deleteAccount(email, otp) (DELETE /users/delete, Basic email:otp). On
success deleteAccount also wipes the offline database (wipeOfflineData) —
logout deliberately no longer does (ADR 0002 amendment 2: the DB survives
logout, owner-scoped to the account; a deleted account's local data must not
outlive it or be adopted by the next login) — then the app clears the session
(logout) and returns to the welcome screen.
A successful deletion answers with 401 — once the account is gone, the
credentials that authorized the call are invalid. So deleteAccount treats a 401
as success (it resolves), and the app then logs out and lands on the
welcome/login screen. The call sets suppressUnauthorizedEvent so that 401 does
not also fire the global enode:unauthorized logout — the component owns the
single teardown + redirect. Genuine failures (network / 5xx) are shown inline.
The DeleteAccountSection component is reusable; the Portal can mount it once it
has a settings surface.
The Pro-app gate (api-key + device-type)
The "Pro app blocks One users" and device-limit behaviors are enforced
server-side from the api-key JWT subject (pro_app / portal) and the
device-type header. The client just renders the right message. Each app sets
both in src/app/ApiConfigInit.tsx:
- Pro app →
pro_appkey,device-typefrom the Capacitor platform (phone/web). - Portal →
portalkey,device-type: "web".
Provide the real keys via
NEXT_PUBLIC_API_KEY. Until thepro_appkey is set, the 314 / device-limit gate cannot fire (the UI is built but inert).
The Pro LicenseBase for registration comes from NEXT_PUBLIC_PRO_LICENSE_BASE_ID
(defaults to DEFAULT_PRO_LICENSE_BASE_ID). All licenses grant canAccessPortal,
so one base works for both apps.
Every request header is ASCII-only
standardApiHeaders (packages/core/src/api/client.ts) passes the whole header
record through toHeaderSafeAscii
(packages/core/src/api/header-value.ts) before it can reach fetch.
This is a correctness requirement, not hardening. Header values are ByteStrings:
fetch throws TypeError: … Value is not a valid ByteString for any code
point above U+00FF, which fails the request before it leaves the device. The
device-name header carries the user's own device name, and that routinely
contains such characters:
- iOS spells its default as
<Name>’s iPhonewith U+2019, not an ASCII' - Android lets the user type anything into the device name, emoji included
- a non-Latin name (
김철수의 iPhone) is entirely above U+00FF
Unsanitized, any of those is a total outage for that one user — every
request fails, login included, with an error that looks nothing like its cause.
Values in U+0080–U+00FF (Müller) don't throw but are silently mis-decoded
server-side.
The sanitizer transliterates accented Latin (Müller → Muller), folds
typographic punctuation, and drops everything else outside printable ASCII.
Removing CR/LF also makes header injection impossible. resolveDeviceName
(api/device-identity.ts) sanitizes at the source as well, so the value stored
in the API config — which also surfaces in diagnostics and the device-session
list — is already clean; the choke point in standardApiHeaders is the hard
guarantee that no future header can reintroduce the problem.
Testing
Automated (Vitest, node env via stubbed fetch — no DOM):
packages/core/src/auth/use-auth-flow.test.ts— the reducer + decision helpers and the full login + registration flows for both deployments, via the exportedrequestOtpStep/verifyOtpStep: OTP request (reason capture + wrong-flow 400 → switch hint), verify → authenticate, legacy-migration routing (loginPro/loginOne/registerOne), the register body (per-flavorlicenseBaseID+ organisation name), 314 → blocked, and 401 → inline error.packages/core/src/api/auth.test.ts— the wire shape of every auth call, includingdeleteAccount's 401-as-success.packages/core/src/email.test.ts— the email validator.- Storybook: the presentational screens and their states —
WelcomeContent(Pro app vs Portal),EmailStep(login / register / submitting),OtpStep(idle / verifying), and the segmentedOtpInput.
Manual: use the backend GET /api/debug/errors/* triggers (device-limit,
open-invitations, user-not-enabled, abort/314) to exercise each surface.
Open items
- 311 onboarding endpoint:
submitOnboardingcallsPUT /users/onboarding(iOS shape). Confirm the exact endpoint/DTO and whether a Bearer token exists at the 311 point to authenticate it, or whether the OTP Basic credentials must be reused. - 311 tier signal:
OnboardingScreenbranches One vs Pro from the 311 body (currentTier/canAccessOne); confirm the field is present, otherwise it defaults to the Pro name-entry path. - Capacitor token storage: tokens live in
localStorage;@capacitor/preferencesis a later hardening (isolated toapi/storage.ts).