API client
Status: Scaffolded 2026-09-24
All network access goes through one function. Screens and hooks never call fetch and never build a URL string; they pick an entry from ENDPOINTS and pass it to request. That keeps the auth header, the timeout, and the error shape in one file, and makes "which routes does the app call" a grep.
src/constants/api.ts
export const API_BASE_URL = process.env.EXPO_PUBLIC_API_BASE_URL;
if (!API_BASE_URL) throw new Error('EXPO_PUBLIC_API_BASE_URL is not set');
export const ENDPOINTS = {
auth: { register: '/api/auth/register', login: '/api/auth/login', magicLink: '/api/auth/magic-link', ... },
me: { root: '/api/me', sessions: '/api/me/sessions', session: (id: string) => `/api/me/sessions/${id}` },
places: { list: '/api/places', bySlug: (slug: string) => `/api/places/${slug}`, ... },
events: { ... }, groves: { ... }, friends: { ... }, feed: { ... }, posts: { ... },
conversations: { ... }, media: { ... }, guides: { ... }, actions: { ... }, companion: { ... },
} as const;
The registry mirrors the API endpoints one for one. Adding a route to the API means adding it here in the same PR; the registry is typed, so a typo in a screen is a compile error. Paths are relative; request prepends the base URL.
EXPO_PUBLIC_API_BASE_URL is inlined at build time by Expo. It comes from .env locally and from the EAS profile's env block for builds. It is the value check-prod-ready.sh guards against being localhost in a store build (build and release).
src/lib/api/client.ts
request<T>(path: string, options?: {
method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
body?: unknown;
skipAuth?: boolean;
timeoutMs?: number; // default 15000
}): Promise<T>
Behavior, in order:
- Auth header. Unless
skipAuth, read the token fromauthStore(already in memory after restore; SecureStore is not hit per request) and setAuthorization: Bearer <token>.skipAuthis for register, login, magic link, and public reads before sign-in. A request without a token and withoutskipAuthis rejected locally withunauthenticatedrather than sent. - Body.
JSON.stringifywithContent-Type: application/json. NoFormData: images go to S3 with a presigned URL fromPOST /api/uploads/image/presign, video to Bunny. - Timeout. An
AbortControllerfires attimeoutMs. The abort becomes{ status: 0, code: 'timeout' }. - Response.
2xxwith a body parses toT;204resolvesundefined. Anything else is normalized and thrown.
Error normalization
Every failure is an ApiError with { message, status, code }:
| Source | status | code |
|---|---|---|
API envelope { error: { code, message } } | the HTTP status | the envelope's code |
| Non-JSON response (proxy error page) | the HTTP status | http_error |
| Timeout | 0 | timeout |
| No network, DNS, TLS | 0 | network_error |
This is the same table the web client uses (error handling).
401 and 403
A 401 means the session is gone: the client calls authStore.signOut() once (guarded so ten parallel failures do not sign out ten times) and AuthGate sends the member to the welcome screen. The ApiError is still thrown so the calling query settles.
A 403 is not a session problem. It is thrown to the screen, which renders a denied state (an organizer-only list, an admin route). It never signs out. A 404 on a resource the member expected is rendered as "not available" without distinguishing missing from hidden, matching the API's own rule.
Retries
None inside the client. TanStack Query retries reads once; mutations do not retry. A 429 rate_limited carries Retry-After and the screen shows it.