@oxyhq/auth 2.0.4 → 2.0.6

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.
Files changed (58) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/WebOxyProvider.js +37 -0
  3. package/dist/cjs/hooks/mutations/index.js +5 -1
  4. package/dist/cjs/hooks/mutations/useAccountMutations.js +185 -43
  5. package/dist/cjs/hooks/mutations/useAppData.js +133 -0
  6. package/dist/cjs/hooks/queries/appDataQueryKeys.js +46 -0
  7. package/dist/cjs/hooks/queries/index.js +8 -1
  8. package/dist/cjs/hooks/queries/useAppData.js +87 -0
  9. package/dist/cjs/hooks/queryClient.js +136 -92
  10. package/dist/cjs/hooks/useFileDownloadUrl.js +12 -36
  11. package/dist/cjs/hooks/useSessionSocket.js +81 -94
  12. package/dist/cjs/index.js +8 -3
  13. package/dist/cjs/utils/sessionHelpers.js +3 -1
  14. package/dist/cjs/utils/storageHelpers.js +36 -10
  15. package/dist/esm/.tsbuildinfo +1 -1
  16. package/dist/esm/WebOxyProvider.js +38 -1
  17. package/dist/esm/hooks/mutations/index.js +2 -0
  18. package/dist/esm/hooks/mutations/useAccountMutations.js +186 -44
  19. package/dist/esm/hooks/mutations/useAppData.js +128 -0
  20. package/dist/esm/hooks/queries/appDataQueryKeys.js +42 -0
  21. package/dist/esm/hooks/queries/index.js +3 -0
  22. package/dist/esm/hooks/queries/useAppData.js +82 -0
  23. package/dist/esm/hooks/queryClient.js +132 -89
  24. package/dist/esm/hooks/useFileDownloadUrl.js +11 -34
  25. package/dist/esm/hooks/useSessionSocket.js +81 -94
  26. package/dist/esm/index.js +3 -3
  27. package/dist/esm/utils/sessionHelpers.js +3 -1
  28. package/dist/esm/utils/storageHelpers.js +36 -10
  29. package/dist/types/.tsbuildinfo +1 -1
  30. package/dist/types/WebOxyProvider.d.ts +1 -1
  31. package/dist/types/hooks/mutations/index.d.ts +1 -0
  32. package/dist/types/hooks/mutations/useAccountMutations.d.ts +153 -9
  33. package/dist/types/hooks/mutations/useAppData.d.ts +47 -0
  34. package/dist/types/hooks/queries/appDataQueryKeys.d.ts +24 -0
  35. package/dist/types/hooks/queries/index.d.ts +2 -0
  36. package/dist/types/hooks/queries/useAccountQueries.d.ts +11 -7
  37. package/dist/types/hooks/queries/useAppData.d.ts +46 -0
  38. package/dist/types/hooks/queries/useSecurityQueries.d.ts +2 -2
  39. package/dist/types/hooks/queries/useServicesQueries.d.ts +7 -5
  40. package/dist/types/hooks/queryClient.d.ts +24 -10
  41. package/dist/types/hooks/useAssets.d.ts +1 -1
  42. package/dist/types/hooks/useFileDownloadUrl.d.ts +2 -6
  43. package/dist/types/index.d.ts +3 -3
  44. package/dist/types/utils/sessionHelpers.d.ts +3 -1
  45. package/package.json +22 -3
  46. package/src/WebOxyProvider.tsx +39 -1
  47. package/src/hooks/mutations/index.ts +3 -0
  48. package/src/hooks/mutations/useAccountMutations.ts +230 -57
  49. package/src/hooks/mutations/useAppData.ts +167 -0
  50. package/src/hooks/queries/appDataQueryKeys.ts +53 -0
  51. package/src/hooks/queries/index.ts +4 -0
  52. package/src/hooks/queries/useAppData.ts +105 -0
  53. package/src/hooks/queryClient.ts +140 -83
  54. package/src/hooks/useFileDownloadUrl.ts +15 -39
  55. package/src/hooks/useSessionSocket.ts +123 -91
  56. package/src/index.ts +7 -1
  57. package/src/utils/sessionHelpers.ts +3 -1
  58. package/src/utils/storageHelpers.ts +49 -10
@@ -0,0 +1,105 @@
1
+ /**
2
+ * App-Data Query Hooks
3
+ *
4
+ * Read side of the `/users/me/app-data/...` per-user JSON KV store. Gated on
5
+ * `isAuthenticated` — when signed out the query stays `enabled: false` and
6
+ * `data` is `null`, so consumers can fall back to localStorage without ever
7
+ * issuing a doomed request.
8
+ *
9
+ * Errors from the network (404 because the endpoint isn't deployed yet,
10
+ * 401 because the session lapsed, etc.) are not user-facing here. Hooks
11
+ * return `data: null` on error so the calling component renders the
12
+ * "nothing yet" state and the consuming app can quietly fall back to local
13
+ * persistence. Mutations still propagate errors so write attempts surface
14
+ * a toast — only reads are silent.
15
+ */
16
+
17
+ import { useQuery, type UseQueryResult } from '@tanstack/react-query';
18
+ import { authenticatedApiCall } from '@oxyhq/core';
19
+ import { useWebOxy } from '../../WebOxyProvider';
20
+ import { appDataQueryKeys, isMissingAppDataEndpointError } from './appDataQueryKeys';
21
+
22
+ interface AppDataQueryOptions {
23
+ /** Disable the query without unmounting the component. */
24
+ enabled?: boolean;
25
+ /** Override the default 1-minute stale time. */
26
+ staleTime?: number;
27
+ /** Override the default 30-minute gc time. */
28
+ gcTime?: number;
29
+ }
30
+
31
+ /**
32
+ * Read a single per-user JSON value.
33
+ *
34
+ * @param namespace - kebab/snake-case identifier (e.g. `"academy"`).
35
+ * @param key - kebab/snake-case identifier (e.g. course slug).
36
+ * @param options - optional `enabled`/`staleTime`/`gcTime` overrides.
37
+ *
38
+ * @returns A `useQuery` result with `data` of type `T | null`. The query
39
+ * stays disabled when the user is signed out; when enabled but the server
40
+ * has no stored value, `data` is `null`. Reads that fail because the
41
+ * endpoint isn't reachable also resolve to `null` so the consumer can
42
+ * fall back to local persistence.
43
+ */
44
+ export const useAppData = <T = unknown>(
45
+ namespace: string,
46
+ key: string,
47
+ options?: AppDataQueryOptions,
48
+ ): UseQueryResult<T | null, Error> => {
49
+ const { oxyServices, activeSessionId, isAuthenticated } = useWebOxy();
50
+
51
+ return useQuery<T | null, Error>({
52
+ queryKey: appDataQueryKeys.value(namespace, key),
53
+ queryFn: async () => {
54
+ try {
55
+ return await authenticatedApiCall(oxyServices, activeSessionId, () =>
56
+ oxyServices.getAppData<T>(namespace, key),
57
+ );
58
+ } catch (error) {
59
+ // Endpoint not deployed yet, no network, etc. — return null so the
60
+ // consumer falls back to localStorage rather than rendering a broken
61
+ // UI state. Authentication errors still bubble up so the auth retry
62
+ // pipeline can surface them at the provider level.
63
+ if (isMissingAppDataEndpointError(error)) {
64
+ return null;
65
+ }
66
+ throw error;
67
+ }
68
+ },
69
+ enabled: (options?.enabled !== false) && isAuthenticated,
70
+ staleTime: options?.staleTime ?? 60 * 1000,
71
+ gcTime: options?.gcTime ?? 30 * 60 * 1000,
72
+ });
73
+ };
74
+
75
+ /**
76
+ * Read every value in a namespace.
77
+ *
78
+ * @returns A `useQuery` result with `data` as a `Record<string, T>`. Empty
79
+ * object when the namespace contains nothing (or when fetching failed).
80
+ */
81
+ export const useAppDataNamespace = <T = unknown>(
82
+ namespace: string,
83
+ options?: AppDataQueryOptions,
84
+ ): UseQueryResult<Record<string, T>, Error> => {
85
+ const { oxyServices, activeSessionId, isAuthenticated } = useWebOxy();
86
+
87
+ return useQuery<Record<string, T>, Error>({
88
+ queryKey: appDataQueryKeys.namespace(namespace),
89
+ queryFn: async () => {
90
+ try {
91
+ return await authenticatedApiCall(oxyServices, activeSessionId, () =>
92
+ oxyServices.listAppData<T>(namespace),
93
+ );
94
+ } catch (error) {
95
+ if (isMissingAppDataEndpointError(error)) {
96
+ return {};
97
+ }
98
+ throw error;
99
+ }
100
+ },
101
+ enabled: (options?.enabled !== false) && isAuthenticated,
102
+ staleTime: options?.staleTime ?? 60 * 1000,
103
+ gcTime: options?.gcTime ?? 30 * 60 * 1000,
104
+ });
105
+ };
@@ -1,112 +1,169 @@
1
- import { QueryClient } from '@tanstack/react-query';
1
+ /**
2
+ * Web QueryClient with offline-first defaults + localStorage persistence.
3
+ *
4
+ * Mirrors the persistence behaviour in `@oxyhq/services/queryClient` so
5
+ * web auth apps (FedCM, popup, redirect flows) survive a page reload with
6
+ * cached identity + paused mutations intact.
7
+ *
8
+ * Persistence is opt-in via `attachQueryPersistence(...)` so SSR callers
9
+ * (Next.js getServerSideProps, Vite SSR, tests) can create a stateless
10
+ * client without touching `window`.
11
+ */
12
+
13
+ import { QueryClient, type Mutation, type Query } from '@tanstack/react-query';
14
+ import {
15
+ persistQueryClient,
16
+ type PersistedClient,
17
+ } from '@tanstack/react-query-persist-client';
18
+ import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';
2
19
  import type { StorageInterface } from '../utils/storageHelpers';
3
20
 
4
- const QUERY_CACHE_KEY = 'oxy_query_cache';
5
- const QUERY_CACHE_VERSION = '1';
21
+ const QUERY_CACHE_KEY = 'oxy_auth_query_cache_v2';
22
+ const QUERY_CACHE_MAX_AGE = 30 * 24 * 60 * 60 * 1000;
23
+ const QUERY_PERSIST_THROTTLE_MS = 1_000;
24
+
25
+ /**
26
+ * Query-key prefixes whose data is safe to restore across reloads.
27
+ * Web auth surfaces are session/profile heavy — lists and history are not
28
+ * persisted to keep the localStorage footprint small.
29
+ */
30
+ const PERSISTED_QUERY_PREFIXES: ReadonlyArray<string> = [
31
+ 'accounts',
32
+ 'users',
33
+ 'sessions',
34
+ 'auth',
35
+ ];
36
+
37
+ function shouldDehydrateQuery(query: Query): boolean {
38
+ if (query.state.status !== 'success') return false;
39
+ const head = query.queryKey[0];
40
+ return typeof head === 'string' && PERSISTED_QUERY_PREFIXES.includes(head);
41
+ }
42
+
43
+ function shouldDehydrateMutation(_mutation: Mutation): boolean {
44
+ return true;
45
+ }
6
46
 
7
47
  /**
8
- * Custom persistence adapter for TanStack Query using our StorageInterface
48
+ * Best-effort detection works in browsers, Node SSR, and React Server
49
+ * Components. `localStorage` is gated behind `window` because Node and edge
50
+ * runtimes may polyfill `globalThis.localStorage` inconsistently.
9
51
  */
10
- export const createPersistenceAdapter = (storage: StorageInterface) => {
11
- return {
12
- persistClient: async (client: any) => {
13
- try {
14
- const serialized = JSON.stringify({
15
- clientState: client,
16
- timestamp: Date.now(),
17
- version: QUERY_CACHE_VERSION,
18
- });
19
- await storage.setItem(QUERY_CACHE_KEY, serialized);
20
- } catch (error) {
21
- if (process.env.NODE_ENV !== 'production') {
22
- console.warn('[QueryClient] Failed to persist cache:', error);
23
- }
52
+ function getBrowserLocalStorage(): Storage | null {
53
+ if (typeof window === 'undefined') return null;
54
+ try {
55
+ if (!window.localStorage) return null;
56
+ return window.localStorage;
57
+ } catch {
58
+ // Access blocked (Safari Private Mode, sandboxed iframe, etc.)
59
+ return null;
60
+ }
61
+ }
62
+
63
+ export const createPersistenceAdapter = (storage: StorageInterface) => ({
64
+ persistClient: async (client: unknown): Promise<void> => {
65
+ try {
66
+ await storage.setItem(QUERY_CACHE_KEY, JSON.stringify(client));
67
+ } catch (error) {
68
+ if (process.env.NODE_ENV !== 'production') {
69
+ console.warn('[QueryClient] Failed to persist cache', error);
24
70
  }
25
- },
26
- restoreClient: async () => {
27
- try {
28
- const cached = await storage.getItem(QUERY_CACHE_KEY);
29
- if (!cached) return undefined;
30
-
31
- const parsed = JSON.parse(cached);
32
-
33
- // Check version compatibility
34
- if (parsed.version !== QUERY_CACHE_VERSION) {
35
- // Clear old cache on version mismatch
36
- await storage.removeItem(QUERY_CACHE_KEY);
37
- return undefined;
38
- }
39
-
40
- // Check if cache is too old (30 days)
41
- const maxAge = 30 * 24 * 60 * 60 * 1000;
42
- if (parsed.timestamp && Date.now() - parsed.timestamp > maxAge) {
43
- await storage.removeItem(QUERY_CACHE_KEY);
44
- return undefined;
45
- }
46
-
47
- return parsed.clientState;
48
- } catch (error) {
49
- if (process.env.NODE_ENV !== 'production') {
50
- console.warn('[QueryClient] Failed to restore cache:', error);
51
- }
52
- return undefined;
71
+ }
72
+ },
73
+ restoreClient: async (): Promise<unknown> => {
74
+ try {
75
+ const cached = await storage.getItem(QUERY_CACHE_KEY);
76
+ return cached ? JSON.parse(cached) : undefined;
77
+ } catch (error) {
78
+ if (process.env.NODE_ENV !== 'production') {
79
+ console.warn('[QueryClient] Failed to restore cache', error);
53
80
  }
54
- },
55
- removeClient: async () => {
56
- try {
57
- await storage.removeItem(QUERY_CACHE_KEY);
58
- } catch (error) {
59
- if (process.env.NODE_ENV !== 'production') {
60
- console.warn('[QueryClient] Failed to remove cache:', error);
61
- }
81
+ return undefined;
82
+ }
83
+ },
84
+ removeClient: async (): Promise<void> => {
85
+ try {
86
+ await storage.removeItem(QUERY_CACHE_KEY);
87
+ } catch (error) {
88
+ if (process.env.NODE_ENV !== 'production') {
89
+ console.warn('[QueryClient] Failed to remove cache', error);
62
90
  }
63
- },
64
- };
65
- };
91
+ }
92
+ },
93
+ });
66
94
 
67
- /**
68
- * Create a QueryClient with offline-first configuration
69
- */
70
- export const createQueryClient = (storage?: StorageInterface | null): QueryClient => {
71
- const client = new QueryClient({
95
+ export const createQueryClient = (): QueryClient =>
96
+ new QueryClient({
72
97
  defaultOptions: {
73
98
  queries: {
74
- // Data is fresh for 5 minutes
75
99
  staleTime: 5 * 60 * 1000,
76
- // Keep unused data in cache for 10 minutes
77
- gcTime: 10 * 60 * 1000,
78
- // Retry 3 times with exponential backoff
100
+ gcTime: 30 * 60 * 1000,
79
101
  retry: 3,
80
- retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
81
- // Refetch on reconnect
102
+ retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30000),
82
103
  refetchOnReconnect: true,
83
- // Don't refetch on window focus (better for mobile)
84
104
  refetchOnWindowFocus: false,
85
- // Offline-first: use cache when offline
86
105
  networkMode: 'offlineFirst',
87
106
  },
88
107
  mutations: {
89
- // Retry once for mutations
90
108
  retry: 1,
91
- // Offline-first: queue mutations when offline
92
109
  networkMode: 'offlineFirst',
93
110
  },
94
111
  },
95
112
  });
96
113
 
97
- // Note: Persistence is handled by TanStack Query's built-in persistence
98
- // For now, we rely on the query client's default behavior with networkMode: 'offlineFirst'
99
- // The cache will be available in memory and queries will use cached data when offline
100
- // Full persistence to AsyncStorage can be added later with @tanstack/react-query-persist-client if needed
101
-
102
- return client;
103
- };
114
+ export interface AttachPersistenceResult {
115
+ restored: Promise<void>;
116
+ unsubscribe: () => void;
117
+ }
104
118
 
105
119
  /**
106
- * Clear persisted query cache
120
+ * Wire `persistQueryClient` to browser `localStorage` (or a no-op when not
121
+ * in a browser). Returns the restore promise so consumers can `await` it
122
+ * before exposing the client to <Suspense> boundaries.
107
123
  */
108
- export const clearQueryCache = async (storage: StorageInterface): Promise<void> => {
109
- const adapter = createPersistenceAdapter(storage);
110
- await adapter.removeClient();
124
+ export const attachQueryPersistence = (
125
+ queryClient: QueryClient,
126
+ ): AttachPersistenceResult => {
127
+ const localStorage = getBrowserLocalStorage();
128
+ if (!localStorage) {
129
+ return { restored: Promise.resolve(), unsubscribe: () => {} };
130
+ }
131
+
132
+ const persister = createSyncStoragePersister({
133
+ storage: localStorage,
134
+ key: QUERY_CACHE_KEY,
135
+ throttleTime: QUERY_PERSIST_THROTTLE_MS,
136
+ });
137
+
138
+ const [unsubscribe, restored] = persistQueryClient({
139
+ queryClient,
140
+ persister,
141
+ maxAge: QUERY_CACHE_MAX_AGE,
142
+ dehydrateOptions: {
143
+ shouldDehydrateQuery,
144
+ shouldDehydrateMutation,
145
+ },
146
+ });
147
+
148
+ restored.catch((error) => {
149
+ if (process.env.NODE_ENV !== 'production') {
150
+ console.warn('[QueryClient] Failed to restore persisted cache', error);
151
+ }
152
+ });
153
+
154
+ return { unsubscribe, restored };
155
+ };
156
+
157
+ export const clearQueryCache = async (
158
+ storage: StorageInterface,
159
+ ): Promise<void> => {
160
+ try {
161
+ await storage.removeItem(QUERY_CACHE_KEY);
162
+ } catch (error) {
163
+ if (process.env.NODE_ENV !== 'production') {
164
+ console.warn('[QueryClient] Failed to remove cache', error);
165
+ }
166
+ }
111
167
  };
112
168
 
169
+ export type { PersistedClient };
@@ -1,11 +1,5 @@
1
1
  import { useEffect, useState } from 'react';
2
- import { OxyServices } from '@oxyhq/core';
3
-
4
- let oxyInstance: OxyServices | null = null;
5
-
6
- export const setOxyFileUrlInstance = (instance: OxyServices) => {
7
- oxyInstance = instance;
8
- };
2
+ import type { OxyServices } from '@oxyhq/core';
9
3
 
10
4
  export interface UseFileDownloadUrlOptions {
11
5
  variant?: string;
@@ -21,38 +15,21 @@ export interface UseFileDownloadUrlResult {
21
15
  /**
22
16
  * Hook to resolve a file's download URL asynchronously.
23
17
  *
24
- * Prefers the provided `oxyServices` instance, falls back to the module-level
25
- * singleton set via `setOxyFileUrlInstance`.
26
- *
27
18
  * Uses `getFileDownloadUrlAsync` first, falling back to the synchronous
28
19
  * `getFileDownloadUrl` if the async call fails.
29
20
  */
30
21
  export const useFileDownloadUrl = (
31
- fileIdOrServices?: string | OxyServices | null,
32
- fileIdOrOptions?: string | UseFileDownloadUrlOptions | null,
33
- maybeOptions?: UseFileDownloadUrlOptions
22
+ oxyServices: OxyServices | null | undefined,
23
+ fileId: string | null | undefined,
24
+ options?: UseFileDownloadUrlOptions,
34
25
  ): UseFileDownloadUrlResult => {
35
- // Support two call signatures:
36
- // 1. useFileDownloadUrl(oxyServices, fileId, options) — preferred
37
- // 2. useFileDownloadUrl(fileId, options) — legacy (uses singleton)
38
- let services: OxyServices | null;
39
- let fileId: string | null | undefined;
40
- let options: UseFileDownloadUrlOptions | undefined;
41
-
42
- if (fileIdOrServices instanceof OxyServices) {
43
- services = fileIdOrServices;
44
- fileId = typeof fileIdOrOptions === 'string' ? fileIdOrOptions : null;
45
- options = maybeOptions;
46
- } else {
47
- services = oxyInstance;
48
- fileId = typeof fileIdOrServices === 'string' ? fileIdOrServices : null;
49
- options = typeof fileIdOrOptions === 'object' && fileIdOrOptions !== null ? fileIdOrOptions as UseFileDownloadUrlOptions : undefined;
50
- }
51
-
52
26
  const [url, setUrl] = useState<string | null>(null);
53
27
  const [loading, setLoading] = useState(false);
54
28
  const [error, setError] = useState<Error | null>(null);
55
29
 
30
+ const variant = options?.variant;
31
+ const expiresIn = options?.expiresIn;
32
+
56
33
  useEffect(() => {
57
34
  if (!fileId) {
58
35
  setUrl(null);
@@ -61,7 +38,7 @@ export const useFileDownloadUrl = (
61
38
  return;
62
39
  }
63
40
 
64
- if (!services) {
41
+ if (!oxyServices) {
65
42
  setUrl(null);
66
43
  setLoading(false);
67
44
  setError(new Error('OxyServices instance not configured for useFileDownloadUrl'));
@@ -69,22 +46,22 @@ export const useFileDownloadUrl = (
69
46
  }
70
47
 
71
48
  let cancelled = false;
72
- const instance = services;
49
+ const instance = oxyServices;
50
+ const targetFileId = fileId;
73
51
 
74
52
  const load = async () => {
75
53
  setLoading(true);
76
54
  setError(null);
77
55
 
78
56
  try {
79
- const { variant, expiresIn } = options || {};
80
57
  let resolvedUrl: string | null = null;
81
58
 
82
59
  if (typeof instance.getFileDownloadUrlAsync === 'function') {
83
- resolvedUrl = await instance.getFileDownloadUrlAsync(fileId!, variant, expiresIn);
60
+ resolvedUrl = await instance.getFileDownloadUrlAsync(targetFileId, variant, expiresIn);
84
61
  }
85
62
 
86
63
  if (!resolvedUrl && typeof instance.getFileDownloadUrl === 'function') {
87
- resolvedUrl = instance.getFileDownloadUrl(fileId!, variant, expiresIn);
64
+ resolvedUrl = instance.getFileDownloadUrl(targetFileId, variant, expiresIn);
88
65
  }
89
66
 
90
67
  if (!cancelled) {
@@ -94,8 +71,7 @@ export const useFileDownloadUrl = (
94
71
  // Fallback to sync URL on error where possible
95
72
  try {
96
73
  if (typeof instance.getFileDownloadUrl === 'function') {
97
- const { variant, expiresIn } = options || {};
98
- const fallbackUrl = instance.getFileDownloadUrl(fileId!, variant, expiresIn);
74
+ const fallbackUrl = instance.getFileDownloadUrl(targetFileId, variant, expiresIn);
99
75
  if (!cancelled) {
100
76
  setUrl(fallbackUrl || null);
101
77
  setError(err instanceof Error ? err : new Error(String(err)));
@@ -103,7 +79,7 @@ export const useFileDownloadUrl = (
103
79
  return;
104
80
  }
105
81
  } catch {
106
- // ignore secondary failure
82
+ // Secondary failure: surface the original error below.
107
83
  }
108
84
 
109
85
  if (!cancelled) {
@@ -121,7 +97,7 @@ export const useFileDownloadUrl = (
121
97
  return () => {
122
98
  cancelled = true;
123
99
  };
124
- }, [fileId, services, options?.variant, options?.expiresIn]);
100
+ }, [fileId, oxyServices, variant, expiresIn]);
125
101
 
126
102
  return { url, loading, error };
127
103
  };