WI-024: Sign in on mobile
WI-024: Sign in on mobile
Renumbered from WI-020, which had been used twice — the catalogue seam claimed it first and is referenced from ADR-0010. Numbers were being handed out ad hoc while chasing deploy failures instead of checked against the backlog;
scripts/check-work-items.mjsnow makes that impossible.
WI-013 taught the API to recognise a session and deliberately stopped there — sign-in UI cannot be tested meaningfully without a live Clerk instance, and shipping untested sign-in is worse than shipping none. There is now an instance. This is the flow.
In scope
@clerk/clerk-expo, wired at the root layout.- An email-OTP screen: type an address, get a code, type the code.
- The session token attached to authenticated API calls.
GET /mefrom the app — the request that creates the local row (ADR-0005).- A token cache in the platform keystore, so closing the app does not sign you out.
Out of scope
- Social sign-in. WI-050, and it needs the Apple Developer Program (WI-H02).
- Protecting any route. Everything is still a public read; the first thing worth protecting arrives with collections (WI-030).
- Profile editing. The display name is derived and changeable later.
One screen, not two
Clerk models sign-in and sign-up as separate flows. From a person’s point of view “type your email, type the code” is one thing, and they neither know nor care whether they already have an account.
So the screen tries sign-in and falls back to sign-up when Clerk reports form_identifier_not_found. The button says Continue, and nobody has to pick.
The token is fetched per request, never stored
setTokenProvider takes a function, not a token. Clerk’s session tokens are short-lived and refreshed in the background; holding one would work for roughly a minute and then produce 401s that present as being mysteriously signed out. There is a test asserting two calls get two different tokens.
Definition of done
- Email OTP signs in an existing account and creates a new one, from one screen.
- The token is attached to
/meand not to public routes. - The provider is asked on every request rather than cached.
- A missing token fails without calling the API — the client already knows.
- A 401 is distinguishable, so a screen can send somebody to sign in rather than print a status code.
- The session survives an app restart.
- Signing in shows the name from our row, not Clerk’s.
- Each check proven able to fail.
- Gate green.
Verification
cp apps/mobile/.env.example apps/mobile/.env # add the publishable keypnpm --filter @tabletop/api dev # terminal onepnpm --filter @tabletop/mobile dev # terminal twoSign in, and the header should read Signed in as … — the name coming from /me, which means the row exists in Neon.
Notes
The token cache is expo-secure-store, not AsyncStorage. The session token is the credential; anything holding it can act as the user until it expires, so it belongs in the keychain or keystore. On web SecureStore throws rather than degrading, so the cache is undefined there and Clerk falls back to its own storage — handing it a cache whose methods reject stops the app booting.
A corrupt cache entry deletes itself rather than throwing. Losing a token costs a sign-in; a launch crash costs everything.
The publishable key lives in .env, not a secret store. It is public by design and compiled into the app binary. .env.example is committed and says so, because treating a public value as secret teaches the wrong reflex about the ones that are not.
The bundle budget rose 1.6MB → 1.95MB. Clerk is ~335KB. That is a real feature dependency rather than a leak, and the raise was made after checking the emitted bundle for pg-core, drizzle and postgres — all zero, so ADR-0022 still holds.