diff --git a/js/mobile/src/App.tsx b/js/mobile/src/App.tsx index 927f81b..1ea860b 100644 --- a/js/mobile/src/App.tsx +++ b/js/mobile/src/App.tsx @@ -20,7 +20,9 @@ import { useAuthStore } from "@/stores/auth-store"; const queryClient = createQueryClient(); -// One-time setup: foreground handler + push-token rotation listener. Idempotent. +// One-time setup: foreground handler + tap routing. Idempotent. Note that +// push-token rotation sync is NOT set up here — that's owned by the auth +// store and only runs while a session is active. configureNotifications(); export default function App() { diff --git a/js/mobile/src/api/client.ts b/js/mobile/src/api/client.ts index 529333a..5ab288d 100644 --- a/js/mobile/src/api/client.ts +++ b/js/mobile/src/api/client.ts @@ -1,5 +1,4 @@ import { appConfig } from "@/config/env"; -import { useSessionStore } from "@/stores/session-store"; import { ApiError } from "@/lib/errors"; import type { z } from "zod"; import { @@ -28,17 +27,20 @@ import type { SignInRequest, } from "./types"; -interface ApiClientConfig { - baseUrl: string; - getToken: () => string | null; - onUnauthorized: () => void; -} - +/** + * HTTP transport for Orion. Holds the bearer token as private state — the auth + * store pushes it in via {@link setToken} on sign-in / restore and clears it + * on sign-out. The client itself has no opinion about what a 401 means; it + * just throws, and the query-client onError handler is the single place that + * turns a 401 into a session invalidation. + */ class ApiClient { - private config: ApiClientConfig; + private token: string | null = null; - constructor(config: ApiClientConfig) { - this.config = config; + constructor(private readonly baseUrl: string) {} + + setToken(token: string | null): void { + this.token = token; } private async fetch( @@ -52,19 +54,17 @@ class ApiClient { headers["Content-Type"] = "application/json"; } - const token = this.config.getToken(); - if (token) { - headers["Authorization"] = `Bearer ${token}`; + if (this.token) { + headers["Authorization"] = `Bearer ${this.token}`; } - const response = await fetch(`${this.config.baseUrl}${path}`, { + const response = await fetch(`${this.baseUrl}${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined, }); if (response.status === 401) { - this.config.onUnauthorized(); throw new ApiError(401, "Unauthorized"); } @@ -273,12 +273,4 @@ class ApiClient { } } -export const apiClient = new ApiClient({ - baseUrl: appConfig.orionUrl, - getToken: () => useSessionStore.getState().token, - // SecureStore writes are async; we fire-and-forget so the throwing - // request doesn't have to wait for persistence to finish. - onUnauthorized: () => { - void useSessionStore.getState().clearToken(); - }, -}); +export const apiClient = new ApiClient(appConfig.orionUrl); diff --git a/js/mobile/src/lib/push-notifications.ts b/js/mobile/src/lib/push-notifications.ts index 5d4dca5..b67ea3e 100644 --- a/js/mobile/src/lib/push-notifications.ts +++ b/js/mobile/src/lib/push-notifications.ts @@ -13,9 +13,12 @@ let configured = false; let tokenListenerSubscription: Notifications.Subscription | null = null; /** - * Sets the foreground notification handler so banners show while the app is - * open, and subscribes to Expo's token-rotation listener so the backend stays - * in sync without the user needing to re-launch. Safe to call multiple times. + * Sets the foreground notification handler and tap routing. Safe to call + * multiple times. Does NOT subscribe to Expo's token-rotation listener — + * that's the auth store's job via {@link startPushTokenSync}, so token sync + * only runs while a session is active. Subscribing here at module scope used + * to fire `apiClient.registerPushToken` before SecureStore hydration completed + * and silently invalidated the user's real session. */ export function configureNotifications(): void { if (configured) return; @@ -30,12 +33,6 @@ export function configureNotifications(): void { }), }); - tokenListenerSubscription = Notifications.addPushTokenListener((event) => { - // Token rotated server-side by Expo or APNs. Sync immediately so we don't - // keep pushing to a dead token. - void syncPushToken(event.data); - }); - // Warm-state taps (app in background or foreground). Cold-start taps are // drained separately via getLastNotificationResponseAsync; see // flushPendingNavigation in notification-routing.ts. @@ -48,6 +45,27 @@ export function configureNotifications(): void { }); } +/** + * Subscribes to Expo's token-rotation listener. Called by the auth store + * after a successful sign-in or session restore so rotation events only fire + * `syncPushToken` while authenticated. Idempotent. + */ +export function startPushTokenSync(): void { + if (tokenListenerSubscription) return; + tokenListenerSubscription = Notifications.addPushTokenListener((event) => { + void syncPushToken(event.data); + }); +} + +/** + * Tears down the rotation listener. Called by the auth store on sign-out and + * session invalidation. Idempotent. + */ +export function stopPushTokenSync(): void { + tokenListenerSubscription?.remove(); + tokenListenerSubscription = null; +} + /** * Acquires (or returns the cached) Expo push token for this device. Returns * null on simulators, when permission is denied, or when any step fails — the diff --git a/js/mobile/src/lib/pusher-provider.tsx b/js/mobile/src/lib/pusher-provider.tsx index d458a2e..00f9e0d 100644 --- a/js/mobile/src/lib/pusher-provider.tsx +++ b/js/mobile/src/lib/pusher-provider.tsx @@ -7,14 +7,14 @@ import { type ReactNode, } from "react"; import { PusherClient, type ConnectionState } from "./pusher-client"; -import { useSessionStore } from "@/stores/session-store"; +import { useAuthStore } from "@/stores/auth-store"; import { appConfig } from "@/config/env"; const PusherContext = createContext(null); const PusherStateContext = createContext("disconnected"); export function PusherProvider({ children }: { children: ReactNode }) { - const token = useSessionStore((s) => s.token); + const token = useAuthStore((s) => s.token); const clientRef = useRef(null); const [connectionState, setConnectionState] = useState("disconnected"); @@ -31,7 +31,7 @@ export function PusherProvider({ children }: { children: ReactNode }) { const client = new PusherClient({ url: appConfig.pusherUrl, - getToken: () => useSessionStore.getState().token, + getToken: () => useAuthStore.getState().token, }); clientRef.current = client; diff --git a/js/mobile/src/lib/query-client.ts b/js/mobile/src/lib/query-client.ts index 6776b63..65125ee 100644 --- a/js/mobile/src/lib/query-client.ts +++ b/js/mobile/src/lib/query-client.ts @@ -5,6 +5,7 @@ import { } from "@tanstack/react-query"; import { toast } from "sonner-native"; import { ApiError, logError, reportError, toUserMessage } from "@/lib/errors"; +import { useAuthStore } from "@/stores/auth-store"; declare module "@tanstack/react-query" { interface Register { @@ -22,6 +23,16 @@ function shouldRetryQuery(failureCount: number, err: unknown): boolean { return failureCount < 2; } +// A 401 surfaced through react-query means the server rejected our bearer +// token. This is the *only* place that turns that into an auth state change — +// the apiClient is a dumb transport. Direct apiClient callers (signIn, +// restoreSession, signInToFirebase) handle their own 401s explicitly. +function handleUnauthorized(err: unknown): void { + if (err instanceof ApiError && err.status === 401) { + void useAuthStore.getState().invalidateSession(); + } +} + export function createQueryClient(): QueryClient { return new QueryClient({ defaultOptions: { @@ -36,6 +47,7 @@ export function createQueryClient(): QueryClient { }, queryCache: new QueryCache({ onError: (err, query) => { + handleUnauthorized(err); logError(err, { scope: "query", queryKey: query.queryKey }); if (query.meta?.toastOnError) { toast.error(toUserMessage(err)); @@ -44,6 +56,7 @@ export function createQueryClient(): QueryClient { }), mutationCache: new MutationCache({ onError: (err, _variables, _context, mutation) => { + handleUnauthorized(err); reportError(err, { scope: "mutation", mutationKey: mutation.options.mutationKey, diff --git a/js/mobile/src/stores/auth-store.ts b/js/mobile/src/stores/auth-store.ts index 8ebc435..6901fb1 100644 --- a/js/mobile/src/stores/auth-store.ts +++ b/js/mobile/src/stores/auth-store.ts @@ -1,3 +1,4 @@ +import * as SecureStore from "expo-secure-store"; import { create } from "zustand"; import { signInWithCustomToken, @@ -8,10 +9,34 @@ import type { Human } from "@/api/types"; import { firebaseAuth } from "@/firebase"; import { logError, ApiError } from "@/lib/errors"; import { + startPushTokenSync, + stopPushTokenSync, syncPushToken, unregisterPushToken, } from "@/lib/push-notifications"; -import { hydrateSession, useSessionStore } from "./session-store"; + +const AUTH_TOKEN_KEY = "auth_token"; + +async function readPersistedToken(): Promise { + try { + return await SecureStore.getItemAsync(AUTH_TOKEN_KEY); + } catch { + // SecureStore failures are non-fatal — proceed unauthenticated. + return null; + } +} + +async function persistToken(token: string): Promise { + await SecureStore.setItemAsync(AUTH_TOKEN_KEY, token); +} + +async function clearPersistedToken(): Promise { + try { + await SecureStore.deleteItemAsync(AUTH_TOKEN_KEY); + } catch { + // ignore — in-memory clear still happens via the caller + } +} async function signInToFirebase(): Promise { try { @@ -29,6 +54,7 @@ type AuthStatus = "idle" | "restoring" | "unauthenticated" | "authenticated"; interface AuthState { status: AuthStatus; user: Human | null; + token: string | null; isRequestingCode: boolean; isSigningIn: boolean; isSigningOut: boolean; @@ -37,12 +63,19 @@ interface AuthState { requestCode: (email: string) => Promise; signIn: (email: string, code: string) => Promise; signOut: () => Promise; + /** + * Wipes the session in response to a server-detected auth failure (e.g. a + * 401 surfaced through react-query). Does not call `/auth/sign-out`; the + * server already considers us unauthenticated. + */ + invalidateSession: () => Promise; clearError: () => void; } -export const useAuthStore = create((set) => ({ +export const useAuthStore = create((set, get) => ({ status: "idle", user: null, + token: null, isRequestingCode: false, isSigningIn: false, isSigningOut: false, @@ -50,26 +83,26 @@ export const useAuthStore = create((set) => ({ restoreSession: async () => { set({ status: "restoring" }); - if (!useSessionStore.getState().hydrated) { - await hydrateSession(); - } - const token = useSessionStore.getState().token; + const token = await readPersistedToken(); if (!token) { set({ status: "unauthenticated" }); return; } + apiClient.setToken(token); + set({ token }); + try { const user = await apiClient.me(); await signInToFirebase(); set({ status: "authenticated", user }); + startPushTokenSync(); void syncPushToken(); } catch (err) { // Expected on expired/invalid tokens — fall back to the login screen. logError(err, { scope: "auth.restore" }); - await useSessionStore.getState().clearToken(); - set({ status: "unauthenticated", user: null }); + await get().invalidateSession(); } }, @@ -91,9 +124,12 @@ export const useAuthStore = create((set) => ({ set({ isSigningIn: true, error: null }); try { const { human, token } = await apiClient.signIn({ email, code }); - await useSessionStore.getState().setToken(token); + await persistToken(token); + apiClient.setToken(token); + set({ token }); await signInToFirebase(); set({ status: "authenticated", user: human }); + startPushTokenSync(); void syncPushToken(); } catch (e) { const message = e instanceof ApiError ? e.message : "Failed to sign in"; @@ -106,7 +142,8 @@ export const useAuthStore = create((set) => ({ signOut: async () => { set({ isSigningOut: true }); - // Unregister the push token first — once the session token is cleared the + stopPushTokenSync(); + // Unregister the push token first — once the bearer token is cleared the // backend call would 401. Best-effort: failures must not block sign-out. await unregisterPushToken(); try { @@ -118,29 +155,24 @@ export const useAuthStore = create((set) => ({ await firebaseSignOut(firebaseAuth).catch((err) => logError(err, { scope: "auth.firebaseSignOut" }), ); - await useSessionStore.getState().clearToken(); + apiClient.setToken(null); + await clearPersistedToken(); set({ status: "unauthenticated", user: null, + token: null, isSigningOut: false, error: null, }); } }, + invalidateSession: async () => { + stopPushTokenSync(); + apiClient.setToken(null); + await clearPersistedToken(); + set({ status: "unauthenticated", user: null, token: null }); + }, + clearError: () => set({ error: null }), })); - -// React to token being cleared externally (e.g. 401 from API client). -useSessionStore.subscribe((state, prevState) => { - if (prevState.token && !state.token) { - const authState = useAuthStore.getState(); - if (authState.status === "authenticated") { - useAuthStore.setState({ - status: "unauthenticated", - user: null, - error: null, - }); - } - } -}); diff --git a/js/mobile/src/stores/session-store.ts b/js/mobile/src/stores/session-store.ts deleted file mode 100644 index 2d24993..0000000 --- a/js/mobile/src/stores/session-store.ts +++ /dev/null @@ -1,46 +0,0 @@ -import * as SecureStore from "expo-secure-store"; -import { create } from "zustand"; - -const AUTH_TOKEN_KEY = "auth_token"; - -interface SessionState { - token: string | null; - /** - * False until SecureStore returns the persisted token (or confirms absence). - * The API client should treat requests as unauthenticated until this flips — - * see `useSessionStore.subscribe` in App.tsx for the bootstrap. - */ - hydrated: boolean; - setToken: (token: string) => Promise; - clearToken: () => Promise; -} - -export const useSessionStore = create((set) => ({ - token: null, - hydrated: false, - - setToken: async (token: string) => { - await SecureStore.setItemAsync(AUTH_TOKEN_KEY, token); - set({ token }); - }, - - clearToken: async () => { - await SecureStore.deleteItemAsync(AUTH_TOKEN_KEY); - set({ token: null }); - }, -})); - -/** - * Bootstrap the session by reading SecureStore once. Call from App.tsx before - * mounting the navigator. Resolves after the store reflects whatever was in - * persistent storage. - */ -export async function hydrateSession(): Promise { - try { - const token = await SecureStore.getItemAsync(AUTH_TOKEN_KEY); - useSessionStore.setState({ token: token ?? null, hydrated: true }); - } catch { - // SecureStore failures are non-fatal — proceed unauthenticated. - useSessionStore.setState({ token: null, hydrated: true }); - } -}