@gonvex/react 0.1.31 → 0.3.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/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,13 +16,22 @@ 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);
22
+ const [clientAuthError, setClientAuthError] = useState(null);
23
+ const authError = auth.authError ?? clientAuthError;
24
+ useEffect(() => props.client.onAuthError((message) => {
25
+ setClientAuthError(new Error(message || "Authentication failed"));
26
+ setTokenReady(false);
27
+ }), [props.client]);
24
28
  useEffect(() => {
25
29
  setTokenReady(false);
26
- if (auth.isLoading || !auth.isAuthenticated || !auth.fetchAccessToken)
30
+ if (auth.isLoading || !auth.isAuthenticated || !auth.fetchAccessToken) {
31
+ if (!auth.isLoading && !auth.isAuthenticated)
32
+ setClientAuthError(null);
27
33
  return;
34
+ }
28
35
  const fetchAccessToken = auth.fetchAccessToken;
29
36
  let cancelled = false;
30
37
  void fetchAccessToken({ forceRefreshToken: false }).then((token) => {
@@ -33,6 +40,7 @@ export function ConvexProviderWithAuth(props) {
33
40
  // on reconnect and force-refreshes on auth.error itself, instead of
34
41
  // replaying this token verbatim after it expires.
35
42
  props.client.setAuth({ token: token ?? undefined, fetchToken: fetchAccessToken });
43
+ setClientAuthError(null);
36
44
  setTokenReady(Boolean(token));
37
45
  }
38
46
  }, (error) => {
@@ -53,18 +61,14 @@ export function ConvexProviderWithAuth(props) {
53
61
  }, [auth.isLoading, auth.isAuthenticated, auth.fetchAccessToken, props.client]);
54
62
  const authValue = useMemo(() => ({
55
63
  ...auth,
56
- isLoading: auth.isLoading || (auth.isAuthenticated && !tokenReady),
57
- isAuthenticated: auth.isAuthenticated && tokenReady,
58
- }), [auth, tokenReady]);
59
- const shouldHoldChildren = auth.isLoading || (auth.isAuthenticated && !tokenReady);
64
+ authError,
65
+ isLoading: !authError && (auth.isLoading || (auth.isAuthenticated && !tokenReady)),
66
+ isAuthenticated: !authError && auth.isAuthenticated && tokenReady,
67
+ }), [auth, authError, tokenReady]);
68
+ const shouldHoldChildren = !authError && (auth.isLoading || (auth.isAuthenticated && !tokenReady));
60
69
  return (_jsx(GonvexAuthContext.Provider, { value: authValue, children: _jsx(GonvexProvider, { client: props.client, children: shouldHoldChildren ? null : props.children }) }));
61
70
  }
62
- /**
63
- * Native Gonvex authentication. The runtime performs the one centrally
64
- * configured Google OAuth flow, while each app uses PKCE and receives a
65
- * project-scoped Gonvex session. No Firebase or Google SDK is loaded in the
66
- * browser.
67
- */
71
+ /** Native Gonvex authentication with password or a configured OAuth provider. */
68
72
  // Dedupe callback bootstrap across React StrictMode remounts so the OAuth
69
73
  // code+PKCE exchange runs once. Without this, the first effect's finally
70
74
  // clears sessionStorage PKCE before the remount can finish verification.
@@ -85,12 +89,15 @@ export function GonvexAuthProvider(props) {
85
89
  if (next) {
86
90
  if (persist)
87
91
  safeLocalStorageSet(storageKey, JSON.stringify(next));
88
- props.client.setAuth({ project: props.projectId, tenant: next.activeTenantId, token: next.accessToken });
92
+ props.client.setAuth({
93
+ project: props.projectId, tenant: next.activeTenantId, token: next.accessToken,
94
+ identity: { sub: next.account.id, iss: props.projectId },
95
+ });
89
96
  }
90
97
  else {
91
98
  if (persist)
92
99
  safeLocalStorageRemove(storageKey);
93
- props.client.setAuth({ project: props.projectId, tenant: undefined, token: undefined });
100
+ props.client.setAuth({ project: props.projectId, tenant: undefined, token: undefined, identity: undefined });
94
101
  }
95
102
  setSession(next);
96
103
  }, [props.client, props.projectId, storageKey]);
@@ -98,7 +105,7 @@ export function GonvexAuthProvider(props) {
98
105
  let cancelled = false;
99
106
  let bootstrap = authBootstrapPromises.get(storageKey);
100
107
  if (!bootstrap) {
101
- bootstrap = bootstrapGonvexAuth({ callbackPath, pkceStorageKey, projectId: props.projectId, runtimeUrl, storageKey })
108
+ bootstrap = bootstrapGonvexAuth({ callbackPath, client: props.client, pkceStorageKey, projectId: props.projectId, runtimeUrl, storageKey })
102
109
  .finally(() => {
103
110
  // Keep the resolved promise briefly so a StrictMode remount attaches
104
111
  // to the same result instead of re-running a spent OAuth code.
@@ -116,7 +123,7 @@ export function GonvexAuthProvider(props) {
116
123
  }).catch((cause) => {
117
124
  if (!cancelled) {
118
125
  installSession(null);
119
- setError(cause instanceof Error ? cause.message : "Google sign-in failed.");
126
+ setError(cause instanceof Error ? cause.message : "Sign-in failed.");
120
127
  }
121
128
  }).finally(() => {
122
129
  if (!cancelled)
@@ -135,12 +142,8 @@ export function GonvexAuthProvider(props) {
135
142
  if (!force && current.expiresAt > Date.now() + 60_000)
136
143
  return current;
137
144
  attemptedRefreshToken = current.refreshToken;
138
- const next = await requestGonvexAuthToken(runtimeUrl, {
139
- grantType: "refresh_token",
140
- project: props.projectId,
141
- refreshToken: current.refreshToken,
142
- tenant: current.activeTenantId,
143
- });
145
+ const grant = await props.client.action(control.auth.refreshSession, { refreshToken: current.refreshToken });
146
+ const next = sessionFromNativeGrant(grant, current);
144
147
  // Persist the rotated token before releasing the cross-tab lock. The
145
148
  // next waiter must never read and reuse the just-consumed refresh token.
146
149
  safeLocalStorageSet(storageKey, JSON.stringify(next));
@@ -181,7 +184,7 @@ export function GonvexAuthProvider(props) {
181
184
  });
182
185
  refreshRef.current = request;
183
186
  return request;
184
- }, [installSession, props.projectId, runtimeUrl, storageKey]);
187
+ }, [installSession, props.client, storageKey]);
185
188
  useEffect(() => {
186
189
  if (!session)
187
190
  return;
@@ -190,6 +193,25 @@ export function GonvexAuthProvider(props) {
190
193
  const timeout = window.setTimeout(() => { void refreshSession(); }, delay);
191
194
  return () => window.clearTimeout(timeout);
192
195
  }, [refreshRetryAt, refreshSession, session]);
196
+ // Keep the account tenant directory authoritative without a reducer+manual
197
+ // refetch pair. The live Control Plane Query resumes on reconnect.
198
+ useEffect(() => {
199
+ if (!sessionRef.current)
200
+ return;
201
+ const watch = props.client.watchControlQuery(control.tenants.mine, {});
202
+ return watch.onUpdate(() => {
203
+ const tenants = watch.getSnapshot().result;
204
+ const current = sessionRef.current;
205
+ if (!current || !tenants)
206
+ return;
207
+ const activeTenantId = tenants.some((tenant) => tenant.id === current.activeTenantId)
208
+ ? current.activeTenantId
209
+ : tenants[0]?.id;
210
+ if (JSON.stringify(current.tenants) === JSON.stringify(tenants) && current.activeTenantId === activeTenantId)
211
+ return;
212
+ installSession({ ...current, tenants, activeTenantId });
213
+ });
214
+ }, [installSession, props.client, session?.account.id]);
193
215
  useEffect(() => {
194
216
  const onStorage = (event) => {
195
217
  if (event.key !== storageKey)
@@ -199,7 +221,7 @@ export function GonvexAuthProvider(props) {
199
221
  window.addEventListener("storage", onStorage);
200
222
  return () => window.removeEventListener("storage", onStorage);
201
223
  }, [installSession, storageKey]);
202
- const signIn = useCallback(async () => {
224
+ const signInWithProvider = useCallback(async (provider) => {
203
225
  setError(null);
204
226
  const verifier = randomBase64Url(64);
205
227
  const state = randomBase64Url(32);
@@ -207,8 +229,8 @@ export function GonvexAuthProvider(props) {
207
229
  const challenge = bytesToBase64Url(new Uint8Array(challengeBytes));
208
230
  const redirectUri = new URL(callbackPath, window.location.origin).toString();
209
231
  const returnTo = `${window.location.pathname}${window.location.search}${window.location.hash}`;
210
- safeSessionStorageSet(pkceStorageKey, JSON.stringify({ state, verifier, redirectUri, returnTo, createdAt: Date.now() }));
211
- const authorizeUrl = new URL(`${runtimeUrl}/auth/google/authorize`);
232
+ safeSessionStorageSet(pkceStorageKey, JSON.stringify({ state, verifier, redirectUri, returnTo, provider, createdAt: Date.now() }));
233
+ const authorizeUrl = new URL(`${runtimeUrl}/auth/${provider}/authorize`);
212
234
  authorizeUrl.searchParams.set("project", props.projectId);
213
235
  authorizeUrl.searchParams.set("redirect_uri", redirectUri);
214
236
  authorizeUrl.searchParams.set("state", state);
@@ -216,18 +238,21 @@ export function GonvexAuthProvider(props) {
216
238
  authorizeUrl.searchParams.set("code_challenge_method", "S256");
217
239
  window.location.assign(authorizeUrl.toString());
218
240
  }, [callbackPath, pkceStorageKey, props.projectId, runtimeUrl]);
241
+ const signIn = useCallback((provider = "google") => signInWithProvider(provider), [signInWithProvider]);
242
+ const signInWithPassword = useCallback(async (email, password) => {
243
+ setError(null);
244
+ const grant = await props.client.action(control.auth.passwordLogin, { email, password });
245
+ const next = sessionFromNativeGrant(grant, sessionRef.current ?? undefined);
246
+ installSession(next);
247
+ }, [installSession, props.client]);
219
248
  const signOut = useCallback(async (options) => {
220
249
  const current = sessionRef.current;
221
- installSession(null);
222
250
  setError(null);
223
- if (!current)
224
- return;
225
- await fetch(`${runtimeUrl}/auth/logout`, {
226
- method: "POST",
227
- headers: { authorization: `Bearer ${current.accessToken}`, "content-type": "application/json" },
228
- body: JSON.stringify({ refreshToken: current.refreshToken, all: options?.allDevices === true }),
229
- }).catch(() => undefined);
230
- }, [installSession, runtimeUrl]);
251
+ if (current) {
252
+ await props.client.reducer(control.auth.logout, { refreshToken: current.refreshToken, all: options?.allDevices === true }).catch(() => undefined);
253
+ }
254
+ installSession(null);
255
+ }, [installSession, props.client]);
231
256
  const fetchAccessToken = useCallback(async (args) => {
232
257
  const current = sessionRef.current;
233
258
  if (!current)
@@ -248,82 +273,67 @@ export function GonvexAuthProvider(props) {
248
273
  if (!token)
249
274
  throw new Error("Sign in before loading tenant memberships.");
250
275
  const current = sessionRef.current;
251
- const response = await fetch(`${runtimeUrl}/auth/me`, {
252
- headers: { authorization: `Bearer ${token}`, ...(current.activeTenantId ? { "x-gonvex-tenant-id": current.activeTenantId } : {}) },
253
- });
254
- const payload = await response.json().catch(() => ({}));
255
- if (!response.ok || !payload.user || !payload.tenants)
256
- throw new Error(payload.error ?? "Could not load tenant memberships.");
257
- installSession({ ...current, user: payload.user, tenants: payload.tenants, activeTenantId: payload.activeTenantId });
258
- return payload.tenants;
259
- }, [fetchAccessToken, installSession, runtimeUrl]);
276
+ const [account, tenants] = await Promise.all([
277
+ props.client.query(control.accounts.me, {}),
278
+ props.client.query(control.tenants.mine, {}),
279
+ ]);
280
+ const mappedAccount = { id: account.id, email: account.email, emailVerified: true, name: account.name, picture: account.avatarUrl, provider: current.account.provider };
281
+ const mappedTenants = tenants;
282
+ const activeTenantId = mappedTenants.some((tenant) => tenant.id === current.activeTenantId) ? current.activeTenantId : mappedTenants[0]?.id;
283
+ installSession({ ...current, account: mappedAccount, tenants: mappedTenants, activeTenantId });
284
+ return mappedTenants;
285
+ }, [fetchAccessToken, installSession, props.client]);
260
286
  const createTenant = useCallback(async (name) => {
261
287
  const token = await fetchAccessToken({ forceRefreshToken: false });
262
288
  if (!token)
263
289
  throw new Error("Sign in before creating a tenant.");
264
- const response = await fetch(`${runtimeUrl}/auth/tenants`, {
265
- method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
266
- body: JSON.stringify({ name }),
267
- });
268
- const payload = await response.json().catch(() => ({}));
269
- if (!response.ok || !payload.tenant)
270
- throw new Error(payload.error ?? "Could not create the tenant.");
290
+ const tenant = await props.client.reducer(control.tenants.create, { name });
271
291
  const current = sessionRef.current;
272
- installSession({ ...current, tenants: [...current.tenants.filter((tenant) => tenant.id !== payload.tenant.id), payload.tenant], activeTenantId: payload.tenant.id });
273
- return payload.tenant;
274
- }, [fetchAccessToken, installSession, runtimeUrl]);
292
+ installSession({ ...current, tenants: [...current.tenants.filter((item) => item.id !== tenant.id), tenant], activeTenantId: tenant.id });
293
+ return tenant;
294
+ }, [fetchAccessToken, installSession, props.client]);
275
295
  const inviteMember = useCallback(async (tenantId, email, options) => {
276
296
  const token = await fetchAccessToken({ forceRefreshToken: false });
277
297
  if (!token)
278
298
  throw new Error("Sign in before inviting a member.");
279
- const response = await fetch(`${runtimeUrl}/auth/tenants/${encodeURIComponent(tenantId)}/members`, {
280
- method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
281
- body: JSON.stringify({ email, role: options?.role ?? "member", permissions: options?.permissions ?? {} }),
282
- });
283
- const payload = await response.json().catch(() => ({}));
284
- if (!response.ok)
285
- throw new Error(payload.error ?? "Could not invite the member.");
286
- }, [fetchAccessToken, runtimeUrl]);
287
- const removeMember = useCallback(async (tenantId, userId) => {
288
- const token = await fetchAccessToken({ forceRefreshToken: false });
289
- if (!token)
290
- throw new Error("Sign in before removing a member.");
291
- const response = await fetch(`${runtimeUrl}/auth/tenants/${encodeURIComponent(tenantId)}/members/${encodeURIComponent(userId)}`, {
292
- method: "DELETE", headers: { authorization: `Bearer ${token}` },
293
- });
294
- const payload = await response.json().catch(() => ({}));
295
- if (!response.ok)
296
- throw new Error(payload.error ?? "Could not remove the member.");
297
- }, [fetchAccessToken, runtimeUrl]);
299
+ if (tenantId !== sessionRef.current?.activeTenantId)
300
+ throw new Error("Switch to the tenant before inviting a member.");
301
+ return props.client.reducer(control.invitations.create, { email, role: options?.role ?? "member", permissions: (options?.permissions ?? {}), teamIds: options?.teamIds ?? [], allowedAuthProviders: options?.allowedAuthProviders ?? [], payload: options?.payload ?? {} });
302
+ }, [fetchAccessToken, props.client]);
303
+ const acceptInvitation = useCallback(async (token) => {
304
+ const accessToken = await fetchAccessToken({ forceRefreshToken: false });
305
+ if (!accessToken)
306
+ throw new Error("Sign in before accepting an invitation.");
307
+ return props.client.reducer(control.invitations.accept, { token });
308
+ }, [fetchAccessToken, props.client]);
298
309
  const revokeInvitation = useCallback(async (tenantId, email) => {
299
310
  const token = await fetchAccessToken({ forceRefreshToken: false });
300
311
  if (!token)
301
312
  throw new Error("Sign in before revoking an invitation.");
302
- const response = await fetch(`${runtimeUrl}/auth/tenants/${encodeURIComponent(tenantId)}/invitations/${encodeURIComponent(email)}`, {
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 revoke the invitation.");
308
- }, [fetchAccessToken, runtimeUrl]);
313
+ if (tenantId !== sessionRef.current?.activeTenantId)
314
+ throw new Error("Switch to the tenant before revoking an invitation.");
315
+ await props.client.reducer(control.invitations.revoke, { id: "", email });
316
+ }, [fetchAccessToken, props.client]);
309
317
  const activeTenant = session?.tenants.find((tenant) => tenant.id === session.activeTenantId) ?? null;
310
318
  const authValue = useMemo(() => ({
311
319
  isLoading,
312
320
  isAuthenticated: Boolean(session && session.refreshExpiresAt > Date.now()),
313
321
  fetchAccessToken,
314
- user: session?.user ?? null,
322
+ account: session?.account ?? null,
315
323
  tenants: session?.tenants ?? [],
316
324
  activeTenant,
317
325
  error,
318
326
  signIn,
327
+ signInWithProvider,
328
+ signInWithPassword,
319
329
  signOut,
320
330
  setActiveTenant,
321
331
  refreshMemberships,
322
332
  createTenant,
323
333
  inviteMember,
334
+ acceptInvitation,
324
335
  revokeInvitation,
325
- removeMember,
326
- }), [activeTenant, createTenant, error, fetchAccessToken, inviteMember, isLoading, refreshMemberships, removeMember, revokeInvitation, session, setActiveTenant, signIn, signOut]);
336
+ }), [acceptInvitation, activeTenant, createTenant, error, fetchAccessToken, inviteMember, isLoading, refreshMemberships, revokeInvitation, session, setActiveTenant, signIn, signInWithPassword, signInWithProvider, signOut]);
327
337
  return (_jsx(ManagedAuthContext.Provider, { value: authValue, children: _jsx(GonvexAuthContext.Provider, { value: authValue, children: _jsx(GonvexProvider, { client: props.client, children: isLoading ? null : props.children }) }) }));
328
338
  }
329
339
  export function useGonvexAuth() {
@@ -332,6 +342,18 @@ export function useGonvexAuth() {
332
342
  throw new Error("GonvexAuthProvider is required");
333
343
  return value;
334
344
  }
345
+ /** Subscribed profile for the active tenant, reconciled by GonvexAuthProvider. */
346
+ export function useCurrentTenantProfile() {
347
+ return useGonvexAuth().activeTenant;
348
+ }
349
+ /** Live tenant-admin invitation list; reducer changes reconcile automatically. */
350
+ export function useInvitationList() {
351
+ return useControlQuery(control.invitations.list, {});
352
+ }
353
+ /** Read the auth state installed by either auth provider. */
354
+ export function useGonvexAuthState() {
355
+ return useContext(GonvexAuthContext);
356
+ }
335
357
  export function GonvexGoogleAuthButton(props) {
336
358
  const { signOutLabel = "Sign out", children, disabled, onClick, ...buttonProps } = props;
337
359
  const auth = useGonvexAuth();
@@ -375,10 +397,9 @@ async function bootstrapGonvexAuth(options) {
375
397
  return latest;
376
398
  if (latest.refreshExpiresAt <= Date.now())
377
399
  throw new GonvexAuthRequestError("Your session expired. Please sign in again.", 401);
378
- const next = await requestGonvexAuthToken(options.runtimeUrl, {
379
- grantType: "refresh_token", project: options.projectId,
380
- refreshToken: latest.refreshToken, tenant: latest.activeTenantId,
381
- });
400
+ options.client.setAuth({ project: options.projectId, token: latest.accessToken });
401
+ const grant = await options.client.action(control.auth.refreshSession, { refreshToken: latest.refreshToken });
402
+ const next = sessionFromNativeGrant(grant, latest);
382
403
  safeLocalStorageSet(options.storageKey, JSON.stringify(next));
383
404
  return next;
384
405
  });
@@ -390,26 +411,34 @@ async function bootstrapGonvexAuth(options) {
390
411
  }
391
412
  }
392
413
  const pkce = readPKCE(options.pkceStorageKey);
393
- // Prefer surfacing the runtime/Google error when PKCE is missing (e.g. after
394
- // a StrictMode remount or a second tab), rather than always saying "verified".
414
+ const provider = pkce?.provider ?? "google";
415
+ const providerLabel = provider[0].toUpperCase() + provider.slice(1);
416
+ // Surface the runtime error even when another tab already consumed PKCE.
395
417
  if (callbackError) {
396
418
  const messages = {
397
- access_denied: "Google sign-in was cancelled.",
398
- invitation_required: "This app is invite-only. Ask an administrator to invite your verified Google email.",
419
+ access_denied: `${providerLabel} sign-in was cancelled.`,
420
+ invitation_required: `This app is invite-only. Ask an administrator to invite your verified ${providerLabel} email.`,
399
421
  verified_google_email_required: "Google must provide a verified email address for this app.",
422
+ verified_microsoft_email_required: "Microsoft must provide a verified email address for this app.",
400
423
  membership_setup_failed: "Your account was verified, but its workspace could not be prepared. Please try again.",
401
424
  google_exchange_failed: "Google rejected the sign-in code exchange. Check GONVEX_GOOGLE_CLIENT_ID/SECRET and the broker callback URI.",
425
+ microsoft_exchange_failed: "Microsoft rejected the sign-in code exchange. Check the project's Microsoft realm configuration.",
426
+ apple_exchange_failed: "Apple rejected the sign-in code exchange. Check the project's Apple realm configuration.",
402
427
  invalid_google_identity: "Google identity verification failed. Please try again.",
403
- account_creation_failed: "Your Google account could not be linked. Please try again.",
428
+ invalid_microsoft_identity: "Microsoft identity verification failed. Please try again.",
429
+ invalid_apple_identity: "Apple identity verification failed. Please try again.",
430
+ microsoft_not_configured: "Microsoft sign-in is not configured for this project.",
431
+ apple_not_configured: "Apple sign-in is not configured for this project.",
432
+ account_creation_failed: `Your ${providerLabel} account could not be linked. Please try again.`,
404
433
  code_creation_failed: "Gonvex could not finish creating a sign-in code. Please try again.",
405
434
  };
406
435
  safeSessionStorageRemove(options.pkceStorageKey);
407
436
  clearAuthCallbackParams(url, pkce?.returnTo);
408
- throw new Error(messages[callbackError] ?? `Google sign-in failed (${callbackError}). Please try again.`);
437
+ throw new Error(messages[callbackError] ?? `${providerLabel} sign-in failed (${callbackError}). Please try again.`);
409
438
  }
410
439
  if (!pkce || !returnedState || returnedState !== pkce.state || Date.now() - pkce.createdAt > 10 * 60 * 1000) {
411
440
  clearAuthCallbackParams(url, pkce?.returnTo);
412
- throw new Error("The Google sign-in response could not be verified. Please try again.");
441
+ throw new Error(`The ${providerLabel} sign-in response could not be verified. Please try again.`);
413
442
  }
414
443
  // Consume PKCE only after validation so a concurrent remount still sees it.
415
444
  safeSessionStorageRemove(options.pkceStorageKey);
@@ -423,6 +452,16 @@ async function bootstrapGonvexAuth(options) {
423
452
  safeLocalStorageSet(options.storageKey, JSON.stringify(session));
424
453
  return session;
425
454
  }
455
+ function sessionFromNativeGrant(value, previous) {
456
+ if (!value || typeof value !== "object" || Array.isArray(value) || !isGonvexAuthSession(value)) {
457
+ throw new GonvexAuthRequestError("Gonvex returned an invalid native session.", 502);
458
+ }
459
+ const session = value;
460
+ const activeTenantId = previous && session.tenants.some((tenant) => tenant.id === previous.activeTenantId)
461
+ ? previous.activeTenantId
462
+ : session.activeTenantId;
463
+ return { ...session, activeTenantId };
464
+ }
426
465
  async function requestGonvexAuthToken(runtimeUrl, body) {
427
466
  const controller = new AbortController();
428
467
  const timeout = window.setTimeout(() => controller.abort(), 15_000);
@@ -450,7 +489,7 @@ function isFatalRefreshError(cause) {
450
489
  }
451
490
  function isGonvexAuthSession(value) {
452
491
  return Boolean(value.accessToken && value.expiresAt && value.refreshToken && value.refreshExpiresAt
453
- && value.user?.id && Array.isArray(value.tenants));
492
+ && value.account?.id && Array.isArray(value.tenants));
454
493
  }
455
494
  async function withBrowserAuthLock(name, action) {
456
495
  const locks = typeof navigator === "undefined"
@@ -523,7 +562,7 @@ function readAuthSession(key) {
523
562
  return null;
524
563
  try {
525
564
  const parsed = JSON.parse(localStorage.getItem(key) ?? "null");
526
- if (!parsed?.accessToken || !parsed.refreshToken || !parsed.user?.id || !Array.isArray(parsed.tenants) || parsed.refreshExpiresAt <= Date.now()) {
565
+ if (!parsed?.accessToken || !parsed.refreshToken || !parsed.account?.id || !Array.isArray(parsed.tenants) || parsed.refreshExpiresAt <= Date.now()) {
527
566
  safeLocalStorageRemove(key);
528
567
  return null;
529
568
  }
@@ -564,11 +603,7 @@ function bytesToBase64Url(bytes) {
564
603
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
565
604
  }
566
605
  const DEFAULT_LIVE_QUERY_SLOW_MS = 15_000;
567
- /**
568
- * Live query hook with an explicit status surface. Unlike `useQuery`, this
569
- * distinguishes loading / success / error / timeout / disconnected, keeps the
570
- * last good result while reconnecting, and exposes `retry()`.
571
- */
606
+ /** One-shot Query hook with explicit loading/error/timeout status and retry. */
572
607
  export function useQueryResult(ref, args = {}, options = {}) {
573
608
  const client = useGonvexClient();
574
609
  const path = ref.path;
@@ -577,6 +612,7 @@ export function useQueryResult(ref, args = {}, options = {}) {
577
612
  const argsKey = JSON.stringify(args);
578
613
  const keepPreviousData = options.keepPreviousData !== false;
579
614
  const timeoutMs = options.timeoutMs ?? DEFAULT_LIVE_QUERY_SLOW_MS;
615
+ const [requestGeneration, setRequestGeneration] = useState(0);
580
616
  const [state, setState] = useState({
581
617
  data: undefined,
582
618
  status: args === "skip" ? "skip" : "loading",
@@ -608,147 +644,183 @@ export function useQueryResult(ref, args = {}, options = {}) {
608
644
  setState({ data: undefined, status: "skip", error: null, isStale: false });
609
645
  return;
610
646
  }
611
- setState({ data: undefined, status: "loading", error: null, isStale: false });
647
+ setState((previous) => ({
648
+ data: keepPreviousData ? previous.data : undefined,
649
+ status: "loading",
650
+ error: null,
651
+ isStale: keepPreviousData && previous.data !== undefined,
652
+ }));
612
653
  startSlowTimer();
613
- const unsubscribeScope = client.onSessionScopeChange(() => {
614
- setState({ data: undefined, status: "loading", error: null, isStale: false });
615
- startSlowTimer();
616
- });
617
- const unsubscribeQuery = client.subscribeQuery(ref, args, (message) => {
618
- if (message.type === "query.result") {
619
- clearSlowTimer();
620
- setState({ data: message.result, status: "success", error: null, isStale: false });
621
- }
622
- if (message.type === "query.error") {
623
- clearSlowTimer();
624
- const error = new GonvexClientError(message.error, { code: "server", path, operation: "query" });
625
- setState((previous) => ({
626
- data: keepPreviousData ? previous.data : undefined,
627
- status: "error",
628
- error,
629
- isStale: keepPreviousData && previous.data !== undefined,
630
- }));
631
- }
632
- });
633
- const applyConnection = (connection) => {
634
- if (!connection.isWebSocketConnected) {
635
- clearSlowTimer();
636
- setState((previous) => {
637
- if (previous.status === "skip")
638
- return previous;
639
- return {
640
- data: keepPreviousData ? previous.data : undefined,
641
- status: "disconnected",
642
- error: previous.error,
643
- isStale: keepPreviousData && previous.data !== undefined,
644
- };
645
- });
654
+ let active = true;
655
+ void client.query(ref, args).then((data) => {
656
+ if (!active)
646
657
  return;
647
- }
648
- // The client resubscribes live queries itself on reconnect; report
649
- // loading until the fresh result lands.
650
- setState((previous) => {
651
- if (previous.status !== "disconnected")
652
- return previous;
653
- startSlowTimer();
654
- return { ...previous, status: "loading" };
655
- });
656
- };
657
- const unsubscribeConnection = typeof client.subscribeToConnectionState === "function"
658
- ? (() => {
659
- if (typeof client.connectionState === "function") {
660
- applyConnection(client.connectionState());
661
- }
662
- return client.subscribeToConnectionState(applyConnection);
663
- })()
664
- : undefined;
658
+ clearSlowTimer();
659
+ setState({ data, status: "success", error: null, isStale: false });
660
+ }, (failure) => {
661
+ if (!active)
662
+ return;
663
+ clearSlowTimer();
664
+ const error = failure instanceof Error
665
+ ? failure
666
+ : new GonvexClientError(String(failure), { code: "server", path, operation: "query" });
667
+ setState((previous) => ({
668
+ data: keepPreviousData ? previous.data : undefined,
669
+ status: "error",
670
+ error,
671
+ isStale: keepPreviousData && previous.data !== undefined,
672
+ }));
673
+ });
665
674
  return () => {
675
+ active = false;
666
676
  clearSlowTimer();
667
- unsubscribeScope();
668
- unsubscribeQuery();
669
- unsubscribeConnection?.();
670
677
  };
671
678
  // eslint-disable-next-line react-hooks/exhaustive-deps
672
- }, [client, kind, path, optimisticKey, argsKey, keepPreviousData, startSlowTimer, clearSlowTimer]);
679
+ }, [client, kind, path, optimisticKey, argsKey, keepPreviousData, requestGeneration, startSlowTimer, clearSlowTimer]);
673
680
  const retry = useCallback(() => {
674
681
  if (args === "skip")
675
682
  return;
676
- setState((previous) => ({
677
- data: previous.data,
678
- status: "loading",
679
- error: null,
680
- isStale: previous.data !== undefined,
681
- }));
682
- startSlowTimer();
683
- if (typeof client.retryQuery === "function") {
684
- client.retryQuery(ref, JSON.parse(argsKey));
685
- }
683
+ setRequestGeneration((generation) => generation + 1);
686
684
  // eslint-disable-next-line react-hooks/exhaustive-deps
687
- }, [client, kind, path, optimisticKey, argsKey, startSlowTimer]);
685
+ }, [argsKey]);
688
686
  return {
689
687
  data: state.data,
690
688
  status: state.status,
691
689
  error: state.error,
692
690
  isLoading: state.status === "loading",
693
- isError: state.status === "error" || state.status === "timeout" || state.status === "disconnected",
691
+ isError: state.status === "error" || state.status === "timeout",
694
692
  isSuccess: state.status === "success",
695
693
  isStale: state.isStale,
696
694
  retry,
697
695
  };
698
696
  }
699
- export function useQuery(ref, args = {}) {
697
+ export function useLiveQuery(ref, args = {}) {
700
698
  const client = useGonvexClient();
701
- const [result, setResult] = useState();
702
- const [error, setError] = useState(null);
699
+ if (ref.delivery !== "live" || !ref.live?.plan) {
700
+ throw new Error(`useLiveQuery requires a structured Live Query reference: ${ref.path}`);
701
+ }
703
702
  const path = ref.path;
704
703
  const kind = ref.kind;
705
704
  const optimisticKey = JSON.stringify(ref.optimistic ?? null);
706
705
  const argsKey = JSON.stringify(args);
706
+ const liveKey = JSON.stringify(ref.live ?? null);
707
+ const liveWatch = useMemo(() => args === "skip" ? undefined : client.watchLiveQuery(ref, args),
708
+ // eslint-disable-next-line react-hooks/exhaustive-deps
709
+ [client, kind, path, optimisticKey, argsKey, liveKey]);
710
+ const liveResult = useSyncExternalStore(useCallback((notify) => liveWatch?.onUpdate(notify) ?? (() => undefined), [liveWatch]), useCallback(() => liveWatch?.localLiveQueryResult(), [liveWatch]), () => undefined);
711
+ return liveResult;
712
+ }
713
+ /** Subscribe to a host-owned Control Plane Query on the existing Gonvex connection. */
714
+ export function useControlQuery(ref, args = {}) {
715
+ const client = useGonvexClient();
716
+ const argsKey = JSON.stringify(args);
717
+ const watch = useMemo(() => args === "skip" ? undefined : client.watchControlQuery(ref, args),
718
+ // eslint-disable-next-line react-hooks/exhaustive-deps
719
+ [client, ref.kind, ref.path, argsKey]);
720
+ const snapshot = useSyncExternalStore(useCallback((notify) => watch?.onUpdate(notify) ?? (() => undefined), [watch]), useCallback(() => watch?.getSnapshot(), [watch]), () => undefined);
721
+ return snapshot?.result;
722
+ }
723
+ /** Read one normalized entity from the single Gonvex Local Replica. */
724
+ export function useEntity(entity, id) {
725
+ const client = useGonvexClient();
726
+ useSyncExternalStore(useCallback((notify) => client.localReplica.subscribe(notify), [client]), useCallback(() => client.localReplica.version(), [client]), () => 0);
727
+ return client.localReplica.entity(entity, id);
728
+ }
729
+ /** Resolve an ordered entity batch with one Local Replica subscription. */
730
+ export function useReplicaEntities(entity, ids) {
731
+ const client = useGonvexClient();
732
+ const idsKey = JSON.stringify(ids);
733
+ const version = useSyncExternalStore(useCallback((notify) => client.localReplica.subscribe(notify), [client]), useCallback(() => client.localReplica.version(), [client]), () => 0);
734
+ return useMemo(() => client.replicaEntities(entity, ids), [client, entity, idsKey, version]);
735
+ }
736
+ /** Read a persisted Live Query window without opening another server subscription. */
737
+ export function useRetainedLiveQuery(signatureOrReference, args = {}) {
738
+ const client = useGonvexClient();
739
+ const argsKey = JSON.stringify(args);
740
+ const signature = typeof signatureOrReference === "string"
741
+ ? signatureOrReference
742
+ : client.replicaSignature(signatureOrReference, args);
743
+ const version = useSyncExternalStore(useCallback((notify) => client.localReplica.subscribe(notify), [client]), useCallback(() => client.localReplica.version(), [client]), () => 0);
744
+ return useMemo(() => client.retainedLiveQuery(signature), [client, signature, argsKey, version]);
745
+ }
746
+ /** Structured Live Query state backed by normalized Local Replica entities. */
747
+ export function useLiveQueryState(ref, args = {}) {
748
+ const client = useGonvexClient();
749
+ const argsKey = JSON.stringify(args);
750
+ const signature = args === "skip" ? "" : client.replicaSignature(ref, args);
707
751
  useEffect(() => {
752
+ if (args === "skip")
753
+ return;
754
+ return client.subscribeLiveQuery(ref, args, () => undefined);
755
+ // eslint-disable-next-line react-hooks/exhaustive-deps
756
+ }, [client, ref.kind, ref.path, argsKey]);
757
+ useSyncExternalStore(useCallback((notify) => client.localReplica.subscribe(notify), [client]), useCallback(() => client.localReplica.version(), [client]), () => 0);
758
+ if (args !== "skip" && client.localReplica.freshness() === "offline") {
759
+ const offline = client.offlineLiveQuery(ref, args);
760
+ return {
761
+ rows: offline.rows,
762
+ ids: offline.rows.map((row) => String(row.id ?? row._id ?? "")).filter(Boolean),
763
+ ...(offline.total === undefined ? {} : { total: offline.total }),
764
+ ...(offline.offset === undefined ? {} : { offset: offline.offset }),
765
+ ...(offline.limit === undefined ? {} : { limit: offline.limit }),
766
+ source: "cache",
767
+ completeness: offline.completeness,
768
+ freshness: "offline",
769
+ supported: offline.supported,
770
+ ...(offline.unsupportedOperator ? { unsupportedOperator: offline.unsupportedOperator } : {}),
771
+ };
772
+ }
773
+ return signature
774
+ ? client.localReplica.liveQuery(signature)
775
+ : { rows: [], ids: [], source: "cache", completeness: "partial", freshness: client.localReplica.freshness() };
776
+ }
777
+ /** Execute a read-only Query once. Queries never subscribe or rerun. */
778
+ export function useQuery(ref, args = {}) {
779
+ const client = useGonvexClient();
780
+ const [result, setResult] = useState();
781
+ const [error, setError] = useState(null);
782
+ const argsKey = JSON.stringify(args);
783
+ useEffect(() => {
784
+ let active = true;
708
785
  if (args === "skip") {
709
786
  setResult(undefined);
710
787
  setError(null);
711
- return;
788
+ return () => { active = false; };
712
789
  }
713
790
  setResult(undefined);
714
791
  setError(null);
715
- const unsubscribeScope = client.onSessionScopeChange(() => {
716
- setResult(undefined);
717
- setError(null);
718
- });
719
- const unsubscribeQuery = client.subscribeQuery(ref, args, (message) => {
720
- if (message.type === "query.result") {
721
- setResult(message.result);
722
- setError(null);
723
- }
724
- if (message.type === "query.error") {
725
- setResult(undefined);
726
- setError(new GonvexClientError(message.error, { code: "server", path, operation: "query" }));
727
- }
728
- });
729
- return () => {
730
- unsubscribeScope();
731
- unsubscribeQuery();
732
- };
733
- }, [client, kind, path, optimisticKey, argsKey]);
734
- // Convex-compatible: a failed query throws during render so error
735
- // boundaries can catch it, instead of being indistinguishable from loading.
792
+ void client.query(ref, args).then((value) => { if (active)
793
+ setResult(value); }, (failure) => { if (active)
794
+ setError(failure instanceof Error ? failure : new Error(String(failure))); });
795
+ return () => { active = false; };
796
+ // eslint-disable-next-line react-hooks/exhaustive-deps
797
+ }, [client, ref.kind, ref.path, argsKey]);
736
798
  if (error)
737
799
  throw error;
738
800
  return result;
739
801
  }
740
- export function useSync(ref, args = {}) {
802
+ export function useReplicaCollection(ref, args = {}) {
741
803
  const client = useGonvexClient();
742
804
  const path = ref.path;
743
805
  const kind = ref.kind;
744
806
  const optimisticKey = JSON.stringify(ref.optimistic ?? null);
745
807
  const argsKey = JSON.stringify(args);
746
- const watch = useMemo(() => args === "skip" ? undefined : client.watchSync(ref, args),
808
+ const watch = useMemo(() => args === "skip" ? undefined : client.watchReplica(ref, args),
747
809
  // eslint-disable-next-line react-hooks/exhaustive-deps
748
810
  [client, kind, path, optimisticKey, argsKey]);
749
- return useSyncExternalStore(useCallback((onStoreChange) => watch?.onUpdate(onStoreChange) ?? (() => undefined), [watch]), useCallback(() => watch?.localSyncResult(), [watch]), () => undefined);
811
+ return useSyncExternalStore(useCallback((onStoreChange) => watch?.onUpdate(onStoreChange) ?? (() => undefined), [watch]), useCallback(() => watch?.localReplicaResult(), [watch]), () => undefined);
812
+ }
813
+ /** Replica rows plus authoritative completeness, truncation, and freshness metadata. */
814
+ export function useReplicaCollectionState(ref, args = {}) {
815
+ const client = useGonvexClient();
816
+ const path = ref.path;
817
+ const argsKey = JSON.stringify(args);
818
+ const watch = useMemo(() => args === "skip" ? undefined : client.watchReplica(ref, args),
819
+ // eslint-disable-next-line react-hooks/exhaustive-deps
820
+ [client, ref.kind, path, argsKey]);
821
+ return useSyncExternalStore(useCallback((onStoreChange) => watch?.onUpdate(onStoreChange) ?? (() => undefined), [watch]), useCallback(() => watch?.localReplicaState(), [watch]), () => undefined);
750
822
  }
751
- export function useSyncSelector(ref, args, selector, isEqual = Object.is) {
823
+ export function useReplicaSelector(ref, args, selector, isEqual = Object.is) {
752
824
  const client = useGonvexClient();
753
825
  const path = ref.path;
754
826
  const kind = ref.kind;
@@ -758,7 +830,7 @@ export function useSyncSelector(ref, args, selector, isEqual = Object.is) {
758
830
  const equalityRef = useRef(isEqual);
759
831
  selectorRef.current = selector;
760
832
  equalityRef.current = isEqual;
761
- const watch = useMemo(() => args === "skip" ? undefined : client.watchSync(ref, args),
833
+ const watch = useMemo(() => args === "skip" ? undefined : client.watchReplica(ref, args),
762
834
  // eslint-disable-next-line react-hooks/exhaustive-deps
763
835
  [client, kind, path, optimisticKey, argsKey]);
764
836
  const selectedRef = useRef({
@@ -769,7 +841,7 @@ export function useSyncSelector(ref, args, selector, isEqual = Object.is) {
769
841
  selectedRef.current = { initialized: false, value: undefined };
770
842
  }, [watch]);
771
843
  const getSnapshot = useCallback(() => {
772
- const rows = watch?.localSyncResult();
844
+ const rows = watch?.localReplicaResult();
773
845
  const next = rows === undefined ? undefined : selectorRef.current(rows);
774
846
  if (!selectedRef.current.initialized
775
847
  || next === undefined
@@ -784,7 +856,7 @@ export function useSyncSelector(ref, args, selector, isEqual = Object.is) {
784
856
  return () => undefined;
785
857
  return watch.onUpdate(() => {
786
858
  const previous = selectedRef.current.value;
787
- const rows = watch.localSyncResult();
859
+ const rows = watch.localReplicaResult();
788
860
  const next = rows === undefined ? undefined : selectorRef.current(rows);
789
861
  if (selectedRef.current.initialized
790
862
  && previous !== undefined
@@ -797,31 +869,25 @@ export function useSyncSelector(ref, args, selector, isEqual = Object.is) {
797
869
  }, [watch]);
798
870
  return useSyncExternalStore(subscribe, getSnapshot, () => undefined);
799
871
  }
800
- export function useMutation(ref, options = {}) {
872
+ export function useReducer(ref, options = {}) {
801
873
  const client = useGonvexClient();
802
- return (args = {}) => client.mutation(ref, args, options);
874
+ return (args = {}) => client.reducer(ref, args, options);
803
875
  }
804
876
  export function useAction(ref, options = {}) {
805
877
  const client = useGonvexClient();
806
878
  return (args = {}) => client.action(ref, args, options);
807
879
  }
808
- export function useConvex() {
809
- return useGonvexClient();
810
- }
811
- export function useConvexAuth() {
812
- return useContext(GonvexAuthContext);
813
- }
814
880
  const FALLBACK_CONNECTION_STATE = {
815
881
  isWebSocketConnected: false,
816
882
  hasEverConnected: false,
817
883
  connectionCount: 0,
818
884
  connectionRetries: 0,
819
885
  hasInflightRequests: false,
820
- inflightMutations: 0,
886
+ inflightReducers: 0,
821
887
  inflightActions: 0,
822
888
  inflightOneShotQueries: 0,
823
889
  };
824
- export function useConvexConnectionState() {
890
+ export function useGonvexConnectionState() {
825
891
  const client = useGonvexClient();
826
892
  const [state, setState] = useState(() => (typeof client.connectionState === "function" ? client.connectionState() : FALLBACK_CONNECTION_STATE));
827
893
  useEffect(() => {
@@ -832,18 +898,7 @@ export function useConvexConnectionState() {
832
898
  }, [client]);
833
899
  return state;
834
900
  }
835
- export function usePaginatedQuery(ref, args = {}, options = {}) {
836
- const pageArgs = args === "skip" ? "skip" : { ...(isRecord(args) ? args : { args }), paginationOpts: { numItems: options.initialNumItems ?? 25, cursor: null } };
837
- const result = useQuery(ref, pageArgs);
838
- const page = Array.isArray(result) ? { page: result, isDone: true, continueCursor: null } : result;
839
- return {
840
- results: (page?.page ?? []),
841
- status: args === "skip" ? "Exhausted" : result === undefined ? "LoadingFirstPage" : page?.isDone ? "Exhausted" : "CanLoadMore",
842
- isLoading: args !== "skip" && result === undefined,
843
- loadMore: (_numItems) => undefined,
844
- };
845
- }
846
- function useGonvexClient() {
901
+ export function useGonvexClient() {
847
902
  const client = useContext(GonvexContext);
848
903
  if (!client)
849
904
  throw new Error("GonvexProvider is required");