@gonvex/react 0.1.32 → 0.3.1

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/dist/index.js CHANGED
@@ -1,14 +1,12 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
3
- import { ConvexReactClient, GonvexClientError } from "@gonvex/client";
3
+ import { GonvexClientError, control } from "@gonvex/client";
4
4
  export { GonvexClientError } from "@gonvex/client";
5
5
  const GonvexContext = createContext(null);
6
6
  const GonvexAuthContext = createContext({ isLoading: false, isAuthenticated: true });
7
- export const ConvexProvider = GonvexProvider;
8
7
  export function GonvexProvider(props) {
9
8
  return _jsx(GonvexContext.Provider, { value: props.client, children: props.children });
10
9
  }
11
- export { ConvexReactClient };
12
10
  class GonvexAuthRequestError extends Error {
13
11
  status;
14
12
  constructor(message, status) {
@@ -18,7 +16,7 @@ class GonvexAuthRequestError extends Error {
18
16
  }
19
17
  }
20
18
  const ManagedAuthContext = createContext(null);
21
- export function ConvexProviderWithAuth(props) {
19
+ export function GonvexProviderWithAuth(props) {
22
20
  const auth = props.useAuth();
23
21
  const [tokenReady, setTokenReady] = useState(false);
24
22
  const [clientAuthError, setClientAuthError] = useState(null);
@@ -70,12 +68,7 @@ export function ConvexProviderWithAuth(props) {
70
68
  const shouldHoldChildren = !authError && (auth.isLoading || (auth.isAuthenticated && !tokenReady));
71
69
  return (_jsx(GonvexAuthContext.Provider, { value: authValue, children: _jsx(GonvexProvider, { client: props.client, children: shouldHoldChildren ? null : props.children }) }));
72
70
  }
73
- /**
74
- * Native Gonvex authentication. The runtime performs the one centrally
75
- * configured Google OAuth flow, while each app uses PKCE and receives a
76
- * project-scoped Gonvex session. No Firebase or Google SDK is loaded in the
77
- * browser.
78
- */
71
+ /** Native Gonvex authentication with password or a configured OAuth provider. */
79
72
  // Dedupe callback bootstrap across React StrictMode remounts so the OAuth
80
73
  // code+PKCE exchange runs once. Without this, the first effect's finally
81
74
  // clears sessionStorage PKCE before the remount can finish verification.
@@ -89,27 +82,61 @@ export function GonvexAuthProvider(props) {
89
82
  const [isLoading, setIsLoading] = useState(true);
90
83
  const [error, setError] = useState(null);
91
84
  const [refreshRetryAt, setRefreshRetryAt] = useState(0);
85
+ const [developerMode, setDeveloperMode] = useState({ active: false });
92
86
  const sessionRef = useRef(session);
93
87
  const refreshRef = useRef(null);
88
+ const developerModeRef = useRef(null);
94
89
  const installSession = useCallback((next, persist = true) => {
95
90
  sessionRef.current = next;
96
91
  if (next) {
97
92
  if (persist)
98
93
  safeLocalStorageSet(storageKey, JSON.stringify(next));
99
- props.client.setAuth({ project: props.projectId, tenant: next.activeTenantId, token: next.accessToken });
94
+ if (!developerModeRef.current) {
95
+ props.client.setAuth({
96
+ project: props.projectId, tenant: next.activeTenantId, token: next.accessToken,
97
+ identity: { sub: next.account.id, iss: props.projectId },
98
+ });
99
+ }
100
100
  }
101
101
  else {
102
102
  if (persist)
103
103
  safeLocalStorageRemove(storageKey);
104
- props.client.setAuth({ project: props.projectId, tenant: undefined, token: undefined });
104
+ if (!developerModeRef.current) {
105
+ props.client.setAuth({ project: props.projectId, tenant: undefined, token: undefined, identity: undefined });
106
+ }
105
107
  }
106
108
  setSession(next);
107
109
  }, [props.client, props.projectId, storageKey]);
110
+ const restoreAccountSession = useCallback(() => {
111
+ const developer = developerModeRef.current;
112
+ if (!developer)
113
+ return;
114
+ developerModeRef.current = null;
115
+ setDeveloperMode({ active: false });
116
+ const current = sessionRef.current;
117
+ const activeTenantId = current?.tenants.some((tenant) => tenant.id === developer.originalTenantId)
118
+ ? developer.originalTenantId
119
+ : current?.activeTenantId;
120
+ installSession(current ? { ...current, activeTenantId } : null);
121
+ }, [installSession]);
122
+ useEffect(() => props.client.onAuthError((message) => {
123
+ if (!developerModeRef.current)
124
+ return;
125
+ restoreAccountSession();
126
+ setError(message || "Developer mode ended because its authorization is no longer valid.");
127
+ }), [props.client, restoreAccountSession]);
128
+ useEffect(() => {
129
+ if (!developerMode.active || !developerMode.expiresAt)
130
+ return;
131
+ const delay = Math.max(0, Date.parse(developerMode.expiresAt) - Date.now());
132
+ const timeout = window.setTimeout(() => restoreAccountSession(), delay);
133
+ return () => window.clearTimeout(timeout);
134
+ }, [developerMode.active, developerMode.expiresAt, restoreAccountSession]);
108
135
  useEffect(() => {
109
136
  let cancelled = false;
110
137
  let bootstrap = authBootstrapPromises.get(storageKey);
111
138
  if (!bootstrap) {
112
- bootstrap = bootstrapGonvexAuth({ callbackPath, pkceStorageKey, projectId: props.projectId, runtimeUrl, storageKey })
139
+ bootstrap = bootstrapGonvexAuth({ callbackPath, client: props.client, pkceStorageKey, projectId: props.projectId, runtimeUrl, storageKey })
113
140
  .finally(() => {
114
141
  // Keep the resolved promise briefly so a StrictMode remount attaches
115
142
  // to the same result instead of re-running a spent OAuth code.
@@ -127,7 +154,7 @@ export function GonvexAuthProvider(props) {
127
154
  }).catch((cause) => {
128
155
  if (!cancelled) {
129
156
  installSession(null);
130
- setError(cause instanceof Error ? cause.message : "Google sign-in failed.");
157
+ setError(cause instanceof Error ? cause.message : "Sign-in failed.");
131
158
  }
132
159
  }).finally(() => {
133
160
  if (!cancelled)
@@ -146,12 +173,8 @@ export function GonvexAuthProvider(props) {
146
173
  if (!force && current.expiresAt > Date.now() + 60_000)
147
174
  return current;
148
175
  attemptedRefreshToken = current.refreshToken;
149
- const next = await requestGonvexAuthToken(runtimeUrl, {
150
- grantType: "refresh_token",
151
- project: props.projectId,
152
- refreshToken: current.refreshToken,
153
- tenant: current.activeTenantId,
154
- });
176
+ const grant = await props.client.action(control.auth.refreshSession, { refreshToken: current.refreshToken });
177
+ const next = sessionFromNativeGrant(grant, current);
155
178
  // Persist the rotated token before releasing the cross-tab lock. The
156
179
  // next waiter must never read and reuse the just-consumed refresh token.
157
180
  safeLocalStorageSet(storageKey, JSON.stringify(next));
@@ -192,7 +215,7 @@ export function GonvexAuthProvider(props) {
192
215
  });
193
216
  refreshRef.current = request;
194
217
  return request;
195
- }, [installSession, props.projectId, runtimeUrl, storageKey]);
218
+ }, [installSession, props.client, storageKey]);
196
219
  useEffect(() => {
197
220
  if (!session)
198
221
  return;
@@ -201,6 +224,25 @@ export function GonvexAuthProvider(props) {
201
224
  const timeout = window.setTimeout(() => { void refreshSession(); }, delay);
202
225
  return () => window.clearTimeout(timeout);
203
226
  }, [refreshRetryAt, refreshSession, session]);
227
+ // Keep the account tenant directory authoritative without a reducer+manual
228
+ // refetch pair. The live Control Plane Query resumes on reconnect.
229
+ useEffect(() => {
230
+ if (!sessionRef.current)
231
+ return;
232
+ const watch = props.client.watchControlQuery(control.tenants.mine, {});
233
+ return watch.onUpdate(() => {
234
+ const tenants = watch.getSnapshot().result;
235
+ const current = sessionRef.current;
236
+ if (!current || !tenants)
237
+ return;
238
+ const activeTenantId = tenants.some((tenant) => tenant.id === current.activeTenantId)
239
+ ? current.activeTenantId
240
+ : tenants[0]?.id;
241
+ if (JSON.stringify(current.tenants) === JSON.stringify(tenants) && current.activeTenantId === activeTenantId)
242
+ return;
243
+ installSession({ ...current, tenants, activeTenantId });
244
+ });
245
+ }, [installSession, props.client, session?.account.id]);
204
246
  useEffect(() => {
205
247
  const onStorage = (event) => {
206
248
  if (event.key !== storageKey)
@@ -210,7 +252,7 @@ export function GonvexAuthProvider(props) {
210
252
  window.addEventListener("storage", onStorage);
211
253
  return () => window.removeEventListener("storage", onStorage);
212
254
  }, [installSession, storageKey]);
213
- const signIn = useCallback(async () => {
255
+ const signInWithProvider = useCallback(async (provider) => {
214
256
  setError(null);
215
257
  const verifier = randomBase64Url(64);
216
258
  const state = randomBase64Url(32);
@@ -218,8 +260,8 @@ export function GonvexAuthProvider(props) {
218
260
  const challenge = bytesToBase64Url(new Uint8Array(challengeBytes));
219
261
  const redirectUri = new URL(callbackPath, window.location.origin).toString();
220
262
  const returnTo = `${window.location.pathname}${window.location.search}${window.location.hash}`;
221
- safeSessionStorageSet(pkceStorageKey, JSON.stringify({ state, verifier, redirectUri, returnTo, createdAt: Date.now() }));
222
- const authorizeUrl = new URL(`${runtimeUrl}/auth/google/authorize`);
263
+ safeSessionStorageSet(pkceStorageKey, JSON.stringify({ state, verifier, redirectUri, returnTo, provider, createdAt: Date.now() }));
264
+ const authorizeUrl = new URL(`${runtimeUrl}/auth/${provider}/authorize`);
223
265
  authorizeUrl.searchParams.set("project", props.projectId);
224
266
  authorizeUrl.searchParams.set("redirect_uri", redirectUri);
225
267
  authorizeUrl.searchParams.set("state", state);
@@ -227,18 +269,21 @@ export function GonvexAuthProvider(props) {
227
269
  authorizeUrl.searchParams.set("code_challenge_method", "S256");
228
270
  window.location.assign(authorizeUrl.toString());
229
271
  }, [callbackPath, pkceStorageKey, props.projectId, runtimeUrl]);
272
+ const signIn = useCallback((provider = "google") => signInWithProvider(provider), [signInWithProvider]);
273
+ const signInWithPassword = useCallback(async (email, password) => {
274
+ setError(null);
275
+ const grant = await props.client.action(control.auth.passwordLogin, { email, password });
276
+ const next = sessionFromNativeGrant(grant, sessionRef.current ?? undefined);
277
+ installSession(next);
278
+ }, [installSession, props.client]);
230
279
  const signOut = useCallback(async (options) => {
231
280
  const current = sessionRef.current;
232
- installSession(null);
233
281
  setError(null);
234
- if (!current)
235
- return;
236
- await fetch(`${runtimeUrl}/auth/logout`, {
237
- method: "POST",
238
- headers: { authorization: `Bearer ${current.accessToken}`, "content-type": "application/json" },
239
- body: JSON.stringify({ refreshToken: current.refreshToken, all: options?.allDevices === true }),
240
- }).catch(() => undefined);
241
- }, [installSession, runtimeUrl]);
282
+ if (current) {
283
+ await props.client.reducer(control.auth.logout, { refreshToken: current.refreshToken, all: options?.allDevices === true }).catch(() => undefined);
284
+ }
285
+ installSession(null);
286
+ }, [installSession, props.client]);
242
287
  const fetchAccessToken = useCallback(async (args) => {
243
288
  const current = sessionRef.current;
244
289
  if (!current)
@@ -248,6 +293,8 @@ export function GonvexAuthProvider(props) {
248
293
  return (await refreshSession(args.forceRefreshToken))?.accessToken ?? null;
249
294
  }, [refreshSession]);
250
295
  const setActiveTenant = useCallback(async (tenantId) => {
296
+ if (developerModeRef.current)
297
+ throw new Error("Exit developer mode before switching tenants.");
251
298
  const current = sessionRef.current;
252
299
  if (!current || !current.tenants.some((tenant) => tenant.id === tenantId)) {
253
300
  throw new Error(`Your account does not have access to tenant ${tenantId}.`);
@@ -259,82 +306,116 @@ export function GonvexAuthProvider(props) {
259
306
  if (!token)
260
307
  throw new Error("Sign in before loading tenant memberships.");
261
308
  const current = sessionRef.current;
262
- const response = await fetch(`${runtimeUrl}/auth/me`, {
263
- headers: { authorization: `Bearer ${token}`, ...(current.activeTenantId ? { "x-gonvex-tenant-id": current.activeTenantId } : {}) },
264
- });
265
- const payload = await response.json().catch(() => ({}));
266
- if (!response.ok || !payload.user || !payload.tenants)
267
- throw new Error(payload.error ?? "Could not load tenant memberships.");
268
- installSession({ ...current, user: payload.user, tenants: payload.tenants, activeTenantId: payload.activeTenantId });
269
- return payload.tenants;
270
- }, [fetchAccessToken, installSession, runtimeUrl]);
309
+ const [account, tenants] = await Promise.all([
310
+ props.client.query(control.accounts.me, {}),
311
+ props.client.query(control.tenants.mine, {}),
312
+ ]);
313
+ const mappedAccount = { id: account.id, email: account.email, emailVerified: true, name: account.name, picture: account.avatarUrl, provider: current.account.provider };
314
+ const mappedTenants = tenants;
315
+ const activeTenantId = mappedTenants.some((tenant) => tenant.id === current.activeTenantId) ? current.activeTenantId : mappedTenants[0]?.id;
316
+ installSession({ ...current, account: mappedAccount, tenants: mappedTenants, activeTenantId });
317
+ return mappedTenants;
318
+ }, [fetchAccessToken, installSession, props.client]);
271
319
  const createTenant = useCallback(async (name) => {
272
320
  const token = await fetchAccessToken({ forceRefreshToken: false });
273
321
  if (!token)
274
322
  throw new Error("Sign in before creating a tenant.");
275
- const response = await fetch(`${runtimeUrl}/auth/tenants`, {
276
- method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
277
- body: JSON.stringify({ name }),
278
- });
279
- const payload = await response.json().catch(() => ({}));
280
- if (!response.ok || !payload.tenant)
281
- throw new Error(payload.error ?? "Could not create the tenant.");
323
+ const tenant = await props.client.reducer(control.tenants.create, { name });
282
324
  const current = sessionRef.current;
283
- installSession({ ...current, tenants: [...current.tenants.filter((tenant) => tenant.id !== payload.tenant.id), payload.tenant], activeTenantId: payload.tenant.id });
284
- return payload.tenant;
285
- }, [fetchAccessToken, installSession, runtimeUrl]);
325
+ installSession({ ...current, tenants: [...current.tenants.filter((item) => item.id !== tenant.id), tenant], activeTenantId: tenant.id });
326
+ return tenant;
327
+ }, [fetchAccessToken, installSession, props.client]);
286
328
  const inviteMember = useCallback(async (tenantId, email, options) => {
287
329
  const token = await fetchAccessToken({ forceRefreshToken: false });
288
330
  if (!token)
289
331
  throw new Error("Sign in before inviting a member.");
290
- const response = await fetch(`${runtimeUrl}/auth/tenants/${encodeURIComponent(tenantId)}/members`, {
291
- method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
292
- body: JSON.stringify({ email, role: options?.role ?? "member", permissions: options?.permissions ?? {} }),
293
- });
294
- const payload = await response.json().catch(() => ({}));
295
- if (!response.ok)
296
- throw new Error(payload.error ?? "Could not invite the member.");
297
- }, [fetchAccessToken, runtimeUrl]);
298
- const removeMember = useCallback(async (tenantId, userId) => {
299
- const token = await fetchAccessToken({ forceRefreshToken: false });
300
- if (!token)
301
- throw new Error("Sign in before removing a member.");
302
- const response = await fetch(`${runtimeUrl}/auth/tenants/${encodeURIComponent(tenantId)}/members/${encodeURIComponent(userId)}`, {
303
- method: "DELETE", headers: { authorization: `Bearer ${token}` },
304
- });
305
- const payload = await response.json().catch(() => ({}));
306
- if (!response.ok)
307
- throw new Error(payload.error ?? "Could not remove the member.");
308
- }, [fetchAccessToken, runtimeUrl]);
332
+ if (tenantId !== sessionRef.current?.activeTenantId)
333
+ throw new Error("Switch to the tenant before inviting a member.");
334
+ return props.client.reducer(control.invitations.create, { email, role: options?.role ?? "member", permissions: (options?.permissions ?? {}), teamIds: options?.teamIds ?? [], allowedAuthProviders: options?.allowedAuthProviders ?? [], payload: options?.payload ?? {} });
335
+ }, [fetchAccessToken, props.client]);
336
+ const acceptInvitation = useCallback(async (token) => {
337
+ const accessToken = await fetchAccessToken({ forceRefreshToken: false });
338
+ if (!accessToken)
339
+ throw new Error("Sign in before accepting an invitation.");
340
+ return props.client.reducer(control.invitations.accept, { token });
341
+ }, [fetchAccessToken, props.client]);
309
342
  const revokeInvitation = useCallback(async (tenantId, email) => {
310
343
  const token = await fetchAccessToken({ forceRefreshToken: false });
311
344
  if (!token)
312
345
  throw new Error("Sign in before revoking an invitation.");
313
- const response = await fetch(`${runtimeUrl}/auth/tenants/${encodeURIComponent(tenantId)}/invitations/${encodeURIComponent(email)}`, {
314
- method: "DELETE", headers: { authorization: `Bearer ${token}` },
315
- });
316
- const payload = await response.json().catch(() => ({}));
317
- if (!response.ok)
318
- throw new Error(payload.error ?? "Could not revoke the invitation.");
319
- }, [fetchAccessToken, runtimeUrl]);
320
- const activeTenant = session?.tenants.find((tenant) => tenant.id === session.activeTenantId) ?? null;
346
+ if (tenantId !== sessionRef.current?.activeTenantId)
347
+ throw new Error("Switch to the tenant before revoking an invitation.");
348
+ await props.client.reducer(control.invitations.revoke, { id: "", email });
349
+ }, [fetchAccessToken, props.client]);
350
+ const enterDeveloperMode = useCallback(async (tenantId) => {
351
+ const current = sessionRef.current;
352
+ if (!current)
353
+ throw new Error("Sign in before entering developer mode.");
354
+ if (developerModeRef.current)
355
+ throw new Error("Exit developer mode before entering another tenant.");
356
+ setError(null);
357
+ const grant = await props.client.reducer(control.developer.enter, { tenantId });
358
+ const expiresAt = String(grant.expiresAt);
359
+ if (!grant.id || !grant.token || !Number.isFinite(Date.parse(expiresAt))) {
360
+ throw new Error("Gonvex returned an invalid developer grant.");
361
+ }
362
+ try {
363
+ await props.client.authenticate({
364
+ project: props.projectId,
365
+ tenant: tenantId,
366
+ token: grant.token,
367
+ fetchToken: undefined,
368
+ identity: { sub: current.account.id, iss: props.projectId },
369
+ });
370
+ }
371
+ catch (cause) {
372
+ props.client.setAuth({
373
+ project: props.projectId,
374
+ tenant: current.activeTenantId,
375
+ token: current.accessToken,
376
+ fetchToken: undefined,
377
+ identity: { sub: current.account.id, iss: props.projectId },
378
+ });
379
+ throw cause;
380
+ }
381
+ const next = { active: true, tenantId, grantId: grant.id, expiresAt, originalTenantId: current.activeTenantId };
382
+ developerModeRef.current = next;
383
+ setDeveloperMode({ active: true, tenantId, grantId: grant.id, expiresAt });
384
+ }, [props.client, props.projectId]);
385
+ const exitDeveloperMode = useCallback(async () => {
386
+ const developer = developerModeRef.current;
387
+ if (!developer?.grantId)
388
+ return;
389
+ setError(null);
390
+ // Remain in developer mode if revocation fails. Restoring first would leave
391
+ // an active grant detached from the provider's state.
392
+ await props.client.reducer(control.developer.exit, { grantId: developer.grantId });
393
+ restoreAccountSession();
394
+ }, [props.client, restoreAccountSession]);
395
+ const visibleTenantId = developerMode.active ? developerMode.tenantId : session?.activeTenantId;
396
+ const activeTenant = session?.tenants.find((tenant) => tenant.id === visibleTenantId) ?? null;
321
397
  const authValue = useMemo(() => ({
322
398
  isLoading,
323
399
  isAuthenticated: Boolean(session && session.refreshExpiresAt > Date.now()),
324
400
  fetchAccessToken,
325
- user: session?.user ?? null,
401
+ account: session?.account ?? null,
326
402
  tenants: session?.tenants ?? [],
327
403
  activeTenant,
328
404
  error,
329
405
  signIn,
406
+ signInWithProvider,
407
+ signInWithPassword,
330
408
  signOut,
331
409
  setActiveTenant,
332
410
  refreshMemberships,
333
411
  createTenant,
334
412
  inviteMember,
413
+ acceptInvitation,
335
414
  revokeInvitation,
336
- removeMember,
337
- }), [activeTenant, createTenant, error, fetchAccessToken, inviteMember, isLoading, refreshMemberships, removeMember, revokeInvitation, session, setActiveTenant, signIn, signOut]);
415
+ developerMode,
416
+ enterDeveloperMode,
417
+ exitDeveloperMode,
418
+ }), [acceptInvitation, activeTenant, createTenant, developerMode, enterDeveloperMode, error, exitDeveloperMode, fetchAccessToken, inviteMember, isLoading, refreshMemberships, revokeInvitation, session, setActiveTenant, signIn, signInWithPassword, signInWithProvider, signOut]);
338
419
  return (_jsx(ManagedAuthContext.Provider, { value: authValue, children: _jsx(GonvexAuthContext.Provider, { value: authValue, children: _jsx(GonvexProvider, { client: props.client, children: isLoading ? null : props.children }) }) }));
339
420
  }
340
421
  export function useGonvexAuth() {
@@ -343,6 +424,18 @@ export function useGonvexAuth() {
343
424
  throw new Error("GonvexAuthProvider is required");
344
425
  return value;
345
426
  }
427
+ /** Subscribed profile for the active tenant, reconciled by GonvexAuthProvider. */
428
+ export function useCurrentTenantProfile() {
429
+ return useGonvexAuth().activeTenant;
430
+ }
431
+ /** Live tenant-admin invitation list; reducer changes reconcile automatically. */
432
+ export function useInvitationList() {
433
+ return useControlQuery(control.invitations.list, {});
434
+ }
435
+ /** Read the auth state installed by either auth provider. */
436
+ export function useGonvexAuthState() {
437
+ return useContext(GonvexAuthContext);
438
+ }
346
439
  export function GonvexGoogleAuthButton(props) {
347
440
  const { signOutLabel = "Sign out", children, disabled, onClick, ...buttonProps } = props;
348
441
  const auth = useGonvexAuth();
@@ -386,10 +479,9 @@ async function bootstrapGonvexAuth(options) {
386
479
  return latest;
387
480
  if (latest.refreshExpiresAt <= Date.now())
388
481
  throw new GonvexAuthRequestError("Your session expired. Please sign in again.", 401);
389
- const next = await requestGonvexAuthToken(options.runtimeUrl, {
390
- grantType: "refresh_token", project: options.projectId,
391
- refreshToken: latest.refreshToken, tenant: latest.activeTenantId,
392
- });
482
+ options.client.setAuth({ project: options.projectId, token: latest.accessToken });
483
+ const grant = await options.client.action(control.auth.refreshSession, { refreshToken: latest.refreshToken });
484
+ const next = sessionFromNativeGrant(grant, latest);
393
485
  safeLocalStorageSet(options.storageKey, JSON.stringify(next));
394
486
  return next;
395
487
  });
@@ -401,26 +493,34 @@ async function bootstrapGonvexAuth(options) {
401
493
  }
402
494
  }
403
495
  const pkce = readPKCE(options.pkceStorageKey);
404
- // Prefer surfacing the runtime/Google error when PKCE is missing (e.g. after
405
- // a StrictMode remount or a second tab), rather than always saying "verified".
496
+ const provider = pkce?.provider ?? "google";
497
+ const providerLabel = provider[0].toUpperCase() + provider.slice(1);
498
+ // Surface the runtime error even when another tab already consumed PKCE.
406
499
  if (callbackError) {
407
500
  const messages = {
408
- access_denied: "Google sign-in was cancelled.",
409
- invitation_required: "This app is invite-only. Ask an administrator to invite your verified Google email.",
501
+ access_denied: `${providerLabel} sign-in was cancelled.`,
502
+ invitation_required: `This app is invite-only. Ask an administrator to invite your verified ${providerLabel} email.`,
410
503
  verified_google_email_required: "Google must provide a verified email address for this app.",
504
+ verified_microsoft_email_required: "Microsoft must provide a verified email address for this app.",
411
505
  membership_setup_failed: "Your account was verified, but its workspace could not be prepared. Please try again.",
412
506
  google_exchange_failed: "Google rejected the sign-in code exchange. Check GONVEX_GOOGLE_CLIENT_ID/SECRET and the broker callback URI.",
507
+ microsoft_exchange_failed: "Microsoft rejected the sign-in code exchange. Check the project's Microsoft realm configuration.",
508
+ apple_exchange_failed: "Apple rejected the sign-in code exchange. Check the project's Apple realm configuration.",
413
509
  invalid_google_identity: "Google identity verification failed. Please try again.",
414
- account_creation_failed: "Your Google account could not be linked. Please try again.",
510
+ invalid_microsoft_identity: "Microsoft identity verification failed. Please try again.",
511
+ invalid_apple_identity: "Apple identity verification failed. Please try again.",
512
+ microsoft_not_configured: "Microsoft sign-in is not configured for this project.",
513
+ apple_not_configured: "Apple sign-in is not configured for this project.",
514
+ account_creation_failed: `Your ${providerLabel} account could not be linked. Please try again.`,
415
515
  code_creation_failed: "Gonvex could not finish creating a sign-in code. Please try again.",
416
516
  };
417
517
  safeSessionStorageRemove(options.pkceStorageKey);
418
518
  clearAuthCallbackParams(url, pkce?.returnTo);
419
- throw new Error(messages[callbackError] ?? `Google sign-in failed (${callbackError}). Please try again.`);
519
+ throw new Error(messages[callbackError] ?? `${providerLabel} sign-in failed (${callbackError}). Please try again.`);
420
520
  }
421
521
  if (!pkce || !returnedState || returnedState !== pkce.state || Date.now() - pkce.createdAt > 10 * 60 * 1000) {
422
522
  clearAuthCallbackParams(url, pkce?.returnTo);
423
- throw new Error("The Google sign-in response could not be verified. Please try again.");
523
+ throw new Error(`The ${providerLabel} sign-in response could not be verified. Please try again.`);
424
524
  }
425
525
  // Consume PKCE only after validation so a concurrent remount still sees it.
426
526
  safeSessionStorageRemove(options.pkceStorageKey);
@@ -434,6 +534,16 @@ async function bootstrapGonvexAuth(options) {
434
534
  safeLocalStorageSet(options.storageKey, JSON.stringify(session));
435
535
  return session;
436
536
  }
537
+ function sessionFromNativeGrant(value, previous) {
538
+ if (!value || typeof value !== "object" || Array.isArray(value) || !isGonvexAuthSession(value)) {
539
+ throw new GonvexAuthRequestError("Gonvex returned an invalid native session.", 502);
540
+ }
541
+ const session = value;
542
+ const activeTenantId = previous && session.tenants.some((tenant) => tenant.id === previous.activeTenantId)
543
+ ? previous.activeTenantId
544
+ : session.activeTenantId;
545
+ return { ...session, activeTenantId };
546
+ }
437
547
  async function requestGonvexAuthToken(runtimeUrl, body) {
438
548
  const controller = new AbortController();
439
549
  const timeout = window.setTimeout(() => controller.abort(), 15_000);
@@ -461,7 +571,7 @@ function isFatalRefreshError(cause) {
461
571
  }
462
572
  function isGonvexAuthSession(value) {
463
573
  return Boolean(value.accessToken && value.expiresAt && value.refreshToken && value.refreshExpiresAt
464
- && value.user?.id && Array.isArray(value.tenants));
574
+ && value.account?.id && Array.isArray(value.tenants));
465
575
  }
466
576
  async function withBrowserAuthLock(name, action) {
467
577
  const locks = typeof navigator === "undefined"
@@ -534,7 +644,7 @@ function readAuthSession(key) {
534
644
  return null;
535
645
  try {
536
646
  const parsed = JSON.parse(localStorage.getItem(key) ?? "null");
537
- if (!parsed?.accessToken || !parsed.refreshToken || !parsed.user?.id || !Array.isArray(parsed.tenants) || parsed.refreshExpiresAt <= Date.now()) {
647
+ if (!parsed?.accessToken || !parsed.refreshToken || !parsed.account?.id || !Array.isArray(parsed.tenants) || parsed.refreshExpiresAt <= Date.now()) {
538
648
  safeLocalStorageRemove(key);
539
649
  return null;
540
650
  }
@@ -575,11 +685,7 @@ function bytesToBase64Url(bytes) {
575
685
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
576
686
  }
577
687
  const DEFAULT_LIVE_QUERY_SLOW_MS = 15_000;
578
- /**
579
- * Live query hook with an explicit status surface. Unlike `useQuery`, this
580
- * distinguishes loading / success / error / timeout / disconnected, keeps the
581
- * last good result while reconnecting, and exposes `retry()`.
582
- */
688
+ /** One-shot Query hook with explicit loading/error/timeout status and retry. */
583
689
  export function useQueryResult(ref, args = {}, options = {}) {
584
690
  const client = useGonvexClient();
585
691
  const path = ref.path;
@@ -588,6 +694,7 @@ export function useQueryResult(ref, args = {}, options = {}) {
588
694
  const argsKey = JSON.stringify(args);
589
695
  const keepPreviousData = options.keepPreviousData !== false;
590
696
  const timeoutMs = options.timeoutMs ?? DEFAULT_LIVE_QUERY_SLOW_MS;
697
+ const [requestGeneration, setRequestGeneration] = useState(0);
591
698
  const [state, setState] = useState({
592
699
  data: undefined,
593
700
  status: args === "skip" ? "skip" : "loading",
@@ -619,147 +726,183 @@ export function useQueryResult(ref, args = {}, options = {}) {
619
726
  setState({ data: undefined, status: "skip", error: null, isStale: false });
620
727
  return;
621
728
  }
622
- setState({ data: undefined, status: "loading", error: null, isStale: false });
729
+ setState((previous) => ({
730
+ data: keepPreviousData ? previous.data : undefined,
731
+ status: "loading",
732
+ error: null,
733
+ isStale: keepPreviousData && previous.data !== undefined,
734
+ }));
623
735
  startSlowTimer();
624
- const unsubscribeScope = client.onSessionScopeChange(() => {
625
- setState({ data: undefined, status: "loading", error: null, isStale: false });
626
- startSlowTimer();
627
- });
628
- const unsubscribeQuery = client.subscribeQuery(ref, args, (message) => {
629
- if (message.type === "query.result") {
630
- clearSlowTimer();
631
- setState({ data: message.result, status: "success", error: null, isStale: false });
632
- }
633
- if (message.type === "query.error") {
634
- clearSlowTimer();
635
- const error = new GonvexClientError(message.error, { code: "server", path, operation: "query" });
636
- setState((previous) => ({
637
- data: keepPreviousData ? previous.data : undefined,
638
- status: "error",
639
- error,
640
- isStale: keepPreviousData && previous.data !== undefined,
641
- }));
642
- }
643
- });
644
- const applyConnection = (connection) => {
645
- if (!connection.isWebSocketConnected) {
646
- clearSlowTimer();
647
- setState((previous) => {
648
- if (previous.status === "skip")
649
- return previous;
650
- return {
651
- data: keepPreviousData ? previous.data : undefined,
652
- status: "disconnected",
653
- error: previous.error,
654
- isStale: keepPreviousData && previous.data !== undefined,
655
- };
656
- });
736
+ let active = true;
737
+ void client.query(ref, args).then((data) => {
738
+ if (!active)
657
739
  return;
658
- }
659
- // The client resubscribes live queries itself on reconnect; report
660
- // loading until the fresh result lands.
661
- setState((previous) => {
662
- if (previous.status !== "disconnected")
663
- return previous;
664
- startSlowTimer();
665
- return { ...previous, status: "loading" };
666
- });
667
- };
668
- const unsubscribeConnection = typeof client.subscribeToConnectionState === "function"
669
- ? (() => {
670
- if (typeof client.connectionState === "function") {
671
- applyConnection(client.connectionState());
672
- }
673
- return client.subscribeToConnectionState(applyConnection);
674
- })()
675
- : undefined;
740
+ clearSlowTimer();
741
+ setState({ data, status: "success", error: null, isStale: false });
742
+ }, (failure) => {
743
+ if (!active)
744
+ return;
745
+ clearSlowTimer();
746
+ const error = failure instanceof Error
747
+ ? failure
748
+ : new GonvexClientError(String(failure), { code: "server", path, operation: "query" });
749
+ setState((previous) => ({
750
+ data: keepPreviousData ? previous.data : undefined,
751
+ status: "error",
752
+ error,
753
+ isStale: keepPreviousData && previous.data !== undefined,
754
+ }));
755
+ });
676
756
  return () => {
757
+ active = false;
677
758
  clearSlowTimer();
678
- unsubscribeScope();
679
- unsubscribeQuery();
680
- unsubscribeConnection?.();
681
759
  };
682
760
  // eslint-disable-next-line react-hooks/exhaustive-deps
683
- }, [client, kind, path, optimisticKey, argsKey, keepPreviousData, startSlowTimer, clearSlowTimer]);
761
+ }, [client, kind, path, optimisticKey, argsKey, keepPreviousData, requestGeneration, startSlowTimer, clearSlowTimer]);
684
762
  const retry = useCallback(() => {
685
763
  if (args === "skip")
686
764
  return;
687
- setState((previous) => ({
688
- data: previous.data,
689
- status: "loading",
690
- error: null,
691
- isStale: previous.data !== undefined,
692
- }));
693
- startSlowTimer();
694
- if (typeof client.retryQuery === "function") {
695
- client.retryQuery(ref, JSON.parse(argsKey));
696
- }
765
+ setRequestGeneration((generation) => generation + 1);
697
766
  // eslint-disable-next-line react-hooks/exhaustive-deps
698
- }, [client, kind, path, optimisticKey, argsKey, startSlowTimer]);
767
+ }, [argsKey]);
699
768
  return {
700
769
  data: state.data,
701
770
  status: state.status,
702
771
  error: state.error,
703
772
  isLoading: state.status === "loading",
704
- isError: state.status === "error" || state.status === "timeout" || state.status === "disconnected",
773
+ isError: state.status === "error" || state.status === "timeout",
705
774
  isSuccess: state.status === "success",
706
775
  isStale: state.isStale,
707
776
  retry,
708
777
  };
709
778
  }
710
- export function useQuery(ref, args = {}) {
779
+ export function useLiveQuery(ref, args = {}) {
711
780
  const client = useGonvexClient();
712
- const [result, setResult] = useState();
713
- const [error, setError] = useState(null);
781
+ if (ref.delivery !== "live" || !ref.live?.plan) {
782
+ throw new Error(`useLiveQuery requires a structured Live Query reference: ${ref.path}`);
783
+ }
714
784
  const path = ref.path;
715
785
  const kind = ref.kind;
716
786
  const optimisticKey = JSON.stringify(ref.optimistic ?? null);
717
787
  const argsKey = JSON.stringify(args);
788
+ const liveKey = JSON.stringify(ref.live ?? null);
789
+ const liveWatch = useMemo(() => args === "skip" ? undefined : client.watchLiveQuery(ref, args),
790
+ // eslint-disable-next-line react-hooks/exhaustive-deps
791
+ [client, kind, path, optimisticKey, argsKey, liveKey]);
792
+ const liveResult = useSyncExternalStore(useCallback((notify) => liveWatch?.onUpdate(notify) ?? (() => undefined), [liveWatch]), useCallback(() => liveWatch?.localLiveQueryResult(), [liveWatch]), () => undefined);
793
+ return liveResult;
794
+ }
795
+ /** Subscribe to a host-owned Control Plane Query on the existing Gonvex connection. */
796
+ export function useControlQuery(ref, args = {}) {
797
+ const client = useGonvexClient();
798
+ const argsKey = JSON.stringify(args);
799
+ const watch = useMemo(() => args === "skip" ? undefined : client.watchControlQuery(ref, args),
800
+ // eslint-disable-next-line react-hooks/exhaustive-deps
801
+ [client, ref.kind, ref.path, argsKey]);
802
+ const snapshot = useSyncExternalStore(useCallback((notify) => watch?.onUpdate(notify) ?? (() => undefined), [watch]), useCallback(() => watch?.getSnapshot(), [watch]), () => undefined);
803
+ return snapshot?.result;
804
+ }
805
+ /** Read one normalized entity from the single Gonvex Local Replica. */
806
+ export function useEntity(entity, id) {
807
+ const client = useGonvexClient();
808
+ useSyncExternalStore(useCallback((notify) => client.localReplica.subscribe(notify), [client]), useCallback(() => client.localReplica.version(), [client]), () => 0);
809
+ return client.localReplica.entity(entity, id);
810
+ }
811
+ /** Resolve an ordered entity batch with one Local Replica subscription. */
812
+ export function useReplicaEntities(entity, ids) {
813
+ const client = useGonvexClient();
814
+ const idsKey = JSON.stringify(ids);
815
+ const version = useSyncExternalStore(useCallback((notify) => client.localReplica.subscribe(notify), [client]), useCallback(() => client.localReplica.version(), [client]), () => 0);
816
+ return useMemo(() => client.replicaEntities(entity, ids), [client, entity, idsKey, version]);
817
+ }
818
+ /** Read a persisted Live Query window without opening another server subscription. */
819
+ export function useRetainedLiveQuery(signatureOrReference, args = {}) {
820
+ const client = useGonvexClient();
821
+ const argsKey = JSON.stringify(args);
822
+ const signature = typeof signatureOrReference === "string"
823
+ ? signatureOrReference
824
+ : client.replicaSignature(signatureOrReference, args);
825
+ const version = useSyncExternalStore(useCallback((notify) => client.localReplica.subscribe(notify), [client]), useCallback(() => client.localReplica.version(), [client]), () => 0);
826
+ return useMemo(() => client.retainedLiveQuery(signature), [client, signature, argsKey, version]);
827
+ }
828
+ /** Structured Live Query state backed by normalized Local Replica entities. */
829
+ export function useLiveQueryState(ref, args = {}) {
830
+ const client = useGonvexClient();
831
+ const argsKey = JSON.stringify(args);
832
+ const signature = args === "skip" ? "" : client.replicaSignature(ref, args);
718
833
  useEffect(() => {
834
+ if (args === "skip")
835
+ return;
836
+ return client.subscribeLiveQuery(ref, args, () => undefined);
837
+ // eslint-disable-next-line react-hooks/exhaustive-deps
838
+ }, [client, ref.kind, ref.path, argsKey]);
839
+ useSyncExternalStore(useCallback((notify) => client.localReplica.subscribe(notify), [client]), useCallback(() => client.localReplica.version(), [client]), () => 0);
840
+ if (args !== "skip" && client.localReplica.freshness() === "offline") {
841
+ const offline = client.offlineLiveQuery(ref, args);
842
+ return {
843
+ rows: offline.rows,
844
+ ids: offline.rows.map((row) => String(row.id ?? row._id ?? "")).filter(Boolean),
845
+ ...(offline.total === undefined ? {} : { total: offline.total }),
846
+ ...(offline.offset === undefined ? {} : { offset: offline.offset }),
847
+ ...(offline.limit === undefined ? {} : { limit: offline.limit }),
848
+ source: "cache",
849
+ completeness: offline.completeness,
850
+ freshness: "offline",
851
+ supported: offline.supported,
852
+ ...(offline.unsupportedOperator ? { unsupportedOperator: offline.unsupportedOperator } : {}),
853
+ };
854
+ }
855
+ return signature
856
+ ? client.localReplica.liveQuery(signature)
857
+ : { rows: [], ids: [], source: "cache", completeness: "partial", freshness: client.localReplica.freshness() };
858
+ }
859
+ /** Execute a read-only Query once. Queries never subscribe or rerun. */
860
+ export function useQuery(ref, args = {}) {
861
+ const client = useGonvexClient();
862
+ const [result, setResult] = useState();
863
+ const [error, setError] = useState(null);
864
+ const argsKey = JSON.stringify(args);
865
+ useEffect(() => {
866
+ let active = true;
719
867
  if (args === "skip") {
720
868
  setResult(undefined);
721
869
  setError(null);
722
- return;
870
+ return () => { active = false; };
723
871
  }
724
872
  setResult(undefined);
725
873
  setError(null);
726
- const unsubscribeScope = client.onSessionScopeChange(() => {
727
- setResult(undefined);
728
- setError(null);
729
- });
730
- const unsubscribeQuery = client.subscribeQuery(ref, args, (message) => {
731
- if (message.type === "query.result") {
732
- setResult(message.result);
733
- setError(null);
734
- }
735
- if (message.type === "query.error") {
736
- setResult(undefined);
737
- setError(new GonvexClientError(message.error, { code: "server", path, operation: "query" }));
738
- }
739
- });
740
- return () => {
741
- unsubscribeScope();
742
- unsubscribeQuery();
743
- };
744
- }, [client, kind, path, optimisticKey, argsKey]);
745
- // Convex-compatible: a failed query throws during render so error
746
- // boundaries can catch it, instead of being indistinguishable from loading.
874
+ void client.query(ref, args).then((value) => { if (active)
875
+ setResult(value); }, (failure) => { if (active)
876
+ setError(failure instanceof Error ? failure : new Error(String(failure))); });
877
+ return () => { active = false; };
878
+ // eslint-disable-next-line react-hooks/exhaustive-deps
879
+ }, [client, ref.kind, ref.path, argsKey]);
747
880
  if (error)
748
881
  throw error;
749
882
  return result;
750
883
  }
751
- export function useSync(ref, args = {}) {
884
+ export function useReplicaCollection(ref, args = {}) {
752
885
  const client = useGonvexClient();
753
886
  const path = ref.path;
754
887
  const kind = ref.kind;
755
888
  const optimisticKey = JSON.stringify(ref.optimistic ?? null);
756
889
  const argsKey = JSON.stringify(args);
757
- const watch = useMemo(() => args === "skip" ? undefined : client.watchSync(ref, args),
890
+ const watch = useMemo(() => args === "skip" ? undefined : client.watchReplica(ref, args),
758
891
  // eslint-disable-next-line react-hooks/exhaustive-deps
759
892
  [client, kind, path, optimisticKey, argsKey]);
760
- return useSyncExternalStore(useCallback((onStoreChange) => watch?.onUpdate(onStoreChange) ?? (() => undefined), [watch]), useCallback(() => watch?.localSyncResult(), [watch]), () => undefined);
893
+ return useSyncExternalStore(useCallback((onStoreChange) => watch?.onUpdate(onStoreChange) ?? (() => undefined), [watch]), useCallback(() => watch?.localReplicaResult(), [watch]), () => undefined);
761
894
  }
762
- export function useSyncSelector(ref, args, selector, isEqual = Object.is) {
895
+ /** Replica rows plus authoritative completeness, truncation, and freshness metadata. */
896
+ export function useReplicaCollectionState(ref, args = {}) {
897
+ const client = useGonvexClient();
898
+ const path = ref.path;
899
+ const argsKey = JSON.stringify(args);
900
+ const watch = useMemo(() => args === "skip" ? undefined : client.watchReplica(ref, args),
901
+ // eslint-disable-next-line react-hooks/exhaustive-deps
902
+ [client, ref.kind, path, argsKey]);
903
+ return useSyncExternalStore(useCallback((onStoreChange) => watch?.onUpdate(onStoreChange) ?? (() => undefined), [watch]), useCallback(() => watch?.localReplicaState(), [watch]), () => undefined);
904
+ }
905
+ export function useReplicaSelector(ref, args, selector, isEqual = Object.is) {
763
906
  const client = useGonvexClient();
764
907
  const path = ref.path;
765
908
  const kind = ref.kind;
@@ -769,7 +912,7 @@ export function useSyncSelector(ref, args, selector, isEqual = Object.is) {
769
912
  const equalityRef = useRef(isEqual);
770
913
  selectorRef.current = selector;
771
914
  equalityRef.current = isEqual;
772
- const watch = useMemo(() => args === "skip" ? undefined : client.watchSync(ref, args),
915
+ const watch = useMemo(() => args === "skip" ? undefined : client.watchReplica(ref, args),
773
916
  // eslint-disable-next-line react-hooks/exhaustive-deps
774
917
  [client, kind, path, optimisticKey, argsKey]);
775
918
  const selectedRef = useRef({
@@ -780,7 +923,7 @@ export function useSyncSelector(ref, args, selector, isEqual = Object.is) {
780
923
  selectedRef.current = { initialized: false, value: undefined };
781
924
  }, [watch]);
782
925
  const getSnapshot = useCallback(() => {
783
- const rows = watch?.localSyncResult();
926
+ const rows = watch?.localReplicaResult();
784
927
  const next = rows === undefined ? undefined : selectorRef.current(rows);
785
928
  if (!selectedRef.current.initialized
786
929
  || next === undefined
@@ -795,7 +938,7 @@ export function useSyncSelector(ref, args, selector, isEqual = Object.is) {
795
938
  return () => undefined;
796
939
  return watch.onUpdate(() => {
797
940
  const previous = selectedRef.current.value;
798
- const rows = watch.localSyncResult();
941
+ const rows = watch.localReplicaResult();
799
942
  const next = rows === undefined ? undefined : selectorRef.current(rows);
800
943
  if (selectedRef.current.initialized
801
944
  && previous !== undefined
@@ -808,31 +951,25 @@ export function useSyncSelector(ref, args, selector, isEqual = Object.is) {
808
951
  }, [watch]);
809
952
  return useSyncExternalStore(subscribe, getSnapshot, () => undefined);
810
953
  }
811
- export function useMutation(ref, options = {}) {
954
+ export function useReducer(ref, options = {}) {
812
955
  const client = useGonvexClient();
813
- return (args = {}) => client.mutation(ref, args, options);
956
+ return (args = {}) => client.reducer(ref, args, options);
814
957
  }
815
958
  export function useAction(ref, options = {}) {
816
959
  const client = useGonvexClient();
817
960
  return (args = {}) => client.action(ref, args, options);
818
961
  }
819
- export function useConvex() {
820
- return useGonvexClient();
821
- }
822
- export function useConvexAuth() {
823
- return useContext(GonvexAuthContext);
824
- }
825
962
  const FALLBACK_CONNECTION_STATE = {
826
963
  isWebSocketConnected: false,
827
964
  hasEverConnected: false,
828
965
  connectionCount: 0,
829
966
  connectionRetries: 0,
830
967
  hasInflightRequests: false,
831
- inflightMutations: 0,
968
+ inflightReducers: 0,
832
969
  inflightActions: 0,
833
970
  inflightOneShotQueries: 0,
834
971
  };
835
- export function useConvexConnectionState() {
972
+ export function useGonvexConnectionState() {
836
973
  const client = useGonvexClient();
837
974
  const [state, setState] = useState(() => (typeof client.connectionState === "function" ? client.connectionState() : FALLBACK_CONNECTION_STATE));
838
975
  useEffect(() => {
@@ -843,18 +980,7 @@ export function useConvexConnectionState() {
843
980
  }, [client]);
844
981
  return state;
845
982
  }
846
- export function usePaginatedQuery(ref, args = {}, options = {}) {
847
- const pageArgs = args === "skip" ? "skip" : { ...(isRecord(args) ? args : { args }), paginationOpts: { numItems: options.initialNumItems ?? 25, cursor: null } };
848
- const result = useQuery(ref, pageArgs);
849
- const page = Array.isArray(result) ? { page: result, isDone: true, continueCursor: null } : result;
850
- return {
851
- results: (page?.page ?? []),
852
- status: args === "skip" ? "Exhausted" : result === undefined ? "LoadingFirstPage" : page?.isDone ? "Exhausted" : "CanLoadMore",
853
- isLoading: args !== "skip" && result === undefined,
854
- loadMore: (_numItems) => undefined,
855
- };
856
- }
857
- function useGonvexClient() {
983
+ export function useGonvexClient() {
858
984
  const client = useContext(GonvexContext);
859
985
  if (!client)
860
986
  throw new Error("GonvexProvider is required");