@hyperyai/sdk 1.0.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.
Files changed (102) hide show
  1. package/LICENSE +13 -0
  2. package/README.md +391 -0
  3. package/dist/components/AuthButton.d.ts +51 -0
  4. package/dist/components/AuthButton.js +60 -0
  5. package/dist/components/AuthModal.d.ts +20 -0
  6. package/dist/components/AuthModal.js +62 -0
  7. package/dist/components/BuyButton.d.ts +47 -0
  8. package/dist/components/BuyButton.js +74 -0
  9. package/dist/components/ErrorBoundary.d.ts +13 -0
  10. package/dist/components/ErrorBoundary.js +24 -0
  11. package/dist/components/HyperyModals.d.ts +30 -0
  12. package/dist/components/HyperyModals.js +30 -0
  13. package/dist/components/InsufficientCreditsAlert.d.ts +11 -0
  14. package/dist/components/InsufficientCreditsAlert.js +12 -0
  15. package/dist/components/ModernAuthForm.d.ts +52 -0
  16. package/dist/components/ModernAuthForm.js +83 -0
  17. package/dist/components/RestrictionModal.d.ts +43 -0
  18. package/dist/components/RestrictionModal.js +167 -0
  19. package/dist/components/SignIn.d.ts +26 -0
  20. package/dist/components/SignIn.js +47 -0
  21. package/dist/components/SignInForm.d.ts +41 -0
  22. package/dist/components/SignInForm.js +86 -0
  23. package/dist/components/SignUp.d.ts +26 -0
  24. package/dist/components/SignUp.js +52 -0
  25. package/dist/components/SpendingLimitAlert.d.ts +12 -0
  26. package/dist/components/SpendingLimitAlert.js +14 -0
  27. package/dist/components/UserButton.d.ts +26 -0
  28. package/dist/components/UserButton.js +35 -0
  29. package/dist/components/UserProfile.d.ts +22 -0
  30. package/dist/components/UserProfile.js +26 -0
  31. package/dist/components/WorkspaceSwitcher.d.ts +29 -0
  32. package/dist/components/WorkspaceSwitcher.js +66 -0
  33. package/dist/components/control.d.ts +64 -0
  34. package/dist/components/control.js +89 -0
  35. package/dist/components/ui/dialog.d.ts +19 -0
  36. package/dist/components/ui/dialog.js +53 -0
  37. package/dist/hooks/index.d.ts +22 -0
  38. package/dist/hooks/index.js +23 -0
  39. package/dist/hooks/useBuyerWallet.d.ts +37 -0
  40. package/dist/hooks/useBuyerWallet.js +66 -0
  41. package/dist/hooks/useCheckout.d.ts +27 -0
  42. package/dist/hooks/useCheckout.js +246 -0
  43. package/dist/hooks/useError.d.ts +19 -0
  44. package/dist/hooks/useError.js +36 -0
  45. package/dist/hooks/useMarketplace.d.ts +50 -0
  46. package/dist/hooks/useMarketplace.js +69 -0
  47. package/dist/hooks/useMemberships.d.ts +71 -0
  48. package/dist/hooks/useMemberships.js +138 -0
  49. package/dist/hooks/useWallet.d.ts +62 -0
  50. package/dist/hooks/useWallet.js +123 -0
  51. package/dist/index.d.ts +55 -0
  52. package/dist/index.js +52 -0
  53. package/dist/lib/context.d.ts +50 -0
  54. package/dist/lib/context.js +409 -0
  55. package/dist/lib/oauth.d.ts +49 -0
  56. package/dist/lib/oauth.js +149 -0
  57. package/dist/lib/parse-error.d.ts +45 -0
  58. package/dist/lib/parse-error.js +185 -0
  59. package/dist/lib/parse-stream.d.ts +43 -0
  60. package/dist/lib/parse-stream.js +89 -0
  61. package/dist/lib/popup.d.ts +42 -0
  62. package/dist/lib/popup.js +80 -0
  63. package/dist/lib/storage.d.ts +43 -0
  64. package/dist/lib/storage.js +125 -0
  65. package/dist/lib/utils.d.ts +8 -0
  66. package/dist/lib/utils.js +11 -0
  67. package/dist/types/index.d.ts +191 -0
  68. package/dist/types/index.js +4 -0
  69. package/package.json +78 -0
  70. package/src/components/AuthButton.tsx +123 -0
  71. package/src/components/AuthModal.tsx +240 -0
  72. package/src/components/BuyButton.tsx +150 -0
  73. package/src/components/ErrorBoundary.tsx +97 -0
  74. package/src/components/HyperyModals.tsx +83 -0
  75. package/src/components/InsufficientCreditsAlert.tsx +73 -0
  76. package/src/components/ModernAuthForm.tsx +322 -0
  77. package/src/components/RestrictionModal.tsx +373 -0
  78. package/src/components/SignIn.tsx +84 -0
  79. package/src/components/SignInForm.tsx +256 -0
  80. package/src/components/SignUp.tsx +86 -0
  81. package/src/components/SpendingLimitAlert.tsx +91 -0
  82. package/src/components/UserButton.tsx +106 -0
  83. package/src/components/UserProfile.tsx +106 -0
  84. package/src/components/WorkspaceSwitcher.tsx +199 -0
  85. package/src/components/control.tsx +133 -0
  86. package/src/components/ui/dialog.tsx +151 -0
  87. package/src/hooks/index.ts +65 -0
  88. package/src/hooks/useBuyerWallet.ts +117 -0
  89. package/src/hooks/useCheckout.ts +312 -0
  90. package/src/hooks/useError.ts +60 -0
  91. package/src/hooks/useMarketplace.ts +129 -0
  92. package/src/hooks/useMemberships.ts +203 -0
  93. package/src/hooks/useWallet.ts +170 -0
  94. package/src/index.ts +147 -0
  95. package/src/lib/context.tsx +478 -0
  96. package/src/lib/oauth.ts +215 -0
  97. package/src/lib/parse-error.ts +224 -0
  98. package/src/lib/parse-stream.ts +121 -0
  99. package/src/lib/popup.ts +98 -0
  100. package/src/lib/storage.ts +140 -0
  101. package/src/lib/utils.ts +14 -0
  102. package/src/types/index.ts +216 -0
@@ -0,0 +1,478 @@
1
+ /**
2
+ * Hypery Auth Context
3
+ * Similar to Clerk's <ClerkProvider>
4
+ */
5
+
6
+ 'use client';
7
+
8
+ import React, {
9
+ createContext,
10
+ useContext,
11
+ useEffect,
12
+ useState,
13
+ useCallback,
14
+ useRef,
15
+ type ReactNode,
16
+ } from 'react';
17
+ import type {
18
+ HyperyAuthConfig,
19
+ AuthContextValue,
20
+ ParsedError,
21
+ PopupAuthResult,
22
+ ResolvedMode,
23
+ User,
24
+ } from '../types';
25
+ import {
26
+ getAuthorizationUrl,
27
+ exchangeCodeForToken,
28
+ refreshAccessToken,
29
+ getUserInfo,
30
+ } from './oauth';
31
+ import { parseError } from './parse-error';
32
+ import { openPopup, resolveInteractionMode } from './popup';
33
+ import { TokenStorage } from './storage';
34
+
35
+ /** window.open target name; also read back in the callback to detect popup context. */
36
+ const AUTH_POPUP_NAME = 'hypery-sdk-popup';
37
+
38
+ const AuthContext = createContext<AuthContextValue | undefined>(undefined);
39
+
40
+ export interface HyperyProviderProps {
41
+ config: HyperyAuthConfig;
42
+ children: ReactNode;
43
+ }
44
+
45
+ /**
46
+ * Hypery Auth Provider
47
+ * Wrap your app with this component to enable authentication
48
+ *
49
+ * @example
50
+ * ```tsx
51
+ * <HyperyProvider config={{
52
+ * clientId: 'your-client-id',
53
+ * redirectUri: 'http://localhost:3000/callback',
54
+ * gatewayUrl: 'https://api.hypery.ai',
55
+ * scopes: ['read', 'write', 'ai:chat']
56
+ * }}>
57
+ * <App />
58
+ * </HyperyProvider>
59
+ * ```
60
+ */
61
+ export function HyperyProvider({
62
+ config,
63
+ children,
64
+ }: HyperyProviderProps) {
65
+ const [user, setUser] = useState<User | null>(null);
66
+ const [isLoading, setIsLoading] = useState(true);
67
+ const [error, setError] = useState<string | null>(null);
68
+ const [isLoggingOut, setIsLoggingOut] = useState(false);
69
+ // Turnkey modal state driven by authenticatedFetch (see <HyperyModals>).
70
+ const [restriction, setRestriction] = useState<ParsedError | null>(null);
71
+ const [authRequired, setAuthRequired] = useState(false);
72
+ // Single-flight refresh: N concurrent expired requests share ONE token POST.
73
+ const refreshInFlight = useRef<Promise<string | null> | null>(null);
74
+
75
+ const storage = new TokenStorage(config.storage || 'localStorage');
76
+ // Default scopes match the app's default OAuth configuration.
77
+ // Includes ai:images so image generation works without a custom scope list.
78
+ const scopes = config.scopes || ['read', 'write', 'ai:chat', 'ai:completions', 'ai:models', 'ai:images', 'billing:read'];
79
+
80
+ /**
81
+ * Get valid access token (refreshes if needed)
82
+ */
83
+ const getAccessToken = useCallback(async (forceRefresh = false): Promise<string | null> => {
84
+ const tokens = storage.getTokens();
85
+ if (!tokens) return null;
86
+
87
+ // Refresh when the local clock says expired, or when forced (the 401 retry
88
+ // path — the server may have revoked a token our clock still thinks valid).
89
+ if (forceRefresh || storage.isTokenExpired()) {
90
+ // Single-flight: if a refresh is already running, await it instead of
91
+ // firing another POST /api/oauth/token for every concurrent request.
92
+ if (!refreshInFlight.current) {
93
+ refreshInFlight.current = (async () => {
94
+ try {
95
+ const newTokens = await refreshAccessToken(tokens.refreshToken, {
96
+ clientId: config.clientId,
97
+ gatewayUrl: config.gatewayUrl,
98
+ });
99
+ storage.saveTokens(newTokens);
100
+ return newTokens.accessToken;
101
+ } catch (err) {
102
+ console.error('Failed to refresh token:', err);
103
+ storage.clear();
104
+ setUser(null);
105
+ return null;
106
+ } finally {
107
+ refreshInFlight.current = null;
108
+ }
109
+ })();
110
+ }
111
+ return refreshInFlight.current;
112
+ }
113
+
114
+ return tokens.accessToken;
115
+ }, [config.clientId, config.gatewayUrl]);
116
+
117
+ /**
118
+ * fetch() wrapper that injects the bearer token and drives the turnkey modal
119
+ * UX. On 401 it does ONE silent-refresh retry; if still 401 it flags
120
+ * `authRequired` (and calls `config.onUnauthorized`). On a 402/429 billing
121
+ * restriction it parses the canonical error and sets `restriction` (and calls
122
+ * `config.onRestricted`). The Response is always returned so the caller can
123
+ * still read the body / handle other statuses itself.
124
+ */
125
+ const authenticatedFetch = useCallback(
126
+ async (input: RequestInfo | URL, init: RequestInit = {}): Promise<Response> => {
127
+ const withToken = (token: string | null): RequestInit => ({
128
+ ...init,
129
+ headers: {
130
+ ...(init.headers as Record<string, string> | undefined),
131
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
132
+ },
133
+ });
134
+
135
+ let token = await getAccessToken();
136
+ let res = await fetch(input, withToken(token));
137
+
138
+ if (res.status === 401) {
139
+ // Force one refresh + retry before giving up.
140
+ token = await getAccessToken(true);
141
+ if (token) {
142
+ res = await fetch(input, withToken(token));
143
+ }
144
+ if (res.status === 401) {
145
+ setAuthRequired(true);
146
+ config.onUnauthorized?.();
147
+ return res;
148
+ }
149
+ }
150
+
151
+ if (res.status === 402 || res.status === 429) {
152
+ try {
153
+ const body = await res.clone().json();
154
+ const parsed = parseError({ ...body, status: res.status });
155
+ setRestriction(parsed);
156
+ config.onRestricted?.(parsed);
157
+ } catch {
158
+ // Non-JSON body — leave it to the caller.
159
+ }
160
+ }
161
+
162
+ return res;
163
+ },
164
+ [getAccessToken, config],
165
+ );
166
+
167
+ /**
168
+ * Load user from storage and validate token
169
+ */
170
+ const loadUser = useCallback(async () => {
171
+ try {
172
+ setIsLoading(true);
173
+ setError(null);
174
+ setIsLoggingOut(false); // Reset logout flag on fresh load
175
+
176
+ const accessToken = await getAccessToken();
177
+ if (!accessToken) {
178
+ setUser(null);
179
+ setIsLoading(false);
180
+ return;
181
+ }
182
+
183
+ // Fetch fresh user info
184
+ const userInfo = await getUserInfo(accessToken, config.gatewayUrl);
185
+ storage.saveUser(userInfo);
186
+ setUser(userInfo);
187
+ } catch (err) {
188
+ console.error('Failed to load user:', err);
189
+ setError(err instanceof Error ? err.message : 'Failed to load user');
190
+ storage.clear();
191
+ setUser(null);
192
+ } finally {
193
+ setIsLoading(false);
194
+ }
195
+ }, [config.gatewayUrl, getAccessToken]);
196
+
197
+ /**
198
+ * Initiate OAuth login flow
199
+ */
200
+ const login = useCallback(async () => {
201
+ try {
202
+ // Check if user just logged out (force account selection)
203
+ const justLoggedOut = typeof window !== 'undefined' &&
204
+ localStorage.getItem('hypery_force_reauth') === 'true';
205
+
206
+ const authUrl = await getAuthorizationUrl({
207
+ clientId: config.clientId,
208
+ redirectUri: config.redirectUri,
209
+ gatewayUrl: config.gatewayUrl,
210
+ scopes,
211
+ storage: config.storage || 'localStorage',
212
+ // Force account selection after logout to prevent auto-login
213
+ prompt: justLoggedOut ? 'select_account' : undefined,
214
+ });
215
+
216
+ // Clear the flag
217
+ if (justLoggedOut) {
218
+ localStorage.removeItem('hypery_force_reauth');
219
+ }
220
+
221
+ window.location.href = authUrl;
222
+ } catch (err) {
223
+ console.error('Failed to initiate login:', err);
224
+ setError(err instanceof Error ? err.message : 'Failed to initiate login');
225
+ }
226
+ }, [config, scopes]);
227
+
228
+ /**
229
+ * Initiate OAuth sign-up flow (forces account selection)
230
+ */
231
+ const signUp = useCallback(async () => {
232
+ try {
233
+ const authUrl = await getAuthorizationUrl({
234
+ clientId: config.clientId,
235
+ redirectUri: config.redirectUri,
236
+ gatewayUrl: config.gatewayUrl,
237
+ scopes,
238
+ storage: config.storage || 'localStorage',
239
+ prompt: 'select_account', // Force account selection for sign-up
240
+ });
241
+
242
+ window.location.href = authUrl;
243
+ } catch (err) {
244
+ console.error('Failed to initiate sign-up:', err);
245
+ setError(err instanceof Error ? err.message : 'Failed to initiate sign-up');
246
+ }
247
+ }, [config, scopes]);
248
+
249
+ /**
250
+ * Log in via a centered popup instead of a full-page redirect. The popup runs
251
+ * the normal OAuth+PKCE flow; its callback page (this same provider, mounted
252
+ * at redirectUri) posts the authorization code back here (see handleCallback),
253
+ * and we exchange it with the verifier stored in THIS window. Same-origin
254
+ * redirectUri required — the code is relayed via postMessage to the opener.
255
+ */
256
+ const loginPopup = useCallback(async (): Promise<PopupAuthResult> => {
257
+ try {
258
+ const authUrl = await getAuthorizationUrl({
259
+ clientId: config.clientId,
260
+ redirectUri: config.redirectUri,
261
+ gatewayUrl: config.gatewayUrl,
262
+ scopes,
263
+ storage: config.storage || 'localStorage',
264
+ });
265
+ const expectedOrigin = new URL(config.redirectUri).origin;
266
+ const result = await openPopup<{ code: string }>({
267
+ url: authUrl,
268
+ name: AUTH_POPUP_NAME,
269
+ expectedOrigin,
270
+ messageType: 'hypery:auth',
271
+ });
272
+ if (result.blocked) return { ok: false, blocked: true, cancelled: false };
273
+ if (result.cancelled || !result.data?.code) {
274
+ return { ok: false, blocked: false, cancelled: true };
275
+ }
276
+
277
+ const tokens = await exchangeCodeForToken(result.data.code, {
278
+ clientId: config.clientId,
279
+ redirectUri: config.redirectUri,
280
+ gatewayUrl: config.gatewayUrl,
281
+ storage: config.storage || 'localStorage',
282
+ });
283
+ storage.saveTokens(tokens);
284
+ const userInfo = await getUserInfo(tokens.accessToken, config.gatewayUrl);
285
+ storage.saveUser(userInfo);
286
+ setUser(userInfo);
287
+ return { ok: true, blocked: false, cancelled: false };
288
+ } catch (err) {
289
+ console.error('Popup login failed:', err);
290
+ setError(err instanceof Error ? err.message : 'Popup login failed');
291
+ return { ok: false, blocked: false, cancelled: true };
292
+ }
293
+ }, [config, scopes]);
294
+
295
+ /**
296
+ * Logout and clear session
297
+ */
298
+ const logout = useCallback(async () => {
299
+ console.log('🚪 [AUTH] Logout initiated');
300
+
301
+ // Set logging out flag to prevent Protect components from triggering login
302
+ setIsLoggingOut(true);
303
+
304
+ // Get the current access token before clearing
305
+ const tokens = storage.getTokens();
306
+ console.log('🔑 [AUTH] Found tokens:', tokens ? 'yes' : 'no');
307
+
308
+ // Revoke the OAuth token on the server
309
+ if (tokens?.accessToken) {
310
+ try {
311
+ console.log('📡 [AUTH] Calling revoke endpoint:', `${config.gatewayUrl}/api/oauth/revoke`);
312
+ const response = await fetch(`${config.gatewayUrl}/api/oauth/revoke`, {
313
+ method: 'POST',
314
+ headers: { 'Content-Type': 'application/json' },
315
+ body: JSON.stringify({ token: tokens.accessToken }),
316
+ });
317
+ console.log('✅ [AUTH] OAuth token revoked on server, status:', response.status);
318
+ } catch (err) {
319
+ console.error('❌ [AUTH] Failed to revoke token on server:', err);
320
+ // Continue with logout even if revocation fails
321
+ }
322
+ } else {
323
+ console.log('⚠️ [AUTH] No access token found to revoke');
324
+ }
325
+
326
+ // Clear local storage
327
+ console.log('🧹 [AUTH] Clearing local storage');
328
+ storage.clear();
329
+ setUser(null);
330
+ setError(null);
331
+
332
+ // Also clear OAuth verifier if it exists
333
+ if (typeof window !== 'undefined') {
334
+ const storageInstance = config.storage === 'sessionStorage' ? sessionStorage : localStorage;
335
+ storageInstance.removeItem('hypery_oauth_verifier');
336
+
337
+ // Set flag to force account selection on next login
338
+ // This prevents auto-login after logout
339
+ localStorage.setItem('hypery_force_reauth', 'true');
340
+ console.log('✅ [AUTH] Set force reauth flag');
341
+
342
+ // Redirect to local landing page
343
+ // Note: This only logs out of THIS app, not the OAuth provider or other apps
344
+ console.log('🔄 [AUTH] Redirecting to /');
345
+ window.location.replace('/');
346
+ }
347
+ }, [config.storage, config.gatewayUrl, storage]);
348
+
349
+ /**
350
+ * Manually refresh auth state
351
+ */
352
+ const refreshAuth = useCallback(async () => {
353
+ await loadUser();
354
+ }, [loadUser]);
355
+
356
+ /**
357
+ * Handle OAuth callback
358
+ */
359
+ useEffect(() => {
360
+ const handleCallback = async () => {
361
+ if (typeof window === 'undefined') return;
362
+
363
+ const params = new URLSearchParams(window.location.search);
364
+ const code = params.get('code');
365
+
366
+ // Popup auth: this callback page is running inside the auth popup we opened.
367
+ // Don't exchange here — relay the code to the opener (which holds the PKCE
368
+ // verifier) and close. The opener validates event.origin (see loginPopup).
369
+ if (code && window.opener && !window.opener.closed && window.name === AUTH_POPUP_NAME) {
370
+ try {
371
+ window.opener.postMessage({ type: 'hypery:auth', code }, window.location.origin);
372
+ } catch (err) {
373
+ console.error('Failed to relay auth code to opener:', err);
374
+ }
375
+ window.close();
376
+ return;
377
+ }
378
+
379
+ if (code) {
380
+ try {
381
+ setIsLoading(true);
382
+
383
+ // Exchange code for tokens
384
+ const tokens = await exchangeCodeForToken(code, {
385
+ clientId: config.clientId,
386
+ redirectUri: config.redirectUri,
387
+ gatewayUrl: config.gatewayUrl,
388
+ storage: config.storage || 'localStorage',
389
+ });
390
+
391
+ storage.saveTokens(tokens);
392
+
393
+ // Fetch user info
394
+ const userInfo = await getUserInfo(
395
+ tokens.accessToken,
396
+ config.gatewayUrl
397
+ );
398
+ storage.saveUser(userInfo);
399
+ setUser(userInfo);
400
+
401
+ // Clean up URL
402
+ window.history.replaceState({}, document.title, window.location.pathname);
403
+ } catch (err) {
404
+ console.error('OAuth callback failed:', err);
405
+ setError(
406
+ err instanceof Error ? err.message : 'Authentication failed'
407
+ );
408
+ } finally {
409
+ setIsLoading(false);
410
+ }
411
+ } else {
412
+ // Not a callback, load existing session
413
+ await loadUser();
414
+ }
415
+ };
416
+
417
+ handleCallback();
418
+ }, [config, loadUser]);
419
+
420
+ const value: AuthContextValue = {
421
+ user,
422
+ isAuthenticated: !!user,
423
+ isLoading,
424
+ error,
425
+ isLoggingOut,
426
+ login,
427
+ loginPopup,
428
+ interactionMode: resolveInteractionMode(config.interactionMode) as ResolvedMode,
429
+ redirectUri: config.redirectUri,
430
+ signUp,
431
+ logout,
432
+ refreshAuth,
433
+ getAccessToken,
434
+ clientId: config.clientId,
435
+ gatewayUrl: config.gatewayUrl,
436
+ authenticatedFetch,
437
+ restriction,
438
+ clearRestriction: () => setRestriction(null),
439
+ authRequired,
440
+ clearAuthRequired: () => setAuthRequired(false),
441
+ };
442
+
443
+ return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
444
+ }
445
+
446
+ /**
447
+ * Hook to access auth state and methods
448
+ * Similar to Clerk's useAuth()
449
+ *
450
+ * @example
451
+ * ```tsx
452
+ * const { user, isAuthenticated, login, logout } = useHyperyAuth();
453
+ * ```
454
+ */
455
+ export function useHyperyAuth(): AuthContextValue {
456
+ const context = useContext(AuthContext);
457
+ if (!context) {
458
+ throw new Error(
459
+ 'useHyperyAuth must be used within HyperyProvider'
460
+ );
461
+ }
462
+ return context;
463
+ }
464
+
465
+ /**
466
+ * Hook to access user data
467
+ * Similar to Clerk's useUser()
468
+ *
469
+ * @example
470
+ * ```tsx
471
+ * const { user, isLoading } = useUser();
472
+ * ```
473
+ */
474
+ export function useUser() {
475
+ const { user, isLoading } = useHyperyAuth();
476
+ return { user, isLoading };
477
+ }
478
+
@@ -0,0 +1,215 @@
1
+ /**
2
+ * OAuth utilities for authenticating with the Hypery
3
+ * Implements PKCE (Proof Key for Code Exchange) flow for secure authentication
4
+ */
5
+
6
+ import type { AuthTokens } from '../types';
7
+
8
+ /**
9
+ * Generate random string for PKCE verifier
10
+ */
11
+ function generateRandomString(length: number): string {
12
+ const array = new Uint8Array(length);
13
+ crypto.getRandomValues(array);
14
+ return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join(
15
+ ''
16
+ );
17
+ }
18
+
19
+ /**
20
+ * Base64 URL encode
21
+ */
22
+ function base64UrlEncode(buffer: ArrayBuffer): string {
23
+ const bytes = new Uint8Array(buffer);
24
+ let binary = '';
25
+ for (let i = 0; i < bytes.byteLength; i++) {
26
+ binary += String.fromCharCode(bytes[i]);
27
+ }
28
+ return btoa(binary)
29
+ .replace(/\+/g, '-')
30
+ .replace(/\//g, '_')
31
+ .replace(/=/g, '');
32
+ }
33
+
34
+ /**
35
+ * Generate PKCE challenge for secure OAuth flow
36
+ */
37
+ export async function generatePKCE(): Promise<{
38
+ verifier: string;
39
+ challenge: string;
40
+ }> {
41
+ const verifier = generateRandomString(32);
42
+
43
+ // Hash the verifier
44
+ const encoder = new TextEncoder();
45
+ const data = encoder.encode(verifier);
46
+ const hash = await crypto.subtle.digest('SHA-256', data);
47
+ const challenge = base64UrlEncode(hash);
48
+
49
+ return { verifier, challenge };
50
+ }
51
+
52
+ /**
53
+ * Build OAuth authorization URL
54
+ */
55
+ export async function getAuthorizationUrl(config: {
56
+ clientId: string;
57
+ redirectUri: string;
58
+ gatewayUrl: string;
59
+ scopes: string[];
60
+ storage: 'localStorage' | 'sessionStorage' | 'memory';
61
+ state?: string;
62
+ prompt?: 'login' | 'select_account' | 'consent';
63
+ }): Promise<string> {
64
+ const { verifier, challenge } = await generatePKCE();
65
+
66
+ // Store verifier (will be used when exchanging code for token)
67
+ if (config.storage !== 'memory' && typeof window !== 'undefined') {
68
+ const storage =
69
+ config.storage === 'localStorage' ? localStorage : sessionStorage;
70
+ storage.setItem('hypery_oauth_verifier', verifier);
71
+ }
72
+
73
+ const state = config.state || generateRandomString(16);
74
+
75
+ const params = new URLSearchParams({
76
+ client_id: config.clientId,
77
+ response_type: 'code',
78
+ redirect_uri: config.redirectUri,
79
+ scope: config.scopes.join(' '),
80
+ code_challenge: challenge,
81
+ code_challenge_method: 'S256',
82
+ state,
83
+ });
84
+
85
+ // Add prompt parameter if provided (forces account selection or re-authentication)
86
+ if (config.prompt) {
87
+ params.set('prompt', config.prompt);
88
+ }
89
+
90
+ return `${config.gatewayUrl}/api/oauth/authorize?${params.toString()}`;
91
+ }
92
+
93
+ /**
94
+ * Exchange authorization code for access token
95
+ */
96
+ export async function exchangeCodeForToken(
97
+ code: string,
98
+ config: {
99
+ clientId: string;
100
+ redirectUri: string;
101
+ gatewayUrl: string;
102
+ storage: 'localStorage' | 'sessionStorage' | 'memory';
103
+ }
104
+ ): Promise<AuthTokens> {
105
+ let verifier: string | null = null;
106
+
107
+ // Get verifier from storage
108
+ if (config.storage !== 'memory' && typeof window !== 'undefined') {
109
+ const storage =
110
+ config.storage === 'localStorage' ? localStorage : sessionStorage;
111
+ verifier = storage.getItem('hypery_oauth_verifier');
112
+ }
113
+
114
+ if (!verifier) {
115
+ throw new Error('OAuth verifier not found');
116
+ }
117
+
118
+ // Call core API directly - PKCE flow doesn't need client_secret
119
+ const response = await fetch(`${config.gatewayUrl}/api/oauth/token`, {
120
+ method: 'POST',
121
+ headers: {
122
+ 'Content-Type': 'application/json',
123
+ },
124
+ body: JSON.stringify({
125
+ grant_type: 'authorization_code',
126
+ code,
127
+ code_verifier: verifier,
128
+ client_id: config.clientId,
129
+ redirect_uri: config.redirectUri,
130
+ }),
131
+ });
132
+
133
+ if (!response.ok) {
134
+ const error = await response.json();
135
+ throw new Error(
136
+ error.error_description || error.error || 'Failed to exchange code for token'
137
+ );
138
+ }
139
+
140
+ // Clear verifier
141
+ if (config.storage !== 'memory' && typeof window !== 'undefined') {
142
+ const storage =
143
+ config.storage === 'localStorage' ? localStorage : sessionStorage;
144
+ storage.removeItem('hypery_oauth_verifier');
145
+ }
146
+
147
+ const data = await response.json();
148
+
149
+ return {
150
+ accessToken: data.access_token,
151
+ refreshToken: data.refresh_token,
152
+ expiresIn: data.expires_in,
153
+ tokenType: data.token_type,
154
+ };
155
+ }
156
+
157
+ /**
158
+ * Refresh access token
159
+ */
160
+ export async function refreshAccessToken(
161
+ refreshToken: string,
162
+ config: {
163
+ clientId: string;
164
+ gatewayUrl: string;
165
+ }
166
+ ): Promise<AuthTokens> {
167
+ const response = await fetch(`${config.gatewayUrl}/api/oauth/token`, {
168
+ method: 'POST',
169
+ headers: {
170
+ 'Content-Type': 'application/json',
171
+ },
172
+ body: JSON.stringify({
173
+ grant_type: 'refresh_token',
174
+ refresh_token: refreshToken,
175
+ client_id: config.clientId,
176
+ }),
177
+ });
178
+
179
+ if (!response.ok) {
180
+ const error = await response.json();
181
+ throw new Error(
182
+ error.error_description || error.error || 'Failed to refresh token'
183
+ );
184
+ }
185
+
186
+ const data = await response.json();
187
+
188
+ return {
189
+ accessToken: data.access_token,
190
+ refreshToken: data.refresh_token,
191
+ expiresIn: data.expires_in,
192
+ tokenType: data.token_type,
193
+ };
194
+ }
195
+
196
+ /**
197
+ * Get user info from access token
198
+ */
199
+ export async function getUserInfo(
200
+ accessToken: string,
201
+ gatewayUrl: string
202
+ ): Promise<{ id: string; email: string; name: string; image?: string }> {
203
+ const response = await fetch(`${gatewayUrl}/api/user/me`, {
204
+ headers: {
205
+ Authorization: `Bearer ${accessToken}`,
206
+ },
207
+ });
208
+
209
+ if (!response.ok) {
210
+ throw new Error('Failed to fetch user info');
211
+ }
212
+
213
+ return response.json();
214
+ }
215
+