@tribe-nest/forge 2.2.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tribe-nest/forge",
3
- "version": "2.2.0",
3
+ "version": "3.2.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -1,4 +1,21 @@
1
- import axios, { type AxiosInstance } from "axios";
1
+ import axios, { type AxiosError, type AxiosInstance } from "axios";
2
+
3
+ /**
4
+ * Access-token refresh (backend §7 cutover). Access tokens live ~15 minutes;
5
+ * when a request 401s the client exchanges the refresh token at `path` for a
6
+ * new pair and retries once. The refresh token ROTATES on every exchange, so
7
+ * `onRefreshed` must persist the new one.
8
+ */
9
+ export interface ForgeClientRefreshOptions {
10
+ /** Exchange endpoint for this lane, e.g. "/public/sessions/refresh". */
11
+ path: string;
12
+ /** The current refresh token, or null when anonymous (no refresh attempted). */
13
+ getRefreshToken: () => string | null;
14
+ /** Persist the rotated pair and update whatever backs `getToken`. */
15
+ onRefreshed: (tokens: { token: string; refreshToken: string }) => void;
16
+ /** The chain is dead (revoked/expired/reused) — clear stored tokens. */
17
+ onFailed: () => void;
18
+ }
2
19
 
3
20
  export interface ForgeClientOptions {
4
21
  /** Base URL of the TribeNest public API, e.g. https://api.tribenest.co */
@@ -13,6 +30,8 @@ export interface ForgeClientOptions {
13
30
  * place (Component 1) this is sent on every request to scope reads to the tenant.
14
31
  */
15
32
  publishableKey?: string;
33
+ /** Enable 401-triggered refresh-and-retry for this client's auth lane. */
34
+ refresh?: ForgeClientRefreshOptions;
16
35
  }
17
36
 
18
37
  /**
@@ -22,7 +41,12 @@ export interface ForgeClientOptions {
22
41
  * the base URL is injected (no `process.env`), the token is read per-request via a
23
42
  * closure, and `localStorage` access is SSR-guarded so the client is safe on Workers.
24
43
  */
25
- export const createForgeClient = ({ baseURL, getToken, publishableKey }: ForgeClientOptions): AxiosInstance => {
44
+ export const createForgeClient = ({
45
+ baseURL,
46
+ getToken,
47
+ publishableKey,
48
+ refresh,
49
+ }: ForgeClientOptions): AxiosInstance => {
26
50
  const client = axios.create({ baseURL });
27
51
 
28
52
  client.interceptors.request.use((config) => {
@@ -44,6 +68,65 @@ export const createForgeClient = ({ baseURL, getToken, publishableKey }: ForgeCl
44
68
  return config;
45
69
  });
46
70
 
71
+ if (refresh) {
72
+ // Single-flight: concurrent 401s (a page of queries expiring together) must
73
+ // produce ONE exchange — the refresh token rotates on use, so parallel
74
+ // exchanges would race each other through the server's grace window.
75
+ let inFlight: Promise<string | null> | null = null;
76
+
77
+ const exchange = (): Promise<string | null> => {
78
+ if (!inFlight) {
79
+ inFlight = (async () => {
80
+ const refreshToken = refresh.getRefreshToken();
81
+ if (!refreshToken) return null;
82
+ try {
83
+ // Bare axios on purpose — the shared client would recurse through
84
+ // this very interceptor.
85
+ const res = await axios.post(
86
+ `${baseURL}${refresh.path}`,
87
+ { refreshToken },
88
+ publishableKey ? { headers: { "x-forge-key": publishableKey } } : undefined,
89
+ );
90
+ const next = res.data as { token: string; refreshToken: string };
91
+ refresh.onRefreshed(next);
92
+ return next.token;
93
+ } catch {
94
+ refresh.onFailed();
95
+ return null;
96
+ } finally {
97
+ setTimeout(() => {
98
+ inFlight = null;
99
+ });
100
+ }
101
+ })();
102
+ }
103
+ return inFlight;
104
+ };
105
+
106
+ client.interceptors.response.use(
107
+ (response) => response,
108
+ async (error: AxiosError) => {
109
+ const config = error.config as (typeof error.config & { _retriedAfterRefresh?: boolean }) | undefined;
110
+ if (
111
+ error.response?.status === 401 &&
112
+ config &&
113
+ !config._retriedAfterRefresh &&
114
+ !config.url?.includes(refresh.path) &&
115
+ refresh.getRefreshToken()
116
+ ) {
117
+ const token = await exchange();
118
+ if (token) {
119
+ config._retriedAfterRefresh = true;
120
+ config.headers = config.headers ?? {};
121
+ config.headers["authorization"] = `Bearer ${token}`;
122
+ return client.request(config);
123
+ }
124
+ }
125
+ return Promise.reject(error);
126
+ },
127
+ );
128
+ }
129
+
47
130
  return client;
48
131
  };
49
132
 
@@ -0,0 +1,31 @@
1
+ // localStorage keys for the two auth lanes a Forge site can carry. They live
2
+ // here (not in the auth contexts) so the client factory and both contexts can
3
+ // share them without an import cycle. The fan/member and app-user lanes are
4
+ // deliberately separate pairs — an app-user session must never collide with a
5
+ // profile session on the same site.
6
+ //
7
+ // Access tokens are ~15 minutes now (backend §7 cutover); the refresh token is
8
+ // the long-lived credential and ROTATES on every exchange — always store the
9
+ // one that came back, never re-use the one you sent.
10
+
11
+ export const PUBLIC_ACCESS_TOKEN_KEY = "public-access-token";
12
+ export const PUBLIC_REFRESH_TOKEN_KEY = "public-refresh-token";
13
+
14
+ export const APP_ACCESS_TOKEN_KEY = "app-access-token";
15
+ export const APP_REFRESH_TOKEN_KEY = "app-refresh-token";
16
+
17
+ const canStore = () => typeof localStorage !== "undefined";
18
+
19
+ export const readStoredToken = (key: string): string | null => (canStore() ? localStorage.getItem(key) : null);
20
+
21
+ export const storeTokenPair = (accessKey: string, refreshKey: string, token: string, refreshToken?: string) => {
22
+ if (!canStore()) return;
23
+ localStorage.setItem(accessKey, token);
24
+ if (refreshToken) localStorage.setItem(refreshKey, refreshToken);
25
+ };
26
+
27
+ export const clearTokenPair = (accessKey: string, refreshKey: string) => {
28
+ if (!canStore()) return;
29
+ localStorage.removeItem(accessKey);
30
+ localStorage.removeItem(refreshKey);
31
+ };
@@ -2,6 +2,13 @@
2
2
  import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
3
3
  import { useForge } from "../provider/ForgeProvider";
4
4
  import { createForgeClient } from "../client/createForgeClient";
5
+ import {
6
+ APP_ACCESS_TOKEN_KEY as APP_ACCESS_KEY,
7
+ APP_REFRESH_TOKEN_KEY,
8
+ clearTokenPair,
9
+ readStoredToken,
10
+ storeTokenPair,
11
+ } from "../client/tokenStorage";
5
12
 
6
13
  // App-user auth for a mini-app frontend (account_associations type='app'). This
7
14
  // is the app's OWN user base — distinct from TribeNest fans (usePublicAuth) and
@@ -9,7 +16,8 @@ import { createForgeClient } from "../client/createForgeClient";
9
16
  // client (its own localStorage key) so an app-user session never collides with a
10
17
  // profile session on the same site. Backed by /public/app-sessions/*.
11
18
 
12
- export const APP_ACCESS_TOKEN_KEY = "app-access-token";
19
+ // Kept for compat — the canonical constant lives in client/tokenStorage.ts.
20
+ export const APP_ACCESS_TOKEN_KEY = APP_ACCESS_KEY;
13
21
 
14
22
  export interface AppAuthUser {
15
23
  id: string;
@@ -38,9 +46,19 @@ export interface AppSignupInput {
38
46
  lastName?: string;
39
47
  }
40
48
 
41
- export type AppSignupResult =
42
- | { status: "pending"; user: AppAuthUser }
43
- | { status: "active"; user: AppAuthUser };
49
+ /**
50
+ * What BOTH `signup` and `login` return: the platform emailed a 6-digit code and
51
+ * issued no session. Collect the code and call `verifyCode`. Neither entry point
52
+ * has a variant that logs a user straight in.
53
+ */
54
+ export type AppAuthChallenge = { status: "verify_email"; email: string };
55
+
56
+ /**
57
+ * The outcome of redeeming the code. `active` means signed in (token stored).
58
+ * `pending` means the address is proven but this app requires OWNER APPROVAL, so
59
+ * there is still no session — show a "waiting for approval" screen.
60
+ */
61
+ export type AppAuthResult = { status: "pending"; user: AppAuthUser } | { status: "active"; user: AppAuthUser };
44
62
 
45
63
  interface AppAuthContextType {
46
64
  appId?: string;
@@ -49,10 +67,26 @@ interface AppAuthContextType {
49
67
  isInitialized: boolean;
50
68
  isLoading: boolean;
51
69
  errorMessage: string | null;
52
- /** Sign up. When the app requires owner approval, returns status "pending" and
53
- * does NOT log the user in (no token) show a "pending approval" screen. */
54
- signup: (input: AppSignupInput) => Promise<AppSignupResult>;
55
- login: (input: { email: string; password: string }) => Promise<void>;
70
+ /**
71
+ * Step 1 of 2. Creates the account and emails a 6-digit code; it NEVER logs the
72
+ * user in. Always returns `{ status: "verify_email" }` — including for an email
73
+ * the platform has never seen, so the response can't be used to discover which
74
+ * addresses are already registered. Collect the code, then call `verifyCode`.
75
+ */
76
+ signup: (input: AppSignupInput) => Promise<AppAuthChallenge>;
77
+ /**
78
+ * Step 1 of 2. Checks the password and emails a 6-digit code — it does NOT
79
+ * return a session. Collect the code and call `verifyCode`. (App login is
80
+ * two-factor by design; a wrong password still fails here, before any code.)
81
+ */
82
+ login: (input: { email: string; password: string }) => Promise<AppAuthChallenge>;
83
+ /**
84
+ * Step 2 of 2 for BOTH signup and login. Redeems the emailed code. On success
85
+ * the user is signed in (`active`) unless this app requires owner approval, in
86
+ * which case the address is proven but the session still waits (`pending`) —
87
+ * proving your email is not the owner approving you.
88
+ */
89
+ verifyCode: (input: { email: string; code: string }) => Promise<AppAuthResult>;
56
90
  logout: () => Promise<void>;
57
91
  refetch: () => Promise<void>;
58
92
  clearError: () => void;
@@ -80,7 +114,26 @@ export function AppAuthProvider({ children }: { children: ReactNode }) {
80
114
  // client (which carries the profile/member token).
81
115
  const tokenRef = useRef<string | null>(null);
82
116
  const client = useMemo(
83
- () => createForgeClient({ baseURL: apiUrl, publishableKey, getToken: () => tokenRef.current }),
117
+ () =>
118
+ createForgeClient({
119
+ baseURL: apiUrl,
120
+ publishableKey,
121
+ getToken: () => tokenRef.current,
122
+ // App-user lane (backend §7): same 15-minute access tokens, own refresh
123
+ // chain on its own storage keys so it never collides with a fan session.
124
+ refresh: {
125
+ path: "/public/app-sessions/refresh",
126
+ getRefreshToken: () => readStoredToken(APP_REFRESH_TOKEN_KEY),
127
+ onRefreshed: ({ token: next, refreshToken }) => {
128
+ tokenRef.current = next;
129
+ storeTokenPair(APP_ACCESS_TOKEN_KEY, APP_REFRESH_TOKEN_KEY, next, refreshToken);
130
+ },
131
+ onFailed: () => {
132
+ tokenRef.current = null;
133
+ clearTokenPair(APP_ACCESS_TOKEN_KEY, APP_REFRESH_TOKEN_KEY);
134
+ },
135
+ },
136
+ }),
84
137
  [apiUrl, publishableKey],
85
138
  );
86
139
 
@@ -127,13 +180,41 @@ export function AppAuthProvider({ children }: { children: ReactNode }) {
127
180
  setErrorMessage(null);
128
181
  try {
129
182
  const res = await client.post("/public/app-sessions/signup", { appId, ...input });
130
- const { status, account, token } = res.data as { status: "pending" | "active"; account: AppAuthUser; token?: string };
183
+ const { email } = res.data as { status: "verify_email"; email: string };
184
+ // No token here, by design — see AppAuthChallenge.
185
+ return { status: "verify_email", email };
186
+ } catch (err) {
187
+ setErrorMessage(extractError(err));
188
+ throw err;
189
+ } finally {
190
+ setIsLoading(false);
191
+ }
192
+ },
193
+ [appId, client],
194
+ );
195
+
196
+ const verifyCode = useCallback<AppAuthContextType["verifyCode"]>(
197
+ async ({ email, code }) => {
198
+ if (!appId) throw new Error("AppAuthProvider: no appId in Forge context.");
199
+ setIsLoading(true);
200
+ setErrorMessage(null);
201
+ try {
202
+ const res = await client.post("/public/app-sessions/verify", { appId, email, code });
203
+ const { status, account, token, refreshToken } = res.data as {
204
+ status: "pending" | "active";
205
+ account: AppAuthUser;
206
+ token?: string;
207
+ refreshToken?: string;
208
+ };
131
209
  if (status === "active" && token) {
132
210
  setToken(token);
211
+ if (refreshToken && typeof localStorage !== "undefined") {
212
+ localStorage.setItem(APP_REFRESH_TOKEN_KEY, refreshToken);
213
+ }
133
214
  setUser(account);
134
215
  return { status: "active", user: account };
135
216
  }
136
- // Pending approval — no token issued.
217
+ // Email proven, but the owner still has to approve — no token issued.
137
218
  return { status: "pending", user: account };
138
219
  } catch (err) {
139
220
  setErrorMessage(extractError(err));
@@ -152,8 +233,10 @@ export function AppAuthProvider({ children }: { children: ReactNode }) {
152
233
  setErrorMessage(null);
153
234
  try {
154
235
  const res = await client.post("/public/app-sessions", { appId, ...input });
155
- setToken(res.data.token);
156
- setUser(res.data.account);
236
+ // The password was checked, but no session is issued until the emailed
237
+ // code is redeemed — see AppAuthChallenge.
238
+ const { email } = res.data as { status: "verify_email"; email: string };
239
+ return { status: "verify_email", email };
157
240
  } catch (err) {
158
241
  setErrorMessage(extractError(err));
159
242
  throw err;
@@ -161,7 +244,7 @@ export function AppAuthProvider({ children }: { children: ReactNode }) {
161
244
  setIsLoading(false);
162
245
  }
163
246
  },
164
- [appId, client, setToken],
247
+ [appId, client],
165
248
  );
166
249
 
167
250
  const logout = useCallback<AppAuthContextType["logout"]>(async () => {
@@ -171,6 +254,7 @@ export function AppAuthProvider({ children }: { children: ReactNode }) {
171
254
  // best-effort — clear locally regardless
172
255
  }
173
256
  setToken(null);
257
+ if (typeof localStorage !== "undefined") localStorage.removeItem(APP_REFRESH_TOKEN_KEY);
174
258
  setUser(null);
175
259
  }, [client, setToken]);
176
260
 
@@ -184,6 +268,7 @@ export function AppAuthProvider({ children }: { children: ReactNode }) {
184
268
  errorMessage,
185
269
  signup,
186
270
  login,
271
+ verifyCode,
187
272
  logout,
188
273
  refetch,
189
274
  clearError: () => setErrorMessage(null),
@@ -192,7 +277,7 @@ export function AppAuthProvider({ children }: { children: ReactNode }) {
192
277
  // crash in a render guard is worse than a hidden button.
193
278
  hasPermission: (permission: string) => (user?.permissions ?? []).includes(permission),
194
279
  }),
195
- [appId, user, isInitialized, isLoading, errorMessage, signup, login, logout, refetch],
280
+ [appId, user, isInitialized, isLoading, errorMessage, signup, login, verifyCode, logout, refetch],
196
281
  );
197
282
 
198
283
  return <AppAuthContext.Provider value={value}>{children}</AppAuthContext.Provider>;
@@ -23,8 +23,16 @@ enum Types {
23
23
  UpdateUser = "UPDATE_USER",
24
24
  UpdateErrorMessage = "UPDATE_ERROR_MESSAGE",
25
25
  }
26
- export const ACCESS_TOKEN_KEY = "public-access-token";
27
- export const PUBLIC_ACCESS_TOKEN_KEY = "public-access-token";
26
+ import {
27
+ PUBLIC_ACCESS_TOKEN_KEY,
28
+ PUBLIC_REFRESH_TOKEN_KEY,
29
+ clearTokenPair,
30
+ storeTokenPair,
31
+ } from "../client/tokenStorage";
32
+
33
+ // Kept for compat — the canonical constants live in client/tokenStorage.ts.
34
+ export const ACCESS_TOKEN_KEY = PUBLIC_ACCESS_TOKEN_KEY;
35
+ export { PUBLIC_ACCESS_TOKEN_KEY };
28
36
 
29
37
  type AuthPayload = {
30
38
  [Types.Initial]: { isAuthenticated: boolean; user: PublicAuthUser | null };
@@ -198,8 +206,8 @@ function PublicAuthProvider({ children }: AuthProviderProps) {
198
206
 
199
207
  try {
200
208
  const response = await client.post("/public/sessions", data);
201
- const { token, account, smartLinkPath } = response.data;
202
- localStorage.setItem(ACCESS_TOKEN_KEY, token);
209
+ const { token, refreshToken, account, smartLinkPath } = response.data;
210
+ storeTokenPair(PUBLIC_ACCESS_TOKEN_KEY, PUBLIC_REFRESH_TOKEN_KEY, token, refreshToken);
203
211
  setToken(token);
204
212
  dispatch({ type: Types.Login, payload: { user: account } });
205
213
  return { smartLinkPath };
@@ -220,8 +228,8 @@ function PublicAuthProvider({ children }: AuthProviderProps) {
220
228
 
221
229
  try {
222
230
  const response = await client.post("/public/sessions/verify", { email, code, profileId });
223
- const { token, account, smartLinkPath } = response.data;
224
- localStorage.setItem(ACCESS_TOKEN_KEY, token);
231
+ const { token, refreshToken, account, smartLinkPath } = response.data;
232
+ storeTokenPair(PUBLIC_ACCESS_TOKEN_KEY, PUBLIC_REFRESH_TOKEN_KEY, token, refreshToken);
225
233
  setToken(token);
226
234
  dispatch({ type: Types.Login, payload: { user: account } });
227
235
  return { smartLinkPath };
@@ -264,8 +272,36 @@ function PublicAuthProvider({ children }: AuthProviderProps) {
264
272
  const response = await client.post("/public/accounts", {
265
273
  ...data,
266
274
  });
267
- const { token, account, smartLinkPath } = response.data;
268
- localStorage.setItem(ACCESS_TOKEN_KEY, token);
275
+ // No token: signup issues no session until the emailed code is redeemed
276
+ // (see PublicAuthChallenge). Keep the caller's data — `verifyRegistration`
277
+ // needs the coupon/consent flags again to finish the job.
278
+ const { email } = response.data as { status: "verify_email"; email: string };
279
+ dispatch({ type: Types.Loading, payload: { isLoading: false } });
280
+ return { status: "verify_email" as const, email };
281
+ } catch (err: unknown) {
282
+ const error = err as ApiError;
283
+ dispatch({ type: Types.Loading, payload: { isLoading: false } });
284
+ throw error;
285
+ }
286
+ };
287
+
288
+ const verifyRegistration = async (data: {
289
+ email: string;
290
+ code: string;
291
+ profileId?: string;
292
+ couponCode?: string;
293
+ acceptedTerms?: boolean;
294
+ acceptedPrivacy?: boolean;
295
+ }) => {
296
+ dispatch({ type: Types.Loading, payload: { isLoading: true } });
297
+
298
+ try {
299
+ const response = await client.post("/public/accounts/verify", {
300
+ ...data,
301
+ profileId: data.profileId ?? profileId,
302
+ });
303
+ const { token, refreshToken, account, smartLinkPath } = response.data;
304
+ storeTokenPair(PUBLIC_ACCESS_TOKEN_KEY, PUBLIC_REFRESH_TOKEN_KEY, token, refreshToken);
269
305
  setToken(token);
270
306
 
271
307
  dispatch({ type: Types.Register, payload: { user: account } });
@@ -279,7 +315,7 @@ function PublicAuthProvider({ children }: AuthProviderProps) {
279
315
 
280
316
  const logout = async (persist = true) => {
281
317
  try {
282
- localStorage.removeItem(ACCESS_TOKEN_KEY);
318
+ clearTokenPair(PUBLIC_ACCESS_TOKEN_KEY, PUBLIC_REFRESH_TOKEN_KEY);
283
319
  dispatch({ type: Types.Logout });
284
320
  if (persist) await client.delete("/sessions");
285
321
  } catch (error) {
@@ -308,6 +344,7 @@ function PublicAuthProvider({ children }: AuthProviderProps) {
308
344
  resendLoginCode,
309
345
  logout,
310
346
  register,
347
+ verifyRegistration,
311
348
  updateLocalUser,
312
349
  clearErrorMessage,
313
350
  refetchUser,
@@ -38,7 +38,7 @@ export function useClaimCourseAccess() {
38
38
  const { client } = useForge();
39
39
 
40
40
  return useMutation<
41
- { token?: string; accessId?: string },
41
+ { token?: string; refreshToken?: string; accessId?: string },
42
42
  unknown,
43
43
  { token: string; password: string; firstName: string; lastName: string }
44
44
  >({
package/src/index.ts CHANGED
@@ -11,7 +11,12 @@ export type { ForgeClientOptions } from "./client/createForgeClient";
11
11
  // + theme). Most sites import just this.
12
12
  export { ForgeProvider, type ForgeProviderProps } from "./provider/ForgeAppProvider";
13
13
  // Low-level pieces for hand-composing the tree.
14
- export { ForgeClientProvider, useForge, type ForgeClientProviderProps, type ForgeContextValue } from "./provider/ForgeProvider";
14
+ export {
15
+ ForgeClientProvider,
16
+ useForge,
17
+ type ForgeClientProviderProps,
18
+ type ForgeContextValue,
19
+ } from "./provider/ForgeProvider";
15
20
  export { SiteConfigProvider, useInitialSiteConfig } from "./provider/SiteConfigProvider";
16
21
 
17
22
  // All domain types + enums/consts (models) and WebPage.
@@ -30,6 +35,8 @@ export {
30
35
  PublicAuthContext,
31
36
  } from "./contexts/PublicAuthContext";
32
37
  export type { AuthActions } from "./contexts/PublicAuthContext";
38
+ export { PUBLIC_REFRESH_TOKEN_KEY, APP_REFRESH_TOKEN_KEY } from "./client/tokenStorage";
39
+ export { safeRedirectPath } from "./utils/safeRedirect";
33
40
  // App-user auth (mini-apps) + the /admin guard.
34
41
  export {
35
42
  useAppAuth,
@@ -37,7 +44,8 @@ export {
37
44
  APP_ACCESS_TOKEN_KEY,
38
45
  type AppAuthUser,
39
46
  type AppSignupInput,
40
- type AppSignupResult,
47
+ type AppAuthChallenge,
48
+ type AppAuthResult,
41
49
  } from "./contexts/AppAuthContext";
42
50
  export { useAppAdminGuard, type AppAdminGuardResult } from "./contexts/useAppAdminGuard";
43
51
 
@@ -1,6 +1,13 @@
1
1
  import { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
2
2
  import type { AxiosInstance } from "axios";
3
3
  import { createForgeClient } from "../client/createForgeClient";
4
+ import {
5
+ PUBLIC_ACCESS_TOKEN_KEY,
6
+ PUBLIC_REFRESH_TOKEN_KEY,
7
+ clearTokenPair,
8
+ readStoredToken,
9
+ storeTokenPair,
10
+ } from "../client/tokenStorage";
4
11
 
5
12
  export interface ForgeContextValue {
6
13
  /** Base URL of the TribeNest public API. */
@@ -69,7 +76,29 @@ export const ForgeClientProvider = ({
69
76
  }, []);
70
77
 
71
78
  const client = useMemo(
72
- () => createForgeClient({ baseURL: apiUrl, publishableKey, getToken: () => tokenRef.current }),
79
+ () =>
80
+ createForgeClient({
81
+ baseURL: apiUrl,
82
+ publishableKey,
83
+ getToken: () => tokenRef.current,
84
+ // Fan/member lane (backend §7): 15-minute access tokens, refresh token
85
+ // rotated per exchange. On a dead chain the stored pair is cleared so
86
+ // the next /me lands cleanly logged-out instead of looping on 401s.
87
+ refresh: {
88
+ path: "/public/sessions/refresh",
89
+ getRefreshToken: () => readStoredToken(PUBLIC_REFRESH_TOKEN_KEY),
90
+ onRefreshed: ({ token: next, refreshToken }) => {
91
+ tokenRef.current = next;
92
+ setTokenState(next);
93
+ storeTokenPair(PUBLIC_ACCESS_TOKEN_KEY, PUBLIC_REFRESH_TOKEN_KEY, next, refreshToken);
94
+ },
95
+ onFailed: () => {
96
+ tokenRef.current = null;
97
+ setTokenState(null);
98
+ clearTokenPair(PUBLIC_ACCESS_TOKEN_KEY, PUBLIC_REFRESH_TOKEN_KEY);
99
+ },
100
+ },
101
+ }),
73
102
  [apiUrl, publishableKey],
74
103
  );
75
104