API client
fetchJSON and its single-flight 401 refresh, the typed facade in api/tala.ts, and the per-feature hooks that predate it — including their known gaps.
fetchJSON
Every request goes through one function in src/utils/api.ts. It attaches credentials, retries once on 401, and normalises errors.
export async function fetchJSON<T>(
url: string,
options: RequestInit = {},
retryOnAuthFailure = true,
): Promise<T>;
What it does, in order:
- Sets
Content-Type: application/jsonwhen there is a string body and no explicit header. - Attaches
Authorization: Bearer <token>fromlocalStorage. - Attaches
x-organization-idfromlocalStoragewhen an active org is set. - On a 401 with a refresh token present, refreshes once and replays the request.
- On a failed refresh, clears both tokens so the app can re-authenticate.
- On a 2xx, returns
{}for a 204 and the parsed JSON otherwise. - On any other status, throws
ApiErrorcarryingstatusand the parsed body.
The retry reuses the built headers
// Reuse the headers we already built (which include Content-Type for
// JSON bodies) and just swap in the fresh token — rebuilding from
// options.headers would drop Content-Type and 400 every retried write.
headers.set("Authorization", `Bearer ${newToken}`);
return fetchJSON<T>(url, { ...options, headers }, false);
The false argument prevents an infinite retry loop. The header reuse is the subtle part — rebuilding from options.headers would lose the Content-Type that step 1 added, and every retried POST or PATCH would 400.
Refresh is single-flight
Many requests can 401 simultaneously. Only one may call the refresh endpoint, because replaying a rotated token revokes every session. A module-scoped promise dedupes them, and is cleared in a finally so a failed refresh does not poison later attempts.
Token storage
| Key | Holds |
|---|---|
tala_token |
access token |
tala_refresh_token |
refresh token |
tala_organization_id |
active organization |
getAccessToken();
getRefreshToken();
setTokens(accessToken?, refreshToken?); // only writes truthy values
clearTokens(); // clears both
getActiveOrganizationId();
setActiveOrganizationId(id | null); // also fires tala:organization-change
The typed facade
src/api/tala.ts wraps the collaboration surface and unwraps the envelope, so callers deal in domain objects:
type Envelope<T> = { data: T; message?: string };
const api = async <T>(path: string, options?: RequestInit) =>
(await fetchJSON<Envelope<T>>(`${API_BASE_URL}/api${path}`, options)).data;
const json = (method: string, body?: unknown): RequestInit => ({
method,
body: body === undefined ? undefined : JSON.stringify(body),
});
It exports interfaces for Organization, Member, Invitation, Project, Task, TaskComment, Collection, AssetDetail, AssetVersion, Plan, and DashboardSummary, plus the functions that return them.
The hooks
| Hook | Endpoints | Notes |
|---|---|---|
useRemoteData |
any | generic fetch-state hook; used by Home, Library, Timeline, Teams, Workspace, Invitation |
useCollections |
list, create, rename, delete | favourites are localStorage-only |
useCollectionDetails |
assets by collection, asset update, asset delete | sends collectionId on update and delete |
useAssetUpload |
initiate, PUT, complete, abort | 5 MB chunks, reads ETag per part |
useAssetLogs |
GET /api/assets/:id/asset-logs |
|
useLogin, useSignUp, useForgotPassword, useResetPassword |
auth forms | call fetchJSON directly |
Collection favourites disagree with the server
const toggleFavorite = async (id: string) => {
const favs = getFavs();
const newFavs = favs.includes(id) ? favs.filter((f) => f !== id) : [...favs, id];
saveFavs(newFavs);
// …no network call
};
PATCH /api/collections/:id/favorite exists and tala.ts exports a working favoriteCollection(). Neither is called. Asset favourites do hit the server, so the two behave differently: favourite an asset and it follows you to another device; favourite a collection and it does not.
